07409eb602
Closes HIF-4 Stage 2 交付:人工奖领奖工单完整闭环。crypto / physical / manual_other 三类奖品从抽中到 mark-paid 的全流程可用。 - 迁移 02159_lottery_claim:UNIQUE(draw_id) + 3 支持索引,状态机 pending_claim→reviewing→paying→paid,rejected 可复活,超时 expired - 3 个 PrizeHandler:Dispatch→ErrDispatchNotSupported 兜底、ClaimSchema 各自形态、ValidateClaim 表驱动 - BuildCryptoClaimSchema:抽中时按奖品 config.networks 注入 enum,前端下拉直接可用 - Draw service dispatchOrEnqueueClaim:人工奖同 tx 插 pending_claim(回滚双清),nonce 重放回读 ExpiresAt + ClaimFormSchema - POST /claim 实装:ownership 校验 → prize 类型校验 → handler.ValidateClaim → crypto network 白名单二次校验 → tx CAS status IN (pending_claim, rejected) AND expires_at > now - Admin CRUD 5 接口:list(IN 批拉 snap + user,无 N+1)、summary(GROUP BY 一次拿计数 + overdue 单查)、approve/reject/mark-paid 全走 CAS + audit - Scheduler @every 1h 扫过期,级联 lottery_draw.dispatch_state → expired - 新增错误码 100005-100011(already_submitted / invalid_claim_data / draw_not_found / not_your_draw / claim_expired / claim_state_invalid) - Rebase 后 Stage 2 测试主动 reuse PR E 的 unmetReasonsNotEmpty + evaluatedAtNotZero matcher,人工奖分支若绕过守卫会立即挂 - Stage 1 全部 4 处 guardrail 后端 rebase 时自检过:UnmetReasons、EvaluatedAt、GrantLedger.Payload、AdminMetaMiddleware 全保留 CI 全绿;28 files, +2442/-126;覆盖率 handler 78.9% / model.lottery 74.3% / draw 68.7% / queue/lottery 76.9%
140 lines
6.5 KiB
Go
140 lines
6.5 KiB
Go
// Package lottery 提供抽奖 Stage 1 后端核心闭环:门槛规则引擎、次数入账、
|
||
// 加权随机选奖、发奖 handler 抽象。
|
||
//
|
||
// Stage 1 只保证接口稳定 + 骨架编译通过;具体业务对接(订阅时长发放、佣金入账、
|
||
// 邀请钩子)留到架构师 review 骨架 PR 之后再补。
|
||
package lottery
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"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 的三种,Stage 2 补齐 crypto/physical/manual_other)。
|
||
//
|
||
// 幂等:所有实现必须以 lottery_draw.id 为外部 ref 做 check-before-write,
|
||
// 避免重试重复发放。见 doc/lottery-stage1-plan.md 的"发奖账本"章节。
|
||
type PrizeHandler interface {
|
||
Type() string
|
||
IsAuto() bool
|
||
// Dispatch 在调用方的事务内执行;返回结果或错误。
|
||
// 错误会导致抽奖事务回滚(次数不扣、draw 不落库),由用户侧重新发起。
|
||
//
|
||
// 对 IsAuto()=false 的人工奖 handler,Dispatch 不会被抽奖服务调用;
|
||
// 实现返回 ErrDispatchNotSupported 即可。
|
||
Dispatch(ctx context.Context, tx *gorm.DB, req DispatchRequest) (DispatchResult, error)
|
||
// ValidateClaim 是人工领奖时校验用户输入(Stage 2 才用);auto handler
|
||
// 直接返回 nil 即可(默认 noopHandler / vpn_duration / commission 都不用)。
|
||
ValidateClaim(raw []byte) error
|
||
// ClaimSchema 返回该奖品的领奖表单 JSON Schema(Stage 2 才用)。
|
||
// - auto handler 返回 nil(前端拿到 nil / null 就知道不用弹表单)。
|
||
// - 人工奖 handler 返回一段合法 JSON Schema,前端据此动态渲染表单。
|
||
ClaimSchema() json.RawMessage
|
||
}
|
||
|
||
// ErrDispatchNotSupported 是 IsAuto()=false handler 的 Dispatch 占位错误:
|
||
// 抽奖服务命中人工奖时不应调用 Dispatch,理论上永远不会返回给用户,仅供
|
||
// 单测断言与防御性编程使用。
|
||
var ErrDispatchNotSupported = errors.New("lottery: dispatch not supported for manual claim handler")
|
||
|
||
// 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")
|