📚 music · 模组知识

HOOP 系统设计图纸 · 正文唯一真相是 docs/music.md;改 md 再跑 ./tools/deploy_docs.sh,这页是生成的

版本 8ae27943
2026-09-08 22:33
档案
module music
summary 音乐:三层模型 —— 录音(songs,持有音频与定版)→ 曲目(release_tracks,只是一个位置)→ 发行物(releases,发布单位);创作只有三条路(AI 生成 / 上传 / 无音频草稿),都是一次同步请求、没有 presign;AI 歌单首发布直接 published(不定版、先发布再异步补封面),上传歌进人工审核,发行物发布和送审都会定版,只有直接发布为 published 时才把符合条件的草稿曲目推上线,送审不推,管理员批发行物也不推曲目;定版锁在录音上、不可逆、唯一出口是分支成新歌;分成在双方都同意那一刻锁(可能早于发布),单首发布前有分成闸、整张发布没有;下架是软删、真删有三道闸、累犯数「removed 且理由≠creator」;已发布的歌在签名器启用时 302 到 CDN、否则后端代理,计数的是符合条件的流请求(带 token 才按人、否则按 IP),Redis 去重正常时每首每小时一次;榜 = 播放 + 打赏 / 10;一起听同步的是「状态」不是音频流(锚点 + 服务器时间),DJ = 会话 owner,状态只在 Redis、歌单落库;手机端一个播放器、一个浮窗、队列不落盘;没做搜索接口、播放历史表;锁屏桥只在 iOS 初始化、App 没有音乐深链分支(安卓和落地页另核)。
updated 2026-09-08
verified_ref 6a1ef741aae6d5dba235788050e29c7e48750821
source_ref [backend/internal/domain/music, backend/internal/domain/conversation(音乐房建房那段), backend/internal/domain/message/service.go(四种卡), backend/internal/domain/post/repository.go(只引), app/lib/services/music_player_service.dart, app/lib/services/music_room_service.dart, app/lib/services/now_playing_bridge.dart, app/lib/widgets/mini_player_bar.dart, app/lib/widgets/expand_player_route.dart, app/lib/screens/music_.dart, app/lib/screens/now_playing_screen.dart, app/lib/screens/immersive_music_screen.dart, app/lib/screens/new_music_room_screen.dart, app/lib/api/music_.dart, app/lib/util/music_recents.dart]
status current
review_after 2026-12-07
owner nova
supersedes [MUSIC_PLATFORM.md, MUSIC_RELEASE.md, MUSIC_ROOMS.md, MUSIC_STUDIO_ONBOARDING.md, MUSIC_SONG_DETAIL.md(只是转向桩;要不要删仍等李敏点头)]
partially_supersedes [hoopstudio/hoopstudio-music-prd.md(它是产品规格与改动史的本体,本文是「接手的人先读」的那份;两处冲突以代码为准,见 §7), hoopstudio/hoopstudio-wallet-prd.md(分成比例、账本、提现 → wallet,本文只写音乐摸到钱的口), hoopstudio/hoopstudio-prd.md(工作室外壳、创作者身份 → studio)]

这份怎么读:每一节先讲「它解决什么、用户看到什么」,再讲「为什么这样做、数据在哪、哪一步会断、断了留下什么、有什么保障和缺口」;代码位置、参数、行号收在每段末尾的「出处」里。 出处标记:[code@6a1ef741 文件:行] = 那棵树上的实现;[prod@日期,谁核] = 生产机当天核过;[decision@日期,谁] = 拍板过的决定。代码证明「那一版怎么做的」,证明不了「线上是这样」和「应该是这样」。 边界:分成比例(revshare.MusicCreatorPct)、账本 dev_ledger、提现、KYC 归 wallet;创作者身份(developers.status='approved',一次放行游戏 / 音乐 / 小程序三条线)和工作室外壳归 studio;一起听的「房」是第五种会话类型,本文写它(蓝图归 music,旧 PRD 抬头说归会话线 —— 代码在 domain/music/room*.go,按代码算)。旧图纸里五处指向 MUSIC_ASBUILT.md 的链接是死的,那个文件已被合并删除;要「现状」看本文。

0. 给接手的人:三分钟读懂音乐

先认六个词:录音(song) = songs 表一行,持有音频版本(song_deploys,最多留 5 版)和定版锁;发行物(release) = 单曲 / EP / 专辑 / 合辑,是发布单位(它没发布,里面还是草稿的曲目看不见;已经单首发布过的录音自己照样可见);曲目(track) = release_tracks 一行,只是「这首录音在这张发行物的第几个位置」,不持音频、不持定版,一首录音可以在多张发行物上;定版(lock) = 发行物发布或送审时把 locked_version 钉死在录音上(单首发布不定版),锁上之后不能再生成、不能回滚,唯一出口是「分支成新歌」;档位(kind) = 按曲目数自动定(1 / 2–6 / 7+ = 单曲 / EP / 专辑),第一次发布时快照进 releases.kind,创作者不能选;一起听(music room) = 第五种会话类型,同步的是「播到哪」这个状态,每台手机自己从 CDN 拉音频。

它保证到什么程度: 1. 三条创作路都是一次同步请求,但失败各留各的半截:AI 生成先扣配额(Redis INCR)、再查老歌归属和定版(锁定的歌 409 locked,配额已扣)、再调 Lyria(报错时尝试 DECR 退一次,结果不检查,不保证退回);成功后先写音频对象、再开库事务songs / song_deploys,事务失败会留下孤儿对象。上传是一条 multipart(≤ 30 MB;扩展名或魔数不对 400 bad_type,读不出或太小 bad_file;必须勾「我有版权」),先独立 INSERT songs 行、再写对象、再开事务登记版本,存储失败会留下一行没音频的歌。草稿是 song_deploys 第 0 版(bytes=0),不扣配额。没有 presign / PUT / finalize,App 里也没有本地草稿。 2. 单首发布和整张发布是两套,别共用一组保证:单首 POST …/publish 先过分成闸(有待答的合作邀请 409 invite_pending,分成没双方同意 409 split_unagreed),AI 歌 → published,上传歌(prompt='(uploaded)')→ review,只有 ADMIN_UIDS 能批;单首发布和管理员批单首都不定版。发行物 POST …/publish 一个事务:快照档位(只在 kind 为空时)→ 锁曲目(送审也锁,幂等) → 只有整张判为 published 才把里面的 draft 推上线 → 改状态;含上传歌的整张进 review;整张发布没有逐曲分成闸。分成锁本身由 tryLockIfAllAgreed 在双方都同意那一刻完成,可能早于发布。缺口:管理员批发行物只改发行物状态 + 发通知,不推曲目,送审时还是 draft / review 的曲目不会跟着上线。 3. 每一处写状态的代码都调 recordReview(读源码的守卫数着,漏一处就红),但 recordReview 写失败只记日志、不返回错误,单首状态 UPDATE 和流水不在一个事务 —— 状态已变而历史缺一行是可能的;在事务里调的那几处才跟着事务走。 4. 下架 ≠ 删除:下架 = status='removed',音频对象不删,创作者自己下架不算累犯;累犯 SQL 数的是 status='removed' AND takedown_reason <> 'creator'(含历史上理由为空的行,不只三类明文理由),≥ 3 行禁上传。真删是另一个端点,三道闸:有打赏、在发行物里、被管理员下架过 → 409。 5. 已发布的歌从 CDN 出:status='published' 且签名器开着 → 302 到签名 URL(这个分支不设缓存头,CDN 怎么缓存看签名器 / 源站,未核);否则后端代理(响应 immutable 一年;有 ReadSeeker 直接 ServeContent,没有就整段读进内存再 ServeContent,Range 都支持,差的是内存);免鉴权,每 IP 120 次 / 分,Redis 挂了不挡。计数:符合条件(无 Range、版本 = live、已发布)的流请求,SetNX 每人每首每小时一次,「人」= 带有效 ?token= 才按 uid、否则按 IP;Redis 报错照样加;先占去重键再 UPDATE,若已成功占到去重键而写库失败,这一小时就漏;Redis 故障 / 未配置时没有这一小时窗口,每次请求都加。它数的是请求,不是「听完一次」。没有播放历史表,「最近播放」只在手机本地(20 条)。 6. 一起听只在两处权威:「现在播到哪」(Redis 锚点:播放中外推 anchor_ms + (now − anchor_at),暂停时保留锚点不外推)和「谁是 DJ」(= 会话 owner)。播放 / 暂停 / 拖 / 跳 / 删歌单 / 批点歌 / 给麦 / 转 DJ 只有 DJ;加歌单谁都能(08-28 放开,删没放开)。房状态在 Redis(2 小时 TTL),歌单落库,房冷了能从 now_playing_pos 重建。 7. 手机端一个播放器、一个浮窗:audioplayers,单例;浮窗挂在根 Overlay,音乐区里钉在底部、别处是可拖的小圆盘,拖进垃圾桶 = 停;队列不落盘,杀 App 就没了;通话来了音乐让路。

当前已核实没做的(§4 全表,先说两条):后端搜索接口(手机端「搜索」只在 discover 那 100 首里过滤);播放历史 / 趋势(明确不做)。已核到一半、其余待核的:锁屏桥只在 iOS 初始化(安卓 app/android 没读);App 里没有音乐深链分支(hoopcomm.com/s/{id} 落地页会不会跳没核)。

出了问题先查哪:§5 —— 「发不了」先看 409 的码(邀请 / 分成 / 锁);「听不到」分 302 签名、代理、缓存三层;「一起听不同步」看锚点和 server_ts;「浮窗不见了」看五个隐藏条件。


1. 两段流程的一生

1.1 一首歌:从工作室到被人听到

你看到的:工作室里填一段风格描述(可选歌词),点生成 → 进度条走到 95% 停着(那是估的)→ 歌出现在「我的歌」;或者上传一个 mp3;或者只存个草稿先邀合作者。点发布 → AI 歌变成已发布(出不出现在发现页看它排不排进前 100,榜缓存 60 秒);上传的歌显示「审核中」;专辑直接发布时,符合条件的草稿曲目会一并上线;送审会锁定音频,但不会把尚未发布的曲目推上线,已经发布的录音仍按自身状态可见。听众在发现页点开,播放,打赏,分享进聊天。

背后发生的: 1. 创作:POST /v1/music/generate(JSON ≤ 8 MiB,prompt ≤ 4000、歌词 ≤ 6000):先扣配额(Redis amb:{uid}:{yyyymmdd} 原子 INCR,普通 10 首 / 管理员 100)→ 再查老歌归属与定版(锁定的歌 409 locked,配额已扣)→ 调 Lyria(报错尝试 DECR 退一次,不检查结果)→ 先 PutObject 音频到 music/{songID}/v{n}/audio → 再开事务:仅新歌插入 songs,生成版本记为 max(version)+1,并更新 live_version;老歌沿用原有 songs 行;上传 POST /v1/music/upload(multipart,≤ 30 MB,扩展名或魔数不对 400 bad_type、读不出 / 太小 bad_file,own_rights=true,累犯 ≥ 3 次 403 upload_banned):先独立 INSERT songs(id 冲突重试 3 次)→ PutObject → 开事务登记 v1;草稿 POST /v1/music/drafts(v0,不扣配额)。时长在 Go 里解析(不装 ffprobe),没有波形。 2. 合作与分成(可选):邀请 ≤ 10 人(含创作者),pending / accepted / declined;分成 share_bp 0..10000,双方都要按同意(split_agreed_bp 为 NULL = 没答,不等于同意 0%);分成在双方都同意那一刻tryLockIfAllAgreed 锁死(split_locked_at),可能早于发布。李敏 08-17:歌上线后不能再调合作者。 3. 发布(单首):先过分成闸;AI 歌 → published(不定版),同时异步:自动补封面(每日 20 张,失败静默,所以单首发布后可能没图)、歌词没时间戳才去对齐 LRC;上传歌 → review,管理员批 = published(也不定版)、驳回 = 回 draft + review_note发布(整张)见 §0 第 2 条,publishDecision 是纯函数:0 首 / 有无音频的 / 没封面 → 拒;有上传歌 → 整张 review;否则 published。封面是整张发布的前提(Leong 08-18),单首没有这道闸;AI 封面 3 个候选 30 分钟内挑一张,和发行物封面共用每日 20 张的钱包。 4. 定版:只在发行物发布 / 送审的事务里 lockTrackslocked_version(幂等),单首发布不锁;锁上之后 generate / rollback 都 409,唯一出口 POST …/fork 分支成新歌(李敏 08-23 统一叫法)。播放数、打赏都记在录音上,跟着录音走。 5. 被听到:发现页 discover = songs.status='published'(只看歌自己,不看所属发行物发没发)、按 (plays + SUM(打赏绿能量)/10) DESC, published_at DESC 取 100,排序缓存 60 秒(只缓存全局 id 列表,每人的 liked / faved / following 按主键再查;缓存 miss 必须回落到查库,不能当「没歌」);stream 见 §0 第 5 条;打赏 10–100000 绿能量,走 wallet 记账,合作者按分成入 dev_ledger(line='music');分享进聊天 = song_share 卡(快照,带 ver 所以缓存键还对)。

哪一步会断、留下什么:第 1 步锁定的歌 = 409 且配额已扣;新建歌:Lyria 失败 = 尝试退配额(不保证)、库里没歌;Lyria 成功但库事务失败 = 存储里有孤儿音频对象、库里没歌。老歌迭代(generatesong_id):失败 = 原有歌行和旧版本都还在;成功 = 不 INSERT songs,只登记 max(version)+1 并把指针挪过去;上传扩展名 / 魔数不对 = 400 bad_type,没落库;上传存储失败 = songs 已有一行、没音频、没版本;第 2 步邀请没答 = 发不了(409);第 3 步 AI 封面 / LRC 失败 = 歌照样发布,只是没封面 / 没同步歌词;上传歌驳回 = 回草稿带 review_note;发行物驳回 = 回草稿,理由只进日志不进库(创作者看不到);第 4 步整张发布 / 送审锁上 = 不可逆;管理员批发行物 = 发行物 published、里面还是 draft / review 的曲目没跟着上(缺口,§4);第 5 步排序缓存 Redis 挂 = 回落查库,变慢不变空。

出处:生成 [code@6a1ef741 backend/internal/domain/music/handler.go:32-35,340,374-455,463-530,1362-1367](配额先扣 :374-419,DECR 不检查 :434-454,先写对象再开事务 :463-530);上传 handler.go:290-308,1847-1893,1907-1950(先 INSERT 再 PutObject 再事务);草稿 [code@6a1ef741 backend/internal/domain/music/draft.go:59-70,88];时长 [code@6a1ef741 backend/internal/domain/music/duration.go:1];合作 [code@6a1ef741 backend/internal/domain/music/collaborators.go:66,85-92],分成 [code@6a1ef741 backend/internal/domain/music/split.go:45,152,294](表 000227 / 000228 / 000248);单首发布 handler.go:1022-1136(分成闸 :1029-1032,isUploadedLive :1071,无 lockTracks),分成锁 split.go:237-284,318-320,审核 [code@6a1ef741 backend/internal/domain/music/moderation.go:28-35,143-181,193],发行物 [code@6a1ef741 backend/internal/domain/music/release_api.go:57-82,307-350,387-456](锁曲目 :456),管理员批发行物不推曲目 [code@6a1ef741 backend/internal/domain/music/release_admin.go:304-335],驳回不落理由 [code@6a1ef741 backend/internal/domain/music/release_admin.go:337-360],封面 [code@6a1ef741 backend/internal/domain/music/cover.go:25,44-52,70-71,88-121]、自动封面 [code@6a1ef741 backend/internal/domain/music/song_cover_auto.go:54,134]、候选 [code@6a1ef741 backend/internal/domain/music/cover_candidates.go:95-111],LRC handler.go:1103-1136;定版 [code@6a1ef741 backend/internal/domain/music/release.go:214-231],分支 handler.go:1187;流水 [code@6a1ef741 backend/internal/domain/music/review_history.go:56-75](失败只记日志)、守卫 [code@6a1ef741 backend/internal/domain/music/review_history_test.go:25];发现页 handler.go:1956-1991,排序缓存 [code@6a1ef741 backend/internal/domain/music/rank_cache.go:5-13,53-77];打赏 handler.go:2003-2017,2059-2082;分享卡 handler.go:1707,1750-1759;手机工作室 [code@6a1ef741 app/lib/screens/music_studio_screen.dart:227-292,479,713-746,781-814,936-1059,4064-4090] [decision@2026-07-12,Jeff:立项,AI 原创绕开版权] [decision@2026-08-11,Jeff:档位按曲目数自动定,别让创作者选] [decision@2026-08-12,李敏:三层模型、定版、删除改下架、累犯只数管理员那三类] [decision@2026-08-17,李敏:歌上线后不能再调合作者;分成要双方同意] [decision@2026-08-18,Leong Sen Fong:没封面不能发,上限 1024²] [decision@2026-08-22,李敏:推翻「只有发行物有封面」,每首歌都要有封面,平台补] [decision@2026-08-23,李敏:下架是隐藏、删除是真删;分支成新歌统一叫法]

1.2 一起听:建房、进房、跟着 DJ 听

你看到的:主页菜单「音乐房」,起个名、拉几个人、可选先塞几首歌 → 进的是一个聊天室,底下多一条音乐条;DJ 按播放,所有人手机尽量同步播同一首(靠偏差纠正,不是实测保证);谁都能往歌单加歌,普通人想插队要「点歌」等 DJ 批;想说话先举手,DJ 给麦才能上「ON AIR」,上麦时音乐自动压低。

背后发生的: 1. 建房:POST /v1/music-rooms → 一个 conversations 行(type='music'),建房者 = owner = DJ,可选种子歌同一事务写进 music_room_playlist,发一条 music_room_created 系统消息。房 id = 会话 id,没有单独的 join:是会话成员就能进。 2. 进房:App 发 music.enter,后端回 music.state(带 server_tsplaylist_len)—— 歌单不在 state 里,App 要另拉 music.playlist.get;umroom:{uid} 记你在哪间房;心跳 music.ping 每 5 秒,mseen 12 秒过期(容忍丢两次)。房上限 32 人。 3. 同步:DJ 的 play / pause / seek / skip / jump 改 Redis mroom:{id}(playing, anchor_ms, anchor_at) 一次 HSET;权威位置 = 播放中 anchor_ms + (now − anchor_at),暂停时锚点保留、不外推;App 用 music.pongserver_ts 估时钟偏移(EWMA α 0.2,rtt 0..10s 才信),每秒对表,偏 > 300 ms 且离上次纠正 > 3 秒才拉;iOS 打断后「房说在播、本地没播」自愈重载。歌播完 App 报 music.ended(每首一次;服务端只核 song_id 是当前这首 + 3 秒去重,不校验真到了末尾,任一成员的报告都算),服务端切下一首 —— 自动接歌靠客户端的结束报告。只有已发布且有 live 版的歌能进歌单。 4. 权限:DJ = OwnerOf(convID);加歌单谁都可以(非 DJ 5 次 / 分),删只有 DJ;点歌 music.request 5 次 / 分进 ZSET,DJ request_resolve;举手 5 次 / 分,DJ grant_mic;麦走 LiveKit 房 music-{convId},发布权在 token 里,所以被给麦要重连一次;music.dj_talking 时手机把音乐压到 0.15。 5. 离开:music.leave → Lua 原子把 members[0] 提成 Redis host、空房删键;但客户端看到的 DJ 仍是会话 owner,Redis 的 host 提升不改它,真正换 DJ 只有 music.dj_transferTransferOwner。手机端 leaveRoom() 只在聊天页 dispose 和浮窗垃圾桶两处调;关掉全屏歌词页不算离开,音乐继续。

哪一步会断、留下什么:第 1 步不是成员 = music.err forbidden;第 2 步 WS 断了 = _reconnectedmusic.sync 全量重拉,playlist_len > 0 而本地空也自动重拉;第 3 步 Redis 房键 2 小时没人碰 = 过期,再进用 conversations.now_playing_pos 重建,当前歌不可用就起一间待命房;第 4 步没给麦就开麦 = LiveKit token 没发布权,发不出声;第 5 步 DJ 走了没转让 = 没人能发 DJ 控制动作;DJ 离开不等于必然停止 —— 仍有听众上报结束且后续切歌成功时,可以继续自动接歌。

出处:建房 [code@6a1ef741 backend/internal/domain/conversation/handler.go:199,459-480][code@6a1ef741 backend/internal/domain/conversation/repository.go:149-199];房 id = 会话 id [code@6a1ef741 backend/internal/domain/music/roomws.go:3-8],DJ :80-97,WS 全表 :349-754(music.ended 任一成员 :447-460),只收已发布 :127-129,冷启动重建 :265-285,歌单落库 :771-793;Redis 键与锚点 [code@6a1ef741 backend/internal/domain/music/room.go:18-34,77-88,190-211,232-243];限流 roomws.go:398,425,442,456,487,551,673;手机端 [code@6a1ef741 app/lib/services/music_room_service.dart:19-35,71-75,135-202,232-251,369-376,408-497,551-626,639-651],压低 [code@6a1ef741 app/lib/services/music_player_service.dart:241-248,1368-1375],建房页 [code@6a1ef741 app/lib/screens/new_music_room_screen.dart:94-119],关全屏不离开 [code@6a1ef741 app/lib/screens/music_room_screen.dart:22,69-76];守卫 music/room_test.goroomws_playlist_add_test.goapp/test/music_room_bar_0828_test.dart [decision@2026-07-15,Jeff:同步「状态」不同步音频流;音乐房是第五种房型] [decision@2026-09-05,工程:麦的闸三态 放行 / 无权 / 答不上来]


2. 用户能看到的能力(怎么用 / 限制 / 入口)

能力 怎么用 / 限制 后端入口 App 落点
发现页 榜 = 播放 + 打赏 / 10,取 100;分类来自 music_tags(不写死);「最近播放」是本机 20 条 GET /v1/music/discover/browse/browse/{slug} music_screenmusic_browse_screen
搜索 只在 discover 那页里过滤,没有后端搜索;热榜 = charts(30 首,免鉴权) GET /v1/music/charts music_search_screen
我的库 ❤️ 赞(song_likes)和 ⭐ 收藏无关;收藏 = 系统歌单 pl-fav-{uid};整份歌单 / 发行物 / 电台可存进库 GET /v1/music/likes/favs/playlists*/library/saves* music_library_screenliked_songs_screen
歌单 名 ≤ 60、描述 ≤ 300、≤ 500 首、每人 ≤ 200 个;只能加已发布的歌;封面自动拼前 4 首(读时算,不存);私密歌单不能分享 /v1/music/playlists*…/share-chat playlist_detail_screen
播放器 队列 ≤ 400,播完自动续(shuffle 取 discover);循环 all / one / off;睡眠定时;锁屏控制 iOS;缓存 40 首 LRU(只缓存带 ver 的) GET /v1/music/songs/{id}/stream mini_player_barnow_playing_screenimmersive_music_screen
工作室 生成(每日 10 首)/ 上传(≤ 30 MB,必须勾版权)/ 草稿;改名、改标签、换封面(上传或 AI 三选一)、分支、下架、真删 /v1/music/generate/upload/drafts/songs/{id}/* music_studio_screen
发行物 单曲 1 / EP ≤ 6 / 专辑 ≤ 50,档位自动;没封面不能发;发布后不能删曲目、只能下架整张 /v1/music/releases* music_albums_screen
合作与分成 ≤ 10 人;邀请卡进两人私聊;分成双方同意;上线后锁 …/collaborators*…/split* 工作室内
收入 四行:总 / 平台 50% / 合作者 / 净;提现不在这儿,按钮跳工作室钱包页 GET /v1/music/revenue music_revenue_screen
打赏 10–100000 绿能量;余额不够跳充值(wallet) POST …/tip 发现页 / 播放页
分享 song_share 卡(点了原地播)、playlist_share 卡(点了开歌单);对外链接 hoopcomm.com/s/{id}(App 没有入站分支;落地页待核) …/share-chat share_song_sheetsong_picker_sheet
一起听 见 §1.2;一房 32 人 POST /v1/music-rooms + WS music.* new_music_room_screenmusic_room_bar
审核(管理员) 队列 / 批 / 驳 / 下架三类;版权页 /copyright /v1/music/admin/*

出处:路由注册 [code@6a1ef741 backend/internal/server/router.go:196-219];分类 [code@6a1ef741 backend/internal/domain/music/browse.go:14-17,37-40,71-107];赞 / 收藏无关 handler.go:236-239[code@6a1ef741 backend/internal/domain/music/favourites.go:22-39,83-86];歌单上限 [code@6a1ef741 backend/internal/domain/music/user_playlist.go:39-43,466,509,547];库 [code@6a1ef741 backend/internal/domain/music/library_saves.go:19-22](表 000342);发行物上限 release.go:20-22;收入 [code@6a1ef741 app/lib/screens/music_revenue_screen.dart:36-38,355];手机搜索只过滤 [code@6a1ef741 app/lib/screens/music_search_screen.dart:17-23,560-567];对外链接 [code@6a1ef741 app/lib/api/song_share.dart:8-14],无入站分支 [code@6a1ef741 app/lib/services/deep_links.dart:111]


3. 内部怎么运作

3.1 三层模型与表

用户看到的:工作室里「我的歌」和「专辑」是两个页;一首歌能同时在一张单曲和一张合辑里;专辑徽章只写档位不写几首。

怎么做的:songs(录音)持有 live_versionlocked_versionstatus(draft | review | published | removed,000266 才加 CHECK)、takedown_reason(注释枚举,Go 侧 adminTakedownReasons 三个 + creator)、playstags;song_deploys 每版一行(bytes / mime / lyrics / lyrics_lrc / prompt),「上传」= prompt='(uploaded)',没有布尔列;releases(kind 第一次发布才快照,status 没有 CHECK)+ release_tracks(PK (release_id, song_id),removed_at 留位);合作 song_collaborators;流水 song_review_events;歌单 music_playlists / music_playlist_songs(PK 就是「不重复」),收藏是 system_kind='favourites' 的系统歌单,song_favs 自 000264 起是视图、不可写;标签两套故意不同步:songs.genre / tags 只展示,song_tags 才驱动分类;albums 两张表 000221 已删,Go 零引用;music_library_saves 一列指三张表、无外键;posts.music_track_id 故意无外键(挂上时验一次,之后歌的命运不影响帖);user_profiles.profile_song_id 有外键 SET NULL。所有权判在 SQL 的 WHERE 里,不是先读再写。

出处:表 [code@6a1ef741 backend/migrations/000145_music_songs.up.sql:4-31][code@6a1ef741 backend/migrations/000216_releases.up.sql:15-76]、000226 / 000227 / 000255 / 000256 / 000264(视图 :78-88)/ 000266 / 000277 / 000323 / 000342;上传标记 handler.go:1071-1078;两套标签 [code@6a1ef741 backend/migrations/000256_music_tags.up.sql:8-11]browse.go:266-273;所有权在 WHERE collaborators.go:85-92[code@6a1ef741 backend/internal/domain/music/lyrics_edit.go:18-19]handler.go:942-946;albums 已删 000221;守卫 favourites_test.golibrary_saves_kind_guard_test.gorelease_test.go

3.2 发布、审核、定版、下架

见 §1.1。补四条:下架幂等 —— 创作者第二次调返回 200 且不覆盖管理员写的理由;发行物下架只改自己 status、不碰曲目(碰了 = 多算十次累犯);删发行物只许没发布过的(判 published_at IS NULL,不判当前状态);真删 purge 的三道闸是 has_tips / in_release / admin_takedown。管理员批发行物(adminApproveRelease)只改发行物状态 + 通知,不调 publishTracks。关注者通知:第一次发布 EP / 专辑才发 KindNewRelease,每创作者 24 小时一次,单曲只进 feed。

出处:handler.go:1092-1094,1309-1345;release_admin.go:236-262,272-291;[code@6a1ef741 backend/internal/domain/music/purge.go:37-39,57-68,79];通知 [code@6a1ef741 backend/internal/domain/music/release_notify.go:27,90,111];守卫 takedown_test.gorelease_notify_test.go

3.3 播放、计数、缓存

怎么做的:stream 免鉴权(?token= 是草稿试听的 JWT,?v= 选版本);已发布 + 签名器 → 302 到 music/{id}/v{n}/audio 的签名 URL(不设缓存头);否则后端代理,响应 Cache-Control: immutable 一年,ReadSeeker 直接 ServeContent、否则读进内存再 ServeContent,Range 都支持;Redis 挂了限流放行不挡播放。计数见 §0 第 5 条。手机端:audioplayers 单例,三个访问器(I / maybeI / existing,因为 I 会造 MethodChannel,31 个无关测试曾因此红);音频会话只在真播放时才占(不是进音乐页就占),普通模式独占 playback,一起听开麦切 playAndRecord + mixWithOthers;文件缓存 music_audio/{id}_v{ver}.bin,先流后存,60 秒超时、< 1 KB 拒、.part 再 rename,LRU 40 首;ver 的行永远不缓存;播放失败 400 ms 后重试一次(强制走网络并删缓存),再失败 toast。锁屏走 MPNowPlayingInfoCenter(iOS,900 ms 节流,一个静态 payloadFor 出载荷);桥在非 iOS 直接返回,安卓有没有别的实现 app/android 没读。

出处:handler.go:1369-1396,1398-1455,1491-1505(302 :1433-1435 无缓存头;代理头 :1451;计数 :1383-1396);[code@6a1ef741 app/lib/services/music_player_service.dart:38-68,112-235,277-312,821-840,988];[code@6a1ef741 app/lib/services/now_playing_bridge.dart:11-49,65-105];[code@6a1ef741 app/ios/Runner/NowPlaying.swift:35-142];守卫 app/test/now_playing_cover_test.dartmusic_yields_to_call_test.dart [decision@2026-07-13,Jeff:播了音乐离开变小窗,去哪都一直播] [decision@2026-08-21,Jeff:通话压过音乐]

3.4 队列、浮窗、两个播放面

用户看到的:歌单页点一首 → 整页队列;发现页滑歌 → 沉浸面;浮窗在音乐区是底部一条,出了音乐区变成能拖的小唱片,拖到垃圾桶就停;两个播放面互相切换不叠层。

怎么做的:队列在服务上(queue + index),每次换队列发一张「听歌票」,别人的票清不掉你的听;contextKind ∈ {list, discover} 记在服务上(浮窗要知道打开哪张脸);shuffle 只重排没播的尾巴;手动加歌插在「手动队」之后;自动续队列 09-08 从沉浸屏挪进服务(fetchMoreSongs 抛错时不当「库空」,401 不会误判);上限 400、回收批 20。浮窗五个隐藏条件:没歌、全屏、房模式、在沉浸 feed、hideMiniBar;MusicSectionScope 是唯一判「用户在不在看音乐面」的地方(7 个屏用它;没包的屏浮的是圆盘)。拖拽:横向按圆心过没过屏幕中线贴边(临界阻尼弹簧 420),纵向惯性;垃圾桶只在拖动中出现,磁吸 96 px 圆心距,进磁场震一次。展开 / 收起同一个 widget 裁剪,唱片不跳;展开路由用浮窗每帧发布的锚点矩形,子树用 GlobalKey 保住不重建。两张脸:cameFromBrowse / cameFromPlayer 让交叉跳转变成 pop。

出处:music_player_service.dart:67-105,325-468,502-612,619-789,873-901;[code@6a1ef741 app/lib/widgets/mini_player_bar.dart:44-81,112-175,222-232,266-332,406-471,526-618];[code@6a1ef741 app/lib/widgets/expand_player_route.dart:37-74,108-142];[code@6a1ef741 app/lib/widgets/music_section_scope.dart:35-105];两张脸 [code@6a1ef741 app/lib/screens/now_playing_screen.dart:25-92];守卫 app/test/mini_player_vinyl_0828_test.dartmini_player_bin_0907_test.dartup_next_autofill_0908_test.dartplayer_expand_transition_test.dartmusic_player_visibility_test.dart [decision@2026-08-28,Mike Ng:浮窗做成 AssistiveTouch 式转唱片] [decision@2026-09-07,Mike Ng:拖进垃圾桶 = 停] [decision@2026-09-02/03,QC:排队的歌优先当下一首]

3.5 钱、身份、通知:音乐只摸到口

怎么做的:创作者 = developers.status='approved'(requireCreator,401 / 403 not_creator),音乐不自己管身份;打赏在一个事务里 wallet.Record 扣绿能量 + 按 splitShares 给创作者和合作者入 dev_ledger(line='music',game 列放歌 id);收入页只算「看得见」,提现 POST /v1/dev/payout 在 studio 那边;通知走 notification.KindNewRelease / KindCollabInvite;四种卡(song_share / playlist_share / collab_invite / music_invite)都在消息类型 CHECK 里(000305,「分享进聊天静默 403」的根因就是漏了这张名单);music_invite 已死:SendMusicInvite 无调用方,注释说的 music.create / music.invite 两个 WS 事件不存在。没有 warmth / presence 钩子。

出处:handler.go:310-324,2059-2082;split.go:45;[code@6a1ef741 backend/internal/domain/message/service.go:1642-1670,2208-2209];[code@6a1ef741 backend/migrations/000305_music_card_message_types.up.sql:53-57];router.go:258,705-764 [decision@2026-08-26,Leong Sen Fong:分成规则收进 revshare,「千万不要在不同地方走不同的规则」] [decision@2026-08-20,李敏:按现有 commission 框架显示,别做新的东西;提现别做先]


4. 已知缺口与待核事项(逐行看状态)

状态 出处
后端搜索 GET /v1/music/search 未做;手机搜索只过滤 discover 那 100 首;周榜、艺人 / 歌单结果也没有 music_search_screen.dart:560-567
播放历史 / 趋势 / 听众画像 明确不做(没有播放事件表) PRD §13.5 / §14.3
安卓锁屏 / 通知栏控制 桥只在 iOS 初始化;app/android 未核 now_playing_bridge.dart:24
入站深链 hoopcomm.com/s/{id} App 没有音乐分支;落地页会不会跳未核 deep_links.dart:111
发行物驳回理由 只进日志,不进库;创作者看不到 release_admin.go:344-359
releases.status CHECK 没有(songs 000266 有) 000216
一首已发布的歌加进第二张发行物 源码层面能:addTrack 只校双方归属和档位上限,不拦 published,主键去重;手机入口 / 实测待核 release_api.go:307-350
管理员批发行物不推曲目 发行物 published 后,送审时还是 draft / review 的曲目留在原状态 release_admin.go:304-335
单首发布不定版 只有整张发布 / 送审 lockTracks handler.go:1023-1068release_api.go:456
状态流水不保证落库 recordReview 失败只记日志;单首 UPDATE 与流水不同事务 review_history.go:60-75
创作失败残留 生成:孤儿音频对象、配额可能没退;上传:没音频的歌行 handler.go:434-454,463-530,1907-1950
音乐房 20 种 WS 消息的测试 19 种没测(并发、转 DJ、锚点外推) PRD §14.1.3
手机端一起听的时钟 / 对表算法 没有测试 music_room_service.dart:31-35
音频文件缓存、重试 没有测试 music_player_service.dart:112-235
收入两种算法 revenue.go 仍按 EXISTS … songs 算,不按 line='music';两边可能给不同数 wallet PRD 517-525
工作室新手引导(七个卡点) 一行没动;其中「30 秒片段做默认」和 Jeff v0.230「整首默认」相反 MUSIC_STUDIO_ONBOARDING §6
Lyria 商用 / 分发条款核实 立项时列的,没人关 MUSIC_PLATFORM §8.1
MUSIC_SONG_DETAIL.md 要不要删 等李敏点头 PRD §17.3.1
idx_song_favs_user 孤儿索引 删要 Jeff / Leong 点头 PRD §15 #6
音乐房点歌队列 mreq 长度上限 只有 5 / 分限流,没见队列上限 roomws.go:551
两个「万 / K」缩写函数四舍五入不同 compactCount vs formatListens music_ui_api.dart:92music_search_screen.dart:107

5. 出问题先查哪里 · 动它之前

排障一条路: 1. 发不了:409 invite_pending = 有合作邀请没答;split_unagreed / split_required = 分成没双方同意(单首才有这道闸);locked = 已被发行物锁定,只能分支;400 cannot_publish(发行物)= 0 首 / 有无音频的 / 没封面。 2. 生成没反应:进度条是估的,到 95% 停是正常;超时是「第三态」不是失败,App 会轮询,歌落地会自动对账;真失败看 429 quota(每日 10)/ 503 music_off。 3. 听不到:先看歌 status 是不是 published、live_version > 0;再看 302 有没有(签名器)/ 代理路;手机上「二播秒开」只对带 ver 的行成立;播放失败会自动重试一次,再失败才 toast。 4. 一起听不同步:看 music.stateanchor_ms / anchor_at / server_ts;手机偏移用 pong 估,rtt ≥ 10 s 的样本不信;偏 < 300 ms 不纠;歌播完没切 = music.ended 没到(3 秒去重);进房没歌单 = music.playlist.get 没拉。 5. 浮窗不见了:五个隐藏条件(§3.4);在音乐区是钉底的条不是圆盘;uiInMusicPage 时主页自己画。 6. 分享进聊天 403:消息类型不在 CHECK 名单(000305 那次);私密歌单服务端拒。 7. 收入对不上:revenue.go 和 wallet 的算法不是同一条 SQL(§4)。 8. 有歌行没音频 / 有音频没歌行:上传存储失败留前者,生成库事务失败留后者(§1.1 断点);发行物已发布但曲目还是草稿 = 管理员批发行物那条不推曲目(§4)。

改它之前: - 动状态:任何写 songs.status 的地方必须走 statusBefore + recordReview,源码守卫会数;但它不保证落库,要保证就放进同一事务;发行物下架别碰曲目。 - 动发布事务:顺序是快照档位 → 锁曲目(发布和送审都锁)→ 推曲目(只在判为 published 时,送审不推)→ 改状态,everPublished 在 UPDATE 之前读;单首发布不锁、不查封面,别顺手加。 - 动封面:上传和 AI 共用一把尺子 coverImageProblem;键带版本,旧对象不删;imagegen.SquareSize 有编译期断言钉在 500..1024 里。 - 动排序缓存:只缓存全局 id,每人的字段按主键再查;miss 必须回落查库。 - 动一起听:锚点只在 DJ 动作时一次 HSET 改;server_ts 每条 state / pong 都带;DJ 是会话 owner,Redis host 不是。 - 动播放器:测试里别碰 MusicPlayerService.I(用 maybeI / existing);队列操作永远不动 player;音频会话只在真播放时占。 - 加消息类型:先进 messages_type_check(000305 的教训),再写发送代码。 - 别信的旧图纸:MUSIC_PLATFORM 的表 / 端点 / 分期全过时;MUSIC_RELEASE 的「AI 封面不做」「不做物理删除」「三张空表不 DROP」全被推翻;MUSIC_ROOMS 的「零迁移」「13 种消息」「LiveKit 留 P3」全过时;PRD §1.4「点 My Music 全屏推」已作废(改成切 body)。


6. 不能碰的数(当前值 / 实现出处 / 守卫或未覆盖)

🔒 = Jeff / 李敏 / Leong 明确锁定的;其余是普通配置值,改之前把守卫一起改。

当前值 实现出处(6a1ef741) 守卫 / 未覆盖
档位上限 🔒 单曲 1 / EP 6 / 专辑 50 release.go:20-22 release_test.gocover_test.go
平台分成 🔒 50%(单一出处在 wallet) revshare.MusicCreatorPct(split.go:45 引) wallet 那边
合作者上限 10(含创作者) collaborators.go:66 collaborators_test.go
每日生成 10 / 管理员 100 handler.go:32-33 未覆盖
上传上限 30 MB;.mp3/.wav/.m4a handler.go:34,1873-1885 upload_test.go(魔数)
留几版 5 handler.go:35 未覆盖
封面 🔒 正方形,边 500..1024,≤ 5 MB cover.go:25,44,52 cover_size_test.go;编译期断言 cover.go:70-71
AI 封面 / 自动封面 各 20 / 日;候选 3 张 30 分钟 release_admin.go:361song_cover_auto.go:54cover_candidates.go:95-101 各自 test
流限流 / 计数 120 / 分 / IP;1 小时一次 handler.go:1378,1389 未覆盖
播放 + 打赏 / 10;discover 100、charts 30;缓存 60 s handler.go:1988-1991rank_cache.go:53 rank_cache_test.go
歌单 名 60 / 描述 300 / 500 首 / 200 个 user_playlist.go:39-43 部分
标签 每首 5;名 16 字;8 个 browse.go:39tags.go:10-11 browse_test.gotags_test.go
累犯 🔒 ≥ 3 行 removed 且理由 ≠ creator(含空理由)禁上传 handler.go:1092-1094,1865 takedown_test.go
打赏 10–100000 handler.go:2017 未覆盖
关注通知 24 h / 创作者 release_notify.go:27 release_notify_test.go
TTL 2h / 心跳 12s / 32 人 / 扫锁 3s room.go:18-21 部分
房限流 控制 5 / 5s;加歌、点歌、举手 5 / 分 roomws.go:398,487,551,673 加歌有
手机对表 300 ms / 3 s / 5 s / 1 s / α 0.2 music_room_service.dart:31-35 未覆盖
队列 400;回收批 20 music_player_service.dart:640-644 up_next_autofill_0908_test.dart
音频缓存 40 首;60 s;≥ 1 KB;重试 400 ms music_player_service.dart:117,156-157,219 未覆盖
压低 0.15 music_player_service.dart:1372 未覆盖
浮窗几何 🔒 358×100;圆盘 54;收起 74×78;垃圾桶 64 / 56 / 96 / 0.9;弹簧 420 mini_player_bar.dart:112-160 mini_player_*_test.dart + goldens
展开路由 360 / 300 ms expand_player_route.dart:39-40 player_expand_transition_test.dart
最近播放 20 条 music_recents.dart:52 music_recents_0827_test.dart
锁屏节流 900 ms now_playing_bridge.dart:67 未覆盖

7. 出处与旧图纸去向

  • 代码(全部 [code@6a1ef741]):backend/internal/domain/music/(38 个 .go,19 个 test)、backend/internal/domain/conversation/{handler,repository}.go(建房)、backend/internal/domain/message/service.go(四种卡)、backend/internal/domain/post/repository.go(借封面表达式)、backend/internal/server/router.go:196-219,258,664-764;App lib/services/{music_player_service,music_room_service,now_playing_bridge}.dartlib/widgets/{mini_player_bar,expand_player_route,music_section_scope,track_swipe,music_share_card,playlist_share_card,music_room_bar,share_song_sheet,song_picker_sheet,song_locked_sheet,…}.dartlib/screens/{music_screen,music_browse_screen,music_search_screen,music_library_screen,music_recents_screen,liked_songs_screen,music_albums_screen,music_studio_screen,music_revenue_screen,now_playing_screen,immersive_music_screen,new_music_room_screen,music_room_screen,playlist_detail_screen,song_posts_screen}.dartlib/api/{music_ui_api,music_caps,song_share,playlist_share,library_saves}.dartlib/util/music_recents.dartios/Runner/NowPlaying.swift
  • 迁移:000145 songs / deploys / tips、000148 社交、000151、000161 LRC、000162 音乐房、000169 审核、000210 → 000221 albums 建又删、000215 下架、000216 三层、000226 流水、000227 / 000228 / 000229 / 000248 合作与分成、000245 歌词编辑、000246 tags、000249 → 000252 → 000282 封面列三改、000255 歌单、000256 标签、000259 合辑、000261 / 000262 关注并入 follows、000264 收藏成歌单 + 视图、000266 status CHECK、000267 房歌单外键、000272 删 emoji、000277 帖挂歌、000305 四种卡、000307 dev_ledger.line(wallet)、000323 主页歌、000342 库。本模组最新 000342;仓库最新 000364。
  • Redis:配额 amb:*、流限流 music:rl:*、播放去重 music:play:*、榜缓存 music:rank:*、房 mroom: umroom: mseen: msweep: mhand: mspk: mreq: mreqby: mrate:无 Meilisearch
  • 生产:本文没有在生产机核任何项(歌 / 发行物行数、签名器开没开、LiveKit 音乐房、/s/{id} 落地页)→ [prod@ 待核];PRD 自己的「事实边界」也说行数未核实、iOS 没编、屏幕没看。
  • 决定(只列本文引到的):07-12 Jeff 立项、身份复用 developers、5/5;07-13 Jeff 作曲页是表单不是聊天、全局小窗;07-15 Jeff 同步状态不同步流、第五种房型;07-18 Jeff 整首默认;08-11 Jeff 档位自动定;08-12 李敏 三层模型全套;08-13 李敏 上生产不碰数据、409 要带按钮;08-14 李敏 歌曲详情分页;08-17 李敏 上线后不调合作者、分成双方同意;08-18 Leong 封面规则(上下午两版);08-19 Leong 关注只有一处;08-20 李敏 按现有框架显示收入、提现别做、赞与收藏无关;08-21 李敏 删 emoji、要能存草稿、邀请要有卡;08-21 Jeff 通话压音乐;08-22 李敏 每首歌都要封面;08-23 李敏 下架是隐藏删除是真删、分支统一叫法、不可逆操作要说后果;08-26 Leong 分成规则一处;08-27 Leong 一份 PRD 更新到位;08-28 Leong dev_ledger.line、Mike 浮窗转唱片;08-29 李敏 音乐页照游戏页排;09-02/03 QC 排队优先;09-04 QC 「+」存整份;09-05 麦的闸三态;09-07 Mike 拖进垃圾桶;09-08 Tora4 队列自动续。
  • 未核清单:① 生产未核(见上);② 安卓有没有锁屏 / 通知栏(app/android 没读);③ /s/{id} 落地页会不会跳进 App;④ 已发布的歌进第二张发行物:源码能加,手机入口 / 实测待核;⑤ SendMusicInvite 为何留着(没查 git 史);⑥ 点歌队列有没有长度上限;⑦ PRD 里 84 vs 93 条端点、000301 vs 000355 最大号,以哪个为准没数;⑧ 302 分支的 CDN 缓存策略(签名器 / 源站)未核;⑨ Lyria 失败那次 DECR 是否真退了配额(错误未检查)。
  • 两处 PRD 与代码冲突,本文按代码:PRD §1.4 / §3.2「点 My Music 全屏推 MusicStudioScreen」→ 已改成切 body(developer_center_screen.dart:1978 embedded: true);PRD §7.6 / §15 #4「建议给 dev_ledger 加来源列」→ 000307 已加 line,但 revenue.go 还没用它(§4)。
  • 旧图纸去向:MUSIC_PLATFORM.md 整份替代(只留立项理由和「Lyria 条款未核」「别模仿真人歌手」两条进 §4);MUSIC_RELEASE.md 整份替代(三层 / 档位 / 定版 / 累犯的判据并入 §0 §1 §3,被推翻的三条标在 §5);MUSIC_ROOMS.md 整份替代(锚点纪律与对表算法并入 §1.2 §3,「零迁移」「13 种消息」「P3 LiveKit」作废);MUSIC_STUDIO_ONBOARDING.md 整份替代(七个卡点作为欠账进 §4,行号全漂、MUSIC_ASBUILT 指针全死);MUSIC_SONG_DETAIL.md 只是桩,删不删等李敏;hoopstudio-music-prd.md 保留为产品规格与 36 条改动史的本体,本文只指路并记两处冲突;00-目录.md 音乐段要改三处(84 条 → 实数、08-03 → 07-12、两处死链)。