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") const ( mimeJPEG = "image/jpeg" mimePNG = "image/png" mimeWebP = "image/webp" mimeGIF = "image/gif" mimeMPEG = "audio/mpeg" extJPG = "jpg" extJPEG = "jpeg" extPNG = "png" extWebP = "webp" extGIF = "gif" extMP3 = "mp3" ) 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) } // PlaylistQuery filters the public music list. type PlaylistQuery struct { CategoryID int64 RequireMember *bool // nil=all; true=仅会员曲;false=仅免费曲 } func (s *Service) Playlist(ctx context.Context, q PlaylistQuery) (*entity.Playlist, error) { if !s.Ready() { return nil, ErrNotReady } var ( items []entity.Track err error ) if q.CategoryID > 0 { items, err = s.repo.ListEnabledByCategory(ctx, q.CategoryID) } else { items, err = s.repo.ListEnabled(ctx) } if err != nil { return nil, err } items = filterRequireMember(items, q.RequireMember) 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 } // GetPublic returns one enabled track for clients (maps to HuanXiu /music/v1/detail). func (s *Service) GetPublic(ctx context.Context, id int64) (*entity.Track, error) { if !s.Ready() { return nil, ErrNotReady } if id <= 0 { return nil, errs.ErrInvalidArgument.WithMsg("id 无效") } t, err := s.repo.Get(ctx, id) if err != nil { return nil, errs.ErrNotFound.WithMsg("曲目不存在") } if !t.Enabled { return nil, errs.ErrNotFound.WithMsg("曲目不存在") } return t, nil } // PlaylistGrouped returns leaf categories each with their tracks // (maps to HuanXiu zoneAndZoneMusicList for home tab). // parentID>0 limits to that category itself (if leaf) or its children. func (s *Service) PlaylistGrouped(ctx context.Context, parentID int64) (*entity.GroupedPlaylist, error) { if !s.Ready() { return nil, ErrNotReady } if parentID < 0 { return nil, errs.ErrInvalidArgument.WithMsg("parentId 无效") } tree, err := s.CategoryTree(ctx, true) if err != nil { return nil, err } leaves := collectLeafCategories(tree, parentID) sec, err := s.repo.MemberPreviewSeconds(ctx) if err != nil { return nil, err } groups := make([]entity.PlaylistGroup, 0, len(leaves)) for i := range leaves { cat := leaves[i] items, err := s.repo.ListEnabledByCategory(ctx, cat.ID) if err != nil { return nil, err } if items == nil { items = []entity.Track{} } groups = append(groups, entity.PlaylistGroup{ CategoryID: cat.ID, Name: cat.Name, ParentID: cat.ParentID, Items: items, }) } return &entity.GroupedPlaylist{Groups: groups, MemberPreviewSeconds: sec}, nil } func filterRequireMember(items []entity.Track, requireMember *bool) []entity.Track { if requireMember == nil { return items } out := make([]entity.Track, 0, len(items)) want := *requireMember for i := range items { if items[i].RequireMember == want { out = append(out, items[i]) } } return out } // collectLeafCategories walks the enabled tree. // parentID==0 → all leaves; parentID>0 → that node if leaf, else its child leaves. func collectLeafCategories(tree []entity.Category, parentID int64) []entity.Category { if parentID > 0 { if cat, ok := findCategory(tree, parentID); ok { if len(cat.Children) == 0 { return []entity.Category{cat} } return leafCategories(cat.Children) } return nil } return leafCategories(tree) } func findCategory(tree []entity.Category, id int64) (entity.Category, bool) { for i := range tree { c := tree[i] if c.ID == id { return c, true } if found, ok := findCategory(c.Children, id); ok { return found, true } } return entity.Category{}, false } func leafCategories(tree []entity.Category) []entity.Category { var out []entity.Category for i := range tree { c := tree[i] if len(c.Children) == 0 { out = append(out, c) continue } out = append(out, leafCategories(c.Children)...) } return out } 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, extJPG) 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, extMP3) 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 { switch { case *in.ParentID == 0: c.ParentID = nil case *in.ParentID == id: return nil, errs.ErrInvalidArgument.WithMsg("不能将分类设为自己的父级") default: 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 i := range flat { flat[i].Children = nil nodes[flat[i].ID] = &flat[i] order = append(order, flat[i].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, maxBytes int) error { if s.objects == nil || !s.objects.Ready() { return errs.ErrAvailable.WithMsg("对象存储未就绪") } return s.objects.PutLarge(ctx, key, body, contentType, maxBytes) } 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 extJPG, extJPEG: return mimeJPEG case extPNG: return mimePNG case extWebP: return mimeWebP case extGIF: return mimeGIF case extMP3: return mimeMPEG 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 extJPG case mimePNG: return extPNG case mimeWebP: return extWebP case mimeGIF: return extGIF case "audio/mpeg", "audio/mp3": return extMP3 default: return fallback } }