spark-server/internal/module/music/logic/service.go
DESKTOP-L8OUL9F\gfl0034 53426af122 feat: 多广场 channel、音乐公开 API 与订阅 package_name
支持 square/squarenovel/squaresteps/squarehealth 实例与 category 筛选;音乐增加免登录 grouped/track;订阅套餐按 package_name 区分;补 migration 0030/0031 与 OpenAPI。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 09:02:28 +08:00

712 lines
18 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")
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 _, cat := range leaves {
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 _, t := range items {
if t.RequireMember == want {
out = append(out, t)
}
}
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 _, c := range tree {
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 _, c := range tree {
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
}
}