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

577 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 礼物 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 <access_token>
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 <access_token>
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<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 调用示例
```dart
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 是可发送配置,礼物墙是当前用户收到过的礼物聚合。