* 新增默认关闭的删除开关,仅在收到 PLC 放行应答后执行 * 本地与 SFTP Provider 校验根目录并支持幂等删除 * 补充工作流、设置页和文件删除相关测试与文档
284 lines
11 KiB
C#
284 lines
11 KiB
C#
using System.IO;
|
|
using AxiOmron.PcbCheck.Models;
|
|
using AxiOmron.PcbCheck.Options;
|
|
using AxiOmron.PcbCheck.Services.Interfaces;
|
|
|
|
namespace AxiOmron.PcbCheck.Services.Implementations;
|
|
|
|
/// <summary>
|
|
/// 提供本地磁盘目录的文件存在性校验能力。
|
|
/// </summary>
|
|
public sealed class LocalFileCheckProvider : IFileCheckProvider
|
|
{
|
|
private readonly IAppLogger<LocalFileCheckProvider> _logger;
|
|
|
|
/// <summary>
|
|
/// 初始化本地文件校验提供程序。
|
|
/// </summary>
|
|
/// <param name="logger">日志记录器。</param>
|
|
public LocalFileCheckProvider(IAppLogger<LocalFileCheckProvider> logger)
|
|
{
|
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 获取当前提供程序对应的通道类型。
|
|
/// </summary>
|
|
public FileCheckProvider Provider => FileCheckProvider.Local;
|
|
|
|
/// <summary>
|
|
/// 按条码检查本地目录中是否存在目标文件。
|
|
/// </summary>
|
|
/// <param name="barcode">条码内容。</param>
|
|
/// <param name="config">根配置对象。</param>
|
|
/// <param name="cancellationToken">取消令牌。取消后立即终止目录枚举。</param>
|
|
/// <returns>文件校验结果。</returns>
|
|
public async Task<FileCheckOutcome> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 删除本地文件校验返回的精确命中文件。
|
|
/// 删除前会校验目标必须位于配置的本地根目录内,文件已不存在时按幂等成功处理。
|
|
/// </summary>
|
|
/// <param name="filePath">文件校验返回的精确命中文件路径。</param>
|
|
/// <param name="config">根配置对象。</param>
|
|
/// <param name="cancellationToken">取消令牌。取消后终止尚未完成的删除操作。</param>
|
|
/// <returns>文件删除结果。</returns>
|
|
public async Task<FileDeleteOutcome> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 测试本地文件校验目录是否可访问。
|
|
/// </summary>
|
|
/// <param name="config">根配置对象。</param>
|
|
/// <param name="cancellationToken">取消令牌。取消后立即终止测试。</param>
|
|
/// <returns>通道测试结果。</returns>
|
|
public Task<FileCheckTestOutcome> TestConnectionAsync(AppConfig config, CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(config);
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
return Task.FromResult(ValidateRootPath(config.FileCheck.Local.RootPath));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 在同步上下文中执行本地文件查找。
|
|
/// </summary>
|
|
/// <param name="barcode">条码内容。</param>
|
|
/// <param name="config">根配置对象。</param>
|
|
/// <param name="cancellationToken">取消令牌。</param>
|
|
/// <returns>文件校验结果。</returns>
|
|
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}"
|
|
};
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 在同步上下文中安全删除本地产品 ID 文件。
|
|
/// </summary>
|
|
/// <param name="filePath">待删除文件路径。</param>
|
|
/// <param name="rootPath">配置的本地根目录。</param>
|
|
/// <param name="cancellationToken">取消令牌。</param>
|
|
/// <returns>文件删除结果。</returns>
|
|
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}"
|
|
};
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 判断目标文件是否位于配置根目录内,防止外部路径被误删。
|
|
/// </summary>
|
|
/// <param name="fullFilePath">目标文件绝对路径。</param>
|
|
/// <param name="fullRootPath">配置根目录绝对路径。</param>
|
|
/// <returns>目标位于根目录内返回 <see langword="true"/>;否则返回 <see langword="false"/>。</returns>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 验证本地根目录配置和可访问性。
|
|
/// </summary>
|
|
/// <param name="rootPath">本地根目录。</param>
|
|
/// <returns>通道测试结果。</returns>
|
|
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}"
|
|
};
|
|
}
|
|
}
|