423 lines
14 KiB
Markdown
423 lines
14 KiB
Markdown
# 通用确认消息方案
|
||
|
||
本文定义 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`。
|