577 lines
20 KiB
Markdown
577 lines
20 KiB
Markdown
# 礼物 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 是可发送配置,礼物墙是当前用户收到过的礼物聚合。
|