新功能(#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
+74 -7
View File
@@ -19,18 +19,21 @@ const (
UnmetActionBlock = "block"
UnmetActionShowReason = "show_reason"
// PrizeType* 是发奖 handler 注册表的键。Stage 1 只实现前三种。
// PrizeType* 是发奖 handler 注册表的键。
// Stage 1 已实装:vpn_duration / commission / none。
// Stage 2 新增人工奖:crypto / physical / manual_other。
PrizeTypeVPNDuration = "vpn_duration"
PrizeTypeCommission = "commission"
PrizeTypeNone = "none"
// Stage 2/3 预留
PrizeTypeBalance = "balance"
PrizeTypeGiftAmount = "gift_amount"
PrizeTypeCoupon = "coupon"
PrizeTypePoints = "points"
PrizeTypeEncrypted = "encrypted"
// Stage 2 人工奖类型(HIF-4
PrizeTypeCrypto = "crypto"
PrizeTypePhysical = "physical"
PrizeTypeManualOther = "manual_other"
// Stage 3 预留。
PrizeTypeBalance = "balance"
PrizeTypeGiftAmount = "gift_amount"
PrizeTypeCoupon = "coupon"
PrizeTypePoints = "points"
// ChanceSource* 是次数入账触发源。
ChanceSourceDailySignin = "daily_signin"
@@ -45,6 +48,24 @@ const (
DispatchStatePaid = "paid"
DispatchStateExpired = "expired"
DispatchStateFailed = "failed"
// ClaimStatus* 是 lottery_claim.status 的取值(Stage 2)。
// pending_claim: 已入库,等用户填领奖信息。
// reviewing: 用户已提交,等运营审核。
// paying: 运营 approve,等运营线下打款/发货 + mark-paid。
// paid: 运营已录入 tx_hash / delivery_ref。终态。
// rejected: 运营 reject(可为 reviewing → rejected 或 paying → rejected);用户可再次提交。
// expired: pending_claim 超时未提交(业务规则:过期不补次数)。终态。
ClaimStatusPendingClaim = "pending_claim"
ClaimStatusReviewing = "reviewing"
ClaimStatusPaying = "paying"
ClaimStatusPaid = "paid"
ClaimStatusRejected = "rejected"
ClaimStatusExpired = "expired"
// DefaultClaimTTLHours 是 Stage 2 spec 里"默认 7 天"的实际编码:
// 每个奖品可通过 config.claim_ttl_hours 覆盖单个奖品的过期窗口。
DefaultClaimTTLHours = 24 * 7
)
// ---- 实体 -----------------------------------------------------------------
@@ -154,6 +175,52 @@ type EligibilitySnapshot struct {
func (EligibilitySnapshot) TableName() string { return "lottery_eligibility_snapshot" }
// Claim 是 Stage 2 的人工奖领奖工单。lottery_draw ↔ lottery_claim 一对一
// (由 lottery_claim.draw_id UNIQUE 保证)。
//
// 生命周期:抽奖事务命中人工类奖品 → 同事务插入一行 status=pending_claim
// 用户 POST /claim → 转 reviewing;运营 approve → paying → mark-paid → paid。
// 详细状态机见 02159_lottery_claim.up.sql 的注释。
type Claim struct {
Id int64 `gorm:"primaryKey"`
DrawId int64 `gorm:"type:bigint unsigned;not null;uniqueIndex:uk_draw_id;comment:抽奖记录 ID"`
UserId int64 `gorm:"type:bigint unsigned;not null;comment:用户 ID"`
ActivityId int64 `gorm:"type:bigint unsigned;not null;comment:活动 ID"`
PrizeType string `gorm:"type:varchar(32);not null;comment:奖品类型(冗余便于后台过滤)"`
ClaimData string `gorm:"type:json;comment:用户提交的领奖表单(结构随 prize_type 变化)"`
Status string `gorm:"type:varchar(32);not null;default:'pending_claim';comment:状态"`
SubmittedAt *time.Time `gorm:"default:null;comment:用户提交领奖信息时间"`
ExpiresAt time.Time `gorm:"not null;comment:领奖窗口截止时间"`
ReviewedBy int64 `gorm:"type:bigint unsigned;default:0;comment:最近一次审核操作者"`
ReviewedAt *time.Time `gorm:"default:null;comment:最近一次审核时间"`
RejectReason string `gorm:"type:varchar(512);not null;default:'';comment:拒绝原因"`
TxHash string `gorm:"type:varchar(128);not null;default:'';comment:链上交易哈希"`
DeliveryRef string `gorm:"type:varchar(128);not null;default:'';comment:快递单号 / 发货单据编号"`
PaidAt *time.Time `gorm:"default:null;comment:运营标记打款/发货完成时间"`
CreatedAt time.Time `gorm:"<-:create;comment:Create Time"`
UpdatedAt time.Time `gorm:"comment:Update Time"`
}
// TableName 对齐 02159 migration。
func (Claim) TableName() string { return "lottery_claim" }
// IsClaimStatusResubmittable 判断当前 claim 状态是否允许用户再次提交领奖数据。
// pending_claim(还没提交过)与 rejected(被运营拒绝后允许重填)算入。
// 其他状态(reviewing / paying / paid / expired)都禁止再提交。
func IsClaimStatusResubmittable(status string) bool {
return status == ClaimStatusPendingClaim || status == ClaimStatusRejected
}
// IsPrizeTypeManualClaim 判断某奖品类型是否属于"人工领奖"类别,
// 即 draw 时需要挂 lottery_claim 而不是自动发放。
func IsPrizeTypeManualClaim(prizeType string) bool {
switch prizeType {
case PrizeTypeCrypto, PrizeTypePhysical, PrizeTypeManualOther:
return true
}
return false
}
// ---- JSON 结构体辅助 -------------------------------------------------------
// EligibilityRule 是持久化在 lottery_activity.eligibility 字段里的门槛规则树。
+2
View File
@@ -2,6 +2,7 @@ package lottery
import (
"context"
"encoding/json"
"sync"
"gorm.io/gorm"
@@ -53,3 +54,4 @@ func (noopHandler) Dispatch(_ context.Context, _ *gorm.DB, _ DispatchRequest) (D
return DispatchResult{State: DispatchStateAutoClaimed, Message: "谢谢参与"}, nil
}
func (noopHandler) ValidateClaim(_ []byte) error { return nil }
func (noopHandler) ClaimSchema() json.RawMessage { return nil }
+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")