新功能(#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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user