hyapp-server/docs/红包功能技术设计.md

899 lines
29 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 红包功能技术设计
本文定义普通红包和延迟红包的产品规则、服务边界、账务模型、后台配置、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 扣款,红包 activeallocation 总和等于总金额 |
| 创建延迟红包 | 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。
```
## 待确认产品点
| 问题 | 默认建议 |
| --- | --- |
| 发送者是否可以抢自己的红包 | 默认允许,减少用户困惑;如需禁止,加领取校验即可 |
| 红包详情是否展示所有领取金额 | 默认展示自己的金额和领取人数,完整明细可配置 |
| 飘屏点击是否跨房间跳转 | 默认点击进入对应房间并打开红包详情 |
| 延迟红包倒计时期间是否展示总额 | 默认展示总额、数量、打开时间,不展示任何随机份额 |