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

12 KiB
Raw Permalink Blame History

通用确认消息 Flutter 对接

本文定义 Flutter 对接 im_confirm 私聊确认消息所需的 IM 解析、HTTP 接口、状态刷新和按钮置灰规则。业务类型首版是 host / agency / bd 身份邀请Flutter 只做通用展示,不写业务分支。

通用约定

Base URL

https://{api_host}

本地开发:

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

成功:

{
  "code": "OK",
  "message": "ok",
  "request_id": "req_xxx",
  "data": {}
}

失败:

{
  "code": "CONFLICT",
  "message": "conflict",
  "request_id": "req_xxx"
}

IM 消息格式

腾讯 IM C2C 自定义消息:

TIM 字段
Desc im_confirm
Ext im_confirm
Data JSON 字符串

Data 示例:

{
  "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_typebusiness_subtype 当成接口参数。确认接口只需要 confirm_id

状态含义

状态 Flutter 展示
pending 展示可点击按钮
processing 按钮置灰,显示处理中或本地 loading
accepted 按钮置灰,接受态
rejected 按钮置灰,拒绝态
cancelled 按钮置灰,已取消
expired 按钮置灰,已过期
failed 按钮置灰,可提示稍后重试或联系客服

按钮是否可点只看最新状态是否为 pending

接口 1接受

地址:

POST /api/v1/message/action-confirms/{confirm_id}/accept

参数:

{
  "command_id": "cfm_123_accept_001"
}

返回值 data

{
  "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拒绝

地址:

POST /api/v1/message/action-confirms/{confirm_id}/reject

参数:

{
  "command_id": "cfm_123_reject_001",
  "reason": ""
}

返回值 data

{
  "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批量刷新状态

地址:

POST /api/v1/message/action-confirms/status

参数:

{
  "confirm_ids": ["cfm_123", "cfm_456"]
}

返回值 data

{
  "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按会话刷新状态

地址:

GET /api/v1/message/action-confirms?peer_user_id=10001&page_size=50

参数:

参数 必填 说明
peer_user_id 当前会话对方用户 ID
page_size 默认 50最大 100
page_token 下一页游标

返回值 data

{
  "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 数据模型

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 解析

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 调用

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

状态刷新

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 时不弹业务成功,只刷新状态后展示最终状态。