# 通用确认消息 Flutter 对接 本文定义 Flutter 对接 `im_confirm` 私聊确认消息所需的 IM 解析、HTTP 接口、状态刷新和按钮置灰规则。业务类型首版是 `host / agency / bd` 身份邀请,Flutter 只做通用展示,不写业务分支。 ## 通用约定 ### Base URL ```text https://{api_host} ``` 本地开发: ```text http://127.0.0.1:13000 ``` ### Headers | Header | 必填 | 说明 | | --- | --- | --- | | `Authorization: Bearer {access_token}` | 是 | 登录后的 App token | | `Content-Type: application/json` | POST 必填 | 请求体 JSON | | `X-App-Code` | 否 | 多 App 包场景可传,通常由 gateway 解析 | ### 响应 Envelope 成功: ```json { "code": "OK", "message": "ok", "request_id": "req_xxx", "data": {} } ``` 失败: ```json { "code": "CONFLICT", "message": "conflict", "request_id": "req_xxx" } ``` ## IM 消息格式 腾讯 IM C2C 自定义消息: | TIM 字段 | 值 | | --- | --- | | `Desc` | `im_confirm` | | `Ext` | `im_confirm` | | `Data` | JSON 字符串 | `Data` 示例: ```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 } ``` 字段说明: | 字段 | 类型 | 说明 | | --- | --- | --- | | `type` | string | 固定 `im_confirm` | | `version` | int | 当前为 `1` | | `confirm_id` | string | 确认消息 ID,后续接口只传这个 | | `business_type` | string | 业务类型,只用于日志和埋点 | | `business_subtype` | string | 业务子类型,只用于展示或埋点 | | `msg` | string | 气泡文案 | | `status` | string | 初始状态,一般为 `pending` | | `actions` | array | 按钮配置 | | `created_at_ms` | int64 | 创建时间 | | `expire_at_ms` | int64 | 过期时间,可能为 0 | Flutter 不要把 `business_type`、`business_subtype` 当成接口参数。确认接口只需要 `confirm_id`。 ## 状态含义 | 状态 | Flutter 展示 | | --- | --- | | `pending` | 展示可点击按钮 | | `processing` | 按钮置灰,显示处理中或本地 loading | | `accepted` | 按钮置灰,接受态 | | `rejected` | 按钮置灰,拒绝态 | | `cancelled` | 按钮置灰,已取消 | | `expired` | 按钮置灰,已过期 | | `failed` | 按钮置灰,可提示稍后重试或联系客服 | 按钮是否可点只看最新状态是否为 `pending`。 ## 接口 1:接受 地址: ```text POST /api/v1/message/action-confirms/{confirm_id}/accept ``` 参数: ```json { "command_id": "cfm_123_accept_001" } ``` 返回值 `data`: ```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:用户点击 `actions.action=accept` 的按钮时调用。 ## 接口 2:拒绝 地址: ```text POST /api/v1/message/action-confirms/{confirm_id}/reject ``` 参数: ```json { "command_id": "cfm_123_reject_001", "reason": "" } ``` 返回值 `data`: ```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:用户点击 `actions.action=reject` 的按钮时调用。 ## 接口 3:批量刷新状态 地址: ```text POST /api/v1/message/action-confirms/status ``` 参数: ```json { "confirm_ids": ["cfm_123", "cfm_456"] } ``` 返回值 `data`: ```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_confirm.confirm_id` 后批量刷新。 - App 从后台回到前台时,刷新当前可见页面的确认消息状态。 - 接口返回的状态覆盖 IM 初始 `status`。 ## 接口 4:按会话刷新状态 地址: ```text GET /api/v1/message/action-confirms?peer_user_id=10001&page_size=50 ``` 参数: | 参数 | 必填 | 说明 | | --- | --- | --- | | `peer_user_id` | 是 | 当前会话对方用户 ID | | `page_size` | 否 | 默认 50,最大 100 | | `page_token` | 否 | 下一页游标 | 返回值 `data`: ```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 SDK 本地消息无法快速遍历 `confirm_id` 时使用。 - 当前用户由 token 决定,Flutter 只传对方 `peer_user_id`。 ## Flutter 数据模型 ```dart class ImConfirmMessage { final String confirmId; final String businessType; final String businessSubtype; final String msg; final String status; final List actions; final int createdAtMs; final int expireAtMs; ImConfirmMessage({ required this.confirmId, required this.businessType, required this.businessSubtype, required this.msg, required this.status, required this.actions, required this.createdAtMs, required this.expireAtMs, }); bool get canOperate => status == 'pending'; factory ImConfirmMessage.fromJson(Map json) { return ImConfirmMessage( confirmId: (json['confirm_id'] ?? '').toString(), businessType: (json['business_type'] ?? '').toString(), businessSubtype: (json['business_subtype'] ?? '').toString(), msg: (json['msg'] ?? '').toString(), status: (json['status'] ?? 'pending').toString(), actions: ((json['actions'] as List?) ?? []) .map((item) => ConfirmAction.fromJson(Map.from(item as Map))) .toList(), createdAtMs: (json['created_at_ms'] as num?)?.toInt() ?? 0, expireAtMs: (json['expire_at_ms'] as num?)?.toInt() ?? 0, ); } } class ConfirmAction { final String action; final String label; final String style; ConfirmAction({ required this.action, required this.label, required this.style, }); factory ConfirmAction.fromJson(Map json) { return ConfirmAction( action: (json['action'] ?? '').toString(), label: (json['label'] ?? '').toString(), style: (json['style'] ?? '').toString(), ); } } class ConfirmStatus { final String confirmId; final String status; final String processedAction; final int processedAtMs; ConfirmStatus({ required this.confirmId, required this.status, required this.processedAction, required this.processedAtMs, }); bool get canOperate => status == 'pending'; factory ConfirmStatus.fromJson(Map json) { return ConfirmStatus( confirmId: (json['confirm_id'] ?? '').toString(), status: (json['status'] ?? '').toString(), processedAction: (json['processed_action'] ?? '').toString(), processedAtMs: (json['processed_at_ms'] as num?)?.toInt() ?? 0, ); } } ``` ## IM 解析 ```dart ImConfirmMessage? parseImConfirmCustomData(String data) { final dynamic decoded = jsonDecode(data); if (decoded is! Map) return null; final map = Map.from(decoded); if (map['type'] != 'im_confirm') return null; final confirmId = (map['confirm_id'] ?? '').toString(); if (confirmId.isEmpty) return null; return ImConfirmMessage.fromJson(map); } ``` 解析规则: - 只处理 `type == im_confirm`。 - `confirm_id` 为空时按普通不支持消息处理。 - `msg` 直接展示,不在 Flutter 端拼接业务文案。 - `actions` 为空时只展示文案,不展示按钮。 ## API 调用 ```dart String newConfirmCommandId(String confirmId, String action) { return '${confirmId}_${action}_${DateTime.now().millisecondsSinceEpoch}'; } Future acceptConfirm(String confirmId) async { final response = await http.post( Uri.parse('$baseUrl/api/v1/message/action-confirms/$confirmId/accept'), headers: authJsonHeaders(), body: jsonEncode({ 'command_id': newConfirmCommandId(confirmId, 'accept'), }), ); return parseConfirmStatusEnvelope(response); } Future rejectConfirm(String confirmId, {String reason = ''}) async { final response = await http.post( Uri.parse('$baseUrl/api/v1/message/action-confirms/$confirmId/reject'), headers: authJsonHeaders(), body: jsonEncode({ 'command_id': newConfirmCommandId(confirmId, 'reject'), 'reason': reason, }), ); return parseConfirmStatusEnvelope(response); } ``` `command_id` 每次点击生成一个新值。网络重试同一次请求时可以复用同一个 `command_id`。 ## 状态刷新 ```dart Future> batchRefreshConfirmStatus(List confirmIds) async { final ids = confirmIds.where((id) => id.isNotEmpty).toSet().toList(); if (ids.isEmpty) return {}; final response = await http.post( Uri.parse('$baseUrl/api/v1/message/action-confirms/status'), headers: authJsonHeaders(), body: jsonEncode({'confirm_ids': ids}), ); final data = parseEnvelopeData(response); final items = (data['items'] as List? ?? []); return { for (final item in items) ConfirmStatus.fromJson(Map.from(item as Map)).confirmId: ConfirmStatus.fromJson(Map.from(item as Map)), }; } ``` 刷新时机: - 进入 IM 会话页。 - App resume。 - 收到新的 `im_confirm`。 - 接受或拒绝接口返回 `CONFLICT`。 ## UI 规则 - 气泡文案展示 `msg`。 - `status == pending` 时展示 `actions` 里的按钮。 - `status != pending` 时按钮置灰,不能再次点击。 - 本地发起请求后,当前按钮进入 loading,同一条消息两个按钮都置灰。 - 接口成功后,用返回状态覆盖本地状态。 - 接口失败但不是鉴权错误时,调用状态接口刷新一次。 按钮样式建议: | style | 展示 | | --- | --- | | `primary` | 主按钮,例如绿色 Accept | | `secondary` | 次按钮,例如描边 Reject | Flutter 不要根据 `business_subtype` 写死按钮文案,按钮文案以 `actions.label` 为准。 ## 错误处理 | code | Flutter 处理 | | --- | --- | | `UNAUTHORIZED` | 重新登录 | | `PERMISSION_DENIED` | 置灰并提示无权限 | | `NOT_FOUND` | 置灰,消息已失效 | | `CONFLICT` | 调状态接口刷新,按最新状态置灰 | | `UPSTREAM_ERROR` | 解除 loading,允许稍后重试 | | `INTERNAL_ERROR` | 解除 loading,提示稍后重试 | 重复点击、跨设备已处理、旧消息重新打开,都以状态接口返回为准。 ## 对接检查 - 收到 `im_confirm` 后能渲染文案和两个按钮。 - 点击 Accept 后调用接受接口,成功后按钮置灰。 - 点击 Reject 后调用拒绝接口,成功后按钮置灰。 - 同一条消息重复点击不会重复请求;如果请求已经发出,两个按钮都置灰。 - App 重新进入会话后,通过状态接口刷新旧消息状态。 - 另一个设备处理后,本设备 resume 能刷新为终态。 - `CONFLICT` 时不弹业务成功,只刷新状态后展示最终状态。