using System.Diagnostics; using System.Security.Cryptography; using System.Text.Json; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.Data.Sqlite; using MySql.Data.MySqlClient; namespace Jiaowu.Api.Infrastructure.Operations; public sealed record BackupArtifact( string Id, string FileName, string Provider, DateTime CreatedAt, long SizeBytes, string Sha256, string? Note, DateTime? LastDrillAt, bool? LastDrillSucceeded, string? LastDrillDetail, long? LastDrillDurationMilliseconds); public sealed record RestoreDrillResult( string BackupId, bool Succeeded, DateTime CompletedAt, string Detail, long DurationMilliseconds, int? TableCount); public sealed class DatabaseBackupService( DatabaseOptions databaseOptions, OperationsOptions options, IConfiguration configuration, IHostEnvironment environment, ILogger logger) { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true }; private readonly SemaphoreSlim operationLock = new(1, 1); private readonly string backupDirectory = ResolveBackupDirectory( options.BackupDirectory, environment.ContentRootPath); public async Task> ListAsync( CancellationToken cancellationToken) { Directory.CreateDirectory(backupDirectory); var items = new List(); foreach (var metadataPath in Directory.EnumerateFiles( backupDirectory, "*.metadata.json", SearchOption.TopDirectoryOnly)) { cancellationToken.ThrowIfCancellationRequested(); try { await using var stream = File.OpenRead(metadataPath); var artifact = await JsonSerializer.DeserializeAsync( stream, JsonOptions, cancellationToken); if (artifact is not null && File.Exists(Path.Combine(backupDirectory, artifact.FileName))) { items.Add(artifact); } } catch (Exception exception) when ( exception is IOException or UnauthorizedAccessException or JsonException) { logger.LogWarning( exception, "Unable to read backup metadata {MetadataFile}.", Path.GetFileName(metadataPath)); } } return items .OrderByDescending(x => x.CreatedAt) .ToArray(); } public async Task CreateAsync( string? note, CancellationToken cancellationToken) { await operationLock.WaitAsync(cancellationToken); try { Directory.CreateDirectory(backupDirectory); var createdAt = DateTime.UtcNow; var id = $"{createdAt:yyyyMMddHHmmss}-{Guid.NewGuid():N}"[..29]; var provider = NormalizeProvider(databaseOptions.Provider); var extension = provider == "SQLite" ? ".sqlite" : ".sql"; var fileName = $"jiaowu-{id}{extension}"; var backupPath = Path.Combine(backupDirectory, fileName); try { if (provider == "SQLite") await CreateSqliteBackupAsync(backupPath, cancellationToken); else await CreateMySqlBackupAsync(backupPath, cancellationToken); var fileInfo = new FileInfo(backupPath); var artifact = new BackupArtifact( id, fileName, provider, createdAt, fileInfo.Length, await ComputeHashAsync(backupPath, cancellationToken), NormalizeNote(note), null, null, null, null); await WriteMetadataAsync(artifact, cancellationToken); return artifact; } catch { if (File.Exists(backupPath)) File.Delete(backupPath); throw; } } finally { operationLock.Release(); } } public async Task RunRestoreDrillAsync( string backupId, CancellationToken cancellationToken) { await operationLock.WaitAsync(cancellationToken); try { var artifact = (await ListAsync(cancellationToken)) .SingleOrDefault(x => x.Id.Equals(backupId, StringComparison.Ordinal)); if (artifact is null) throw new FileNotFoundException("未找到指定备份。"); var backupPath = Path.Combine(backupDirectory, artifact.FileName); var actualHash = await ComputeHashAsync(backupPath, cancellationToken); if (!CryptographicOperations.FixedTimeEquals( Convert.FromHexString(artifact.Sha256), Convert.FromHexString(actualHash))) { var damaged = await CompleteDrillAsync( artifact, false, "备份文件校验和不一致,恢复演练已中止。", 0, null, cancellationToken); return damaged; } var stopwatch = Stopwatch.StartNew(); RestoreDrillResult result; try { var tableCount = artifact.Provider == "SQLite" ? await DrillSqliteAsync(backupPath, cancellationToken) : await DrillMySqlAsync(backupPath, cancellationToken); stopwatch.Stop(); result = await CompleteDrillAsync( artifact, true, $"已在隔离数据库完成恢复并通过完整性检查,共发现 {tableCount} 张业务表。", stopwatch.ElapsedMilliseconds, tableCount, cancellationToken); } catch (Exception exception) when (exception is not OperationCanceledException) { stopwatch.Stop(); logger.LogError( exception, "Restore drill failed for backup {BackupId}.", artifact.Id); result = await CompleteDrillAsync( artifact, false, $"恢复演练失败:{SafeMessage(exception)}", stopwatch.ElapsedMilliseconds, null, cancellationToken); } return result; } finally { operationLock.Release(); } } private async Task CreateSqliteBackupAsync( string backupPath, CancellationToken cancellationToken) { var sourceBuilder = new SqliteConnectionStringBuilder( configuration.GetConnectionString("SQLite") ?? throw new InvalidOperationException("缺少 ConnectionStrings:SQLite。")); if (!Path.IsPathRooted(sourceBuilder.DataSource)) { sourceBuilder.DataSource = Path.GetFullPath( sourceBuilder.DataSource, environment.ContentRootPath); } var destinationBuilder = new SqliteConnectionStringBuilder { DataSource = backupPath, Mode = SqliteOpenMode.ReadWriteCreate, Pooling = false }; await using var source = new SqliteConnection(sourceBuilder.ConnectionString); await using var destination = new SqliteConnection(destinationBuilder.ConnectionString); await source.OpenAsync(cancellationToken); await destination.OpenAsync(cancellationToken); source.BackupDatabase(destination); } private async Task CreateMySqlBackupAsync( string backupPath, CancellationToken cancellationToken) { var connection = GetMySqlConnectionBuilder(); var arguments = new List { "--protocol=tcp", $"--host={connection.Server}", $"--port={connection.Port}", $"--user={connection.UserID}", "--single-transaction", "--quick", "--routines", "--triggers", "--events", "--hex-blob", "--default-character-set=utf8mb4" }; arguments.AddRange(options.MySqlAdditionalArguments); arguments.Add(connection.Database); await using var output = new FileStream( backupPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, FileOptions.Asynchronous); await RunToolAsync( options.MySqlDumpPath, arguments, connection.Password, standardInput: null, standardOutput: output, cancellationToken); } private static async Task DrillSqliteAsync( string backupPath, CancellationToken cancellationToken) { var drillPath = Path.Combine( Path.GetDirectoryName(backupPath)!, $".restore-drill-{Guid.NewGuid():N}.sqlite"); try { var sourceBuilder = new SqliteConnectionStringBuilder { DataSource = backupPath, Mode = SqliteOpenMode.ReadOnly, Pooling = false }; var drillBuilder = new SqliteConnectionStringBuilder { DataSource = drillPath, Mode = SqliteOpenMode.ReadWriteCreate, Pooling = false }; await using var source = new SqliteConnection(sourceBuilder.ConnectionString); await using var drill = new SqliteConnection(drillBuilder.ConnectionString); await source.OpenAsync(cancellationToken); await drill.OpenAsync(cancellationToken); source.BackupDatabase(drill); await using var integrity = drill.CreateCommand(); integrity.CommandText = "PRAGMA integrity_check;"; var integrityResult = Convert.ToString( await integrity.ExecuteScalarAsync(cancellationToken)); if (!string.Equals(integrityResult, "ok", StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException($"SQLite 完整性检查返回 {integrityResult ?? "空结果"}。"); await using var tables = drill.CreateCommand(); tables.CommandText = "SELECT COUNT(*) FROM sqlite_master " + "WHERE type = 'table' AND name NOT LIKE 'sqlite_%';"; return Convert.ToInt32(await tables.ExecuteScalarAsync(cancellationToken)); } finally { if (File.Exists(drillPath)) File.Delete(drillPath); if (File.Exists($"{drillPath}-shm")) File.Delete($"{drillPath}-shm"); if (File.Exists($"{drillPath}-wal")) File.Delete($"{drillPath}-wal"); } } private async Task DrillMySqlAsync( string backupPath, CancellationToken cancellationToken) { var connection = GetMySqlConnectionBuilder(); var drillDatabase = $"jiaowu_restore_drill_{DateTime.UtcNow:yyyyMMddHHmmss}_" + Guid.NewGuid().ToString("N")[..8]; var adminBuilder = new MySqlConnectionStringBuilder(connection.ConnectionString) { Database = "" }; await using var admin = new MySqlConnection(adminBuilder.ConnectionString); await admin.OpenAsync(cancellationToken); try { await using (var create = admin.CreateCommand()) { create.CommandText = $"CREATE DATABASE `{drillDatabase}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"; await create.ExecuteNonQueryAsync(cancellationToken); } var arguments = new List { "--protocol=tcp", $"--host={connection.Server}", $"--port={connection.Port}", $"--user={connection.UserID}", "--default-character-set=utf8mb4" }; arguments.AddRange(options.MySqlAdditionalArguments); arguments.Add($"--database={drillDatabase}"); await using (var input = new FileStream( backupPath, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, FileOptions.Asynchronous)) { await RunToolAsync( options.MySqlClientPath, arguments, connection.Password, input, standardOutput: null, cancellationToken); } await using var count = admin.CreateCommand(); count.CommandText = "SELECT COUNT(*) FROM information_schema.tables " + "WHERE table_schema = @schema AND table_type = 'BASE TABLE';"; count.Parameters.AddWithValue("@schema", drillDatabase); return Convert.ToInt32(await count.ExecuteScalarAsync(cancellationToken)); } finally { try { await using var drop = admin.CreateCommand(); drop.CommandText = $"DROP DATABASE IF EXISTS `{drillDatabase}`;"; await drop.ExecuteNonQueryAsync(CancellationToken.None); } catch (Exception exception) { logger.LogCritical( exception, "Unable to remove isolated restore drill database {DatabaseName}.", drillDatabase); } } } private async Task RunToolAsync( string executable, IReadOnlyCollection arguments, string password, Stream? standardInput, Stream? standardOutput, CancellationToken cancellationToken) { var startInfo = new ProcessStartInfo { FileName = executable, UseShellExecute = false, RedirectStandardError = true, RedirectStandardInput = standardInput is not null, RedirectStandardOutput = standardOutput is not null, CreateNoWindow = true }; foreach (var argument in arguments) startInfo.ArgumentList.Add(argument); if (!string.IsNullOrEmpty(password)) startInfo.Environment["MYSQL_PWD"] = password; using var process = Process.Start(startInfo) ?? throw new InvalidOperationException($"无法启动数据库工具 {executable}。"); using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeout.CancelAfter(TimeSpan.FromMinutes(options.ToolTimeoutMinutes)); var errorTask = process.StandardError.ReadToEndAsync(timeout.Token); var inputTask = standardInput is null ? Task.CompletedTask : CopyInputAsync(standardInput, process.StandardInput.BaseStream, timeout.Token); var outputTask = standardOutput is null ? Task.CompletedTask : process.StandardOutput.BaseStream.CopyToAsync( standardOutput, timeout.Token); try { await Task.WhenAll( process.WaitForExitAsync(timeout.Token), inputTask, outputTask); } catch (OperationCanceledException) { if (!process.HasExited) process.Kill(entireProcessTree: true); throw; } var error = await errorTask; if (process.ExitCode != 0) throw new InvalidOperationException( $"数据库工具执行失败(退出码 {process.ExitCode}):{TrimToolError(error)}"); } private static async Task CopyInputAsync( Stream input, Stream processInput, CancellationToken cancellationToken) { await input.CopyToAsync(processInput, cancellationToken); await processInput.FlushAsync(cancellationToken); processInput.Close(); } private MySqlConnectionStringBuilder GetMySqlConnectionBuilder() { var value = configuration.GetConnectionString("OperationsMySql"); if (string.IsNullOrWhiteSpace(value)) { throw new InvalidOperationException( "MySQL 备份与恢复演练必须配置独立的 " + "ConnectionStrings:OperationsMySql 运维账号,不能复用日常业务账号。"); } var builder = new MySqlConnectionStringBuilder(value); if (string.IsNullOrWhiteSpace(builder.Database)) throw new InvalidOperationException( "OperationsMySql 连接字符串未指定业务数据库名称。"); return builder; } private async Task CompleteDrillAsync( BackupArtifact artifact, bool succeeded, string detail, long durationMilliseconds, int? tableCount, CancellationToken cancellationToken) { var completedAt = DateTime.UtcNow; var updated = artifact with { LastDrillAt = completedAt, LastDrillSucceeded = succeeded, LastDrillDetail = detail, LastDrillDurationMilliseconds = durationMilliseconds }; await WriteMetadataAsync(updated, cancellationToken); return new RestoreDrillResult( artifact.Id, succeeded, completedAt, detail, durationMilliseconds, tableCount); } private async Task WriteMetadataAsync( BackupArtifact artifact, CancellationToken cancellationToken) { var metadataPath = Path.Combine( backupDirectory, $"{artifact.Id}.metadata.json"); var temporaryPath = $"{metadataPath}.{Guid.NewGuid():N}.tmp"; try { await using (var stream = new FileStream( temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 16384, FileOptions.Asynchronous)) { await JsonSerializer.SerializeAsync( stream, artifact, JsonOptions, cancellationToken); } File.Move(temporaryPath, metadataPath, overwrite: true); } finally { if (File.Exists(temporaryPath)) File.Delete(temporaryPath); } } private static async Task ComputeHashAsync( string path, CancellationToken cancellationToken) { await using var stream = File.OpenRead(path); return Convert.ToHexString( await SHA256.HashDataAsync(stream, cancellationToken)); } private static string ResolveBackupDirectory( string configuredPath, string contentRoot) { if (string.IsNullOrWhiteSpace(configuredPath)) throw new InvalidOperationException("Operations:BackupDirectory 不能为空。"); return Path.IsPathRooted(configuredPath) ? Path.GetFullPath(configuredPath) : Path.GetFullPath(configuredPath, contentRoot); } private static string NormalizeProvider(string provider) => provider.Equals("SQLite", StringComparison.OrdinalIgnoreCase) ? "SQLite" : provider.Equals("MySql", StringComparison.OrdinalIgnoreCase) ? "MySql" : throw new InvalidOperationException($"不支持数据库 Provider '{provider}'。"); private static string? NormalizeNote(string? note) { if (string.IsNullOrWhiteSpace(note)) return null; var trimmed = note.Trim(); return trimmed.Length <= 200 ? trimmed : trimmed[..200]; } private static string SafeMessage(Exception exception) { var message = exception.GetBaseException().Message; return message.Length <= 500 ? message : message[..500]; } private static string TrimToolError(string error) { var trimmed = error.Trim(); if (trimmed.Length == 0) return "未返回错误详情。"; return trimmed.Length <= 500 ? trimmed : trimmed[..500]; } }