修复MySQL部署迁移中断重入并更新实库验收进度
This commit is contained in:
parent
dbcdf05b3e
commit
c6982ab827
@ -84,6 +84,9 @@ func applyMigration(ctx context.Context, db *sql.DB, driver string, version int)
|
|||||||
return applySQLiteTaskMigration(ctx, db, statements, version)
|
return applySQLiteTaskMigration(ctx, db, statements, version)
|
||||||
}
|
}
|
||||||
if driver == "mysql" {
|
if driver == "mysql" {
|
||||||
|
if version >= 15 && version <= 17 {
|
||||||
|
return applyMySQLDeploymentMigration(ctx, db, version, statements)
|
||||||
|
}
|
||||||
if version == 14 {
|
if version == 14 {
|
||||||
if err := installMySQLProjectMembers(ctx, db); err != nil {
|
if err := installMySQLProjectMembers(ctx, db); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
215
backend/internal/database/mysql_deployment_migration.go
Normal file
215
backend/internal/database/mysql_deployment_migration.go
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MySQL 的建表语句会单独提交,恢复时必须校验结构,不能直接吞掉同名表错误。
|
||||||
|
func applyMySQLDeploymentMigration(ctx context.Context, db *sql.DB, version int, statements []string) error {
|
||||||
|
if len(statements) == 0 {
|
||||||
|
return fmt.Errorf("部署迁移缺少建表语句:%d", version)
|
||||||
|
}
|
||||||
|
shape, err := parseMySQLDeploymentShape(statements[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
create := strings.Replace(statements[0], "CREATE TABLE ", "CREATE TABLE IF NOT EXISTS ", 1)
|
||||||
|
if _, err := db.ExecContext(ctx, create); err != nil {
|
||||||
|
return fmt.Errorf("创建部署迁移表失败:%w", err)
|
||||||
|
}
|
||||||
|
if err := validateMySQLDeploymentShape(ctx, db, shape); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tx, err := db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
if version == 15 {
|
||||||
|
if err := backfillMySQLTaskIdempotency(ctx, tx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, "INSERT INTO schema_migrations (version) VALUES (?)", version); err != nil {
|
||||||
|
return fmt.Errorf("记录部署迁移版本失败:%w", err)
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
type mysqlDeploymentColumn struct {
|
||||||
|
Name string
|
||||||
|
Type string
|
||||||
|
Default string
|
||||||
|
HasDefault bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type mysqlDeploymentShape struct {
|
||||||
|
Table string
|
||||||
|
Columns []mysqlDeploymentColumn
|
||||||
|
Indexes []string
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
mysqlDeploymentTablePattern = regexp.MustCompile(`^CREATE TABLE ([a-z_]+) \($`)
|
||||||
|
mysqlDeploymentColumnPattern = regexp.MustCompile(`^([a-z_]+) (VARCHAR\([0-9]+\)|INT|TEXT|DATETIME) NOT NULL(?: DEFAULT (CURRENT_TIMESTAMP|0))?( PRIMARY KEY)?$`)
|
||||||
|
mysqlDeploymentIndexPattern = regexp.MustCompile(`^(PRIMARY KEY|UNIQUE KEY ([a-z_]+)|INDEX ([a-z_]+)) \(([a-z_, ]+)\)$`)
|
||||||
|
mysqlIntegerWidthPattern = regexp.MustCompile(`^int\([0-9]+\)$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 只解析这三份内嵌迁移使用的语法;出现未知定义时拒绝迁移,避免漏检结构。
|
||||||
|
func parseMySQLDeploymentShape(statement string) (mysqlDeploymentShape, error) {
|
||||||
|
var shape mysqlDeploymentShape
|
||||||
|
lines := strings.Split(strings.TrimSpace(statement), "\n")
|
||||||
|
if len(lines) < 3 {
|
||||||
|
return shape, fmt.Errorf("部署迁移表定义不完整")
|
||||||
|
}
|
||||||
|
match := mysqlDeploymentTablePattern.FindStringSubmatch(strings.TrimSpace(lines[0]))
|
||||||
|
if len(match) != 2 || strings.TrimSpace(lines[len(lines)-1]) != ")" {
|
||||||
|
return shape, fmt.Errorf("部署迁移表定义不受支持")
|
||||||
|
}
|
||||||
|
shape.Table = match[1]
|
||||||
|
for _, raw := range lines[1 : len(lines)-1] {
|
||||||
|
line := strings.TrimSuffix(strings.TrimSpace(raw), ",")
|
||||||
|
if parts := mysqlDeploymentColumnPattern.FindStringSubmatch(line); len(parts) == 5 {
|
||||||
|
shape.Columns = append(shape.Columns, mysqlDeploymentColumn{parts[1], strings.ToLower(parts[2]), strings.ToLower(parts[3]), parts[3] != ""})
|
||||||
|
if parts[4] != "" {
|
||||||
|
shape.Indexes = append(shape.Indexes, "PRIMARY:0:1:"+parts[1])
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := mysqlDeploymentIndexPattern.FindStringSubmatch(line)
|
||||||
|
if len(parts) != 5 {
|
||||||
|
return shape, fmt.Errorf("部署迁移表包含不受支持的定义:%s", shape.Table)
|
||||||
|
}
|
||||||
|
name, nonUnique := "PRIMARY", 0
|
||||||
|
if parts[2] != "" {
|
||||||
|
name = parts[2]
|
||||||
|
} else if parts[3] != "" {
|
||||||
|
name, nonUnique = parts[3], 1
|
||||||
|
}
|
||||||
|
for index, column := range strings.Split(parts[4], ",") {
|
||||||
|
shape.Indexes = append(shape.Indexes, fmt.Sprintf("%s:%d:%d:%s", name, nonUnique, index+1, strings.TrimSpace(column)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(shape.Indexes)
|
||||||
|
return shape, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateMySQLDeploymentShape(ctx context.Context, db *sql.DB, expected mysqlDeploymentShape) error {
|
||||||
|
var tableCollation, schemaCollation string
|
||||||
|
if err := db.QueryRowContext(ctx, `SELECT t.table_collation, s.default_collation_name
|
||||||
|
FROM information_schema.tables t JOIN information_schema.schemata s ON t.table_schema = s.schema_name
|
||||||
|
WHERE t.table_schema = DATABASE() AND t.table_name = ?`, expected.Table).Scan(&tableCollation, &schemaCollation); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tableCollation != schemaCollation {
|
||||||
|
return fmt.Errorf("部署迁移表排序规则与数据库默认定义不匹配:%s", expected.Table)
|
||||||
|
}
|
||||||
|
var engine, tableType string
|
||||||
|
if err := db.QueryRowContext(ctx, `SELECT ENGINE, TABLE_TYPE FROM information_schema.tables
|
||||||
|
WHERE table_schema = DATABASE() AND table_name = ?`, expected.Table).Scan(&engine, &tableType); err != nil {
|
||||||
|
return fmt.Errorf("读取部署迁移表状态失败:%w", err)
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(engine, "InnoDB") || tableType != "BASE TABLE" {
|
||||||
|
return fmt.Errorf("部署迁移表必须是 InnoDB 基础表:%s", expected.Table)
|
||||||
|
}
|
||||||
|
rows, err := db.QueryContext(ctx, `SELECT column_name, column_type, column_default, is_nullable, extra, collation_name
|
||||||
|
FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ? ORDER BY ordinal_position`, expected.Table)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var actual []mysqlDeploymentColumn
|
||||||
|
for rows.Next() {
|
||||||
|
var column mysqlDeploymentColumn
|
||||||
|
var defaultValue sql.NullString
|
||||||
|
var columnCollation sql.NullString
|
||||||
|
var nullable, extra string
|
||||||
|
if err := rows.Scan(&column.Name, &column.Type, &defaultValue, &nullable, &extra, &columnCollation); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
column.Type = strings.ToLower(column.Type)
|
||||||
|
if mysqlIntegerWidthPattern.MatchString(column.Type) {
|
||||||
|
column.Type = "int"
|
||||||
|
}
|
||||||
|
column.Default = strings.TrimSuffix(strings.ToLower(defaultValue.String), "()")
|
||||||
|
column.HasDefault = defaultValue.Valid
|
||||||
|
if columnCollation.Valid && columnCollation.String != tableCollation {
|
||||||
|
rows.Close()
|
||||||
|
return fmt.Errorf("部署迁移表字段排序规则不匹配:%s.%s", expected.Table, column.Name)
|
||||||
|
}
|
||||||
|
if nullable != "NO" || (extra != "" && !(strings.EqualFold(extra, "DEFAULT_GENERATED") && column.Default == "current_timestamp")) {
|
||||||
|
rows.Close()
|
||||||
|
return fmt.Errorf("部署迁移表字段属性不匹配:%s.%s", expected.Table, column.Name)
|
||||||
|
}
|
||||||
|
actual = append(actual, column)
|
||||||
|
}
|
||||||
|
err = rows.Err()
|
||||||
|
rows.Close()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(actual, expected.Columns) {
|
||||||
|
return fmt.Errorf("部署迁移表字段定义不匹配:%s,拒绝跳过或覆盖现有数据", expected.Table)
|
||||||
|
}
|
||||||
|
rows, err = db.QueryContext(ctx, `SELECT index_name, non_unique, seq_in_index, column_name, sub_part, index_type, collation
|
||||||
|
FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = ?`, expected.Table)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var indexes []string
|
||||||
|
for rows.Next() {
|
||||||
|
var name, column, indexType, collation string
|
||||||
|
var nonUnique, position int
|
||||||
|
var prefix sql.NullInt64
|
||||||
|
if err := rows.Scan(&name, &nonUnique, &position, &column, &prefix, &indexType, &collation); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if prefix.Valid || indexType != "BTREE" || collation != "A" {
|
||||||
|
rows.Close()
|
||||||
|
return fmt.Errorf("部署迁移表索引属性不匹配:%s.%s", expected.Table, name)
|
||||||
|
}
|
||||||
|
indexes = append(indexes, fmt.Sprintf("%s:%d:%d:%s", name, nonUnique, position, column))
|
||||||
|
}
|
||||||
|
err = rows.Err()
|
||||||
|
rows.Close()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sort.Strings(indexes)
|
||||||
|
if !reflect.DeepEqual(indexes, expected.Indexes) {
|
||||||
|
return fmt.Errorf("部署迁移表索引定义不匹配:%s,拒绝跳过或覆盖现有数据", expected.Table)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回填与版本标记同事务提交;已落盘的历史回填只有内容完全一致才可复用。
|
||||||
|
func backfillMySQLTaskIdempotency(ctx context.Context, tx *sql.Tx) error {
|
||||||
|
const expected = `SELECT domain_id, idempotency_key, MIN(id) AS task_id, MIN(created_at) AS created_at
|
||||||
|
FROM tasks WHERE idempotency_key <> '' GROUP BY domain_id, idempotency_key`
|
||||||
|
var conflicts int
|
||||||
|
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM task_idempotency AS actual
|
||||||
|
LEFT JOIN (`+expected+`) AS expected ON actual.domain_id = expected.domain_id AND actual.idempotency_key = expected.idempotency_key
|
||||||
|
WHERE expected.task_id IS NULL OR BINARY actual.task_id <> BINARY expected.task_id OR actual.created_at <> expected.created_at
|
||||||
|
OR BINARY actual.domain_id <> BINARY expected.domain_id OR BINARY actual.idempotency_key <> BINARY expected.idempotency_key`).Scan(&conflicts); err != nil {
|
||||||
|
return fmt.Errorf("检查任务幂等历史回填失败:%w", err)
|
||||||
|
}
|
||||||
|
if conflicts != 0 {
|
||||||
|
return fmt.Errorf("任务幂等历史回填与已有数据冲突,拒绝覆盖,需核查后重试")
|
||||||
|
}
|
||||||
|
_, err := tx.ExecContext(ctx, `INSERT INTO task_idempotency (domain_id, idempotency_key, task_id, created_at)
|
||||||
|
SELECT expected.domain_id, expected.idempotency_key, expected.task_id, expected.created_at FROM (`+expected+`) AS expected
|
||||||
|
LEFT JOIN task_idempotency AS actual ON actual.domain_id = expected.domain_id AND actual.idempotency_key = expected.idempotency_key
|
||||||
|
WHERE actual.domain_id IS NULL`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("回填任务幂等历史失败:%w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
157
backend/internal/database/mysql_deployment_migration_test.go
Normal file
157
backend/internal/database/mysql_deployment_migration_test.go
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMySQLDeploymentShapeDefinitions(t *testing.T) {
|
||||||
|
for version := 15; version <= 17; version++ {
|
||||||
|
statements := mysqlDeploymentStatements(t, version)
|
||||||
|
shape, err := parseMySQLDeploymentShape(statements[0])
|
||||||
|
if err != nil || len(shape.Columns) == 0 || len(shape.Indexes) == 0 {
|
||||||
|
t.Fatalf("内嵌迁移定义必须完整解析:%d:%v", version, err)
|
||||||
|
}
|
||||||
|
for _, invalid := range []string{
|
||||||
|
strings.Replace(statements[0], "NOT NULL", "NULL", 1),
|
||||||
|
strings.Replace(statements[0], "VARCHAR", "CHAR", 1),
|
||||||
|
strings.Replace(statements[0], "CREATE TABLE", "CREATE VIEW", 1),
|
||||||
|
} {
|
||||||
|
if _, err := parseMySQLDeploymentShape(invalid); err == nil {
|
||||||
|
t.Fatal("未知迁移定义不能被静默忽略")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMySQLRealDeploymentMigrationRecovery(t *testing.T) {
|
||||||
|
f := startRealMySQL(t)
|
||||||
|
baseline := f.database(t)
|
||||||
|
if err := Migrate(t.Context(), baseline.DB, "mysql"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
expectedSchema := mysqlSchemaSnapshot(t, baseline.DB)
|
||||||
|
for _, boundary := range []string{"before_create", "after_create", "partial_backfill", "after_backfill"} {
|
||||||
|
t.Run(boundary, func(t *testing.T) {
|
||||||
|
db := f.database(t)
|
||||||
|
mysqlPrepareVersion(t, db.DB, 14)
|
||||||
|
seedMySQLIdempotencyTasks(t, db.DB)
|
||||||
|
statements := mysqlDeploymentStatements(t, 15)
|
||||||
|
if boundary != "before_create" {
|
||||||
|
mysqlDeploymentExec(t, db.DB, statements[0])
|
||||||
|
}
|
||||||
|
switch boundary {
|
||||||
|
case "partial_backfill":
|
||||||
|
mysqlDeploymentExec(t, db.DB, `INSERT INTO task_idempotency VALUES ('domain-a','request-a','task-a','2026-01-01 00:00:00')`)
|
||||||
|
case "after_backfill":
|
||||||
|
mysqlDeploymentExec(t, db.DB, statements[1])
|
||||||
|
}
|
||||||
|
mysqlAssertUpgrade(t, db.DB, expectedSchema)
|
||||||
|
var count int
|
||||||
|
if err := db.DB.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM task_idempotency").Scan(&count); err != nil || count != 3 {
|
||||||
|
t.Fatalf("幂等回填必须去重、跨域隔离并排除空键:%d:%v", count, err)
|
||||||
|
}
|
||||||
|
var task, created string
|
||||||
|
if err := db.DB.QueryRowContext(t.Context(), `SELECT task_id, DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') FROM task_idempotency WHERE domain_id='domain-a' AND idempotency_key='request-a'`).Scan(&task, &created); err != nil || task != "task-a" || created != "2026-01-01 00:00:00" {
|
||||||
|
t.Fatalf("历史代表任务及最早创建时间必须保留:%s:%s:%v", task, created, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, invalid := range []struct {
|
||||||
|
name string
|
||||||
|
statement string
|
||||||
|
}{
|
||||||
|
{"wrong_task", `INSERT INTO task_idempotency VALUES ('domain-a','request-a','task-z','2026-01-01 00:00:00')`},
|
||||||
|
{"wrong_task_case", `INSERT INTO task_idempotency VALUES ('domain-a','request-a','TASK-A','2026-01-01 00:00:00')`},
|
||||||
|
{"wrong_time", `INSERT INTO task_idempotency VALUES ('domain-a','request-a','task-a','2026-01-02 00:00:00')`},
|
||||||
|
{"orphan_mapping", `INSERT INTO task_idempotency VALUES ('unknown','unknown','unknown','2026-01-01 00:00:00')`},
|
||||||
|
} {
|
||||||
|
t.Run(invalid.name, func(t *testing.T) {
|
||||||
|
db := f.database(t)
|
||||||
|
mysqlPrepareVersion(t, db.DB, 14)
|
||||||
|
seedMySQLIdempotencyTasks(t, db.DB)
|
||||||
|
mysqlDeploymentExec(t, db.DB, mysqlDeploymentStatements(t, 15)[0])
|
||||||
|
mysqlDeploymentExec(t, db.DB, invalid.statement)
|
||||||
|
var before string
|
||||||
|
query := `SELECT CONCAT(domain_id, ':', idempotency_key, ':', task_id, ':', created_at) FROM task_idempotency`
|
||||||
|
if err := db.DB.QueryRowContext(t.Context(), query).Scan(&before); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assertMySQLDeploymentMigrationRejected(t, db.DB, 14)
|
||||||
|
var after string
|
||||||
|
if err := db.DB.QueryRowContext(t.Context(), query).Scan(&after); err != nil || after != before {
|
||||||
|
t.Fatalf("拒绝恢复不得修改冲突数据:%v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, invalid := range []struct {
|
||||||
|
name string
|
||||||
|
version int
|
||||||
|
statement string
|
||||||
|
}{
|
||||||
|
{"wrong_column", 15, "ALTER TABLE task_idempotency MODIFY COLUMN task_id VARCHAR(64) NOT NULL"},
|
||||||
|
{"empty_string_default", 15, "ALTER TABLE task_idempotency ALTER COLUMN task_id SET DEFAULT ''"},
|
||||||
|
{"wrong_collation", 15, "ALTER TABLE task_idempotency MODIFY COLUMN task_id VARCHAR(36) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL"},
|
||||||
|
{"nullable_column", 15, "ALTER TABLE task_idempotency MODIFY COLUMN task_id VARCHAR(36) NULL"},
|
||||||
|
{"missing_unique", 15, "ALTER TABLE task_idempotency DROP INDEX uq_task_idempotency_task"},
|
||||||
|
{"wrong_default", 16, "ALTER TABLE task_deployment_results ALTER COLUMN attempt_number SET DEFAULT 7"},
|
||||||
|
{"missing_index", 16, "ALTER TABLE task_deployment_results DROP INDEX idx_task_deployment_results_task_id"},
|
||||||
|
{"wrong_engine", 17, "ALTER TABLE deployment_transactions ENGINE=MyISAM"},
|
||||||
|
{"extra_column", 17, "ALTER TABLE deployment_transactions ADD COLUMN unexpected INT NOT NULL DEFAULT 0"},
|
||||||
|
{"wrong_index_order", 17, "ALTER TABLE deployment_transactions DROP INDEX idx_deployment_transactions_status, ADD INDEX idx_deployment_transactions_status (updated_at, status)"},
|
||||||
|
} {
|
||||||
|
t.Run(invalid.name, func(t *testing.T) {
|
||||||
|
db := f.database(t)
|
||||||
|
mysqlPrepareVersion(t, db.DB, invalid.version-1)
|
||||||
|
mysqlDeploymentExec(t, db.DB, mysqlDeploymentStatements(t, invalid.version)[0])
|
||||||
|
mysqlDeploymentExec(t, db.DB, invalid.statement)
|
||||||
|
before := fmt.Sprint(mysqlSchemaSnapshot(t, db.DB))
|
||||||
|
assertMySQLDeploymentMigrationRejected(t, db.DB, invalid.version-1)
|
||||||
|
if after := fmt.Sprint(mysqlSchemaSnapshot(t, db.DB)); after != before {
|
||||||
|
t.Fatal("拒绝恢复不得擅自修复或覆盖已有结构")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mysqlDeploymentStatements(t *testing.T, version int) []string {
|
||||||
|
t.Helper()
|
||||||
|
content, err := migrationFiles.ReadFile(fmt.Sprintf("migrations/mysql/%04d_%s.sql", version, migrationName(version)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return migrationStatements(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mysqlDeploymentExec(t *testing.T, db *sql.DB, statement string) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := db.ExecContext(t.Context(), statement); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedMySQLIdempotencyTasks(t *testing.T, db *sql.DB) {
|
||||||
|
t.Helper()
|
||||||
|
mysqlDeploymentExec(t, db, `INSERT INTO tasks (id, domain_id, task_type, status, message, idempotency_key, created_at) VALUES
|
||||||
|
('task-z','domain-a','deploy','failed','','request-a','2026-01-01 00:00:00'),
|
||||||
|
('task-a','domain-a','deploy','success','','request-a','2026-01-02 00:00:00'),
|
||||||
|
('task-b','domain-a','deploy','success','','request-b','2026-01-03 00:00:00'),
|
||||||
|
('task-c','domain-b','deploy','success','','request-a','2026-01-04 00:00:00'),
|
||||||
|
('task-empty','domain-a','deploy','success','','','2026-01-05 00:00:00')`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertMySQLDeploymentMigrationRejected(t *testing.T, db *sql.DB, expectedVersion int) {
|
||||||
|
t.Helper()
|
||||||
|
for repeat := 0; repeat < 2; repeat++ {
|
||||||
|
if err := Migrate(t.Context(), db, "mysql"); err == nil {
|
||||||
|
t.Fatal("结构或历史冲突时必须拒绝迁移")
|
||||||
|
}
|
||||||
|
version, err := currentMigrationVersion(t.Context(), db)
|
||||||
|
if err != nil || version != expectedVersion {
|
||||||
|
t.Fatalf("拒绝恢复不得提前写入版本:%d:%v", version, err)
|
||||||
|
}
|
||||||
|
mysqlAssertPreserved(t, db)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1293,3 +1293,34 @@ git diff --check
|
|||||||
- [!] 第一台该测试域名公网 HTTP 有明确拦截页面,公网 HTTPS 失败,公网访问验收不通过;第二台管理及测试站点正常。
|
- [!] 第一台该测试域名公网 HTTP 有明确拦截页面,公网 HTTPS 失败,公网访问验收不通过;第二台管理及测试站点正常。
|
||||||
- [~] 仅同证书重部署,其他负向窗口、新旧证书切换和历史证据缺失事务尚未全部闭环。
|
- [~] 仅同证书重部署,其他负向窗口、新旧证书切换和历史证据缺失事务尚未全部闭环。
|
||||||
- [ ] 最终清库首次流程及其他原清单生产门禁未完成;不宣称整体生产部署验收通过。
|
- [ ] 最终清库首次流程及其他原清单生产门禁未完成;不宣称整体生产部署验收通过。
|
||||||
|
|
||||||
|
## 数据库实机矩阵与新阻塞(2026-10-01 17:45,北京时间)
|
||||||
|
|
||||||
|
### 范围与环境
|
||||||
|
|
||||||
|
- 沿用第二台 Linux 测试机,正式运行主程序仍为 302111c 对应产物,SHA-256 为 393ae5a1cfaf8e4442ec30e47950f020e6ab47adb47af7fdca45a36eb9486ed2。
|
||||||
|
- 测试使用项目原有 Go 集成测试及正式 CLI,不使用新建辅助脚本。非 root 测试进程由 systemd 临时单元以 mysql 用户启动,隔离数据库仅监听回环地址。
|
||||||
|
- 测试根目录 /var/tmp/scert-db-matrix-20261001-1720;MySQL 数据根目录 /var/lib/mysql-files/scert-20261001。保留 AppArmor 策略,不放宽 /scert 权限。
|
||||||
|
- 未重建或清空业务库,未额外备份业务数据;当前不是最终清库首次部署验收。
|
||||||
|
|
||||||
|
### 已结束结果
|
||||||
|
|
||||||
|
| 测试 | 单元或日志 | 结果 |
|
||||||
|
|---|---|---|
|
||||||
|
| 原 MySQL 数据库矩阵 | scert-mysql-matrix-20261001-1722 / mysql-database.log | 退出 1;模式 15 中断后 1、2 条语句及模式 16、17 建表后中断,共四个边界因表已存在失败 |
|
||||||
|
| MySQL 8.0.46 仓储 | scert-mysql-repository-20261001-1732 / mysql-repository.log | 退出 0;9 个主测试、92 个子场景通过,无失败或跳过 |
|
||||||
|
| PostgreSQL 14.24 首次尝试 | scert-postgres-matrix-20261001-1723 / postgres-cli.log | 七场景均因备份工具版本不满足要求失败;不计通过 |
|
||||||
|
| PostgreSQL 16.15 | scert-postgres16-matrix-20261001-1727 / postgres16-cli.log | 退出 0;七场景全部通过,无跳过;总耗时 14.52 秒 |
|
||||||
|
| SQLite 旧断言 | scert-sqlite-matrix-20261001-1726 / sqlite-cli.log | 失败,测试将当前迁移版本写死为 14,实际已为 18 |
|
||||||
|
| SQLite 修正断言及历史样本 | scert-sqlite-history-20261001-1740 / sqlite-history-cli.log | 退出 0;historical_backup、upgraded_backup 全部通过;总耗时 0.79 秒 |
|
||||||
|
|
||||||
|
PostgreSQL 的 success、certificate、permission、inventory、authentication、tls 使用独立正式 CLI;cancel 使用进程内确定性取消和真实工具回退,仅据实记录该范围。16.15 工具位于 /var/tmp/scert-pg16-tools-20261001/extracted,包来自经官方公钥签名验证的独立 PGDG 索引。测试后无残留 PostgreSQL 服务进程。
|
||||||
|
|
||||||
|
SQLite 新测试程序 SHA-256:4c81656fae63a2eebad999d58e6a79e63e6f441810e02ce29b2a9ce3706ae7f6;上传后核对一致。新增样本覆盖 task_idempotency、task_attempts、task_deployment_results、deployment_transactions 及 recovery_evidence_json;恢复前实际修改样本,恢复后全表快照核验。
|
||||||
|
|
||||||
|
### 当前修复与边界
|
||||||
|
|
||||||
|
- [~] 原 MySQL 15~17 迁移使用非事务建表,已落盘后再次执行报表已存在。不能简单忽略冲突:模式 15 的历史幂等回填还需避免重复写入,同时拒绝内容不一致的数据。
|
||||||
|
- [~] 正在实测可重入建表、字段和索引校验、回填与版本标记同事务提交,以及部分回填、冲突数据、异常结构负向场景。待后续结果更新,不能根据运行中的单元 Result=success 提前判定通过。
|
||||||
|
- [✓] 双机端口复核符合 Nginx 80/443 反代要求;Scert 与 MySQL 均未新增公网监听。
|
||||||
|
- [!] 第一台测试域名公网平台拦截、无可信证据的历史事务及此前剩余生产门禁继续保留,不以数据库子项通过替代。
|
||||||
|
|||||||
@ -1577,3 +1577,17 @@ P0-05 从 `[!]` 调整为 `[~]`,并未标记完成;P0-04 剩余门禁和全
|
|||||||
- [ ] T4-3 与 T7 剩余生产门禁:最终清库首次流程、系统 SMTP、超级管理员密码重置、数据库矩阵、备份恢复与升级回滚等按原清单继续执行。
|
- [ ] T4-3 与 T7 剩余生产门禁:最终清库首次流程、系统 SMTP、超级管理员密码重置、数据库矩阵、备份恢复与升级回滚等按原清单继续执行。
|
||||||
|
|
||||||
本轮是保留故障证据的连续复测,不是清库首次初始化验收;没有创建辅助验收脚本,没有额外制作远端数据备份。整体生产验收仍未放行。
|
本轮是保留故障证据的连续复测,不是清库首次初始化验收;没有创建辅助验收脚本,没有额外制作远端数据备份。整体生产验收仍未放行。
|
||||||
|
|
||||||
|
## 最新有效数据库整改进度(2026-10-01 17:45,北京时间)
|
||||||
|
|
||||||
|
本节沿用已确认需求并补充最新进度;前文各日期记录保留为历史证据,不再以旧版模式 14 推断当前代码状态。当前迁移最高版本为 18;只处理 Linux,不将本地交叉编译视作 Linux 原生构建。
|
||||||
|
|
||||||
|
- [✓] SQLite 正式 CLI 子项:修正写死模式 14 的过时断言,检查内嵌源码迁移序号及数据库历史连续性;增加幂等表、任务尝试、部署结果、部署事务与恢复证据的非空恢复样本。第二台 Linux 的历史备份、升级后备份两个恢复子场景通过,进程退出 0。测试修正提交 dbcdf05 已推送。
|
||||||
|
- [✓] MySQL 8.0.46 仓储子项:第二台隔离实库的 9 个主测试、92 个子场景全部通过,无失败或跳过;涵盖项目、成员及资源相关仓储行为,不等同于完整 Web 验收。
|
||||||
|
- [✓] PostgreSQL 16.15 备份恢复子项:success、certificate、cancel、permission、inventory、authentication、tls 七场景通过,进程退出 0。六场景使用独立正式 CLI;cancel 是进程内确定性取消加真实数据库回退,不冒充独立 CLI 取消验收。
|
||||||
|
- [!] PostgreSQL 14.24 首次尝试:因工具版本低于当前备份入口要求而失败,保留失败记录;之后改用经官方签名索引验证的隔离 16.15 工具,不修改系统 APT 源、不安装全局 PostgreSQL 服务。
|
||||||
|
- [~] MySQL 中断恢复修复:原有实测发现模式 15~17 的四个中断边界因表已存在而失败。正在加入结构校验、可重入建表、事务内幂等回填与版本记录,并补充部分回填、数据冲突、字段和索引异常负向测试;新版实库回归尚在运行,不能提前标记通过。
|
||||||
|
- [✓] 网络边界只读复核:双机 Scert 仍只监听 127.0.0.1:8443,MySQL 只监听 127.0.0.1:3306;Web 由 Nginx 80/443 提供。没有增加公网服务端口。
|
||||||
|
- [ ] 待完成:MySQL 修复后的全量门禁、正式构建及双机部署;MySQL 5.7、本轮三库完整 Web 流程、系统发信及密码重置、备份恢复剩余矩阵、最终清库首次部署等。
|
||||||
|
|
||||||
|
本轮各实库测试创建自己的隔离数据库,未清空现有业务数据库;没有创建辅助验收脚本、没有额外备份远端业务数据。整体生产验收仍未放行。
|
||||||
|
|||||||
@ -2206,3 +2206,17 @@ P0-11 生产验收门禁
|
|||||||
- [ ] 最终清库首次生产流程、Smail 系统 SMTP 与超级管理员密码重置、三数据库完整矩阵、备份恢复及升级回滚等仍须按原清单闭环。
|
- [ ] 最终清库首次生产流程、Smail 系统 SMTP 与超级管理员密码重置、三数据库完整矩阵、备份恢复及升级回滚等仍须按原清单闭环。
|
||||||
|
|
||||||
结论:本轮关闭“真实进程崩溃恢复并复用原任务重试”子项,整体生产验收仍未通过,不给缺乏逐项统计依据的完成百分比。当前不再用 P0-03 单项状态代指全部生产阻塞。测试服务器和域名只是测试资源,不作为产品验收限制。
|
结论:本轮关闭“真实进程崩溃恢复并复用原任务重试”子项,整体生产验收仍未通过,不给缺乏逐项统计依据的完成百分比。当前不再用 P0-03 单项状态代指全部生产阻塞。测试服务器和域名只是测试资源,不作为产品验收限制。
|
||||||
|
|
||||||
|
## 最新有效数据库整改进度(2026-10-01 17:45,北京时间)
|
||||||
|
|
||||||
|
本节沿用已确认需求并补充最新进度;前文各日期记录保留为历史证据,不再以旧版模式 14 推断当前代码状态。当前迁移最高版本为 18;只处理 Linux,不将本地交叉编译视作 Linux 原生构建。
|
||||||
|
|
||||||
|
- [✓] SQLite 正式 CLI 子项:修正写死模式 14 的过时断言,检查内嵌源码迁移序号及数据库历史连续性;增加幂等表、任务尝试、部署结果、部署事务与恢复证据的非空恢复样本。第二台 Linux 的历史备份、升级后备份两个恢复子场景通过,进程退出 0。测试修正提交 dbcdf05 已推送。
|
||||||
|
- [✓] MySQL 8.0.46 仓储子项:第二台隔离实库的 9 个主测试、92 个子场景全部通过,无失败或跳过;涵盖项目、成员及资源相关仓储行为,不等同于完整 Web 验收。
|
||||||
|
- [✓] PostgreSQL 16.15 备份恢复子项:success、certificate、cancel、permission、inventory、authentication、tls 七场景通过,进程退出 0。六场景使用独立正式 CLI;cancel 是进程内确定性取消加真实数据库回退,不冒充独立 CLI 取消验收。
|
||||||
|
- [!] PostgreSQL 14.24 首次尝试:因工具版本低于当前备份入口要求而失败,保留失败记录;之后改用经官方签名索引验证的隔离 16.15 工具,不修改系统 APT 源、不安装全局 PostgreSQL 服务。
|
||||||
|
- [~] MySQL 中断恢复修复:原有实测发现模式 15~17 的四个中断边界因表已存在而失败。正在加入结构校验、可重入建表、事务内幂等回填与版本记录,并补充部分回填、数据冲突、字段和索引异常负向测试;新版实库回归尚在运行,不能提前标记通过。
|
||||||
|
- [✓] 网络边界只读复核:双机 Scert 仍只监听 127.0.0.1:8443,MySQL 只监听 127.0.0.1:3306;Web 由 Nginx 80/443 提供。没有增加公网服务端口。
|
||||||
|
- [ ] 待完成:MySQL 修复后的全量门禁、正式构建及双机部署;MySQL 5.7、本轮三库完整 Web 流程、系统发信及密码重置、备份恢复剩余矩阵、最终清库首次部署等。
|
||||||
|
|
||||||
|
本轮各实库测试创建自己的隔离数据库,未清空现有业务数据库;没有创建辅助验收脚本、没有额外备份远端业务数据。整体生产验收仍未放行。
|
||||||
|
|||||||
@ -1341,3 +1341,17 @@ go test -p 1 -count=1 -json -timeout=6m ./internal/bootstrap
|
|||||||
| Windows 服务 | 已有隔离构建与启动证据,实机服务验收尚未完成 | 待复审 |
|
| Windows 服务 | 已有隔离构建与启动证据,实机服务验收尚未完成 | 待复审 |
|
||||||
|
|
||||||
`odacm.cn` 当前能够返回 Web 首页 HTTP `200`,但本机 DNS 解析为 `198.18.0.109`,与两台测试服务器不一致。因此不能将该结果作为公网 DNS、HTTPS/SNI 或 ACME 通过证据。数据库类型支持声明仍以完整矩阵和生产环境证据为准,当前总体验收不得升级。
|
`odacm.cn` 当前能够返回 Web 首页 HTTP `200`,但本机 DNS 解析为 `198.18.0.109`,与两台测试服务器不一致。因此不能将该结果作为公网 DNS、HTTPS/SNI 或 ACME 通过证据。数据库类型支持声明仍以完整矩阵和生产环境证据为准,当前总体验收不得升级。
|
||||||
|
|
||||||
|
## 最新有效数据库整改进度(2026-10-01 17:45,北京时间)
|
||||||
|
|
||||||
|
本节沿用已确认需求并补充最新进度;前文各日期记录保留为历史证据,不再以旧版模式 14 推断当前代码状态。当前迁移最高版本为 18;只处理 Linux,不将本地交叉编译视作 Linux 原生构建。
|
||||||
|
|
||||||
|
- [✓] SQLite 正式 CLI 子项:修正写死模式 14 的过时断言,检查内嵌源码迁移序号及数据库历史连续性;增加幂等表、任务尝试、部署结果、部署事务与恢复证据的非空恢复样本。第二台 Linux 的历史备份、升级后备份两个恢复子场景通过,进程退出 0。测试修正提交 dbcdf05 已推送。
|
||||||
|
- [✓] MySQL 8.0.46 仓储子项:第二台隔离实库的 9 个主测试、92 个子场景全部通过,无失败或跳过;涵盖项目、成员及资源相关仓储行为,不等同于完整 Web 验收。
|
||||||
|
- [✓] PostgreSQL 16.15 备份恢复子项:success、certificate、cancel、permission、inventory、authentication、tls 七场景通过,进程退出 0。六场景使用独立正式 CLI;cancel 是进程内确定性取消加真实数据库回退,不冒充独立 CLI 取消验收。
|
||||||
|
- [!] PostgreSQL 14.24 首次尝试:因工具版本低于当前备份入口要求而失败,保留失败记录;之后改用经官方签名索引验证的隔离 16.15 工具,不修改系统 APT 源、不安装全局 PostgreSQL 服务。
|
||||||
|
- [~] MySQL 中断恢复修复:原有实测发现模式 15~17 的四个中断边界因表已存在而失败。正在加入结构校验、可重入建表、事务内幂等回填与版本记录,并补充部分回填、数据冲突、字段和索引异常负向测试;新版实库回归尚在运行,不能提前标记通过。
|
||||||
|
- [✓] 网络边界只读复核:双机 Scert 仍只监听 127.0.0.1:8443,MySQL 只监听 127.0.0.1:3306;Web 由 Nginx 80/443 提供。没有增加公网服务端口。
|
||||||
|
- [ ] 待完成:MySQL 修复后的全量门禁、正式构建及双机部署;MySQL 5.7、本轮三库完整 Web 流程、系统发信及密码重置、备份恢复剩余矩阵、最终清库首次部署等。
|
||||||
|
|
||||||
|
本轮各实库测试创建自己的隔离数据库,未清空现有业务数据库;没有创建辅助验收脚本、没有额外备份远端业务数据。整体生产验收仍未放行。
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user