hyapp-server/docs/幸运礼物Activity服务开发.md

28 KiB
Raw Blame History

Lucky Gift Activity Service Development Guide

本文档定义幸运礼物在 activity-service 中的开发落地。产品规则见 docs/幸运礼物产品方案.md

当前代码已经落地到 api/proto/activity/v1/activity.protoservices/activity-service/internal/service/luckygiftservices/activity-service/internal/storage/mysql/lucky_gift_repository.go。线上事实以 docs/幸运礼物线上落地文档.md 为准;本文件保留开发阶段设计背景,涉及“建议新增 proto”“固定概率表”“后台权重表”的段落不再代表当前实现。当前实现的核心配置是 pool_id + multiplier_ppms,后台配置多个倍率,服务端在抽奖时按 RTP 窗口、奖池和风控自动生成运行时权重。

Ownership

幸运礼物归属 activity-service,但金币账务仍归属 wallet-service,房间状态仍归属 room-service

Capability Owner Notes
幸运礼物规则配置 activity-service 概率表、奖档、启停、版本
三层奖池 activity-service 平台、房间、礼物资金水位
体验池 activity-service 新手、中级、高级候选奖档和权重
全站 RTP 控制窗口 activity-service 控全站基础返奖总成本
礼物 RTP 子窗口 activity-service 控单个幸运礼物基础返奖成本
房间气氛池 activity-service 控房间爆点、共享奖励和表现预算
活动预算池 activity-service 控限时活动补贴,不污染基础 RTP
抽奖决策 activity-service 先过滤奖档,再执行随机
风控阈值 activity-service 用户、设备、房间、主播、活动周期
金币扣费 wallet-service room-service 送礼前同步扣费
金币返还 wallet-service 需要新增幂等入账 RPC
房间热度和榜单 room-service Room Cell 内串行变更
房间消息投递 腾讯云 IM 承载客户端群消息和历史

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 Tencent Cloud IM

  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 via REST
  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 coin_spent = 4;
  int64 heat_value = 5;
}

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 coin_spent = 4;
  int64 heat_value = 5;
  string billing_receipt_id = 6;
  string rule_version = 7;
}

message RewardTierResult {
  string tier_id = 1;
  int64 base_reward_coins = 2;
  int64 room_atmosphere_reward_coins = 3;
  int64 activity_subsidy_coins = 4;
  int64 effective_reward_coins = 5;
  string tier_type = 6;
  string budget_sources_json = 7;
}

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 effective_reward_coins = 6;
  int64 base_reward_coins = 7;
  int64 room_atmosphere_reward_coins = 8;
  int64 activity_subsidy_coins = 9;
  string reason = 10;
  string budget_sources_json = 11;
}

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
target_rtp_ppm int controlled RTP target, e.g. 950000 for 95%
control_window_draws int paid draws per RTP control window
gift_rtp_window_draws int paid draws per gift-level RTP sub-window
novice_draw_limit bigint inclusive max paid draw count for novice pool
intermediate_draw_limit bigint inclusive max paid draw count for intermediate pool
room_atmosphere_rule_json json room burst, shared reward, and presentation policy
activity_budget_id varchar optional active subsidy budget
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
experience_pool varchar novice, intermediate, advanced
tier_id varchar none, small_1x, medium_5x
reward_type varchar none, fixed, multiplier
reward_value bigint fixed coins or multiplier base
base_weight int relative selection weight inside the experience pool
budget_source varchar base_rtp, room_atmosphere, activity_subsidy, presentation_only
presentation_type varchar none, room_message, floating_screen, shared_drop
shared_reward_policy_json json optional shared reward split for room atmosphere
min_pool_balance bigint activation waterline
enabled bool tier switch

All tier weights in one (gift_id, rule_version, experience_pool) group should be positive and internally normalizable. They are not the source of truth for total RTP; they only shape the visible distribution inside the RTP control window.

lucky_rtp_windows

保存 RTP 控制窗口。窗口行必须在抽奖事务内和三层奖池一起锁定,避免并发抽奖同时消费同一段目标支出。

Column Type Notes
scope_type varchar global, gift
scope_id varchar global or gift_id
window_index bigint monotonic index by paid draw count
target_rtp_ppm int immutable target for this window
control_window_draws bigint expected paid draws in this window
paid_draws bigint paid draws already assigned to this window
wager_coins bigint accumulated cost in this window
target_payout_coins bigint integer payout target for this window
actual_base_payout_coins bigint base RTP rewards selected in this window
carry_ppm bigint fractional target remainder carried into next window
status varchar open, closed, underpaid
updated_at_ms bigint update time

The primary key is (scope_type, scope_id, window_index).

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_room_atmosphere_pools

保存房间气氛预算。它控制房间爆点和共享奖励,不拥有 Room Cell 核心状态。

Column Type Notes
room_id varchar room id
balance bigint available room atmosphere coins
reserve_floor bigint minimum balance before shared rewards stop
lucky_heat bigint non-coin room atmosphere score
burst_level int current presentation intensity
hourly_shared_payout_limit bigint hourly shared reward cap
daily_shared_payout_limit bigint daily shared reward cap
version bigint optimistic version
updated_at_ms bigint update time

The primary key is room_id.

lucky_activity_budgets

保存活动补贴预算。活动补贴提高用户看到的 effective_rtp,但不计入基础 RTP。

Column Type Notes
budget_id varchar primary key
activity_id varchar activity id
gift_id varchar optional gift scope, empty means all lucky gifts
budget_coins bigint total subsidy budget
spent_coins bigint granted subsidy coins
daily_spend_limit bigint daily subsidy cap
status varchar draft, active, paused, exhausted, retired
effective_from_ms bigint UTC start time
effective_to_ms bigint UTC end time
updated_at_ms bigint update time

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
coin_spent bigint paid coins
heat_value bigint room heat contributed by this gift
rule_version varchar immutable rule version
experience_pool varchar novice, intermediate, advanced
rtp_scope_type varchar scope that controlled this draw
rtp_scope_id varchar concrete scope id
rtp_window_index bigint RTP control window
gift_rtp_window_index bigint gift-level RTP sub-window
candidate_tiers_json json tiers after filtering
selected_tier_id varchar final selected tier
base_reward_coins bigint reward counted in global and gift RTP
room_atmosphere_reward_coins bigint shared or room-burst reward outside base RTP
activity_subsidy_coins bigint activity subsidy outside base RTP
effective_reward_coins bigint total user-visible coin reward
budget_sources_json json exact budget sources consumed by this draw
random_digest varchar audit digest
pool_snapshot_json json balances before draw
rtp_snapshot_json json global and gift RTP window snapshots
budget_snapshot_json json room atmosphere and activity budget snapshots
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. V2 separates base RTP payout from entertainment subsidies.

1. Experience Pool

paid_draw_number = user_lucky_paid_draw_count + 1
if paid_draw_number <= novice_draw_limit:
  experience_pool = novice
else if paid_draw_number <= intermediate_draw_limit:
  experience_pool = intermediate
else:
  experience_pool = advanced

Stage feedback is calculated from the same paid draw number. It is a presentation state unless the selected candidate explicitly carries base_rtp or activity_subsidy budget.

novice_feedback_marks = [1, novice_draw_limit * 25%, novice_draw_limit * 50%, novice_draw_limit * 75%, novice_draw_limit]
stage_feedback_enabled = paid_draw_number in novice_feedback_marks

Feedback cannot credit coins after selection. If a milestone should pay coins, that payout must be present in the pre-random candidate set and must pass all RTP, pool, and risk filters.

2. Pool Inflow And Capacity

pool_in = floor(coin_spent * 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

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 remains a hard cap:

risk_capacity = min(
  max_single_payout,
  user_daily_remaining,
  device_daily_remaining,
  room_hourly_remaining,
  anchor_daily_remaining,
  activity_period_remaining
)

Time-window caps must be configured above the expected payout for that window. Otherwise the transaction can satisfy the user risk cap or the RTP target, but not both.

expected_window_payout = paid_draw_capacity
                       * gift_price
                       * target_rtp_ppm
                       / 1000000

hourly_cap >= expected_hourly_payout * volatility_factor
daily_cap >= expected_daily_payout * volatility_factor

Use a larger volatility_factor for higher variance pools. The default demo model uses 2.0 for hourly and 1.5 for daily caps.

3. Global And Gift RTP Windows

Every draw enters two RTP windows:

global_window_target = floor((global_window_wager * global_target_rtp_ppm + global_carry_ppm) / 1000000)
gift_window_target = floor((gift_window_wager * gift_target_rtp_ppm + gift_carry_ppm) / 1000000)

global_remaining_payout = global_window_target - global_window_base_payout
gift_remaining_payout = gift_window_target - gift_window_base_payout

global_remaining_draws = global_window_draws - global_window_paid_draws
gift_remaining_draws = gift_window_draws - gift_window_paid_draws

High multiplier tiers are disabled until both RTP windows have positive payout headroom large enough to pay the tier:

rtp_headroom = remaining_payout
             - remaining_draws * gift_price * target_rtp_ppm / 1000000

high_multiplier_enabled = global_rtp_headroom >= tier_reward
                       && gift_rtp_headroom >= tier_reward

The full production filter must additionally require platform, room, and gift pool headroom above the same tier reward plus reserve floors.

The base payout capacity is the strict minimum:

base_capacity = min(
  platform_capacity,
  room_capacity,
  gift_capacity,
  risk_capacity,
  global_remaining_payout,
  gift_remaining_payout
)

4. Candidate Construction

Build candidates from three independent sources:

Source Candidate Fields Accounting
base_rtp tier id, base reward, experience pool weight counts in global and gift RTP windows
room_atmosphere room burst, shared reward, presentation consumes room atmosphere budget only
activity_subsidy subsidy reward, activity id, display tag consumes activity budget only

Base candidates must preserve both RTP windows:

base_reward_coins <= base_capacity

global_remaining_payout - base_reward_coins
  <= global_min_future_max_reward * (global_remaining_draws - 1)

gift_remaining_payout - base_reward_coins
  <= gift_min_future_max_reward * (gift_remaining_draws - 1)

Room atmosphere and activity candidates cannot be used to satisfy these formulas. They only increase effective_reward_coins.

5. Selection

candidate_set = filtered_base_candidates
              + filtered_room_atmosphere_candidates
              + filtered_activity_subsidy_candidates

selected = weighted_random(candidate_set, secure_random)

Selection rules:

Rule Behavior
base reward exceeds base capacity base tier disabled
base reward breaks either RTP window feasibility base tier disabled
high multiplier lacks global, gift, or pool headroom high tier disabled and its weight moves to none
room atmosphere reward exceeds room atmosphere budget room candidate disabled
activity subsidy exceeds active budget or daily limit activity candidate disabled
risk cap would be exceeded coin-paying candidate disabled
disabled tier weight moved to none only inside the same source

The final random selection runs only on the filtered table. It must never select a tier that cannot be paid.

6. Accounting

base_reward_coins = selected.base_reward_coins
room_atmosphere_reward_coins = selected.room_atmosphere_reward_coins
activity_subsidy_coins = selected.activity_subsidy_coins

effective_reward_coins = base_reward_coins
                       + room_atmosphere_reward_coins
                       + activity_subsidy_coins

Only base_reward_coins updates global and gift RTP windows. Room atmosphere and activity subsidies update their own budgets and are reported separately in metrics.

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 global lucky_rtp_windows row
4. lock gift lucky_rtp_windows row
5. lock platform, room, gift pool rows with SELECT FOR UPDATE
6. lock lucky_room_atmosphere_pools row
7. lock active lucky_activity_budgets row when configured
8. load or lock risk counters for current windows
9. choose novice/intermediate/advanced experience pool
10. calculate stage feedback state from the user's paid draw number
11. calculate base RTP capacity, room atmosphere capacity, and activity subsidy capacity
12. calculate single, hourly, daily, device, room, anchor, and activity risk remaining
13. calculate high multiplier headroom from RTP windows and three-layer pools
14. build and filter base, room atmosphere, and activity subsidy candidates
15. execute weighted random selection once
16. update platform, room, gift pool balances for base reward only
17. update global and gift RTP windows for base_reward_coins only
18. update room atmosphere and activity budget spending separately
19. update risk counters for all coin-paying rewards
20. insert lucky_draw_records with separated reward fields and snapshots
21. insert lucky_outbox if effective_reward_coins > 0 or room presentation is required
22. 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

V2 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
      rtp/
        window.go
        controller.go
      atmosphere/
        room_pool.go
        presenter.go
      budget/
        activity_budget.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
experience pool validation novice, intermediate, advanced tier weights are positive and usable
RTP window validation 100000 paid draws deviate from target RTP by <1%
RTP million validation 1000000 paid draws deviate from target RTP by <0.5%
gift RTP sub-window validation one gift cannot overpay while global RTP is still healthy
subsidy isolation room atmosphere and activity subsidy do not update base RTP windows
low-water filtering unavailable tier weight moves to none
pool transaction platform, room, gift balances update atomically
RTP transaction concurrent draws cannot double-spend the same window remaining payout
room atmosphere budget shared rewards stop when room atmosphere budget reaches floor
activity budget exhaustion subsidy candidates are disabled after budget or daily cap is exhausted
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.

Production simulation must cover the full online control surface before service implementation starts:

go run ./tools/lucky-gift-v2-demo \
  -users 1000 \
  -devices 900 \
  -rooms 50 \
  -draws 100000 \
  -base-rtp 95 \
  -initial 1000000000 \
  -cost 500 \
  -global-window 100000 \
  -gift-window 100000

The expected pass condition is:

Check Required Result
base_rtp remains within the configured global and gift RTP windows
effective_rtp may be higher than base_rtp only through room atmosphere or activity subsidy
platform, room, gift pools no pool pays below its reserve floor
user, device, room, anchor caps all coin-paying candidates are filtered before selection
high multiplier disabled unless RTP windows and three-layer pools have enough high-water headroom

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