diff --git a/server/cmd/migrate/main.go b/server/cmd/migrate/main.go index ae96592..b2497b9 100644 --- a/server/cmd/migrate/main.go +++ b/server/cmd/migrate/main.go @@ -17,6 +17,7 @@ import ( "log" "os" + "github.com/wangjia/pangolin/server/internal/codes" "github.com/wangjia/pangolin/server/internal/config" "github.com/wangjia/pangolin/server/internal/store" ) @@ -48,6 +49,13 @@ func main() { if err := store.ApplyCodesLibMigrations(context.Background(), db, driver); err != nil { log.Fatalf("migrate up (codes lib): %v", err) } + n, err := codes.BackfillLegacy(context.Background(), db) + if err != nil { + log.Fatalf("migrate up (codes backfill): %v", err) + } + if n > 0 { + log.Printf("migrate: codes backfill — %d legacy codes migrated", n) + } log.Println("migrate: up — done") case "down": diff --git a/server/internal/codes/backfill.go b/server/internal/codes/backfill.go new file mode 100644 index 0000000..7e11f07 --- /dev/null +++ b/server/internal/codes/backfill.go @@ -0,0 +1,189 @@ +package codes + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "time" + + dbx "github.com/wangjia/pangolin/server/internal/db" +) + +// BackfillLegacy copies legacy_codes / legacy_code_batches rows (renamed away +// by migration 000020) into the shared library's codes / codes_batches +// tables. Idempotent: batches keyed by preserved id, codes keyed by the +// unique code_hash; rows already present are skipped. Returns the number of +// code rows migrated. If the legacy tables don't exist (fresh install after +// the eventual legacy cleanup), it is a no-op. +func BackfillLegacy(ctx context.Context, database *sql.DB) (int, error) { + exists, err := legacyTablesExist(ctx, database) + if err != nil { + return 0, fmt.Errorf("codes.BackfillLegacy: probe legacy tables: %w", err) + } + if !exists { + return 0, nil + } + + type legacyBatch struct { + id int64 + channel string + createdBy string + note sql.NullString + createdAt time.Time + } + type legacyCode struct { + id int64 + hash string + planCode string + days int + batchID int64 + status string + redeemedBy sql.NullInt64 + redeemedAt sql.NullTime + } + + // ── 读阶段:全量取出、关闭游标(不能在同事务连接上边迭代边写)── + var batches []legacyBatch + rows, err := database.QueryContext(ctx, + `SELECT id, channel, created_by, note, created_at FROM legacy_code_batches ORDER BY id`) + if err != nil { + return 0, fmt.Errorf("codes.BackfillLegacy: read batches: %w", err) + } + for rows.Next() { + var b legacyBatch + if err := rows.Scan(&b.id, &b.channel, &b.createdBy, &b.note, &b.createdAt); err != nil { + rows.Close() + return 0, fmt.Errorf("codes.BackfillLegacy: scan batch: %w", err) + } + batches = append(batches, b) + } + if err := rows.Err(); err != nil { + rows.Close() + return 0, err + } + rows.Close() + + var lcodes []legacyCode + rows, err = database.QueryContext(ctx, + `SELECT c.id, c.code_hash, p.code, c.duration_days, c.batch_id, c.status, c.redeemed_by, c.redeemed_at + FROM legacy_codes c JOIN plans p ON p.id = c.plan_id ORDER BY c.id`) + if err != nil { + return 0, fmt.Errorf("codes.BackfillLegacy: read codes: %w", err) + } + for rows.Next() { + var c legacyCode + if err := rows.Scan(&c.id, &c.hash, &c.planCode, &c.days, &c.batchID, &c.status, &c.redeemedBy, &c.redeemedAt); err != nil { + rows.Close() + return 0, fmt.Errorf("codes.BackfillLegacy: scan code: %w", err) + } + lcodes = append(lcodes, c) + } + if err := rows.Err(); err != nil { + rows.Close() + return 0, err + } + rows.Close() + + // batch id → (payload, createdAt):batch 权益取其第一条 code 合成。 + batchAt := make(map[int64]time.Time, len(batches)) + batchPayload := make(map[int64]string, len(batches)) + for _, b := range batches { + batchAt[b.id] = b.createdAt + } + for _, c := range lcodes { + if _, ok := batchPayload[c.batchID]; !ok { + batchPayload[c.batchID] = durationPayloadJSON(c.planCode, c.days) + } + } + + // ── 写阶段:单事务,全成或全无;逐行判重保证幂等。── + tx, err := database.BeginTx(ctx, nil) + if err != nil { + return 0, fmt.Errorf("codes.BackfillLegacy: begin: %w", err) + } + defer tx.Rollback() + + for _, b := range batches { + var n int + if err := tx.QueryRowContext(ctx, + `SELECT COUNT(*) FROM codes_batches WHERE id = ?`, b.id).Scan(&n); err != nil { + return 0, fmt.Errorf("codes.BackfillLegacy: probe batch %d: %w", b.id, err) + } + if n > 0 { + continue + } + payload, ok := batchPayload[b.id] + if !ok { + payload = durationPayloadJSON("unknown", 0) // 空批次:信息性占位 + } + var note any + if b.note.Valid { + note = b.note.String + } + if _, err := tx.ExecContext(ctx, + `INSERT INTO codes_batches (id, channel, entitlement_kind, entitlement_payload, created_by, note, created_at) + VALUES (?, ?, 'duration', ?, ?, ?, ?)`, + b.id, b.channel, payload, b.createdBy, note, b.createdAt.UTC()); err != nil { + return 0, fmt.Errorf("codes.BackfillLegacy: insert batch %d: %w", b.id, err) + } + } + + migrated := 0 + for _, c := range lcodes { + var n int + if err := tx.QueryRowContext(ctx, + `SELECT COUNT(*) FROM codes WHERE code_hash = ?`, c.hash).Scan(&n); err != nil { + return 0, fmt.Errorf("codes.BackfillLegacy: probe code %d: %w", c.id, err) + } + if n > 0 { + continue + } + var redeemedBy, redeemedAt any + if c.redeemedBy.Valid { + redeemedBy = fmt.Sprintf("user:%d", c.redeemedBy.Int64) + } + if c.redeemedAt.Valid { + redeemedAt = c.redeemedAt.Time.UTC() + } + createdAt, ok := batchAt[c.batchID] + if !ok { + createdAt = time.Now() + } + if _, err := tx.ExecContext(ctx, + `INSERT INTO codes (id, code_hash, batch_id, entitlement_kind, entitlement_payload, status, redeemed_by, redeemed_at, void_reason, created_at) + VALUES (?, ?, ?, 'duration', ?, ?, ?, ?, NULL, ?)`, + c.id, c.hash, c.batchID, durationPayloadJSON(c.planCode, c.days), + c.status, redeemedBy, redeemedAt, createdAt.UTC()); err != nil { + return 0, fmt.Errorf("codes.BackfillLegacy: insert code %d: %w", c.id, err) + } + migrated++ + } + + if err := tx.Commit(); err != nil { + return 0, fmt.Errorf("codes.BackfillLegacy: commit: %w", err) + } + return migrated, nil +} + +func durationPayloadJSON(plan string, days int) string { + b, _ := json.Marshal(struct { + Plan string `json:"plan"` + Days int `json:"days"` + }{plan, days}) + return string(b) +} + +// legacyTablesExist probes for legacy_codes per dialect (sqlite_master vs +// information_schema) — no error-swallowing "SELECT and see" hacks. +func legacyTablesExist(ctx context.Context, database *sql.DB) (bool, error) { + var n int + if dbx.DialectForDB(database).Name() == "sqlite" { + err := database.QueryRowContext(ctx, + `SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='legacy_codes'`).Scan(&n) + return n > 0, err + } + err := database.QueryRowContext(ctx, + `SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'legacy_codes'`).Scan(&n) + return n > 0, err +} diff --git a/server/internal/codes/backfill_test.go b/server/internal/codes/backfill_test.go new file mode 100644 index 0000000..5879b3b --- /dev/null +++ b/server/internal/codes/backfill_test.go @@ -0,0 +1,131 @@ +package codes_test + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/wangjia/pangolin/server/internal/codes" + "github.com/wangjia/pangolin/server/internal/config" + "github.com/wangjia/pangolin/server/internal/store" +) + +func openMigratedSQLite(t *testing.T) *sql.DB { + t.Helper() + db, err := store.Open(&config.Config{Driver: "sqlite", DSN: ":memory:"}) + if err != nil { + t.Fatalf("open: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := store.MigrateUp(db, "sqlite"); err != nil { + t.Fatalf("migrate: %v", err) + } + if err := store.ApplyCodesLibMigrations(context.Background(), db, "sqlite"); err != nil { + t.Fatalf("lib migrate: %v", err) + } + return db +} + +func TestBackfillLegacy(t *testing.T) { + ctx := context.Background() + db := openMigratedSQLite(t) + + // 造 legacy 数据(000020 之后 legacy 表空壳存在,直插即可)。plans 已 seed(000007)。 + var proID int64 + if err := db.QueryRow(`SELECT id FROM plans WHERE code='pro'`).Scan(&proID); err != nil { + t.Fatalf("plan: %v", err) + } + batchAt := time.Date(2026, 5, 1, 8, 0, 0, 0, time.UTC) + if _, err := db.Exec( + `INSERT INTO legacy_code_batches (id, channel, created_by, note, created_at) VALUES (7, 'manual', 'admin:1', 'may batch', ?)`, + batchAt); err != nil { + t.Fatalf("seed batch: %v", err) + } + redeemedAt := time.Date(2026, 6, 2, 9, 30, 0, 0, time.UTC) + seed := []struct { + id int64 + hash string + status string + redeemedBy any + redeemedAt any + }{ + {101, "hash-unused-000000000000000000000000000000000000000000000000000001", "unused", nil, nil}, + {102, "hash-redeemed-0000000000000000000000000000000000000000000000000002", "redeemed", int64(42), redeemedAt}, + {103, "hash-void-00000000000000000000000000000000000000000000000000000003", "void", nil, nil}, + } + for _, c := range seed { + if _, err := db.Exec( + `INSERT INTO legacy_codes (id, code_hash, plan_id, duration_days, batch_id, status, redeemed_by, redeemed_at) + VALUES (?, ?, ?, 30, 7, ?, ?, ?)`, + c.id, c.hash, proID, c.status, c.redeemedBy, c.redeemedAt); err != nil { + t.Fatalf("seed code %d: %v", c.id, err) + } + } + + n, err := codes.BackfillLegacy(ctx, db) + if err != nil { + t.Fatalf("BackfillLegacy: %v", err) + } + if n != 3 { + t.Fatalf("migrated = %d, want 3", n) + } + + // batch:id 保留 + 权益合成自第一条 code。 + var kind, payload string + if err := db.QueryRow( + `SELECT entitlement_kind, entitlement_payload FROM codes_batches WHERE id=7`).Scan(&kind, &payload); err != nil { + t.Fatalf("batch row: %v", err) + } + if kind != "duration" { + t.Errorf("batch kind = %q", kind) + } + if payload != `{"plan":"pro","days":30}` { + t.Errorf("batch payload = %s", payload) + } + + // code:redeemed_by 映射 + created_at 取 batch 时间 + status passthrough。 + var status, rby, cpayload string + var cat time.Time + if err := db.QueryRow( + `SELECT status, redeemed_by, entitlement_payload, created_at FROM codes WHERE code_hash=?`, + seed[1].hash).Scan(&status, &rby, &cpayload, &cat); err != nil { + t.Fatalf("code row: %v", err) + } + if status != "redeemed" || rby != "user:42" { + t.Errorf("status=%q redeemed_by=%q", status, rby) + } + if cpayload != `{"plan":"pro","days":30}` { + t.Errorf("code payload = %s", cpayload) + } + if !cat.Equal(batchAt) { + t.Errorf("created_at = %v, want batch created_at %v", cat, batchAt) + } + + // 幂等:重复跑迁 0 行、不重不错。 + n2, err := codes.BackfillLegacy(ctx, db) + if err != nil || n2 != 0 { + t.Fatalf("re-run: n=%d err=%v, want 0,nil", n2, err) + } + var cnt int + if err := db.QueryRow(`SELECT COUNT(*) FROM codes`).Scan(&cnt); err != nil || cnt != 3 { + t.Fatalf("codes count = %d (err=%v), want 3", cnt, err) + } +} + +func TestBackfillLegacyNoLegacyTables(t *testing.T) { + // 全新库(未来 legacy 表 retire 后)也不报错。用一个没跑 pangolin 迁移、只有库表的 DB 模拟。 + ctx := context.Background() + db, err := store.Open(&config.Config{Driver: "sqlite", DSN: ":memory:"}) + if err != nil { + t.Fatalf("open: %v", err) + } + defer db.Close() + if err := store.ApplyCodesLibMigrations(ctx, db, "sqlite"); err != nil { + t.Fatalf("lib migrate: %v", err) + } + n, err := codes.BackfillLegacy(ctx, db) + if err != nil || n != 0 { + t.Fatalf("n=%d err=%v, want 0,nil", n, err) + } +}