986 lines
36 KiB
C#
986 lines
36 KiB
C#
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;
|
||
|
||
/// <summary>
|
||
/// 构造函数
|
||
/// </summary>
|
||
/// <param name="db">数据库上下文</param>
|
||
/// <param name="config">配置</param>
|
||
/// <param name="env">Web主机环境</param>
|
||
/// <param name="operationLog">操作日志服务</param>
|
||
/// <param name="fileStorage">文件存储服务</param>
|
||
/// <param name="uploadTracking">上传跟踪服务</param>
|
||
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<ApiResponse<List<SystemSettingDto>>> 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<List<SystemSettingDto>>.Success(settings);
|
||
}
|
||
|
||
public async Task<ApiResponse> 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<string, string>();
|
||
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<ApiResponse<SystemSettingDto>> GetSettingAsync(string key)
|
||
{
|
||
var setting = await _db.SystemSettings.FirstOrDefaultAsync(s => s.Key == key);
|
||
if (setting == null)
|
||
return ApiResponse<SystemSettingDto>.Fail("设置项不存在", 404);
|
||
|
||
return ApiResponse<SystemSettingDto>.Success(new SystemSettingDto
|
||
{
|
||
Key = setting.Key,
|
||
Value = setting.Value,
|
||
Description = setting.Description
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取公开系统设置(无需鉴权):返回平台名、Logo(签名URL)、备案号等供前台显示
|
||
/// Logo 字段在库中存储的是上传时返回的签名URL,这里通过 ExtractKey + GetSignedUrl 刷新为最新签名URL,避免过期
|
||
/// </summary>
|
||
public async Task<ApiResponse<PublicSettingsDto>> GetPublicSettingsAsync()
|
||
{
|
||
var keys = new List<string>
|
||
{
|
||
"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<PublicSettingsDto>.Success(dto);
|
||
}
|
||
|
||
// ===== 关于我们内容管理 =====
|
||
|
||
/// <summary>
|
||
/// 获取关于我们设置(公开端点,前台 About 页消费)
|
||
/// </summary>
|
||
public async Task<ApiResponse<AboutSettingsDto>> GetAboutSettingsAsync()
|
||
{
|
||
var keys = new List<string> { "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<AboutContentDto>(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<AboutSettingsDto>.Success(dto);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 更新关于我们设置(Admin 端点)
|
||
/// </summary>
|
||
public async Task<ApiResponse> UpdateAboutSettingsAsync(AboutSettingsDto dto)
|
||
{
|
||
var keys = new List<string> { "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("保存成功");
|
||
}
|
||
|
||
// ===== 工厂重置 =====
|
||
|
||
/// <summary>
|
||
/// 恢复出厂设置:清空所有业务数据(保留 admin 账户)+ 删除 COS 资源(保留 backups/)
|
||
/// 重置前自动创建数据库备份作为安全网
|
||
/// </summary>
|
||
public async Task<ApiResponse<FactoryResetResultDto>> FactoryResetAsync(FactoryResetRequest request)
|
||
{
|
||
if (request?.ConfirmText != "RESET")
|
||
{
|
||
return ApiResponse<FactoryResetResultDto>.Fail("确认文本不正确,请输入 RESET");
|
||
}
|
||
|
||
// 1. 重置前先创建数据库备份
|
||
var backupResult = await CreateBackupAsync();
|
||
var backupId = backupResult.Data?.Id ?? 0;
|
||
|
||
// 2. 列出所有 COS 对象(在清库前读取配置,保证配置可用)
|
||
// 容错:即使 COS 列举失败,仍继续清库,确保数据库恢复出厂状态
|
||
List<string> 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<FactoryResetResultDto>.Success(new FactoryResetResultDto
|
||
{
|
||
TablesCleared = tables.Length + 1, // +1 for Users
|
||
ObjectsDeleted = deletedCount,
|
||
BackupId = backupId
|
||
}, "工厂重置完成");
|
||
}
|
||
|
||
// ===== 操作日志 =====
|
||
|
||
public async Task<ApiResponse<PagedResult<OperationLogDto>>> 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<PagedResult<OperationLogDto>>.Success(
|
||
PagedResult<OperationLogDto>.Create(items, total, request.PageIndex, request.PageSize));
|
||
}
|
||
|
||
public async Task<ApiResponse> 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<ApiResponse> 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<ApiResponse<List<BackupDto>>> 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<List<BackupDto>>.Success(backups);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 将备份类型映射为中文:Manual->手动,Auto->自动,其余原样返回
|
||
/// </summary>
|
||
private static string MapBackupType(string type) => type switch
|
||
{
|
||
"Manual" => "手动",
|
||
"Auto" => "自动",
|
||
_ => string.IsNullOrEmpty(type) ? "手动" : type
|
||
};
|
||
|
||
public async Task<ApiResponse<BackupDto>> 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<BackupDto>.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<BackupDto>.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<ApiResponse> 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<ApiResponse<(Stream Stream, string FileName)>> 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<ApiResponse<List<WakeProtocolDto>>> 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<List<WakeProtocolDto>>.Success(list);
|
||
}
|
||
|
||
public async Task<ApiResponse<WakeProtocolDto>> 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<WakeProtocolDto>.Success(MapToDto(wp));
|
||
}
|
||
|
||
public async Task<ApiResponse<WakeProtocolDto>> UpdateWakeProtocolAsync(long id, CreateWakeProtocolRequest request)
|
||
{
|
||
var wp = await _db.WakeProtocols.FindAsync(id);
|
||
if (wp == null)
|
||
return ApiResponse<WakeProtocolDto>.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<WakeProtocolDto>.Success(MapToDto(wp));
|
||
}
|
||
|
||
public async Task<ApiResponse> 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("删除成功");
|
||
}
|
||
|
||
// ===== 本地存储目录测试 =====
|
||
|
||
/// <summary>
|
||
/// 测试本地存储目录是否可读写(保存存储配置前校验)
|
||
/// 支持相对路径(相对于 ContentRootPath)或绝对路径
|
||
/// </summary>
|
||
public Task<ApiResponse> 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<string> 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
|
||
};
|
||
|
||
/// <summary>
|
||
/// 尝试使用 mysqldump 导出数据库
|
||
/// </summary>
|
||
private async Task<bool> 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;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 回退方案:直接查询数据库生成 SQL 脚本
|
||
/// </summary>
|
||
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<string>();
|
||
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<string>();
|
||
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<string>();
|
||
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") + "'";
|
||
}
|
||
}
|