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