# 房内红包需求接口拆分

## 1. 结论

房内红包拆成两类通道：

- HTTP 接口：承载有状态业务，包括配置、发送、领取、详情、记录、退款后台操作；房间在线态使用语音房通用接口。
- IM 消息：承载实时 UI 触达，包括房内弹窗、延时红包同语区飘窗、倒计时结束强弹、侧边栏数量刷新。

本需求以产品文档为准：

- 红包类型：即时红包、延时红包。
- 发送成功立刻扣除金币。
- 发送者本人可以领取自己发出的红包。
- 红包有效领取时间为 24 小时，过期未领取金额退回发送者。
- 即时红包不触发同语区广播飘窗。
- 延时红包发送成功后触发同语区广播飘窗。
- 当前房间内无论即时还是延时，发送成功后都需要立刻弹出抢红包窗口。

现有历史文档中的 `2 小时退款` 不适用于本次新需求，应改为 24 小时。

## 2. 统一响应约定

现有 Go 网关成功响应会同时返回 `body` 和 `data`，客户端读取任意一个都可以。

成功响应：

```json
{
  "status": true,
  "errorCode": 0,
  "errorMsg": "success",
  "body": {},
  "code": "success",
  "message": "success",
  "data": {},
  "time": 1780000000000
}
```

错误响应：

```json
{
  "status": false,
  "errorCode": 409,
  "errorCodeName": "voice_room_red_packet_expired",
  "errorMsg": "red packet expired",
  "body": null,
  "code": "voice_room_red_packet_expired",
  "message": "red packet expired",
  "data": null,
  "time": 1780000000000
}
```

接口文档下文只定义 `body/data` 中的业务字段。

## 3. 哪些需要使用 IM

| 场景 | IM key | 发送范围 | 使用 IM 的原因 |
| --- | --- | --- | --- |
| 红包创建后当前房间立刻弹窗 | `ROOM_RED_PACKET`，`event=CREATED`，`scope=ROOM` | 当前房间 IM 群 | 房内所有在线用户需要实时收到弹窗，不能依赖轮询 |
| 延时红包同语区飘窗引流 | `ROOM_RED_PACKET`，`event=CREATED`，`scope=REGION` | 发送者所在同语区 IM 群 | 首页和其他房间用户没有主动请求，现有区域飘屏能力通过语区 IM 群触达 |
| 延时红包倒计时结束强弹 | `ROOM_RED_PACKET`，`event=OPEN_DUE`，`scope=ROOM` | 当前房间 IM 群 | 用户可能提前关闭弹窗，到点必须再次强制弹出 |
| 红包抢完、过期、退款后刷新侧边栏 | `ROOM_RED_PACKET`，`event=STATUS_CHANGED`，`scope=ROOM` | 当前房间 IM 群 | 房内侧边栏红包数量需要实时减少 |

不使用 IM 的动作：

| 动作 | 原因 |
| --- | --- |
| 发送红包 | 需要鉴权、扣款、档位校验、日上限、幂等落库 |
| 抢红包 | 需要鉴权、并发防超卖、领取幂等、钱包入账 |
| 查询配置 | 普通读接口，礼物面板打开时请求即可 |
| 查询详情和记录 | 需要分页、权限和稳定数据 |
| 过期退款 | 服务端定时任务和后台补偿，不应由客户端 IM 触发 |

## 4. IM 消息格式

现有腾讯 IM 自定义消息通过 `TIMCustomElem.MsgContent.Data` 发送 JSON 字符串。业务层统一使用外层：

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

腾讯 IM 实际 payload：

```json
{
  "MsgType": "TIMCustomElem",
  "MsgContent": {
    "Data": "{\"type\":\"ROOM_RED_PACKET\",\"data\":{...}}",
    "Desc": "customBody",
    "Sound": "dingdong.aiff"
  }
}
```

### 4.1 CREATED

当前房间弹窗和同语区飘窗都使用 `CREATED`，通过 `scope` 区分。

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

字段说明：

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `event` | string | 固定为 `CREATED` |
| `scope` | string | `ROOM` 表示房间弹窗，`REGION` 表示同语区飘窗 |
| `packetNo` | string | 红包唯一编号，接口参数使用该字段 |
| `packetId` | string | 兼容旧 Flutter 飘屏字段，值同 `packetNo` |
| `roomId` | string | 红包所属房间，点击飘窗进入该房间 |
| `regionCode` | string | 语区编码 |
| `packetMode` | string | `IMMEDIATE` 或 `DELAYED` |
| `senderUserId` | string | 发送者用户 ID |
| `actualAccount` | string | 发送者账号 |
| `userNickname` | string | 发送者昵称 |
| `userAvatar` | string | 发送者头像 |
| `totalAmount` | int64 | 红包总金币 |
| `totalCount` | int | 红包份数 |
| `remainCount` | int | 剩余可领份数 |
| `status` | string | 红包状态 |
| `delaySeconds` | int | 后台配置的延时秒数 |
| `claimStartTime` | int64 | 可领取开始时间，毫秒时间戳 |
| `expireTime` | int64 | 过期时间，毫秒时间戳，创建后 24 小时 |
| `serverTime` | int64 | 服务端当前时间，毫秒时间戳 |

发送规则：

- 即时红包：只发送 `scope=ROOM` 到当前房间 IM 群。
- 延时红包：发送 `scope=ROOM` 到当前房间 IM 群；同时发送 `scope=REGION` 到同语区 IM 群。

### 4.2 OPEN_DUE

延时红包倒计时结束后，服务端向当前房间发送强弹消息。

```json
{
  "type": "ROOM_RED_PACKET",
  "data": {
    "event": "OPEN_DUE",
    "scope": "ROOM",
    "packetNo": "VRRP2026052712340001",
    "packetId": "VRRP2026052712340001",
    "roomId": "9001",
    "packetMode": "DELAYED",
    "claimStartTime": 1780000000000,
    "expireTime": 1780086400000,
    "serverTime": 1780000000000
  }
}
```

### 4.3 STATUS_CHANGED

红包被抢完、过期、退款完成后，服务端向当前房间发送状态刷新消息。

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

客户端收到后刷新侧边栏未领取数量；如果当前展示的是该红包弹窗或详情页，同步刷新状态。

## 5. App HTTP 接口

### 5.1 获取当前用户语区 IM 群

```http
GET /app/region-im-group/current
```

兼容旧路径：

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

返回：

```json
{
  "configured": true,
  "sysOrigin": "LIKEI",
  "regionCode": "AR",
  "groupId": "@TGS#xxxx",
  "groupName": "region-im-group-LIKEI-AR",
  "status": "ACTIVE"
}
```

客户端处理：

- `configured=true` 且 `groupId` 非空时加入腾讯 IM 群。
- `joinGroup` 返回成功或已在群内都按成功处理。
- 区域变化时退出旧区域群并加入新区域群。

### 5.2 通用房间心跳

```http
POST /api/v1/rooms/heartbeat
```

请求：

```json
{
  "room_id": "9001",
  "command_id": "cmd_heartbeat_9001_10001_1780000000000"
}
```

返回：

```json
{
  "result": {
    "ok": true
  },
  "user": {
    "user_id": "10001",
    "room_id": "9001",
    "last_seen_at_ms": 1780000000000
  },
  "room": {}
}
```

用途：

- 这是语音房通用 presence 能力，不属于红包专用接口。
- 只刷新已经存在的业务 presence，不能替代进房 `JoinRoom`。
- 红包发送、红包领取、侧边栏有效红包查询都依赖该 presence 判断用户是否仍在当前房间。
- 用户不在房间、已离房、被踢或房间关闭时，心跳应失败，客户端应停止心跳并清理本地房间态。

客户端心跳间隔由房间模块统一配置，必须小于 room-service 的 presence stale 窗口。

### 5.3 通用离开房间

```http
POST /api/v1/rooms/leave
```

请求：

```json
{
  "room_id": "9001",
  "command_id": "cmd_leave_9001_10001_1780000000000"
}
```

返回：

```json
{
  "result": {
    "ok": true
  },
  "room": {}
}
```

用途：

- 这是语音房通用离房接口，不属于红包专用接口。
- 主动关闭房间页、切换账号、被客户端确认退出时调用。
- 离房后 room-service 移除业务 presence，红包领取接口再校验该用户时应返回不在房间。
- 如果用户在麦上，离房流程同时释放麦位。
- LeaveRoom 后客户端必须停止房间心跳、退出 RTC 房、退出或忽略 IM 房间群消息。

### 5.4 红包配置

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

返回：

```json
{
  "enabled": true,
  "dailySendLimit": 10,
  "todaySendCount": 1,
  "delaySeconds": 300,
  "expireSeconds": 86400,
  "amountOptions": [15000, 30000, 70000, 100000],
  "countOptions": [5, 10, 20, 50],
  "ruleUrl": "https://example.com/red-packet-rule"
}
```

参数定义：

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `enabled` | bool | 红包功能开关 |
| `dailySendLimit` | int | 每日发送上限 |
| `todaySendCount` | int | 当前用户当天已发送数量 |
| `delaySeconds` | int | 延时红包等待秒数，后台全局唯一配置 |
| `expireSeconds` | int | 固定 86400 秒 |
| `amountOptions` | int64[] | 后台配置金额档位 |
| `countOptions` | int[] | 后台配置红包份数档位 |
| `ruleUrl` | string | 红包规则 H5 地址 |

### 5.5 发送红包

```http
POST /app/voice-room/red-packet/send
```

请求：

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

参数定义：

| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `requestId` | string | 是 | 客户端幂等 ID，重复请求返回同一红包 |
| `roomId` | string | 是 | 当前房间 ID |
| `packetMode` | string | 是 | `IMMEDIATE` 或 `DELAYED` |
| `totalAmount` | int64 | 是 | 总金币，必须命中后台金额档位 |
| `totalCount` | int | 是 | 红包份数，必须命中后台份数档位 |

返回：

```json
{
  "packetNo": "VRRP2026052712340001",
  "roomId": "9001",
  "packetMode": "DELAYED",
  "totalAmount": 15000,
  "totalCount": 5,
  "remainAmount": 15000,
  "remainCount": 5,
  "claimStartTime": "2026-05-27T21:05:00+08:00",
  "claimStartTimeMs": 1780000000000,
  "expireTime": "2026-05-28T21:00:00+08:00",
  "expireTimeMs": 1780086400000,
  "status": "ACTIVE",
  "balanceAfter": 100000
}
```

服务端逻辑：

1. 校验登录态、功能开关，并通过 room-service 通用 presence 校验发送者仍在 `roomId` 房间内。
2. 校验金额和份数档位。
3. 校验每日发送上限。
4. 校验余额并立刻扣金币。
5. 预拆随机份额，写红包主表和份额表。
6. 即时红包 `claimStartTime = createdAt`。
7. 延时红包 `claimStartTime = createdAt + delaySeconds`。
8. 所有红包 `expireTime = createdAt + 24h`。
9. 发送对应 IM 消息。

### 5.6 房间有效红包列表

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

用途：

- 进入房间时初始化侧边栏红包数量。
- 用户关闭弹窗后，通过侧边栏图标重新唤起红包窗口。
- 收到 IM 状态变化后刷新列表。

返回：

```json
{
  "roomId": "9001",
  "unclaimedCount": 3,
  "items": [
    {
      "packetNo": "VRRP2026052712340001",
      "packetMode": "DELAYED",
      "senderUserId": "10001",
      "senderNickname": "Tom",
      "senderAvatar": "https://example.com/avatar.png",
      "totalAmount": 15000,
      "totalCount": 5,
      "remainCount": 5,
      "claimStartTime": "2026-05-27T21:05:00+08:00",
      "claimStartTimeMs": 1780000000000,
      "expireTime": "2026-05-28T21:00:00+08:00",
      "expireTimeMs": 1780086400000,
      "status": "ACTIVE",
      "currentUserClaimed": false,
      "currentUserAmount": 0
    }
  ]
}
```

`unclaimedCount` 统计当前用户未领取且未过期、未抢完的红包数量。

### 5.7 抢红包

```http
POST /app/voice-room/red-packet/claim
```

请求：

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

返回：

```json
{
  "packetNo": "VRRP2026052712340001",
  "claimed": true,
  "amount": 3000,
  "remainCount": 4,
  "totalCount": 5,
  "claimTime": "2026-05-27T21:05:10+08:00",
  "claimTimeMs": 1780000010000,
  "status": "ACTIVE"
}
```

领取失败但业务可展示的情况通过错误码返回：

- `voice_room_red_packet_room_presence_required`：用户不在红包所属房间内，需要先进入房间。
- `voice_room_red_packet_not_started`：延时红包未到可领取时间。
- `voice_room_red_packet_expired`：红包已过期。
- `voice_room_red_packet_finished`：红包已抢完。
- `voice_room_region_not_match`：非同语区不可领取。

领取前校验：

- 抢红包接口不创建、不刷新房间 presence，只读取 room-service 当前 presence。
- 用户必须仍在 `packetNo` 对应的 `roomId` 房间内，否则返回 `voice_room_red_packet_room_presence_required`。
- 进入房间和离开房间仍由通用房间接口负责，不在红包接口里重复实现。

幂等要求：

- 同一用户重复领取同一红包，返回第一次领取结果。
- 钱包入账处理中，返回 `claim_processing`，客户端可重试或查详情。

### 5.8 红包详情

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

返回：

```json
{
  "packetNo": "VRRP2026052712340001",
  "roomId": "9001",
  "packetMode": "DELAYED",
  "sendTime": "2026-05-27 21:00:00",
  "sendTimeMs": 1779999700000,
  "totalCount": 5,
  "totalAmount": 15000,
  "remainCount": 4,
  "remainAmount": 12000,
  "claimedCount": 1,
  "claimedAmount": 3000,
  "refundAmount": 0,
  "claimStartTime": "2026-05-27T21:05:00+08:00",
  "claimStartTimeMs": 1780000000000,
  "expireTime": "2026-05-28T21:00:00+08:00",
  "expireTimeMs": 1780086400000,
  "status": "ACTIVE",
  "sender": {
    "userId": "10001",
    "account": "10001",
    "nickname": "Tom",
    "avatar": "https://example.com/avatar.png"
  },
  "currentUserClaimed": true,
  "currentUserAmount": 3000
}
```

### 5.9 领取记录列表

```http
GET /app/voice-room/red-packet/claim-records?packetNo=VRRP2026052712340001&cursor=&limit=20
```

返回：

```json
{
  "packetNo": "VRRP2026052712340001",
  "totalCount": 5,
  "claimedCount": 1,
  "claimedAmount": 3000,
  "records": [
    {
      "id": "2052643780674195456",
      "userId": "10002",
      "account": "10002",
      "nickname": "Jerry",
      "avatar": "https://example.com/avatar2.png",
      "amount": 3000,
      "claimTime": "2026-05-27 21:05:10",
      "claimTimeMs": 1780000010000
    }
  ],
  "nextCursor": "2052643780674195456",
  "hasMore": false
}
```

### 5.10 发送记录列表

```http
GET /app/voice-room/red-packet/sent-records?cursor=&limit=20
```

返回：

```json
{
  "records": [
    {
      "packetNo": "VRRP2026052712340001",
      "roomId": "9001",
      "packetMode": "DELAYED",
      "sendTime": "2026-05-27 21:00:00",
      "sendTimeMs": 1779999700000,
      "totalCount": 5,
      "totalAmount": 15000,
      "refundAmount": 0,
      "status": "ACTIVE"
    }
  ],
  "nextCursor": "VRRP2026052712340001",
  "hasMore": false
}
```

列表页展示字段：

- 发送时间：`sendTime`
- 红包个数：`totalCount`
- 红包总金额：`totalAmount`
- 退回金额：`refundAmount`

## 6. 后台接口

### 6.1 查询配置

```http
GET /resident-activity/voice-room-red-packet/config
```

返回：

```json
{
  "enabled": true,
  "dailySendLimit": 10,
  "delaySeconds": 300,
  "expireSeconds": 86400,
  "amountOptions": [15000, 30000, 70000, 100000],
  "countOptions": [5, 10, 20, 50],
  "ruleUrl": "https://example.com/red-packet-rule"
}
```

### 6.2 保存配置

```http
POST /resident-activity/voice-room-red-packet/config
```

请求：

```json
{
  "enabled": true,
  "dailySendLimit": 10,
  "delaySeconds": 300,
  "amountOptions": [15000, 30000, 70000, 100000],
  "countOptions": [5, 10, 20, 50],
  "ruleUrl": "https://example.com/red-packet-rule"
}
```

说明：

- `expireSeconds` 固定为 86400，不建议后台配置。
- `amountOptions` 和 `countOptions` 必须都是正整数数组。
- `totalAmount >= totalCount`，保证每份最小金额大于 0。

### 6.3 红包记录查询

```http
GET /resident-activity/voice-room-red-packet/records?sysOrigin=LIKEI&roomId=&senderUserId=&status=&startTime=&endTime=&page=1&pageSize=20
```

返回：

```json
{
  "page": 1,
  "pageSize": 20,
  "total": 1,
  "records": [
    {
      "packetNo": "VRRP2026052712340001",
      "roomId": "9001",
      "senderUserId": "10001",
      "senderNickname": "Tom",
      "packetMode": "DELAYED",
      "totalAmount": 15000,
      "totalCount": 5,
      "claimedAmount": 3000,
      "claimedCount": 1,
      "refundAmount": 0,
      "status": "ACTIVE",
      "sendTime": "2026-05-27 21:00:00",
      "expireTime": "2026-05-28 21:00:00"
    }
  ]
}
```

### 6.4 退款重试

```http
POST /resident-activity/voice-room-red-packet/refund/retry
```

请求：

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

返回：

```json
{
  "packetNo": "VRRP2026052712340001",
  "refundAmount": 3000,
  "refundStatus": "SUCCESS"
}
```

## 7. 错误码

| code | 场景 | 客户端处理 |
| --- | --- | --- |
| `voice_room_red_packet_disabled` | 功能关闭 | 隐藏入口或提示暂不可用 |
| `voice_room_red_packet_amount_invalid` | 金额不在后台档位 | 刷新配置 |
| `voice_room_red_packet_count_invalid` | 个数不在后台档位 | 刷新配置 |
| `voice_room_red_packet_daily_limit_exceeded` | 今日发送上限 | 提示今日发送次数已达上限 |
| `voice_room_red_packet_room_presence_required` | 用户不在当前房间在线态 | 提示需在房间内操作 |
| `voice_room_red_packet_insufficient_balance` | 余额不足 | 拉起充值弹窗 |
| `voice_room_region_missing` | 用户区域缺失 | 提示暂不可参与 |
| `voice_room_region_not_match` | 非同语区领取 | 提示非同语区不可领取 |
| `voice_room_red_packet_not_found` | 红包不存在 | 刷新列表 |
| `voice_room_red_packet_not_started` | 延时红包未到时间 | 展示倒计时 |
| `voice_room_red_packet_expired` | 红包已过期 | 提示已过期并刷新侧边栏 |
| `voice_room_red_packet_finished` | 红包已抢完 | 提示已抢完并刷新侧边栏 |
| `claim_processing` | 钱包入账处理中 | 展示处理中，可重试或查详情 |
| `voice_room_red_packet_wallet_deduct_failed` | 扣款失败 | 提示发送失败 |
| `voice_room_red_packet_wallet_income_failed` | 入账失败 | 提示领取处理中或稍后重试 |
| `voice_room_red_packet_refund_failed` | 退款失败 | 后台重试 |

## 8. 验证点

功能验证：

- 发送即时红包，当前房间用户收到 `ROOM_RED_PACKET CREATED scope=ROOM`，同语区群不收到飘窗。
- 发送延时红包，当前房间用户收到 `scope=ROOM` 弹窗，同语区用户收到 `scope=REGION` 飘窗。
- 延时红包未到 `claimStartTime` 领取返回 `voice_room_red_packet_not_started`。
- 延时红包到点后房间收到 `OPEN_DUE`，已关闭弹窗的用户被强弹。
- 发送者本人可以领取自己发送的红包。
- 同一用户重复领取同一红包返回同一领取结果。
- 并发领取不会超过 `totalAmount` 和 `totalCount`。
- 红包创建 24 小时后，未领取金额退回发送者。
- 领取成功、抢完、过期后房间侧边栏未领取数量实时减少。

接口验证：

- 所有成功接口同时返回 `body` 和 `data`。
- 所有失败接口 `status=false`，`body/data=null`，`code` 与 `errorCodeName` 一致。
- `requestId` 重复发送只生成一个红包。
- `packetNo + userId` 重复领取只生成一条领取记录。
