feat(music): 分类多对多、后台完整编辑与 OpenAPI

新增总/子分类与曲目绑定;playlist 可按 categoryId 筛选并返回分类树;
popadmin 支持行内改曲名/排序/上下架/会员开关与封面背景音频上传;文档同步。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
DESKTOP-L8OUL9F\gfl0034 2026-09-24 10:38:06 +08:00
parent b10c5d63a3
commit d3a68c3cad
14 changed files with 1181 additions and 52 deletions

View File

@ -2495,17 +2495,31 @@ const pages = {
}, },
async music() { async music() {
let data = { items: [], settings: { memberPreviewSeconds: 30 } }; let data = { items: [], settings: { memberPreviewSeconds: 30 }, categories: [] };
try { try {
data = await api('/spark/music'); data = await api('/spark/music');
} catch (e) { } catch (e) {
$('content').innerHTML = `<div class="note error">音乐 API 不可用:${esc(e.message)}。需部署 spark-server 迁移 0019_music 并更新 nginx music 路由。</div>`; $('content').innerHTML = `<div class="note error">音乐 API 不可用:${esc(e.message)}。需部署 spark-server 迁移 0020–0022 并更新 nginx music 路由。</div>`;
return; return;
} }
const items = Array.isArray(data.items) ? data.items : []; const items = Array.isArray(data.items) ? data.items : [];
const cats = Array.isArray(data.categories) ? data.categories : [];
const preview = data.settings?.memberPreviewSeconds ?? 30; const preview = data.settings?.memberPreviewSeconds ?? 30;
const flatCats = [];
const walk = (nodes, depth) => {
(nodes || []).forEach((c) => {
flatCats.push({ id: c.id, name: c.name, parentId: c.parentId ?? null, depth, enabled: !!c.enabled, sortOrder: c.sortOrder ?? 0, children: c.children || [] });
walk(c.children || [], depth + 1);
});
};
walk(cats, 0);
const catLabel = (id) => {
const c = flatCats.find((x) => String(x.id) === String(id));
return c ? c.name : String(id);
};
const roots = flatCats.filter((c) => c.depth === 0);
$('content').innerHTML = ` $('content').innerHTML = `
<div class="note">火花音乐列表 · 管理 <code>/api/admin/spark/music*</code> · 客户端 <code>GET /v1/music/playlist</code>(sparkapi)· 封面/背景图/mp3 存 sparkr</div> <div class="note">火花音乐列表 · 管理 <code>/api/admin/spark/music*</code> · 客户端 <code>GET /v1/music/playlist</code>(sparkapi)· 封面/背景图/mp3 存 sparkr · 分类多对多</div>
<div class="section-block note" style="border:1px solid var(--border);padding:14px 16px;border-radius:10px;margin-bottom:12px"> <div class="section-block note" style="border:1px solid var(--border);padding:14px 16px;border-radius:10px;margin-bottom:12px">
<h3 style="margin:0 0 8px">总控 · 会员音乐免费试听(秒)</h3> <h3 style="margin:0 0 8px">总控 · 会员音乐免费试听(秒)</h3>
<div class="toolbar" style="gap:8px;align-items:center"> <div class="toolbar" style="gap:8px;align-items:center">
@ -2514,6 +2528,43 @@ const pages = {
<span class="muted">0~3600;仅对 requireMember=true 的曲目生效</span> <span class="muted">0~3600;仅对 requireMember=true 的曲目生效</span>
</div> </div>
</div> </div>
<div class="section-block note" style="border:1px solid var(--border);padding:14px 16px;border-radius:10px;margin-bottom:12px">
<h3 style="margin:0 0 8px">分类管理(总分类 / 子分类)</h3>
<div class="toolbar" style="flex-wrap:wrap;gap:8px;margin-bottom:10px">
<input id="catName" placeholder="分类名称" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);min-width:140px">
<select id="catParent" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<option value="">总分类</option>
${roots.map((r) => `<option value="${esc(r.id)}">${esc(r.name)}</option>`).join('')}
</select>
<input id="catSort" type="number" placeholder="排序" value="0" style="width:80px;padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<button type="button" id="catCreate">新建分类</button>
</div>
<table>
<thead><tr><th>ID</th><th>名称</th><th>层级</th><th>父级</th><th>排序</th><th>启用</th><th>操作</th></tr></thead>
<tbody>
${
flatCats.length
? flatCats
.map(
(c) => `<tr>
<td>${esc(c.id)}</td>
<td style="padding-left:${c.depth * 16}px">${esc(c.name)}</td>
<td>${c.depth === 0 ? '总分类' : '子分类'}</td>
<td>${c.parentId == null ? '—' : esc(catLabel(c.parentId))}</td>
<td>${esc(c.sortOrder)}</td>
<td>${c.enabled ? '是' : '否'}</td>
<td style="white-space:nowrap">
<button type="button" class="secondary btn-sm" data-catedit="${esc(c.id)}">改</button>
<button type="button" class="secondary btn-sm" data-catdel="${esc(c.id)}">删</button>
</td>
</tr>`
)
.join('')
: '<tr><td colspan="7">暂无分类</td></tr>'
}
</tbody>
</table>
</div>
<div class="toolbar" style="flex-wrap:wrap;gap:8px;margin-bottom:12px"> <div class="toolbar" style="flex-wrap:wrap;gap:8px;margin-bottom:12px">
<input id="musicTitle" placeholder="曲名" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);min-width:180px"> <input id="musicTitle" placeholder="曲名" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);min-width:180px">
<label style="display:flex;align-items:center;gap:6px"><input type="checkbox" id="musicMember">需要会员</label> <label style="display:flex;align-items:center;gap:6px"><input type="checkbox" id="musicMember">需要会员</label>
@ -2522,35 +2573,44 @@ const pages = {
<button type="button" class="secondary" id="musicReload">刷新</button> <button type="button" class="secondary" id="musicReload">刷新</button>
</div> </div>
<table> <table>
<thead><tr><th>封面</th><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>背景</th><th>ID</th><th>曲名</th><th>上架</th><th>排序</th><th>分类</th><th>音频</th><th>操作</th></tr></thead>
<tbody> <tbody>
${ ${
items.length items.length
? items ? items
.map( .map((t) => {
(t) => `<tr> const ids = Array.isArray(t.categoryIds) ? t.categoryIds : [];
const catText = ids.length ? ids.map(catLabel).join('、') : '—';
return `<tr data-mid="${esc(t.id)}">
<td>
<label style="display:flex;align-items:center;gap:4px;cursor:pointer" title="是否需要会员">
<input type="checkbox" data-mmember="${esc(t.id)}" ${t.requireMember ? 'checked' : ''}>
</label>
</td>
<td>${t.coverUrl ? `<img src="${esc(t.coverUrl)}" alt="" style="width:48px;height:48px;object-fit:cover;border-radius:6px">` : '—'}</td> <td>${t.coverUrl ? `<img src="${esc(t.coverUrl)}" alt="" style="width:48px;height:48px;object-fit:cover;border-radius:6px">` : '—'}</td>
<td>${t.backgroundUrl ? `<img src="${esc(t.backgroundUrl)}" alt="" style="width:48px;height:48px;object-fit:cover;border-radius:6px">` : '—'}</td> <td>${t.backgroundUrl ? `<img src="${esc(t.backgroundUrl)}" alt="" style="width:48px;height:48px;object-fit:cover;border-radius:6px">` : '—'}</td>
<td>${esc(t.id)}</td> <td>${esc(t.id)}</td>
<td>${esc(t.title)}</td> <td><input data-mtitle="${esc(t.id)}" value="${esc(t.title)}" style="width:140px;padding:6px;border-radius:6px;border:1px solid var(--border);background:#0b1220;color:var(--text)"></td>
<td>${t.requireMember ? '是' : '否'}</td> <td><input type="checkbox" data-menabled="${esc(t.id)}" ${t.enabled ? 'checked' : ''} title="上架"></td>
<td>${t.enabled ? '是' : '否'}</td> <td><input type="number" data-msort="${esc(t.id)}" value="${esc(t.sortOrder)}" style="width:72px;padding:6px;border-radius:6px;border:1px solid var(--border);background:#0b1220;color:var(--text)"></td>
<td>${esc(t.sortOrder)}</td> <td style="max-width:160px;font-size:12px">${esc(catText)}</td>
<td style="max-width:140px;word-break:break-all">${t.audioUrl ? `<a href="${esc(t.audioUrl)}" target="_blank" rel="noopener">mp3</a>` : '—'}</td> <td style="max-width:100px;word-break:break-all">${t.audioUrl ? `<a href="${esc(t.audioUrl)}" target="_blank" rel="noopener">mp3</a>` : '—'}</td>
<td style="white-space:nowrap"> <td style="white-space:nowrap">
<button type="button" class="secondary btn-sm" data-medit="${esc(t.id)}">改</button> <button type="button" class="secondary btn-sm" data-msave="${esc(t.id)}">保存</button>
<button type="button" class="secondary btn-sm" data-mcats="${esc(t.id)}">分类</button>
<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> <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>
<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> <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>
<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">mp3<input type="file" accept="audio/mpeg,.mp3" data-maudio="${esc(t.id)}" hidden></label>
<button type="button" class="secondary btn-sm" data-mdel="${esc(t.id)}">删</button> <button type="button" class="secondary btn-sm" data-mdel="${esc(t.id)}">删</button>
</td> </td>
</tr>` </tr>`;
) })
.join('') .join('')
: '<tr><td colspan="9">暂无曲目</td></tr>' : '<tr><td colspan="10">暂无曲目</td></tr>'
} }
</tbody> </tbody>
</table>`; </table>`;
$('musicPreviewSave').onclick = async () => { $('musicPreviewSave').onclick = async () => {
const sec = parseInt($('musicPreviewSec').value, 10); const sec = parseInt($('musicPreviewSec').value, 10);
await api('/spark/music/settings', { await api('/spark/music/settings', {
@ -2575,17 +2635,122 @@ const pages = {
pages.music(); pages.music();
}; };
$('musicReload').onclick = () => pages.music(); $('musicReload').onclick = () => pages.music();
$('content').querySelectorAll('[data-medit]').forEach((btn) => {
$('catCreate').onclick = async () => {
const name = $('catName').value.trim();
if (!name) return alert('请填写分类名称');
const parentRaw = $('catParent').value;
const body = {
name,
sortOrder: parseInt($('catSort').value, 10) || 0,
enabled: true,
};
if (parentRaw) body.parentId = parseInt(parentRaw, 10);
await api('/spark/music/categories', { method: 'POST', body: JSON.stringify(body) });
pages.music();
};
$('content').querySelectorAll('[data-catedit]').forEach((btn) => {
btn.onclick = async () => { btn.onclick = async () => {
const id = btn.dataset.medit; const id = btn.dataset.catedit;
const row = items.find((x) => String(x.id) === String(id)); const row = flatCats.find((x) => String(x.id) === String(id));
const title = prompt('曲名', row?.title || ''); const name = prompt('分类名称', row?.name || '');
if (title == null) return; if (name == null) return;
const member = confirm('是否需要会员?(确定=是,取消=否)'); const sortRaw = prompt('排序(数字,越小越前)', String(row?.sortOrder ?? 0));
const enabled = confirm('是否上架?(确定=是,取消=否)'); if (sortRaw == null) return;
const enabled = confirm('是否启用?(确定=是,取消=否)');
await api('/spark/music/categories/' + id, {
method: 'PATCH',
body: JSON.stringify({
name: name.trim(),
sortOrder: parseInt(sortRaw, 10) || 0,
enabled,
}),
});
pages.music();
};
});
$('content').querySelectorAll('[data-catdel]').forEach((btn) => {
btn.onclick = async () => {
if (!confirm('确认删除该分类?将同时删除其子分类与曲目绑定。')) return;
await api('/spark/music/categories/' + btn.dataset.catdel, { method: 'DELETE' });
pages.music();
};
});
$('content').querySelectorAll('[data-msave]').forEach((btn) => {
btn.onclick = async () => {
const id = btn.dataset.msave;
const titleEl = $('content').querySelector(`[data-mtitle="${id}"]`);
const memberEl = $('content').querySelector(`[data-mmember="${id}"]`);
const enabledEl = $('content').querySelector(`[data-menabled="${id}"]`);
const sortEl = $('content').querySelector(`[data-msort="${id}"]`);
const title = (titleEl?.value || '').trim();
if (!title) return alert('曲名不能为空');
await api('/spark/music/' + id, { await api('/spark/music/' + id, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify({ title: title.trim(), requireMember: member, enabled }), body: JSON.stringify({
title,
requireMember: !!memberEl?.checked,
enabled: !!enabledEl?.checked,
sortOrder: parseInt(sortEl?.value, 10) || 0,
}),
});
alert('已保存');
pages.music();
};
});
$('content').querySelectorAll('[data-mmember]').forEach((inp) => {
inp.onchange = async () => {
const id = inp.dataset.mmember;
try {
await api('/spark/music/' + id, {
method: 'PATCH',
body: JSON.stringify({ requireMember: !!inp.checked }),
});
} catch (e) {
alert('会员开关保存失败:' + e.message);
pages.music();
}
};
});
$('content').querySelectorAll('[data-menabled]').forEach((inp) => {
inp.onchange = async () => {
const id = inp.dataset.menabled;
try {
await api('/spark/music/' + id, {
method: 'PATCH',
body: JSON.stringify({ enabled: !!inp.checked }),
});
} catch (e) {
alert('上下架保存失败:' + e.message);
pages.music();
}
};
});
$('content').querySelectorAll('[data-mcats]').forEach((btn) => {
btn.onclick = async () => {
const id = btn.dataset.mcats;
const row = items.find((x) => String(x.id) === String(id));
const selected = new Set((row?.categoryIds || []).map(String));
const lines = flatCats.map((c) => {
const mark = selected.has(String(c.id)) ? '[x]' : '[ ]';
const pad = c.depth ? ' ' : '';
return `${mark} ${pad}${c.id} ${c.name}`;
});
const tip =
'勾选归属分类(多对多)。输入要归属的分类 ID,逗号分隔;留空=清除。\n\n' +
lines.join('\n') +
'\n\n当前:' +
(row?.categoryIds || []).join(',');
const raw = prompt(tip, (row?.categoryIds || []).join(','));
if (raw == null) return;
const categoryIds = raw
.split(/[,,\s]+/)
.map((s) => parseInt(s.trim(), 10))
.filter((n) => Number.isFinite(n) && n > 0);
await api('/spark/music/' + id + '/categories', {
method: 'PUT',
body: JSON.stringify({ categoryIds }),
}); });
pages.music(); pages.music();
}; };

View File

@ -5,7 +5,7 @@
```bash ```bash
# 本机(Windows/WSL):构建并部署到新加坡机 # 本机(Windows/WSL):构建并部署到新加坡机
make deploy make deploy
# 会:linux 交叉编译 → docker image → 上传 ECS → spark-migrate(含 0020_music / 0021_music_background)→ 重启容器 → nginx reload # 会:linux 交叉编译 → docker image → 上传 ECS → spark-migrate(含 0020_music / 0021_music_background / 0022_music_categories)→ 重启容器 → nginx reload
``` ```
新加坡机手工补跑迁移(仅 image 已更新但 migrate 未跑时): 新加坡机手工补跑迁移(仅 image 已更新但 migrate 未跑时):
@ -17,10 +17,28 @@ sudo nginx -t && sudo nginx -s reload
# 确认 include 了 popadmin-spark-admin-locations.conf(含 music,client_max_body_size 55m) # 确认 include 了 popadmin-spark-admin-locations.conf(含 music,client_max_body_size 55m)
``` ```
### Admin JWT(必对齐,否则音乐列表登录循环)
`/api/admin/spark/music*` 经 nginx 反代到 spark-server,走 `AdminAuth`,必须与 Java popadmin 登录签发密钥一致:
| 侧 | 配置 |
|---|---|
| Java | `/etc/wyrs-admin/app.env` → `WYRS_ADMIN_JWT_SECRET`(未设则用 jar 默认 `...-production-2025`) |
| spark | `/data/spark-server/configs/config.yaml` → `admin.jwt_secret` 或 `SPARK_ADMIN_JWT_SECRET` |
注意:
- spark **prod** 会拒绝 jar 默认值 `wyrs-admin-jwt-secret-change-in-production-2025`(无法启动)。生产应两边写**同一套非默认**密钥;改后 `docker restart spark-server` + `systemctl restart wyrs-admin`,用户需重新登录一次。
- Java 签发算法为 **HS512**;spark `AdminAuth` **与** `popadmin-im`(`:8091`,`/api/admin/sg/*`、`/spark/im/*`)均需同时接受 HS512 / HS256。仅 HS256 时仪表盘轮询 `/sg/host-metrics` 会 401,前端 `api()` 清会话 → **全局登录循环**。
- 三处密钥须一致:`/etc/wyrs-admin/app.env` 的 `WYRS_ADMIN_JWT_SECRET`、`/etc/popadmin-im/env` 同名变量、spark `admin.jwt_secret`(或 `SPARK_ADMIN_JWT_SECRET`)。
症状:带 popadmin token 调 music 返回 `401` + `登录已失效` → 密钥不一致或算法不兼容;IM 等仍走 Java `:8090` 故可正常。
验证: 验证:
- `GET https://sparkapi.wyrs365.com/v1/music/playlist` → `code=0`,曲目含 `coverUrl` / `backgroundUrl`(可空)/ `audioUrl` - `GET https://sparkapi.wyrs365.com/v1/music/playlist` → `code=0`,曲目含 `coverUrl` / `backgroundUrl`(可空)/ `audioUrl`
- `GET https://sparkapi.wyrs365.com/docs` → 可见 music / admin-music - `GET https://sparkapi.wyrs365.com/docs` → 可见 music / admin-music
- popadmin **重新登录**后「音乐列表」不再跳登录;`GET /api/admin/spark/music` → `200` + `success:true`
- popadmin「音乐列表」:封面 / **背景** / mp3 上传;`POST /api/admin/spark/music/{id}/background` - popadmin「音乐列表」:封面 / **背景** / mp3 上传;`POST /api/admin/spark/music/{id}/background`
## 2. Aliyun wyrs-admin(popadmin 静态) ## 2. Aliyun wyrs-admin(popadmin 静态)
@ -53,7 +71,7 @@ OpenAPI 已按 `Spark资源文档/devices-client.md` 更新契约。
## 5. Demo 曲目(可选) ## 5. Demo 曲目(可选)
仓库旁 `_demo_music/`:`tracks.json` + `01..10.mp3/png` + `upload_demo.py`。 仓库旁 `_demo_music/`:`tracks.json` + `01..10.mp3/png` + `upload_demo.py`。
背景图**可空**,demo **不强制**补背景;脚本勿删已有曲目(仅 POST 新建)。 背景图**可空**,demo **不强制**补背景;脚本勿删已有曲目(仅 POST 新建)。背景可用 upload_backgrounds.py 补传。
```bash ```bash
# 在新加坡机(或可访问 popadmin 登录与 spark admin API 的环境) # 在新加坡机(或可访问 popadmin 登录与 spark admin API 的环境)

View File

@ -46,10 +46,13 @@ func (s *TokenService) Parse(tokenStr string) (*Claims, error) {
return nil, ErrInvalidToken return nil, ErrInvalidToken
} }
t, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) { t, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) {
if t.Method != jwt.SigningMethodHS256 { // Java JwtTokenProvider signs with HS512; local CreateToken / tests use HS256.
switch t.Method {
case jwt.SigningMethodHS256, jwt.SigningMethodHS512:
return s.secret, nil
default:
return nil, ErrInvalidToken return nil, ErrInvalidToken
} }
return s.secret, nil
}) })
if err != nil { if err != nil {
return nil, ErrInvalidToken return nil, ErrInvalidToken

View File

@ -1,6 +1,11 @@
package logic package logic
import "testing" import (
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
func TestAdminJWTRoundTrip(t *testing.T) { func TestAdminJWTRoundTrip(t *testing.T) {
s := NewTokenService("wyrs-admin-jwt-secret-change-in-production-2025") s := NewTokenService("wyrs-admin-jwt-secret-change-in-production-2025")
@ -17,6 +22,35 @@ func TestAdminJWTRoundTrip(t *testing.T) {
} }
} }
func TestAdminJWTAcceptsHS512(t *testing.T) {
secret := "wyrs-admin-jwt-secret-change-in-production-2025"
s := NewTokenService(secret)
now := time.Now()
claims := Claims{
AdminID: 1,
Role: "SUPER_ADMIN",
Type: TypeAdmin,
Permissions: "*",
RegisteredClaims: jwt.RegisteredClaims{
Subject: "admin",
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)),
},
}
tok := jwt.NewWithClaims(jwt.SigningMethodHS512, &claims)
signed, err := tok.SignedString([]byte(secret))
if err != nil {
t.Fatal(err)
}
got, err := s.Parse(signed)
if err != nil {
t.Fatal(err)
}
if got.AdminID != 1 || got.Subject != "admin" {
t.Fatalf("%+v", got)
}
}
func TestAdminJWTRejectsWrongType(t *testing.T) { func TestAdminJWTRejectsWrongType(t *testing.T) {
s := NewTokenService("wyrs-admin-jwt-secret-change-in-production-2025") s := NewTokenService("wyrs-admin-jwt-secret-change-in-production-2025")
tok, err := s.CreateToken(1, "x", "SUPER_ADMIN", "") tok, err := s.CreateToken(1, "x", "SUPER_ADMIN", "")

View File

@ -24,10 +24,23 @@ type Track struct {
RequireMember bool `json:"requireMember"` RequireMember bool `json:"requireMember"`
SortOrder int `json:"sortOrder"` SortOrder int `json:"sortOrder"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
CategoryIDs []int64 `json:"categoryIds"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
} }
// Category is a top-level or sub music category (parentID nil = 总分类).
type Category struct {
ID int64 `json:"id"`
ParentID *int64 `json:"parentId"`
Name string `json:"name"`
SortOrder int `json:"sortOrder"`
Enabled bool `json:"enabled"`
Children []Category `json:"children,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// Settings is the global music ops config. // Settings is the global music ops config.
type Settings struct { type Settings struct {
MemberPreviewSeconds int `json:"memberPreviewSeconds"` MemberPreviewSeconds int `json:"memberPreviewSeconds"`
@ -35,6 +48,7 @@ type Settings struct {
// Playlist is the client-facing payload. // Playlist is the client-facing payload.
type Playlist struct { type Playlist struct {
Items []Track `json:"items"` Items []Track `json:"items"`
MemberPreviewSeconds int `json:"memberPreviewSeconds"` Categories []Category `json:"categories"`
MemberPreviewSeconds int `json:"memberPreviewSeconds"`
} }

View File

@ -18,12 +18,20 @@ var ErrNotReady = errors.New("music: service not ready")
type Repository interface { type Repository interface {
ListAll(ctx context.Context) ([]entity.Track, error) ListAll(ctx context.Context) ([]entity.Track, error)
ListEnabled(ctx context.Context) ([]entity.Track, error) ListEnabled(ctx context.Context) ([]entity.Track, error)
ListEnabledByCategory(ctx context.Context, categoryID int64) ([]entity.Track, error)
Get(ctx context.Context, id int64) (*entity.Track, error) Get(ctx context.Context, id int64) (*entity.Track, error)
Insert(ctx context.Context, t *entity.Track) error Insert(ctx context.Context, t *entity.Track) error
Update(ctx context.Context, t *entity.Track) error Update(ctx context.Context, t *entity.Track) error
Delete(ctx context.Context, id int64) error Delete(ctx context.Context, id int64) error
MemberPreviewSeconds(ctx context.Context) (int, error) MemberPreviewSeconds(ctx context.Context) (int, error)
SetSetting(ctx context.Context, key, value string) error SetSetting(ctx context.Context, key, value string) error
ListCategories(ctx context.Context) ([]entity.Category, error)
ListEnabledCategories(ctx context.Context) ([]entity.Category, error)
GetCategory(ctx context.Context, id int64) (*entity.Category, error)
InsertCategory(ctx context.Context, c *entity.Category) error
UpdateCategory(ctx context.Context, c *entity.Category) error
DeleteCategory(ctx context.Context, id int64) error
SetTrackCategories(ctx context.Context, trackID int64, categoryIDs []int64) error
} }
type Objects interface { type Objects interface {
@ -62,10 +70,18 @@ func (s *Service) Ready() bool {
} }
type UpsertInput struct { type UpsertInput struct {
Title string `json:"title"` Title string `json:"title"`
RequireMember *bool `json:"requireMember"` RequireMember *bool `json:"requireMember"`
SortOrder *int `json:"sortOrder"` SortOrder *int `json:"sortOrder"`
Enabled *bool `json:"enabled"` Enabled *bool `json:"enabled"`
CategoryIDs []int64 `json:"categoryIds"`
}
type CategoryInput struct {
ParentID *int64 `json:"parentId"`
Name string `json:"name"`
SortOrder *int `json:"sortOrder"`
Enabled *bool `json:"enabled"`
} }
func (s *Service) ListAdmin(ctx context.Context) ([]entity.Track, error) { func (s *Service) ListAdmin(ctx context.Context) ([]entity.Track, error) {
@ -75,11 +91,19 @@ func (s *Service) ListAdmin(ctx context.Context) ([]entity.Track, error) {
return s.repo.ListAll(ctx) return s.repo.ListAll(ctx)
} }
func (s *Service) Playlist(ctx context.Context) (*entity.Playlist, error) { func (s *Service) Playlist(ctx context.Context, categoryID int64) (*entity.Playlist, error) {
if !s.Ready() { if !s.Ready() {
return nil, ErrNotReady return nil, ErrNotReady
} }
items, err := s.repo.ListEnabled(ctx) var (
items []entity.Track
err error
)
if categoryID > 0 {
items, err = s.repo.ListEnabledByCategory(ctx, categoryID)
} else {
items, err = s.repo.ListEnabled(ctx)
}
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -87,7 +111,11 @@ func (s *Service) Playlist(ctx context.Context) (*entity.Playlist, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &entity.Playlist{Items: items, MemberPreviewSeconds: sec}, nil tree, err := s.CategoryTree(ctx, true)
if err != nil {
return nil, err
}
return &entity.Playlist{Items: items, Categories: tree, MemberPreviewSeconds: sec}, nil
} }
func (s *Service) Settings(ctx context.Context) (*entity.Settings, error) { func (s *Service) Settings(ctx context.Context) (*entity.Settings, error) {
@ -127,6 +155,7 @@ func (s *Service) Create(ctx context.Context, in UpsertInput) (*entity.Track, er
RequireMember: false, RequireMember: false,
SortOrder: 0, SortOrder: 0,
Enabled: true, Enabled: true,
CategoryIDs: []int64{},
} }
if in.RequireMember != nil { if in.RequireMember != nil {
t.RequireMember = *in.RequireMember t.RequireMember = *in.RequireMember
@ -140,6 +169,12 @@ func (s *Service) Create(ctx context.Context, in UpsertInput) (*entity.Track, er
if err := s.repo.Insert(ctx, t); err != nil { if err := s.repo.Insert(ctx, t); err != nil {
return nil, err return nil, err
} }
if in.CategoryIDs != nil {
if err := s.applyTrackCategories(ctx, t.ID, in.CategoryIDs); err != nil {
return nil, err
}
t.CategoryIDs = dedupeIDs(in.CategoryIDs)
}
return t, nil return t, nil
} }
@ -169,6 +204,12 @@ func (s *Service) Update(ctx context.Context, id int64, in UpsertInput) (*entity
if err := s.repo.Update(ctx, t); err != nil { if err := s.repo.Update(ctx, t); err != nil {
return nil, err return nil, err
} }
if in.CategoryIDs != nil {
if err := s.applyTrackCategories(ctx, id, in.CategoryIDs); err != nil {
return nil, err
}
t.CategoryIDs = dedupeIDs(in.CategoryIDs)
}
return t, nil return t, nil
} }
@ -277,6 +318,189 @@ func (s *Service) UploadAudio(ctx context.Context, id int64, filename string, bo
return t, nil return t, nil
} }
func (s *Service) CategoryTree(ctx context.Context, enabledOnly bool) ([]entity.Category, error) {
if !s.Ready() {
return nil, ErrNotReady
}
var (
flat []entity.Category
err error
)
if enabledOnly {
flat, err = s.repo.ListEnabledCategories(ctx)
} else {
flat, err = s.repo.ListCategories(ctx)
}
if err != nil {
return nil, err
}
return buildCategoryTree(flat), nil
}
func (s *Service) ListCategoriesAdmin(ctx context.Context) ([]entity.Category, error) {
return s.CategoryTree(ctx, false)
}
func (s *Service) CreateCategory(ctx context.Context, in CategoryInput) (*entity.Category, error) {
if !s.Ready() {
return nil, ErrNotReady
}
name := strings.TrimSpace(in.Name)
if name == "" {
return nil, errs.ErrInvalidArgument.WithMsg("name 不能为空")
}
if in.ParentID != nil && *in.ParentID > 0 {
parent, err := s.repo.GetCategory(ctx, *in.ParentID)
if err != nil {
return nil, errs.ErrNotFound.WithMsg("父分类不存在")
}
if parent.ParentID != nil {
return nil, errs.ErrInvalidArgument.WithMsg("仅支持两级:总分类 → 子分类")
}
} else {
in.ParentID = nil
}
c := &entity.Category{
ParentID: in.ParentID,
Name: name,
SortOrder: 0,
Enabled: true,
}
if in.SortOrder != nil {
c.SortOrder = *in.SortOrder
}
if in.Enabled != nil {
c.Enabled = *in.Enabled
}
if err := s.repo.InsertCategory(ctx, c); err != nil {
return nil, err
}
return c, nil
}
func (s *Service) UpdateCategory(ctx context.Context, id int64, in CategoryInput) (*entity.Category, error) {
if !s.Ready() {
return nil, ErrNotReady
}
c, err := s.repo.GetCategory(ctx, id)
if err != nil {
return nil, errs.ErrNotFound.WithMsg("分类不存在")
}
if in.Name != "" {
c.Name = strings.TrimSpace(in.Name)
}
if c.Name == "" {
return nil, errs.ErrInvalidArgument.WithMsg("name 不能为空")
}
if in.ParentID != nil {
if *in.ParentID == 0 {
c.ParentID = nil
} else if *in.ParentID == id {
return nil, errs.ErrInvalidArgument.WithMsg("不能将分类设为自己的父级")
} else {
parent, err := s.repo.GetCategory(ctx, *in.ParentID)
if err != nil {
return nil, errs.ErrNotFound.WithMsg("父分类不存在")
}
if parent.ParentID != nil {
return nil, errs.ErrInvalidArgument.WithMsg("仅支持两级:总分类 → 子分类")
}
c.ParentID = in.ParentID
}
}
if in.SortOrder != nil {
c.SortOrder = *in.SortOrder
}
if in.Enabled != nil {
c.Enabled = *in.Enabled
}
if err := s.repo.UpdateCategory(ctx, c); err != nil {
return nil, err
}
return c, nil
}
func (s *Service) DeleteCategory(ctx context.Context, id int64) error {
if !s.Ready() {
return ErrNotReady
}
if _, err := s.repo.GetCategory(ctx, id); err != nil {
return errs.ErrNotFound.WithMsg("分类不存在")
}
return s.repo.DeleteCategory(ctx, id)
}
func (s *Service) SetTrackCategories(ctx context.Context, trackID int64, categoryIDs []int64) (*entity.Track, error) {
if !s.Ready() {
return nil, ErrNotReady
}
t, err := s.repo.Get(ctx, trackID)
if err != nil {
return nil, errs.ErrNotFound.WithMsg("曲目不存在")
}
if err := s.applyTrackCategories(ctx, trackID, categoryIDs); err != nil {
return nil, err
}
t.CategoryIDs = dedupeIDs(categoryIDs)
return t, nil
}
func (s *Service) applyTrackCategories(ctx context.Context, trackID int64, categoryIDs []int64) error {
ids := dedupeIDs(categoryIDs)
for _, cid := range ids {
if _, err := s.repo.GetCategory(ctx, cid); err != nil {
return errs.ErrInvalidArgument.WithMsg(fmt.Sprintf("分类 %d 不存在", cid))
}
}
return s.repo.SetTrackCategories(ctx, trackID, ids)
}
func buildCategoryTree(flat []entity.Category) []entity.Category {
nodes := make(map[int64]*entity.Category, len(flat))
order := make([]int64, 0, len(flat))
for _, c := range flat {
cc := c
cc.Children = nil
nodes[cc.ID] = &cc
order = append(order, cc.ID)
}
rootPtrs := make([]*entity.Category, 0)
for _, id := range order {
node := nodes[id]
if node.ParentID == nil || *node.ParentID == 0 {
rootPtrs = append(rootPtrs, node)
continue
}
parent, ok := nodes[*node.ParentID]
if !ok {
rootPtrs = append(rootPtrs, node)
continue
}
parent.Children = append(parent.Children, *node)
}
out := make([]entity.Category, 0, len(rootPtrs))
for _, r := range rootPtrs {
out = append(out, *r)
}
return out
}
func dedupeIDs(ids []int64) []int64 {
seen := map[int64]struct{}{}
out := make([]int64, 0, len(ids))
for _, id := range ids {
if id <= 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out
}
func (s *Service) put(ctx context.Context, key string, body []byte, contentType string, max int) error { func (s *Service) put(ctx context.Context, key string, body []byte, contentType string, max int) error {
if s.objects == nil || !s.objects.Ready() { if s.objects == nil || !s.objects.Ready() {
return errs.ErrAvailable.WithMsg("对象存储未就绪") return errs.ErrAvailable.WithMsg("对象存储未就绪")

View File

@ -52,6 +52,26 @@ type settingRow struct {
func (settingRow) TableName() string { return "t_music_settings" } func (settingRow) TableName() string { return "t_music_settings" }
type categoryRow struct {
ID int64 `gorm:"column:id;primaryKey"`
ParentID *int64 `gorm:"column:parent_id"`
Name string `gorm:"column:name"`
SortOrder int `gorm:"column:sort_order"`
Enabled bool `gorm:"column:enabled"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
}
func (categoryRow) TableName() string { return "t_music_categories" }
type categoryTrackRow struct {
CategoryID int64 `gorm:"column:category_id;primaryKey"`
TrackID int64 `gorm:"column:track_id;primaryKey"`
CreatedAt time.Time `gorm:"column:created_at"`
}
func (categoryTrackRow) TableName() string { return "t_music_category_tracks" }
func (r *Repo) ListAll(ctx context.Context) ([]entity.Track, error) { func (r *Repo) ListAll(ctx context.Context) ([]entity.Track, error) {
if !r.Ready() { if !r.Ready() {
return nil, errors.New("music repo not ready") return nil, errors.New("music repo not ready")
@ -65,7 +85,7 @@ func (r *Repo) ListAll(ctx context.Context) ([]entity.Track, error) {
for _, row := range rows { for _, row := range rows {
out = append(out, toEntity(row)) out = append(out, toEntity(row))
} }
return out, nil return r.attachCategoryIDs(ctx, out)
} }
func (r *Repo) ListEnabled(ctx context.Context) ([]entity.Track, error) { func (r *Repo) ListEnabled(ctx context.Context) ([]entity.Track, error) {
@ -81,6 +101,63 @@ func (r *Repo) ListEnabled(ctx context.Context) ([]entity.Track, error) {
for _, row := range rows { for _, row := range rows {
out = append(out, toEntity(row)) out = append(out, toEntity(row))
} }
return r.attachCategoryIDs(ctx, out)
}
func (r *Repo) ListEnabledByCategory(ctx context.Context, categoryID int64) ([]entity.Track, error) {
if !r.Ready() {
return nil, errors.New("music repo not ready")
}
ids, err := r.categoryScopeIDs(ctx, categoryID)
if err != nil {
return nil, err
}
if len(ids) == 0 {
return []entity.Track{}, nil
}
var trackIDs []int64
err = r.db.WithContext(ctx).Model(&categoryTrackRow{}).
Where("category_id IN ?", ids).Distinct("track_id").Pluck("track_id", &trackIDs).Error
if err != nil {
return nil, err
}
if len(trackIDs) == 0 {
return []entity.Track{}, nil
}
var rows []trackRow
err = r.db.WithContext(ctx).Where("enabled = ? AND id IN ?", true, trackIDs).
Order("sort_order ASC, id ASC").Find(&rows).Error
if err != nil {
return nil, err
}
out := make([]entity.Track, 0, len(rows))
for _, row := range rows {
out = append(out, toEntity(row))
}
return r.attachCategoryIDs(ctx, out)
}
func (r *Repo) categoryScopeIDs(ctx context.Context, categoryID int64) ([]int64, error) {
var all []categoryRow
if err := r.db.WithContext(ctx).Find(&all).Error; err != nil {
return nil, err
}
byParent := map[int64][]int64{}
for _, c := range all {
if c.ParentID != nil {
byParent[*c.ParentID] = append(byParent[*c.ParentID], c.ID)
}
}
out := []int64{categoryID}
queue := []int64{categoryID}
for len(queue) > 0 {
cur := queue[0]
queue = queue[1:]
for _, child := range byParent[cur] {
out = append(out, child)
queue = append(queue, child)
}
}
return out, nil return out, nil
} }
@ -94,6 +171,11 @@ func (r *Repo) Get(ctx context.Context, id int64) (*entity.Track, error) {
return nil, err return nil, err
} }
t := toEntity(row) t := toEntity(row)
ids, err := r.TrackCategoryIDs(ctx, id)
if err != nil {
return nil, err
}
t.CategoryIDs = ids
return &t, nil return &t, nil
} }
@ -122,6 +204,9 @@ func (r *Repo) Insert(ctx context.Context, t *entity.Track) error {
t.ID = row.ID t.ID = row.ID
t.CreatedAt = row.CreatedAt t.CreatedAt = row.CreatedAt
t.UpdatedAt = row.UpdatedAt t.UpdatedAt = row.UpdatedAt
if t.CategoryIDs == nil {
t.CategoryIDs = []int64{}
}
return nil return nil
} }
@ -150,7 +235,12 @@ func (r *Repo) Delete(ctx context.Context, id int64) error {
if !r.Ready() { if !r.Ready() {
return errors.New("music repo not ready") return errors.New("music repo not ready")
} }
return r.db.WithContext(ctx).Delete(&trackRow{}, id).Error return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("track_id = ?", id).Delete(&categoryTrackRow{}).Error; err != nil {
return err
}
return tx.Delete(&trackRow{}, id).Error
})
} }
func (r *Repo) GetSetting(ctx context.Context, key string) (string, error) { func (r *Repo) GetSetting(ctx context.Context, key string) (string, error) {
@ -192,6 +282,180 @@ func (r *Repo) MemberPreviewSeconds(ctx context.Context) (int, error) {
return n, nil return n, nil
} }
func (r *Repo) ListCategories(ctx context.Context) ([]entity.Category, error) {
if !r.Ready() {
return nil, errors.New("music repo not ready")
}
var rows []categoryRow
err := r.db.WithContext(ctx).Order("sort_order ASC, id ASC").Find(&rows).Error
if err != nil {
return nil, err
}
out := make([]entity.Category, 0, len(rows))
for _, row := range rows {
out = append(out, toCategoryEntity(row))
}
return out, nil
}
func (r *Repo) ListEnabledCategories(ctx context.Context) ([]entity.Category, error) {
if !r.Ready() {
return nil, errors.New("music repo not ready")
}
var rows []categoryRow
err := r.db.WithContext(ctx).Where("enabled = ?", true).Order("sort_order ASC, id ASC").Find(&rows).Error
if err != nil {
return nil, err
}
out := make([]entity.Category, 0, len(rows))
for _, row := range rows {
out = append(out, toCategoryEntity(row))
}
return out, nil
}
func (r *Repo) GetCategory(ctx context.Context, id int64) (*entity.Category, error) {
if !r.Ready() {
return nil, errors.New("music repo not ready")
}
var row categoryRow
err := r.db.WithContext(ctx).First(&row, id).Error
if err != nil {
return nil, err
}
c := toCategoryEntity(row)
return &c, nil
}
func (r *Repo) InsertCategory(ctx context.Context, c *entity.Category) error {
if !r.Ready() {
return errors.New("music repo not ready")
}
now := time.Now().UTC()
row := categoryRow{
ParentID: c.ParentID,
Name: c.Name,
SortOrder: c.SortOrder,
Enabled: c.Enabled,
CreatedAt: now,
UpdatedAt: now,
}
if err := r.db.WithContext(ctx).Create(&row).Error; err != nil {
return err
}
c.ID = row.ID
c.CreatedAt = row.CreatedAt
c.UpdatedAt = row.UpdatedAt
return nil
}
func (r *Repo) UpdateCategory(ctx context.Context, c *entity.Category) error {
if !r.Ready() {
return errors.New("music repo not ready")
}
now := time.Now().UTC()
c.UpdatedAt = now
return r.db.WithContext(ctx).Model(&categoryRow{}).Where("id = ?", c.ID).Updates(map[string]any{
"parent_id": c.ParentID,
"name": c.Name,
"sort_order": c.SortOrder,
"enabled": c.Enabled,
"updated_at": now,
}).Error
}
func (r *Repo) DeleteCategory(ctx context.Context, id int64) error {
if !r.Ready() {
return errors.New("music repo not ready")
}
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var childIDs []int64
if err := tx.Model(&categoryRow{}).Where("parent_id = ?", id).Pluck("id", &childIDs).Error; err != nil {
return err
}
all := append([]int64{id}, childIDs...)
if err := tx.Where("category_id IN ?", all).Delete(&categoryTrackRow{}).Error; err != nil {
return err
}
if len(childIDs) > 0 {
if err := tx.Where("id IN ?", childIDs).Delete(&categoryRow{}).Error; err != nil {
return err
}
}
return tx.Delete(&categoryRow{}, id).Error
})
}
func (r *Repo) TrackCategoryIDs(ctx context.Context, trackID int64) ([]int64, error) {
if !r.Ready() {
return nil, errors.New("music repo not ready")
}
var ids []int64
err := r.db.WithContext(ctx).Model(&categoryTrackRow{}).
Where("track_id = ?", trackID).Order("category_id ASC").Pluck("category_id", &ids).Error
if err != nil {
return nil, err
}
if ids == nil {
ids = []int64{}
}
return ids, nil
}
func (r *Repo) SetTrackCategories(ctx context.Context, trackID int64, categoryIDs []int64) error {
if !r.Ready() {
return errors.New("music repo not ready")
}
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("track_id = ?", trackID).Delete(&categoryTrackRow{}).Error; err != nil {
return err
}
now := time.Now().UTC()
seen := map[int64]struct{}{}
for _, cid := range categoryIDs {
if cid <= 0 {
continue
}
if _, ok := seen[cid]; ok {
continue
}
seen[cid] = struct{}{}
row := categoryTrackRow{CategoryID: cid, TrackID: trackID, CreatedAt: now}
if err := tx.Create(&row).Error; err != nil {
return err
}
}
return nil
})
}
func (r *Repo) attachCategoryIDs(ctx context.Context, tracks []entity.Track) ([]entity.Track, error) {
if len(tracks) == 0 {
return tracks, nil
}
ids := make([]int64, 0, len(tracks))
for _, t := range tracks {
ids = append(ids, t.ID)
}
var links []categoryTrackRow
err := r.db.WithContext(ctx).Where("track_id IN ?", ids).Order("category_id ASC").Find(&links).Error
if err != nil {
return nil, err
}
m := map[int64][]int64{}
for _, l := range links {
m[l.TrackID] = append(m[l.TrackID], l.CategoryID)
}
for i := range tracks {
cats := m[tracks[i].ID]
if cats == nil {
cats = []int64{}
}
tracks[i].CategoryIDs = cats
}
return tracks, nil
}
func toEntity(row trackRow) entity.Track { func toEntity(row trackRow) entity.Track {
return entity.Track{ return entity.Track{
ID: row.ID, ID: row.ID,
@ -205,7 +469,20 @@ func toEntity(row trackRow) entity.Track {
RequireMember: row.RequireMember, RequireMember: row.RequireMember,
SortOrder: row.SortOrder, SortOrder: row.SortOrder,
Enabled: row.Enabled, Enabled: row.Enabled,
CategoryIDs: []int64{},
CreatedAt: row.CreatedAt, CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt, UpdatedAt: row.UpdatedAt,
} }
} }
func toCategoryEntity(row categoryRow) entity.Category {
return entity.Category{
ID: row.ID,
ParentID: row.ParentID,
Name: row.Name,
SortOrder: row.SortOrder,
Enabled: row.Enabled,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
}

View File

@ -90,7 +90,7 @@ func publicPath(path string) bool {
"/favicon.ico", "/apple-touch-icon.png": "/favicon.ico", "/apple-touch-icon.png":
return true return true
case "/v1/meta/enums", "/v1/auth/login", "/v1/auth/token/refresh", "/v1/sms/codes", case "/v1/meta/enums", "/v1/auth/login", "/v1/auth/token/refresh", "/v1/sms/codes",
"/v1/music/playlist", "/v1/music/settings": "/v1/music/playlist", "/v1/music/categories", "/v1/music/settings":
return true return true
default: default:
// Admin routes use AdminAuth (Java popadmin JWT), not client Bearer. // Admin routes use AdminAuth (Java popadmin JWT), not client Bearer.

View File

@ -65,7 +65,7 @@ info:
| Header | 放哪 | 必填 | 说明 | | Header | 放哪 | 必填 | 说明 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `Authorization` | Header | 需登录的接口必填 | `Bearer <access_token>`。登录、发码、刷新、`/healthz`、`/readyz`、`/docs`、`/openapi.yaml`、`GET /v1/meta/enums`、`GET /v1/music/playlist`、`GET /v1/music/settings` **不要**带。 | | `Authorization` | Header | 需登录的接口必填 | `Bearer <access_token>`。登录、发码、刷新、`/healthz`、`/readyz`、`/docs`、`/openapi.yaml`、`GET /v1/meta/enums`、`GET /v1/music/playlist`、`GET /v1/music/categories`、`GET /v1/music/settings` **不要**带。 |
| `X-Device-ID` | Header | 可选 | 十进制 `device_id`,须等于登录响应 / JWT `did`(**服务端按物理指纹生成**,客户端勿自造)。不一致则 `40101`。 | | `X-Device-ID` | Header | 可选 | 十进制 `device_id`,须等于登录响应 / JWT `did`(**服务端按物理指纹生成**,客户端勿自造)。不一致则 `40101`。 |
| `X-App-ID` | Header | 可选 | 若传,须与 JWT **`aid`(app_id)** 一致。 | | `X-App-ID` | Header | 可选 | 若传,须与 JWT **`aid`(app_id)** 一致。 |
| `X-Language` | Header | 可选 | `zh-CN` / `en` / `zh-TW`。优先于 `Accept-Language`。缺省 `zh-CN`。决定 `msg` 语言。 | | `X-Language` | Header | 可选 | `zh-CN` / `en` / `zh-TW`。优先于 `Accept-Language`。缺省 `zh-CN`。决定 `msg` 语言。 |
@ -373,10 +373,12 @@ tags:
- name: square - name: square
- name: music - name: music
description: | description: |
运营音乐列表。客户端公开读 `GET /v1/music/playlist`、`GET /v1/music/settings`(无需登录,V3 envelope)。 运营音乐列表。客户端公开读 `GET /v1/music/playlist`(可带 `categoryId`)、`GET /v1/music/categories`、`GET /v1/music/settings`(无需登录,V3 envelope)。
playlist 返回曲目、`categories` 分类树与 `memberPreviewSeconds`;曲目含 `backgroundUrl`、`requireMember`、`categoryIds`。
- name: admin-music - name: admin-music
description: | description: |
popadmin 音乐管理:`/api/admin/spark/music*`(`/api/admin/pop/music*` 别名)。 popadmin 音乐管理:`/api/admin/spark/music*`(`/api/admin/pop/music*` 别名)。
曲目 CRUD、封面/背景/音频上传、分类 CRUD、曲目分类绑定(多对多)、全局试听秒数。
Admin JWT;响应 `{success,message,data}`。Host 常用 `https://popadmin.wyrs365.com`。 Admin JWT;响应 `{success,message,data}`。Host 常用 `https://popadmin.wyrs365.com`。
- name: novels - name: novels
description: | description: |
@ -1868,11 +1870,18 @@ paths:
tags: [music] tags: [music]
summary: 音乐列表(公开) summary: 音乐列表(公开)
description: | description: |
返回已上架曲目与全局「会员音乐免费试听秒数」。无需登录。 返回已上架曲目、启用中的分类树与全局「会员音乐免费试听秒数」。无需登录。
封面/背景图/音频为 sparkr 公网 URL(`https://sparkr.wyrs365.com/spark/perm/music/...`)。 封面/背景图/音频为 sparkr 公网 URL(`https://sparkr.wyrs365.com/spark/perm/music/...`)。
客户端应按 `requireMember` 与 `memberPreviewSeconds` 控制试听;`backgroundUrl` 可空。 客户端应按 `requireMember` 与 `memberPreviewSeconds` 控制试听;`backgroundUrl` 可空。
传 `categoryId` 时仅返回归属该分类或其子分类的曲目(多对多)。
operationId: getMusicPlaylist operationId: getMusicPlaylist
security: [] security: []
parameters:
- name: categoryId
in: query
required: false
schema: { type: integer, format: int64, minimum: 0 }
description: 按分类筛选;0 或不传=全部上架曲目
responses: responses:
"200": "200":
description: 统一 envelope,`data` 为 MusicPlaylist description: 统一 envelope,`data` 为 MusicPlaylist
@ -1885,6 +1894,30 @@ paths:
properties: properties:
data: data:
$ref: "#/components/schemas/MusicPlaylist" $ref: "#/components/schemas/MusicPlaylist"
/v1/music/categories:
get:
tags: [music]
summary: 音乐分类树(公开)
description: 返回启用中的总分类与子分类树。无需登录。
operationId: getMusicCategories
security: []
responses:
"200":
description: 统一 envelope,`data.categories` 为 MusicCategory 数组(含 children)
content:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/Envelope"
- type: object
properties:
data:
type: object
properties:
categories:
type: array
items:
$ref: "#/components/schemas/MusicCategory"
/v1/music/settings: /v1/music/settings:
get: get:
tags: [music] tags: [music]
@ -1910,7 +1943,7 @@ paths:
tags: [admin-music] tags: [admin-music]
summary: 管理端曲目列表 summary: 管理端曲目列表
description: | description: |
popadmin 同源调用。`data.items` 为全部曲目(含下架),`data.settings` 为全局试听配置。 popadmin 同源调用。`data.items` 为全部曲目(含下架与 `categoryIds`),`data.settings` 为全局试听配置,`data.categories` 为分类树。
别名:`GET /api/admin/pop/music`。 别名:`GET /api/admin/pop/music`。
operationId: adminListMusic operationId: adminListMusic
security: security:
@ -1976,7 +2009,10 @@ paths:
/api/admin/spark/music/{id}: /api/admin/spark/music/{id}:
patch: patch:
tags: [admin-music] tags: [admin-music]
summary: 更新曲目元数据 summary: 更新曲目元数据(曲名/会员/排序/上下架/分类)
description: |
可改 `title`、`requireMember`、`sortOrder`、`enabled`、`categoryIds`。
封面/背景/音频请分别调用 POST `/music/{id}/cover|background|audio` 上传或替换。
operationId: adminUpdateMusic operationId: adminUpdateMusic
security: security:
- bearerAuth: [] - bearerAuth: []
@ -2014,6 +2050,112 @@ paths:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/AdminApiResponse" $ref: "#/components/schemas/AdminApiResponse"
/api/admin/spark/music/categories:
get:
tags: [admin-music]
summary: 分类树(含下架)
description: `data.categories` 为总分类树(children 为子分类)。别名 `/api/admin/pop/music/categories`。
operationId: adminListMusicCategories
security:
- bearerAuth: []
responses:
"200":
content:
application/json:
schema:
$ref: "#/components/schemas/AdminApiResponse"
post:
tags: [admin-music]
summary: 新建总分类或子分类
description: |
Body:`name` 必填;`parentId` 空/0=总分类,非空=挂到该总分类下(仅两级);
`sortOrder`、`enabled` 可选。
operationId: adminCreateMusicCategory
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/MusicCategoryUpsertRequest"
responses:
"200":
content:
application/json:
schema:
$ref: "#/components/schemas/AdminApiResponse"
/api/admin/spark/music/categories/{id}:
patch:
tags: [admin-music]
summary: 更新分类(改名/排序/启用/父级)
operationId: adminUpdateMusicCategory
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema: { type: integer, format: int64 }
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/MusicCategoryUpsertRequest"
responses:
"200":
content:
application/json:
schema:
$ref: "#/components/schemas/AdminApiResponse"
delete:
tags: [admin-music]
summary: 删除分类(级联删子分类与曲目绑定)
operationId: adminDeleteMusicCategory
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema: { type: integer, format: int64 }
responses:
"200":
content:
application/json:
schema:
$ref: "#/components/schemas/AdminApiResponse"
/api/admin/spark/music/{id}/categories:
put:
tags: [admin-music]
summary: 设置曲目所属分类(多对多全量替换)
description: Body `{"categoryIds":[1,2,3]}`;空数组清除全部归属。亦可在 PATCH `/music/{id}` 传 `categoryIds`。
operationId: adminSetMusicTrackCategories
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema: { type: integer, format: int64 }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [categoryIds]
properties:
categoryIds:
type: array
items: { type: integer, format: int64 }
responses:
"200":
content:
application/json:
schema:
$ref: "#/components/schemas/AdminApiResponse"
/api/admin/spark/music/{id}/cover: /api/admin/spark/music/{id}/cover:
post: post:
tags: [admin-music] tags: [admin-music]
@ -2951,20 +3093,48 @@ components:
data: {} data: {}
MusicTrack: MusicTrack:
type: object type: object
description: |
曲目。管理端可 PATCH 改 title/requireMember/sortOrder/enabled/categoryIds;
封面/背景/音频分别用 POST .../cover|background|audio(multipart `file`)上传或替换。
properties: properties:
id: { type: integer, format: int64 } id: { type: integer, format: int64 }
title: { type: string } title: { type: string }
coverKey: { type: string } coverKey: { type: string }
audioKey: { type: string } audioKey: { type: string }
backgroundKey: { type: string, description: 背景图 object_key;可空 } 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;可空 } backgroundUrl: { type: string, description: 背景图 sparkr 公网 URL;可空 }
requireMember: { type: boolean, description: true=需会员 } requireMember: { type: boolean, description: true=需会员 }
sortOrder: { type: integer } sortOrder: { type: integer, description: 越小越靠前 }
enabled: { type: boolean } enabled: { type: boolean, description: true=上架 }
categoryIds:
type: array
items: { type: integer, format: int64 }
description: 归属的分类 id 列表(总分类与/或子分类,多对多)
createdAt: { type: string, format: date-time } createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time } updatedAt: { type: string, format: date-time }
MusicCategory:
type: object
properties:
id: { type: integer, format: int64 }
parentId: { type: integer, format: int64, nullable: true, description: null=总分类 }
name: { type: string }
sortOrder: { type: integer }
enabled: { type: boolean }
children:
type: array
items:
$ref: "#/components/schemas/MusicCategory"
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
MusicCategoryUpsertRequest:
type: object
properties:
name: { type: string }
parentId: { type: integer, format: int64, nullable: true }
sortOrder: { type: integer }
enabled: { type: boolean }
MusicSettings: MusicSettings:
type: object type: object
properties: properties:
@ -2980,14 +3150,25 @@ components:
type: array type: array
items: items:
$ref: "#/components/schemas/MusicTrack" $ref: "#/components/schemas/MusicTrack"
categories:
type: array
items:
$ref: "#/components/schemas/MusicCategory"
description: 启用中的分类树
memberPreviewSeconds: { type: integer } memberPreviewSeconds: { type: integer }
MusicUpsertRequest: MusicUpsertRequest:
type: object type: object
description: |
管理端新建/更新曲目元数据。上传封面/背景/音频请用独立 multipart 接口。
`categoryIds` 传入时全量替换曲目分类绑定。
properties: properties:
title: { type: string } title: { type: string }
requireMember: { type: boolean } requireMember: { type: boolean }
sortOrder: { type: integer } sortOrder: { type: integer }
enabled: { type: boolean } enabled: { type: boolean }
categoryIds:
type: array
items: { type: integer, format: int64 }
FsnGenre: FsnGenre:
type: object type: object
properties: properties:

View File

@ -43,7 +43,12 @@ func (h *MusicHandler) List(c *gin.Context) {
Fail(c, http.StatusInternalServerError, err.Error()) Fail(c, http.StatusInternalServerError, err.Error())
return return
} }
OK(c, gin.H{"items": list, "settings": settings}) cats, err := h.svc.ListCategoriesAdmin(c.Request.Context())
if err != nil {
Fail(c, http.StatusInternalServerError, err.Error())
return
}
OK(c, gin.H{"items": list, "settings": settings, "categories": cats})
} }
func (h *MusicHandler) GetSettings(c *gin.Context) { func (h *MusicHandler) GetSettings(c *gin.Context) {
@ -132,6 +137,100 @@ func (h *MusicHandler) Delete(c *gin.Context) {
OK(c, nil) OK(c, nil)
} }
func (h *MusicHandler) ListCategories(c *gin.Context) {
if !h.ready(c) {
return
}
out, err := h.svc.ListCategoriesAdmin(c.Request.Context())
if err != nil {
Fail(c, http.StatusInternalServerError, err.Error())
return
}
OK(c, gin.H{"categories": out})
}
func (h *MusicHandler) CreateCategory(c *gin.Context) {
if !h.ready(c) {
return
}
var in musiclogic.CategoryInput
if err := c.ShouldBindJSON(&in); err != nil {
Fail(c, http.StatusBadRequest, "无效 JSON")
return
}
out, err := h.svc.CreateCategory(c.Request.Context(), in)
if err != nil {
failMusic(c, err)
return
}
OK(c, out)
}
func (h *MusicHandler) UpdateCategory(c *gin.Context) {
if !h.ready(c) {
return
}
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || id <= 0 {
Fail(c, http.StatusBadRequest, "无效 id")
return
}
var in musiclogic.CategoryInput
if err := c.ShouldBindJSON(&in); err != nil {
Fail(c, http.StatusBadRequest, "无效 JSON")
return
}
out, err := h.svc.UpdateCategory(c.Request.Context(), id, in)
if err != nil {
failMusic(c, err)
return
}
OK(c, out)
}
func (h *MusicHandler) DeleteCategory(c *gin.Context) {
if !h.ready(c) {
return
}
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || id <= 0 {
Fail(c, http.StatusBadRequest, "无效 id")
return
}
if err := h.svc.DeleteCategory(c.Request.Context(), id); err != nil {
failMusic(c, err)
return
}
OK(c, nil)
}
func (h *MusicHandler) SetTrackCategories(c *gin.Context) {
if !h.ready(c) {
return
}
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || id <= 0 {
Fail(c, http.StatusBadRequest, "无效 id")
return
}
var body struct {
CategoryIDs []int64 `json:"categoryIds"`
}
if err := c.ShouldBindJSON(&body); err != nil {
Fail(c, http.StatusBadRequest, "无效 JSON")
return
}
if body.CategoryIDs == nil {
body.CategoryIDs = []int64{}
}
out, err := h.svc.SetTrackCategories(c.Request.Context(), id, body.CategoryIDs)
if err != nil {
failMusic(c, err)
return
}
OK(c, out)
}
func (h *MusicHandler) UploadCover(c *gin.Context) { func (h *MusicHandler) UploadCover(c *gin.Context) {
h.upload(c, "cover") h.upload(c, "cover")
} }

View File

@ -1,6 +1,8 @@
package musichandler package musichandler
import ( import (
"strconv"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
musiclogic "spark-server/internal/module/music/logic" musiclogic "spark-server/internal/module/music/logic"
@ -16,13 +18,23 @@ func New(svc *musiclogic.Service) *Handler {
return &Handler{svc: svc} return &Handler{svc: svc}
} }
// Playlist GET /v1/music/playlist — public curated list + global preview seconds. // Playlist GET /v1/music/playlist — public curated list + category tree.
// Query categoryId>0 filters tracks belonging to that category or its descendants.
func (h *Handler) Playlist(c *gin.Context) { func (h *Handler) Playlist(c *gin.Context) {
if h == nil || h.svc == nil || !h.svc.Ready() { if h == nil || h.svc == nil || !h.svc.Ready() {
respond.JSONError(c, errs.ErrAvailable) respond.JSONError(c, errs.ErrAvailable)
return return
} }
out, err := h.svc.Playlist(c.Request.Context()) var categoryID int64
if raw := c.Query("categoryId"); raw != "" {
n, err := strconv.ParseInt(raw, 10, 64)
if err != nil || n < 0 {
respond.JSONError(c, errs.ErrInvalidArgument.WithMsg("categoryId 无效"))
return
}
categoryID = n
}
out, err := h.svc.Playlist(c.Request.Context(), categoryID)
if err != nil { if err != nil {
respond.JSONError(c, err) respond.JSONError(c, err)
return return
@ -30,6 +42,20 @@ func (h *Handler) Playlist(c *gin.Context) {
respond.JSON(c, out) respond.JSON(c, out)
} }
// Categories GET /v1/music/categories — enabled category tree.
func (h *Handler) Categories(c *gin.Context) {
if h == nil || h.svc == nil || !h.svc.Ready() {
respond.JSONError(c, errs.ErrAvailable)
return
}
out, err := h.svc.CategoryTree(c.Request.Context(), true)
if err != nil {
respond.JSONError(c, err)
return
}
respond.JSON(c, gin.H{"categories": out})
}
// Settings GET /v1/music/settings — global member preview seconds only. // Settings GET /v1/music/settings — global member preview seconds only.
func (h *Handler) Settings(c *gin.Context) { func (h *Handler) Settings(c *gin.Context) {
if h == nil || h.svc == nil || !h.svc.Ready() { if h == nil || h.svc == nil || !h.svc.Ready() {

View File

@ -219,6 +219,7 @@ func registerMusicRoutes(v1 *gin.RouterGroup, deps *EngineDeps) {
g := v1.Group("/music") g := v1.Group("/music")
// 客户端可读:无需登录(列表为运营配置的公开曲目元数据) // 客户端可读:无需登录(列表为运营配置的公开曲目元数据)
g.GET("/playlist", h.Playlist) g.GET("/playlist", h.Playlist)
g.GET("/categories", h.Categories)
g.GET("/settings", h.Settings) g.GET("/settings", h.Settings)
} }

View File

@ -80,9 +80,14 @@ func registerMusicAdmin(g *gin.RouterGroup, h *adminhandler.MusicHandler) {
g.GET("/music", h.List) g.GET("/music", h.List)
g.GET("/music/settings", h.GetSettings) g.GET("/music/settings", h.GetSettings)
g.PUT("/music/settings", h.PutSettings) g.PUT("/music/settings", h.PutSettings)
g.GET("/music/categories", h.ListCategories)
g.POST("/music/categories", h.CreateCategory)
g.PATCH("/music/categories/:id", h.UpdateCategory)
g.DELETE("/music/categories/:id", h.DeleteCategory)
g.POST("/music", h.Create) g.POST("/music", h.Create)
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.PUT("/music/:id/categories", h.SetTrackCategories)
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/background", h.UploadBackground)
g.POST("/music/:id/audio", h.UploadAudio) g.POST("/music/:id/audio", h.UploadAudio)

View File

@ -0,0 +1,82 @@
-- 音乐总分类 / 子分类 + 曲目多对多。种子:首页 + 分类1/2/3;按 demo 曲名分配到子分类。
CREATE TABLE IF NOT EXISTS t_music_categories (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '分类自增 id',
parent_id BIGINT NULL COMMENT 'NULL=总分类;非空=所属总分类 id',
name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '分类名称(后台可改)',
sort_order INT NOT NULL DEFAULT 0 COMMENT '越小越靠前',
enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '1=启用',
created_at DATETIME(3) NOT NULL COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL COMMENT '更新时间',
KEY idx_music_cat_parent (parent_id, enabled, sort_order, id),
KEY idx_music_cat_name (name)
) COMMENT='音乐总分类与子分类';
CREATE TABLE IF NOT EXISTS t_music_category_tracks (
category_id BIGINT NOT NULL COMMENT '分类 id',
track_id BIGINT NOT NULL COMMENT '曲目 id',
created_at DATETIME(3) NOT NULL COMMENT '绑定时间',
PRIMARY KEY (category_id, track_id),
KEY idx_mct_track (track_id)
) COMMENT='曲目与分类多对多(同一曲可属多个分类)';
INSERT INTO t_music_categories (parent_id, name, sort_order, enabled, created_at, updated_at)
SELECT NULL, '首页', 0, 1, CURRENT_TIMESTAMP(3), CURRENT_TIMESTAMP(3)
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM t_music_categories WHERE parent_id IS NULL AND name = '首页'
);
INSERT INTO t_music_categories (parent_id, name, sort_order, enabled, created_at, updated_at)
SELECT p.id, '分类1', 1, 1, CURRENT_TIMESTAMP(3), CURRENT_TIMESTAMP(3)
FROM t_music_categories p
WHERE p.parent_id IS NULL AND p.name = '首页'
AND NOT EXISTS (
SELECT 1 FROM t_music_categories c WHERE c.parent_id = p.id AND c.name = '分类1'
);
INSERT INTO t_music_categories (parent_id, name, sort_order, enabled, created_at, updated_at)
SELECT p.id, '分类2', 2, 1, CURRENT_TIMESTAMP(3), CURRENT_TIMESTAMP(3)
FROM t_music_categories p
WHERE p.parent_id IS NULL AND p.name = '首页'
AND NOT EXISTS (
SELECT 1 FROM t_music_categories c WHERE c.parent_id = p.id AND c.name = '分类2'
);
INSERT INTO t_music_categories (parent_id, name, sort_order, enabled, created_at, updated_at)
SELECT p.id, '分类3', 3, 1, CURRENT_TIMESTAMP(3), CURRENT_TIMESTAMP(3)
FROM t_music_categories p
WHERE p.parent_id IS NULL AND p.name = '首页'
AND NOT EXISTS (
SELECT 1 FROM t_music_categories c WHERE c.parent_id = p.id AND c.name = '分类3'
);
INSERT INTO t_music_category_tracks (category_id, track_id, created_at)
SELECT c.id, t.id, CURRENT_TIMESTAMP(3)
FROM t_music_categories c
JOIN t_music_tracks t ON t.title IN ('晨光微风', '城市夜行', '雨巷漫步', '山间回响')
WHERE c.name = '分类1'
AND c.parent_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM t_music_category_tracks x WHERE x.category_id = c.id AND x.track_id = t.id
);
INSERT INTO t_music_category_tracks (category_id, track_id, created_at)
SELECT c.id, t.id, CURRENT_TIMESTAMP(3)
FROM t_music_categories c
JOIN t_music_tracks t ON t.title IN ('海边日落', '咖啡馆时光', '极光之夜', '纸飞机')
WHERE c.name = '分类2'
AND c.parent_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM t_music_category_tracks x WHERE x.category_id = c.id AND x.track_id = t.id
);
INSERT INTO t_music_category_tracks (category_id, track_id, created_at)
SELECT c.id, t.id, CURRENT_TIMESTAMP(3)
FROM t_music_categories c
JOIN t_music_tracks t ON t.title IN ('极光之夜', '星河入梦', '旅途序曲', '晨光微风')
WHERE c.name = '分类3'
AND c.parent_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM t_music_category_tracks x WHERE x.category_id = c.id AND x.track_id = t.id
);