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 +}