feat(music): 曲目增加背景图字段与管理端上传

新增 migration 0021(background_key/url)、playlist/CRUD 返回 backgroundUrl、
POST .../music/{id}/background,并热更 popadmin 音乐列表背景上传;公读策略放行 perm/music/*。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
DESKTOP-L8OUL9F\gfl0034 2026-09-24 09:17:21 +08:00
parent 0a54e9b2ef
commit ade98eef4d
14 changed files with 5326 additions and 41 deletions

4
.gitignore vendored
View File

@ -11,3 +11,7 @@ coverage.out
.env
certs/*.key
server
_release/
*.pem

View File

@ -57,9 +57,11 @@ jwt:
denylist_prefix: "denylist:"
refresh_prefix: "refresh:"
# Must match Aliyun wyrs.admin.jwt-secret / WYRS_ADMIN_JWT_SECRET (popadmin login).
# Must match Java WYRS_ADMIN_JWT_SECRET (popadmin login).
# Prod validate REJECTS the jar default "wyrs-admin-jwt-secret-change-in-production-2025".
# Prefer setting the same value via SPARK_ADMIN_JWT_SECRET and /etc/wyrs-admin/app.env.
admin:
jwt_secret: "wyrs-admin-jwt-secret-change-in-production"
jwt_secret: "replace-with-shared-WYRS_ADMIN_JWT_SECRET"
taskpool:
size: 256

View File

@ -0,0 +1,90 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Patch popadmin.js music() UI to support background image upload/display."""
from __future__ import annotations
from pathlib import Path
p = Path(__file__).with_name("popadmin.js")
c = p.read_text(encoding="utf-8")
replacements = [
(
"· 封面/mp3 存 sparkr",
"· 封面/背景图/mp3 存 sparkr",
),
(
"<thead><tr><th>封面</th><th>ID</th><th>曲名</th><th>会员</th><th>上架</th><th>排序</th><th>音频</th><th>操作</th></tr></thead>",
"<thead><tr><th>封面</th><th>背景</th><th>ID</th><th>曲名</th><th>会员</th><th>上架</th><th>排序</th><th>音频</th><th>操作</th></tr></thead>",
),
(
"${t.coverUrl ? `<img src=\"${esc(t.coverUrl)}\" alt=\"\" style=\"width:48px;height:48px;object-fit:cover;border-radius:6px\">` : '—'}</td>\n"
" <td>${esc(t.id)}</td>",
"${t.coverUrl ? `<img src=\"${esc(t.coverUrl)}\" alt=\"\" style=\"width:48px;height:48px;object-fit:cover;border-radius:6px\">` : '—'}</td>\n"
" <td>${t.backgroundUrl ? `<img src=\"${esc(t.backgroundUrl)}\" alt=\"\" style=\"width:48px;height:48px;object-fit:cover;border-radius:6px\">` : '—'}</td>\n"
" <td>${esc(t.id)}</td>",
),
(
'<label class="secondary btn-sm" style="display:inline-block;cursor:pointer;padding:4px 8px">封面<input type="file" accept="image/*" data-mcover="${esc(t.id)}" hidden></label>\n'
' <label class="secondary btn-sm" style="display:inline-block;cursor:pointer;padding:4px 8px">mp3<input type="file" accept="audio/mpeg,.mp3" data-maudio="${esc(t.id)}" hidden></label>',
'<label class="secondary btn-sm" style="display:inline-block;cursor:pointer;padding:4px 8px">封面<input type="file" accept="image/*" data-mcover="${esc(t.id)}" hidden></label>\n'
' <label class="secondary btn-sm" style="display:inline-block;cursor:pointer;padding:4px 8px">背景<input type="file" accept="image/*" data-mbg="${esc(t.id)}" hidden></label>\n'
' <label class="secondary btn-sm" style="display:inline-block;cursor:pointer;padding:4px 8px">mp3<input type="file" accept="audio/mpeg,.mp3" data-maudio="${esc(t.id)}" hidden></label>',
),
(
": '<tr><td colspan=\"8\">暂无曲目</td></tr>'",
": '<tr><td colspan=\"9\">暂无曲目</td></tr>'",
),
]
for old, new in replacements:
if old not in c:
raise SystemExit(f"pattern not found:\n{old[:120]!r}")
if old.count(old) != 1 and c.count(old) != 1:
raise SystemExit(f"pattern not unique ({c.count(old)}):\n{old[:80]!r}")
c = c.replace(old, new, 1)
handler_old = """ $('content').querySelectorAll('[data-mcover]').forEach((inp) => {
inp.onchange = async () => {
if (!inp.files?.[0]) return;
const fd = new FormData();
fd.append('file', inp.files[0]);
await apiUpload('/spark/music/' + inp.dataset.mcover + '/cover', fd);
pages.music();
};
});
$('content').querySelectorAll('[data-maudio]').forEach((inp) => {"""
handler_new = """ $('content').querySelectorAll('[data-mcover]').forEach((inp) => {
inp.onchange = async () => {
if (!inp.files?.[0]) return;
const fd = new FormData();
fd.append('file', inp.files[0]);
await apiUpload('/spark/music/' + inp.dataset.mcover + '/cover', fd);
pages.music();
};
});
$('content').querySelectorAll('[data-mbg]').forEach((inp) => {
inp.onchange = async () => {
if (!inp.files?.[0]) return;
const fd = new FormData();
fd.append('file', inp.files[0]);
await apiUpload('/spark/music/' + inp.dataset.mbg + '/background', fd);
pages.music();
};
});
$('content').querySelectorAll('[data-maudio]').forEach((inp) => {"""
if handler_old not in c:
raise SystemExit("cover/audio handlers not found")
if "data-mbg" in c and "/background" in c[c.find("async music()") : c.find("async novelCms()")]:
print("already patched?")
else:
c = c.replace(handler_old, handler_new, 1)
p.write_text(c, encoding="utf-8")
music = c[c.find("async music()") : c.find("async novelCms()")]
assert "backgroundUrl" in music
assert "data-mbg" in music
assert "/background" in music
print("patched ok", p, "bytes", p.stat().st_size)

5087
deploy/popadmin/popadmin.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -3,28 +3,38 @@
## 1. spark-server(音乐 API + OpenAPI)
```bash
# 新加坡机
cd /opt/spark-server # 或实际路径
# 拉代码后
./cmd/migrate # 或项目惯用 migrate,执行 migrations/0020_music.sql
# 重启 spark-server 进程 / docker compose
# 本机(Windows/WSL):构建并部署到新加坡机
make deploy
# 会:linux 交叉编译 → docker image → 上传 ECS → spark-migrate(含 0020_music / 0021_music_background)→ 重启容器 → nginx reload
```
新加坡机手工补跑迁移(仅 image 已更新但 migrate 未跑时):
```bash
docker exec spark-server spark-migrate -config /app/configs/config.yaml
# 或 compose 目录下:docker compose run --rm spark-server spark-migrate -config /app/configs/config.yaml
sudo nginx -t && sudo nginx -s reload
# 确认 include 了更新后的 popadmin-spark-admin-locations.conf(含 music)
# 确认 include 了 popadmin-spark-admin-locations.conf(含 music,client_max_body_size 55m)
```
验证:
- `GET https://sparkapi.wyrs365.com/v1/music/playlist` → `code=0`
- `GET https://sparkapi.wyrs365.com/docs` → 可见 music / admin-music / novels / admin-novels / 登录设备 Breaking 说明
- popadmin 登录后「音乐列表」:`GET/POST /api/admin/spark/music`
- `GET https://sparkapi.wyrs365.com/v1/music/playlist` → `code=0`,曲目含 `coverUrl` / `backgroundUrl`(可空)/ `audioUrl`
- `GET https://sparkapi.wyrs365.com/docs` → 可见 music / admin-music
- popadmin「音乐列表」:封面 / **背景** / mp3 上传;`POST /api/admin/spark/music/{id}/background`
## 2. Aliyun wyrs-admin(popadmin 静态)
热更或发版 `static/popadmin/js/popadmin.js`(侧栏「音乐列表」「小说内容管理」)。
热更音乐背景图 UI(与现网一致):
新加坡若用 static-override,同步覆盖:
```bash
# 源:spark-server/deploy/popadmin/popadmin.js(已含背景列与上传)
scp -i ~/.ssh/wyrs_shanghai_deploy.pem deploy/popadmin/popadmin.js \
root@47.236.66.9:/opt/wyrs-admin/static-override/popadmin.js
# 若 index 带 ?v= 缓存戳,同步 bump popadmin.index.html
```
`/opt/wyrs-admin/static-override/popadmin.js`(或等价路径)并 bump `?v=`。
新加坡若用 static-override,覆盖后强刷浏览器即可。
## 3. FullStoryNovel CMS(小说 iframe)
@ -39,3 +49,16 @@ sudo nginx -t && sudo nginx -s reload
OpenAPI 已按 `Spark资源文档/devices-client.md` 更新契约。
**当前 Go 实现仍为旧登录**(Body 必填客户端 `device_id`、字符串 `device_type`,JWT 无 `aid`)。上线新契约前需改 auth/login、JWT、meta 枚举。
## 5. Demo 曲目(可选)
仓库旁 `_demo_music/`:`tracks.json` + `01..10.mp3/png` + `upload_demo.py`。
背景图**可空**,demo **不强制**补背景;脚本勿删已有曲目(仅 POST 新建)。
```bash
# 在新加坡机(或可访问 popadmin 登录与 spark admin API 的环境)
export SPARK_ADMIN_BASE=http://127.0.0.1:18080
export POPADMIN_LOGIN=http://127.0.0.1:8090/api/admin/auth/login
python3 upload_demo.py
curl -sS https://sparkapi.wyrs365.com/v1/music/playlist | jq '.data.items|length,.data.items[0]|{title,coverUrl,backgroundUrl,audioUrl}'
```

View File

@ -86,8 +86,8 @@ func (s *Store) applyCORS(ctx context.Context) {
}
}
// applyPublicRead lets browsers load avatars without a presigned GET.
// chat/ and perm/fav/ stay private.
// applyPublicRead lets browsers load avatars / group icons / music assets without a
// presigned GET. chat/ and perm/fav/ stay private.
func (s *Store) applyPublicRead(ctx context.Context) {
pabCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
@ -117,13 +117,14 @@ func publicReadPolicy(bucket string) string {
return fmt.Sprintf(`{
"Version": "2012-10-17",
"Statement": [{
"Sid": "PublicReadAvatars",
"Sid": "PublicReadPerm",
"Effect": "Allow",
"Principal": "*",
"Action": ["s3:GetObject"],
"Resource": [
"arn:aws:s3:::%[1]s/perm/avatar/*",
"arn:aws:s3:::%[1]s/perm/group/*"
"arn:aws:s3:::%[1]s/perm/group/*",
"arn:aws:s3:::%[1]s/perm/music/*"
]
}]
}`, bucket)

View File

@ -104,9 +104,9 @@ func TestGetPutNotReady(t *testing.T) {
}
}
func TestPublicReadPolicyCoversAvatarsOnly(t *testing.T) {
func TestPublicReadPolicyCoversAvatarsAndMusic(t *testing.T) {
p := publicReadPolicy("spark")
if !strings.Contains(p, "spark/perm/avatar/*") || !strings.Contains(p, "spark/perm/group/*") {
if !strings.Contains(p, "spark/perm/avatar/*") || !strings.Contains(p, "spark/perm/group/*") || !strings.Contains(p, "spark/perm/music/*") {
t.Fatalf("%s", p)
}
if strings.Contains(p, "chat/") || strings.Contains(p, "perm/fav") || strings.Contains(p, "perm/moment") {

View File

@ -6,8 +6,9 @@ const (
SettingMemberPreviewSeconds = "member_preview_seconds"
DefaultPreviewSeconds = 30
MaxCoverBytes = 5 << 20 // 5 MiB
MaxAudioBytes = 50 << 20 // 50 MiB
MaxCoverBytes = 5 << 20 // 5 MiB
MaxBackgroundBytes = 5 << 20 // 5 MiB
MaxAudioBytes = 50 << 20 // 50 MiB
)
// Track is one curated music item for the Spark music list.
@ -16,8 +17,10 @@ type Track struct {
Title string `json:"title"`
CoverKey string `json:"coverKey"`
AudioKey string `json:"audioKey"`
BackgroundKey string `json:"backgroundKey"`
CoverURL string `json:"coverUrl"`
AudioURL string `json:"audioUrl"`
BackgroundURL string `json:"backgroundUrl"`
RequireMember bool `json:"requireMember"`
SortOrder int `json:"sortOrder"`
Enabled bool `json:"enabled"`
@ -32,6 +35,6 @@ type Settings struct {
// Playlist is the client-facing payload.
type Playlist struct {
Items []Track `json:"items"`
MemberPreviewSeconds int `json:"memberPreviewSeconds"`
Items []Track `json:"items"`
MemberPreviewSeconds int `json:"memberPreviewSeconds"`
}

View File

@ -181,36 +181,58 @@ func (s *Service) Delete(ctx context.Context, id int64) error {
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
}
if len(body) == 0 {
return nil, errs.ErrInvalidArgument.WithMsg("请选择封面文件")
label := "封面"
maxBytes := entity.MaxCoverBytes
if kind == "background" {
label = "背景图"
maxBytes = entity.MaxBackgroundBytes
}
if len(body) > entity.MaxCoverBytes {
return nil, errs.ErrInvalidArgument.WithMsg("封面不能超过 5MB")
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("封面仅支持 jpg/png/webp/gif")
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/cover/%d/%d.%s", id, time.Now().UnixNano(), ext)
if err := s.put(ctx, key, body, ct, entity.MaxCoverBytes); err != nil {
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
}
old := t.CoverKey
t.CoverKey = key
t.CoverURL = s.publicURL(key)
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
}

View File

@ -31,8 +31,10 @@ type trackRow struct {
Title string `gorm:"column:title"`
CoverKey string `gorm:"column:cover_key"`
AudioKey string `gorm:"column:audio_key"`
BackgroundKey string `gorm:"column:background_key"`
CoverURL string `gorm:"column:cover_url"`
AudioURL string `gorm:"column:audio_url"`
BackgroundURL string `gorm:"column:background_url"`
RequireMember bool `gorm:"column:require_member"`
SortOrder int `gorm:"column:sort_order"`
Enabled bool `gorm:"column:enabled"`
@ -104,8 +106,10 @@ func (r *Repo) Insert(ctx context.Context, t *entity.Track) error {
Title: t.Title,
CoverKey: t.CoverKey,
AudioKey: t.AudioKey,
BackgroundKey: t.BackgroundKey,
CoverURL: t.CoverURL,
AudioURL: t.AudioURL,
BackgroundURL: t.BackgroundURL,
RequireMember: t.RequireMember,
SortOrder: t.SortOrder,
Enabled: t.Enabled,
@ -131,8 +135,10 @@ func (r *Repo) Update(ctx context.Context, t *entity.Track) error {
"title": t.Title,
"cover_key": t.CoverKey,
"audio_key": t.AudioKey,
"background_key": t.BackgroundKey,
"cover_url": t.CoverURL,
"audio_url": t.AudioURL,
"background_url": t.BackgroundURL,
"require_member": t.RequireMember,
"sort_order": t.SortOrder,
"enabled": t.Enabled,
@ -192,8 +198,10 @@ func toEntity(row trackRow) entity.Track {
Title: row.Title,
CoverKey: row.CoverKey,
AudioKey: row.AudioKey,
BackgroundKey: row.BackgroundKey,
CoverURL: row.CoverURL,
AudioURL: row.AudioURL,
BackgroundURL: row.BackgroundURL,
RequireMember: row.RequireMember,
SortOrder: row.SortOrder,
Enabled: row.Enabled,

View File

@ -1869,8 +1869,8 @@ paths:
summary: 音乐列表(公开)
description: |
返回已上架曲目与全局「会员音乐免费试听秒数」。无需登录。
封面/音频为 sparkr 公网 URL(`https://sparkr.wyrs365.com/spark/perm/music/...`)。
客户端应按 `requireMember` 与 `memberPreviewSeconds` 控制试听。
封面/背景图/音频为 sparkr 公网 URL(`https://sparkr.wyrs365.com/spark/perm/music/...`)。
客户端应按 `requireMember` 与 `memberPreviewSeconds` 控制试听;`backgroundUrl` 可空。
operationId: getMusicPlaylist
security: []
responses:
@ -2044,6 +2044,36 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/AdminApiResponse"
/api/admin/spark/music/{id}/background:
post:
tags: [admin-music]
summary: 上传背景图(multipart file)
description: 字段名 `file`;jpg/png/webp/gif,≤5MB。写入 RustFS `perm/music/background/...`。可空(未上传时 `backgroundUrl` 为空串)。
operationId: adminUploadMusicBackground
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema: { type: integer, format: int64 }
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file:
type: string
format: binary
responses:
"200":
content:
application/json:
schema:
$ref: "#/components/schemas/AdminApiResponse"
/api/admin/spark/music/{id}/audio:
post:
tags: [admin-music]
@ -2926,8 +2956,10 @@ components:
title: { type: string }
coverKey: { type: string }
audioKey: { type: string }
backgroundKey: { type: string, description: 背景图 object_key;可空 }
coverUrl: { type: string, description: sparkr 公网 URL }
audioUrl: { type: string, description: sparkr 公网 URL }
backgroundUrl: { type: string, description: 背景图 sparkr 公网 URL;可空 }
requireMember: { type: boolean, description: true=需会员 }
sortOrder: { type: integer }
enabled: { type: boolean }

View File

@ -133,14 +133,18 @@ func (h *MusicHandler) Delete(c *gin.Context) {
}
func (h *MusicHandler) UploadCover(c *gin.Context) {
h.upload(c, true)
h.upload(c, "cover")
}
func (h *MusicHandler) UploadBackground(c *gin.Context) {
h.upload(c, "background")
}
func (h *MusicHandler) UploadAudio(c *gin.Context) {
h.upload(c, false)
h.upload(c, "audio")
}
func (h *MusicHandler) upload(c *gin.Context, cover bool) {
func (h *MusicHandler) upload(c *gin.Context, kind string) {
if !h.ready(c) {
return
}
@ -162,9 +166,12 @@ func (h *MusicHandler) upload(c *gin.Context, cover bool) {
}
ct := hdr.Header.Get("Content-Type")
var out any
if cover {
switch kind {
case "cover":
out, err = h.svc.UploadCover(c.Request.Context(), id, hdr.Filename, body, ct)
} else {
case "background":
out, err = h.svc.UploadBackground(c.Request.Context(), id, hdr.Filename, body, ct)
default:
out, err = h.svc.UploadAudio(c.Request.Context(), id, hdr.Filename, body, ct)
}
if err != nil {

View File

@ -84,5 +84,6 @@ func registerMusicAdmin(g *gin.RouterGroup, h *adminhandler.MusicHandler) {
g.PATCH("/music/:id", h.Update)
g.DELETE("/music/:id", h.Delete)
g.POST("/music/:id/cover", h.UploadCover)
g.POST("/music/:id/background", h.UploadBackground)
g.POST("/music/:id/audio", h.UploadAudio)
}

View File

@ -0,0 +1,5 @@
-- 曲目背景图(与 cover 同形态:object key + 公网 URL;可空)。
ALTER TABLE t_music_tracks
ADD COLUMN background_key VARCHAR(512) NOT NULL DEFAULT '' COMMENT '背景图 object_key(sparkr)' AFTER cover_url,
ADD COLUMN background_url VARCHAR(1024) NOT NULL DEFAULT '' COMMENT '背景图公网 URL' AFTER background_key;