# 语音房红包 Flutter 对接

本文按当前 gateway、wallet-service 和 activity-service 实现描述 Flutter 需要对接的房内红包 HTTP 接口和腾讯 IM 自定义消息。红包金额、份额、可抢时间、过期退款、领取幂等和房间在线态校验全部以后端为准；Flutter 只负责展示、提交用户动作和处理实时刷新。

## 1. 基本约定

- 所有 HTTP 路径都有 `/api/v1` 前缀。
- 所有接口都需要 `Authorization: Bearer <access_token>`，并且用户必须已完成资料。
- 成功响应统一是 gateway envelope：

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

- 失败响应也使用同一个 envelope；Flutter 只按 HTTP 状态和 `code` 分支，`message` 只做兜底展示或日志。
- 所有时间字段都是 Unix epoch milliseconds。Flutter 不用本地时区参与业务判断，只做展示格式化。
- 红包主键统一使用 `packetNo`。IM 里兼容带 `packetId`，值和 `packetNo` 相同。
- Go 返回的部分 ID 是 number，Flutter 模型里建议统一转成 `String` 保存，避免大整数精度和 UI 拼接问题。

## 2. 接入流程

进入语音房后：

1. 走现有进房流程，加入房间 IM 群。
2. 调用 `GET /api/v1/app/voice-room/red-packet/active?roomId=<roomId>` 初始化侧边栏红包列表。
3. 监听腾讯 IM 房间群 `TIMCustomElem`，解析 `ROOM_RED_PACKET`。
4. 如果需要接收延时红包同语区飘窗，调用 `GET /api/v1/app/voice-room/red-packet/im-group` 获取当前语区群并加入。
5. 用户点击 IM 红包弹窗或侧边栏红包时，直接用 payload 里的 `packetNo` 调领取或详情接口。

收到 `CREATED` IM 后，payload 已带发送者头像昵称、金额、份数和剩余份数。Flutter 可以直接弹窗或显示飘窗，不需要为了基础展示再立刻拉 active/detail。断线重连、进房初始化、详情页打开和二级领取记录页仍然走 HTTP 查询。

## 3. 当前语区 IM 群

```http
GET /api/v1/app/voice-room/red-packet/im-group
```

返回：

```json
{
  "code": "OK",
  "message": "ok",
  "request_id": "req_xxx",
  "data": {
    "regionId": 310,
    "regionCode": "AR",
    "regionName": "Arabic",
    "groupId": "hy_lalu_bc_r_310",
    "groupName": "hy_lalu_bc_r_310"
  }
}
```

Flutter 用途：

| 字段 | 用途 |
| --- | --- |
| `groupId` | 腾讯 IM 区域广播群 ID，加入后接收延时红包同语区飘窗。 |
| `regionId` | 当前用户所在语区 ID。 |
| `regionCode` | UI 或埋点使用。 |

如果返回 `voice_room_region_missing`，说明用户资料没有有效语区，隐藏区域红包飘窗入口。

## 4. 红包配置

```http
GET /api/v1/app/voice-room/red-packet/config
```

当前该接口沿用 wallet 配置 DTO，字段是 snake_case：

```json
{
  "code": "OK",
  "message": "ok",
  "request_id": "req_xxx",
  "data": {
    "config": {
      "app_code": "lalu",
      "enabled": true,
      "count_tiers": [5, 10],
      "amount_tiers": [15000, 30000],
      "delayed_open_seconds": 300,
      "expire_seconds": 86400,
      "daily_send_limit": 10,
      "rule_url": "https://example.com/red-packet-rule",
      "created_at_ms": 1780000000000,
      "updated_at_ms": 1780000000000
    },
    "server_time_ms": 1780000000000
  }
}
```

Flutter 用途：

| 字段 | 用途 |
| --- | --- |
| `enabled` | false 时隐藏发送入口，已收到的历史红包仍可按接口结果展示。 |
| `count_tiers` | 发送红包份数选项。 |
| `amount_tiers` | 发送红包总金币金额选项。 |
| `delayed_open_seconds` | 延时红包等待秒数；Flutter 不提交该值。 |
| `expire_seconds` | 过期秒数；当前为 86400。 |
| `daily_send_limit` | 每人每日发送上限提示。 |
| `rule_url` | 规则说明 H5。 |

## 5. 发送红包

```http
POST /api/v1/app/voice-room/red-packet/send
Content-Type: application/json
```

请求：

```json
{
  "roomId": "9001",
  "packetMode": "DELAYED",
  "totalAmount": 15000,
  "totalCount": 5,
  "requestId": "client-uuid-or-action-id"
}
```

字段：

| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `roomId` | 是 | 当前语音房 ID。 |
| `packetMode` | 是 | `IMMEDIATE` 或 `DELAYED`。后端也兼容 `normal/delayed`。 |
| `totalAmount` | 是 | 红包总金币，必须命中 `amount_tiers`。 |
| `totalCount` | 是 | 红包份数，必须命中 `count_tiers`。 |
| `requestId` | 是 | 客户端动作幂等 ID。同一用户同一 `requestId` 重试返回同一红包。 |

返回：

```json
{
  "code": "OK",
  "message": "ok",
  "request_id": "req_xxx",
  "data": {
    "packet": {
      "packetNo": "VRRP2026052712340001",
      "roomId": "9001",
      "regionId": 310,
      "senderUserId": 10001,
      "senderAccount": "10001",
      "senderNickname": "Tom",
      "senderAvatar": "https://example.com/avatar1.png",
      "packetMode": "DELAYED",
      "totalAmount": 15000,
      "totalCount": 5,
      "remainingAmount": 15000,
      "remainingCount": 5,
      "status": "WAITING_OPEN",
      "claimStartTime": 1780000000000,
      "expireTime": 1780086400000,
      "createdAt": 1779999700000,
      "claimedByMe": false,
      "myClaimAmount": 0,
      "refundAmount": 0,
      "refundStatus": "",
      "refundedAt": 0,
      "claims": []
    },
    "balanceAfter": 985000,
    "serverTime": 1779999700000
  }
}
```

发送成功后服务端会异步发送 IM：

- 当前房间群：`ROOM_RED_PACKET / CREATED / scope=ROOM`
- 延时红包额外发送当前语区广播群：`ROOM_RED_PACKET / CREATED / scope=REGION`
- 延时红包到点后当前房间群：`ROOM_RED_PACKET / OPEN_DUE / scope=ROOM`

Flutter 发送成功后可以立即把返回的 `packet` 放入本地红包列表；后续 IM 可能也会收到同一个红包，需要按 `packetNo + event + scope` 做去重或覆盖更新。

## 6. 房间有效红包列表

```http
GET /api/v1/app/voice-room/red-packet/active?roomId=9001
```

返回：

```json
{
  "code": "OK",
  "message": "ok",
  "request_id": "req_xxx",
  "data": {
    "packets": [
      {
        "packetNo": "VRRP2026052712340001",
        "roomId": "9001",
        "regionId": 310,
        "senderUserId": 10001,
        "senderAccount": "10001",
        "senderNickname": "Tom",
        "senderAvatar": "https://example.com/avatar1.png",
        "packetMode": "DELAYED",
        "totalAmount": 15000,
        "totalCount": 5,
        "remainingAmount": 15000,
        "remainingCount": 5,
        "status": "WAITING_OPEN",
        "claimStartTime": 1780000000000,
        "expireTime": 1780086400000,
        "createdAt": 1779999700000,
        "claimedByMe": false,
        "myClaimAmount": 0
      }
    ],
    "serverTime": 1779999700000
  }
}
```

Flutter 用途：

- 进房或重连后初始化侧边栏列表。
- 侧边栏未领取数量可以用 `packets.where((p) => !p.claimedByMe).length`。
- 该接口只返回仍处于 `ACTIVE` 或 `WAITING_OPEN` 的红包。抢完、已退款或过期后的最终状态通过详情或 IM `STATUS_CHANGED` 刷新。

## 7. 抢红包

```http
POST /api/v1/app/voice-room/red-packet/claim
Content-Type: application/json
```

请求：

```json
{
  "packetNo": "VRRP2026052712340001"
}
```

返回：

```json
{
  "code": "OK",
  "message": "ok",
  "request_id": "req_xxx",
  "data": {
    "claim": {
      "claimId": "claim_xxx",
      "packetNo": "VRRP2026052712340001",
      "userId": 10002,
      "account": "10002",
      "nickname": "Jerry",
      "avatar": "https://example.com/avatar2.png",
      "amount": 3000,
      "status": "claimed",
      "createdAt": 1780000010000
    },
    "packet": {
      "packetNo": "VRRP2026052712340001",
      "roomId": "9001",
      "regionId": 310,
      "senderUserId": 10001,
      "senderAccount": "10001",
      "senderNickname": "Tom",
      "senderAvatar": "https://example.com/avatar1.png",
      "remainingAmount": 12000,
      "remainingCount": 4,
      "status": "ACTIVE",
      "claimedByMe": true,
      "myClaimAmount": 3000
    },
    "balanceAfter": 1003000,
    "serverTime": 1780000010000
  }
}
```

处理规则：

- 领取成功响应里的 `packet` 结构和详情接口一致，示例只保留弹窗刷新需要的关键字段。
- Flutter 只提交 `packetNo`。当前服务端按 `packetNo + 当前用户` 保证同一用户重复领取返回同一结果。
- 如果返回 `voice_room_red_packet_room_presence_required`，提示用户需要在房间内操作，并刷新房间在线态。
- 如果返回 `voice_room_red_packet_not_started`，展示倒计时，不要重试抢。
- 如果返回 `voice_room_red_packet_finished` 或 `voice_room_red_packet_expired`，刷新侧边栏和详情。
- 如果返回 `claim_processing`，展示处理中，可让用户稍后重试同一个红包或打开详情。

## 8. 红包详情

```http
GET /api/v1/app/voice-room/red-packet/detail?packetNo=VRRP2026052712340001
```

返回：

```json
{
  "code": "OK",
  "message": "ok",
  "request_id": "req_xxx",
  "data": {
    "packet": {
      "packetNo": "VRRP2026052712340001",
      "roomId": "9001",
      "regionId": 310,
      "senderUserId": 10001,
      "senderAccount": "10001",
      "senderNickname": "Tom",
      "senderAvatar": "https://example.com/avatar1.png",
      "packetMode": "DELAYED",
      "totalAmount": 15000,
      "totalCount": 5,
      "remainingAmount": 12000,
      "remainingCount": 4,
      "status": "ACTIVE",
      "claimStartTime": 1780000000000,
      "expireTime": 1780086400000,
      "createdAt": 1779999700000,
      "claimedByMe": true,
      "myClaimAmount": 3000,
      "refundAmount": 0,
      "refundStatus": "",
      "refundedAt": 0,
      "claims": []
    },
    "serverTime": 1780000010000
  }
}
```

详情和领取成功响应里的 `packet` 会补发送者 `senderAccount/senderNickname/senderAvatar`。详情接口里的 `claims` 不作为二级领取记录页的资料来源。需要领取用户头像昵称账号时使用 `claim-records`，因为该接口会按当前页批量补用户资料。

## 9. 领取记录

```http
GET /api/v1/app/voice-room/red-packet/claim-records?packetNo=VRRP2026052712340001&page=1&pageSize=20
```

当前实现使用 `page/pageSize`，不要按旧文档使用 `cursor/limit`。

返回：

```json
{
  "code": "OK",
  "message": "ok",
  "request_id": "req_xxx",
  "data": {
    "items": [
      {
        "claimId": "claim_xxx",
        "packetNo": "VRRP2026052712340001",
        "userId": 10002,
        "account": "10002",
        "nickname": "Jerry",
        "avatar": "https://example.com/avatar2.png",
        "amount": 3000,
        "status": "claimed",
        "createdAt": 1780000010000
      }
    ],
    "total": 1,
    "page": 1,
    "pageSize": 20,
    "serverTime": 1780000010000
  }
}
```

Flutter 用途：

- 二级领取记录页使用 `items` 渲染头像、昵称、账号、领取金额和领取时间。
- `account/nickname/avatar` 已由 gateway 从 user-service 补齐。
- 分页加载下一页时递增 `page`。

## 10. 我发出的红包记录

```http
GET /api/v1/app/voice-room/red-packet/sent-records?page=1&pageSize=20
```

返回：

```json
{
  "code": "OK",
  "message": "ok",
  "request_id": "req_xxx",
  "data": {
    "items": [
      {
        "packetNo": "VRRP2026052712340001",
        "roomId": "9001",
        "packetMode": "DELAYED",
        "totalAmount": 15000,
        "totalCount": 5,
        "remainingAmount": 12000,
        "remainingCount": 4,
        "status": "ACTIVE",
        "claimStartTime": 1780000000000,
        "expireTime": 1780086400000,
        "createdAt": 1779999700000,
        "refundAmount": 0,
        "refundStatus": "",
        "refundedAt": 0
      }
    ],
    "total": 1,
    "page": 1,
    "pageSize": 20,
    "serverTime": 1780000010000
  }
}
```

## 11. IM 消息

腾讯 IM 自定义消息的 `TIMCustomElem.Data` 是 JSON 字符串。Flutter 先解析外层：

```json
{
  "type": "ROOM_RED_PACKET",
  "data": {}
}
```

只有 `type == "ROOM_RED_PACKET"` 才进入红包处理。

### 11.1 CREATED

当前房间弹窗和同语区飘窗都使用 `CREATED`，通过 `scope` 区分。修正后的 CREATED payload 已带足够的首屏展示字段。

```json
{
  "type": "ROOM_RED_PACKET",
  "data": {
    "event": "CREATED",
    "scope": "ROOM",
    "packetNo": "VRRP2026052712340001",
    "packetId": "VRRP2026052712340001",
    "roomId": "9001",
    "regionId": 310,
    "regionCode": "AR",
    "packetMode": "DELAYED",
    "senderUserId": "10001",
    "actualAccount": "10001",
    "userNickname": "Tom",
    "userAvatar": "https://example.com/avatar.png",
    "totalAmount": 15000,
    "totalCount": 5,
    "remainAmount": 15000,
    "remainCount": 5,
    "status": "WAITING_OPEN",
    "delaySeconds": 300,
    "refundedAmount": 0,
    "claimStartTime": 1780000000000,
    "expireTime": 1780086400000,
    "serverTime": 1779999700000
  }
}
```

Flutter 行为：

| `scope` | 行为 |
| --- | --- |
| `ROOM` | 如果当前正在该房间，直接弹抢红包窗口，并更新侧边栏。 |
| `REGION` | 作为同语区飘窗展示；点击后进入 `roomId` 对应房间并打开红包弹窗或详情。 |

字段说明：

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `packetNo` / `packetId` | string | 红包 ID，接口参数使用 `packetNo`。 |
| `packetMode` | string | `IMMEDIATE` 或 `DELAYED`。 |
| `senderUserId` | string | 发送者长 ID。 |
| `actualAccount` | string | 发送者展示账号。 |
| `userNickname` | string | 发送者昵称。 |
| `userAvatar` | string | 发送者头像 URL。 |
| `totalAmount` | int64 | 红包总金币。 |
| `totalCount` | int | 红包总份数。 |
| `remainAmount` | int64 | 剩余总金币。CREATED 时通常等于 `totalAmount`。 |
| `remainCount` | int | 剩余可领份数。CREATED 时通常等于 `totalCount`。 |
| `status` | string | `ACTIVE`、`WAITING_OPEN`、`FINISHED`、`REFUNDED`。 |
| `delaySeconds` | int64 | `DELAYED` 红包的等待秒数，普通红包为 0。 |
| `claimStartTime` | int64 | 可领取开始时间。 |
| `expireTime` | int64 | 过期时间。 |
| `serverTime` | int64 | 该红包事件生成时间。 |

### 11.2 OPEN_DUE

延时红包到可领取时间后，服务端向房间群发 `OPEN_DUE`。当前 payload 使用同一结构，Flutter 只需要关心 `event/scope/packetNo/status/claimStartTime/expireTime/remainCount`。

```json
{
  "type": "ROOM_RED_PACKET",
  "data": {
    "event": "OPEN_DUE",
    "scope": "ROOM",
    "packetNo": "VRRP2026052712340001",
    "packetId": "VRRP2026052712340001",
    "roomId": "9001",
    "packetMode": "DELAYED",
    "senderUserId": "10001",
    "actualAccount": "10001",
    "userNickname": "Tom",
    "userAvatar": "https://example.com/avatar.png",
    "totalAmount": 15000,
    "totalCount": 5,
    "remainAmount": 15000,
    "remainCount": 5,
    "status": "WAITING_OPEN",
    "claimStartTime": 1780000000000,
    "expireTime": 1780086400000,
    "serverTime": 1779999700000
  }
}
```

Flutter 收到后：

- 如果用户仍在 `roomId` 房间，强制弹出该红包或把已展示红包切换为可抢。
- 不要在客户端发放金额；仍然调用领取接口。
- 如果本地已经显示该红包，按 `packetNo` 覆盖更新。

### 11.3 STATUS_CHANGED

红包抢完、退款完成或状态变化时，服务端向房间群发 `STATUS_CHANGED`。

```json
{
  "type": "ROOM_RED_PACKET",
  "data": {
    "event": "STATUS_CHANGED",
    "scope": "ROOM",
    "packetNo": "VRRP2026052712340001",
    "packetId": "VRRP2026052712340001",
    "roomId": "9001",
    "status": "FINISHED",
    "remainCount": 0,
    "refundedAmount": 0,
    "serverTime": 1780000020000
  }
}
```

Flutter 收到后：

- 更新侧边栏数量。
- 如果当前弹窗或详情页是同一个 `packetNo`，同步刷新状态。
- `remainCount == 0` 或 `status == FINISHED/REFUNDED` 时不再展示抢按钮。

## 12. Flutter 状态模型建议

建议本地以 `packetNo` 为 key 存一份红包 read model：

```dart
class VoiceRoomRedPacket {
  final String packetNo;
  final String roomId;
  final String packetMode;
  final String status;
  final String senderUserId;
  final String senderAccount;
  final String senderNickname;
  final String senderAvatar;
  final int totalAmount;
  final int totalCount;
  final int remainingAmount;
  final int remainingCount;
  final int claimStartTimeMs;
  final int expireTimeMs;
  final bool claimedByMe;
  final int myClaimAmount;
}
```

解析建议：

- 对 `senderUserId/userId` 这类 ID，兼容 number 和 string，统一转 string。
- HTTP 发送成功、active、详情、领取成功里的 `packet` 使用 `senderAccount/senderNickname/senderAvatar`；IM `CREATED/OPEN_DUE` 使用 `actualAccount/userNickname/userAvatar`，落本地模型时归一到发送者字段。
- 对金额和时间，兼容 number 和 string，统一转 int。
- `CREATED` IM 里的 `remainAmount/remainCount` 对应 HTTP 里的 `remainingAmount/remainingCount`。
- 如果 IM 和 HTTP 字段冲突，以 HTTP 最新查询结果为准。

## 13. 错误码处理

| HTTP code | `code` | Flutter 处理 |
| --- | --- | --- |
| 409 | `voice_room_red_packet_disabled` | 隐藏发送入口，提示暂不可用。 |
| 400 | `voice_room_red_packet_amount_invalid` | 刷新配置，提示金额档位已变化。 |
| 400 | `voice_room_red_packet_count_invalid` | 刷新配置，提示份数档位已变化。 |
| 409 | `voice_room_red_packet_daily_limit_exceeded` | 提示今日发送次数已达上限。 |
| 409 | `voice_room_red_packet_insufficient_balance` | 拉起充值或余额不足提示。 |
| 404 | `voice_room_red_packet_not_found` | 关闭弹窗并刷新 active 列表。 |
| 409 | `voice_room_red_packet_not_started` | 展示倒计时，不重试领取。 |
| 409 | `voice_room_red_packet_expired` | 提示已过期，刷新列表和详情。 |
| 409 | `voice_room_red_packet_finished` | 提示已抢完，刷新列表和详情。 |
| 409 | `voice_room_red_packet_room_presence_required` | 提示需在房间内操作，重新走进房或刷新在线态。 |
| 403 | `voice_room_region_not_match` | 用户语区和红包语区不一致，禁止领取。 |
| 409 | `voice_room_region_missing` | 用户资料缺少语区，提示完善资料或重登刷新。 |
| 409 | `claim_processing` | 展示处理中，可稍后重试或打开详情。 |

## 14. 联调检查清单

- 发送普通红包后，当前房间收到 `CREATED scope=ROOM`，payload 带 sender 头像昵称、金额、总份数和剩余份数。
- 发送延时红包后，当前房间收到 `CREATED scope=ROOM`，当前语区群收到 `CREATED scope=REGION`。
- 延时红包未到 `claimStartTime` 调领取返回 `voice_room_red_packet_not_started`。
- 到点后房间收到 `OPEN_DUE`，Flutter 可以强弹并允许点击领取。
- 用户不在红包所属房间时领取返回 `voice_room_red_packet_room_presence_required`。
- 同一用户重复领取同一 `packetNo` 返回同一领取结果。
- 抢完后房间收到 `STATUS_CHANGED`，侧边栏数量减少，抢按钮隐藏。
- 领取记录页使用 `claim-records?page=&pageSize=`，每条记录有 `account/nickname/avatar`。
- 重连或重新进房后调用 `active` 恢复侧边栏，不依赖历史 IM 补全状态。
