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