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