a46fb83054
Closes HIF-3 (阶段 PR B) PR B:handler 真实业务对接 + 邀请钩子(迭代含 R1 修复) - 迁移 02157_lottery_grant_ledger:external_ref UNIQUE 作为发奖幂等键 - log.CommissionTypeLottery=339(架构师批准的新常量) - DispatchRequest.IdempotencyKey(架构师 review 建议第 2 条) - GrantLedger + LedgerService.Reserve:INSERT ON CONFLICT DO NOTHING 幂等 upsert - VPNDurationHandler:ResolveEffectiveUser 归位家庭 owner + UpdateSubscribe,ExpireTime 三分支对齐 grantGiftDays - CommissionHandler:UpdateCommission + WriteCommissionLog(339),发给中奖者本人,不做家庭组归位 - Handler 从 model 层迁到 logic 层(避免 model → logic 反向依赖);noop 保留在 model 层 - InviteHook:fire-and-forget 独立 goroutine + 10s timeout,扫 running 活动的 invite_success 源 - ServiceContext 新增 LotteryChance / LotteryLedger / LotteryInviteHook - activateOrderLogic.handleCommission 两条分支通过 invokeInviteHookIfEligible 助手触发,助手内统一 gate IsNew(架构师 R1 打回后的修复) 架构师 R1 打回:branch B 未按"首次付款激活"gate,续费也会给 referer 发抽奖机会 → 已通过助手函数集中收拢,避免 branch A/B 判断漂移。 测试覆盖率:model 76.5% / handler 73.2% / hook 91.7%;补 4 个回归测试覆盖 IsNew 门槛(含关键的 DoesNotFireOnRenewal)。 线上仍零可见变更:无对外路由,钩子仅在活动 status=running 时生效,Stage 1 全流程无活动记录时 loadRunningActivities 返回空。
127 lines
5.7 KiB
Go
127 lines
5.7 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 // 抽奖时刻快照
|
||
// 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")
|