15 KiB
Lucky Gift Activity Service Development Guide
本文档定义幸运礼物在 activity-service 中的开发落地。产品规则见 docs/lucky-gift-product.md。
Ownership
幸运礼物归属 activity-service,但金币账务仍归属 wallet-service,房间状态仍归属 room-service。
| Capability | Owner | Notes |
|---|---|---|
| 幸运礼物规则配置 | activity-service |
概率表、奖档、启停、版本 |
| 三层奖池 | activity-service |
平台、房间、礼物水位 |
| 抽奖决策 | activity-service |
先过滤奖档,再执行随机 |
| 风控阈值 | activity-service |
用户、设备、房间、主播、活动周期 |
| 金币扣费 | wallet-service |
room-service 送礼前同步扣费 |
| 金币返还 | wallet-service |
需要新增幂等入账 RPC |
| 房间热度和榜单 | room-service |
Room Cell 内串行变更 |
| 房间消息投递 | im-service |
只做投递和时间线 |
activity-service 不直接修改用户金币余额,不直接写 Room Cell,不直接保存 WebSocket 连接。
Target Flow
幸运礼物在普通 SendGift 链路上增加一次活动决策。
sequenceDiagram
participant C as Client
participant G as gateway-service
participant R as room-service
participant W as wallet-service
participant A as activity-service
participant I as im-service
C->>G: HTTP SendGift
G->>R: gRPC SendGift
R->>A: CheckLuckyGift
A-->>R: enabled + rule_version
R->>W: DebitGift
W-->>R: billing_receipt_id
R->>A: ExecuteLuckyGiftDraw
A->>A: lock pools + filter tiers + draw + write log
A->>W: CreditLuckyReward
W-->>A: credit_receipt_id
A-->>R: draw_result
R->>R: Room Cell apply gift + lucky presentation
R->>I: PublishRoomEvent
R-->>G: SendGiftResponse
G-->>C: JSON result
如果 CreditLuckyReward 暂时失败,activity-service 返回 reward_status=PENDING,并由本服务 outbox 重试入账。room-service 不能广播“已到账”,只能广播普通礼物或“奖励处理中”。
RPC Contracts
建议新增 api/proto/activity/v1/lucky_gift.proto。
syntax = "proto3";
package hyapp.activity.v1;
option go_package = "hyapp/api/proto/activity/v1;activityv1";
message LuckyGiftMeta {
string request_id = 1;
string command_id = 2;
string room_id = 3;
int64 sender_user_id = 4;
int64 target_user_id = 5;
string device_id = 6;
int64 sent_at_ms = 7;
}
message CheckLuckyGiftRequest {
LuckyGiftMeta meta = 1;
string gift_id = 2;
int32 gift_count = 3;
int64 gift_unit_price = 4;
}
message CheckLuckyGiftResponse {
bool enabled = 1;
string rule_version = 2;
string reject_reason = 3;
}
message ExecuteLuckyGiftDrawRequest {
LuckyGiftMeta meta = 1;
string gift_id = 2;
int32 gift_count = 3;
int64 gift_unit_price = 4;
int64 total_gift_value = 5;
string billing_receipt_id = 6;
string rule_version = 7;
}
message RewardTierResult {
string tier_id = 1;
int64 reward_coins = 2;
string tier_type = 3;
}
message ExecuteLuckyGiftDrawResponse {
string draw_id = 1;
string rule_version = 2;
bool hit = 3;
RewardTierResult tier = 4;
string reward_status = 5;
string credit_receipt_id = 6;
string reject_reason = 7;
}
service LuckyGiftService {
rpc CheckLuckyGift(CheckLuckyGiftRequest) returns (CheckLuckyGiftResponse);
rpc ExecuteLuckyGiftDraw(ExecuteLuckyGiftDrawRequest) returns (ExecuteLuckyGiftDrawResponse);
}
wallet-service 需要新增金币返还接口,避免 activity-service 直接写账务库。
message CreditLuckyRewardRequest {
string reward_id = 1;
string command_id = 2;
string draw_id = 3;
string room_id = 4;
int64 user_id = 5;
int64 reward_coins = 6;
string reason = 7;
}
message CreditLuckyRewardResponse {
string credit_receipt_id = 1;
int64 balance_after = 2;
}
Database Tables
activity-service 需要独立 MySQL schema。Redis 只用于短期计数和热缓存,不能作为抽奖结果的唯一存储。
lucky_gift_rules
保存幸运礼物主规则。
| Column | Type | Notes |
|---|---|---|
id |
bigint | primary key |
gift_id |
varchar | unique with rule_version |
rule_version |
varchar | immutable after publish |
gift_price |
bigint | single gift price in coins |
pool_rate_ppm |
int | pool inflow rate, ppm |
target_payout_rate_ppm |
int | expected long-term payout |
max_single_payout |
bigint | max reward per draw |
max_user_daily_payout |
bigint | user daily cap |
max_room_hourly_payout |
bigint | room hourly cap |
status |
varchar | draft, active, paused, retired |
effective_at_ms |
bigint | version effective time |
created_at_ms |
bigint | creation time |
updated_at_ms |
bigint | update time |
lucky_reward_tiers
保存概率表奖档。
| Column | Type | Notes |
|---|---|---|
id |
bigint | primary key |
gift_id |
varchar | gift id |
rule_version |
varchar | rule version |
tier_id |
varchar | none, small_1x, medium_5x |
reward_type |
varchar | none, fixed, multiplier |
reward_value |
bigint | fixed coins or multiplier base |
probability_ppm |
int | base probability |
min_pool_balance |
bigint | activation waterline |
enabled |
bool | tier switch |
All tier probabilities in one rule version must sum to 1000000 ppm.
lucky_pools
保存三层奖池水位。
| Column | Type | Notes |
|---|---|---|
scope_type |
varchar | platform, room, gift |
scope_id |
varchar | global, room_id, gift_id |
balance |
bigint | available coins |
reserve_floor |
bigint | minimum safe waterline |
total_in |
bigint | historical inflow |
total_out |
bigint | historical payout |
version |
bigint | optimistic version |
updated_at_ms |
bigint | update time |
The primary key is (scope_type, scope_id).
lucky_draw_records
保存每次抽奖事实。
| Column | Type | Notes |
|---|---|---|
draw_id |
varchar | primary key |
command_id |
varchar | unique idempotency key |
billing_receipt_id |
varchar | wallet debit receipt |
room_id |
varchar | room id |
sender_user_id |
bigint | payer |
target_user_id |
bigint | gift receiver |
gift_id |
varchar | gift id |
gift_count |
int | count |
total_gift_value |
bigint | paid coins |
rule_version |
varchar | immutable rule version |
candidate_tiers_json |
json | tiers after filtering |
selected_tier_id |
varchar | final selected tier |
reward_coins |
bigint | reward amount |
random_digest |
varchar | audit digest |
pool_snapshot_json |
json | balances before draw |
risk_snapshot_json |
json | counters before draw |
reward_status |
varchar | none, pending, granted, failed |
credit_receipt_id |
varchar | wallet credit receipt |
created_at_ms |
bigint | draw time |
updated_at_ms |
bigint | update time |
lucky_risk_counters
保存可审计的风控计数。Redis 可以做热计数,MySQL 必须能重建关键窗口。
| Column | Type | Notes |
|---|---|---|
counter_key |
varchar | primary key |
scope_type |
varchar | user, device, room, anchor, activity |
scope_id |
varchar | concrete id |
window_type |
varchar | hourly, daily, period |
payout_coins |
bigint | reward total |
draw_count |
bigint | draw total |
hit_count |
bigint | hit total |
window_start_ms |
bigint | window start |
window_end_ms |
bigint | window end |
lucky_outbox
保存需要补偿的外部动作。
| Column | Type | Notes |
|---|---|---|
event_id |
varchar | primary key |
event_type |
varchar | reward_credit, room_event, activity_event |
aggregate_id |
varchar | draw_id |
payload |
blob | protobuf bytes |
status |
varchar | pending, processing, done, failed |
retry_count |
int | retry count |
next_retry_at_ms |
bigint | backoff time |
created_at_ms |
bigint | creation time |
updated_at_ms |
bigint | update time |
Draw Algorithm
All money-like values use integer coins. Percentages use ppm to avoid floating point drift.
gift_value = gift_unit_price * gift_count
pool_in = floor(gift_value * pool_rate_ppm / 1000000)
platform_in = floor(pool_in * platform_weight_ppm / 1000000)
room_in = floor(pool_in * room_weight_ppm / 1000000)
gift_in = pool_in - platform_in - room_in
Available payout is the minimum safe capacity across all layers and risk caps:
platform_capacity = platform_balance + platform_in - platform_reserve_floor
room_capacity = room_balance + room_in - room_reserve_floor
gift_capacity = gift_balance + gift_in - gift_reserve_floor
risk_capacity = min(
max_single_payout,
user_daily_remaining,
device_daily_remaining,
room_hourly_remaining,
anchor_daily_remaining,
activity_period_remaining
)
available_payout = min(platform_capacity, room_capacity, gift_capacity, risk_capacity)
Tier filtering rules:
| Rule | Behavior |
|---|---|
reward_coins > available_payout |
tier disabled for this draw |
tier.min_pool_balance > current_pool_balance |
tier disabled for this draw |
tier.enabled=false |
tier disabled for this draw |
| disabled tier probability | moved to none tier |
The final random selection runs only on the filtered table. It must never select a tier that cannot be paid.
Transaction Boundary
ExecuteLuckyGiftDraw uses one local MySQL transaction for the draw truth.
1. read idempotency by command_id
2. lock lucky_gift_rules active version
3. lock platform, room, gift pool rows with SELECT FOR UPDATE
4. load or lock risk counters for current windows
5. calculate pool inflow and available payout
6. filter tiers and execute random selection
7. update pool balances
8. update risk counters
9. insert lucky_draw_records
10. insert lucky_outbox if reward_coins > 0
11. commit
After commit, activity-service may synchronously dispatch the wallet credit for low latency. If dispatch fails, it must keep the outbox pending and retry.
Do not call wallet-service while holding MySQL pool locks. External RPC inside the pool transaction can block all draws for the same room or gift.
Idempotency
command_id is the global idempotency key.
| Case | Required Behavior |
|---|---|
duplicate CheckLuckyGift |
return current enabled rule |
duplicate ExecuteLuckyGiftDraw before commit |
one succeeds, one waits or fails retryably |
duplicate ExecuteLuckyGiftDraw after commit |
return stored draw result |
| duplicate wallet credit | wallet returns same credit_receipt_id |
| duplicate outbox dispatch | consumer ignores completed event_id |
draw_id should be derived from command_id or stored under a unique command_id constraint.
Randomness and Audit
V1 should use server-side cryptographically secure randomness. Every draw record must keep enough audit material to prove the result came from the recorded candidate table.
Recommended audit fields:
| Field | Meaning |
|---|---|
random_digest |
HMAC or hash of random source and draw context |
candidate_tiers_json |
exact filtered probability table |
pool_snapshot_json |
pool balances before selection |
risk_snapshot_json |
caps and remaining quota |
rule_version |
immutable probability version |
如果后续需要更强公信力,可以升级为 daily seed commit-reveal:每天提前公布 hash(seed),开奖后公布 seed,用户可复算结果。
Failure Handling
| Failure | Behavior |
|---|---|
CheckLuckyGift unavailable before debit |
reject lucky gift send, do not debit |
DebitGift failed |
do not call draw |
ExecuteLuckyGiftDraw timeout after debit |
room gift can land as pending lucky draw; retry by command_id |
| draw transaction committed but wallet credit failed | keep reward_status=PENDING, retry outbox |
| wallet credit succeeded but response lost | retry credit by reward_id, wallet returns same receipt |
| im broadcast failed | room-service outbox handles room event compensation |
| activity outbox exhausted retries | mark failed and alert; do not delete record |
The product response must distinguish GRANTED and PENDING. Only GRANTED can show “金币已到账”。
Service Modules
Recommended activity-service layout:
services/activity-service/
internal/
luckygift/
service/
service.go
draw.go
risk.go
idempotency.go
rule/
repository.go
validator.go
pool/
repository.go
allocator.go
reward/
wallet_client.go
dispatcher.go
outbox/
store.go
worker.go
audit/
random.go
digest.go
storage/
mysql/
redis/
transport/
grpc/
Keep the draw logic in one service package. Do not split probability, pool, and risk into separate network services in V1.
Integration Events
activity-service should emit protobuf events for downstream statistics and audit.
Suggested event types:
| Event | Payload |
|---|---|
LuckyGiftDrawn |
draw id, gift id, rule version, tier, reward coins |
LuckyRewardGranted |
reward id, draw id, user id, credit receipt |
LuckyRewardPending |
reward id, draw id, retry reason |
LuckyPoolChanged |
scope, delta in, delta out, balance |
LuckyRiskRejected |
scope, threshold, reason |
These events should live under api/proto/events/activity/v1 when implementation starts.
Config
services/activity-service/configs/config.yaml should eventually include:
grpc_addr: ":13006"
mysql_dsn: "root:root@tcp(mysql:3306)/hyapp_activity?parseTime=true"
redis_addr: "redis:6379"
redis_password: ""
wallet_grpc_addr: "wallet-service:13004"
outbox_batch_size: 100
outbox_poll_interval: "500ms"
draw_timeout: "300ms"
wallet_credit_timeout: "500ms"
Local Docker uses MySQL and Redis from docker-compose.yml; production uses Tencent Cloud Redis and TencentDB for MySQL with the same config keys.
Testing
Required tests:
| Test | Assertion |
|---|---|
| probability table validation | ppm sum equals 1000000 |
| low-water filtering | unavailable tier probability moves to none |
| pool transaction | platform, room, gift balances update atomically |
| user daily cap | reward tier over cap is filtered before draw |
| idempotent draw | duplicate command_id returns same result |
| wallet credit retry | pending reward eventually becomes granted |
| draw timeout retry | no duplicate pool payout |
| concurrent room draws | row locks prevent negative pool balance |
| audit replay | stored candidate table reproduces selected tier check |
Load tests should verify the hottest room path because room pool row locks can become the first bottleneck.
Rollout
V1 rollout order:
| Step | Scope |
|---|---|
| 1 | Add proto and DB schema behind disabled config |
| 2 | Implement CheckLuckyGift and rule validation |
| 3 | Implement pool transaction and draw records with reward disabled |
| 4 | Add wallet CreditLuckyReward and pending reward outbox |
| 5 | Connect room-service SendGift for selected lucky gifts |
| 6 | Enable one low-price gift in internal room |
| 7 | Enable room-level metrics and alerting |
| 8 | Expand to higher-price gifts only after payout rate is stable |
Operational kill switches:
| Switch | Effect |
|---|---|
global_lucky_gift_enabled=false |
all lucky gifts reject before debit |
gift_rule.status=paused |
single gift stops lucky draw |
large_tier_enabled=false |
disables high-risk tiers |
wallet_credit_dispatch=false |
rewards stay pending; used only during wallet incident |
room_scope_paused |
disables lucky draw for one room |