hyapp-server/docs/app-message-tab-architecture.md
2026-05-09 21:47:33 +08:00

598 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 通过 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 |
### 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`
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。