From c6982ab827038c2cb16bc1432fb393f68a5296e2 Mon Sep 17 00:00:00 2001 From: xuans Date: Thu, 1 Oct 2026 17:48:18 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8DMySQL=E9=83=A8=E7=BD=B2?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=E4=B8=AD=E6=96=AD=E9=87=8D=E5=85=A5=E5=B9=B6?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=AE=9E=E5=BA=93=E9=AA=8C=E6=94=B6=E8=BF=9B?= =?UTF-8?q?=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/database/migrate.go | 3 + .../database/mysql_deployment_migration.go | 215 ++++++++++++++++++ .../mysql_deployment_migration_test.go | 157 +++++++++++++ doc/Linux生产流程测试记录.md | 31 +++ doc/Scert整改任务执行清单.md | 14 ++ doc/Scert整改合并优化清单.md | 14 ++ doc/构建版本与数据库支持矩阵.md | 14 ++ 7 files changed, 448 insertions(+) create mode 100644 backend/internal/database/mysql_deployment_migration.go create mode 100644 backend/internal/database/mysql_deployment_migration_test.go diff --git a/backend/internal/database/migrate.go b/backend/internal/database/migrate.go index 0e6e8e3..797dd1e 100644 --- a/backend/internal/database/migrate.go +++ b/backend/internal/database/migrate.go @@ -84,6 +84,9 @@ func applyMigration(ctx context.Context, db *sql.DB, driver string, version int) return applySQLiteTaskMigration(ctx, db, statements, version) } if driver == "mysql" { + if version >= 15 && version <= 17 { + return applyMySQLDeploymentMigration(ctx, db, version, statements) + } if version == 14 { if err := installMySQLProjectMembers(ctx, db); err != nil { return err diff --git a/backend/internal/database/mysql_deployment_migration.go b/backend/internal/database/mysql_deployment_migration.go new file mode 100644 index 0000000..82160cf --- /dev/null +++ b/backend/internal/database/mysql_deployment_migration.go @@ -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 +} diff --git a/backend/internal/database/mysql_deployment_migration_test.go b/backend/internal/database/mysql_deployment_migration_test.go new file mode 100644 index 0000000..6991c70 --- /dev/null +++ b/backend/internal/database/mysql_deployment_migration_test.go @@ -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) + } +} diff --git a/doc/Linux生产流程测试记录.md b/doc/Linux生产流程测试记录.md index 1aa18c1..f9b590f 100644 --- a/doc/Linux生产流程测试记录.md +++ b/doc/Linux生产流程测试记录.md @@ -1293,3 +1293,34 @@ git diff --check - [!] 第一台该测试域名公网 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 均未新增公网监听。 +- [!] 第一台测试域名公网平台拦截、无可信证据的历史事务及此前剩余生产门禁继续保留,不以数据库子项通过替代。 diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index f8518c2..01cfb50 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -1577,3 +1577,17 @@ P0-05 从 `[!]` 调整为 `[~]`,并未标记完成;P0-04 剩余门禁和全 - [ ] 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 流程、系统发信及密码重置、备份恢复剩余矩阵、最终清库首次部署等。 + +本轮各实库测试创建自己的隔离数据库,未清空现有业务数据库;没有创建辅助验收脚本、没有额外备份远端业务数据。整体生产验收仍未放行。 diff --git a/doc/Scert整改合并优化清单.md b/doc/Scert整改合并优化清单.md index b9100f6..b00c72d 100644 --- a/doc/Scert整改合并优化清单.md +++ b/doc/Scert整改合并优化清单.md @@ -2206,3 +2206,17 @@ P0-11 生产验收门禁 - [ ] 最终清库首次生产流程、Smail 系统 SMTP 与超级管理员密码重置、三数据库完整矩阵、备份恢复及升级回滚等仍须按原清单闭环。 结论:本轮关闭“真实进程崩溃恢复并复用原任务重试”子项,整体生产验收仍未通过,不给缺乏逐项统计依据的完成百分比。当前不再用 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 流程、系统发信及密码重置、备份恢复剩余矩阵、最终清库首次部署等。 + +本轮各实库测试创建自己的隔离数据库,未清空现有业务数据库;没有创建辅助验收脚本、没有额外备份远端业务数据。整体生产验收仍未放行。 diff --git a/doc/构建版本与数据库支持矩阵.md b/doc/构建版本与数据库支持矩阵.md index f7a7e70..e1cd426 100644 --- a/doc/构建版本与数据库支持矩阵.md +++ b/doc/构建版本与数据库支持矩阵.md @@ -1341,3 +1341,17 @@ go test -p 1 -count=1 -json -timeout=6m ./internal/bootstrap | Windows 服务 | 已有隔离构建与启动证据,实机服务验收尚未完成 | 待复审 | `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 流程、系统发信及密码重置、备份恢复剩余矩阵、最终清库首次部署等。 + +本轮各实库测试创建自己的隔离数据库,未清空现有业务数据库;没有创建辅助验收脚本、没有额外备份远端业务数据。整体生产验收仍未放行。