修复(#3): 抽奖 EligibilitySnapshot.UnmetReasons 空字符串违反 MySQL JSON 校验
Closes HIF-3 (Stage 1 P0 from QA smoke)
F4 (P0): EligibilitySnapshot.UnmetReasons=\"\" → MySQL error 3140 (Invalid JSON text: The document is empty)
- 修法:方式 A 对称守卫 UnmetReasons=\"[]\" (与 PrizeSnapshot.Config 的 \"{}\" 守卫模式一致)
- Stage 1 只有 passed=true 分支进 insertSnapshots,语义正确
F5 (P2 顺手): EvaluatedAt 显式 time.Now() 避免 GORM zero time 触 sql_mode STRICT
回归护栏:TestInsertSnapshots_UnmetReasonsIsValidJSON 用 sqlmock per-arg matcher (unmetReasonsNotEmpty + evaluatedAtNotZero),退化时 t.Fatalf 立即抛出
CI 全绿;2 files, +108/-0;无 DB 变更
架构师侧透明:这是 PR C review 时漏检查——PrizeSnapshot.Config 的空串守卫看到了,但没同步检查 EligibilitySnapshot.UnmetReasons。single-code-path 的错觉是审查盲点,Stage 2 起会 sweep 所有 JSON/字符串写库字段。
This commit is contained in:
@@ -383,6 +383,14 @@ func (s *Service) insertSnapshots(ctx context.Context, tx *gorm.DB, draw *lotter
|
|||||||
UserId: draw.UserId,
|
UserId: draw.UserId,
|
||||||
ActivityId: draw.ActivityId,
|
ActivityId: draw.ActivityId,
|
||||||
Passed: passedEligibility,
|
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
|
return tx.WithContext(ctx).Create(&es).Error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package draw
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"database/sql/driver"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -511,3 +512,102 @@ func errAsCode(err error) (uint32, bool) {
|
|||||||
}
|
}
|
||||||
return 0, false
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user