hyapp-server/docs/flutter对接/礼物TabFlutter对接.md

20 KiB
Raw Permalink Blame History

礼物 Tab Flutter 对接

本文描述 Flutter App 在语音房内接入礼物 Tab 的接口、字段、交互和 IM 同步规则。礼物配置、价格、扣费、房间表现和统计事实都以后端为准App 只展示 gateway 返回的数据,不在本地推算价格、余额、热度或礼物榜。

边界

  • 礼物 Tab 基础配置入口是 GET /api/v1/gift-tabs,不需要 room_id,只返回后台启用的 Tab 配置。
  • GET /api/v1/gift-tabs 不按礼物数量过滤;只要后台启用了 Tab即使该 Tab 下当前没有礼物也必须返回给 App 展示。
  • GET /api/v1/gift-tabs 会按 Tab 返回当前 active 礼物列表App 可以用它提前预加载礼物资源;无礼物的 Tab 返回 gifts: []
  • 房间送礼面板入口是 GET /api/v1/rooms/{room_id}/gift-panel,返回当前房间可送礼物、可收礼人、金币余额和房间内可展示 Tab。
  • GET /api/v1/gift-tabs 不能替代 GET /api/v1/rooms/{room_id}/gift-panelApp 真正送礼前必须以房间面板返回的数据为准。
  • 送礼只调用 POST /api/v1/rooms/gift/send
  • 金币余额以礼物面板、送礼响应里的 lucky_gift.coin_balance_after、钱包通知或 GET /api/v1/wallet/me/balances 为准;普通礼物和未中奖场景仍以后续钱包事实校准。
  • 房间内其他用户看到的礼物表现以腾讯云 IM 自定义消息 room_gift_sent 为准。
  • command_id 是送礼幂等键;同一次用户点击重试必须复用同一个值。
  • 当前 App 送礼主流程只按单个用户收礼处理:target_type=usertarget_user_ids 只传 1 个用户。
  • 幸运礼物的专门接入规则见 幸运礼物 Flutter 对接

交互流程

  1. 用户进入房间并完成 JoinRoom
  2. App 可在启动或进入房间前调用 GET /api/v1/gift-tabs 预加载 Tab 配置。
  3. 发射礼物 Tab 时调用 GET /api/v1/rooms/{room_id}/gift-panel
  4. tabs 渲染当前房间分类,用 gifts 渲染礼物网格,用 recipients 渲染收礼人。
  5. 用户选择收礼人、礼物和数量。
  6. 点击发送时生成稳定 command_id,调用 POST /api/v1/rooms/gift/send
  7. 发送成功后用响应里的 room_heatgift_rankrocket 更新房间 UI如果有 lucky_gift 且已入账,用 coin_balance_after 立即刷新金币余额。
  8. 监听房间 IM room_gift_sent,同步播放礼物动效。
  9. 普通礼物和未中奖余额仍监听私有钱包通知或主动刷新余额,不本地扣减余额作为最终值。

获取礼物 Tab 配置

GET /api/v1/gift-tabs
X-App-Code: lalu

查询参数:

参数 类型 必填 说明
region_id int64 不传时预加载全部 active 礼物;传入时只返回 GLOBAL 和该区域可用礼物。

成功响应:

{
  "code": "OK",
  "message": "ok",
  "request_id": "req_abc",
  "data": {
    "items": [
      {
        "key": "normal",
        "gift_type_code": "normal",
        "name": "普通礼物",
        "label": "Gift",
        "tab_key": "Gift",
        "status": "active",
        "order": 10,
        "sort_order": 10,
        "created_at_ms": 1710000000000,
        "updated_at_ms": 1710000000000,
        "gifts": [
          {
            "gift_id": "rose",
            "resource_id": 11,
            "gift_type_code": "normal",
            "name": "Rose",
            "coin_price": 100,
            "resource": {
              "resource_id": 11,
              "resource_code": "gift_rose",
              "resource_type": "gift",
              "name": "Rose",
              "status": "active",
              "asset_url": "https://cdn.example/rose.png"
            }
          }
        ]
      }
    ],
    "total": 1
  }
}

字段说明:

字段 类型 说明
key string Tab 稳定 key等同于礼物类型编码。
gift_type_code string 礼物类型编码,用于和 gifts[].gift_type_code 对齐。
name string 后台配置的中文显示名。
label string App Tab 展示名,来源于后台 tab_key
tab_key string 后台配置的 Tab Name。
status string 当前只返回启用中的配置,正常为 active
order int App 排序值,等同于 sort_order
sort_order int 后台配置排序值。
created_at_ms int64 创建时间Unix epoch milliseconds。
updated_at_ms int64 更新时间Unix epoch milliseconds。
gifts array 当前 Tab 下的 active 礼物列表;没有礼物时返回空数组。

获取礼物面板

GET /api/v1/rooms/{room_id}/gift-panel
Authorization: Bearer <access_token>
X-App-Code: lalu

路径参数:

参数 类型 必填 说明
room_id string 当前语音房 ID。

成功响应:

{
  "code": "OK",
  "message": "ok",
  "request_id": "req_abc",
  "data": {
    "coin_balance": 1000000,
    "recipients": [
      {
        "target_type": "user",
        "user_id": "10002",
        "seat_no": 1,
        "label": "Alice",
        "profile": {
          "user_id": "10002",
          "username": "Alice",
          "avatar": "https://cdn.example/avatar.png",
          "display_user_id": "10002",
          "vip": {},
          "level": {},
          "badges": [],
          "avatar_frame": {},
          "vehicle": {},
          "charm": 0
        }
      }
    ],
    "tabs": [
      {
        "key": "all",
        "order": 0
      },
      {
        "key": "normal",
        "gift_type_code": "normal",
        "label": "normal",
        "order": 10
      }
    ],
    "gifts": [
      {
        "gift_id": "rose",
        "resource_id": 11,
        "resource": {
          "resource_id": 11,
          "resource_code": "gift_rose",
          "resource_type": "gift",
          "name": "Rose",
          "status": "active",
          "grantable": true,
          "grant_strategy": "increase_quantity",
          "usage_scopes": ["gift"],
          "asset_url": "https://cdn.example/rose.png",
          "preview_url": "",
          "animation_url": "https://cdn.example/rose.svga",
          "metadata_json": "{}",
          "sort_order": 10,
          "created_at_ms": 1710000000000,
          "updated_at_ms": 1710000000000
        },
        "status": "active",
        "name": "Rose",
        "sort_order": 10,
        "presentation_json": "{}",
        "price_version": "v1",
        "gift_type_code": "normal",
        "charge_asset_type": "COIN",
        "coin_price": 100,
        "gift_point_amount": 100,
        "heat_value": 100,
        "effect_types": ["static", "svga"],
        "region_ids": [688],
        "created_at_ms": 1710000000000,
        "updated_at_ms": 1710000000000
      }
    ],
    "quantity_presets": [1, 9, 99, 999]
  }
}

字段说明:

字段 说明
coin_balance 当前登录用户 COIN 可用余额快照。只用于发射面板时展示,最终余额仍以后续钱包事实为准。
recipients 当前房间可收礼人。当前主流程只启用 target_type=user 的单个用户。
tabs 礼物分类。key=all 是全量分类;其他 tab 通常对应 gift_type_code
gifts 当前房间国家维度可用的 active 礼物列表。
quantity_presets 数量快捷档位,当前固定 [1,9,99,999]

礼物字段:

字段 说明
gift_id 送礼请求使用的礼物 ID。
name 展示名称。
gift_type_code 分类编码,例如 normalluckysuper_lucky
charge_asset_type 当前收费资产;普通金币礼物为 COIN
coin_price 单个礼物金币价格。仅用于展示和按钮预估,扣费以后端为准。
gift_point_amount 主播收到的礼物积分增量口径。
heat_value 房间热度增量口径。
price_version 后端价格版本。App 不需要上传。
presentation_json 展示扩展 JSON例如幸运礼物奖池 ID、角标、动效配置解析失败时按空对象处理。
effect_types 可用动效类型。App 根据本地能力选择图片、SVGA、Lottie 或兜底静态图。
resource.asset_url 礼物图标。
resource.preview_url 预览图,可为空。
resource.animation_url 礼物动效资源,可为空。
region_ids 后端配置的可用区域面板接口已经按房间区域过滤App 不再二次过滤。

Tab 渲染规则

  • tabsorder 升序展示。
  • key=all 展示全部 gifts
  • 非 all tab 用 tab.gift_type_codetab.key 过滤 gift.gift_type_code
  • 礼物网格按 gift.sort_order 升序展示;相同排序保持接口返回顺序。
  • coin_price * selected_count > coin_balance 时发送按钮置灰或引导充值,但最终仍以送礼接口错误为准。
  • resource.animation_url 只在发送成功或收到 IM 后播放,不要在列表滚动中自动播放重动效。

送礼

POST /api/v1/rooms/gift/send
Authorization: Bearer <access_token>
X-App-Code: lalu
Content-Type: application/json

请求体:

{
  "room_id": "lalu_room_001",
  "command_id": "gift_1779259000000_10001_rose_01",
  "target_type": "user",
  "target_user_ids": [10002],
  "gift_id": "rose",
  "gift_count": 9
}

参数:

字段 类型 必填 说明
room_id string 当前房间 ID。
command_id string 本次送礼动作幂等键。同一次失败重试必须复用。
target_type string 当前传 user;为空后端按 user
target_user_ids int64[] 当前只能传 1 个收礼用户。
target_user_id int64 旧单目标兼容字段;新代码优先用 target_user_ids
gift_id string 礼物 ID。
gift_count int32 礼物数量,必须大于 0。
pool_id string 幸运礼物奖池 ID普通礼物不传。

成功响应:

{
  "code": "OK",
  "message": "ok",
  "request_id": "req_abc",
  "data": {
    "result": {
      "applied": true,
      "room_version": 14,
      "server_time_ms": 1779259000000
    },
    "billing_receipt_id": "br_abc",
    "room_heat": 123456,
    "gift_rank": [
      {
        "user_id": "10001",
        "score": 900,
        "gift_value": 900,
        "updated_at_ms": 1779259000000
      }
    ],
    "room": {},
    "rocket": {},
    "lucky_gift": {
      "enabled": true,
      "draw_id": "lucky_draw_test",
      "command_id": "gift_1779259000000_10001_rose_01",
      "pool_id": "super_lucky",
      "gift_id": "rose",
      "rule_version": 12,
      "experience_pool": "novice",
      "selected_tier_id": "novice_2x",
      "multiplier_ppm": 2000000,
      "effective_reward_coins": 1800,
      "reward_status": "granted",
      "stage_feedback": false,
      "high_multiplier": false,
      "created_at_ms": 1779259000000,
      "wallet_transaction_id": "wallet_tx_lucky",
      "coin_balance_after": 8800
    }
  }
}

响应字段:

字段 说明
result.applied 本次命令是否实际落地。重复提交同一个 command_id 时可能返回 falseApp 不要重复播放发送者本地扣费动画。
result.room_version 房间版本,可用于丢弃旧房间快照。
result.server_time_ms 服务端时间。
billing_receipt_id 钱包扣费回执;非空表示扣费和房间表现已提交。
room_heat 最新房间热度。
gift_rank 最新房间礼物榜。
room 房间快照字段较大App 可按已有房间快照解析逻辑局部更新。
rocket 房间火箭状态;火箭 UI 以该字段或火箭 IM 事件更新。
lucky_gift 幸运礼物抽奖结果;普通礼物可为空。reward_status=granted 且中奖时,coin_balance_after 是当前用户返奖入账后的余额。

发送后处理:

  • 成功后立即关闭 loading保留礼物面板或按产品交互关闭。
  • room_heatgift_rank 更新房间顶部和贡献榜。
  • 播放当前用户送礼动效时按 command_id 去重,避免 HTTP 响应和 IM 各播放一次。
  • 如果 lucky_gift.reward_status=grantedlucky_gift.effective_reward_coins > 0,立即用 lucky_gift.coin_balance_after 覆盖本地金币余额。
  • 如果 lucky_gift.reward_status=pending,展示中奖到账处理中,不要生成新 command_id 重送;补偿 worker 会继续使用同一个 draw 发放。
  • 普通礼物和未中奖场景不要本地直接写最终余额;等待 WalletBalanceChanged 私有 IM 或主动刷新余额。

房间 IM 同步

送礼成功后,房间群会收到腾讯云 IM 自定义消息:

字段
MsgContent.Desc room_gift_sent
Data.event_type room_gift_sent

当前消息核心字段来自 room outbox

{
  "event_type": "room_gift_sent",
  "event_id": "room_event_xxx",
  "room_id": "lalu_room_001",
  "room_version": 14,
  "occurred_at_ms": 1779259000000,
  "actor_user_id": "10001",
  "target_user_id": "10002",
  "gift_value": 900,
  "attributes": {
    "gift_id": "rose",
    "gift_count": "9",
    "billing_receipt_id": "br_abc"
  }
}

处理规则:

  • event_id 去重。
  • event_type != room_gift_sent 时交给其他房间消息处理器。
  • actor_user_id 是送礼人,target_user_id 是收礼人。
  • attributes.gift_id 对应面板里的礼物 ID本地找不到配置时展示兜底礼物图标。
  • attributes.gift_count 当前是字符串Flutter 解析时需要 int.tryParse
  • gift_value 是本次房间价值/热度口径,不等同于客户端自己算的 coin_price * count
  • 自己发送的礼物如果已经由 HTTP 成功响应播放过,收到同一 command_id 或短时间同一礼物 IM 时要做播放去重。

错误处理

外层 envelope

{
  "code": "INVALID_ARGUMENT",
  "message": "invalid argument",
  "request_id": "req_abc",
  "data": null
}

处理规则:

场景 处理
UNAUTHENTICATED 重新登录或刷新 token。
INVALID_ARGUMENT 参数错误;记录 request_id,检查 room_idgift_id、数量和收礼人。
NOT_FOUND 房间或礼物不存在;刷新房间和礼物面板。
FAILED_PRECONDITION 用户不在房间、房间不可送礼或状态不允许;刷新房间详情。
INSUFFICIENT_BALANCE 金币不足;跳转充值或刷新余额。
CONFLICT 幂等冲突或命令状态冲突;复用原 command_id 查询/重试,不生成新命令导致重复送礼。
UPSTREAM_ERROR / 5xx 保留原 command_id 允许用户重试。

Flutter 数据模型示例

class GiftPanel {
  GiftPanel({
    required this.coinBalance,
    required this.recipients,
    required this.tabs,
    required this.gifts,
    required this.quantityPresets,
  });

  final int coinBalance;
  final List<GiftRecipient> recipients;
  final List<GiftTab> tabs;
  final List<GiftItem> gifts;
  final List<int> quantityPresets;

  factory GiftPanel.fromJson(Map<String, dynamic> json) {
    return GiftPanel(
      coinBalance: (json['coin_balance'] as num?)?.toInt() ?? 0,
      recipients: (json['recipients'] as List<dynamic>? ?? const [])
          .map((item) => GiftRecipient.fromJson(item as Map<String, dynamic>))
          .toList(),
      tabs: (json['tabs'] as List<dynamic>? ?? const [])
          .map((item) => GiftTab.fromJson(item as Map<String, dynamic>))
          .toList(),
      gifts: (json['gifts'] as List<dynamic>? ?? const [])
          .map((item) => GiftItem.fromJson(item as Map<String, dynamic>))
          .toList(),
      quantityPresets: (json['quantity_presets'] as List<dynamic>? ?? const [])
          .map((item) => (item as num).toInt())
          .toList(),
    );
  }
}

class GiftRecipient {
  GiftRecipient({
    required this.targetType,
    required this.userId,
    required this.seatNo,
    required this.label,
  });

  final String targetType;
  final String userId;
  final int seatNo;
  final String label;

  factory GiftRecipient.fromJson(Map<String, dynamic> json) {
    return GiftRecipient(
      targetType: json['target_type'] as String? ?? '',
      userId: json['user_id'] as String? ?? '',
      seatNo: (json['seat_no'] as num?)?.toInt() ?? 0,
      label: json['label'] as String? ?? '',
    );
  }
}

class GiftTab {
  GiftTab({
    required this.key,
    required this.giftTypeCode,
    required this.label,
    required this.order,
  });

  final String key;
  final String giftTypeCode;
  final String label;
  final int order;

  factory GiftTab.fromJson(Map<String, dynamic> json) {
    return GiftTab(
      key: json['key'] as String? ?? '',
      giftTypeCode: json['gift_type_code'] as String? ?? '',
      label: json['label'] as String? ?? '',
      order: (json['order'] as num?)?.toInt() ?? 0,
    );
  }
}

class GiftItem {
  GiftItem({
    required this.giftId,
    required this.name,
    required this.giftTypeCode,
    required this.coinPrice,
    required this.assetUrl,
    required this.animationUrl,
    required this.presentationJson,
  });

  final String giftId;
  final String name;
  final String giftTypeCode;
  final int coinPrice;
  final String assetUrl;
  final String animationUrl;
  final String presentationJson;

  factory GiftItem.fromJson(Map<String, dynamic> json) {
    final resource = json['resource'] as Map<String, dynamic>? ?? const {};
    return GiftItem(
      giftId: json['gift_id'] as String? ?? '',
      name: json['name'] as String? ?? '',
      giftTypeCode: json['gift_type_code'] as String? ?? '',
      coinPrice: (json['coin_price'] as num?)?.toInt() ?? 0,
      assetUrl: resource['asset_url'] as String? ?? '',
      animationUrl: resource['animation_url'] as String? ?? '',
      presentationJson: json['presentation_json'] as String? ?? '{}',
    );
  }
}

Flutter 调用示例

Future<GiftPanel> fetchGiftPanel({
  required Dio dio,
  required String roomId,
}) async {
  final response = await dio.get('/api/v1/rooms/$roomId/gift-panel');
  final body = response.data as Map<String, dynamic>;
  if (body['code'] != 'OK') {
    throw StateError('${body['code']}: ${body['message']}');
  }
  return GiftPanel.fromJson(body['data'] as Map<String, dynamic>);
}

Future<Map<String, dynamic>> sendGift({
  required Dio dio,
  required String roomId,
  required String commandId,
  required int targetUserId,
  required String giftId,
  required int giftCount,
  String? poolId,
}) async {
  final response = await dio.post(
    '/api/v1/rooms/gift/send',
    data: {
      'room_id': roomId,
      'command_id': commandId,
      'target_type': 'user',
      'target_user_ids': [targetUserId],
      'gift_id': giftId,
      'gift_count': giftCount,
      if (poolId != null && poolId.isNotEmpty) 'pool_id': poolId,
    },
  );
  final body = response.data as Map<String, dynamic>;
  if (body['code'] != 'OK') {
    throw StateError('${body['code']}: ${body['message']}');
  }
  return body['data'] as Map<String, dynamic>;
}

客户端状态建议

  • 礼物面板缓存以 room_id 为粒度;切房间必须重新拉取。
  • 房间 visible_region_id 变化或用户重新 JoinRoom 后重新拉取礼物面板。
  • 礼物图片可以缓存;animation_url 建议按需预加载当前 tab 前几项。
  • 发送按钮只防重复点击当前 command_id,不要全局锁死礼物面板。
  • HTTP 超时后不要自动生成新 command_id;让用户点击重试时复用原值。
  • 礼物墙和礼物 Tab 是两个不同接口:礼物 Tab 是可发送配置,礼物墙是当前用户收到过的礼物聚合。