29 KiB
红包功能技术设计
本文定义普通红包和延迟红包的产品规则、服务边界、账务模型、后台配置、App 接口、区域飘屏、过期退款和验收边界。红包是房间内用户主动发起的金币互动玩法,资金事实必须由 wallet-service 持久化,区域飘屏只是已提交红包事实的实时展示入口。所有业务时间统一使用 UTC,跨服务字段统一使用 Unix epoch milliseconds,字段名保持 *_ms。
相关现有文档:
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 |
展示配置档位、发红包、抢红包、展示领取结果和飘屏入口 | 本地计算可抢状态之外的账务结果 |
状态机
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。
配置表
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 表示禁止发送,不表示无限制。需要无限制时必须用显式大值,避免运营误配置。
红包事实表
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)
);
退款和次数表
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,避免领取顺序和金额排序相关。
推荐算法:
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 改成配置项,但不能让客户端控制。
发红包流程
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展示倒计时。
抢红包流程
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或等价行锁策略获取一条unclaimedallocation。 - 领取成功必须和 claimer 钱包入账、红包剩余数量更新在同一个事务中提交。
- 如果没有未领取份额,返回
RED_PACKET_SOLD_OUT。
过期退款流程
过期退款由 wallet-service worker 执行。cron-service 可以通过内部 gRPC 触发批处理,但不能直接修改红包或钱包表。
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
退款命令号固定:
red_packet_refund:<packet_id>
规则:
- 退款金额等于未领取 allocation 金额总和,不由客户端或外部任务传入。
remaining_amount = 0时只把红包改成finished或expired,不产生 0 金额退款交易。- 重复扫描同一个过期红包必须返回同一退款结果,不能重复加钱。
- 过期判断使用
expires_at_ms <= now_ms。
区域飘屏与 MQ
红包创建成功后,wallet-service 写 wallet_outbox:
{
"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 消费该事实后调用现有播报能力:
BroadcastService.PublishRegionBroadcast(
region_id = event.region_id,
broadcast_type = "red_packet_created",
payload_json = event payload
)
MQ 设计结论:
- 可以把
wallet_outboxrelay 到 MQ,提高实时性。 - 不能让 MQ 成为扣款、领取或退款事实来源。
- activity 消费失败只影响飘屏实时性,不回滚红包创建。
- activity 必须按
event_id幂等,避免 outbox 重投导致重复飘屏。 - 如果 MQ 不可用,activity 可以像首冲奖励 worker 一样轮询
wallet_outbox作为补偿路径。
内部 gRPC 契约
建议在 api/proto/wallet/v1/wallet.proto 增加红包 RPC。
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:
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 接口
获取红包配置
GET /api/v1/red-packets/config
Authorization: Bearer <access_token>
Response data:
{
"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
}
发送红包
POST /api/v1/rooms/{room_id}/red-packets/create
Authorization: Bearer <access_token>
Content-Type: application/json
Request:
{
"command_id": "uuid",
"packet_type": "normal",
"total_amount": 1000,
"packet_count": 10
}
packet_type:
| 值 | 语义 |
|---|---|
normal |
普通红包 |
delayed |
延迟红包,打开时间由后台 delayed_open_seconds 决定 |
Response data:
{
"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
}
房间红包列表
GET /api/v1/rooms/{room_id}/red-packets?status=active&page=1&page_size=20
Authorization: Bearer <access_token>
Response data:
{
"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
}
红包详情
GET /api/v1/red-packets/{packet_id}
Authorization: Bearer <access_token>
Response data:
{
"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
}
领取列表是否展示完整金额由产品决定;如果需要弱化刺激,可以只展示自己的领取金额和已领取人数。
抢红包
POST /api/v1/red-packets/{packet_id}/claim
Authorization: Bearer <access_token>
Content-Type: application/json
Request:
{
"command_id": "uuid"
}
Response data:
{
"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 不一致 |
重新生成命令号后由用户重新发起 |
后台设计
菜单:
活动管理
红包配置
红包记录
红包配置页
字段:
| 字段 | 控件 | 校验 |
|---|---|---|
| 是否启用 | Switch | 关闭后 App 获取配置仍返回档位,但发送接口拒绝 |
| 红包数量档位 | 可增删数字列表 | 每项 > 0,去重,建议排序 |
| 红包金额档位 | 可增删数字列表 | 每项 > 0,去重,金额单位金币 |
| 延迟红包秒数 | 数字输入 | >= 0,0 表示延迟红包立即可抢,但仍标记为 delayed |
| 每人每日最多发送次数 | 数字输入 | >= 0,0 表示禁止发送 |
| 红包过期秒数 | 数字输入 | > 0,必须大于延迟秒数,否则延迟红包可能还没打开就过期 |
后台提交时 admin server 生成审计字段:
admin_id
request_id
operation
before_config
after_config
created_at_ms
红包记录页
筛选:
packet_id
sender_user_id
room_id
region_id
packet_type
status
created_start_ms / created_end_ms
列表字段:
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:
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 不实现红包金额随机、发送次数、过期退款或账务判断,只消费服务端结果。
初始化
进入房间时并行拉取:
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 计算倒计时偏移:
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 重试同一请求。
普通红包:
{
"command_id": "uuid",
"packet_type": "normal",
"total_amount": 1000,
"packet_count": 10
}
延迟红包:
{
"command_id": "uuid",
"packet_type": "delayed",
"total_amount": 1000,
"packet_count": 10
}
延迟秒数不由 Flutter 传入,只使用后台配置。
抢红包
每次点击抢红包生成新的 command_id。如果返回超时,可以用同一个 command_id 查询或重试。收到 RED_PACKET_ALREADY_CLAIMED 时需要拉详情展示已领取金额。
IM 飘屏
区域播报消息:
{
"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,点击后直接拉详情,以服务端状态为准。
实现顺序
wallet-service增加红包表、配置表、领域模型和 MySQL repository。api/proto/wallet/v1增加红包 App RPC、Admin RPC 和ExpireRedPackets批处理 RPC,运行make proto。wallet-service实现CreateRedPacket,覆盖档位、余额、每日次数、随机拆分、幂等和 outbox。wallet-service实现ClaimRedPacket,覆盖延迟未开、过期、重复领取、并发抢完和入账。wallet-service实现过期退款 worker 或批处理 RPC。activity-service增加红包 outbox consumer,调用PublishRegionBroadcast写区域播报。gateway-service增加 App HTTP 接口,保持{code,message,request_id,data}envelope。server/admin增加配置和记录查询接口,写后台审计。hyapp-admin-platform增加活动管理 / 红包配置和红包记录页。- 更新 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 记录查询 | 可按用户、房间、状态和时间范围查询 |
本地真实数据流程
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。
待确认产品点
| 问题 | 默认建议 |
|---|---|
| 发送者是否可以抢自己的红包 | 默认允许,减少用户困惑;如需禁止,加领取校验即可 |
| 红包详情是否展示所有领取金额 | 默认展示自己的金额和领取人数,完整明细可配置 |
| 飘屏点击是否跨房间跳转 | 默认点击进入对应房间并打开红包详情 |
| 延迟红包倒计时期间是否展示总额 | 默认展示总额、数量、打开时间,不展示任何随机份额 |