spark-server/internal/module/music/logic/service.go
DESKTOP-L8OUL9F\gfl0034 ade98eef4d feat(music): 曲目增加背景图字段与管理端上传
新增 migration 0021(background_key/url)、playlist/CRUD 返回 backgroundUrl、
POST .../music/{id}/background,并热更 popadmin 音乐列表背景上传;公读策略放行 perm/music/*。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 09:17:21 +08:00

357 lines
9.1 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)
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.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) 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
}
}