package model_test import ( "fmt" "sync/atomic" "testing" "github.com/glebarez/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" "github.com/wangjia/pay/internal/model" "github.com/wangjia/pay/internal/store" ) // legacyWebhookDelivery 复刻 P2 时代 webhook_deliveries 的表结构:uq_delivery // 只覆盖 (out_trade_no,event_type) 两列,压根没有 refund_id 列。用它单独 // AutoMigrate 出一张全新表,模拟"升级前"的生产库(当前 model.WebhookDelivery // 是 P4 才把 refund_id 并入 uq_delivery 的三列版本)。 type legacyWebhookDelivery struct { model.Base OutTradeNo string `gorm:"size:64;not null;uniqueIndex:uq_delivery"` EventType string `gorm:"size:32;not null;uniqueIndex:uq_delivery"` BizSystem string `gorm:"index;size:32"` Payload string `gorm:"type:text"` Delivered bool `gorm:"index;default:false"` Attempts int LastError string `gorm:"size:255"` } func (legacyWebhookDelivery) TableName() string { return "webhook_deliveries" } var legacyDBCounter int64 // openLegacyDB 开一个全新的内存 sqlite 库,只灌 legacyWebhookDelivery 这张 // "老版本"表结构 —— 不经过 model.OpenTestDB(它已经是当前 3 列 schema,没法 // 用来复现"库还停在 2 列"的升级前状态)。 func openLegacyDB(t *testing.T) *gorm.DB { t.Helper() n := atomic.AddInt64(&legacyDBCounter, 1) dsn := fmt.Sprintf("file:legacytestdb_%d?mode=memory&cache=shared", n) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent), TranslateError: true}) if err != nil { t.Fatalf("open legacy db: %v", err) } if err := db.AutoMigrate(&legacyWebhookDelivery{}); err != nil { t.Fatalf("migrate legacy schema: %v", err) } sqlDB, _ := db.DB() t.Cleanup(func() { _ = sqlDB.Close() }) return db } // TestUpgradeWidensLegacyUqDeliveryIndex 复现 reviewer 报的 Critical:GORM // AutoMigrate 只按名字判断索引是否存在,P2 时代库升级后同名 uq_delivery 还 // 停在 2 列,store.EnqueueDelivery 的 3 列 ON CONFLICT 命中不到任何唯一约束, // 每次调用(含最普通的 payment webhook)都会报 // "ON CONFLICT clause does not match any PRIMARY KEY or UNIQUE constraint"。 // // fix 前跑本测试应在 (a) 处失败复现该错误(RED);fix 后 // (model.UpgradeWebhookDeliveryIndex 显式加宽索引 + backfill refund_id) // 应全绿(GREEN)。 func TestUpgradeWidensLegacyUqDeliveryIndex(t *testing.T) { db := openLegacyDB(t) // 模拟一条"升级前"就存在的历史行:没有 refund_id 列,自然也没有值。 if err := db.Exec( `INSERT INTO webhook_deliveries (out_trade_no, event_type, biz_system, payload, delivered, attempts, created_at, updated_at) VALUES ('PAY-LEGACY', 'payment.succeeded', 'pangolin', '{}', 0, 0, datetime('now'), datetime('now'))`, ).Error; err != nil { t.Fatalf("seed legacy row: %v", err) } // "升级":跑当前(3 列 refund_id)模型的 AutoMigrate,再跑显式 schema 升级步骤 —— // 与 main.go 的 autoMigrate() / model.OpenTestDB 同一顺序。 if err := db.AutoMigrate(&model.WebhookDelivery{}); err != nil { t.Fatalf("automigrate current schema: %v", err) } if err := model.UpgradeWebhookDeliveryIndex(db); err != nil { t.Fatalf("UpgradeWebhookDeliveryIndex: %v", err) } // (a) EnqueueDelivery 的 3 列 ON CONFLICT 必须能命中唯一约束:同订单同 // event_type 下,1 条 payment(refund_id="")+ 2 条不同 refund_id 的退款 // 事件 = 3 行(升级前的 bug 会让这三次调用全部报错)。 ws := store.NewWebhookStore(db) if err := ws.EnqueueDelivery("PAY-1", "pangolin", "payment.succeeded", "", `{}`); err != nil { t.Fatalf("enqueue payment: %v", err) } if err := ws.EnqueueDelivery("PAY-1", "pangolin", "refund.succeeded", "rf-A", `{}`); err != nil { t.Fatalf("enqueue refund rf-A: %v", err) } if err := ws.EnqueueDelivery("PAY-1", "pangolin", "refund.succeeded", "rf-B", `{}`); err != nil { t.Fatalf("enqueue refund rf-B: %v", err) } list, err := ws.ListUndelivered(10) if err != nil { t.Fatalf("list: %v", err) } var forPay1 int for _, row := range list { if row.OutTradeNo == "PAY-1" { forPay1++ } } // 期望 PAY-1 恰 3 行:1 payment + 2 refund(rf-A / rf-B);第 4 行是 PAY-LEGACY 的历史行。 if forPay1 != 3 { t.Fatalf("want 3 undelivered rows for PAY-1 (1 payment + 2 distinct refund_id), got %d: %+v", forPay1, list) } // (b) 历史行(升级前没有 refund_id 列)的 refund_id 应被 backfill 成 '',不是 NULL。 var legacyRefundID string if err := db.Raw(`SELECT refund_id FROM webhook_deliveries WHERE out_trade_no = 'PAY-LEGACY'`). Scan(&legacyRefundID).Error; err != nil { t.Fatalf("read legacy row: %v", err) } if legacyRefundID != "" { t.Fatalf("legacy row refund_id want '', got %q", legacyRefundID) } }