using System.IO; using AxiOmron.PcbCheck.Models; using AxiOmron.PcbCheck.Options; using AxiOmron.PcbCheck.Services.Interfaces; namespace AxiOmron.PcbCheck.Services.Implementations; /// /// 提供本地磁盘目录的文件存在性校验能力。 /// public sealed class LocalFileCheckProvider : IFileCheckProvider { private readonly IAppLogger _logger; /// /// 初始化本地文件校验提供程序。 /// /// 日志记录器。 public LocalFileCheckProvider(IAppLogger logger) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// /// 获取当前提供程序对应的通道类型。 /// public FileCheckProvider Provider => FileCheckProvider.Local; /// /// 按条码检查本地目录中是否存在目标文件。 /// /// 条码内容。 /// 根配置对象。 /// 取消令牌。取消后立即终止目录枚举。 /// 文件校验结果。 public async Task CheckFileAsync(string barcode, AppConfig config, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(config); if (string.IsNullOrWhiteSpace(barcode)) { return new FileCheckOutcome { Exists = false, IsConfigurationError = true, ErrorMessage = "条码为空,无法执行本地文件查询。" }; } FileCheckTestOutcome testOutcome = ValidateRootPath(config.FileCheck.Local.RootPath); if (!testOutcome.IsSuccess) { return new FileCheckOutcome { Exists = false, IsConfigurationError = testOutcome.IsConfigurationError, IsSystemError = testOutcome.IsSystemError, ErrorMessage = testOutcome.StatusMessage }; } return await Task.Run(() => CheckInternal(barcode, config, cancellationToken), cancellationToken).ConfigureAwait(false); } /// /// 删除本地文件校验返回的精确命中文件。 /// 删除前会校验目标必须位于配置的本地根目录内,文件已不存在时按幂等成功处理。 /// /// 文件校验返回的精确命中文件路径。 /// 根配置对象。 /// 取消令牌。取消后终止尚未完成的删除操作。 /// 文件删除结果。 public async Task DeleteFileAsync(string filePath, AppConfig config, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(config); if (string.IsNullOrWhiteSpace(filePath)) { return new FileDeleteOutcome { IsSuccess = false, FilePath = filePath, ErrorMessage = "待删除的本地产品 ID 文件路径为空。" }; } return await Task.Run( () => DeleteInternal(filePath, config.FileCheck.Local.RootPath, cancellationToken), cancellationToken) .ConfigureAwait(false); } /// /// 测试本地文件校验目录是否可访问。 /// /// 根配置对象。 /// 取消令牌。取消后立即终止测试。 /// 通道测试结果。 public Task TestConnectionAsync(AppConfig config, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(config); cancellationToken.ThrowIfCancellationRequested(); return Task.FromResult(ValidateRootPath(config.FileCheck.Local.RootPath)); } /// /// 在同步上下文中执行本地文件查找。 /// /// 条码内容。 /// 根配置对象。 /// 取消令牌。 /// 文件校验结果。 private FileCheckOutcome CheckInternal(string barcode, AppConfig config, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); try { string rootPath = Path.GetFullPath(config.FileCheck.Local.RootPath); string candidateName = FileCheckPathMatcher.BuildExpectedFileName(barcode, config.FileCheck.FileNamePattern); string? matchedPath = Directory.EnumerateFiles(rootPath) .FirstOrDefault(path => { cancellationToken.ThrowIfCancellationRequested(); return FileCheckPathMatcher.IsMatch(Path.GetFileName(path), candidateName, barcode); }); if (matchedPath is null) { return new FileCheckOutcome { Exists = false, ConnectionSucceeded = true, ErrorMessage = $"未找到与条码 {barcode} 匹配的本地文件。" }; } return new FileCheckOutcome { Exists = true, ConnectionSucceeded = true, MatchedFilePath = matchedPath }; } catch (OperationCanceledException) { throw; } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException) { _logger.LogError(ex, "本地文件校验异常"); return new FileCheckOutcome { Exists = false, IsSystemError = true, ErrorMessage = $"本地文件校验异常: {ex.Message}" }; } } /// /// 在同步上下文中安全删除本地产品 ID 文件。 /// /// 待删除文件路径。 /// 配置的本地根目录。 /// 取消令牌。 /// 文件删除结果。 private FileDeleteOutcome DeleteInternal(string filePath, string rootPath, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); try { string fullRootPath = Path.GetFullPath(rootPath); string fullFilePath = Path.GetFullPath(filePath); if (!IsPathWithinRoot(fullFilePath, fullRootPath)) { return new FileDeleteOutcome { IsSuccess = false, FilePath = fullFilePath, ErrorMessage = $"拒绝删除本地根目录之外的文件: {fullFilePath}" }; } if (!File.Exists(fullFilePath)) { return new FileDeleteOutcome { IsSuccess = true, WasAlreadyMissing = true, FilePath = fullFilePath }; } File.Delete(fullFilePath); return new FileDeleteOutcome { IsSuccess = true, FilePath = fullFilePath }; } catch (OperationCanceledException) { throw; } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException or ArgumentException or NotSupportedException) { _logger.LogError(ex, "删除本地产品 ID 文件异常,路径={FilePath}", true, filePath); return new FileDeleteOutcome { IsSuccess = false, FilePath = filePath, ErrorMessage = $"删除本地产品 ID 文件失败: {ex.Message}" }; } } /// /// 判断目标文件是否位于配置根目录内,防止外部路径被误删。 /// /// 目标文件绝对路径。 /// 配置根目录绝对路径。 /// 目标位于根目录内返回 ;否则返回 。 private static bool IsPathWithinRoot(string fullFilePath, string fullRootPath) { string relativePath = Path.GetRelativePath(fullRootPath, fullFilePath); return !Path.IsPathRooted(relativePath) && !string.Equals(relativePath, "..", StringComparison.Ordinal) && !relativePath.StartsWith($"..{Path.DirectorySeparatorChar}", StringComparison.Ordinal) && !relativePath.StartsWith($"..{Path.AltDirectorySeparatorChar}", StringComparison.Ordinal); } /// /// 验证本地根目录配置和可访问性。 /// /// 本地根目录。 /// 通道测试结果。 private static FileCheckTestOutcome ValidateRootPath(string rootPath) { if (string.IsNullOrWhiteSpace(rootPath)) { return new FileCheckTestOutcome { IsConfigurationError = true, StatusMessage = "本地文件校验目录未配置。" }; } string fullPath; try { fullPath = Path.GetFullPath(rootPath); } catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) { return new FileCheckTestOutcome { IsConfigurationError = true, StatusMessage = $"本地文件校验目录配置无效: {ex.Message}" }; } if (!Directory.Exists(fullPath)) { return new FileCheckTestOutcome { IsConfigurationError = true, StatusMessage = $"本地文件校验目录不存在: {fullPath}" }; } return new FileCheckTestOutcome { IsSuccess = true, ConnectionSucceeded = true, RootPathAccessible = true, StatusMessage = $"本地文件校验目录可访问: {fullPath}" }; } }