| 档案 | |
|---|---|
module |
chat |
summary |
聊天:会话与七种房型的消息底座。只有文字(HTTP / WS)和普通转发是同一事务落库 + 发件箱保投递;附件、贴纸、投票、链、工具卡、帖子转发都是分步写入、无发件箱。WS 推 + 离线设备 APNs;撤回 60 小时、当前接口无恢复入口;到期服务端 60 秒扫 + 客户端掐表。 |
updated |
2026-09-07 |
verified_ref |
1cfdcfcb |
source_ref |
[backend/internal/domain/message, backend/internal/domain/conversation, backend/internal/realtime, backend/internal/domain/chattool, backend/internal/domain/sticker, backend/internal/domain/notification, backend/internal/domain/presence, backend/internal/domain/block, backend/internal/domain/report, app/lib/api/ws_client.dart, app/lib/api/api_client.dart, app/lib/data/local_store.dart, app/lib/data/conv_sync.dart, app/lib/screens/chat_screen.dart, app/lib/screens/conversations_screen.dart, app/lib/widgets/voice_bubble.dart, app/lib/services/voicemail_outbox.dart, app/lib/services/notification_route.dart, app/lib/util/app_badge.dart, backend/migrations] |
status |
current |
review_after |
2026-12-06 |
owner |
nova |
supersedes |
[MESSAGE_DELIVERY.md] |
partially_supersedes |
[API.md(会话 / 消息 / WS 三节), ARCHITECTURE.md(消息核心流程 / 会话列表增量同步 / 消息层缺口), CHAT_SECURITY_STABILITY_REVIEW_2026-09-06.md(结论), CHAT_BENCHMARK_BASELINE_2026-09-06.md(结论数字), WHATSAPP_PARITY.md(§3-6 / §11-12)] |
这份怎么读:每一节先讲「它解决什么、用户看到什么」,再讲「为什么这样做、数据在哪、哪一步会断、断了留下什么、有什么保障和缺口」;代码位置、参数、行号收在每段末尾的「出处」里,要核查再看。 出处标记:
[code@1cfdcfcb 文件:行]= 那棵树上的实现;[prod@日期]= 生产机 / 数据库当天核过;[decision@日期,谁]= 拍板过的决定。三种不混 —— 代码证明「那一版怎么做的」,证明不了「线上是这样」和「应该是这样」。
0. 给接手的人:三分钟读懂聊天
HOOP 的聊天是整个产品的地基:私聊、群、幽灵房、一起听、狼人杀、工作房、频道,七种房型跑的是同一条消息路、同一个聊天屏。你在别的模组(通话、社群、游戏房)看到的"发消息",最后都落到这里。
先认四个词(后面到处用):事务 = 数据库里「几笔写要么全成、要么全不成」的一次操作;发件箱 = 消息落库时顺手记的一条「待投递任务」,没投完就留着,有人定时来补;回执 = 每个收件人一行「收到没 / 读了没」的记录;幂等 = 同一条消息重发多少次,服务器只入库一次(靠手机生成的 client_message_id 认出来)。
它保证到什么程度,以及各自靠什么:
1. 文字和普通转发,成功落库时会同时记下待投递任务 —— 消息、回执、会话未读、发件箱行写在同一个事务里;暂时投递失败可以补投(每 5 秒扫,最多 8 次、7 天内),不承诺设备必达。手机上的「失败」气泡分三种,接手人要分清:① 本次未发送 —— 发出前已知未连接,或被本地校验拦下(引用了还没发出去的临时消息);② 服务器未新增 —— 服务器明确拒绝(闸没过)或事务回滚;③ 结果未知 —— 消息已经发出,只因断线或 12 秒没收到回显而显示失败,服务器可能已经保存了。具备重试条件后(网络回来了、引用的消息已发出),重试仍沿用原 client_message_id,服务器按幂等处理,不会重复入库;禁言、被拉黑这类原因没解决,反复点也不会成功。附件目前不在这个保证里(§1.2、§3.3,这是当前最大的缺口)。
2. 对方在线就即时到,不在线就推送 —— 在线靠 WebSocket 长连接;离线靠 APNs,按设备记账,已记录成功的设备会跳过,仍可能重复通知。「推送发出去了」不等于「对方设备收到了」,三个词的边界在 §1.1 末段。
3. 两边看到的一样 —— 撤回、编辑、到期在服务器和手机端各有一道闸,重连时能补上离线期间被撤回 / 编辑的;手机本地缓存认主人,换号不串。
当前明确没做的(§4 有全表,这里只说最要紧的三条):附件没进事务和发件箱;贴纸 / 投票 / 工具卡这类"花样消息"广播丢了没人补;还没有双真机验收和本地缓存加密。
出了问题先查哪:§5 有一条排障路;一句话版 —— 拿消息 id 在后端日志里串一生,看发件箱那行还在不在,再看八个 chat_* 指标哪个红。
1. 一条消息的一生(两个完整例子)
1.1 发一段文字:正常 → 断线 → 失败重发 → 对方离线 → 已读
你看到的:按下发送,气泡立刻出现在自己屏上(带"发送中");一两秒后变成单勾(服务器收到);对方读了变双勾深蓝。断网时气泡停在"发送中",12 秒没回执变成"失败",可以点重发;重新联网后没发出去的会自动补。
背后发生的,按顺序:
1. 手机先摆气泡、再发。App 给这条消息生成一个 client_message_id(UUID),把它写进本地缓存并画出乐观气泡,然后从 WebSocket 发 message.send,同时起一个 12 秒的钟。为什么先摆气泡:用户不该等网络(实现推断,没有单独的决定记录);为什么带 id:重发时服务器能认出「这是同一条」,只入库一次(幂等)。引用了一条本地还没发出去的临时消息、或者根本没连上,当场判失败,不白等 12 秒。
2. 服务器过六道闸。顺序固定:是不是成员(私聊不存在就顺手建)→ 正文不空且不超 65,536 字 → 每分钟不超 60 条 → 没被对方拉黑 → 好友关系 → 没被禁言 → 房没锁(仅管理员可发 / 群停用 / 频道只读 / 狼人杀锁字)。任何一道不过,回 error 帧,气泡标失败并写原因。
3. 一个事务落库。消息行、每个成员的回执行、会话的"最后一条 + 未读数"、发件箱一行,四样在同一个 pgx 事务里写;去重查在事务前,撞了唯一键回滚再查一次。为什么放一个事务:以前是分步写,中间断了就出现「消息在、未读没加」这类半截状态(CHANGELOG 2026-09-06「九条全修」③ InsertAtomic,Vega 做、Gode 审)。
4. 广播。服务器把 message.new 发到 Redis 的单一频道 hoop:events,每台后端实例收到后只投给自己连着的成员。同一个人的所有设备都收。发送者自己也收一份回显 —— 手机就靠这份回显把乐观气泡变成"已发送";重发导致的重复消息,只回投发送者、不推送。
5. 推送。不在线的成员走 APNs;"在线"的判据是最近 25 秒内有心跳。已经推过的设备记进 pushed_tokens,补推时跳过。整体预算 2 分钟,32 路并发。通话记录类消息不推。
6. 发件箱收尾。广播成功打一个标、推送做完(没有可重试失败)打一个标,两标齐了删那一行。任何一步没打全,中继器每 5 秒扫一次:一次只领一行、租约 5 分钟(盖过推送预算)、按 2 的 n 次方秒退避封顶 300 秒、8 次封顶、7 天没投出去的清掉。中继器重读消息时发现已被撤回或到期,直接丢弃不再投。
7. 对方手机收到。收到 message.new 写缓存、画气泡、更新会话列表。断过线的手机重连后用 ?after= 补齐,并带 changed —— 离线期间被撤回或编辑的消息清单(上限 200,超了给 changed_truncated,手机整段重拉)。
8. 已读。对方手机只在 App 在前台时才打 /read;服务器只把 read 事件投给这批消息的发件人和读者自己,不投全群(群里几十个人的已读不需要人人知道)。已读不补 —— 断线期间的已读不会事后补发。
三个词的边界(MESSAGE_DELIVERY 定的,本文沿用):已保存 = 事务提交;已投递 = 广播进了 Redis,且适用的推送这一步做完 —— 在线的人不走 APNs,没配置推送、推送关了、通话记录、没有离线目标都直接算做完;这不证明对方设备已经收到。已读 = /read 回执,不补。屏上不会重,推送可能多响一次 —— 不作严格一次承诺。
哪一步失败会留下什么:第 1 步有两种 —— 发出前已知未连接或被本地校验拦下 = 本次未发送,本次尝试没有向服务器发送;已经发出、只是 12 秒没收到回显 = 结果未知,服务器可能已经保存,手机把它标成失败只是「没收到确认」,不是「没保存」,所以重试必须沿用同一个 client_message_id(幂等);第 2 步失败 = 服务器明确拒绝,什么都没写,手机收到 error;第 3 步失败 = 整个事务回滚,服务器未新增;第 4 / 5 步失败 = 发件箱那行留着,中继器 5 秒后开始补,屏上不会重、推送可能多响一次 —— 这是有意的取舍,不做严格一次;第 7 步(手机没收到广播)= 重连补齐兜底。
出处:摆气泡与发送
_send→_enqueue→_dispatch[code@1cfdcfcb app/lib/screens/chat_screen.dart:2290,2389-2461],乐观气泡唯一入口_addTempMessage(:2708-2745),_dispatch里三种失败:引用临时消息:2426-2434、未连接:2433-2438、发出后 12 秒没回显:2440-2454(后一种服务器可能已提交);六道闸[code@1cfdcfcb backend/internal/domain/message/service.go:322-380],字数MaxTextRunes = 65536(model.go:13,判在:353,2594),限流maxTextPerMin = 60(:42,357);事务InsertAtomic[code@1cfdcfcb backend/internal/domain/message/repository.go:93](:99-140),文字Send调它[code@1cfdcfcb backend/internal/domain/message/service.go:438];广播扇出[code@1cfdcfcb backend/internal/realtime/hub.go:20-33,228],去重重发只回投发送者[code@1cfdcfcb backend/internal/domain/message/handler.go:372-377];推送PushNewMessageExcept[code@1cfdcfcb backend/internal/domain/message/service.go:2847](:2856-2870,2830,2919,2932-2935),在线新鲜窗口 25 秒realtime/conn.go:38;发件箱与中继器[code@1cfdcfcb backend/internal/domain/message/outbox.go:194](参数:211-213,领取:80,退避:114,清死信:145,撤回不补:250-262,两标:63-72,161,178);补齐MessagesAfterservice.go:2539+ChangedSince[code@1cfdcfcb backend/internal/domain/message/repository.go:301-316];已读MarkRead[code@1cfdcfcb backend/internal/domain/message/service.go:481](:492-518),手机前台才打[code@1cfdcfcb app/lib/screens/chat_screen.dart:2272-2288]。
1.2 发一张照片:上传成功 ≠ 消息完整保存
你看到的:选图后气泡立刻出现,带进度条;进度满了还要等一下(那是在等服务器"确认这条消息");成功后和文字一样单勾双勾。失败会标"发送失败"并留原因。
背后发生的:
1. 要票。手机先向服务器要上传票(presign):一次最多 9 个文件、单个不超过 100 MB、拒绝可执行脚本类型、每分钟最多 10 个;票 15 分钟有效,存在 Redis。这一步也过发言闸 —— 被禁言的人连票都拿不到。
2. 直传。手机拿票把字节直接 PUT 到对象存储(R2),不经过后端;后端看不到字节。
3. 确认(finalize)。手机告诉服务器"传完了",服务器用票换出对象 key,然后分四步写库:先插消息行,再插附件行,再插回执,最后更新会话最后一条。票在这一步开头就被消耗(GetDel)。之后只有两类失败会尝试把票写回 10 分钟让手机重试(写回本身不查 Redis 返回值,:1015):换票后的前置校验没过、插消息行失败。插附件、插回执、更新会话这三步失败则直接返回,票不恢复 —— 留下的是「消息行有、后面缺一截」的半截消息,而且原票已经没了,手机拿同一张票再试只会得到「票不存在」;接手人判断能不能重试,就看失败在哪一步。用户放弃则调 abandon,票作废、已传对象删掉。
4. 之后的广播 / 推送和文字一样。
这里的缺口,接手人必须知道:第 3 步的四次写不在同一个事务里,也没有发件箱行。所以:① 四步中间断了,可能留下"消息行有、附件行没有"或"消息在、会话最后一条没更新"这类半截状态;② 广播如果丢了(Redis 抖一下),没有中继器来补 —— 之后调用的那个共用扇出函数只会更新"已有的"发件箱行,附件没有那一行。文字消息 09-06 已经改成一个事务,附件还没跟上;这是 §4 未实现表的第一行。
手机端的补救:视频有幂等锚,重试不会传两份;上传中退出页面,语音能从缓存接着重发,图 / 视频 / 文件目前只能提示"重发不了"。
出处:要票
PresignUpload[code@1cfdcfcb backend/internal/domain/message/service.go:876](9 个:871,100 MBhandler.go:83,脚本 MIME:822,885,每分钟 10 个:43,895,票 15 分钟:903-922);确认FinalizeUpload四步Insert(:1103)→InsertAttachment(:1120)→InsertReceipts(:1138)→TouchLastMessage(:1142),票消耗GetDel:1011,restore定义:1015,会恢复票的两类失败:1027-1034,1104-1106,不恢复的三步:1120,1138,1142,放弃AbandonUploads:930;multipart 那条老路同样四步Insert(:713;712 是safeReplyTo)→:731→:749→:753;Repository.Insert不写发件箱[code@1cfdcfcb backend/internal/domain/message/repository.go:42-58];共用扇出只更新已有发件箱行[code@1cfdcfcb backend/internal/domain/message/handler.go:1361-1376](附件在:177,283调它);手机 presign → PUT → finalize[code@1cfdcfcb app/lib/api/api_client.dart:2388-2531],页面侧chat_screen.dart:4990-5011,5338,5447,视频幂等锚:5381,进度 null = 等确认:1029,5553,图 / 视频 / 文件不能从缓存重发:2662-2666。
2. 用户能看到的能力(怎么用 / 限制 / 入口)
下面这张表给人看:一行一个能力,写清怎么用、有什么限制、代码入口在哪。规则本体在 §3。
| 能力 | 怎么用 / 限制 | 后端入口 [code@1cfdcfcb backend/internal/domain/message/handler.go:45-80] |
App 落点 |
|---|---|---|---|
| 发文字 | ≤ 65,536 字;每分钟 60 条;@ 提及;引用回复 | POST …/text(:48)、WS message.send |
chat_screen.dart 输入栏;mention_caption_field、reply_compose_bar、swipe_to_reply |
| 发图 / 视频 / 文件 | 一次 ≤ 9 个、单个 ≤ 100 MB、每分钟 10 个;直传 R2 | presign(:49)→ finalize(:50)/ abandon(:51) | chat_plus_panel、quick_gallery_sheet、send_file_sheet |
| 语音消息 | 按住录、波形、拖着听;同一时刻只播一条 | 同附件 | recording_bar、voice_bubble(_startPlaying 闸) |
| 贴纸 | 官方包人人可发,私人贴纸只归自己;可收藏 / 保存 | POST …/stickers(:59);/v1/stickers* |
sticker_pane、emoji_sticker_panel;长按 view/save/fav_sticker |
| 回复 / 引用 | 引用挂不上不拒发,丢引用 | 随消息 reply_to |
reply_banner_slot |
| 转发 | 到期消息不许转;附件复用同一对象 | POST /v1/messages/{mid}/forward(:58) |
forward_picker_sheet |
| 编辑 | 只改自己的;过发言闸;所有人收到 message.edited |
PATCH …/messages/{mid}(:57) |
长按 edit |
| 撤回 | 60 小时内、只撤自己的;留墓碑 | DELETE …/messages/{mid}(:56) |
长按 delete;deleted_tombstone_line |
| 反应 | 表情反应;能看谁反应了 | POST/GET …/reactions(:75-76) |
reaction_bar、reaction_users_sheet |
| 收藏 | 跨会话收藏消息 | /v1/messages/{mid}/favorite、GET /v1/favorites(:61-63) |
长按 favorite |
| 置顶消息 | 一会话一条;广播 pinned |
/v1/conversations/{id}/pin、…/pinned(:64-66) |
长按 pin |
| 投票 | 建投票、投票、看结果;多选(000197) | …/poll、/vote、GET …/poll(:67-69) |
poll_compose_screen、poll_card |
| 接龙链 | 建链、加入、移出、看条目 | …/chain*(:70-74) |
chain_card |
| 聊天工具 / 工具卡 | 房里装工具、开局发卡;卡失败要说人话 | …/tool-card(:71);/v1/tools*(chattool) |
chat_tool_card、chat_tool_layer |
| 搜索 | 全文搜(Meilisearch),范围不越出自己的会话 | GET /v1/search/messages(:77) |
chat_search_bar |
| 已读 | 双勾深蓝(v1.872);谁读了只有发送者能看;可关回执 | POST …/read(:52)、GET /v1/messages/{mid}/receipts(:53) |
长按 receipts |
| 正在输入 | 3 秒 | WS typing |
typing_bubble |
| 限时消息 | 群设置选档位;到期两端一起消失 | PATCH …/settings(conversation :231) |
群设置 |
| 消息闹钟 | 给一条消息设提醒,到点回到它 | — | 长按 alarm;message_alarm_test.dart |
| 会话:置顶 / 静音 / 归档 / 删除 / 清空 | 归档 = vault;清空只清自己这端 | POST/DELETE …/vault(:195-196)、POST …/clear(:194)、DELETE …(:193) |
conv_actions_sheet(conv_pin / mute / archive / delete)、archived_conversations_screen |
| 从我这端删除 | 只删本地,写读两头拦 | — | local_store.dart:535-558 |
| 拉黑 / 举报 | 拉黑后对方发不进(403 blocked);举报 = 理由 + 说明 + ≤ 4 张图 |
/v1/blocks(block)、POST /v1/reports(report) |
长按 report;资料页拉黑 |
| 聊天背景 | 实时预览 | — | chat_wallpaper_screen |
| 分享进来 / 深链 / 粘贴图 | 系统分享面板选会话;hoop://call|group|invite;粘贴板图片 |
— | share_to_chat_screen、deep_links、system_paste_bridge |
| 群管理 | 成员 / 角色 / 禁言 / 公告 / 入群申请 / 管理员申请 / 转让 / 邀请链接 | conversation/handler.go:188-239、invite_link.go:263-266 |
群信息页(规则本体 → community 那份) |
3. 内部怎么运作
3.1 会话与七种房型
是什么:一个会话就是一张 conversations 行,类型七种:direct(私聊)、group(群)、ghost(幽灵房,到期自毁)、music(一起听)、werewolf(狼人杀)、workroom(工作房)、channel(频道,只读广播)。它们共用消息表和聊天屏,差别在各自的规矩(谁能发、什么时候消失),那些规矩归 rooms / community 那两份,这里只管消息。
为什么类型是数据库约束不是 Go 枚举(实现推断,没查到决定记录):加一种房只改一条迁移;代价是 Go 侧没有集中的类型清单,唯一的判据是"是不是私聊"(IsMultiParty = type != "direct")。接手人想知道"现在有几种房",看最新那条迁移的 CHECK,不要在 Go 里找。
会话列表怎么保持最新:手机本地优先 —— 先画缓存,再用 sync?since= 只拉变化(含"消失了的",带 removed=true 墓碑);游标是 "时间|id" 的键集,收官时回拨 3 秒防漏。幽灵房到期是物理删除、没有墓碑,手机靠"死期 + 90 秒"自己判死。排序:置顶 → 最后一条时间(没有就建房时间)倒序。这套是 Jeff 08-07 定的"对标 WhatsApp,不要 MVP"。
出处:类型 CHECK
[code@1cfdcfcb backend/migrations/000187_channel_rooms.up.sql:14];IsMultiParty[code@1cfdcfcb backend/internal/domain/conversation/repository.go:1460];会话路由conversation/handler.go:188-239,邀请链接invite_link.go:263-266;同步实现[code@1cfdcfcb app/lib/data/conv_sync.dart:27](单飞 +has_more封顶 20 批)[decision@2026-08-07,Jeff:「对标 WhatsApp,不要 MVP」]。
3.2 发言闸:谁能在哪发什么
用户看到的:被禁言 / 被拉黑 / 频道非管理员,发出去立刻变失败,气泡上写原因;不会"发出去了对方没收到"。
怎么做的:一条固定顺序的闸(§1.1 第 2 步),文字、附件要票、编辑都过它;撤回故意不过 —— 被禁言的人也能撤自己的话,这是产品上有意的。频道另有一道在 SQL 前:非 owner / admin 发 channel 直接拒。群管理判据是 owner || admin;聊天工具货架谁能管,按房型和角色查表。
改它之前:新增一种"不许发"的情形,要同时加进禁言闸和 admins_only 闸(有测试逼你成对加);新消息类型要同时进数据库白名单(有测试逼)。
出处:闸序
[code@1cfdcfcb backend/internal/domain/message/service.go:322-380];频道channelGate[code@1cfdcfcb backend/internal/domain/message/repository.go:143-152];isAdmin[code@1cfdcfcb backend/internal/domain/conversation/handler.go:241];CanManageTools[code@1cfdcfcb backend/internal/domain/chattool/install.go:33];撤回不套闸[code@1cfdcfcb backend/internal/domain/message/service.go:1352-1359],编辑套闸:1372-1394;守卫send_gates_test.go、message_type_allowlist_test.go。
3.3 落库与发件箱:哪些消息落库时记了待投递任务
结论先说:只有文字(HTTP 和 WS 两个入口都进同一个 Send)和普通转发是"同一事务 + 发件箱"。其余都不是:
- 附件(multipart 和直传 finalize 两条路):分四步写,无发件箱(§1.2 讲透了)。
- 贴纸、帖子转发、投票、接龙链、工具卡:走旧扇出 —— 直接广播 + 后台推送,广播丢了没人补。
- 置顶、反应、投票更新、链更新、已读:只广播,不推送,也无发件箱。广播丢了各有各的下场,不能一概说「重连补齐」:重连的 changed 只按 deleted_at / edited_at 找撤回和编辑;已读明确不补;反应、置顶、投票、链是否在进房时重拉、从哪拉,本文没逐一核 → 待核。
- 通话录音消息:用了同一事务,但没传发件箱 —— 事务和发件箱是两件事,要分开核。
为什么会这样:09-06 那次加固(CHANGELOG「九条全修」③、CHAT-01)先修最常用的文字路;其余入口沿用旧写法 —— 没有「故意不接」的决定记录,是修到哪算哪(实现推断)。接手人要改哪个入口进发件箱,照文字 Send 那段抄:调 InsertAtomic 并传 Outbox。
发件箱本身的规矩(§1.1 第 6 步的细节):建了 10 秒还没打全标才算"该补";一次只领一行,FOR UPDATE SKIP LOCKED,写随机 lease_owner,只有持有人能改状态 —— 第一版把领取当退避 2 秒、第二版固定 60 秒且一次领 200 条,都盖不住整段处理,才改成 5 分钟租约;已推的设备记在 pushed_tokens,补推只补没成的。
出处:
InsertAtomic的两处调用 —— 文字[code@1cfdcfcb backend/internal/domain/message/service.go:438]、转发:1469;通话录音有事务无发件箱:2114;旧扇出五处[code@1cfdcfcb backend/internal/domain/message/handler.go:400-401,421-422,542-543,625-626,686-687],只广播不推:508,570,715,737,777,942;发件箱表 000351、pushed_tokens000353、lease_owner000354[prod@2026-09-06 已上生产];守卫outbox_test.go(提交没扇出 / Redis 恢复 / 只补缺的 / 撤回不补 / 超次放弃)。
3.4 实时通道:长连接怎么保持、怎么鉴权
用户看到的:顶栏偶尔出现"连接中",几秒内消失;换 WiFi、锁屏再回来不用重新登录;30 秒连不上会自己拆了重连。
怎么做的:一条 /ws 长连接。鉴权三条进法 —— 手机原生端把令牌放 Authorization 头(令牌不进网址);老客户端和哨兵脚本用 ?token=;Web 端升级后第一帧交票,5 秒不交或票不对就关(关闭码 4401)。票 15 分钟有效,手机提前 90 秒续;服务器在票过期后再宽限 30 秒,没续就关 4401;续期必须是同一个人的票(不许拿别人的票把连接变成别人)。帧上限 512 KB(要装得下游戏快照);服务器 15 秒 ping、60 秒没 pong 断;慢消费者缓冲 32 帧满了直接以 1013 关掉让它重连补齐,不静默丢帧。
多台后端怎么协作:所有实例订阅 Redis 一个频道,信封里带收件人清单,各实例只投给自己连着的人 —— 简单,但规模大了每台都要看每条,hub.go 抬头写了以后按 user:{id}:server_id 路由,还没做。
手机端的连接纪律(Lisa 09-06 那几刀):退避首跳 300 毫秒、之后 1.2 秒指数、封顶 8 秒(代价是真断网时每 8 秒试一次);每条退出路都必须排下一次重连(有测试逼);连接带世代号,旧连接的回音作废;离开前台超过 20 秒当僵尸重握手;换网即重握手;30 秒没连上看门狗拆了重来;最近 80 条连接大事记长按版本号能看。事件是一条广播流,各屏自己订阅。
出问题先查:手机"一直连接中" → 先看是不是 4401(票坏了)还是握手 401/403(令牌坏了)还是根本连不上(网络 / FD 耗尽,见 §3.12)。
出处:入口与三条进法
[code@1cfdcfcb backend/internal/domain/message/handler.go:80,1035-1056],手机头鉴权[code@1cfdcfcb app/lib/api/ws_connect_io.dart:9]、Web 首帧ws_connect_default.dart:5;首帧 / 4401 / 宽限 / 续期同人[code@1cfdcfcb backend/internal/realtime/conn.go:130-148](:26,35,108-118,194-198);帧上限 / ping / 慢消费者conn.go:45,20,23,47,160-172;扇出与规模化取舍[code@1cfdcfcb backend/internal/realtime/hub.go:20-33,228](:16-19,57,70);上行 kindhandler.go:1107-1210,下行 typehandler.go:266-1362各处、presence/service.go:104、conversation/ghost.go:87;手机纪律[code@1cfdcfcb app/lib/api/ws_client.dart:63,361-380](退避:32-36,每条退出路排重连:396,427-431,世代:90,僵尸:189-209,换网:298-317,看门狗:130-131,159-180,大事记:139-147,4401 强制换票:486,411-415,事件流:251);聊天页处理器[code@1cfdcfcb app/lib/screens/chat_screen.dart:1855];守卫hub_ws_test.go、ws_auth_0903_test.dart、ws_reconnect_never_gives_up_test.dart、ws_watchdog_0906_test.dart、wslimit_test.go、fresh_test.go。
3.5 已读与未读:双勾、角标、口径
用户看到的:对方读了双勾变深蓝;会话列表和桌面角标上的未读数。
怎么做的:手机只在 App 前台时上报已读(后台不算读),回前台补一次;服务器记回执、更新未读、只把 read 事件投给发件人和读者自己;用户关了"已读回执"就不广播。谁读了只有发送者本人能看。未读横线按消息 id 的字典序算(UUIDv7 单调),不比时间戳。
角标怎么写:未读总数只有一个写入口 —— 会话列表求和;桌面角标去抖 200 毫秒写,退到后台那一刻不写(避免写进退出动画切一半),只在值变过时补一次,回前台强制写一次;只在 iOS 写。这是 09-07 五家合议的 v3。
一个已知的口径差:手机求和不含归档会话,后端给推送算角标是全表 SUM(unread_count),含归档。归档会话里有未读时,后台推送写一个数、打开 App 再退又写另一个数。归档算不算未读是产品决定,等 Lisa 拍,拍之前照现状。
出处:
MarkRead[code@1cfdcfcb backend/internal/domain/message/service.go:481](:492,509-518),只投发件人handler.go:927,回执仅发送者:547;手机前台才打[code@1cfdcfcb app/lib/screens/chat_screen.dart:2272-2288](补一次:2263-2266,群 / 1v1 记法:1908-1926);未读横线[code@1cfdcfcb app/lib/util/unread_anchor.dart:22];唯一写入口[code@1cfdcfcb app/lib/screens/conversations_screen.dart:895];角标app_badge.dart:41,52,85-107[decision@2026-09-07,五家合议 v3 派 Tora];后端 SQL[code@1cfdcfcb backend/internal/domain/message/repository.go:1312-1314],手机侧说明[code@1cfdcfcb app/lib/util/app_badge.dart:25-27][decision@2026-09-07 待拍,Lisa];守卫app_badge_lifecycle_test.dart、unread_anchor_test.dart。
3.6 撤回、编辑、转发、引用、到期
撤回 —— 用户看到:自己 60 小时内的消息能撤,撤后两边都变成"此消息已撤回"的墓碑。怎么做的:服务器只是把 deleted_at 打上时间(软删),条件是自己发的、还没删、在 60 小时内;读历史时已删的行照样返回但正文、附件、链接、多语言全清空,引用它的预览也一起清;搜索索引异步尝试删除(后台起一个协程,忽略错误 —— 接口返回不代表索引已删)。手机端把"撤回不可逆"钉在缓存写入口:盒子里已经是墓碑的,一份"活的"副本不许盖回去(09-06 CHAT-03 修的,之前补拉会把撤回的消息复活)。范围要说清:当前接口没有恢复入口(backend / app 里没有把 deleted_at 置回 NULL 的语句),手机也不会复活它;但这不承诺数据库 / 备份层面不可恢复。
编辑 —— 走发言闸(被禁言就不能改),所有人收 message.edited。转发 —— 文字直发、附件复用同一个存储对象;到期消息不许转发;和文字同一条落库路(有事务有发件箱)。帖子转发是另一条路(旧扇出)。引用 —— 引用目标找不到就丢掉引用、照发消息,不因为引用毙掉整条。
到期(限时消息) —— 用户看到:群设置选档位后,消息到点两端一起消失。怎么做的:到期时刻在插入时由数据库触发器算出并随行返回;服务器每 60 秒扫一轮、每轮最多 2000 条,先取媒体 key 再删行、再清搜索;读路径另有一道当场过滤,防扫描间隙漏出来;手机端读出口丢弃并删除已到期的、写入口也挡、页面自己掐表。缺口:到期不由服务端逐条广播,靠两端各自守时。
离线期间的变化怎么补:重连时 ?after= 补新消息,同时按 GREATEST(deleted_at, edited_at) 找出离线期间被撤回 / 编辑的,上限 200,超了整段重拉。
出处:撤回
Recall→SoftDelete[code@1cfdcfcb backend/internal/domain/message/repository.go:663-684],窗口RecallWindow = 60hmodel.go:200(make_interval:671),墓碑清空:261-266,540-542,引用过滤:253-256,索引异步service.go:1345-1346;手机写入口_wouldResurrect[code@1cfdcfcb app/lib/data/local_store.dart:589-602,630-646],页面侧chat_screen.dart:1356,1814,2105;编辑service.go:1360,广播handler.go:357;转发service.go:1406(事务:1469,扇出handler.go:378),帖子转发:1571,贴纸:1507;引用safeReplyToservice.go:305,预览:313;到期触发器repository.go:168-181,扫描[code@1cfdcfcb backend/internal/domain/conversation/disappearing.go:14-18,30,45,116,166],读路径过滤repository.go:266,档位conversation/group_settings.go:26,手机两头拦[code@1cfdcfcb app/lib/data/local_store.dart:568-584,594,631]、页面掐表chat_screen.dart:1225-1258;补齐changed上限 200(CHANGELOG 六条全修 8-A);守卫recall_window_test.dart、chat_audit_0906_cache_test.dart、chat_race_frame_vs_delta_0906_test.dart。
3.7 附件的存与取
存:§1.2 讲了(要票 → 直传 R2 → 确认)。取:下载有两条路 —— 鉴权后先看签名器开没开:开了就取对象 key、302 跳到 CDN 签名地址,字节不经后端,边缘缓存吃 Range;没开才由后端代理流出。线上是开的(MEDIA_CDN_ENABLED=true,cdn.hoopcomm.com,签名 1 小时)→ 线上走 302 那条,排障看 CDN,别在后端找字节。缩略图同理。"生产已是纯 R2"是代码自陈,桶配置没逐一核(§7 待核)。
出处:下载两条路
handler.go:842-880(signer.Enabled():854,302:853-860,代理:863);存储接口internal/platform/storage/storage.go:46,自陈:7-9;线上开关[prod@2026-09-07 18:38 MYT]。
3.8 手机本地缓存与同步
用户看到的:打开 App 立刻有内容,不等网络;换账号登录不会看到上一个人的聊天。
怎么做的:四个 Hive 盒子(会话 / 消息 / 好友 / 杂项),坏了删掉重建、再不行退回内存盒子(Jeff 06-08 定"先 Hive,全方案再上 drift")。缓存认主:盒子记着主人;主人不同就清;没有主人标记且盒子非空、又不是开机钥匙串那条可信路进来的,不认领、清掉(登录路永远按不可信处理 —— 防止无主缓存被新账号认领)。缓存有代次号,不匹配全清。每个会话最多留 300 条,但发送中和失败的永不修剪(它们是待办)。清会话时草稿、@ 名单、群信息一起清。群信息缓存故意不存我的角色 —— 缓存了角色会让按钮显示出来,点了 403。
已知代价:页面快照只保证"先有东西看"不保证正确;动态只缓存第一页 20 条。
出处:盒子
[code@1cfdcfcb app/lib/data/local_store.dart:36-39,71-84][decision@2026-06-08,Jeff:先 Hive];认主adoptOwner:197-209,可信路main.dart:98,登录路auth_store.dart:48;代次:159,170-183;300 条与不修剪:609-628;清会话:665-677;孤儿媒体:128-141;不存角色:738-741;代价:472,696。
3.9 语音消息与通话留言
语音消息 —— 用户看到:按住说、松手发;先出气泡再传;拖波形能从中间听;同一时刻只播一条。怎么做的:录前先停掉正在播的;波形是录时采样的振幅,随消息发;上传每轮都核"这条还是我这个账号的"(换号了一个字节都不发),退避 1 秒、3 秒;失败标失败、可从缓存重发(重启后也行);发成功删本地录音。播放闸:起播只能从一个入口进 —— 先"认领"(失败就不播),取一张票,真正出声前再验票,总闸落下后不许 resume;拖波形起播走同一入口。为什么这么严:CHANGELOG 2026-09-06 v1.840 那条 —— 评审 B/C/D 加三轮复核修的「群通话里放语音把音频会话掰成 playback」和「两条路起播不走同一道闸」,Jeff 09-05 逐条拍的板。
通话留言 —— 归开录那一刻的账号;有自己的发件箱:先落盘再发,发成功才删,重启 / 断线重连 / 切回原账号都补投,7 天没送出去丢弃,同一条不并发。
出处:录音与上传
[code@1cfdcfcb app/lib/screens/chat_screen.dart:6132-6386],真正上传:6389-6440,缓存重发:2668-2706,发成功删录音:2852-2857;播放闸_startPlaying[code@1cfdcfcb app/lib/widgets/voice_bubble.dart:426-457],拖波形同入口:471,闸本体:27-93;留言发件箱[code@1cfdcfcb app/lib/services/voicemail_outbox.dart:1-14,215-261](CHANGELOG v1.858);守卫voice_playback_gate_test.dart、voice_seek_start_claims_0905_test.dart、voicemail_outbox_0906_test.dart。
3.10 贴纸、聊天工具、分享进来、推送打开
贴纸:官方包人人可发、私人贴纸只归主人、官方包用户删不掉;发送走旧扇出(§3.3)。聊天工具:房里装工具、开局发工具卡;每个会话有货架,KV 每键 16 KiB 且必须是 JSON 对象;工具卡发失败必须说人话(有测试:"按了小猫什么都没出来"这种不许)。分享进来:系统分享面板 → 选会话发;深链 hoop://call|group|invite;系统粘贴板的图片送到当前有焦点的输入框。推送打开聊天:认两种载荷,3 秒去重,App 没就绪就排队(400 毫秒 × 50 次),本地找不到会话就拉一次列表(含归档),按房型开对应的屏。来电不走这条路,一律 VoIP → CallKit,没有 VoIP 打开聊天页的路径。通知中心故意没有删除接口。在线状态只在 Redis(90 秒 TTL),没有表。举报证据图最多 4 张。
出处:贴纸路由
[code@1cfdcfcb backend/internal/domain/sticker/sticker.go:290-293]、贴纸包pack.go:203,206、守卫pack_db_test.go;发送api_client.dart:1003-1007;工具[code@1cfdcfcb backend/internal/domain/chattool/handler.go:34-49],手机chat_screen.dart:5610-5631(说人话:5619-5626),自动播两分钟内:1895-1904;分享share_inbox.dart:24-47、深链deep_links.dart:1-45、粘贴system_paste_bridge.dart:1-40;推送打开[code@1cfdcfcb app/lib/services/notification_route.dart:35-198],VoIPhome_screen.dart:287;通知中心[code@1cfdcfcb backend/internal/domain/notification/handler.go:20-29];在线presence/service.go:39-83;拉黑block/handler.go:21-25,举报report/handler.go:111-119,000356[prod@2026-09-07]。
3.11 观测:出了问题看哪几个数
八个 chat_* 指标进了运维体温表,五条告警:发件箱积压 ≥ 10 红、死信 > 0 黄、广播失败每小时 ≥ 5 黄、推送失败每小时 ≥ 50 台黄、发送失败每小时 ≥ 100 红。日志按消息 id 串一条消息的一生,不带正文与令牌。还没做的:客户端侧指标(补拉失败、缓存重建)没有上报通道;运维空间 App 那一屏还没画 chat 块。
出处:八列
[code@1cfdcfcb backend/migrations/000352_ops_chat_metrics.up.sql];采集[code@1cfdcfcb backend/internal/ops/collector.go:124-134];规则[code@1cfdcfcb backend/internal/ops/handler.go:59-106];守卫chat_ops_0906_test.go。
3.12 容量与那次"一直 connecting"
压测(CHAT-05,mini 16 GiB,连接池 20):400 在线连接、100 条每秒、200 人单群、20 并行群,两轮 19,800 条发送、779,400 次预期投递,漏 0 重 0 错 0;长尾两轮不一致(私聊 P99 一轮 604 毫秒一轮 23 毫秒),重连补齐 7 毫秒左右。它没测到容量上限,不替代双真机验收和故障恢复验证。
09-06 晚 build 192 的"一直 connecting":已证实阻塞条件是进程文件描述符耗尽(socketpair failed 24,同类错误 2,442 条);没证实是哪段代码积累的;WS 看门狗只重建连接,不能以此关闭。已做的两刀:自建 HttpClient 超时时 close(force:true)(包里的 connectTimeout 用 Future.timeout,不取消握手,会漏 socket);AuthedHttp 改裸 HttpClient(副作用:13 条音乐 widget 测试基线红,未修)。Jeff 定过:保留现场、不重启不重装、先取证。
出处:压测
[decision@2026-09-06,CHAT_BENCHMARK §结论];现场[decision@2026-09-06,Jeff:保留现场];两刀ws_connect_io.dart:19-33、CHANGELOG 09-07 那条副作用记录。
4. 还没做的(不是现状)
| 项 | 状态 | 出处 |
|---|---|---|
| 附件(multipart / 直传 finalize)进同一事务 + 发件箱 | 未做,分步写入、无发件箱行(§1.2、§3.3) | service.go:713-753,1103-1142;Gode 09-07 二审 |
| 贴纸 / 帖子转发 / 音乐卡 / 投票 / 游戏卡 / 工具卡落发件箱 | 未做,仍分步写入 | MESSAGE_DELIVERY §没做的;§3.3 |
通话录音消息传 Outbox |
未做(有事务无发件箱) | service.go:2114 |
| CHAT-02 双真机验收 | 未做([ ]) |
TASKS.md |
| CHAT-06 本地缓存加密(Hive 开盒无应用层加密) | 未做 | TASKS.md;审查评分安全 82 扣分项 |
| CHAT-07 拆聊天页(1.26 万行) | 未做 | TASKS.md;评分可维护性 60 |
message.deleted / message.edited 广播走发件箱 |
未做,丢了靠重连 changed 兜底 |
MESSAGE_DELIVERY |
changed 分页 |
未做,固定 200 + changed_truncated |
CHANGELOG 六条全修 8-A |
| 到期由服务端逐条广播 | 未做,靠两端各自守时 | CHANGELOG 六条全修 |
回执写入改 INSERT … SELECT unnest |
未做,仍 pgx.Batch 逐人 |
审查 §000353 复核末段 |
| 客户端指标上报通道;运维空间 App 那屏的 chat 块 | 未做(后端已回) | MESSAGE_DELIVERY |
| 中继器独立进程 | 未做,只在后端进程内跑 | 同上 |
| 多设备冲突、媒体本地缓存、云备份 | 明确本期不做 | ARCHITECTURE §本期范围;local_store.dart:31 |
跨实例按 user:{id}:server_id 路由 |
未做,单频道 + 本地过滤 | hub.go:16-19 |
chat_regress.sh 当推送闸 / CI |
未做,只是「推之前自己跑」 | WORKFLOW §4 1a |
| 归档会话未读顶不顶角标 | 待拍 | Lisa |
5. 出问题先查哪里 · 动它之前
排障一条路:
1. 拿到消息 id(手机长按 → 复制,或后端日志)→ 后端日志按 msg= 串它的一生:落库了吗、广播了吗、推了哪些设备。
2. 先问这条消息的入口有没有接发件箱:只有文字和普通转发有;附件、贴纸、投票、链、工具卡、帖子转发从来没有那一行,「不在发件箱」对它们不说明任何事。接了的再看那行在不在:在 = 没投全,看 attempts 和 lease_owner;不在 = 两标齐了,或已死信 / 被丢弃(撤回、到期)。
3. 八个 chat_* 指标哪个红:积压 → 中继器是不是没跑(它只在后端进程内);推送失败 → APNs 证书 / 设备 token。
4. 手机侧:长按版本号看连接大事记(最近 80 条);"一直连接中"先分 4401 / 401 / 连不上三种。
5. 记住两条设计内的现象:屏上不会重复,推送可能多响一次;断线期间的已读不补。
改它之前:
- 改发送路径:先跑 tools/chat_regress.sh(三包真库 + analyze + 十三份测试串行,--selfcheck 证明会红);新的写入口照文字 Send 那样过 InsertAtomic 并传 Outbox,别再加分步写入;新消息类型要同时进 DB 白名单。
- 改 WS:每条退出路都要排下一次重连;票、续期、4401 三件一起看;别把 connectTimeout 换回包里的。
- 改缓存:写入口只有 putMessage / putMessages 两个;撤回墓碑不许被活副本盖回;到期在读写两头都拦;账号切换看 adoptOwner 三种情形。
- 改已读 / 未读:未读总数只有一个写入口;角标别写进退出动画;归档口径动之前等 Lisa。
- 改语音:起播只能从 _startPlaying 进;上传每轮核 stillMine()。
- 改附件:presign 的 9 个 / 100 MB / 15 分钟三个数和 R2 桶配置对得上再动;直传不过后端。
- 改发件箱:租约必须盖过推送预算(5 分钟 > 2 分钟);一次只领一行;pushed_tokens 是"已成功的"不是"试过的"。
- 别信的旧图纸:API.md 抬头"大部分端点尚未实现"、ARCHITECTURE 的分步写入与 sync(cursor)、WHATSAPP_PARITY 的 [ ] Not built 清单 —— 都被本文推翻,读到先查这里。
6. 不能碰的数(当前值 / 实现出处 / 守卫或未覆盖)
「有个常量」不等于「不可改」也不等于「受测试保护」。🔒 = Jeff / 评审明确锁定的;其余是普通配置值,改之前只需把守卫一起改。
| 数 | 当前值 | 实现出处(1cfdcfcb) | 守卫 / 未覆盖 |
|---|---|---|---|
| 文字上限 | 65,536 字 | message/model.go:13 |
textlimit_test.go |
| 撤回窗口 🔒 | 60 h | message/model.go:200、repository.go:671(make_interval) |
app/test/recall_window_test.dart;后端边界未覆盖 |
| WS 票 / 续期提前 / 宽限 / 关闭码 🔒 | 15 min / 90 s / 30 s / 4401 | realtime/conn.go:26,35、ws_client.dart:59,63 |
app/test/ws_auth_0903_test.dart、realtime/hub_ws_test.go |
| WS 帧上限 | 512 KB | realtime/conn.go:45 |
realtime/wslimit_test.go |
| 退避封顶 🔒 | 8 s | ws_client.dart:32-36 |
app/test/ws_retry_delay_test.dart |
| 看门狗 | 30 s | ws_client.dart:130-131 |
app/test/ws_watchdog_0906_test.dart |
| 发件箱 🔒 | 5 s 扫 / 10 s grace / 5 min 租约 / 8 次 / 7 天 | message/outbox.go:211-213 |
message/outbox_test.go;租约 > 推送预算无守卫 |
| 每会话缓存 | 300 条,pending / failed 不修剪 | local_store.dart:609-628 |
未覆盖 |
| 附件 | 9 个 / 100 MB / 15 min / 每分钟 10 个 | service.go:871,895、handler.go:83 |
未覆盖 |
| 历史一页 | 200 条 | message/repository.go(History) |
message/history_limit_test.go |
| 举报证据图 | ≤ 4 张 | report/ |
report/evidence_test.go |
| 在线新鲜窗口 | 25 s(ping 15 s 必须短于它) | realtime/conn.go:23,38 |
realtime/fresh_test.go |
| 限流 | 文字 60 每分钟 / 附件 10 每分钟 | service.go:42-43 |
未覆盖(限流键有测试,阈值本身没有) |
7. 出处与旧图纸去向
- 代码(全部
[code@1cfdcfcb]):backend/internal/domain/message/{handler,service,repository,outbox,model}.go、backend/internal/realtime/{hub,conn}.go、backend/internal/domain/conversation/{handler,repository,disappearing,ghost,invite_link,group_settings}.go、chattool/ sticker/ notification/ presence/ block/ report/、backend/internal/ops/{collector,handler}.go;Applib/api/{ws_client,ws_connect_io,ws_connect_default,api_client}.dart、lib/data/{local_store,conv_sync}.dart、lib/screens/{chat_screen,conversations_screen}.dart、lib/widgets/voice_bubble.dart、lib/services/{voicemail_outbox,notification_route,share_inbox,deep_links,system_paste_bridge}.dart、lib/util/{app_badge,unread_anchor,message_expiry,waveform}.dart。 - 迁移:000001(conversations / messages / receipts / attachments / blocks / reports)、000002 reactions、000007 favorites、000008 pinned、000009 polls、000187 会话类型最新白名单、000199 stickers、000213 notifications、000273 sticker packs、000310-000332 chat tools、000351 outbox、000352 指标、000353 pushed_tokens、000354 lease_owner、000356 report_media(触及聊天的最新号 = 000356)。
- 生产
[prod@2026-09-07,Nova Master 在生产机核;Gode 未连生产,这条是我报的证据]:三次 ssh —— 18:38 MYT(镜像 / 库 /.env)、18:49–18:50 MYT(二进制 grep)、19:22 MYT(集合比对)。后端镜像fe73ccbd706918:00:47 构建,容器 18:01:00 起。迁移文件证据:容器里/usr/local/bin/hoop(30.3 MB,18:00)提取到 311 个嵌入的.up.sql文件名,服务器/root/hoop/backend/migrations/有 311 个,两边文件名集合一致(comm -3无差);000351 / 000352 / 000353 / 000354 / 000356 各命中 1 次;最大号 000358(357 / 358 是 KYC,不属聊天)。嵌入文件的内容一致性未核(只比了名字)。启动日志「数据库迁移:已是最新」、schema_migrations = 358是旁证。.envMEDIA_CDN_ENABLED=true、MEDIA_CDN_BASE=https://cdn.hoopcomm.com。R2 桶配置未逐一核 → 待核。 - 决定:2026-06-08 Jeff 先 Hive;2026-08-07 Jeff 会话列表对标 WhatsApp 不要 MVP;2026-09-02 WS 连接纪律定型;2026-09-04 Jeff 会话列表头「轻轻一推就上」;2026-09-06 Jeff / Lisa 对 Gode 审查「独立对照代码再判,不照单全收」;2026-09-06 Jeff 真机 connecting 保留现场;2026-09-07 Mike Ng 已读双勾换深蓝;2026-09-07 五家合议角标 v3。待拍:归档未读顶不顶角标(Lisa)。
- 旧图纸去向:
MESSAGE_DELIVERY.md整份被本文替代 →_archive/(等 maintainer 挪);API.md的会话 / 消息 / WS 三节、ARCHITECTURE.md的消息流程 / 会话列表同步 / 消息层缺口 → 本文,其余留在原件;CHAT_SECURITY_STABILITY_REVIEW、CHAT_BENCHMARK_BASELINE只取结论,原件留作证据链(执行状态唯一出处 =TASKS.mdCHAT-01…08);WHATSAPP_PARITY.md§3-6 / §11-12 改指本文,§21 定位决策单独保留。