fix: keep participant changes out of channel pts

(cherry picked from commit 07b2497664bd108dec84f6cfe43715540faf2688)
This commit is contained in:
A 2026-06-07 20:57:31 +08:00
parent 23a2b2aff7
commit 6fd690a06e
10 changed files with 184 additions and 115 deletions

View file

@ -92,7 +92,7 @@ gotd 已提供所有需要的 Layer 225 类型和 dispatcher
- `UpdatesGetChannelDifferenceRequest` 返回 `UpdatesChannelDifferenceClass`,请求 limit 对普通用户建议 10-100服务端必须 cap。
- `UpdateNewChannelMessage` 携带 `message/pts/pts_count`
- `UpdateEditChannelMessage``UpdateDeleteChannelMessages``UpdatePinnedChannelMessages` 都走 channel ptsdelete 的 `pts_count` 必须等于本次删除 id 数pin/edit 为 1。
- `UpdateChannelParticipant` 携带 actor、prev/new participant适合 editAdmin/editBanned 的在线更新
- `UpdateChannelParticipant` 携带 actor、prev/new participant适合 editAdmin/editBanned 的在线瞬时更新Layer 225 该 update 不带 `pts/pts_count`,不能作为 channel pts durable event
- `ChatInviteExported``ChatInvite``ChatInviteAlready` 覆盖邀请链接导出、预览和已加入状态。
- `Channel``ChannelFull` 有 TDesktop 最小必需字段:`AccessHash``Broadcast``Megagroup``HasLink``ParticipantsCount``AdminRights``BannedRights``DefaultBannedRights``LinkedChatID``ReadInboxMaxID``ReadOutboxMaxID``UnreadCount``NotifySettings``ExportedInvite``Pts`
- `channels.getGroupsForDiscussion#f5dad378` 无入参,返回 `messages.Chats``channels.setDiscussionGroup#40582bb2``broadcast:InputChannel group:InputChannel`,返回 Bool并显式定义 `LINK_NOT_MODIFIED/BROADCAST_ID_INVALID/MEGAGROUP_ID_INVALID/MEGAGROUP_PREHISTORY_HIDDEN` 等错误。`channelAdminLogEventActionChangeLinkedChat` 可记录管理日志。
@ -128,7 +128,7 @@ username 与管理项方面,参考实现 的 `channels.checkUsername` 只校
参考实现 的 send/forward 请求转换器保留 Layer225 `InputReplyTo`message mapper 再把业务 `ReplyTo/InputReplyTo` 转回 `MessageReplyHeader`;它还用 `MessageForwardedEvent/MessageReplyUpdatedEvent` 维护原消息回复统计。参考实现 的 message/dialog app service 会把 `FwdHeader.FromId/SavedFromPeer/SavedFromId` 放进额外 peer 集合,确保客户端 apply update 前能解析 forward 来源。telesrv 保留 `reply_to_msg_id/top_id/quote` 与 forward header 的客户端可见语义在响应、durable difference、outbox 投递里补齐可解析的 user/channel peer 上下文,并用 `reply_to_top_id` + linked discussion root 维护首版 channel replies 统计/已读。
参考实现 的 admin/ban 流程先校验 `add_admins/ban_users`,再通过 saga 改成员 read model 并把 `updateChannelParticipant` 放入可恢复的 updates 流TDesktop 对 `updateChannelParticipant` 本身不走 channel pts 检查,但会在 `updateChannelTooLong(pts)` 后主动拉 `updates.getChannelDifference`。telesrv 因此把 editAdmin/editBanned 写入 `channel_update_events(channel_participant)`payload 保存 prev/new participant在线响应额外携带 `updateChannelTooLong(pts)` 触发客户端补齐 channel pts。
参考实现 的 admin/ban 流程先校验 `add_admins/ban_users`,再更新成员 read model并通过 `updateChannel`/`updateChannelParticipant` 让在线客户端刷新状态TDesktop 对 `updateChannelParticipant` 本身不走 channel pts 检查,且基线 `applyUpdateNoPtsCheck()` 不处理它。telesrv 因此不把 editAdmin/editBanned 写入 `channel_update_events`,也不为纯权限/封禁状态变化分配 channel pts在线响应/推送只携带 `updateChannelParticipant + updateChannel`,离线客户端通过 full channel、participants 列表或后续可见消息的 channel pts 路径恢复状态。若操作产生可见 service message例如加人/踢人消息),那条 service message 作为 `updateNewChannelMessage` 单独占 channel pts。
### 参考实现 B
@ -148,7 +148,7 @@ username 与管理项方面,参考实现 的 `channels.checkUsername` 只校
参考实现 的 channel outbox 在写入 reply 消息前会按 `channel_id + reply_to_msg_id` 读取被回复消息:如果目标消息已有 `reply_to_top_id` 就继承,否则把当前 `reply_to_msg_id` 作为 top最终把 `ReplyTo/ReplyToTopId` 一起落库forum topic 发送后会更新 topic 的 top message。TDesktop 在 topic 输入框里会发送 `reply_to_msg_id=0 + top_msg_id=topicRootId`,参考实现 的 `InputReplyToMessage.TopMsgId` 也按 topic/thread 维度保存并在 header 上打 `forum_topic`。telesrv 借鉴该语义,但改成事务内校验目标或 topic root 未删除且对当前成员可见,非法目标返回 `REPLY_MESSAGE_ID_INVALID`topic 内普通消息返回 `messageReplyHeader{forum_topic, reply_to_top_id}` 并更新 topic top message。
参考实现 的 `updates.getChannelDifference` 还会在当前 participant 的 `AvailableMinPts > req.pts` 时把请求 pts 抬到 `AvailableMinPts`,再去读 `channel_pts_updates`。这是避免新成员用 `pts=0` 拉到入群前 participant/admin/settings 事件的关键边界telesrv 因此在 `channel_members` 中同时保存 `available_min_pts`,加入/导入/受邀/重新加入时设为加入前 `channels.pts`,消息历史可见性仍由 `available_min_id` 独立控制。
参考实现 的 `updates.getChannelDifference` 还会在当前 participant 的 `AvailableMinPts > req.pts` 时把请求 pts 抬到 `AvailableMinPts`,再去读 `channel_pts_updates`。这是避免新成员用 `pts=0` 拉到入群前消息类 durable 事件的关键边界telesrv 因此在 `channel_members` 中同时保存 `available_min_pts`,加入/导入/受邀/重新加入时设为加入前 `channels.pts`,消息历史可见性仍由 `available_min_id` 独立控制。
参考实现 的 `channels.deleteHistory` 本地清空路径不写 channel pts而是返回并同步 `updateChannelAvailableMessages{channel_id, available_min_id}`TDesktop 在 `api_updates.cpp` 收到后设置 channel `available_min_id` 并对已加载 history 执行 `clearUpTill``ChannelData::setAvailableMinId` 本身不会做 max-clamp。telesrv 采用同一客户端语义:本地清空只更新当前账号成员/dialog 水位,同时写账号级 durable update 供其它设备在线推送或 `updates.getDifference` 离线恢复;返回和推送的 `available_min_id` 必须是实际应用后的单调水位 `max(old_available_min_id, requested_max_id)`,避免多设备乱序或 stale 请求把 TDesktop 本地可见下界回退。
@ -162,7 +162,7 @@ username 与管理项方面,参考实现 的 `channels.checkUsername` 只校
- `importInvite` 必须在同一事务内锁定对应 invite row 后检查并递增 `usage_count`,避免多个客户端同时导入一次性链接时突破 `usage_limit`
- `ChannelMember``ChannelID``UserID``InviterUserID``Role``Status``JoinedAt``LeftAt``AdminRights``BannedRights``Rank``AvailableMinID``AvailableMinPts``ReadInboxMaxID``ReadInboxDate``ReadOutboxMaxID``UnreadMark`
- `ChannelMember.AvailableMinID`:当前成员可见历史下界;开启 prehistory hidden 后,新加入/导入/受邀成员初始化为加入前 `channel.top_message_id`,只看后续消息和自己的加入服务消息。
- `ChannelMember.AvailableMinPts`:当前成员可恢复 channel difference 的 pts 下界;新加入/导入/受邀/重新加入成员初始化为加入前 `channels.pts``updates.getChannelDifference(pts=0)` 也会先抬到该值,避免入群前 participant/admin/settings durable event 泄漏。
- `ChannelMember.AvailableMinPts`:当前成员可恢复 channel difference 的 pts 下界;新加入/导入/受邀/重新加入成员初始化为加入前 `channels.pts``updates.getChannelDifference(pts=0)` 也会先抬到该值,避免入群前消息类 durable event 泄漏。
- `ChannelMember.ReadInboxDate`:当前成员最后一次推进 `read_inbox_max_id` 的时间,用于 `messages.getMessageReadParticipants` 返回 `readParticipantDate.date`;不参与 channel pts。
- `ChannelMember.SlowmodeLastSendDate`:普通成员最近一次成功发言时间,用于服务端按 channel 维度返回 `SLOWMODE_WAIT_X`creator/admin 不受首批 slowmode 限制。
- `ChannelMessage``ChannelID``ID``RandomID``SenderUserID``From``SendAs``Date``EditDate``Post``Silent``NoForwards``Body``Entities``ReplyTo``Forward``Action``Pts``Deleted`
@ -314,10 +314,10 @@ Redis miss 恢复来源:
- `limit` cap普通用户 `1..100`,超过按 100内部硬上限 1000拒绝负数和超大值。
- 若 `pts < 0``pts > current_channel_pts` 返回 `PERSISTENT_TIMESTAMP_INVALID`,避免客户端用未来水位跳过 durable log。
- 从 `channel_update_events` 读取 `pts > req.pts ORDER BY pts ASC LIMIT cap+1`
- `channel_update_events.payload` 对 new/edit/pin/admin participant 等事件保存 domain 快照;`updates.getChannelDifference` 必须优先使用事件时刻的 message/participant snapshot不能回读当前 `channel_messages` 覆盖旧事件,否则连续编辑、删除后的离线补偿会丢失中间状态。
- `channel_update_events.payload` 对 new/edit/pin 等消息类事件保存 domain 快照;`updates.getChannelDifference` 必须优先使用事件时刻的 message snapshot不能回读当前 `channel_messages` 覆盖旧事件,否则连续编辑、删除后的离线补偿会丢失中间状态。
- 没有事件返回 `channelDifferenceEmpty{final=true, pts=current_channel_pts, timeout=30}`
- 事件数 `<= cap` 返回 `channelDifference{final=true, pts=max_pts, new_messages, other_updates, chats, users}`
- 如果当前 member 的 `available_min_pts > req.pts`,先把请求 pts 抬到 `available_min_pts`,从源头跳过入群/重新加入前的 participant/admin/settings 事件。
- 如果当前 member 的 `available_min_pts > req.pts`,先把请求 pts 抬到 `available_min_pts`,从源头跳过入群/重新加入前的消息类 durable 事件。
- 对 `available_min_id` 之后才可见的成员,普通差量仍扫描 durable log 并推进返回 `pts`,但会过滤 `new/edit/delete/pin``message_id <= available_min_id` 的消息内容和 id若本页全被过滤返回 `channelDifferenceEmpty{pts=max_scanned_pts}`,避免隐藏历史或本地清空后的旧消息通过差量恢复泄露。部分可见的 delete/pin 事件只裁剪 `messages` 向量,保留原始 `pts_count`TDesktop 在线 update 用 `pts_count` 推进 channel PTS差量响应最终用 `channelDifference.pts` 初始化,不要求 `len(messages)==pts_count`
- 若 `current_channel_pts - req.pts > cap`,返回 `channelDifferenceTooLong`,包含带当前 channel pts 的 dialog、最新一页有界消息、channel、相关 users避免大频道旧 pts 客户端循环拉取大量差量页。
- 否则事件数 `<= cap` 返回 `channelDifference{final=true, pts=max_pts, new_messages, other_updates, chats, users}`;事件数达到 cap 但仍未追上当前 pts 时返回 `final=false`,客户端会继续拉下一页。
@ -375,7 +375,7 @@ Redis miss 恢复来源:
- 已实现 `messages.getMessagesViews(InputPeerChannel)`TDesktop 每秒最多 100 条批量增量,服务端按 `(channel_id,message_id,viewer_user_id)` 持久去重并维护 `channel_messages.views_count` 聚合列;本地清历史前不可见、已删除或不存在的 id 不递增且返回空 viewreplies/comment 信息继续从 discussion/thread model 回填。
- 已实现 `channels.exportMessageLink`:复制频道/超级群消息链接前会校验 channel message 对当前成员真实存在且未被删除/本地清历史隐藏;公开 username 走 `t.me/{username}/{msg_id}`,私有 channel 走 `t.me/c/{channel_id}/{msg_id}`,普通 reply/thread 链接支持 `?thread={root_id}``grouped/html` 与 linked discussion 的 `?comment=` 细分链接留后续。
- 已实现管理面最小真实能力:`channels.editAdmin/editBanned/editTitle/deleteChannel``channels.getParticipants` 的 admins/kicked/banned/search 等过滤、`messages.updatePinnedMessage/unpinAllMessages``messages.exportChatInvite/checkChatInvite/importChatInvite``channels.deleteChannel` 按 TDesktop/参考实现预期返回并推送 `updateChannel + channelForbidden`,同时 dialog 列表过滤 deleted channel。`channels.editPhoto/messages.editChatPhoto` 在头像 media store 接入前只接受 `inputChatPhotoEmpty` no-op 删除uploaded/existing photo 明确返回 `PHOTO_INVALID`,不伪造 `messageActionChatEditPhoto`
- `channels.editAdmin/editBanned` 会写 channel-scoped `channel_participant` durable event保存 prev/new participant在线响应/推送包含 `updateChannelParticipant + updateChannel + updateChannelTooLong(pts)`,离线或 pts gap 通过 `updates.getChannelDifference` 补偿
- `channels.editAdmin/editBanned` 只更新成员状态/计数并写 admin log不占 channel pts、不写 `channel_update_events`;在线响应/推送包含 `updateChannelParticipant + updateChannel`。离线客户端通过 `channels.getFullChannel/getParticipants/getParticipant` 或后续可见消息触发的 channel state 刷新补偿;如果操作另行产生可见 service message则由该 service message 进入 channel pts
- 已实现公开 username 管理:`channels.checkUsername/updateUsername/getAdminedPublicChannels`PG 用 `channel_usernames(username_lower)` 与 users username 查询避免跨 peer 占用;主 username 的清除只走 `channels.updateUsername("")`
- 已实现 `channels.toggleSignatures`:权限校验后持久化 `channels.signatures`,返回/在线推 `updateChannel`
- 已实现 `channels.updateColor/updateEmojiStatus`:颜色分别持久化 `color/profile_color` 与 background emoji id并保留 color flag 显式 0普通 emoji status 保存 document id/until`emojiStatusEmpty` 清空collectible gift 状态因缺少 gift/read model 先返回 `EMOJI_STATUS_INVALID`。响应、在线推送、`channels.getChannels` 都回填 `Channel.color/profile_color/emoji_status`
@ -394,7 +394,7 @@ Redis miss 恢复来源:
- 已实现 invite managementTDesktop 管理 invite links、admins with invites、importers、join requests 时会调用 `messages.getExportedChatInvites/getExportedChatInvite/editExportedChatInvite/deleteExportedChatInvite/deleteRevokedExportedChatInvites/getAdminsWithInvites/getChatInviteImporters/hideChatJoinRequest/hideAllChatJoinRequests`;当前已持久化 invite 列表、detail/edit/revoke/delete、按 admin 统计、importer/read model、`request_needed` pending join request、单个 approve/dismiss 与 bounded `hideAll``getExportedChatInvites` 使用 `offset_date + offset_link` seek`getChatInviteImporters` limit cap=100`hideAll` 单批最多 1000避免按客户端超大参数生成无界更新subscription/chatlist/paid invite 与 join-request service notification 仍留后续。
- 已实现 public join settings`channels.toggleJoinToSend`/`channels.toggleJoinRequest` 持久化 `channels.join_to_send/join_request` 并返回带 flags 28/29 的 `tg.Channel``join_request` 仅 public megagroup 可开启,非成员 `channels.joinChannel` 会写入 `channel_invite_importers(invite_id=0, requested=true)` 并返回 `INVITE_REQUEST_SENT`,之后可通过 `messages.getChatInviteImporters(requested=true)` 查询和 `messages.hideChatJoinRequest` approve/dismiss。admin 侧 `channels.getFullChannel` 会回填 `requests_pending/recent_requesters`request-needed import/public join 以及 approve/dismiss 会向有界管理员集合推 `updatePendingJoinRequests`;该状态不写入无界 durable update log离线管理员重新打开 full channel 时补偿。
- 已修正当前普通成员的 `channels.getParticipant(inputPeerSelf)` 与 participants 列表 TL constructorTDesktop `requestSelf` 期望普通本人是 `channelParticipantSelf`creator/admin 仍分别返回 creator/admin self 语义;避免客户端记录 `Got self regular participant`,且不改变 domain/store 成员模型。
- PG channel pts 已补失败保护:现有 channel 的 send/edit/deleteHistory/deleteMessages/pin 和成员/标题服务消息在事务失败或权限失败后写 `noop` 占位,避免 Redis 分配过的 channel pts 形成 TDesktop `PtsWaiter` 永久 gap。
- PG channel pts 已补失败保护:现有 channel 的 send/edit/deleteHistory/deleteMessages/pin 和加入/退出/标题等可见 service message 在事务失败或权限失败后写 `noop` 占位,避免 Redis 分配过的 channel pts 形成 TDesktop `PtsWaiter` 永久 gap。
- 2026-06-01 双 TDesktop 在线/离线实测已覆盖超级群发送、reply、forward、edit 与离线恢复Bob 对 Alice 消息 replyBob 将回复 forward 回同一超级群随后编辑该频道消息Bob 关闭期间 Alice 发送 channel 消息Bob 重启后 dialog 未读数=1打开群后看到离线消息Alice/Bob 双窗口均实时显示server 日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / panic。删除、清历史、踢/禁言仍需用户行动时确认后做 UI 实测。
- 2026-06-02 Computer Use 双 TDesktop 复测已覆盖当前非破坏性频道/超级群 UI 路径Alice/Bob 在 `E2E Super 0307` 中双向发送 `cu-round-alice-*` / `cu-round-bob-*` 并实时互见,成员栏显示 2 members/onlineAlice 全局搜索 Bob 的新消息返回 `Found 1 message`Alice 在 `CU Public Search 44238` 频道发布 `cu-channel-round-*` 后频道消息流和左侧 preview 同步更新。server 日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / panic客户端本轮无新增 `Bad participant` / `Got self regular participant`;清空搜索框产生的 `SEARCH_QUERY_EMPTY` 保持可解释。
- 2026-06-02 09:55 Computer Use reaction/sticker 启动复测Debug/Alice 与 DebugBob/Bob 同时打开 `E2E Super 0307`,互发 `cu-stubfix-alice-*` / `cu-stubfix-bob-*` 后双方消息列表和左侧 preview 均可见;打开 emoji 面板触发 `messages.getAvailableReactions` / `messages.getStickerSet` / `messages.getAvailableEffects`server 日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / panicDebug 当前 `log.txt` 无新增 `Unexpected messages.stickerSetNotModified` / participant 告警。右键消息菜单可打开但本轮未显示 reaction 快捷项,真实 reaction sticker animations/custom UI 仍留后续。
@ -468,4 +468,4 @@ TDesktop 最小入口:
- `channel_messages``media` JSONB 快照列(与私聊同构),`SendChannelMessage` 透传 `req.Media`,讨论组联动消息一并带 media`scanChannel*`/`channelMessageColumns` 统一加 `media::text`,所有 history/getMessages/replies/difference 读取路径自动带出。`tgChannelMessage` 在 media 非空时 `SetMedia`。放宽 `channel_messages` content CHECK 为 `body<>'' OR action<>'{}' OR media<>'{}'`
- 频道头像:`channels` 表反范式 `photo_id/photo_dc_id/photo_stripped`migration `0059``channelColumns` + 全部 5 处 channel scanner 同步;`channels.editPhoto`/`messages.editChatPhoto``resolveInputChatPhoto` 上传或引用照片adminchange_info校验后落列并返回 `updateChannel` + 推 channel state`tgChannel.Photo`(ChatPhoto)/`tgChannelFull.ChatPhoto` 渲染真实头像(`getFile``photo:<id>:<type>` 解析忽略 access_hash合成 a/c 尺寸即可下载)。
- 2026-06-03 接手审计修正:`SendChannelMessage` 的空内容校验已把 `req.Media` 纳入,允许超级群/频道发送无 caption 的 photo/document/sticker新增 PG 集成测试覆盖 channel media 经 `ListChannelDifference` 恢复,防止离线 TDesktop 拉差分丢媒体。
- 范围外in-history `MessageActionChatEditPhoto` service 消息留 todo。
- 范围外in-history `MessageActionChatEditPhoto` service 消息留 todo。

View file

@ -127,7 +127,7 @@ status 取值done(真实实现) / stub(兼容响应) / todo(已发现未实
|---|---|---|---|
| updates.getState | done | real | auth_key+user 维度持久化 update_states账号级当前 pts 报告**最大连续已提交 pts**MaxContiguousPts非 allocator 最大已分配值),避免同设备换号/多账号串差分,也避免越过在途空洞 |
| updates.getDifference | done | real | 按 user_id 从 user_update_events 拉取 pts 后增量;只返回从客户端 pts 起**连续**的事件(遇在途空洞即截断),超 100 条置 differenceSlice支持 new_message、read_history_inbox/read_history_outbox私聊与 channel peerchannel read 映射 updateReadChannelInbox、edit_message、message_reactions输出带最新 `message.reactions` 的 affected message + `updateMessageReactions`,并按 viewer 重取最新聚合,避免 TDesktop 离线恢复时本地 message cache 不刷新、delete_messages、contacts_reset、dialog pinned/order/manual unread、peer_settings、dialog filters/folder peers 与 noop gap消息事件携带 fwd/reply 所需 users/chatspayload 来自 durable log。**账号级绝不返回 `differenceTooLong`**:已核对 TDesktop 基线 `api_updates.cpp:516`——对账号级 differenceTooLong 只打一行日志、不读 pts 且漏 `setRequesting(false)`,会永久锁死 update 引擎(`:689` 早退,重连/新 session 不可恢复);落后客户端改用 `differenceSlice` 续传。因此 `user_update_events` **永久保留、不做 retention 裁剪**(对齐 参考实现),详见 docs/performance-audit.md 附录 C |
| updates.getChannelDifference | done | real-channel | 超级群/频道使用 channel 维度 durable log`channel_update_events(channel_id, pts, pts_count, ...)`;按 channel pts 返回 `channelDifferenceEmpty/channelDifference/channelDifferenceTooLong`limit cap=100`pts < 0``pts > current_channel_pts` 返回 `PERSISTENT_TIMESTAMP_INVALID`;当前 member 的 `available_min_pts` 会抬高请求 pts避免新加入/重新加入成员拉到入群前 participant/admin/settings 事件;公共 username 频道允许非成员以只读预览身份拉可见差分并返回 synthetic read dialog私有频道和禁看用户仍返回权限错误`current_channel_pts-pts > cap` 时返回带当前 dialog pts 和最新有界消息快照的 `channelDifferenceTooLong`,避免大频道旧 pts 客户端循环拉大量页;普通 difference 优先使用事件 payload 中的 message/participant 快照,连续编辑/删除后不会被当前消息状态污染;本页消息的 sender/send_as/fwd_from/reply_to/action peers 会随 users/chats 返回,禁止复用 user_update_events |
| updates.getChannelDifference | done | real-channel | 超级群/频道使用 channel 维度 durable log`channel_update_events(channel_id, pts, pts_count, ...)`;按 channel pts 返回 `channelDifferenceEmpty/channelDifference/channelDifferenceTooLong`limit cap=100`pts < 0``pts > current_channel_pts` 返回 `PERSISTENT_TIMESTAMP_INVALID`;当前 member 的 `available_min_pts` 会抬高请求 pts避免新加入/重新加入成员拉到入群前消息类 durable 事件;公共 username 频道允许非成员以只读预览身份拉可见差分并返回 synthetic read dialog私有频道和禁看用户仍返回权限错误`current_channel_pts-pts > cap` 时返回带当前 dialog pts 和最新有界消息快照的 `channelDifferenceTooLong`,避免大频道旧 pts 客户端循环拉大量页;普通 difference 优先使用事件 payload 中的 message 快照,连续编辑/删除后不会被当前消息状态污染;`updateChannelParticipant` 不进入 channel pts log成员权限/封禁状态靠在线 `updateChannelParticipant/updateChannel``channels.getFullChannel/getParticipants/getParticipant` 刷新;本页消息的 sender/send_as/fwd_from/reply_to/action peers 会随 users/chats 返回,禁止复用 user_update_events |
### 兼容硬约束pts / pts_count跨所有产生 update 的 RPC
@ -279,7 +279,7 @@ outbox 多 worker 并发 + 发送事务乱序提交 → **主动推送可能乱
| channels.updateUsername | done | real | creator 可设置/清除频道或超级群主 usernamePG 事务内更新 `channels.username``channel_usernames`,大小写不敏感唯一约束兜底,成功后向在线成员推 `updateChannel` |
| channels.getAdminedPublicChannels | done | real | 返回当前用户 active creator/admin 且带主 username 的公开 channel/supergroup用于 TDesktop public username limit/选择入口 |
| channels.toggleSignatures | done | real-minimal | creator/change_info admin 可切换 `channels.signatures` 并返回/推送 `updateChannel`profiles_enabled 先不单独持久化 |
| channels.togglePreHistoryHidden | done | real-minimal | creator 可切换 `channels.pre_history_hidden`,返回/在线推 `updateChannel``getFullChannel` 暴露 `hidden_prehistory`;新加入/导入/受邀成员按当前 `top_message_id` 初始化 `available_min_id/read_inbox`,并按加入前 channel pts 初始化 `available_min_pts`,避免看到旧历史或补到入群前成员事件 |
| channels.togglePreHistoryHidden | done | real-minimal | creator 可切换 `channels.pre_history_hidden`,返回/在线推 `updateChannel``getFullChannel` 暴露 `hidden_prehistory`;新加入/导入/受邀成员按当前 `top_message_id` 初始化 `available_min_id/read_inbox`,并按加入前 channel pts 初始化 `available_min_pts`,避免看到旧历史或补到入群前消息类 durable 事件 |
| channels.toggleSlowMode | done | real-minimal | change_info 管理员可持久化 `channels.slowmode_seconds``Channel.slowmode_enabled/ChannelFull.slowmode_seconds` 可见;普通成员发送按 `channel_members.slowmode_last_send_date` 返回 `SLOWMODE_WAIT_X`,管理员/creator 豁免 |
| channels.setStickers | stub | empty-only | 当前无群贴纸集 store仅 megagroup + `inputStickerSetEmpty` 清空入口做权限校验 no-op 成功,非空 sticker set 返回 `STICKERSET_INVALID`,避免 TDesktop 误以为贴纸集已落库 |
| channels.reorderUsernames | stub | permission-ok | 当前无 Fragment/多 username 模型,限制 order<=32、校验 change_info 后返回 BoolTrue |
@ -323,8 +323,8 @@ outbox 多 worker 并发 + 发送事务乱序提交 → **主动推送可能乱
| channels.readHistory | done | real | TDesktop 可能直接调用 channels.readHistory语义同 messages.readHistory(InputPeerChannel),含发送方 `updateReadChannelOutbox` 在线通知 |
| channels.deleteMessages | done | real-channel | 管理员/作者权限校验后软删单份 channel messages生成有界 `updateDeleteChannelMessages``pts_count=len(ids)`,单次 id cap=1000 |
| channels.deleteHistory | done | real-partial | `for_everyone` 执行一个有界管理员删除 page 并推 `updateDeleteChannelMessages`,单批 cap=1000该 TL 返回 `Updates` 且 TDesktop 不读取 offset禁止在同步 RPC 内循环构造超大 id/update非 for_everyone 只清当前用户可见历史/read/dialog不写扩散、不生成超大 update |
| channels.editAdmin | done | real | creator 或具备 add_admins 的 admin 可更新 admin rights/rank非 creator 只能授予自己拥有的权限;禁止改 creator`channel_participant` durable event返回/在线推 `updateChannelParticipant/updateChannel/updateChannelTooLong(pts)` |
| channels.editBanned | done | real | creator 或具备 ban_users 的 admin 可更新 banned rights/kicked 状态,刷新 participants/admin/banned/kicked 计数;写 `channel_participant` durable event返回/在线推 `updateChannelParticipant/updateChannel/updateChannelTooLong(pts)` |
| channels.editAdmin | done | real | creator 或具备 add_admins 的 admin 可更新 admin rights/rank非 creator 只能授予自己拥有的权限;禁止改 creatoradmin log不占 channel pts、不写 `channel_update_events`;返回/在线推 `updateChannelParticipant + updateChannel`,离线设备通过 full channel/participants 刷新状态 |
| channels.editBanned | done | real | creator 或具备 ban_users 的 admin 可更新 banned rights/kicked 状态,刷新 participants/admin/banned/kicked 计数;写 admin log不占 channel pts、不写 `channel_update_events`;返回/在线推 `updateChannelParticipant + updateChannel`,若后续产生可见踢人/加人 service message则 service message 单独占 channel pts |
| channels.editTitle | done | real | creator/change_info admin 可改标题;写 megagroup service message `messageActionChatEditTitle` 与 channel pts返回 `updateChannel + updateNewChannelMessage` |
| channels.editPhoto | done | real-photo | change_info 权限校验后解析 `inputChatPhoto``inputChatUploadedPhoto` 组装上传→建 Photo`inputChatPhoto{inputPhoto}` 引用已存在照片,`inputChatPhotoEmpty`/`inputPhotoEmpty` 清除;落 `channels.photo_id/photo_dc_id/photo_stripped` 反范式列,`tgChannel.Photo`(ChatPhoto)/`tgChannelFull.ChatPhoto` 渲染真实头像,返回 `updateChannel` + 推 channel state服务端无 Files 时按 `PHOTO_INVALID` 处理in-history `MessageActionChatEditPhoto` service 消息留 todo |
| channels.deleteChannel | done | real | creator 权限,标记 channel deleted返回/推送 `updateChannel + channelForbidden`dialog 列表过滤 deleted channel后续 admin log 细化 |
@ -417,4 +417,4 @@ outbox 多 worker 并发 + 发送事务乱序提交 → **主动推送可能乱
| method/type_id | first_seen | raw_note |
|---|---|---|
| — | — | 当前无未实现 RPC trace |
| — | — | 当前无未实现 RPC trace |

View file

@ -63,8 +63,8 @@ DDL 见 [`deploy/migrations/0001_init.up.sql`](../deploy/migrations/0001_init.up
- **`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 分区点查。
- **`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/participant/noop 的恢复负载,`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` 起点,避免新成员或重新加入成员恢复到入群前的 participant/admin/settings 事件。`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` 分区。
- **`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_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。
@ -171,4 +171,4 @@ blob backend`internal/app/files``BlobBackend` 接口 + `LocalFS` 本地磁
种子导入:启动时 `files.Service.SeedMedia``TELESRV_STICKER_SEED_DIR`(真实 Telegram 导出,含 `available_reactions_raw.json` + 各集 `set_info.json` + `.tgs/.webp/缩略图`幂等导入JSON 元数据→表二进制→blob`dc_id` 重写为本 server DC。实测 74 reactions / 24 sets / ~1.5k documents / ~3k blob 索引 / ~2.8k 去重磁盘文件。
类型边界:`domain.Document/Photo/MessageMedia/StickerSet/AvailableReaction` 带 json tagstore 直接 marshal JSONB完全不依赖 `tg.*`domain↔tg 转换集中在 `internal/rpc/convert_media.go`
类型边界:`domain.Document/Photo/MessageMedia/StickerSet/AvailableReaction` 带 json tagstore 直接 marshal JSONB完全不依赖 `tg.*`domain↔tg 转换集中在 `internal/rpc/convert_media.go`