新增总/子分类与曲目绑定;playlist 可按 categoryId 筛选并返回分类树; popadmin 支持行内改曲名/排序/上下架/会员开关与封面背景音频上传;文档同步。 Co-authored-by: Cursor <cursoragent@cursor.com>
581 lines
15 KiB
Go
581 lines
15 KiB
Go
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
|
||
}
|
||
}
|