Skip to content

Commit 498d51f

Browse files
author
quantal
committed
feat: 重构启动迁移逻辑
1 parent 3066e53 commit 498d51f

2 files changed

Lines changed: 445 additions & 270 deletions

File tree

backend/internal/bootstrap/startup.go

Lines changed: 1 addition & 270 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,8 @@ package bootstrap
22

33
import (
44
"context"
5-
"crypto/sha256"
6-
stdsql "database/sql"
7-
"embed"
8-
"encoding/hex"
9-
"fmt"
10-
"io/fs"
115
"log/slog"
12-
"path/filepath"
13-
"sort"
146
"strings"
15-
"time"
167

178
entsql "entgo.io/ent/dialect/sql"
189

@@ -23,277 +14,17 @@ import (
2314
"github.com/DouDOU-start/airgate-core/internal/auth"
2415
)
2516

26-
//go:embed migrations/*.sql
27-
var startupMigrationFiles embed.FS
28-
2917
// RunStartupTasks 运行启动阶段的整理任务。
3018
func RunStartupTasks(db *ent.Client, drv *entsql.Driver, apiKeySecret string) {
3119
slog.Info("bootstrap_startup_tasks_start")
3220
backfillKeyHints(db, apiKeySecret)
3321
backfillResellerMarkupColumns(drv)
3422
migrateAccountState(drv)
3523
migrateUserHistoryRefs(drv)
36-
runStartupMigrations(drv)
24+
runSystemUpgrades(drv)
3725
slog.Info("bootstrap_startup_tasks_done")
3826
}
3927

40-
type startupMigration struct {
41-
ID string
42-
Description string
43-
Checksum string
44-
SQL string
45-
}
46-
47-
func runStartupMigrations(drv *entsql.Driver) {
48-
if drv == nil {
49-
return
50-
}
51-
migrations := loadStartupMigrations()
52-
if len(migrations) == 0 {
53-
return
54-
}
55-
56-
ctx := context.Background()
57-
conn, err := drv.DB().Conn(ctx)
58-
if err != nil {
59-
panicStartupMigration("open startup migration connection", err)
60-
}
61-
defer func() { _ = conn.Close() }()
62-
63-
const createTableSQL = `CREATE TABLE IF NOT EXISTS public.bootstrap_migrations (
64-
id text PRIMARY KEY,
65-
description text NOT NULL DEFAULT '',
66-
checksum text NOT NULL DEFAULT '',
67-
applied_at timestamptz NOT NULL DEFAULT now(),
68-
duration_ms bigint NOT NULL DEFAULT 0
69-
)`
70-
if _, err := conn.ExecContext(ctx, createTableSQL); err != nil {
71-
panicStartupMigration("create bootstrap_migrations table", err)
72-
}
73-
if _, err := conn.ExecContext(ctx, `ALTER TABLE public.bootstrap_migrations ADD COLUMN IF NOT EXISTS checksum text NOT NULL DEFAULT ''`); err != nil {
74-
panicStartupMigration("add bootstrap_migrations checksum column", err)
75-
}
76-
77-
const lockKey int64 = 2026052809517
78-
if _, err := conn.ExecContext(ctx, `SELECT pg_advisory_lock($1)`, lockKey); err != nil {
79-
panicStartupMigration("lock startup migrations", err)
80-
}
81-
defer func() {
82-
if _, err := conn.ExecContext(context.Background(), `SELECT pg_advisory_unlock($1)`, lockKey); err != nil {
83-
slog.Warn("bootstrap_migration_unlock_failed", sdk.LogFieldError, err)
84-
}
85-
}()
86-
87-
for _, migration := range migrations {
88-
var appliedChecksum stdsql.NullString
89-
const appliedSQL = `SELECT checksum FROM public.bootstrap_migrations WHERE id = $1`
90-
err := conn.QueryRowContext(ctx, appliedSQL, migration.ID).Scan(&appliedChecksum)
91-
if err == nil {
92-
if appliedChecksum.Valid && appliedChecksum.String != "" && appliedChecksum.String != migration.Checksum {
93-
panicStartupMigration("verify startup migration checksum "+migration.ID, fmt.Errorf("recorded=%s current=%s", appliedChecksum.String, migration.Checksum))
94-
}
95-
if !appliedChecksum.Valid || appliedChecksum.String == "" {
96-
const updateSQL = `UPDATE public.bootstrap_migrations
97-
SET checksum = $2, description = $3
98-
WHERE id = $1 AND checksum = ''`
99-
if _, err := conn.ExecContext(ctx, updateSQL, migration.ID, migration.Checksum, migration.Description); err != nil {
100-
panicStartupMigration("backfill startup migration checksum "+migration.ID, err)
101-
}
102-
}
103-
continue
104-
}
105-
if err != stdsql.ErrNoRows {
106-
panicStartupMigration("check startup migration "+migration.ID, err)
107-
}
108-
109-
start := time.Now()
110-
slog.Info("bootstrap_migration_start", "id", migration.ID)
111-
if err := executeStartupMigrationSQL(ctx, conn, migration); err != nil {
112-
panicStartupMigration("run startup migration "+migration.ID, err)
113-
}
114-
duration := time.Since(start).Milliseconds()
115-
const insertSQL = `INSERT INTO public.bootstrap_migrations (id, description, checksum, duration_ms)
116-
VALUES ($1, $2, $3, $4)
117-
ON CONFLICT (id) DO NOTHING`
118-
if _, err := conn.ExecContext(ctx, insertSQL, migration.ID, migration.Description, migration.Checksum, duration); err != nil {
119-
panicStartupMigration("record startup migration "+migration.ID, err)
120-
}
121-
slog.Info("bootstrap_migration_done", "id", migration.ID, "duration_ms", duration)
122-
}
123-
}
124-
125-
func loadStartupMigrations() []startupMigration {
126-
entries, err := fs.ReadDir(startupMigrationFiles, "migrations")
127-
if err != nil {
128-
panicStartupMigration("read startup migration files", err)
129-
}
130-
sort.Slice(entries, func(i, j int) bool {
131-
return entries[i].Name() < entries[j].Name()
132-
})
133-
134-
migrations := make([]startupMigration, 0, len(entries))
135-
for _, entry := range entries {
136-
if entry.IsDir() || filepath.Ext(entry.Name()) != ".sql" {
137-
continue
138-
}
139-
path := filepath.ToSlash(filepath.Join("migrations", entry.Name()))
140-
data, err := startupMigrationFiles.ReadFile(path)
141-
if err != nil {
142-
panicStartupMigration("read startup migration "+entry.Name(), err)
143-
}
144-
hash := sha256.Sum256(data)
145-
id := strings.TrimSuffix(entry.Name(), ".sql")
146-
sql := string(data)
147-
migrations = append(migrations, startupMigration{
148-
ID: id,
149-
Description: startupMigrationDescription(sql, id),
150-
Checksum: hex.EncodeToString(hash[:]),
151-
SQL: sql,
152-
})
153-
}
154-
return migrations
155-
}
156-
157-
func startupMigrationDescription(sql, fallback string) string {
158-
for _, line := range strings.Split(sql, "\n") {
159-
line = strings.TrimSpace(line)
160-
if strings.HasPrefix(line, "-- description:") {
161-
return strings.TrimSpace(strings.TrimPrefix(line, "-- description:"))
162-
}
163-
}
164-
return fallback
165-
}
166-
167-
func executeStartupMigrationSQL(ctx context.Context, conn *stdsql.Conn, migration startupMigration) error {
168-
for _, stmt := range splitSQLStatements(migration.SQL) {
169-
if _, err := conn.ExecContext(ctx, stmt); err != nil {
170-
return fmt.Errorf("execute statement in %s: %w", migration.ID, err)
171-
}
172-
}
173-
return nil
174-
}
175-
176-
func splitSQLStatements(sql string) []string {
177-
var statements []string
178-
start := 0
179-
inSingleQuote := false
180-
inDoubleQuote := false
181-
inLineComment := false
182-
inBlockComment := false
183-
dollarTag := ""
184-
185-
for i := 0; i < len(sql); i++ {
186-
ch := sql[i]
187-
next := byte(0)
188-
if i+1 < len(sql) {
189-
next = sql[i+1]
190-
}
191-
192-
switch {
193-
case inLineComment:
194-
if ch == '\n' {
195-
inLineComment = false
196-
}
197-
continue
198-
case inBlockComment:
199-
if ch == '*' && next == '/' {
200-
inBlockComment = false
201-
i++
202-
}
203-
continue
204-
case dollarTag != "":
205-
if strings.HasPrefix(sql[i:], dollarTag) {
206-
i += len(dollarTag) - 1
207-
dollarTag = ""
208-
}
209-
continue
210-
case inSingleQuote:
211-
if ch == '\'' {
212-
if next == '\'' {
213-
i++
214-
} else {
215-
inSingleQuote = false
216-
}
217-
}
218-
continue
219-
case inDoubleQuote:
220-
if ch == '"' {
221-
if next == '"' {
222-
i++
223-
} else {
224-
inDoubleQuote = false
225-
}
226-
}
227-
continue
228-
}
229-
230-
if ch == '-' && next == '-' {
231-
inLineComment = true
232-
i++
233-
continue
234-
}
235-
if ch == '/' && next == '*' {
236-
inBlockComment = true
237-
i++
238-
continue
239-
}
240-
if ch == '\'' {
241-
inSingleQuote = true
242-
continue
243-
}
244-
if ch == '"' {
245-
inDoubleQuote = true
246-
continue
247-
}
248-
if ch == '$' {
249-
if tag, ok := sqlDollarTag(sql[i:]); ok {
250-
dollarTag = tag
251-
i += len(tag) - 1
252-
continue
253-
}
254-
}
255-
if ch == ';' {
256-
stmt := strings.TrimSpace(sql[start:i])
257-
if stmt != "" {
258-
statements = append(statements, stmt)
259-
}
260-
start = i + 1
261-
}
262-
}
263-
264-
tail := strings.TrimSpace(sql[start:])
265-
if tail != "" {
266-
statements = append(statements, tail)
267-
}
268-
return statements
269-
}
270-
271-
func sqlDollarTag(sql string) (string, bool) {
272-
if sql == "" || sql[0] != '$' {
273-
return "", false
274-
}
275-
for i := 1; i < len(sql); i++ {
276-
ch := sql[i]
277-
if ch == '$' {
278-
return sql[:i+1], true
279-
}
280-
if !((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_') {
281-
return "", false
282-
}
283-
}
284-
return "", false
285-
}
286-
287-
func panicStartupMigration(action string, err error) {
288-
if err != nil {
289-
err = fmt.Errorf("%s: %w", action, err)
290-
} else {
291-
err = fmt.Errorf("%s", action)
292-
}
293-
slog.Error("bootstrap_startup_migration_failed", sdk.LogFieldError, err)
294-
panic(err)
295-
}
296-
29728
// migrateUserHistoryRefs 允许硬删除用户,同时保留历史使用记录和余额流水。
29829
// 用量/计费聚合依赖 usage_logs 的成本快照字段;这里把历史表的 user 外键改为 SET NULL,
29930
// 并回填 user_id/user_email 快照,避免删除用户后历史记录丢失归属信息。

0 commit comments

Comments
 (0)