| 档案 | |
|---|---|
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_id 和 caller_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.mergehandler.go:1282-1294;转发补名:1296-1300、MarkCallRoom4 小时service.go:3263-3268、LogCallStarted与异步 VoIPhandler.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](gcallSeenTTL75 秒: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],aloneGrace3 秒:216、响铃窗 50 秒:222,页面按住引擎(陈注释):19-22,双腿离开:395-414;最小化 / 转聊天保留通话、dispose见_minimizing不挂[code@f1d9dad6 app/lib/screens/group_call_screen.dart:348-403];WS 只认gcall.leave / gcall.declinegroup.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.dart、call_session.dart |
| 来电 | 系统 CallKit 界面;锁屏也响;接听后进 App | VoIP 推送(SendVoIP)+ WS 兜底 |
AppDelegate.swift、home_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.dart、group_call_session.dart |
| 临机通话 | 不在群里临时拉几个人;一次最多点 30 人 | /group/start 带成员;不落通话卡 |
同上 |
| 通话链接 | 生成可分享入口;撤销只挡后来的人 | POST /v1/calls/link、GET /v1/calls/links、/link/revoke、/link/join、GET /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.dart、call_log_service.dart |
| 录音 | 房里任何人开,双端红标,开 / 停都广播;停要等确认;失败会提示 | POST/GET /v1/calls/record;广播 call.rec |
call_engine_recording.dart |
| 留言 | 30 秒没接或响够 10 秒挂断时可留;60 秒不点作废;最长 90 秒;落成对方私聊里的一条语音 | 无后端(普通附件消息) | voicemail_service.dart、voicemail_outbox.dart |
| 静音 / 摄像头 / 外放 / 切换前后 | 路由真相翻按钮;静音了还在说话会提示(阈值 .35、持续 400 毫秒、8 秒冷却) | — | call_engine_audio_route.dart、call_engine_mute_hint.dart |
| 屏幕共享 | iOS 可;安卓不画按钮 | — | call_engine_screen_share.dart |
| 小窗 / 画中画 | 左上最小化、左边缘右滑收窗(24pt 感应条、滑过 56px);拖动四角吸附;抖着点也算点(总位移 < 24 像素) | — | call_float_pip.dart、call_edge_swipe.dart |
| 发言者视图 | 点谁谁上大屏(含自己),共享者压过一切;别人新开口才松钉,钉住的人退了自动松 | — | group_call_stage.dart、speaker_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-88、callroom_audit1_repro_test.go:13;答不上来 503gate_fail_closed_test.go:25-162、gate_chain_test.go:92-263[decision@2026-09-05,Jeff 拍板评审 A:「答不上来不当有权」];AuthorizeCallservice.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 只剪 Redisgcall_http.go:479-498;链接只挡后来者kick.go:12;手机不重连[code@f1d9dad6 app/lib/services/call_engine.dart:1060-1068];守卫kick_test.go:84-164、auth/logout_kick_test.go:17、conversation/call_kick_hook_test.go:10、call_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.hangUp走super.hangUp(),call_session.dart:454-460);守卫call_engine_test、call_audio_release_race_0906_test、call_reconnect_audio_test、call_reconnect_peer_guard_test、call_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_test、callkit_outgoing_test、callkit_vendor_guard_test(两句 Swift 修补必须在)、answer_stop_ring_test.go、stop_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:774、group_call_screen.dart:739,最后一人 stopcall_engine.dart:1443-1447;守卫record_state_test.go(20 条)、record_target_test.go:30、callrec_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_test、voicemail_outbox_0906_test。
3.7 通话记录、未接来电、推送排除
用户看到的:聊天里一行「📞 语音通话 · 03:21」;未接来电有铃铛;记录页能回拨。
怎么做的:记录是 type: text 加 subtype: call_log 的普通消息(不新开 type,否则等于白拿一张免死金牌);它不加未读、不推送,未接来电靠 calls/log.go 写 missed_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 显式 subtypehandler.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:77、calllog_guard_test.go:71、call_log_subtype_test。
3.8 媒体与网络:LiveKit 怎么部署、质量怎么调
用户看到的:同一 WiFi 下清晰不卡;弱网自动降到语音优先;掉档有提示。
怎么做的:LiveKit 在生产 compose 里(信令 7880 内网,媒体 UDP 7882 单端口 + TCP 7881),Caddy 在 api.hoopcomm.com 上 handle /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 在听;.env 有 LIVEKIT_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_test、group_call_bandwidth_test、call_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_test、group_call_speaker_pin_test、call_edge_swipe_0905_test、call_pip_swap_1v1_test[decision@2026-09-07,Jeff 配图 + 语音 → Tora v1.882][decision@2026-09-04,Jeff 语音:「轻轻一推它就会上」→ v1.832 边缘右滑]。
4. 还没做的(不是现状)
| 项 | 状态 | 出处 |
|---|---|---|
~~1v1 hangUp 里 try/catch 包不住 callRecord('stop')~~ |
已不成立:CallSession.hangUp 现在直接 await super.hangUp(),callRecord 全仓只在引擎(Future.sync(...).catchError)和录音 part 里调;call_engine.dart:1438 那句「同一颗雷还埋着」是陈注释 |
call_session.dart:454-460、call_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-772、gcall_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_test、gate_fail_closed_test、gate_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:3268、calls/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_test、group_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}.go、backend/internal/domain/callkeys/callkeys.go、backend/internal/domain/message/{handler,service,model,repository}.go(call.* 信令、OfflineCallOffer、通话记录、VoIP 令牌)、backend/internal/platform/push/{push,apns}.go、backend/internal/config/config.go、backend/internal/server/router.go、backend/internal/domain/auth/service.go、backend/internal/domain/conversation/handler.go;Applib/services/{call_engine*,call_session,call_waiting,callkit_guard,group_call_session,group_call_state,call_log_service,voicemail_service,voicemail_outbox}.dart、lib/screens/{call_screen,group_call_screen,home_screen}.dart、lib/widgets/{call_float_pip,call_edge_swipe,group_call_pip,group_call_stage}.dart、lib/util/logout.dart、lib/api/api_client.dart、ios/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与通话零交集,不动。