新功能(#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:
@@ -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,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 }
|
||||
|
||||
@@ -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 的人工奖 handler,Dispatch 不会被抽奖服务调用;
|
||||
// 实现返回 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 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")
|
||||
|
||||
Reference in New Issue
Block a user