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