# 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","fami","huwaa"]
```

如果消息 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。
