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() {
let data = { items: [], settings: { memberPreviewSeconds: 30 } };
let data = { items: [], settings: { memberPreviewSeconds: 30 }, categories: [] };
try {
data = await api('/spark/music');
} 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;
}
const items = Array.isArray(data.items) ? data.items : [];
const cats = Array.isArray(data.categories) ? data.categories : [];
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 = `
<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">
<h3 style="margin:0 0 8px">总控 · 会员音乐免费试听(秒)</h3>
<div class="toolbar" style="gap:8px;align-items:center">
@ -2514,6 +2528,43 @@ const pages = {
<span class="muted">0~3600;仅对 requireMember=true 的曲目生效</span>
</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">
<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>
@ -2522,35 +2573,44 @@ const pages = {
<button type="button" class="secondary" id="musicReload">刷新</button>
</div>
<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>
${
items.length
? items
.map(
(t) => `<tr>
.map((t) => {
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.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.title)}</td>
<td>${t.requireMember ? '是' : '否'}</td>
<td>${t.enabled ? '是' : '否'}</td>
<td>${esc(t.sortOrder)}</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><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><input type="checkbox" data-menabled="${esc(t.id)}" ${t.enabled ? 'checked' : ''} title="上架"></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 style="max-width:160px;font-size:12px">${esc(catText)}</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">
<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-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>
<button type="button" class="secondary btn-sm" data-mdel="${esc(t.id)}">删</button>
</td>
</tr>`
)
</tr>`;
})
.join('')
: '<tr><td colspan="9">暂无曲目</td></tr>'
: '<tr><td colspan="10">暂无曲目</td></tr>'
}
</tbody>
</table>`;
$('musicPreviewSave').onclick = async () => {
const sec = parseInt($('musicPreviewSec').value, 10);
await api('/spark/music/settings', {
@ -2575,17 +2635,122 @@ const pages = {
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 () => {
const id = btn.dataset.medit;
const row = items.find((x) => String(x.id) === String(id));
const title = prompt('曲名', row?.title || '');
if (title == null) return;
const member = confirm('是否需要会员?(确定=是,取消=否)');
const enabled = confirm('是否上架?(确定=是,取消=否)');
const id = btn.dataset.catedit;
const row = flatCats.find((x) => String(x.id) === String(id));
const name = prompt('分类名称', row?.name || '');
if (name == null) return;
const sortRaw = prompt('排序(数字,越小越前)', String(row?.sortOrder ?? 0));
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, {
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();
};

View File

@ -5,7 +5,7 @@
```bash
# 本机(Windows/WSL):构建并部署到新加坡机
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 未跑时):
@ -17,10 +17,28 @@ sudo nginx -t && sudo nginx -s reload
# 确认 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/docs` → 可见 music / admin-music
- popadmin **重新登录**后「音乐列表」不再跳登录;`GET /api/admin/spark/music` → `200` + `success:true`
- popadmin「音乐列表」:封面 / **背景** / mp3 上传;`POST /api/admin/spark/music/{id}/background`
## 2. Aliyun wyrs-admin(popadmin 静态)
@ -53,7 +71,7 @@ OpenAPI 已按 `Spark资源文档/devices-client.md` 更新契约。
## 5. Demo 曲目(可选)
仓库旁 `_demo_music/`:`tracks.json` + `01..10.mp3/png` + `upload_demo.py`。
背景图**可空**,demo **不强制**补背景;脚本勿删已有曲目(仅 POST 新建)。
背景图**可空**,demo **不强制**补背景;脚本勿删已有曲目(仅 POST 新建)。背景可用 upload_backgrounds.py 补传。
```bash
# 在新加坡机(或可访问 popadmin 登录与 spark admin API 的环境)

View File

@ -46,10 +46,13 @@ func (s *TokenService) Parse(tokenStr string) (*Claims, error) {
return nil, ErrInvalidToken
}
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 s.secret, nil
})
if err != nil {
return nil, ErrInvalidToken

View File

@ -1,6 +1,11 @@
package logic
import "testing"
import (
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
func TestAdminJWTRoundTrip(t *testing.T) {
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) {
s := NewTokenService("wyrs-admin-jwt-secret-change-in-production-2025")
tok, err := s.CreateToken(1, "x", "SUPER_ADMIN", "")

View File

@ -24,10 +24,23 @@ type Track struct {
RequireMember bool `json:"requireMember"`
SortOrder int `json:"sortOrder"`
Enabled bool `json:"enabled"`
CategoryIDs []int64 `json:"categoryIds"`
CreatedAt time.Time `json:"createdAt"`
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.
type Settings struct {
MemberPreviewSeconds int `json:"memberPreviewSeconds"`
@ -35,6 +48,7 @@ type Settings struct {
// Playlist is the client-facing payload.
type Playlist struct {
Items []Track `json:"items"`
MemberPreviewSeconds int `json:"memberPreviewSeconds"`
Items []Track `json:"items"`
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 {
ListAll(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)
Insert(ctx context.Context, t *entity.Track) error
Update(ctx context.Context, t *entity.Track) error
Delete(ctx context.Context, id int64) error
MemberPreviewSeconds(ctx context.Context) (int, error)
SetSetting(ctx context.Context, key, value string) error
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 {
@ -62,10 +70,18 @@ func (s *Service) Ready() bool {
}
type UpsertInput struct {
Title string `json:"title"`
RequireMember *bool `json:"requireMember"`
SortOrder *int `json:"sortOrder"`
Enabled *bool `json:"enabled"`
Title string `json:"title"`
RequireMember *bool `json:"requireMember"`
SortOrder *int `json:"sortOrder"`
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) {
@ -75,11 +91,19 @@ func (s *Service) ListAdmin(ctx context.Context) ([]entity.Track, error) {
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() {
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 {
return nil, err
}
@ -87,7 +111,11 @@ func (s *Service) Playlist(ctx context.Context) (*entity.Playlist, error) {
if err != nil {
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) {
@ -127,6 +155,7 @@ func (s *Service) Create(ctx context.Context, in UpsertInput) (*entity.Track, er
RequireMember: false,
SortOrder: 0,
Enabled: true,
CategoryIDs: []int64{},
}
if in.RequireMember != nil {
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 {
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
}
@ -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 {
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
}
@ -277,6 +318,189 @@ func (s *Service) UploadAudio(ctx context.Context, id int64, filename string, bo
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 {
if s.objects == nil || !s.objects.Ready() {
return errs.ErrAvailable.WithMsg("对象存储未就绪")

View File

@ -52,6 +52,26 @@ type settingRow struct {
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) {
if !r.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 {
out = append(out, toEntity(row))
}
return out, nil
return r.attachCategoryIDs(ctx, out)
}
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 {
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
}
@ -94,6 +171,11 @@ func (r *Repo) Get(ctx context.Context, id int64) (*entity.Track, error) {
return nil, err
}
t := toEntity(row)
ids, err := r.TrackCategoryIDs(ctx, id)
if err != nil {
return nil, err
}
t.CategoryIDs = ids
return &t, nil
}
@ -122,6 +204,9 @@ func (r *Repo) Insert(ctx context.Context, t *entity.Track) error {
t.ID = row.ID
t.CreatedAt = row.CreatedAt
t.UpdatedAt = row.UpdatedAt
if t.CategoryIDs == nil {
t.CategoryIDs = []int64{}
}
return nil
}
@ -150,7 +235,12 @@ func (r *Repo) Delete(ctx context.Context, id int64) error {
if !r.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) {
@ -192,6 +282,180 @@ func (r *Repo) MemberPreviewSeconds(ctx context.Context) (int, error) {
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 {
return entity.Track{
ID: row.ID,
@ -205,7 +469,20 @@ func toEntity(row trackRow) entity.Track {
RequireMember: row.RequireMember,
SortOrder: row.SortOrder,
Enabled: row.Enabled,
CategoryIDs: []int64{},
CreatedAt: row.CreatedAt,
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":
return true
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
default:
// Admin routes use AdminAuth (Java popadmin JWT), not client Bearer.

View File

@ -65,7 +65,7 @@ info:
| 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-App-ID` | Header | 可选 | 若传,须与 JWT **`aid`(app_id)** 一致。 |
| `X-Language` | Header | 可选 | `zh-CN` / `en` / `zh-TW`。优先于 `Accept-Language`。缺省 `zh-CN`。决定 `msg` 语言。 |
@ -373,10 +373,12 @@ tags:
- name: square
- name: music
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
description: |
popadmin 音乐管理:`/api/admin/spark/music*`(`/api/admin/pop/music*` 别名)。
曲目 CRUD、封面/背景/音频上传、分类 CRUD、曲目分类绑定(多对多)、全局试听秒数。
Admin JWT;响应 `{success,message,data}`。Host 常用 `https://popadmin.wyrs365.com`。
- name: novels
description: |
@ -1868,11 +1870,18 @@ paths:
tags: [music]
summary: 音乐列表(公开)
description: |
返回已上架曲目与全局「会员音乐免费试听秒数」。无需登录。
返回已上架曲目、启用中的分类树与全局「会员音乐免费试听秒数」。无需登录。
封面/背景图/音频为 sparkr 公网 URL(`https://sparkr.wyrs365.com/spark/perm/music/...`)。
客户端应按 `requireMember` 与 `memberPreviewSeconds` 控制试听;`backgroundUrl` 可空。
传 `categoryId` 时仅返回归属该分类或其子分类的曲目(多对多)。
operationId: getMusicPlaylist
security: []
parameters:
- name: categoryId
in: query
required: false
schema: { type: integer, format: int64, minimum: 0 }
description: 按分类筛选;0 或不传=全部上架曲目
responses:
"200":
description: 统一 envelope,`data` 为 MusicPlaylist
@ -1885,6 +1894,30 @@ paths:
properties:
data:
$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:
get:
tags: [music]
@ -1910,7 +1943,7 @@ paths:
tags: [admin-music]
summary: 管理端曲目列表
description: |
popadmin 同源调用。`data.items` 为全部曲目(含下架),`data.settings` 为全局试听配置。
popadmin 同源调用。`data.items` 为全部曲目(含下架与 `categoryIds`),`data.settings` 为全局试听配置,`data.categories` 为分类树。
别名:`GET /api/admin/pop/music`。
operationId: adminListMusic
security:
@ -1976,7 +2009,10 @@ paths:
/api/admin/spark/music/{id}:
patch:
tags: [admin-music]
summary: 更新曲目元数据
summary: 更新曲目元数据(曲名/会员/排序/上下架/分类)
description: |
可改 `title`、`requireMember`、`sortOrder`、`enabled`、`categoryIds`。
封面/背景/音频请分别调用 POST `/music/{id}/cover|background|audio` 上传或替换。
operationId: adminUpdateMusic
security:
- bearerAuth: []
@ -2014,6 +2050,112 @@ paths:
application/json:
schema:
$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:
post:
tags: [admin-music]
@ -2951,20 +3093,48 @@ components:
data: {}
MusicTrack:
type: object
description: |
曲目。管理端可 PATCH 改 title/requireMember/sortOrder/enabled/categoryIds;
封面/背景/音频分别用 POST .../cover|background|audio(multipart `file`)上传或替换。
properties:
id: { type: integer, format: int64 }
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 }
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 }
sortOrder: { type: integer, description: 越小越靠前 }
enabled: { type: boolean, description: true=上架 }
categoryIds:
type: array
items: { type: integer, format: int64 }
description: 归属的分类 id 列表(总分类与/或子分类,多对多)
createdAt: { 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:
type: object
properties:
@ -2980,14 +3150,25 @@ components:
type: array
items:
$ref: "#/components/schemas/MusicTrack"
categories:
type: array
items:
$ref: "#/components/schemas/MusicCategory"
description: 启用中的分类树
memberPreviewSeconds: { type: integer }
MusicUpsertRequest:
type: object
description: |
管理端新建/更新曲目元数据。上传封面/背景/音频请用独立 multipart 接口。
`categoryIds` 传入时全量替换曲目分类绑定。
properties:
title: { type: string }
requireMember: { type: boolean }
sortOrder: { type: integer }
enabled: { type: boolean }
categoryIds:
type: array
items: { type: integer, format: int64 }
FsnGenre:
type: object
properties:

View File

@ -43,7 +43,12 @@ func (h *MusicHandler) List(c *gin.Context) {
Fail(c, http.StatusInternalServerError, err.Error())
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) {
@ -132,6 +137,100 @@ func (h *MusicHandler) Delete(c *gin.Context) {
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) {
h.upload(c, "cover")
}

View File

@ -1,6 +1,8 @@
package musichandler
import (
"strconv"
"github.com/gin-gonic/gin"
musiclogic "spark-server/internal/module/music/logic"
@ -16,13 +18,23 @@ func New(svc *musiclogic.Service) *Handler {
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) {
if h == nil || h.svc == nil || !h.svc.Ready() {
respond.JSONError(c, errs.ErrAvailable)
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 {
respond.JSONError(c, err)
return
@ -30,6 +42,20 @@ func (h *Handler) Playlist(c *gin.Context) {
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.
func (h *Handler) Settings(c *gin.Context) {
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.GET("/playlist", h.Playlist)
g.GET("/categories", h.Categories)
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/settings", h.GetSettings)
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.PATCH("/music/:id", h.Update)
g.DELETE("/music/:id", h.Delete)
g.PUT("/music/:id/categories", h.SetTrackCategories)
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,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
);