AutoVersionUpdate.cs 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917
  1. // Admin.NET 项目的版权、商标、专利和其他相关权利均受相应法律法规的保护。使用本项目应遵守相关法律法规和许可证的要求。
  2. //
  3. // 本项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。许可证位于源代码树根目录中的 LICENSE-MIT 和 LICENSE-APACHE 文件。
  4. //
  5. // 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
  6. #if NET10_0_OR_GREATER
  7. using System.Data;
  8. using System.Diagnostics;
  9. using System.Security.Cryptography;
  10. using System.Text;
  11. using System.Text.RegularExpressions;
  12. using Microsoft.AspNetCore.Builder;
  13. using XiHan.Framework.Utils.Logging;
  14. using XiHan.Framework.Utils.Reflections;
  15. namespace Admin.NET.Core.Update;
  16. /// <summary>
  17. /// 自动版本更新中间件拓展
  18. /// </summary>
  19. [SuppressSniffer]
  20. public static class AutoVersionUpdate
  21. {
  22. private const int MigrationCommandTimeoutSeconds = 600;
  23. private const string MigrationLogTable = "sys_db_migration_log";
  24. private const string StatusRunning = "Running";
  25. private const string StatusSuccess = "Success";
  26. private const string StatusFailed = "Failed";
  27. private const string StatusSkipped = "Skipped";
  28. private const string VerifyNotConfigured = "NotConfigured";
  29. private const string VerifySuccess = "Success";
  30. private const string VerifyFailed = "Failed";
  31. /// <summary>
  32. /// 使用自动版本更新中间件
  33. /// </summary>
  34. public static IApplicationBuilder UseAutoVersionUpdate(this IApplicationBuilder app)
  35. {
  36. LogHelper.Info("AutoVersionUpdate 中间件运行");
  37. var snowIdOpt = App.GetConfig<SnowIdOptions>("SnowId", true);
  38. if (snowIdOpt.WorkerId != 1)
  39. {
  40. LogHelper.Handle("非主节点,不执行脚本");
  41. return app;
  42. }
  43. var stopOnFailure = App.GetConfig<bool?>("AutoVersionUpdate:StopApplicationOnFailure") ?? true;
  44. try
  45. {
  46. RunPendingMigrations(app);
  47. LogHelper.Success("AutoVersionUpdate 中间件结束");
  48. }
  49. catch (Exception ex)
  50. {
  51. LogHelper.Error($"AutoVersionUpdate 执行失败:{ex.Message}");
  52. if (stopOnFailure)
  53. throw;
  54. }
  55. return app;
  56. }
  57. private static void RunPendingMigrations(IApplicationBuilder app)
  58. {
  59. using var scope = App.GetRequiredService<IServiceScopeFactory>().CreateScope();
  60. var db = scope.ServiceProvider.GetRequiredService<ISqlSugarClient>();
  61. EnsureMigrationLogTable(db);
  62. var currentVersionText = GetEntryAssemblyCurrentVersion();
  63. var currentVersion = ParseVersion(currentVersionText);
  64. var historyFromTxt = GetEntryAssemblyHistoryVersionInfo();
  65. var databaseName = GetDatabaseName(db);
  66. LogHelper.Handle($"当前版本:{currentVersionText},目标数据库:{databaseName ?? "(unknown)"}");
  67. var scripts = LoadMigrationScripts();
  68. var migrationRows = LoadMigrationLogs(db);
  69. // 已登记的「迁移记录被误记」重整。必须在计算 pending **之前**做:
  70. // ShouldSkipScript 正是在计算 pending 时就对 hash 不符抛错,放在执行循环里(像
  71. // TryRecoverKnownFailedMigration 那样)永远够不到这类事故。
  72. if (TryReconcileKnownCollisions(db, scripts, migrationRows))
  73. migrationRows = LoadMigrationLogs(db);
  74. var successVersions = migrationRows
  75. .Where(x => string.Equals(x.Status, StatusSuccess, StringComparison.OrdinalIgnoreCase))
  76. .Select(x => ParseVersion(x.Version))
  77. .ToHashSet();
  78. Version? legacyBoundary = null;
  79. if (migrationRows.Count == 0 &&
  80. !string.IsNullOrWhiteSpace(historyFromTxt.Version) &&
  81. historyFromTxt.IsRunScript &&
  82. Version.TryParse(historyFromTxt.Version, out var legacyVersion))
  83. {
  84. legacyBoundary = legacyVersion;
  85. LogHelper.Handle(
  86. $"检测到 {MigrationLogTable} 为空但 version.txt 存在,legacy 跳过边界:{legacyBoundary}");
  87. }
  88. var pending = scripts
  89. .Where(s => s.ParsedVersion <= currentVersion)
  90. .Where(s => !ShouldSkipScript(s, migrationRows, legacyBoundary))
  91. .OrderBy(s => s.ParsedVersion)
  92. .ToList();
  93. LogHelper.Handle($"发现脚本 {scripts.Count} 个,已成功 {successVersions.Count} 个,待执行 {pending.Count} 个");
  94. if (pending.Count == 0)
  95. {
  96. SetEntryAssemblyCurrentVersion(currentVersionText, true);
  97. return;
  98. }
  99. foreach (var script in pending)
  100. {
  101. TryRecoverKnownFailedMigration(db, script, migrationRows);
  102. ExecuteOneMigrationScript(db, script, databaseName);
  103. }
  104. SetEntryAssemblyCurrentVersion(currentVersionText, true);
  105. }
  106. /// <summary>
  107. /// 已登记的「迁移记录被误记」重整。裁决全部交给 <see cref="MigrationCollisionPolicy"/>(纯函数、可单测),
  108. /// 本方法只负责执行副作用。
  109. ///
  110. /// <para><b>不删除任何历史行</b>:只把误记行的 status 置为
  111. /// <see cref="MigrationCollisionPolicy.SupersededStatus"/>,随后由原有正常流程执行真正的脚本,
  112. /// <see cref="UpsertMigrationLog"/> 会按 version 原地把同一行覆写成真实执行结果。</para>
  113. ///
  114. /// <para><b>幂等</b>:重整后若进程崩溃,该行停在 Superseded、hash 仍是旧值,
  115. /// 下次启动闸门 3 不成立 ⇒ 返回 None,而 ShouldSkipScript 对非 Success 行返回 false,
  116. /// 脚本照常执行,仍然收敛。执行成功后 hash 变为目标值,闸门 4 不成立 ⇒ 永不再触发。</para>
  117. /// </summary>
  118. /// <returns>是否实际改动过迁移日志(true 时调用方需重新载入)。</returns>
  119. private static bool TryReconcileKnownCollisions(
  120. ISqlSugarClient db,
  121. List<MigrationScript> scripts,
  122. List<MigrationLogRow> migrationRows)
  123. {
  124. var changed = false;
  125. foreach (var plan in MigrationCollisionPolicy.KnownCollisions)
  126. {
  127. var rows = migrationRows
  128. .Where(x => string.Equals(x.Version, plan.Version, StringComparison.OrdinalIgnoreCase))
  129. .ToList();
  130. var row = rows.Count == 1 ? rows[0] : null;
  131. var displacedRow = migrationRows.FirstOrDefault(x =>
  132. string.Equals(x.Version, plan.DisplacedVersion, StringComparison.OrdinalIgnoreCase));
  133. var targetScript = scripts.FirstOrDefault(s =>
  134. string.Equals(s.Version, plan.Version, StringComparison.OrdinalIgnoreCase));
  135. var displacedScript = scripts.FirstOrDefault(s =>
  136. string.Equals(s.Version, plan.DisplacedVersion, StringComparison.OrdinalIgnoreCase));
  137. var outcome = MigrationCollisionPolicy.Decide(
  138. plan.Version,
  139. rows.Count,
  140. row?.Status,
  141. row?.FileHash,
  142. targetScript != null && MigrationScriptHash.MatchesFile(plan.ExpectedDiskHash, targetScript.FilePath),
  143. displacedRow?.Status,
  144. displacedRow?.FileHash,
  145. displacedScript != null && MigrationScriptHash.MatchesFile(plan.DisplacedFileHash, displacedScript.FilePath));
  146. if (outcome.Decision == MigrationCollisionDecision.None) continue;
  147. if (outcome.Decision == MigrationCollisionDecision.Reject)
  148. throw new InvalidOperationException(
  149. $"版本 {plan.Version} 命中已登记的记录冲突策略 {plan.Strategy},但拒绝执行:"
  150. + $"{outcome.RejectReason}。该操作会修改迁移历史,不对未确认状态执行,请人工确认。");
  151. LogHelper.Handle(
  152. $"Migration collision reconcile start version={plan.Version} strategy={plan.Strategy} "
  153. + $"collided={plan.CollidedFileHash} displaced_by={plan.DisplacedVersion}");
  154. ResetCollidedMigrationRecord(db, plan);
  155. changed = true;
  156. LogHelper.Handle(
  157. $"Migration collision reconcile done version={plan.Version};"
  158. + "该行已置为 Superseded,接下来由原迁移脚本正常执行并原地覆写,状态由正常流程写入。");
  159. }
  160. return changed;
  161. }
  162. /// <summary>
  163. /// 把误记行的状态重整掉。事务内执行,命中数必须恰为 1,否则回滚并停机。
  164. /// 谓词同时钉死 version + file_hash + status,确保只可能命中那一行。
  165. /// </summary>
  166. private static void ResetCollidedMigrationRecord(ISqlSugarClient db, MigrationCollisionPlan plan)
  167. {
  168. var audit =
  169. $"{plan.Strategy}: 本行原记录 version={plan.Version} / hash={plan.CollidedFileHash},"
  170. + $"实为 {plan.DisplacedVersion} 的脚本内容被误记;该内容已由 {plan.DisplacedVersion} "
  171. + $"(hash={plan.DisplacedFileHash})合法记录在案。本行已重整,待 {plan.Version} 正式执行后覆写。";
  172. db.Ado.BeginTran();
  173. try
  174. {
  175. var affected = db.Ado.ExecuteCommand(
  176. $"""
  177. UPDATE {MigrationLogTable}
  178. SET status = @newStatus,
  179. error_message = @audit,
  180. updated_at = @now
  181. WHERE version = @version
  182. AND file_hash = @collidedHash
  183. AND status = '{StatusSuccess}'
  184. """,
  185. new
  186. {
  187. newStatus = MigrationCollisionPolicy.SupersededStatus,
  188. audit,
  189. now = DateTime.Now,
  190. version = plan.Version,
  191. collidedHash = plan.CollidedFileHash
  192. });
  193. if (affected != 1)
  194. throw new InvalidOperationException(
  195. $"重整 {plan.Version} 误记行时命中 {affected} 行,期望恰好 1 行。已回滚,不继续启动。");
  196. db.Ado.CommitTran();
  197. }
  198. catch
  199. {
  200. db.Ado.RollbackTran();
  201. throw;
  202. }
  203. }
  204. /// <summary>
  205. /// 已知失败迁移的「重跑前恢复」。裁决全部交给 <see cref="MigrationRecoveryPolicy"/>(纯函数、可单测),
  206. /// 本方法只负责执行副作用。
  207. ///
  208. /// <para><b>已 Success 的环境零影响</b>:<see cref="ShouldSkipScript"/> 已把它们排除在 pending 之外,
  209. /// 本方法根本不会被调用;策略里再判一次状态是纵深防御。</para>
  210. ///
  211. /// <para><b>幂等</b>:TRUNCATE 空表安全。若 TRUNCATE 之后进程崩溃,下次启动日志行仍是 Failed、
  212. /// hash 仍是原始值,会再 TRUNCATE 一次空表,无副作用。</para>
  213. ///
  214. /// <para><b>不写迁移日志</b>:恢复只清数据,状态一律由原有正常流程写入 ——
  215. /// 让「这条迁移到底成没成功」始终是脚本自己跑出来的结论。</para>
  216. /// </summary>
  217. private static void TryRecoverKnownFailedMigration(
  218. ISqlSugarClient db,
  219. MigrationScript script,
  220. List<MigrationLogRow> migrationRows)
  221. {
  222. var row = migrationRows.FirstOrDefault(x =>
  223. string.Equals(x.Version, script.Version, StringComparison.OrdinalIgnoreCase));
  224. var registered = MigrationRecoveryPolicy.KnownFailedRecoveries.FirstOrDefault(x =>
  225. string.Equals(x.Version, script.Version, StringComparison.OrdinalIgnoreCase));
  226. // 只有登记过的版本才需要读盘算 hash,避免给每条迁移平白加一次 IO。
  227. var diskMatches = registered != null
  228. && MigrationScriptHash.MatchesFile(registered.OriginalFileHash, script.FilePath);
  229. var outcome = MigrationRecoveryPolicy.Decide(
  230. script.Version, row?.Status, row?.FileHash, diskMatches);
  231. if (outcome.Decision == MigrationRecoveryDecision.None) return;
  232. if (outcome.Decision == MigrationRecoveryDecision.Reject)
  233. throw new InvalidOperationException(
  234. $"版本 {script.Version} 处于 Failed 且命中已登记的恢复策略 {outcome.Plan!.Strategy},"
  235. + $"但拒绝执行:{outcome.RejectReason}。"
  236. + "该策略含不可回滚操作,不对未知版本执行,请人工确认。");
  237. var plan = outcome.Plan!;
  238. if (!TableExists(db, plan.TableName)) return;
  239. LogHelper.Handle(
  240. $"Migration recovery start version={plan.Version} strategy={plan.Strategy} table={plan.TableName}");
  241. db.Ado.ExecuteCommand($"TRUNCATE TABLE `{plan.TableName}`");
  242. LogHelper.Handle(
  243. $"Migration recovery done version={plan.Version} strategy={plan.Strategy};"
  244. + "接下来由原迁移脚本原样重跑,日志状态由正常流程写入。");
  245. }
  246. /// <summary>目标表是否存在于当前库。恢复策略只在表确实存在时才动手。</summary>
  247. private static bool TableExists(ISqlSugarClient db, string tableName)
  248. {
  249. var count = db.Ado.SqlQuerySingle<int>(
  250. "SELECT COUNT(*) FROM information_schema.TABLES "
  251. + "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = @name",
  252. new { name = tableName });
  253. return count > 0;
  254. }
  255. private static bool ShouldSkipScript(
  256. MigrationScript script,
  257. List<MigrationLogRow> migrationRows,
  258. Version? legacyBoundary)
  259. {
  260. var row = migrationRows.FirstOrDefault(x =>
  261. string.Equals(x.Version, script.Version, StringComparison.OrdinalIgnoreCase));
  262. if (row != null)
  263. {
  264. if (string.Equals(row.Status, StatusSuccess, StringComparison.OrdinalIgnoreCase))
  265. {
  266. // 只容忍 CRLF/CR/LF 行尾差异(同一文件在 Windows 与 Linux 检出字节不同);
  267. // SQL 字符内容一旦变化仍然抛错,历史脚本不可被静默改写。
  268. if (!MigrationScriptHash.MatchesFile(row.FileHash, script.FilePath))
  269. {
  270. throw new InvalidOperationException(
  271. $"版本脚本 {script.FileName} 的 SHA256 与已成功记录不一致,禁止静默覆盖。请新建更高版本脚本。");
  272. }
  273. LogHelper.Handle($"版本 {script.Version} 已成功且 hash 未变,跳过");
  274. return true;
  275. }
  276. return false;
  277. }
  278. if (legacyBoundary != null && script.ParsedVersion <= legacyBoundary)
  279. {
  280. LogHelper.Handle($"版本 {script.Version} 处于 legacy 边界内,跳过");
  281. return true;
  282. }
  283. return false;
  284. }
  285. private static void ExecuteOneMigrationScript(ISqlSugarClient db, MigrationScript script, string? databaseName)
  286. {
  287. var sql = File.ReadAllText(script.FilePath);
  288. if (string.IsNullOrWhiteSpace(sql))
  289. {
  290. LogHelper.Handle($"版本 {script.Version} 脚本为空,记录 Skipped");
  291. UpsertMigrationLog(db, script, databaseName, StatusSkipped, 0, 0, VerifyNotConfigured, null, 0);
  292. return;
  293. }
  294. if (SqlScriptSplitter.ContainsDelimiterDirective(sql))
  295. {
  296. throw new InvalidOperationException(
  297. $"版本脚本 {script.FileName} 包含 DELIMITER 指令,当前执行器不支持,请改为普通 SQL 或手工执行。");
  298. }
  299. var startedAt = DateTime.Now;
  300. var sw = Stopwatch.StartNew();
  301. UpsertMigrationLog(db, script, databaseName, StatusRunning, 0, 0, VerifyNotConfigured, null, 0);
  302. var originalCommandTimeout = db.Ado.CommandTimeOut;
  303. db.Ado.CommandTimeOut = Math.Max(originalCommandTimeout, MigrationCommandTimeoutSeconds);
  304. try
  305. {
  306. LogHelper.Handle($"执行版本 {script.Version} 脚本 {script.FileName},SHA256={script.Hash}");
  307. var executeResult = SqlScriptSplitter.Execute(db, sql);
  308. var verifyStatus = RunVerifyScriptIfExists(db, script);
  309. sw.Stop();
  310. UpsertMigrationLog(
  311. db,
  312. script,
  313. databaseName,
  314. StatusSuccess,
  315. executeResult.StatementCount,
  316. executeResult.AffectedRows,
  317. verifyStatus,
  318. null,
  319. sw.ElapsedMilliseconds);
  320. LogHelper.Handle(
  321. $"版本 {script.Version} 成功:语句 {executeResult.StatementCount} 条,影响行 {executeResult.AffectedRows},校验 {verifyStatus},耗时 {sw.ElapsedMilliseconds}ms");
  322. }
  323. catch (Exception ex)
  324. {
  325. sw.Stop();
  326. var message = BuildExceptionMessage(ex);
  327. UpsertMigrationLog(
  328. db,
  329. script,
  330. databaseName,
  331. StatusFailed,
  332. 0,
  333. 0,
  334. VerifyFailed,
  335. message,
  336. sw.ElapsedMilliseconds);
  337. LogHelper.Error($"AutoVersionUpdate 版本 {script.Version} 失败:{message}");
  338. throw new InvalidOperationException($"AutoVersionUpdate 版本 {script.Version} 执行失败:{message}", ex);
  339. }
  340. finally
  341. {
  342. db.Ado.CommandTimeOut = originalCommandTimeout;
  343. }
  344. }
  345. private static string RunVerifyScriptIfExists(ISqlSugarClient db, MigrationScript script)
  346. {
  347. var verifyPath = Path.ChangeExtension(script.FilePath, ".verify.sql");
  348. if (!File.Exists(verifyPath))
  349. return VerifyNotConfigured;
  350. var verifySql = File.ReadAllText(verifyPath);
  351. var statements = SqlScriptSplitter.Split(verifySql);
  352. if (statements.Count == 0)
  353. return VerifyNotConfigured;
  354. for (var i = 0; i < statements.Count; i++)
  355. {
  356. var statement = statements[i];
  357. var result = db.Ado.SqlQuerySingle<object>(statement);
  358. if (!IsTruthy(result))
  359. {
  360. throw new InvalidOperationException(
  361. $"校验 SQL 第 {i + 1} 条未通过:{TrimForLog(statement)}");
  362. }
  363. }
  364. return VerifySuccess;
  365. }
  366. private static bool IsTruthy(object? value)
  367. {
  368. if (value == null || value is DBNull)
  369. return false;
  370. return value switch
  371. {
  372. bool b => b,
  373. byte or sbyte or short or ushort or int or uint or long or ulong or float or double or decimal =>
  374. Convert.ToDecimal(value) != 0,
  375. string s => !string.IsNullOrWhiteSpace(s) &&
  376. !string.Equals(s, "0", StringComparison.OrdinalIgnoreCase) &&
  377. !string.Equals(s, "false", StringComparison.OrdinalIgnoreCase),
  378. _ => true
  379. };
  380. }
  381. private static void EnsureMigrationLogTable(ISqlSugarClient db)
  382. {
  383. db.Ado.ExecuteCommand(
  384. """
  385. CREATE TABLE IF NOT EXISTS sys_db_migration_log (
  386. id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
  387. version VARCHAR(50) NOT NULL COMMENT '脚本版本',
  388. file_name VARCHAR(255) NOT NULL COMMENT '脚本文件名',
  389. file_hash VARCHAR(128) NOT NULL COMMENT '脚本 SHA256',
  390. status VARCHAR(20) NOT NULL COMMENT 'Running/Success/Failed/Skipped',
  391. started_at DATETIME(3) NOT NULL COMMENT '开始时间',
  392. finished_at DATETIME(3) NULL COMMENT '结束时间',
  393. elapsed_ms BIGINT NULL COMMENT '耗时毫秒',
  394. statement_count INT NOT NULL DEFAULT 0 COMMENT '执行语句数',
  395. affected_rows BIGINT NULL COMMENT '影响行数合计',
  396. verify_status VARCHAR(20) NULL COMMENT '校验状态',
  397. error_message LONGTEXT NULL COMMENT '错误信息',
  398. database_name VARCHAR(128) NULL COMMENT '执行数据库',
  399. created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  400. updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
  401. PRIMARY KEY (id),
  402. UNIQUE KEY uk_sys_db_migration_log_version (version),
  403. KEY idx_sys_db_migration_log_status (status),
  404. KEY idx_sys_db_migration_log_started_at (started_at)
  405. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据库版本脚本执行记录'
  406. """);
  407. }
  408. private static List<MigrationLogRow> LoadMigrationLogs(ISqlSugarClient db)
  409. {
  410. try
  411. {
  412. return db.Ado.SqlQuery<MigrationLogRow>(
  413. $"""
  414. SELECT version AS Version, file_hash AS FileHash, status AS Status
  415. FROM {MigrationLogTable}
  416. """);
  417. }
  418. catch
  419. {
  420. return [];
  421. }
  422. }
  423. private static void UpsertMigrationLog(
  424. ISqlSugarClient db,
  425. MigrationScript script,
  426. string? databaseName,
  427. string status,
  428. int statementCount,
  429. long affectedRows,
  430. string verifyStatus,
  431. string? errorMessage,
  432. long elapsedMs)
  433. {
  434. var now = DateTime.Now;
  435. var existing = db.Ado.SqlQuerySingle<int?>(
  436. $"SELECT id FROM {MigrationLogTable} WHERE version = @version LIMIT 1",
  437. new { version = script.Version });
  438. if (existing is > 0)
  439. {
  440. db.Ado.ExecuteCommand(
  441. $"""
  442. UPDATE {MigrationLogTable}
  443. SET file_name = @fileName,
  444. file_hash = @fileHash,
  445. status = @status,
  446. started_at = CASE WHEN @status = '{StatusRunning}' THEN @startedAt ELSE started_at END,
  447. finished_at = @finishedAt,
  448. elapsed_ms = @elapsedMs,
  449. statement_count = @statementCount,
  450. affected_rows = @affectedRows,
  451. verify_status = @verifyStatus,
  452. error_message = @errorMessage,
  453. database_name = @databaseName,
  454. updated_at = @updatedAt
  455. WHERE version = @version
  456. """,
  457. new
  458. {
  459. version = script.Version,
  460. fileName = script.FileName,
  461. fileHash = script.Hash,
  462. status,
  463. startedAt = now,
  464. finishedAt = status is StatusRunning ? (DateTime?)null : now,
  465. elapsedMs = status is StatusRunning ? (long?)null : elapsedMs,
  466. statementCount,
  467. affectedRows,
  468. verifyStatus,
  469. errorMessage,
  470. databaseName,
  471. updatedAt = now
  472. });
  473. return;
  474. }
  475. db.Ado.ExecuteCommand(
  476. $"""
  477. INSERT INTO {MigrationLogTable}
  478. (version, file_name, file_hash, status, started_at, finished_at, elapsed_ms,
  479. statement_count, affected_rows, verify_status, error_message, database_name)
  480. VALUES
  481. (@version, @fileName, @fileHash, @status, @startedAt, @finishedAt, @elapsedMs,
  482. @statementCount, @affectedRows, @verifyStatus, @errorMessage, @databaseName)
  483. """,
  484. new
  485. {
  486. version = script.Version,
  487. fileName = script.FileName,
  488. fileHash = script.Hash,
  489. status,
  490. startedAt = now,
  491. finishedAt = status is StatusRunning ? (DateTime?)null : now,
  492. elapsedMs = status is StatusRunning ? (long?)null : elapsedMs,
  493. statementCount,
  494. affectedRows,
  495. verifyStatus,
  496. errorMessage,
  497. databaseName
  498. });
  499. }
  500. private static List<MigrationScript> LoadMigrationScripts()
  501. {
  502. var path = Path.Combine(AppContext.BaseDirectory, "UpdateScripts");
  503. if (!Directory.Exists(path))
  504. return [];
  505. return Directory.GetFiles(path, "*.sql", SearchOption.TopDirectoryOnly)
  506. .Where(file =>
  507. {
  508. var name = Path.GetFileName(file);
  509. return !name.EndsWith(".verify.sql", StringComparison.OrdinalIgnoreCase);
  510. })
  511. .Select(file =>
  512. {
  513. var versionText = Path.GetFileNameWithoutExtension(file);
  514. if (!Version.TryParse(versionText, out var parsedVersion))
  515. return null;
  516. return new MigrationScript(
  517. versionText,
  518. parsedVersion,
  519. file,
  520. Path.GetFileName(file),
  521. ComputeSha256(file));
  522. })
  523. .Where(x => x != null)
  524. .Cast<MigrationScript>()
  525. .OrderBy(x => x.ParsedVersion)
  526. .ToList();
  527. }
  528. private static string GetEntryAssemblyCurrentVersion()
  529. {
  530. var entryAssemblyVersion = ReflectionHelper.GetEntryAssemblyVersion();
  531. return entryAssemblyVersion.ToString(3);
  532. }
  533. private static void SetEntryAssemblyCurrentVersion(string version, bool isRunScript)
  534. {
  535. var path = Path.Combine(AppContext.BaseDirectory, "version.txt");
  536. var now = DateTime.Now;
  537. File.WriteAllText(path, $"{version}^{now:yyyy-MM-dd HH:mm:ss}^{isRunScript}");
  538. }
  539. private static HistoryVersionInfo GetEntryAssemblyHistoryVersionInfo()
  540. {
  541. var path = Path.Combine(AppContext.BaseDirectory, "version.txt");
  542. if (!File.Exists(path))
  543. return new HistoryVersionInfo(string.Empty, string.Empty, false);
  544. var info = File.ReadAllText(path);
  545. if (!info.Contains('^'))
  546. return new HistoryVersionInfo(string.Empty, string.Empty, false);
  547. var parts = info.Split('^');
  548. var version = parts.Length > 0 ? parts[0] : string.Empty;
  549. var date = parts.Length > 1 ? parts[1] : string.Empty;
  550. var isRunScript = parts.Length > 2 && parts[2].ToBoolean();
  551. return new HistoryVersionInfo(version, date, isRunScript);
  552. }
  553. private static Version ParseVersion(string value)
  554. {
  555. if (!Version.TryParse(value, out var version))
  556. throw new InvalidOperationException($"非法版本号:{value}");
  557. return version;
  558. }
  559. /// <summary>
  560. /// 迁移脚本 hash 一律用 LF 归一化结果(<see cref="MigrationScriptHash.ComputeCanonical"/>),
  561. /// 使 Windows / Linux / macOS 执行同一文件时写入数据库的 hash 一致。
  562. /// 历史上已写入的 raw CRLF / raw LF 记录由 <see cref="MigrationScriptHash.Matches"/> 继续兼容。
  563. /// </summary>
  564. private static string ComputeSha256(string filePath)
  565. => MigrationScriptHash.ComputeCanonicalFromFile(filePath);
  566. private static string? GetDatabaseName(ISqlSugarClient db)
  567. {
  568. try
  569. {
  570. return db.Ado.GetString("SELECT DATABASE()");
  571. }
  572. catch
  573. {
  574. return null;
  575. }
  576. }
  577. private static string BuildExceptionMessage(Exception ex)
  578. {
  579. var messages = new List<string>();
  580. for (var current = ex; current != null; current = current.InnerException)
  581. messages.Add(current.Message);
  582. return string.Join(" | ", messages);
  583. }
  584. private static string TrimForLog(string sql, int maxLength = 300)
  585. {
  586. var normalized = sql.Replace('\r', ' ').Replace('\n', ' ').Trim();
  587. return normalized.Length <= maxLength ? normalized : normalized[..maxLength] + "...";
  588. }
  589. private sealed record MigrationScript(
  590. string Version,
  591. Version ParsedVersion,
  592. string FilePath,
  593. string FileName,
  594. string Hash);
  595. private sealed class MigrationLogRow
  596. {
  597. public string Version { get; set; } = string.Empty;
  598. public string FileHash { get; set; } = string.Empty;
  599. public string Status { get; set; } = string.Empty;
  600. }
  601. private sealed record ScriptExecuteResult(int StatementCount, long AffectedRows);
  602. private static class SqlScriptSplitter
  603. {
  604. public static bool ContainsDelimiterDirective(string sql) =>
  605. sql.Contains("DELIMITER", StringComparison.OrdinalIgnoreCase);
  606. public static ScriptExecuteResult Execute(ISqlSugarClient db, string sql)
  607. // SET @var / PREPARE / EXECUTE 依赖同一连接,但不需要作为一个超长批次提交。
  608. // 分句执行既保留会话变量,又让超时和失败定位落到单条 DDL。
  609. => ExecuteSplitWithSharedConnection(db, sql);
  610. private static ScriptExecuteResult ExecuteSplitWithSharedConnection(ISqlSugarClient db, string sql)
  611. {
  612. var statements = Split(sql);
  613. long affectedRows = 0;
  614. var wasOpen = EnsureConnectionOpen(db);
  615. try
  616. {
  617. for (var i = 0; i < statements.Count; i++)
  618. {
  619. var statement = statements[i];
  620. try
  621. {
  622. using var command = db.Ado.Connection.CreateCommand();
  623. command.CommandText = statement;
  624. command.CommandTimeout = db.Ado.CommandTimeOut;
  625. affectedRows += Math.Max(0, command.ExecuteNonQuery());
  626. }
  627. catch (Exception ex)
  628. {
  629. throw new InvalidOperationException(
  630. $"第 {i + 1}/{statements.Count} 条语句失败:{TrimForLog(statement)} | {ex.Message}", ex);
  631. }
  632. }
  633. return new ScriptExecuteResult(statements.Count, affectedRows);
  634. }
  635. finally
  636. {
  637. RestoreConnection(db, wasOpen);
  638. }
  639. }
  640. private static bool EnsureConnectionOpen(ISqlSugarClient db)
  641. {
  642. var connection = db.Ado.Connection;
  643. if (connection.State == ConnectionState.Open)
  644. return true;
  645. connection.Open();
  646. return false;
  647. }
  648. private static void RestoreConnection(ISqlSugarClient db, bool wasOpen)
  649. {
  650. if (wasOpen)
  651. return;
  652. var connection = db.Ado.Connection;
  653. if (connection.State == ConnectionState.Open)
  654. connection.Close();
  655. }
  656. public static List<string> Split(string sql)
  657. {
  658. var statements = new List<string>();
  659. if (string.IsNullOrWhiteSpace(sql))
  660. return statements;
  661. var current = new StringBuilder();
  662. var inSingleQuote = false;
  663. var inDoubleQuote = false;
  664. var inBacktick = false;
  665. var inLineComment = false;
  666. var inBlockComment = false;
  667. for (var i = 0; i < sql.Length; i++)
  668. {
  669. var ch = sql[i];
  670. var next = i + 1 < sql.Length ? sql[i + 1] : '\0';
  671. if (inLineComment)
  672. {
  673. current.Append(ch);
  674. if (ch is '\n' or '\r')
  675. inLineComment = false;
  676. continue;
  677. }
  678. if (inBlockComment)
  679. {
  680. current.Append(ch);
  681. if (ch == '*' && next == '/')
  682. {
  683. current.Append(next);
  684. i++;
  685. inBlockComment = false;
  686. }
  687. continue;
  688. }
  689. if (!inSingleQuote && !inDoubleQuote && !inBacktick)
  690. {
  691. if (ch == '-' && next == '-')
  692. {
  693. inLineComment = true;
  694. current.Append(ch);
  695. continue;
  696. }
  697. if (ch == '#')
  698. {
  699. inLineComment = true;
  700. current.Append(ch);
  701. continue;
  702. }
  703. if (ch == '/' && next == '*')
  704. {
  705. inBlockComment = true;
  706. current.Append(ch);
  707. continue;
  708. }
  709. }
  710. if (!inDoubleQuote && !inBacktick && ch == '\'' && !inSingleQuote)
  711. {
  712. inSingleQuote = true;
  713. current.Append(ch);
  714. continue;
  715. }
  716. if (inSingleQuote)
  717. {
  718. current.Append(ch);
  719. if (ch == '\'' && next == '\'')
  720. {
  721. current.Append(next);
  722. i++;
  723. continue;
  724. }
  725. if (ch == '\\' && next != '\0')
  726. {
  727. current.Append(next);
  728. i++;
  729. continue;
  730. }
  731. if (ch == '\'')
  732. inSingleQuote = false;
  733. continue;
  734. }
  735. if (!inSingleQuote && !inBacktick && ch == '"' && !inDoubleQuote)
  736. {
  737. inDoubleQuote = true;
  738. current.Append(ch);
  739. continue;
  740. }
  741. if (inDoubleQuote)
  742. {
  743. current.Append(ch);
  744. if (ch == '"' && next == '"')
  745. {
  746. current.Append(next);
  747. i++;
  748. continue;
  749. }
  750. if (ch == '\\' && next != '\0')
  751. {
  752. current.Append(next);
  753. i++;
  754. continue;
  755. }
  756. if (ch == '"')
  757. inDoubleQuote = false;
  758. continue;
  759. }
  760. if (!inSingleQuote && !inDoubleQuote && ch == '`' && !inBacktick)
  761. {
  762. inBacktick = true;
  763. current.Append(ch);
  764. continue;
  765. }
  766. if (inBacktick)
  767. {
  768. current.Append(ch);
  769. if (ch == '`')
  770. inBacktick = false;
  771. continue;
  772. }
  773. if (ch == ';')
  774. {
  775. AppendStatement(statements, current);
  776. continue;
  777. }
  778. current.Append(ch);
  779. }
  780. AppendStatement(statements, current);
  781. return statements;
  782. }
  783. private static void AppendStatement(List<string> statements, StringBuilder current)
  784. {
  785. var text = current.ToString().Trim();
  786. current.Clear();
  787. if (string.IsNullOrWhiteSpace(text))
  788. return;
  789. statements.Add(text);
  790. }
  791. }
  792. }
  793. public record HistoryVersionInfo(string Version, string Date, bool IsRunScript);
  794. #endif // NET10_0_OR_GREATER