diff --git a/backend/internal/infrastructure/ent.go b/backend/internal/infrastructure/ent.go index 0e15c471..13184a83 100644 --- a/backend/internal/infrastructure/ent.go +++ b/backend/internal/infrastructure/ent.go @@ -5,6 +5,7 @@ package infrastructure import ( "context" "database/sql" + "time" "github.com/Wei-Shaw/sub2api/ent" "github.com/Wei-Shaw/sub2api/internal/config" @@ -54,7 +55,9 @@ func InitEnt(cfg *config.Config) (*ent.Client, *sql.DB, error) { // 确保数据库 schema 已准备就绪。 // SQL 迁移文件是 schema 的权威来源(source of truth)。 // 这种方式比 Ent 的自动迁移更可控,支持复杂的迁移场景。 - if err := applyMigrationsFS(context.Background(), drv.DB(), migrations.FS); err != nil { + migrationCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + if err := applyMigrationsFS(migrationCtx, drv.DB(), migrations.FS); err != nil { _ = drv.Close() // 迁移失败时关闭驱动,避免资源泄露 return nil, nil, err } diff --git a/backend/internal/infrastructure/migrations_runner.go b/backend/internal/infrastructure/migrations_runner.go index 69919a19..8477c031 100644 --- a/backend/internal/infrastructure/migrations_runner.go +++ b/backend/internal/infrastructure/migrations_runner.go @@ -10,6 +10,7 @@ import ( "io/fs" "sort" "strings" + "time" "github.com/Wei-Shaw/sub2api/migrations" ) @@ -31,6 +32,7 @@ CREATE TABLE IF NOT EXISTS schema_migrations ( // 在多实例部署场景下,该锁确保同一时间只有一个实例执行迁移。 // 任何稳定的 int64 值都可以,只要不与同一数据库中的其他锁冲突即可。 const migrationsAdvisoryLockID int64 = 694208311321144027 +const migrationsLockRetryInterval = 500 * time.Millisecond // ApplyMigrations 将嵌入的 SQL 迁移文件应用到指定的数据库。 // @@ -166,11 +168,23 @@ func applyMigrationsFS(ctx context.Context, db *sql.DB, fsys fs.FS) error { // Advisory Lock 是一种轻量级的锁机制,不与任何特定的数据库对象关联。 // 它非常适合用于应用层面的分布式锁场景,如迁移序列化。 func pgAdvisoryLock(ctx context.Context, db *sql.DB) error { - _, err := db.ExecContext(ctx, "SELECT pg_advisory_lock($1)", migrationsAdvisoryLockID) - if err != nil { - return fmt.Errorf("acquire migrations lock: %w", err) + ticker := time.NewTicker(migrationsLockRetryInterval) + defer ticker.Stop() + + for { + var locked bool + if err := db.QueryRowContext(ctx, "SELECT pg_try_advisory_lock($1)", migrationsAdvisoryLockID).Scan(&locked); err != nil { + return fmt.Errorf("acquire migrations lock: %w", err) + } + if locked { + return nil + } + select { + case <-ctx.Done(): + return fmt.Errorf("acquire migrations lock: %w", ctx.Err()) + case <-ticker.C: + } } - return nil } // pgAdvisoryUnlock 释放 PostgreSQL Advisory Lock。 diff --git a/backend/internal/repository/usage_log_repo.go b/backend/internal/repository/usage_log_repo.go index 246285cf..5939c827 100644 --- a/backend/internal/repository/usage_log_repo.go +++ b/backend/internal/repository/usage_log_repo.go @@ -70,9 +70,6 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog) } rateMultiplier := log.RateMultiplier - if rateMultiplier == 0 { - rateMultiplier = 1 - } query := ` INSERT INTO usage_logs ( diff --git a/backend/internal/setup/setup.go b/backend/internal/setup/setup.go index 759b930c..5565ab91 100644 --- a/backend/internal/setup/setup.go +++ b/backend/internal/setup/setup.go @@ -260,7 +260,9 @@ func initializeDatabase(cfg *SetupConfig) error { } }() - return infrastructure.ApplyMigrations(context.Background(), db) + migrationCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + return infrastructure.ApplyMigrations(migrationCtx, db) } func createAdminUser(cfg *SetupConfig) error { diff --git a/backend/migrations/008_seed_default_group.sql b/backend/migrations/008_seed_default_group.sql new file mode 100644 index 00000000..44522152 --- /dev/null +++ b/backend/migrations/008_seed_default_group.sql @@ -0,0 +1,4 @@ +-- Seed a default group for fresh installs. +INSERT INTO groups (name, description) +SELECT 'default', 'Default group' +WHERE NOT EXISTS (SELECT 1 FROM groups);