# App Message Tab Architecture 本文定义 App 底部 `消息` tab 的产品边界、服务架构、数据模型、接口契约和开发顺序。消息 tab 固定分为 `用户`、`系统`、`活动` 三个分区。当前仓库不自研 IM 长连接,用户私聊仍由腾讯云 IM SDK 承担;后端只持久化系统和活动 inbox,并提供列表、未读、已读、删除和运营触达能力。 ## Goals - App 底部消息 tab 展示三个分区:`用户`、`系统`、`活动`。 - `用户` 分区来自腾讯云 IM C2C conversation list,不把私聊内容重复落进业务库。 - `系统` 和 `活动` 分区由后端 inbox 持久化,支持分页、未读、已读、删除、过期、撤回和跳转。 - 消息事实必须按 `app_code` 和 `user_id` 隔离,不允许跨 App、跨用户读取或写入。 - 消息事实必须在 MySQL,可用 Redis 做未读数短 TTL 缓存,但 Redis 不能作为恢复来源。 - 后端外部入口统一在 `gateway-service`,响应使用 `/api/v1` envelope。 - 生产者只发领域事件或内部消息命令,message inbox owner 负责模板快照、幂等、入箱和未读数。 ## Non-Goals - 不实现自研 IM 长连接、单聊投递、离线漫游或私聊已读同步。腾讯云 IM 继续负责实时用户消息。 - 不把房间群消息、公屏消息或房间系统消息复制到个人消息 tab。 - 不把后台管理端 `admin_notifications` 复用给 App 用户。后台通知属于 `hyapp_admin`,App 消息属于 App 业务库。 - 不允许客户端自报消息归属、未读数、系统消息内容、活动消息内容或目标用户。 - 不在 `room-service`、`wallet-service`、`user-service` 内直接写用户 inbox 表;它们只通过 outbox/事件或内部 RPC 触发入箱。 ## Current Implementation Snapshot | Capability | Status | Current Evidence | Gap | | --- | --- | --- | --- | | 腾讯云 IM 登录 | `DONE` | `gateway-service` 已有 `/api/v1/im/usersig` | App 侧会话列表仍需客户端 SDK 接入 | | 用户资料批量查询 | `DONE` | `user-service.BatchGetUsers` + gateway `/api/v1/users/profiles:batch` | 仅返回 IM 会话展示字段 | | activity-service 底座 | `DONE` | `ActivityService` + `MessageInboxService` 共用 gRPC 入口 | message inbox 事实 owner 已放在 activity-service | | gateway 消息接口 | `DONE` | `/api/v1/messages/tabs`、列表、已读、read-all、删除 | gateway 只透传 JWT user_id 和 app_code | | inbox MySQL 表 | `DONE` | `message_templates`、`user_inbox_messages`、`message_fanout_jobs` | MySQL 是 system/activity inbox 唯一事实来源 | | 系统消息生产者 | `TODO` | wallet、room、user host domain 已有各自业务/outbox 方向 | 尚无统一 `CreateInboxMessage` 或 MQ consumer | | 活动消息生产者 | `TODO` | activity-service 目前只有活动状态查询 | 需要活动上线、奖励、任务、运营触达入箱 | | 未读数缓存 | `TODO` | 无 Redis key 设计落地 | 需要缓存失效和 fallback 统计 | | 广播/fanout | `DONE` | `message_fanout_jobs` + `CreateFanoutJob` + `ActivityCronService.ProcessMessageFanoutBatch`;region/country/all_active_users/all_registered_users 通过 user-service `ListUserIDs` 游标取目标 | 还需要后台运营生产者接入 | 这张表只描述当前仓库事实和缺口。后续实现必须先补 Redis 未读缓存和第一个领域生产者,不能让生产服务直接写 inbox 表。 ## Message Sections | Section | Meaning | Source Of Truth | Backend Responsibility | | --- | --- | --- | --- | | `user` | 用户之间的单聊消息和会话 | Tencent IM SDK | 签发 UserSig、按需批量补用户资料 | | `system` | 账号、钱包、房间、host/agency/bd、审核、提现等系统通知 | message inbox MySQL | 持久化、分页、未读、已读、删除、撤回 | | `activity` | 活动上线、奖励、任务、运营触达 | message inbox MySQL, activity producer | 持久化、分页、未读、已读、删除、跳转参数 | `user` 分区在客户端通过腾讯云 IM SDK 拉取 conversation list。后端不能把用户私聊复制一份做列表,否则会引入隐私、合规、已读同步和消息一致性问题。 `system` 和 `activity` 分区使用同一套 inbox 表,通过 `message_type` 区分。活动服务或后台运营系统只生产消息事件,最终个人收件箱由 message inbox owner 落库。 ## Service Boundary ```mermaid graph LR Client["Client"] --> Gateway["gateway-service"] Client --> TIM["Tencent IM SDK"] Gateway --> User["user-service"] Gateway --> Message["message inbox owner\nactivity-service inbox module first"] Gateway --> Activity["activity-service"] TIM --> TIMCloud["Tencent IM"] User --> UserDB[("hyapp_user")] Message --> MessageDB[("hyapp_activity message tables")] Activity --> ActivityDB[("hyapp_activity")] RoomOutbox[("room_outbox")] --> MQ["MQ / event bus"] WalletOutbox[("wallet_outbox")] --> MQ UserOutbox[("user/host outbox")] --> MQ ActivityOutbox[("activity_outbox")] --> MQ MQ --> Message Admin["hyapp-admin-server"] --> Message ``` 首版把 `message inbox owner` 放在 `activity-service` 内的 inbox 模块,原因是当前已经有 `activity-service` 和 `hyapp_activity` 库,且活动通知天然需要 message fanout。后续如果系统/活动消息量、部署策略或权限模型明显独立,再拆为独立 `message-service`。 无论放在哪个进程,都必须遵守以下边界: - `gateway-service` 只做 HTTP 鉴权、协议转换、app_code 解析、内部 gRPC 编排,不保存 inbox 状态。 - `activity-service inbox module` 是 `system/activity` 消息事实 owner,负责 MySQL 表、未读数、fanout job 和幂等。 - `user-service` 只提供用户资料、区域、账号状态和批量查询,不承载消息内容。 - `room-service` 只拥有房间状态和房间腾讯 IM 群消息,不直接写个人消息 tab。 - `wallet-service` 只拥有账务事实,通过 wallet outbox 或内部事件触发系统通知。 - `hyapp-admin-server` 只做后台入口、权限和审计;创建 App 消息时必须调用 message inbox owner,不能直接写业务表。 ## Product Behavior ### Tab Summary - App 打开消息 tab 时展示三块:`用户`、`系统`、`活动`。 - `用户` 分区的标题、最近会话、未读数由腾讯云 IM SDK 返回。 - 后端 `/api/v1/messages/tabs` 返回 `system/activity` 未读数,并明确 `user.source=tencent_im_sdk`。 - App 总红点 = Tencent IM unread + backend system unread + backend activity unread。 ### Message List - `system/activity` 列表按 `sent_at_ms DESC, inbox_message_id DESC` 排序。 - 只展示当前 `app_code`、当前 `user_id`、`status=visible`、未删除、未过期的消息。 - 列表项必须包含 title、summary、icon/image、action、read 状态、sent_at_ms。 - `body` 用于详情或富文本,不要求首版做详情页;首版可以在列表响应里返回简短 body。 - 客户端展示跳转动作时只能使用服务端白名单 action,不能执行任意 URL 或脚本。 ### Read And Delete - 标记已读是用户侧状态变更,重复请求返回成功,不覆盖首次 `read_at_ms`。 - 删除是用户侧隐藏,设置 `deleted_at_ms`,不物理删除事实行。 - 删除未读消息后不再计入未读。 - 过期消息不展示、不计入未读,但保留审计行。 - 撤回消息不展示、不计入未读;撤回原因仅用于审计和后台排查。 ## Data Model ### Message Templates 模板只保存服务端可控内容。App 展示标题、摘要、图标和跳转动作时,以消息实例 snapshot 为准,避免模板更新影响历史消息。 ```sql message_templates( app_code VARCHAR(32) NOT NULL, template_id VARCHAR(96) NOT NULL, template_version VARCHAR(64) NOT NULL, message_type VARCHAR(32) NOT NULL, locale VARCHAR(32) NOT NULL DEFAULT '', title VARCHAR(160) NOT NULL, summary VARCHAR(512) NOT NULL, body VARCHAR(2048) NOT NULL DEFAULT '', icon_url VARCHAR(512) NOT NULL DEFAULT '', image_url VARCHAR(512) NOT NULL DEFAULT '', action_type VARCHAR(64) NOT NULL DEFAULT '', action_param VARCHAR(1024) NOT NULL DEFAULT '', status VARCHAR(32) NOT NULL, created_by VARCHAR(64) NOT NULL DEFAULT '', created_at_ms BIGINT NOT NULL, updated_at_ms BIGINT NOT NULL, PRIMARY KEY (app_code, template_id, template_version), KEY idx_message_templates_type_status (app_code, message_type, status), KEY idx_message_templates_template_status (app_code, template_id, status) ); ``` 字段规则: - `message_type` 只允许 `system` 或 `activity`。 - `template_version` 必须进入消息实例 snapshot,避免模板修改影响历史消息。 - `locale` 首版可以为空;后续多语言按 `app_code + template_id + template_version + locale` 选择。 - `action_type` 必须是服务端白名单枚举,例如 `wallet_withdraw_detail`、`host_application_detail`、`activity_detail`、`room_detail`。 - 模板可以被停用,但停用不影响已入箱消息展示。 ### User Inbox ```sql user_inbox_messages( app_code VARCHAR(32) NOT NULL, inbox_message_id VARCHAR(96) NOT NULL, user_id BIGINT NOT NULL, message_type VARCHAR(32) NOT NULL, producer VARCHAR(64) NOT NULL, producer_event_id VARCHAR(128) NOT NULL, producer_event_type VARCHAR(96) NOT NULL DEFAULT '', aggregate_type VARCHAR(64) NOT NULL DEFAULT '', aggregate_id VARCHAR(128) NOT NULL DEFAULT '', template_id VARCHAR(96) NOT NULL DEFAULT '', template_version VARCHAR(64) NOT NULL DEFAULT '', title VARCHAR(160) NOT NULL, summary VARCHAR(512) NOT NULL, body VARCHAR(2048) NOT NULL DEFAULT '', icon_url VARCHAR(512) NOT NULL DEFAULT '', image_url VARCHAR(512) NOT NULL DEFAULT '', action_type VARCHAR(64) NOT NULL DEFAULT '', action_param VARCHAR(1024) NOT NULL DEFAULT '', priority INT NOT NULL DEFAULT 0, status VARCHAR(32) NOT NULL, read_at_ms BIGINT NULL, deleted_at_ms BIGINT NULL, sent_at_ms BIGINT NOT NULL, expire_at_ms BIGINT NULL, metadata_json JSON NULL, created_at_ms BIGINT NOT NULL, updated_at_ms BIGINT NOT NULL, PRIMARY KEY (app_code, inbox_message_id), UNIQUE KEY uk_user_inbox_producer_event (app_code, user_id, producer, producer_event_id), KEY idx_user_inbox_list (app_code, user_id, message_type, deleted_at_ms, status, sent_at_ms, inbox_message_id), KEY idx_user_inbox_unread (app_code, user_id, message_type, read_at_ms, deleted_at_ms, status, expire_at_ms), KEY idx_user_inbox_aggregate (app_code, user_id, aggregate_type, aggregate_id) ); ``` 字段规则: - `producer_event_id` 是入箱幂等键。钱包提现审核、host 申请、活动奖励等每个业务事件必须生成稳定 ID。 - `producer_event_type` 保留源事件类型,方便排查和消费策略分流。 - `aggregate_type/aggregate_id` 用于同一业务对象查询,例如 `withdraw_request/wd_01`、`host_application/ha_01`。 - `status=visible` 才能被 App 列表看到。`recalled`、`hidden`、`expired` 不展示。 - `deleted_at_ms` 是用户侧删除标记,不删除事实行。 - `expire_at_ms` 只影响展示和未读,不影响审计。 - `metadata_json` 只放低敏业务快照,不放支付凭证、手机号、证件号、provider receipt 或密钥。 ### Fanout Jobs 大范围活动消息不要在 HTTP 请求里同步 fanout。后台只创建任务,cron-service 触发 activity-service 分批写入,并记录进度和失败原因。 ```sql message_fanout_jobs( app_code VARCHAR(32) NOT NULL, job_id VARCHAR(96) NOT NULL, command_id VARCHAR(128) NOT NULL, message_type VARCHAR(32) NOT NULL, target_scope VARCHAR(64) NOT NULL, target_filter_json JSON NOT NULL, template_snapshot_json JSON NOT NULL, status VARCHAR(32) NOT NULL, cursor_user_id BIGINT NOT NULL DEFAULT 0, total_count BIGINT NOT NULL DEFAULT 0, success_count BIGINT NOT NULL DEFAULT 0, failure_count BIGINT NOT NULL DEFAULT 0, batch_size INT NOT NULL DEFAULT 500, next_run_at_ms BIGINT NOT NULL DEFAULT 0, locked_by VARCHAR(96) NOT NULL DEFAULT '', locked_until_ms BIGINT NOT NULL DEFAULT 0, error_message VARCHAR(512) NOT NULL DEFAULT '', created_by VARCHAR(64) NOT NULL, created_at_ms BIGINT NOT NULL, updated_at_ms BIGINT NOT NULL, PRIMARY KEY (app_code, job_id), UNIQUE KEY uk_message_fanout_command (app_code, command_id), KEY idx_message_fanout_status (app_code, status, next_run_at_ms, updated_at_ms) ); ``` `target_filter_json` 必须由后台服务端生成,不能直接信任客户端或运营前端提交的任意 SQL 条件。首版支持以下 scope: | Scope | Meaning | Target Source | | --- | --- | --- | | `single_user` | 单个用户消息 | request target_user_id | | `user_ids` | 指定用户列表 | admin 上传或活动产出名单 | | `region` | 区域活动、区域公告 | user-service `users.region_id` | | `country` | 国家定向通知 | user-service `users.country` | | `all_active_users` | 全 App 活跃用户 | user-service active users cursor | | `all_registered_users` | 全 App 注册用户 | user-service registered users cursor | ### Global Broadcast State 如果全量公告需要“未读、已读、删除”语义,不能只保存一条公告定义后让客户端自行判断。可以二选一: - P0 方案:全量公告也通过 `message_fanout_jobs` 分批物化到 `user_inbox_messages`,实现简单,查询统一。 - P1 方案:新增 `message_broadcasts` 和 `user_broadcast_states`,列表时合并个人 inbox 和广播定义,适合超大规模全量公告。 首版建议采用 P0 物化方案,避免 read-all、delete 和未读数出现两套语义。只有当全量 fanout 成本明显不可接受时,再实现 P1 懒加载模型。 ### Unread Counter Cache 未读数可以缓存,但缓存不是事实。 ```text message:unread:{app_code}:{user_id}:{message_type} ``` 缓存规则: - cache miss 时从 `user_inbox_messages` 统计。 - 入箱、已读、read-all、删除、撤回后删除或更新缓存。 - 缓存 TTL 建议 30-120 秒,防止缓存失效遗漏导致长期错误。 - 未读数不得由客户端上报覆盖。 ## Internal RPC Surface 首版建议在 `api/proto/activity/v1` 追加 message inbox RPC,或者单独新增 `api/proto/message/v1`。如果实现仍放在 `activity-service`,可以先追加到 activity proto,但命名必须保持 message inbox 边界。 ```proto service MessageInboxService { rpc ListMessageTabs(ListMessageTabsRequest) returns (ListMessageTabsResponse); rpc ListInboxMessages(ListInboxMessagesRequest) returns (ListInboxMessagesResponse); rpc MarkInboxMessageRead(MarkInboxMessageReadRequest) returns (MarkInboxMessageReadResponse); rpc MarkInboxSectionRead(MarkInboxSectionReadRequest) returns (MarkInboxSectionReadResponse); rpc DeleteInboxMessage(DeleteInboxMessageRequest) returns (DeleteInboxMessageResponse); rpc CreateInboxMessage(CreateInboxMessageRequest) returns (CreateInboxMessageResponse); rpc CreateFanoutJob(CreateFanoutJobRequest) returns (CreateFanoutJobResponse); } ``` RPC 要求: - 所有请求必须携带 `RequestMeta.app_code` 和 `request_id`。 - App 用户读写请求必须携带 `user_id`,并由 gateway 使用 JWT 中的用户 ID 填充。 - `CreateInboxMessage` 必须携带 `producer`、`producer_event_id`、`message_type`、`target_user_id`、内容 snapshot 或 template 引用。 - `CreateFanoutJob` 必须携带后台 `command_id`,同 command 重试返回原 job。 - 查询接口不需要 `command_id`,但必须透传 `request_id` 用于追踪。 - 修改 proto 后必须运行 `make proto`,并提交生成后的 `.pb.go` / `_grpc.pb.go`。 ## App API Surface 所有接口都由 `gateway-service` 暴露,使用 `/api/v1` envelope:`{code,message,request_id,data}`。`app_code` 来自 gateway 的 App 解析或 JWT,不接受客户端 body 自报。 ### Tab Summary ```http GET /api/v1/messages/tabs ``` Response data: ```json { "sections": [ { "section": "user", "title": "用户", "unread_count": 0, "source": "tencent_im_sdk" }, { "section": "system", "title": "系统", "unread_count": 2, "source": "backend" }, { "section": "activity", "title": "活动", "unread_count": 1, "source": "backend" } ] } ``` `user.unread_count` 推荐由客户端腾讯云 IM SDK 直接提供。后端返回 `source=tencent_im_sdk`,提醒客户端不要等待后端私聊列表。 ### System And Activity List ```http GET /api/v1/messages?section=system&page_size=20&page_token= GET /api/v1/messages?section=activity&page_size=20&page_token= ``` Response data: ```json { "section": "system", "items": [ { "message_id": "msg_01", "title": "提现审核通过", "summary": "你的提现申请已通过,请等待人工转账。", "body": "", "icon_url": "", "image_url": "", "action_type": "wallet_withdraw_detail", "action_param": "withdrawal_id=wd_01", "read": false, "sent_at_ms": 1777996800000 } ], "next_page_token": "" } ``` 分页使用 `(sent_at_ms, inbox_message_id)` 游标。不要使用 offset,大用户消息量上来后 offset 翻页会越来越慢且容易重复或漏项。 ### Mark Read ```http POST /api/v1/messages/{message_id}/read POST /api/v1/messages/read-all ``` `read-all` body: ```json { "section": "system" } ``` 已读接口必须幂等:重复标记已读返回成功,不改变 `read_at_ms` 的首次已读时间。`read-all` 只影响当前 `app_code/user_id/section` 下未删除、未过期、visible 的消息。 ### Delete ```http DELETE /api/v1/messages/{message_id} ``` 删除是用户侧隐藏,设置 `deleted_at_ms`。已经删除的消息不进入列表,也不计入未读。删除不存在或不属于当前用户的消息应返回 `NOT_FOUND`,不能泄露其他用户消息是否存在。 ### User Message Section 用户消息列表由客户端腾讯云 IM SDK 获取: ```text Tencent IM SDK getConversationList() ``` 后端只补充两个能力: - `GET /api/v1/im/usersig`:已有,给客户端登录腾讯云 IM。 - `GET /api/v1/users/profiles:batch?user_ids=...`:后续可新增,用于会话列表头像、昵称、短号批量补全。底层复用 `user-service.BatchGetUsers`。 `profiles:batch` 只返回展示字段:`user_id`、`display_user_id`、`username`、`avatar`、`gender`、`country`、`region_id`、`app_code`。不能返回手机号、登录凭证、实名信息或后台身份详情。 ## Producer Rules 系统消息生产者: | Producer | Event | Section | Target | | --- | --- | --- | --- | | `wallet-service` | 充值到账、提现审核、提现失败、提现打款完成 | `system` | affected user | | `user-service` | 登录安全、账号状态、资料审核、host/agency/bd 申请结果 | `system` | affected user | | `room-service` | 被踢、被禁言、被解除 ban 等个人强相关通知 | `system` | affected user | | `activity-service` | 活动奖励、任务完成、排行榜奖励 | `activity` | affected user | | `hyapp-admin-server` | 定向运营通知、全量公告、区域活动通知 | `activity` | selected users | 生产规则: - 生产者只发领域事件或调用内部 `CreateInboxMessage`,不能直接写 `user_inbox_messages`。 - 每个生产者事件必须提供稳定 `producer_event_id`,重复消费只能生成一条 inbox。 - 房间公屏、房间礼物、麦位变更等群内事件仍走腾讯云 IM 群,不进入个人消息 tab。 - `room-service` 只有个人强相关的管理结果才进入消息 tab,例如被踢出房间、被禁言;这类消息不替代房间 IM 系统消息。 - 钱包消息必须以 wallet 交易或提现单状态为事实来源,不能由 gateway 根据接口返回自行拼通知。 - 后台运营通知必须经过 admin 权限和审计,最终由 message inbox owner 入箱。 ## Event To Inbox Flow ```mermaid sequenceDiagram participant P as Producer Service participant O as Producer Outbox participant M as MQ / Consumer participant I as Message Inbox Owner participant DB as message MySQL participant R as Redis P->>O: commit domain fact + outbox M->>O: poll or consume event M->>I: CreateInboxMessage(producer_event_id) I->>DB: insert user_inbox_messages with unique key I->>R: invalidate unread cache I-->>M: idempotent result ``` 一致性要求: - 生产者业务事实提交不能依赖消息入箱成功。 - 入箱失败由生产者 outbox 或 MQ consumer 重试。 - message inbox owner 以唯一键保证重复消费不重复发消息。 - 如果模板不存在或已停用,消费者应进入 retry 或 dead-letter,并告警;不能生成空标题消息。 - 如果目标用户不存在、跨 app 或已注销,按业务策略记录 skipped,不应无限重试。 ## Broadcast And Fanout 消息分发分三种: | Mode | Use Case | Rule | | --- | --- | --- | | `single_user` | 提现审核、申请结果、个人奖励 | 直接写一条 inbox | | `user_group` | 区域活动、主播群体通知、指定用户列表 | 创建 fanout job,分批写 inbox | | `global` | 全 App 公告 | 首版仍建议 fanout 物化;超大规模后再做 broadcast lazy state | Fanout batch 要求: - 每轮锁定一批待执行 job,使用 `locked_by/locked_until_ms` 防止多实例重复处理。 - 按 `cursor_user_id` 分页查询目标用户,不使用 offset。 - 每个目标用户的 `producer_event_id` 必须稳定,例如 `fanout:{job_id}:{user_id}`。 - 单批失败不能让整个 job 丢失,记录 `failure_count/error_message` 并继续或进入 retry。 - 目标范围按区域/国家选择时,使用 user-service 当前主数据;是否需要冻结目标用户快照由具体活动决定。 - 大型 fanout 配置必须支持 cron `interval`、`batch_size`、`lock_ttl`;失败重试次数由 activity-service owner 侧状态机控制。 ## Read Consistency - 用户消息的已读、未读和最后一条消息以腾讯云 IM 为准。 - 系统和活动消息的已读、未读以 `user_inbox_messages` 为准。 - App tab 总红点 = IM SDK unread + backend system unread + backend activity unread。 - 后端不接收客户端上报的 IM unread 作为事实,只能作为埋点或调试信息。 - `read-all` 和删除成功后,必须清理对应 section 的 Redis 未读缓存。 - 列表接口如果读到 Redis 未读数和 MySQL 列表状态短暂不一致,以 MySQL 为最终事实。 ## Error Semantics | Error | Meaning | HTTP Mapping | | --- | --- | --- | | `INVALID_SECTION` | section 不是 `system/activity` | 400 | | `MESSAGE_NOT_FOUND` | 消息不存在、已删除或不属于当前用户 | 404 | | `MESSAGE_RECALLED` | 消息已撤回,不允许展示或操作 | 404 | | `PAGE_TOKEN_INVALID` | 游标无法解析或不属于当前查询 | 400 | | `REQUEST_CONFLICT` | 同 command_id/fanout job 不同 payload | 409 | | `PRODUCER_EVENT_CONFLICT` | 同 producer_event_id 不同 payload | 409 | | `MESSAGE_INTERNAL` | DB、缓存或内部未知错误 | 500 | `request_id` 只做链路追踪,不作为消息入箱幂等键。入箱幂等键必须是 `producer_event_id` 或后台 `command_id`。 ## Security And Privacy - `app_code` 由 gateway 解析或 token 携带,不接受客户端 body 覆盖。 - `user_id` 由 JWT 提供,读写接口不能让客户端指定目标用户。 - 后台创建消息必须通过 admin 鉴权和审计,审计留在 `hyapp_admin`。 - `action_param` 必须是白名单动作参数,不允许任意外链、脚本、内嵌 HTML。 - 消息 title/summary/body 不得包含支付凭证、provider receipt、敏感证件、完整手机号或密钥。 - 用户删除消息只影响自己,不影响其他用户和后台审计。 - 用户私聊内容不进入后端业务库,不出现在系统/活动消息搜索里。 ## Configuration 首版实现需要在 `activity-service` 增加 message inbox 读取配置;`message_fanout_jobs` 由 `cron-service` 调度,不再由 activity-service 内置 worker 轮询: ```yaml message_inbox: unread_cache_ttl: 60s # services/cron-service/configs/config*.yaml tasks: message_fanout: enabled: true interval: 1s timeout: 10s lock_ttl: 30s batch_size: 500 app_codes: ["lalu"] ``` 如果消息 owner 后续拆成独立 `message-service`,这些配置迁移到新服务;gateway 只需要更新 message gRPC 地址。 ## Implementation Order 1. 追加 proto:新增 `MessageInboxService`、消息列表、未读、已读、删除、创建入箱、fanout job RPC。 2. 更新 `activity-service` MySQL initdb:新增 `message_templates`、`user_inbox_messages`、`message_fanout_jobs`。 3. 在 `activity-service` 增加 inbox domain/repository/service,先实现单用户入箱、列表、未读、已读、删除。 4. 在 `gateway-service` 增加 activity/message client 和 App HTTP:`/api/v1/messages/tabs`、`/api/v1/messages`、`/api/v1/messages/{id}/read`、`/api/v1/messages/read-all`、`DELETE /api/v1/messages/{id}`。 5. 接入第一个系统生产者,建议从提现审核或 host 申请结果开始,因为它们天然是单用户消息。 6. 增加 fanout job 和 `ActivityCronService.ProcessMessageFanoutBatch`,由 cron-service 调度,先支持 `region`、`country`、`user_ids`、`all_active_users`、`all_registered_users`。 7. 增加 `/api/v1/users/profiles:batch`,给腾讯云 IM 用户会话列表补头像、昵称、短号。 8. 增加 Redis 未读数缓存和缓存失效逻辑。 9. 补 OpenAPI 文档、Docker 配置、线上配置示例和端到端冒烟脚本。 ## Verification Matrix | Scenario | Expected | | --- | --- | | 打开消息 tab | 返回 `user/system/activity` 三个分区 | | `user` 分区 | response 标记 `source=tencent_im_sdk`,不返回私聊内容 | | 拉系统消息列表 | 只返回当前 `app_code`、当前用户、未删除、未过期、visible 的 system 消息 | | 拉活动消息列表 | 只返回当前 `app_code`、当前用户、未删除、未过期、visible 的 activity 消息 | | 重复消费同一 producer event | 只生成一条 inbox 消息 | | 同 producer event 不同 payload | 返回或记录冲突,不覆盖原消息 | | 标记已读重复请求 | 返回成功,`read_at_ms` 不被覆盖,未读数不变成负数 | | read-all | 当前 section 未读全部归零,其他 section 不受影响 | | 删除未读消息 | 消息不再展示,未读数减少 | | 删除其他用户消息 | 返回 `NOT_FOUND` | | 过期消息 | 不展示、不计入未读,但数据库事实保留 | | 全量活动通知 | fanout job 分批写入,不阻塞 Admin HTTP 请求 | | 区域活动通知 | 只给当前 app 下目标 region 用户入箱 | | 用户私聊会话 | App 从腾讯云 IM SDK 获取,后端不返回私聊内容 | | Redis unread cache miss | 从 MySQL 统计后回填缓存 | ## Test And Release Requirements 涉及 proto 时运行: ```bash make proto go test ./... ``` 涉及 DB、配置、Docker 时运行: ```bash docker compose config docker compose build ``` 涉及启动链路时实际拉起: ```bash docker compose up -d docker compose ps docker compose down ``` 最小测试覆盖: - activity-service inbox repository 单元/集成测试:幂等、分页、未读、已读、删除、过期。 - gateway transport 测试:JWT 用户 ID 生效、app_code 透传、envelope、错误映射。 - fanout batch 测试:锁 job、分页、重复 target 幂等、失败重试。 - producer consumer 测试:同 event 重复消费只入箱一次。 - profiles batch 测试:只返回安全展示字段。 ## Critical Rules - `user` 分区不落业务库,不复制腾讯云 IM 私聊内容。 - `system` 和 `activity` 必须持久化到 MySQL,不能只靠 Redis 或推送。 - 所有 inbox 事实必须带 `app_code`。 - 已读和删除必须幂等。 - 大范围活动消息必须异步 fanout。 - 房间群消息仍走腾讯云 IM 群,不进入个人消息 tab。 - `request_id` 只做追踪,不做幂等。 - 后台运营消息必须有 admin 鉴权和审计。 - 生产者不能直接写 inbox 表,只能发事件或调用 message inbox owner。