| 档案 | |
|---|---|
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. 定版:只在发行物发布 / 送审的事务里 lockTracks 锁 locked_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 成功但库事务失败 = 存储里有孤儿音频对象、库里没歌。老歌迭代(generate 带 song_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],LRChandler.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_ts、playlist_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.pong 的 server_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_transfer → TransferOwner。手机端 leaveRoom() 只在聊天页 dispose 和浮窗垃圾桶两处调;关掉全屏歌词页不算离开,音乐继续。
哪一步会断、留下什么:第 1 步不是成员 = music.err forbidden;第 2 步 WS 断了 = _reconnected 后 music.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.go、roomws_playlist_add_test.go、app/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_screen、music_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_screen、liked_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_bar、now_playing_screen、immersive_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_sheet、song_picker_sheet |
| 一起听 | 见 §1.2;一房 32 人 | POST /v1/music-rooms + WS music.* |
new_music_room_screen、music_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_version、locked_version、status(draft | review | published | removed,000266 才加 CHECK)、takedown_reason(注释枚举,Go 侧 adminTakedownReasons 三个 + creator)、plays、tags;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;所有权在 WHEREcollaborators.go:85-92、[code@6a1ef741 backend/internal/domain/music/lyrics_edit.go:18-19]、handler.go:942-946;albums 已删 000221;守卫favourites_test.go、library_saves_kind_guard_test.go、release_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.go、release_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.dart、music_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.dart、mini_player_bin_0907_test.dart、up_next_autofill_0908_test.dart、player_expand_transition_test.dart、music_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-1068、release_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:92、music_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.state 的 anchor_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.go、cover_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:361、song_cover_auto.go:54、cover_candidates.go:95-101 |
各自 test |
| 流限流 / 计数 | 120 / 分 / IP;1 小时一次 | handler.go:1378,1389 |
未覆盖 |
| 榜 | 播放 + 打赏 / 10;discover 100、charts 30;缓存 60 s | handler.go:1988-1991、rank_cache.go:53 |
rank_cache_test.go |
| 歌单 | 名 60 / 描述 300 / 500 首 / 200 个 | user_playlist.go:39-43 |
部分 |
| 标签 | 每首 5;名 16 字;8 个 | browse.go:39、tags.go:10-11 |
browse_test.go、tags_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;Applib/services/{music_player_service,music_room_service,now_playing_bridge}.dart、lib/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,…}.dart、lib/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}.dart、lib/api/{music_ui_api,music_caps,song_share,playlist_share,library_saves}.dart、lib/util/music_recents.dart、ios/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:1978embedded: 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、两处死链)。