# 礼物 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",
            "vip_level": 0,
            "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 礼物列表；没有礼物时返回空数组。 |

`gifts[].vip_level` 是后台明确配置的 VIP 等级。`gift_type_code=noble|vip` 时为正整数；其他类型固定为 `0`，不能根据价格、名称或排序推断。VIP 礼物左上角标签必须读取 `/api/v1/vip/packages` 中同等级、`benefit_code=vip_badge_identity` 的 `resource`，不能在客户端拼文字或绑定本地等级图。

## 获取礼物面板

```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",
        "vip_level": 0,
        "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`。 |
| `vip_level` | VIP/贵族礼物对应的 VIP 等级；非 VIP 礼物为 `0`。 |
| `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,
  "sender_vip_level": "7",
  "attributes": {
    "gift_id": "rose",
    "gift_count": "9",
    "billing_receipt_id": "br_abc",
    "sender_vip_level": "7"
  }
}
```

处理规则：

- 按 `event_id` 去重。
- `event_type != room_gift_sent` 时交给其他房间消息处理器。
- `actor_user_id` 是送礼人，`target_user_id` 是收礼人。
- `attributes.gift_id` 对应面板里的礼物 ID；本地找不到配置时展示兜底礼物图标。
- `attributes.gift_count` 当前是字符串，Flutter 解析时需要 `int.tryParse`。
- `sender_vip_level` 是送礼人发送瞬间的生效 VIP 等级，腾讯云 IM JSON 中是字符串；`0` 或字段缺失表示当前无有效 VIP。
- `RoomEvent` 会把展示属性同时铺到顶层并保留在 `attributes`；Flutter 优先读取顶层 `sender_vip_level`，兼容读取 `attributes.sender_vip_level`。
- 后端不返回 VIP 房间名称色值；Flutter 只按等级映射客户端已有 VIP 样式。
- 多人送礼事件 `room_gift_batch_sent` 使用完全相同的 `sender_vip_level` 语义和解析方式。
- `gift_value` 是本次房间价值/热度口径，不等同于客户端自己算的 `coin_price * count`。
- 自己发送的礼物如果已经由 HTTP 成功响应播放过，收到同一 `command_id` 或短时间同一礼物 IM 时要做播放去重。

Flutter 兼容解析：

```dart
int parseSenderVipLevel(Map<String, dynamic> data) {
  final attributes = data['attributes'];
  final raw = data['sender_vip_level'] ??
      (attributes is Map ? attributes['sender_vip_level'] : null);
  if (raw is num) return raw.toInt();
  return int.tryParse(raw?.toString() ?? '') ?? 0;
}
```

## 错误处理

外层 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` | 金币不足；跳转充值或刷新余额。 |
| `VIP_GIFT_LEVEL_REQUIRED` | 当前 effective VIP 不足；读取 `data.required_level`，用本地国际化文案提示需要的 VIP 等级，不展示后端英文 message。 |
| `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.vipLevel,
    required this.coinPrice,
    required this.assetUrl,
    required this.animationUrl,
    required this.presentationJson,
  });

  final String giftId;
  final String name;
  final String giftTypeCode;
  final int vipLevel;
  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? ?? '',
      vipLevel: (json['vip_level'] as num?)?.toInt() ?? 0,
      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 是可发送配置，礼物墙是当前用户收到过的礼物聚合。
