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) 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 } 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"` } 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) (*entity.Playlist, error) { if !s.Ready() { return nil, ErrNotReady } items, err := s.repo.ListEnabled(ctx) if err != nil { return nil, err } sec, err := s.repo.MemberPreviewSeconds(ctx) if err != nil { return nil, err } return &entity.Playlist{Items: items, 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, } 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 } 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 } 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.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) { if !s.Ready() { return nil, ErrNotReady } if len(body) == 0 { return nil, errs.ErrInvalidArgument.WithMsg("请选择封面文件") } if len(body) > entity.MaxCoverBytes { return nil, errs.ErrInvalidArgument.WithMsg("封面不能超过 5MB") } ct := normalizeMIME(contentType, filename) if !isImageMIME(ct) { return nil, errs.ErrInvalidArgument.WithMsg("封面仅支持 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/cover/%d/%d.%s", id, time.Now().UnixNano(), ext) if err := s.put(ctx, key, body, ct, entity.MaxCoverBytes); err != nil { return nil, err } 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) 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 } }