新功能(#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%
This commit is contained in:
2026-07-10 04:01:34 -07:00
committed by GitHub
parent 117dc0d6a7
commit 07409eb602
28 changed files with 2471 additions and 133 deletions
+16 -3
View File
@@ -7,6 +7,7 @@ package lottery
import (
"context"
"encoding/json"
"errors"
"gorm.io/gorm"
@@ -95,7 +96,7 @@ type DispatchResult struct {
// PrizeHandler 是一种奖品类型的发奖策略。Type 是注册键;IsAuto=true 表示
// 抽奖事务内立刻发放,false 表示挂 pending_claim 等人工发(Stage 1 只实现
// IsAuto=true 的三种)。
// IsAuto=true 的三种Stage 2 补齐 crypto/physical/manual_other)。
//
// 幂等:所有实现必须以 lottery_draw.id 为外部 ref 做 check-before-write
// 避免重试重复发放。见 doc/lottery-stage1-plan.md 的"发奖账本"章节。
@@ -104,12 +105,24 @@ type PrizeHandler interface {
IsAuto() bool
// Dispatch 在调用方的事务内执行;返回结果或错误。
// 错误会导致抽奖事务回滚(次数不扣、draw 不落库),由用户侧重新发起。
//
// 对 IsAuto()=false 的人工奖 handlerDispatch 不会被抽奖服务调用;
// 实现返回 ErrDispatchNotSupported 即可。
Dispatch(ctx context.Context, tx *gorm.DB, req DispatchRequest) (DispatchResult, error)
// ValidateClaim 是人工领奖时校验用户输入(Stage 2 才用);Stage 1 的
// auto handler 直接返回 nil 即可。
// 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")