新功能(#3): 抽奖活动 Stage 1 骨架(DB + 规则引擎 + Handler 抽象)

Closes HIF-3

Stage 1 骨架:7 张表迁移 + 门槛规则引擎 + 加权选奖 + 次数入账/消耗(幂等)+ 发奖 handler 抽象与注册表。

- 单测覆盖 75.5%(未覆盖行 = stub handler ErrNotImplemented,合理)
- CI 全绿(构建/Vet/测试 + golangci-lint)
- 骨架不接入真实业务,vpn_duration/commission handler 在 Dispatch 中返回 ErrNotImplemented;合并后线上零变更

架构师 review 通过,4 项决策已在 issue 上给出:
1. 邀请转化语义 = 首次付款激活
2. 佣金日志类型 = 新增 CommissionTypeLottery=339
3. 家庭组归属 = 穿透到 owner
4. 管理端 IP 白名单 = 不做(推到 nginx/ingress 层)

后续 PR B/C 补真实业务对接 + 用户 API + 后台 CRUD + 集成/并发/概率测试。
This commit is contained in:
2026-07-08 20:05:05 -07:00
committed by GitHub
parent d2710d356f
commit 9933d34bdd
12 changed files with 1495 additions and 0 deletions
+122
View File
@@ -0,0 +1,122 @@
// Package lottery 提供抽奖 Stage 1 后端核心闭环:门槛规则引擎、次数入账、
// 加权随机选奖、发奖 handler 抽象。
//
// Stage 1 只保证接口稳定 + 骨架编译通过;具体业务对接(订阅时长发放、佣金入账、
// 邀请钩子)留到架构师 review 骨架 PR 之后再补。
package lottery
import (
"context"
"errors"
"gorm.io/gorm"
)
// ---- 门槛规则引擎 ----------------------------------------------------------
// RuleContext 是评估门槛规则时可见的用户上下文。构造时应做一次批量
// 读,避免每条子规则各自查库。
type RuleContext struct {
UserId int64
Now int64 // Unix seconds
// 下列字段由 RuleContextBuilder 填充。规则实现只读,不写。
HasActiveSubscription bool
SubscriptionExpiresIn int64 // seconds until expiry; 0 if no active sub
SubscriptionPlanIds []int64
InviteCount int64 // 若规则限定 window_days,则调用方需自行按窗口预计算
TotalRechargeUSDT int64 // 已充值总额,单位与业务侧一致
RegisterDays int64
UserTags []string
}
// RuleEvaluator 评估一棵门槛规则树,返回是否通过 + 未通过项。
// 实现是纯计算,不做任何 DB 写。
type RuleEvaluator interface {
Evaluate(ctx context.Context, tree *EligibilityRule, rc RuleContext) (passed bool, unmet []UnmetReason, err error)
}
// ---- 加权随机选奖 ----------------------------------------------------------
// WeightedPicker 从奖池中按 weight 加权抽一次。实现应使用注入的 rand 源,
// 便于测试;weight 为 0 的奖品视作不参与随机(可用于挂出但不发放)。
type WeightedPicker interface {
// Pick 从 candidates 中返回一个索引 i;若累计权重为 0 返回 ErrEmptyPool。
// 调用方在事务外先做快照,事务内再对返回的 candidates[i] 做库存乐观扣减。
Pick(candidates []Prize) (int, error)
}
// ErrEmptyPool 表示奖池累计权重为 0,无法完成一次随机。
var ErrEmptyPool = errors.New("lottery: empty prize pool")
// ---- 次数入账 / 消耗 -------------------------------------------------------
// ChanceService 处理"用户抽奖次数账户",为触发源提供幂等入账、为抽奖流程提供
// 事务内原子扣减。
type ChanceService interface {
// Grant 记录一次次数入账;幂等键 = (activityId, source, sourceRef)。
// 已存在的 sourceRef 视为幂等命中,返回 nil 不重复发放。
Grant(ctx context.Context, userId, activityId int64, source, sourceRef string, amount int) error
// Consume 在事务内扣减一次次数(SELECT ... FOR UPDATE 锁 chance_balance),
// 返回扣减后剩余次数。剩余为 0 时返回 ErrNoChances。
Consume(ctx context.Context, tx *gorm.DB, userId, activityId int64) (remaining int64, err error)
// Query 返回用户当前剩余次数(非事务,用于 GET /config 展示)。
Query(ctx context.Context, userId, activityId int64) (remaining int64, err error)
}
// ErrNoChances 表示用户在该活动下已无剩余抽奖次数。
var ErrNoChances = errors.New("lottery: no chances remaining")
// ---- 发奖 Handler ---------------------------------------------------------
// DispatchRequest 是 PrizeHandler.Dispatch 的输入。事务由调用方开启并传入,
// handler 在同 tx 内完成外部账本写入 + 业务侧发放。
type DispatchRequest struct {
UserId int64
ActivityId int64
DrawId int64
Prize Prize // 当前奖品(含 Config JSON
Snapshot PrizeSnapshot // 抽奖时刻快照
}
// DispatchResult 是发奖结果。
type DispatchResult struct {
// State 会写入 lottery_draw.dispatch_state。
State string
// Message 供前端展示("已加到订阅"/"佣金到账 3 USDT" 等)。
Message string
}
// PrizeHandler 是一种奖品类型的发奖策略。Type 是注册键;IsAuto=true 表示
// 抽奖事务内立刻发放,false 表示挂 pending_claim 等人工发(Stage 1 只实现
// IsAuto=true 的三种)。
//
// 幂等:所有实现必须以 lottery_draw.id 为外部 ref 做 check-before-write
// 避免重试重复发放。见 doc/lottery-stage1-plan.md 的"发奖账本"章节。
type PrizeHandler interface {
Type() string
IsAuto() bool
// Dispatch 在调用方的事务内执行;返回结果或错误。
// 错误会导致抽奖事务回滚(次数不扣、draw 不落库),由用户侧重新发起。
Dispatch(ctx context.Context, tx *gorm.DB, req DispatchRequest) (DispatchResult, error)
// ValidateClaim 是人工领奖时校验用户输入(Stage 2 才用);Stage 1 的
// auto handler 直接返回 nil 即可。
ValidateClaim(raw []byte) error
}
// ErrNotImplemented 是 Stage 1 骨架里 handler 的占位错误:抽奖流程接入前
// 若不慎命中真实 handler 会立即失败,避免误发。
var ErrNotImplemented = errors.New("lottery: handler not yet wired to real business")
// Registry 是 type → PrizeHandler 的路由表。抽奖服务只依赖此接口,
// 具体 handler 由 initialize 阶段注入。
type Registry interface {
Get(prizeType string) (PrizeHandler, bool)
// MustGet 在类型未注册时返回 ErrHandlerNotRegistered。
MustGet(prizeType string) (PrizeHandler, error)
}
// ErrHandlerNotRegistered 表示奖品类型没有对应 handler。
var ErrHandlerNotRegistered = errors.New("lottery: prize handler not registered")