owpengram-server/docs/persistence-layer.md
A 860e581d06 business: fix contact projection and phone sharing
(cherry picked from commit c0a0e5b52240ed415d3b43ba77659821887bf50b)
2026-06-10 22:06:56 +08:00

28 KiB
Raw Blame History

telesrv 持久化层设计

第一阶段(协议 + 登录 + 空账号主界面)的存储地基。后端已定:PostgreSQL + Redis 依赖由 deploy/docker-compose.yml 启动对象存储MinIO留第二阶段文件域。 决策见 README.md。

0. 定位与第一价值

把登录链路所需状态可靠落地,使 server 可反复重启而不丢握手态 / 登录态——这是真机联调能否愉快迭代的前提:

  • auth_key 丢 → TDesktop 缓存的 key 在 server 端查不到 → 客户端加密包解不开 → 触发重建密钥甚至重登。
  • 登录态丢 → 每次重启都要重走 sendCode / signIn

所以持久化第一价值是「联调可重启」,不是上规模。

1. 后端职责划分

维度 PostgreSQL权威、强一致、可查询 Redis高频、易失、TTL、原子计数
第一阶段 auth_keysusersauthorizationsaccount_passwordstemp_auth_key_bindingsapp_configscountriescountry_codesupdate_statescontactslang_packslang_pack_strings 验证码 phone_code_hash → code(短 TTL、session、登录尝试限流
第二阶段 private_messagesmessage_boxesdialogsuser_update_eventsdispatch_outbox pts / owner box_id 原子自增Redis miss 时从 PG durable log 恢复)、在线态缓存
不放这里 大对象/媒体(→ 二阶段 MinIO 任何需要持久强一致的业务事实

原则:PG 存「事实」Redis 存「态与计数」。Redis 丢了能从 PG/重新计算恢复PG 丢了就是数据丢失。

2. 第一阶段 schemaPostgreSQL

DDL 见 deploy/migrations/0001_init.up.sqldeploy/migrations/0002_phase1_business.up.sqldeploy/migrations/0003_startup_config_security.up.sqldeploy/migrations/0004_temp_auth_key_binding_session.up.sqldeploy/migrations/0005_system_login_messages.up.sqldeploy/migrations/0006_update_events.up.sqldeploy/migrations/0007_read_history_events.up.sqldeploy/migrations/0008_user_id_sequence_base.up.sqldeploy/migrations/0009_private_message_pipeline.up.sqldeploy/migrations/0010_message_performance_indexes.up.sqldeploy/migrations/0011_drop_dead_tables.up.sqldeploy/migrations/0012_outbox_delete_on_deliver.up.sqldeploy/migrations/0013_contact_profiles_and_dialog_pins.up.sqldeploy/migrations/0014_settings_update_events.up.sqldeploy/migrations/0015_update_event_payloads_and_outbox_auth.up.sqldeploy/migrations/0016_delete_message_updates.up.sqldeploy/migrations/0017_dialog_folders.up.sqldeploy/migrations/0018_user_search_indexes.up.sqldeploy/migrations/0019_usernames.up.sqldeploy/migrations/0020_profile_message_state.up.sql

0011 清除了三张已被取代、运行时零引用的死表:update_events(一阶段 auth_key 级 update 队列,被 user_update_events 取代)、messages_legacydialogs_legacy0009 重命名保留的迁移残骸,数据已迁入 private_messages / message_boxes / 新 dialogs)。0006 / 0007 作为历史演进记录保留,但其建立的 update_events 已不再使用。

第一批表:

  • auth_keys —— 密钥交换产物。auth_key_id(BIGINTSHA1 低 64 位小端 int64) + body(256B BYTEA) + server_salt
  • users —— 登录链路必须字段:id / access_hash / phone(UNIQUE) / first_name / last_name / username / about / country_code / verified / supportaccess_hash 为任何 InputUser 校验所必须,不可省;普通注册用户 ID 从 17802432002026-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_keysusers
  • 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_messageread_history_inbox/read_history_outbox(私聊与 channel peeredit_messageread_message_contentsdelete_messagescontacts_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.AppendWithDispatchdispatch_outbox 同事务写入,并持久化 event_peers / peer_settings / message_ids / dialog_filter / filter_order / folder_peers 负载,避免状态只存在在线 push 中。account difference 中的 updateChannelTooLong channel nudge 是按 channel events + request date 计算出来的提示,不写入本表、不消耗账号 pts。
  • contacts —— 当前账号通讯录关系与 owner 视角联系人资料。contact_phone/contact_first_name/contact_last_name/note/note_entities 均只属于 (user_id, contact_user_id),同一个全局 user 在不同 owner 的通讯录中可以有不同姓名、电话和备注;mutual 由双方是否互存维护,删除一方联系人会清理对方 reverse mutual。
  • contact_blocks —— 当前账号 blocklistowner_user_id HASH 分区;(owner_user_id, blocked_user_id) 唯一,按 date DESC, blocked_user_id DESC 返回 contacts.getBlocked。当前 full privacy keys 未接入,私聊 send/edit/delete 的拒绝来源仅为这张表。
  • private_messages —— 共享私聊消息主体,按 sender_user_id HASH 分区;sender_user_id + random_id 唯一保证 messages.sendMessage/forwardMessages 幂等;文本编辑更新共享 body/entities/edit_datesilent/noforwards/reply_to/fwd_from 元数据随消息持久化。
  • message_boxes —— owner 视角消息盒,按 owner_user_id HASH 分区;每个账号看到自己的 box_id、peer、outgoing、pts、edit_date、media_unread/reaction_unread 与删除状态,历史/搜索走该表索引。删除只软删 owner 视角 message_boxrevoke 通过 (message_sender_id, private_message_id) 定位其它 owner 视角并软删;编辑会同步所有可见 owner 视角盒子。messages.readMessageContents 只锁定当前 owner exact ids 且仅清理 unread 状态为 true 的行,有变化才写 read_message_contents durable event。该反向定位与分区键不一致规划会展开全部 owner 分区,后续应增加 unpartitioned box 映射或先推导 owner_user_id 后再按 owner 分区点查。
  • dialogs —— 当前账号会话摘要,按 user_id HASH 分区;只允许 user peer支持 top message、置顶过滤、folder_id=0/1 主列表/归档与 offset 分页,并保存当前 owner 的 pinned_order、manual unread_markhidden_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 UIchannels.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_statuschannels.linked_chat_id 维护 broadcast 与 discussion megagroup 的双向链接并走 channels_linked_chat_idx 反查,channel_update_events(channel_id, pts) 存 new/edit/delete/pin/noop 的恢复负载,成员权限/封禁变化不进入该 logchannel_messages(channel_id, id) 走 seek pagination 并保存 views_count 聚合列,channel_message_viewers(channel_id,message_id,viewer_user_id) 用主键完成 views 去重递增,reply_to_msg_id/reply_to_top_id 支撑 thread/comment 分页,discussion_channel_id/discussion_message_id 把 broadcast post 映射到 linked megagroup root禁止按成员写扩散。
  • channel_members / channel_dialogs / channel_unread_mentions —— 成员权限、读水位、owner 视角 channel dialog 与未读提及索引,分别按 channel_id / user_id / user_id HASH 分区;available_min_id 限制成员可见历史消息,available_min_pts 限制 updates.getChannelDifference 起点,避免新成员或重新加入成员恢复到入群前的消息类 durable 事件。channel_dialogs.unread_count 是小超级群普通未读缓存字段,不是 broadcast/大超级群真值;大频道读取 dialog/full channel 时按 channel_members.read_inbox_max_idavailable_min_idchannels.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_counthistory/getMessages/getChannelDifference/online update 按 viewer 用它回填 mentioned/media_unread。共同超级群查询已迁到 user_channel_member_index(user_id, channel_id),排除 broadcast 与非 active/deleted 成员;后续 channels.getLeftChannels、joined/admined channel 列表和启动 dialog 聚合也必须从 user 维度 read model 或两步 channel_id 列表读取,不能直接用 channel_members WHERE user_id=... 反向扫 channel_id 分区。
  • channel_invites / channel_invite_importers —— 邀请链接、导入者与 join request read model均按 channel_id HASH 分区invite 保存 usage_count/requested_countimporter 以 (channel_id,user_id) 保证同一用户只有一个 pending/approved 状态,管理页查询走 admin/revoked/offset_linkrequested/link/date/user_id seek 索引,禁止按超大 limit 或 hash 反查做全表扫。public channels.toggleJoinRequest 使用 channels.join_requestchannel_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 + Chatsmessages.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           —— PGByID / ByPhone / Search / Create / Update
  authorization.go   # AuthorizationStore  —— PGBind / ByAuthKey / ByUser / Delete
  code.go            # CodeStore           —— RedisSet(TTL) / Get / Del
  updatestate.go     # UpdateStateStore    —— PGauth_key+user 维度 pts/qts/seq
  update_event.go    # UpdateEventStore    —— PGuser 维度 getDifference 事件
  dispatch_outbox.go # DispatchOutboxStore —— PG在线 update transactional outbox
  contact.go         # ContactStore        —— PG当前账号通讯录
  dialog.go          # DialogStore         —— PG当前账号会话摘要
  message.go         # MessageStore        —— PG账号视角下的私聊消息
  langpack.go        # LangPackStore       —— PGTDesktop 语言包
  account.go         # PasswordStore       —— PG账号 2FA/SRP 配置
  help.go            # AppConfig/Country   —— PG启动配置与国家区号目录
  temp_auth_key.go   # TempAuthKeyBinding  —— PGtemp→perm auth key 绑定
  memory/            # 现有内存实现(保留作测试替身)
  postgres/          # pgxpool 实现 + 迁移 runner
  redisstore/        # go-redis 实现

4. 关键设计决策

  1. auth_key 与 authorization 分表auth_key 是协议产物、授权是业务产物README.md §3。从现有 AuthKeyData 拆出 UserID,新增 domain.Authorization + AuthorizationStore。授权表同时是 rpc.ClientInfo 的落库归宿。
  2. store 接口收发 domain 实体DTO 仅存在于实现内部;AuthKeyData / SessionData 这类纯协议数据保留为 store 层 DTO它们不属业务 domain。domain 不依赖 tg.*,下沉到接口签名安全。
  3. auth_key_id 存 BIGINT:内部 [8]byte 在 store 边界按小端转 int64MTProto auth_key_id 定义即 SHA1 低 64 位)。可读、可索引、与日志一致。
  4. update 状态分两层user_update_events 是账号级 durable logupdate_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 写入真实 userauth.logOut 清理同业务 auth_key 的活跃连接身份与 auth_key+user update state账号级事件不随设备退出删除。
  6. 大表查询必须游标化message_boxesdialogsuser_update_eventsdispatch_outbox 从建表开始 HASH 分区;历史和会话列表使用 box_id/datetop_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/v4iofs 嵌入 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/postgrespgxpool + golang-migrate runner + sqlc AuthKeyStore+ store/redisstoreSessionStoremain 启动迁移并注入。连接层测试保持 Memory 替身(测协议不被 DB 绑架PG/Redis 由 env-gate 往返集成测试验证。——
  • P2 业务 storeUserStore / AuthorizationStorePG+ CodeStoreRedisMemory 实现抽到 store/memory 子包三后端对称。登录注册闭环已用上auth.* + users.getUsers + updates.getState手机号在 auth 业务层规范为纯数字以匹配 TDesktop 验证码页行为。——
  • P3 启动业务 RPC 持久化UpdateStateStore / ContactStore / DialogStore / LangPackStore updates.getDifferencecontacts.getContactsmessages.getDialogs/getPinnedDialogslangpack.* 已由 PG-backed 服务响应;空账号仍返回空业务数据。——
  • P3.5 启动配置与账号安全持久化PasswordStore / AppConfigStore / CountryStore / TempAuthKeyBindingStoreaccount.getPasswordhelp.getAppConfig/getCountriesListauth.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_eventsupdates.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/acceptContact/importContacts/deleteContacts/updateContactNote/getContactIDs/getStatusesmessages.toggleDialogPin/reorderPinnedDialogs/markDialogUnread/getDialogUnreadMarks/hidePeerSettingsBar/getPeerSettings 已接入 PG-backed 服务;contacts.acceptContact 复用联系人反向 upsert把当前用户手机号/姓名写入对方 owner 视角并清除双方单向联系人 shareContact action集成测试覆盖不同 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_outboxupdates.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 payloadmessage_ids + pts_count=len(message_ids))与 outbox 投递;后续新消息会正常重建被删除的 dialog。——
  • P4.4 Dialog 分组/归档messages.getDialogFilters/updateDialogFilter/updateDialogFiltersOrder/toggleDialogFilterTagsfolders.editPeerFolders 已接入 PG-backed 服务;集成测试覆盖 folder_id 0/1 主列表/归档、自定义 filter、tags 和归档还原。——
  • P4.5 TDesktop 搜索入口contacts.search 已支持联系人/非联系人用户搜索,contacts.getSponsoredPeers 返回空 sponsored peersmessages.searchGlobal 接当前 owner 私聊文本搜索;查询保护与索引设计参考实现,避免客户端搜索框卡在 Loading。——
  • P4.6 内容已读与 blocklist privacy gatemessage_boxes.media_unread/reaction_unreaduser_update_events(read_message_contents)contact_blocks 已落地;messages.readMessageContents 只在实际清理 unread 内容状态时分配 pts 并 durable 推送,被 block 后私聊 send 只写 sender outboxedit/revoke delete 会返回 forbidden。——

7. 与铁律的关系

  • §2 类型边界:storedomain 均不依赖 tg.*,仅在 RPC 边界转换。
  • §3 协议/业务隔离:auth_keys(协议)与 authorizations(业务)分表落地。
  • §6 范围:第一阶段只实现登录 + 空账号主界面必经 RPC以及登录必需的官方系统消息二阶段已补私聊文本闭环群组/频道/媒体仍留后续阶段。

8. 媒体层documents / photos / blobs2026-06-02

媒体闭环新增表migration 0057_media

  • upload_partsowner+file_id+partupload.saveFilePart/saveBigFilePart 累积的分片,组装成 blob 后清理transient
  • file_blobsPK location_key):可下载二进制的索引,doc:<id>[:type] / photo:<id>:<type>backend/object_key/size/mime;字节本身落 blob backend。
  • documents / photosTelegram 文档 / 照片元数据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、InputDocumentinputDocumentFileLocation 均直接使用该 id。
  • sticker_sets:贴纸 / 自定义 emoji 集(含有序 document_idspackssystem_key 路由系统集)。
  • available_reactionsreaction 目录(引用真实文档 id
  • profile_photosowner_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_configsreactions_defaultmigration 0058)。

blob backendinternal/app/filesBlobBackend 接口 + LocalFS 本地磁盘实现,内容寻址 sha256 两级 fanout 去重,默认 data/blobs)。MediaStorePG只管元数据 + file_blobs 索引;字节由 backend 按 object_key 读写。

种子导入:启动时 files.Service.SeedMediaTELESRV_STICKER_SEED_DIR(真实 Telegram 导出,含 available_reactions_raw.json + 各集 set_info.json + .tgs/.webp/缩略图幂等导入JSON 元数据→表二进制→blobdc_id 重写为本 server DC。实测 74 reactions / 24 sets / ~1.5k documents / ~3k blob 索引 / ~2.8k 去重磁盘文件。

类型边界:domain.Document/Photo/MessageMedia/StickerSet/AvailableReaction 带 json tagstore 直接 marshal JSONB完全不依赖 tg.*domain↔tg 转换集中在 internal/rpc/convert_media.go