28 KiB
telesrv 持久化层设计
第一阶段(协议 + 登录 + 空账号主界面)的存储地基。后端已定:PostgreSQL + Redis, 依赖由
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/0002_phase1_business.up.sql 与
deploy/migrations/0003_startup_config_security.up.sql、
deploy/migrations/0004_temp_auth_key_binding_session.up.sql 与
deploy/migrations/0005_system_login_messages.up.sql、
deploy/migrations/0006_update_events.up.sql、
deploy/migrations/0007_read_history_events.up.sql、
deploy/migrations/0008_user_id_sequence_base.up.sql 与
deploy/migrations/0009_private_message_pipeline.up.sql、
deploy/migrations/0010_message_performance_indexes.up.sql 与
deploy/migrations/0011_drop_dead_tables.up.sql、
deploy/migrations/0012_outbox_delete_on_deliver.up.sql 与
deploy/migrations/0013_contact_profiles_and_dialog_pins.up.sql、
deploy/migrations/0014_settings_update_events.up.sql、
deploy/migrations/0015_update_event_payloads_and_outbox_auth.up.sql、
deploy/migrations/0016_delete_message_updates.up.sql、
deploy/migrations/0017_dialog_folders.up.sql、
deploy/migrations/0018_user_search_indexes.up.sql、
deploy/migrations/0019_usernames.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 绑定记录。写入前校验 encryptedbind_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_idHASH 分区的账号级增量事件队列。承载new_message、read_history_inbox/read_history_outbox(私聊与 channel peer)、edit_message、read_message_contents、delete_messages、contacts_reset、dialog 置顶/顺序/manual unread/peer settings、dialog filter/order/reload、folder peers、channel 本地清空后的channel_available_messages、当前账号 forum 展示模式channel_view_forum_as_messages与 allocator gap 的noop,供updates.getDifference和 outbox worker 补偿错过的推送;设置类事件通过UpdateEventStore.AppendWithDispatch与dispatch_outbox同事务写入,并持久化event_peers/peer_settings/message_ids/dialog_filter/filter_order/folder_peers负载,避免状态只存在在线 push 中。account difference 中的updateChannelTooLongchannel nudge 是按 channel events + request date 计算出来的提示,不写入本表、不消耗账号 pts。contacts—— 当前账号通讯录关系与 owner 视角联系人资料。contact_phone/contact_first_name/contact_last_name/note/note_entities均只属于(user_id, contact_user_id),同一个全局 user 在不同 owner 的通讯录中可以有不同姓名、电话和备注;mutual由双方是否互存维护,删除一方联系人会清理对方 reverse mutual。contact_blocks—— 当前账号 blocklist,按owner_user_idHASH 分区;(owner_user_id, blocked_user_id)唯一,按date DESC, blocked_user_id DESC返回contacts.getBlocked。当前 full privacy keys 未接入,私聊 send/edit/delete 的拒绝来源仅为这张表。private_messages—— 共享私聊消息主体,按sender_user_idHASH 分区;sender_user_id + random_id唯一保证messages.sendMessage/forwardMessages幂等;文本编辑更新共享 body/entities/edit_date;silent/noforwards/reply_to/fwd_from 元数据随消息持久化。message_boxes—— owner 视角消息盒,按owner_user_idHASH 分区;每个账号看到自己的box_id、peer、outgoing、pts、edit_date、media_unread/reaction_unread与删除状态,历史/搜索走该表索引。删除只软删 owner 视角 message_box,revoke通过(message_sender_id, private_message_id)定位其它 owner 视角并软删;编辑会同步所有可见 owner 视角盒子。messages.readMessageContents只锁定当前 owner exact ids 且仅清理 unread 状态为 true 的行,有变化才写read_message_contentsdurable event。该反向定位与分区键不一致,规划会展开全部 owner 分区,后续应增加 unpartitioned box 映射或先推导 owner_user_id 后再按 owner 分区点查。dialogs—— 当前账号会话摘要,按user_idHASH 分区;只允许userpeer,支持 top message、置顶过滤、folder_id=0/1 主列表/归档与 offset 分页,并保存当前 owner 的pinned_order、manualunread_mark与hidden_peer_settings_bar。列表查询 joincontacts时优先返回当前 owner 保存的联系人姓名/电话,避免不同账号看同一 peer 串备注;相关状态变化会写入账号级 durable update log,离线设备可通过updates.getDifference恢复。channels/channel_messages/channel_message_viewers/channel_update_events—— 超级群/频道单份消息模型,按channel_idHASH 分区;channels.pts是 channel-scoped durable log 水位,channels.forum/forum_tabs持久化 megagroup topics 开关与 TDesktop tabs/list 布局,channels.participants_hidden持久化隐藏成员设置并由ChannelFull.participants_hidden恢复 TDesktop UI,channels.antispam持久化 native anti-spam 开关并由ChannelFull.antispam恢复管理入口,channels.color_set/color/color_background_emoji_id/profile_color_set/profile_color/profile_color_background_emoji_id/emoji_status_document_id/emoji_status_until持久化频道外观并回填Channel.color/profile_color/emoji_status,channels.linked_chat_id维护 broadcast 与 discussion megagroup 的双向链接并走channels_linked_chat_idx反查,channel_update_events(channel_id, pts)存 new/edit/delete/pin/noop 的恢复负载,成员权限/封禁变化不进入该 log;channel_messages(channel_id, id)走 seek pagination 并保存views_count聚合列,channel_message_viewers(channel_id,message_id,viewer_user_id)用主键完成 views 去重递增,reply_to_msg_id/reply_to_top_id支撑 thread/comment 分页,discussion_channel_id/discussion_message_id把 broadcast post 映射到 linked megagroup root,禁止按成员写扩散。channel_members/channel_dialogs/channel_unread_mentions—— 成员权限、读水位、owner 视角 channel dialog 与未读提及索引,分别按channel_id/user_id/user_idHASH 分区;available_min_id限制成员可见历史消息,available_min_pts限制updates.getChannelDifference起点,避免新成员或重新加入成员恢复到入群前的消息类 durable 事件。channel_dialogs.unread_count是小超级群普通未读缓存字段,不是 broadcast/大超级群真值;大频道读取 dialog/full channel 时按channel_members.read_inbox_max_id、available_min_id、channels.top_message_id与未删除消息动态派生普通未读。channel_dialogs.default_send_as_peer_type/default_send_as_peer_id保存当前 owner 的默认发送身份,由channels.getFullChannel输出为channelFull.default_send_as,不参与历史分页或 dialog 排序;channel_dialogs.view_forum_as_messages是当前账号本地 forum 展示模式,由Dialog/ChannelFull.view_forum_as_messages恢复 UI,并通过账号级 durable update 同步多 session。channel_unread_mentions(user_id,channel_id,message_id,media_unread)不复制消息正文,发送/编辑时只写解析出的 active/可见/未读成员,清除后重算channel_dialogs.unread_mentions_count;history/getMessages/getChannelDifference/online update 按 viewer 用它回填mentioned/media_unread。共同超级群查询已迁到user_channel_member_index(user_id, channel_id),排除 broadcast 与非 active/deleted 成员;后续channels.getLeftChannels、joined/admined channel 列表和启动 dialog 聚合也必须从 user 维度 read model 或两步 channel_id 列表读取,不能直接用channel_members WHERE user_id=...反向扫channel_id分区。channel_invites/channel_invite_importers—— 邀请链接、导入者与 join request read model,均按channel_idHASH 分区;invite 保存usage_count/requested_count,importer 以(channel_id,user_id)保证同一用户只有一个 pending/approved 状态,管理页查询走admin/revoked/offset_link与requested/link/date/user_idseek 索引,禁止按超大 limit 或 hash 反查做全表扫。publicchannels.toggleJoinRequest使用channels.join_request与channel_invite_importers(invite_id=0, requested=true)表达非 invite-link pending request;管理员实时提醒用 boundedupdatePendingJoinRequests+ full channel 回填,不为每条 pending 状态生成无界 durable updates。dialog_filters/dialog_filter_settings—— 当前账号自定义 dialog filter、filter 顺序与 folder tags 开关,按user_idHASH 分区;自定义 filter 从 ID 2 开始,归档只由dialogs.folder_id=1表达,避免一列同时承担归档状态和任意筛选规则。dispatch_outbox—— 按target_user_idHASH 分区的 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 接口蓝图
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. 关键设计决策
- auth_key 与 authorization 分表:auth_key 是协议产物、授权是业务产物(README.md §3)。从现有
AuthKeyData拆出UserID,新增domain.Authorization+AuthorizationStore。授权表同时是rpc.ClientInfo的落库归宿。 - store 接口收发 domain 实体,DTO 仅存在于实现内部;
AuthKeyData/SessionData这类纯协议数据保留为 store 层 DTO(它们不属业务 domain)。domain 不依赖tg.*,下沉到接口签名安全。 auth_key_id存 BIGINT:内部[8]byte在 store 边界按小端转 int64(MTProto auth_key_id 定义即 SHA1 低 64 位)。可读、可索引、与日志一致。- update 状态分两层:
user_update_events是账号级 durable log;update_states(auth_key_id,user_id)是设备在某账号下看到的状态。这样同一设备退出后换号、或一个设备先后登录多个账号,都不会把旧账号差分串到新账号。 - 连接身份缓存:
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,账号级事件不随设备退出删除。 - 大表查询必须游标化:
message_boxes、dialogs、user_update_events和dispatch_outbox从建表开始 HASH 分区;历史和会话列表使用box_id/date或top_message_date/top_message_id/peer_idseek pagination,避免在 owner 分区内做大 SQLOFFSET扫描。 - 查询入口必须匹配分区键:分区表上的二级索引不能替代分区裁剪。user→channel、message→owners、global queue claim 这类反向访问必须有独立 read model,或拆成「先取 bounded id,再按分区键批量点查」两段;禁止把动态 join 当作分区裁剪。
- 联系人和 dialog 均是 owner 视角:
users只保存全局账号资料;通讯录姓名/电话/备注、dialog 置顶顺序、manual unread、隐藏 action bar 都必须落在当前 owner 维度。后续群消息、群成员备注、会话排序可以复用这条边界,不能把个人备注写回全局 user。 - 搜索不能无界扫大表:用户搜索限制 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 + sqlcAuthKeyStore)+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、Redispts/box_idallocator、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、updateDeleteMessagesdurable payload(message_ids+pts_count=len(message_ids))与 outbox 投递;后续新消息会正常重建被删除的 dialog。—— ✅ - P4.4 Dialog 分组/归档:
messages.getDialogFilters/updateDialogFilter/updateDialogFiltersOrder/toggleDialogFilterTags与folders.editPeerFolders已接入 PG-backed 服务;集成测试覆盖 folder_id 0/1 主列表/归档、自定义 filter、tags 和归档还原。—— ✅ - P4.5 TDesktop 搜索入口:
contacts.search已支持联系人/非联系人用户搜索,contacts.getSponsoredPeers返回空 sponsored peers,messages.searchGlobal接当前 owner 私聊文本搜索;查询保护与索引设计参考实现,避免客户端搜索框卡在 Loading。—— ✅ - P4.6 内容已读与 blocklist privacy gate:
message_boxes.media_unread/reaction_unread、user_update_events(read_message_contents)、contact_blocks已落地;messages.readMessageContents只在实际清理 unread 内容状态时分配 pts 并 durable 推送,被 block 后私聊 send 只写 sender outbox,edit/revoke delete 会返回 forbidden。—— ✅
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(PKlocation_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)新增mediaJSONB 快照列;放宽 body 非空 CHECK 为「body 非空 OR media 非空(OR channel action)」,支持「仅媒体」消息。 - 头像反范式:
users.photo_*(migration 0057 用户表无需改,富化在 users 服务)与channels.photo_*(migration0059)。 app_configs增reactions_default等(migration0058)。
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。