修复(#11): 抽奖 Stage 2 Claim.ClaimData 空字符串违反 MySQL JSON 校验
Closes HIF-11 (Stage 2 P0)
F7 (P0): Claim.ClaimData=\"\" → MySQL error 3140 (Invalid JSON text: The document is empty)
- 修法:service.go:450 dispatchOrEnqueueClaim 构造 Claim 时显式 ClaimData=\"{}\"
- 完全同构 PR C PrizeSnapshot.Config / PR E UnmetReasons=\"[]\" / PR F Payload=\"{}\" 三处守卫
回归护栏:TestDraw_ManualClaimClaimDataIsValidJSON + claimDataIsValidJSON per-arg matcher(既拒 \"\" 也 json.Valid 校验)
Backend 反向自检:git stash fix 后测试立即抛 F7 regression 明确错误
CI 全绿;2 files, +116/-5;无 DB / API / flag 变更
架构师复盘:这是 code review 第 4 次漏检查同类 JSON 空串守卫(Stage 1 的 F4/F6 + Stage 2 的 F7)。感谢 QA 三次挖坑救场。Stage 3 起 code review 硬性 checklist 第一步:grep -RIn type:json sweep 全库。
This commit is contained in:
@@ -452,6 +452,11 @@ func (s *Service) dispatchOrEnqueueClaim(ctx context.Context, tx *gorm.DB, req R
|
||||
UserId: req.UserId,
|
||||
ActivityId: req.ActivityId,
|
||||
PrizeType: prize.Type,
|
||||
// ClaimData 是 JSON 列,MySQL 拒绝空字符串(error 3140)——用户填领奖
|
||||
// 表单前用 "{}" 兜底,用户 POST /claim 会覆盖真实数据。与
|
||||
// PrizeSnapshot.Config、EligibilitySnapshot.UnmetReasons、
|
||||
// GrantLedger.Payload 的守卫风格一致。
|
||||
ClaimData: "{}",
|
||||
Status: lottery.ClaimStatusPendingClaim,
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
|
||||
@@ -763,3 +763,109 @@ func TestDraw_ManualClaimDefaultsTo7DayTTL(t *testing.T) {
|
||||
t.Fatalf("expected default 7-day TTL, got diff=%ds", diff)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDraw_ManualClaimClaimDataIsValidJSON is the F7 regression guard
|
||||
// (HIF-11): the Stage 2 manual-claim path inserts a lottery_claim row inside
|
||||
// the draw tx; Claim.ClaimData is declared `gorm:"type:json"` and MySQL error
|
||||
// 3140 rejects the Go zero value "" on JSON columns. Same class of bug as
|
||||
// PR E (UnmetReasons="[]") and PR F (GrantLedger.Payload="{}").
|
||||
//
|
||||
// The regression before the fix: 100% of manual-prize draws (crypto /
|
||||
// physical / manual_other) hit `100500 insert lottery_claim for draw N:
|
||||
// Error 3140 (22032): Invalid JSON text: "The document is empty."`.
|
||||
//
|
||||
// sqlmock does no JSON validation, but this per-arg matcher snapshots the
|
||||
// exact ClaimData bind value and fails if it's the empty string — the exact
|
||||
// Go-layer bug real MySQL would reject downstream.
|
||||
func TestDraw_ManualClaimClaimDataIsValidJSON(t *testing.T) {
|
||||
db, mock, cleanup := newTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
expectRunningActivity(mock, 100)
|
||||
expectPrizePool(mock, 100, lottery.Prize{
|
||||
Id: 70, ActivityId: 100, Slot: 5, Type: lottery.PrizeTypeManualOther,
|
||||
Name: "定制手办",
|
||||
Config: `{"sku_name":"定制"}`,
|
||||
Weight: 100,
|
||||
})
|
||||
|
||||
mock.ExpectBegin()
|
||||
expectExistingDrawEmpty(mock)
|
||||
mock.ExpectExec("INSERT INTO `lottery_draw`").
|
||||
WillReturnResult(sqlmock.NewResult(7777, 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 survives
|
||||
evaluatedAtNotZero{t}, // F5 guard survives
|
||||
).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
// Actual GORM bind order (nullable *time.Time fields — SubmittedAt,
|
||||
// ReviewedAt, PaidAt — are elided when nil):
|
||||
// draw_id, user_id, activity_id, prize_type, claim_data, status,
|
||||
// expires_at, reviewed_by, reject_reason, tx_hash, delivery_ref,
|
||||
// created_at, updated_at.
|
||||
// Assert ClaimData (position 5) is valid JSON — never "".
|
||||
mock.ExpectExec("INSERT INTO `lottery_claim`").
|
||||
WithArgs(
|
||||
sqlmock.AnyArg(), // draw_id
|
||||
sqlmock.AnyArg(), // user_id
|
||||
sqlmock.AnyArg(), // activity_id
|
||||
sqlmock.AnyArg(), // prize_type
|
||||
claimDataIsValidJSON{t}, // MUST be valid JSON, not ""
|
||||
sqlmock.AnyArg(), // status
|
||||
sqlmock.AnyArg(), // expires_at
|
||||
sqlmock.AnyArg(), // reviewed_by
|
||||
sqlmock.AnyArg(), // reject_reason
|
||||
sqlmock.AnyArg(), // tx_hash
|
||||
sqlmock.AnyArg(), // delivery_ref
|
||||
sqlmock.AnyArg(), // created_at
|
||||
sqlmock.AnyArg(), // updated_at
|
||||
).
|
||||
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.PrizeTypeManualOther: &recordingHandler{handlerType: lottery.PrizeTypeManualOther, auto: false},
|
||||
}},
|
||||
ContextBuilder: fakeContextBuilder{},
|
||||
})
|
||||
if _, err := svc.Draw(context.Background(), Request{UserId: 87437, ActivityId: 100, ClientNonce: "hif-11-regression"}); err != nil {
|
||||
t.Fatalf("Draw: %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// claimDataIsValidJSON is a per-arg matcher: the value MUST be a non-empty
|
||||
// string that parses as valid JSON. The exact regression (HIF-11) is
|
||||
// ClaimData=="" — MySQL error 3140 rejects it on JSON columns.
|
||||
type claimDataIsValidJSON struct{ t *testing.T }
|
||||
|
||||
func (m claimDataIsValidJSON) Match(v driver.Value) bool {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
m.t.Fatalf("F7 guard: expected string for ClaimData, got %T (%v)", v, v)
|
||||
}
|
||||
if s == "" {
|
||||
m.t.Fatalf("F7 regression (HIF-11): ClaimData must not be empty string (MySQL error 3140)")
|
||||
}
|
||||
if !json.Valid([]byte(s)) {
|
||||
m.t.Fatalf("F7 guard: ClaimData must be valid JSON, got %q", s)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user