478 lines
12 KiB
Markdown
478 lines
12 KiB
Markdown
# 通用确认消息 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<ConfirmAction> 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<String, dynamic> 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<String, dynamic>.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<String, dynamic> 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<String, dynamic> 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<String, dynamic>.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<ConfirmStatus> 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<ConfirmStatus> 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<Map<String, ConfirmStatus>> batchRefreshConfirmStatus(List<String> 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<String, dynamic>.from(item as Map)).confirmId:
|
||
ConfirmStatus.fromJson(Map<String, dynamic>.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` 时不弹业务成功,只刷新状态后展示最终状态。
|