using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using System.Text; using WIDESEA_Core; using WIDESEA_Core.BaseController; using WIDESEA_ISystemService; using WIDESEA_Model.Models; namespace WIDESEA_WMSServer.Controllers { /// /// 日志 /// [Route("api/Sys_Log")] [ApiController] public class Sys_LogController : ApiBaseController { // 配置常量 private const int MAX_FILE_SIZE_MB = 50; private const int MAX_RETRY_COUNT = 3; private const int RETRY_DELAY_MS = 100; private static readonly string[] ALLOWED_FILE_TYPES = { ".txt", ".log", ".csv", ".json", ".xml" }; public Sys_LogController(ISys_LogService service) : base(service) { } [HttpPost, Route("GetLogName"), AllowAnonymous] public WebResponseContent GetLogName() { WebResponseContent content = new WebResponseContent(); try { List data = new List(); DirectoryInfo folder = new DirectoryInfo(AppContext.BaseDirectory + "\\logs\\"); DirectoryInfo[] firstDirectoryInfos = folder.GetDirectories().OrderByDescending(x => x.CreationTime).ToArray(); int k = 2020; for (int i = 0; i < firstDirectoryInfos.Length; i++) { if (firstDirectoryInfos[i].Name != "Info") { FileInfo[] nextFileInfos = firstDirectoryInfos[i].GetFiles(); List values = new List(); for (int j = 0; j < nextFileInfos.Length; j++) { values.Add(new { label = nextFileInfos[j].Name, id = k, hidden = true, fatherNode = firstDirectoryInfos[i].Name }); k++; } data.Add(new { label = firstDirectoryInfos[i].Name, children = values, id = i, hidden = false }); } } FileInfo[] nextFileInfo = folder.GetFiles(); List value = new List(); for (int j = 0; j < nextFileInfo.Length; j++) { value.Add(new { label = nextFileInfo[j].Name, id = k, hidden = true, fatherNode = folder.Name }); k++; } data.Add(new { label = folder.Name, children = value, id = 1, hidden = false }); return WebResponseContent.Instance.OK(data: data); } catch (Exception ex) { return WebResponseContent.Instance.Error(ex.Message); } } [HttpPost, Route("GetLog"), AllowAnonymous] public WebResponseContent GetLog(string fileName) { WebResponseContent content = new WebResponseContent(); try { List files = new List(); DirectoryInfo folder = new DirectoryInfo(AppContext.BaseDirectory + "\\logs\\"); DirectoryInfo[] firstDirectoryInfos = folder.GetDirectories(); for (int i = 0; i < firstDirectoryInfos.Length; i++) { FileInfo[] nextFileInfos = firstDirectoryInfos[i].GetFiles(); files.AddRange(nextFileInfos); } FileInfo[] nextFileInfo = folder.GetFiles(); files.AddRange(nextFileInfo); if (files.Count > 0) { FileInfo file = files.Where(x => x.Name == fileName).FirstOrDefault(); if (file == null) { return WebResponseContent.Instance.Error($"未找到日志文件: {fileName}"); } // 使用共享读取模式 using (FileStream stream = new FileStream( file.FullName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) using (StreamReader reader = new StreamReader(stream, Encoding.UTF8)) { StringBuilder text = new StringBuilder(); List lines = new List(); while (!reader.EndOfStream) { var line = reader.ReadLine(); lines.Add(line); } content = WebResponseContent.Instance.OK(data: lines); } } else { content = WebResponseContent.Instance.Error($"未找到日志文件,【{fileName}】"); } } catch (IOException ex) { if (IsFileLockedException(ex)) { content = WebResponseContent.Instance.Error($"日志文件正在被系统写入,请稍后再试"); } else { content = WebResponseContent.Instance.Error($"打开日志文件错误,{ex.Message}"); } } catch (Exception ex) { content = WebResponseContent.Instance.Error($"打开日志文件错误,{ex.Message}"); } return content; } [HttpPost, HttpGet, Route("DownLoadLog"), AllowAnonymous] public virtual async Task DownLoadLog(string fileName) { try { // 1. 参数验证 if (string.IsNullOrWhiteSpace(fileName)) { return BadRequest("文件名不能为空"); } // 安全性检查:防止路径遍历攻击 if (fileName.Contains("..") || Path.IsPathRooted(fileName)) { return BadRequest("无效的文件名"); } //string logDirectory = Path.Combine(AppContext.BaseDirectory, "logs"); string logDirectory = Path.Combine(AppContext.BaseDirectory); if (!Directory.Exists(logDirectory)) { Directory.CreateDirectory(logDirectory); } string filePath = Path.Combine(logDirectory, fileName); if (Directory.Exists(filePath)) { return NotFound($"文件 {fileName} 不存在"); } string extension = Path.GetExtension(fileName).ToLowerInvariant(); if (!IsAllowedFileType(extension)) { return BadRequest($"不支持的文件类型: {extension}"); } FileInfo fileInfo = new FileInfo(filePath); if (fileInfo.Length > MAX_FILE_SIZE_MB * 1024 * 1024) { return BadRequest($"文件过大,超过{MAX_FILE_SIZE_MB}MB限制"); } // 方案1:使用重试机制 + 共享读取(推荐) byte[] fileBytes = await ReadFileWithRetryAsync(filePath); if (fileBytes == null) { return StatusCode(500, "文件被占用,无法下载,请稍后重试"); } string contentType = GetContentType(extension); // 设置下载头 Response.Headers.Add("Content-Disposition", $"attachment; filename=\"{System.Web.HttpUtility.UrlEncode(fileName, Encoding.UTF8)}\""); Response.Headers.Add("Cache-Control", "no-cache, no-store, must-revalidate"); Response.Headers.Add("Pragma", "no-cache"); Response.Headers.Add("Expires", "0"); return File(fileBytes, contentType, fileName); } catch (UnauthorizedAccessException) { return StatusCode(403, "没有访问该文件的权限"); } catch (PathTooLongException) { return BadRequest("文件路径过长"); } catch (IOException ex) { if (IsFileLockedException(ex)) { return StatusCode(500, "文件被锁定,可能正在被系统写入,请稍后再试"); } return StatusCode(500, $"文件读取失败: {ex.Message}"); } catch (Exception ex) { // 记录异常日志(这里简化为返回,实际项目中应该记录到日志系统) return StatusCode(500, $"服务器内部错误: {ex.Message}"); } } /// /// 带重试机制的文件读取方法 /// private async Task ReadFileWithRetryAsync(string filePath) { for (int attempt = 0; attempt < MAX_RETRY_COUNT; attempt++) { try { // 使用 FileShare.ReadWrite 允许其他进程同时读取和写入 // 使用异步读取提高性能 using (var fileStream = new FileStream( filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, // 允许删除操作 bufferSize: 4096, useAsync: true)) { using (var memoryStream = new MemoryStream()) { await fileStream.CopyToAsync(memoryStream); return memoryStream.ToArray(); } } } catch (IOException) when (attempt < MAX_RETRY_COUNT - 1) { // 如果不是最后一次重试,等待一段时间 await Task.Delay(RETRY_DELAY_MS * (attempt + 1)); } catch (IOException ex) { // 最后一次尝试也失败了 throw; } } return null; } /// /// 判断是否为文件被锁定的异常 /// private bool IsFileLockedException(IOException ex) { int errorCode = ex.HResult & 0xFFFF; return errorCode == 32 || errorCode == 33; // ERROR_SHARING_VIOLATION or ERROR_LOCK_VIOLATION } /// /// 检查文件类型是否允许 /// private bool IsAllowedFileType(string extension) { return ALLOWED_FILE_TYPES.Contains(extension); } /// /// 获取Content-Type /// private string GetContentType(string extension) { return extension.ToLowerInvariant() switch { ".txt" => "text/plain; charset=utf-8", ".log" => "text/plain; charset=utf-8", ".csv" => "text/csv; charset=utf-8", ".json" => "application/json; charset=utf-8", ".xml" => "application/xml; charset=utf-8", _ => "application/octet-stream" }; } /// /// 备选方案:创建临时副本下载(最安全,但性能稍差) /// [HttpPost, HttpGet, Route("DownLoadLogCopy"), AllowAnonymous] public virtual async Task DownLoadLogCopy(string fileName) { try { // 参数验证(同上) if (string.IsNullOrWhiteSpace(fileName)) { return BadRequest("文件名不能为空"); } string logDirectory = Path.Combine(AppContext.BaseDirectory, "logs"); string filePath = Path.Combine(logDirectory, fileName); if (Directory.Exists(filePath)) { return NotFound($"文件 {fileName} 不存在"); } // 生成临时文件名 string tempFileName = $"{Path.GetFileNameWithoutExtension(fileName)}_{Guid.NewGuid():N}{Path.GetExtension(fileName)}"; string tempFilePath = Path.Combine(Path.GetTempPath(), tempFileName); try { // 尝试复制文件到临时位置(使用重试机制) bool copySuccess = false; for (int attempt = 0; attempt < MAX_RETRY_COUNT; attempt++) { try { //Directory.GetFiles.Copy(filePath, tempFilePath, false); copySuccess = true; break; } catch (IOException) when (attempt < MAX_RETRY_COUNT - 1) { await Task.Delay(RETRY_DELAY_MS * (attempt + 1)); } } if (!copySuccess) { return StatusCode(500, "无法复制文件,可能被其他进程占用"); } // 从临时文件读取 byte[] fileBytes; using (FileStream tempStream = new FileStream(tempFilePath, FileMode.Open, FileAccess.Read, FileShare.Read)) { using (MemoryStream memoryStream = new MemoryStream()) { await tempStream.CopyToAsync(memoryStream); fileBytes = memoryStream.ToArray(); } } string extension = Path.GetExtension(fileName).ToLowerInvariant(); string contentType = GetContentType(extension); // 返回文件后清理临时文件 var result = File(fileBytes, contentType, fileName); // 异步清理临时文件 _ = Task.Run(() => { try { Directory.Delete(tempFilePath); } catch { // 忽略删除失败 } }); return result; } finally { // 确保临时文件被清理 if (Directory.Exists(tempFilePath)) { try { Directory.Delete(tempFilePath); } catch { // 忽略删除失败 } } } } catch (Exception ex) { return StatusCode(500, $"服务器内部错误: {ex.Message}"); } } } }