Initial open source release
This commit is contained in:
commit
74992e893f
377 changed files with 118084 additions and 0 deletions
BIN
docs/assets/tdesktop-alice.png
Normal file
BIN
docs/assets/tdesktop-alice.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 609 KiB |
BIN
docs/assets/tdesktop-bob.png
Normal file
BIN
docs/assets/tdesktop-bob.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 583 KiB |
BIN
docs/assets/tdesktop-dual-session.png
Normal file
BIN
docs/assets/tdesktop-dual-session.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1 MiB |
471
docs/channel-module.md
Normal file
471
docs/channel-module.md
Normal file
|
|
@ -0,0 +1,471 @@
|
|||
# Supergroup / Channel Module Design
|
||||
|
||||
Date: 2026-06-01
|
||||
|
||||
## Scope
|
||||
|
||||
本模块实现 Telegram Desktop 第一兼容目标下的超级群与频道闭环。项目不保留 legacy 普通群长期形态:`messages.createChat` 在服务端直接创建 `megagroup`,TDesktop 同步创建响应仅暴露带 `migrated_to` 的 legacy `chat` 外观,后续主路径回到 `channel` / `channelFull` 语义,避免服务端维护真正的 `migrateToMegagroup` 升级链路。
|
||||
|
||||
首批真实实现:
|
||||
|
||||
- `messages.createChat` -> create megagroup
|
||||
- `channels.createChannel`
|
||||
- `channels.getFullChannel`
|
||||
- `channels.getParticipants`
|
||||
- `channels.getParticipant`
|
||||
- `channels.checkUsername/updateUsername/getAdminedPublicChannels`
|
||||
- `channels.toggleSignatures`
|
||||
- `channels.inviteToChannel`
|
||||
- `channels.joinChannel`
|
||||
- `channels.leaveChannel`
|
||||
- `channels.editAdmin/editBanned/editTitle/deleteChannel`
|
||||
- `channels.getAdminLog`
|
||||
- `messages.updatePinnedMessage`
|
||||
- `messages.editChatDefaultBannedRights`
|
||||
- `messages.exportChatInvite/checkChatInvite/importChatInvite`
|
||||
- `messages.getMessageReadParticipants`
|
||||
- `messages.getMessageEditData`
|
||||
- `updates.getChannelDifference`
|
||||
- `messages.sendMessage/getHistory/readHistory/editMessage/deleteMessages/deleteHistory/forwardMessages/setTyping/getReplies/getDiscussionMessage/readDiscussion` 的 channel peer 分支
|
||||
- `messages.getDialogs/getPeerDialogs/getPinnedDialogs` 展示 channel / megagroup dialog
|
||||
|
||||
首批兼容 stub:
|
||||
|
||||
- `channels.convertToGigagroup`:当前 megagroup 已是目标形态,返回 ok 或 `CHAT_NOT_MODIFIED` 语义。
|
||||
- `messages.migrateChat`:不创建 legacy chat,不执行双写迁移;校验 change_info/creator 权限后把 `chat_id` 映射到既有 megagroup `channel_id`,返回 `updateChannel + tg.Channel`,满足 TDesktop `applyUpdates -> migrateTo()` 路径。
|
||||
- `channels.editPhoto`:files/media 头像存储未接入前只允许 `inputChatPhotoEmpty` 删除头像 no-op 兼容;真实上传/已有照片返回 `PHOTO_INVALID`,避免 TDesktop 误以为头像已成功更新。
|
||||
- `channels.togglePreHistoryHidden/toggleSlowMode/toggleForum/toggleAntiSpam/updateColor/updateEmojiStatus`:持久化 channel 设置并返回 `updateChannel`;TDesktop 通过 `ChannelFull.hidden_prehistory/slowmode_seconds/antispam`、`Channel.slowmode_enabled`、`Channel.forum/forum_tabs`、`Channel.color/profile_color/emoji_status` 恢复 UI。emoji status 当前支持 empty 与普通 document id,collectible gift 状态等 gift/read model 后续补;forum 当前保存开关/layout 与 admin log,`messages.getForumTopics*` 会在 forum 开启后返回虚拟 `General` topic(id=1) 加持久化 topic store;`messages.create/edit/pin/reorder/deleteTopicHistory` 已接入最小真实 topic mutation;anti-spam 当前只保存开关与 admin log,不接入真实垃圾消息删除管线。
|
||||
- `channels.setStickers`:files/sticker store 未接入前只允许 megagroup `inputStickerSetEmpty` 清空 no-op 兼容;非空 sticker set 返回 `STICKERSET_INVALID`,避免 TDesktop 误以为群贴纸集已持久化。`channels.reorderUsernames/toggleUsername/deactivateAllUsernames` 仍先做权限校验型兼容响应;Fragment/多 username 入口不改主 username,主 username 只允许 `channels.updateUsername` 设置或清除。
|
||||
- TDesktop 已有调用证据但非首批真实业务的入口统一注册为显式 stub:`channels.reportSpam/editLocation/convertToGigagroup/reportAntiSpamFalsePositive/setBoostsToUnblockRestrictions/setEmojiStickers/checkSearchPostsFlood/setMainProfileTab`。`channels.searchPosts` 已提升为公开频道/超级群帖子真实搜索,`checkSearchPostsFlood` 仍保持免费额度兼容 stub;二者共享 query 长度边界。`setBoostsToUnblockRestrictions` 按 Layer225 限制 0..8。`channels.restrictSponsoredMessages/updatePaidMessagesPrice/toggleAutotranslation` 已提升为最小真实设置持久化:分别回填 `ChannelFull.restricted_sponsored`、`Channel/ChannelFull.send_paid_messages_stars` + broadcast `Channel.broadcast_messages_allowed`、`Channel.autotranslation`,其中 `updatePaidMessagesPrice` 按 TDesktop 默认 app config 限制 stars<=10000,并允许 broadcast direct messages 用 `-1` 表示关闭;真实广告投放、boost/premium 校验、paid messages/monoforum/结算后续补。`channels.exportMessageLink`、`channels.readMessageContents`、`channels.getMessageAuthor`、`channels.deleteParticipantHistory`、`getInactiveChannels`、`getChannelRecommendations`、`toggleJoinToSend`、`toggleJoinRequest`、`toggleParticipantsHidden`、`toggleForum`、`toggleViewForumAsMessages`、`toggleAntiSpam`、`getLeftChannels`、`getGroupsForDiscussion`、`setDiscussionGroup` 已从该列表提升为真实实现。
|
||||
- `channels.getGroupsForDiscussion/setDiscussionGroup` 真实维护 broadcast channel 与 megagroup 的双向 `linked_chat_id`。候选列表只返回当前用户可管理的 supergroup,不返回 legacy basic group;设置时校验 access_hash、broadcast/group 类型、管理权限与 hidden prehistory,替换链接会同步清理旧 group/old broadcast,TDesktop 通过 `Channel.has_link` 和 `ChannelFull.linked_chat_id` 刷新讨论组入口。linked broadcast 发新 post 时会在 discussion megagroup 创建一条 forwarded root message,source post 保存 `discussion_channel_id/message_id`,`messages.getDiscussionMessage/getReplies/readDiscussion` 都映射到该 root 和 target channel read state。
|
||||
- TDesktop 已有源码调用但当前业务模型尚未维护独立 media/sticker/custom-emoji/saved-message tag assignment/poll/todo/scheduled 的 `messages.reportSpam/report/reportReaction/reportMessagesDelivery/reportReadMetrics/reportMusicListen/reportSponsoredMessage/getSavedReactionTags/updateSavedReactionTag/getDefaultTagReactions/getExtendedMedia/getAttachedStickers/getCustomEmojiDocuments/searchStickerSets/searchStickers/getEmojiKeywords/getEmojiKeywordsDifference/sendVote/getPollResults/getPollVotes/addPollAnswer/deletePollAnswer/getUnreadPollVotes/readPollVotes/appendTodoList/toggleTodoCompleted/getSearchCounters/getSearchResultsCalendar/getSearchResultsPositions/getOnlines/getWebPagePreview/uploadMedia/sendMedia/sendMultiMedia/getScheduledHistory/getScheduledMessages/sendScheduledMessages/deleteScheduledMessages` 均显式注册;统一做参数上限、peer 校验和空/零兼容响应,禁止落到未知 RPC。其中 `messages.readMessageContents` 已提升为 private exact-id real-partial:只校验当前账号可见 message box 并向其它 session 推 content-read update,不维护 media/reaction content-read 状态;channel/supergroup 的 `channels.readMessageContents` 会按可见消息 ID 清理当前作者视角的 unread reaction、重算 `channel_dialogs.unread_reactions_count`,并向当前账号 session 推 `updateMessageReactions` 让 TDesktop 立即去掉 dialog reaction 角标;`messages.getMessagesViews` 已维护 channel-scoped views 去重计数并返回 replies/comment 统计;`messages.getUnreadMentions/readMentions` 已维护 channel-scoped unread mention index,readMentions 返回 channel pts;`messages.sendReaction/getMessagesReactions/getMessageReactionsList/getUnreadReactions/readReactions/getRecentReactions/clearRecentReactions/getTopReactions` 已提升为 private + channel/supergroup emoji reaction 最小真实实现:private 写 `private_message_reactions` 并按双端 owner-visible message box 返回/推送 `updateMessageReactions`,channel 写 `channel_message_reactions` 并按消息作者维护 unread reaction 状态,history/getMessages/search 均回填 `message.reactions`,`add_to_recent` 写账号级最近 channel reaction 列表且 get/clear 支持 hash/notModified,`getTopReactions` 按账号使用次数排序并优先用真实 `available_reactions` catalog 兜底;`messages.getSavedReactionTags/updateSavedReactionTag` 已持久化账号级 emoji saved reaction tag 标题并向其它 session 推 `updateSavedReactionTags`,但 peer 维度 count 与 saved-message tag assignment 仍返回空;`messages.getForumTopics/getForumTopicsByID/createForumTopic/editForumTopic/updatePinnedForumTopic/reorderPinnedForumTopics/deleteTopicHistory` 已从纯空响应提升为真实最小 topic store:虚拟 General + root service message topic + bounded delete;`messages.getCommonChats` 已提升为真实共同超级群查询并回填 `users.getFullUser.common_chats_count`;`messages.report` 保持 TDesktop 分步举报 UI 所需的 choose option/add comment/reported 形态但暂不落库;report/metrics/music/sponsored 这类 telemetry 入口只返回 BoolTrue/reported,不写业务状态;TDesktop 资料页 shared media count 会用 `messages.search(limit=0, filter=photo/video/document/url/gif/music/roundVoice/poll)` 取 `messages.channelMessages.count`,当前显式返回空页/count=0,避免纯文本 channel history 污染 photos/videos/files 等计数;search calendar 空结果会回填请求 offset date/id,避免空月份重复拉取;default tag、sticker/custom emoji 与 extended media 均只返回空/notModified 或明确错误,不伪造 paid media 或 tag 绑定状态;poll/todo mutating 入口在缺少媒体 store 时返回可解释错误,不伪造 `updateMessagePoll` 或 todo service message;web preview 返回 `messageMediaEmpty` 且不会抓外网,`sendMedia(inputMediaWebPage)` 降级为纯文本发送,真实 photo/document/poll/album、todo、scheduled store 留待后续模型。
|
||||
- legacy chat 管理入口 `messages.getChats/getFullChat/addChatUser/deleteChatUser/editChatTitle/editChatPhoto/editChatAdmin/editChatAbout/editChatDefaultBannedRights/editChatParticipantRank` 统一映射到 megagroup/channel 语义;其中 `about` 与 default banned rights 真实持久化,default banned rights 参与普通成员发送和邀请权限校验。`editChatCreator` 已显式注册并校验 peer/user,但账号 2FA/SRP 与所有权转移事务未接入前返回可解释的密码错误,不进入 fallback。
|
||||
|
||||
## Reference Audit
|
||||
|
||||
### TDesktop
|
||||
|
||||
TDesktop 创建群入口仍会调用 `MTPmessages_CreateChat`,创建频道/超级群调用 `MTPchannels_CreateChannel` 并根据 UI 类型设置 `f_megagroup` 或 `f_broadcast`。`messages.createChat` 的 `ChatCreateDone` 会从 `messages.InvitedUsers.updates.chats` 取第一条 `chat`,不接受 `channel`;因此 TDesktop ctx 下同步响应必须把 `legacy chat(migrated_to=inputChannel)` 放在第一项,同时继续附带真实 `channel.id/access_hash/title/broadcast/megagroup/participants_count/date` 供 `updateNewChannelMessage` 和迁移后的 supergroup 历史使用。服务端不持久化 legacy chat,后续 `InputPeerChat` 主路径统一映射回同 id channel。
|
||||
|
||||
频道更新走 `ChannelData` 的独立 `PtsWaiter`。`updateNewChannelMessage` 若找不到频道资料,会延迟触发 `getDifference`;找到频道且不在处理 channel difference 时,会调用 `channel->ptsUpdateAndApply(pts, pts_count, update)`。`PtsWaiter` 依赖 `pts_count` 累加判断连续性,缺口会缓存乱序 update,1 秒后拉 `updates.getChannelDifference`。因此服务端必须保证 channel pts 单调、`pts_count` 准确、每个已分配 pts 都能从 channel durable log 补到。
|
||||
|
||||
管理入口会直接触发 `MTPchannels_EditAdmin`、`MTPchannels_EditBanned`、`MTPchannels_EditTitle`、`MTPchannels_DeleteChannel`;pin 使用 `MTPmessages_UpdatePinnedMessage`,邀请链接使用 `MTPmessages_ExportChatInvite/CheckChatInvite/ImportChatInvite`。TDesktop 对这些 RPC 返回的 `Updates` 会立即 apply,因此响应必须至少带 `updateChannel`、成员变更时带 `updateChannelParticipant`,pin 时带 `updatePinnedChannelMessages`,并把相关 `chats/users` 填齐。
|
||||
|
||||
TDesktop 源码还会在频道/群 UI、导出和搜索路径触发 `MTPmessages_GetMessagesViews`、`ReadMessageContents`、`GetUnreadMentions/ReadMentions`、`GetSearchCounters`、`GetReplies`、`GetForumTopics/GetForumTopicsByID` 和 legacy basic group wrappers。参考实现 对 `messages.getOnlines` 返回固定 1,参考实现 handler 仍未实现;telesrv 比 参考实现 多走一步:使用在线 session 快照与 active channel member 有界交集返回实时在线数,缺少在线 provider 时才退回兼容 1。basic group 管理入口再下沉到 megagroup/channel 语义;参考实现 对 default banned rights 要求 `ban_users` 权限并发布频道设置命令。telesrv 借鉴这个边界:views/mentions 已有真实最小实现,forum topic create/read/edit/pin/delete/reorder 已有持久化最小实现,复杂媒体仍返回显式 stub,legacy basic group 管理统一落到 megagroup/channel 语义,default banned rights 真实落库并影响普通成员 send/invite。
|
||||
|
||||
管理员日志页调用 `MTPchannels_GetAdminLog(channel, q, events_filter, admins, max_id, min_id, limit)`,TDesktop 既会用 `max_id` 向旧事件翻页,也会用 `min_id`/polling 拉新事件;响应必须是 `channels.adminLogResults{events,chats,users}`,并让 action 里的 message/participant 能被客户端渲染。服务端必须 cap `limit/admins/query`,按 `(channel_id,id)` seek,不能按客户端传入的大 id 构造数组。
|
||||
|
||||
已读详情浮层在 outgoing 且本地已读、成员数小于 `chat_read_mark_size_threshold`、消息未超过 `chat_read_mark_expire_period` 时调用 `MTPmessages_GetMessageReadParticipants(peer,msg_id)`。TDesktop 只接收 `Vector<readParticipantDate>`,不附带 users/chats;因此服务端必须只返回当前客户端已经能解析的 user id,并保证接口失败不会变成持续 `NOT_IMPLEMENTED` 噪声。
|
||||
|
||||
reply/forward 渲染依赖 `MessageReplyHeader` 与 `MessageFwdHeader` 内引用的 peer 已可解析。TDesktop 的 `ReplyFieldsFromMTP` 会优先用 `reply_to_top_id`,没有时退回 `reply_to_msg_id`;`api_updates.cpp` 的 `ForwardedInfoDataIsLoaded/ReplyDataIsLoaded` 会在 forward/reply header 引用的 peer 未加载时暂缓 apply update。因此服务端发送 channel reply 时必须用已存在、当前成员可见的 channel message 反算 `reply_to_top_id`,不能信任客户端传入;forward/reply header 如果引用 user/channel peer,当前 RPC 响应、账号级 `updates.getDifference`、在线 outbox 和 `updates.getChannelDifference` 都要补齐对应 users/chats 上下文,不能只修实时响应路径。
|
||||
|
||||
会话内搜索也走 channel peer:TDesktop 的 `api_messages_search.cpp` 与 `dialogs_widget.cpp` 在群/频道里直接发 `messages.search(peer=inputPeerChannel, q, offset_id, limit)`,并把 `offset_id` 当 seek cursor 继续翻页。参考实现 对 channel peer 把 `OwnerPeerId` 切到 channel id,从单份 message read model 查询,并补齐 sender/forward/reply 相关 users/channels。telesrv 因此不把 channel 搜索转成私聊 `message_boxes`,而是在 `channel_messages(channel_id,id)` 上做有界文本搜索;PG 使用 `channel_messages_body_trgm_idx` 辅助 `ILIKE` 命中,仍按 `id DESC LIMIT` 分页。
|
||||
|
||||
公开 username 管理入口会调用 `MTPchannels_CheckUsername`、`MTPchannels_UpdateUsername` 与 `MTPchannels_GetAdminedPublicChannels`;settings/profile 管理区还会触发 signatures、prehistory、slowmode、stickers、color、emoji status、Fragment usernames 等 RPC。主 username 是客户端可见资料,必须真实落库;Fragment/多 username、颜色、贴纸、emoji status 可先权限校验型 stub,但不能误修改主 username 或静默吞掉未知 RPC。
|
||||
|
||||
TDesktop 左侧搜索框通过 `MTPcontacts_Search` 查 peer,返回的 channel/supergroup 命中必须同时出现在 `results/my_results` 的 `peerChannel` 和 `chats` 向量中,否则 UI 无法 materialize peer;用户名跳转通过 `MTPcontacts_ResolveUsername`,公开 channel/supergroup 同样返回 `peerChannel + chats`。telesrv 只暴露带主 username 且未删除的公开频道/超级群,PG 使用 `channels_public_username_trgm_idx` / `channels_public_title_trgm_idx` 避免无索引模糊扫。非成员从公开搜索结果打开频道时,`channels.getFullChannel`、`messages.getPeerDialogs(InputPeerChannel)` 与 `messages.getHistory(InputPeerChannel)` 使用只读公开预览视图返回 full/dialog/history;私有频道、ban/kick/view_messages 禁止仍拒绝,发送、差分、管理等写路径仍要求 active member。
|
||||
|
||||
源码检索还显示导出/管理/搜索路径会触发 `MTPchannels_ExportMessageLink`、`ReadMessageContents`、`DeleteParticipantHistory`、`GetGroupsForDiscussion`、`SetDiscussionGroup`、`ToggleForum`、`ToggleJoin*`、`SearchPosts`、`CheckSearchPostsFlood`、`GetChannelRecommendations`、`GetMessageAuthor` 等 Layer 225 方法。`ToggleForum` 保存 `enabled/tabs`,仅 creator 可改,返回 `updateChannel`;`GetChannelRecommendations` 先按公开 username broadcast channel 做最小真实推荐,真实相似度/订阅画像/Premium 扩容后续补;其它未接入完整业务的入口必须显式注册、加参数上限、返回可解释 stub,并写入 compatibility matrix。
|
||||
|
||||
讨论组设置入口在 `edit_peer_info_box.cpp` 中:broadcast 侧无当前链接时会调用 `channels.getGroupsForDiscussion` 拉候选;保存时若选中的 megagroup 开启了 hidden prehistory,TDesktop 会先调用 `channels.togglePreHistoryHidden(group,false)` 再重试 `channels.setDiscussionGroup`。保存成功后客户端本地调用 `ChannelData::setDiscussionLink`,同时 `ChannelFull.linked_chat_id` 会在后续 full channel 刷新时恢复状态。因此 server 必须返回候选 chats,设置/解绑成功后更新双方 `linked_chat_id` 并推 `updateChannel`,不能只返回 BoolTrue。
|
||||
|
||||
TDesktop 导出路径的 `channels.getLeftChannels` 使用 count-offset 分页:客户端每次把 offset 增加已返回 chats 数量,`messages.chats` 立即结束,`messages.chatsSlice` 只有空 chats 才结束。telesrv 因此按 `channel_members(user_id,status='left')` 查询用户已离开的频道/超级群,pageSize 固定 100,offset 上限 10000;最终非空页返回 `messages.chats`,offset 已越过总数时返回空 `messages.chatsSlice{count}`,避免导出流程多拉或循环。
|
||||
|
||||
输入框与高级会话路径还会触发 `MTPmessages_GetWebPagePreview`、`UploadMedia`、`SendMedia`、`SendMultiMedia`,定时消息入口会触发 `GetScheduledMessages/SendScheduledMessages/DeleteScheduledMessages`,forum UI 会触发 topic create/edit/pin/reorder/delete。参考实现 对 web preview 空文本报错、无预览返回 `messageMediaEmpty`,media 和 scheduled 都有独立持久化;参考实现 的 web preview 是空响应,media 走 helper,scheduled/forum 多数仍未实现。telesrv 当前不伪造这些缺失模型:web preview 返回空,webpage media 可降级纯文本;真实 media/scheduled/forum 只做有界校验与可解释错误或 cleanup update。
|
||||
|
||||
投票和 todo 是 channel/supergroup 消息媒体的后续模型,不应在缺少 store 时假成功。TDesktop 的 `api_polls.cpp` 会在 `sendVote/addPollAnswer/deletePollAnswer/getPollResults` 成功后 apply updates,投票人列表通过 `messages.getPollVotes` 的 `next_offset` 翻页;`api_unread_things.cpp` 和 `menu_send.cpp` 还会拉取/清除未读投票,todo 则在 `api_todo_lists.cpp` 中 append/toggle 后 apply updates。参考实现 只实现旧层 poll 三件套,先按 peer+msg_id 取消息并提取 poll_id,`getPollVotes` 把 limit 压到 50;参考实现 对 poll 已有 domain event 与 sender/self update 分离,todo append 依赖 `MessageMediaToDo`,toggle/read-unread/add/delete 多数仍未实现。telesrv 当前做显式有界兼容:read-only poll 入口返回空,投票/新增/删除答案和 todo 变更返回 `MESSAGE_ID_INVALID`,并保留 `OPTIONS_TOO_MUCH/OPTION_INVALID/TODO_NOT_MODIFIED/BROADCAST_FORBIDDEN` 等客户端可理解错误,等 media store 接入后再生成真实 `updateMessagePoll`、todo service message 与 channel pts。
|
||||
|
||||
资料页、贴纸、custom emoji 与 reaction tag 也会在群/频道页面后台触发,不应落到 fallback。TDesktop `getCommonChats` 用 `max_id/limit` 分页共同群,且资料页按钮依赖 `UserFull.common_chats_count`;参考实现 都只把共同 channel 中的 megagroup 作为共同群。telesrv 只查双方 active membership 的 megagroup/supergroup,排除 broadcast/left/kicked/deleted,PG 走 `user_channel_member_index(user_id, channel_id) WHERE active megagroup` 交集和 channel id seek 分页,避免 `users.getFullUser` 私聊打开路径反向规划 `channel_members` 的 64 个 channel 分区。`getExtendedMedia` 成功后只 apply updates,paid media 未接入时空 updates 是安全结果;attached stickers、custom emoji documents、sticker search、emoji keyword difference 都接受空 vector/空 found results;reaction top/recent/saved tag titles 用 hash 缓存,rename tag 会本地先改名再调用 `updateSavedReactionTag`。sticker/custom emoji 不查外部索引,saved-message tag assignment/count/per-peer 暂不持久化,避免为了兼容 UI 预取而引入无界查询或半截状态。
|
||||
|
||||
`updates.getChannelDifference` 有三种客户端路径:
|
||||
|
||||
- 无新增:`updates.channelDifferenceEmpty{final=true, pts, timeout}`。
|
||||
- 正常补差:`updates.channelDifference{final, pts, new_messages, other_updates, chats, users}`。
|
||||
- 差量过长:`updates.channelDifferenceTooLong{dialog, messages, chats, users}`,客户端会用返回 dialog 的 `pts` 重置 channel 本地状态,并拉历史范围校验。
|
||||
|
||||
### gotd / TL Layer 225
|
||||
|
||||
gotd 已提供所有需要的 Layer 225 类型和 dispatcher:
|
||||
|
||||
- `MessagesCreateChatRequest` 返回 `messages.InvitedUsers`。
|
||||
- `ChannelsCreateChannelRequest` 返回 `UpdatesClass`,包含 `Broadcast/Megagroup/Forum/TTLPeriod`。
|
||||
- `UpdatesGetChannelDifferenceRequest` 返回 `UpdatesChannelDifferenceClass`,请求 limit 对普通用户建议 10-100,服务端必须 cap。
|
||||
- `UpdateNewChannelMessage` 携带 `message/pts/pts_count`。
|
||||
- `UpdateEditChannelMessage`、`UpdateDeleteChannelMessages`、`UpdatePinnedChannelMessages` 都走 channel pts;delete 的 `pts_count` 必须等于本次删除 id 数,pin/edit 为 1。
|
||||
- `UpdateChannelParticipant` 携带 actor、prev/new participant,适合 editAdmin/editBanned 的在线更新。
|
||||
- `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` 可记录管理日志。
|
||||
- `messages.getReplies#22ddd30c` / `messages.getDiscussionMessage#446972fd` / `messages.readDiscussion#f731a9f4` 使用 channel peer + message id 定位 thread;`messageReplies` 的 `comments/channel_id/replies_pts/max_id/read_max_id` 是 TDesktop 展示 comment button、recent replies 与 unread state 的直接输入。
|
||||
|
||||
实现时 `tg.*` 仍只能出现在 `internal/rpc`,domain/app/store 使用自有模型。
|
||||
|
||||
### 参考实现 A
|
||||
|
||||
参考实现 有一个关键语义可借鉴:`MessageSubType.AutoCreateChannelFromChat`。普通群创建最终进入 channel 创建 saga,先创建 channel creator/member/invite,再发送 `messageActionChannelCreate` 服务消息。它在 `SendMessageSaga` 中按 owner peer 分配 message id 与 pts;当 `ToPeer == Channel` 时只创建 channel owner 的 outbox message,不为每个成员创建 inbox message,随后 `SetChannelPts` 更新 channel read model 的 `Pts/TopMessageId/LastSenderPeerId/LastSendDate`。
|
||||
|
||||
成员加入/邀请/退出会创建或更新成员 dialog,并且仅 megagroup 生成 `ChatAddUser/ChatDeleteUser/Join` 等服务消息;broadcast channel 不向普通成员写这类群服务消息。它还把 channel update 保存和在线推送分开:channel durable update 供 `getChannelDifference`,在线推送只给活跃成员/管理员/被 mention 用户。
|
||||
|
||||
ack/globalSeqNo 的设计也值得保留为边界:`msgs_ack` 只确认某个 server msg_id/RPC response 已送达,ack cache 再把它映射回 pts/globalSeqNo 更新设备已确认水位。业务 pts 仍由 owner/channel 事件流决定,不能让 MTProto ack 直接产生业务事件。
|
||||
|
||||
管理语义方面,参考实现 的 editAdmin/editBanned 都先做权限校验,再构造 participant update;title 变更会产生可见服务消息;pin 走 `messages.updatePinnedMessage` 并只推进 channel pts;invite link 支持导出、检查和导入,join-approval/request_needed 是单独路径,不能误当作已经入群。TDesktop 对 `messages.importChatInvite` 明确分支处理 `INVITE_REQUEST_SENT` 与 `USERS_TOO_MUCH`,因此 request-needed 的首版兼容 stub 必须返回 `INVITE_REQUEST_SENT`,usage limit 满必须返回 `USERS_TOO_MUCH`,不能退化成坏链接/不可访问错误。
|
||||
|
||||
username 与管理项方面,参考实现 的 `channels.checkUsername` 只校验格式、access_hash 和全局 username 占用;`channels.updateUsername` 要求 channel owner,大小写不敏感相同值返回 `USERNAME_NOT_MODIFIED`;`getAdminedPublicChannels` 返回当前用户管理的公开 channel 列表。`toggleSignatures/togglePreHistoryHidden/toggleSlowMode/updateColor` 都先校验权限后发布命令;`updateEmojiStatus` handler 仍是空 updates,但 read model/converter 已有 `EmojiStatus` 字段。telesrv 采用这个边界:主 username/signatures/prehistory/slowmode/color/profile_color/普通 emoji status 真实持久化,Fragment usernames、贴纸、collectible emoji status 等依赖额外模型的入口继续显式 stub 或错误。
|
||||
|
||||
参考实现 的 `messages.toggleNoForwards` 已实现 channel 路径:先校验 access hash,再发布 `ToggleChannelNoForwardsCommand`,通过 channel 聚合返回 updates;`messages.setChatAvailableReactions` 和 `messages.setChatTheme` handler 仍未实现,但 `ChannelFullReadModel/ChannelFullMapper` 已有 `ReactionType/AvailableReactions/ReactionsLimit` 到 `chatReactions*` 的映射。telesrv 因此不把 reactions 当纯兼容噪声,而是持久化到 channel setting,并在 full channel 中恢复给 TDesktop。
|
||||
|
||||
参考实现 对 TDesktop 长尾入口多采用空/BoolTrue 响应:`readMessageContents/reportSpam/setEmojiStickers/getInactiveChannels/getChannelRecommendations/checkSearchPostsFlood/setMainProfileTab` 等直接返回兼容值;`channels.getMessageAuthor` handler 存在但未实现。参考实现 的 `messages.readMessageContents` / `channels.readMessageContents` 会先查当前用户可见消息、过滤 mention/media_unread/reaction 内容状态,再向 not-me 推 `updateReadMessagesContents` 或 `updateChannelReadMessagesContents`;未找到 `channels.getMessageAuthor` 对应实现。telesrv 当前没有 media/reaction content-read 持久表,因此 private 与 channel 都只用现有 exact-id message store 校验可见消息,并把存在的 id 推给当前用户其它在线 session;缺失 id 仍按参考项目返回当前 pts/BoolTrue。`getMessageAuthor` 则按 Layer225/TDesktop monoforum 右键菜单的最小可用语义,只查可见 channel message 的 `SenderUserID` 并返回 user,完整 monoforum 管理员权限留后续模型。`deleteParticipantHistory` 做管理员权限后按固定 page size 分批删除;`searchPosts` 按 参考实现 的 PublicPosts 语义落到本项目公开 username channel_messages 查询,只返回公开频道/超级群文本消息,满页用 `next_rate + offset_peer + offset_id` seek 翻页,付费 flood/限额继续由 `checkSearchPostsFlood` 免费 stub 承接;`getInactiveChannels` 按 TDesktop Premium 限额弹窗消费方式返回当前用户 active 频道/超级群,`dates[i]` 与 `chats[i]` 对齐并按最久未活跃排序;`getChannelRecommendations` 则从空响应提升为公开 broadcast channel 推荐,指定来源时排除 source,无来源时排除当前账号已加入频道;`getGroupsForDiscussion/setDiscussionGroup` 是讨论组链路。telesrv 借鉴其删除边界:按 sender 分页取一批 message id,生成一条 channel delete update,通过 `offset` 提示客户端续删,禁止一次性展开超大历史。
|
||||
|
||||
参考实现 的 `channels.getAdminLog` 当前返回空结果,但保留了 read model/query 模型:按 `channel_id`、action types、skip/limit 拉取 admin log event。telesrv 采用“有真实 event store、但只实现 TDesktop 首批可见 action”的路线,避免管理入口只能打开空页。
|
||||
|
||||
参考实现 的 `messages.getMessageReadParticipants` 先按 owner peer 校验 message 存在和 7 天过期窗口,再查询 `ReadingHistoryReadModel(TargetPeerId, MessageId >= msg_id, ReaderPeerId != self)` 并返回 `TReadParticipantDate{user_id,date}`。telesrv 采用同一语义边界,但落到 channel 单份消息模型:成员 `read_inbox_max_id >= msg_id`、`available_min_id < msg_id` 且有显式 `read_inbox_date` 才算已读者。
|
||||
|
||||
参考实现 的 `messages.getMessageEditData` 先校验 access_hash/message,再按编辑时间窗口返回 caption 可编辑标记;参考实现 对私聊/旧群先校验作者或管理员编辑权限,再返回 `messages.messageEditData{caption=false}`,channel 分支在旧项目被商业版挡住。telesrv 当前没有媒体 caption 编辑,因此采用更窄且可验证的语义:对私聊和 channel peer 都先校验消息存在、可见和作者/`edit_messages` 权限,再返回 `caption=false`,避免 TDesktop 编辑入口触发未知 RPC 或绕过权限。
|
||||
|
||||
参考实现 的 `channels.readHistory` 会读取 `max_id` 对应消息的 sender,触发 `UpdateReadChannelOutbox` saga,并向 sender 推送 `TUpdateReadChannelOutbox{channel_id,max_id}`。`channels.getFullChannel` 还会把 dialog read model 的 `ReadInboxMaxId/ReadOutboxMaxId` 写回 `ChannelFull`,dialog mapper 也持久返回 `ReadOutboxMaxId`。TDesktop 收到 `updateReadChannelOutbox` 后只调用 `History::outboxRead(max_id)`;如果在线 update 丢失或设备离线,TDesktop 会在 `messages.getDialogs/messages.getPeerDialogs` 的 dialog 字段、`channels.getFullChannel` 的 channel full 字段中重新应用 `read_outbox_max_id`。telesrv 采用同一客户端语义,但在 store 内 bounded 扫描最近 read delta,可一次推进多个相关发送者,同时保留 fanout/scan 硬上限。
|
||||
|
||||
参考实现 的 `EditPeerFoldersSaga` 明确把 `TInputPeerChannel` 转成 `PeerType.Channel` 并发布 `UpdateDialogFolderCommand`,最终回 `updateFolderPeers`;dialog unread/pinned/read-outbox 都在同一个 dialog read model 上表达。TDesktop 侧 `updateDialogPinned/updatePinnedDialogs/updateDialogUnreadMark/updateFolderPeers` 也按 `DialogPeer/Peer` 泛化消费,因此 telesrv 不能只更新私聊 `dialogs` 表,channel peer 必须更新 `channel_dialogs` 并仍走账号级 user pts/update。
|
||||
|
||||
参考实现 的 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。
|
||||
|
||||
### 参考实现 B
|
||||
|
||||
参考实现 的 channel 模型包含 `broadcast`、`megagroup`、`top_message`、`pts`、`participants_count`、`default_banned_rights`、`hidden_prehistory`、`slowmode`、`forum`、`noforwards` 等业务字段。它用 Redis key `channel_pts` 缓存 channel pts,并用 `channel_pts_updates` 持久化 `channel_id/pts/pts_count/update_type/update_data/date`,`updates.getChannelDifferenceV2` 按 `channel_id AND pts > ? ORDER BY pts ASC` 返回差量。
|
||||
|
||||
参考实现 对 `messages.toggleNoForwards` 先把 peer 限定为 chat/channel,再在 chat service 校验创建者权限,更新 `noforwards` 与版本后返回 chat update;`chat.setChatAvailableReactions` 会校验成员/管理员权限,把 reaction type 与 reaction list 持久化。telesrv 借鉴“设置是 channel state,不是消息事件”的边界,但权限放宽到 change_info 管理员,与当前 channel 管理设置统一。
|
||||
|
||||
参考实现 的不足需要避免:部分差量恢复时靠反解 message 的 `from_id` 临时判断 `out`,代码里有 sender TODO。telesrv 的 `channel_update_events` 必须显式保存 `sender_user_id`、`affected_user_ids`、`message_ids` 等负载,不能把客户端展示所需信息藏在 TL JSON 里。
|
||||
|
||||
参考实现 的 admin log 通过 `channel_admin_logs` 保存 actor、event、JSON action、query、date,并在 BFF 先校验 creator/admin 后调用 service。它的不足是只按最近 24h/channel 扫描且过滤 TODO;telesrv 改为 channel hash 分区、`id` 单调分页、actor/type 索引和 request cap,保留语义但不继承实现。
|
||||
|
||||
参考实现 的 `messages.getMessageReadParticipants` 在 channel 版本中通过 dialog service 拉取已读成员 id,旧 chat 版本则按参与者 dialog 的 `read_inbox_max_id` 判断是否覆盖目标消息;日期字段仍返回 0。telesrv 保留“按成员读水位判断”的核心语义,并新增 `read_inbox_date`,避免 UI 已读详情只能显示无时间。
|
||||
|
||||
参考实现 的 `messages.getMessageEditData` 私聊路径会按 owner message id 取 message box,并拒绝非作者;旧群允许具备编辑权限的管理员,最终仍返回 `caption=false`。telesrv 的 channel/supergroup 路径沿用这个权限边界,但查询单份 `channel_messages` 与当前 viewer 的 member 状态,不落 legacy chat。
|
||||
|
||||
参考实现 频道 participant/dialog 模型都保存 `read_outbox_max_id`,BFF `channels.getFullChannel` 会用当前 dialog 的 `ReadOutboxMaxId` 覆盖 `ChannelFull.ReadOutboxMaxId`,另有 `message_read_outbox` 记录读回执排查维度。telesrv 当前先把发送者 `channel_members/channel_dialogs.read_outbox_max_id` 与在线 `updateReadChannelOutbox`、离线 dialog/full channel 恢复路径打通;单条读者时间仍由 `read_inbox_date` 支撑。
|
||||
|
||||
参考实现 的 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` 独立控制。
|
||||
|
||||
参考实现 的 `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 本地可见下界回退。
|
||||
|
||||
## Domain Model
|
||||
|
||||
新增 domain model:
|
||||
|
||||
- `Channel`:`ID`、`AccessHash`、`Title`、`About`、`Username`、`CreatorUserID`、`Broadcast`、`Megagroup`、`Forum`、`ForumTabs`、`Date`、`ParticipantsCount`、`AdminsCount`、`KickedCount`、`BannedCount`、`DefaultBannedRights`、`TopMessageID`、`PinnedMessageID`、`Pts`、`Deleted`、`NoForwards`、`TTLPeriod`。
|
||||
- `Channel` 还保存设置字段:`PreHistoryHidden`、`ParticipantsHidden`、`AntiSpam`、`SlowmodeSeconds`、`Signatures`、`ReactionPolicy`、`Color`、`ProfileColor`、`EmojiStatus`。`ParticipantsHidden` 会让 `ChannelFull.participants_hidden` 可见,并让非管理员成员列表/已读详情按隐藏成员语义收敛;`AntiSpam` 会让 `ChannelFull.antispam` 可见,供 TDesktop 管理员页恢复开关;`SlowmodeSeconds>0` 会让 `tg.Channel.slowmode_enabled` 和 `tg.ChannelFull.slowmode_seconds` 可见;`Color/ProfileColor/EmojiStatus` 转成 `tg.Channel.color/profile_color/emoji_status`;`ReactionPolicy` 转成 `ChannelFull.available_reactions/reactions_limit/paid_reactions_available`。
|
||||
- `ChannelInvite`:`ChannelID`、`InviteID`、`Hash`、`AdminUserID`、`Title`、`Permanent`、`Revoked`、`RequestNeeded`、`ExpireDate`、`UsageLimit`、`UsageCount`、`Date`。
|
||||
- `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.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`。
|
||||
- `ChannelDialog`:当前 user 对 channel 的会话摘要,保存 `FolderID`、`Pinned`、`PinnedOrder`、`TopMessageID`、`ReadInboxMaxID`、`ReadOutboxMaxID`、`UnreadCount`、`UnreadMentions`、`UnreadMark`、`ViewForumAsMessages`、`NotifySettings`。channel message id 是 channel-scoped,跨频道 dialog 排序不能只看 `top_message_id`;统一按 `pinned DESC, pinned_order DESC, top_message_date DESC, top_message_id DESC, channel_id DESC` 排序,并用 `offset_date + offset_id + offset_peer(channel_id)` 做 seek cursor。folder include/exclude/read/archive/type 条件必须在 SQL `LIMIT` 前下推,避免 TDesktop dialogs 翻页重复顶部频道或自定义分组漏掉旧频道。
|
||||
- `ChannelUnreadMention`:按 `(user_id, channel_id, message_id)` 保存未读提及,`top_message_id` 支持 thread/topic 级清除;它是 owner 视角状态,不写入 `channel_update_events`,`messages.readMentions` 返回当前 channel pts 供客户端清本地 badge。
|
||||
- `ChannelUpdateEvent`:`ChannelID`、`Pts`、`PtsCount`、`Type`、`Date`、`MessageID`、`MessageIDs`、`SenderUserID`、`UserIDs`、`Payload`。
|
||||
- `ChannelAdminLogEvent`:`ChannelID`、`ID`、`UserID`、`Date`、`Type`、前后字符串/布尔/整数、前后 participant、相关 message、`Query`;用于 `channels.getAdminLog`,不参与 channel pts。
|
||||
|
||||
`domain.PeerType` 扩展为:
|
||||
|
||||
- `user`
|
||||
- `channel`
|
||||
|
||||
首批不持久化 `chat` peer;RPC 层只在 `messages.createChat` 的 TDesktop 同步响应暴露 migrated legacy `chat` 外观,主消息/media/history/read 等入口遇到 `InputPeerChat/InputChat` 均映射到同 id megagroup/channel。
|
||||
|
||||
## Storage
|
||||
|
||||
大表从第一版开始分区:
|
||||
|
||||
| table | partition key | purpose |
|
||||
|---|---|---|
|
||||
| `channels` | hash(`id`) | channel/supergroup 主体 |
|
||||
| `channel_members` | hash(`channel_id`) | 成员、权限、读水位、可见历史边界 |
|
||||
| `user_channel_member_index` | primary key(`user_id`, `channel_id`) | user 维度成员索引;供共同群、`users.getFullUser.common_chats_count` 等 user→channel 热路径使用 |
|
||||
| `channel_messages` | hash(`channel_id`) | 单份 channel message;TDesktop 看到的 message id 即 `(channel_id, id)` 中的 `id` |
|
||||
| `channel_message_viewers` | hash(`channel_id`) | `(channel_id,message_id,viewer_user_id)` 去重视图,支持 `messages.getMessagesViews(increment=true)` 幂等递增 |
|
||||
| `channel_message_reactions` | hash(`channel_id`) | `(channel_id,message_id,reacted_user_id,reaction)` 当前 reaction 状态,支持 `sendReaction/getMessagesReactions/getMessageReactionsList` |
|
||||
| `private_message_reactions` | message id fk | `(private_message_id,user_id,reaction)` 当前私聊 reaction 状态,双端 message box 共享聚合,支持 private `sendReaction/getMessagesReactions/getMessageReactionsList` |
|
||||
| `user_saved_reaction_tags` | user_id prefix | 账号级 saved-message reaction tag 标题;支持 `messages.getSavedReactionTags/updateSavedReactionTag`,不承载 message assignment/count |
|
||||
| `channel_unread_mentions` | hash(`user_id`) | owner 视角未读提及索引,支持 `messages.getUnreadMentions/readMentions`,不复制 channel message body |
|
||||
| `channel_update_events` | hash(`channel_id`) | channel pts durable log,供 `updates.getChannelDifference` |
|
||||
| `channel_dialogs` | hash(`user_id`) | 当前账号对 channel 的 dialog/read/folder/pin/mute 摘要 |
|
||||
| `dialog_drafts` | hash(`user_id`) | user/channel peer 云草稿;支持 forum `top_msg_id`,不复制 channel message |
|
||||
| `channel_invites` | hash(`channel_id`) | 默认 invite link、管理员导出的邀请链接、usage/requested 计数 |
|
||||
| `channel_invite_importers` | hash(`channel_id`) | invite importer/read model 与 pending join request;每个 `(channel_id,user_id)` 只保留当前状态 |
|
||||
| `channel_admin_log_events` | hash(`channel_id`) | 管理日志;按 channel 内单调 `id` seek pagination,不复制 channel 消息正文 |
|
||||
|
||||
关键索引:
|
||||
|
||||
- `channel_usernames(username_lower)` 主键保存公开 channel username 占用;`channels.username` 保存展示值,避免 PG 分区表唯一索引必须包含分区键的问题。
|
||||
- `channel_members(channel_id, user_id)` 主键;父表上的 `(user_id, channel_id)` 索引不能裁剪 `channel_id` HASH 分区,只可作为单分区内辅助索引,禁止用它承载 user→channel 热路径。
|
||||
- `user_channel_member_index(user_id, channel_id) WHERE status='active' AND megagroup AND NOT broadcast AND NOT deleted` 支持共同群 count/list;由 channel member upsert、leave 和 delete channel 事务同步维护。后续凡是从 user 入口列 joined/admined/left channel,都应扩展这张 user 维度 read model 或两步取 bounded channel_id 后再按 `channel_id/id = ANY($1)` 访问分区表。
|
||||
- `channel_members(channel_id, read_inbox_max_id, user_id) WHERE status='active'` 支持小群已读详情按读水位有界查询;同时用 `available_min_id < msg_id` 排除加入前不可见历史。
|
||||
- `channel_messages(channel_id, id DESC) WHERE deleted=false` 历史 seek pagination。
|
||||
- `channel_messages(channel_id, sender_user_id, id DESC) WHERE deleted=false` 支持 `channels.deleteParticipantHistory` 按发送者有界取最近一页消息,避免成员历史删除全表扫。
|
||||
- `channel_messages(channel_id, sender_user_id, random_id)` 唯一,保障 channel send 幂等。
|
||||
- `channel_messages(channel_id, reply_to_top_id, id DESC) WHERE reply_to_top_id > 0 AND NOT deleted` 支持 `messages.getReplies` thread/comment seek pagination。
|
||||
- `channel_messages(discussion_channel_id, discussion_message_id) WHERE discussion_channel_id <> 0 AND discussion_message_id <> 0 AND NOT deleted` 支持 broadcast post 到 discussion root 的反查与排查。
|
||||
- `channel_message_viewers(channel_id, message_id, viewer_user_id)` 主键去重;`messages.getMessagesViews` 先按最多 100 个 id 过滤可见 `channel_messages`,只对首次插入的 viewer 更新 `channel_messages.views_count`,不按 viewer 做 count 聚合扫描。
|
||||
- `channel_message_reactions(channel_id, message_id, reaction_date DESC, reacted_user_id DESC, reaction_value ASC)` 支持最近 reaction 回填和列表 seek;`(channel_id,message_id,reaction_type,reaction_value,reaction_date DESC,reacted_user_id DESC)` 支持按单个 emoji 过滤,不使用 SQL OFFSET。
|
||||
- `channel_unread_mentions(user_id, channel_id, top_message_id, message_id DESC)` 支持当前账号 unread mentions seek;发送时只为解析出的 active/可见/未读成员插入,单条消息最多 100 个候选,清除时单批最多 1000 条。
|
||||
- `channel_update_events(channel_id, pts)` 主键;`(channel_id, pts)` 升序扫描差量。
|
||||
- `channel_dialogs(user_id, folder_id, pinned DESC, pinned_order DESC, top_message_date DESC, top_message_id DESC, channel_id DESC)` 支持 dialogs seek;`default_send_as_peer_type/default_send_as_peer_id` 只保存当前 owner 的默认发送身份,不参与列表排序。
|
||||
- `dialog_drafts(user_id, date DESC, peer_type, peer_id, top_message_id)` 支持 `messages.getAllDrafts/clearAllDrafts` 有界扫描;draft 内容用 domain JSON 保存,业务层不持有 `tg.*`。
|
||||
- `channel_invites(channel_id, admin_user_id, revoked, created_at DESC, hash DESC)` 支持 TDesktop invite links 管理页按 admin/revoked/offset seek 翻页;`channel_invite_hashes(hash)` 用于导入链接按 hash 快速定位,撤销 invite 不删除 hash 映射,避免管理页 detail 失效。
|
||||
- `channel_invite_importers(channel_id, invite_id, requested, date DESC, user_id DESC)` 与 `(channel_id, requested, date DESC, user_id DESC)` 支持 `messages.getChatInviteImporters` 的 link/requested/filter 查询;`q` 过滤走有界 candidate 后关联 user,不允许全表搜索。
|
||||
- `channel_admin_log_events(channel_id, id DESC)` 支持 admin log 翻页;`(channel_id, actor_user_id, id DESC)` 支持 admins 过滤;`(channel_id, event_type, id DESC)` 支持 events_filter。
|
||||
|
||||
Redis 可恢复计数:
|
||||
|
||||
- `counter:channel_id`:channel id,值域应避开 user id。
|
||||
- `counter:channel_msg_id:{channel_id}`:channel message id。
|
||||
- `counter:channel_pts:{channel_id}`:channel pts。
|
||||
|
||||
管理日志 `id` 使用 `channels.admin_log_seq` 在同一事务内 `UPDATE ... RETURNING` 分配,原因是它只服务管理页 seek,不参与高频消息 pts;后续如果 admin log 写入成为瓶颈,再迁移到可恢复 Redis counter + noop 占位策略。
|
||||
|
||||
Redis miss 恢复来源:
|
||||
|
||||
- channel id 从 `MAX(channels.id)` 恢复。
|
||||
- message id 从 `MAX(channel_messages.id WHERE channel_id=?)` 恢复。
|
||||
- channel pts 从 `MAX(channel_update_events.pts WHERE channel_id=?)` 恢复。
|
||||
|
||||
分配必须使用 Redis Lua 的初始化+递增原子脚本;批量 delete/clear 这类 `pts_count>1` 的操作必须一次性分配连续 range,PG fallback 也要实现 `NextChannelPtsN(current+count)`,不能用多次读取 `MAX(pts)+1` 模拟。事务失败但 pts 已分配时必须写 `noop` channel update 占位,避免 TDesktop channel PtsWaiter 永久 gap。
|
||||
|
||||
## Create Flow
|
||||
|
||||
`messages.createChat`:
|
||||
|
||||
1. RPC 层解析 users/title/ttl,校验至少 1 个非自己用户,限制单次邀请数量。
|
||||
2. 调用 `ChannelService.CreateMegagroupFromCreateChat`,内部强制 `Broadcast=false, Megagroup=true`。
|
||||
3. 创建 channel、creator member、受邀成员、默认 invite link。
|
||||
4. 分配 channel message id + channel pts,写 `messageActionChannelCreate` 或 `messageActionChatCreate` 兼容服务消息。
|
||||
5. 为 creator 与受邀成员创建 `channel_dialogs`,但不复制 message body。
|
||||
6. 写 `channel_update_events(updateNewChannelMessage)`。
|
||||
7. 给 creator 当前 RPC 返回 `messages.InvitedUsers{updates}`;TDesktop ctx 下该同步响应的 `chats` 第一项是带 `migrated_to` 的 legacy `chat` 外观,第二项保留真实 `channel`。其它在线成员通过 outbox/active channel push 收到真实 channel updates,不推 legacy 外观。
|
||||
|
||||
`channels.createChannel`:
|
||||
|
||||
- `broadcast=true` 创建频道,`megagroup=true` 创建超级群。
|
||||
- 如果两个 flag 都没传,TDesktop 路径按 broadcast 处理;服务端可返回 `CHANNEL_INVALID` 防错误客户端。
|
||||
- `for_import` 依赖 `messages.initHistoryImport` 与导入 session,本阶段没有历史导入模型,返回 `CHAT_INVALID`;`geo_point/address` 依赖 geogroup/location 模型,本阶段返回 `ADDRESS_INVALID`。这两类高级 flags 不能落 `NOT_IMPLEMENTED`,也不能伪造成普通群创建成功。
|
||||
- `ttl_period` 只接受非负值,负数返回 `TTL_PERIOD_INVALID`;真实 TTL 自动删除管线后续接入。
|
||||
- broadcast 的普通成员不能发普通消息;creator/admin 可发 `post` 消息。
|
||||
- megagroup 的 slowmode 只限制普通成员发普通消息;服务端检查 `slowmode_last_send_date + slowmode_seconds`,重复 `random_id` 命中幂等结果时不重新触发限速。
|
||||
- 如果 `pre_history_hidden=true`,invite/join/import invite 写成员时用加入前的 `top_message_id` 初始化 `available_min_id`;无论是否隐藏历史,都用加入前 `channels.pts` 初始化 `available_min_pts`。为了避免旧历史在惰性 unread 计算中变成未读,invite/join/import invite 都会把 `read_inbox_max_id` 初始化到加入前 top;主动 join/rejoin/import invite 生成的自服务消息会继续把当前成员 read 水位推进到该服务消息,邀请服务消息则保留为被邀请者的一条未读。
|
||||
|
||||
## Send / History Flow
|
||||
|
||||
频道/超级群消息只写一份 `channel_messages`:
|
||||
|
||||
1. 校验成员状态、ban/default banned rights、broadcast 发送权限、普通成员 invite/send 默认限制、slowmode、noforwards。
|
||||
2. 如带 `reply_to`,按当前 channel + 当前成员 `available_min_id` 校验目标消息存在、未删除、可见,并反算 `reply_to_top_id`:目标已有 top 则继承,否则 top=目标 msg_id;quote 文本/entities/offset 原样保留,但 `quote_text` 上限 1024、`quote_offset` 按原消息文本 offset 收口到 4096,不能按 message id 放行。
|
||||
3. 通过 Redis 分配 `channel_msg_id` 与 `channel_pts`。
|
||||
4. 写 `channel_messages` 与 `channel_update_events(updateNewChannelMessage, pts_count=1)`。
|
||||
- `random_id` 幂等重试必须返回原始 `updateNewChannelMessage` 的 durable snapshot;即使该消息之后被编辑或删除,也不能把重试响应污染成 edit/delete 事件或当前消息状态。
|
||||
5. 更新 `channels.top_message_id/pts`。
|
||||
6. 更新发送者 `channel_members/channel_dialogs.read_inbox_max_id/read_outbox_max_id/top_message`,自己的新消息立即视为已读/已发。
|
||||
7. 不写成员 message box;Postgres 用单条 set-based SQL 推进 active/可见成员的 `channel_dialogs.top_message_id/unread_count` 缓存,dialog 查询仍以 `channels.top_message_id` + 当前 member 的 `available_min_id/read_inbox_max_id` 可重算可见 top 与 unread,避免缓存陈旧影响 TDesktop 离线恢复。
|
||||
8. 在线推送只 fanout 给当前活跃 channel 成员 session,且每个接收者按自己的 viewer user id 重新生成 `tg.Message.out`,不能复用发送者视角的 update;离线设备走 `updates.getChannelDifference`。
|
||||
|
||||
`messages.getHistory(InputPeerChannel)` 直接查 `channel_messages(channel_id, id)`,按 `offset_id/max_id/min_id` 做 seek pagination,`offset_id=0` 时支持 `offset_date` date cursor,禁止 SQL 大 OFFSET。`channels.getMessages` 必须按 `id = ANY(...)` 精确批量读取,不允许把稀疏 ID 转成 min/max 范围后再 `LIMIT`,否则 TDesktop 拉 pinned/reply/跳转消息会漏旧消息。`channels.getChannels/channels.getMessages` 这类精确 ID vector 入口统一 cap=100,不能按客户端传入数量无界查库或构造响应。返回消息时按请求 user 动态设置 `out`、`post`、`from_id/send_as` 与 reply/forward 信息。
|
||||
|
||||
## Dialog / Read / Unread
|
||||
|
||||
`channel_dialogs` 是 owner 视角:
|
||||
|
||||
- `read_inbox_max_id`:当前用户已读到的最大 channel message id。
|
||||
- 被邀请加入已有 megagroup/channel 时,成员的初始 `read_inbox_max_id` 固定到邀请发生前的 `channels.top_message_id`;如果 megagroup 产生 invite service message,则只让这条新服务消息成为未读,不能把入群前历史全部计入 unread。主动 join/rejoin/import invite 也先把 read 水位固定到加入前 top,再把自己的 join service message 标为已读,避免 TDesktop 打开会话时把旧消息批量推进成读回执。`pre_history_hidden=true` 时 `available_min_id` 也落到同一个旧 top,既隐藏旧历史又避免未读数膨胀。成员的 `available_min_pts` 固定到加入前 `channels.pts`,避免离线补偿返回入群前权限/成员事件。
|
||||
- `joinChannel/importInvite` 必须尊重既有 member 状态:`active` 重复加入返回 `USER_ALREADY_PARTICIPANT`,`kicked`、`banned` 或 `view_messages` ban 不允许直接重新加入;`left` 用户重新加入时恢复 `participants_count`。`inviteToChannel` 对单个已加入用户返回 `USER_ALREADY_PARTICIPANT`,对被踢/被禁看消息用户只有 creator 或具备 `ban_users` 的 admin 才能通过邀请恢复,普通成员单人邀请返回 `USER_KICKED`,多人邀请跳过无权恢复的目标。`leaveChannel` 的在线 recipients 必须包含离开的 user 本人,用于同账号其它设备同步离群状态。
|
||||
- `importInvite` 的 invite link 错误码需要保持 TDesktop 可解释:`request_needed` 当前不落真实申请流,但返回 `INVITE_REQUEST_SENT`;`usage_limit` 用尽返回 `USERS_TOO_MUCH`;被踢/禁看仍返回 `INVITE_HASH_INVALID`,避免泄漏私有成员状态。
|
||||
- `read_outbox_max_id`:当前用户发送的 channel/supergroup 消息被其它成员读到的最大值。`readHistory` 只扫描 bounded recent delta,推进涉及发送者的 member/dialog outbox watermark,并通过 `updateReadChannelOutbox` 在线通知发送方;不做全员 O(n) fanout。该水位必须同时从 `messages.getDialogs/messages.getPeerDialogs` 的 dialog 和 `channels.getFullChannel` 的 `ChannelFull` 返回,作为离线设备或丢失实时 update 后的恢复路径。
|
||||
- `unread_count`:发送事务推进 active/可见成员的 `channel_dialogs` 缓存,计算方式仍是 `max(0, channels.top_message_id - read_inbox_max_id)`;删除/隐藏历史会用 `available_min_id` 修正,读请求必须可重算,不能只信缓存。
|
||||
- `top_message_id/top_message_date`:`channel_dialogs` 中的值是当前 owner 状态缓存,发送时会随最新消息推进;本地清空历史后可能落后,PG 查询仍必须优先用 `channels.top_message_id > channel_members.available_min_id` 判断当前可见 top,并 join 当前 top `channel_messages` 取真实 `message_date` 排序。
|
||||
- `folder/pin/manual unread/notify settings/default send-as/view-forum-as-messages` 仍按当前 user 保存,并通过 user_update_events 通知多设备;频道消息本身通过 channel_update_events 补。`messages.toggleDialogPin/reorderPinnedDialogs/markDialogUnread/getDialogUnreadMarks` 和 `folders.editPeerFolders` 必须把 `InputPeerChannel` 分流到 `channel_dialogs`,与私聊 `dialogs` 合并返回;这些是账号级 dialog 状态,不推进 channel pts。Layer 225 `markDialogUnread/getDialogUnreadMarks` 的 `parent_peer` 只用于 monoforum/SavedSublist;当前没有 monoforum subdialog store,因此先校验 parent channel 与 sublist peer 后 BoolTrue/空列表 no-op,避免 TDesktop 后台 `NOT_IMPLEMENTED`,真实 monoforum unread marks 留后续模型。`messages.saveDefaultSendAs` 写 `channel_dialogs.default_send_as_peer_*`,`channels.toggleViewForumAsMessages` 写 `channel_dialogs.view_forum_as_messages` 并发 `updateChannelViewForumAsMessages`,`channels.getFullChannel` 输出 `channelFull.default_send_as/view_forum_as_messages` 供 TDesktop 恢复本地选择。`folder_id=0/1` 是物理主列表/归档状态,必须在 SQL `LIMIT` 前下推;`folder_id>=2` 是 TDesktop 自定义 filter 规则,不能拿它和 `channel_dialogs.folder_id` 直接相等比较。
|
||||
|
||||
`messages.readHistory(InputPeerChannel)`:
|
||||
|
||||
1. 锁当前 user 的 `channel_dialogs`。
|
||||
2. 将 `read_inbox_max_id` 推进到 `min(req.max_id, channel.top_message_id)`。
|
||||
3. 若水位前进,写 `channel_members.read_inbox_date=req.date`;再更新 `unread_count/manual unread`。
|
||||
4. 对当前 user 产生账号级 `updateReadChannelInbox` 或兼容的 dialog/user update;该事件用于多设备 dialog 未读状态同步。
|
||||
- 当前实现记录一条 `user_update_events(read_history_inbox, peer_type='channel')`,在线/离线设备转换为 TL `updateReadChannelInbox`;实时 fallback 使用 channel pts 填 `updateReadChannelInbox.pts`,可靠 outbox/difference 使用账号 pts 作为 durable cursor,TDesktop 在 pts 不匹配时仍会推进 `max_id` 并通过 dialog entry 取回精确 unread。
|
||||
5. 对读水位前进区间做 bounded sender scan:最多查看最近 `MaxChannelReadOutboxScanMessages=1000` 条、最多通知 `MaxChannelReadOutboxFanout=128` 个发送者;每个发送者只推进自身 `read_outbox_max_id` 并在线推 `updateReadChannelOutbox(channel_id,max_id)`,避免客户端传超大 `max_id` 时生成无界 update。
|
||||
|
||||
`messages.getMessageReadParticipants(InputPeerChannel)`:
|
||||
|
||||
- 只对 megagroup 返回真实小群读者;broadcast channel 或成员数超过 `chat_read_mark_size_threshold` 返回空列表,避免大频道 O(n) 放大。
|
||||
- 若 `channels.participants_hidden=true`,已读详情直接返回空;TDesktop 在隐藏成员模式下也会关闭 read participants UI,服务端不通过该接口泄漏成员身份。
|
||||
- 先校验当前用户可见 channel 与目标 message,删除消息或不可见历史返回 `MESSAGE_ID_INVALID`。
|
||||
- 消息超过 `chat_read_mark_expire_period` 返回空列表;客户端正常不会发起过期请求。
|
||||
- 查询 `channel_members` 时只取 `status='active'`、未禁看消息、`available_min_id < msg_id`、`read_inbox_max_id >= msg_id` 且 `read_inbox_date > 0` 的成员,并排除请求者自己;单次最多 50 个。初始 read 水位只用于 unread 基线,不应让新加入成员凭默认水位出现在旧消息的已读列表中。
|
||||
- 返回的 date 来自 `read_inbox_date`,旧数据未记录日期时允许为 0,TDesktop 会按无具体时间展示。
|
||||
|
||||
## Channel Difference
|
||||
|
||||
`updates.getChannelDifference(channel, pts, limit)`:
|
||||
|
||||
- 校验 access_hash、成员/可见权限、ban。
|
||||
- `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` 覆盖旧事件,否则连续编辑、删除后的离线补偿会丢失中间状态。
|
||||
- 没有事件返回 `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 事件。
|
||||
- 对 `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`,客户端会继续拉下一页。
|
||||
- `new_messages/other_updates` 中 message 的 `from/send_as/fwd_from/reply_to/action` 涉及的 user/channel 必须随本页 `users/chats` 返回;账号级 `user_update_events` 同样持久带出 fwd/reply 的 users/channels,供 `updates.getDifference` 和 `dispatch_outbox` 复用,避免在线响应正常但离线恢复缺 peer。
|
||||
|
||||
## Input Hash And Visibility
|
||||
|
||||
- 外部 `InputChannel` / `InputPeerChannel` 带非零 `access_hash` 时,RPC 层必须先和当前 domain channel 的 `access_hash` 比对;不匹配返回 `CHANNEL_PRIVATE` 或在 vector 查询中跳过该项,禁止只靠 channel_id + member 关系兜底。
|
||||
- 这个校验不仅覆盖 `channels.*` 和 `messages.send/edit/delete/read/history/search` 主路径,也覆盖 `messages.getPeerSettings`、`messages.saveDraft`、`messages.getDialogs` offset peer、dialog pin/unread/folder、`messages.updateDialogFilter`、`folders.editPeerFolders`、reply header 的 `reply_to_peer_id` 等旁路入口。
|
||||
- `InputChannelFromMessage` / `InputPeerChannelFromMessage` 属于 min channel 解析路径,没有 access_hash 字段;服务端只接受正 channel_id,并继续执行成员/可见性校验。
|
||||
- legacy basic group wrapper 由服务端内部映射到 megagroup,内部构造的 `InputChannel{access_hash=0}` 允许跳过 hash 比对;真实客户端传来的非零 hash 一律校验。
|
||||
|
||||
硬约束:
|
||||
|
||||
- `updateNewChannelMessage.PtsCount=1`。
|
||||
- `updateEditChannelMessage.PtsCount=1`。
|
||||
- `updateDeleteChannelMessages.PtsCount=len(message_ids)`,单次 cap 1000。
|
||||
- 全清历史不能生成十几万条 update,也不能一个超大 vector;`messages.deleteHistory(InputPeerChannel)` 返回 `affectedHistory.offset` 供客户端/管理端续删,`channels.deleteHistory` 的 TL 返回 `Updates` 且 TDesktop 不读取 offset,所以该入口只执行一个有界 page 或本地 `available_min_id` 清空,不能在同步 RPC 内循环展开全量历史。
|
||||
- 所有 history/search 入口的 `add_offset` 必须在 RPC/store 层 clamp 到小窗口;即使 channel 单份历史当前按 seek 查询忽略大 offset,也不能让私聊或后续 channel around-load 分支把客户端传入的极端值变成无界 slice/SQL OFFSET。
|
||||
- `channels.deleteParticipantHistory` 同样只删除一个有界 page,按 `(channel_id, sender_user_id, id DESC)` seek 最近消息,`offset=1` 表示后面仍可能有更多该成员历史需要客户端/管理端继续请求。
|
||||
|
||||
## Admin Log
|
||||
|
||||
`channels.getAdminLog` 是管理页的只读审计视图,不推进 channel pts,也不写入普通用户 update。当前真实记录这些 TDesktop 首批能展示的事件:
|
||||
|
||||
- 元信息/设置:title、username、signatures、prehistory、slowmode、anti-spam。
|
||||
- 成员权限:invite、join、leave、promote/demote、ban/unban、kick/unkick。
|
||||
- 消息管理:pin/unpin、channel post/send、edit、delete。
|
||||
|
||||
请求边界:
|
||||
|
||||
- `limit` cap 100;`admins` cap 100;`q` cap 128。
|
||||
- `max_id`/`min_id` 只作为 seek 条件:`id < max_id`、`id > min_id`,禁止展开区间数组。
|
||||
- `q` 只做有界页面搜索,覆盖元信息字符串与消息类事件 body;后续若管理日志量级增大,再接 PG full-text/trigram 索引。
|
||||
- `events_filter` 空表示不过滤;非空只映射当前支持的 action types,未来 forum/group_call/subscription 等先保持无结果而不是伪造。
|
||||
- `admins` 过滤 actor_user_id,不按受影响成员过滤;TDesktop 侧该参数语义就是管理员 actor 列表。
|
||||
|
||||
返回 `channels.adminLogResults` 时,RPC 层按事件收集 actor、participant、message sender,填充 `users`;`chats` 返回当前 channel。domain/store 层只保存自有模型,TL action 转换集中在 `internal/rpc`。
|
||||
|
||||
当前实现状态:
|
||||
|
||||
- 已实现 `messages.createChat -> megagroup`、`channels.createChannel`、成员 invite/join/leave、channel 单份文本发送、history、dialogs、read、typing 与 `updates.getChannelDifference`;`channels.createChannel` 的 history import/geogroup 高级 flags 暂不建模,已映射为显式 TL 错误而非 `NOT_IMPLEMENTED`。
|
||||
- 已实现 channel 文本 edit:只更新 `channel_messages` 单份消息,写 `channel_update_events(edit_channel_message)`,在线推 `updateEditChannelMessage`;TDesktop 文本编辑携带的 `inputMediaWebPage/inputMediaEmpty` 降级为文本编辑,真实 media/reply_markup/quick replies 仍返回显式 TL 错误等待后续模型。
|
||||
- 已实现 channel delete:`channels.deleteMessages` 与 `channels.deleteHistory(for_everyone)` 软删单份消息,单批最多 1000 个 id,写一条 `delete_channel_messages` 事件,`pts_count=len(ids)`;需要多页续删的管理路径应使用 `messages.deleteHistory(InputPeerChannel)` / `channels.deleteParticipantHistory` 这类带 `offset` 的响应继续推进,避免复现 参考实现 按超大范围构造 id vector 的 OOM 风险。
|
||||
- 已实现 `channels.deleteParticipantHistory`:管理员按 participant sender 删除一页消息,PG 走 sender history 部分索引,单批最多 1000 个 id,返回 `affectedHistory.offset` 供续删,在线推 `updateDeleteChannelMessages`。
|
||||
- 已实现当前用户本地清空 channel history:只推进该用户 `available_min_id/read_inbox` 与 `channel_dialogs`,不生成 channel pts,也不对成员写扩散;同时写账号级 `channel_available_messages` durable update,并给同账号其它 session 推 `updateChannelAvailableMessages`;重复或 stale 清空请求返回实际单调水位,不能把客户端 `available_min_id` 回退。
|
||||
- 已实现 channel reply:发送时校验被回复消息是同 channel 内当前成员可见消息,继承/计算 `reply_to_top_id`,保留 quote metadata,并在 TL 层返回 `messageReplyHeader`;非法、本地清历史后不可见目标或超界 `quote_offset` 返回 `REPLY_MESSAGE_ID_INVALID`。
|
||||
- 阶段外高级发送/转发 flags 不再返回 `NOT_IMPLEMENTED`:quick reply、effect、paid、suggested post、monoforum/todo/poll/story reply 分别映射到 `SHORTCUT_INVALID`、`EFFECT_ID_INVALID`、`PAYMENT_UNSUPPORTED`/`STARS_AMOUNT_INVALID`、`SUGGESTED_POST_PEER_INVALID`、`REPLY_TO_MONOFORUM_PEER_INVALID`、`REPLY_MESSAGE_ID_INVALID`、`POLL_OPTION_INVALID`、`STORY_ID_INVALID`;后续接入对应模型前不伪造成功 update。
|
||||
- 已实现 linked discussion/comment 基础闭环:broadcast post 写入单份 source message 时同步在 linked megagroup 写一条 forwarded root message;source message 保存 discussion ref 并在 `messageReplies` 填 `comments/channel_id/replies_pts/max_id/read_max_id`;`messages.getDiscussionMessage` 返回 root、`messages.getReplies` 读 linked group thread,`messages.readDiscussion` 推进 linked group read watermark。
|
||||
- 已实现文本转发的 channel 路径:channel→channel、channel→user、user→channel;目的 channel 仍按单份消息写入并生成 channel pts;请求携带的目标会话 `reply_to` 会正常校验和持久化,TDesktop 转发到 forum topic 时发送的 `top_msg_id` 会映射为 topic-only reply 并更新 topic top message,但源消息自身 reply 不继承;RPC 响应、账号级 difference/outbox 与 channel difference 都会为 `fwd_from/reply_to` 中可解析的 user/channel peer 补齐 users/chats,避免 TDesktop 因 header peer 未加载而延迟 apply。
|
||||
- 已实现 `messages.search(InputPeerChannel)`:支持 channel/supergroup 单份消息文本搜索、`from_id` 用户过滤、`min_date/max_date`、`offset_id/max_id/min_id` 与 limit cap,结果附带当前 channel 与消息 sender/forward/reply 所需 users/chats。
|
||||
- 已实现 `messages.searchGlobal` 的 channel/supergroup 分支:TDesktop 主搜索的 Channels/Groups tab 会按当前账号 active membership 搜索单份 channel message,支持 `broadcasts_only/groups_only/users_only`、`offset_rate+offset_peer+offset_id` seek、folder_id=0/1 下推与 limit cap=50;未加入/left/kicked/view_messages banned 的频道不暴露,media filter 在 media store 接入前返回空结果。
|
||||
- 已实现 `messages.getMessageReadParticipants(InputPeerChannel)`:基于小 megagroup 成员读水位返回 `readParticipantDate`,含 50 人阈值、7 天过期窗口、`available_min_id` 可见性过滤与 PG 索引。
|
||||
- 已实现 `messages.getMessageEditData`:私聊与 channel peer 都做 message/peer/作者或管理员编辑权限校验;当前文本-only 编辑没有媒体 caption 状态,返回 `caption=false`。
|
||||
- 已实现 `messages.getMessagesViews(InputPeerChannel)`:TDesktop 每秒最多 100 条批量增量,服务端按 `(channel_id,message_id,viewer_user_id)` 持久去重并维护 `channel_messages.views_count` 聚合列;本地清历史前不可见、已删除或不存在的 id 不递增且返回空 view,replies/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` 补偿。
|
||||
- 已实现公开 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`。
|
||||
- 已实现 `channels.toggleViewForumAsMessages`:这是当前账号本地论坛展示模式,不修改 topic 列表;服务端写 `channel_dialogs.view_forum_as_messages`,返回/可靠投递 `updateChannelViewForumAsMessages` 给同账号其它 session,并在 `messages.getDialogs` 与 `channels.getFullChannel` 回填 `Dialog/ChannelFull.view_forum_as_messages`。
|
||||
- 已实现 forum topic 最小真实闭环:`channels.toggleForum` 打开 forum 后,`messages.getForumTopics/getForumTopicsByID` 返回虚拟 General + `channel_forum_topics` 分区表里的 topic;`messages.createForumTopic` 写 `messageActionTopicCreate` root service message,topic_id 绑定该 message id,并用 channel pts 通过 `updateNewChannelMessage` 在线推送。TDesktop 的 topic 输入框 `reply_to_msg_id=0/top_msg_id=topicRootId` 已支持:服务端校验 topic 存在且未关闭/隐藏,消息只写一份 `channel_messages`,保存 `reply_to_top_id` 和 `forum_topic` 标志,`messages.getReplies(topicRoot)` 返回 `messages.channelMessages{pts,topics}` 且 topic 用 short constructor;发送 topic 消息会更新 topic top message,`messages.forwardMessages.top_msg_id` 也走同一 topic-only reply 语义,避免转发到主题落到主会话。`messages.editForumTopic` 写 `messageActionTopicEdit` service message 且 reply_to_top_id 指向 topic root;`messages.updatePinnedForumTopic/reorderPinnedForumTopics` 更新 topic pinned/order 并推送对应 TL update;`messages.deleteTopicHistory` 每页最多删 `MaxDeleteHistoryBatch` 条 root/thread message,通过 `affectedHistory.offset` 续删,最后一页才隐藏 topic。topic page 走 limit cap=100 与 seek 条件,不按客户端超大 offset/id 展开数组。
|
||||
- 已实现 `channels.toggleAntiSpam`:服务端写 `channels.antispam`,返回/推送 `updateChannel`,在 `channels.getFullChannel` 回填 `ChannelFull.antispam`,并记录 `channelAdminLogEventActionToggleAntiSpam`;真实 native anti-spam bot 和自动删垃圾消息管线后续单独接入。
|
||||
- 已实现 `channels.getSendAs` + `messages.saveDefaultSendAs` 的保守 current-channel 链路,并让 `messages.sendMessage/forwardMessages(InputPeerChannel)` 接受同一套 `send_as` 校验结果:列表始终包含 self,只有 creator、broadcast post admin、megagroup anonymous admin 才追加 current channel,且响应必须带 current channel chat + self user 供 TDesktop 解析;不暴露 public channel 扩展候选。`channels.getSendAs` handler 在一次 `GetChannel` 中完成 access_hash 校验、成员读取与候选构造,避免 TDesktop 打开输入框时重复查同一 channel。保存 current channel 会写入 `channel_dialogs` 并在 `channelFull.default_send_as` 恢复;保存 self 清空默认身份。发送/转发未显式带 `send_as` 时会读取已保存默认身份并重新校验,默认值因权限变化失效时自动降级 self,避免陈旧状态阻塞发送。
|
||||
- 已实现 `messages.toggleNoForwards`:legacy chat/channel 内容保护入口持久化 `channels.noforwards`,返回/在线推 `updateChannel`;后续 channel/supergroup 单份消息写入时自动继承 noforwards,避免转发受保护内容。
|
||||
- 已注册 `messages.setChatTheme`,并实现 `messages.setChatAvailableReactions`:前者对 private peer 返回空 updates、对 legacy chat/channel 返回 channel context;后者参考实现 的 `available_reactions` 存储语义,用 domain policy + PG JSONB 持久化,`channels.getFullChannel` 返回 `available_reactions/reactions_limit/paid_reactions_available`,并对 reaction vector/reactions_limit 做 cap。
|
||||
- 已实现 private + channel/supergroup emoji message reaction 最小闭环:`messages.getAvailableReactions` 优先返回 seed 自真实导出的 reaction documents;`messages.sendReaction` 对 private peer 写 `private_message_reactions`,按共享 `private_message_id` 聚合但以各 owner 的 peer/msg_id 构造 `updateMessageReactions`,并写账号级 `message_reactions` durable event,离线设备经 `updates.getDifference` 收到带最新 `message.reactions` 的 message + `updateMessageReactions`;对 channel/supergroup 写 `channel_message_reactions`,替换或清除当前用户对单条 channel message 的 emoji reaction,返回并在线推 update,离线/重开由 `messages.getHistory` / `updates.getChannelDifference` / `messages.getMessagesReactions` 补偿;channel `sendReaction` 每次还累计账号级 `user_top_reactions`,`getTopReactions` 按使用次数排序并优先用真实 available reaction catalog 有界补齐;`add_to_recent` 会写账号级 `user_recent_reactions`,`messages.getRecentReactions/clearRecentReactions` 按 hash 有界返回/清空最近 emoji reaction;`messages.updateSavedReactionTag` 会写账号级 `user_saved_reaction_tags` 并推 `updateSavedReactionTags` 给同账号其它 session,`getSavedReactionTags` 全局请求按 hash 返回标题列表;channel 反应者不是消息作者时标记 unread 并重算 `channel_dialogs.unread_reactions_count`,`messages.getUnreadReactions/readReactions` 按消息作者视角拉取/清理未读 reaction;TDesktop 打开会话时的 `channels.readMessageContents` 会按 visible message ids 清理这些 unread reaction 并推 `updateMessageReactions`,避免 dialog reaction icon 重启后重新出现;`messages.getMessagesReactions` 按请求 id 返回 reaction 聚合清理/刷新 UI,`messages.getMessageReactionsList` 返回具体用户列表(channel 用 keyset offset,private 当前受 recent 嵌入上限约束)。custom emoji、paid reaction、saved-message tag assignment/count/per-peer ranking 留后续模型。
|
||||
- 已实现 `channels.togglePreHistoryHidden/toggleSlowMode`:prehistory 仅 creator 可改,并通过 `available_min_id/read_inbox_max_id` 裁剪新成员历史,同时用 `available_min_pts` 裁剪新成员 difference 起点;slowmode 允许 change_info 管理员改;PG 持久化 `pre_history_hidden/slowmode_seconds/slowmode_last_send_date`,TDesktop 可从 channel/full channel 看到状态,普通成员过快发言返回 `SLOWMODE_WAIT_X`。
|
||||
- 已实现 `channels.toggleParticipantsHidden`:creator/具备 `ban_users` 的管理员可切换 `channels.participants_hidden`,返回/推送 `updateChannel`;TDesktop 通过 `ChannelFull.participants_hidden` 恢复隐藏成员 UI,普通成员的 participants 列表只保留 aggregate count,read participants 返回空,避免用已读详情绕过隐藏成员设置。
|
||||
- 已实现 `channels.getAdminLog`:持久化 channel-scoped admin log,支持 actor/filter/query/max_id/min_id/limit 的有界查询,并返回真实 TL admin log actions;当前覆盖 metadata、成员权限、pin、send/edit/delete,forum/group_call/subscription 等长尾 action 留待对应业务模型。
|
||||
- 已注册 `stats.*` 统计域兼容入口:TDesktop 统计页会调用 `stats.getBroadcastStats/getMegagroupStats/getMessageStats/getMessagePublicForwards/loadAsyncGraph`,当前先做 access_hash、admin、类型、msg_id、limit/offset 边界校验并返回可解析空图表或空 public forwards;真实聚合表、预计算窗口和异步 graph token 后续单独设计,避免在首版频道模块里引入无界统计扫描。
|
||||
- 已注册 `premium.*` boost 域兼容入口:TDesktop 频道统计页、颜色/权限入口和 boost 弹窗会调用 `premium.getBoostsStatus/getBoostsList/getMyBoosts/applyBoost/getUserBoosts`;当前仅做 channel/access_hash/admin/user/limit/offset/slots 边界校验并返回零状态或空列表,不落库、不生成虚假 boost 关系。真实 boost slots、giveaway、premium audience 聚合后续独立建模。
|
||||
- 已实现 invite management:TDesktop 管理 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 constructor:TDesktop `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。
|
||||
- 2026-06-01 双 TDesktop 在线/离线实测已覆盖超级群发送、reply、forward、edit 与离线恢复:Bob 对 Alice 消息 reply,Bob 将回复 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/online,Alice 全局搜索 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` / panic,Debug 当前 `log.txt` 无新增 `Unexpected messages.stickerSetNotModified` / participant 告警。右键消息菜单可打开但本轮未显示 reaction 快捷项,真实 reaction sticker animations/custom UI 仍留后续。
|
||||
- 2026-06-02 10:10 Computer Use 主干路径复测:Debug/Alice 与 DebugBob/Bob 在 `E2E Super 0307` 中双向在线发送 `cu-continue-*`,消息流与左侧 preview 双端可见,未读通过 `channels.readHistory` 清除;Alice 在 `CU Public Search 44238` 发布 `cu-channel-continue-*`,频道消息流和左侧 preview 同步更新。server 日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / panic,Debug 当前日志无新增 API error;DebugBob 主日志仅有 08:05 前旧 sticker/participant 噪声。
|
||||
- 2026-06-02 10:49 Computer Use unread reaction 回归冒烟:本轮 server 启动后自动应用 migration 0051,Debug/Alice 与 DebugBob/Bob 同时打开 `E2E Super 0307`;Alice 发送 `cu-unreadrx-alice-1780368577183` 后 Bob 左侧 preview 出现新消息/未读 badge,Bob 发送 `cu-super-bob-1780356886095` 后 Alice 消息流实时显示。server 日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / panic,Debug/DebugBob 最新 `DebugLogs/log_10_45.txt` 无新增 API error/Unexpected;当前 TDesktop reaction 快捷入口仍未稳定显示,未读 reaction 语义由 `internal/rpc` router 测试覆盖。
|
||||
- 2026-06-02 11:20 Computer Use recent reactions 相关冒烟:本轮 server 启动后自动应用 migration 0052,Debug/Alice 与 DebugBob/Bob 同时打开 `E2E Super 0307`;Bob 发送 `cu-recent-bob-1780370400982` 后双方消息流和左侧 preview 可见,Bob 打开 emoji 面板后 Emoji/Stickers/GIFs 面板正常渲染。server 日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / panic,Debug/DebugBob 最新日志无新增 API error/Unexpected;TDesktop 本轮未重新请求 `messages.getRecentReactions`,recent get/clear 的 hash/notModified/clear 后空列表语义由 `internal/rpc` router 测试覆盖。
|
||||
- 2026-06-02 11:36 Computer Use top reactions 回归冒烟:本轮 server 启动后自动应用 migration 0053,Debug/Alice 与 DebugBob/Bob 同时打开 `E2E Super 0307`;Bob 发送 `cu-toprx-bob-1780371301984` 后双方消息流和左侧 preview 可见,Bob/Alice emoji 面板正常渲染,Bob 右键最新消息菜单可打开。server 日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / panic,Debug 当前日志无新增 API error,DebugBob 仅有 08:05 前旧 sticker/participant 噪声;本轮 TDesktop 没重新请求 `messages.getTopReactions`,top 排序、静态 catalog 兜底、hash/notModified 语义由 `internal/rpc` router 测试覆盖。
|
||||
- 2026-06-02 12:23 Computer Use saved reaction tag title 回归冒烟:本轮 server 启动后自动应用 migration 0054,Debug/Alice 与 DebugBob/Bob 同时打开 `E2E Super 0307`;Bob 发送 `cu-savedtags-final-bob-1780374193558` 后另一客户端消息流可见并触发 channel difference。server 日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / panic/error;本轮 TDesktop 未直接触发 `messages.getSavedReactionTags/updateSavedReactionTag`,账号级标题持久化、hash/notModified 与 `updateSavedReactionTags` 推送语义由 `internal/rpc` router 测试覆盖。
|
||||
- 2026-06-03 unread reaction / 打开历史卡顿回归:Alice Debug 中 `AAAA` 的 unread reaction 由 Bob 对 Alice 自己消息的 reaction 产生;修复后 `channels.readMessageContents` 按 visible message ids 清理该作者视角 unread reaction 并回推 `updateMessageReactions`。Computer Use 验证 Alice 点开 `AAAA` 后 PG `channel_dialogs.unread_reactions_count=0`、`channel_message_reactions.unread=false`;重启 Alice 后 `AAAA` 列表无红色 unread reaction 图标;Bob DebugBob 同时启动并可打开 `AAAA`。同轮修复 `channels.getSendAs` 内重复 `GetChannel`,打开历史时 `messages.getHistory` 为十毫秒级,日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg`。
|
||||
- `channels.editPhoto/setStickers/setEmojiStickers/reorderUsernames/toggleUsername/deactivateAllUsernames` 仍为兼容 stub:头像删除只做 no-op channel state 响应,真实上传/已有照片返回 `PHOTO_INVALID`;群贴纸/custom emoji 只允许 megagroup `inputStickerSetEmpty` no-op 清空,非空 sticker set 返回 `STICKERSET_INVALID`;Fragment/多 username 入口不修改主 username,避免数据破坏。
|
||||
- Layer 225 `OnChannels*` dispatcher 与 legacy chat/channel 入口已全部显式注册;未进入首批真实业务的入口均为参数有界、权限校验或空结果 stub,避免 TDesktop 实测出现 `NOT_IMPLEMENTED`/unknown RPC。
|
||||
|
||||
## Permissions
|
||||
|
||||
首批权限模型:
|
||||
|
||||
- creator:全权限。
|
||||
- admin:按 `AdminRights` 判断 invite/delete/edit/bans/change_info/post_messages。
|
||||
- member:megagroup 可发消息,broadcast 默认不可发。
|
||||
- left/kicked/banned:不可读写;banned rights 可限制 `send_messages/view_messages/invite_users/pin_messages/change_info`。
|
||||
- default banned rights:channel 级默认禁言,member 无显式权限时继承。
|
||||
|
||||
TDesktop 最小入口:
|
||||
|
||||
- `channels.getFullChannel` 必须返回当前用户 admin/banned/default rights、participants/read/unread/notify/invite link/pts。
|
||||
- `channels.getParticipants` 支持 recent/admins/search/kicked/banned/bots/contacts/mentions 的最小分页,limit cap 200;banned/kicked 只向 admin 暴露。
|
||||
- `channels.editAdmin/editBanned/editTitle/deleteChannel/updateUsername/toggleSignatures/togglePreHistoryHidden/toggleParticipantsHidden/toggleForum/toggleAntiSpam/toggleSlowMode/updateColor/updateEmojiStatus/toggleViewForumAsMessages/getAdminLog/exportMessageLink` 已有真实权限/可见性校验;`channels.editPhoto/setStickers/setEmojiStickers/reorderUsernames/toggleUsername/deactivateAllUsernames` 暂为兼容 stub,后续接 files/Fragment username/stickers/custom-emoji/boost gating 等模型。
|
||||
|
||||
## Online Push
|
||||
|
||||
在线推送分两类:
|
||||
|
||||
- user 维度:dialog/folder/pin/notify/read state 等 owner 状态,继续写 `user_update_events + dispatch_outbox`。
|
||||
- channel 维度:消息/edit/delete/pin/member service message,写 `channel_update_events`,在线 fanout 给 active channel sessions。
|
||||
|
||||
为避免大频道 O(n) 离线写扩散:
|
||||
|
||||
- 不把每条 channel message 写入所有成员的 `dispatch_outbox`。
|
||||
- 参考实现 的 status online sessions / `MutableChannelOnlinePush` 边界,当前实现先从 `SessionManager.OnlineUserIDs` 取有界在线 user 快照,再用 `channel_members(channel_id,user_id)` 过滤 active member,最后合并操作显式 recipient(例如 leave/kick 后的本人)。这样避免“大频道取前 500 个成员却漏掉真实在线用户”,也避免每条消息按全体成员写扩散;后续可升级为 `channel_id -> active sessions` 订阅索引。
|
||||
- `messages.setTyping(InputPeerChannel)` 是纯瞬时在线推送:TDesktop topic 输入框带来的 `top_msg_id` 只在 `updateChannelUserTyping` 中透传给在线成员,先做 `0..MaxMessageBoxID` 边界校验,非法返回 `MSG_ID_INVALID`,避免异常 topic id 扩散到其它 session。
|
||||
- 离线成员不接 outbox;重新打开会话或 gap 时通过 `updates.getChannelDifference` 补齐。
|
||||
|
||||
## Safety / Performance
|
||||
|
||||
- 所有 list/id/vector 参数必须 cap:participants limit 200、offset 最大 10000、participants/admin-log 搜索 q 最大 128 字符、history/search/searchPosts q 最大 256 字符、searchPosts limit 50、paid message stars 10000、boost unrestrict 0..8、history 100、get ids 100、delete ids 1000、forward ids 100、channel difference 100。
|
||||
- 所有实时 fanout 必须有硬上限:首版 `MaxChannelRealtimeFanout=500`,不能因为一个大频道消息或 typing 动作把全体成员读入内存。
|
||||
- channel history/search 只返回有界页;PG 用 `limit+1` 判断是否还有下一页,不为 UI 计数执行 `count(*) over()` 或全量 count。
|
||||
- 禁止按客户端传入 `int64 max/max_id` 构造数组;deleteHistory 必须批量 seek。
|
||||
- `channel_messages` 和 `channel_update_events` 查询必须带 `channel_id` 分区键。
|
||||
- `channel_dialogs` 查询必须带 `user_id` 分区键。
|
||||
- 从 user 入口访问 channel membership 时不得直接扫 `channel_members WHERE user_id=...`;该表按 `channel_id` 分区,实测会展开 64 个分区。必须走 `user_channel_member_index`,或先取 bounded channel_id 列表再用 `channel_id/id = ANY($1)` 二段查询。
|
||||
- 对 `channels` / `channel_members` 这类分区表,SQL 内动态 join key 不能保证分区裁剪;`index -> partitioned table` 单条 join 实测仍会展开全部分区。
|
||||
- 单条消息文本沿用 4096 code point 限制,entities cap 256。
|
||||
- 大频道 unread 不能在应用层逐成员循环;当前兼容实现只允许数据库 set-based 缓存推进并保留 read watermark 可重算路径,后续高规模频道需拆后台 fanout 或更强惰性缓存。
|
||||
- 所有 channel pts 分配失败/事务失败要有 noop gap 补偿与指标。
|
||||
- 压测目标:单机 1000 msg/s megagroup send p99 < 150ms;`getChannelDifference` p99 < 100ms;10 万成员频道单条 broadcast 不产生 O(n) 数据库写。
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
1. Domain/store:补 `PeerTypeChannel`、channel/member/message/dialog/update DTO 与 store interfaces。
|
||||
2. Migration:新增分区表、索引、Redis allocator source。
|
||||
3. App:新增 `internal/app/channels`,封装创建、成员、发送、history、read、difference。
|
||||
4. RPC:注册 `channels.*`、`messages.createChat`、`updates.getChannelDifference`,给 messages 现有 RPC 增加 channel peer 分支。
|
||||
5. TL 转换:只在 `internal/rpc` 实现 domain ↔ `tg.Channel/ChannelFull/Message/UpdateNewChannelMessage`。
|
||||
6. Online push:新增 channel active fanout,user dialog 状态继续用 reliable outbox。
|
||||
7. Tests:domain/store/app/rpc 单元测试,PG/Redis 集成测试覆盖 pts 连续、差量、deleteHistory 分批、权限。
|
||||
8. TDesktop:双端/多端验证创建超级群、频道、邀请、退出、在线/离线消息、history、read、edit/delete、forward/reply、typing、dialog/filter/归档展示。
|
||||
|
||||
## 媒体与频道头像(2026-06-02)
|
||||
|
||||
- `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` 上传或引用照片,admin(change_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。
|
||||
419
docs/compatibility-matrix.md
Normal file
419
docs/compatibility-matrix.md
Normal file
|
|
@ -0,0 +1,419 @@
|
|||
# Telegram Desktop Compatibility Matrix
|
||||
|
||||
Client: Telegram Desktop dev 9caf32dffc (describe v6.8.4-15-g9caf32dffc, pinned baseline)
|
||||
Server: telesrv
|
||||
gotd/td: v0.144.0
|
||||
Layer: 225
|
||||
Date: 2026-06-02
|
||||
|
||||
status 取值:done(真实实现) / stub(兼容响应) / todo(已发现未实现) / blocked(需先补协议或数据结构)
|
||||
|
||||
> 连接层(mtprotoedge)M0–M5 已完成。**登录注册闭环已打通**:
|
||||
> auth.sendCode/signIn/signUp/logOut + users.getUsers + updates.getState 真实实现,
|
||||
> telegram.Client 端到端验证(sendCode→signIn→signUp→getUsers→启动后空账号 RPC,TestLoginRegisterFlow)。
|
||||
> 启动必经的空账号 RPC 已从硬编码 stub 推进到 PG-backed 业务查询:
|
||||
> app_configs / countries / account_passwords / temp_auth_key_bindings /
|
||||
> update_states / contacts / dialogs / langpack,语言包 seed 来自导出的 TDesktop 数据。
|
||||
> TDesktop 进入空账号主界面后的异步预取 RPC 已补第一阶段空响应,避免后台重试噪声。
|
||||
> PG 集成测试覆盖 AuthKeyStore 与业务 store 往返(TestBusinessStoresRoundTrip)。
|
||||
> active session 已缓存 raw auth_key_id、业务 auth_key_id、session_id、user_id;router 入口只做一次 temp→perm 与 auth_key→user_id 解析,业务服务按 user_id 查询;普通注册 user_id 从 1780243200 起递增。
|
||||
> 二阶段私聊文本消息链路已落地:`messages.sendMessage` 事务内写 `private_messages` / 双端 `message_boxes` / `dialogs` / `user_update_events` / `dispatch_outbox`,`pts` 与 owner 视角 `box_id` 由 Redis 原子计数并可从 PG durable log 恢复;outbox worker 批量推送在线 session,链路指标覆盖发送、限流、outbox claim/deliver/fail。
|
||||
> 联系人与 dialog owner 视角已补齐:`contacts.addContact/importContacts/deleteContacts/updateContactNote/getContactIDs/getStatuses/search`、dialog pinned/manual unread/peer settings、`messages.*DialogFilter*` 与 `folders.editPeerFolders` 均真实落库;`contacts` 保存每个 owner 对同一 user 的独立姓名/电话/备注,`dialogs` 保存置顶顺序、manual unread、action bar 隐藏态与 archive folder_id,自定义 filters 独立存 `dialog_filters`。
|
||||
> username 生命周期已补齐:注册仍按 Telegram 语义只创建手机号/姓名账号,后续通过 `account.checkUsername/updateUsername` 设置或清除主 username;PG 以 `lower(username)` partial unique index 防并发占用,`contacts.resolveUsername/resolvePhone` 可用于 TDesktop 搜索/链接解析。
|
||||
> 注册资料闭环已补齐:TDesktop 手机号验证码后进入 sign-up 页,`auth.signUp` 携带 first_name/last_name 完成建号;主界面 Settings/Profile 的姓名与 bio 变更走 `account.updateProfile`,写入 `users.first_name/last_name/about` 并向其它设备可靠推送 `updateUserName`。
|
||||
> 联系人 reset、dialog pinned/order/manual unread/peer settings、dialog filters/order/reload、folder peers 已写入账号级 durable update log,并同事务写入 `dispatch_outbox`;离线设备可通过 `updates.getDifference` 恢复这些状态变化,在线其它端由 outbox worker 可靠投递且排除当前 session。
|
||||
> PG 集成测试覆盖“两个 owner 给同一联系人不同备注、改备注 hash 变化、互相关系同步、删除反向 mutual 清理、dialog 列表按当前 owner 备注展示、置顶重排、manual unread、隐藏 peer settings bar、归档 folder 与自定义 filter”。
|
||||
> 2026-05-31 TDesktop Debug 客户端已用独立 workdir 启动并连到当前 server,日志证明 key exchange、`auth.bindTempAuthKey`、启动配置 RPC、搜索用户、打开私聊、发送消息、`messages.getDialogFilters` 与自定义 filter 左侧分组展示均可走通;本轮 Settings/Folders 搜索页额外触发的 account/contacts/messages 预取 RPC 已补第一阶段兼容 stub。
|
||||
> 2026-05-31 双 TDesktop 在线/离线复测已覆盖私聊 read/edit/delete:在线 read 触发 sender `Seen`,在线 edit/delete 两端同步;Bob 离线时 Alice send+edit,Bob 重启拿到最终文本并可回读,Bob 离线期间 Alice revoke delete,Bob 重启后会话和消息均清除。联调中补齐 `messages.getOutboxReadDate`,并修正 MTProto content-related service request 口径(`ping*` / `get_future_salts` / `msgs_state_req` / `msg_resend_req` / `destroy_*` 需要 odd seq_no 与 msgs_ack),23:51 后复跑日志无新增 bad_msg / NOT_IMPLEMENTED。
|
||||
> 2026-06-01 双 TDesktop 在线/离线复测已覆盖超级群普通发送、目标会话 reply、forward 到同一超级群、频道消息编辑与离线恢复:Alice/Bob 双窗口均能实时看到对应 `UpdateNewChannelMessage` / `UpdateEditChannelMessage` 结果;Bob 关闭期间 Alice 发送 channel 消息,Bob 重启后 dialog 未读数=1,打开群后通过恢复路径看到离线消息;server 日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / panic。删除、清历史、踢/禁言属于破坏性 UI 操作,需行动时确认后再点客户端确认。
|
||||
> 2026-06-02 Computer Use 双 TDesktop 复测已覆盖超级群双向在线发送、成员栏显示、全局搜索命中 channel/supergroup 消息与公开频道广播发帖:Alice/Bob 在 `E2E Super 0307` 中互发 `cu-round-alice-*` / `cu-round-bob-*` 并实时显示,Alice 搜索 Bob 消息返回 `Found 1 message`,`CU Public Search 44238` 频道发 `cu-channel-round-*` 后消息流与左侧预览同步更新;server 日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / panic,客户端本轮无新增 participant 告警。清空搜索框触发的 `SEARCH_QUERY_EMPTY` 保持可解释。
|
||||
> 2026-06-02 09:55 Computer Use 双 TDesktop 复测补充覆盖 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` / panic,Debug 当前 `log.txt` 无新增 `Unexpected messages.stickerSetNotModified` / participant 告警。右键消息菜单可打开但未显示 reaction 快捷项,真实 reaction sticker animations/custom UI 留后续。
|
||||
> 2026-06-02 10:10 Computer Use 双 TDesktop 复测再次覆盖当前主干频道/超级群路径:Debug/Alice 与 DebugBob/Bob 在 `E2E Super 0307` 中双向在线发送 `cu-continue-*`,消息流与左侧 preview 双端可见,`channels.readHistory` 清除未读;Alice 在 `CU Public Search 44238` 发布 `cu-channel-continue-*`,频道消息流和左侧 preview 同步更新。server 日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / panic,Debug 当前日志无新增 API error;DebugBob 主日志仅有 08:05 前旧 sticker/participant 噪声。
|
||||
> 2026-06-02 11:20 Computer Use 双 TDesktop recent reactions 相关冒烟:新 server 应用 migration 0052 后,Debug/Alice 与 DebugBob/Bob 同时连接 `E2E Super 0307`;Bob 发送 `cu-recent-bob-1780370400982`,双方消息流与左侧 preview 可见;Bob 打开 emoji 面板,Emoji/Stickers/GIFs 面板正常渲染。server 日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / panic,Debug/DebugBob 最新日志无新增 API error/Unexpected;本轮 TDesktop 受客户端缓存影响未观察到重新发送 `messages.getRecentReactions`,get/clear 的 hash/notModified/clear 后空列表语义由 `internal/rpc` router 测试覆盖。
|
||||
> 2026-06-02 11:36 Computer Use 双 TDesktop top reactions 回归:新 server 应用 migration 0053 后,Debug/Alice 与 DebugBob/Bob 同时连接 `E2E Super 0307`;Bob 发送 `cu-toprx-bob-1780371301984` 后双方消息流与左侧 preview 可见,emoji 面板保持 Emoji/Stickers/GIFs 正常渲染,右键消息菜单可打开。server 日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / panic,Debug 当前日志无新增 API error,DebugBob 仅有 08:05 前旧 sticker/participant 噪声;TDesktop 本轮命中本地 reaction 缓存未重新请求 `messages.getTopReactions`,账号 top 排序、catalog 兜底与 hash/notModified 语义由 `internal/rpc` router 测试覆盖。
|
||||
> 2026-06-02 媒体闭环已落地:migration `0057_media`(upload_parts/file_blobs/documents/photos/sticker_sets/available_reactions/profile_photos + 消息表 media JSONB 列)/`0058`(appConfig reactions_default)/`0059`(channel photo 列);新增 `upload.saveFilePart/saveBigFilePart/getFile/getFileHashes`、`photos.uploadProfilePhoto/updateProfilePhoto/getUserPhotos/deletePhotos`,本地磁盘 blob backend;`getAvailableReactions/getStickerSet/getAllStickers/getEmojiStickers/getCustomEmojiDocuments` 返回真实 document(启动从 `TELESRV_STICKER_SEED_DIR` 幂等 seed 74 reactions/24 sets/~1.5k docs/~3k blob 索引);`messages.uploadMedia/sendMedia/sendMultiMedia` 支持 photo/document/sticker 主路径(私聊 + channel,复用 pts/box/outbox/在线推送/离线 difference);User/Channel/Full 渲染真实头像。**自动化验证**:`go test ./...`(含 PG 集成测试 167s 全过,覆盖媒体列 + 频道 photo 列扫描)、server 启动 migrate+seed 无错误、`internal/rpc` 新增贴纸/图片 sendMedia 端到端单测全过。**待人工**:双 TDesktop 测私聊/超级群/频道的图片/文件/贴纸消息、reaction 面板真实动画、头像、在线 fanout 与离线 difference,确认日志无新增 `NOT_IMPLEMENTED`/`Unhandled RPC`/`bad_msg`/panic/API error。范围外 poll/todo/scheduled/paid/CDN/sticker 管理(install/uninstall/reorder)/grouped_id 相册聚合继续显式 stub/todo。
|
||||
> 2026-06-03 媒体接手审计补充:全量 `go test ./...`(PG/Redis env-gated,169s)通过,`TestSeedMediaFromRealExport -count=1` 确认 `TELESRV_STICKER_SEED_DIR` seed 为 74 reactions / 11 sets(maxRegularSets=2)/ 1355 docs / 2682 blobs。参考 TDesktop `image_location_factory.cpp` 发现 `StickerSet.thumbs` 的可下载 `PhotoSize` 会触发 `inputStickerSetThumb`,而当前导出目录仅有 set_cover `PhotoPathSize` SVG、无可服务 raster blob;已在 seed 与 TL 转换层过滤 sticker set cover 的 downloadable thumb,只保留非下载占位,避免 TDesktop 生成不可满足的 cover 下载请求。另修复 channel/supergroup 仅媒体无 caption 被 `SendChannelMessage` 误判空消息的问题,并新增 PG 集成测试覆盖 channel media `updates.getChannelDifference` 恢复。**仍待人工**:双 TDesktop 按本轮目标完整验证图片/文件/贴纸、头像、reaction 动画下载与在线/离线同步日志。
|
||||
> 2026-06-03 头像/资料路径审计补充:Alice 上传用户头像时 TDesktop 额外触发 `account.getDefaultGroupPhotoEmojis`、`account.getConnectedBots`、`stories.getAlbums`;群/频道头像编辑页继续触发 `messages.getEmojiProfilePhotoGroups`。参考 TDesktop 消费点、gotd Layer225 返回类型、参考实现 空集合 handler、参考实现 emoji-categories DAO/connected-bots/custom-emoji 空集合实现后,当前按有界兼容 stub 返回空列表,避免资料页/头像编辑后台 `NOT_IMPLEMENTED` 与 business/stories/custom-emoji 模型扩张。自动化验证:`go test ./internal/compat/tdesktop ./internal/rpc -run "Test(EmojiProfilePhotoGroups|TDesktopStartupRPCsEncode)" -count=1`、`go test ./...`、`TestSeedMediaFromRealExport -count=1` 均通过,migration 状态 `59|f`;双 TDesktop 群/频道头像 UI 回归仍待 Computer Use 恢复后补证据。
|
||||
> 2026-06-03 reaction 全路径审计与双 TDesktop 回归:参考 TDesktop `api_reactions_notify_settings.cpp`、`data_message_reactions.cpp`、`api_updates.cpp`,参考实现 reaction 设置/聚合/更新语义,以及 gotd Layer225 `messages.sendReaction` / `updateMessageReactions` / paid/privacy/participant/stories 入口后,补齐私聊 reaction durable event 与账号设置/paid privacy/participant 删除/stories 兼容入口。自动化验证:`go test ./internal/app/updates ./internal/rpc -run "Test(GetState|GetDifference|Record|UpdatesGetState|UpdatesDifferenceIncludesReaction|MessagesSendReactionPrivate)" -count=1`、`go test ./... -count=1` 通过。Computer Use 双端实测仅使用 `patched Telegram.exe -workdir .tdata-alice`(Alice)与 `patched Telegram.exe -workdir .tdata-bob`(Bob):私聊在线 reaction Alice 实时同步;Alice 离线后 Bob 改 reaction,Alice 重启经 `updates.getDifference` 恢复;超级群 `AAAA` 右键 reaction 快捷栏不再空白,在线同步正常;Alice 离线后 Bob 改群消息 reaction,Alice 重启打开群后经 `messages.getHistory` / `updates.getChannelDifference` / `messages.getMessagesReactions` 恢复。最终 server `bin\\telesrv.exe` 监听 2398,日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / panic / internal error。
|
||||
> 2026-06-03 unread reaction / 打开历史卡顿修复回归:参考 TDesktop `UnreadThings::requestReactions`、`History::clearUnreadReactionsFor`、`SendAsPeers::request`,参考实现 `readMessageContents/readReactions` 重算 unread reaction 语义,参考实现 send-as/reaction read model 与 gotd Layer225 `channels.readMessageContents` / `messages.getUnreadReactions` / `channels.getSendAs` 后,修复 channel/supergroup 打开会话只读 visible messages、不清作者视角 unread reaction 的问题;并去掉 `channels.getSendAs` handler 内 access_hash 校验 + 后续取 channel 的重复 `GetChannel`。验证:Alice Debug 打开 `AAAA` 后 `channel_dialogs.unread_reactions_count` 从 1 变 0,`channel_message_reactions.unread` 从 true 变 false;重启 Alice 后 `AAAA` 会话列表不再出现红色 unread reaction 图标;DebugBob/Bob 同时启动并打开 `AAAA`,history 正常展开。最新 server 日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg`,Alice 打开 `AAAA` 的 `messages.getHistory` 为 10.8ms/5.3ms,Bob 为 34.0ms/15.9ms,`channels.getSendAs` 去重后主路径约 10-27ms(Alice 一条 72ms 属独立预取调用,未伴随 history 慢查询)。
|
||||
> 2026-06-03 sticker/reaction 启动资源检查修复:参考 TDesktop `api_hash.cpp` / `data_stickers.cpp` 后,`messages.getAllStickers` / `messages.getEmojiStickers` 的 catalog hash 改为客户端同公式(按 set 顺序仅 `HashUpdate(set.hash)`,跳过 archived/default set),避免 Debug/DebugBob 日志反复出现 `received stickers hash ... while counted hash ...` 并导致重启后 full catalog 失效重拉;`messages.getAvailableReactions` 继续用服务端稳定 hash,但统一到同一 hash helper,命中时返回 `messages.availableReactionsNotModified`。自动化验证:`go test ./internal/rpc -run "TestSticker|TestMessagesGetAllStickers|TestMessagesGetAvailableReactions" -count=1`、`go test ./internal/rpc -count=1`、`go test ./internal/app/files -count=1`、`go test ./... -count=1` 均通过;双 TDesktop Debug/Alice + DebugBob/Bob 连新 server 重启两轮,server 日志出现 `messages.getAvailableReactions/getStickerSet/getStickers` 与一次成功 `upload.getFile`,无 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / `LOCATION_INVALID`,客户端新日志无 `received stickers hash ... counted ...` / `Unexpected messages.stickerSetNotModified`。
|
||||
> 2026-06-03 sticker/reaction 历史首开资源冷路径修复:参考 TDesktop `ApiWrap::requestStickerSets`、`Data::Stickers::somethingReceived`、`DocumentMedia::checkStickerSmall` 后确认,打开历史时若 sticker set 处于 `NotLoaded` 或文档缩略图/动画首次解码,客户端会拉完整 `messages.getStickerSet(hash=0)` 并依赖 128px document thumb 先显示。Files service 新增 `object_key→小 blob bytes` LRU(单项 ≤256KB,总 64MB)和完整 sticker set cache,server 启动 `WarmCaches` 从已 seed 的 sticker/reaction 元数据预热 set、document 与可下载缩略图。验证:`go test ./internal/app/files -count=1`、`go test ./internal/rpc -count=1`、`go test ./... -count=1` 均通过;`bin\\telesrv.exe` 启动日志预热 `sticker_sets=24 documents=1554 blobs=2809`;Computer Use 启动 Debug/Alice 与 DebugBob/Bob 并点击含 sticker 历史会话,`messages.getStickerSet` 从冷路径约 18-24ms 降到 0-1.7ms,点击历史 `messages.getHistory` 为 5-16ms,未再触发 `upload.getFile` 冷下载;server/client 日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / `LOCATION_INVALID` / `Unexpected messages.stickerSetNotModified`。
|
||||
> 2026-06-03 sticker installed-cache 语义修复:继续对照 TDesktop `Storage::Account::writeInstalledStickers` / `Data::ParseStickersSetFlags` 后确认,`installed_date` 会让 set 进入 Installed;普通 installed stickers 集若仍处于 `NotLoaded`,客户端会中止本地 installed stickers 写入。telesrv seed 过去把 `InputStickerSetAnimatedEmoji` / dice / generic animations 等 system set 也标成 installed,导致系统资源混入普通 installed stickers 缓存路径。现 migration `0066_system_sticker_sets_not_installed` 修复既有库,并改 seed:`set_kind=system` 仅作为按 input 系统 key 解析的内置资源,不再声明为 installed。
|
||||
> 2026-06-03 sticker 静态缩略图元数据修复:继续读 TDesktop `history_view_sticker.cpp` / `data_document_media.cpp` / `data_cloud_file.cpp` 后确认,历史页若看到 document `PhotoPathSize` 会把它当成 vector placeholder,`dataMediaCreated()` 因 `thumbnailPath()` 非空跳过 `thumbnailWanted()`,从而挡住同时存在的真实 raster thumb。seed 现将 ≤32KB 可下载 document thumb 转为 `PhotoCachedSize` 并写入 image cache 所需 bytes;当 document 已有 raster/default/cached/progressive thumb 时丢弃 `PhotoPathSize`,仅没有 raster 的 set cover 继续保留 path 占位;thumb blob MIME 由字节魔数写入,`upload.getFile` 也优先按魔数返回 `storage.fileWebp`,兼容旧库误标 `image/jpeg`。验证:`go test ./internal/app/files -run "TestSeedMediaFromRealExport|TestSeedPreferRasterDocumentThumbsDropsPathWhenRasterExists|TestDocumentsNeedInlineCachedThumbsDetectsPathWithRaster|TestDocumentsNeedInlineCachedThumbsDetectsStaleMime" -count=1`、`go test ./internal/rpc -run "TestStorageFileType" -count=1`、`go test ./... -count=1` 通过;新 server 启动 repair 后 PG `documents.thumbs` kind 分布为 `cached=2313`,两条样例 `5415908822013185960/5381935901284774004` 均仅 `cached/m`,`file_blobs` document thumb MIME 为 `image/webp=2313`;服务进程 PID 53892 监听 2398。
|
||||
> 2026-06-03 sticker 文档身份修复:继续对比 TDesktop `data_session.cpp` / `data_document.cpp` / `history_view_sticker.cpp` 后确认,TDesktop 以 `document_id` 复用 `DocumentData`,且 `DocumentData::updateThumbnails()` 不会清掉旧的 inline/path thumbnail 状态;一旦服务端把外部导出 document id 当成本服资源主键,旧 Debug tdata 中同 id 的污染对象会持续影响历史页渲染。telesrv 现把导出 JSON/文件名中的 document id 只作为 seed source id,在 `internal/app/files` 导入阶段归一为 telesrv-owned storage id;RPC、`InputDocument`、`inputDocumentFileLocation`、`getCustomEmojiDocuments`、channel custom emoji status/reaction/color 均直接使用同一个服务端 document id,不再做边界映射。migration `0067_seed_document_id_namespace` 同步修复既有开发库中的 documents/file_blobs/sticker_sets/available_reactions/message media/channel appearance 引用。document thumb 在 store 中可保留 cached bytes,但 RPC 对 document 统一暴露 downloadable `photoSize m`,避免 `PhotoCachedSize` 与本地旧 cache 组合出不可替换状态。验证:`go test ./internal/app/files -run TestSeedMediaFromRealExport -count=1 -v`、`go test ./... -count=1` 通过;server `bin\\telesrv.exe` 监听 2398,migration 状态 `67|false`,PG `documents/file_blobs/message media/available_reactions/sticker_sets` 均无 `>4e18` 外部 document id;Computer Use 重启 Debug/Alice 与 DebugBob/Bob 后分别打开 Bob B/Alice A,250ms 截图已显示 sticker,server 与两端客户端日志无 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / `LOCATION_INVALID` / `API Error` / sticker hash 异常。
|
||||
|
||||
## Transport / MTProto 服务消息
|
||||
|
||||
| 消息 | status | behavior | note |
|
||||
|---|---|---|---|
|
||||
| transport codec 协商 | done | real | intermediate/abridged/full 自动探测 |
|
||||
| TCP obfuscation | done | real | TDesktop tcpo_only:先解 64 字节 obfuscated 前缀,再探测 abridged codec |
|
||||
| TDesktop reconnect fake req_pq | done | real | 既有 auth key 重连时先 fake req_pq 再发加密帧;server 回 resPQ 后 replay 后续加密帧,不断开 session |
|
||||
| 密钥交换 (req_pq…dh_gen_ok) | done | real | exchange.Server,auth key 落 AuthKeyStore |
|
||||
| 加密消息收发 | done | real | crypto.ServerCipher + container/gzip |
|
||||
| new_session_created | done | real | 连接首个加密消息后发送 |
|
||||
| msgs_ack | done | real | content-related 消息统一确认;口径对齐 TDesktop `SerializedRequest::needAck()`,`ping*` / `get_future_salts` / `msgs_state_req` / `msg_resend_req` / `destroy_*` 都按 content-related 处理 |
|
||||
| msgs_state_req | done | real-minimal | 连接层按当前连接已见 client msg_id 返回 msgs_state_info,并作为 content-related request 回 ack,避免 TDesktop 重连状态探测落到 RPC fallback;完整 out queue 状态留后续 session 状态机扩展 |
|
||||
| msg_resend_req | done | real-minimal | 连接层按当前连接已见 client msg_id 兜底回复 msgs_state_info,并作为 content-related request 回 ack;后续完整 outgoing queue 后再补真实重发 |
|
||||
| rpc_drop_answer | done | real-minimal | 连接层以 rpc_result 包装 rpc_answer_unknown,避免清理请求落到业务 RPC fallback |
|
||||
| destroy_session | done | real-minimal | 返回 destroy_session_ok/none;命中非当前活跃 session 时清理运行态索引与 SessionStore 记录 |
|
||||
| http_wait | done | real-minimal | TCP/container 场景下解码后吞掉,不加入 msgs_ack;HTTP long-poll 语义第一阶段不启用 |
|
||||
| destroy_auth_key | done | real-minimal | TDesktop 清理旧 auth key 时连接层直接返回 destroy_auth_key_ok,避免落到业务 RPC fallback;物理删除 auth key 留后续安全清理任务 |
|
||||
| active session update gate | done | real | 在线 session 先注册但不接收业务 updates;`updates.getState/getDifference` 后才放开,期间主动推送暂存,语义对齐 参考实现 session 的 canSync |
|
||||
| ping / ping_delay_disconnect | done | real | 回 pong,并作为 content-related request 回 msgs_ack;TDesktop 保活 odd seq_no 不再触发 bad_msg code 34 |
|
||||
| get_future_salts | done | real | 返回当前 auth key 的权威 server_salt 有效窗口,并作为 content-related request 回 ack |
|
||||
| bad_msg_notification | done | real | msg_id==0 等非法情形 |
|
||||
| bad_server_salt | done | real | 客户端带错 salt 时返回 error_code=48 与当前权威 salt |
|
||||
|
||||
## Wrapper
|
||||
|
||||
| 方法 | status | behavior | note |
|
||||
|---|---|---|---|
|
||||
| invokeWithLayer | done | unwrap | 注入 layer 到 RPC ctx |
|
||||
| initConnection | done | unwrap | 注入设备/应用信息到 RPC ctx |
|
||||
| invokeWithoutUpdates | done | unwrap | 透传内层 query |
|
||||
|
||||
## Boot / Config
|
||||
|
||||
| method | status | behavior | note |
|
||||
|---|---|---|---|
|
||||
| help.getConfig | done | real | 返回含自建 DC 的 DCOptions |
|
||||
| help.getNearestDc | done | real | 返回自建 DC |
|
||||
| help.getAppConfig | done | real | PG-backed app_configs;默认 seed 包含 TDesktop read mark config:`chat_read_mark_size_threshold=50`、`chat_read_mark_expire_period=604800`、quote reply config:`quote_length_max=1024`,以及 anti-spam UI config:`telegram_antispam_group_size_min=200`、`telegram_antispam_user_id=5434988373` |
|
||||
| help.getCountriesList | done | real | PG-backed countries/country_codes;默认 seed US/CN |
|
||||
| help.getTimezonesList | stub | small-list | TDesktop Business hours 预加载入口;返回非空常用时区列表和 hash/notModified,避免客户端周期性 `NOT_IMPLEMENTED` |
|
||||
| help.getPeerColors | stub | notModified | TDesktop 主界面加载 peer 色板;第一阶段无服务端色板 |
|
||||
| help.getPeerProfileColors | stub | notModified | TDesktop 主界面加载 profile 色板;第一阶段无服务端色板 |
|
||||
| help.getPromoData | stub | empty | 无 PSA/MTProxy 推广,返回空并设置短期 expires |
|
||||
| help.getTermsOfServiceUpdate | stub | empty | 第一阶段无 TOS 更新 |
|
||||
| help.getPremiumPromo | stub | empty | 第一阶段不做 Premium 展示数据 |
|
||||
|
||||
## Auth
|
||||
|
||||
| method | status | behavior | note |
|
||||
|---|---|---|---|
|
||||
| auth.bindTempAuthKey | done | real-validated | 校验 encrypted bind_auth_key_inner(nonce/temp key/perm key/session/expires_at)后记录 temp_auth_key_bindings;后续 temp auth_key RPC 会解析为 perm auth_key 身份,并刷新 active session 的业务 auth_key_id / user_id 缓存 |
|
||||
| auth.exportLoginToken | stub | tdesktop-qr-placeholder | TDesktop QR 登录页轮询;第一阶段只支持手机号登录,返回短期 auth.loginToken 避免未实现噪声 |
|
||||
| auth.sendCode | done | dev-code | 开发固定验证码,phone_code_hash 存 Redis CodeStore;手机号统一规范为纯数字 |
|
||||
| auth.signIn | done | real | 校验码;兼容 TDesktop sendCode 后 signIn 使用 DigitsOnly(phone);登录成功绑定在线 session 的 user_id,写入并延迟推送 777000 官方登录消息 updateNewMessage,同时向其它在线 session 推送新登录 updateServiceNotification |
|
||||
| auth.signUp | done | real | 建 user + 绑定 authorization;登录成功绑定在线 session 的 user_id,写入并延迟推送 777000 官方登录消息 updateNewMessage |
|
||||
| auth.logOut | done | real | 解绑当前 auth_key 的授权,并清理同业务 auth_key 的活跃连接 user_id 缓存与 auth_key+user update state;账号级 user_update_events 不随设备退出删除,退出后同设备换号不会继承旧账号差分 |
|
||||
| account.checkUsername | done | real | username 规则为 ASCII 字母开头、后续字母/数字/下划线、5-32 字符;大小写不敏感检查占用,已被本人占用视为可用 |
|
||||
| account.updateUsername | done | real | 设置或清除当前账号主 username;大小写不敏感唯一约束由 PG partial unique index 兜底,并向其它在线 session 推送 updateUserName |
|
||||
| account.updateProfile | done | real | 更新当前账号 first_name/last_name/about;first_name 必填且姓名 64 字符内,about 70 字符内;资料变化后向其它在线 session 推送 updateUserName,`users.getFullUser` 返回 about |
|
||||
| account.getPassword | done | real | PG-backed account_passwords;第一阶段默认无 2FA |
|
||||
| account.getAccountTTL | stub | default-365d | TDesktop Settings/self-destruct 预取;当前不做账号自动销毁持久配置,返回正数默认 TTL 避免后台 NOT_IMPLEMENTED |
|
||||
| account.getNotifySettings | stub | default | TDesktop 主界面读取通知设置;显式返回 show_previews=true、silent=false、mute_until=0 与 default sound,避免空 settings 被客户端按静默展示 |
|
||||
| account.updateNotifySettings | stub | ok | 第一阶段不持久化通知偏好,但接受 TDesktop 设置写入,避免本地通知状态 RPC 报错 |
|
||||
| account.getPrivacy | stub | default | Settings/Folders 预取隐私项;参考实现 默认规则:手机号默认 disallowAll、生日 allowContacts、其它 allowAll |
|
||||
| account.getAuthorizations | stub | empty | Settings 设备列表预取;第一阶段不展示授权设备管理,返回空 authorizations |
|
||||
| account.getDefaultEmojiStatuses | stub | notModified | 第一阶段不提供默认 emoji status 列表 |
|
||||
| account.getCollectibleEmojiStatuses | stub | empty | TDesktop 启动/emoji status 面板会刷新 collectible gift emoji statuses;当前无 gift/status 模型,返回空 `account.emojiStatuses` |
|
||||
| account.getDefaultGroupPhotoEmojis | stub | empty | TDesktop 头像/群头像编辑页默认 emoji 图片入口;当前无 custom-emoji photo 候选,返回空 `emojiList` |
|
||||
| account.getConnectedBots | stub | empty | TDesktop business chatbot 资料预取;当前无 business bot 模型,返回空 `account.connectedBots`,避免客户端 API error |
|
||||
| account.getReactionsNotifySettings / account.setReactionsNotifySettings | done | real-account-settings | 持久化账号 reaction 通知范围(none/contacts/all)与 show_previews,返回默认提示音;无记录时默认 contacts + previews |
|
||||
| account.getContactSignUpNotification | stub | false | 第一阶段不推送联系人注册提醒 |
|
||||
| account.getThemes | stub | notModified | Settings/theme 预取,第一阶段不提供云主题 |
|
||||
| account.getContentSettings | stub | default | Settings 内容敏感项预取,第一阶段不启用 NSFW 内容配置 |
|
||||
| account.getGlobalPrivacySettings | stub | default | Settings 全局隐私项预取,返回空默认设置 |
|
||||
| account.getPasskeys | stub | empty | Settings 安全项预取,第一阶段不提供 passkeys |
|
||||
| account.getSavedMusicIds | stub | empty | TDesktop saved/profile music 预取;当前没有 profile music media store,返回空 id vector |
|
||||
| account.updateStatus | done | persisted-presence | 记录 `auth_key/session/user` 维度运行时在线状态并持久化 `users.last_seen_at`;`offline=false` 与重连后 session 身份恢复都会写入 last_seen 并推送 `updateUserStatus(userStatusOnline expires=now+5m)`,`offline=true` 写入精确 `userStatusOffline.was_online`;最后一个 MTProto session 断开/destroy_session 也会写 last_seen 并推送 offline。状态推送给当前用户其它在线 session、在线联系人、以及已有私聊 dialog 的在线对端;session 恢复时还会向当前 session 补发在线联系人/私聊对端状态,避免 TDesktop 等用户动作后才刷新。当前未接 account privacy,已知 last_seen 默认精确可见 |
|
||||
|
||||
## Updates
|
||||
|
||||
| method | status | behavior | note |
|
||||
|---|---|---|---|
|
||||
| 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 peer,channel 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/chats,payload 来自 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 |
|
||||
|
||||
### 兼容硬约束:pts / pts_count(跨所有产生 update 的 RPC)
|
||||
|
||||
> 已核对 TDesktop 基线源码(`data_pts_waiter.cpp` / `.h`、`api_updates.cpp`)。详细推导见 docs/message-module.md「客户端 pts 重排依赖 pts_count 准确」。
|
||||
|
||||
outbox 多 worker 并发 + 发送事务乱序提交 → **主动推送可能乱序到达客户端**(pts=6 先于 pts=5)。客户端 `PtsWaiter` 靠 `_count += pts_count` 累加判连续:乱序的先缓存、等空缺补齐再按序应用,1 秒补不齐才 `getDifference`。因此**任何产生 update 的 RPC 都必须遵守**:
|
||||
|
||||
1. **每条 update 的 `pts_count` 必须准确等于它推进的 pts 步数**。私聊文本、已读 inbox/outbox、文本编辑均为 1;批量删除为本次删除的 owner 视角 message_id 数量。
|
||||
2. **每个分配出去的 pts 最终都要能被 getDifference 拿到**——事务回滚也要写 `noop` 占位(`recordPtsGaps`),否则连续水位永久卡死、客户端永久 gap。
|
||||
|
||||
当前私聊文本、转发、已读回执、文本编辑与删除消息满足两条(new_message/forward/read/edit pts_count=1,delete_messages pts_count=len(message_ids),noop 补洞,连续水位兜底)。**新增任何其它 `pts_count ≠ 1` 的场景(批量 service action、频道 editMessage 等)时,必须回到此约束重新核对**,否则客户端 `_count` 永久错位。
|
||||
|
||||
## Dialogs / Messages
|
||||
|
||||
| method | status | behavior | note |
|
||||
|---|---|---|---|
|
||||
| messages.getDialogFilters | done | real | 返回 dialogFilterDefault + 当前账号持久化自定义 filters,并带 tags_enabled;离线设备启动可重拉完整分组状态 |
|
||||
| messages.getSuggestedDialogFilters | stub | empty | 第一阶段不提供推荐文件夹 |
|
||||
| messages.updateDialogFilter | done | real | 持久化/删除当前账号自定义 filter,校验 filter_id/title/peer 数量;channel peer 必须校验非零 access_hash;写 durable updateDialogFilter + dispatch_outbox |
|
||||
| messages.updateDialogFiltersOrder | done | real | 持久化自定义 filter 顺序,去重并忽略默认/归档保留 ID,写 durable updateDialogFilterOrder + dispatch_outbox |
|
||||
| messages.toggleDialogFilterTags | done | real | 持久化 folder tags 开关,写 durable updateDialogFilters reload + dispatch_outbox |
|
||||
| folders.editPeerFolders | done | real | 支持 folder_id=0/1 的归档/还原;user peer 更新 dialogs.folder_id,channel peer 更新 channel_dialogs.folder_id;返回/记录 updateFolderPeers(pts,pts_count=1),可靠投递其它 session |
|
||||
| messages.getDialogs | done | real | 从按 user_id HASH 分区的 dialogs/message_boxes/users 查询当前账号私聊会话,并合并 channel_dialogs/channels/channel_messages;支持 exclude_pinned、folder_id(0 主列表/1 归档/2+自定义 filter)、offset_date/offset_id/offset_peer seek pagination、limit、hash notModified;channel offset_peer 校验非零 access_hash;channel dialog 返回持久 `read_outbox_max_id`,可恢复离线/丢失的 `updateReadChannelOutbox`;不会把返回的 channel 宽泛标记为 active viewer,避免 typing/reaction 等瞬时事件误推给全体 dialog 列表;登录后可见 777000 官方系统会话 |
|
||||
| messages.getPinnedDialogs | done | real | 从 dialogs + channel_dialogs 合并查询 pinned 会话,返回 top messages/users/channels,并附持久化 update state |
|
||||
| messages.getPeerDialogs | done | real | 按 InputDialogPeer 精确查询当前账号会话,返回 dialog/top message/users/update state;channel peer 同样返回持久 `read_outbox_max_id`;公开 username channel/supergroup 对非成员返回只读 preview dialog/top message,private/ban/kick/view_messages 不暴露;未建会话的 user peer 返回空 dialog 占位;peer vector cap=100 |
|
||||
| messages.getPeerSettings | done | real | 当前 owner 视角 peerSettings:非联系人显示 add/block,联系人显示 shareContact;若该 owner 已 hide peer settings bar 则返回空 action bar;附带当前 owner 视角用户资料 |
|
||||
| messages.getMessages | done | real-private | TDesktop reply/webpage/media lazy load 入口;当前按 owner 视角 `InputMessageID` 精确拉取私聊 message_box,ID 不存在返回 messageEmpty,单次最多 100 个 |
|
||||
| messages.getHistory | done | real | 从按 owner_user_id HASH 分区的 message_boxes 按当前账号+peer 查询历史;支持 offset_id、offset_date、add_offset、limit、max_id、min_id、hash,查询条件走分区索引与 seek pagination;`add_offset` clamp 到 `[-100,100]`,避免异常客户端触发超大跳过扫描或内存分配 |
|
||||
| messages.readHistory | done | real-partial | 标记 reader dialog 的 read_inbox_max_id/unread_count 并清 manual unread;真正发生已读时给 reader 生成 updateReadHistoryInbox,同时推进 sender dialog 的 read_outbox_max_id 并生成 updateReadHistoryOutbox;发送消息时不预先推进 read_outbox,避免假已读 |
|
||||
| messages.getOutboxReadDate | done | real-private | TDesktop 已读详情入口;按当前 owner+peer+msg_id 校验 outgoing message,读取最早覆盖该 msg_id 的 `read_history_outbox` durable event 日期,无读回执时返回 `MESSAGE_NOT_READ_YET` |
|
||||
| messages.getMessageReadParticipants | done | real-channel-small | TDesktop 小群已读详情入口;仅 channel/megagroup peer,校验消息存在与可见历史,按 `channel_members.read_inbox_max_id >= msg_id AND read_inbox_date > 0` 返回最多 50 个 `readParticipantDate`,并用 `read_inbox_date` 给出已读时间;初始 join/invite read 水位只用于 unread 基线,不会把新成员伪造成旧消息读者;broadcast/超阈值/过期返回空 |
|
||||
| messages.getMessageEditData | done | real-text-only | 参考实现/参考实现 的编辑前校验语义:按 peer/msg_id 校验消息存在与作者/管理员 edit_messages 权限;当前只支持文本消息编辑,没有媒体 caption 状态,因此返回 `messages.messageEditData{caption=false}` |
|
||||
| messages.getWebPagePreview | stub | empty-preview | TDesktop 输入框链接预览入口;参考实现 空预览与 参考实现 空文本校验,`message` trim 后为空返回 `MESSAGE_EMPTY`,entities/text 有界,返回 `messages.webPagePreview{media=messageMediaEmpty}`,不抓取外网、不落缓存 |
|
||||
| messages.uploadMedia / messages.sendMedia / messages.sendMultiMedia | done | photo/document/sticker | 接入 files/media 存储(documents/photos/file_blobs + 本地 blob backend)与消息 media 快照列;`resolveInputMedia` 解析 `inputMediaUploadedPhoto/Document`(组装上传分片→建 Photo/Document)与 `inputMediaPhoto/Document`(按服务端 document id 引用已存在资源,含贴纸)→ `domain.MessageMedia`,经 sendOutgoing 走与文本相同的 pts/box/outbox/在线推送/离线 difference;私聊与 channel 均支持;`sendMedia(inputMediaEmpty/WebPage)` 仍降级为纯文本 `sendMessage`;`uploadMedia` 返回可复用 `messageMedia`;sendMultiMedia 各条作为独立消息发送(grouped_id 相册聚合留 todo);geo/contact/poll/todo/dice/story 等仍返回 `MEDIA_INVALID`;album cap=10,caption/entities/random_id/peer/access_hash 均校验 |
|
||||
| messages.readMessageContents | done | real-partial | TDesktop 普通消息内容已读入口;校验 id cap=100、message_id 范围与当前账号 exact 可见私聊消息,对存在消息向当前账号其它在线 session 推 `updateReadMessagesContents`;当前无 media/reaction content-read 持久状态,返回当前账号 affectedMessages 且不生成新 pts |
|
||||
| messages.getMessagesViews | done | real-channel-views | TDesktop 频道浏览计数入口;channel/supergroup peer 校验 access_hash 与 id cap=100,`increment=true` 时按 `(channel_id,message_id,viewer_user_id)` 去重后递增 `channel_messages.views_count`,按请求顺序返回显式 `views` 与 replies/comment context;不存在、删除或本地清历史前不可见的 id 返回空 `messageViews`;forwards 计数与 forwarded-channel source views 透传仍待媒体/转发统计模型补齐 |
|
||||
| messages.getUnreadMentions | done | real-channel-mentions | Channel/supergroup peer 维护 `channel_unread_mentions(user_id,channel_id,message_id)` 独立索引;sendMessage 解析 mention-name entity 与 `@username`,写入 active 且可见成员;查询按 user+channel+top_msg_id+message_id seek,limit cap=100,返回 channel context |
|
||||
| messages.readMentions | done | real-channel-mentions | Channel/supergroup peer 按 top_msg_id 有界清除 unread mention,单次最多 1000 条,重算 `channel_dialogs.unread_mentions_count`;返回 current channel pts/offset,供 TDesktop channel PtsWaiter 消费 |
|
||||
| messages.reportSpam / messages.report | stub | reported-bounded | TDesktop peer bar/消息举报入口;校验 peer/access_hash/message id/option/comment 上限,不落 report 表;`report` 空 option 返回举报原因,`other` 返回 addComment,其余合法 option 返回 reported,非法 option 返回 `OPTION_INVALID` |
|
||||
| messages.reportReaction / messages.reportMessagesDelivery / messages.reportReadMetrics / messages.reportMusicListen / messages.reportSponsoredMessage | stub | telemetry-noop | TDesktop 反应举报、Gateway 送达、阅读指标、音乐播放、广告举报入口;校验 peer/access_hash/id vector/metrics/document/duration/random_id 上限后返回 BoolTrue 或 sponsored reported,不落 telemetry/report 表 |
|
||||
| messages.sendReaction / messages.getMessagesReactions / messages.getMessageReactionsList | done | real-private-channel-emoji | Private peer 支持 emoji reaction 持久化到 `private_message_reactions(private_message_id,user_id,reaction)`,双端 owner-visible message box 共享 reaction 聚合但按 viewer 重算 `my/chosen_order`,`sendReaction` 返回当前端 `updateMessageReactions`,向当前账号其它 session 与对端在线 session 推对应 peer/msg_id update,并写账号级 `message_reactions` durable event;离线设备经 `updates.getDifference` 补带 `message.reactions` 的 message + `updateMessageReactions`;history/getMessages/search 回填 `message.reactions`。Channel/supergroup peer 继续持久化到 `channel_message_reactions`,按当前用户替换/清除本消息 reaction,在线瞬时推送面向当前 active viewers 与反应者其它 session,history/getMessages/getMessagesReactions 可恢复当前聚合,list 按 `(reaction_date,reacted_user_id,reaction_value)` seek 分页,limit cap=100;custom emoji 普通消息 reaction 仍拒绝,paid reaction 走独立入口 |
|
||||
| messages.setDefaultReaction | done | real-account-default | TDesktop quick/favorite reaction 保存入口;校验 emoji reaction 后持久化账号默认 reaction(默认 👍),供后续启动/设置路径复用 |
|
||||
| messages.getPaidReactionPrivacy / messages.togglePaidReactionPrivacy / messages.sendPaidReaction | stub | privacy-real/send-balance-low | paid reaction privacy 持久化 default/anonymous/peer,并向当前账号其它 session 推 `updatePaidReactionPrivacy`;当前无 Stars 余额/paid reaction 计费模型,`sendPaidReaction` 校验 peer/msg_id/count/private 后返回 `BALANCE_TOO_LOW`,不伪造付费 reaction 聚合 |
|
||||
| messages.deleteParticipantReaction / messages.deleteParticipantReactions | done | real-channel-moderation | 群/超级群管理员删除用户 reaction 入口;校验 peer/access_hash、participant user 与 delete_messages 权限,单条返回真实 `updateMessageReactions`,批量按最多 1000 条删除并给在线 active viewers 推受影响消息反应更新,同时刷新消息作者 unread reaction 计数;private peer 当前返回兼容成功/空 updates |
|
||||
| messages.getUnreadReactions / messages.readReactions | done | real-channel-emoji-read | Channel/supergroup peer 按 `sender_user_id = 当前用户 AND unread` 维护 emoji reaction 未读状态;`sendReaction` 在反应者不是消息作者时写 unread 并重算 `channel_dialogs.unread_reactions_count`,查询按 user+channel+top_msg_id+message_id seek,limit cap=100,返回带 unread recent reaction 的 channel messages;`readReactions` 单次最多清 1000 条并重算 dialog count,返回 current channel pts/offset;TDesktop 打开 channel/supergroup history 发出的 `channels.readMessageContents` 会清理可见消息上的 unread reaction、回推 `updateMessageReactions` 并刷新 dialog reaction 计数;saved_peer/monoforum 与 reaction tag/ranking 仍留后续模型 |
|
||||
| messages.getRecentReactions / messages.clearRecentReactions | done | real-account-recent | TDesktop reaction 面板后台入口;`messages.sendReaction(add_to_recent)` 会记录当前账号最近使用的私聊/channel/supergroup emoji reaction,limit cap=100,内容 hash 命中返回 notModified,clear 后返回 hash=0 空列表 |
|
||||
| messages.getTopReactions | done | real-account-top | TDesktop reaction 面板后台入口;`messages.sendReaction` 会按当前账号累计私聊/channel/supergroup emoji reaction 使用次数,limit 0 时按 TDesktop 默认 14、cap=100,内容 hash 命中返回 notModified;账号 top 不足时优先用真实 `available_reactions` catalog(Files 服务缺失时才静态兜底)有界补齐,保证 top/recent/default 的 emoji id 能被 `messages.getAvailableReactions` 解析,避免右键 reaction selector 只有空白占位 |
|
||||
| messages.getDefaultTagReactions | stub | empty/notModified | TDesktop saved-message tags 默认候选入口;当前无 saved reaction tag/custom-emoji document store,返回空 reactions/notModified;真实 tag ranking 与 custom emoji 候选后续补 |
|
||||
| messages.getSavedReactionTags / messages.updateSavedReactionTag | done | real-account-title | TDesktop saved messages tag 入口;全局请求返回账号级 emoji saved reaction tag 标题,内容 hash 命中返回 notModified;update 校验 emoji reaction 与 title<=12 后持久化并向其它 session 推 `updateSavedReactionTags`;可选 peer 仍只校验 access_hash 并返回空,saved-message tag assignment/count、custom emoji 与默认 tag 候选留后续模型 |
|
||||
| messages.getExtendedMedia | stub | empty-updates | TDesktop paid media 可见消息轮询入口;校验 peer/access_hash 与 id cap=100,当前无 paid media/extended media store,返回空 Updates,不伪造 `updateMessageExtendedMedia` |
|
||||
| messages.sendVote / messages.getPollResults / messages.getPollVotes / messages.addPollAnswer / messages.deletePollAnswer / messages.getUnreadPollVotes / messages.readPollVotes | blocked | poll-store-missing | TDesktop poll UI 会直接调用这些入口;参考实现 都先由 peer+msg_id 找 poll media/poll_id,getPollVotes 有分页上限。当前 media/poll store 未接入,显式注册并校验 peer/access_hash/msg_id/options/offset/limit;read-only 返回空 updates/votes/unread 或 affectedHistory,真实 vote/add/delete 返回 `MESSAGE_ID_INVALID`,避免伪造 poll update;broadcast channel 拉投票人返回 `BROADCAST_FORBIDDEN` |
|
||||
| messages.appendTodoList / messages.toggleTodoCompleted | blocked | todo-store-missing | Layer225 todo 媒体未接入;参考实现 的 todo item 上限配置与 append 对 `MessageMediaToDo` 的依赖,当前只做 peer/msg_id/list/id/title/entities 有界校验,空变更返回 `TODO_NOT_MODIFIED`,非空变更返回 `MESSAGE_ID_INVALID`,不生成不可恢复的 fake updates |
|
||||
| messages.getSearchCounters / messages.search(media filters) | stub | zero-counters | TDesktop 搜索面板 counters 返回每个 filter 的 0 count;资料页 shared media count 会发 `messages.search(limit=0, filter=photo/video/document/url/gif/music/roundVoice/poll)`,当前无 media/webpage/poll/document store,显式返回空页/count=0,避免纯文本 channel history 污染 photos/videos/files 等计数;filter cap=32 |
|
||||
| messages.getSearchResultsCalendar / messages.getSearchResultsPositions | stub | empty-bounded | TDesktop shared media 日历/位置辅助入口;校验 peer/access_hash/saved_peer/offset/limit 后返回空结果,calendar 回填请求 offset date/id,避免空月份重复拉取;真实媒体索引与按日聚合后续补 |
|
||||
| messages.getReplies | done | real-channel-thread | 支持 channel/supergroup `reply_to_top_id` thread 分页;broadcast post 若已 linked discussion group,会映射到 linked megagroup root 后读取评论;forum topic root 支持 TDesktop `reply_to_msg_id=0/top_msg_id=topicRoot` 发送路径,返回 `messages.channelMessages{pts,topics}` 与 short topic;limit cap=100,add_offset clamp,按 message id seek,hash 命中返回 notModified,响应补齐 source/group chats/users |
|
||||
| messages.getDiscussionMessage | done | linked-discussion-root | 校验 channel peer/access_hash/msg_id 后返回 thread root、`max_id/read_inbox/read_outbox/unread_count` 与 source/group chats;broadcast linked post 返回 discussion megagroup 中的 forwarded root message,未 linked 时返回当前 channel root/context;forum topic root 已由 `createForumTopic` 落 service message,topic 消息历史由 `messages.getReplies(topicRoot)` 补齐 |
|
||||
| messages.readDiscussion | done | linked-read-state | 校验 root 后把 broadcast post 映射到 linked discussion megagroup,再推进该 target channel 的 read inbox 水位;不生成 channel pts,账号级 read update/outbox 用现有 channel read 机制补偿其它 session/作者回执 |
|
||||
| messages.getForumTopics | done | forum-topic-page | 校验 channel peer/q/limit/offset,非 forum 返回 `CHANNEL_FORUM_MISSING`;forum 开启后返回虚拟 `General` topic(id=1) + 持久化 topic store,topic page 按 `(pinned,pinned_order,date,topic_id)` seek,不用 OFFSET;响应带 channel pts、channel context、root service messages 和 creator users |
|
||||
| messages.getForumTopicsByID | done | forum-topic-lookup | topic id cap=100,非 forum 返回 `CHANNEL_FORUM_MISSING`;id=1 返回虚拟 `General`,其它 id 从 topic store 精确查询并补 root service message/context |
|
||||
| messages.createForumTopic | done | root-service-message | 校验 peer/access_hash、forum megagroup、title/random_id/send_as;创建 `messageActionTopicCreate` channel service message,topic_id 使用 root message id,写入 `channel_forum_topics` 分区表,返回 `updateMessageID + updateNewChannelMessage` 并在线推送其它成员;重复 random_id 幂等返回既有 root |
|
||||
| messages.editForumTopic | done | topic-edit-service | 校验 peer/access_hash、forum topic、title/icon/closed/hidden flags;topic creator 或具备 pin/manage 能力的成员可改;写 `messageActionTopicEdit` service message,reply_to_top_id 指向 topic root,更新 topic read model 并返回/推送 `updateNewChannelMessage` |
|
||||
| messages.updatePinnedForumTopic / messages.reorderPinnedForumTopics | done | pinned-topic-updates | 校验 topic id/order cap=100 和 pin 权限;更新 `channel_forum_topics.pinned/pinned_order`,返回/在线推送 `updatePinnedForumTopic` 或 `updatePinnedForumTopics(order)`;该 TL update 本身不带 pts,离线重开通过 getForumTopics 补状态 |
|
||||
| messages.deleteTopicHistory | done | bounded-topic-delete | 校验 forum topic 与权限;每次最多删除 `MaxDeleteHistoryBatch` 条 topic root/reply_to_top_id 消息,返回 `messages.affectedHistory.offset` 供 TDesktop 续删;最后一页才从 topic store 隐藏 topic,避免对十几万消息一次性生成 update 或 id vector |
|
||||
| messages.getOnlines | done | bounded-online-members | 超级群/频道在线人数入口;校验 peer/access_hash 后用 `SessionManager.OnlineUserIDs(limit<=500)` 与 active channel member 做有界交集,caller 自动加入候选避免本连接快照遗漏;无在线 provider 时保留 参考实现 兼容 `onlines=1` |
|
||||
| messages.editMessage | done | real-partial | 当前阶段支持私聊文本消息编辑:仅原发送者可编辑自己的 outgoing message,更新 shared private_messages 与所有可见 owner message_boxes,生成 updateEditMessage(pts_count=1) 并可靠投递;`inputMediaWebPage/inputMediaEmpty` 降级为文本编辑,真实 media 返回 `MEDIA_INVALID`,reply_markup 返回 `REPLY_MARKUP_INVALID`,quick replies 返回 `MESSAGE_ID_INVALID`,scheduled 返回 `SCHEDULE_DATE_INVALID` |
|
||||
| messages.deleteMessages | done | real-partial | 当前 owner 视角软删除指定私聊消息;`revoke` 会删除同一 private_message 在其它 owner 视角的 message_box;按 owner 生成 `updateDeleteMessages`,`pts_count=len(message_ids)`,并重算或删除 dialog |
|
||||
| messages.deleteHistory | done | real-partial | 当前 owner 视角按 peer/max_id 清空历史;默认清空后无可见消息则删除 dialog,后续新消息可重建;`just_clear` 保留空 dialog;`revoke` 同步清理对端 owner 视角;min_date/max_date 第一阶段兼容 no-op |
|
||||
| messages.search | done | real | 当前账号消息搜索;user peer 走 `message_boxes`,channel/supergroup peer 走单份 `channel_messages`;支持 q/from_id/min_date/max_date/offset_id/add_offset/limit/max_id/min_id/hash,limit cap,文本命中有 pg_trgm GIN 索引兜底;channel 结果附带 sender/fwd/reply 所需 users/chats |
|
||||
| messages.searchGlobal | done | real-partial | TDesktop 搜索框全局消息分支;当前查当前 owner 私聊 message_boxes,并合并当前账号 active membership 的频道/超级群单份文本消息;支持 users_only/groups_only/broadcasts_only、filterEmpty、q、limit cap=50、offset_rate+offset_peer+offset_id channel seek、min/max_date 与 folder_id=0/1 下推,media filters 显式空结果;参考实现 的 joined channel list 语义与 参考实现 的 bounded search,后续再接外部全文搜索/媒体索引 |
|
||||
| messages.toggleDialogPin | done | real | 当前 owner 的 dialog pinned/pinned_order 持久化;user peer 写 dialogs,channel peer 写 channel_dialogs;写 durable updateDialogPinned + dispatch_outbox,可靠投递给其它在线 session |
|
||||
| messages.reorderPinnedDialogs | done | real | 当前 owner 的 pinned dialog 顺序持久化;支持混合 user/channel peer 与 force 清理未在 order 中的 pinned dialog,写 durable updatePinnedDialogs(order) + dispatch_outbox,可靠投递给其它在线 session |
|
||||
| messages.markDialogUnread | done | real | 当前 owner manual unread mark 持久化;user peer 写 dialogs,channel peer 写 channel_dialogs/channel_members,`messages.getDialogs/getPeerDialogs` 返回 dialog.unread_mark;readHistory 清除 unread_mark,写 durable updateDialogUnreadMark + dispatch_outbox |
|
||||
| messages.getDialogUnreadMarks | done | real+monoforum-stub | 返回当前 owner 所有 user/channel manual unread peer;`parent_peer` 分支校验 parent channel 后返回空 monoforum marks,`messages.markDialogUnread(parent_peer)` 同样校验 parent/sublist peer 后 BoolTrue no-op,避免 TDesktop SavedSublist/monoforum 后台触发 `NOT_IMPLEMENTED`;真实 monoforum topic unread store 后续补 |
|
||||
| messages.hidePeerSettingsBar | done | real | 当前 owner 隐藏 peer action bar 状态持久化;后续 getPeerSettings 返回空 action bar,写 durable updatePeerSettings(settings) + dispatch_outbox,并可靠投递给其它在线 session |
|
||||
| messages.setTyping | done | real-transient | 私聊 typing/cancel/upload 等 SendMessageAction 实时包装为 updateShort(updateUserTyping) 推给对端在线 session,并排除当前 auth_key_id+session_id;InputPeerChannel 走 `updateChannelUserTyping` 在线瞬时推送;`top_msg_id` 有界校验,非法返回 `MSG_ID_INVALID`;typing 不写 durable log、不做离线补偿 |
|
||||
| messages.saveDraft | done | real-partial | 接受 TDesktop 输入框云草稿保存/清空 RPC,channel peer 校验非零 access_hash,持久化 user/channel peer 文本、entities、reply、forum `top_msg_id`、no_webpage/invert_media/effect 与 webpage URL,并按 参考实现 语义向同账号其它在线 session 推 `updateDraftMessage`;发送消息 `clear_draft` 会清对应 draft 并推空 draft update;文件 media、suggested_post、monoforum saved_peer 与 getDifference draft event 后续补 |
|
||||
| messages.getAllDrafts | done | real-partial | 从 `dialog_drafts` 读取最多 1000 条云草稿,返回对应 `updateDraftMessage`,forum draft 设置 `top_msg_id`,附带已知 users/chats;文件 media、suggested_post、monoforum saved_peer 暂不返回 |
|
||||
| messages.clearAllDrafts | done | real-partial | 有界清空最多 1000 条当前账号云草稿,向其它在线 session 推 `draftMessageEmpty` updates;超大 draft 集合按后续调用继续清理,durable difference 事件后续补 |
|
||||
| messages.getSavedDialogs | stub | empty/notModified | 第一阶段不做 saved messages 分会话 |
|
||||
| messages.getPinnedSavedDialogs | stub | empty | 第一阶段不做 saved messages 分会话置顶 |
|
||||
| messages.toggleSavedDialogPin | stub | ok | 第一阶段不做 saved dialogs 置顶,显式接受避免未知 RPC |
|
||||
| messages.reorderPinnedSavedDialogs | stub | ok | 第一阶段不做 saved dialogs 置顶排序 |
|
||||
| messages.getSavedDialogsByID | stub | empty | 第一阶段不做 saved dialogs 指定查询 |
|
||||
| messages.getSavedHistory | stub | empty/notModified-bounded | TDesktop 打开群/用户资料页时会后台查询 saved history/monoforum 入口;当前不做 saved history,但会校验可选 `parent_peer` 必须是可见 channel、peer/access_hash、offset/add_offset/limit/id/date 边界,返回空 messages 或 hash notModified,并给 parent/peer channel 补 chat context |
|
||||
| messages.readSavedHistory | stub | ok-bounded | 当前不维护 saved history read state;按 monoforum 语义要求 `parent_peer` 是可见 channel,校验 peer/access_hash 与 max_id 后返回 BoolTrue |
|
||||
| messages.deleteSavedHistory | stub | affected-empty-bounded | 当前不维护 saved history;校验可选 `parent_peer`、peer/access_hash、max_id/min_date/max_date 后返回 `affectedHistory{pts=current,pts_count=0,offset=0}` |
|
||||
| messages.getCommonChats | done | common-megagroups | TDesktop 用户资料页共同群/机器人共同群入口;校验 input user/access_hash、max_id、limit<=100,只返回双方均 active 的 megagroup/supergroup,排除 broadcast/left/kicked/deleted,按 channel id seek 分页;PG 走按 `user_id` 主键的 `user_channel_member_index`,避免 `users.getFullUser.common_chats_count` 在私聊打开路径反向规划 `channel_members` 64 分区;`users.getFullUser` 同步填 `common_chats_count` |
|
||||
| messages.getScheduledHistory / messages.getScheduledMessages / messages.sendScheduledMessages / messages.deleteScheduledMessages | stub | scheduled-store-missing | 第一阶段不做定时消息;history/exact get 返回空 messages 并校验 peer/id cap=100,send-now 对非空 id 返回 `MESSAGE_ID_INVALID`,delete 返回 `updateDeleteScheduledMessages` 清理调用端本地幽灵项;真实 scheduled store、has_scheduled dialog 标记与 job sender 后续补 |
|
||||
| messages.getAvailableReactions | done | real-resources | 从 `available_reactions` 表(由 `TELESRV_STICKER_SEED_DIR` 真实导出 seed)加载 74 个 reaction,static_icon/appear/select/activate/effect/around/center 均返回真实 `document`(seed source id 已在导入阶段归一为服务端 document id,access_hash/file_reference/dc_id/attributes/thumbs 重写为本 DC 数据),TDesktop 经 `upload.getFile` 从本地 blob backend 下载动画;启动 seed 会校验所有 reaction document 的 `doc:<id>` 主 blob,自动修复半导入状态,且导入扫描只把 `_thumb\d+_` 识别为缩略图,避免 `reaction_thumbs_up/down...` 主文件被误跳过;hash 命中返回 notModified;Files 服务缺失或未 seed 时回退固定 emoji + documentEmpty stub |
|
||||
| messages.getAvailableEffects | stub | empty | TDesktop message effects 后台预取;参考实现 空响应与 参考实现 无数据响应,返回 `messages.availableEffects{hash=0,effects=[],documents=[]}` |
|
||||
| messages.getAllStickers / messages.getEmojiStickers | done | real-sets | 列出已 seed 的贴纸集(`getAllStickers`→stickers 类,`getEmojiStickers`→emoji 类)元数据(cover/thumb),稳定 hash 命中返回 notModified;TDesktop 再按集调 `getStickerSet` 拉文档;无 seed 时回退空目录 |
|
||||
| messages.getRecentStickers | stub | empty/notModified | TDesktop 输入框/emoji 面板预取最近贴纸;第一阶段返回空列表或 hash notModified |
|
||||
| messages.getFavedStickers | stub | empty/notModified | TDesktop 输入框/emoji 面板预取收藏贴纸;第一阶段返回空列表或 hash notModified |
|
||||
| messages.getStickers | stub | notModified | 第一阶段不提供贴纸搜索结果 |
|
||||
| messages.getStickerSet | done | real-sets | `stickerSetRefFromInput` 把 `inputStickerSetID/ShortName/AnimatedEmoji/Dice/EmojiGenericAnimations` 解析为 `domain.StickerSetRef`,从 `sticker_sets` 加载集元数据 + 按 `document_ids` 顺序加载真实文档(含 packs;document id / pack document id / thumb_document_id 均为 seed 阶段归一后的服务端 id),返回完整 `messages.stickerSet`;hash 命中返回 notModified;未 seed 的系统集/未知短名回退空集 stub 避免破坏客户端 |
|
||||
| messages.getFeaturedStickers | stub | empty/notModified | TDesktop 输入框/emoji 面板预取推荐贴纸;第一阶段返回空列表或 hash notModified |
|
||||
| messages.getEmojiGroups | stub | notModified | 第一阶段不提供 emoji 分组目录 |
|
||||
| messages.getEmojiStickerGroups | stub | notModified | 第一阶段不提供 emoji sticker 分组目录 |
|
||||
| messages.getEmojiProfilePhotoGroups | stub | empty | TDesktop 头像/群头像编辑页 custom-emoji profile photo categories 入口;当前无 custom-emoji profile photo 分组,返回空 `messages.emojiGroups`,避免后台 `NOT_IMPLEMENTED` 重试 |
|
||||
| messages.getEmojiStickers | stub | empty/notModified | TDesktop 输入框/emoji 面板预取 emoji stickers;第一阶段返回空目录或 hash notModified |
|
||||
| messages.getFeaturedEmojiStickers | stub | empty/notModified | TDesktop 输入框/emoji 面板预取 featured emoji stickers;第一阶段返回空列表或 hash notModified |
|
||||
| messages.getEmojiKeywordsLanguages | stub | empty | TDesktop 输入框打开后拉 emoji keyword 语言,第一阶段返回空列表 |
|
||||
| messages.getEmojiKeywords / messages.getEmojiKeywordsDifference | stub | empty-difference | TDesktop emoji keyword 增量入口;校验 lang_code 和 from_version,返回空 `emojiKeywordsDifference`,difference 版本回显客户端 from_version,避免重复大包拉取 |
|
||||
| messages.getCustomEmojiDocuments | done | real-documents | 按服务端 document id 批量从 `documents` 表加载真实文档(单批 cap=100、id 必须为正),命中返回真实 `document`,未命中返回 `documentEmpty{id}` 占位;无 Files 服务时返回空 |
|
||||
| messages.getAttachedStickers | stub | empty | TDesktop 图片/视频“查看相关贴纸包”入口;nil media 返回 `MEDIA_EMPTY`,有效 media 返回空 sticker set list |
|
||||
| messages.searchStickerSets / messages.searchStickers | stub | empty-bounded | TDesktop 贴纸/emoji 云搜索入口;query/emoticon/lang/offset/limit/hash 有界,hash 非零返回 notModified,当前无云贴纸索引,返回空 found results |
|
||||
| messages.getSavedGifs | stub | empty/notModified | TDesktop 输入框/emoji 面板预取 saved GIFs;第一阶段返回空列表或 hash notModified |
|
||||
| messages.getAttachMenuBots | stub | notModified | 第一阶段不提供 attach menu bot |
|
||||
| messages.getQuickReplies | stub | notModified | 第一阶段不做 business quick replies |
|
||||
| messages.getWebPage | stub | empty | Settings/消息预览路径可能预取 instant view;第一阶段返回 webPageEmpty |
|
||||
| messages.getDefaultHistoryTTL | stub | disabled | 参考实现,返回 `defaultHistoryTTL{period=0}`,表示新聊天默认不自毁 |
|
||||
| messages.getSponsoredMessages | stub | empty | 参考实现,返回 `messages.sponsoredMessagesEmpty`,避免群/频道页面后台广告拉取重试 |
|
||||
| messages.sendMessage | done | real-private-text | 私聊文本消息;支持 random_id 幂等、silent/noforwards、reply_to 私聊消息头(含 quote 文本/entities/offset 与双端 message_id 翻译;quote_text≤1024,quote_offset 按消息文本 offset 限制在 4096 内)、双端 message box、dialog、pts/update event、transactional outbox、在线 session 批量推送、4096 字符上限与 Redis 用户级窗口限流;outbox 排除当前设备使用 auth_key_id+session_id;pts_count 恒 1;channel peer 的 `send_as` 支持 self/current channel 校验链路;media/scheduled 仍返回 `MEDIA_INVALID`/`SCHEDULE_DATE_INVALID`;reply_markup/quick_reply/effect/paid/suggested 与 story/monoforum/todo/poll reply 返回显式 TL 错误,避免 TDesktop 高级输入路径产生 `NOT_IMPLEMENTED` |
|
||||
| messages.forwardMessages | done | real-private-text | 支持当前 owner 可见文本消息在 user/channel peer 间转发,保留原 forward header 或生成 messageFwdHeader,支持目标会话 `reply_to`、TDesktop forum topic `top_msg_id`、silent/noforwards/drop_author、random_id 幂等和 100 条上限;目标 channel 的 `send_as` 支持 self/current channel 校验链路;源消息自身 reply 不继承,响应、difference 与 outbox 都补齐 fwd/reply/send_as 来源 users/chats;被 noforwards 保护的源消息返回 `CHAT_FORWARDS_RESTRICTED`;scheduled/monoforum/quick_reply/effect/video_timestamp/paid/suggested 返回显式 TL 错误,不再落 `NOT_IMPLEMENTED` |
|
||||
| messages.createChat | done | real-as-megagroup | 不落 legacy chat 持久化;按 docs/channel-module.md 直接创建 `megagroup` channel。TDesktop New Group 回调只接受 `updates.chats[0] == chat`,因此 TDesktop ctx 下同步响应返回 `legacy chat(migrated_to=channel)` + 真实 `channel`;后续 `InputPeerChat` 主路径映射回同 id channel,避免 `ContactsBox::creationDone` 的 `chat not found in updates` |
|
||||
| messages.getChats | done | legacy-as-channel | 无 legacy chat 长期形态;若 TDesktop/export 路径用 chat_id 询问,按同 id 查询 megagroup channel 并返回 `tg.Channel` |
|
||||
| messages.getFullChat | done | legacy-as-channel | 无 legacy chat 长期形态;将 chat_id 映射为 channel_id,返回 `channels.getFullChannel` 语义 |
|
||||
| messages.addChatUser | done | legacy-as-channel | legacy 普通群入口映射到 `channels.inviteToChannel`;仍执行 channel invite 权限和单次 invite cap |
|
||||
| messages.deleteChatUser | done | legacy-as-channel | legacy 普通群入口映射到 self leave 或 channel kick;仍执行 ban/kick 权限 |
|
||||
| messages.editChatTitle | done | legacy-as-channel | legacy 普通群标题入口映射到 `channels.editTitle`,生成 megagroup service message 与 channel pts |
|
||||
| messages.editChatPhoto | done | legacy-as-channel | legacy 普通群头像入口映射到 `channels.editPhoto`:上传/引用照片落 `channels.photo_*` 反范式列并返回 `updateChannel` + 推 channel state;`inputChatPhotoEmpty` 清除头像 |
|
||||
| messages.editChatAdmin | done | legacy-as-channel | legacy 普通群管理员入口映射到 `channels.editAdmin`;`is_admin=true` 授予 basic group 管理权,`false` 清空 admin rights |
|
||||
| messages.editChatAbout | done | legacy-as-channel | legacy/basic wrapper 支持 `InputPeerChat` 与 `InputPeerChannel`,真实持久化 `channels.about` 并校验 change_info 权限 |
|
||||
| messages.editChatDefaultBannedRights | done | real-channel-permissions | 按 参考实现 语义要求 ban_users 权限;持久化 `channels.default_banned_rights`,TDesktop full channel 可见;普通成员发送/邀请会实时受 default rights 与个人 banned rights 限制 |
|
||||
| messages.editChatParticipantRank | done | legacy-as-channel | legacy 管理员 rank 入口映射到 `channels.editAdmin`,保留当前 admin rights,仅更新 rank,rank 长度有界 |
|
||||
| messages.editChatCreator | blocked | needs-2fa-transfer | 显式注册并校验 peer/user;当前未实现账号 2FA/SRP 与所有权转移事务,返回 `PASSWORD_HASH_INVALID` 而非 fallback |
|
||||
| messages.setChatTheme | stub | channel-context/private-ack | TDesktop private/theme 入口;user peer 返回空 updates,legacy chat/channel peer 校验可见性并返回 channel state,避免设置页后台 unknown RPC |
|
||||
| messages.toggleNoForwards | done | real-channel | legacy chat/channel content protection 入口映射到 `channels.noforwards`,校验 change_info 权限,返回/推送 `updateChannel`;后续 channel message 自动继承 noforwards |
|
||||
| messages.setChatAvailableReactions | done | real-channel-policy | legacy chat/channel reaction 设置入口;参考实现 持久化 channel reaction policy,校验 change_info、reaction vector/reactions_limit cap=64,`channels.getFullChannel` 返回 `available_reactions/reactions_limit/paid_reactions_available`,返回/推送 channel state |
|
||||
| messages.saveDefaultSendAs | done | real-current-channel | TDesktop send-as 选择器保存入口;当前 `channels.getSendAs` 返回 self,并在 creator、broadcast post admin、megagroup anonymous admin 可用时追加 current channel;保存入口同样只接受 self/current channel,拒绝其它 `send_as`;默认身份写入当前 user 的 `channel_dialogs.default_send_as_peer_*`,保存 self 会清空默认;`channels.getFullChannel` 只在默认身份仍有效时输出 `channelFull.default_send_as`;`sendMessage/forwardMessages(InputPeerChannel)` 未显式带 `send_as` 时会读取并重新校验该默认身份,失效默认值自动降级 self |
|
||||
| messages.migrateChat | done | legacy-as-channel | 服务端无 legacy chat 长期形态;校验 change_info/creator 权限后把 chat_id 视作既有 megagroup channel_id,返回 `updateChannel + tg.Channel`,不创建迁移双写路径 |
|
||||
| messages.sendMessage(InputPeerChannel) | done | real-channel-text | channel/supergroup 单份消息写 `channel_messages`,分配 channel message id + channel pts,生成 `updateNewChannelMessage`;支持同 channel 可见消息 reply_to,服务端反算 `reply_to_top_id` 并保留 quote metadata;支持 `send_as` self/current channel,未显式带 `send_as` 时读取已保存默认身份并重新校验,消息 `from_id` 按 send_as 输出并补齐 peers;`random_id` 幂等重试返回原始 send snapshot,不受后续 edit/delete 污染;broadcast 校验 post 权限;发送事务只对小超级群同步刷新 `channel_dialogs` 普通未读缓存,broadcast/超阈值 megagroup 不做全员 unread 写入,`messages.getDialogs/getPeerDialogs/channels.getFullChannel` 按 `channel_members.read_inbox_max_id + available_min_id + channels.top_message_id + channel_messages.deleted` 读时派生普通未读;发送者 `channel_members.read_inbox` 推到新消息,自己消息不计入动态未读;持久 channel updates 在线 fanout 走 updates-ready session 的 channel membership 索引,按当前在线 active 成员 best-effort 推送(不再受 500 cap 截断),离线靠 `updates.getChannelDifference` 补偿 |
|
||||
| messages.getHistory(InputPeerChannel) | done | real-channel | 查 `channel_messages(channel_id,id)`,按 seek pagination 返回;PG 使用 `limit+1` 探测下一页,不做 `count(*) over()` / SQL 大 OFFSET;带 username 的公开 channel/supergroup 允许非成员只读预览历史,私有频道与 ban/kick/view_messages 禁止仍返回对应错误 |
|
||||
| messages.readHistory(InputPeerChannel) | done | real-channel | 推进当前 user 的 `channel_dialogs.read_inbox_max_id`,未读惰性重算;bounded 扫描最近 read delta,推进相关发送者 `read_outbox_max_id` 并在线推 `updateReadChannelOutbox`,不对全体成员做 O(n) fanout |
|
||||
| messages.editMessage(InputPeerChannel) | done | real-channel-text | 作者或具备 edit_messages 权限的管理员可编辑单份 `channel_messages` 文本,生成 `updateEditChannelMessage(pts_count=1)` 并进入 `channel_update_events`;网页预览 media 降级文本编辑,真实 media/reply_markup/quick replies/scheduled 按现有 messages.editMessage 显式错误拒绝 |
|
||||
| messages.deleteHistory(InputPeerChannel) / channels.deleteHistory(local) | done | real-channel-clear | 当前 user 本地清空时只推进 `available_min_id/read_inbox` 和 `channel_dialogs`,不生成 channel pts;同时写账号级 `channel_available_messages` durable update,并在线推 `updateChannelAvailableMessages` 给同账号其它 session,TDesktop 会 `clearUpTill(available_min_id)`;返回/推送实际应用后的单调 `available_min_id=max(old, requested)`,避免多设备 stale clear 让客户端水位回退;`messages.deleteHistory(InputPeerChannel,revoke)` 映射为有权限时的 channel 维度批量删除,返回 `affectedHistory.offset` 供后续 RPC 续删,单批 cap=1000 |
|
||||
| messages.forwardMessages(InputPeerChannel) | done | real-channel-text | 支持 channel→channel、channel→user、user→channel 的文本转发,保留 forward header 或按 `drop_author` 隐藏;支持目标会话 `reply_to` 与 forum topic `top_msg_id`,后者映射为 topic-only reply 并由 store 校验 topic/权限/可见性,但不继承源消息自身 reply;目的 channel 仍单份写入并生成 channel pts;目标 channel 支持 `send_as` self/current channel,未显式带 `send_as` 时读取已保存默认身份并重新校验,响应/difference/outbox/channelDifference 补齐可解析的 send_as/fwd_from/reply_to user/channel 上下文,单次 cap=100 |
|
||||
| messages.setTyping(InputPeerChannel) | done | real-channel-transient | megagroup/topic typing 走 `updateChannelUserTyping` 在线瞬时推送;`top_msg_id` 只作为 forum/thread 维度透传,范围非法返回 `MSG_ID_INVALID` 且不广播;不写 durable log、不做离线补偿;仅推给已通过 channel-specific RPC(如 getHistory/getFullChannel/getChannelDifference/getPeerDialogs)标记的 active viewers,不向全体在线成员扩散 |
|
||||
|
||||
## Channels / Supergroups
|
||||
|
||||
| method | status | behavior | note |
|
||||
|---|---|---|---|
|
||||
| channels.createChannel | done | real | 创建 broadcast channel 或 megagroup;写 channel、creator member、service message、channel_update_events;响应包含 `tg.Channel`;history import/geogroup 暂无业务模型,显式返回 `CHAT_INVALID`/`ADDRESS_INVALID`,负 `ttl_period` 返回 `TTL_PERIOD_INVALID`,避免阶段外 flags 继续落 `NOT_IMPLEMENTED` |
|
||||
| channels.getChannels | done | real | 按 inputChannel 返回当前用户可见的 `tg.Channel` 列表;精确 ID vector cap=100;非零 access_hash 不匹配时跳过该项,避免异常客户端触发无界逐项查询或绕过最小 channel hash 校验 |
|
||||
| channels.getFullChannel | done | real-minimal | 返回 `ChannelFull` 最小必需字段:about、participants/read/unread/notify/pts/admin rights/default banned rights;非零 access_hash 不匹配返回 `CHANNEL_PRIVATE`;带 username 的公开 channel/supergroup 允许非成员只读预览 full/history,并以 left/self 无 unread 视图返回,避免 TDesktop 公开搜索结果打开后空白;`read_outbox_max_id` 来自当前 viewer 的 channel dialog/member 状态;有邀请管理权限的管理员会收到 `requests_pending/recent_requesters`,供 TDesktop join request 管理入口恢复 |
|
||||
| channels.getSendAs | done | real-current-channel | TDesktop 群/频道输入框预取 send-as peers;参考实现/参考实现 语义,当前返回 self,并在 creator、broadcast post admin、megagroup anonymous admin 可用时追加 current channel,同时返回可解析的 current channel chat + self user;公开非成员预览返回 self-only,不因非成员报 `CHANNEL_PRIVATE`;真实 public channel send_as 候选留后续 |
|
||||
| channels.checkUsername | done | real | 校验 channel 可见性与 username 规则,大小写不敏感检查 users/channel_usernames 占用;同一 channel 当前 username 视为可用 |
|
||||
| channels.updateUsername | done | real | creator 可设置/清除频道或超级群主 username;PG 事务内更新 `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.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 |
|
||||
| channels.toggleUsername | stub | permission-ok | Fragment/额外 username 入口;当前不改主 username,只校验 username 格式与 change_info 权限后返回 BoolTrue,避免误删 `channels.updateUsername` 的主 username |
|
||||
| channels.deactivateAllUsernames | stub | permission-ok | Fragment/额外 username 入口;当前无 purchased username 模型,仅校验 change_info 后返回 BoolTrue |
|
||||
| channels.updateColor | done | real-appearance | 持久化 `channels.color/profile_color` 与 background emoji id,保留 color flag 显式 0;校验 access_hash + change_info 后返回/推送带 `Channel.color/profile_color` 的 `updateChannel`;boost level 暂不强制,避免本地测试频道外观入口失效 |
|
||||
| channels.updateEmojiStatus | done | real-minimal | 支持 `emojiStatusEmpty` 清除与普通 `emojiStatus(document_id,until)` 持久化并输出到 `Channel.emoji_status`;collectible emoji status 缺少 gift/read model 时返回 `EMOJI_STATUS_INVALID`,不伪造 collectible 元数据 |
|
||||
| channels.exportMessageLink | done | real-message-link | 校验 channel/access_hash、msg_id 范围和当前成员可见的单份 channel message;公开 channel 返回 `t.me/{username}/{msg_id}`,私有 channel 返回 `t.me/c/{channel_id}/{msg_id}`,`thread=true` 且消息有 reply root 时追加 `?thread={root_id}`;`grouped/html` 与 public discussion `comment=` 精细链接后续补 |
|
||||
| channels.readMessageContents | done | real-partial | 校验 channel/access_hash、id vector cap=100 与可见 exact message;对存在消息向当前用户其它在线 session 推 `updateChannelReadMessagesContents`,当前无 media/reaction content-read 持久状态,不生成 pts |
|
||||
| channels.reportSpam | stub | ok | 校验 channel、participant 与 id cap=100 后返回 BoolTrue;风控/举报队列后续补 |
|
||||
| channels.getLeftChannels | done | real-left-export | TDesktop takeout/export 路径;按当前 user 的 left channel/supergroup membership 返回有界 pageSize=100,offset<=10000,带 full count 与 left channel flag;最终非空页返回 `messages.chats`,越界空页返回空 `messages.chatsSlice` 让导出流程结束 |
|
||||
| channels.getInactiveChannels | done | real-least-active | Premium limits 路径;按当前用户 active 频道/超级群的可见 top message date 旧到新返回,dates 与 chats 对齐,limit cap=100;不实现 Premium 限额策略 |
|
||||
| channels.getGroupsForDiscussion | done | real-owned-megagroups | 讨论组选择入口;返回当前用户可管理的 megagroup/supergroup(无 legacy basic group),按 ID 倒序限流,TDesktop 可直接选择 |
|
||||
| channels.setDiscussionGroup | done | real-link | 校验 access_hash、broadcast/group 类型、管理权限与 hidden prehistory;双向维护 `linked_chat_id`,清理旧链接,返回 BoolTrue 并推送相关 `updateChannel` |
|
||||
| channels.editLocation | stub | permission-ok | geogroup 未接入;校验 change_info 后返回 BoolTrue |
|
||||
| channels.convertToGigagroup | stub | updateChannel | 当前 createChat 已直接创建 megagroup;校验 change_info 后返回 `updateChannel` |
|
||||
| channels.deleteParticipantHistory | done | bounded-delete | 管理员按 `participant` 的 `sender_user_id` 分批软删 channel_messages,单批 cap=1000,返回 `affectedHistory(offset=1 表示仍需续删)` 并生成 `updateDeleteChannelMessages`,`pts_count=len(ids)` |
|
||||
| channels.toggleJoinToSend | done | real-join-settings | 持久化 `join_to_send`,校验 megagroup + invite/admin 权限,返回/推送带 flags.28 的 `updateChannel` |
|
||||
| channels.toggleJoinRequest | done | real-join-settings | 持久化 `join_request`,仅 public megagroup 可开启;`channels.joinChannel` 对非成员写入 pending public join request 并返回 `INVITE_REQUEST_SENT`,并向管理员推有界 `updatePendingJoinRequests`;approve/dismiss 复用 `messages.hideChatJoinRequest` 有界路径 |
|
||||
| channels.toggleForum | done | real-setting | 持久化 megagroup `forum/forum_tabs`,仅 creator 可改;已绑定 discussion group 时返回 `CHAT_DISCUSSION_UNALLOWED`;返回/推送 `updateChannel` 并写 `channelAdminLogEventActionToggleForum`;`messages.getForumTopics*` 与 create/edit/pin/reorder/deleteTopicHistory 已支持虚拟 General + 持久化 topic store |
|
||||
| channels.toggleAntiSpam | done | real-setting | megagroup 管理员可持久化 `channels.antispam`,返回/推送 `updateChannel`;`ChannelFull.antispam` 恢复 TDesktop 开关状态,并写 `channelAdminLogEventActionToggleAntiSpam`;真实反垃圾删除管线未接入 |
|
||||
| channels.reportAntiSpamFalsePositive | stub | ok | 校验 change_info 与 msg_id 后返回 BoolTrue;真实 antispam admin-log 后续补 |
|
||||
| channels.toggleParticipantsHidden | done | real-hidden-members | 持久化 `channels.participants_hidden`;creator/具备 ban_users 的管理员可切换,返回/推送 `updateChannel`;`ChannelFull.participants_hidden` 恢复 UI 状态,非管理员成员 `getParticipants(recent/search/bots/contacts/mentions)` 只返回 aggregate count 不暴露列表,`messages.getMessageReadParticipants` 返回空避免隐藏成员场景泄漏已读名单 |
|
||||
| channels.toggleViewForumAsMessages | done | real-local-dialog | 持久化当前账号 `channel_dialogs.view_forum_as_messages`;返回/可靠投递 `updateChannelViewForumAsMessages` 同步同账号其它 session,并在 `messages.getDialogs` 的 `Dialog.view_forum_as_messages` 与 `channels.getFullChannel` 的 `ChannelFull.view_forum_as_messages` 回填;forum topic create/read/edit/pin/reorder/delete 已接入 |
|
||||
| channels.getChannelRecommendations | done | real-public-broadcasts | TDesktop similar/recommended 入口;返回公开 username broadcast channel,指定 source 时校验 access_hash 且排除 source,无 source 时排除当前用户 active membership;默认 10、cap=100,真实相似度/订阅画像/Premium 扩容后续补 |
|
||||
| channels.setBoostsToUnblockRestrictions | stub | permission-update | boost bypass 未接入;按 Layer225 限制 boosts 为 0..8(0 关闭)并校验 change_info 后返回/推送 `updateChannel` |
|
||||
| channels.setEmojiStickers | stub | empty-only | 当前无 custom emoji sticker store/boost gating;仅 megagroup + `inputStickerSetEmpty` 清空入口做权限校验 no-op 成功,非空 sticker set 返回 `STICKERSET_INVALID` |
|
||||
| channels.restrictSponsoredMessages | done | real-setting | 持久化 `channels.restricted_sponsored`,校验 change_info 后返回/推送 `updateChannel`;`channels.getFullChannel` 回填 `ChannelFull.restricted_sponsored`,真实广告投放/boost 等级校验后续补 |
|
||||
| channels.searchPosts | done | real-public-post-search | 全局公开频道/超级群帖子搜索;按 Layer225 校验 query/hashtag 二选一、文本长度<=256、limit cap=50、offset_id 范围、offset_peer 可为空或公开/可见 channel peer、paid stars 非负;只搜索 username 非空且未删除的公开 channel_messages,PG 走 body trigram `ILIKE` + `(message_date,channel_id,id)` seek 分页,满页返回 `messages.messagesSlice(next_rate,search_flood)` |
|
||||
| channels.updatePaidMessagesPrice | done | real-setting | 持久化 `channels.send_paid_messages_stars` 与 broadcast `channels.broadcast_messages_allowed`;按 TDesktop app config 默认限制 stars<=10000,supergroup 负数返回 `STARS_AMOUNT_INVALID`,broadcast direct messages 允许 `-1` 表示关闭;返回/推送 `updateChannel` 并在 `Channel`/`ChannelFull` 回填状态;真实 paid messages/monoforum/结算后续补 |
|
||||
| channels.toggleAutotranslation | done | real-setting | 持久化 `channels.autotranslation` 并记录 admin log `toggle_autotranslation`;校验 change_info 后返回/推送 `updateChannel`,`channels.getChannels` 回填 `Channel.autotranslation` |
|
||||
| channels.getMessageAuthor | done | real-partial | monoforum 专有权限未接入;按可见 channel message 精确 id 查 sender 并返回 user,找不到或非用户作者报 MESSAGE_ID_INVALID |
|
||||
| channels.checkSearchPostsFlood | stub | free-bounded | 校验 query 必填且长度<=256,返回 query_is_free=true 与剩余免费次数,避免 Search Posts 付费检查阻塞;真实付费/限额模型后续接入 |
|
||||
| channels.setMainProfileTab | stub | permission-ok | profile tabs 未接入;校验 change_info 后返回 BoolTrue |
|
||||
| channels.getMessages | done | real-channel | 按 channel_id + InputMessageID 精确拉取 channel_messages,单次 cap=100;稀疏 id 走 `id=ANY`,不存在返回 messageEmpty |
|
||||
| channels.getParticipants | done | real | 支持 recent/admins/search/kicked/banned/bots/contacts/mentions 最小过滤语义,limit cap=200、offset cap=10000;banned/kicked 对非 admin 不暴露管理列表,搜索走 user 字段匹配,避免恶意深分页拖垮 PG |
|
||||
| channels.getParticipant | done | real | 返回当前/指定 user 的成员身份与权限;`inputPeerSelf` 对普通成员返回 `channelParticipantSelf`,避免 TDesktop 记录 `Got self regular participant`;缺失按 TL 错误映射 |
|
||||
| channels.inviteToChannel | done | real | 校验 invite 权限与用户数量 cap,创建成员/dialog,megagroup 生成 service message;单人重复邀请返回 `USER_ALREADY_PARTICIPANT`,被踢/禁看用户只能由 creator 或具备 ban_users 的 admin 恢复,普通成员单人恢复返回 `USER_KICKED`;broadcast 不写普通成员服务消息 |
|
||||
| channels.joinChannel | done | real | 创建/恢复 member/dialog;megagroup 生成 join service message;重复加入返回 `USER_ALREADY_PARTICIPANT`;join/rejoin read 水位推进到加入动作后的 top,避免旧历史计入未读或后续打开会话时批量生成旧消息读回执 |
|
||||
| channels.leaveChannel | done | real | 标记 member left,更新 participants_count;megagroup 生成 leave service message |
|
||||
| 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.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 细化 |
|
||||
| channels.getAdminLog | done | real-minimal | 校验 creator/admin,按 channel admin log event store 有界查询;支持 actor admins、events_filter、q、max_id/min_id/limit,覆盖 metadata、成员权限、pin、send/edit/delete;forum/group_call/subscription 等长尾 action 待对应业务模型 |
|
||||
| messages.updatePinnedMessage(InputPeerChannel) | done | real-channel | pin/unpin channel message,校验 pin 权限,更新 `channels.pinned_message_id`,写 `updatePinnedChannelMessages(pts_count=1)`;不生成超大 update |
|
||||
| messages.unpinAllMessages(InputPeerChannel) | done | real-channel-single | TDesktop pinned section 清空入口;当前 channel 模型只维护单个 `pinned_message_id`,有置顶则复用单条 unpin 生成 `affectedHistory` 与 `updatePinnedChannelMessages`,无置顶返回当前 channel pts + `pts_count=0`;forum/monoforum 参数有界 no-op,后续多置顶模型再做分页 offset |
|
||||
| stats.getBroadcastStats / stats.getMegagroupStats | stub | empty-graphs | TDesktop 频道/超级群统计页入口;校验 channel/access_hash/admin 与 broadcast/megagroup 类型后返回可解析空图表,真实统计聚合后续补 |
|
||||
| stats.getMessageStats / stats.getMessagePublicForwards | stub | empty-graphs | TDesktop 消息统计/公开转发入口;校验 channel/access_hash/admin/msg_id,public forwards `limit<=100`、`offset<=128` 后返回空结果,避免 unknown RPC 与无界分页 |
|
||||
| stats.loadAsyncGraph / stats.getStoryStats / stats.getStoryPublicForwards / stats.getPollStats | stub | empty/error | Layer 225 stats 域剩余入口全部显式注册;graph token 过长返回 `statsGraphError`,story/poll 校验 peer/id/limit 后返回空图表或空 public forwards |
|
||||
| premium.getBoostsStatus | stub | zero-status | TDesktop 频道统计、颜色/权限和 boost 弹窗入口;校验 channel/access_hash 后返回零 boost 状态和必填 `boost_url` 空串,不写入虚假 boost 状态 |
|
||||
| premium.getBoostsList / premium.getUserBoosts | stub | empty-list | TDesktop boost 列表入口;校验 channel/access_hash/admin/user,`limit<=100`、`offset<=128` 后返回空列表,真实 boost 聚合后续补 |
|
||||
| premium.getMyBoosts / premium.applyBoost | stub | empty-noop | TDesktop boost 弹窗入口;`getMyBoosts` 返回空槽位,`applyBoost` 校验 slots 存在、非空、数量上限后空 no-op,真实 premium/boost 模型后续补 |
|
||||
| messages.exportChatInvite(InputPeerChannel) | done | real-channel | 支持导出 channel invite link,校验 invite/change_info 权限,支持 expire/usage_limit/request_needed/title 与 legacy revoke permanent |
|
||||
| messages.checkChatInvite | done | real-channel | 按 invite hash 返回 `chatInvite` / `chatInviteAlready`,处理过期、撤销、已封禁与已加入状态 |
|
||||
| messages.importChatInvite | done | real-channel | 通过 invite hash 加入 channel/megagroup,megagroup 生成 join service message 与 channel pts;read 水位先落到加入前 top,再推进到自己的 join service,避免旧历史计入未读或打开会话时批量生成旧消息读回执;PG 在事务内锁 invite row 后检查/递增 usage_count,usage limit 满返回 `USERS_TOO_MUCH`,request_needed 持久化 pending importer、返回 `INVITE_REQUEST_SENT` 并向管理员推 `updatePendingJoinRequests`,用户后续通过其它 invite 加入会清理旧 pending request |
|
||||
| messages.getExportedChatInvites / messages.getExportedChatInvite | done | real-invite-list | TDesktop invite links 管理页入口;校验 channel/access_hash/admin、admin user、link、limit/offset 边界后按 admin/revoked 返回持久化 invite 列表与 detail;`limit<=100`,分页使用 `offset_date + offset_link` seek,不做 OFFSET 大分页 |
|
||||
| messages.editExportedChatInvite / messages.deleteExportedChatInvite / messages.deleteRevokedExportedChatInvites | done | real-invite-state | 支持 title/expire/usage_limit/request_needed 修改、永久链接 revoke 后返回 replacement、单链接删除与按 admin 清理 revoked invites;撤销链接仍可被管理页 detail 查询,import 会拒绝 revoked hash |
|
||||
| messages.getAdminsWithInvites / messages.getChatInviteImporters | done | real-importers | TDesktop invite admins、importers、join request 管理入口;按 admin 聚合 active/revoked invite 数,importer 支持 requested/link/q/offset_user/offset_date 查询;`limit<=100`、link/q 长度有界,`q+link` 返回 `SEARCH_WITH_LINK_NOT_SUPPORTED` |
|
||||
| messages.hideChatJoinRequest / messages.hideAllChatJoinRequests | done | real-join-request | 支持 pending join request approve/dismiss;approve 复用 channel join 流并生成 megagroup join service update,dismiss 清理 pending;响应和管理员在线 push 都包含新的 `updatePendingJoinRequests`;`hideAll` 单批 cap=1000,避免对超大 dialog 硬生成无界 updates |
|
||||
|
||||
## Contacts / Users
|
||||
|
||||
| method | status | behavior | note |
|
||||
|---|---|---|---|
|
||||
| contacts.getContacts | done | real | 从 contacts 表查询当前账号通讯录;支持 hash notModified;返回当前 owner 保存的姓名/电话/备注视角,互相关系按双方是否互存维护 |
|
||||
| contacts.getContactIDs | done | real | 返回当前 owner 通讯录 user_id 列表,支持 hash notModified |
|
||||
| contacts.getStatuses | done | persisted-presence | 返回当前通讯录联系人 `contactStatus`;状态来自运行时 presence、活跃 session 兜底与持久化 `users.last_seen_at`,离线联系人返回精确 `userStatusOffline.was_online`,未知用户退回 `userStatusRecently`。当前未接 account privacy,粗粒度 `recently/lastWeek/lastMonth/empty` 隐私降级仍为后续项 |
|
||||
| contacts.importContacts | done | real | 按手机号匹配已注册用户,写入当前 owner 视角的联系人姓名/电话/备注,返回 imported/users,并写 durable updatePeerSettings + updateContactsReset + dispatch_outbox |
|
||||
| contacts.addContact | done | real | 写入当前 owner 视角联系人资料;禁止 self/空姓名/不存在 user,维护 reverse mutual,并写 durable updatePeerSettings + updateContactsReset + dispatch_outbox |
|
||||
| contacts.deleteContacts | done | real | 删除当前 owner 的联系人关系,并清理对方 reverse mutual;写 durable updatePeerSettings + updateContactsReset + dispatch_outbox |
|
||||
| contacts.updateContactNote | done | real | 更新当前 owner 对某个联系人的备注与备注实体,不影响其它 owner 对同一 user 的备注,并写 durable updateContactsReset + dispatch_outbox |
|
||||
| contacts.search | done | real | TDesktop 搜索框 peer 分支;strip `@`、空/过短查询报 SEARCH_QUERY_EMPTY/QUERY_TOO_SHORT,limit cap=50;联系人 user 进 MyResults,非联系人 user 进 Results;公开 username channel/supergroup 同步返回 PeerChannel + Chats,当前已加入的放 MyResults,其它公开命中放 Results 并以 left chat 标记只读预览,避免 TDesktop 误显示已加入;用户搜索走手机号前缀/username/姓名/owner 保存姓名索引,公开频道搜索走 username/title trgm 索引 |
|
||||
| contacts.resolveUsername | done | real | 按大小写不敏感 username 解析 user 或公开 channel/supergroup peer;channel 返回 PeerChannel + Chats;不存在返回 USERNAME_NOT_OCCUPIED,非法格式返回 USERNAME_INVALID |
|
||||
| contacts.resolvePhone | done | real-partial | 按手机号解析 user peer;当前阶段未接完整 privacy,默认已知手机号可解析,未命中返回 PHONE_NOT_OCCUPIED |
|
||||
| contacts.getBlocked | stub | empty | Settings 隐私/安全预取;第一阶段无 blocklist,limit cap=50 |
|
||||
| contacts.getTopPeers | stub | disabled | 第一阶段不维护 top peers 统计 |
|
||||
| contacts.getSponsoredPeers | stub | empty | 第一阶段不做 sponsored peers,TDesktop 搜索框分支返回 sponsoredPeersEmpty |
|
||||
| users.getUsers | done | real | InputUserSelf 与已知 InputUser 返回用户(含 777000 官方账号);未登录则跳过(空列表) |
|
||||
| users.getFullUser | done | real-minimal | InputUserSelf/已知 InputUser 返回 user + 最小 userFull;空账号主界面需要 |
|
||||
| users.getSavedMusic | stub | empty | TDesktop 资料页/profile music/export 路径会按 offset/limit/hash 拉取;校验 input user、offset>=0、limit<=100,当前无 profile music media store,返回 `users.savedMusic{count=0,documents=[]}` |
|
||||
| users.getSavedMusicByID | stub | empty | TDesktop saved music 文件引用刷新入口;校验 input user 与 documents vector cap=100,当前返回空 `users.savedMusic` |
|
||||
|
||||
## Stories / Payments / AI Compose
|
||||
|
||||
| method | status | behavior | note |
|
||||
|---|---|---|---|
|
||||
| stories.getAllStories | stub | empty | 第一阶段不做 stories,返回空列表与空 stealth_mode |
|
||||
| stories.getStoriesArchive | stub | empty | TDesktop 主界面后台拉 archived stories;第一阶段返回空 archive |
|
||||
| stories.getPinnedStories | stub | empty | TDesktop 资料页 stories 区域后台查询;当前返回空 `stories.stories` |
|
||||
| stories.getAlbums | stub | empty | TDesktop 资料页 story albums 后台查询;当前无 story album 模型,返回空 `stories.albums` |
|
||||
| stories.sendReaction | stub | validated-noop | 当前无 story store;校验 peer/story_id/reaction,`add_to_recent` 会更新账号 top/recent emoji reaction,返回空 updates,避免 story 反应入口落 unknown |
|
||||
| payments.getStarGiftActiveAuctions | stub | notModified | 第一阶段不做 stars/gifts |
|
||||
| payments.getSavedStarGifts | stub | empty | TDesktop 资料页 gifts 区域后台查询;当前返回空 `payments.savedStarGifts` |
|
||||
| payments.getSavedStarGift | stub | empty | 单个 gift 详情入口;当前返回空 `payments.savedStarGifts` |
|
||||
| aicompose.getTones | stub | notModified | 第一阶段不做 AI compose tones |
|
||||
|
||||
## Langpack
|
||||
|
||||
| method | status | behavior | note |
|
||||
|---|---|---|---|
|
||||
| langpack.getLangPack | done | real | PG-backed;启动 seed `tdesktop_en/zh-hans_v12000000.strings` |
|
||||
| langpack.getDifference | done | real | 按 version 返回语言包差异;当前 seed 同版本全量 |
|
||||
| langpack.getStrings | done | real | 按 key 查询 PG 中的语言包字符串 |
|
||||
|
||||
## Files / Media / Photos
|
||||
|
||||
| method | status | behavior | note |
|
||||
|---|---|---|---|
|
||||
| upload.saveFilePart | done | real-localfs | 累积 small file 分片到 `upload_parts`(PG),part>=0、单片 ≤512KB;需已登录 |
|
||||
| upload.saveBigFilePart | done | real-localfs | 累积 big file 分片,校验 total_parts 上限;组装见 uploadMedia/uploadProfilePhoto |
|
||||
| upload.getFile | done | real-localfs | 把 `inputDocumentFileLocation/inputPhotoFileLocation/inputPeerPhotoFileLocation` 推导为 `file_blobs.location_key`(document 查 `doc:<id>[:type]`,photo 查 `photo:<id>:<type>`),从本地 blob backend 按 offset/limit 切片返回 `upload.file`,storage type 优先按 bytes 魔数、再按 mime 判定,避免历史 seed 把 WebP thumb 误标 `image/jpeg` 后客户端解码失败;`location_key→FileBlob` 元数据 LRU 消除每 chunk PG 查,≤256KB sticker/reaction/thumb 小 blob 走 `object_key→bytes` LRU 并由启动 `WarmCaches` 预热;CDN/legacy `inputFileLocation`/`inputStickerSetThumb` 返回 `LOCATION_INVALID`(todo);location 仅按 `id` 解析,**不校验 `access_hash`/`file_reference`**(安全取舍见下方 note) |
|
||||
| upload.getFileHashes | stub | empty | 本阶段不做 CDN/分片完整性校验,返回空 hash 列表(客户端信任数据) |
|
||||
| photos.uploadProfilePhoto | done | real-photo | 组装上传分片→建头像 Photo(合成 a/c 尺寸,落 `photos`/`file_blobs`/`profile_photos`),设为当前头像,返回 `photos.photo{photo, 带头像 self}` 并向其它在线 session 推 `updateUser`+self(头像即时同步,见下方 note);仅支持 file 变体,fallback/video/emoji-markup 返回 `PHOTO_INVALID` |
|
||||
| photos.updateProfilePhoto | done | real-photo | `inputPhoto` 把历史头像设为当前;`inputPhotoEmpty` 停用当前头像;变更后向其它在线 session 推 `updateUser`+self 同步头像 |
|
||||
| photos.getUserPhotos | done | real-photo | 按 `profile_photos` 返回某用户头像历史(最新在前),offset/limit/max_id 有界,返回 `photos.photos[Slice]` + target user |
|
||||
| photos.deletePhotos | done | real-photo | 按 inputPhoto id 停用头像,返回被删 id 列表 |
|
||||
|
||||
> 头像渲染:`users` 表反范式 `photo_id/photo_dc_id/photo_stripped`,users 服务对 getUsers/getFullUser/self/resolve 批量富化,`tgUser`/`tgSelfUser` 输出 `userProfilePhoto`;channel 头像反范式 `channels.photo_*`,`tgChannel.Photo`/`tgChannelFull.ChatPhoto` 渲染。资源 seed 来自 `TELESRV_STICKER_SEED_DIR` 真实导出,启动时幂等导入(reactions/default 系统集/常规集)。
|
||||
|
||||
> 头像多设备同步:`uploadProfilePhoto`/`updateProfilePhoto`(含 `inputPhotoEmpty` 清除)变更后复用 `pushUserUpdates`,向该账号其它在线 session 推 `updateUser` + `Updates.users` 携带含新 `userProfilePhoto` 的 self;当前设备经 RPC 返回更新,TDesktop 经 `processUser→setPhoto→peerUpdated(Photo)` 即时刷新(与 参考实现 `MakeUpdatesByUpdatesUsers([self],updateUser)` 对齐;`updateUserName` 不含 photo 无法刷新头像)。联系人/对话方不主动广播头像(Telegram 同行为,对端下次拉取 user 时刷新);频道头像变更经 `channels.editPhoto` → `pushChannelStateToMembers` 推在线成员。
|
||||
|
||||
> 安全取舍(待生产补强):`upload.getFile` 与 `messages.sendMedia` 引用既有 `inputMediaPhoto`/`inputMediaDocument` 均只按 `id` 解析、**不校验 `access_hash`/`file_reference`**(不返回 `FILE_REFERENCE_EXPIRED`),依赖 64-bit `crypto/rand` 不可枚举 id 防越权——任意登录用户若得知某 `document`/`photo` id 即可引用/下载其内容。dev 单 DC 可接受,生产需补 owner/access_hash 校验与 file_reference 轮换。
|
||||
|
||||
> 媒体性能与容量债见 `performance-audit.md`「媒体管线」条目:`upload_parts` 无 GC/每用户配额(未 assemble 分片滞留 PG)、`media` JSONB 内联放大 fan-out 写、`getUserPhotos` N+1 + OFFSET 分页。`upload.getFile` 整文件入内存 + 每 chunk 查 PG 已于 2026-06-03 修:`BlobBackend.GetRange` 段读(`ReadAt`)+ `location_key→FileBlob` 元数据 LRU;sticker/reaction/thumb 首开冷路径已补 `object_key→bytes` 小 blob LRU、完整 sticker set cache 与启动预热。
|
||||
|
||||
## Unknown Trace
|
||||
|
||||
未注册 RPC 经 rpc.Router fallback 记录到日志(type_id + layer),返回 NOT_IMPLEMENTED rpc_error。
|
||||
最近一次 TDesktop 联调发现的异步预取 trace 已全部转入上方各分区并实现第一阶段空响应;2026-05-31 复跑登录注册→空账号主界面后,无 `Unhandled RPC` / `NOT_IMPLEMENTED`。
|
||||
|
||||
| method/type_id | first_seen | raw_note |
|
||||
|---|---|---|
|
||||
| — | — | 当前无未实现 RPC trace |
|
||||
252
docs/message-module.md
Normal file
252
docs/message-module.md
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
# Message Module Design
|
||||
|
||||
Date: 2026-05-31
|
||||
|
||||
## Scope
|
||||
|
||||
二阶段先实现私聊文本闭环:
|
||||
|
||||
- `messages.sendMessage`
|
||||
- `messages.forwardMessages`
|
||||
- `messages.getDialogs`
|
||||
- `messages.getHistory`
|
||||
- `messages.readHistory`
|
||||
- `messages.editMessage`
|
||||
- `messages.deleteMessages`
|
||||
- `messages.deleteHistory`
|
||||
- `updates.getState`
|
||||
- `updates.getDifference`
|
||||
- 在线 session update 推送
|
||||
|
||||
不在本阶段实现:媒体、定时消息、群组/频道、Bot API、文件 DC。
|
||||
|
||||
## Storage
|
||||
|
||||
大表从第一版开始分区:
|
||||
|
||||
| table | partition key | purpose |
|
||||
|---|---|---|
|
||||
| `private_messages` | `sender_user_id` HASH | 共享私聊消息主体;`sender_user_id + random_id` 保证发送幂等 |
|
||||
| `message_boxes` | `owner_user_id` HASH | owner 视角消息盒;TDesktop 看到的 message id 即 `box_id` |
|
||||
| `dialogs` | `user_id` HASH | 会话摘要;`folder_id=0/1` 表示主列表/归档,置顶、manual unread、action bar 隐藏均是 owner 视角 |
|
||||
| `dialog_filters` / `dialog_filter_settings` | `user_id` HASH | TDesktop 自定义 dialog filter、filter 顺序与 folder tags 开关;不把自定义 filter 伪装成 dialogs.folder_id |
|
||||
| `user_update_events` | `user_id` HASH | 账号级 pts durable log;承载新消息、已读 inbox/outbox、文本编辑、删除消息,也承载 contacts reset、dialog pinned/order/manual unread、peer settings、dialog filters 与 folder peers 等 owner 视角状态事件 |
|
||||
| `dispatch_outbox` | `target_user_id` HASH | transactional outbox,事务后批量推送在线 session;投递成功即删除,仅保留未完成任务 |
|
||||
|
||||
Redis 只存可恢复计数:
|
||||
|
||||
- `counter:pts:{user_id}`:账号级 pts。
|
||||
- `counter:box_id:{user_id}`:owner 视角 box_id。
|
||||
- `ratelimit:messages:send:{user_id}`:发送窗口限流,当前为每用户每分钟 30 条。
|
||||
|
||||
Redis miss 时分别从 `MAX(user_update_events.pts)` 与 `MAX(message_boxes.box_id)` 恢复。
|
||||
分配器热路径使用 Redis Lua 脚本递增;首次 miss 时先从 PG durable log 读取恢复值,再通过恢复脚本完成「初始化 + 首次自增」,避免并发 first-use 把 `pts` / `box_id` 分配出重复或回退。
|
||||
|
||||
## Send Flow
|
||||
|
||||
`messages.sendMessage` 在 RPC 层只做 TL 转换和当前 user/session 校验,业务写入由 `MessageStore.SendPrivateText` 单事务完成:
|
||||
|
||||
1. 写 `private_messages`,遇到同 `sender_user_id + random_id` 直接返回原消息盒。
|
||||
2. Redis 分配 sender/recipient 的 `box_id` 与 `pts`。
|
||||
3. 写 sender/recipient `message_boxes`,并保存 `silent/noforwards/reply_to/fwd_from` 元数据;reply 会把当前 owner 的 `reply_to_msg_id` 翻译成对端 owner 视角的 box_id。
|
||||
4. upsert 双方 `dialogs`。
|
||||
5. 写双方 `user_update_events(new_message)`。
|
||||
6. 写双方 `dispatch_outbox`,sender 侧带 `exclude_session_id`。
|
||||
7. 提交后由 outbox worker 批量推送在线 session。
|
||||
|
||||
若事务失败但 Redis 已分配 pts,store 会尽力写 `noop` 事件占位,避免 pts 回退;PG 不可用时该补偿也可能失败,后续需要告警指标覆盖。
|
||||
|
||||
## Forward / Reply Flow
|
||||
|
||||
`messages.forwardMessages` 当前覆盖私聊文本转发,参考实现 的业务语义但保持 telesrv 的 owner 视角模型:
|
||||
|
||||
1. RPC 层校验 from/to peer、id/random_id 等长、单次最多 100 条;scheduled/monoforum/quick reply/effect/paid/suggested 等当前阶段外能力用显式 TL 错误拒绝,目标为 channel 的 `send_as` 交由频道模块校验 self/current channel。
|
||||
2. store 按当前 owner + from peer + box_id 保序读取源消息,源消息带 `noforwards` 时返回 `CHAT_FORWARDS_RESTRICTED`。
|
||||
3. 未设置 `drop_author` 时,若源消息已有 forward header 则沿用;否则用源消息 `from/date` 生成 `messageFwdHeader`。`drop_author` 会去掉 forward header。
|
||||
4. 每条转发复用 `SendPrivateText` 写入双端 message box / dialog / update event / dispatch outbox,`pts_count=1`,并使用对应 random_id 保证幂等。
|
||||
|
||||
`messages.sendMessage` 的 `reply_to` 支持 `inputReplyToMessage` 的私聊同 peer 回复和 quote 文本/entities/offset;`quote_text` 按 TDesktop `quote_length_max=1024` 限制,`quote_offset` 是原消息文本内 offset,不是 message id,按当前文本消息上限 4096 收口。cross-peer reply、story、monoforum、todo/poll reply 仍拒绝,但返回 `REPLY_MESSAGE_ID_INVALID` / `STORY_ID_INVALID` / `REPLY_TO_MONOFORUM_PEER_INVALID` / `POLL_OPTION_INVALID` 等显式错误,不再落 `NOT_IMPLEMENTED`。服务端保存 sender 视角和 recipient 视角各自的 `reply_to_msg_id`,避免一个 owner 的 box_id 泄漏到另一个 owner。
|
||||
|
||||
## Read Flow
|
||||
|
||||
`messages.readHistory` 采用 owner 视角的双端回执模型,参考实现 的 inbox 已读链路和 参考实现 的 inbox/outbox read history event:
|
||||
|
||||
1. reader 侧锁定当前 dialog,按 `max_id` 与当前可见 incoming message 计算新的 `read_inbox_max_id`,清零 unread_count 并清除 manual unread。
|
||||
2. 如果 reader 确实推进了已读水位,给 reader 生成 `updateReadHistoryInbox(pts_count=1)`,写入 `user_update_events + dispatch_outbox`。
|
||||
3. 同一事务中找出本次被读到的最新 incoming message,定位原 sender 的 dialog,推进 sender 侧 `read_outbox_max_id`。
|
||||
4. sender 水位真正推进时,给 sender 生成 `updateReadHistoryOutbox(pts_count=1)`,离线设备可通过 `updates.getDifference` 补齐。
|
||||
|
||||
发送消息时不会预先推进 sender 的 `read_outbox_max_id`;只有对端实际 readHistory 后才产生 outbox 已读回执,避免“刚发出就被标成已读”的假状态。
|
||||
|
||||
`messages.getOutboxReadDate` 复用 sender 侧 durable `read_history_outbox` 事件:先校验当前 owner 的 `msg_id` 是该 peer 下可见 outgoing message,再取最早一条 `max_id >= msg_id` 的 outbox read event 日期返回 `outboxReadDate`;未被读到返回 `MESSAGE_NOT_READ_YET`。PG 上有 `(user_id, peer_type, peer_id, max_id, date) WHERE event_type='read_history_outbox'` partial index,避免 TDesktop 已读详情查询扫全量 update log。
|
||||
|
||||
## Edit Flow
|
||||
|
||||
`messages.editMessage` 当前只支持私聊文本编辑:
|
||||
|
||||
1. RPC 层校验当前账号、peer、message id、文本长度和 entities;`inputMediaWebPage/inputMediaEmpty` 降级为文本编辑,真实 media/reply_markup/quick replies/scheduled 返回显式 TL 错误。
|
||||
2. store 锁定当前 owner 的 message_box,确认它是当前 user 发出的 outgoing 私聊消息。
|
||||
3. 更新共享 `private_messages` 文本与 `edit_date`,并更新同一 private message 下所有未删除 owner message_box。
|
||||
4. 每个受影响 owner 各自分配 `pts`,写 `updateEditMessage(pts_count=1)` 与 `dispatch_outbox`。
|
||||
5. 当前请求设备直接拿到 `updates` 响应;其它在线设备走 reliable outbox,离线设备走 `updates.getDifference`。
|
||||
|
||||
如果文本和 entities 完全未变化,返回 `MESSAGE_NOT_MODIFIED`;非作者编辑返回 `MESSAGE_AUTHOR_REQUIRED`。
|
||||
|
||||
## Delete Flow
|
||||
|
||||
`messages.deleteMessages` / `messages.deleteHistory` 以 owner 视角软删除 `message_boxes`,不会删除共享 `private_messages` 主体。默认 deleteHistory 清空后如果该 peer 已无可见消息,则删除当前 owner 的 dialog;后续任意新消息会通过正常 send/upsert 路径重建 dialog。`just_clear=true` 对齐 参考实现 语义:清空历史但保留一个空 dialog(当前阶段不生成 `messageActionHistoryClear` 服务消息)。
|
||||
|
||||
`revoke=true` 时按 `(message_sender_id, private_message_id)` 找到同一私聊消息在其它 owner 下的 message_box 并软删除。每个受影响 owner 都生成自己的 `updateDeleteMessages`,`message_ids` 是该 owner 视角 box_id,`pts_count=len(message_ids)`,并写入 `user_update_events + dispatch_outbox`。如果删除后仍有可见消息,dialog 的 top/unread 会按剩余消息重算;否则删除 dialog 或在 `just_clear` 下保留空 dialog。
|
||||
|
||||
全清也必须让所有被删的 owner 视角 message_id 最终进入 update/difference,但不能合成一个超大 update。`messages.deleteHistory` 单次最多删除 `MaxDeleteHistoryBatch=1000` 条,按 box_id 倒序批量软删并返回 `affectedHistory.offset=1` 表示客户端应继续发起下一批;每一批各自产生一条有界 `updateDeleteMessages`。`messages.deleteMessages` 单次 id 数限制为 `MaxDeleteMessageIDs=1000`,服务端还会丢弃 `<=0` 或超过 TL/PG int4 可表达范围的 id。
|
||||
|
||||
## Query Path
|
||||
|
||||
- `messages.getHistory` 以 `owner_user_id + peer_user_id + box_id/date` 命中 `message_boxes` 分区索引;`offset_id`、`offset_date`、`max_id`、`min_id` 都下推为游标条件;`add_offset` 只允许 `[-100,100]` 的小窗口偏移,避免异常客户端把超大偏移变成 SQL 跳过扫描或内存 slice capacity。
|
||||
- `messages.search` / `messages.searchGlobal` 当前只覆盖当前 owner 私聊文本搜索;查询仍限定在 `owner_user_id` HASH 分区内,文本条件由 `pg_trgm` GIN 索引兜底。参考实现 的经验,未接外部全文搜索前禁止无索引大表模糊扫;后续群组/频道/全局多 peer 搜索应接专用搜索索引或 FTS。
|
||||
- `messages.getDialogs` 以 `user_id` 分区定位当前账号,再按 `top_message_date/top_message_id/peer_id` 做 seek pagination;folder_id=0/1 直接走 `dialogs.folder_id`,folder_id>=2 先取当前账号 `dialog_filters` 后按 include/exclude/contact 规则过滤;`hash` 与 `count` 基于当前筛选后的完整会话集计算。
|
||||
- `messages.getDialogFilters` 返回 `dialogFilterDefault` + 当前账号持久化 filters;`messages.updateDialogFilter/updateDialogFiltersOrder/toggleDialogFilterTags` 与 `folders.editPeerFolders` 都是 owner 视角写入,归档只允许 folder_id 0/1,自定义 filters 从 ID 2 开始。
|
||||
- `updates.getDifference` 只按 `user_id + pts` 顺序扫描 `user_update_events`,多设备各自的 `(auth_key_id,user_id)` state 只记录消费位置,不参与账号事件归属;离线设备通过同一条 durable log 恢复新消息、已读 inbox/outbox、文本编辑、删除消息、联系人 reset、dialog pinned/order/manual unread、peer settings、dialog filters/order/reload 与 folder peers 变化,置顶顺序、peer settings flags、filter payload 与 folder peers 会随事件负载持久化。
|
||||
|
||||
## 参考实现 Comparison
|
||||
|
||||
参考实现 的 `SyncUpdatesNotMe` 设计有两个值得借鉴的点:所有“其它端通知”收敛到 sync 服务,以及带 pts 的消息/已读类 update 会写 `user_pts_updates` 后再推 session。它的不足也很明确:`updatePeerSettings` 只在线 push 不入 durable pts 队列,`markDialogUnread` / `hidePeerSettingsBar` 仍有 TODO,离线设备可能依赖后续主动刷新才能看到状态变化。
|
||||
|
||||
telesrv 当前做得更进一步:联系人 reset、dialog pinned/order/manual unread、peer settings、dialog filter/order/reload 与 folder peers 都写入账号级 `user_update_events`,并通过 Postgres `AppendWithDispatch` 在同一事务里写入 `dispatch_outbox`。`updatePinnedDialogs.order`、`updatePeerSettings.settings`、`updateDialogFilter.filter`、`updateDialogFilterOrder.order`、`updateFolderPeers.folder_peers` 都是 durable payload,不依赖后续主动刷新;在线投递由 outbox worker 负责,排除当前设备时同时携带 `exclude_auth_key_id + exclude_session_id`,避免同一 session_id 在不同 auth key 下误排除或串号。RPC handler 在检测到可靠 outbox 后不再额外手动 push,避免其它设备收到重复在线通知。
|
||||
|
||||
dialog 分组借鉴 参考实现 的 `dialog_filters`/`editPeerFolders` 业务语义与 参考实现 的 `UpdateDialogFilter` / `UpdateFolderPeers` 事件模型,但修正两点:filter 设置不仅在线 push,也有持久化真值表和 durable update;归档 folder 与自定义 filter 分离,避免一个 `folder_id` 字段同时承担归档状态和任意筛选规则。
|
||||
|
||||
删除链路借鉴 参考实现 的 `just_clear` / dialog 删除边界:普通 deleteHistory 会移除 dialog,`just_clear` 保留 dialog。借鉴 参考实现 的点是“先按 owner 计算受影响 message_id,再按 owner 重建 top message 并发 `updateDeleteMessages`”,而不是只对当前请求账号返回 affectedMessages;这样 revoke、多设备离线补偿和 dialog 后续重建都在同一条 owner 视角语义里。
|
||||
|
||||
已读和编辑链路也分别借鉴了两边的优点:参考实现 在 edit 时会校验 sender 并把编辑同步到 inbox/outbox,参考实现 把 readHistory 分成 reader 的 `UpdateReadHistoryInbox` 和 sender 的 `UpdateReadHistoryOutbox`。telesrv 将这两类事件都落到账号级 durable log,同事务写 reliable outbox,且 edit 更新共享消息主体和所有 owner 视角 message_box,避免只在线 push 或只改当前盒子导致离线设备/对端历史不一致。
|
||||
|
||||
reply/forward 的语义参考实现 的 handler:send/forward 都透传 `silent` 和 `noforwards`,reply 用 `inputReplyToMessage`,forward 在源消息没有 `fwd_from` 时构造原作者 header,遇到受保护内容拒绝转发。telesrv 额外修正 owner 视角 id 翻译与 durable update 负载:对端收到的 reply header 使用对端自己的 message id,离线设备通过 `updates.getDifference` 也能拿到同样的 `reply_to/fwd_from` 元数据。
|
||||
|
||||
## Observability
|
||||
|
||||
消息链路 RPC 层预留 `Metrics` 接口,当前覆盖:
|
||||
|
||||
- `MessageSend`:记录发送成功、payload 长度与双端 pts。
|
||||
- `MessageRateLimited`:记录用户级窗口限流。
|
||||
- `OutboxClaimed` / `OutboxDelivered` / `OutboxFailed`:记录 outbox claim、在线推送成功与重试失败。
|
||||
|
||||
默认实现是 no-op,生产接 Prometheus/OpenTelemetry 时只需在 `rpc.Deps` 注入实现,不污染业务服务和 store 边界。
|
||||
|
||||
## Next Execution Plan
|
||||
|
||||
连接层已具备 per-connection outbound actor、scoped session context 与 bounded inbound RPC scheduler。消息模块下一轮按以下顺序推进,不再先扩 RPC 面:
|
||||
|
||||
1. 压测基线:单机目标 200 msg/s 私聊文本、p99 sendMessage < 150ms、p99 getDifference < 100ms。已落地 `internal/loadtest` 真实 PG+Redis 压测 harness,首版基线见下「Load Baseline」节;1k online session 的网络 fanout 属连接层,本轮 harness 未覆盖(binder 用零连接 SessionManager,只测 outbox 的 PG 排空)。
|
||||
2. Redis 计数强一致:`pts` / `box_id` 分配已收敛到 Lua 脚本路径,覆盖 Redis miss 从 PG durable log 恢复与并发 first-use 连续分配;事务失败 noop gap 与 random_id 幂等已有 store 测试覆盖,后续补真实 Redis+PG 混合压测。
|
||||
3. PG 分区验收:已有 `message_boxes` history seek 与 `dispatch_outbox` stale retry 索引;已补 explain 集成测试,固定 `message_boxes` / `user_update_events` 单用户查询命中分区索引,`dispatch_outbox` 全局 claim 允许跨分区但必须走分区 partial index、不出现 Seq Scan。
|
||||
4. Outbox 背压(已做到生产级,见下「Load Baseline」):worker 有 batch claim、lease timeout、retry/backoff、failed 终态和 claim/deliver/fail 指标;batch size / interval / lease timeout / **worker 数** 均配置化(env `TELESRV_OUTBOX_BATCH` / `_INTERVAL` / `_LEASE_TIMEOUT` / `_WORKERS`)。**已并行化(N worker 竞争 `FOR UPDATE SKIP LOCKED` claim)并批量化(每批一次 `BatchListDispatchEvents` unnest join + 一次 `MarkDispatchDeliveredBatch`,取代逐条往返)**,排空上限从单 worker ~270 行/s 提到 ~12k 行/s(本机 PG,~45x);批量化后瓶颈转移到 PG 本身。主动 push 仍只走 `auth_key_id + session_id + user_id` scoped API。
|
||||
5. TDesktop 私聊闭环:用双账号/多设备验证 sendMessage、forwardMessages、reply、getDialogs、getHistory、readHistory、getOutboxReadDate、editMessage、deleteMessages/deleteHistory、updates.getDifference、当前设备过滤与退出换号不串号。2026-05-31 已用 Alice/Bob 两个 TDesktop workdir 验证在线 read/edit/delete 与 Bob 离线 send+edit/read/delete 差量同步;删除后 dialog 可清除,后续新消息仍按正常路径重建。2026-06-01 已补 reply/forward 服务端路径和单测,仍待重新用双 TDesktop 实机点 UI 行为(通知声音、reply 预览、forward header)。
|
||||
6. 兼容矩阵回填:所有真实跑到的未知 RPC 自动入 trace,按 TDesktop 首屏和私聊流程补 `done/stub/todo` 状态。
|
||||
|
||||
## Load Baseline
|
||||
|
||||
压测 harness:[`internal/loadtest/send_load_test.go`](../internal/loadtest/send_load_test.go),env-gated,未设 `TELESRV_TEST_POSTGRES_DSN`+`TELESRV_TEST_REDIS_ADDR` 时 Skip,对默认 `go test ./...` 无副作用。
|
||||
|
||||
**方法**:与 main.go 一致装配连接池 + Redis 分配器 + PG 消息存储 + transactional outbox + 真实 `OutboxDispatcher`(binder 用零连接 `SessionManager`,`PushToUserExceptSession` 返回 0,因此 outbox 走完整 `claim→批量取事件→push→批量标记` 的 PG 往返,测排空而非网络 fanout)。closed-loop 饱和:`concurrency` 个 worker 各自不停发满 `messages` 条。`TELESRV_LOAD_DEFER_DISPATCH=1` 时先把积压攒满、发送结束才启 dispatcher,用以隔离测量 outbox **纯排空上限**(否则 dispatcher 实时跟上发送、积压近 0 量不出天花板)。
|
||||
|
||||
**运行**(PowerShell):
|
||||
|
||||
```powershell
|
||||
$env:TELESRV_TEST_POSTGRES_DSN = "postgres://telesrv:telesrv@localhost:5432/telesrv?sslmode=disable"
|
||||
$env:TELESRV_TEST_REDIS_ADDR = "localhost:6399"
|
||||
# 稳态(dispatcher 与发送并发)
|
||||
go test ./internal/loadtest/ -run TestMessageSendBaseline -v -count=1 -timeout 240s
|
||||
# 纯排空上限(先攒满积压再排空)
|
||||
$env:TELESRV_LOAD_MESSAGES=30000; $env:TELESRV_OUTBOX_BATCH=200; $env:TELESRV_LOAD_DEFER_DISPATCH=1
|
||||
go test ./internal/loadtest/ -run TestMessageSendBaseline -v -count=1 -timeout 300s
|
||||
```
|
||||
|
||||
可调 env:`TELESRV_LOAD_USERS`(50) / `_CONCURRENCY`(32) / `_MESSAGES`(5000) / `_POOL_CONNS`(64) / `_DEFER_DISPATCH`;outbox 用 `TELESRV_OUTBOX_WORKERS`(loadtest 默认 8,运行时默认 2) / `_BATCH`(100) / `_INTERVAL` / `_LEASE_TIMEOUT`;`TELESRV_LOAD_ENFORCE_SLO=1` 时 SLO 超标硬失败(回归门禁)。正确性(无发送错误/无意外重复/全部发出/outbox 必须排空/无终态失败)始终硬断言。
|
||||
|
||||
**生产化做了什么**(2026-05-31):
|
||||
|
||||
- **连接池配置化**(`TELESRV_POSTGRES_MAX_CONNS` 默认 50,`TELESRV_POSTGRES_MIN_CONNS` 默认 16,经 `postgres.WithMaxConns/WithMinConns`)。`postgres.Open` 会在启动时显式预热 min 连接,避免 TDesktop 双端冷启动时大量 RPC 一边建 PG 连接一边排队;pgx 默认 `max(4,NumCPU)` 在高并发下排队,是首版 send max 2s 突刺的根因。
|
||||
- **dispatcher 并行**(`TELESRV_OUTBOX_WORKERS`,运行时默认 2,loadtest 默认 8,`rpc.WithOutboxWorkers`)。N worker 各跑 claim 循环,靠 `FOR UPDATE SKIP LOCKED` 互不重叠;本地 TDesktop 双端启动默认偏保守,避免多个 worker 同扫 64 分区父表触发 PG lock/shared-memory 压力。
|
||||
- **dispatcher 批量**(`store` 具备批量能力时自动启用,否则逐条回退)。每批一次 `BatchListDispatchEvents`(双 `unnest WITH ORDINALITY USING(ord)` 配对 (user_id,pts) 的 join)+ 一次 `MarkDispatchDeliveredBatch`,把每条 ~2 次 PG 往返降到每批 ~3 次。
|
||||
|
||||
**outbox 排空上限演进**(本机 docker PG+Redis,攒满 6 万行积压后纯排空):
|
||||
|
||||
| 方案 | drain 速率 | 相对 |
|
||||
|---|---|---|
|
||||
| 单 worker 逐条(首版) | ~270 行/s | 1x |
|
||||
| 8 worker 逐条(仅并行) | ~1711 行/s | ~6x |
|
||||
| **8 worker 批量(当前)** | **~12285 行/s** | **~45x** |
|
||||
|
||||
稳态下(concurrent,出厂默认 users=1000/concurrency=32/8 worker/batch100):**峰值积压仅 ~330 行、排空 ~80ms**——dispatcher 实时跟上发送(~2500 行/s 入队);send p99 ~114ms、getDifference p99 ~2ms、吞吐 ~1255 msg/s,SLO 全过。12k 行/s ≈ 6k msg/s 可持续 outbox 吞吐;16 worker/batch500 仍 ~11.7k 行/s(与 8 worker 持平 → 瓶颈已是 PG 而非 worker 数)。
|
||||
|
||||
> send p99 对**用户池集中度**敏感:harness 用 50 用户时 p99 飙到 ~220–325ms(32 并发挤少数用户的 dialog/message_box 行,行锁争用),2000 用户降到 ~58ms。这是小池假象,生产 20 万用户分散后争用极低;默认已取 users=1000。
|
||||
|
||||
**面向 20 万在线的剩余 levers(按 §8 记账,本轮未做)**:
|
||||
|
||||
1. **PG 已是共享瓶颈**:批量化后 send 与 drain 都打在同一 PG 上,本机 ~12k 行/s 触顶。生产需更强的 PG(多核/NVMe/调参),更高规模再上读副本 / 按 user 分库。worker/batch/池均可调以匹配硬件。
|
||||
2. **send 路径 Redis 分配已移出事务(已做)**:原先 sender/recipient 的 `pts`/`box_id` 分配(4 次 Redis 往返)嵌在 PG 事务内,持连接空等 Redis。现已移到 `BeginTx` 之前——分配本就走 Redis 不属 PG 事务,前移后不再在持有 PG 连接(与行锁)期间空等,连接周转更快(池受压时收益最大;收益随 Redis RTT 增大,远端 Redis 更明显)。本机 docker(Redis 亚毫秒 RTT)下 pool=24/conc=96(4x 超订)实测 send p99 ~130ms(<150ms)、~1800 msg/s、0 err 0 dup、SLO 全过。
|
||||
- **配套的 pts 连续性兜底(mtproto 对齐,已做)**:分配前移加宽了「pts 已分配未提交」的瞬时空洞窗口(并发发送 commit 重排序,本就存在),故 `updates` 服务改为只暴露**连续 pts**:`getState` 报告最大连续已提交 pts(非 allocator 最大已分配值),`getDifference` 只返回从客户端 pts 起连续的事件、遇空洞即截断、`State.Pts` 取最后连续值、超 `limit`(100) 置 `updates.differenceSlice`。客户端永不越过在途空洞,空洞由 commit/补洞在毫秒内自愈,下次拉取补齐——绝不丢消息。连续值由 `UpdateEventStore.MaxContiguousPts`(PG 顶部 4096 窗口)计算。正确性已由单测(空洞截断/slice 翻页/getState 连续)+ 真实 PG 集成测试(200 并发发送 pts 严格 1..200 无空洞无重复无丢失、PG 空洞场景)覆盖。
|
||||
- 仍可继续:把 4 次 Redis 分配 pipeline/合并成 1 次(每用户 pts+box 一个 Lua、sender/recipient 并行),进一步缩短分配耗时。
|
||||
3. **连接层 `SessionManager` 全局锁——已实测,非吞吐瓶颈(结论被数据修正)**:原假设是"pushToUser 抢全局锁 = 20 万在线最大瓶颈"。新增连接层 benchmark(`session_manager_bench_test.go`,20 万连接、内存 Conn 不走真实 socket)实测:
|
||||
- **push fanout**:~4.6M push/s(24 核),延迟 1→24 核仅 249ns→328ns,**几乎不随核数恶化**——临界区只是 snapshot 一个 per-user 小 map,锁极短。20 万在线 @ 200 msg/s × 双端 = ~40 万 push/s,**比上限低一个数量级,push 不是瓶颈**。
|
||||
- **Register/Unregister churn**:全局**写锁**,~2067ns/op @ 24 核 ≈ 48 万 churn/s。稳态 churn(20 万连接 × 平均存活分钟级)≈ 千级/s,余量巨大。
|
||||
- mutexprofile 确认两者都 100% 串行在同一把锁,但因临界区极短,**当前规模下不构成瓶颈**。
|
||||
- **真正的风险点**(分片的价值所在,但非紧急):重连风暴(断网恢复 / 发版重启)时瞬时 churn 飙升,**写锁会阻塞同期所有 push 读锁**。按 userID 分片可把写锁从全局 1 把拆成 N 片,churn 风暴只影响 1/N 的 push——这是**稳健性/尾延迟**提升,不是吞吐解锁。建议留待真有重连风暴尾延迟问题、或单机连接数再上一个量级时再做。
|
||||
- 仍未覆盖:真实 socket 的 per-conn 加密/写出背压、20 万 fd 的内核态开销——那需要真实网络压测环境,非本 harness(内存 Conn)目标。
|
||||
|
||||
## Multi-Account / Multi-Device
|
||||
|
||||
- `authorizations` 是 auth_key 到 user 的权威绑定。
|
||||
- active session 缓存 `auth_key_id + user_id`;router 额外按业务 auth_key 缓存 `user_id`,并用 singleflight 合并启动期并发 miss,避免同一永久 auth_key 派生的多个 temp session 重复查授权表。
|
||||
- `update_states` 主键是 `(auth_key_id, user_id)`;同设备退出登录或换账号只清 auth_key 设备状态,不删除账号级 `user_update_events`。
|
||||
- 一个账号多设备共享 `user_update_events`,各设备通过自己的 `auth_key_id + user_id` 状态和 `updates.getDifference` 补偿。
|
||||
- 联系人备注、dialog pinned/order/manual unread、peer settings 都是 owner 视角数据;写业务表后同步写账号级 durable update event,并把投递任务写入 `dispatch_outbox`,避免“只在线 push、离线设备永远不知道”的状态漂移。
|
||||
|
||||
## ACK / Global Sequence Note
|
||||
|
||||
`参考实现` 的可借鉴点是:`msgs_ack` 不直接生成业务事件,它只确认某个已发送 server msg_id / RPC response 已被客户端收到;server 通过 ack cache 找回该响应对应的 `pts` / `globalSeqNo`,再推进 `(auth_key_id,user_id)` 维度的已确认水位。这个设计适合频道、多 peer 混合更新和 server 侧 delivered watermark。
|
||||
|
||||
当前阶段私聊文本只需要账号级 `pts` durable log,客户端 `updates.getDifference` 仍以请求里的 `pts` 为准;MTProto ACK 只释放出站重发缓存。后续引入频道或跨 peer 全局更新流时,再新增独立的 per-device `global_seq_no` / delivered watermark,不把它塞进 MTProto ACK 状态本身。
|
||||
|
||||
## 客户端 pts 重排依赖 pts_count 准确(TDesktop PtsWaiter,已核对源码)
|
||||
|
||||
> 结论来自实读 TDesktop pinned baseline(`9caf32dffc`)源码:
|
||||
> `tdesktop/Telegram/SourceFiles/data/data_pts_waiter.cpp`、`data_pts_waiter.h`、`api/api_updates.cpp`。
|
||||
|
||||
**背景**:outbox worker 多 worker 并发 claim + 发送事务乱序提交,所以**主动推送可能乱序到达客户端**(pts=6 的 `UpdateNewMessage` 先于 pts=5 发出)。这不会让客户端乱序或丢消息——前提是每条 update 的 `pts` / `pts_count` 准确。
|
||||
|
||||
**客户端如何处理乱序**(`PtsWaiter::check`,data_pts_waiter.cpp:170-187):客户端维护 `_good`(已应用的最大连续 pts)、`_last = max(见过的 pts)`、`_count += 每条 update 的 pts_count`:
|
||||
|
||||
- 收到 pts=6(count=1):`_last=6, _count=5` → `_last > _count`(中间缺 pts)→ 这条 update **不应用,先缓存进 `_updatesQueue`**,`setWaitingForSkipped(1000ms)`。
|
||||
- 随后收到 pts=5(count=1):`_last=6, _count=6` → 相等 → `_good=6`,`applySkippedUpdates` 把缓存的 5、6 **按序应用**(data_pts_waiter.cpp:48-72)。
|
||||
- 若 1 秒(`kWaitForSkippedTimeout`,data_pts_waiter.h:24)内空缺没补上 → 触发 `getDifference` 主动补齐。
|
||||
|
||||
即客户端**缓存乱序、等空缺、按序应用,1 秒补不齐才 getDifference**——比"丢弃重拉"更优雅。普通私聊 updates 与 channel 复用同一个 `_ptsWaiter` 单例(调用时 channel 传 `nullptr`,api_updates.cpp:603-604)。
|
||||
|
||||
**对 server 的硬约束**:客户端这套重排完全依赖 `_count += pts_count` 累加,因此 server 必须保证:
|
||||
|
||||
1. **每条 update 的 `pts_count` 准确等于它推进的 pts 步数**。私聊文本、已读 inbox/outbox、文本编辑恒为 1;批量删除为 owner 视角删除 message_id 数量:`UpdateDeleteMessages{Pts: event.Pts, PtsCount: len(message_ids)}`。
|
||||
2. **每个分配出去的 pts 最终都能被 getDifference 拿到**(即使事务回滚,也要写 `noop` 占位,见 `recordPtsGaps`)——否则连续水位永远卡在空缺处,客户端会永久 `getDifference` 重试或永久 gap。
|
||||
|
||||
**当前安全**:私聊文本、转发、已读回执、文本编辑 `pts_count` 恒为 1,删除消息 `pts_count` 等于删除数量;每个 pts 都有真实事件或 noop 占位,且 getState/getDifference 只暴露连续 pts(见上文 Load Baseline 的「pts 连续性兜底」)。**未来风险**:引入其它“一次操作产生多条 update / `pts_count ≠ 1`”的场景(批量 service action、频道 editMessage 等)时,若 server 的 `pts_count` 算错,客户端 `_count` 会永久错位 → 永久 gap。新增此类 RPC 时必须同步核对 pts_count 语义。
|
||||
|
||||
## Current Limits
|
||||
|
||||
- `messages.sendMessage` 仅支持私聊文本;reply 仅支持同一私聊 peer 的 `inputReplyToMessage`。
|
||||
- `messages.forwardMessages` 仅支持当前 owner 可见的私聊文本消息转发到 user peer,单次最多 100 条。
|
||||
- `messages.editMessage` 仅支持文本消息编辑;网页预览 media 降级文本编辑,真实媒体、reply markup、quick replies、scheduled edit 留后续并返回显式 TL 错误。
|
||||
- `messages.deleteMessages` / `messages.deleteHistory` 仅支持私聊消息;`min_date/max_date` 当前作为兼容 no-op,服务消息 `messageActionHistoryClear` 留到后续消息类型扩展。
|
||||
- `messages.deleteHistory` 全清按 1000 条一批推进,`offset>0` 续删;`deleteMessages` 单次最多 1000 个 id,避免客户端构造超大数组或超大 `max_id` 导致服务端 OOM。
|
||||
- `messages.getHistory/messages.search` 的 `add_offset` 统一 clamp 到 `[-100,100]`;TDesktop 正常滚动只会使用小偏移,恶意超大值不能触发无界内存分配或大 SQL OFFSET。
|
||||
- `reply_markup`、quick replies、effects、paid send、suggested posts、story/monoforum/todo/poll reply 均返回客户端可理解的显式 TL 错误(如 `REPLY_MARKUP_INVALID`、`SHORTCUT_INVALID`、`EFFECT_ID_INVALID`、`PAYMENT_UNSUPPORTED`、`SUGGESTED_POST_PEER_INVALID`、`STORY_ID_INVALID`、`REPLY_TO_MONOFORUM_PEER_INVALID`、`POLL_OPTION_INVALID`),不再落 `NOT_IMPLEMENTED`;`send_as` 仅在目标为 channel 时按频道模块规则接受 self/current channel,私聊目标返回 `SEND_AS_PEER_INVALID`;目标为 channel 且请求未显式带 `send_as` 时,RPC 层读取 `messages.saveDefaultSendAs` 保存的默认身份并重新校验。
|
||||
- 定时消息返回 `SCHEDULE_DATE_INVALID`。
|
||||
- `messages.search` / `messages.searchGlobal` 当前是私聊文本 `ILIKE` + `pg_trgm`,后续大规模数据需要按语言/分词策略补全文索引或外部搜索服务。
|
||||
- 单条文本当前限制 4096 个 Unicode code point;超限返回 `MESSAGE_TOO_LONG`,触发窗口限流返回 `FLOOD_WAIT_X`。
|
||||
|
||||
## 媒体消息(2026-06-02)
|
||||
|
||||
- `messages.sendMedia` / `uploadMedia` / `sendMultiMedia` 接入:RPC 层 `resolveInputMedia` 把 `inputMediaUploadedPhoto/Document`(组装 `upload.*` 分片→建 `Photo`/`Document`)与 `inputMediaPhoto/Document`(引用已存在资源,含贴纸)转成 `domain.MessageMedia`,经抽取的 `sendOutgoing` 走与文本完全相同的 pts/box/outbox/在线推送/离线 `getDifference` 路径;私聊与 channel 共用。
|
||||
- `private_messages` / `message_boxes` 增 `media` JSONB 快照列:发送在事务内随 body/entities 一起写双端盒子,历史 `getHistory`/`getMessages`/dialog preview 读取时随消息一并解码,无需 join `documents`/`photos`。转发复制源消息 media(同一文档引用);文本编辑保留 media。
|
||||
- `tgMessage` 在 media 非空时 `SetMedia(MessageMediaPhoto/Document)`,客户端经 `upload.getFile` 从 blob backend 下载。Document id 在 domain/store 中保持 telesrv-owned 正数;外部 seed source id 在导入阶段归一,`InputDocument` / `inputDocumentFileLocation` 入站直接按服务端 id 解析,避免把第三方导出 id 当成本服资源身份。
|
||||
- 仅媒体消息(无 caption):放宽 `private_messages` body 非空 CHECK 为 `body<>'' OR media<>'{}'`。
|
||||
- 范围外:grouped_id 相册聚合(sendMultiMedia 当前各条独立成消息)、geo/contact/poll/todo/dice/story media 仍 `MEDIA_INVALID`。
|
||||
249
docs/performance-audit.md
Normal file
249
docs/performance-audit.md
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
# telesrv 性能审计报告
|
||||
|
||||
- **日期**:2026-06-02
|
||||
- **审计范围**:全部 140 个 Go 源文件(约 4.7 万行非测试代码)——连接层 `mtprotoedge`、`rpc`、业务 `app/*`、`store`(PostgreSQL / Redis / memory)、`queries/*.sql` 与 55 个迁移的索引。
|
||||
- **方法**:5 个并行深度审计(PG 私聊查询 / PG channel 查询 / 连接层 fan-out / RPC 私聊热路径 / channel RPC + Redis),再对全部 P0 逐条读码核实(见附录 A)。
|
||||
- **性质**:本文是当前阶段的**性能快照与债务维护清单**。RPC 实现进度仍以 [compatibility-matrix.md](compatibility-matrix.md) 为权威;性能项完成后需在本文记录状态、验证方式与剩余阻断。
|
||||
- **当前阶段**:已不再是第一阶段/纯私聊阶段;当前目标是 TDesktop 主路径功能闭环 + 性能/可靠性硬化,大规模群组/频道放开前必须先清掉 P0 阻断项。
|
||||
- **本轮落地状态(2026-06-02 / 2026-06-03)**:已修复 P0-1/P0-2/P0-4/P0-5、P1-b/P1-e/P1-i/P1-l、`users.getFullUser.common_chats_count` 反向规划 channel 分区、媒体 range read/元数据 LRU/sticker 小资源预热与 P2 update watermark/retention 的公共根因;P0-6 的 AES key schedule 属 MTProto per-message msg_key 成本,不做错误缓存优化,先保留 buffer/编码压测项;P1-a 受 `gotd/td` transport 抽象限制,禁止修改 `github.com/gotd/td`,需另做 telesrv-owned transport writer spike。
|
||||
- **严重度口径**:`P0` 当前即瓶颈 / 开闸即炸;`P1` 上量级会炸或高频路径浪费;`P2` 浪费但不致命;`P3` 轻微。
|
||||
- **规模假设**(用于判断后果):单机目标 20 万在线 TCP 长连接;私聊 ~200 msg/s × 双端写;在线推送 fan-out ~40 万 push/s;PostgreSQL(默认连接池 50)+ Redis。
|
||||
|
||||
---
|
||||
|
||||
## 1. 总体结论
|
||||
|
||||
**基础工程素养很好**——大表按 user / channel 做 HASH 64 分区、私聊热路径 keyset 游标分页(无大 OFFSET)、服务端 limit 钳制几乎覆盖所有 RPC、`convert` 层预分配 + 去重、send 路径 Redis 已合并单 Lua、outbox 已批量化、SessionManager 锁临界区已被实测确认极短。**没有发现「按客户端巨值无界分配」这类可被单请求 DoS 打爆的洞。**
|
||||
|
||||
真正的性能债高度集中,归为**两条主线**:
|
||||
|
||||
| 主线 | 何时致命 | 性质 |
|
||||
|---|---|---|
|
||||
| **A. channel / 群组 fan-out 乘积放大** | 放开群组规模后 | 一条群消息触发「全员写 + 全量排序 + 数千次单查」的乘积爆炸 |
|
||||
| **B. 连接层出站热路径 + 服务层缺批量** | 当前 TDesktop 主路径上量后放大 | 全局锁串行、每消息多次堆分配 / syscall、N+1 |
|
||||
|
||||
> 注:channel 代码虽已实现并通过功能联调,但**性能压测主要覆盖私聊**。主线 A 的几项在小规模群联调下不会暴露,需在放开大规模群组前先拆。
|
||||
|
||||
---
|
||||
|
||||
## 2. 核心洞察:一条群消息的乘积放大链
|
||||
|
||||
发一条 megagroup 消息,会同步触发**四段独立放大**(全部经代码核实,见附录 A):
|
||||
|
||||
```
|
||||
发送 1 条群消息(设 10 万成员,其中在线池 20 万里有该群成员若干)
|
||||
│
|
||||
├─① 存储层:upsertChannelDialogsForMessageTx internal/store/postgres/channel.go:7958
|
||||
│ 对【全体活跃成员】INSERT…ON CONFLICT,WHERE 无 LIMIT
|
||||
│ → 写 ~100,000 行 / 条消息,全在发送事务内,长持行锁 + 撑爆 WAL
|
||||
│
|
||||
├─② 选收件人:pushChannelUpdates → OnlineChannelMemberUserIDs
|
||||
│ 已从旧的全局在线 map 遍历改为 channel→online session 成员索引
|
||||
│ → 持久 channel updates 按当前在线 active 成员 best-effort 推送;typing/reaction 等瞬时事件走 viewer 索引
|
||||
│
|
||||
├─③ 推送构造:pushChannelUpdates 对【每个 recipient】调 build(viewer) internal/rpc/channels.go:3053
|
||||
│ 发送者 / 频道信息对所有 recipient 相同,却被重算 500 遍
|
||||
│
|
||||
└─④ 每次 build 内:tgUsersForIDs 逐个 Users.ByID internal/rpc/channels.go:3143
|
||||
而 Service.ByID 每次先查 Self 再查目标(×2) internal/app/users/service.go:50
|
||||
→ 500 viewer × ~5 引用 × 2 ≈ 5,000 次单行 PG 查询 / 条消息
|
||||
```
|
||||
|
||||
**旧链路合计:单条群消息 ≈ 10 万行写 + 一次 20 万项排序 + ~5,000 次单行查询。** 当前已拆掉全局在线排序和 users 公共 N+1,剩余风险集中在大群 dialog 写放大、收件人无关 update 模板化,以及 channel 读 RPC 的专用批量化。
|
||||
|
||||
---
|
||||
|
||||
## 3. P0 — 必修(当前即瓶颈 / 开闸即炸)
|
||||
|
||||
### P0-1 广播频道发消息对全员同步写 dialog(无上限写放大)
|
||||
- **位置**:`internal/store/postgres/channel.go:7958` `upsertChannelDialogsForMessageTx`,由 `sendChannelMessageOnce` 在每条消息事务内调用。
|
||||
- **问题**:`FROM channel_members WHERE channel_id=$1 AND status='active'` 无 LIMIT,每发一条消息写 / 更新 = 成员数 行;讨论组联动再来一遍。
|
||||
- **影响**:唯一随频道规模**线性放大且在同步关键路径**的写。百万成员频道单条 post 跑数秒、长持行锁、阻塞该频道后续所有写。
|
||||
- **建议**:广播频道(broadcast)不维护 per-member dialog,改 Telegram 式「单一 read state + 读时按 `top_message_id` vs `read_inbox_max_id` 懒算未读」(`ListChannelDialogs` 已有该回退逻辑,fan-out 行其实可删);megagroup 设成员阈值,超阈值改异步 / 分批 job。
|
||||
- **状态**:已加同步写闸门:broadcast 跳过全员 dialog 写;megagroup 仅 `ParticipantsCount <= 1000` 时同步写,未知/超阈值不做全员写。读取端已不再信任 broadcast/大超级群的 `channel_dialogs.unread_count` 缓存,改按 read watermark、`available_min_id`、channel top 与未删除消息动态派生普通未读。仍需大频道 PG `EXPLAIN` 与端到端压测。
|
||||
|
||||
### P0-2 `OnlineUserIDs` 每次 fan-out 全量遍历 + 排序 20 万在线
|
||||
- **位置**:`internal/mtprotoedge/session_manager.go:469`。
|
||||
- **问题**:截断发生在排序**之后**——为拿 500 个先把 20 万个全 append 全 `sort.Slice`,且全程持 `m.mu.RLock`。
|
||||
- **影响**:每次 channel 更新都触发 O(n log n) 排序 + ~200KB 分配,持读锁阻塞连接注册 / 注销,是连接层热锁上的持续 CPU / GC 压力。
|
||||
- **建议**:提供 O(1) 的 `IsOnline(userID)`,用频道成员(已 LIMIT 500)反向过滤,不导出全量在线集;若必须导出则「遍历到 limit 即停、不排序」。
|
||||
- **状态**:已加 `IsUserOnline` / `OnlineUserIDsForCandidates` / viewer 索引 `OnlineChannelUserIDs` / membership 索引 `OnlineChannelMemberUserIDs`。`updates.getState/getDifference` 会分页加载当前 session 已加入的 active channel;join/leave/invite/ban/delete 路径会运行态增删索引。持久 channel updates 走在线成员索引并经 PG active membership 复核,取消 500 cap;typing/reaction 等瞬时事件走 viewer-only scope;`messages.getDialogs` 不再标记 viewer。旧 `OnlineUserIDs` 保留兼容但已改为到 limit 即停、不排序。Redis/PG 缓存在线关系暂不引入,待多实例或压测数据证明需要再做。
|
||||
|
||||
### P0-3 channel fan-out 每收件人重建相同 user/chat + N+1 单查
|
||||
- **位置**:`internal/rpc/channels.go:3053` `pushChannelUpdates` → `internal/rpc/channels.go:3143` `tgUsersForIDs`。
|
||||
- **问题**:收件人无关的发送者 / 频道信息被每个 viewer 重算;每次 `tgUsersForIDs` 逐个 `Users.ByID`。
|
||||
- **建议**:把收件人无关的富集提到 fan-out 循环**外**做一次,循环内只算 viewer 差异(out/self,纯内存);配合 P0-4 的批量接口。
|
||||
- **状态**:已先把 `tgUsersForIDs` 改为批量 `Users.ByIDs`,消除该公共 N+1。收件人无关的 channel update 模板化仍待压测后继续拆。
|
||||
|
||||
### P0-4 `Users.Service.ByID` 每次额外查一次 `Self`(全局 ×2 放大)+ 服务层缺批量
|
||||
- **位置**:`internal/app/users/service.go:50`,被 RPC 层 **24 处** N+1 循环调用。
|
||||
- **问题**:每次解析一个 user = 2 次 PG 查询;且 `UsersService` / `MessagesService` / `ChannelsService` **根本没暴露批量 `ByIDs`**,存储层明明已有 `listUsersByIDs` / `listChannelsByIDs`(`= ANY`)却用不上。
|
||||
- **建议**:① 给三个 service 加批量 `ByIDs`,底层接已有的 `= ANY` 查询;② `ByID` 热路径不必每次重查 `Self`(`currentUserID` 已由 session 鉴权)。**这是 P0-3 与下面一批 P1 N+1 的公共根因。**
|
||||
- **状态**:已给 `UserStore` / `UsersService` 增加 `ByIDs`、`ByPhones`,`UsersService.ByID` 去掉重复 `Self` 查询;channel/user 富集公共函数已迁到批量 users。Messages/Channels 更细粒度批量接口仍在后续项。
|
||||
|
||||
### P0-5 `MessageIDGen` 进程级全局锁串行化所有连接的出站写
|
||||
- **位置**:`internal/mtprotoedge/outbound.go:432` → 共享单例 `internal/mtprotoedge/server.go:184`(创建)/ `server.go:202`(注入每个 Conn)。gotd `proto.MessageIDGen.New` 全程持 `g.mux`。
|
||||
- **问题**:全进程一个 `s.msgID`,20 万连接共享;每条出站消息(rpc_result / push / ack / pong)必过这把锁。40 万 push/s ⇒ ~50 万次/s 全局锁 acquire,多核下 cache-line 争用 + lock convoy,把本可并行的 N 个 outbound actor 强行串到一点。
|
||||
- **建议**:改 **per-Conn `MessageIDGen`**(msg_id 只需单连接内单调,无需跨连接全局唯一)。msg_id 已只在该连接 outbound actor 单 goroutine 内调用 → per-Conn 后退化为无争用锁甚至无锁 int64。**改动最小、收益最大。**
|
||||
- **状态**:已完成 per-Conn `MessageIDGen`,移除 server 级共享 generator。
|
||||
|
||||
### P0-6 出站写路径每条消息 4–6 次堆分配,gotd 零拷贝编码被旁路
|
||||
- **位置**:`internal/mtprotoedge/outbound.go:419-481`(`body.Copy()` + `var out bin.Buffer` + 回退到 `Encode` 再复制 + 每条 `aes.NewCipher` 重建 key schedule)。
|
||||
- **影响**:40 万 push/s × 5~7 次分配 ≈ **250 万 alloc/s**,是稳态主导 GC 压力;20 万连接广播同一 update 时同份 body 被各连接独立 encode + copy + encrypt。
|
||||
- **建议**:outbound actor 内复用 per-Conn encode / encrypt buffer(单 goroutine 天然安全,免锁);走 `Message`-based `EncodeWithoutCopy`;**广播 push 在 fan-out 前把 body 编码一次**,各连接只做 salt/msgID 包头 + 加密。
|
||||
- **状态**:未完全落地。AES `NewCipher` 不作为缓存目标:MTProto 2.0 每条消息由 msg_key 派生 AES key/iv,不能按 auth key 长期复用 cipher。后续只做 TL body 预编码、buffer 复用和实测分配优化。
|
||||
|
||||
---
|
||||
|
||||
## 4. P1 — 上量级会炸 / 高频路径浪费
|
||||
|
||||
### 连接层(TDesktop 主路径上量后相关)
|
||||
- **P1-a 每帧两次裸 `write` syscall,无 bufio** — `internal/mtprotoedge/outbound.go:473` → transport 直写裸 `net.Conn`,每条 ≈3 次 syscall(含 `SetWriteDeadline`)。40 万 push/s ⇒ ~120 万 write syscall/s。`gotd/td` transport 当前隐藏底层 writer,项目规则禁止改 `github.com/gotd/td`;已在 `Conn` 内加 telesrv-owned outbound writer 接口作为替换点,但真正 bufio/syscall 合并仍需 transport writer/codec spike 后再落地。
|
||||
- **P1-b fan-out 中 `c.Send` 同步阻塞,慢连接拖累整批** — `internal/mtprotoedge/session_manager.go:446` + `internal/mtprotoedge/outbound.go:117`:`Send` 同步等到写完 / 超时,队列满时阻塞 fan-out 协程最长到 ctx 超时(5s)。**状态:已新增 `Conn.SendBestEffort` 与 SessionManager best-effort fan-out,updates push 默认只等 `TELESRV_OUTBOUND_PUSH_TIMEOUT=200ms` 入队;RPC result/ack/pong 仍走可靠同步发送,outbox 入队失败不删 durable 任务。**
|
||||
- **P1-c 入站每帧分配新 buffer** — `internal/mtprotoedge/server.go:344`:连接级 `bin.Buffer` 复用被 codec `ResetN` 的 `make` 抵消,每帧新分配明文缓冲。建议入站缓冲走 `sync.Pool`。
|
||||
|
||||
### N+1(根因 = P0-4 服务层缺批量)
|
||||
- **P1-d `forwardSources` 转发逐条查源 + 富集 N+1** — `internal/rpc/messages.go:4155`:100 条转发 = 100 次取源 + ~100 次富集查询。
|
||||
- **P1-e `getMessages` 逐 ID 单查** — `internal/rpc/messages.go:2888`:高频 RPC,100 个 ID = 100 次 `Search(Limit:1)`。**状态:已新增 `MessagesService.GetMessages` / `MessageStore.GetByIDs`,PG 用 `unnest(ids) WITH ORDINALITY` 一次取 owner-visible box ids;RPC 保持缺失项返回 `MessageEmpty`。**
|
||||
- **P1-f `getDifference` 富集逐 ID 单查 + 跨事件重复查** — `internal/rpc/update_peer_refs.go:9`:登录风暴期高频,每事件重建 map + 同 user 在多事件被重复查。
|
||||
- **P1-g channel 读 RPC 普遍 N+1** — `getParticipants` / `getAdminLog` / `getHistory` 等 24 处逐个 `Users.ByID`(`internal/rpc/channels.go:3350` 等)。
|
||||
- **P1-h `enrichChannelHistory` 与存储层重复解析** — `internal/rpc/update_peer_refs.go:45`:存储层已批量填好 `Users` / `Channels`,RPC 层又逐个重查一遍后 merge 丢弃(纯浪费)。
|
||||
- **P1-i `importContacts` 逐条 `ByPhone` + `Upsert`** — `internal/app/contacts/service.go:84`:登录潮高发,500 条 = ~1000 次串行往返,单请求长期占一条 PG 连接。建议 `WHERE phone = ANY($1)` + `unnest` 批量 upsert(~1000 → ~2)。
|
||||
- **状态**:已改为批量 `ByPhones` + 批量 `UpsertMany`,同一目标用户去重写入,`Imported` 保留各 client_id。
|
||||
- **P1-j `ListForumTopics` 每话题 3 次 COUNT** — `internal/store/postgres/channel.go:5513` → `:6804`:单次最多 300 条额外 COUNT。建议改 `GROUP BY` 批量聚合(同文件 reactions / replies 已有该范式)。
|
||||
- **P1-k `ListChannelDialogs` 逐个回查 top message** — `internal/store/postgres/channel.go:3601`:主查询已 JOIN 到 top_msg 却只取了 date,又逐行 +100 单查;且过量取数 500 再内存排序切片。
|
||||
|
||||
### COUNT(*) 全量统计
|
||||
- **P1-l `ListMessagesByUser` 每页都 COUNT 全量** — `internal/store/postgres/queries/message.sql:380` + `internal/store/postgres/message.go:566`:每次翻页 / 搜索都把整个匹配集数一遍(trigram 只缩候选,count 仍大扫)。建议仅首页算 count、翻页省略(Telegram 客户端只首屏用总数)。
|
||||
- **状态**:已新增 `MessageFilter.NeedTotalCount`,SQL 默认不 COUNT;私聊搜索首屏按显式 flag 计算,其余 history/翻页默认返回 0 count。
|
||||
- **P1-m `ListChannelRecommendations` 全库 COUNT** — `internal/store/postgres/channel.go:3824`:对全系统公开广播频道计数,带 `NOT EXISTS` 反连接。建议返回近似值或不返回精确总数。
|
||||
- **P1-n `users.getFullUser.common_chats_count` 反向规划 channel 分区** — ✅ **已修(2026-06-03)**:TDesktop 打开私聊会触发 `users.getFullUser`,旧实现为填 `common_chats_count` 调 `ListCommonChannels(limit=1)`,同时跑 COUNT + list,并从 `user_id` 入口反查按 `channel_id` hash 分区的 `channel_members/channels`,实测 Alice/Bob `EXPLAIN` planning 约 70ms、执行约 1.6ms,UI 表现为点开 Bob B 卡一下。现新增 `user_channel_member_index(user_id, channel_id)`,由 member upsert/leave/delete channel 事务同步;`users.getFullUser` 走 `CountOnly`,只在该 user 维度索引上 count。实测同一 count query planning 0.873ms、execution 0.079ms,Alice 打开 Bob B 日志中 `users.getFullUser` handler 3.26ms、端到端 9.49ms。
|
||||
|
||||
### PG 分区访问维度审计(2026-06-03)
|
||||
|
||||
本轮逐个核对 20 张 HASH 分区表,重点检查「查询入口维度」是否等于分区键。结论:`dialogs/dialog_drafts/dialog_filters/dialog_filter_settings/user_update_events/message_boxes` 的 owner/user 主路径基本都带 user 分区键;`channel_messages/channel_update_events/channel_forum_topics/channel_invites/channel_invite_importers/channel_admin_log_events/channel_message_viewers/channel_message_reactions` 的 channel 主路径基本都带 channel 分区键。剩余风险集中在需要反向访问的 read model / 队列:
|
||||
|
||||
| 级别 | 问题 | 实测计划 | 修复方向 |
|
||||
|---|---|---:|---|
|
||||
| P1 | `ListChannelDialogs` / `ListInactiveChannels` / `ListLeftChannels` / `ListDiscussionGroups` / `ListAdminedPublicChannels` / `ListActiveChannelIDsForUser` 从 `user_id` 入口读 `channel_members`,但表按 `channel_id` 分区 | `ListChannelDialogs` planning 118ms,`ListInactiveChannels` 114ms,`ListActiveChannelIDsForUser` 26ms,均展开 64 个 `channel_members` 分区;带 `channels/channel_messages` join 时最多展开 192 个分区 | 把 `user_channel_member_index` 升级为正式 user 维度 membership read model,或两步取 bounded channel_id 后用 `channel_id/id = ANY($1)` 访问 channel 分区;禁止 SQL 内直接 `index -> channel_members/channels` 动态 join,实测仍会展开 64 分区 |
|
||||
| P1 | `message_boxes` 的编辑/reaction 可见 box 查询与 revoke 删除按 `(message_sender_id, private_message_id)` 反查,表按 `owner_user_id` 分区 | `ListVisibleMessageBoxesByPrivateMessage` planning 50ms、`DeleteMessageBoxesByPrivateMessages` 等价查询 48ms,展开 64 个 `message_boxes` 分区 | 增加 unpartitioned `private_message_box_index(message_sender_id, private_message_id, owner_user_id, box_id)`,或从 `private_messages(sender_user_id,id)` 得到 sender/recipient 后按 owner 分区两点查询 |
|
||||
| P1 | `ListUserUpdateEventsAfter` / `BatchListDispatchEvents` 在账号级 update 查询里直接 left join `channels fwd_ch/reply_ch`,`channels` 按 id 分区但 join key 来自消息行 | planning 37ms,展开 128 个 `channels` 分区;`user_update_events/message_boxes` 本身已正确裁剪到 1 个 user 分区 | update 查询只返回 channel ref id,Go 层收集后批量 `listChannelsByIDs(id = ANY($1))` 富集,复用 channel difference 的做法 |
|
||||
| P1 | `dispatch_outbox` worker 全局 claim/failed cleanup 按 `status/next_attempt_at/updated_at` 取任务,表按 `target_user_id` 分区 | claim planning 28ms,展开 128 个 `dispatch_outbox` 分区;这是队列热路径,不是用户 RPC 单次路径 | 引入 unpartitioned ready queue / claim index table,或改为固定 worker shard 维度分区;按 target_user_id 分区适合投递/删除,不适合全局 claim |
|
||||
| P2 | `ResolvePublicChannelUsername` 从 `channel_usernames` join `channels`,动态 channel id 让 `channels` 64 分区全展开 | planning 37ms,展开 64 个 `channels` 分区 | 先查 `channel_usernames` 得到 channel_id,再 `getChannelByID` 单分区读取 |
|
||||
| P2 | 删除频道消息时 `deleteChannelUnreadMentionsTx` 按 `channel_id + message_id` 删除 `channel_unread_mentions`,但该表按 `user_id` 分区;随后按 affected user 更新 `channel_dialogs` 也会动态展开 user 分区 | mentions delete planning 16ms,affected dialog update planning 24ms,分别展开 64 个 `channel_unread_mentions` / `channel_dialogs` 分区 | 删除前从 message mention read model 取 bounded affected user_id,或增加 `(channel_id,message_id,user_id)` 辅助索引表;更新 dialog 按 user_id 分批两点写 |
|
||||
|
||||
验证补充:
|
||||
|
||||
- `channels WHERE id = ANY($1)`、`channel_members WHERE channel_id = ANY($1)` 在参数/常量数组已知时能裁剪到对应分区;但 `WITH ids AS (...) SELECT ... WHERE id = ANY(ARRAY(SELECT ...))` 这种 SQL 内动态数组不会裁剪,planning 仍约 33ms/64 分区。
|
||||
- `user_channel_member_index` 单表 user 维度查询 planning 0.7ms;但 `user_channel_member_index -> channel_members/channels` 一条 SQL 动态 join planning 71ms,仍不是可接受修法。
|
||||
- `private_messages` 目前仅按 `sender_user_id` 幂等/编辑主体查询,未发现 recipient-only 热查询;`recipient_user_id` 索引存在但不会改变 sender 分区裁剪限制。
|
||||
|
||||
### 媒体管线(2026-06-03 媒体闭环审计新增,放开大文件 / 大群前评估)
|
||||
- **P1-媒体-a `upload.getFile` 整文件入内存 + 每 chunk 查 PG / sticker 小资源首开冷路径** — ✅ **已修(2026-06-03)**:原 `blobs.Get` 一次 `os.ReadFile` 读整个 blob 再切片,客户端按 ≤512KB/1MB 分块多次请求 ⇒ 同一大文件被重复整读 N 次(O(N²))+ 每 chunk 一次 `GetFileBlob` PG 往返;sticker/reaction/thumb 虽小,但 TDesktop 重启或打开历史首次渲染时仍可能在 `messages.getStickerSet` + `upload.getFile` + 本地文件冷读上形成可感知卡顿。现 `BlobBackend.GetRange`/`LocalFS`(`internal/app/files/blobfs.go`)用 `ReadAt` 只读 offset+limit 段(`n` 受文件大小约束,超大 limit 不会按客户端值分配内存);并加 `location_key→FileBlob` 进程内 LRU(容量 65536,元数据不可变故只读填充无需失效)消除每 chunk 的 PG 查;再加 `object_key→bytes` 小 blob LRU(单项 ≤256KB,总 64MB)、完整 sticker set cache 与启动 `WarmCaches`,从已 seed 元数据预热 sticker/reaction document 和可下载缩略图。参考实现 用 MinIO ranged read(`object.ReadAt`)+ SSDB 两级,但其下载命中冷存储不回填、无 LRU/阈值、photo/头像直连 MinIO;telesrv 当前先做进程内元数据/小字节 LRU + 段读,**多实例共享缓存(Redis)仍待二阶段换对象存储时评估**。实测启动预热 `49 sets / 2313 docs / 2298 blobs`,TDesktop `messages.getStickerSet` 从约 18-24ms 降至 0-1.7ms;seed document id 归一后,首次新服务端 id 会触发必要的 `upload.getFile` 主体/缩略图请求,但 server 端大多 0.5-6ms、样例最长 59ms,重复打开 Bob 会话 0.58s 截图已可见 sticker。
|
||||
|
||||
- **P1-媒体-a.1 system sticker set 污染 installed stickers 本地索引** — ✅ **已修(2026-06-03)**:TDesktop 贴纸正文缓存 key 为 `dc_id + document_id`,但 installed stickers 本地索引写入依赖 set flags;`installed_date` 会使 set 进入 Installed,普通 stickers 类型的 `Installed + NotLoaded` set 会让 `writeInstalledStickers()` 中止。此前 seed 把 animated emoji/dice/generic animations 这类 system set 也标成 installed,可能导致重启后 installed sticker 索引反复失效与 set 元数据重拉。现 migration `0066_system_sticker_sets_not_installed` 修复既有库,seed 后续也不再把 `set_kind=system` 声明为 installed。
|
||||
- **P1-媒体-a.2 sticker document 同时暴露 `PhotoPathSize` 与 raster thumb,历史页静态图被占位短路** — ✅ **已修(2026-06-03)**:TDesktop `history_view_sticker.cpp` 在创建 sticker media view 时若 `thumbnailPath()` 非空就不调用 `thumbnailWanted()`,而 `PhotoPathSize` 只是 vector placeholder;此前 seed 给同一 document 同时返回 path + raster thumb,导致历史页持续画 path/等完整 TGS 解码,用户反复重启 Debug 仍看不到静态图。现 document 有 default/cached/progressive raster thumb 时过滤 `PhotoPathSize`,thumb blob MIME 按魔数写入;domain 中可用 cached bytes 做修复依据,但 RPC 对 document 统一暴露 downloadable `photoSize m`,避免 `PhotoCachedSize` 与旧本地 cache 叠加出不可替换状态。启动 repair 后 PG 中 document thumbs kind 仅剩 `cached=2313`,document thumb `file_blobs.mime_type` 全部为 `image/webp`。
|
||||
- **P1-媒体-a.3 复用导出 document id 命中 TDesktop 旧 `DocumentData` 缓存** — ✅ **已修(2026-06-03)**:TDesktop `Data::Session::document(id)` 按 `document_id` 单例化,`DocumentData::updateThumbnails()` 不会清空旧 inline/path thumbnail;因此 server 修掉坏 thumb 字段后,旧 Debug tdata 仍可能用同 id 的污染对象,打开历史先空白再等完整 TGS。根因是 seed 曾把外部导出 document id 直接作为本服资源主键。现 `internal/app/files` 在导入阶段把 source id 归一为 telesrv-owned storage id,RPC/download/custom emoji/channel appearance 全部直接使用该服务端 id;migration `0067_seed_document_id_namespace` 修复既有库的 documents/file_blobs/sticker_sets/available_reactions/message media/channel appearance 引用。这样不需要改官方客户端,也不在 RPC 层保留客户端特判。复测:migration 后 PG 相关引用均无 `>4e18` 外部 document id;双 TDesktop 重启后打开 Bob B/Alice A 首屏 250ms 已显示 sticker,`messages.getHistory` 约 6-12ms,`upload.getFile` 命中新 id 成功,server/client 日志无 location/hash/API 错误。
|
||||
- **P1-媒体-b `upload_parts` 无 GC / 无每用户配额** — `internal/app/files/service.go:30` + `deploy/migrations/0057_media.up.sql`:分片直接进 PG `bytea`,仅 `assembleUpload` 成功才删;**未 assemble 的分片永久滞留**,且不同 `file_id` 无上限 ⇒ 任意登录用户可用海量 fileID 各传几片撑爆 PG(容量 DoS)。建议每用户 in-flight 上传字节/分片配额 + 后台按 `created_at` 过期清理 worker。
|
||||
- **P1-媒体-c `media` JSONB 内联放大 fan-out 写** — `deploy/migrations/0057_media.up.sql:129`:`media` 快照内联在 `message_boxes`(owner 双份)/`channel_messages`,含 stripped thumb/attributes 使单行变大;叠加主线 A 的 channel 全员写扇出时,大群每条媒体消息按成员数复制整个 media JSONB。建议随主线 A 改懒算/单副本时一并评估 media 是否只存引用、下沉 `documents`/`photos`。
|
||||
- **P2-媒体-d `photos.getUserPhotos` N+1 + OFFSET** — `internal/app/files/photos.go:165` 逐个 `GetPhoto` + `internal/store/postgres/queries/media.sql:314` OFFSET 分页。头像数少、`limit<=100`,影响有限;建议批量 `GetPhotos(ids)` + keyset 分页。
|
||||
- **P2-媒体-e sticker set cover 元数据声明可下载但 seed 无 raster blob** — ✅ **已修(2026-06-03)**:TDesktop 会把 `StickerSet.thumbs` 中的 downloadable `PhotoSize` 变成 `inputStickerSetThumb` 下载;`TELESRV_STICKER_SEED_DIR` 当前 set_cover 只有 `PhotoPathSize` SVG(40 个 only-svg、4 个 empty),没有可服务的 jpg/png/webp。现 seed 与 `tgStickerSet` 转换均过滤 sticker set cover 的 downloadable thumb,只保留非下载占位;实际 sticker/reaction document 缩略图仍按 `inputDocumentFileLocation` 服务。
|
||||
|
||||
---
|
||||
|
||||
## 5. P2 / P3 — 浪费但不致命 / 轻微
|
||||
|
||||
| 级别 | 问题 | 位置 |
|
||||
|---|---|---|
|
||||
| P2 | `getDialogs` 全量拉内存排序 + 无条件拉 1000 条草稿(最高频 RPC 之一,应下推 SQL `ORDER BY LIMIT` + 按页 peer 取草稿) | `internal/app/dialogs/service.go:31` |
|
||||
| P2 | `MaxContiguousPts` 每次 getState 读 4096 行(应 O(1) 持久化单列 / Redis) | ✅ 已新增 `user_update_watermarks`,`MaxContiguousPts` O(1) 读水位;缺行时一次性补算并 upsert。`Current()` 保留最大已提交 pts 语义,供 Redis allocator 恢复最大分配点,避免 gap 时回退 |
|
||||
| P2 | **`user_update_events` / dispatch/outbox delivered rows 无保留期清理**,按长期在线量永久膨胀;`message_boxes` 是用户历史,不得在没有产品保留策略时通用 TTL 删除 | ✅ 已确认账号级 `user_update_events` 不能通用 TTL 裁剪,需永久保留以支持 TDesktop `differenceSlice` 续传;retention worker 仅清理 failed/过期 outbox,delivered outbox 已投递即删,不删除 `message_boxes` |
|
||||
| P2 | `advanceChannelReadOutboxTx` 最多 128 次串行已读写(应单条集合写) | `internal/store/postgres/channel.go:5952` |
|
||||
| P2 | `InviteToChannel` 200 人逐人循环 ~800 往返(应 `unnest` 批量) | `internal/store/postgres/channel.go:426` |
|
||||
| P2 | `GetParticipants` OFFSET 分页 + `CASE role` 排序必触发 sort | `internal/store/postgres/channel.go:355` |
|
||||
| P2 | `ReadHistory` / `EditMessage` / `DeleteMessages` 在 PG 事务内做 Redis pts 分配(send 已优化,这三条没跟上) | `internal/store/postgres/message.go:650` |
|
||||
| P2 | `ReadHistory` / `RefreshDialog` 用 `COUNT(*)` 重算未读(应纯增量维护) | `internal/store/postgres/queries/message.sql:651` |
|
||||
| P2 | `ListChannelDifference` 逐事件回查消息 | `internal/store/postgres/channel.go:6115` |
|
||||
| P2 | `ListInactiveChannels` / `ListLeftChannels` 计算表达式排序 / OFFSET 分页(低频) | `channel.go:3756` / `:3729` |
|
||||
| P2 | 私聊发送富集仍单查 self / 对端(最热路径,可缓存 self + 透传已查的对端) | `internal/rpc/messages.go:3911` |
|
||||
| P2 | memory store 多处全 map O(n) 扫描(**仅测试 / 本地用,不影响生产**) | `internal/store/memory/memory.go:1287` 等 |
|
||||
| P3 | SessionManager 注册表分片(仅重连风暴尾延迟,实测非稳态瓶颈,与既有结论一致) | `internal/mtprotoedge/session_manager.go` |
|
||||
| P3 | `validateSeq` 每消息 O(400) map 扫描、热路径 hex 日志求值、`enrichUpdateEvents` 整片复制、`pushToUser` 每次切片快照 | 多处 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 已确认健康(避免误报,证明审计严谨性)
|
||||
|
||||
- **send 路径 Redis 已是单 Lua**(`user_counter_allocator` 把 pts + box 合一,hash-tag 同 slot)——既定优化已落地,热路径仅 1 次 RTT。
|
||||
- **私聊核心已达标**:双写 / dialog upsert / event / outbox 单事务、`ON CONFLICT(sender, random_id)` 幂等、keyset 分页无大 OFFSET、批量转发 / 删除已 `unnest`、outbox `FOR UPDATE SKIP LOCKED` + 多 worker + 投递即删、相关索引齐全。
|
||||
- **channel 存储核心决策正确**:消息单副本存储(不给每成员写一行)、keyset 分页、服务端 limit 钳制、trigram 搜索索引、按 channel_id 分区;在线 fan-out 先用内存索引缩小到当前在线成员/active viewers,再用 `= ANY` 分批复核 active membership。
|
||||
- **`convert.go` 预分配 + 去重到位,RPC 入参钳制完整**——无「按客户端值无界分配」DoS。
|
||||
- **Redis 健康**:无大 key、无 `KEYS` / `SCAN` 全库扫、无 `SMEMBERS / LRANGE 0 -1`,RateLimiter 正常放行 1 RTT。
|
||||
- **SessionManager 锁**:push 在锁外发送,临界区极短——既有 benchmark 结论仍成立(注意:这与 P0-2 的 `OnlineUserIDs` 全量排序是两回事,后者是新发现)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 修复优先级路线图
|
||||
|
||||
**第一梯队 — 当前主路径就该做(与群组规模无关,直接影响 20 万连接稳态)**
|
||||
1. **P0-5 `MessageIDGen` 改 per-Conn** — ✅ 已完成。
|
||||
2. **P0-6 + P1-a 出站去分配 + bufio 合并 syscall** — 直击 250 万 alloc/s + 120 万 syscall/s。
|
||||
3. **P0-4 服务层加批量 `ByIDs` + 去掉 `ByID` 重复 `Self`** — ✅ 已完成 users 公共根因;messages/channels 专用批量接口继续推进。
|
||||
4. **P1-i `importContacts` 批量化** — ✅ 已完成。
|
||||
5. **P1-l `getHistory` 翻页省略 COUNT** — ✅ 已完成 `NeedTotalCount` 默认 false。
|
||||
6. **P2 `MaxContiguousPts` O(1) 化 + outbox 死任务清理** — ✅ 已完成 watermark;账号级 `user_update_events` 永久保留,retention worker 只清理 failed outbox。
|
||||
|
||||
**第二梯队 — 放开大规模群组 / 频道前必须拆雷(否则开闸即炸)**
|
||||
7. **P0-1 广播频道去全员写扇出**(改懒算未读)— ✅ 已加同步写闸门,读取端已改为大群动态未读;仍需压测。
|
||||
8. **P0-2 `OnlineUserIDs` 去全量排序**(改 `IsOnline` / 频道 viewer+member 在线索引)— ✅ 已完成主 fan-out 路径;Redis 跨实例在线关系暂缓。
|
||||
9. **P0-3 + P1-g/h channel fan-out 与读 RPC 批量化、富集提到循环外、去重复解析。** — 部分完成:users 富集已批量化,模板化与 messages/channels 专用批量仍待做。
|
||||
10. P1-j/k、P2 channel 各项。
|
||||
|
||||
**第三梯队 — 稳健性 / 尾延迟**
|
||||
11. P1-b fan-out 满即丢 — ✅ 已完成 best-effort updates push;P3 注册表分片待真有重连风暴尾延迟再做。
|
||||
|
||||
---
|
||||
|
||||
## 附录 A:关键 P0 复核记录
|
||||
|
||||
下列 P0 在出报告前已逐条读码核实属实(非纯 agent 转述):
|
||||
|
||||
| 发现 | 核实点 | 结论 |
|
||||
|---|---|---|
|
||||
| P0-1 | `channel.go:7958` 的 SQL `WHERE m.channel_id=$1 AND m.status='active'` 确无 LIMIT,在发送事务内 | ✅ 属实 |
|
||||
| P0-2 | `session_manager.go:469-487`:`make(…, len(m.byUser))` → `range` 全 map → `sort.Slice` → `ids[:limit]`(排序在截断前) | ✅ 属实 |
|
||||
| P0-3 | `channels.go:3053-3082` `for _, userID := range recipients { build(userID) }`;`:3143` `tgUsersForIDs` 循环内 `Users.ByID` | ✅ 属实 |
|
||||
| P0-4 | `users/service.go:49-58` `ByID` 先 `s.Self(ctx, currentUserID)` 再 `s.users.ByID`(每次 ×2) | ✅ 属实 |
|
||||
| P0-5 | `server.go:147` 字段、`:184` 建一个、`:202` 注入每个 Conn 共享;`outbound.go:432` 每条出站调 `c.msgID.New` | ✅ 属实 |
|
||||
|
||||
## 附录 B:复跑与验证
|
||||
|
||||
- **连接层**:`internal/mtprotoedge` 的 `BenchmarkSessionManager*` + `-mutexprofile`(既有 harness)。已新增 `BenchmarkSessionManagerOnlineCandidateFilter`,用于 20 万在线下候选过滤路径复跑。
|
||||
- **私聊 / 消息**:`internal/loadtest` + 本机 docker PG/Redis(env-gated `TELESRV_TEST_POSTGRES_DSN` + `TELESRV_TEST_REDIS_ADDR`,未设则 Skip)。
|
||||
- **PG 查询计划**:对 P1-l / P1-m / P0-1 用 `EXPLAIN (ANALYZE, BUFFERS)` 在有量级数据的分区上验证扫描行数与是否走索引。
|
||||
- **channel fan-out**:在放开大规模群组前,构造「大成员群 + 高在线占比」场景压测主线 A 的端到端放大。
|
||||
- **本轮回归**:`go test ./...` 已通过(2026-06-02)。2026-06-03 media/avatar 接手回归补充:`go test ./...`(PG/Redis env-gated)通过,`TestSeedMediaFromRealExport -count=1` 确认 sticker seed 74 reactions / 11 sets / 1355 docs / 2682 blobs,migration 状态 `59|f`;本轮新增头像编辑页 `messages.getEmojiProfilePhotoGroups` empty stub 属 TDesktop 兼容面补齐,不改变性能债优先级。双 TDesktop 群/频道头像 UI 回归仍待 Windows Computer Use 恢复后补实测证据。
|
||||
|
||||
---
|
||||
|
||||
## 附录 C:复核与落地轮(2026-06-02,第二轮)
|
||||
|
||||
对第一轮已落地的优化做正确性复核,并把剩余 P0/P1 改到位 + 加测试验证。
|
||||
|
||||
### 协议关键纠正:账号级 `differenceTooLong` 不可用(§2.5 参考审计救场)
|
||||
原计划给账号级 `getDifference` 补 `differenceTooLong` 让落后客户端整库重置。审计 TDesktop 基线源码发现 **`api_updates.cpp:516` 对账号级 differenceTooLong 只打一行日志、不读 pts、且漏 `setRequesting(false)`**——收到后 `_ptsWaiter.requesting()` 永真,之后所有 `getDifference()` 在 `:689` 早退,**永久锁死整个 update 引擎,重连/新 session 都救不回**。参考实现 也都故意不发账号级 too-long。
|
||||
→ 结论:账号级落后只能 `differenceSlice` 分批续传;**绝不能裁剪 `user_update_events` 到客户端够不着**——这直接决定了 P0 的修法(不删事件,而非加 too-long)。
|
||||
|
||||
### 本轮落地与验证
|
||||
- **P0(retention)✅**:retention worker 改为**不再删 `user_update_events`**(pts log 永久保留,对齐参考实现),仅保留 outbox failed 清理。根除「裁剪事件→落后客户端静默丢消息」。
|
||||
- **P1(watermark 死锁)✅**:先识破「预锁 watermark」会引入 watermark↔dialog 跨类型死锁,且 send 双向并发本就有 **dialog 行 pre-existing AB-BA**。终选**事务级 advisory lock 按 user_id 升序串行化同一对用户的写**(`lockUsersForUpdate`,独立锁空间、不与行锁交叉),一举消除 watermark+dialog+box 所有 AB-BA;覆盖 send/read/edit/delete/deleteHistory,delete 另配 dialog rebuild 升序。**强验证**:新增 `TestMessageStoreBidirectionalConcurrencyNoDeadlock`,临时禁用 advisory 时 116/320 操作 `deadlock detected (40P01)`(死锁点 `upsert dialog`),启用后 0 失败。
|
||||
- **P1(tgUsersForIDs)✅**:批量 `ByIDs` 失败不再静默 `return nil`,记 Warn 日志(批量无部分结果,不降级逐个以免 DB 抖动放大)。
|
||||
- **P1(outbox 拥塞)✅**:`pushOutboxUpdate` 区分 `retriable`——best-effort 队列拥塞保留 dispatching 行靠租约(30s)重投、**不计入 attempts 升级**;新增 `TestOutboxDispatcherDefersOnPushQueueFull`。
|
||||
|
||||
验证:`go build/vet ./...` 全绿;单测 16 ok / 0 FAIL;PG 集成**单独/分批**全绿(watermark / retention / 并发 / 双向死锁 / outbox 拥塞 / send-read-edit-delete 往返)。
|
||||
|
||||
### 发现:一批 pre-existing 集成测试失败(第一轮优化遗留,非本轮引入)
|
||||
全量跑 `internal/store/postgres` 集成套件**整体不稳定**(stash 掉本轮改动的基线也大量失败,含无关的 `TestAuthKeyStoreRoundTrip`——根因是每测试建独立连接池不释放,全量连接耗尽)。逐个单独跑时,下列测试仍失败,**根因是第一轮优化改了行为但旧断言未同步**,已逐条核对非本轮 4 项修复引入:
|
||||
- `TestMessageStoreDeleteHistoryBatchesHugeMaxID`:断言 `history.Count==2`,但 P1-l(`NeedTotalCount` 默认 false)使 history 返回 `Count=0`(删除断言本身通过)。
|
||||
- `TestChannelStoreSendMessageFansOutDialogRows` / `...ReadOutboxDoesNotRegressSenderDialogUnread` / `...SendFailureRecordsNoopPtsGap`:channel fan-out / read outbox 行为随 P0-1 等 channel 优化变化,旧断言未更新(`channel.go` 本轮未改)。
|
||||
→ **待办**:更新这些测试断言对齐新行为(count 语义 / channel fan-out),或确认行为符合预期;并考虑给集成测试套件改共享连接池以支持全量运行。属第一轮优化的测试债,独立于本轮性能修复。
|
||||
174
docs/persistence-layer.md
Normal file
174
docs/persistence-layer.md
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
# telesrv 持久化层设计
|
||||
|
||||
> 第一阶段(协议 + 登录 + 空账号主界面)的存储地基。后端已定:**PostgreSQL + Redis**,
|
||||
> 依赖由 [`deploy/docker-compose.yml`](../deploy/docker-compose.yml) 启动;对象存储(MinIO)留第二阶段文件域。
|
||||
> 决策见 README.md。
|
||||
|
||||
## 0. 定位与第一价值
|
||||
|
||||
把登录链路所需状态可靠落地,使 **server 可反复重启而不丢握手态 / 登录态**——这是真机联调能否愉快迭代的前提:
|
||||
|
||||
- **auth_key 丢** → TDesktop 缓存的 key 在 server 端查不到 → 客户端加密包解不开 → 触发重建密钥甚至重登。
|
||||
- **登录态丢** → 每次重启都要重走 `sendCode` / `signIn`。
|
||||
|
||||
所以持久化第一价值是「联调可重启」,不是上规模。
|
||||
|
||||
## 1. 后端职责划分
|
||||
|
||||
| 维度 | PostgreSQL(权威、强一致、可查询) | Redis(高频、易失、TTL、原子计数) |
|
||||
|---|---|---|
|
||||
| 第一阶段 | `auth_keys`、`users`、`authorizations`、`account_passwords`、`temp_auth_key_bindings`、`app_configs`、`countries`、`country_codes`、`update_states`、`contacts`、`lang_packs`、`lang_pack_strings` | 验证码 `phone_code_hash → code`(短 TTL)、session、登录尝试限流 |
|
||||
| 第二阶段 | `private_messages`、`message_boxes`、`dialogs`、`user_update_events`、`dispatch_outbox` | `pts` / owner `box_id` 原子自增(Redis miss 时从 PG durable log 恢复)、在线态缓存 |
|
||||
| 不放这里 | 大对象/媒体(→ 二阶段 MinIO) | 任何需要持久强一致的业务事实 |
|
||||
|
||||
原则:**PG 存「事实」,Redis 存「态与计数」**。Redis 丢了能从 PG/重新计算恢复;PG 丢了就是数据丢失。
|
||||
|
||||
## 2. 第一阶段 schema(PostgreSQL)
|
||||
|
||||
DDL 见 [`deploy/migrations/0001_init.up.sql`](../deploy/migrations/0001_init.up.sql)、
|
||||
[`deploy/migrations/0002_phase1_business.up.sql`](../deploy/migrations/0002_phase1_business.up.sql) 与
|
||||
[`deploy/migrations/0003_startup_config_security.up.sql`](../deploy/migrations/0003_startup_config_security.up.sql)、
|
||||
[`deploy/migrations/0004_temp_auth_key_binding_session.up.sql`](../deploy/migrations/0004_temp_auth_key_binding_session.up.sql) 与
|
||||
[`deploy/migrations/0005_system_login_messages.up.sql`](../deploy/migrations/0005_system_login_messages.up.sql)、
|
||||
[`deploy/migrations/0006_update_events.up.sql`](../deploy/migrations/0006_update_events.up.sql)、
|
||||
[`deploy/migrations/0007_read_history_events.up.sql`](../deploy/migrations/0007_read_history_events.up.sql)、
|
||||
[`deploy/migrations/0008_user_id_sequence_base.up.sql`](../deploy/migrations/0008_user_id_sequence_base.up.sql) 与
|
||||
[`deploy/migrations/0009_private_message_pipeline.up.sql`](../deploy/migrations/0009_private_message_pipeline.up.sql)、
|
||||
[`deploy/migrations/0010_message_performance_indexes.up.sql`](../deploy/migrations/0010_message_performance_indexes.up.sql) 与
|
||||
[`deploy/migrations/0011_drop_dead_tables.up.sql`](../deploy/migrations/0011_drop_dead_tables.up.sql)、
|
||||
[`deploy/migrations/0012_outbox_delete_on_deliver.up.sql`](../deploy/migrations/0012_outbox_delete_on_deliver.up.sql) 与
|
||||
[`deploy/migrations/0013_contact_profiles_and_dialog_pins.up.sql`](../deploy/migrations/0013_contact_profiles_and_dialog_pins.up.sql)、
|
||||
[`deploy/migrations/0014_settings_update_events.up.sql`](../deploy/migrations/0014_settings_update_events.up.sql)、
|
||||
[`deploy/migrations/0015_update_event_payloads_and_outbox_auth.up.sql`](../deploy/migrations/0015_update_event_payloads_and_outbox_auth.up.sql)、
|
||||
[`deploy/migrations/0016_delete_message_updates.up.sql`](../deploy/migrations/0016_delete_message_updates.up.sql)、
|
||||
[`deploy/migrations/0017_dialog_folders.up.sql`](../deploy/migrations/0017_dialog_folders.up.sql)、
|
||||
[`deploy/migrations/0018_user_search_indexes.up.sql`](../deploy/migrations/0018_user_search_indexes.up.sql)、
|
||||
[`deploy/migrations/0019_usernames.up.sql`](../deploy/migrations/0019_usernames.up.sql) 与
|
||||
[`deploy/migrations/0020_profile_message_state.up.sql`](../deploy/migrations/0020_profile_message_state.up.sql)。
|
||||
|
||||
> `0011` 清除了三张已被取代、运行时零引用的死表:`update_events`(一阶段 auth_key 级 update 队列,被 `user_update_events` 取代)、`messages_legacy` 与 `dialogs_legacy`(`0009` 重命名保留的迁移残骸,数据已迁入 `private_messages` / `message_boxes` / 新 `dialogs`)。`0006` / `0007` 作为历史演进记录保留,但其建立的 `update_events` 已不再使用。
|
||||
|
||||
第一批表:
|
||||
|
||||
- **`auth_keys`** —— 密钥交换产物。`auth_key_id`(BIGINT,SHA1 低 64 位小端 int64) + `body`(256B BYTEA) + `server_salt`。
|
||||
- **`users`** —— 登录链路必须字段:`id` / `access_hash` / `phone`(UNIQUE) / `first_name` / `last_name` / `username` / `about` / `country_code` / `verified` / `support`。`access_hash` 为任何 `InputUser` 校验所必须,不可省;普通注册用户 ID 从 `1780243200`(2026-06-01 00:00:00 Asia/Shanghai 的 Unix 秒级时间戳)起递增,内置 777000 官方系统账号显式保留在低位区间。注册页只写手机号与姓名,后续 Settings/Profile 通过 `account.updateProfile` 更新姓名与 bio。
|
||||
- **`authorizations`** —— `auth_key ↔ user` 绑定 + 设备信息(`layer` / `device_model` / `app_version` / `api_id` …)。PK 为 `auth_key_id`(一个 auth_key 一条授权),外键挂 `auth_keys` 与 `users`。
|
||||
- **`account_passwords`** —— 账号 2FA/SRP 配置。第一阶段默认 `has_password=false`,但 `account.getPassword` 已走持久化查询。
|
||||
- **`temp_auth_key_bindings`** —— `auth.bindTempAuthKey` 的 temp→perm 绑定记录。写入前校验 encrypted `bind_auth_key_inner`,持久化 `temp_session_id`,后续 temp auth_key RPC 在 router 入口解析为 perm auth_key 身份,并缓存在 active session 上。
|
||||
- **`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 中。
|
||||
- **`contacts`** —— 当前账号通讯录关系与 owner 视角联系人资料。`contact_phone/contact_first_name/contact_last_name/note/note_entities` 均只属于 `(user_id, contact_user_id)`,同一个全局 user 在不同 owner 的通讯录中可以有不同姓名、电话和备注;`mutual` 由双方是否互存维护,删除一方联系人会清理对方 reverse mutual。
|
||||
- **`private_messages`** —— 共享私聊消息主体,按 `sender_user_id` HASH 分区;`sender_user_id + random_id` 唯一保证 `messages.sendMessage/forwardMessages` 幂等;文本编辑更新共享 body/entities/edit_date;silent/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` 分区。
|
||||
- **`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。
|
||||
- **`lang_packs` / `lang_pack_strings`** —— TDesktop 语言包元信息与字符串。开发 seed 来自 导出的 `.strings` 数据文件。
|
||||
|
||||
搜索相关索引:`0018` 启用 `pg_trgm`,为 `users.phone` 前缀、`users.username`/姓名、`contacts` owner 保存姓名和 `message_boxes.body` 建索引;`0049` 为公开 username channel/supergroup peer 搜索补 `channels.username/title` trgm 索引。`contacts.search` 先按当前 owner 视角区分联系人/非联系人,并补公开频道/超级群的 `PeerChannel + Chats`;`messages.searchGlobal` 当前只查当前账号私聊文本;跨群组/频道的大规模全局搜索后续应接全文索引或外部搜索服务。
|
||||
|
||||
## 3. store 接口蓝图
|
||||
|
||||
```text
|
||||
internal/domain/
|
||||
user.go # User(不依赖 tg.*)
|
||||
authorization.go # Authorization(含设备信息)
|
||||
|
||||
internal/store/
|
||||
authkey.go # AuthKeyStore —— PG(去掉现有 AuthKeyData.UserID)
|
||||
session.go # SessionStore —— PG / Redis
|
||||
user.go # UserStore —— PG:ByID / ByPhone / Search / Create / Update
|
||||
authorization.go # AuthorizationStore —— PG:Bind / ByAuthKey / ByUser / Delete
|
||||
code.go # CodeStore —— Redis:Set(TTL) / Get / Del
|
||||
updatestate.go # UpdateStateStore —— PG:auth_key+user 维度 pts/qts/seq
|
||||
update_event.go # UpdateEventStore —— PG:user 维度 getDifference 事件
|
||||
dispatch_outbox.go # DispatchOutboxStore —— PG:在线 update transactional outbox
|
||||
contact.go # ContactStore —— PG:当前账号通讯录
|
||||
dialog.go # DialogStore —— PG:当前账号会话摘要
|
||||
message.go # MessageStore —— PG:账号视角下的私聊消息
|
||||
langpack.go # LangPackStore —— PG:TDesktop 语言包
|
||||
account.go # PasswordStore —— PG:账号 2FA/SRP 配置
|
||||
help.go # AppConfig/Country —— PG:启动配置与国家区号目录
|
||||
temp_auth_key.go # TempAuthKeyBinding —— PG:temp→perm auth key 绑定
|
||||
memory/ # 现有内存实现(保留作测试替身)
|
||||
postgres/ # pgxpool 实现 + 迁移 runner
|
||||
redisstore/ # go-redis 实现
|
||||
```
|
||||
|
||||
## 4. 关键设计决策
|
||||
|
||||
1. **auth_key 与 authorization 分表**:auth_key 是协议产物、授权是业务产物(README.md §3)。从现有 [`AuthKeyData`](../internal/store/authkey.go:11) 拆出 `UserID`,新增 `domain.Authorization` + `AuthorizationStore`。授权表同时是 [`rpc.ClientInfo`](../internal/rpc/context.go) 的落库归宿。
|
||||
2. **store 接口收发 domain 实体**,DTO 仅存在于实现内部;`AuthKeyData` / `SessionData` 这类纯协议数据保留为 store 层 DTO(它们不属业务 domain)。domain 不依赖 `tg.*`,下沉到接口签名安全。
|
||||
3. **`auth_key_id` 存 BIGINT**:内部 `[8]byte` 在 store 边界按小端转 int64(MTProto auth_key_id 定义即 SHA1 低 64 位)。可读、可索引、与日志一致。
|
||||
4. **update 状态分两层**:`user_update_events` 是账号级 durable log;`update_states(auth_key_id,user_id)` 是设备在某账号下看到的状态。这样同一设备退出后换号、或一个设备先后登录多个账号,都不会把旧账号差分串到新账号。
|
||||
5. **连接身份缓存**:`authorizations` 仍是权威事实;router 在 active session 首次 RPC 时解析 temp→perm auth_key,并把业务 auth_key_id 写回连接上下文;`auth_key→user_id` 额外做 router 级业务 auth_key 缓存并用 singleflight 合并并发 miss,避免 TDesktop 启动时多个 temp session 同时打 `authorizations`。登录前的未授权结果也做缓存;`auth.bindTempAuthKey` 切换业务 auth_key 时清掉旧 raw key 的 user 缓存,`auth.signIn/signUp` 写入真实 user,`auth.logOut` 清理同业务 auth_key 的活跃连接身份与 auth_key+user update state,账号级事件不随设备退出删除。
|
||||
6. **大表查询必须游标化**:`message_boxes`、`dialogs`、`user_update_events` 和 `dispatch_outbox` 从建表开始 HASH 分区;历史和会话列表使用 `box_id/date` 或 `top_message_date/top_message_id/peer_id` seek pagination,避免在 owner 分区内做大 SQL `OFFSET` 扫描。
|
||||
7. **查询入口必须匹配分区键**:分区表上的二级索引不能替代分区裁剪。user→channel、message→owners、global queue claim 这类反向访问必须有独立 read model,或拆成「先取 bounded id,再按分区键批量点查」两段;禁止把动态 join 当作分区裁剪。
|
||||
8. **联系人和 dialog 均是 owner 视角**:`users` 只保存全局账号资料;通讯录姓名/电话/备注、dialog 置顶顺序、manual unread、隐藏 action bar 都必须落在当前 owner 维度。后续群消息、群成员备注、会话排序可以复用这条边界,不能把个人备注写回全局 user。
|
||||
9. **搜索不能无界扫大表**:用户搜索限制 query/limit,并用 phone prefix / username / owner 保存姓名索引;消息全局搜索第一版只在当前 owner 分区内执行,并有 `pg_trgm` 兜底。参考实现,未接 Meilisearch/FTS 前不做跨全库模糊扫。
|
||||
|
||||
## 5. 选型
|
||||
|
||||
| 组件 | 选型 | 理由 |
|
||||
|---|---|---|
|
||||
| PG 驱动/连接池 | `jackc/pgx/v5` + `pgxpool` | Go 生态主流,性能好,原生 PG 协议,不引 ORM |
|
||||
| Redis 客户端 | `redis/go-redis/v9` | 事实标准 |
|
||||
| 迁移 | `golang-migrate/migrate/v4`,`iofs` 嵌入 `deploy/migrations` | 版本化、可 up/down,启动自动 `up` |
|
||||
| 查询层 | `sqlc` 生成 pgx/v5 代码(以 `deploy/migrations` 为 schema 源,与 golang-migrate 共用同一份 SQL) | 类型安全、消除 `rows.Scan` 样板、schema 演进编译期暴露;动态查询用 pgx 手写补充 |
|
||||
|
||||
## 6. 落地里程碑
|
||||
|
||||
- **P0 基础设施**(本文档 + docker-compose + 0001 schema + config 扩展)—— ✅
|
||||
- **P1 Go 接入**:`store/postgres`(pgxpool + golang-migrate runner + sqlc `AuthKeyStore`)+ `store/redisstore`(`SessionStore`);main 启动迁移并注入。连接层测试保持 Memory 替身(测协议不被 DB 绑架),PG/Redis 由 env-gate 往返集成测试验证。—— ✅
|
||||
- **P2 业务 store**:`UserStore` / `AuthorizationStore`(PG)+ `CodeStore`(Redis);Memory 实现抽到 `store/memory` 子包(三后端对称)。登录注册闭环已用上(auth.* + users.getUsers + updates.getState),手机号在 auth 业务层规范为纯数字以匹配 TDesktop 验证码页行为。—— ✅
|
||||
- **P3 启动业务 RPC 持久化**:`UpdateStateStore` / `ContactStore` / `DialogStore` / `LangPackStore`,
|
||||
`updates.getDifference`、`contacts.getContacts`、`messages.getDialogs/getPinnedDialogs`、`langpack.*`
|
||||
已由 PG-backed 服务响应;空账号仍返回空业务数据。—— ✅
|
||||
- **P3.5 启动配置与账号安全持久化**:`PasswordStore` / `AppConfigStore` / `CountryStore` /
|
||||
`TempAuthKeyBindingStore`,`account.getPassword`、`help.getAppConfig/getCountriesList`、
|
||||
`auth.bindTempAuthKey` 已由 PG-backed 服务校验并响应,temp auth_key 会映射到 perm auth_key 授权。—— ✅
|
||||
- **P3.6 官方系统账号与登录消息**:内置 777000 官方账号,`auth.signIn/signUp` 成功后写入登录消息,
|
||||
`messages.getDialogs/getHistory/search/readHistory` 返回或更新账号视角下的官方会话,并支持 TDesktop
|
||||
的 offset/hash 参数;`messages.getDialogs` 的 count/hash 按企业版同样的“完整列表先统计、再分页”语义计算;
|
||||
`messages.getPeerDialogs` 按企业版同样的指定 peer 查询路径返回 dialog/top message/users/state,缺失 user peer 返回空 dialog 占位;RPC 层延迟向当前
|
||||
session 推送 `updateNewMessage`,并向其它在线 session 推送新登录 `updateServiceNotification`;
|
||||
在线 session 需先完成 `updates.getState/getDifference` 才接收主动 updates,未 ready 的推送先暂存;
|
||||
同时写入账号级 `user_update_events`,`updates.getDifference` 可补偿登录官方消息与官方会话已读事件。—— ✅
|
||||
- **P4 消息闭环**:私聊文本 `messages.sendMessage/forwardMessages/readHistory/editMessage/deleteMessages/deleteHistory`、reply_to/forward header 元数据、双端 `message_boxes`、已读 inbox/outbox 回执、`user_update_events`、Redis `pts/box_id` allocator、transactional outbox、在线 session 批量推送、窗口限流、链路指标和游标分页已落地;媒体、群组/频道、完整 qts/seq 仍是后续阶段。
|
||||
- **P4.1 联系人与 dialog owner 视角**:`contacts.addContact/importContacts/deleteContacts/updateContactNote/getContactIDs/getStatuses` 与 `messages.toggleDialogPin/reorderPinnedDialogs/markDialogUnread/getDialogUnreadMarks/hidePeerSettingsBar/getPeerSettings` 已接入 PG-backed 服务;集成测试覆盖不同 owner 对同一 user 的独立备注、备注 hash、reverse mutual、dialog 用户视角、置顶重排、manual unread 与隐藏 action bar。—— ✅
|
||||
- **P4.2 设置类 durable updates**:联系人 reset、dialog pinned、pinned order、manual unread、peer settings、dialog filters/order/reload 与 folder peers 均写入 `user_update_events`,并同事务写 `dispatch_outbox`;`updates.getDifference` 与 outbox TL 转换支持这些事件,且保存 pinned order / peer settings flags / dialog filter payload / folder peers / exclude auth key,在线 push 不再是唯一通知路径,也不会与 reliable outbox 双重推送。—— ✅
|
||||
- **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。—— ✅
|
||||
|
||||
## 7. 与铁律的关系
|
||||
|
||||
- §2 类型边界:`store` 与 `domain` 均不依赖 `tg.*`,仅在 RPC 边界转换。
|
||||
- §3 协议/业务隔离:`auth_keys`(协议)与 `authorizations`(业务)分表落地。
|
||||
- §6 范围:第一阶段只实现登录 + 空账号主界面必经 RPC,以及登录必需的官方系统消息;二阶段已补私聊文本闭环,群组/频道/媒体仍留后续阶段。
|
||||
|
||||
## 8. 媒体层(documents / photos / blobs,2026-06-02)
|
||||
|
||||
媒体闭环新增表(migration `0057_media`):
|
||||
|
||||
- `upload_parts`(owner+file_id+part):`upload.saveFilePart/saveBigFilePart` 累积的分片,组装成 blob 后清理(transient)。
|
||||
- `file_blobs`(PK `location_key`):可下载二进制的索引,`doc:<id>[:type]` / `photo:<id>:<type>` → `backend/object_key/size/mime`;字节本身落 blob backend。
|
||||
- `documents` / `photos`:Telegram 文档 / 照片元数据(id、access_hash、file_reference、dc_id、attributes/thumbs/sizes JSONB)。
|
||||
- `documents.id` 持久化为 telesrv-owned 正数 id;外部导出资源的 source id 只在 seed 扫描文件名/JSON 时使用,进入 `documents` / `file_blobs` / `sticker_sets` / `available_reactions` 前已归一为服务端 id。RPC、`InputDocument` 与 `inputDocumentFileLocation` 均直接使用该 id。
|
||||
- `sticker_sets`:贴纸 / 自定义 emoji 集(含有序 `document_ids`、`packs`、`system_key` 路由系统集)。
|
||||
- `available_reactions`:reaction 目录(引用真实文档 id)。
|
||||
- `profile_photos`(owner_peer_type+id+photo_id):用户/频道头像历史,current=active 中 sort_order 最大者。
|
||||
- 消息表(`private_messages`/`message_boxes`/`channel_messages`)新增 `media` JSONB 快照列;放宽 body 非空 CHECK 为「body 非空 OR media 非空(OR channel action)」,支持「仅媒体」消息。
|
||||
- 头像反范式:`users.photo_*`(migration 0057 用户表无需改,富化在 users 服务)与 `channels.photo_*`(migration `0059`)。
|
||||
- `app_configs` 增 `reactions_default` 等(migration `0058`)。
|
||||
|
||||
blob backend:`internal/app/files`(`BlobBackend` 接口 + `LocalFS` 本地磁盘实现,内容寻址 sha256 两级 fanout 去重,默认 `data/blobs`)。`MediaStore`(PG)只管元数据 + `file_blobs` 索引;字节由 backend 按 `object_key` 读写。
|
||||
|
||||
种子导入:启动时 `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 tag(store 直接 marshal JSONB),完全不依赖 `tg.*`;domain↔tg 转换集中在 `internal/rpc/convert_media.go`。
|
||||
116
docs/tdesktop-patch-notes.md
Normal file
116
docs/tdesktop-patch-notes.md
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
# Telegram Desktop Patch Notes
|
||||
|
||||
These notes describe the minimal Telegram Desktop patch required to connect a
|
||||
self-built client to `telesrv`.
|
||||
|
||||
## Target
|
||||
|
||||
| Item | Value |
|
||||
|---|---|
|
||||
| Telegram Desktop commit | `9caf32dffc90ddd9bb08ad5777b865f729fa167b` |
|
||||
| Describe | `v6.8.4-15-g9caf32dffc` |
|
||||
| TL layer | 225 |
|
||||
| Local DC id | `2` |
|
||||
| Local endpoint | `127.0.0.1:2398` |
|
||||
| IPv6 endpoint | `[::1]:2398` |
|
||||
|
||||
`telesrv` defaults match this patch:
|
||||
|
||||
```powershell
|
||||
TELESRV_LISTEN=0.0.0.0:2398
|
||||
TELESRV_ADVERTISE_IP=127.0.0.1
|
||||
TELESRV_DC=2
|
||||
```
|
||||
|
||||
## RSA Key
|
||||
|
||||
Telegram Desktop must contain the public key that matches the private key used
|
||||
by your local `telesrv` instance.
|
||||
|
||||
Start `telesrv` once so it creates `data/server_rsa.pem`, then export:
|
||||
|
||||
```powershell
|
||||
openssl rsa -in data/server_rsa.pem -RSAPublicKey_out -out data/server_rsa.pub
|
||||
```
|
||||
|
||||
Copy the PEM contents of `data/server_rsa.pub` into both `kPublicRSAKeys` and
|
||||
`kTestPublicRSAKeys` in:
|
||||
|
||||
```text
|
||||
Telegram/SourceFiles/mtproto/mtproto_dc_options.cpp
|
||||
```
|
||||
|
||||
## DC List Patch
|
||||
|
||||
In the same file, replace the built-in DC arrays with:
|
||||
|
||||
```cpp
|
||||
const BuiltInDc kBuiltInDcs[] = {
|
||||
{ 2, "127.0.0.1", 2398 },
|
||||
};
|
||||
|
||||
const BuiltInDc kBuiltInDcsIPv6[] = {
|
||||
{ 2, "::1", 2398 },
|
||||
};
|
||||
|
||||
const BuiltInDc kBuiltInDcsTest[] = {
|
||||
{ 2, "127.0.0.1", 2398 },
|
||||
};
|
||||
|
||||
const BuiltInDc kBuiltInDcsIPv6Test[] = {
|
||||
{ 2, "::1", 2398 },
|
||||
};
|
||||
```
|
||||
|
||||
In `DcOptions::constructFromBuiltIn()`, mark built-in endpoints TCP-only:
|
||||
|
||||
```cpp
|
||||
const auto flags = Flag::f_static | Flag::f_tcpo_only;
|
||||
const auto flags = Flag::f_static | Flag::f_ipv6 | Flag::f_tcpo_only;
|
||||
```
|
||||
|
||||
That is the whole client patch: local DC endpoints, matching RSA public key,
|
||||
and TCP-only flags.
|
||||
|
||||
## Build
|
||||
|
||||
Follow Telegram Desktop's upstream build docs for your platform. For Windows
|
||||
x64 at the pinned baseline:
|
||||
|
||||
```powershell
|
||||
git clone --recursive https://github.com/telegramdesktop/tdesktop.git
|
||||
cd tdesktop
|
||||
git checkout 9caf32dffc90ddd9bb08ad5777b865f729fa167b
|
||||
git submodule update --init --recursive
|
||||
|
||||
Telegram\build\prepare\win.bat
|
||||
cd Telegram
|
||||
configure.bat x64 -D TDESKTOP_API_ID=YOUR_API_ID -D TDESKTOP_API_HASH=YOUR_API_HASH
|
||||
```
|
||||
|
||||
Open `out\Telegram.slnx` in Visual Studio and build the `Telegram` project.
|
||||
|
||||
## Multi-Client Local Test
|
||||
|
||||
Run two clients with isolated working directories:
|
||||
|
||||
```powershell
|
||||
$tdesktop = "C:\path\to\tdesktop\out\Debug\Telegram.exe"
|
||||
Start-Process $tdesktop -ArgumentList @("-workdir", "$PWD\.tdata-alice")
|
||||
Start-Process $tdesktop -ArgumentList @("-workdir", "$PWD\.tdata-bob")
|
||||
```
|
||||
|
||||
Use different phone numbers for Alice and Bob. The default development login
|
||||
code is `12345`.
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
- `telesrv` logs `tl_layer=225` and listens on `2398`.
|
||||
- Telegram Desktop connects without reconnect loops.
|
||||
- No new `NOT_IMPLEMENTED`, `Unhandled RPC`, `bad_msg`, panic, or internal error
|
||||
appears in server logs during startup and basic messaging.
|
||||
- A second client with a separate `-workdir` can sign in and exchange messages
|
||||
with the first client.
|
||||
|
||||
Keep every client-side workaround mapped to a server-side compatibility decision
|
||||
and keep protocol changes separate from UI experiments.
|
||||
Loading…
Add table
Add a link
Reference in a new issue