spark-server/internal/module/music/logic/service.go
DESKTOP-L8OUL9F\gfl0034 d3a68c3cad feat(music): 分类多对多、后台完整编辑与 OpenAPI
新增总/子分类与曲目绑定;playlist 可按 categoryId 筛选并返回分类树;
popadmin 支持行内改曲名/排序/上下架/会员开关与封面背景音频上传;文档同步。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 10:38:06 +08:00

581 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package logic
import (
"context"
"errors"
"fmt"
"path"
"strconv"
"strings"
"time"
"spark-server/internal/module/music/entity"
"spark-server/internal/pkg/errs"
)
var ErrNotReady = errors.New("music: service not ready")
type Repository interface {
ListAll(ctx context.Context) ([]entity.Track, error)
ListEnabled(ctx context.Context) ([]entity.Track, error)
ListEnabledByCategory(ctx context.Context, categoryID int64) ([]entity.Track, error)
Get(ctx context.Context, id int64) (*entity.Track, error)
Insert(ctx context.Context, t *entity.Track) error
Update(ctx context.Context, t *entity.Track) error
Delete(ctx context.Context, id int64) error
MemberPreviewSeconds(ctx context.Context) (int, error)
SetSetting(ctx context.Context, key, value string) error
ListCategories(ctx context.Context) ([]entity.Category, error)
ListEnabledCategories(ctx context.Context) ([]entity.Category, error)
GetCategory(ctx context.Context, id int64) (*entity.Category, error)
InsertCategory(ctx context.Context, c *entity.Category) error
UpdateCategory(ctx context.Context, c *entity.Category) error
DeleteCategory(ctx context.Context, id int64) error
SetTrackCategories(ctx context.Context, trackID int64, categoryIDs []int64) error
}
type Objects interface {
Ready() bool
PutLarge(ctx context.Context, key string, body []byte, contentType string, maxBytes int) error
Delete(ctx context.Context, key string) error
}
type Service struct {
repo Repository
objects Objects
publicURL func(objectKey string) string
}
func NewService(repo Repository, objects Objects, publicBase, bucket string) *Service {
base := strings.TrimRight(strings.TrimSpace(publicBase), "/")
bkt := strings.TrimSpace(bucket)
if bkt == "" {
bkt = "spark"
}
return &Service{
repo: repo,
objects: objects,
publicURL: func(key string) string {
key = strings.TrimPrefix(strings.TrimSpace(key), "/")
if key == "" || base == "" {
return ""
}
return base + "/" + bkt + "/" + key
},
}
}
func (s *Service) Ready() bool {
return s != nil && s.repo != nil
}
type UpsertInput struct {
Title string `json:"title"`
RequireMember *bool `json:"requireMember"`
SortOrder *int `json:"sortOrder"`
Enabled *bool `json:"enabled"`
CategoryIDs []int64 `json:"categoryIds"`
}
type CategoryInput struct {
ParentID *int64 `json:"parentId"`
Name string `json:"name"`
SortOrder *int `json:"sortOrder"`
Enabled *bool `json:"enabled"`
}
func (s *Service) ListAdmin(ctx context.Context) ([]entity.Track, error) {
if !s.Ready() {
return nil, ErrNotReady
}
return s.repo.ListAll(ctx)
}
func (s *Service) Playlist(ctx context.Context, categoryID int64) (*entity.Playlist, error) {
if !s.Ready() {
return nil, ErrNotReady
}
var (
items []entity.Track
err error
)
if categoryID > 0 {
items, err = s.repo.ListEnabledByCategory(ctx, categoryID)
} else {
items, err = s.repo.ListEnabled(ctx)
}
if err != nil {
return nil, err
}
sec, err := s.repo.MemberPreviewSeconds(ctx)
if err != nil {
return nil, err
}
tree, err := s.CategoryTree(ctx, true)
if err != nil {
return nil, err
}
return &entity.Playlist{Items: items, Categories: tree, MemberPreviewSeconds: sec}, nil
}
func (s *Service) Settings(ctx context.Context) (*entity.Settings, error) {
if !s.Ready() {
return nil, ErrNotReady
}
sec, err := s.repo.MemberPreviewSeconds(ctx)
if err != nil {
return nil, err
}
return &entity.Settings{MemberPreviewSeconds: sec}, nil
}
func (s *Service) UpdateSettings(ctx context.Context, previewSeconds int) (*entity.Settings, error) {
if !s.Ready() {
return nil, ErrNotReady
}
if previewSeconds < 0 || previewSeconds > 3600 {
return nil, errs.ErrInvalidArgument.WithMsg("memberPreviewSeconds 须在 0~3600")
}
if err := s.repo.SetSetting(ctx, entity.SettingMemberPreviewSeconds, strconv.Itoa(previewSeconds)); err != nil {
return nil, err
}
return &entity.Settings{MemberPreviewSeconds: previewSeconds}, nil
}
func (s *Service) Create(ctx context.Context, in UpsertInput) (*entity.Track, error) {
if !s.Ready() {
return nil, ErrNotReady
}
title := strings.TrimSpace(in.Title)
if title == "" {
return nil, errs.ErrInvalidArgument.WithMsg("title 不能为空")
}
t := &entity.Track{
Title: title,
RequireMember: false,
SortOrder: 0,
Enabled: true,
CategoryIDs: []int64{},
}
if in.RequireMember != nil {
t.RequireMember = *in.RequireMember
}
if in.SortOrder != nil {
t.SortOrder = *in.SortOrder
}
if in.Enabled != nil {
t.Enabled = *in.Enabled
}
if err := s.repo.Insert(ctx, t); err != nil {
return nil, err
}
if in.CategoryIDs != nil {
if err := s.applyTrackCategories(ctx, t.ID, in.CategoryIDs); err != nil {
return nil, err
}
t.CategoryIDs = dedupeIDs(in.CategoryIDs)
}
return t, nil
}
func (s *Service) Update(ctx context.Context, id int64, in UpsertInput) (*entity.Track, error) {
if !s.Ready() {
return nil, ErrNotReady
}
t, err := s.repo.Get(ctx, id)
if err != nil {
return nil, errs.ErrNotFound.WithMsg("曲目不存在")
}
if in.Title != "" {
t.Title = strings.TrimSpace(in.Title)
}
if t.Title == "" {
return nil, errs.ErrInvalidArgument.WithMsg("title 不能为空")
}
if in.RequireMember != nil {
t.RequireMember = *in.RequireMember
}
if in.SortOrder != nil {
t.SortOrder = *in.SortOrder
}
if in.Enabled != nil {
t.Enabled = *in.Enabled
}
if err := s.repo.Update(ctx, t); err != nil {
return nil, err
}
if in.CategoryIDs != nil {
if err := s.applyTrackCategories(ctx, id, in.CategoryIDs); err != nil {
return nil, err
}
t.CategoryIDs = dedupeIDs(in.CategoryIDs)
}
return t, nil
}
func (s *Service) Delete(ctx context.Context, id int64) error {
if !s.Ready() {
return ErrNotReady
}
t, err := s.repo.Get(ctx, id)
if err != nil {
return errs.ErrNotFound.WithMsg("曲目不存在")
}
_ = s.deleteObject(ctx, t.CoverKey)
_ = s.deleteObject(ctx, t.BackgroundKey)
_ = s.deleteObject(ctx, t.AudioKey)
return s.repo.Delete(ctx, id)
}
func (s *Service) UploadCover(ctx context.Context, id int64, filename string, body []byte, contentType string) (*entity.Track, error) {
return s.uploadImage(ctx, id, filename, body, contentType, "cover")
}
func (s *Service) UploadBackground(ctx context.Context, id int64, filename string, body []byte, contentType string) (*entity.Track, error) {
return s.uploadImage(ctx, id, filename, body, contentType, "background")
}
func (s *Service) uploadImage(ctx context.Context, id int64, filename string, body []byte, contentType, kind string) (*entity.Track, error) {
if !s.Ready() {
return nil, ErrNotReady
}
label := "封面"
maxBytes := entity.MaxCoverBytes
if kind == "background" {
label = "背景图"
maxBytes = entity.MaxBackgroundBytes
}
if len(body) == 0 {
return nil, errs.ErrInvalidArgument.WithMsg("请选择" + label + "文件")
}
if len(body) > maxBytes {
return nil, errs.ErrInvalidArgument.WithMsg(label + "不能超过 5MB")
}
ct := normalizeMIME(contentType, filename)
if !isImageMIME(ct) {
return nil, errs.ErrInvalidArgument.WithMsg(label + "仅支持 jpg/png/webp/gif")
}
t, err := s.repo.Get(ctx, id)
if err != nil {
return nil, errs.ErrNotFound.WithMsg("曲目不存在")
}
ext := extFor(ct, filename, "jpg")
key := fmt.Sprintf("perm/music/%s/%d/%d.%s", kind, id, time.Now().UnixNano(), ext)
if err := s.put(ctx, key, body, ct, maxBytes); err != nil {
return nil, err
}
var old string
if kind == "background" {
old = t.BackgroundKey
t.BackgroundKey = key
t.BackgroundURL = s.publicURL(key)
} else {
old = t.CoverKey
t.CoverKey = key
t.CoverURL = s.publicURL(key)
}
if err := s.repo.Update(ctx, t); err != nil {
return nil, err
}
if old != "" && old != key {
_ = s.deleteObject(ctx, old)
}
return t, nil
}
func (s *Service) UploadAudio(ctx context.Context, id int64, filename string, body []byte, contentType string) (*entity.Track, error) {
if !s.Ready() {
return nil, ErrNotReady
}
if len(body) == 0 {
return nil, errs.ErrInvalidArgument.WithMsg("请选择 mp3 文件")
}
if len(body) > entity.MaxAudioBytes {
return nil, errs.ErrInvalidArgument.WithMsg("音频不能超过 50MB")
}
ct := normalizeMIME(contentType, filename)
if !isAudioMIME(ct, filename) {
return nil, errs.ErrInvalidArgument.WithMsg("音频仅支持 mp3(audio/mpeg)")
}
t, err := s.repo.Get(ctx, id)
if err != nil {
return nil, errs.ErrNotFound.WithMsg("曲目不存在")
}
ext := extFor(ct, filename, "mp3")
key := fmt.Sprintf("perm/music/audio/%d/%d.%s", id, time.Now().UnixNano(), ext)
if err := s.put(ctx, key, body, ct, entity.MaxAudioBytes); err != nil {
return nil, err
}
old := t.AudioKey
t.AudioKey = key
t.AudioURL = s.publicURL(key)
if err := s.repo.Update(ctx, t); err != nil {
return nil, err
}
if old != "" && old != key {
_ = s.deleteObject(ctx, old)
}
return t, nil
}
func (s *Service) CategoryTree(ctx context.Context, enabledOnly bool) ([]entity.Category, error) {
if !s.Ready() {
return nil, ErrNotReady
}
var (
flat []entity.Category
err error
)
if enabledOnly {
flat, err = s.repo.ListEnabledCategories(ctx)
} else {
flat, err = s.repo.ListCategories(ctx)
}
if err != nil {
return nil, err
}
return buildCategoryTree(flat), nil
}
func (s *Service) ListCategoriesAdmin(ctx context.Context) ([]entity.Category, error) {
return s.CategoryTree(ctx, false)
}
func (s *Service) CreateCategory(ctx context.Context, in CategoryInput) (*entity.Category, error) {
if !s.Ready() {
return nil, ErrNotReady
}
name := strings.TrimSpace(in.Name)
if name == "" {
return nil, errs.ErrInvalidArgument.WithMsg("name 不能为空")
}
if in.ParentID != nil && *in.ParentID > 0 {
parent, err := s.repo.GetCategory(ctx, *in.ParentID)
if err != nil {
return nil, errs.ErrNotFound.WithMsg("父分类不存在")
}
if parent.ParentID != nil {
return nil, errs.ErrInvalidArgument.WithMsg("仅支持两级:总分类 → 子分类")
}
} else {
in.ParentID = nil
}
c := &entity.Category{
ParentID: in.ParentID,
Name: name,
SortOrder: 0,
Enabled: true,
}
if in.SortOrder != nil {
c.SortOrder = *in.SortOrder
}
if in.Enabled != nil {
c.Enabled = *in.Enabled
}
if err := s.repo.InsertCategory(ctx, c); err != nil {
return nil, err
}
return c, nil
}
func (s *Service) UpdateCategory(ctx context.Context, id int64, in CategoryInput) (*entity.Category, error) {
if !s.Ready() {
return nil, ErrNotReady
}
c, err := s.repo.GetCategory(ctx, id)
if err != nil {
return nil, errs.ErrNotFound.WithMsg("分类不存在")
}
if in.Name != "" {
c.Name = strings.TrimSpace(in.Name)
}
if c.Name == "" {
return nil, errs.ErrInvalidArgument.WithMsg("name 不能为空")
}
if in.ParentID != nil {
if *in.ParentID == 0 {
c.ParentID = nil
} else if *in.ParentID == id {
return nil, errs.ErrInvalidArgument.WithMsg("不能将分类设为自己的父级")
} else {
parent, err := s.repo.GetCategory(ctx, *in.ParentID)
if err != nil {
return nil, errs.ErrNotFound.WithMsg("父分类不存在")
}
if parent.ParentID != nil {
return nil, errs.ErrInvalidArgument.WithMsg("仅支持两级:总分类 → 子分类")
}
c.ParentID = in.ParentID
}
}
if in.SortOrder != nil {
c.SortOrder = *in.SortOrder
}
if in.Enabled != nil {
c.Enabled = *in.Enabled
}
if err := s.repo.UpdateCategory(ctx, c); err != nil {
return nil, err
}
return c, nil
}
func (s *Service) DeleteCategory(ctx context.Context, id int64) error {
if !s.Ready() {
return ErrNotReady
}
if _, err := s.repo.GetCategory(ctx, id); err != nil {
return errs.ErrNotFound.WithMsg("分类不存在")
}
return s.repo.DeleteCategory(ctx, id)
}
func (s *Service) SetTrackCategories(ctx context.Context, trackID int64, categoryIDs []int64) (*entity.Track, error) {
if !s.Ready() {
return nil, ErrNotReady
}
t, err := s.repo.Get(ctx, trackID)
if err != nil {
return nil, errs.ErrNotFound.WithMsg("曲目不存在")
}
if err := s.applyTrackCategories(ctx, trackID, categoryIDs); err != nil {
return nil, err
}
t.CategoryIDs = dedupeIDs(categoryIDs)
return t, nil
}
func (s *Service) applyTrackCategories(ctx context.Context, trackID int64, categoryIDs []int64) error {
ids := dedupeIDs(categoryIDs)
for _, cid := range ids {
if _, err := s.repo.GetCategory(ctx, cid); err != nil {
return errs.ErrInvalidArgument.WithMsg(fmt.Sprintf("分类 %d 不存在", cid))
}
}
return s.repo.SetTrackCategories(ctx, trackID, ids)
}
func buildCategoryTree(flat []entity.Category) []entity.Category {
nodes := make(map[int64]*entity.Category, len(flat))
order := make([]int64, 0, len(flat))
for _, c := range flat {
cc := c
cc.Children = nil
nodes[cc.ID] = &cc
order = append(order, cc.ID)
}
rootPtrs := make([]*entity.Category, 0)
for _, id := range order {
node := nodes[id]
if node.ParentID == nil || *node.ParentID == 0 {
rootPtrs = append(rootPtrs, node)
continue
}
parent, ok := nodes[*node.ParentID]
if !ok {
rootPtrs = append(rootPtrs, node)
continue
}
parent.Children = append(parent.Children, *node)
}
out := make([]entity.Category, 0, len(rootPtrs))
for _, r := range rootPtrs {
out = append(out, *r)
}
return out
}
func dedupeIDs(ids []int64) []int64 {
seen := map[int64]struct{}{}
out := make([]int64, 0, len(ids))
for _, id := range ids {
if id <= 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out
}
func (s *Service) put(ctx context.Context, key string, body []byte, contentType string, max int) error {
if s.objects == nil || !s.objects.Ready() {
return errs.ErrAvailable.WithMsg("对象存储未就绪")
}
return s.objects.PutLarge(ctx, key, body, contentType, max)
}
func (s *Service) deleteObject(ctx context.Context, key string) error {
if key == "" || s.objects == nil || !s.objects.Ready() {
return nil
}
return s.objects.Delete(ctx, key)
}
func normalizeMIME(raw, filename string) string {
s := strings.ToLower(strings.TrimSpace(raw))
s, _, _ = strings.Cut(s, ";")
s = strings.TrimSpace(s)
if s != "" && s != "application/octet-stream" {
return s
}
ext := strings.ToLower(strings.TrimPrefix(path.Ext(filename), "."))
switch ext {
case "jpg", "jpeg":
return "image/jpeg"
case "png":
return "image/png"
case "webp":
return "image/webp"
case "gif":
return "image/gif"
case "mp3":
return "audio/mpeg"
case "m4a":
return "audio/mp4"
default:
return s
}
}
func isImageMIME(ct string) bool {
switch ct {
case "image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif":
return true
default:
return false
}
}
func isAudioMIME(ct, filename string) bool {
switch ct {
case "audio/mpeg", "audio/mp3", "audio/x-mpeg":
return true
}
return strings.HasSuffix(strings.ToLower(filename), ".mp3")
}
func extFor(ct, filename, fallback string) string {
ext := strings.ToLower(strings.TrimPrefix(path.Ext(filename), "."))
if ext != "" && len(ext) <= 8 {
return ext
}
switch ct {
case "image/jpeg", "image/jpg":
return "jpg"
case "image/png":
return "png"
case "image/webp":
return "webp"
case "image/gif":
return "gif"
case "audio/mpeg", "audio/mp3":
return "mp3"
default:
return fallback
}
}