| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745 |
- // Admin.NET 项目的版权、商标、专利和其他相关权利均受相应法律法规的保护。使用本项目应遵守相关法律法规和许可证的要求。
- //
- // 本项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。许可证位于源代码树根目录中的 LICENSE-MIT 和 LICENSE-APACHE 文件。
- //
- // 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
- #if NET10_0_OR_GREATER
- using System.Data;
- using System.Diagnostics;
- using System.Security.Cryptography;
- using System.Text;
- using System.Text.RegularExpressions;
- using Microsoft.AspNetCore.Builder;
- using XiHan.Framework.Utils.Logging;
- using XiHan.Framework.Utils.Reflections;
- namespace Admin.NET.Core.Update;
- /// <summary>
- /// 自动版本更新中间件拓展
- /// </summary>
- [SuppressSniffer]
- public static class AutoVersionUpdate
- {
- private const string MigrationLogTable = "sys_db_migration_log";
- private const string StatusRunning = "Running";
- private const string StatusSuccess = "Success";
- private const string StatusFailed = "Failed";
- private const string StatusSkipped = "Skipped";
- private const string VerifyNotConfigured = "NotConfigured";
- private const string VerifySuccess = "Success";
- private const string VerifyFailed = "Failed";
- /// <summary>
- /// 使用自动版本更新中间件
- /// </summary>
- public static IApplicationBuilder UseAutoVersionUpdate(this IApplicationBuilder app)
- {
- LogHelper.Info("AutoVersionUpdate 中间件运行");
- var snowIdOpt = App.GetConfig<SnowIdOptions>("SnowId", true);
- if (snowIdOpt.WorkerId != 1)
- {
- LogHelper.Handle("非主节点,不执行脚本");
- return app;
- }
- var stopOnFailure = App.GetConfig<bool?>("AutoVersionUpdate:StopApplicationOnFailure") ?? true;
- try
- {
- RunPendingMigrations(app);
- LogHelper.Success("AutoVersionUpdate 中间件结束");
- }
- catch (Exception ex)
- {
- LogHelper.Error($"AutoVersionUpdate 执行失败:{ex.Message}");
- if (stopOnFailure)
- throw;
- }
- return app;
- }
- private static void RunPendingMigrations(IApplicationBuilder app)
- {
- using var scope = App.GetRequiredService<IServiceScopeFactory>().CreateScope();
- var db = scope.ServiceProvider.GetRequiredService<ISqlSugarClient>();
- EnsureMigrationLogTable(db);
- var currentVersionText = GetEntryAssemblyCurrentVersion();
- var currentVersion = ParseVersion(currentVersionText);
- var historyFromTxt = GetEntryAssemblyHistoryVersionInfo();
- var databaseName = GetDatabaseName(db);
- LogHelper.Handle($"当前版本:{currentVersionText},目标数据库:{databaseName ?? "(unknown)"}");
- var scripts = LoadMigrationScripts();
- var migrationRows = LoadMigrationLogs(db);
- var successVersions = migrationRows
- .Where(x => string.Equals(x.Status, StatusSuccess, StringComparison.OrdinalIgnoreCase))
- .Select(x => ParseVersion(x.Version))
- .ToHashSet();
- Version? legacyBoundary = null;
- if (migrationRows.Count == 0 &&
- !string.IsNullOrWhiteSpace(historyFromTxt.Version) &&
- historyFromTxt.IsRunScript &&
- Version.TryParse(historyFromTxt.Version, out var legacyVersion))
- {
- legacyBoundary = legacyVersion;
- LogHelper.Handle(
- $"检测到 {MigrationLogTable} 为空但 version.txt 存在,legacy 跳过边界:{legacyBoundary}");
- }
- var pending = scripts
- .Where(s => s.ParsedVersion <= currentVersion)
- .Where(s => !ShouldSkipScript(s, migrationRows, legacyBoundary))
- .OrderBy(s => s.ParsedVersion)
- .ToList();
- LogHelper.Handle($"发现脚本 {scripts.Count} 个,已成功 {successVersions.Count} 个,待执行 {pending.Count} 个");
- if (pending.Count == 0)
- {
- SetEntryAssemblyCurrentVersion(currentVersionText, true);
- return;
- }
- foreach (var script in pending)
- {
- ExecuteOneMigrationScript(db, script, databaseName);
- }
- SetEntryAssemblyCurrentVersion(currentVersionText, true);
- }
- private static bool ShouldSkipScript(
- MigrationScript script,
- List<MigrationLogRow> migrationRows,
- Version? legacyBoundary)
- {
- var row = migrationRows.FirstOrDefault(x =>
- string.Equals(x.Version, script.Version, StringComparison.OrdinalIgnoreCase));
- if (row != null)
- {
- if (string.Equals(row.Status, StatusSuccess, StringComparison.OrdinalIgnoreCase))
- {
- if (!string.Equals(row.FileHash, script.Hash, StringComparison.OrdinalIgnoreCase))
- {
- throw new InvalidOperationException(
- $"版本脚本 {script.FileName} 的 SHA256 与已成功记录不一致,禁止静默覆盖。请新建更高版本脚本。");
- }
- LogHelper.Handle($"版本 {script.Version} 已成功且 hash 未变,跳过");
- return true;
- }
- return false;
- }
- if (legacyBoundary != null && script.ParsedVersion <= legacyBoundary)
- {
- LogHelper.Handle($"版本 {script.Version} 处于 legacy 边界内,跳过");
- return true;
- }
- return false;
- }
- private static void ExecuteOneMigrationScript(ISqlSugarClient db, MigrationScript script, string? databaseName)
- {
- var sql = File.ReadAllText(script.FilePath);
- if (string.IsNullOrWhiteSpace(sql))
- {
- LogHelper.Handle($"版本 {script.Version} 脚本为空,记录 Skipped");
- UpsertMigrationLog(db, script, databaseName, StatusSkipped, 0, 0, VerifyNotConfigured, null, 0);
- return;
- }
- if (SqlScriptSplitter.ContainsDelimiterDirective(sql))
- {
- throw new InvalidOperationException(
- $"版本脚本 {script.FileName} 包含 DELIMITER 指令,当前执行器不支持,请改为普通 SQL 或手工执行。");
- }
- var startedAt = DateTime.Now;
- var sw = Stopwatch.StartNew();
- UpsertMigrationLog(db, script, databaseName, StatusRunning, 0, 0, VerifyNotConfigured, null, 0);
- try
- {
- LogHelper.Handle($"执行版本 {script.Version} 脚本 {script.FileName},SHA256={script.Hash}");
- var executeResult = SqlScriptSplitter.Execute(db, sql);
- var verifyStatus = RunVerifyScriptIfExists(db, script);
- sw.Stop();
- UpsertMigrationLog(
- db,
- script,
- databaseName,
- StatusSuccess,
- executeResult.StatementCount,
- executeResult.AffectedRows,
- verifyStatus,
- null,
- sw.ElapsedMilliseconds);
- LogHelper.Handle(
- $"版本 {script.Version} 成功:语句 {executeResult.StatementCount} 条,影响行 {executeResult.AffectedRows},校验 {verifyStatus},耗时 {sw.ElapsedMilliseconds}ms");
- }
- catch (Exception ex)
- {
- sw.Stop();
- var message = BuildExceptionMessage(ex);
- UpsertMigrationLog(
- db,
- script,
- databaseName,
- StatusFailed,
- 0,
- 0,
- VerifyFailed,
- message,
- sw.ElapsedMilliseconds);
- LogHelper.Error($"AutoVersionUpdate 版本 {script.Version} 失败:{message}");
- throw new InvalidOperationException($"AutoVersionUpdate 版本 {script.Version} 执行失败:{message}", ex);
- }
- }
- private static string RunVerifyScriptIfExists(ISqlSugarClient db, MigrationScript script)
- {
- var verifyPath = Path.ChangeExtension(script.FilePath, ".verify.sql");
- if (!File.Exists(verifyPath))
- return VerifyNotConfigured;
- var verifySql = File.ReadAllText(verifyPath);
- var statements = SqlScriptSplitter.Split(verifySql);
- if (statements.Count == 0)
- return VerifyNotConfigured;
- for (var i = 0; i < statements.Count; i++)
- {
- var statement = statements[i];
- var result = db.Ado.SqlQuerySingle<object>(statement);
- if (!IsTruthy(result))
- {
- throw new InvalidOperationException(
- $"校验 SQL 第 {i + 1} 条未通过:{TrimForLog(statement)}");
- }
- }
- return VerifySuccess;
- }
- private static bool IsTruthy(object? value)
- {
- if (value == null || value is DBNull)
- return false;
- return value switch
- {
- bool b => b,
- byte or sbyte or short or ushort or int or uint or long or ulong or float or double or decimal =>
- Convert.ToDecimal(value) != 0,
- string s => !string.IsNullOrWhiteSpace(s) &&
- !string.Equals(s, "0", StringComparison.OrdinalIgnoreCase) &&
- !string.Equals(s, "false", StringComparison.OrdinalIgnoreCase),
- _ => true
- };
- }
- private static void EnsureMigrationLogTable(ISqlSugarClient db)
- {
- db.Ado.ExecuteCommand(
- """
- 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 '校验状态',
- 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='数据库版本脚本执行记录'
- """);
- }
- private static List<MigrationLogRow> LoadMigrationLogs(ISqlSugarClient db)
- {
- try
- {
- return db.Ado.SqlQuery<MigrationLogRow>(
- $"""
- SELECT version AS Version, file_hash AS FileHash, status AS Status
- FROM {MigrationLogTable}
- """);
- }
- catch
- {
- return [];
- }
- }
- private static void UpsertMigrationLog(
- ISqlSugarClient db,
- MigrationScript script,
- string? databaseName,
- string status,
- int statementCount,
- long affectedRows,
- string verifyStatus,
- string? errorMessage,
- long elapsedMs)
- {
- var now = DateTime.Now;
- var existing = db.Ado.SqlQuerySingle<int?>(
- $"SELECT id FROM {MigrationLogTable} WHERE version = @version LIMIT 1",
- new { version = script.Version });
- if (existing is > 0)
- {
- db.Ado.ExecuteCommand(
- $"""
- UPDATE {MigrationLogTable}
- SET file_name = @fileName,
- file_hash = @fileHash,
- status = @status,
- started_at = CASE WHEN @status = '{StatusRunning}' THEN @startedAt ELSE started_at END,
- finished_at = @finishedAt,
- elapsed_ms = @elapsedMs,
- statement_count = @statementCount,
- affected_rows = @affectedRows,
- verify_status = @verifyStatus,
- error_message = @errorMessage,
- database_name = @databaseName,
- updated_at = @updatedAt
- WHERE version = @version
- """,
- new
- {
- version = script.Version,
- fileName = script.FileName,
- fileHash = script.Hash,
- status,
- startedAt = now,
- finishedAt = status is StatusRunning ? (DateTime?)null : now,
- elapsedMs = status is StatusRunning ? (long?)null : elapsedMs,
- statementCount,
- affectedRows,
- verifyStatus,
- errorMessage,
- databaseName,
- updatedAt = now
- });
- return;
- }
- db.Ado.ExecuteCommand(
- $"""
- INSERT INTO {MigrationLogTable}
- (version, file_name, file_hash, status, started_at, finished_at, elapsed_ms,
- statement_count, affected_rows, verify_status, error_message, database_name)
- VALUES
- (@version, @fileName, @fileHash, @status, @startedAt, @finishedAt, @elapsedMs,
- @statementCount, @affectedRows, @verifyStatus, @errorMessage, @databaseName)
- """,
- new
- {
- version = script.Version,
- fileName = script.FileName,
- fileHash = script.Hash,
- status,
- startedAt = now,
- finishedAt = status is StatusRunning ? (DateTime?)null : now,
- elapsedMs = status is StatusRunning ? (long?)null : elapsedMs,
- statementCount,
- affectedRows,
- verifyStatus,
- errorMessage,
- databaseName
- });
- }
- private static List<MigrationScript> LoadMigrationScripts()
- {
- var path = Path.Combine(AppContext.BaseDirectory, "UpdateScripts");
- if (!Directory.Exists(path))
- return [];
- return Directory.GetFiles(path, "*.sql", SearchOption.TopDirectoryOnly)
- .Where(file =>
- {
- var name = Path.GetFileName(file);
- return !name.EndsWith(".verify.sql", StringComparison.OrdinalIgnoreCase);
- })
- .Select(file =>
- {
- var versionText = Path.GetFileNameWithoutExtension(file);
- if (!Version.TryParse(versionText, out var parsedVersion))
- return null;
- return new MigrationScript(
- versionText,
- parsedVersion,
- file,
- Path.GetFileName(file),
- ComputeSha256(file));
- })
- .Where(x => x != null)
- .Cast<MigrationScript>()
- .OrderBy(x => x.ParsedVersion)
- .ToList();
- }
- private static string GetEntryAssemblyCurrentVersion()
- {
- var entryAssemblyVersion = ReflectionHelper.GetEntryAssemblyVersion();
- return entryAssemblyVersion.ToString(3);
- }
- private static void SetEntryAssemblyCurrentVersion(string version, bool isRunScript)
- {
- var path = Path.Combine(AppContext.BaseDirectory, "version.txt");
- var now = DateTime.Now;
- File.WriteAllText(path, $"{version}^{now:yyyy-MM-dd HH:mm:ss}^{isRunScript}");
- }
- private static HistoryVersionInfo GetEntryAssemblyHistoryVersionInfo()
- {
- var path = Path.Combine(AppContext.BaseDirectory, "version.txt");
- if (!File.Exists(path))
- return new HistoryVersionInfo(string.Empty, string.Empty, false);
- var info = File.ReadAllText(path);
- if (!info.Contains('^'))
- return new HistoryVersionInfo(string.Empty, string.Empty, false);
- var parts = info.Split('^');
- var version = parts.Length > 0 ? parts[0] : string.Empty;
- var date = parts.Length > 1 ? parts[1] : string.Empty;
- var isRunScript = parts.Length > 2 && parts[2].ToBoolean();
- return new HistoryVersionInfo(version, date, isRunScript);
- }
- private static Version ParseVersion(string value)
- {
- if (!Version.TryParse(value, out var version))
- throw new InvalidOperationException($"非法版本号:{value}");
- return version;
- }
- private static string ComputeSha256(string filePath)
- {
- using var sha = SHA256.Create();
- using var stream = File.OpenRead(filePath);
- return Convert.ToHexString(sha.ComputeHash(stream));
- }
- private static string? GetDatabaseName(ISqlSugarClient db)
- {
- try
- {
- return db.Ado.GetString("SELECT DATABASE()");
- }
- catch
- {
- return null;
- }
- }
- private static string BuildExceptionMessage(Exception ex)
- {
- var messages = new List<string>();
- for (var current = ex; current != null; current = current.InnerException)
- messages.Add(current.Message);
- return string.Join(" | ", messages);
- }
- private static string TrimForLog(string sql, int maxLength = 300)
- {
- var normalized = sql.Replace('\r', ' ').Replace('\n', ' ').Trim();
- return normalized.Length <= maxLength ? normalized : normalized[..maxLength] + "...";
- }
- private sealed record MigrationScript(
- string Version,
- Version ParsedVersion,
- string FilePath,
- string FileName,
- string Hash);
- private sealed class MigrationLogRow
- {
- public string Version { get; set; } = string.Empty;
- public string FileHash { get; set; } = string.Empty;
- public string Status { get; set; } = string.Empty;
- }
- private sealed record ScriptExecuteResult(int StatementCount, long AffectedRows);
- private static class SqlScriptSplitter
- {
- public static bool ContainsDelimiterDirective(string sql) =>
- sql.Contains("DELIMITER", StringComparison.OrdinalIgnoreCase);
- public static ScriptExecuteResult Execute(ISqlSugarClient db, string sql)
- {
- if (RequiresSameSessionExecution(sql))
- return ExecuteAsBatch(db, sql);
- return ExecuteSplitWithSharedConnection(db, sql);
- }
- private static bool RequiresSameSessionExecution(string sql) =>
- sql.Contains("PREPARE", StringComparison.OrdinalIgnoreCase) ||
- Regex.IsMatch(sql, @"\bSET\s+@", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant) ||
- Regex.IsMatch(sql, @"\bSELECT\b.+\bINTO\s+@", RegexOptions.IgnoreCase | RegexOptions.Singleline);
- private static ScriptExecuteResult ExecuteAsBatch(ISqlSugarClient db, string sql)
- {
- var wasOpen = EnsureConnectionOpen(db);
- try
- {
- var affected = db.Ado.ExecuteCommand(sql);
- var statementCount = Math.Max(1, Split(sql).Count);
- return new ScriptExecuteResult(statementCount, affected);
- }
- finally
- {
- RestoreConnection(db, wasOpen);
- }
- }
- private static ScriptExecuteResult ExecuteSplitWithSharedConnection(ISqlSugarClient db, string sql)
- {
- var statements = Split(sql);
- long affectedRows = 0;
- var wasOpen = EnsureConnectionOpen(db);
- try
- {
- for (var i = 0; i < statements.Count; i++)
- {
- var statement = statements[i];
- try
- {
- affectedRows += db.Ado.ExecuteCommand(statement);
- }
- catch (Exception ex)
- {
- throw new InvalidOperationException(
- $"第 {i + 1}/{statements.Count} 条语句失败:{TrimForLog(statement)} | {ex.Message}", ex);
- }
- }
- return new ScriptExecuteResult(statements.Count, affectedRows);
- }
- finally
- {
- RestoreConnection(db, wasOpen);
- }
- }
- private static bool EnsureConnectionOpen(ISqlSugarClient db)
- {
- var connection = db.Ado.Connection;
- if (connection.State == ConnectionState.Open)
- return true;
- connection.Open();
- return false;
- }
- private static void RestoreConnection(ISqlSugarClient db, bool wasOpen)
- {
- if (wasOpen)
- return;
- var connection = db.Ado.Connection;
- if (connection.State == ConnectionState.Open)
- connection.Close();
- }
- public static List<string> Split(string sql)
- {
- var statements = new List<string>();
- if (string.IsNullOrWhiteSpace(sql))
- return statements;
- var current = new StringBuilder();
- var inSingleQuote = false;
- var inDoubleQuote = false;
- var inBacktick = false;
- var inLineComment = false;
- var inBlockComment = false;
- for (var i = 0; i < sql.Length; i++)
- {
- var ch = sql[i];
- var next = i + 1 < sql.Length ? sql[i + 1] : '\0';
- if (inLineComment)
- {
- current.Append(ch);
- if (ch is '\n' or '\r')
- inLineComment = false;
- continue;
- }
- if (inBlockComment)
- {
- current.Append(ch);
- if (ch == '*' && next == '/')
- {
- current.Append(next);
- i++;
- inBlockComment = false;
- }
- continue;
- }
- if (!inSingleQuote && !inDoubleQuote && !inBacktick)
- {
- if (ch == '-' && next == '-')
- {
- inLineComment = true;
- current.Append(ch);
- continue;
- }
- if (ch == '#')
- {
- inLineComment = true;
- current.Append(ch);
- continue;
- }
- if (ch == '/' && next == '*')
- {
- inBlockComment = true;
- current.Append(ch);
- continue;
- }
- }
- if (!inDoubleQuote && !inBacktick && ch == '\'' && !inSingleQuote)
- {
- inSingleQuote = true;
- current.Append(ch);
- continue;
- }
- if (inSingleQuote)
- {
- current.Append(ch);
- if (ch == '\'' && next == '\'')
- {
- current.Append(next);
- i++;
- continue;
- }
- if (ch == '\\' && next != '\0')
- {
- current.Append(next);
- i++;
- continue;
- }
- if (ch == '\'')
- inSingleQuote = false;
- continue;
- }
- if (!inSingleQuote && !inBacktick && ch == '"' && !inDoubleQuote)
- {
- inDoubleQuote = true;
- current.Append(ch);
- continue;
- }
- if (inDoubleQuote)
- {
- current.Append(ch);
- if (ch == '"' && next == '"')
- {
- current.Append(next);
- i++;
- continue;
- }
- if (ch == '\\' && next != '\0')
- {
- current.Append(next);
- i++;
- continue;
- }
- if (ch == '"')
- inDoubleQuote = false;
- continue;
- }
- if (!inSingleQuote && !inDoubleQuote && ch == '`' && !inBacktick)
- {
- inBacktick = true;
- current.Append(ch);
- continue;
- }
- if (inBacktick)
- {
- current.Append(ch);
- if (ch == '`')
- inBacktick = false;
- continue;
- }
- if (ch == ';')
- {
- AppendStatement(statements, current);
- continue;
- }
- current.Append(ch);
- }
- AppendStatement(statements, current);
- return statements;
- }
- private static void AppendStatement(List<string> statements, StringBuilder current)
- {
- var text = current.ToString().Trim();
- current.Clear();
- if (string.IsNullOrWhiteSpace(text))
- return;
- statements.Add(text);
- }
- }
- }
- public record HistoryVersionInfo(string Version, string Date, bool IsRunScript);
- #endif // NET10_0_OR_GREATER
|