# 通用确认消息方案

本文定义 App 私聊中可操作确认消息的后端方案。首个业务场景是 `host / agency / bd` 身份邀请，后续其他业务只新增 `business_type` 处理器，不改变 Flutter 的通用消息渲染和确认接口。

## 目标

- 腾讯云 IM 私聊里支持通用确认消息，App 可以展示文案和 `Accept / Reject` 等操作按钮。
- App 点击按钮后只提交 `confirm_id` 和动作，不提交业务参数。
- 后端根据 `confirm_id` 查出业务类型、业务主键、发送人、接收人和当前状态，再调用对应 owner service 执行业务。
- 处理成功后确认消息进入终态，App 再次渲染同一条 IM 时按钮置灰。
- 当前用户只能处理发给自己的确认消息，不能替别人确认，也不能枚举别人的消息状态。

## 非目标

- 不用 `confirm` 表替代业务事实表。`role_invitations.status` 仍然是身份邀请的权威状态。
- 不让客户端传 `business_type`、`business_id`、`sender_user_id` 或 `receiver_user_id` 来决定业务行为。
- 不把确认消息做成房间状态、连接态或腾讯云 IM 会话状态的 owner。
- 本方案不包含 `bd_leader` 邀请，当前只覆盖 `host / agency / bd`。

## 服务边界

| 模块 | 职责 |
| --- | --- |
| `gateway-service` | 提供 `/api/v1` HTTP 入口，透传当前登录用户和 `app_code`，不直接写确认表 |
| `message action module` | 确认消息 owner，首版落在 `activity-service/internal/service/message`；负责确认表、状态机、幂等和业务分发 |
| `user-service` | `host / agency / bd` 业务 owner，负责 `role_invitations` 和 `ProcessRoleInvitation` |
| `notice-service` | 只负责把确认消息投递到腾讯云 IM C2C，不执行确认业务 |
| 腾讯云 IM | 私聊消息、离线、漫游和客户端长连接 |

首版物理落点建议：

- 确认表放在 `hyapp_activity`，由 `activity-service` 的 message 模块管理。
- `gateway-service` 新增 action confirm HTTP handler，内部调用 activity-service gRPC。
- `notice-service` 消费 message action outbox，发送腾讯 IM C2C 自定义消息。

## 总流程

```mermaid
sequenceDiagram
  participant A as Sender App
  participant Gateway as gateway-service
  participant User as user-service
  participant UserDB as hyapp_user
  participant Message as activity-service message module
  participant MsgDB as hyapp_activity
  participant Notice as notice-service
  participant TIM as Tencent IM
  participant B as Receiver App

  A->>Gateway: 创建 host/agency/bd 邀请
  Gateway->>User: InviteHost / InviteAgency / InviteBD
  User->>UserDB: tx 写 role_invitations + user_outbox
  User-->>Gateway: invitation
  Gateway-->>A: invitation
  Message->>UserDB: consume RoleInvitationCreated
  Message->>MsgDB: 创建 message_action_confirms + message_action_outbox
  Notice->>MsgDB: consume ConfirmMessageCreated
  Notice->>TIM: C2C TIMCustomElem(type=im_confirm)
  TIM-->>B: IM 确认消息
  B->>Gateway: POST /api/v1/message/action-confirms/{confirm_id}/accept
  Gateway->>Message: AcceptActionConfirm
  Message->>MsgDB: pending -> processing
  Message->>User: ProcessRoleInvitation(action=accept)
  User->>UserDB: tx 推进 role_invitations 终态
  Message->>MsgDB: processing -> accepted
  Gateway-->>B: confirm status accepted
```

## 数据表

### message_action_confirms

确认消息的当前状态表。

```sql
CREATE TABLE message_action_confirms (
  app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
  confirm_id VARCHAR(96) NOT NULL,
  business_type VARCHAR(64) NOT NULL,
  business_subtype VARCHAR(64) NOT NULL DEFAULT '',
  business_id VARCHAR(96) NOT NULL,
  sender_user_id BIGINT NOT NULL,
  receiver_user_id BIGINT NOT NULL,
  status VARCHAR(32) NOT NULL,
  message_text VARCHAR(1024) NOT NULL,
  actions_json JSON NOT NULL,
  payload_json JSON NULL,
  source_name VARCHAR(64) NOT NULL,
  source_event_id VARCHAR(128) NOT NULL,
  processing_action VARCHAR(32) NOT NULL DEFAULT '',
  processing_command_id VARCHAR(96) NOT NULL DEFAULT '',
  locked_by VARCHAR(128) NOT NULL DEFAULT '',
  lock_until_ms BIGINT NOT NULL DEFAULT 0,
  processed_action VARCHAR(32) NOT NULL DEFAULT '',
  processed_command_id VARCHAR(96) NOT NULL DEFAULT '',
  processed_by_user_id BIGINT NULL,
  processed_at_ms BIGINT NULL,
  expire_at_ms BIGINT NULL,
  created_at_ms BIGINT NOT NULL,
  updated_at_ms BIGINT NOT NULL,
  PRIMARY KEY (app_code, confirm_id),
  UNIQUE KEY uk_confirm_source_event (app_code, source_name, source_event_id),
  KEY idx_confirm_receiver_status (app_code, receiver_user_id, status, created_at_ms),
  KEY idx_confirm_peer (app_code, receiver_user_id, sender_user_id, created_at_ms),
  KEY idx_confirm_business (app_code, business_type, business_id)
);
```

字段规则：

- `confirm_id` 由服务端生成，例如 `cfm_xxx`。
- `business_type` 首版支持 `role_invitation`。
- `business_subtype` 首版支持 `host`、`agency`、`bd`。
- `business_id` 对 `role_invitation` 来说就是 `role_invitations.invitation_id`。
- `sender_user_id` 是邀请发起人，`receiver_user_id` 是目标用户。
- `message_text` 是 IM 展示快照，业务判断不能依赖它。
- `actions_json` 首版为 `["accept","reject"]`。
- `payload_json` 只放展示扩展，例如邀请人昵称、公会名、头像等。
- `source_name + source_event_id` 用于从 owner outbox 重试时幂等创建确认消息。

### message_action_outbox

确认消息投递事件表，由 `notice-service` 消费后发腾讯 IM。

```sql
CREATE TABLE message_action_outbox (
  app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
  event_id VARCHAR(128) NOT NULL,
  event_type VARCHAR(64) NOT NULL,
  confirm_id VARCHAR(96) NOT NULL,
  receiver_user_id BIGINT NOT NULL,
  status VARCHAR(32) NOT NULL,
  payload_json JSON NOT NULL,
  created_at_ms BIGINT NOT NULL,
  retry_count INT NOT NULL DEFAULT 0,
  last_error VARCHAR(512) NOT NULL DEFAULT '',
  PRIMARY KEY (app_code, event_id),
  KEY idx_message_action_outbox_status (app_code, status, created_at_ms)
);
```

首版事件：

- `ConfirmMessageCreated`：发送一条新的 `im_confirm`。
- 后续如果需要跨设备实时置灰，可新增 `ConfirmMessageProcessed`，投递状态变更 IM。首版可以通过状态接口刷新。

## 状态机

| 状态 | 说明 |
| --- | --- |
| `pending` | 待处理，App 展示可点击按钮 |
| `processing` | 后端正在执行，App 按钮置灰并显示处理中 |
| `accepted` | 已接受，终态 |
| `rejected` | 已拒绝，终态 |
| `cancelled` | 业务方取消，终态 |
| `expired` | 超时未处理，终态 |
| `failed` | 后端处理失败且需要人工排查，终态 |

状态推进规则：

- 只有 `receiver_user_id == 当前登录用户` 才能接受或拒绝。
- `pending` 才能进入 `processing`。
- `processing` 通过 `locked_by + lock_until_ms` 防止重复点击并发执行。
- owner service 成功后，`processing` 进入 `accepted` 或 `rejected`。
- 如果服务在 owner service 成功后、confirm 状态更新前崩溃，下一次同 `confirm_id` 请求必须用 `business_type + business_id` 查询 owner 当前事实，修复 confirm 状态。

## 业务类型

### role_invitation

首版处理 `host / agency / bd`。

| subtype | business_id | accept 动作 | reject 动作 |
| --- | --- | --- | --- |
| `host` | `role_invitations.invitation_id` | 调 user-service `ProcessRoleInvitation(action=accept)` | 调 user-service `ProcessRoleInvitation(action=reject)` |
| `agency` | `role_invitations.invitation_id` | 调 user-service `ProcessRoleInvitation(action=accept)` | 调 user-service `ProcessRoleInvitation(action=reject)` |
| `bd` | `role_invitations.invitation_id` | 调 user-service `ProcessRoleInvitation(action=accept)` | 调 user-service `ProcessRoleInvitation(action=reject)` |

`role_invitation` 的最终状态以 user-service 为准。confirm 模块只保存按钮状态和业务路由。

## HTTP 接口

所有接口都走 `/api/v1` envelope。

### 接受确认消息

地址：

```text
POST /api/v1/message/action-confirms/{confirm_id}/accept
```

参数：

```json
{
  "command_id": "cfm_123_accept_001"
}
```

返回值：

```json
{
  "confirm_id": "cfm_123",
  "business_type": "role_invitation",
  "business_subtype": "host",
  "business_id": "987",
  "status": "accepted",
  "processed_action": "accept",
  "processed_at_ms": 1710000001000
}
```

相关 IM：收到 `type=im_confirm` 且 `confirm_id=cfm_123` 的消息后，点击 `accept` 调此接口。

### 拒绝确认消息

地址：

```text
POST /api/v1/message/action-confirms/{confirm_id}/reject
```

参数：

```json
{
  "command_id": "cfm_123_reject_001",
  "reason": ""
}
```

返回值：

```json
{
  "confirm_id": "cfm_123",
  "business_type": "role_invitation",
  "business_subtype": "host",
  "business_id": "987",
  "status": "rejected",
  "processed_action": "reject",
  "processed_at_ms": 1710000001000
}
```

相关 IM：收到 `type=im_confirm` 且 `confirm_id=cfm_123` 的消息后，点击 `reject` 调此接口。

### 批量查询确认状态

地址：

```text
POST /api/v1/message/action-confirms/status
```

参数：

```json
{
  "confirm_ids": ["cfm_123", "cfm_456"]
}
```

返回值：

```json
{
  "items": [
    {
      "confirm_id": "cfm_123",
      "business_type": "role_invitation",
      "business_subtype": "host",
      "business_id": "987",
      "status": "accepted",
      "processed_action": "accept",
      "processed_at_ms": 1710000001000
    }
  ]
}
```

相关 IM：App 打开会话或回到前台时，收集当前页面内的 `confirm_id` 后调用该接口刷新按钮状态。

### 按会话查询确认状态

地址：

```text
GET /api/v1/message/action-confirms?peer_user_id=10001&page_size=50
```

参数：

| 参数 | 必填 | 说明 |
| --- | --- | --- |
| `peer_user_id` | 是 | 对方用户 ID；当前用户从 token 取，不能由客户端传 |
| `page_size` | 否 | 默认 50，最大 100 |
| `page_token` | 否 | 分页游标 |

返回值：

```json
{
  "items": [
    {
      "confirm_id": "cfm_123",
      "sender_user_id": "10001",
      "receiver_user_id": "10002",
      "business_type": "role_invitation",
      "business_subtype": "host",
      "business_id": "987",
      "status": "pending",
      "processed_action": "",
      "processed_at_ms": 0,
      "created_at_ms": 1710000000000
    }
  ],
  "next_page_token": ""
}
```

相关 IM：App 如果无法从本地消息列表快速收集 `confirm_id`，可以按当前会话刷新最近确认消息状态。

## IM 负载

腾讯 IM 使用 C2C `TIMCustomElem`。

```json
{
  "type": "im_confirm",
  "version": 1,
  "confirm_id": "cfm_123",
  "business_type": "role_invitation",
  "business_subtype": "host",
  "msg": "Alice has invited you to join ABC guild",
  "status": "pending",
  "actions": [
    {
      "action": "reject",
      "label": "Reject",
      "style": "secondary"
    },
    {
      "action": "accept",
      "label": "Accept",
      "style": "primary"
    }
  ],
  "created_at_ms": 1710000000000,
  "expire_at_ms": 1710604800000
}
```

腾讯 IM 字段建议：

| TIM 字段 | 值 |
| --- | --- |
| `Desc` | `im_confirm` |
| `Ext` | `im_confirm` |
| `Data` | 上面的 JSON 字符串 |

App 只依赖 `type`、`confirm_id`、`msg`、`status` 和 `actions`。`business_type` 只用于日志和埋点，不用于 App 决定业务接口。

## 创建消息规则

`host / agency / bd` 邀请创建成功后，user-service 写 `RoleInvitationCreated` outbox。payload 必须包含：

```json
{
  "event_type": "RoleInvitationCreated",
  "invitation_id": "987",
  "invitation_type": "host",
  "status": "pending",
  "inviter_user_id": "10001",
  "target_user_id": "10002",
  "agency_name": "ABC guild",
  "created_at_ms": 1710000000000
}
```

message action worker 根据该 payload 创建：

- `business_type = role_invitation`
- `business_subtype = invitation_type`
- `business_id = invitation_id`
- `sender_user_id = inviter_user_id`
- `receiver_user_id = target_user_id`
- `actions_json = ["accept","reject"]`

## 错误码

| HTTP | code | 场景 |
| --- | --- | --- |
| 400 | `INVALID_ARGUMENT` | `confirm_id`、`command_id` 或 action 参数非法 |
| 401 | `UNAUTHORIZED` | 未登录或 token 无效 |
| 403 | `PERMISSION_DENIED` | 当前用户不是接收人 |
| 404 | `NOT_FOUND` | `confirm_id` 不存在 |
| 409 | `CONFLICT` | 已处理、已过期、处理中或业务状态不允许操作 |
| 502 | `UPSTREAM_ERROR` | owner service 不可用 |

## 开发顺序

1. 在 activity-service message 模块新增 `message_action_confirms` 和 `message_action_outbox`。
2. 新增 `ActionConfirmService` gRPC：`AcceptActionConfirm`、`RejectActionConfirm`、`BatchGetActionConfirmStatus`、`ListConversationActionConfirms`。
3. gateway 新增 HTTP 接口并调用 action confirm gRPC。
4. user-service 邀请创建事务写 `RoleInvitationCreated` outbox。
5. activity-service 增加 user outbox consumer，创建 confirm row 和 message action outbox。
6. notice-service 增加 message action outbox consumer，发送 `im_confirm`。
7. 补 `role_invitation` business handler，接受/拒绝时调用 user-service `ProcessRoleInvitation`。
8. 补状态修复逻辑：confirm 仍 pending/processing 但 user-service 邀请已经终态时，以 user-service 状态回写 confirm。

## 验证

- 创建 Host 邀请后，能查到 `role_invitations.pending`、`message_action_confirms.pending` 和一条待投递 outbox。
- notice-service 投递后，App 收到 `type=im_confirm`。
- 接收人接受后，`role_invitations.status=accepted`，confirm 状态为 `accepted`。
- 接收人拒绝后，`role_invitations.status=rejected`，confirm 状态为 `rejected`。
- 非接收人调用接受/拒绝接口返回 `PERMISSION_DENIED`。
- 重复点击同一个动作返回同一个终态，不重复创建业务事实。
- 已接受后再拒绝返回 `CONFLICT`，状态接口返回 `accepted`。
