Files
hi-server/internal/model/lottery/service.go
T
shanshanzhong147 07409eb602 新功能(#4): 抽奖 Stage 2 人工奖领奖工单(crypto / physical / manual_other)
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%
2026-07-10 04:01:34 -07:00

140 lines
6.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 的人工奖 handlerDispatch 不会被抽奖服务调用;
// 实现返回 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 SchemaStage 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")