# 礼物 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-panel`;App 真正送礼前必须以房间面板返回的数据为准。 - 送礼只调用 `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=user`,`target_user_ids` 只传 1 个用户。 - 幸运礼物的专门接入规则见 [幸运礼物 Flutter 对接](幸运礼物Flutter对接.md)。 ## 交互流程 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_heat`、`gift_rank`、`rocket` 更新房间 UI;如果有 `lucky_gift` 且已入账,用 `coin_balance_after` 立即刷新金币余额。 8. 监听房间 IM `room_gift_sent`,同步播放礼物动效。 9. 普通礼物和未中奖余额仍监听私有钱包通知或主动刷新余额,不本地扣减余额作为最终值。 ## 获取礼物 Tab 配置 ```http GET /api/v1/gift-tabs X-App-Code: lalu ``` 查询参数: | 参数 | 类型 | 必填 | 说明 | | --- | --- | --- | --- | | `region_id` | int64 | 否 | 不传时预加载全部 active 礼物;传入时只返回 GLOBAL 和该区域可用礼物。 | 成功响应: ```json { "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 礼物列表;没有礼物时返回空数组。 | ## 获取礼物面板 ```http GET /api/v1/rooms/{room_id}/gift-panel Authorization: Bearer X-App-Code: lalu ``` 路径参数: | 参数 | 类型 | 必填 | 说明 | | --- | --- | --- | --- | | `room_id` | string | 是 | 当前语音房 ID。 | 成功响应: ```json { "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` | 分类编码,例如 `normal`、`lucky`、`super_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 渲染规则 - `tabs` 按 `order` 升序展示。 - `key=all` 展示全部 `gifts`。 - 非 all tab 用 `tab.gift_type_code` 或 `tab.key` 过滤 `gift.gift_type_code`。 - 礼物网格按 `gift.sort_order` 升序展示;相同排序保持接口返回顺序。 - `coin_price * selected_count > coin_balance` 时发送按钮置灰或引导充值,但最终仍以送礼接口错误为准。 - `resource.animation_url` 只在发送成功或收到 IM 后播放,不要在列表滚动中自动播放重动效。 ## 送礼 ```http POST /api/v1/rooms/gift/send Authorization: Bearer X-App-Code: lalu Content-Type: application/json ``` 请求体: ```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;普通礼物不传。 | 成功响应: ```json { "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` 时可能返回 `false`,App 不要重复播放发送者本地扣费动画。 | | `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_heat` 和 `gift_rank` 更新房间顶部和贡献榜。 - 播放当前用户送礼动效时按 `command_id` 去重,避免 HTTP 响应和 IM 各播放一次。 - 如果 `lucky_gift.reward_status=granted` 且 `lucky_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: ```json { "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: ```json { "code": "INVALID_ARGUMENT", "message": "invalid argument", "request_id": "req_abc", "data": null } ``` 处理规则: | 场景 | 处理 | | --- | --- | | `UNAUTHENTICATED` | 重新登录或刷新 token。 | | `INVALID_ARGUMENT` | 参数错误;记录 `request_id`,检查 `room_id`、`gift_id`、数量和收礼人。 | | `NOT_FOUND` | 房间或礼物不存在;刷新房间和礼物面板。 | | `FAILED_PRECONDITION` | 用户不在房间、房间不可送礼或状态不允许;刷新房间详情。 | | `INSUFFICIENT_BALANCE` | 金币不足;跳转充值或刷新余额。 | | `CONFLICT` | 幂等冲突或命令状态冲突;复用原 `command_id` 查询/重试,不生成新命令导致重复送礼。 | | `UPSTREAM_ERROR` / 5xx | 保留原 `command_id` 允许用户重试。 | ## Flutter 数据模型示例 ```dart class GiftPanel { GiftPanel({ required this.coinBalance, required this.recipients, required this.tabs, required this.gifts, required this.quantityPresets, }); final int coinBalance; final List recipients; final List tabs; final List gifts; final List quantityPresets; factory GiftPanel.fromJson(Map json) { return GiftPanel( coinBalance: (json['coin_balance'] as num?)?.toInt() ?? 0, recipients: (json['recipients'] as List? ?? const []) .map((item) => GiftRecipient.fromJson(item as Map)) .toList(), tabs: (json['tabs'] as List? ?? const []) .map((item) => GiftTab.fromJson(item as Map)) .toList(), gifts: (json['gifts'] as List? ?? const []) .map((item) => GiftItem.fromJson(item as Map)) .toList(), quantityPresets: (json['quantity_presets'] as List? ?? 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 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 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 json) { final resource = json['resource'] as Map? ?? 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 调用示例 ```dart Future 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; if (body['code'] != 'OK') { throw StateError('${body['code']}: ${body['message']}'); } return GiftPanel.fromJson(body['data'] as Map); } Future> 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; if (body['code'] != 'OK') { throw StateError('${body['code']}: ${body['message']}'); } return body['data'] as Map; } ``` ## 客户端状态建议 - 礼物面板缓存以 `room_id` 为粒度;切房间必须重新拉取。 - 房间 `visible_region_id` 变化或用户重新 JoinRoom 后重新拉取礼物面板。 - 礼物图片可以缓存;`animation_url` 建议按需预加载当前 tab 前几项。 - 发送按钮只防重复点击当前 `command_id`,不要全局锁死礼物面板。 - HTTP 超时后不要自动生成新 `command_id`;让用户点击重试时复用原值。 - 礼物墙和礼物 Tab 是两个不同接口:礼物 Tab 是可发送配置,礼物墙是当前用户收到过的礼物聚合。