20 KiB
20 KiB
礼物 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 对接。
交互流程
- 用户进入房间并完成
JoinRoom。 - App 可在启动或进入房间前调用
GET /api/v1/gift-tabs预加载 Tab 配置。 - 发射礼物 Tab 时调用
GET /api/v1/rooms/{room_id}/gift-panel。 - 用
tabs渲染当前房间分类,用gifts渲染礼物网格,用recipients渲染收礼人。 - 用户选择收礼人、礼物和数量。
- 点击发送时生成稳定
command_id,调用POST /api/v1/rooms/gift/send。 - 发送成功后用响应里的
room_heat、gift_rank、rocket更新房间 UI;如果有lucky_gift且已入账,用coin_balance_after立即刷新金币余额。 - 监听房间 IM
room_gift_sent,同步播放礼物动效。 - 普通礼物和未中奖余额仍监听私有钱包通知或主动刷新余额,不本地扣减余额作为最终值。
获取礼物 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 |
分类编码,例如 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 后播放,不要在列表滚动中自动播放重动效。
送礼
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 时可能返回 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:
{
"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_id、gift_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 是可发送配置,礼物墙是当前用户收到过的礼物聚合。