using System.Diagnostics; using System.Text; using Microsoft.EntityFrameworkCore; using MilliSim.Common.Dtos; using MilliSim.Common.Entities; using MilliSim.Common.Models; using MilliSim.Data; using MilliSim.Services.Infrastructure; namespace MilliSim.Services; public class SystemService : ISystemService { private readonly MilliSimDbContext _db; private readonly IConfiguration _config; private readonly IWebHostEnvironment _env; private readonly IOperationLogService _operationLog; private readonly IFileStorageService _fileStorage; private readonly IUploadTrackingService _uploadTracking; /// /// 构造函数 /// /// 数据库上下文 /// 配置 /// Web主机环境 /// 操作日志服务 /// 文件存储服务 /// 上传跟踪服务 public SystemService(MilliSimDbContext db, IConfiguration config, IWebHostEnvironment env, IOperationLogService operationLog, IFileStorageService fileStorage, IUploadTrackingService uploadTracking) { _db = db; _config = config; _env = env; _operationLog = operationLog; _fileStorage = fileStorage; _uploadTracking = uploadTracking; } // ===== 系统设置 ===== public async Task>> GetSettingsAsync() { var settings = await _db.SystemSettings .OrderBy(s => s.Id) .Select(s => new SystemSettingDto { Key = s.Key, Value = s.Value, Description = s.Description }) .ToListAsync(); // Logo 字段在 DB 中存储的是对象键,返回前需通过 GetSignedUrl 转换为可访问的 URL var logoKeys = new[] { "LogoFrontend", "LogoBackend", "LogoFavicon", "LogoLogin", "LogoFooter" }; foreach (var s in settings) { if (logoKeys.Contains(s.Key) && !string.IsNullOrEmpty(s.Value)) { s.Value = _fileStorage.GetSignedUrl(s.Value); } } return ApiResponse>.Success(settings); } public async Task UpdateSettingsAsync(UpdateSettingsRequest request) { if (request.Settings == null || request.Settings.Count == 0) return ApiResponse.Fail("没有需要更新的设置"); var keys = request.Settings.Keys.ToList(); var settings = await _db.SystemSettings .Where(s => keys.Contains(s.Key)) .ToListAsync(); // Logo 五项的 key 集合(用于对比旧值并清理旧文件) var logoKeys = new[] { "LogoFrontend", "LogoBackend", "LogoFavicon", "LogoLogin", "LogoFooter" }; // 保存前记录旧 Logo 值(用于对比变更后删除旧文件) var oldLogoValues = new Dictionary(); foreach (var setting in settings) { if (logoKeys.Contains(setting.Key)) { oldLogoValues[setting.Key] = setting.Value; } } foreach (var setting in settings) { if (request.Settings.TryGetValue(setting.Key, out var value)) { // Logo 字段需通过 ExtractKey 转为对象键存储,避免签名 URL 过期导致数据丢失 setting.Value = logoKeys.Contains(setting.Key) ? _fileStorage.ExtractKey(value) : value; setting.UpdatedAt = DateTime.Now; } } await _db.SaveChangesAsync(); // 保存后对比 Logo 变化:替换则删旧文件,新文件消费标记 foreach (var logoKey in logoKeys) { if (!request.Settings.TryGetValue(logoKey, out var newValue)) continue; var oldKey = _fileStorage.ExtractKey(oldLogoValues.GetValueOrDefault(logoKey) ?? ""); var newKey = _fileStorage.ExtractKey(newValue); if (!string.IsNullOrEmpty(oldKey) && oldKey != newKey) { await _fileStorage.DeleteAsync(oldKey); } if (!string.IsNullOrEmpty(newKey)) { await _uploadTracking.ConsumeAsync(newKey); } } // 存储相关设置变更时清除缓存,确保后续请求读取最新配置 var storageKeys = new[] { "StorageMode", "CosSecretId", "CosSecretKey", "CosBucket", "CosRegion", "CosSignedUrlExpireMinutes", "LocalBasePath", "LocalBaseUrl" }; if (request.Settings.Keys.Any(k => storageKeys.Contains(k))) { _fileStorage.ClearConfigCache(); } await _operationLog.LogAsync("系统设置", "更新", $"更新了 {settings.Count} 项设置"); return ApiResponse.Success("设置更新成功"); } public async Task> GetSettingAsync(string key) { var setting = await _db.SystemSettings.FirstOrDefaultAsync(s => s.Key == key); if (setting == null) return ApiResponse.Fail("设置项不存在", 404); return ApiResponse.Success(new SystemSettingDto { Key = setting.Key, Value = setting.Value, Description = setting.Description }); } /// /// 获取公开系统设置(无需鉴权):返回平台名、Logo(签名URL)、备案号等供前台显示 /// Logo 字段在库中存储的是上传时返回的签名URL,这里通过 ExtractKey + GetSignedUrl 刷新为最新签名URL,避免过期 /// public async Task> GetPublicSettingsAsync() { var keys = new List { "PlatformName", "Slogan", "CompanyName", "Contact", "ICP", "ContactPhone", "ContactEmail", "ContactAddress", "ContactWebsite", "ContactWorkTime", "LogoFrontend", "LogoBackend", "LogoFavicon", "LogoLogin", "LogoFooter", "ShowPartners", "ShowAbout" }; var settings = await _db.SystemSettings .Where(s => keys.Contains(s.Key)) .Select(s => new { s.Key, s.Value }) .ToListAsync(); string Get(string key) => settings.FirstOrDefault(s => s.Key == key)?.Value ?? string.Empty; string RefreshLogo(string key) { var raw = Get(key); if (string.IsNullOrEmpty(raw)) return string.Empty; var objectKey = _fileStorage.ExtractKey(raw); return string.IsNullOrEmpty(objectKey) ? raw : _fileStorage.GetSignedUrl(objectKey); } var dto = new PublicSettingsDto { PlatformName = Get("PlatformName"), Slogan = Get("Slogan"), CompanyName = Get("CompanyName"), Contact = Get("Contact"), ContactPhone = Get("ContactPhone"), ContactEmail = Get("ContactEmail"), ContactAddress = Get("ContactAddress"), ContactWebsite = Get("ContactWebsite"), ContactWorkTime = Get("ContactWorkTime"), ICP = Get("ICP"), LogoFrontend = RefreshLogo("LogoFrontend"), LogoBackend = RefreshLogo("LogoBackend"), LogoFavicon = RefreshLogo("LogoFavicon"), LogoLogin = RefreshLogo("LogoLogin"), LogoFooter = RefreshLogo("LogoFooter"), ShowPartners = Get("ShowPartners") != "false", ShowAbout = Get("ShowAbout") != "false", }; return ApiResponse.Success(dto); } // ===== 关于我们内容管理 ===== /// /// 获取关于我们设置(公开端点,前台 About 页消费) /// public async Task> GetAboutSettingsAsync() { var keys = new List { "ShowAbout", "AboutContent" }; var settings = await _db.SystemSettings .Where(s => keys.Contains(s.Key)) .Select(s => new { s.Key, s.Value }) .ToListAsync(); string Get(string key) => settings.FirstOrDefault(s => s.Key == key)?.Value ?? string.Empty; var dto = new AboutSettingsDto { ShowAbout = Get("ShowAbout") != "false", }; var contentJson = Get("AboutContent"); if (!string.IsNullOrEmpty(contentJson)) { try { dto.Content = System.Text.Json.JsonSerializer.Deserialize(contentJson) ?? new AboutContentDto(); // 刷新使命卡片图标的签名URL(存储的是COS对象键,读取时转换为签名URL) if (dto.Content.MissionCards != null) { foreach (var card in dto.Content.MissionCards) { if (!string.IsNullOrEmpty(card.Icon) && card.Icon.Contains('/')) { card.Icon = _fileStorage.GetSignedUrl(card.Icon); } } } } catch { dto.Content = new AboutContentDto(); } } return ApiResponse.Success(dto); } /// /// 更新关于我们设置(Admin 端点) /// public async Task UpdateAboutSettingsAsync(AboutSettingsDto dto) { var keys = new List { "ShowAbout", "AboutContent" }; var settings = await _db.SystemSettings .Where(s => keys.Contains(s.Key)) .ToListAsync(); var settingDict = settings.ToDictionary(s => s.Key, s => s); if (!settingDict.TryGetValue("ShowAbout", out var showAboutSetting)) { showAboutSetting = new SystemSetting { Key = "ShowAbout", Description = "前台是否显示关于我们页面" }; _db.SystemSettings.Add(showAboutSetting); } showAboutSetting.Value = dto.ShowAbout ? "true" : "false"; showAboutSetting.UpdatedAt = DateTime.Now; if (!settingDict.TryGetValue("AboutContent", out var contentSetting)) { contentSetting = new SystemSetting { Key = "AboutContent", Description = "关于我们页面内容(JSON)" }; _db.SystemSettings.Add(contentSetting); } // 保存前将使命卡片图标的签名URL转换为COS对象键,避免签名过期 var contentToSave = dto.Content ?? new AboutContentDto(); if (contentToSave.MissionCards != null) { foreach (var card in contentToSave.MissionCards) { if (!string.IsNullOrEmpty(card.Icon)) { card.Icon = _fileStorage.ExtractKey(card.Icon); } } } contentSetting.Value = System.Text.Json.JsonSerializer.Serialize(contentToSave); contentSetting.UpdatedAt = DateTime.Now; await _db.SaveChangesAsync(); await _operationLog.LogAsync("关于我们", "更新", "更新了关于我们页面内容"); return ApiResponse.Success("保存成功"); } // ===== 工厂重置 ===== /// /// 恢复出厂设置:清空所有业务数据(保留 admin 账户)+ 删除 COS 资源(保留 backups/) /// 重置前自动创建数据库备份作为安全网 /// public async Task> FactoryResetAsync(FactoryResetRequest request) { if (request?.ConfirmText != "RESET") { return ApiResponse.Fail("确认文本不正确,请输入 RESET"); } // 1. 重置前先创建数据库备份 var backupResult = await CreateBackupAsync(); var backupId = backupResult.Data?.Id ?? 0; // 2. 列出所有 COS 对象(在清库前读取配置,保证配置可用) // 容错:即使 COS 列举失败,仍继续清库,确保数据库恢复出厂状态 List keysToDelete = new(); try { var allKeys = await _fileStorage.ListAllObjectsAsync(); // 过滤掉 backups/ 前缀(保留数据库备份作为安全网) keysToDelete = allKeys.Where(k => !k.StartsWith("backups/", StringComparison.OrdinalIgnoreCase)).ToList(); } catch (Exception ex) { await _operationLog.LogAsync("工厂重置", "COS列举失败", $"列举对象失败,将继续清库。错误:{ex.Message}"); } // 3. 清空所有业务表(保留 admin 账户) var tables = new[] { "ProjectTagRelations", "ProjectScreenshots", "ProjectPackages", "Favorites", "ExperienceLogs", "Authorizations", "NotificationReads", "Notifications", "Consultations", "OperationLogs", "PendingUploads", "Projects", "Banners", "Partners", "ProjectCategories", "PlatformTypes", "FeatureTags", "WakeProtocols", "Backups", "SystemSettings" }; await _db.Database.ExecuteSqlRawAsync("SET FOREIGN_KEY_CHECKS = 0"); try { foreach (var table in tables) { await _db.Database.ExecuteSqlRawAsync("DELETE FROM `" + table + "`"); } // Users 表:仅删除非 admin 用户,并清空 admin 的 avatar(避免引用已删除的 COS 文件) await _db.Database.ExecuteSqlRawAsync("DELETE FROM `Users` WHERE `Username` != 'admin'"); await _db.Database.ExecuteSqlRawAsync("UPDATE `Users` SET `Avatar` = '' WHERE `Username` = 'admin'"); } finally { await _db.Database.ExecuteSqlRawAsync("SET FOREIGN_KEY_CHECKS = 1"); } // 4. 重新执行种子(admin 已存在跳过;分类/平台/标签/项目/轮播图/合作伙伴/设置按为空才补) await DbSeeder.SeedAsync(_db); // 5. 批量删除 COS 资源 var deletedCount = 0; try { deletedCount = await _fileStorage.DeleteObjectsAsync(keysToDelete); } catch (Exception ex) { // COS 删除失败不回滚 DB(DB 已是干净状态),仅记录日志 await _operationLog.LogAsync("工厂重置", "COS清理失败", ex.Message); } await _operationLog.LogAsync("工厂重置", "执行", $"清空 {tables.Length + 1} 张表,删除 {deletedCount} 个 COS 对象,备份ID {backupId}"); return ApiResponse.Success(new FactoryResetResultDto { TablesCleared = tables.Length + 1, // +1 for Users ObjectsDeleted = deletedCount, BackupId = backupId }, "工厂重置完成"); } // ===== 操作日志 ===== public async Task>> GetOperationLogsAsync(OperationLogQueryRequest request) { var query = _db.OperationLogs.AsQueryable(); if (request.UserId.HasValue) query = query.Where(l => l.UserId == request.UserId.Value); if (!string.IsNullOrEmpty(request.Username)) query = query.Where(l => l.Username.Contains(request.Username)); if (!string.IsNullOrEmpty(request.Module)) query = query.Where(l => l.Module.Contains(request.Module)); if (request.StartDate.HasValue) query = query.Where(l => l.CreatedAt >= request.StartDate.Value); if (request.EndDate.HasValue) query = query.Where(l => l.CreatedAt <= request.EndDate.Value); if (!string.IsNullOrEmpty(request.Keyword)) query = query.Where(l => l.Username.Contains(request.Keyword) || l.Operation.Contains(request.Keyword) || l.Detail.Contains(request.Keyword)); var total = await query.CountAsync(); var logEntities = await query .OrderByDescending(l => l.CreatedAt) .Skip((request.PageIndex - 1) * request.PageSize) .Take(request.PageSize) .ToListAsync(); var userIds = logEntities.Select(l => l.UserId).Distinct().ToList(); var nicknames = await _db.Users .Where(u => userIds.Contains(u.Id)) .ToDictionaryAsync(u => u.Id, u => u.Nickname); var items = logEntities.Select(l => new OperationLogDto { Id = l.Id, UserId = l.UserId, Username = l.Username, Nickname = nicknames.GetValueOrDefault(l.UserId) ?? string.Empty, Module = l.Module, Operation = l.Operation, Detail = l.Detail, Ip = l.Ip, CreatedAt = l.CreatedAt }).ToList(); return ApiResponse>.Success( PagedResult.Create(items, total, request.PageIndex, request.PageSize)); } public async Task ClearLogsAsync(DateTime? beforeDate) { int count; if (beforeDate.HasValue) { count = await _db.OperationLogs .Where(l => l.CreatedAt < beforeDate.Value) .ExecuteDeleteAsync(); await _operationLog.LogAsync("操作日志", "清理", $"清理了 {beforeDate.Value:yyyy-MM-dd} 之前的日志 {count} 条"); } else { count = await _db.OperationLogs.ExecuteDeleteAsync(); await _operationLog.LogAsync("操作日志", "清理", $"清空了全部日志 {count} 条"); } return ApiResponse.Success($"已清理 {count} 条日志"); } public async Task CleanExpiredLogsAsync() { var retentionDays = int.Parse(await GetSettingValueAsync("LogRetentionDays", "90")); var cutoff = DateTime.Now.AddDays(-retentionDays); var count = await _db.OperationLogs .Where(l => l.CreatedAt < cutoff) .ExecuteDeleteAsync(); return ApiResponse.Success($"已清理 {retentionDays} 天前的过期日志 {count} 条"); } // ===== 数据备份 ===== public async Task>> GetBackupsAsync() { var backups = await _db.Backups .OrderByDescending(b => b.CreatedAt) .Select(b => new BackupDto { Id = b.Id, FileName = b.FileName, FileSize = b.FileSize, Type = b.Type, Url = b.Url, CreatedAt = b.CreatedAt }) .ToListAsync(); foreach (var b in backups) { b.Type = MapBackupType(b.Type); } return ApiResponse>.Success(backups); } /// /// 将备份类型映射为中文:Manual->手动,Auto->自动,其余原样返回 /// private static string MapBackupType(string type) => type switch { "Manual" => "手动", "Auto" => "自动", _ => string.IsNullOrEmpty(type) ? "手动" : type }; public async Task> CreateBackupAsync() { // 生成本地备份文件 var dir = GetBackupDir(); var fileName = $"backup_{DateTime.Now:yyyyMMddHHmmss}.sql"; var filePath = Path.Combine(dir, fileName); var localUrl = $"/backups/{fileName}"; // 尝试使用 mysqldump 导出 var success = await TryMySqlDumpAsync(filePath); if (!success) { // 回退:使用 SQL 脚本生成 try { await GenerateSqlScriptAsync(filePath); success = true; } catch (Exception ex) { if (File.Exists(filePath)) File.Delete(filePath); return ApiResponse.Fail($"备份失败:{ex.Message}"); } } var fileInfo = new FileInfo(filePath); // 在 COS 上传前先保存文件大小,避免上传后本地文件被删除导致 FileNotFoundException var fileSize = fileInfo.Length; // 读取存储模式(Local=本地存储,Cos=腾讯云COS) var storageMode = await GetSettingValueAsync("StorageMode", "Cos"); string backupUrl = localUrl; // 如果是COS模式,将备份文件上传到COS if (storageMode.Equals("Cos", StringComparison.OrdinalIgnoreCase)) { try { // 创建FormFile用于上传到COS FileStream? fileStream = null; try { fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read); var formFile = new FormFile(fileStream, 0, fileStream.Length, "file", fileName) { Headers = new HeaderDictionary(), ContentType = "application/sql" }; // 上传备份文件到COS的backups目录 backupUrl = await _fileStorage.UploadAsync(formFile, "backups"); } finally { // 确保文件流被关闭 fileStream?.Dispose(); } // 上传成功后删除本地临时文件 if (File.Exists(filePath)) File.Delete(filePath); } catch { // COS上传失败,使用本地文件作为兜底 backupUrl = localUrl; } } // 创建备份记录(使用预先保存的文件大小) var backup = new Backup { FileName = fileName, FileSize = fileSize, Type = "Manual", Url = backupUrl }; _db.Backups.Add(backup); await _db.SaveChangesAsync(); await _operationLog.LogAsync("数据备份", "创建", $"手动创建备份 {fileName}"); return ApiResponse.Success(new BackupDto { Id = backup.Id, FileName = backup.FileName, FileSize = backup.FileSize, Type = MapBackupType(backup.Type), Url = backup.Url, CreatedAt = backup.CreatedAt }); } public async Task DeleteBackupAsync(long id) { var backup = await _db.Backups.FindAsync(id); if (backup == null) return ApiResponse.Fail("备份不存在", 404); // 判断备份文件存储位置(URL以"/"开头为本地文件,否则为COS对象键) if (backup.Url.StartsWith("/")) { // 本地文件删除 var dir = GetBackupDir(); var filePath = Path.Combine(dir, backup.FileName); if (File.Exists(filePath)) File.Delete(filePath); } else { // COS文件删除 await _fileStorage.DeleteAsync(backup.Url); } _db.Backups.Remove(backup); await _db.SaveChangesAsync(); await _operationLog.LogAsync("数据备份", "删除", $"删除备份 {backup.FileName}"); return ApiResponse.Success("备份已删除"); } public async Task> DownloadBackupAsync(long id) { var backup = await _db.Backups.FindAsync(id); if (backup == null) return ApiResponse<(Stream, string)>.Fail("备份不存在", 404); // 判断备份文件存储位置(URL以"/"开头为本地文件,否则为COS对象键) if (backup.Url.StartsWith("/")) { // 本地文件下载 var dir = GetBackupDir(); var filePath = Path.Combine(dir, backup.FileName); if (!File.Exists(filePath)) return ApiResponse<(Stream, string)>.Fail("备份文件不存在", 404); var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); return ApiResponse<(Stream, string)>.Success((stream, backup.FileName)); } else { // COS文件下载 try { var (stream, _, _) = await _fileStorage.DownloadAsync(backup.Url); return ApiResponse<(Stream, string)>.Success((stream, backup.FileName)); } catch { return ApiResponse<(Stream, string)>.Fail("备份文件不存在", 404); } } } public async Task CleanExpiredBackupsAsync() { // 读取备份保留天数 var retentionDays = int.Parse(await GetSettingValueAsync("BackupRetentionDays", "30")); var cutoff = DateTime.Now.AddDays(-retentionDays); // 查询过期备份 var expired = await _db.Backups .Where(b => b.CreatedAt < cutoff) .ToListAsync(); // 遍历删除过期备份文件 foreach (var b in expired) { // 判断备份文件存储位置(URL以"/"开头为本地文件,否则为COS对象键) if (b.Url.StartsWith("/")) { // 本地文件删除 var dir = GetBackupDir(); var filePath = Path.Combine(dir, b.FileName); if (File.Exists(filePath)) File.Delete(filePath); } else { // COS文件删除 await _fileStorage.DeleteAsync(b.Url); } } // 删除数据库记录 _db.Backups.RemoveRange(expired); await _db.SaveChangesAsync(); } // ===== 唤醒协议 ===== public async Task>> GetWakeProtocolsAsync() { var list = await _db.WakeProtocols .OrderBy(w => w.Id) .Select(w => new WakeProtocolDto { Id = w.Id, TerminalType = w.TerminalType, ProtocolPrefix = w.ProtocolPrefix, ParamTemplate = w.ParamTemplate, Status = w.Status }) .ToListAsync(); return ApiResponse>.Success(list); } public async Task> CreateWakeProtocolAsync(CreateWakeProtocolRequest request) { var wp = new WakeProtocol { TerminalType = request.TerminalType, ProtocolPrefix = request.ProtocolPrefix, ParamTemplate = request.ParamTemplate, Status = request.Status }; _db.WakeProtocols.Add(wp); await _db.SaveChangesAsync(); await _operationLog.LogAsync("唤醒协议", "创建", $"创建唤醒协议 {wp.TerminalType}"); return ApiResponse.Success(MapToDto(wp)); } public async Task> UpdateWakeProtocolAsync(long id, CreateWakeProtocolRequest request) { var wp = await _db.WakeProtocols.FindAsync(id); if (wp == null) return ApiResponse.Fail("唤醒协议不存在", 404); wp.TerminalType = request.TerminalType; wp.ProtocolPrefix = request.ProtocolPrefix; wp.ParamTemplate = request.ParamTemplate; wp.Status = request.Status; wp.UpdatedAt = DateTime.Now; await _db.SaveChangesAsync(); await _operationLog.LogAsync("唤醒协议", "更新", $"更新唤醒协议 {wp.TerminalType}"); return ApiResponse.Success(MapToDto(wp)); } public async Task DeleteWakeProtocolAsync(long id) { var wp = await _db.WakeProtocols.FindAsync(id); if (wp == null) return ApiResponse.Fail("唤醒协议不存在", 404); wp.IsDeleted = true; wp.UpdatedAt = DateTime.Now; await _db.SaveChangesAsync(); await _operationLog.LogAsync("唤醒协议", "删除", $"删除唤醒协议 {wp.TerminalType}"); return ApiResponse.Success("删除成功"); } // ===== 本地存储目录测试 ===== /// /// 测试本地存储目录是否可读写(保存存储配置前校验) /// 支持相对路径(相对于 ContentRootPath)或绝对路径 /// public Task TestLocalPathAsync(string localBasePath) { if (string.IsNullOrWhiteSpace(localBasePath)) return Task.FromResult(ApiResponse.Fail("存储根目录不能为空")); try { // 解析为绝对路径 var absolutePath = Path.IsPathRooted(localBasePath) ? localBasePath : Path.Combine(_env.ContentRootPath, localBasePath); // 确保目录存在 Directory.CreateDirectory(absolutePath); // 测试写入 var testFile = Path.Combine(absolutePath, $".millisim_test_{Guid.NewGuid():N}"); File.WriteAllText(testFile, "test"); // 测试读取 var content = File.ReadAllText(testFile); // 清理测试文件 File.Delete(testFile); if (content != "test") return Task.FromResult(ApiResponse.Fail("目录可写但读取内容不匹配,请检查目录权限")); return Task.FromResult(ApiResponse.Success($"目录可读写:{absolutePath}")); } catch (UnauthorizedAccessException) { return Task.FromResult(ApiResponse.Fail("目录无访问权限,请选择其他目录或修改权限")); } catch (ArgumentException ex) { return Task.FromResult(ApiResponse.Fail($"路径格式无效:{ex.Message}")); } catch (Exception ex) { return Task.FromResult(ApiResponse.Fail($"目录测试失败:{ex.Message}")); } } // ===== 辅助方法 ===== private async Task GetSettingValueAsync(string key, string defaultValue) { var setting = await _db.SystemSettings.FirstOrDefaultAsync(s => s.Key == key); return setting?.Value ?? defaultValue; } private string GetBackupDir() { var root = _env.WebRootPath ?? Path.Combine(_env.ContentRootPath, "wwwroot"); var dir = Path.Combine(root, "backups"); Directory.CreateDirectory(dir); return dir; } private static WakeProtocolDto MapToDto(WakeProtocol wp) => new() { Id = wp.Id, TerminalType = wp.TerminalType, ProtocolPrefix = wp.ProtocolPrefix, ParamTemplate = wp.ParamTemplate, Status = wp.Status }; /// /// 尝试使用 mysqldump 导出数据库 /// private async Task TryMySqlDumpAsync(string filePath) { try { var connectionString = _config.GetConnectionString("DefaultConnection")!; var builder = new MySqlConnector.MySqlConnectionStringBuilder(connectionString); var host = builder.Server; var port = builder.Port; var database = builder.Database; var user = builder.UserID; var password = builder.Password; var args = new StringBuilder(); args.Append($"-h{host} -P{port} -u{user}"); if (!string.IsNullOrEmpty(password)) args.Append($" -p\"{password}\""); args.Append(" --default-character-set=utf8mb4 --single-transaction --routines --triggers --skip-lock-tables"); args.Append($" \"{database}\""); var psi = new ProcessStartInfo { FileName = "mysqldump", Arguments = args.ToString(), UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true }; using var process = Process.Start(psi); if (process == null) return false; // 并发读取 stderr 防止缓冲区死锁 var stderrTask = process.StandardError.ReadToEndAsync(); await using (var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write)) { await process.StandardOutput.BaseStream.CopyToAsync(fileStream); await fileStream.FlushAsync(); } await process.WaitForExitAsync(); await stderrTask; if (process.ExitCode != 0) { if (File.Exists(filePath)) File.Delete(filePath); return false; } var fi = new FileInfo(filePath); if (fi.Length == 0) { File.Delete(filePath); return false; } return true; } catch { if (File.Exists(filePath)) File.Delete(filePath); return false; } } /// /// 回退方案:直接查询数据库生成 SQL 脚本 /// private async Task GenerateSqlScriptAsync(string filePath) { var connectionString = _config.GetConnectionString("DefaultConnection")!; await using var connection = new MySqlConnector.MySqlConnection(connectionString); await connection.OpenAsync(); var sb = new StringBuilder(); sb.AppendLine("-- MilliSim Database Backup"); sb.AppendLine($"-- Generated: {DateTime.Now:yyyy-MM-dd HH:mm:ss}"); sb.AppendLine("SET NAMES utf8mb4;"); sb.AppendLine("SET FOREIGN_KEY_CHECKS=0;"); sb.AppendLine(); var tables = new List(); await using (var cmd = connection.CreateCommand()) { cmd.CommandText = "SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_TYPE = 'BASE TABLE'"; await using var reader = await cmd.ExecuteReaderAsync(); while (await reader.ReadAsync()) { tables.Add(reader.GetString(0)); } } foreach (var table in tables) { // 表结构 await using (var createCmd = connection.CreateCommand()) { createCmd.CommandText = $"SHOW CREATE TABLE `{table}`"; await using var createReader = await createCmd.ExecuteReaderAsync(); if (await createReader.ReadAsync()) { sb.AppendLine($"-- ----------------------------"); sb.AppendLine($"-- Table structure for {table}"); sb.AppendLine($"-- ----------------------------"); sb.AppendLine($"DROP TABLE IF EXISTS `{table}`;"); sb.AppendLine(createReader.GetString(1) + ";"); sb.AppendLine(); } } // 表数据 await using (var dataCmd = connection.CreateCommand()) { dataCmd.CommandText = $"SELECT * FROM `{table}`"; await using var dataReader = await dataCmd.ExecuteReaderAsync(); var columns = new List(); for (var i = 0; i < dataReader.FieldCount; i++) { columns.Add(dataReader.GetName(i)); } var hasData = false; while (await dataReader.ReadAsync()) { hasData = true; var values = new List(); for (var i = 0; i < dataReader.FieldCount; i++) { values.Add(FormatSqlValue(dataReader.GetValue(i))); } sb.AppendLine($"INSERT INTO `{table}` (`{string.Join("`, `", columns)}`) VALUES ({string.Join(", ", values)});"); } if (hasData) sb.AppendLine(); } } sb.AppendLine("SET FOREIGN_KEY_CHECKS=1;"); await File.WriteAllTextAsync(filePath, sb.ToString()); } private static string FormatSqlValue(object? value) { if (value == null || value == DBNull.Value) return "NULL"; if (value is bool b) return b ? "1" : "0"; if (value is DateTime dt) return $"'{dt:yyyy-MM-dd HH:mm:ss}'"; if (value is byte[] bytes) return $"0x{Convert.ToHexString(bytes)}"; if (value is sbyte or short or int or long or byte or ushort or uint or ulong) return value.ToString()!; if (value is float or double or decimal) return value.ToString()!; var s = value.ToString() ?? string.Empty; return "'" + s.Replace("\\", "\\\\").Replace("'", "\\'").Replace("\n", "\\n").Replace("\r", "\\r") + "'"; } }