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

29 KiB
Raw Permalink Blame History

红包功能技术设计

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

相关现有文档:

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

当前实现依据

能力 当前事实 红包设计决策
账务 owner wallet-service 已拥有 wallet_accountswallet_transactionswallet_entrieswallet_outbox 红包扣款、领取入账和过期退款全部放在 wallet-service
账务幂等 wallet_transactions.command_idrequest_hash 已作为命令幂等模型 发红包、抢红包和退款都必须有稳定 command_id
事件投递 钱包文档约束 wallet_outbox 必须和账务交易同事务写入 红包创建成功后写 wallet outbox不让 MQ 成为资金事实来源
区域播报 activity-service 已有 BroadcastService.PublishRegionBroadcastim_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_claimsuk_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 触发批处理,但不能直接修改红包或钱包表。

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 时只把红包改成 finishedexpired,不产生 0 金额退款交易。
  • 重复扫描同一个过期红包必须返回同一退款结果,不能重复加钱。
  • 过期判断使用 expires_at_ms <= now_ms

区域飘屏与 MQ

红包创建成功后,wallet-servicewallet_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_outbox relay 到 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,去重,金额单位金币
延迟红包秒数 数字输入 >= 00 表示延迟红包立即可抢,但仍标记为 delayed
每人每日最多发送次数 数字输入 >= 00 表示禁止发送
红包过期秒数 数字输入 > 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_tiersamount_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,点击后直接拉详情,以服务端状态为准。

实现顺序

  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_openopen_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。

待确认产品点

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