# 红包功能技术设计

本文定义普通红包和延迟红包的产品规则、服务边界、账务模型、后台配置、App 接口、区域飘屏、过期退款和验收边界。红包是房间内用户主动发起的金币互动玩法，资金事实必须由 `wallet-service` 持久化，区域飘屏只是已提交红包事实的实时展示入口。所有业务时间统一使用 UTC，跨服务字段统一使用 Unix epoch milliseconds，字段名保持 `*_ms`。

相关现有文档：

```text
docs/钱包服务架构.md
docs/IM全局与区域广播架构.md
docs/资源组与礼物架构.md
docs/语音房客户端接口流程.md
```

## 当前实现依据

| 能力 | 当前事实 | 红包设计决策 |
| --- | --- | --- |
| 账务 owner | `wallet-service` 已拥有 `wallet_accounts`、`wallet_transactions`、`wallet_entries`、`wallet_outbox` | 红包扣款、领取入账和过期退款全部放在 `wallet-service` |
| 账务幂等 | `wallet_transactions.command_id` 和 `request_hash` 已作为命令幂等模型 | 发红包、抢红包和退款都必须有稳定 `command_id` |
| 事件投递 | 钱包文档约束 `wallet_outbox` 必须和账务交易同事务写入 | 红包创建成功后写 wallet outbox，不让 MQ 成为资金事实来源 |
| 区域播报 | `activity-service` 已有 `BroadcastService.PublishRegionBroadcast` 和 `im_broadcast_outbox` | 红包飘屏由 activity 消费红包创建事件后写区域播报 outbox |
| App HTTP | `gateway-service` `/api/v1` 响应使用 `{code,message,request_id,data}` envelope | 红包 App 接口只在 gateway 暴露，内部走 gRPC |
| 后台入口 | `server/admin` 只能通过 protobuf client 或明确集成接口访问 app 后端 | 后台红包配置通过 admin server 调 wallet-service 管理 RPC |
| 时间规则 | 全局业务时间统一 UTC，时间范围使用 `[start_ms,end_ms)` | 每日发送次数按 UTC 日期统计，过期和延迟打开都用 UTC epoch ms |

## Goals

- 支持普通红包和延迟红包。
- 后台可配置红包数量档位、红包金额档位、延迟红包秒数、每用户每日发送次数上限和红包过期秒数。
- 发红包时立即扣除发送者金币，并把红包金额拆成服务端随机份额。
- 每个用户每个红包最多领取一次，领取金额随机且总和严格等于红包总金额。
- 红包过期后自动退回未领取金额，退款必须幂等。
- 发红包成功后触发区域飘屏，飘屏失败不能回滚已提交账务事实。
- Flutter 只对接接口和 IM 飘屏消息，不在客户端计算红包金额、过期、限次或随机结果。

## Non-Goals

| 不做 | 原因 |
| --- | --- |
| 客户端提交随机拆分结果 | 金额分配必须由服务端生成并持久化 |
| 只用 Redis 保存红包剩余金额 | 红包资金事实需要 MySQL 事务和账务分录恢复 |
| 把红包状态放进 `room-service` | 红包不是 Room Cell 核心状态，房间只提供入口上下文 |
| 用 MQ 直接表示扣款或退款成功 | MQ 可以投递事实，不能成为账务事实来源 |
| 为每个红包创建 IM 群 | 红包是业务消息，不是会话边界 |
| 后台直接写 wallet 业务表 | 后台必须调用 owner service 管理接口并写审计 |

## 产品规则

| 规则 | 决策 |
| --- | --- |
| 红包类型 | `normal` 普通红包，`delayed` 延迟红包 |
| 普通红包 | 创建成功后立即可领取，`open_at_ms = created_at_ms` |
| 延迟红包 | 创建成功时立即扣款，`open_at_ms = created_at_ms + delay_seconds * 1000` |
| 扣款资产 | 只允许 `COIN` 金币 |
| 金额单位 | 整数金币，不使用小数或浮点数 |
| 档位校验 | `total_amount` 必须命中金额档位，`packet_count` 必须命中数量档位 |
| 最小金额 | `total_amount >= packet_count`，保证每份至少 1 金币 |
| 每日次数 | 按 UTC 日期统计 `sender_user_id` 发红包次数 |
| 过期退款 | 过期后未领取金额退回发送者金币账户 |
| 可见范围 | 红包绑定 `room_id`，飘屏发送到房间所在 `region_id` |
| 发送者自领 | 默认允许发送者领取自己的红包；如果产品要求禁止，只需要在领取校验加 `sender_user_id != claimer_user_id` |

## 服务边界

| 模块 | 拥有 | 不拥有 |
| --- | --- | --- |
| `wallet-service` | 红包配置、发红包扣款、随机份额、领取入账、过期退款、每日发送次数、红包账务 outbox | 房间核心状态、IM 群生命周期、用户资料展示 |
| `gateway-service` | App HTTP envelope、鉴权、协议转换、从 room/user 上下文补齐 `room_id/region_id` 后调用 wallet | 红包金额随机、账务分录、过期扫描 |
| `activity-service` | 消费红包创建事实并写 `im_broadcast_outbox`，调用 `PublishRegionBroadcast` | 红包扣款、领取、退款和剩余金额 |
| `server/admin` | 后台菜单、配置表单、审计、调用 wallet 管理 RPC | 直接修改红包业务表或钱包账本 |
| `hyapp-admin-platform` | `活动管理 / 红包配置` 页面和红包记录查询 | 业务校验最终裁决 |
| `hyapp-flutter` | 展示配置档位、发红包、抢红包、展示领取结果和飘屏入口 | 本地计算可抢状态之外的账务结果 |

## 状态机

```mermaid
stateDiagram-v2
  [*] --> created
  created --> active: normal created
  created --> waiting_open: delayed created
  waiting_open --> active: now_ms >= open_at_ms
  active --> finished: remaining_count = 0
  active --> expired: now_ms >= expires_at_ms
  waiting_open --> expired: now_ms >= expires_at_ms
  expired --> refunded: refund remaining_amount
  finished --> [*]
  refunded --> [*]
```

| 状态 | 语义 |
| --- | --- |
| `created` | 红包创建命令开始，通常不作为长期状态暴露 |
| `waiting_open` | 延迟红包已扣款，但未到领取时间 |
| `active` | 可领取 |
| `finished` | 所有份额已领取 |
| `expired` | 已过期，等待或正在执行退款 |
| `refunded` | 未领取金额已退回发送者 |

## 数据库设计

红包表放在 `wallet-service` 所属 MySQL schema，和账务交易同库，便于在同一事务中维护金额、分录和 outbox。

### 配置表

```sql
CREATE TABLE red_packet_configs (
  app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
  enabled TINYINT(1) NOT NULL DEFAULT 0,
  delayed_open_seconds INT NOT NULL DEFAULT 0,
  expire_seconds INT NOT NULL DEFAULT 300,
  daily_send_limit INT NOT NULL DEFAULT 0,
  updated_by_admin_id BIGINT NOT NULL DEFAULT 0,
  created_at_ms BIGINT NOT NULL,
  updated_at_ms BIGINT NOT NULL,
  PRIMARY KEY (app_code)
);

CREATE TABLE red_packet_count_tiers (
  app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
  tier_id BIGINT NOT NULL AUTO_INCREMENT,
  packet_count INT NOT NULL,
  status VARCHAR(32) NOT NULL,
  sort_order INT NOT NULL DEFAULT 0,
  created_at_ms BIGINT NOT NULL,
  updated_at_ms BIGINT NOT NULL,
  PRIMARY KEY (tier_id),
  UNIQUE KEY uk_red_packet_count_tier (app_code, packet_count)
);

CREATE TABLE red_packet_amount_tiers (
  app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
  tier_id BIGINT NOT NULL AUTO_INCREMENT,
  total_amount BIGINT NOT NULL,
  status VARCHAR(32) NOT NULL,
  sort_order INT NOT NULL DEFAULT 0,
  created_at_ms BIGINT NOT NULL,
  updated_at_ms BIGINT NOT NULL,
  PRIMARY KEY (tier_id),
  UNIQUE KEY uk_red_packet_amount_tier (app_code, total_amount)
);
```

`daily_send_limit = 0` 表示禁止发送，不表示无限制。需要无限制时必须用显式大值，避免运营误配置。

### 红包事实表

```sql
CREATE TABLE red_packets (
  app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
  packet_id VARCHAR(96) NOT NULL,
  command_id VARCHAR(128) NOT NULL,
  sender_user_id BIGINT NOT NULL,
  room_id VARCHAR(96) NOT NULL,
  region_id BIGINT NOT NULL,
  packet_type VARCHAR(32) NOT NULL,
  total_amount BIGINT NOT NULL,
  packet_count INT NOT NULL,
  remaining_amount BIGINT NOT NULL,
  remaining_count INT NOT NULL,
  status VARCHAR(32) NOT NULL,
  open_at_ms BIGINT NOT NULL,
  expires_at_ms BIGINT NOT NULL,
  created_at_ms BIGINT NOT NULL,
  updated_at_ms BIGINT NOT NULL,
  PRIMARY KEY (app_code, packet_id),
  UNIQUE KEY uk_red_packets_command (app_code, command_id),
  KEY idx_red_packets_room_time (app_code, room_id, created_at_ms),
  KEY idx_red_packets_expire (app_code, status, expires_at_ms)
);

CREATE TABLE red_packet_allocations (
  app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
  packet_id VARCHAR(96) NOT NULL,
  allocation_no INT NOT NULL,
  amount BIGINT NOT NULL,
  status VARCHAR(32) NOT NULL,
  claimed_by_user_id BIGINT NOT NULL DEFAULT 0,
  claim_id VARCHAR(96) NOT NULL DEFAULT '',
  claimed_at_ms BIGINT NOT NULL DEFAULT 0,
  PRIMARY KEY (app_code, packet_id, allocation_no),
  KEY idx_red_packet_alloc_claimable (app_code, packet_id, status, allocation_no),
  KEY idx_red_packet_alloc_user (app_code, claimed_by_user_id, claimed_at_ms)
);

CREATE TABLE red_packet_claims (
  app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
  claim_id VARCHAR(96) NOT NULL,
  command_id VARCHAR(128) NOT NULL,
  packet_id VARCHAR(96) NOT NULL,
  user_id BIGINT NOT NULL,
  amount BIGINT NOT NULL,
  wallet_transaction_id VARCHAR(96) NOT NULL,
  status VARCHAR(32) NOT NULL,
  failure_reason VARCHAR(64) NOT NULL DEFAULT '',
  created_at_ms BIGINT NOT NULL,
  updated_at_ms BIGINT NOT NULL,
  PRIMARY KEY (app_code, claim_id),
  UNIQUE KEY uk_red_packet_claim_command (app_code, command_id),
  UNIQUE KEY uk_red_packet_claim_user (app_code, packet_id, user_id),
  KEY idx_red_packet_claim_packet (app_code, packet_id, created_at_ms)
);
```

### 退款和次数表

```sql
CREATE TABLE red_packet_refunds (
  app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
  refund_id VARCHAR(96) NOT NULL,
  command_id VARCHAR(128) NOT NULL,
  packet_id VARCHAR(96) NOT NULL,
  sender_user_id BIGINT NOT NULL,
  refund_amount BIGINT NOT NULL,
  wallet_transaction_id VARCHAR(96) NOT NULL,
  status VARCHAR(32) NOT NULL,
  created_at_ms BIGINT NOT NULL,
  updated_at_ms BIGINT NOT NULL,
  PRIMARY KEY (app_code, refund_id),
  UNIQUE KEY uk_red_packet_refund_command (app_code, command_id),
  UNIQUE KEY uk_red_packet_refund_packet (app_code, packet_id)
);

CREATE TABLE red_packet_user_daily_counters (
  app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
  user_id BIGINT NOT NULL,
  send_day CHAR(10) NOT NULL,
  sent_count INT NOT NULL,
  created_at_ms BIGINT NOT NULL,
  updated_at_ms BIGINT NOT NULL,
  PRIMARY KEY (app_code, user_id, send_day)
);
```

`send_day` 使用 UTC 日期字符串，例如 `2026-05-22`。不要使用服务器本地时区或客户端时区。

## 账务分录

红包使用 `COIN` 资产，建议新增账务类型：

| `biz_type` | 场景 | 分录 |
| --- | --- | --- |
| `red_packet_create` | 发送红包 | sender `COIN` `available_delta = -total_amount` |
| `red_packet_claim` | 抢红包成功 | claimer `COIN` `available_delta = +amount` |
| `red_packet_refund` | 过期退款 | sender `COIN` `available_delta = +remaining_amount` |

发红包扣款不是冻结余额，而是实际扣除并形成红包负债。未领取金额由 `red_packets.remaining_amount` 表达，过期后通过退款交易回到发送者账户。这样账本只保留用户余额事实，红包池余额由红包领域表负责审计，避免给用户账户引入复杂冻结语义。

## 随机金额拆分

红包创建时一次性生成所有份额，写入 `red_packet_allocations`。领取时只分配已经生成的未领取份额，不再动态计算金额。

约束：

- 每份最小 `1` 金币。
- 份额数量等于 `packet_count`。
- 份额总和严格等于 `total_amount`。
- 使用服务端安全随机数。
- 生成后打乱 `allocation_no`，避免领取顺序和金额排序相关。

推荐算法：

```text
remaining_amount = total_amount
remaining_count = packet_count

for i in 1..packet_count:
  if remaining_count == 1:
    amount = remaining_amount
  else:
    min_amount = 1
    max_amount = remaining_amount - (remaining_count - 1)
    avg = remaining_amount / remaining_count
    draw_max = min(max_amount, max(1, avg * 2))
    amount = secure_random_int(min_amount, draw_max)

  append amount
  remaining_amount -= amount
  remaining_count -= 1

shuffle amounts
```

这个算法保留随机波动，同时保证最后不会出现剩余金额不足以分配最小份额的情况。后续如果产品需要更平滑分布，可以把 `avg * 2` 改成配置项，但不能让客户端控制。

## 发红包流程

```mermaid
sequenceDiagram
  participant C as Flutter
  participant G as gateway-service
  participant R as room-service
  participant W as wallet-service
  participant DB as wallet MySQL
  participant A as activity-service
  participant IM as Tencent IM

  C->>G: POST /api/v1/rooms/{room_id}/red-packets
  G->>R: GetRoomContext(room_id)
  R-->>G: room_id, visible_region_id
  G->>W: CreateRedPacket(command_id, sender, room, region, type, amount, count)
  W->>DB: validate config + daily counter + debit + allocations + wallet_outbox
  W-->>G: packet_id, open_at_ms, expires_at_ms
  G-->>C: red packet created
  A->>DB: scan wallet_outbox red_packet.created
  A->>A: PublishRegionBroadcast
  A->>IM: region floating broadcast
```

关键规则：

- `gateway-service` 不能信任客户端提交的 `region_id`，必须从房间上下文取房间可见区域。
- `wallet-service` 在同一个 MySQL 事务内完成配置校验、每日次数递增、sender 扣款、红包记录、份额记录、钱包交易、钱包分录和 `wallet_outbox`。
- 同一个 `app_code + command_id` 重复调用，payload 相同返回原红包，payload 不同返回 `REQUEST_CONFLICT`。
- 延迟红包创建成功后立即扣款和飘屏，客户端根据 `open_at_ms` 展示倒计时。

## 抢红包流程

```mermaid
sequenceDiagram
  participant C as Flutter
  participant G as gateway-service
  participant W as wallet-service
  participant DB as wallet MySQL

  C->>G: POST /api/v1/red-packets/{packet_id}/claim
  G->>W: ClaimRedPacket(command_id, packet_id, user_id)
  W->>DB: lock packet
  W->>DB: check open/expire/already claimed
  W->>DB: lock one unclaimed allocation
  W->>DB: credit claimer + claim + update remaining
  W-->>G: amount, claim_id
  G-->>C: claim result
```

并发控制：

- `red_packets` 行先加锁，保证剩余金额和数量更新一致。
- `red_packet_claims` 上 `uk_red_packet_claim_user(app_code, packet_id, user_id)` 保证每人每红包最多领取一次。
- 未领取份额使用 `SELECT ... FOR UPDATE SKIP LOCKED` 或等价行锁策略获取一条 `unclaimed` allocation。
- 领取成功必须和 claimer 钱包入账、红包剩余数量更新在同一个事务中提交。
- 如果没有未领取份额，返回 `RED_PACKET_SOLD_OUT`。

## 过期退款流程

过期退款由 `wallet-service` worker 执行。`cron-service` 可以通过内部 gRPC 触发批处理，但不能直接修改红包或钱包表。

```mermaid
sequenceDiagram
  participant Cron as cron-service or wallet worker
  participant W as wallet-service
  participant DB as wallet MySQL

  Cron->>W: ExpireRedPackets(batch_size)
  W->>DB: select active/waiting packets expires_at_ms <= now for update
  W->>DB: sum unclaimed allocations
  W->>DB: credit sender refund
  W->>DB: mark allocations expired
  W->>DB: mark packet refunded
  W->>DB: insert wallet_outbox red_packet.refunded
```

退款命令号固定：

```text
red_packet_refund:<packet_id>
```

规则：

- 退款金额等于未领取 allocation 金额总和，不由客户端或外部任务传入。
- `remaining_amount = 0` 时只把红包改成 `finished` 或 `expired`，不产生 0 金额退款交易。
- 重复扫描同一个过期红包必须返回同一退款结果，不能重复加钱。
- 过期判断使用 `expires_at_ms <= now_ms`。

## 区域飘屏与 MQ

红包创建成功后，`wallet-service` 写 `wallet_outbox`：

```json
{
  "event_type": "red_packet.created",
  "packet_id": "rp_xxx",
  "packet_type": "normal",
  "sender_user_id": 123,
  "room_id": "100001",
  "region_id": 86,
  "total_amount": 1000,
  "packet_count": 10,
  "open_at_ms": 1780000000000,
  "expires_at_ms": 1780000300000,
  "created_at_ms": 1780000000000
}
```

`activity-service` 消费该事实后调用现有播报能力：

```text
BroadcastService.PublishRegionBroadcast(
  region_id = event.region_id,
  broadcast_type = "red_packet_created",
  payload_json = event payload
)
```

MQ 设计结论：

- 可以把 `wallet_outbox` relay 到 MQ，提高实时性。
- 不能让 MQ 成为扣款、领取或退款事实来源。
- activity 消费失败只影响飘屏实时性，不回滚红包创建。
- activity 必须按 `event_id` 幂等，避免 outbox 重投导致重复飘屏。
- 如果 MQ 不可用，activity 可以像首冲奖励 worker 一样轮询 `wallet_outbox` 作为补偿路径。

## 内部 gRPC 契约

建议在 `api/proto/wallet/v1/wallet.proto` 增加红包 RPC。

```proto
message RedPacketConfig {
  bool enabled = 1;
  repeated int32 count_tiers = 2;
  repeated int64 amount_tiers = 3;
  int32 delayed_open_seconds = 4;
  int32 expire_seconds = 5;
  int32 daily_send_limit = 6;
  int64 updated_at_ms = 7;
}

message CreateRedPacketRequest {
  common.v1.RequestMeta meta = 1;
  string command_id = 2;
  string app_code = 3;
  int64 sender_user_id = 4;
  string room_id = 5;
  int64 region_id = 6;
  string packet_type = 7;
  int64 total_amount = 8;
  int32 packet_count = 9;
}

message CreateRedPacketResponse {
  string packet_id = 1;
  string status = 2;
  int64 total_amount = 3;
  int32 packet_count = 4;
  int64 open_at_ms = 5;
  int64 expires_at_ms = 6;
  int64 balance_after = 7;
}

message ClaimRedPacketRequest {
  common.v1.RequestMeta meta = 1;
  string command_id = 2;
  string app_code = 3;
  string packet_id = 4;
  int64 user_id = 5;
}

message ClaimRedPacketResponse {
  string claim_id = 1;
  string packet_id = 2;
  int64 amount = 3;
  int64 claimed_at_ms = 4;
  int64 balance_after = 5;
}
```

管理侧 RPC：

```proto
rpc GetRedPacketConfig(GetRedPacketConfigRequest) returns (GetRedPacketConfigResponse);
rpc UpdateRedPacketConfig(UpdateRedPacketConfigRequest) returns (UpdateRedPacketConfigResponse);
rpc ListRedPackets(ListRedPacketsRequest) returns (ListRedPacketsResponse);
rpc GetRedPacket(GetRedPacketRequest) returns (GetRedPacketResponse);
rpc ExpireRedPackets(ExpireRedPacketsRequest) returns (ExpireRedPacketsResponse);
```

## App HTTP 接口

### 获取红包配置

```http
GET /api/v1/red-packets/config
Authorization: Bearer <access_token>
```

Response `data`：

```json
{
  "config": {
    "enabled": true,
    "count_tiers": [5, 10, 20],
    "amount_tiers": [100, 500, 1000],
    "delayed_open_seconds": 30,
    "expire_seconds": 300,
    "daily_send_limit": 5
  },
  "server_time_ms": 1780000000000
}
```

### 发送红包

```http
POST /api/v1/rooms/{room_id}/red-packets/create
Authorization: Bearer <access_token>
Content-Type: application/json
```

Request：

```json
{
  "command_id": "uuid",
  "packet_type": "normal",
  "total_amount": 1000,
  "packet_count": 10
}
```

`packet_type`：

| 值 | 语义 |
| --- | --- |
| `normal` | 普通红包 |
| `delayed` | 延迟红包，打开时间由后台 `delayed_open_seconds` 决定 |

Response `data`：

```json
{
  "packet": {
    "packet_id": "rp_xxx",
    "packet_type": "normal",
    "status": "active",
    "total_amount": 1000,
    "packet_count": 10,
    "remaining_count": 10,
    "open_at_ms": 1780000000000,
    "expires_at_ms": 1780000300000
  },
  "server_time_ms": 1780000000000,
  "balance_after": 99000
}
```

### 房间红包列表

```http
GET /api/v1/rooms/{room_id}/red-packets?status=active&page=1&page_size=20
Authorization: Bearer <access_token>
```

Response `data`：

```json
{
  "packets": [
    {
      "packet_id": "rp_xxx",
      "packet_type": "delayed",
      "sender_user_id": 123,
      "total_amount": 1000,
      "packet_count": 10,
      "remaining_count": 10,
      "status": "waiting_open",
      "open_at_ms": 1780000030000,
      "expires_at_ms": 1780000300000,
      "created_at_ms": 1780000000000,
      "claimed_by_viewer": false
    }
  ],
  "total": 1,
  "page": 1,
  "page_size": 20,
  "server_time_ms": 1780000001000
}
```

### 红包详情

```http
GET /api/v1/red-packets/{packet_id}
Authorization: Bearer <access_token>
```

Response `data`：

```json
{
  "packet": {
    "packet_id": "rp_xxx",
    "packet_type": "normal",
    "sender_user_id": 123,
    "room_id": "100001",
    "region_id": 86,
    "total_amount": 1000,
    "packet_count": 10,
    "remaining_amount": 420,
    "remaining_count": 4,
    "status": "active",
    "open_at_ms": 1780000000000,
    "expires_at_ms": 1780000300000,
    "claimed_by_viewer": true,
    "viewer_claim_amount": 88,
    "claims": [
      {
        "user_id": 456,
        "amount": 120,
        "created_at_ms": 1780000005000
      }
    ]
  },
  "server_time_ms": 1780000010000
}
```

领取列表是否展示完整金额由产品决定；如果需要弱化刺激，可以只展示自己的领取金额和已领取人数。

### 抢红包

```http
POST /api/v1/red-packets/{packet_id}/claim
Authorization: Bearer <access_token>
Content-Type: application/json
```

Request：

```json
{
  "command_id": "uuid"
}
```

Response `data`：

```json
{
  "claim": {
    "claim_id": "rpc_xxx",
    "packet_id": "rp_xxx",
    "amount": 88,
    "created_at_ms": 1780000010000
  },
  "server_time_ms": 1780000010000,
  "balance_after": 100088
}
```

## 错误码

| code | HTTP | 场景 | Flutter 处理 |
| --- | --- | --- | --- |
| `RED_PACKET_DISABLED` | 409 | 红包功能未启用 | 隐藏入口或提示暂不可用 |
| `INVALID_ARGUMENT` | 400 | 类型不是 `normal/delayed` 或请求字段缺失 | 不重试，刷新配置 |
| `INVALID_AMOUNT_TIER` | 400 | 金额未命中后台档位 | 不重试，刷新配置 |
| `INVALID_COUNT_TIER` | 400 | 数量未命中后台档位 | 不重试，刷新配置 |
| `RED_PACKET_AMOUNT_TOO_SMALL` | 400 | 总金额小于红包数量 | 不重试，前端校验兜底 |
| `DAILY_SEND_LIMIT_REACHED` | 429 | 达到 UTC 当日发送上限 | 禁用发送入口到下一 UTC 日 |
| `INSUFFICIENT_BALANCE` | 409 | 金币不足 | 引导充值或选择低档 |
| `NOT_FOUND` | 404 | 红包不存在 | 关闭详情页并刷新列表 |
| `RED_PACKET_NOT_OPEN` | 409 | 延迟红包未到打开时间 | 展示倒计时，不重试抢 |
| `RED_PACKET_EXPIRED` | 409 | 红包已过期 | 刷新详情和列表 |
| `RED_PACKET_ALREADY_CLAIMED` | 409 | 当前用户已领取 | 展示已领取金额 |
| `RED_PACKET_SOLD_OUT` | 409 | 已被抢完 | 展示已抢完 |
| `REQUEST_CONFLICT` | 409 | 同一 `command_id` payload 不一致 | 重新生成命令号后由用户重新发起 |

## 后台设计

菜单：

```text
活动管理
  红包配置
  红包记录
```

### 红包配置页

字段：

| 字段 | 控件 | 校验 |
| --- | --- | --- |
| 是否启用 | Switch | 关闭后 App 获取配置仍返回档位，但发送接口拒绝 |
| 红包数量档位 | 可增删数字列表 | 每项 `> 0`，去重，建议排序 |
| 红包金额档位 | 可增删数字列表 | 每项 `> 0`，去重，金额单位金币 |
| 延迟红包秒数 | 数字输入 | `>= 0`，`0` 表示延迟红包立即可抢，但仍标记为 delayed |
| 每人每日最多发送次数 | 数字输入 | `>= 0`，`0` 表示禁止发送 |
| 红包过期秒数 | 数字输入 | `> 0`，必须大于延迟秒数，否则延迟红包可能还没打开就过期 |

后台提交时 admin server 生成审计字段：

```text
admin_id
request_id
operation
before_config
after_config
created_at_ms
```

### 红包记录页

筛选：

```text
packet_id
sender_user_id
room_id
region_id
packet_type
status
created_start_ms / created_end_ms
```

列表字段：

```text
packet_id
sender_user_id
room_id
region_id
packet_type
total_amount
packet_count
remaining_amount
remaining_count
status
open_at_ms
expires_at_ms
created_at_ms
```

详情页展示领取明细、退款记录和钱包交易 ID，只读，不允许后台手动改红包状态。需要人工补偿时走现有后台调账或补偿命令，不直接改红包表。

Admin HTTP：

```http
GET /api/v1/admin/activity/red-packets/config
PUT /api/v1/admin/activity/red-packets/config
GET /api/v1/admin/activity/red-packets
GET /api/v1/admin/activity/red-packets/{packet_id}
```

## Flutter 对接方案

Flutter 不实现红包金额随机、发送次数、过期退款或账务判断，只消费服务端结果。

### 初始化

进入房间时并行拉取：

```text
GET /api/v1/red-packets/config
GET /api/v1/rooms/{room_id}/red-packets?status=active&page=1&page_size=20
```

UI 使用 `server_time_ms` 计算倒计时偏移：

```text
client_offset_ms = server_time_ms - local_now_ms
effective_now_ms = local_now_ms + client_offset_ms
```

倒计时只用于展示，最终可抢状态以服务端 `ClaimRedPacket` 结果为准。

### 发红包

用户只能从 `count_tiers` 和 `amount_tiers` 选择。提交前生成一次性 `command_id`，网络超时可用同一个 `command_id` 重试同一请求。

普通红包：

```json
{
  "command_id": "uuid",
  "packet_type": "normal",
  "total_amount": 1000,
  "packet_count": 10
}
```

延迟红包：

```json
{
  "command_id": "uuid",
  "packet_type": "delayed",
  "total_amount": 1000,
  "packet_count": 10
}
```

延迟秒数不由 Flutter 传入，只使用后台配置。

### 抢红包

每次点击抢红包生成新的 `command_id`。如果返回超时，可以用同一个 `command_id` 查询或重试。收到 `RED_PACKET_ALREADY_CLAIMED` 时需要拉详情展示已领取金额。

### IM 飘屏

区域播报消息：

```json
{
  "broadcast_type": "red_packet_created",
  "packet_id": "rp_xxx",
  "packet_type": "delayed",
  "sender_user_id": 123,
  "room_id": "100001",
  "region_id": 86,
  "total_amount": 1000,
  "packet_count": 10,
  "open_at_ms": 1780000030000,
  "expires_at_ms": 1780000300000,
  "created_at_ms": 1780000000000
}
```

Flutter 收到飘屏后：

- 当前在同区域时展示飘屏。
- 点击飘屏进入对应房间或打开红包详情。
- 如果 `effective_now_ms < open_at_ms`，展示倒计时。
- 如果 `effective_now_ms >= expires_at_ms`，点击后直接拉详情，以服务端状态为准。

## 实现顺序

1. `wallet-service` 增加红包表、配置表、领域模型和 MySQL repository。
2. `api/proto/wallet/v1` 增加红包 App RPC、Admin RPC 和 `ExpireRedPackets` 批处理 RPC，运行 `make proto`。
3. `wallet-service` 实现 `CreateRedPacket`，覆盖档位、余额、每日次数、随机拆分、幂等和 outbox。
4. `wallet-service` 实现 `ClaimRedPacket`，覆盖延迟未开、过期、重复领取、并发抢完和入账。
5. `wallet-service` 实现过期退款 worker 或批处理 RPC。
6. `activity-service` 增加红包 outbox consumer，调用 `PublishRegionBroadcast` 写区域播报。
7. `gateway-service` 增加 App HTTP 接口，保持 `{code,message,request_id,data}` envelope。
8. `server/admin` 增加配置和记录查询接口，写后台审计。
9. `hyapp-admin-platform` 增加 `活动管理 / 红包配置` 和红包记录页。
10. 更新 Flutter 对接文档，不改 Flutter 代码，等 App 侧按接口接入。

## 验证计划

### wallet-service 单元和 MySQL 集成

| 场景 | 预期 |
| --- | --- |
| 创建普通红包 | sender 扣款，红包 active，allocation 总和等于总金额 |
| 创建延迟红包 | sender 扣款，红包 waiting_open，`open_at_ms` 正确 |
| 非档位金额 | 返回 `INVALID_AMOUNT_TIER`，余额不变 |
| 非档位数量 | 返回 `INVALID_COUNT_TIER`，余额不变 |
| 总金额小于数量 | 返回 `RED_PACKET_AMOUNT_TOO_SMALL` |
| 金币不足 | 返回 `INSUFFICIENT_BALANCE`，不写红包 |
| 每日次数超过 | 返回 `DAILY_SEND_LIMIT_REACHED`，按 UTC 日期切换 |
| 同 command 重试 | 返回同一 `packet_id`，余额只扣一次 |
| 同 command 不同 payload | 返回 `REQUEST_CONFLICT` |
| 未到打开时间领取 | 返回 `RED_PACKET_NOT_OPEN` |
| 重复领取 | 第二次返回 `RED_PACKET_ALREADY_CLAIMED` |
| 并发领取 | 不超发，领取总额等于红包总额 |
| 抢完后领取 | 返回 `RED_PACKET_SOLD_OUT` |
| 过期退款 | 未领取金额退回 sender，重复扫描不重复退款 |

### activity-service 验证

| 场景 | 预期 |
| --- | --- |
| 消费 `red_packet.created` | 写入一条 `im_broadcast_outbox` |
| outbox 重投 | 不重复写同一飘屏 |
| 区域 ID 无效 | 记录失败原因，不影响红包账务 |

### gateway/admin 验证

| 场景 | 预期 |
| --- | --- |
| App 接口 envelope | 所有业务响应符合 `{code,message,request_id,data}` |
| gateway 不信任 region_id | 客户端无法伪造区域飘屏 |
| admin 更新配置 | wallet 配置生效，写后台审计 |
| admin 记录查询 | 可按用户、房间、状态和时间范围查询 |

### 本地真实数据流程

```text
1. admin 配置数量档位、金额档位、延迟秒数、过期秒数和每日次数。
2. 用户 A 充值金币或后台入账金币。
3. 用户 A 在房间发普通红包，确认钱包扣款、红包 active、区域飘屏 outbox 产生。
4. 用户 B/C/D 抢红包，确认每人金额随机、总和不超过总额、钱包入账。
5. 用户 A 发延迟红包，open_at_ms 前抢返回 RED_PACKET_NOT_OPEN。
6. 到 open_at_ms 后领取成功。
7. 创建一个短过期红包，等待过期 worker 执行，确认未领取金额退回用户 A。
8. 同一 UTC 日连续发送到上限，再发送返回 DAILY_SEND_LIMIT_REACHED。
```

## 待确认产品点

| 问题 | 默认建议 |
| --- | --- |
| 发送者是否可以抢自己的红包 | 默认允许，减少用户困惑；如需禁止，加领取校验即可 |
| 红包详情是否展示所有领取金额 | 默认展示自己的金额和领取人数，完整明细可配置 |
| 飘屏点击是否跨房间跳转 | 默认点击进入对应房间并打开红包详情 |
| 延迟红包倒计时期间是否展示总额 | 默认展示总额、数量、打开时间，不展示任何随机份额 |
