From 980e5adb9064f023e6703a68e89e4d7b4b1658ca Mon Sep 17 00:00:00 2001 From: shanshanzhong147 Date: Sun, 12 Jul 2026 19:09:54 -0700 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(#11):=20=E6=8A=BD=E5=A5=96?= =?UTF-8?q?=20Stage=202=20Claim.ClaimData=20=E7=A9=BA=E5=AD=97=E7=AC=A6?= =?UTF-8?q?=E4=B8=B2=E8=BF=9D=E5=8F=8D=20MySQL=20JSON=20=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 全库。 --- internal/logic/lottery/draw/service.go | 9 +- internal/logic/lottery/draw/service_test.go | 112 +++++++++++++++++++- 2 files changed, 116 insertions(+), 5 deletions(-) diff --git a/internal/logic/lottery/draw/service.go b/internal/logic/lottery/draw/service.go index 30ca045..0d645d9 100644 --- a/internal/logic/lottery/draw/service.go +++ b/internal/logic/lottery/draw/service.go @@ -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)) diff --git a/internal/logic/lottery/draw/service_test.go b/internal/logic/lottery/draw/service_test.go index 8789877..c6fa161 100644 --- a/internal/logic/lottery/draw/service_test.go +++ b/internal/logic/lottery/draw/service_test.go @@ -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 +}