新功能(#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
+138 -51
View File
@@ -4,22 +4,22 @@
// model layer into one atomic sequence.
//
// Ordering matters — the flow is:
// 1. feature-flag gate (config.Lottery.Enable)
// 2. Redis rate limit (per-user 1/sec)
// 3. Load activity + validate window/status
// 4. Build RuleContext (pre-tx reads)
// 5. Evaluate eligibility (pure compute)
// 6. Load prize pool snapshot (pre-tx read)
// 7. Open tx →
// 7a. Nonce dedupe: SELECT lottery_draw WHERE (user_id, client_nonce)
// — hit returns the recorded draw
// 7b. ChanceService.Consume (SELECT FOR UPDATE + decrement)
// 7c. WeightedPicker.Pick
// 7d. Limited-stock optimistic decrement; fallback on RowsAffected=0
// 7e. INSERT lottery_draw + PrizeSnapshot + EligibilitySnapshot
// 7f. Auto-handler Dispatch (in tx)
// 7g. Update draw.dispatch_state
// → commit
// 1. feature-flag gate (config.Lottery.Enable)
// 2. Redis rate limit (per-user 1/sec)
// 3. Load activity + validate window/status
// 4. Build RuleContext (pre-tx reads)
// 5. Evaluate eligibility (pure compute)
// 6. Load prize pool snapshot (pre-tx read)
// 7. Open tx →
// 7a. Nonce dedupe: SELECT lottery_draw WHERE (user_id, client_nonce)
// — hit returns the recorded draw
// 7b. ChanceService.Consume (SELECT FOR UPDATE + decrement)
// 7c. WeightedPicker.Pick
// 7d. Limited-stock optimistic decrement; fallback on RowsAffected=0
// 7e. INSERT lottery_draw + PrizeSnapshot + EligibilitySnapshot
// 7f. Auto-handler Dispatch (in tx)
// 7g. Update draw.dispatch_state
// → commit
//
// Everything past step 5 uses the caller's transaction; post-commit cache
// invalidation is the handler layer's job (a future enhancement — the
@@ -35,6 +35,7 @@ import (
"strconv"
"time"
"github.com/perfect-panel/server/internal/logic/lottery/handler"
"github.com/perfect-panel/server/internal/model/lottery"
"github.com/perfect-panel/server/pkg/limit"
"github.com/perfect-panel/server/pkg/xerr"
@@ -72,6 +73,11 @@ type ClaimSummary struct {
Required bool
AutoClaimed bool
Message string
// ExpiresAt 是人工奖领奖窗口截止时间(Unix 秒;0 表示不适用)。
ExpiresAt int64
// ClaimFormSchema 是人工奖前端渲染领奖表单用的 JSON Schema
// nil 表示不适用;auto handler 与"谢谢参与"都返回 nil)。
ClaimFormSchema json.RawMessage
}
// Service orchestrates the transactional draw flow. Deps are struct-injected
@@ -82,13 +88,13 @@ type Service struct {
// Deps groups the collaborators. Nil-safe checks live in Draw itself, not here.
type Deps struct {
DB *gorm.DB
Enabled bool
RateLimiter RateLimiter
Chance lottery.ChanceService
Evaluator lottery.RuleEvaluator
Picker lottery.WeightedPicker
Registry lottery.Registry
DB *gorm.DB
Enabled bool
RateLimiter RateLimiter
Chance lottery.ChanceService
Evaluator lottery.RuleEvaluator
Picker lottery.WeightedPicker
Registry lottery.Registry
ContextBuilder RuleContextBuilder
}
@@ -200,8 +206,8 @@ func (s *Service) Draw(ctx context.Context, req Request) (*Result, error) {
return wrapInternal(snapErr)
}
// (f) Dispatch prize.
dispatch, dispatchErr := s.dispatchIfAutomatic(ctx, tx, req, draw, final)
// (f) Dispatch prize (auto handler) or create pending claim (manual handler).
dispatch, claimInfo, dispatchErr := s.dispatchOrEnqueueClaim(ctx, tx, req, draw, final)
if dispatchErr != nil {
return dispatchErr
}
@@ -211,7 +217,7 @@ func (s *Service) Draw(ctx context.Context, req Request) (*Result, error) {
return wrapInternal(updateErr)
}
result = buildResult(draw, final, dispatch, remaining)
result = buildResult(draw, final, dispatch, claimInfo, remaining)
return nil
})
if txErr != nil {
@@ -395,32 +401,91 @@ func (s *Service) insertSnapshots(ctx context.Context, tx *gorm.DB, draw *lotter
return tx.WithContext(ctx).Create(&es).Error
}
func (s *Service) dispatchIfAutomatic(ctx context.Context, tx *gorm.DB, req Request, draw *lottery.Draw, prize lottery.Prize) (lottery.DispatchResult, error) {
// pendingClaimInfo carries the manual-claim details the draw service produced
// this turn. Zero-value = draw did not create a claim (auto prize or none).
type pendingClaimInfo struct {
ExpiresAt time.Time
ClaimFormSchema json.RawMessage
}
// dispatchOrEnqueueClaim routes the prize to either an auto-handler dispatch
// (Stage 1 path) or to a lottery_claim insert (Stage 2 manual path).
//
// - draw.IsWin == false → thanks-for-playing, auto_claimed.
// - handler.IsAuto()==true → call Dispatch inside caller's tx.
// - handler.IsAuto()==false → insert lottery_claim (pending_claim) and
// return ClaimFormSchema + ExpiresAt so the
// caller can render the response.
func (s *Service) dispatchOrEnqueueClaim(ctx context.Context, tx *gorm.DB, req Request, draw *lottery.Draw, prize lottery.Prize) (lottery.DispatchResult, pendingClaimInfo, error) {
if !draw.IsWin {
return lottery.DispatchResult{State: lottery.DispatchStateAutoClaimed, Message: "谢谢参与"}, nil
return lottery.DispatchResult{State: lottery.DispatchStateAutoClaimed, Message: "谢谢参与"}, pendingClaimInfo{}, nil
}
if s.deps.Registry == nil {
return lottery.DispatchResult{State: lottery.DispatchStatePendingClaim, Message: "等待人工发放"}, nil
return lottery.DispatchResult{State: lottery.DispatchStatePendingClaim, Message: "等待人工发放"}, pendingClaimInfo{}, nil
}
handler, err := s.deps.Registry.MustGet(prize.Type)
prizeHandler, err := s.deps.Registry.MustGet(prize.Type)
if err != nil {
if errors.Is(err, lottery.ErrHandlerNotRegistered) {
return lottery.DispatchResult{State: lottery.DispatchStatePendingClaim, Message: "等待人工发放"}, nil
return lottery.DispatchResult{State: lottery.DispatchStatePendingClaim, Message: "等待人工发放"}, pendingClaimInfo{}, nil
}
return lottery.DispatchResult{}, wrapInternal(err)
return lottery.DispatchResult{}, pendingClaimInfo{}, wrapInternal(err)
}
if !handler.IsAuto() {
return lottery.DispatchResult{State: lottery.DispatchStatePendingClaim, Message: "等待人工发放"}, nil
if prizeHandler.IsAuto() {
dispatchReq := lottery.DispatchRequest{
UserId: req.UserId,
ActivityId: req.ActivityId,
DrawId: draw.Id,
Prize: prize,
Snapshot: lottery.PrizeSnapshot{DrawId: draw.Id, PrizeId: prize.Id, Slot: prize.Slot, Type: prize.Type, Name: prize.Name, Config: prize.Config},
IdempotencyKey: fmt.Sprintf("lottery:%d:%d", req.ActivityId, draw.Id),
}
result, dispatchErr := prizeHandler.Dispatch(ctx, tx, dispatchReq)
return result, pendingClaimInfo{}, dispatchErr
}
dispatchReq := lottery.DispatchRequest{
UserId: req.UserId,
ActivityId: req.ActivityId,
DrawId: draw.Id,
Prize: prize,
Snapshot: lottery.PrizeSnapshot{DrawId: draw.Id, PrizeId: prize.Id, Slot: prize.Slot, Type: prize.Type, Name: prize.Name, Config: prize.Config},
IdempotencyKey: fmt.Sprintf("lottery:%d:%d", req.ActivityId, draw.Id),
// Manual-claim path (Stage 2). Insert a pending_claim row inside the same
// draw tx so a rollback also erases the claim.
expiresAt := s.computeClaimExpiry(prize)
claim := lottery.Claim{
DrawId: draw.Id,
UserId: req.UserId,
ActivityId: req.ActivityId,
PrizeType: prize.Type,
Status: lottery.ClaimStatusPendingClaim,
ExpiresAt: expiresAt,
}
return handler.Dispatch(ctx, tx, dispatchReq)
if err := tx.WithContext(ctx).Create(&claim).Error; err != nil {
return lottery.DispatchResult{}, pendingClaimInfo{}, wrapInternal(fmt.Errorf("insert lottery_claim for draw %d: %w", draw.Id, err))
}
schema := prizeHandler.ClaimSchema()
// crypto handler 需要用奖品 config.networks 生成带 enum 的最终 schema。
if prize.Type == lottery.PrizeTypeCrypto {
schema = handler.BuildCryptoClaimSchema(prize.Config)
}
return lottery.DispatchResult{
State: lottery.DispatchStatePendingClaim,
Message: "等待填写领奖信息",
}, pendingClaimInfo{
ExpiresAt: expiresAt,
ClaimFormSchema: schema,
}, nil
}
// computeClaimExpiry 从奖品 config.claim_ttl_hours 读窗口配置;缺失或非正
// 则回落到 lottery.DefaultClaimTTLHours (7 天)。
func (s *Service) computeClaimExpiry(prize lottery.Prize) time.Time {
hours := lottery.DefaultClaimTTLHours
if prize.Config != "" {
var cfg struct {
ClaimTTLHours int `json:"claim_ttl_hours"`
}
if err := json.Unmarshal([]byte(prize.Config), &cfg); err == nil && cfg.ClaimTTLHours > 0 {
hours = cfg.ClaimTTLHours
}
}
return time.Now().Add(time.Duration(hours) * time.Hour)
}
func (s *Service) finalizeDrawState(ctx context.Context, tx *gorm.DB, draw *lottery.Draw, dispatch lottery.DispatchResult) error {
@@ -454,31 +519,53 @@ func (s *Service) buildResultFromExistingDraw(ctx context.Context, tx *gorm.DB,
Config: json.RawMessage(snap.Config),
}
}
claim := ClaimSummary{
Required: existing.DispatchState == lottery.DispatchStatePendingClaim,
AutoClaimed: existing.DispatchState == lottery.DispatchStateAutoClaimed,
}
// 重放场景(同一 client_nonce)也补回 expires_at / schema,避免前端第二次
// 收到的响应比首次少字段。
if claim.Required {
var claimRow lottery.Claim
err := tx.WithContext(ctx).Where("draw_id = ?", existing.Id).First(&claimRow).Error
if err == nil {
claim.ExpiresAt = claimRow.ExpiresAt.Unix()
if s.deps.Registry != nil {
if h, ok := s.deps.Registry.Get(claimRow.PrizeType); ok {
if claimRow.PrizeType == lottery.PrizeTypeCrypto {
claim.ClaimFormSchema = handler.BuildCryptoClaimSchema(snap.Config)
} else {
claim.ClaimFormSchema = h.ClaimSchema()
}
}
}
}
}
return &Result{
DrawId: existing.Id,
IsWin: existing.IsWin,
Prize: prize,
ChancesRemaining: remaining,
Claim: ClaimSummary{
Required: existing.DispatchState == lottery.DispatchStatePendingClaim,
AutoClaimed: existing.DispatchState == lottery.DispatchStateAutoClaimed,
Message: "",
},
Claim: claim,
}, nil
}
func buildResult(draw *lottery.Draw, prize lottery.Prize, dispatch lottery.DispatchResult, remaining int64) *Result {
func buildResult(draw *lottery.Draw, prize lottery.Prize, dispatch lottery.DispatchResult, claim pendingClaimInfo, remaining int64) *Result {
res := &Result{
DrawId: draw.Id,
IsWin: draw.IsWin,
ChancesRemaining: remaining,
Message: dispatch.Message,
Claim: ClaimSummary{
Required: dispatch.State == lottery.DispatchStatePendingClaim,
AutoClaimed: dispatch.State == lottery.DispatchStateAutoClaimed,
Message: dispatch.Message,
Required: dispatch.State == lottery.DispatchStatePendingClaim,
AutoClaimed: dispatch.State == lottery.DispatchStateAutoClaimed,
Message: dispatch.Message,
ClaimFormSchema: claim.ClaimFormSchema,
},
}
if !claim.ExpiresAt.IsZero() {
res.Claim.ExpiresAt = claim.ExpiresAt.Unix()
}
if draw.IsWin {
res.Prize = &PrizeSummary{
Slot: prize.Slot,
+160 -8
View File
@@ -95,7 +95,8 @@ func (h *recordingHandler) Dispatch(_ context.Context, _ *gorm.DB, _ lottery.Dis
h.calls++
return h.result, h.err
}
func (h *recordingHandler) ValidateClaim([]byte) error { return nil }
func (h *recordingHandler) ValidateClaim([]byte) error { return nil }
func (h *recordingHandler) ClaimSchema() json.RawMessage { return nil }
// stubRegistry only knows what we register.
type stubRegistry struct {
@@ -513,7 +514,8 @@ func errAsCode(err error) (uint32, bool) {
return 0, false
}
// TestInsertSnapshots_UnmetReasonsIsValidJSON is the F4 regression guard.
// TestInsertSnapshots_UnmetReasonsIsValidJSON is the F4 regression guard
// (kept from PR E — must survive Stage 2 rebase).
//
// Before PR E, insertSnapshots created lottery_eligibility_snapshot rows with
// UnmetReasons="" — MySQL error 3140 rejects empty strings on JSON columns,
@@ -548,12 +550,12 @@ func TestInsertSnapshots_UnmetReasonsIsValidJSON(t *testing.T) {
// We assert UnmetReasons=="[]" (never "") and EvaluatedAt is non-zero.
mock.ExpectExec("INSERT INTO `lottery_eligibility_snapshot`").
WithArgs(
sqlmock.AnyArg(), // draw_id
sqlmock.AnyArg(), // user_id
sqlmock.AnyArg(), // activity_id
sqlmock.AnyArg(), // passed
unmetReasonsNotEmpty{t}, // MUST be "[]"
evaluatedAtNotZero{t}, // MUST be non-zero time
sqlmock.AnyArg(), // draw_id
sqlmock.AnyArg(), // user_id
sqlmock.AnyArg(), // activity_id
sqlmock.AnyArg(), // passed
unmetReasonsNotEmpty{t}, // MUST be "[]"
evaluatedAtNotZero{t}, // MUST be non-zero time
).
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec("UPDATE `lottery_draw`").
@@ -611,3 +613,153 @@ func (m evaluatedAtNotZero) Match(v driver.Value) bool {
}
return true
}
// ---- Stage 2 (manual claim) tests ----------------------------------------
// TestDraw_ManualClaimHandlerInsertsPendingClaim 验证:命中 IsAuto()==false
// 的 handler 时,draw 事务里会 INSERT lottery_claim 并返回 ExpiresAt + Schema。
// 复用 PR E 的 F4/F5 matcher 断言 EligibilitySnapshot 守卫在人工奖分支同样生效。
func TestDraw_ManualClaimHandlerInsertsPendingClaim(t *testing.T) {
db, mock, cleanup := newTestDB(t)
defer cleanup()
expectRunningActivity(mock, 100)
expectPrizePool(mock, 100,
lottery.Prize{
Id: 50, ActivityId: 100, Slot: 3, Type: lottery.PrizeTypeCrypto,
Name: "1 BTC",
Config: `{"amount":"1","currency":"BTC","networks":["BTC","TRX"],"claim_ttl_hours":48}`,
Weight: 100,
},
)
manualHandler := &recordingHandler{
handlerType: lottery.PrizeTypeCrypto,
auto: false,
}
mock.ExpectBegin()
expectExistingDrawEmpty(mock)
mock.ExpectExec("INSERT INTO `lottery_draw`").
WillReturnResult(sqlmock.NewResult(5678, 1))
mock.ExpectExec("INSERT INTO `lottery_prize_snapshot`").
WillReturnResult(sqlmock.NewResult(1, 1))
// F4/F5 regression guards MUST hold on manual-claim path too.
mock.ExpectExec("INSERT INTO `lottery_eligibility_snapshot`").
WithArgs(
sqlmock.AnyArg(), // draw_id
sqlmock.AnyArg(), // user_id
sqlmock.AnyArg(), // activity_id
sqlmock.AnyArg(), // passed
unmetReasonsNotEmpty{t}, // MUST be "[]"
evaluatedAtNotZero{t}, // MUST be non-zero time
).
WillReturnResult(sqlmock.NewResult(1, 1))
// pending_claim row insert
mock.ExpectExec("INSERT INTO `lottery_claim`").
WillReturnResult(sqlmock.NewResult(1, 1))
// finalize draw.dispatch_state = pending_claim
mock.ExpectExec("UPDATE `lottery_draw`").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
svc := NewService(Deps{
DB: db,
Enabled: true,
Chance: &fakeChance{consumeRemaining: 0},
Evaluator: &fakeEvaluator{passed: true},
Picker: &fakePicker{idx: 0},
Registry: &stubRegistry{handlers: map[string]lottery.PrizeHandler{lottery.PrizeTypeCrypto: manualHandler}},
ContextBuilder: fakeContextBuilder{},
})
res, err := svc.Draw(context.Background(), Request{UserId: 1, ActivityId: 100, ClientNonce: "manual1"})
if err != nil {
t.Fatalf("Draw: %v", err)
}
if manualHandler.calls != 0 {
t.Fatalf("manual handler.Dispatch must NOT be called, got %d calls", manualHandler.calls)
}
if !res.Claim.Required {
t.Fatalf("expected Claim.Required=true, got %+v", res.Claim)
}
if res.Claim.AutoClaimed {
t.Fatalf("expected AutoClaimed=false for manual, got %+v", res.Claim)
}
if res.Claim.ExpiresAt == 0 {
t.Fatal("expected non-zero ExpiresAt")
}
// 48h TTL from prize config
expected := time.Now().Add(48 * time.Hour).Unix()
if diff := res.Claim.ExpiresAt - expected; diff > 5 || diff < -5 {
t.Fatalf("ExpiresAt off by %ds; got %d expected ~%d", diff, res.Claim.ExpiresAt, expected)
}
// crypto handler builds schema with enum injected from prize config
if len(res.Claim.ClaimFormSchema) == 0 {
t.Fatal("expected ClaimFormSchema for crypto")
}
if !strings.Contains(string(res.Claim.ClaimFormSchema), `"enum":["BTC","TRX"]`) {
t.Fatalf("expected enum with BTC/TRX in schema, got %s", res.Claim.ClaimFormSchema)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("expectations: %v", err)
}
}
// TestDraw_ManualClaimDefaultsTo7DayTTL 验证:奖品 config 没写 claim_ttl_hours
// 时,落在默认 168h(7 天)窗口。
func TestDraw_ManualClaimDefaultsTo7DayTTL(t *testing.T) {
db, mock, cleanup := newTestDB(t)
defer cleanup()
expectRunningActivity(mock, 100)
expectPrizePool(mock, 100,
lottery.Prize{
Id: 60, ActivityId: 100, Slot: 4, Type: lottery.PrizeTypePhysical,
Name: "T-shirt",
Config: `{"sku_id":"tee-01","sku_name":"限量 T 恤"}`,
Weight: 100,
},
)
mock.ExpectBegin()
expectExistingDrawEmpty(mock)
mock.ExpectExec("INSERT INTO `lottery_draw`").
WillReturnResult(sqlmock.NewResult(9001, 1))
mock.ExpectExec("INSERT INTO `lottery_prize_snapshot`").
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec("INSERT INTO `lottery_eligibility_snapshot`").
WithArgs(
sqlmock.AnyArg(),
sqlmock.AnyArg(),
sqlmock.AnyArg(),
sqlmock.AnyArg(),
unmetReasonsNotEmpty{t}, // F4 guard also applies here
evaluatedAtNotZero{t}, // F5 guard also applies here
).
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec("INSERT INTO `lottery_claim`").
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec("UPDATE `lottery_draw`").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
svc := NewService(Deps{
DB: db,
Enabled: true,
Chance: &fakeChance{consumeRemaining: 0},
Evaluator: &fakeEvaluator{passed: true},
Picker: &fakePicker{idx: 0},
Registry: &stubRegistry{handlers: map[string]lottery.PrizeHandler{
lottery.PrizeTypePhysical: &recordingHandler{handlerType: lottery.PrizeTypePhysical, auto: false},
}},
ContextBuilder: fakeContextBuilder{},
})
res, err := svc.Draw(context.Background(), Request{UserId: 2, ActivityId: 100, ClientNonce: "manual2"})
if err != nil {
t.Fatalf("Draw: %v", err)
}
expected := time.Now().Add(time.Duration(lottery.DefaultClaimTTLHours) * time.Hour).Unix()
if diff := res.Claim.ExpiresAt - expected; diff > 5 || diff < -5 {
t.Fatalf("expected default 7-day TTL, got diff=%ds", diff)
}
}