diff --git a/internal/logic/lottery/draw/service.go b/internal/logic/lottery/draw/service.go index e2b55e2..3d2d85d 100644 --- a/internal/logic/lottery/draw/service.go +++ b/internal/logic/lottery/draw/service.go @@ -383,6 +383,14 @@ func (s *Service) insertSnapshots(ctx context.Context, tx *gorm.DB, draw *lotter UserId: draw.UserId, ActivityId: draw.ActivityId, Passed: passedEligibility, + // UnmetReasons 是 JSON 列,MySQL 拒绝空字符串(error 3140)—— + // Stage 1 只有 passed=true 进 insertSnapshots,语义上"没有未过项", + // 用 "[]" 与 PrizeSnapshot.Config 的 "{}" 守卫对称。 + // Stage 2 若开始持久化 passed=false 的失败评估,再改成真正的 marshal。 + UnmetReasons: "[]", + // 显式 time.Now():GORM 遇 zero time 有时会传 '0000-00-00 00:00:00', + // 触 sql_mode STRICT。不依赖 DB DEFAULT CURRENT_TIMESTAMP。 + EvaluatedAt: time.Now(), } return tx.WithContext(ctx).Create(&es).Error } diff --git a/internal/logic/lottery/draw/service_test.go b/internal/logic/lottery/draw/service_test.go index 35ddce6..965da8b 100644 --- a/internal/logic/lottery/draw/service_test.go +++ b/internal/logic/lottery/draw/service_test.go @@ -3,6 +3,7 @@ package draw import ( "context" "database/sql" + "database/sql/driver" "encoding/json" "errors" "fmt" @@ -511,3 +512,102 @@ func errAsCode(err error) (uint32, bool) { } return 0, false } + +// TestInsertSnapshots_UnmetReasonsIsValidJSON is the F4 regression guard. +// +// Before PR E, insertSnapshots created lottery_eligibility_snapshot rows with +// UnmetReasons="" — MySQL error 3140 rejects empty strings on JSON columns, +// 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) +// +// 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 +// layer. This is a defense-in-depth check. +func TestInsertSnapshots_UnmetReasonsIsValidJSON(t *testing.T) { + db, mock, cleanup := newTestDB(t) + defer cleanup() + + expectRunningActivity(mock, 100) + expectPrizePool(mock, 100, lottery.Prize{ + Id: 1, ActivityId: 100, Slot: 0, Type: lottery.PrizeTypeNone, Name: "谢谢参与", Config: "{}", Weight: 100, + }) + + mock.ExpectBegin() + expectExistingDrawEmpty(mock) + mock.ExpectExec("INSERT INTO `lottery_draw`"). + WillReturnResult(sqlmock.NewResult(555, 1)) + mock.ExpectExec("INSERT INTO `lottery_prize_snapshot`"). + WillReturnResult(sqlmock.NewResult(1, 1)) + + // Field order matches EligibilitySnapshot struct-tag order under GORM: + // draw_id, user_id, activity_id, passed, unmet_reasons, evaluated_at. + // We assert UnmetReasons=="[]" (never "") and EvaluatedAt is non-zero. + mock.ExpectExec("INSERT INTO `lottery_eligibility_snapshot`"). + WithArgs( + sqlmock.AnyArg(), // draw_id + sqlmock.AnyArg(), // user_id + sqlmock.AnyArg(), // activity_id + sqlmock.AnyArg(), // passed + unmetReasonsNotEmpty{t}, // MUST be "[]" + evaluatedAtNotZero{t}, // MUST be non-zero time + ). + 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: 2}, + Evaluator: &fakeEvaluator{passed: true}, + Picker: &fakePicker{idx: 0}, + Registry: &stubRegistry{handlers: map[string]lottery.PrizeHandler{}}, + ContextBuilder: fakeContextBuilder{}, + }) + _, err := svc.Draw(context.Background(), Request{UserId: 1, ActivityId: 100, ClientNonce: "f4-regression"}) + if err != nil { + t.Fatalf("Draw: %v", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("expectations: %v", err) + } +} + +// unmetReasonsNotEmpty is a per-arg matcher: the value MUST be the string +// "[]"; the empty string is the exact F4 regression we are guarding against. +type unmetReasonsNotEmpty struct{ t *testing.T } + +func (m unmetReasonsNotEmpty) Match(v driver.Value) bool { + s, ok := v.(string) + if !ok { + m.t.Fatalf("F4 guard: expected string for UnmetReasons, got %T (%v)", v, v) + } + if s == "" { + m.t.Fatalf("F4 regression: UnmetReasons must not be empty string (MySQL error 3140)") + } + if s != "[]" { + m.t.Fatalf("F4 guard: expected UnmetReasons==%q, got %q", "[]", s) + } + return true +} + +// evaluatedAtNotZero is a per-arg matcher: the value MUST be a non-zero +// time.Time; the zero time is the F5 regression that STRICT sql_mode rejects +// as '0000-00-00 00:00:00'. +type evaluatedAtNotZero struct{ t *testing.T } + +func (m evaluatedAtNotZero) Match(v driver.Value) bool { + tv, ok := v.(time.Time) + if !ok { + m.t.Fatalf("F5 guard: expected time.Time for EvaluatedAt, got %T (%v)", v, v) + } + if tv.IsZero() { + m.t.Fatalf("F5 regression: EvaluatedAt must not be zero time") + } + return true +}