AutoVersionUpdate假成功彻底修复任务书.md 43 KB

AutoVersionUpdate 假成功彻底修复任务书

适用范围:server/Admin.NET.Core/Update/AutoVersionUpdate.cs 与入口项目 server/Admin.NET.Web.Entry/UpdateScripts/ 版本脚本执行链路
目标读者:后端开发、大语言模型执行代理、测试/发布负责人
编写日期:2026-06-11
任务类型:后端基础设施修复 / 数据库迁移执行可靠性治理


1. 背景与问题定义

当前 Admin.NET 启动阶段通过 UseAutoVersionUpdate() 自动执行入口项目运行目录下的 UpdateScripts/*.sql。该机制负责把版本 SQL 脚本落到数据库中,项目内 S1/S2/S3/S4/S8 等多处表结构、种子数据、MDP 实体登记均依赖此链路。

现状存在“假成功”风险:

  • SQL 文件被读取,日志显示“执行版本 xxx 脚本”。
  • 启动日志最终显示 AutoVersionUpdate 中间件结束。
  • 运行目录 version.txt 被写为 {version}^{datetime}^True。
  • 但数据库中对应表、字段、数据、实体登记并未真实落库,或只有部分语句落库。

本任务要求彻底修复执行与成功判定逻辑,使版本脚本执行链路满足:

  • 未执行的脚本不会被跳过。
  • 脚本失败不会被标记成功。
  • 可追踪每个脚本的执行状态、错误、校验结果。
  • 可从数据库真实状态判断迁移是否完成。
  • 兼容现有 version.txt,但不继续依赖它作为唯一事实来源。

2. 当前源码位置与关键风险

2.1 入口位置

  • server/Admin.NET.Web.Core/Startup.cs

    • Configure() 中调用 app.UseAutoVersionUpdate();
  • server/Admin.NET.Core/Update/AutoVersionUpdate.cs

    • 当前所有自动版本脚本逻辑位于此文件。

2.2 当前核心逻辑

现有逻辑大致为:

  1. 仅 SnowId:WorkerId == 1 的主节点执行。
  2. 读取入口程序集版本:ReflectionHelper.GetEntryAssemblyVersion().ToString(3)。
  3. 读取运行目录 version.txt。
  4. 若 version.txt 不存在或格式不对,视为历史版本为空。
  5. 从运行目录 UpdateScripts/*.sql 读取脚本。
  6. 对每个脚本执行:
    • dbContext.Ado.BeginTran();
    • dbContext.Ado.ExecuteCommand(sql);
    • dbContext.Ado.CommitTran();
    • 未抛异常则写 version.txt = {sqlVersion}^{time}^True。

2.3 已确认风险点

风险 1:首次启动跳过所有历史脚本

当前逻辑中,当 version.txt 不存在时:

if (historyVersion == string.Empty)
{
    LogHelper.Handle("历史版本为空,默认为最新版本,不执行脚本");
    SetEntryAssemblyCurrentVersion(currentVersion, true);
    return app;
}

这会导致新环境首次启动直接标记当前版本已执行,但 UpdateScripts 里的历史脚本全部未跑。

风险 2:当前版本高于最新脚本时跳过未执行脚本

当前逻辑:

if (scriptSqlVersions.All(s => s.Version.CompareTo(currentVersion) < 0))
{
    LogHelper.Handle("不存在当前版本的脚本,只保存当前版本信息,不执行脚本");
    SetEntryAssemblyCurrentVersion(currentVersion, false);
    return app;
}

如果入口项目版本为 1.0.161,而 UpdateScripts 最新是 1.0.158,即使历史版本是 1.0.154,1.0.155 到 1.0.158 也可能被整体跳过。

风险 3:成功判定只看 ExecuteCommand 是否抛异常

没有验证:

  • 表是否存在。
  • 字段是否存在。
  • 种子数据是否写入。
  • UPDATE 是否命中目标行。
  • 脚本中多条语句是否全部执行。
  • 执行库是否为预期库。

风险 4:使用字符串比较版本号

当前使用 string.CompareTo。这对语义版本不安全,例如:

  • 1.0.9 与 1.0.10
  • 1.0.158 与 1.0.99

应改为 System.Version 或等价 SemVer 比较。

风险 5:version.txt 是单点状态,且不在数据库内

version.txt 只在本机运行目录中,无法反映:

  • 多节点真实执行状态。
  • 当前连接数据库是否已迁移。
  • 脚本 checksum 是否变化。
  • 某个版本脚本是否失败后重跑。

风险 6:MySQL DDL 与事务边界不可靠

MySQL ALTER TABLE / CREATE TABLE 等 DDL 通常会隐式提交,不能依赖 BeginTran/CommitTran 保证整文件原子性。


3. 修复目标

3.1 必须达成

  1. version.txt 不存在时,不得跳过历史脚本。
  2. 当前版本高于最新脚本时,仍应执行所有历史版本之后、当前版本之前或等于当前版本的未执行脚本。
  3. 每个脚本必须有数据库内迁移记录。
  4. 脚本失败必须记录失败状态,不得写 version.txt=True。
  5. 成功状态必须基于:
    • SQL 执行无异常;
    • 迁移记录写入成功;
    • 若存在校验规则,校验通过。
  6. 版本比较必须使用语义版本比较。
  7. 日志必须能定位:
    • 执行的脚本文件;
    • 当前数据库;
    • 脚本 checksum;
    • 执行耗时;
    • 失败语句或失败校验。

3.2 建议达成

  1. 支持脚本校验文件或脚本内校验注释。
  2. 支持空脚本、注释脚本跳过但记录。
  3. 支持失败后下次启动自动重试。
  4. 支持重复启动幂等跳过已成功且 checksum 未变化的脚本。
  5. 支持发现同版本脚本 checksum 变化时报错,避免已执行脚本被静默篡改。

4. 设计方案

4.1 新增数据库迁移记录表

建议表名:sys_db_migration_log

DDL 由代码启动时幂等创建,不依赖 UpdateScripts 自己创建。

CREATE TABLE IF NOT EXISTS sys_db_migration_log (
    id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
    version VARCHAR(50) NOT NULL COMMENT '脚本版本',
    file_name VARCHAR(255) NOT NULL COMMENT '脚本文件名',
    file_hash VARCHAR(128) NOT NULL COMMENT '脚本 SHA256',
    status VARCHAR(20) NOT NULL COMMENT 'Running/Success/Failed/Skipped',
    started_at DATETIME(3) NOT NULL COMMENT '开始时间',
    finished_at DATETIME(3) NULL COMMENT '结束时间',
    elapsed_ms BIGINT NULL COMMENT '耗时毫秒',
    statement_count INT NOT NULL DEFAULT 0 COMMENT '执行语句数',
    affected_rows BIGINT NULL COMMENT '影响行数合计',
    verify_status VARCHAR(20) NULL COMMENT '校验状态:NotConfigured/Success/Failed',
    error_message LONGTEXT NULL COMMENT '错误信息',
    database_name VARCHAR(128) NULL COMMENT '执行数据库',
    created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
    updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
    PRIMARY KEY (id),
    UNIQUE KEY uk_sys_db_migration_log_version (version),
    KEY idx_sys_db_migration_log_status (status),
    KEY idx_sys_db_migration_log_started_at (started_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据库版本脚本执行记录';

说明:

  • version 唯一,保持每个版本脚本只成功一次。
  • 如果同版本文件 hash 与历史成功记录不一致,应停止启动并报错。
  • 若历史状态为 Failed,允许重试并覆盖状态或新增 retry 字段。为简化,建议更新同一行。

4.2 版本状态事实来源

优先级:

  1. 数据库表 sys_db_migration_log 中 status='Success' 的版本记录。
  2. 兼容读取 version.txt,仅作为迁移表为空时的旧状态参考。

不得再只依赖 version.txt 判断脚本是否执行。

4.3 脚本选择逻辑

输入:

  • currentVersion:入口程序集版本。
  • scriptVersions:UpdateScripts/*.sql 中所有版本脚本。
  • successVersions:迁移记录表中成功版本。
  • historyVersionFromTxt:旧 version.txt 中记录的版本。

应执行脚本集合:

  1. 脚本版本 <= currentVersion。
  2. 数据库迁移表中不存在 Success 记录。
  3. 若迁移表为空但 version.txt 存在且 isRunScript=True,可将 <= historyVersionFromTxt 的脚本视为 legacy 已成功,但建议只作为兼容策略,并打印警告。
  4. 不要求存在与 currentVersion 同名的 SQL 文件。

伪代码:

var current = Version.Parse(currentVersion);
var scripts = GetScriptSqlVersions()
    .Where(x => Version.Parse(x.Version) <= current)
    .OrderBy(x => Version.Parse(x.Version))
    .ToList();

foreach (var script in scripts)
{
    if (MigrationAlreadySucceeded(script))
        continue;

    ExecuteOneScript(script);
}

4.4 版本比较

必须新增统一比较方法:

private static Version ParseVersion(string value)
{
    if (!Version.TryParse(value, out var version))
        throw new InvalidOperationException($"非法版本号:{value}");
    return version;
}

若项目后续出现 1.0.158-alpha,再引入 SemVer 库;当前脚本命名均为 x.y.z,System.Version 足够。

4.5 SQL 执行方式

基线方案

保留整文件执行能力,但必须增强结果判断与日志。

var affected = db.Ado.ExecuteCommand(sql);

注意:这仍无法精确定位多语句中的哪条失败,但能先消除“无记录、无校验”的问题。

推荐方案

实现 SQL 脚本分割器,按语句执行:

  • 支持 -- 单行注释。
  • 支持 # 单行注释。
  • 支持 /* ... */ 块注释。
  • 支持字符串中的分号不切分。
  • 支持普通 MySQL SET / PREPARE / EXECUTE / DEALLOCATE / CREATE / ALTER / INSERT / UPDATE。
  • 暂不要求支持 DELIMITER 与存储过程;若发现脚本含 DELIMITER,应报错并提示手工执行或扩展执行器。

执行策略:

  • 普通 DML 可在事务中执行。
  • DDL 不依赖事务回滚,失败即停。
  • 每条语句执行前后记录序号。
  • 任一语句失败,则该脚本 Failed,不继续后续脚本。

4.6 成功判定

脚本成功必须满足:

  1. 所有待执行 SQL 语句无异常。
  2. 迁移记录表该版本写入 Success。
  3. 若存在校验 SQL,全部校验通过。

version.txt 只在上述三项成功后更新。

4.7 校验机制

建议支持两种方式,至少实现一种。

方式 A:旁路校验文件

同版本脚本旁新增可选文件:

UpdateScripts/1.0.158.sql
UpdateScripts/1.0.158.verify.sql

*.verify.sql 可包含一条或多条 SELECT,每条必须返回第一行第一列为 truthy:

  • 1
  • true
  • 大于 0 的数字

示例:

SELECT COUNT(*) > 0
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
  AND TABLE_NAME = 'dwd_po_trans';

方式 B:脚本内校验注释

在 SQL 文件末尾约定:

-- VERIFY:
-- SELECT COUNT(*) > 0 FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='xxx';

实现复杂度高于方式 A。建议本轮优先实现方式 A。

4.8 日志要求

启动日志至少包含:

  • 当前程序集版本。
  • 当前数据库名:SELECT DATABASE()。
  • 发现脚本数量。
  • 已成功脚本数量。
  • 待执行脚本数量。
  • 每个脚本:
    • 版本;
    • 文件名;
    • SHA256;
    • 语句数;
    • affected rows;
    • verify 状态;
    • elapsed ms。

失败时日志必须包含:

  • 脚本版本;
  • 语句序号;
  • SQL 片段前 300 字符;
  • 异常 message;
  • 内部异常 message。

4.9 启动失败策略

推荐:版本脚本失败应阻止应用继续启动。

理由:

  • 数据库结构与代码版本不匹配时继续启动更危险。
  • 当前“继续启动”导致隐藏问题。

实现:

  • HandleSqlScript 失败后抛出异常。
  • UseAutoVersionUpdate() 不吞异常。

如果项目负责人要求不中断启动,可加配置:

"AutoVersionUpdate": {
  "StopApplicationOnFailure": true
}

默认必须为 true。


5. 具体执行步骤

Step 1:备份当前文件

阅读并理解:

  • server/Admin.NET.Core/Update/AutoVersionUpdate.cs
  • server/Admin.NET.Web.Core/Startup.cs
  • server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj
  • server/Admin.NET.Web.Entry/UpdateScripts/*.sql

不要改动业务插件代码。

Step 2:新增内部模型

在 AutoVersionUpdate.cs 中新增私有模型:

private sealed record MigrationScript(
    string Version,
    Version ParsedVersion,
    string FilePath,
    string FileName,
    string Hash);

private sealed class MigrationLogRow
{
    public long Id { get; set; }
    public string Version { get; set; } = string.Empty;
    public string FileHash { get; set; } = string.Empty;
    public string Status { get; set; } = string.Empty;
}

也可独立文件,但为减少影响范围,建议本轮集中在 AutoVersionUpdate.cs。

Step 3:确保迁移记录表

在执行脚本前调用:

EnsureMigrationLogTable(dbContext);

该方法必须执行 CREATE TABLE IF NOT EXISTS sys_db_migration_log (...)。

Step 4:替换版本比较与脚本筛选

废弃:

string.CompareTo(...)

改为:

Version.Parse(...)

修复以下逻辑:

  • version.txt 为空不再直接返回。
  • 当前版本没有同名脚本不再直接返回。
  • 执行所有 scriptVersion <= currentVersion && !success 的脚本。

Step 5:实现 hash

使用 SHA256:

private static string ComputeSha256(string filePath)
{
    using var sha = SHA256.Create();
    using var stream = File.OpenRead(filePath);
    return Convert.ToHexString(sha.ComputeHash(stream));
}

需要添加:

using System.Security.Cryptography;

Step 6:实现执行前记录 Running

执行脚本前 upsert:

  • 若无记录:插入 Running。
  • 若 Failed:更新为 Running,清空错误。
  • 若 Success 且 hash 相同:跳过。
  • 若 Success 且 hash 不同:抛异常,阻止启动。

Step 7:执行 SQL

优先实现分语句执行。若时间不足,可第一版保留整文件执行,但必须:

  • 写迁移记录;
  • 失败抛异常;
  • 支持 verify;
  • 不再跳过未执行脚本。

推荐接口:

private static ScriptExecuteResult ExecuteSqlScript(ISqlSugarClient db, string sql)

返回:

private sealed record ScriptExecuteResult(int StatementCount, long AffectedRows);

Step 8:执行校验 SQL

寻找同目录:

var verifyPath = Path.ChangeExtension(script.FilePath, ".verify.sql");

注意 Path.ChangeExtension("1.0.158.sql", ".verify.sql") 会得到 1.0.158.verify.sql。

若存在,逐条执行 SELECT 校验。

校验失败:

  • 记录 verify_status='Failed';
  • 记录错误;
  • 抛异常;
  • 不写 version.txt=True。

若不存在:

  • verify_status='NotConfigured'。

Step 9:执行成功后更新迁移记录

成功后更新:

  • status='Success'
  • finished_at
  • elapsed_ms
  • statement_count
  • affected_rows
  • verify_status
  • error_message=NULL
  • database_name=SELECT DATABASE()

Step 10:更新 version.txt

只有当本轮所有应执行脚本成功后,写:

{currentVersion}^{now}^{True}

或者在每个脚本成功后写脚本版本也可,但推荐最终写当前程序集版本,数据库真实状态以 sys_db_migration_log 为准。

Step 11:失败处理

任一脚本失败:

  1. 更新迁移记录 Failed。
  2. 写入错误信息。
  3. 不更新 version.txt=True。
  4. 抛出异常,使启动失败。

Step 12:兼容旧 version.txt

如果迁移表为空但 version.txt 存在:

  • 读取 version.txt。
  • 若 isRunScript=True,可将 <= historyVersion 的脚本视为 legacy 已成功,但不要自动批量插入 Success,避免伪造历史。
  • 日志提示:

    检测到 sys_db_migration_log 为空但 version.txt 存在,将 version.txt 作为 legacy 跳过边界:{historyVersion}
    

更严格方案:

  • 不信任 version.txt,全部重新跑幂等脚本。
  • 但历史脚本不一定全部幂等,风险较大。

本任务建议采用兼容边界方案。


6. 测试方案

6.1 单元/伪单元测试(可手动)

至少覆盖:

  1. 版本排序:
    • 1.0.9 < 1.0.10
    • 1.0.158 < 1.0.161
  2. 当前版本 1.0.161,脚本最新 1.0.158,历史成功到 1.0.154:
    • 应执行 1.0.155、1.0.157、1.0.158 等未成功脚本。
  3. version.txt 不存在:
    • 不得直接跳过所有脚本。
  4. 同版本 hash 变化:
    • 必须报错。

6.2 本地集成测试

测试 A:首次启动不跳过

  1. 备份并删除运行目录 version.txt。
  2. 清空或新建测试库的 sys_db_migration_log。
  3. 放一个测试脚本:

    CREATE TABLE IF NOT EXISTS auto_version_update_test (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    code VARCHAR(50) NOT NULL
    );
    INSERT INTO auto_version_update_test(code)
    SELECT 'OK'
    WHERE NOT EXISTS (SELECT 1 FROM auto_version_update_test WHERE code='OK');
    
  4. 启动后验证:

    SELECT COUNT(*) FROM auto_version_update_test WHERE code='OK';
    SELECT version, status FROM sys_db_migration_log ORDER BY version;
    

测试 B:失败不标成功

添加一个故意失败脚本:

ALTER TABLE not_exists_table ADD COLUMN x INT;

预期:

  • 启动失败。
  • sys_db_migration_log.status='Failed'。
  • version.txt 不写 True 到该版本。

测试 C:verify 失败不标成功

脚本成功但 .verify.sql 返回 false:

SELECT 0;

预期同失败。

测试 D:当前版本高于最新脚本

入口项目版本保持 1.0.161,脚本最新 1.0.158。

构造历史成功到 1.0.154。

预期:

  • 1.0.155 到 1.0.158 中未成功脚本会执行。
  • 不因“没有 1.0.161.sql”而直接返回。

6.3 生产/预发验证 SQL

执行后检查:

SELECT version, file_name, status, verify_status, started_at, finished_at, elapsed_ms
FROM sys_db_migration_log
ORDER BY version;

针对关键版本脚本补充业务校验,例如:

-- 表是否存在
SELECT COUNT(*)
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
  AND TABLE_NAME = '目标表名';

-- 字段是否存在
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
  AND TABLE_NAME = '目标表名'
  AND COLUMN_NAME = '目标字段';

-- 种子数据是否存在
SELECT COUNT(*)
FROM 目标表
WHERE 业务唯一条件;

7. 验收标准

7.1 功能验收

  • 无 version.txt 的新环境启动时,不再跳过历史脚本。
  • 当前程序集版本高于最新脚本时,仍执行所有未成功脚本。
  • 任一脚本执行失败时,启动失败或至少明确失败,不写成功状态。
  • sys_db_migration_log 有每个脚本的状态记录。
  • 成功脚本重启后不重复执行。
  • 已成功脚本被修改 hash 后,启动阻断并提示。
  • 可选 .verify.sql 失败时,脚本不标成功。
  • 日志能定位失败脚本和失败语句。

7.2 回归验收

  • dotnet build server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj 通过。
  • 后端可正常启动。
  • 旧的 UpdateScripts 不因新执行器误拆分而报错。
  • WorkerId 非 1 的节点仍不执行脚本。
  • 现有 version.txt 兼容读取。

8. 风险与注意事项

  1. 历史脚本不一定全部幂等,不要贸然在生产全量重跑所有历史脚本。
  2. 首次引入 sys_db_migration_log 时,要明确如何处理已有环境:
    • 推荐以 version.txt 作为 legacy 边界;
    • 对边界之后的脚本按新机制执行。
  3. MySQL DELIMITER / 存储过程脚本若存在,普通分号拆分器会失败。当前脚本初查主要为普通 DDL/DML 与 PREPARE/EXECUTE,但执行前仍需扫描。
  4. 不要在本任务中顺手改业务脚本内容,除非脚本本身语法导致新执行器无法执行。
  5. 失败后不要手动把 version.txt 改成 True 掩盖问题,应修复脚本或校验。

9. 建议文件改动清单

必改

  • server/Admin.NET.Core/Update/AutoVersionUpdate.cs
    • 重写脚本选择、版本比较、执行记录、成功判定、失败处理。

可选新增

  • server/Admin.NET.Web.Entry/UpdateScripts/*.verify.sql
    • 为关键脚本增加验证。

不建议本任务改

  • 业务插件实体/服务。
  • 前端代码。
  • 现有 SQL 脚本业务内容。

10. 给执行模型的最终指令

请严格按以下顺序执行:

  1. 读取并理解 AutoVersionUpdate.cs 当前实现。
  2. 不修改业务逻辑,先重构版本脚本执行器。
  3. 新增 sys_db_migration_log 幂等建表逻辑。
  4. 改掉 version.txt 不存在时跳过脚本的逻辑。
  5. 改掉“当前版本无同名脚本则跳过全部”的逻辑。
  6. 改用 System.Version 比较版本。
  7. 增加 SHA256 hash 与已成功脚本 hash 校验。
  8. 增加 Running/Success/Failed 迁移记录。
  9. 增加可选 .verify.sql 校验。
  10. 失败时抛异常并阻止启动,不写成功状态。
  11. 构建验证。
  12. 用临时测试脚本或测试库验证首次启动、失败脚本、verify 失败、当前版本高于最新脚本四类场景。

执行完成后输出:

  • 改动文件列表。
  • 核心逻辑说明。
  • 已执行测试。
  • 尚未覆盖的风险。
  • 是否需要为关键历史脚本补 .verify.sql。

11. 推荐后续治理

  1. 对所有重要 UpdateScripts 补充 .verify.sql。
  2. 禁止只依赖 version.txt 作为发布验收依据。
  3. 发布验收模板增加:

    SELECT version, status, verify_status, finished_at
    FROM sys_db_migration_log
    ORDER BY version DESC
    LIMIT 20;
    
  4. 对 DDL 类脚本要求幂等。

  5. 对数据种子脚本要求唯一键或 INSERT ... ON DUPLICATE KEY UPDATE。

  6. 对非幂等脚本必须在脚本头注明风险与回滚方式。

    AutoVersionUpdate 假成功彻底修复任务书

适用范围:server/Admin.NET.Core/Update/AutoVersionUpdate.cs 与入口项目 server/Admin.NET.Web.Entry/UpdateScripts/ 版本脚本执行链路
目标读者:后端开发、大语言模型执行代理、测试/发布负责人
编写日期:2026-06-11
任务类型:后端基础设施修复 / 数据库迁移执行可靠性治理


1. 背景与问题定义

当前 Admin.NET 启动阶段通过 UseAutoVersionUpdate() 自动执行入口项目运行目录下的 UpdateScripts/*.sql。该机制负责把版本 SQL 脚本落到数据库中,项目内 S1/S2/S3/S4/S8 等多处表结构、种子数据、MDP 实体登记均依赖此链路。

现状存在“假成功”风险:

  • SQL 文件被读取,日志显示“执行版本 xxx 脚本”。
  • 启动日志最终显示 AutoVersionUpdate 中间件结束。
  • 运行目录 version.txt 被写为 {version}^{datetime}^True。
  • 但数据库中对应表、字段、数据、实体登记并未真实落库,或只有部分语句落库。

本任务要求彻底修复执行与成功判定逻辑,使版本脚本执行链路满足:

  • 未执行的脚本不会被跳过。
  • 脚本失败不会被标记成功。
  • 可追踪每个脚本的执行状态、错误、校验结果。
  • 可从数据库真实状态判断迁移是否完成。
  • 兼容现有 version.txt,但不继续依赖它作为唯一事实来源。

2. 当前源码位置与关键风险

2.1 入口位置

  • server/Admin.NET.Web.Core/Startup.cs

    • Configure() 中调用 app.UseAutoVersionUpdate();
  • server/Admin.NET.Core/Update/AutoVersionUpdate.cs

    • 当前所有自动版本脚本逻辑位于此文件。

2.2 当前核心逻辑

现有逻辑大致为:

  1. 仅 SnowId:WorkerId == 1 的主节点执行。
  2. 读取入口程序集版本:ReflectionHelper.GetEntryAssemblyVersion().ToString(3)。
  3. 读取运行目录 version.txt。
  4. 若 version.txt 不存在或格式不对,视为历史版本为空。
  5. 从运行目录 UpdateScripts/*.sql 读取脚本。
  6. 对每个脚本执行:
    • dbContext.Ado.BeginTran();
    • dbContext.Ado.ExecuteCommand(sql);
    • dbContext.Ado.CommitTran();
    • 未抛异常则写 version.txt = {sqlVersion}^{time}^True。

2.3 已确认风险点

风险 1:首次启动跳过所有历史脚本

当前逻辑中,当 version.txt 不存在时:

if (historyVersion == string.Empty)
{
    LogHelper.Handle("历史版本为空,默认为最新版本,不执行脚本");
    SetEntryAssemblyCurrentVersion(currentVersion, true);
    return app;
}

这会导致新环境首次启动直接标记当前版本已执行,但 UpdateScripts 里的历史脚本全部未跑。

风险 2:当前版本高于最新脚本时跳过未执行脚本

当前逻辑:

if (scriptSqlVersions.All(s => s.Version.CompareTo(currentVersion) < 0))
{
    LogHelper.Handle("不存在当前版本的脚本,只保存当前版本信息,不执行脚本");
    SetEntryAssemblyCurrentVersion(currentVersion, false);
    return app;
}

如果入口项目版本为 1.0.161,而 UpdateScripts 最新是 1.0.158,即使历史版本是 1.0.154,1.0.155 到 1.0.158 也可能被整体跳过。

风险 3:成功判定只看 ExecuteCommand 是否抛异常

没有验证:

  • 表是否存在。
  • 字段是否存在。
  • 种子数据是否写入。
  • UPDATE 是否命中目标行。
  • 脚本中多条语句是否全部执行。
  • 执行库是否为预期库。

风险 4:使用字符串比较版本号

当前使用 string.CompareTo。这对语义版本不安全,例如:

  • 1.0.9 与 1.0.10
  • 1.0.158 与 1.0.99

应改为 System.Version 或等价 SemVer 比较。

风险 5:version.txt 是单点状态,且不在数据库内

version.txt 只在本机运行目录中,无法反映:

  • 多节点真实执行状态。
  • 当前连接数据库是否已迁移。
  • 脚本 checksum 是否变化。
  • 某个版本脚本是否失败后重跑。

风险 6:MySQL DDL 与事务边界不可靠

MySQL ALTER TABLE / CREATE TABLE 等 DDL 通常会隐式提交,不能依赖 BeginTran/CommitTran 保证整文件原子性。


3. 修复目标

3.1 必须达成

  1. version.txt 不存在时,不得跳过历史脚本。
  2. 当前版本高于最新脚本时,仍应执行所有历史版本之后、当前版本之前或等于当前版本的未执行脚本。
  3. 每个脚本必须有数据库内迁移记录。
  4. 脚本失败必须记录失败状态,不得写 version.txt=True。
  5. 成功状态必须基于:
    • SQL 执行无异常;
    • 迁移记录写入成功;
    • 若存在校验规则,校验通过。
  6. 版本比较必须使用语义版本比较。
  7. 日志必须能定位:
    • 执行的脚本文件;
    • 当前数据库;
    • 脚本 checksum;
    • 执行耗时;
    • 失败语句或失败校验。

3.2 建议达成

  1. 支持脚本校验文件或脚本内校验注释。
  2. 支持空脚本、注释脚本跳过但记录。
  3. 支持失败后下次启动自动重试。
  4. 支持重复启动幂等跳过已成功且 checksum 未变化的脚本。
  5. 支持发现同版本脚本 checksum 变化时报错,避免已执行脚本被静默篡改。

4. 设计方案

4.1 新增数据库迁移记录表

建议表名:sys_db_migration_log

DDL 由代码启动时幂等创建,不依赖 UpdateScripts 自己创建。

CREATE TABLE IF NOT EXISTS sys_db_migration_log (
    id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
    version VARCHAR(50) NOT NULL COMMENT '脚本版本',
    file_name VARCHAR(255) NOT NULL COMMENT '脚本文件名',
    file_hash VARCHAR(128) NOT NULL COMMENT '脚本 SHA256',
    status VARCHAR(20) NOT NULL COMMENT 'Running/Success/Failed/Skipped',
    started_at DATETIME(3) NOT NULL COMMENT '开始时间',
    finished_at DATETIME(3) NULL COMMENT '结束时间',
    elapsed_ms BIGINT NULL COMMENT '耗时毫秒',
    statement_count INT NOT NULL DEFAULT 0 COMMENT '执行语句数',
    affected_rows BIGINT NULL COMMENT '影响行数合计',
    verify_status VARCHAR(20) NULL COMMENT '校验状态:NotConfigured/Success/Failed',
    error_message LONGTEXT NULL COMMENT '错误信息',
    database_name VARCHAR(128) NULL COMMENT '执行数据库',
    created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
    updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
    PRIMARY KEY (id),
    UNIQUE KEY uk_sys_db_migration_log_version (version),
    KEY idx_sys_db_migration_log_status (status),
    KEY idx_sys_db_migration_log_started_at (started_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据库版本脚本执行记录';

说明:

  • version 唯一,保持每个版本脚本只成功一次。
  • 如果同版本文件 hash 与历史成功记录不一致,应停止启动并报错。
  • 若历史状态为 Failed,允许重试并覆盖状态或新增 retry 字段。为简化,建议更新同一行。

4.2 版本状态事实来源

优先级:

  1. 数据库表 sys_db_migration_log 中 status='Success' 的版本记录。
  2. 兼容读取 version.txt,仅作为迁移表为空时的旧状态参考。

不得再只依赖 version.txt 判断脚本是否执行。

4.3 脚本选择逻辑

输入:

  • currentVersion:入口程序集版本。
  • scriptVersions:UpdateScripts/*.sql 中所有版本脚本。
  • successVersions:迁移记录表中成功版本。
  • historyVersionFromTxt:旧 version.txt 中记录的版本。

应执行脚本集合:

  1. 脚本版本 <= currentVersion。
  2. 数据库迁移表中不存在 Success 记录。
  3. 若迁移表为空但 version.txt 存在且 isRunScript=True,可将 <= historyVersionFromTxt 的脚本视为 legacy 已成功,但建议只作为兼容策略,并打印警告。
  4. 不要求存在与 currentVersion 同名的 SQL 文件。

伪代码:

var current = Version.Parse(currentVersion);
var scripts = GetScriptSqlVersions()
    .Where(x => Version.Parse(x.Version) <= current)
    .OrderBy(x => Version.Parse(x.Version))
    .ToList();

foreach (var script in scripts)
{
    if (MigrationAlreadySucceeded(script))
        continue;

    ExecuteOneScript(script);
}

4.4 版本比较

必须新增统一比较方法:

private static Version ParseVersion(string value)
{
    if (!Version.TryParse(value, out var version))
        throw new InvalidOperationException($"非法版本号:{value}");
    return version;
}

若项目后续出现 1.0.158-alpha,再引入 SemVer 库;当前脚本命名均为 x.y.z,System.Version 足够。

4.5 SQL 执行方式

基线方案

保留整文件执行能力,但必须增强结果判断与日志。

var affected = db.Ado.ExecuteCommand(sql);

注意:这仍无法精确定位多语句中的哪条失败,但能先消除“无记录、无校验”的问题。

推荐方案

实现 SQL 脚本分割器,按语句执行:

  • 支持 -- 单行注释。
  • 支持 # 单行注释。
  • 支持 /* ... */ 块注释。
  • 支持字符串中的分号不切分。
  • 支持普通 MySQL SET / PREPARE / EXECUTE / DEALLOCATE / CREATE / ALTER / INSERT / UPDATE。
  • 暂不要求支持 DELIMITER 与存储过程;若发现脚本含 DELIMITER,应报错并提示手工执行或扩展执行器。

执行策略:

  • 普通 DML 可在事务中执行。
  • DDL 不依赖事务回滚,失败即停。
  • 每条语句执行前后记录序号。
  • 任一语句失败,则该脚本 Failed,不继续后续脚本。

4.6 成功判定

脚本成功必须满足:

  1. 所有待执行 SQL 语句无异常。
  2. 迁移记录表该版本写入 Success。
  3. 若存在校验 SQL,全部校验通过。

version.txt 只在上述三项成功后更新。

4.7 校验机制

建议支持两种方式,至少实现一种。

方式 A:旁路校验文件

同版本脚本旁新增可选文件:

UpdateScripts/1.0.158.sql
UpdateScripts/1.0.158.verify.sql

*.verify.sql 可包含一条或多条 SELECT,每条必须返回第一行第一列为 truthy:

  • 1
  • true
  • 大于 0 的数字

示例:

SELECT COUNT(*) > 0
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
  AND TABLE_NAME = 'dwd_po_trans';

方式 B:脚本内校验注释

在 SQL 文件末尾约定:

-- VERIFY:
-- SELECT COUNT(*) > 0 FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='xxx';

实现复杂度高于方式 A。建议本轮优先实现方式 A。

4.8 日志要求

启动日志至少包含:

  • 当前程序集版本。
  • 当前数据库名:SELECT DATABASE()。
  • 发现脚本数量。
  • 已成功脚本数量。
  • 待执行脚本数量。
  • 每个脚本:
    • 版本;
    • 文件名;
    • SHA256;
    • 语句数;
    • affected rows;
    • verify 状态;
    • elapsed ms。

失败时日志必须包含:

  • 脚本版本;
  • 语句序号;
  • SQL 片段前 300 字符;
  • 异常 message;
  • 内部异常 message。

4.9 启动失败策略

推荐:版本脚本失败应阻止应用继续启动。

理由:

  • 数据库结构与代码版本不匹配时继续启动更危险。
  • 当前“继续启动”导致隐藏问题。

实现:

  • HandleSqlScript 失败后抛出异常。
  • UseAutoVersionUpdate() 不吞异常。

如果项目负责人要求不中断启动,可加配置:

"AutoVersionUpdate": {
  "StopApplicationOnFailure": true
}

默认必须为 true。


5. 具体执行步骤

Step 1:备份当前文件

阅读并理解:

  • server/Admin.NET.Core/Update/AutoVersionUpdate.cs
  • server/Admin.NET.Web.Core/Startup.cs
  • server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj
  • server/Admin.NET.Web.Entry/UpdateScripts/*.sql

不要改动业务插件代码。

Step 2:新增内部模型

在 AutoVersionUpdate.cs 中新增私有模型:

private sealed record MigrationScript(
    string Version,
    Version ParsedVersion,
    string FilePath,
    string FileName,
    string Hash);

private sealed class MigrationLogRow
{
    public long Id { get; set; }
    public string Version { get; set; } = string.Empty;
    public string FileHash { get; set; } = string.Empty;
    public string Status { get; set; } = string.Empty;
}

也可独立文件,但为减少影响范围,建议本轮集中在 AutoVersionUpdate.cs。

Step 3:确保迁移记录表

在执行脚本前调用:

EnsureMigrationLogTable(dbContext);

该方法必须执行 CREATE TABLE IF NOT EXISTS sys_db_migration_log (...)。

Step 4:替换版本比较与脚本筛选

废弃:

string.CompareTo(...)

改为:

Version.Parse(...)

修复以下逻辑:

  • version.txt 为空不再直接返回。
  • 当前版本没有同名脚本不再直接返回。
  • 执行所有 scriptVersion <= currentVersion && !success 的脚本。

Step 5:实现 hash

使用 SHA256:

private static string ComputeSha256(string filePath)
{
    using var sha = SHA256.Create();
    using var stream = File.OpenRead(filePath);
    return Convert.ToHexString(sha.ComputeHash(stream));
}

需要添加:

using System.Security.Cryptography;

Step 6:实现执行前记录 Running

执行脚本前 upsert:

  • 若无记录:插入 Running。
  • 若 Failed:更新为 Running,清空错误。
  • 若 Success 且 hash 相同:跳过。
  • 若 Success 且 hash 不同:抛异常,阻止启动。

Step 7:执行 SQL

优先实现分语句执行。若时间不足,可第一版保留整文件执行,但必须:

  • 写迁移记录;
  • 失败抛异常;
  • 支持 verify;
  • 不再跳过未执行脚本。

推荐接口:

private static ScriptExecuteResult ExecuteSqlScript(ISqlSugarClient db, string sql)

返回:

private sealed record ScriptExecuteResult(int StatementCount, long AffectedRows);

Step 8:执行校验 SQL

寻找同目录:

var verifyPath = Path.ChangeExtension(script.FilePath, ".verify.sql");

注意 Path.ChangeExtension("1.0.158.sql", ".verify.sql") 会得到 1.0.158.verify.sql。

若存在,逐条执行 SELECT 校验。

校验失败:

  • 记录 verify_status='Failed';
  • 记录错误;
  • 抛异常;
  • 不写 version.txt=True。

若不存在:

  • verify_status='NotConfigured'。

Step 9:执行成功后更新迁移记录

成功后更新:

  • status='Success'
  • finished_at
  • elapsed_ms
  • statement_count
  • affected_rows
  • verify_status
  • error_message=NULL
  • database_name=SELECT DATABASE()

Step 10:更新 version.txt

只有当本轮所有应执行脚本成功后,写:

{currentVersion}^{now}^{True}

或者在每个脚本成功后写脚本版本也可,但推荐最终写当前程序集版本,数据库真实状态以 sys_db_migration_log 为准。

Step 11:失败处理

任一脚本失败:

  1. 更新迁移记录 Failed。
  2. 写入错误信息。
  3. 不更新 version.txt=True。
  4. 抛出异常,使启动失败。

Step 12:兼容旧 version.txt

如果迁移表为空但 version.txt 存在:

  • 读取 version.txt。
  • 若 isRunScript=True,可将 <= historyVersion 的脚本视为 legacy 成功,但不要自动批量插入 Success,避免伪造历史。
  • 日志提示:

    检测到 sys_db_migration_log 为空但 version.txt 存在,将 version.txt 作为 legacy 跳过边界:{historyVersion}
    

更严格方案:

  • 不信任 version.txt,全部重新跑幂等脚本。
  • 但历史脚本不一定全部幂等,风险较大。

本任务建议采用兼容边界方案。


6. 测试方案

6.1 单元/伪单元测试(可手动)

至少覆盖:

  1. 版本排序:
    • 1.0.9 < 1.0.10
    • 1.0.158 < 1.0.161
  2. 当前版本 1.0.161,脚本最新 1.0.158,历史成功到 1.0.154:
    • 应执行 1.0.155、1.0.157、1.0.158 等未成功脚本。
  3. version.txt 不存在:
    • 不得直接跳过所有脚本。
  4. 同版本 hash 变化:
    • 必须报错。

6.2 本地集成测试

测试 A:首次启动不跳过

  1. 备份并删除运行目录 version.txt。
  2. 清空或新建测试库的 sys_db_migration_log。
  3. 放一个测试脚本:

    CREATE TABLE IF NOT EXISTS auto_version_update_test (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    code VARCHAR(50) NOT NULL
    );
    INSERT INTO auto_version_update_test(code)
    SELECT 'OK'
    WHERE NOT EXISTS (SELECT 1 FROM auto_version_update_test WHERE code='OK');
    
  4. 启动后验证:

    SELECT COUNT(*) FROM auto_version_update_test WHERE code='OK';
    SELECT version, status FROM sys_db_migration_log ORDER BY version;
    

测试 B:失败不标成功

添加一个故意失败脚本:

ALTER TABLE not_exists_table ADD COLUMN x INT;

预期:

  • 启动失败。
  • sys_db_migration_log.status='Failed'。
  • version.txt 不写 True 到该版本。

测试 C:verify 失败不标成功

脚本成功但 .verify.sql 返回 false:

SELECT 0;

预期同失败。

测试 D:当前版本高于最新脚本

入口项目版本保持 1.0.161,脚本最新 1.0.158。

构造历史成功到 1.0.154。

预期:

  • 1.0.155 到 1.0.158 中未成功脚本会执行。
  • 不因“没有 1.0.161.sql”而直接返回。

6.3 生产/预发验证 SQL

执行后检查:

SELECT version, file_name, status, verify_status, started_at, finished_at, elapsed_ms
FROM sys_db_migration_log
ORDER BY version;

针对关键版本脚本补充业务校验,例如:

-- 表是否存在
SELECT COUNT(*)
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
  AND TABLE_NAME = '目标表名';

-- 字段是否存在
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
  AND TABLE_NAME = '目标表名'
  AND COLUMN_NAME = '目标字段';

-- 种子数据是否存在
SELECT COUNT(*)
FROM 目标表
WHERE 业务唯一条件;

7. 验收标准

7.1 功能验收

  • 无 version.txt 的新环境启动时,不再跳过历史脚本。
  • 当前程序集版本高于最新脚本时,仍执行所有未成功脚本。
  • 任一脚本执行失败时,启动失败或至少明确失败,不写成功状态。
  • sys_db_migration_log 有每个脚本的状态记录。
  • 成功脚本重启后不重复执行。
  • 已成功脚本被修改 hash 后,启动阻断并提示。
  • 可选 .verify.sql 失败时,脚本不标成功。
  • 日志能定位失败脚本和失败语句。

7.2 回归验收

  • dotnet build server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj 通过。
  • 后端可正常启动。
  • 旧的 UpdateScripts 不因新执行器误拆分而报错。
  • WorkerId 非 1 的节点仍不执行脚本。
  • 现有 version.txt 兼容读取。

8. 风险与注意事项

  1. 历史脚本不一定全部幂等,不要贸然在生产全量重跑所有历史脚本。
  2. 首次引入 sys_db_migration_log 时,要明确如何处理已有环境:
    • 推荐以 version.txt 作为 legacy 边界;
    • 对边界之后的脚本按新机制执行。
  3. MySQL DELIMITER / 存储过程脚本若存在,普通分号拆分器会失败。当前脚本初查主要为普通 DDL/DML 与 PREPARE/EXECUTE,但执行前仍需扫描。
  4. 不要在本任务中顺手改业务脚本内容,除非脚本本身语法导致新执行器无法执行。
  5. 失败后不要手动把 version.txt 改成 True 掩盖问题,应修复脚本或校验。

9. 建议文件改动清单

必改

  • server/Admin.NET.Core/Update/AutoVersionUpdate.cs
    • 重写脚本选择、版本比较、执行记录、成功判定、失败处理。

可选新增

  • server/Admin.NET.Web.Entry/UpdateScripts/*.verify.sql
    • 为关键脚本增加验证。

不建议本任务改

  • 业务插件实体/服务。
  • 前端代码。
  • 现有 SQL 脚本业务内容。

10. 给执行模型的最终指令

请严格按以下顺序执行:

  1. 读取并理解 AutoVersionUpdate.cs 当前实现。
  2. 不修改业务逻辑,先重构版本脚本执行器。
  3. 新增 sys_db_migration_log 幂等建表逻辑。
  4. 改掉 version.txt 不存在时跳过脚本的逻辑。
  5. 改掉“当前版本无同名脚本则跳过全部”的逻辑。
  6. 改用 System.Version 比较版本。
  7. 增加 SHA256 hash 与已成功脚本 hash 校验。
  8. 增加 Running/Success/Failed 迁移记录。
  9. 增加可选 .verify.sql 校验。
  10. 失败时抛异常并阻止启动,不写成功状态。
  11. 构建验证。
  12. 用临时测试脚本或测试库验证首次启动、失败脚本、verify 失败、当前版本高于最新脚本四类场景。

执行完成后输出:

  • 改动文件列表。
  • 核心逻辑说明。
  • 已执行测试。
  • 尚未覆盖的风险。
  • 是否需要为关键历史脚本补 .verify.sql。

11. 推荐后续治理

  1. 对所有重要 UpdateScripts 补充 .verify.sql。
  2. 禁止只依赖 version.txt 作为发布验收依据。
  3. 发布验收模板增加:

    SELECT version, status, verify_status, finished_at
    FROM sys_db_migration_log
    ORDER BY version DESC
    LIMIT 20;
    
  4. 对 DDL 类脚本要求幂等。

  5. 对数据种子脚本要求唯一键或 INSERT ... ON DUPLICATE KEY UPDATE。

  6. 对非幂等脚本必须在脚本头注明风险与回滚方式。