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。