feat(server): legacy 码表 → codes 库新表的幂等回填(Go 双方言)(#codes-lib)

This commit is contained in:
wangjia
2026-07-10 14:13:21 +08:00
parent 7a4c9b97d0
commit 15f0d78a91
3 changed files with 328 additions and 0 deletions
+8
View File
@@ -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":
+189
View File
@@ -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
}
+131
View File
@@ -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)
}
}