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:
parent
0a54e9b2ef
commit
ade98eef4d
4
.gitignore
vendored
4
.gitignore
vendored
@ -11,3 +11,7 @@ coverage.out
|
|||||||
.env
|
.env
|
||||||
certs/*.key
|
certs/*.key
|
||||||
server
|
server
|
||||||
|
|
||||||
|
_release/
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
|||||||
@ -57,9 +57,11 @@ jwt:
|
|||||||
denylist_prefix: "denylist:"
|
denylist_prefix: "denylist:"
|
||||||
refresh_prefix: "refresh:"
|
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:
|
admin:
|
||||||
jwt_secret: "wyrs-admin-jwt-secret-change-in-production"
|
jwt_secret: "replace-with-shared-WYRS_ADMIN_JWT_SECRET"
|
||||||
|
|
||||||
taskpool:
|
taskpool:
|
||||||
size: 256
|
size: 256
|
||||||
|
|||||||
90
deploy/popadmin/patch_music_bg.py
Normal file
90
deploy/popadmin/patch_music_bg.py
Normal 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
5087
deploy/popadmin/popadmin.js
Normal file
File diff suppressed because it is too large
Load Diff
@ -3,28 +3,38 @@
|
|||||||
## 1. spark-server(音乐 API + OpenAPI)
|
## 1. spark-server(音乐 API + OpenAPI)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 新加坡机
|
# 本机(Windows/WSL):构建并部署到新加坡机
|
||||||
cd /opt/spark-server # 或实际路径
|
make deploy
|
||||||
# 拉代码后
|
# 会:linux 交叉编译 → docker image → 上传 ECS → spark-migrate(含 0020_music / 0021_music_background)→ 重启容器 → nginx reload
|
||||||
./cmd/migrate # 或项目惯用 migrate,执行 migrations/0020_music.sql
|
```
|
||||||
# 重启 spark-server 进程 / docker compose
|
|
||||||
|
新加坡机手工补跑迁移(仅 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
|
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/v1/music/playlist` → `code=0`,曲目含 `coverUrl` / `backgroundUrl`(可空)/ `audioUrl`
|
||||||
- `GET https://sparkapi.wyrs365.com/docs` → 可见 music / admin-music / novels / admin-novels / 登录设备 Breaking 说明
|
- `GET https://sparkapi.wyrs365.com/docs` → 可见 music / admin-music
|
||||||
- popadmin 登录后「音乐列表」:`GET/POST /api/admin/spark/music`
|
- popadmin「音乐列表」:封面 / **背景** / mp3 上传;`POST /api/admin/spark/music/{id}/background`
|
||||||
|
|
||||||
## 2. Aliyun wyrs-admin(popadmin 静态)
|
## 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)
|
## 3. FullStoryNovel CMS(小说 iframe)
|
||||||
|
|
||||||
@ -39,3 +49,16 @@ sudo nginx -t && sudo nginx -s reload
|
|||||||
|
|
||||||
OpenAPI 已按 `Spark资源文档/devices-client.md` 更新契约。
|
OpenAPI 已按 `Spark资源文档/devices-client.md` 更新契约。
|
||||||
**当前 Go 实现仍为旧登录**(Body 必填客户端 `device_id`、字符串 `device_type`,JWT 无 `aid`)。上线新契约前需改 auth/login、JWT、meta 枚举。
|
**当前 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}'
|
||||||
|
```
|
||||||
|
|||||||
@ -86,8 +86,8 @@ func (s *Store) applyCORS(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyPublicRead lets browsers load avatars without a presigned GET.
|
// applyPublicRead lets browsers load avatars / group icons / music assets without a
|
||||||
// chat/ and perm/fav/ stay private.
|
// presigned GET. chat/ and perm/fav/ stay private.
|
||||||
func (s *Store) applyPublicRead(ctx context.Context) {
|
func (s *Store) applyPublicRead(ctx context.Context) {
|
||||||
pabCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
pabCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@ -117,13 +117,14 @@ func publicReadPolicy(bucket string) string {
|
|||||||
return fmt.Sprintf(`{
|
return fmt.Sprintf(`{
|
||||||
"Version": "2012-10-17",
|
"Version": "2012-10-17",
|
||||||
"Statement": [{
|
"Statement": [{
|
||||||
"Sid": "PublicReadAvatars",
|
"Sid": "PublicReadPerm",
|
||||||
"Effect": "Allow",
|
"Effect": "Allow",
|
||||||
"Principal": "*",
|
"Principal": "*",
|
||||||
"Action": ["s3:GetObject"],
|
"Action": ["s3:GetObject"],
|
||||||
"Resource": [
|
"Resource": [
|
||||||
"arn:aws:s3:::%[1]s/perm/avatar/*",
|
"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)
|
}`, bucket)
|
||||||
|
|||||||
@ -104,9 +104,9 @@ func TestGetPutNotReady(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPublicReadPolicyCoversAvatarsOnly(t *testing.T) {
|
func TestPublicReadPolicyCoversAvatarsAndMusic(t *testing.T) {
|
||||||
p := publicReadPolicy("spark")
|
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)
|
t.Fatalf("%s", p)
|
||||||
}
|
}
|
||||||
if strings.Contains(p, "chat/") || strings.Contains(p, "perm/fav") || strings.Contains(p, "perm/moment") {
|
if strings.Contains(p, "chat/") || strings.Contains(p, "perm/fav") || strings.Contains(p, "perm/moment") {
|
||||||
|
|||||||
@ -7,6 +7,7 @@ const (
|
|||||||
DefaultPreviewSeconds = 30
|
DefaultPreviewSeconds = 30
|
||||||
|
|
||||||
MaxCoverBytes = 5 << 20 // 5 MiB
|
MaxCoverBytes = 5 << 20 // 5 MiB
|
||||||
|
MaxBackgroundBytes = 5 << 20 // 5 MiB
|
||||||
MaxAudioBytes = 50 << 20 // 50 MiB
|
MaxAudioBytes = 50 << 20 // 50 MiB
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -16,8 +17,10 @@ type Track struct {
|
|||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
CoverKey string `json:"coverKey"`
|
CoverKey string `json:"coverKey"`
|
||||||
AudioKey string `json:"audioKey"`
|
AudioKey string `json:"audioKey"`
|
||||||
|
BackgroundKey string `json:"backgroundKey"`
|
||||||
CoverURL string `json:"coverUrl"`
|
CoverURL string `json:"coverUrl"`
|
||||||
AudioURL string `json:"audioUrl"`
|
AudioURL string `json:"audioUrl"`
|
||||||
|
BackgroundURL string `json:"backgroundUrl"`
|
||||||
RequireMember bool `json:"requireMember"`
|
RequireMember bool `json:"requireMember"`
|
||||||
SortOrder int `json:"sortOrder"`
|
SortOrder int `json:"sortOrder"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
|
|||||||
@ -181,36 +181,58 @@ func (s *Service) Delete(ctx context.Context, id int64) error {
|
|||||||
return errs.ErrNotFound.WithMsg("曲目不存在")
|
return errs.ErrNotFound.WithMsg("曲目不存在")
|
||||||
}
|
}
|
||||||
_ = s.deleteObject(ctx, t.CoverKey)
|
_ = s.deleteObject(ctx, t.CoverKey)
|
||||||
|
_ = s.deleteObject(ctx, t.BackgroundKey)
|
||||||
_ = s.deleteObject(ctx, t.AudioKey)
|
_ = s.deleteObject(ctx, t.AudioKey)
|
||||||
return s.repo.Delete(ctx, id)
|
return s.repo.Delete(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) UploadCover(ctx context.Context, id int64, filename string, body []byte, contentType string) (*entity.Track, error) {
|
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() {
|
if !s.Ready() {
|
||||||
return nil, ErrNotReady
|
return nil, ErrNotReady
|
||||||
}
|
}
|
||||||
if len(body) == 0 {
|
label := "封面"
|
||||||
return nil, errs.ErrInvalidArgument.WithMsg("请选择封面文件")
|
maxBytes := entity.MaxCoverBytes
|
||||||
|
if kind == "background" {
|
||||||
|
label = "背景图"
|
||||||
|
maxBytes = entity.MaxBackgroundBytes
|
||||||
}
|
}
|
||||||
if len(body) > entity.MaxCoverBytes {
|
if len(body) == 0 {
|
||||||
return nil, errs.ErrInvalidArgument.WithMsg("封面不能超过 5MB")
|
return nil, errs.ErrInvalidArgument.WithMsg("请选择" + label + "文件")
|
||||||
|
}
|
||||||
|
if len(body) > maxBytes {
|
||||||
|
return nil, errs.ErrInvalidArgument.WithMsg(label + "不能超过 5MB")
|
||||||
}
|
}
|
||||||
ct := normalizeMIME(contentType, filename)
|
ct := normalizeMIME(contentType, filename)
|
||||||
if !isImageMIME(ct) {
|
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)
|
t, err := s.repo.Get(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errs.ErrNotFound.WithMsg("曲目不存在")
|
return nil, errs.ErrNotFound.WithMsg("曲目不存在")
|
||||||
}
|
}
|
||||||
ext := extFor(ct, filename, "jpg")
|
ext := extFor(ct, filename, "jpg")
|
||||||
key := fmt.Sprintf("perm/music/cover/%d/%d.%s", id, time.Now().UnixNano(), ext)
|
key := fmt.Sprintf("perm/music/%s/%d/%d.%s", kind, id, time.Now().UnixNano(), ext)
|
||||||
if err := s.put(ctx, key, body, ct, entity.MaxCoverBytes); err != nil {
|
if err := s.put(ctx, key, body, ct, maxBytes); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
old := t.CoverKey
|
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.CoverKey = key
|
||||||
t.CoverURL = s.publicURL(key)
|
t.CoverURL = s.publicURL(key)
|
||||||
|
}
|
||||||
if err := s.repo.Update(ctx, t); err != nil {
|
if err := s.repo.Update(ctx, t); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@ -31,8 +31,10 @@ type trackRow struct {
|
|||||||
Title string `gorm:"column:title"`
|
Title string `gorm:"column:title"`
|
||||||
CoverKey string `gorm:"column:cover_key"`
|
CoverKey string `gorm:"column:cover_key"`
|
||||||
AudioKey string `gorm:"column:audio_key"`
|
AudioKey string `gorm:"column:audio_key"`
|
||||||
|
BackgroundKey string `gorm:"column:background_key"`
|
||||||
CoverURL string `gorm:"column:cover_url"`
|
CoverURL string `gorm:"column:cover_url"`
|
||||||
AudioURL string `gorm:"column:audio_url"`
|
AudioURL string `gorm:"column:audio_url"`
|
||||||
|
BackgroundURL string `gorm:"column:background_url"`
|
||||||
RequireMember bool `gorm:"column:require_member"`
|
RequireMember bool `gorm:"column:require_member"`
|
||||||
SortOrder int `gorm:"column:sort_order"`
|
SortOrder int `gorm:"column:sort_order"`
|
||||||
Enabled bool `gorm:"column:enabled"`
|
Enabled bool `gorm:"column:enabled"`
|
||||||
@ -104,8 +106,10 @@ func (r *Repo) Insert(ctx context.Context, t *entity.Track) error {
|
|||||||
Title: t.Title,
|
Title: t.Title,
|
||||||
CoverKey: t.CoverKey,
|
CoverKey: t.CoverKey,
|
||||||
AudioKey: t.AudioKey,
|
AudioKey: t.AudioKey,
|
||||||
|
BackgroundKey: t.BackgroundKey,
|
||||||
CoverURL: t.CoverURL,
|
CoverURL: t.CoverURL,
|
||||||
AudioURL: t.AudioURL,
|
AudioURL: t.AudioURL,
|
||||||
|
BackgroundURL: t.BackgroundURL,
|
||||||
RequireMember: t.RequireMember,
|
RequireMember: t.RequireMember,
|
||||||
SortOrder: t.SortOrder,
|
SortOrder: t.SortOrder,
|
||||||
Enabled: t.Enabled,
|
Enabled: t.Enabled,
|
||||||
@ -131,8 +135,10 @@ func (r *Repo) Update(ctx context.Context, t *entity.Track) error {
|
|||||||
"title": t.Title,
|
"title": t.Title,
|
||||||
"cover_key": t.CoverKey,
|
"cover_key": t.CoverKey,
|
||||||
"audio_key": t.AudioKey,
|
"audio_key": t.AudioKey,
|
||||||
|
"background_key": t.BackgroundKey,
|
||||||
"cover_url": t.CoverURL,
|
"cover_url": t.CoverURL,
|
||||||
"audio_url": t.AudioURL,
|
"audio_url": t.AudioURL,
|
||||||
|
"background_url": t.BackgroundURL,
|
||||||
"require_member": t.RequireMember,
|
"require_member": t.RequireMember,
|
||||||
"sort_order": t.SortOrder,
|
"sort_order": t.SortOrder,
|
||||||
"enabled": t.Enabled,
|
"enabled": t.Enabled,
|
||||||
@ -192,8 +198,10 @@ func toEntity(row trackRow) entity.Track {
|
|||||||
Title: row.Title,
|
Title: row.Title,
|
||||||
CoverKey: row.CoverKey,
|
CoverKey: row.CoverKey,
|
||||||
AudioKey: row.AudioKey,
|
AudioKey: row.AudioKey,
|
||||||
|
BackgroundKey: row.BackgroundKey,
|
||||||
CoverURL: row.CoverURL,
|
CoverURL: row.CoverURL,
|
||||||
AudioURL: row.AudioURL,
|
AudioURL: row.AudioURL,
|
||||||
|
BackgroundURL: row.BackgroundURL,
|
||||||
RequireMember: row.RequireMember,
|
RequireMember: row.RequireMember,
|
||||||
SortOrder: row.SortOrder,
|
SortOrder: row.SortOrder,
|
||||||
Enabled: row.Enabled,
|
Enabled: row.Enabled,
|
||||||
|
|||||||
@ -1869,8 +1869,8 @@ paths:
|
|||||||
summary: 音乐列表(公开)
|
summary: 音乐列表(公开)
|
||||||
description: |
|
description: |
|
||||||
返回已上架曲目与全局「会员音乐免费试听秒数」。无需登录。
|
返回已上架曲目与全局「会员音乐免费试听秒数」。无需登录。
|
||||||
封面/音频为 sparkr 公网 URL(`https://sparkr.wyrs365.com/spark/perm/music/...`)。
|
封面/背景图/音频为 sparkr 公网 URL(`https://sparkr.wyrs365.com/spark/perm/music/...`)。
|
||||||
客户端应按 `requireMember` 与 `memberPreviewSeconds` 控制试听。
|
客户端应按 `requireMember` 与 `memberPreviewSeconds` 控制试听;`backgroundUrl` 可空。
|
||||||
operationId: getMusicPlaylist
|
operationId: getMusicPlaylist
|
||||||
security: []
|
security: []
|
||||||
responses:
|
responses:
|
||||||
@ -2044,6 +2044,36 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/AdminApiResponse"
|
$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:
|
/api/admin/spark/music/{id}/audio:
|
||||||
post:
|
post:
|
||||||
tags: [admin-music]
|
tags: [admin-music]
|
||||||
@ -2926,8 +2956,10 @@ components:
|
|||||||
title: { type: string }
|
title: { type: string }
|
||||||
coverKey: { type: string }
|
coverKey: { type: string }
|
||||||
audioKey: { type: string }
|
audioKey: { type: string }
|
||||||
|
backgroundKey: { type: string, description: 背景图 object_key;可空 }
|
||||||
coverUrl: { type: string, description: sparkr 公网 URL }
|
coverUrl: { type: string, description: sparkr 公网 URL }
|
||||||
audioUrl: { type: string, description: sparkr 公网 URL }
|
audioUrl: { type: string, description: sparkr 公网 URL }
|
||||||
|
backgroundUrl: { type: string, description: 背景图 sparkr 公网 URL;可空 }
|
||||||
requireMember: { type: boolean, description: true=需会员 }
|
requireMember: { type: boolean, description: true=需会员 }
|
||||||
sortOrder: { type: integer }
|
sortOrder: { type: integer }
|
||||||
enabled: { type: boolean }
|
enabled: { type: boolean }
|
||||||
|
|||||||
@ -133,14 +133,18 @@ func (h *MusicHandler) Delete(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *MusicHandler) UploadCover(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) {
|
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) {
|
if !h.ready(c) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -162,9 +166,12 @@ func (h *MusicHandler) upload(c *gin.Context, cover bool) {
|
|||||||
}
|
}
|
||||||
ct := hdr.Header.Get("Content-Type")
|
ct := hdr.Header.Get("Content-Type")
|
||||||
var out any
|
var out any
|
||||||
if cover {
|
switch kind {
|
||||||
|
case "cover":
|
||||||
out, err = h.svc.UploadCover(c.Request.Context(), id, hdr.Filename, body, ct)
|
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)
|
out, err = h.svc.UploadAudio(c.Request.Context(), id, hdr.Filename, body, ct)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -84,5 +84,6 @@ func registerMusicAdmin(g *gin.RouterGroup, h *adminhandler.MusicHandler) {
|
|||||||
g.PATCH("/music/:id", h.Update)
|
g.PATCH("/music/:id", h.Update)
|
||||||
g.DELETE("/music/:id", h.Delete)
|
g.DELETE("/music/:id", h.Delete)
|
||||||
g.POST("/music/:id/cover", h.UploadCover)
|
g.POST("/music/:id/cover", h.UploadCover)
|
||||||
|
g.POST("/music/:id/background", h.UploadBackground)
|
||||||
g.POST("/music/:id/audio", h.UploadAudio)
|
g.POST("/music/:id/audio", h.UploadAudio)
|
||||||
}
|
}
|
||||||
|
|||||||
5
migrations/0021_music_background.sql
Normal file
5
migrations/0021_music_background.sql
Normal 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;
|
||||||
Loading…
Reference in New Issue
Block a user