diff --git a/deploy/popadmin/popadmin.js b/deploy/popadmin/popadmin.js
index e521ab5..80e63a2 100644
--- a/deploy/popadmin/popadmin.js
+++ b/deploy/popadmin/popadmin.js
@@ -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 = `
音乐 API 不可用:${esc(e.message)}。需部署 spark-server 迁移 0019_music 并更新 nginx music 路由。
`;
+ $('content').innerHTML = `音乐 API 不可用:${esc(e.message)}。需部署 spark-server 迁移 0020–0022 并更新 nginx music 路由。
`;
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 = `
- 火花音乐列表 · 管理 /api/admin/spark/music* · 客户端 GET /v1/music/playlist(sparkapi)· 封面/背景图/mp3 存 sparkr
+ 火花音乐列表 · 管理 /api/admin/spark/music* · 客户端 GET /v1/music/playlist(sparkapi)· 封面/背景图/mp3 存 sparkr · 分类多对多
总控 · 会员音乐免费试听(秒)
@@ -2514,6 +2528,43 @@ const pages = {
0~3600;仅对 requireMember=true 的曲目生效
+
@@ -2522,35 +2573,44 @@ const pages = {
`;
+
$('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();
};
diff --git a/docs/popadmin-music-novels-deploy.md b/docs/popadmin-music-novels-deploy.md
index 6af8169..6d7bd49 100644
--- a/docs/popadmin-music-novels-deploy.md
+++ b/docs/popadmin-music-novels-deploy.md
@@ -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 的环境)
diff --git a/internal/module/admin/logic/jwt.go b/internal/module/admin/logic/jwt.go
index caa705a..f07e1d4 100644
--- a/internal/module/admin/logic/jwt.go
+++ b/internal/module/admin/logic/jwt.go
@@ -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
diff --git a/internal/module/admin/logic/jwt_test.go b/internal/module/admin/logic/jwt_test.go
index dfcc3b7..56e12dd 100644
--- a/internal/module/admin/logic/jwt_test.go
+++ b/internal/module/admin/logic/jwt_test.go
@@ -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", "")
diff --git a/internal/module/music/entity/track.go b/internal/module/music/entity/track.go
index ac05446..e455ff9 100644
--- a/internal/module/music/entity/track.go
+++ b/internal/module/music/entity/track.go
@@ -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"`
}
diff --git a/internal/module/music/logic/service.go b/internal/module/music/logic/service.go
index 591efe4..c4b8d48 100644
--- a/internal/module/music/logic/service.go
+++ b/internal/module/music/logic/service.go
@@ -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("对象存储未就绪")
diff --git a/internal/module/music/model/repo.go b/internal/module/music/model/repo.go
index 9b5451f..08c2a68 100644
--- a/internal/module/music/model/repo.go
+++ b/internal/module/music/model/repo.go
@@ -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,
+ }
+}
diff --git a/internal/transport/http/auth.go b/internal/transport/http/auth.go
index d399084..11b832f 100644
--- a/internal/transport/http/auth.go
+++ b/internal/transport/http/auth.go
@@ -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.
diff --git a/internal/transport/http/docs/openapi.yaml b/internal/transport/http/docs/openapi.yaml
index 70dcde4..bc03e65 100644
--- a/internal/transport/http/docs/openapi.yaml
+++ b/internal/transport/http/docs/openapi.yaml
@@ -65,7 +65,7 @@ info:
| Header | 放哪 | 必填 | 说明 |
| --- | --- | --- | --- |
- | `Authorization` | Header | 需登录的接口必填 | `Bearer `。登录、发码、刷新、`/healthz`、`/readyz`、`/docs`、`/openapi.yaml`、`GET /v1/meta/enums`、`GET /v1/music/playlist`、`GET /v1/music/settings` **不要**带。 |
+ | `Authorization` | Header | 需登录的接口必填 | `Bearer `。登录、发码、刷新、`/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:
diff --git a/internal/transport/http/handler/admin/music.go b/internal/transport/http/handler/admin/music.go
index d6504a9..fba3894 100644
--- a/internal/transport/http/handler/admin/music.go
+++ b/internal/transport/http/handler/admin/music.go
@@ -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")
}
diff --git a/internal/transport/http/handler/music/handler.go b/internal/transport/http/handler/music/handler.go
index 9c65130..7ae8272 100644
--- a/internal/transport/http/handler/music/handler.go
+++ b/internal/transport/http/handler/music/handler.go
@@ -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() {
diff --git a/internal/transport/http/router.go b/internal/transport/http/router.go
index 202fcdc..f6ed3a3 100644
--- a/internal/transport/http/router.go
+++ b/internal/transport/http/router.go
@@ -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)
}
diff --git a/internal/transport/http/router_admin.go b/internal/transport/http/router_admin.go
index 471afa5..09687c9 100644
--- a/internal/transport/http/router_admin.go
+++ b/internal/transport/http/router_admin.go
@@ -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)
diff --git a/migrations/0022_music_categories.sql b/migrations/0022_music_categories.sql
new file mode 100644
index 0000000..4bbeb84
--- /dev/null
+++ b/migrations/0022_music_categories.sql
@@ -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
+ );