hyapp-server/docs/flutter对接/通用确认消息Flutter对接.md
2026-06-25 19:57:16 +08:00

478 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 通用确认消息 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` 时不弹业务成功,只刷新状态后展示最终状态。