📚 calls · 模组知识

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

版本 04c293a4
2026-09-08 15:41
档案
module calls
summary 通话:1v1 与群通话跑在自建 LiveKit 上,后端只发票(十分钟)和信令;来电一律 VoIP 推送走 CallKit;群通话是加入制、30 秒心跳,收房分两边(手机曾有同伴且独自 3 秒;后端剩一人且接通过,或响过铃的房 60 秒对账收);录音是服务器 Egress、归档目标开录那一刻定死;留言完全在手机端。踢群 / 退群 / 登出正常路径立即移出,依赖故障可能未完成。
updated 2026-09-07
verified_ref f1d9dad6cc450671049ca6bee914297e7e62eaad
source_ref [backend/internal/domain/calls, backend/internal/domain/callkeys, backend/internal/domain/message(call. 信令 / 通话记录 / VoIP 出口), backend/internal/platform/push, app/lib/services/call_engine.dart, app/lib/services/call_session.dart, app/lib/services/group_call_session.dart, app/lib/services/voicemail_service.dart, app/lib/screens/call_screen.dart, app/lib/screens/group_call_screen.dart, app/lib/widgets/call_float_pip.dart, app/ios/Runner/AppDelegate.swift]
status current
review_after 2026-12-06
owner nova
supersedes [GROUP_CALLS.md, call_1v1.html]
partially_supersedes [API.md(通话端点与 call.* 事件,原件一个都没写), ARCHITECTURE.md(技术栈表那一行), DEPLOY.md(coturn / TURN 几节已被 LiveKit 自带 ICE + TLS 5349 兜底推翻,归 ops)]

这份怎么读:每一节先讲「它解决什么、用户看到什么」,再讲「为什么这样做、数据在哪、哪一步会断、断了留下什么、有什么保障和缺口」;代码位置、参数、行号收在每段末尾的「出处」里。 出处标记:[code@f1d9dad6 文件:行] = 那棵树上的实现;[prod@日期,谁核] = 生产机当天核过;[decision@日期,谁] = 拍板过的决定。代码证明「那一版怎么做的」,证明不了「线上是这样」和「应该是这样」。 和 chat 的边界:通话记录那行消息、通话留言那条语音消息,落库后就是普通消息,走 docs/chat.md 的路;本文只写它们怎么产生、归谁。

0. 给接手的人:三分钟读懂通话

先认五个词:LiveKit = 我们自建的音视频服务器(SFU),手机把声音画面发给它、它转给房里其他人,后端不碰媒体;入房票 = 后端签的一张十分钟有效的 JWT,手机拿它连 LiveKit,票上写着能进哪个房、能不能发;信令 = 通过聊天那条 WebSocket 传的小消息(call.offer 打给谁、call.end 挂了),不带媒体;CallKit = iOS 的系统来电界面,来电靠 VoIP 推送叫醒它,不是我们画的页;Egress = LiveKit 的录音器,把房里混好的音频写成文件。

通话分四种房:1v1(主叫手机生成房号,信令走 WS)、群 / 临机通话(服务器生成房号,REST 加入制,响铃走 VoIP)、通话链接房(一个可分享的入口)、约定通话(到点提醒)。四种共用同一套引擎、同一张票、同一个录音器。

它保证到什么程度 —— 一次呼叫有四个状态,别合成一句「接通了」: 1. 发出了呼叫:主叫过了两道门(好友 / 拉黑 / 每分钟 10 次;房能不能用),后端把 call.offer 转给对方并推 VoIP。门答不上来当没权(503),不当有权。 2. 对方收到:VoIP 推送到了对方 iOS,系统弹 CallKit;来电一律推,不看对方在不在线(退后台的 App 一秒内被挂起,WS 关闭包传不到)。推送发出去不等于设备收到。 3. 对方接听:对方取入房票那一刻算接听;后端记 answered、给他其它设备止铃(放过接听那一台)。 4. 双方真正能听见:两边都连上 LiveKit、音轨在流。手机端有看门狗盯着「麦克风在不在流」,不在就修。

群通话是加入制不是响铃制,但 ≤ 20 人会像单聊一样响(Jeff 07-10 定);每 30 秒心跳续命,谁 75 秒没心跳就被剪掉。收房有两道、分两边:手机侧曾经有过同伴、现在一个都没有、3 秒后还没有且本机没断线,才自己挂断;后端侧在场只剩一人且这房接通过,或响过铃的房 60 秒没人接(到期后对账收,不是准点),才收 —— 超过响铃上限、没响过铃的大群加入房没有 60 秒这条。

撤权即断(Jeff 09-06 定的目标):踢出群、退群、退出账号 → 正常路径下后端立即请求 LiveKit 移出、清授权、发 call.kicked,手机不重连;入房票从 1 小时改成 10 分钟。依赖故障时可能未完成:LiveKit 踢人失败只记日志、Redis 照清;读不到群房指针或用户房列表就直接返回;这条路上没见补偿重试,别处有没有待核(§3.2、§7)。撤销通话链接只挡后来的人,锁门不散会。

当前明确没做的(§4 全表,先说三条):安卓屏幕共享整颗按钮不画;手机取票失败不分「没权」和「答不上来」,一律「通话失败」不重试;群通话同一段逻辑两份(页面与引擎)的第三刀合并还没做。

出了问题先查哪:§5 一条路 —— 先问是哪种房、走的哪条腿(WS 信令 / REST / VoIP / LiveKit),再看 Redis 里 gcall:{room}call_room:{room} 两把键,录音看 call_rec:{room} 四态。


1. 两次通话的一生

1.1 打一通 1v1:发起 → 响铃 → 接听 → 真正接通 → 挂断,以及拒接 / 超时 / 断线

你看到的:点通话,页面立刻进「呼叫中」,2 秒后变「响铃中」并有回铃音;对方接了变成计时;30 秒没人接会问你要不要留言;对方占线会提示占线。对方那边是系统来电界面,接听后才进 App 的通话页。

背后发生的: 1. 主叫生成房号(UUID)并发 call.offer(带对方 id、房号、要不要视频)。为什么房号由主叫生成:1v1 没有「先建房」这一步,两人撞线同时拨时靠合并(第 5 步)(实现推断,没查到单独的决定记录)。同时上两根弦:2 秒后显示「响铃中」—— 这是本地定时器,只是界面在换字,不证明对方设备响了;30 秒没接就转留言。 2. 后端两道门:AuthorizeCall(好友关系 / 没被拉黑 / 每分钟最多 10 次)和 CanUseCallRoom(这个房号能不能用)。任一不过就不转发,回投一条 call.reject 给主叫;门本身答不上来(Redis 抖了)时带 reason: unavailable,主叫看到的是「暂时打不通」而不是「对方拒绝」。这是 Jeff 09-05 拍的「答不上来不当有权」。 3. 转发 + 推送 + 记录:补上 from_user_idcaller_name,call.offer 通过 WS 投给对方一个人;写 call_room:{room} 授权名单(4 小时);写通话记录「电话开始了」(这是 1v1 记录的唯一入口);异步发 VoIP 推送,不看对方在不在线。 4. 对方手机:VoIP 推送进来,iOS 原生层必须先 reportNewIncomingCall(苹果铁律,否则以后永久停发 VoIP),弹 CallKit。前台在线的话 WS 也会收到 call.offer,本地也弹一次 CallKit —— 两条路用房号当 id,系统自动去重。 5. 撞线:两人同时拨,后端 ClaimCallGlare 命中,只给后来的那个发 call.merge,让他并进对方的房,不再响铃。 6. 接听 = 取票:对方点接听,手机 POST /v1/calls/token 拿入房票 —— 这一下就是「我接了」:后端记 answered(记录写失败只告警,票照发,所以 call_logs 的 answered 可能缺;它记的是「取票」这个事件,也不证明媒体已接通),给他其它设备发 call.claimed 和 VoIP cancel 止铃,但必须放过接听那一台(靠它自报的 voip_token 和 device_id 认;一条线索都没有就整条止铃腿不发,宁可多响也不能把接听那台停掉)。 7. 真正接通:两边各自拿票连 LiveKit,音轨开始流。手机端引擎在 start()任何 await 之前先认领音频会话(不然 CallKit 的音频会话和我们的抢),连房前先等上一通的还原落地。 8. 挂断:任一方 hangUp()(幂等;notifyListeners 排在所有 await 之前,免得页面卡在通话中)。主叫超时 / 取消走 REST cancel / timeout,并发 WS call.end 双腿冗余;后端写记录(end = missed,reject = declined)、清响铃;被叫那边收到就收掉 CallKit 条目。房里最后一个人挂断时才发录音 stop

为什么四个状态要分开看:界面上的「响铃中」是本地定时器;服务端的 answered 是取票事件且可能没写成;VoIP「推出去了」不等于设备收到;只有 LiveKit 两边音轨都在流才是「真正能听见」。排障时别拿前一个当后一个的证据。

四种异常各留下什么: - 对方拒接:主叫收 call.reject,记录 declined,主叫页面提示;reason: busy 且还没接通 = 占线。 - 30 秒没人接:主叫 timedOut,问要不要留言(§3.6);后端记 missed,未接来电靠通话记录那条路写 missed_call 铃铛(不走普通推送)。 - 接通后一方断线:LiveKit 侧参与者掉线;手机看门狗和重连逻辑修媒体;call.end 在房里还有别人(remoteCount > 1)时被忽略,防止一个人掉线把三方通话拆散。 - 被踢 / 登出:收 call.kicked 或 LiveKit participantRemoved → 「你已被移出通话」,不重连(重连只会拿旧票撞 403);退出登录先 hangUp() 再解绑 VoIP 令牌再断 WS。

出处:主叫 armOutgoing 发 offer 与两根弦 [code@f1d9dad6 app/lib/services/call_session.dart:468-497](30 秒转留言 :481-486,回铃 :530);后端 offer 两道门与不转发 [code@f1d9dad6 backend/internal/domain/message/handler.go:1223-1252](unavailable :1245-1248),AuthorizeCall [code@f1d9dad6 backend/internal/domain/message/service.go:3048](限频 :44),CanUseCallRoom :3220;撞线 call.merge handler.go:1282-1294;转发补名 :1296-1300MarkCallRoom 4 小时 service.go:3263-3268LogCallStarted 与异步 VoIP handler.go:1303-1311;来电一律推 OfflineCallOffer [code@f1d9dad6 backend/internal/domain/message/service.go:3281-3283];iOS 先 reportNewIncomingCall [code@f1d9dad6 app/ios/Runner/AppDelegate.swift:318-375](铁律注释 :323-325);前台 WS 兜底与去重 [code@f1d9dad6 app/lib/screens/home_screen.dart:803-806,892-895];「响铃中」本地定时器 [code@f1d9dad6 app/lib/services/call_session.dart:487-491];接听 = 取票 :541,后端记 answered、失败只告警照样发票 [code@f1d9dad6 backend/internal/domain/calls/calls.go:574-583],止铃放过接听那台 :604-620(线索 :300-321);音频会话先认领 [code@f1d9dad6 app/lib/services/call_engine.dart:582-585],连房前等还原 :635;挂断幂等 :1357(notifyListeners 先于 await :1455),最后一人才发录音 stop :1443-1447;收场双腿 call_session.dart:392-430(remoteCount > 1 不发结束 :404);拒接 / 占线 / 忽略 end :556-592;被踢不重连 [code@f1d9dad6 app/lib/services/call_engine.dart:1044,1063-1068];登出先挂断 [code@f1d9dad6 app/lib/util/logout.dart:50-56];后端 end / reject 记录 handler.go:1313-1322

1.2 开一场群通话:开房 → 响铃 → 加入 → 心跳 → 剩一人收房

你看到的:群里点通话,群里出现「通话进行中」的横幅,成员手机像单聊一样响(≤ 20 人);点加入就进房,人来人往;曾经有过别人、后来只剩你一个人时,3 秒后你这边会自己挂断(一开始就没人来的不算);响过铃却没人接的房,一分钟上下会消失;最小化成小窗或转去聊天,通话保留。

背后发生的: 1. 开房:REST POST /v1/calls/group/start,服务器生成房号,写 gcall:{room}(谁开的、视频否、成员)和 conv_gcall:{conv} 指针(一个群同一时刻只有一场;SetNX);临机通话(不在群里)同样开房但不落通话卡。限频:每人每房每分钟最多响 3 次、临机每分钟 10 次,10 秒内重复开房幂等。 2. 响铃:≤ 20 人走 VoIP + CallKit,8 路并发、每个 token 5 秒;> 20 人不响。响铃后 35 / 65 / 110 秒三枪对账;60 秒没人接由后端收房。 3. 加入 = 取票:POST /v1/calls/group/join 回 LiveKit 地址 + 十分钟票 + 会话 id;群房只认此刻的群成员,call_room 集合对群房不算数(09-05 安全修:以前集合命中也放行,退群的人还能进)。 4. 心跳:手机每 30 秒 heartbeat,服务器续 75 秒租约,并且必须把他加回在场集合(自愈:被误剪的人还在说话就不会被判离场),回包带一份房态快照,手机拿它对齐(applyRoomState)。 5. 收房是惰性对账,两边各一道:后端在 join / leave / state 任一次调用时剪掉心跳过期的成员,在场只剩一人且这房接通过(answered)就收;离房留 90 秒墓碑,防迟到的心跳把人复活;响过铃的房(写了 deadline 的)60 秒没人接由三枪对账(35 / 65 / 110 秒)收,不是准点;没响过铃的大群加入房没有这条超时。手机侧另有一道:曾经有过同伴、现在一个都没有、aloneGrace 3 秒后还没有、且本机没断线,才自己挂断(断线时人数是「不可知」不是零,作废倒计时等重连重数);响铃窗 50 秒。 6. 离开:WS gcall.leave + REST leave 双腿;正常离开只剪 Redis,不调 LiveKit 踢人;被踢才调 RemoveParticipant。 7. 1v1 变群:1v1 里再拉一个人,后端把房转成群房(三条判据:房里有人、还不是群房、被加的人不在名单),给房里所有人广播 call.became_group,再给第三人响铃。

哪一步会断、留下什么:开房幂等所以重复点不多开;响铃推送失败只影响那个人收不到系统来电,横幅照样在;心跳丢三次(75 秒)会被剪掉但一次心跳就自愈;后端进程重启时 Redis 里的房还在,对账接着算;手机端群通话由页面按住引擎:最小化成小窗、转去聊天都会设标记保留通话(dispose 见到 _minimizing 直接返回、不挂断),普通离页且没最小化才挂断 —— group_call_session.dart 文件头那句「小窗是下一刀」是陈注释,以页面代码为准。

出处:群路由 [code@f1d9dad6 backend/internal/domain/calls/gcall_http.go:23-33];开房生成房号与指针 :221,239(临机 :338,不落卡 :282);键与 TTL [code@f1d9dad6 backend/internal/domain/calls/group.go:34-70](gcallSeenTTL 75 秒 :35,墓碑 90 秒 :43-49,幂等 :52-55,限频 :65-70,829);响铃超时只对响过铃的房 :706-713,三枪 :715,后端「剩一人且接通过」 :413,30 秒宽限只对没接通的房 :429;手机侧收房四个前提 [code@f1d9dad6 app/lib/services/group_call_session.dart:314-324];心跳 SAdd 回 active [code@f1d9dad6 backend/internal/domain/calls/gcall_http.go:563-578];正常离开只剪 Redis :479-498;群房只认群成员 room_kind_gate_test.go(09-05);1v1 变群 [code@f1d9dad6 backend/internal/domain/message/service.go:3104],广播与响铃 handler.go:1259-1277;手机 join / 心跳 / applyRoomState [code@f1d9dad6 app/lib/services/group_call_session.dart:97,343-376,382],aloneGrace 3 秒 :216、响铃窗 50 秒 :222,页面按住引擎(陈注释):19-22,双腿离开 :395-414;最小化 / 转聊天保留通话、dispose_minimizing 不挂 [code@f1d9dad6 app/lib/screens/group_call_screen.dart:348-403];WS 只认 gcall.leave / gcall.decline group.go:950-952,下行事件名单 :74-79;群响铃走 VoIP [decision@2026-07-10,Jeff:「要像单聊一样响」]、加入制 [decision@2026-07-08,Jeff]


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

能力 怎么用 / 限制 后端入口 App 落点
1v1 语音 / 视频 好友才能打;被拉黑打不通;每分钟 10 次;30 秒没接转留言 WS call.offer / call.end / call.reject;POST /v1/calls/token(接听)、/v1/calls/signal(cancel / timeout / end) call_screen.dartcall_session.dart
来电 系统 CallKit 界面;锁屏也响;接听后进 App VoIP 推送(SendVoIP)+ WS 兜底 AppDelegate.swifthome_screen.dart
呼叫等待 通话中来第二通:接了就挂上一通 call_waiting.dart(maximumCallGroups: 2)
撞线合并 两人同时拨 → 自动并成一通 call.merge call_session.dart(mergeInto)
群通话 群里发起,≤ 20 人响铃,加入制;1v1 可升级成群 POST /v1/calls/group/{start,join,leave,decline,upgrade,heartbeat,invite}GET …/state group_call_screen.dartgroup_call_session.dart
临机通话 不在群里临时拉几个人;一次最多点 30 人 /group/start 带成员;不落通话卡 同上
通话链接 生成可分享入口;撤销只挡后来的人 POST /v1/calls/linkGET /v1/calls/links/link/revoke/link/joinGET /link/peek(不鉴权) 链接页
约定通话 定时间、邀人、到点提醒(进程那一分钟挂了提醒就丢) POST/GET /v1/calls/scheduled/scheduled/cancel schedule_call_screen.dart
收藏联系人 常打的人置顶、排序 /v1/calls/favorites* call_favorites_screen.dart
通话记录 未接 / 已接 / 拒接;群里发起的群通话只有发起人看得到 GET /v1/calls/history;记录消息 subtype: call_log call_history_screen.dartcall_log_service.dart
录音 房里任何人开,双端红标,开 / 停都广播;停要等确认;失败会提示 POST/GET /v1/calls/record;广播 call.rec call_engine_recording.dart
留言 30 秒没接或响够 10 秒挂断时可留;60 秒不点作废;最长 90 秒;落成对方私聊里的一条语音 无后端(普通附件消息) voicemail_service.dartvoicemail_outbox.dart
静音 / 摄像头 / 外放 / 切换前后 路由真相翻按钮;静音了还在说话会提示(阈值 .35、持续 400 毫秒、8 秒冷却) call_engine_audio_route.dartcall_engine_mute_hint.dart
屏幕共享 iOS 可;安卓不画按钮 call_engine_screen_share.dart
小窗 / 画中画 左上最小化、左边缘右滑收窗(24pt 感应条、滑过 56px);拖动四角吸附;抖着点也算点(总位移 < 24 像素) call_float_pip.dartcall_edge_swipe.dart
发言者视图 点谁谁上大屏(含自己),共享者压过一切;别人新开口才松钉,钉住的人退了自动松 group_call_stage.dartspeaker_focus
网络质量 24k ↔ 48k 自适应(降快升慢,升要等 15 秒),弱网关视频,掉档提醒 call_engine_adaptive.dart
诊断 通话页长按发诊断(每分钟 60 次限频) POST /v1/calls/diag(表 000294) call_diag_expand

出处:1v1 / 通用路由 [code@f1d9dad6 backend/internal/domain/calls/calls.go:333-341];群 gcall_http.go:23-33;链接 [code@f1d9dad6 backend/internal/domain/calls/link.go:69-77];收藏 [code@f1d9dad6 backend/internal/domain/calls/favorites.go:32-36];约定 [code@f1d9dad6 backend/internal/domain/calls/scheduled.go:93-96](提醒会丢 :373);诊断限频 [code@f1d9dad6 backend/internal/domain/calls/diag.go:50-52];呼叫等待 [code@f1d9dad6 app/lib/services/callkit_guard.dart:73-89];响铃 20 人 / 临机 30 人 [code@f1d9dad6 backend/internal/domain/calls/group.go:245,250](用处 :661);静音提示 [code@f1d9dad6 app/lib/services/call_engine_mute_hint.dart:19];自适应 [code@f1d9dad6 app/lib/services/call_engine_adaptive.dart:29-39];小窗 [code@f1d9dad6 app/lib/widgets/call_float_pip.dart:51-66,127,145-150],边缘滑 [code@f1d9dad6 app/lib/widgets/call_edge_swipe.dart:3-25];发言者视图 [code@f1d9dad6 app/lib/widgets/group_call_stage.dart:100-104,133,186-199] [decision@2026-09-07,Jeff 语音 15:49 / 15:55 → Tora v1.882]


3. 内部怎么运作

3.1 房型与门禁:谁能进哪个房

用户看到的:陌生人打不进来;退了群的人进不了群通话;系统抖的时候是「暂时不可用」不是「你没权」。

怎么做的:四种房,门禁先问房型 —— 群房只认此刻的群成员,call_room:{room} 授权集合对群房不算数(09-05 之前「集合命中或有 conv 标记」都放行,退群的人还能凭旧集合进,是安全洞);1v1 / 临机 / 链接房照旧认集合。房型本身答不上来(Redis 读错)一律 503,不是 403;两层依赖故障时外人在故障期永远拿不到票。1v1 主叫的门:AuthorizeCall(好友 / 拉黑 / 限频),这是 Jeff 06-29 定的底线「陌生人和被拉黑者不能呼叫」。并发窗口故意不堵:两人同时用同一个没用过的房号发 offer 都算新房。

改它之前:任何「能不能进」的判断必须回 (bool, error),错误就 503;别把「查不到」当「没权」也别当「有权」。

出处:房型门 [code@f1d9dad6 backend/internal/domain/message/service.go:3220],群房只认成员 room_kind_gate_test.go:20-88callroom_audit1_repro_test.go:13;答不上来 503 gate_fail_closed_test.go:25-162gate_chain_test.go:92-263 [decision@2026-09-05,Jeff 拍板评审 A:「答不上来不当有权」];AuthorizeCall service.go:3048 [decision@2026-06-29,Jeff:陌生人 / 被拉黑不能呼叫];并发窗口不堵 :3255;键名唯一出处 [code@f1d9dad6 backend/internal/domain/callkeys/callkeys.go:1-12]

3.2 入房票与撤权即断

用户看到的:被踢出群那一刻通话里就被请出去,屏上写「你已被移出通话」;退出登录时正在打的电话先挂;撤销一个通话链接后,已经在里面的人不受影响。

怎么做的:票是 HS256 签的 LiveKit JWT,claims 里写房号、能否发布、显示名,十分钟有效 —— LiveKit 只在连接那一刻验票,之后自己续,所以短票不影响通话;签票时顺手记 user_calls:{uid}(他取过票的房,2 小时),登出时按这个索引清。踢人只有一个出口 RemoveFromRoom:LiveKit RemoveParticipant(404 视为成功)+ 清三把 Redis 键 + 发 call.kicked 给被踢者一人。失败时的样子要说清:LiveKit 那一步失败只 slog.Warn,Redis 照清、call.kicked 照发,清 Redis 的返回值也没看;所以「正常路径立即移出并通知」成立,「所有故障下必断」不成立 —— 那个人可能还挂在 LiveKit 房里,直到自己掉线或离开(票过期不会把他踢下来,见下段)。两个触发点:退群 / 被踢群 → 按 conv_gcall:{conv} 指针找房,指针读不到就直接返回;退出账号 → 遍历 user_calls,列表读不到也直接退出。这条路上两处都没见补偿重试;别处有没有,待核(§7)。正常 leave 不踢,只剪 Redis。撤销链接只挡后来的人。

为什么票缩到十分钟:Jeff 09-06 定「撤权即断」—— 票是入场券不是通行证,取票那一刻查了权限,进去之后被踢也得能请出去;票长一小时意味着被踢的人一小时内还能重进。注意:短票挡的是「重进」,不是「已在房里的连接被断开」—— 断开靠 RemoveParticipant,那一步会失败(上面);LiveKit 连上之后自己续票,所以既有连接何时断,入房票的 TTL 保证不了

出处:签票 [code@f1d9dad6 backend/internal/domain/calls/calls.go:227-263](kJoinTokenTTL 十分钟 :263,理由 :250-252,noteUserRoom :257);管理令牌 [code@f1d9dad6 backend/internal/domain/calls/lkexp.go:34-47];踢人唯一出口 [code@f1d9dad6 backend/internal/domain/calls/kick.go:24-30,49-77](失败只告警 :56-61、Redis 照清 :64-68、返回值忽略),两个触发点 :110-118,122-129(指针 / 列表读不到直接返回),钩子 [code@f1d9dad6 backend/internal/domain/conversation/handler.go:92-95]、登出 [code@f1d9dad6 backend/internal/domain/auth/service.go:541-542];正常 leave 只剪 Redis gcall_http.go:479-498;链接只挡后来者 kick.go:12;手机不重连 [code@f1d9dad6 app/lib/services/call_engine.dart:1060-1068];守卫 kick_test.go:84-164auth/logout_kick_test.go:17conversation/call_kick_hook_test.go:10call_kicked_0906_test.dart [decision@2026-09-06,Jeff:撤权即断]

3.3 手机端引擎:一个类拆十份,谁能进和接线员分开

用户看到的:1v1 和群通话的按钮、静音、外放、录音红标、小窗长得一样、行为一样。

怎么做的:CallEngine 一个抽象基类拆成十个 part(自适应 / 音频路由 / CallKit / 生命周期 / 静音提示 / 录音 / 屏幕共享 / 轨道 / 类型 / 看门狗),「接线员干的活」在引擎,「谁能进」在子类:CallSession(1v1 六样规矩:留言 / 回铃 / 占线 / 拒接 / 撞线合并 / 通话记录)和 GroupCallSession(REST 加入 / 30 秒心跳 / 离开)。这是 Jeff 08-22 定的顺序:「1v1 不要动,做一个 call engine 先给群用,群跑顺了再把 1v1 接过来」—— 现在 1v1 已接过来,但同一段逻辑有两份(屏幕共享 / 录音 / 显示名 / 画中画在 call_screen.dart 和引擎里重复)是故意的、有期限的,第三刀合并还没做。

代次(generation):每个「恢复 / 修复媒体」的任务起步领一张票,每次 await 回来、每个真会改轨的动作前核票还在不在;让票作废的四处 —— 挂断、重连回来、新一轮起步、超时放闸。为什么:09-06 审计 #5 发现挂断后迟到的恢复任务会把麦克风重新打开。交还音频会话同理:只有认领过的人才交(否则减掉的是别人的计数),callInProgress = false 必须跟着这道闸走;屏障要等全部在飞的还原,不是最后一次。动作前重读意图:静音 / 关摄像头的人不许被重连打开。

看门狗三条:麦克风壳 2 秒一核、退避 1 → 2 → 4 → 8;流量按 source 点名麦克风;冻帧。修复一拍一枪,靠 _flow.clear() 兑现。

出处:拆分与边界 [code@f1d9dad6 app/lib/services/call_engine.dart:36-47,60-88] [decision@2026-08-22,Jeff 语音:「1v1 不要动,引擎先给群用」];start() 顺序 :559-628,认领音频 :582-585,drainCallKit 一锅端 :601-620;交还 :1486-1493,callInProgress 跟闸 :1471-1474,退场静置 350 毫秒 :215;代次 [code@f1d9dad6 app/lib/services/call_engine_watchdog.dart:46-59,476,490,658,668],动作前重读 :696-711,麦克风壳 :39-59,流量 :195-230,冻帧 :557,修复 :475-497;1v1 六样 [code@f1d9dad6 app/lib/services/call_session.dart:53];两份逻辑 call_engine.dart:60-67,陈注释 :1438(实际 CallSession.hangUpsuper.hangUp(),call_session.dart:454-460);守卫 call_engine_testcall_audio_release_race_0906_testcall_reconnect_audio_testcall_reconnect_peer_guard_testcall_audio_handoff_test(CHANGELOG v1.845 / v1.848 / v1.850)。

3.4 CallKit 与 VoIP:来电为什么一律推

用户看到的:锁屏也能接到电话,像系统电话一样;接听后进 App;主叫取消了,来电界面自己消失。

怎么做的:来电只走 VoIP 推送 → CallKit,没有 App 内来电页。后端发 offer 时不看对方在不在线,一律推(退后台的 App 一秒内被挂起,WS 关闭包传不到);前台在线的 WS 也弹一次,靠房号去重。iOS 原生层收到 VoIP 推送必须先 reportNewIncomingCall,否则苹果永久停发。VoIP 令牌和横幅推送令牌是两条线、两个字段,启动时立刻 + 3 秒后 + 事件钩子三次上报;登出时解绑。取消 / 超时走 VoIP cancel;接听止铃放过接听那台。APNs 客户端 prod 和 dev 两个都建、依次试,APNS_PRODUCTION 这个开关对发送路径已不起作用(config 里还读,别被它骗)。这套是 06-29「锁屏来电终局」四条根因的产物。

排障:来电不响 → 先看设备有没有 voip_token(user_devices,iOS 且 active)、坏 token 会被置 NULL;再看后端有没有走 OfflineCallOffer;最后看原生层有没有 reportNewIncomingCall 的日志。

出处:PKPushRegistry 与铁律 [code@f1d9dad6 app/ios/Runner/AppDelegate.swift:41-43,318-375];前台兜底 [code@f1d9dad6 app/lib/screens/home_screen.dart:803-806,892-895],止铃门卫 :813-822,860-872,令牌两条线 :685-697,733-741,API 字段 [code@f1d9dad6 app/lib/api/api_client.dart:1191-1201];后端 SendVoIP 家族 [code@f1d9dad6 backend/internal/platform/push/push.go:34-41],APNs 双客户端 [code@f1d9dad6 backend/internal/platform/push/apns.go:28-51,113-152],APNS_PRODUCTION 仍被读 [code@f1d9dad6 backend/internal/config/config.go:278];令牌存取 [code@f1d9dad6 backend/internal/domain/message/repository.go:1336-1362,1381](列在迁移 000017);取消 calls.go:525-538;群响铃接线 [code@f1d9dad6 backend/internal/server/router.go:478-479];守卫 callkit_guard_testcallkit_outgoing_testcallkit_vendor_guard_test(两句 Swift 修补必须在)、answer_stop_ring_test.gostop_own_ring_device_test.go

3.5 录音:四态状态机,归档目标开录那一刻定死

用户看到的:任何人按录音,所有人屏上出红标,「开 / 停都广播」是 Jeff 07-23 定的知情铁律,双端红标不可拆。按停之后有一段「正在停止」,确认了才灭;网络不好会提示「录音暂不可用」,按钮放开可再点。

怎么做的:录音是服务器让 LiveKit Egress 把房里音频混成 MP4(audio-only 默认 Ogg/Opus,iOS 播不了,所以强制 MP4),落到主存储同一桶 callrec/{room}/{时间}.mp4。状态存 Redis call_rec:{room} 一个 hash,四态 starting → recording → stopping → archiving(键不在 = off)。六条铁律:取消意图 ≠ 停止完成;StopEgress 回 200 不算停,要 ListEgress 看到终态;外部副作用前先登记占位;对账按房名找活的 Egress,不靠记住的 id;状态转换 = 操作号 + 允许的前态一起比(Lua 原子);旧格式键读不到一律答「不可确认」不答 off。

归档目标在开录那一刻定死(Vega 09-06 的判据),判断顺序固定:① 有群房记录(gcall:{room})且绑了会话 → 落;② 有群房记录但会话为空 → 直接 none(临机开房写的就是空会话,所以临机房不论几个人都不落消息);③ 没有群房记录且恰好两人 → 落两人的私聊;④ 其余 → none,文件留 R2、不落消息。所以「3 人以上不落消息」这句不准:临机房 2 个人也不落;1v1 落私聊;群房里多少人都落群里。定不下来(Redis 读错)就不开录、回 503、一条 Egress 都不起;空目标绝不改投。停录:同步等 6 秒确认,没确认回 409 rec_stopping,手机每 3 秒轮询最多 60 轮,红标不灭;重发 StopEgress 退避 2 / 4 / 8 / 16 / 32 秒。对账循环每 5 秒一轮,90 秒判「进程当时死了」;归档交接用 Lua 把任务从 call_rec: 原子搬到 call_rec_deliver:{egress}(7 天),失败停在 archiving 对外 = off;投递退避 2 秒到 5 分钟封顶,只认任务里带的会话,路上不再解析;幂等锚 = Egress id 派生的 UUIDv5,落消息走 chat 的 InsertAtomic(有事务、无发件箱,见 docs/chat.md §3.3)。

手机端:开 / 停双闸(recBusy / recStopping);409 不算失败也不算已停;失败只置一个序号让页面弹一次;挂断时只有房里最后一个人才发 stop。

已知边界:recTTL 定义了 12 小时,但三段 Lua 里 EXPIRE 写死 43200 —— 同一把尺子两个出处,改一处会腐。

出处:状态机注释与四态 [code@f1d9dad6 backend/internal/domain/calls/record.go:64-98,90-95],六条铁律 :79-89,转换 Lua :138-155;开录与目标定死 :275-292,判断顺序 :753-772(有记录绑会话 → 群 :759,有记录会话空 → none :762,无记录恰好两人 → 私聊 :764-769,其余 none :771);临机开房写空会话 [code@f1d9dad6 backend/internal/domain/calls/gcall_http.go:353-355];Egress 参数 :331-342,无 egress_id 的窗口 :118-121,361,补偿 :400;停录 :462,确认 6 秒 :124,退避 :127-130;对账 :869-892,90 秒 :101,5 秒 :99;交接与投递 :695-740,776-820,锁 60 秒 :781,幂等锚 :743;落消息钩子 [code@f1d9dad6 backend/internal/server/router.go:441-462,528];recTTL 两个出处 record.go:96,152,172,177;手机 [code@f1d9dad6 app/lib/services/call_engine_recording.dart:21-34,91-128,210,242],页面 call_screen.dart:774group_call_screen.dart:739,最后一人 stop call_engine.dart:1443-1447;守卫 record_state_test.go(20 条)、record_target_test.go:30callrec_idempotent_0906_test.go:25 [decision@2026-07-23,Jeff:录音知情铁律] [decision@2026-09-06,Vega:归档目标开录那一刻定]

3.6 留言:完全在手机端

用户看到的:30 秒没人接,或者响够 10 秒你挂断,会问要不要留言;60 秒不点作废;最长 90 秒自动发;对方在你们的私聊里收到一条语音。

怎么做的:后端没有任何留言端点、表或字段 —— 留言就是一条普通语音附件消息(必要时先建私聊)。归属在开录那一刻定(_vmOwner = 当前账号),读文件前、建私聊前、每次重试前都核 stillMine(),换了号一个字节都不发(Jeff 09-05 v1.838);有自己的发件箱:先落盘再发、发成功才删、重启 / 断线重连 / 切回原账号补投、7 天丢弃(v1.858)。播放没有独立播放器,在聊天里播。

出处:触发 [code@f1d9dad6 app/lib/services/call_session.dart:481-486,705-712,724,753-756,773,787];归属核对 [code@f1d9dad6 app/lib/services/voicemail_service.dart:95-99,102,122-165];发件箱 [code@f1d9dad6 app/lib/services/voicemail_outbox.dart:1-15,235,255-259];后端零命中(全仓 grep -i voicemail 只在 app/ 与 CHANGELOG);守卫 voicemail_owner_0905_testvoicemail_outbox_0906_test

3.7 通话记录、未接来电、推送排除

用户看到的:聊天里一行「📞 语音通话 · 03:21」;未接来电有铃铛;记录页能回拨。

怎么做的:记录是 type: textsubtype: call_log 的普通消息(不新开 type,否则等于白拿一张免死金牌);它不加未读、不推送,未接来电靠 calls/log.gomissed_call 铃铛;HTTP 发送路径必须显式带 subtype,漏了两条管道行为就不一致。1v1「电话开始了」只有 offer 收尾一个入口;end = missed、reject = declined;cancel 记 missed 而非 cancelled(理由在 log.go 文件头);已知一处 end_reason 分不出来;群里发起的群通话只有发起人在记录页看得到(迁移取舍,不是漏做)。

出处:SubtypeCallLog 与校验 [code@f1d9dad6 backend/internal/domain/message/model.go:141-173];不加未读 service.go:433、不推送 :2856-2858;HTTP 显式 subtype handler.go:813-818;唯一入口 handler.go:1303-1311,收尾 :1313-1322;log.go 取舍 [code@f1d9dad6 backend/internal/domain/calls/log.go:3,26,381],cancel 理由 calls.go:492;手机落行 [code@f1d9dad6 app/lib/services/call_log_service.dart:24-38];表 000195 / 000203 / 000330;守卫 calllog_test.go:77calllog_guard_test.go:71call_log_subtype_test

3.8 媒体与网络:LiveKit 怎么部署、质量怎么调

用户看到的:同一 WiFi 下清晰不卡;弱网自动降到语音优先;掉档有提示。

怎么做的:LiveKit 在生产 compose 里(信令 7880 内网,媒体 UDP 7882 单端口 + TCP 7881),Caddy 在 api.hoopcomm.comhandle /rtc*,不需要新域名;TURN 兜底 TLS 5349 + UDP 3478 复用同一张证书,真 443 需第二 IP(08-29 记账未办)。不做 ICE 折腾:LiveKit 默认配置就不弹本地网络权限,「nohost / relay 那些折腾都不需要了」(06-29 实测;仓里 iceTransportPolicy 零命中)。群不开 simulcast;视频 600 kbps @ 24 fps h264;音频 24k(群)/ 48k(1v1)自适应,16k 地板;降快升慢,升要等 15 秒。

生产核过 [prod@2026-09-07 20:33 MYT,Nova Master 在生产机核]:livekit/livekit-server:v1.8.0 + livekit/egress:v1.8.4 两个容器在跑;LiveKit 发布端口 5349/tcp、3478/udp、7882/udp、7881/tcp;Caddy handle /rtc* → reverse_proxy livekit:7880(deploy/Caddyfile:56-57);TURN 5349 tcp 与 3478 udp 在听;.envLIVEKIT_URL / LIVEKIT_API_KEY / LIVEKIT_API_SECRET / TURN_HOST / TURN_REALM / TURN_SECRET / TURN_TTL;录音落 STORAGE_BUCKET=hoop 单桶的 callrec/ 前缀。没核的:APNs 证书、R2 桶里 callrec/ 下是否真有文件。这些部署参数以后归 ops 那份,本文只留结论。

出处:自适应 [code@f1d9dad6 app/lib/services/call_engine_adaptive.dart:7-39];码率与 simulcast(GROUP_CALLS 09-02 更正表);部署与 ICE [decision@2026-06-29,Jeff:自建 LiveKit SFU,不做 ICE 折腾](记忆 hoop-livekit-sfu);TURN 5349 [decision@2026-08-29 核实,CHANGELOG v1.500 ①];守卫 call_audio_bitrate_testgroup_call_bandwidth_testcall_quality_options_test

3.9 小窗、发言者视图、手势

用户看到的:通话页左上能缩成小窗,左边缘右滑也能;小窗能拖、四角吸附、点一下换大屏(手抖也算点);群里发言者视图点谁谁上大屏。

怎么做的:全仓唯一一份浮窗壳 call_float_pip.dart(1v1 和群共用):拖动门槛 touchSlop: 4 不动(免得 Lisa「拉不动」那个病回来),另加总位移 < 24 逻辑像素当点击、回原角报 onTap(Tora 09-07 v1.882:之前随手一点手抖过 8 像素就被判成拖,只有小心点那两个字才灵)。发言者视图:共享者压过一切,focusId 可以是我自己;别人新开口才松钉,钉住的人退了自动松;主角那块不吃点击,留给收放顶栏。语音通话里点击不许藏挂断。

出处:浮窗壳 [code@f1d9dad6 app/lib/widgets/call_float_pip.dart:4,51-66,127,145-161];边缘滑 call_edge_swipe.dart:3-25;群小窗 [code@f1d9dad6 app/lib/widgets/group_call_pip.dart:27],1v1 小窗与最小化 [code@f1d9dad6 app/lib/screens/call_screen.dart:75-129,532-545,673-682];发言者 [code@f1d9dad6 app/lib/screens/group_call_screen.dart:245,484-489,715-718]、舞台 group_call_stage.dart:186-199,语音里不藏挂断 group_call_screen.dart:533;守卫 group_call_pip_jitter_tap_testgroup_call_speaker_pin_testcall_edge_swipe_0905_testcall_pip_swap_1v1_test [decision@2026-09-07,Jeff 配图 + 语音 → Tora v1.882] [decision@2026-09-04,Jeff 语音:「轻轻一推它就会上」→ v1.832 边缘右滑]


4. 还没做的(不是现状)

状态 出处
~~1v1 hangUptry/catch 包不住 callRecord('stop')~~ 已不成立:CallSession.hangUp 现在直接 await super.hangUp(),callRecord 全仓只在引擎(Future.sync(...).catchError)和录音 part 里调;call_engine.dart:1438 那句「同一颗雷还埋着」是陈注释 call_session.dart:454-460call_engine.dart:1445-1446;Gode 09-07 三审
同一段逻辑两份(屏幕共享 / 录音 / 显示名 / 画中画在 call_screen 与引擎重复),第三刀合并 未做,故意有期限 call_engine.dart:60-67
安卓屏幕共享(MediaProjection) 未做,整颗按钮不画 GROUP_CALLS §3 表
手机取票失败不分 403 / 503,一律「通话失败」不重试 未做 CHANGELOG 09-05 评审 A 末尾自认
~~群通话「退出页面通话还活着」~~ 已成立:最小化 / 转聊天保留通话,普通离页且非最小化才挂(group_call_screen.dart:348-403);group_call_session.dart:19-22 是陈注释 Gode 09-07 四审
真 443 TURN(需第二 IP) 记账未办 CHANGELOG v1.500 ①
临机房(会话为空)和其余非两人房的录音不落消息,只留 R2 现状如此(none 分支),不是缺口;要不要给临机房一个落点待拍 record.go:753-772gcall_http.go:353-355
App 猝死 → R2 孤儿录音归 GC 挂账 同上
recTTL 12h 与 Lua 写死 43200 两个出处 未合 record.go:96,152,172,177
约定通话到点那一分钟进程挂了提醒丢 已知取舍 scheduled.go:373
GROUP_CALLS §5 容量账建立在已废前提(8 人 720p / simulcast) 需重算 GROUP_CALLS 09-02 更正表
装机验收清单(点 Me 小窗 20 次要 20 次换;发言者视图逐格点;点完让别人说一句要切回) 待真机(v1.882 整包没编过,build ≥ 194) CHANGELOG v1.882
widget test 造不出真 Room:getSenderStats 真读得到包数、拆轨后 SFU 真收到 AddTrack、轨道是否真活 只能装机验 GROUP_CALLS §4.1 / §4.3 ⚠️
「playback 踢掉 WebRTC 音频单元」是强推断,未在开发机复现 待核 GROUP_CALLS §4.2 ⚠️

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

排障一条路: 1. 先问是哪种房、走的哪条腿:1v1 = WS 信令 + VoIP;群 = REST + VoIP 响铃;链接 / 约定各自的表。四个状态分开问:发出了吗(后端有没有 call.offer 转发日志 / 门是 403 还是 503)→ 对方收到了吗(设备 voip_token 在不在、APNs 有没有报 invalid)→ 接听了吗(call_logs answered)→ 真接通了吗(LiveKit 房里有没有两个参与者、手机看门狗日志)。 2. Redis 两把键:gcall:{room}(群房本体,谁开的、成员、结束没)和 call_room:{room}(1v1 / 临机授权名单);群房门禁不看集合。conv_gcall:{conv} 是「这个群此刻的房」指针,踢群按它找房。 3. 录音:call_rec:{room} 四态;archiving 对外 = off 但任务在 call_rec_deliver:{egress},7 天内会重投;none 目标的文件在 R2 callrec/{room}/。 4. 被踢 / 登出后还在房里:看 user_calls:{uid} 索引在不在、RemoveParticipant 有没有 404。 5. 来电不响:§3.4 那三步。群通话人被误判离场:看心跳有没有 SAdd 回 active。

改它之前: - 改门禁:一律 (bool, error),错误 503;群房不认集合;跑 room_kind_gate_testgate_fail_closed_testgate_chain_test。 - 改票:TTL 缩短没关系(LiveKit 只在连接时验),延长要先想「被踢的人多久内还能重进」;user_calls 索引别漏写。 - 改引擎:start() 里认领音频必须早于任何 await;每个恢复任务带代次,四处作废别少;notifyListeners 别挪到 await 之后(要挪就补 CallPipNative.stop());两个调用点故意不写 return,谁拿掉 _flow.clear() 就必须补回。 - 改录音:状态转换只走 Lua;别直接删状态;停要看 ListEgress 终态;归档目标只在开录那一刻算;播报绝不能带 defaultToSpeaker。 - 改 CallKit:原生层 reportNewIncomingCall 必须在收到 VoIP 的同一回合;两句 Swift 修补必须在(callkit_vendor_guard_test);止铃必须放过接听那台。 - 改群房:心跳必须 SAdd 回 active;房散时名单要在删 conv 指针之后仍算得出来;30 秒宽限只对没接通的房。 - 改小窗:touchSlop: 4 不动,动的是总位移判据;跑 group_call_pip_jitter_tap_test。 - 别信的旧图纸:GROUP_CALLS §0 / §1 / §3 旧段(上限、WS 信令、simulcast 开、普通横幅)全被它自己 09-02 的更正表推翻,§2 门禁写法 09-05 后是错的;call_1v1.html 底部「一通电话都没打过」是 08-29 的状态;API.md 通话端点一个都没写。


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

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

当前值 实现出处(f1d9dad6) 守卫 / 未覆盖
入房票 TTL 🔒 10 分钟 calls/calls.go:263 kick_test.go:126
主叫限频 每分钟 10 次 message/service.go:44 scan_0905_test.go(按人限频)
call_room 授权 TTL 4 小时(1v1)/ 12 小时(约定通话到点开房 SeedRoom,scheduled.go:343 调) message/service.go:3268calls/group.go:612,626 未覆盖
群房 TTL / 心跳租约 / 墓碑 12 小时 / 75 秒 / 90 秒 calls/group.go:34-49 group_batch_g_test.go:96,247
群响铃上限 / 临机一次 20 / 30 calls/group.go:245,250(用处 :661) 未覆盖(没有测试钉这两个数)
响铃超时 / 对账三枪 60 秒 / 35 · 65 · 110 秒 calls/group.go:712-715 group_ring_timeout_gate_test.go
手机心跳 / 剩一人收 / 响铃窗 30 秒 / 3 秒 / 50 秒 group_call_session.dart:216,222,371 group_call_alone_testgroup_call_session_seams_test
留言:转留言 / 挂断可留 / 作废 / 上限 30 秒 / 10 秒 / 60 秒 / 90 秒 call_session.dart:481,705,753,787 call_vm_callback_guard_test(部分)
录音停录确认 / 退避 / 对账周期 / 死亡判定 6 秒 / 2-32 秒 / 5 秒 / 90 秒 calls/record.go:124-130,99,101 record_state_test.go
录音状态 TTL 12 小时(两个出处) record.go:96 与 Lua :152,172,177 未覆盖
归档任务 / 投递锁 7 天 / 60 秒 record.go:726-740,781 record_state_test.go:975
手机录音轮询 3 秒 × 60 轮 call_engine_recording.dart:34,128 未覆盖
静音提示 .35 / 400 毫秒 / 8 秒冷却 call_engine_mute_hint.dart:19 call_mute_hint_test
音频自适应 24k ↔ 48k,升等 15 秒,16k 地板 call_engine_adaptive.dart:29-39 call_audio_bitrate_test
小窗点击总位移 🔒 < 24 逻辑像素,touchSlop 4 不动 call_float_pip.dart:66,127 group_call_pip_jitter_tap_test
边缘右滑 24pt 感应条 / 56px call_edge_swipe.dart:3-25 call_edge_swipe_0905_test
诊断限频 每分钟 60 calls/diag.go:50-52 scan_0905_test.go
呼叫等待 teardown 等待 6 × 250 毫秒 call_waiting.dart:30-37 call_waiting_decision_test

7. 出处与旧图纸去向

  • 代码(全部 [code@f1d9dad6]):backend/internal/domain/calls/{calls,gcall_http,group,kick,record,link,log,scheduled,diag,favorites,lkexp}.gobackend/internal/domain/callkeys/callkeys.gobackend/internal/domain/message/{handler,service,model,repository}.go(call.* 信令、OfflineCallOffer、通话记录、VoIP 令牌)、backend/internal/platform/push/{push,apns}.gobackend/internal/config/config.gobackend/internal/server/router.gobackend/internal/domain/auth/service.gobackend/internal/domain/conversation/handler.go;App lib/services/{call_engine*,call_session,call_waiting,callkit_guard,group_call_session,group_call_state,call_log_service,voicemail_service,voicemail_outbox}.dartlib/screens/{call_screen,group_call_screen,home_screen}.dartlib/widgets/{call_float_pip,call_edge_swipe,group_call_pip,group_call_stage}.dartlib/util/logout.dartlib/api/api_client.dartios/Runner/AppDelegate.swift
  • 迁移:000017(user_devices.voip_token)、000135(通话卡消息类型)、000195(call_logs)、000203(临机记录、call_log_participants)、000204(call_links)、000207(call_favorites)、000208(scheduled_calls + invitees)、000294(call_diag)、000330(call_logs 房号索引,触及通话的最新);录音 / 留言 / LiveKit 没有表(录音状态全在 Redis,落成普通消息;留言纯客户端)。
  • 生产 [prod@2026-09-07 20:33 MYT,Nova Master 在生产机核;Gode 未连生产]:见 §3.8 —— LiveKit v1.8.0 / egress v1.8.4、端口、Caddy /rtc*、TURN 在听、.env 键名、录音单桶 hoop没核:APNs 证书、callrec/ 下是否真有文件。
  • 决定:2026-06-29 Jeff 自建 LiveKit SFU、不做 ICE 折腾、陌生人 / 被拉黑不能呼叫;2026-07-08 Jeff 群通话加入制;2026-07-10 Jeff 群「要像单聊一样响」;2026-07-23 Jeff 录音知情铁律;2026-08-22 Jeff「1v1 不要动,引擎先给群用」;2026-08-29 核实 TURN 5349 已建、真 443 记账;2026-09-04 Jeff 「轻轻一推就上」→ 边缘右滑;2026-09-05 Jeff 评审 A「答不上来不当有权」、评审 B 语音消息不掰音频会话、「修全部」六处;2026-09-06 Jeff 撤权即断、票十分钟;2026-09-06 Vega 归档目标开录那一刻定;2026-09-07 Jeff 小窗抖着点算点、发言者视图点谁谁上大屏。
  • 未核清单(第四版剩下的):① 「playback 踢掉 WebRTC 音频单元」是推断,未在开发机复现;② APNs 证书与 callrec/ 桶内文件实存未核;③ 踢人失败(LiveKit / 指针 / 列表读不到)这条路上未见补偿重试,别处有没有待核。已核掉的:响铃 / 临机上限行号(group.go:245,250)、SeedRoom 只有约定通话到点开房在调(scheduled.go:343)、归档目标三分支、生产 LiveKit / Caddy / TURN / 桶。
  • 旧图纸去向:GROUP_CALLS.md 整份被本文替代(§4.1–§4.3 四条判据已并进 §3.3 / §5)→ _archive/(等 maintainer 挪);call_1v1.html 整份替代(分层图 = §3.3);API.md 通话端点与 call.* 事件由本文接管(原件本来就没写);ARCHITECTURE.md 技术栈那一行改指本文;DEPLOY.md 的 coturn / TURN 几节已被 LiveKit 自带 ICE + TLS 5349 兜底推翻,归 ops 那份收口;记忆 hoop-livekit-sfu / hoop-call-recording 的产品判据并进本文,部署参数归 ops;LINKED_DEVICES.md 与通话零交集,不动。