9933d34bdd
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 + 集成/并发/概率测试。
123 lines
5.4 KiB
Go
123 lines
5.4 KiB
Go
// 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")
|