# 幸运礼物 Flutter 对接

本文描述 Flutter App 对接幸运礼物所需的 HTTP 接口、请求参数、返回值、错误处理和腾讯云 IM 自定义消息。幸运礼物支持多个互相独立的奖池，App 只负责传入 `pool_id`，不计算 RTP、倍率概率、奖池水位或中奖结果。

## 基础约定

本地开发地址：

```text
http://127.0.0.1:13000
```

业务接口统一响应 envelope：

```json
{
  "code": "OK",
  "message": "ok",
  "request_id": "req_xxx",
  "data": {}
}
```

| 字段 | 说明 |
| --- | --- |
| `code` | 成功固定 `OK`；失败为稳定错误码。 |
| `message` | 短错误文案，不用于客户端分支判断。 |
| `request_id` | 服务端链路追踪 ID；客户端日志要记录。 |
| `data` | 成功业务数据；失败时通常不存在。 |

通用请求头：

| Header | 必填 | 说明 |
| --- | --- | --- |
| `Authorization` | 是 | `Bearer <access_token>`。 |
| `X-App-Code` | 否 | App 编码，默认 `lalu`；登录态接口最终以 token 内 app_code 为准。 |
| `Content-Type` | POST 必填 | `application/json`。 |

所有 `*_ms` 都是 Unix epoch milliseconds。金额单位都是整数金币。

## 核心概念

| 概念 | 说明 |
| --- | --- |
| `gift_id` | 用户实际送出的幸运礼物 ID，例如某个 100 金币、5000 金币或 500000 金币礼物。 |
| `pool_id` | 后台配置的幸运礼物奖池 ID，例如 `pool_95`、`pool_98`。不同 `pool_id` 的 RTP、奖池、风控和用户阶段完全隔离。 |
| `command_id` | 本次送礼动作幂等键。用户同一次点击产生一个值，HTTP 超时或重试必须复用。 |
| `coin_spent` | 服务端按礼物价格和数量计算的实际扣费金额，App 不上传该字段。 |
| `multiplier_ppm` | 倍率的 ppm 表达，`1x=1000000`、`0.5x=500000`、`100x=100000000`。 |
| `base_reward_coins` | 基础 RTP 返奖，计入平台成本控制。 |
| `effective_reward_coins` | 用户可见总奖励，等于基础返奖、房间气氛奖励、活动补贴之和。 |

App 不要展示后台 RTP 数值，不要展示实时概率，不要本地推算“下一次该中”。客户端只展示服务端返回的结果。

## 当前实现状态

当前仓库已经具备：

| 能力 | 状态 |
| --- | --- |
| `POST /api/v1/rooms/gift/send` 支持 `pool_id` 入参 | 已有 |
| 房间送礼 IM `room_gift_sent` 可携带 `pool_id` | 已有 |
| `activity-service` 内部 gRPC `CheckLuckyGift` / `ExecuteLuckyGiftDraw` | 已有 |
| 后台按 `pool_id` 配置多奖池 | 已有 |

Flutter 完整体验还需要后端补齐：

| 能力 | 建议 |
| --- | --- |
| App 查询某个奖池是否可抽 | 新增 HTTP `POST /api/v1/activities/lucky-gifts/check` |
| 送礼响应直接返回抽奖结果 | 在 `/rooms/gift/send` 成功 `data` 中增加 `lucky_gift` |
| 中奖房间表现 | 新增房间 IM `lucky_gift_drawn` |
| 返奖到账 | 钱包侧新增幸运礼物返奖入账后继续发 `WalletBalanceChanged` |

下面文档按最终 Flutter 契约编写；已存在字段可直接对接，标注为“建议新增”的字段需要后端继续落地。

## 客户端流程

1. 进入房间后调用 `GET /api/v1/rooms/{room_id}/gift-panel` 获取礼物面板、金币余额和收礼人。
2. 找到 `gift_type_code=lucky` 或 `gift_type_code=super_lucky` 的礼物，读取该礼物对应的 `pool_id`。当前可从 `presentation_json.lucky_pool_id` 下发，后续建议提升为顶层字段 `lucky_pool_id`。
3. 用户点击幸运礼物前，可调用 `POST /api/v1/activities/lucky-gifts/check` 判断奖池是否启用。
4. 用户确认送礼时调用 `POST /api/v1/rooms/gift/send`，请求体带上 `pool_id`。
5. 成功响应里如果有 `lucky_gift`，立即播放中奖或未中奖反馈。
6. 房间内监听 `room_gift_sent` 和 `lucky_gift_drawn` IM，给其他用户同步展示。
7. 金币余额以 `WalletBalanceChanged` 私有 IM 或 `GET /api/v1/wallet/me/balances` 刷新为准，不在本地推算。

## 获取礼物面板

```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_xxx",
  "data": {
    "coin_balance": 1000000,
    "recipients": [],
    "tabs": [],
    "gifts": [
      {
        "gift_id": "lucky_star_100",
        "name": "幸运星",
        "gift_type_code": "lucky",
        "charge_asset_type": "COIN",
        "coin_price": 100,
        "presentation_json": "{\"lucky_pool_id\":\"pool_95\"}",
        "resource": {
          "asset_url": "https://cdn.example/lucky_star.png",
          "animation_url": "https://cdn.example/lucky_star.svga"
        }
      }
    ],
    "quantity_presets": [1, 9, 99, 999]
  }
}
```

幸运礼物识别规则：

| 字段 | 说明 |
| --- | --- |
| `gift_type_code=lucky` | 普通幸运礼物。 |
| `gift_type_code=super_lucky` | 超级幸运礼物。 |
| `presentation_json.lucky_pool_id` | 建议下发的奖池 ID；为空时 App 传 `default` 或不传，由服务端按默认奖池处理。 |
| `coin_price` | 单个礼物价格；实际扣费仍以服务端钱包价格为准。 |

## 检查幸运礼物状态

建议新增 App HTTP 接口：

```http
POST /api/v1/activities/lucky-gifts/check
Authorization: Bearer <access_token>
X-App-Code: lalu
Content-Type: application/json
```

请求体：

```json
{
  "room_id": "lalu_xxx",
  "gift_id": "lucky_star_100",
  "pool_id": "pool_95"
}
```

参数：

| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `room_id` | string | 是 | 当前房间 ID。 |
| `gift_id` | string | 是 | 用户准备送出的幸运礼物 ID。 |
| `pool_id` | string | 否 | 奖池 ID；为空时服务端使用 `default`。 |

成功响应：

```json
{
  "code": "OK",
  "message": "ok",
  "request_id": "req_xxx",
  "data": {
    "enabled": true,
    "reason": "enabled",
    "pool_id": "pool_95",
    "gift_id": "lucky_star_100",
    "rule_version": 12,
    "experience_pool": "novice",
    "server_time_ms": 1779259000000
  }
}
```

返回字段：

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `enabled` | bool | 是否允许当前用户在当前房间抽该奖池。 |
| `reason` | string | 服务端原因，客户端只做日志或灰度排查。 |
| `pool_id` | string | 实际使用的奖池 ID。 |
| `gift_id` | string | 礼物 ID。 |
| `rule_version` | int64 | 当前奖池规则版本。 |
| `experience_pool` | string | `novice`、`intermediate`、`advanced`，只影响展示文案或动效强度。 |
| `server_time_ms` | int64 | 服务端当前时间。 |

客户端处理：

| 场景 | 处理 |
| --- | --- |
| `enabled=true` | 允许点击送礼。 |
| `enabled=false` | 置灰幸运抽奖入口，但普通礼物展示可继续。 |
| 接口失败 | 不在本地兜底开奖；可以提示稍后重试或隐藏幸运玩法。 |

## 送礼并抽奖

已存在 App HTTP 入口：

```http
POST /api/v1/rooms/gift/send
Authorization: Bearer <access_token>
X-App-Code: lalu
Content-Type: application/json
```

请求体：

```json
{
  "room_id": "lalu_xxx",
  "command_id": "gift_1779259000000_10001_01",
  "target_type": "user",
  "target_user_ids": [10002],
  "gift_id": "lucky_star_100",
  "gift_count": 1,
  "pool_id": "pool_95"
}
```

参数：

| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `room_id` | string | 是 | 当前房间 ID。 |
| `command_id` | string | 是 | 本次用户动作幂等键；同一次重试必须复用。 |
| `target_type` | string | 否 | 当前主流程使用 `user`；为空按 `user`。 |
| `target_user_ids` | int64[] | 是 | 收礼用户 ID；`target_type=user` 时必须且只能 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_xxx",
  "data": {
    "result": {
      "applied": true,
      "room_version": 14,
      "server_time_ms": 1779259000000
    },
    "billing_receipt_id": "br_xxx",
    "room_heat": 123456,
    "gift_rank": [],
    "room": {},
    "treasure": {},
    "lucky_gift": {
      "enabled": true,
      "draw_id": "lucky_draw_xxx",
      "command_id": "gift_1779259000000_10001_01",
      "pool_id": "pool_95",
      "gift_id": "lucky_star_100",
      "rule_version": 12,
      "experience_pool": "novice",
      "selected_tier_id": "novice_2x",
      "multiplier_ppm": 2000000,
      "base_reward_coins": 200,
      "room_atmosphere_reward_coins": 0,
      "activity_subsidy_coins": 0,
      "effective_reward_coins": 200,
      "reward_status": "granted",
      "stage_feedback": false,
      "high_multiplier": false,
      "created_at_ms": 1779259000000
    }
  }
}
```

`lucky_gift` 字段为建议新增。普通礼物或奖池未启用时可以为空。

`data` 字段：

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `result.applied` | bool | 本次命令是否实际落地；幂等重放可能为 `false`。 |
| `result.room_version` | int64 | 房间版本。 |
| `result.server_time_ms` | int64 | 服务端时间。 |
| `billing_receipt_id` | string | 钱包扣费回执；存在表示送礼扣费成功。 |
| `room_heat` | int64 | 房间热度。 |
| `gift_rank` | array | 房间礼物榜快照。 |
| `room` | object | 房间快照。 |
| `treasure` | object | 宝箱状态；如果房间宝箱开启可直接更新。 |
| `lucky_gift` | object | 幸运礼物抽奖结果；建议新增。 |

`lucky_gift` 字段：

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `enabled` | bool | 本次是否执行了幸运礼物抽奖。 |
| `draw_id` | string | 抽奖记录 ID。 |
| `command_id` | string | 和送礼请求一致的幂等键。 |
| `pool_id` | string | 本次使用的奖池 ID。 |
| `gift_id` | string | 礼物 ID。 |
| `rule_version` | int64 | 抽奖时使用的规则版本。 |
| `experience_pool` | string | `novice`、`intermediate`、`advanced`。 |
| `selected_tier_id` | string | 服务端选中的奖档 ID。 |
| `multiplier_ppm` | int64 | 建议新增；用于直接展示倍率。后端暂未返回时客户端不要本地解析倍率。 |
| `base_reward_coins` | int64 | 基础 RTP 返奖。 |
| `room_atmosphere_reward_coins` | int64 | 房间气氛补贴奖励。 |
| `activity_subsidy_coins` | int64 | 活动补贴奖励。 |
| `effective_reward_coins` | int64 | 用户可见总奖励。 |
| `reward_status` | string | `pending`、`granted`、`failed`。 |
| `stage_feedback` | bool | 是否触发阶段反馈。可能没有金币奖励。 |
| `high_multiplier` | bool | 是否属于高倍结果，可用于强化动效。 |
| `created_at_ms` | int64 | 抽奖结果生成时间。 |

展示规则：

| 场景 | 处理 |
| --- | --- |
| `lucky_gift` 为空 | 按普通礼物成功展示。 |
| `effective_reward_coins=0` 且 `stage_feedback=false` | 展示未中奖或普通送礼效果。 |
| `stage_feedback=true` | 展示阶段进度、幸运值或轻量动画；不要本地加金币。 |
| `effective_reward_coins>0` 且 `reward_status=granted` | 展示中奖金额和到账效果；余额仍以钱包 IM 为准。 |
| `effective_reward_coins>0` 且 `reward_status=pending` | 展示“奖励处理中”，等待钱包 IM 或余额刷新。 |
| `reward_status=failed` | 不本地加金币；提示稍后刷新或走补偿查询。 |
| `high_multiplier=true` | 播放高倍中奖动效，并等待房间 IM 同步其他用户。 |

## 错误处理

通用错误：

| HTTP | `code` | 说明 | Flutter 处理 |
| --- | --- | --- | --- |
| 400 | `INVALID_JSON` | JSON 解析失败。 | 修正客户端请求体。 |
| 400 | `INVALID_ARGUMENT` | 参数不合法，例如缺 `room_id`、`gift_id`、`command_id`。 | 不重试，提示操作失败并打点。 |
| 401 | `UNAUTHORIZED` | token 无效或过期。 | 走登录刷新或重新登录。 |
| 403 | `PROFILE_REQUIRED` | 用户未完成资料。 | 引导补全资料。 |
| 403 | `PERMISSION_DENIED` | 没有权限。 | 提示不可操作。 |
| 409 | `INSUFFICIENT_BALANCE` | 金币不足。 | 弹充值或余额不足提示。 |
| 409 | `ROOM_CLOSED` | 房间已关闭。 | 退出或刷新房间状态。 |
| 409 | `RULE_NOT_ACTIVE` | 幸运礼物奖池未启用。 | 置灰幸运玩法，不本地开奖。 |
| 409 | `IDEMPOTENCY_CONFLICT` | 同一 `command_id` 对应的请求内容不一致。 | 生成新的 `command_id` 重新发起新动作；旧动作不再重放。 |
| 409 | `CONFLICT` | 规则、奖池、水位或风控状态冲突。 | 提示稍后再试，必要时刷新面板。 |
| 502 | `UPSTREAM_ERROR` | room、wallet、activity 等依赖暂不可用。 | 可保留同一个 `command_id` 重试同一次送礼。 |
| 500 | `INTERNAL_ERROR` | 服务端内部异常。 | 记录 `request_id`，提示稍后重试。 |

幂等和重试规则：

| 场景 | 处理 |
| --- | --- |
| HTTP 超时、断网、502 | 使用同一个 `command_id` 重试。 |
| 409 `IDEMPOTENCY_CONFLICT` | 不要复用该 `command_id`；说明客户端把同一个幂等键用于不同请求。 |
| 收到成功响应且有 `billing_receipt_id` | 扣费已成功，不要再次生成新命令补发。 |
| 成功响应丢失但钱包余额已变化 | 以钱包余额和房间 IM 为准，避免重复扣费。 |
| App 进程重启 | 本地持久化最近未完成的 `command_id`，优先用同一键查询或重试。 |

## IM：房间送礼消息

已存在房间 IM：

| 字段 | 值 |
| --- | --- |
| 腾讯消息类型 | `TIMCustomElem` |
| `Desc` | `room_gift_sent` |
| `Ext` / `extension` | `room_system_message` |
| 群 ID | 当前 `room_id` |
| `CloudCustomData` | 与 `TIMCustomElem.Data` 相同的 JSON 字符串 |

Payload 示例：

```json
{
  "event_id": "evt_xxx",
  "room_id": "lalu_xxx",
  "event_type": "room_gift_sent",
  "actor_user_id": 10001,
  "target_user_id": 10002,
  "gift_value": 100,
  "room_heat": 123456,
  "room_version": 14,
  "attributes": {
    "gift_id": "lucky_star_100",
    "pool_id": "pool_95",
    "gift_count": "1",
    "billing_receipt_id": "br_xxx",
    "coin_spent": "100",
    "gift_point_added": "10",
    "price_version": "pv_xxx"
  }
}
```

处理规则：

| 场景 | 处理 |
| --- | --- |
| `Ext` 不是 `room_system_message` | 忽略。 |
| `event_type` 不是 `room_gift_sent` | 交给其他房间消息处理器。 |
| `event_id` 已处理 | 忽略。 |
| `room_version` 小于本地当前版本 | 忽略或只展示轻量动画，不回滚房间状态。 |
| `attributes.pool_id` 为空 | 按普通礼物处理。 |
| `attributes.pool_id` 非空 | 展示幸运礼物送出效果，但中奖结果仍等 `lucky_gift_drawn` 或 HTTP `lucky_gift`。 |

## IM：幸运礼物开奖结果

建议新增房间 IM：

| 字段 | 值 |
| --- | --- |
| 腾讯消息类型 | `TIMCustomElem` |
| `Desc` | `lucky_gift_drawn` |
| `Ext` / `extension` | `room_system_message` |
| 群 ID | 当前 `room_id` |
| `CloudCustomData` | 与 `TIMCustomElem.Data` 相同的 JSON 字符串 |

Payload 示例：

```json
{
  "event_id": "lucky_draw_event_xxx",
  "room_id": "lalu_xxx",
  "event_type": "lucky_gift_drawn",
  "room_version": 14,
  "draw_id": "lucky_draw_xxx",
  "command_id": "gift_1779259000000_10001_01",
  "pool_id": "pool_95",
  "gift_id": "lucky_star_100",
  "gift_count": 1,
  "sender_user_id": 10001,
  "target_user_id": 10002,
  "coin_spent": 100,
  "selected_tier_id": "novice_2x",
  "multiplier_ppm": 2000000,
  "effective_reward_coins": 200,
  "reward_status": "granted",
  "stage_feedback": false,
  "high_multiplier": false,
  "created_at_ms": 1779259000000
}
```

字段说明：

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `event_id` | string | IM 事件幂等键。 |
| `event_type` | string | 固定 `lucky_gift_drawn`。 |
| `room_id` | string | 房间 ID。 |
| `room_version` | int64 | 房间版本。 |
| `draw_id` | string | 抽奖记录 ID。 |
| `command_id` | string | 送礼幂等键。 |
| `pool_id` | string | 奖池 ID。 |
| `gift_id` | string | 礼物 ID。 |
| `gift_count` | int32 | 礼物数量。 |
| `sender_user_id` | int64 | 送礼用户。 |
| `target_user_id` | int64 | 收礼用户。 |
| `coin_spent` | int64 | 本次实际扣费。 |
| `selected_tier_id` | string | 中奖奖档。 |
| `multiplier_ppm` | int64 | 中奖倍率，建议新增。 |
| `effective_reward_coins` | int64 | 用户可见奖励金币。 |
| `reward_status` | string | `pending`、`granted`、`failed`。 |
| `stage_feedback` | bool | 是否阶段反馈。 |
| `high_multiplier` | bool | 是否高倍结果。 |
| `created_at_ms` | int64 | 事件创建时间。 |

展示规则：

| 场景 | 处理 |
| --- | --- |
| 当前用户是 `sender_user_id` | 如果 HTTP 已展示过同一 `draw_id`，只更新状态，不重复播中奖动画。 |
| 当前用户不是 `sender_user_id` | 展示房间内中奖横幅、飘屏或公屏提示。 |
| `effective_reward_coins=0` | 不展示大奖飘屏，只保留普通送礼效果或阶段反馈。 |
| `high_multiplier=true` | 展示高倍动画。 |
| `reward_status=pending` | 文案使用“奖励处理中”，不要展示已到账。 |
| JSON 解析失败或字段缺失 | 忽略并记录客户端日志。 |

## IM：钱包余额变更

幸运礼物扣费和返奖都应通过钱包私有 IM 同步余额。该 IM 已在钱包文档中定义。

| 字段 | 值 |
| --- | --- |
| 腾讯消息类型 | `TIMCustomElem` |
| `Desc` | `WalletBalanceChanged` |
| `Ext` / `extension` | `wallet_notice` |
| 通道 | C2C 私有消息 |

Payload 示例：

```json
{
  "event_type": "WalletBalanceChanged",
  "event_id": "wev_xxx",
  "app_code": "lalu",
  "transaction_id": "tx_xxx",
  "command_id": "gift_1779259000000_10001_01",
  "user_id": "10001",
  "asset_type": "COIN",
  "available_delta": -100,
  "frozen_delta": 0,
  "available_after": 999900,
  "frozen_after": 0,
  "balance_version": 18,
  "created_at_ms": 1779259000000,
  "source_created_at_ms": 1779259000000,
  "metadata": {
    "biz_type": "gift_debit",
    "gift_id": "lucky_star_100",
    "pool_id": "pool_95"
  }
}
```

返奖到账时 `available_delta` 为正数，`metadata.biz_type` 建议使用 `lucky_gift_reward`。

处理规则：

| 场景 | 处理 |
| --- | --- |
| `Ext` 不是 `wallet_notice` | 忽略。 |
| `event_type` 不是 `WalletBalanceChanged` | 忽略。 |
| `event_id` 已处理 | 忽略。 |
| 本地 `asset_type` 的版本 `>= balance_version` | 忽略。 |
| 长时间没有收到余额 IM | 调 `GET /api/v1/wallet/me/balances` 兜底刷新。 |

## IM：高倍全站或区域播报

建议新增播报 IM，用于高倍中奖或运营配置的大额中奖展示。

| 字段 | 值 |
| --- | --- |
| 腾讯消息类型 | `TIMCustomElem` |
| `Desc` | `lucky_gift_big_win` |
| `Ext` / `extension` | `im_broadcast` |
| 群 ID | 全站播报群或区域播报群 |

Payload 示例：

```json
{
  "event_id": "lucky_big_win_xxx",
  "broadcast_type": "lucky_gift_big_win",
  "scope": "region",
  "app_code": "lalu",
  "region_id": 1001,
  "room_id": "lalu_xxx",
  "sender_user_id": 10001,
  "target_user_id": 10002,
  "pool_id": "pool_95",
  "gift_id": "lucky_star_100",
  "multiplier_ppm": 100000000,
  "effective_reward_coins": 10000,
  "sent_at_ms": 1779259000000,
  "action": {
    "type": "enter_room",
    "room_id": "lalu_xxx"
  }
}
```

处理规则：

| 场景 | 处理 |
| --- | --- |
| 当前用户已在该房间且已收到 `lucky_gift_drawn` | 可以只展示一次，避免重复刷屏。 |
| 当前用户不在该房间 | 展示全站或区域飘屏，点击 `action` 进房。 |
| `event_id` 已处理 | 忽略。 |

## App 最小对接顺序

1. 登录后调用 `GET /api/v1/im/usersig`，登录腾讯云 IM。
2. 进入房间后加入房间 IM 群，并调用 `GET /api/v1/rooms/{room_id}/gift-panel`。
3. 从礼物面板识别幸运礼物和对应 `pool_id`。
4. 用户点击送礼时生成 `command_id`，调用 `POST /api/v1/rooms/gift/send`。
5. 使用 HTTP 响应里的 `lucky_gift` 做当前用户即时反馈。
6. 监听房间 IM `room_gift_sent`、`lucky_gift_drawn`，同步其他用户看到的表现。
7. 监听 C2C IM `WalletBalanceChanged`，更新金币余额。
8. 任意 IM 丢失、页面重进或余额不一致时，重新拉取礼物面板和钱包余额纠正状态。

