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 的曲目生效
+
+

分类管理(总分类 / 子分类)

+
+ + + + +
+ + + + ${ + flatCats.length + ? flatCats + .map( + (c) => ` + + + + + + + + ` + ) + .join('') + : '' + } + +
ID名称层级父级排序启用操作
${esc(c.id)}${esc(c.name)}${c.depth === 0 ? '总分类' : '子分类'}${c.parentId == null ? '—' : esc(catLabel(c.parentId))}${esc(c.sortOrder)}${c.enabled ? '是' : '否'} + + +
暂无分类
+
@@ -2522,35 +2573,44 @@ const pages = {
- + ${ items.length ? items - .map( - (t) => ` + .map((t) => { + const ids = Array.isArray(t.categoryIds) ? t.categoryIds : []; + const catText = ids.length ? ids.map(catLabel).join('、') : '—'; + return ` + - - - - - + + + + + - ` - ) + `; + }) .join('') - : '' + : '' }
封面背景ID曲名会员上架排序音频操作
会员封面背景ID曲名上架排序分类音频操作
+ + ${t.coverUrl ? `` : '—'} ${t.backgroundUrl ? `` : '—'} ${esc(t.id)}${esc(t.title)}${t.requireMember ? '是' : '否'}${t.enabled ? '是' : '否'}${esc(t.sortOrder)}${t.audioUrl ? `mp3` : '—'}${esc(catText)}${t.audioUrl ? `mp3` : '—'} - + +
暂无曲目
暂无曲目
`; + $('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 + );