spark-server/internal/module/music/logic/service.go
DESKTOP-L8OUL9F\gfl0034 ce2cdf6bf3 feat: music 运营曲目、seed 机器人欢迎消息,并补充 novels/music OpenAPI
合并 origin/main 设备注册后,纳入本地 music 模块、seed 命令与 popadmin 相关路由/文档;music migration 编号为 0020 以避免与 0019_devices 冲突。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 16:07:05 +08:00

335 lines
8.4 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.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
}
}