package database import ( "context" "database/sql" "embed" "fmt" "regexp" "strings" ) //go:embed migrations/sqlite/0001_initial.sql migrations/sqlite/0002_domain_options.sql migrations/sqlite/0003_deployment.sql migrations/sqlite/0004_task_attempts.sql migrations/sqlite/0005_users.sql migrations/sqlite/0006_server_validation.sql migrations/sqlite/0007_server_password_auth.sql migrations/sqlite/0009_server_ssh_key_status.sql migrations/mysql/0001_initial.sql migrations/mysql/0002_domain_options.sql migrations/mysql/0003_deployment.sql migrations/mysql/0004_task_attempts.sql migrations/mysql/0005_users.sql migrations/mysql/0006_server_validation.sql migrations/mysql/0007_server_password_auth.sql migrations/mysql/0009_server_ssh_key_status.sql migrations/postgres/0001_initial.sql migrations/postgres/0002_domain_options.sql migrations/postgres/0003_deployment.sql migrations/postgres/0004_task_attempts.sql migrations/postgres/0005_users.sql migrations/postgres/0006_server_validation.sql migrations/postgres/0007_server_password_auth.sql migrations/postgres/0009_server_ssh_key_status.sql //go:embed migrations/sqlite/0008_user_groups.sql migrations/mysql/0008_user_groups.sql migrations/postgres/0008_user_groups.sql //go:embed migrations/sqlite/0010_projects.sql migrations/mysql/0010_projects.sql migrations/postgres/0010_projects.sql //go:embed migrations/sqlite/0011_resource_projects.sql migrations/mysql/0011_resource_projects.sql migrations/postgres/0011_resource_projects.sql //go:embed migrations/sqlite/0012_resource_replacement.sql migrations/mysql/0012_resource_replacement.sql migrations/postgres/0012_resource_replacement.sql //go:embed migrations/sqlite/0013_history_scope.sql migrations/mysql/0013_history_scope.sql migrations/postgres/0013_history_scope.sql //go:embed migrations/sqlite/0014_project_members.sql migrations/mysql/0014_project_members.sql migrations/postgres/0014_project_members.sql //go:embed migrations/sqlite/0015_task_idempotency.sql migrations/mysql/0015_task_idempotency.sql migrations/postgres/0015_task_idempotency.sql //go:embed migrations/sqlite/0016_task_deployment_results.sql migrations/mysql/0016_task_deployment_results.sql migrations/postgres/0016_task_deployment_results.sql //go:embed migrations/sqlite/0017_deployment_transactions.sql migrations/mysql/0017_deployment_transactions.sql migrations/postgres/0017_deployment_transactions.sql //go:embed migrations/sqlite/0018_recovery_evidence.sql migrations/mysql/0018_recovery_evidence.sql migrations/postgres/0018_recovery_evidence.sql migrations/sqlite/0019_password_auth_only.sql migrations/mysql/0019_password_auth_only.sql migrations/postgres/0019_password_auth_only.sql //go:embed migrations/sqlite/0020_task_deployment_reports.sql migrations/mysql/0020_task_deployment_reports.sql migrations/postgres/0020_task_deployment_reports.sql //go:embed migrations/sqlite/0021_task_validation_results.sql migrations/mysql/0021_task_validation_results.sql migrations/postgres/0021_task_validation_results.sql //go:embed migrations/sqlite/0022_recovery_dispositions.sql migrations/mysql/0022_recovery_dispositions.sql migrations/postgres/0022_recovery_dispositions.sql //go:embed migrations/sqlite/0023_recovery_claims.sql migrations/mysql/0023_recovery_claims.sql migrations/postgres/0023_recovery_claims.sql //go:embed migrations/sqlite/0024_recovery_events.sql migrations/mysql/0024_recovery_events.sql migrations/postgres/0024_recovery_events.sql //go:embed migrations/sqlite/0025_recovery_reconciliation.sql migrations/mysql/0025_recovery_reconciliation.sql migrations/postgres/0025_recovery_reconciliation.sql //go:embed migrations/sqlite/0026_domain_challenge.sql migrations/mysql/0026_domain_challenge.sql migrations/postgres/0026_domain_challenge.sql //go:embed migrations/sqlite/0027_dns_accounts.sql migrations/mysql/0027_dns_accounts.sql migrations/postgres/0027_dns_accounts.sql var migrationFiles embed.FS func Migrate(ctx context.Context, db *sql.DB, driver string) error { if db == nil { return fmt.Errorf("数据库连接不能为空") } if driver != "sqlite" && driver != "mysql" && driver != "postgres" { return fmt.Errorf("数据库驱动无效:%s", driver) } if _, err := db.ExecContext(ctx, migrationTableSQL(driver)); err != nil { return fmt.Errorf("创建迁移表失败:%w", err) } version, err := currentMigrationVersion(ctx, db) if err != nil { return err } for nextVersion := version + 1; nextVersion <= latestMigrationVersion; nextVersion++ { if err := applyMigration(ctx, db, driver, nextVersion); err != nil { return err } } return nil } const latestMigrationVersion = 27 // currentMigrationVersion 在执行业务迁移前拒绝新版数据库及不连续的迁移历史。 func currentMigrationVersion(ctx context.Context, db *sql.DB) (int, error) { rows, err := db.QueryContext(ctx, "SELECT version FROM schema_migrations ORDER BY version") if err != nil { return 0, fmt.Errorf("读取迁移版本失败:%w", err) } defer rows.Close() current := 0 for rows.Next() { var version int if err := rows.Scan(&version); err != nil { return 0, fmt.Errorf("读取迁移版本失败:%w", err) } if version > latestMigrationVersion { return 0, fmt.Errorf("数据库版本 %d 高于当前程序支持的版本 %d,拒绝启动;请使用兼容版本程序,不要删除迁移记录", version, latestMigrationVersion) } if version != current+1 { return 0, fmt.Errorf("数据库迁移历史不连续:期望版本 %d,实际版本 %d,拒绝继续迁移", current+1, version) } current = version } if err := rows.Err(); err != nil { return 0, fmt.Errorf("读取迁移版本失败:%w", err) } return current, nil } func applyMigration(ctx context.Context, db *sql.DB, driver string, version int) error { path := fmt.Sprintf("migrations/%s/%04d_%s.sql", driver, version, migrationName(version)) content, err := migrationFiles.ReadFile(path) if err != nil { return fmt.Errorf("读取迁移文件失败:%w", err) } statements := migrationStatements(content) if driver == "sqlite" && version == 4 { return applySQLiteTaskMigration(ctx, db, statements, version) } if driver == "mysql" { if version == 27 { return applyMySQLDNSAccountMigration(ctx, db, version, statements) } if (version >= 15 && version <= 17) || (version >= 20 && version <= 24) { return applyMySQLDeploymentMigration(ctx, db, version, statements) } if version == 14 { if err := installMySQLProjectMembers(ctx, db); err != nil { return err } } for _, statement := range statements { skip, err := shouldSkipMySQLStatement(ctx, db, statement) if err != nil { return fmt.Errorf("检查数据库迁移状态失败:%w", err) } if skip { continue } if _, err := db.ExecContext(ctx, statement); err != nil { return fmt.Errorf("执行数据库迁移失败:%w", err) } } if version == 13 { if err := backfillHistoryScope(ctx, db); err != nil { return err } } if version == 25 { var compatible int if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'recovery_dispositions' AND column_name = 'source_disposition_id' AND data_type = 'varchar' AND character_maximum_length = 64 AND is_nullable = 'NO' AND column_default = ''`).Scan(&compatible); err != nil || compatible != 1 { return fmt.Errorf("人工处置来源字段与预期不一致,拒绝记录迁移版本") } } if version == 26 { var compatible int if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'domains' AND column_name = 'challenge_type' AND data_type = 'varchar' AND character_maximum_length = 16 AND is_nullable = 'NO' AND column_default = 'dns-01'`).Scan(&compatible); err != nil || compatible != 1 { return fmt.Errorf("域名验证方式字段与预期不一致,拒绝记录迁移版本") } } if _, err := db.ExecContext(ctx, "INSERT INTO schema_migrations (version) VALUES (?)", version); err != nil { return fmt.Errorf("记录迁移版本失败:%w", err) } return nil } tx, err := db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("开始迁移事务失败:%w", err) } for _, statement := range statements { if _, err := tx.ExecContext(ctx, statement); err != nil { _ = tx.Rollback() return fmt.Errorf("执行数据库迁移失败:%w", err) } } if driver == "sqlite" && version == 11 { if err := installSQLiteResourceConstraints(ctx, tx); err != nil { _ = tx.Rollback() return err } } if driver == "sqlite" && version == 12 { if err := installSQLiteReplacementGuards(ctx, tx); err != nil { _ = tx.Rollback() return err } } if version == 13 { if err := backfillHistoryScope(ctx, tx); err != nil { _ = tx.Rollback() return err } } if driver == "sqlite" && version == 14 { if err := installSQLiteProjectMemberGuards(ctx, tx); err != nil { _ = tx.Rollback() return err } } if driver == "sqlite" && version == 27 { if err := installSQLiteDNSAccountConstraints(ctx, tx); err != nil { _ = tx.Rollback() return err } } migrationVersionInsert := "INSERT INTO schema_migrations (version) VALUES (?)" if driver == "postgres" { migrationVersionInsert = "INSERT INTO schema_migrations (version) VALUES ($1)" } if _, err := tx.ExecContext(ctx, migrationVersionInsert, version); err != nil { _ = tx.Rollback() return fmt.Errorf("记录迁移版本失败:%w", err) } if err := tx.Commit(); err != nil { return fmt.Errorf("提交数据库迁移失败:%w", err) } return nil } var ( mysqlAddColumnPattern = regexp.MustCompile(`(?is)^ALTER\s+TABLE\s+` + "`?([a-zA-Z0-9_]+)`?" + `\s+ADD\s+COLUMN\s+` + "`?([a-zA-Z0-9_]+)`?") mysqlCreateIndexPattern = regexp.MustCompile(`(?is)^CREATE\s+(?:UNIQUE\s+)?INDEX\s+` + "`?([a-zA-Z0-9_]+)`?" + `\s+ON\s+` + "`?([a-zA-Z0-9_]+)`?") mysqlAddConstraintPattern = regexp.MustCompile(`(?is)^ALTER\s+TABLE\s+` + "`?([a-zA-Z0-9_]+)`?" + `\s+ADD\s+CONSTRAINT\s+` + "`?([a-zA-Z0-9_]+)`?") ) // shouldSkipMySQLStatement 检查可恢复迁移中的字段和索引是否已经存在。 // MySQL 5.7 不支持 ADD COLUMN IF NOT EXISTS,因此需要在执行前查询元数据。 func shouldSkipMySQLStatement(ctx context.Context, db *sql.DB, statement string) (bool, error) { statement = strings.TrimSpace(statement) if matches := mysqlAddConstraintPattern.FindStringSubmatch(statement); len(matches) == 3 { var count int err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.table_constraints WHERE constraint_schema = DATABASE() AND table_name = ? AND constraint_name = ?`, matches[1], matches[2]).Scan(&count) return count > 0, err } if matches := mysqlAddColumnPattern.FindStringSubmatch(statement); len(matches) == 3 { var count int err := db.QueryRowContext(ctx, ` SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?`, matches[1], matches[2]).Scan(&count) return count > 0, err } if matches := mysqlCreateIndexPattern.FindStringSubmatch(statement); len(matches) == 3 { var count int err := db.QueryRowContext(ctx, ` SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = ? AND index_name = ?`, matches[2], matches[1]).Scan(&count) return count > 0, err } return false, nil } func migrationName(version int) string { switch version { case 1: return "initial" case 2: return "domain_options" case 3: return "deployment" case 4: return "task_attempts" case 5: return "users" case 6: return "server_validation" case 7: return "server_password_auth" case 8: return "user_groups" case 9: return "server_ssh_key_status" case 10: return "projects" case 11: return "resource_projects" case 12: return "resource_replacement" case 13: return "history_scope" case 14: return "project_members" case 15: return "task_idempotency" case 16: return "task_deployment_results" case 17: return "deployment_transactions" case 18: return "recovery_evidence" case 19: return "password_auth_only" case 20: return "task_deployment_reports" case 21: return "task_validation_results" case 22: return "recovery_dispositions" case 23: return "recovery_claims" case 24: return "recovery_events" case 25: return "recovery_reconciliation" case 26: return "domain_challenge" case 27: return "dns_accounts" default: return "unknown" } } func migrationStatements(content []byte) []string { statements := make([]string, 0) // 只移除文件头的编码标记,不能替换 SQL 字面量中的相同字符。 script := strings.TrimPrefix(string(content), "\ufeff") for _, statement := range strings.Split(script, ";") { statement = strings.TrimSpace(statement) if statement != "" { statements = append(statements, statement) } } return statements } func migrationTableSQL(driver string) string { if driver == "mysql" { return `CREATE TABLE IF NOT EXISTS schema_migrations ( version INT NOT NULL PRIMARY KEY, applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP )` } if driver == "postgres" { return `CREATE TABLE IF NOT EXISTS schema_migrations ( version INTEGER NOT NULL PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP )` } return `CREATE TABLE IF NOT EXISTS schema_migrations ( version INTEGER NOT NULL PRIMARY KEY, applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP )` }