feat(music): 分类多对多、后台完整编辑与 OpenAPI
新增总/子分类与曲目绑定;playlist 可按 categoryId 筛选并返回分类树; popadmin 支持行内改曲名/排序/上下架/会员开关与封面背景音频上传;文档同步。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
b10c5d63a3
commit
d3a68c3cad
@ -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();
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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 的环境)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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", "")
|
||||||
|
|||||||
@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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("对象存储未就绪")
|
||||||
|
|||||||
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
82
migrations/0022_music_categories.sql
Normal file
82
migrations/0022_music_categories.sql
Normal 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
|
||||||
|
);
|
||||||
Loading…
Reference in New Issue
Block a user