fix: align private and channel update semantics

(cherry picked from commit c65f76f56278f74082c4fa792ed49104d5d33c38)
This commit is contained in:
A 2026-06-07 22:22:23 +08:00
parent dce7b92772
commit d84fa6e126
36 changed files with 1765 additions and 382 deletions

View file

@ -58,13 +58,14 @@ DDL 见 [`deploy/migrations/0001_init.up.sql`](../deploy/migrations/0001_init.up
- **`app_configs`** —— `help.getAppConfig` 的 data-backed JSON config包含 TDesktop read mark、quote reply 与 native anti-spam 管理入口所需参数。
- **`countries` / `country_codes`** —— `help.getCountriesList` 的登录页国家区号目录。
- **`update_states`** —— `auth_key_id + user_id` 维度的设备状态快照。账号级 pts 以 `user_update_events` 为权威,设备退出/换号只清当前 auth_key 状态,不删除账号事件。
- **`user_update_events`** —— 按 `user_id` HASH 分区的账号级增量事件队列。承载 `new_message``read_history_inbox/read_history_outbox`(私聊与 channel peer`edit_message``delete_messages`、`contacts_reset`、dialog 置顶/顺序/manual unread/peer settings、dialog filter/order/reload、folder peers、channel 本地清空后的 `channel_available_messages`、当前账号 forum 展示模式 `channel_view_forum_as_messages` 与 allocator gap 的 `noop`,供 `updates.getDifference` 和 outbox worker 补偿错过的推送;设置类事件通过 `UpdateEventStore.AppendWithDispatch``dispatch_outbox` 同事务写入,并持久化 `event_peers` / `peer_settings` / `message_ids` / `dialog_filter` / `filter_order` / `folder_peers` 负载,避免状态只存在在线 push 中。
- **`user_update_events`** —— 按 `user_id` HASH 分区的账号级增量事件队列。承载 `new_message``read_history_inbox/read_history_outbox`(私聊与 channel peer`edit_message``read_message_contents`、`delete_messages`、`contacts_reset`、dialog 置顶/顺序/manual unread/peer settings、dialog filter/order/reload、folder peers、channel 本地清空后的 `channel_available_messages`、当前账号 forum 展示模式 `channel_view_forum_as_messages` 与 allocator gap 的 `noop`,供 `updates.getDifference` 和 outbox worker 补偿错过的推送;设置类事件通过 `UpdateEventStore.AppendWithDispatch``dispatch_outbox` 同事务写入,并持久化 `event_peers` / `peer_settings` / `message_ids` / `dialog_filter` / `filter_order` / `folder_peers` 负载,避免状态只存在在线 push 中。account difference 中的 `updateChannelTooLong` channel nudge 是按 channel events + request date 计算出来的提示,不写入本表、不消耗账号 pts。
- **`contacts`** —— 当前账号通讯录关系与 owner 视角联系人资料。`contact_phone/contact_first_name/contact_last_name/note/note_entities` 均只属于 `(user_id, contact_user_id)`,同一个全局 user 在不同 owner 的通讯录中可以有不同姓名、电话和备注;`mutual` 由双方是否互存维护,删除一方联系人会清理对方 reverse mutual。
- **`contact_blocks`** —— 当前账号 blocklist`owner_user_id` HASH 分区;`(owner_user_id, blocked_user_id)` 唯一,按 `date DESC, blocked_user_id DESC` 返回 `contacts.getBlocked`。当前 full privacy keys 未接入,私聊 send/edit/delete 的拒绝来源仅为这张表。
- **`private_messages`** —— 共享私聊消息主体,按 `sender_user_id` HASH 分区;`sender_user_id + random_id` 唯一保证 `messages.sendMessage/forwardMessages` 幂等;文本编辑更新共享 body/entities/edit_datesilent/noforwards/reply_to/fwd_from 元数据随消息持久化。
- **`message_boxes`** —— owner 视角消息盒,按 `owner_user_id` HASH 分区;每个账号看到自己的 `box_id`、peer、outgoing、pts、edit_date 与删除状态,历史/搜索走该表索引。删除只软删 owner 视角 message_box`revoke` 通过 `(message_sender_id, private_message_id)` 定位其它 owner 视角并软删;编辑会同步所有可见 owner 视角盒子。该反向定位与分区键不一致,规划会展开全部 owner 分区,后续应增加 unpartitioned box 映射或先推导 owner_user_id 后再按 owner 分区点查。
- **`message_boxes`** —— owner 视角消息盒,按 `owner_user_id` HASH 分区;每个账号看到自己的 `box_id`、peer、outgoing、pts、edit_date`media_unread/reaction_unread` 与删除状态,历史/搜索走该表索引。删除只软删 owner 视角 message_box`revoke` 通过 `(message_sender_id, private_message_id)` 定位其它 owner 视角并软删;编辑会同步所有可见 owner 视角盒子。`messages.readMessageContents` 只锁定当前 owner exact ids 且仅清理 unread 状态为 true 的行,有变化才写 `read_message_contents` durable event。该反向定位与分区键不一致,规划会展开全部 owner 分区,后续应增加 unpartitioned box 映射或先推导 owner_user_id 后再按 owner 分区点查。
- **`dialogs`** —— 当前账号会话摘要,按 `user_id` HASH 分区;只允许 `user` peer支持 top message、置顶过滤、folder_id=0/1 主列表/归档与 offset 分页,并保存当前 owner 的 `pinned_order`、manual `unread_mark``hidden_peer_settings_bar`。列表查询 join `contacts` 时优先返回当前 owner 保存的联系人姓名/电话,避免不同账号看同一 peer 串备注;相关状态变化会写入账号级 durable update log离线设备可通过 `updates.getDifference` 恢复。
- **`channels` / `channel_messages` / `channel_message_viewers` / `channel_update_events`** —— 超级群/频道单份消息模型,按 `channel_id` HASH 分区;`channels.pts` 是 channel-scoped durable log 水位,`channels.forum/forum_tabs` 持久化 megagroup topics 开关与 TDesktop tabs/list 布局,`channels.participants_hidden` 持久化隐藏成员设置并由 `ChannelFull.participants_hidden` 恢复 TDesktop UI`channels.antispam` 持久化 native anti-spam 开关并由 `ChannelFull.antispam` 恢复管理入口,`channels.color_set/color/color_background_emoji_id/profile_color_set/profile_color/profile_color_background_emoji_id/emoji_status_document_id/emoji_status_until` 持久化频道外观并回填 `Channel.color/profile_color/emoji_status``channels.linked_chat_id` 维护 broadcast 与 discussion megagroup 的双向链接并走 `channels_linked_chat_idx` 反查,`channel_update_events(channel_id, pts)` 存 new/edit/delete/pin/noop 的恢复负载,成员权限/封禁变化不进入该 log`channel_messages(channel_id, id)` 走 seek pagination 并保存 `views_count` 聚合列,`channel_message_viewers(channel_id,message_id,viewer_user_id)` 用主键完成 views 去重递增,`reply_to_msg_id/reply_to_top_id` 支撑 thread/comment 分页,`discussion_channel_id/discussion_message_id` 把 broadcast post 映射到 linked megagroup root禁止按成员写扩散。
- **`channel_members` / `channel_dialogs` / `channel_unread_mentions`** —— 成员权限、读水位、owner 视角 channel dialog 与未读提及索引,分别按 `channel_id` / `user_id` / `user_id` HASH 分区;`available_min_id` 限制成员可见历史消息,`available_min_pts` 限制 `updates.getChannelDifference` 起点,避免新成员或重新加入成员恢复到入群前的消息类 durable 事件。`channel_dialogs.unread_count` 是小超级群普通未读缓存字段,不是 broadcast/大超级群真值;大频道读取 dialog/full channel 时按 `channel_members.read_inbox_max_id``available_min_id``channels.top_message_id` 与未删除消息动态派生普通未读。`channel_dialogs.default_send_as_peer_type/default_send_as_peer_id` 保存当前 owner 的默认发送身份,由 `channels.getFullChannel` 输出为 `channelFull.default_send_as`,不参与历史分页或 dialog 排序;`channel_dialogs.view_forum_as_messages` 是当前账号本地 forum 展示模式,由 `Dialog/ChannelFull.view_forum_as_messages` 恢复 UI并通过账号级 durable update 同步多 session。`channel_unread_mentions(user_id,channel_id,message_id)` 不复制消息正文,发送时只写解析出的 active/可见/未读成员,清除后重算 `channel_dialogs.unread_mentions_count`。共同超级群查询已迁到 `user_channel_member_index(user_id, channel_id)`,排除 broadcast 与非 active/deleted 成员;后续 `channels.getLeftChannels`、joined/admined channel 列表和启动 dialog 聚合也必须从 user 维度 read model 或两步 channel_id 列表读取,不能直接用 `channel_members WHERE user_id=...` 反向扫 `channel_id` 分区。
- **`channel_members` / `channel_dialogs` / `channel_unread_mentions`** —— 成员权限、读水位、owner 视角 channel dialog 与未读提及索引,分别按 `channel_id` / `user_id` / `user_id` HASH 分区;`available_min_id` 限制成员可见历史消息,`available_min_pts` 限制 `updates.getChannelDifference` 起点,避免新成员或重新加入成员恢复到入群前的消息类 durable 事件。`channel_dialogs.unread_count` 是小超级群普通未读缓存字段,不是 broadcast/大超级群真值;大频道读取 dialog/full channel 时按 `channel_members.read_inbox_max_id``available_min_id``channels.top_message_id` 与未删除消息动态派生普通未读。`channel_dialogs.default_send_as_peer_type/default_send_as_peer_id` 保存当前 owner 的默认发送身份,由 `channels.getFullChannel` 输出为 `channelFull.default_send_as`,不参与历史分页或 dialog 排序;`channel_dialogs.view_forum_as_messages` 是当前账号本地 forum 展示模式,由 `Dialog/ChannelFull.view_forum_as_messages` 恢复 UI并通过账号级 durable update 同步多 session。`channel_unread_mentions(user_id,channel_id,message_id,media_unread)` 不复制消息正文,发送/编辑时只写解析出的 active/可见/未读成员,清除后重算 `channel_dialogs.unread_mentions_count`history/getMessages/getChannelDifference/online update 按 viewer 用它回填 `mentioned/media_unread`。共同超级群查询已迁到 `user_channel_member_index(user_id, channel_id)`,排除 broadcast 与非 active/deleted 成员;后续 `channels.getLeftChannels`、joined/admined channel 列表和启动 dialog 聚合也必须从 user 维度 read model 或两步 channel_id 列表读取,不能直接用 `channel_members WHERE user_id=...` 反向扫 `channel_id` 分区。
- **`channel_invites` / `channel_invite_importers`** —— 邀请链接、导入者与 join request read model均按 `channel_id` HASH 分区invite 保存 `usage_count/requested_count`importer 以 `(channel_id,user_id)` 保证同一用户只有一个 pending/approved 状态,管理页查询走 `admin/revoked/offset_link``requested/link/date/user_id` seek 索引,禁止按超大 limit 或 hash 反查做全表扫。public `channels.toggleJoinRequest` 使用 `channels.join_request``channel_invite_importers(invite_id=0, requested=true)` 表达非 invite-link pending request管理员实时提醒用 bounded `updatePendingJoinRequests` + full channel 回填,不为每条 pending 状态生成无界 durable updates。
- **`dialog_filters` / `dialog_filter_settings`** —— 当前账号自定义 dialog filter、filter 顺序与 folder tags 开关,按 `user_id` HASH 分区;自定义 filter 从 ID 2 开始,归档只由 `dialogs.folder_id=1` 表达,避免一列同时承担归档状态和任意筛选规则。
- **`dispatch_outbox`** —— 按 `target_user_id` HASH 分区的 transactional outbox。发送事务内写入RPC outbox worker 用 `FOR UPDATE SKIP LOCKED` 批量 claim成功标记 delivered失败退避重试排除当前设备使用 `exclude_auth_key_id + exclude_session_id`,避免一个设备换号或多账号登录时误过滤。按 target 分区适合投递完成/失败按用户更新,但全局 claim/cleanup 与分区键不一致,规划会展开所有分区;上量前需引入 ready queue 或 worker shard read model。
@ -145,6 +146,7 @@ internal/store/
- **P4.3 删除消息/清空历史**`messages.deleteMessages/deleteHistory` 已支持 owner 视角软删除、`revoke` 对端清理、dialog top 重算/删除/`just_clear` 保留空 dialog、`updateDeleteMessages` durable payload`message_ids` + `pts_count=len(message_ids)`)与 outbox 投递;后续新消息会正常重建被删除的 dialog。—— ✅
- **P4.4 Dialog 分组/归档**`messages.getDialogFilters/updateDialogFilter/updateDialogFiltersOrder/toggleDialogFilterTags``folders.editPeerFolders` 已接入 PG-backed 服务;集成测试覆盖 folder_id 0/1 主列表/归档、自定义 filter、tags 和归档还原。—— ✅
- **P4.5 TDesktop 搜索入口**`contacts.search` 已支持联系人/非联系人用户搜索,`contacts.getSponsoredPeers` 返回空 sponsored peers`messages.searchGlobal` 接当前 owner 私聊文本搜索;查询保护与索引设计参考实现,避免客户端搜索框卡在 Loading。—— ✅
- **P4.6 内容已读与 blocklist privacy gate**`message_boxes.media_unread/reaction_unread``user_update_events(read_message_contents)``contact_blocks` 已落地;`messages.readMessageContents` 只在实际清理 unread 内容状态时分配 pts 并 durable 推送,被 block 后私聊 send 只写 sender outboxedit/revoke delete 会返回 forbidden。—— ✅
## 7. 与铁律的关系