From 117dc0d6a7cedec27254f944048518402c9812d2 Mon Sep 17 00:00:00 2001 From: shanshanzhong147 Date: Thu, 9 Jul 2026 09:27:14 -0700 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(#3):=20=E6=8A=BD=E5=A5=96=20?= =?UTF-8?q?GrantLedger.Payload=20=E7=A9=BA=E5=AD=97=E7=AC=A6=E4=B8=B2?= =?UTF-8?q?=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-3 (Stage 1 P0 from QA smoke - third and final of the same class) F6 (P0): GrantLedger.Payload=\"\" → MySQL error 3140 (Invalid JSON text: The document is empty) - 修法:方式 A 对称守卫 Payload=\"{}\" (与 PR E UnmetReasons=\"[]\", PR C PrizeSnapshot.Config=\"{}\" 三处守卫模式统一) - QA 已 sweep 全部 6 个 lottery JSON 列,这是最后一处漏守 - handler 成功 dispatch 后会 UpdateColumn 覆盖真实 payload;Reserve 阶段用 {} 兜底 回归护栏:TestReserve_EmptyPayloadDefaultsToEmptyJSONObject 用 payloadNotEmptyString per-arg matcher CI 全绿;2 files, +83/-0 架构师复盘:三次同一 pattern 漏检查(F2 audit ctx keys / F4 UnmetReasons / F6 Payload)。Stage 2 起硬性规则:grep -RIn 'type:json' internal/model/ 全量清单逐列 sweep + 每列至少一条 per-arg matcher 单测。感谢 QA 三次挖坑。 --- internal/model/lottery/grant_ledger.go | 7 ++ internal/model/lottery/grant_ledger_test.go | 76 +++++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/internal/model/lottery/grant_ledger.go b/internal/model/lottery/grant_ledger.go index 96ed6e5..be40a23 100644 --- a/internal/model/lottery/grant_ledger.go +++ b/internal/model/lottery/grant_ledger.go @@ -51,6 +51,13 @@ func (s *ledgerService) Reserve(ctx context.Context, tx *gorm.DB, entry GrantLed if entry.ExternalRef == "" { return nil, false, errors.New("Reserve requires a non-empty ExternalRef") } + // Payload 是 JSON 列,MySQL 拒绝空字符串(error 3140)—— + // handler 在成功 dispatch 后会 UpdateColumn 覆盖真实 payload;Reserve 阶 + // 段的空 payload 用 "{}" 兜底,与 PrizeSnapshot.Config、 + // EligibilitySnapshot.UnmetReasons 的守卫对称。 + if entry.Payload == "" { + entry.Payload = "{}" + } insertRes := tx.WithContext(ctx). Clauses(clause.OnConflict{DoNothing: true}). diff --git a/internal/model/lottery/grant_ledger_test.go b/internal/model/lottery/grant_ledger_test.go index 48ee40d..50a0bfd 100644 --- a/internal/model/lottery/grant_ledger_test.go +++ b/internal/model/lottery/grant_ledger_test.go @@ -2,6 +2,7 @@ package lottery import ( "context" + "database/sql/driver" "testing" "github.com/DATA-DOG/go-sqlmock" @@ -110,3 +111,78 @@ func TestLedgerService_Reserve_RequiresExternalRef(t *testing.T) { t.Fatalf("expected error on empty ExternalRef") } } + +// TestReserve_EmptyPayloadDefaultsToEmptyJSONObject is the F6 regression guard. +// +// Before PR F, Reserve created lottery_grant_ledger rows with the caller's +// empty entry.Payload written verbatim ("") into the `payload` JSON column — +// MySQL error 3140 rejects empty strings on JSON columns, so every real +// draw's ledger INSERT died. sqlmock does no JSON validation so the earlier +// tests were silent about it. +// +// Guard the exact Go-layer value we send by asserting the INSERT arg for +// `payload` is "{}" (never ""). This is the same pattern as PR E's +// EligibilitySnapshot.UnmetReasons guard. +func TestReserve_EmptyPayloadDefaultsToEmptyJSONObject(t *testing.T) { + db, mock, cleanup := newLotteryTestDB(t) + defer cleanup() + + // GORM omits granted_at from the INSERT column list because it has + // `<-:create;default:CURRENT_TIMESTAMP` — 7 args, not 8. Column order: + // external_ref, handler_type, user_id, activity_id, draw_id, amount, payload. + mock.ExpectBegin() + mock.ExpectExec("INSERT INTO `lottery_grant_ledger`"). + WithArgs( + sqlmock.AnyArg(), // external_ref + sqlmock.AnyArg(), // handler_type + sqlmock.AnyArg(), // user_id + sqlmock.AnyArg(), // activity_id + sqlmock.AnyArg(), // draw_id + sqlmock.AnyArg(), // amount + payloadNotEmptyString{t}, // MUST be "{}", never "" + ). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectQuery("FROM `lottery_grant_ledger`"). + WithArgs("lottery:100:200", 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "external_ref"}).AddRow(int64(1), "lottery:100:200")) + mock.ExpectCommit() + + svc := NewLedgerService() + err := db.Transaction(func(tx *gorm.DB) error { + // Intentionally leave Payload empty — the guard must default it. + _, _, e := svc.Reserve(context.Background(), tx, GrantLedger{ + ExternalRef: "lottery:100:200", + HandlerType: "vpn_duration", + UserId: 42, + ActivityId: 100, + DrawId: 200, + Amount: 3, + }) + return e + }) + if err != nil { + t.Fatalf("Reserve: %v", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("expectations: %v", err) + } +} + +// payloadNotEmptyString is a per-arg matcher: the value MUST be a non-empty +// string; specifically "{}" per the PR F guard. Empty string is the exact F6 +// regression symptom (MySQL error 3140). +type payloadNotEmptyString struct{ t *testing.T } + +func (m payloadNotEmptyString) Match(v driver.Value) bool { + s, ok := v.(string) + if !ok { + m.t.Fatalf("F6 guard: expected string for Payload, got %T (%v)", v, v) + } + if s == "" { + m.t.Fatalf("F6 regression: Payload must not be empty string (MySQL error 3140)") + } + if s != "{}" { + m.t.Fatalf("F6 guard: expected Payload==%q, got %q", "{}", s) + } + return true +}