480 lines
16 KiB
Markdown
480 lines
16 KiB
Markdown
# 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 | 承载客户端群消息和历史 |
|
||
|
||
`activity-service` 不直接修改用户金币余额,不直接写 Room Cell,不直接保存 WebSocket 连接。
|
||
|
||
## Target Flow
|
||
|
||
幸运礼物在普通 `SendGift` 链路上增加一次活动决策。
|
||
|
||
```mermaid
|
||
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`。
|
||
|
||
```protobuf
|
||
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 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` 直接写账务库。
|
||
|
||
```protobuf
|
||
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 |
|
||
| `coin_spent` | bigint | paid coins |
|
||
| `heat_value` | bigint | room heat contributed by this gift |
|
||
| `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.
|
||
|
||
```text
|
||
gift_value = heat_value
|
||
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:
|
||
|
||
```text
|
||
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.
|
||
|
||
```text
|
||
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:
|
||
|
||
```text
|
||
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:
|
||
|
||
```yaml
|
||
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 |
|