✨ feat(file-check): 添加 PLC 应答后命中文件删除

* 新增默认关闭的删除开关,仅在收到 PLC 放行应答后执行

* 本地与 SFTP Provider 校验根目录并支持幂等删除

* 补充工作流、设置页和文件删除相关测试与文档
This commit is contained in:
2026-08-01 16:42:28 +08:00
parent 404ebadbe7
commit c67509ec82
15 changed files with 700 additions and 1 deletions

View File

@@ -61,6 +61,33 @@ public sealed class LocalFileCheckProvider : IFileCheckProvider
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>
@@ -129,6 +156,83 @@ public sealed class LocalFileCheckProvider : IFileCheckProvider
}
}
/// <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>