修复(#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:
2026-07-12 19:09:54 -07:00
committed by GitHub
parent 07409eb602
commit 980e5adb90
2 changed files with 116 additions and 5 deletions
+7 -2
View File
@@ -452,8 +452,13 @@ func (s *Service) dispatchOrEnqueueClaim(ctx context.Context, tx *gorm.DB, req R
UserId: req.UserId,
ActivityId: req.ActivityId,
PrizeType: prize.Type,
Status: lottery.ClaimStatusPendingClaim,
ExpiresAt: expiresAt,
// ClaimData 是 JSON 列,MySQL 拒绝空字符串(error 3140)——用户填领奖
// 表单前用 "{}" 兜底,用户 POST /claim 会覆盖真实数据。与
// PrizeSnapshot.Config、EligibilitySnapshot.UnmetReasons、
// GrantLedger.Payload 的守卫风格一致。
ClaimData: "{}",
Status: lottery.ClaimStatusPendingClaim,
ExpiresAt: expiresAt,
}
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))
+109 -3
View File
@@ -522,9 +522,9 @@ func errAsCode(err error) (uint32, bool) {
// so every real /draw request 100% failed the tx commit even though sqlmock
// (which does no JSON validation) was happy. This test snapshots the exact
// INSERT arg values and asserts:
// 1. UnmetReasons must never be "" (it should be "[]")
// 2. EvaluatedAt must not be the zero time.Time (STRICT sql_mode rejects
// '0000-00-00 00:00:00' on DATETIME NOT NULL)
// 1. UnmetReasons must never be "" (it should be "[]")
// 2. EvaluatedAt must not be the zero time.Time (STRICT sql_mode rejects
// '0000-00-00 00:00:00' on DATETIME NOT NULL)
//
// sqlmock cannot catch the JSON validity itself — only real MySQL can — but
// it can catch the two upstream bugs that let bad values through the Go
@@ -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
}