// 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 // 抽奖时刻快照 // IdempotencyKey 是 lottery_grant_ledger.external_ref 的最终值。 // 调用方(抽奖服务)负责生成,惯例 = fmt.Sprintf("lottery:%d:%d", ActivityId, DrawId)。 // 每次 Dispatch 用同一 IdempotencyKey 重试 → handler 命中 ledger UNIQUE 返回原结果。 IdempotencyKey string } // 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")