✨ feat(*): 重构PCB放行流程并接入双扫码校验

- 将流程改为 OMRON FINS + TCP 双扫码 + SFTP 文件校验
- 新增文件校验与扫码结果汇总实现,补充对应单测
- 同步更新流程设计文档、配置和系统设置页面文案
This commit is contained in:
2026-05-17 14:08:05 +08:00
parent 9a2211ccaa
commit ce356bffff
37 changed files with 4443 additions and 2436 deletions

View File

@@ -0,0 +1,179 @@
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="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>
/// 验证本地根目录配置和可访问性。
/// </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}"
};
}
}