fix(security): 添加路径验证和CSV转义防止安全漏洞
This commit is contained in:
157
src/WCTDataMiner.Core/Security/PathValidator.cs
Normal file
157
src/WCTDataMiner.Core/Security/PathValidator.cs
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace WCTDataMiner.Core.Security;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 路径验证结果
|
||||||
|
/// </summary>
|
||||||
|
public class PathValidationResult
|
||||||
|
{
|
||||||
|
public bool IsValid { get; }
|
||||||
|
public string? NormalizedPath { get; }
|
||||||
|
public string? ErrorMessage { get; }
|
||||||
|
|
||||||
|
private PathValidationResult(bool isValid, string? normalizedPath, string? errorMessage)
|
||||||
|
{
|
||||||
|
IsValid = isValid;
|
||||||
|
NormalizedPath = normalizedPath;
|
||||||
|
ErrorMessage = errorMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PathValidationResult Success(string normalizedPath)
|
||||||
|
=> new(true, normalizedPath, null);
|
||||||
|
|
||||||
|
public static PathValidationResult Fail(string errorMessage)
|
||||||
|
=> new(false, null, errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 路径验证器 - 防止路径遍历攻击
|
||||||
|
/// </summary>
|
||||||
|
public class PathValidator
|
||||||
|
{
|
||||||
|
private readonly string _applicationBasePath;
|
||||||
|
private readonly HashSet<string> _allowedDirectories;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 默认允许的输出目录
|
||||||
|
/// </summary>
|
||||||
|
private static readonly string[] DefaultAllowedDirectories = ["exports", "output", "data/exports"];
|
||||||
|
|
||||||
|
public PathValidator(string applicationBasePath, IEnumerable<string>? allowedDirectories = null)
|
||||||
|
{
|
||||||
|
_applicationBasePath = Path.GetFullPath(applicationBasePath);
|
||||||
|
_allowedDirectories = new HashSet<string>(
|
||||||
|
(allowedDirectories ?? DefaultAllowedDirectories).Select(d => d.Replace('/', Path.DirectorySeparatorChar)),
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证输出目录路径
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="outputDir">用户输入的输出目录路径</param>
|
||||||
|
/// <returns>验证结果</returns>
|
||||||
|
public PathValidationResult ValidateOutputDirectory(string outputDir)
|
||||||
|
{
|
||||||
|
// 1. 基础验证
|
||||||
|
if (string.IsNullOrWhiteSpace(outputDir))
|
||||||
|
return PathValidationResult.Fail("输出目录不能为空");
|
||||||
|
|
||||||
|
// 2. 禁止的模式检查
|
||||||
|
var forbiddenPatterns = new[]
|
||||||
|
{
|
||||||
|
"..", // 路径遍历
|
||||||
|
"~", // 用户主目录
|
||||||
|
"\\\\", // UNC 路径
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var pattern in forbiddenPatterns)
|
||||||
|
{
|
||||||
|
if (outputDir.Contains(pattern))
|
||||||
|
{
|
||||||
|
return PathValidationResult.Fail($"路径包含禁止的模式: {pattern}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 检查是否为绝对路径(Windows 和 Unix)
|
||||||
|
if (Path.IsPathRooted(outputDir) ||
|
||||||
|
Regex.IsMatch(outputDir, @"^[A-Za-z]:") || // Windows 驱动器
|
||||||
|
outputDir.StartsWith("/")) // Unix 绝对路径
|
||||||
|
{
|
||||||
|
return PathValidationResult.Fail("仅允许相对路径");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 规范化并验证
|
||||||
|
string normalizedPath;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
normalizedPath = Path.GetFullPath(Path.Combine(_applicationBasePath, outputDir));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return PathValidationResult.Fail($"路径格式无效: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 严格验证必须在应用目录内
|
||||||
|
if (!normalizedPath.StartsWith(_applicationBasePath, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return PathValidationResult.Fail("路径必须在应用程序目录内");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 白名单检查
|
||||||
|
var relative = normalizedPath
|
||||||
|
.Substring(_applicationBasePath.Length)
|
||||||
|
.TrimStart(Path.DirectorySeparatorChar);
|
||||||
|
|
||||||
|
bool inAllowedDir = _allowedDirectories
|
||||||
|
.Any(allowed => relative.StartsWith(allowed + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(relative, allowed, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (!inAllowedDir)
|
||||||
|
{
|
||||||
|
var allowedList = string.Join(", ", _allowedDirectories);
|
||||||
|
return PathValidationResult.Fail($"输出目录必须在允许列表中: {allowedList}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return PathValidationResult.Success(normalizedPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 简化版验证 - 仅检查路径安全性,不做白名单检查
|
||||||
|
/// </summary>
|
||||||
|
public PathValidationResult ValidatePathSafety(string path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
|
return PathValidationResult.Fail("路径不能为空");
|
||||||
|
|
||||||
|
// 检查路径遍历
|
||||||
|
if (path.Contains(".."))
|
||||||
|
return PathValidationResult.Fail("路径包含非法的遍历字符");
|
||||||
|
|
||||||
|
// 检查绝对路径
|
||||||
|
if (Path.IsPathRooted(path) ||
|
||||||
|
Regex.IsMatch(path, @"^[A-Za-z]:") ||
|
||||||
|
path.StartsWith("/"))
|
||||||
|
{
|
||||||
|
return PathValidationResult.Fail("仅允许相对路径");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 UNC 路径
|
||||||
|
if (path.StartsWith("\\\\"))
|
||||||
|
return PathValidationResult.Fail("不允许 UNC 路径");
|
||||||
|
|
||||||
|
// 规范化验证
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var normalized = Path.GetFullPath(Path.Combine(_applicationBasePath, path));
|
||||||
|
if (!normalized.StartsWith(_applicationBasePath, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return PathValidationResult.Fail("路径超出应用程序目录范围");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return PathValidationResult.Fail($"路径格式无效: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return PathValidationResult.Success(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/WCTDataMiner.Core/Security/SecurityConstants.cs
Normal file
54
src/WCTDataMiner.Core/Security/SecurityConstants.cs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
namespace WCTDataMiner.Core.Security;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 安全相关常量定义
|
||||||
|
/// </summary>
|
||||||
|
public static class SecurityConstants
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 默认允许的输出目录
|
||||||
|
/// </summary>
|
||||||
|
public static readonly string[] DefaultAllowedExportDirectories =
|
||||||
|
[
|
||||||
|
"exports",
|
||||||
|
"output",
|
||||||
|
"data/exports",
|
||||||
|
"data/output"
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 路径最大长度限制
|
||||||
|
/// </summary>
|
||||||
|
public const int MaxPathLength = 260;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 文件名最大长度限制
|
||||||
|
/// </summary>
|
||||||
|
public const int MaxFileNameLength = 255;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 禁止的路径模式
|
||||||
|
/// </summary>
|
||||||
|
public static readonly string[] ForbiddenPathPatterns =
|
||||||
|
[
|
||||||
|
"..", // 路径遍历
|
||||||
|
"~", // 用户主目录
|
||||||
|
"\\\\", // UNC 路径开始
|
||||||
|
"|", // 管道字符(Windows 禁止)
|
||||||
|
">", // 重定向字符
|
||||||
|
"<", // 重定向字符
|
||||||
|
"*", // 通配符(用于文件名时)
|
||||||
|
"?" // 通配符(用于文件名时)
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 安全的文件扩展名白名单
|
||||||
|
/// </summary>
|
||||||
|
public static readonly string[] AllowedExportExtensions =
|
||||||
|
[
|
||||||
|
".csv",
|
||||||
|
".json",
|
||||||
|
".txt",
|
||||||
|
".log"
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -2,21 +2,27 @@ using System.Globalization;
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using WCTDataMiner.Core.Data;
|
using WCTDataMiner.Core.Data;
|
||||||
using WCTDataMiner.Core.Models;
|
using WCTDataMiner.Core.Models;
|
||||||
|
using WCTDataMiner.Core.Security;
|
||||||
|
|
||||||
namespace WCTDataMiner.Core.Services;
|
namespace WCTDataMiner.Core.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 数据导出服务
|
/// 数据导出服务 - 支持安全的路径验证和 RFC 4180 标准 CSV 转义
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ExportService
|
public class ExportService
|
||||||
{
|
{
|
||||||
private readonly WctMinerDbContext _context;
|
private readonly WctMinerDbContext _context;
|
||||||
|
private readonly ILogger<ExportService> _logger;
|
||||||
|
private readonly PathValidator _pathValidator;
|
||||||
|
|
||||||
public ExportService(WctMinerDbContext context)
|
public ExportService(WctMinerDbContext context, ILogger<ExportService> logger)
|
||||||
{
|
{
|
||||||
_context = context;
|
_context = context;
|
||||||
|
_logger = logger;
|
||||||
|
_pathValidator = new PathValidator(AppDomain.CurrentDomain.BaseDirectory);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -24,30 +30,72 @@ public class ExportService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<string> ExportQfodToCsvAsync(string outputDir)
|
public async Task<string> ExportQfodToCsvAsync(string outputDir)
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(outputDir);
|
_logger.LogInformation("开始导出 Qfod 数据为 CSV");
|
||||||
var filePath = Path.Combine(outputDir, $"qfod_export_{DateTime.Now:yyyyMMdd_HHmmss}.csv");
|
|
||||||
|
|
||||||
var records = await _context.QfodRecords
|
// 路径验证
|
||||||
.Include(r => r.Scenario)
|
var validationResult = _pathValidator.ValidateOutputDirectory(outputDir);
|
||||||
.ThenInclude(s => s!.TxPanel)
|
if (!validationResult.IsValid)
|
||||||
.Include(r => r.Scenario)
|
|
||||||
.ThenInclude(s => s!.TxHardware)
|
|
||||||
.Include(r => r.Scenario)
|
|
||||||
.ThenInclude(s => s!.TxSoftware)
|
|
||||||
.Include(r => r.Scenario)
|
|
||||||
.ThenInclude(s => s!.RxType)
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
var sb = new StringBuilder();
|
|
||||||
// CSV Header
|
|
||||||
sb.AppendLine("id,scenario_id,tx_panel,tx_hardware,tx_software,rx_type,test_purpose,test_date,test_sequence,charger_index,coil_index,delta_q,current_q,raw_q,fod_type");
|
|
||||||
|
|
||||||
foreach (var r in records)
|
|
||||||
{
|
{
|
||||||
sb.AppendLine($"{r.Id},{r.ScenarioId},{r.Scenario?.TxPanel?.Name},{r.Scenario?.TxHardware?.Version},{r.Scenario?.TxSoftware?.Version},{r.Scenario?.RxType?.Name},{r.Scenario?.TestPurpose},{r.Scenario?.TestDate:yyyy-MM-dd},{r.Scenario?.TestSequence},{r.ChargerIndex},{r.CoilIndex},{r.DeltaQ},{r.CurrentQ},{r.RawQ},{r.FodType}");
|
throw new ArgumentException($"无效的输出目录: {validationResult.ErrorMessage}");
|
||||||
}
|
}
|
||||||
|
|
||||||
await File.WriteAllTextAsync(filePath, sb.ToString());
|
var safeOutputDir = validationResult.NormalizedPath!;
|
||||||
|
Directory.CreateDirectory(safeOutputDir);
|
||||||
|
var filePath = Path.Combine(safeOutputDir, $"qfod_export_{DateTime.Now:yyyyMMdd_HHmmss}.csv");
|
||||||
|
|
||||||
|
// 使用流式导出避免内存问题
|
||||||
|
await using var writer = new StreamWriter(filePath, false, Encoding.UTF8);
|
||||||
|
|
||||||
|
// CSV Header
|
||||||
|
await writer.WriteLineAsync("id,scenario_id,tx_panel,tx_hardware,tx_software,rx_type,test_purpose,test_date,test_sequence,charger_index,coil_index,delta_q,current_q,raw_q,fod_type");
|
||||||
|
|
||||||
|
// 使用 Select 投影避免 N+1 查询
|
||||||
|
var query = _context.QfodRecords
|
||||||
|
.Select(r => new
|
||||||
|
{
|
||||||
|
r.Id,
|
||||||
|
r.ScenarioId,
|
||||||
|
TxPanelName = r.Scenario!.TxPanel.Name,
|
||||||
|
TxHardwareVersion = r.Scenario.TxHardware.Version,
|
||||||
|
TxSoftwareVersion = r.Scenario.TxSoftware.Version,
|
||||||
|
RxTypeName = r.Scenario.RxType.Name,
|
||||||
|
r.Scenario.TestPurpose,
|
||||||
|
r.Scenario.TestDate,
|
||||||
|
r.Scenario.TestSequence,
|
||||||
|
r.ChargerIndex,
|
||||||
|
r.CoilIndex,
|
||||||
|
r.DeltaQ,
|
||||||
|
r.CurrentQ,
|
||||||
|
r.RawQ,
|
||||||
|
r.FodType
|
||||||
|
})
|
||||||
|
.OrderBy(r => r.Id);
|
||||||
|
|
||||||
|
int count = 0;
|
||||||
|
await foreach (var r in query.AsAsyncEnumerable())
|
||||||
|
{
|
||||||
|
var line = string.Join(',',
|
||||||
|
EscapeCsvField(r.Id),
|
||||||
|
EscapeCsvField(r.ScenarioId),
|
||||||
|
EscapeCsvField(r.TxPanelName),
|
||||||
|
EscapeCsvField(r.TxHardwareVersion),
|
||||||
|
EscapeCsvField(r.TxSoftwareVersion),
|
||||||
|
EscapeCsvField(r.RxTypeName),
|
||||||
|
EscapeCsvField(r.TestPurpose),
|
||||||
|
EscapeCsvField(r.TestDate),
|
||||||
|
EscapeCsvField(r.TestSequence),
|
||||||
|
EscapeCsvField(r.ChargerIndex),
|
||||||
|
EscapeCsvField(r.CoilIndex),
|
||||||
|
EscapeCsvField(r.DeltaQ),
|
||||||
|
EscapeCsvField(r.CurrentQ),
|
||||||
|
EscapeCsvField(r.RawQ),
|
||||||
|
EscapeCsvField(r.FodType)
|
||||||
|
);
|
||||||
|
await writer.WriteLineAsync(line);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Qfod CSV 导出完成: {FilePath}, 共 {Count} 条记录", filePath, count);
|
||||||
return filePath;
|
return filePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,30 +104,88 @@ public class ExportService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<string> ExportPlossToCsvAsync(string outputDir)
|
public async Task<string> ExportPlossToCsvAsync(string outputDir)
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(outputDir);
|
_logger.LogInformation("开始导出 Ploss 数据为 CSV");
|
||||||
var filePath = Path.Combine(outputDir, $"ploss_export_{DateTime.Now:yyyyMMdd_HHmmss}.csv");
|
|
||||||
|
|
||||||
var records = await _context.PlossRecords
|
// 路径验证
|
||||||
.Include(r => r.Scenario)
|
var validationResult = _pathValidator.ValidateOutputDirectory(outputDir);
|
||||||
.ThenInclude(s => s!.TxPanel)
|
if (!validationResult.IsValid)
|
||||||
.Include(r => r.Scenario)
|
|
||||||
.ThenInclude(s => s!.TxHardware)
|
|
||||||
.Include(r => r.Scenario)
|
|
||||||
.ThenInclude(s => s!.TxSoftware)
|
|
||||||
.Include(r => r.Scenario)
|
|
||||||
.ThenInclude(s => s!.RxType)
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
var sb = new StringBuilder();
|
|
||||||
// CSV Header
|
|
||||||
sb.AppendLine("id,scenario_id,tx_panel,tx_hardware,tx_software,rx_type,test_purpose,test_date,test_sequence,rx_type_field,rx_power,tx_power,vcoil,vin,isns,ploss,threshold,trigger_count,fod_result,protocol_type,pwm_duty,coil_index,margin");
|
|
||||||
|
|
||||||
foreach (var r in records)
|
|
||||||
{
|
{
|
||||||
sb.AppendLine($"{r.Id},{r.ScenarioId},{r.Scenario?.TxPanel?.Name},{r.Scenario?.TxHardware?.Version},{r.Scenario?.TxSoftware?.Version},{r.Scenario?.RxType?.Name},{r.Scenario?.TestPurpose},{r.Scenario?.TestDate:yyyy-MM-dd},{r.Scenario?.TestSequence},{r.RxType},{r.RxPower},{r.TxPower},{r.Vcoil},{r.Vin},{r.Isns},{r.Ploss},{r.Threshold},{r.TriggerCount},{r.FodResult},{r.ProtocolType},{r.PwmDuty},{r.CoilIndex},{r.Margin}");
|
throw new ArgumentException($"无效的输出目录: {validationResult.ErrorMessage}");
|
||||||
}
|
}
|
||||||
|
|
||||||
await File.WriteAllTextAsync(filePath, sb.ToString());
|
var safeOutputDir = validationResult.NormalizedPath!;
|
||||||
|
Directory.CreateDirectory(safeOutputDir);
|
||||||
|
var filePath = Path.Combine(safeOutputDir, $"ploss_export_{DateTime.Now:yyyyMMdd_HHmmss}.csv");
|
||||||
|
|
||||||
|
// 使用流式导出避免内存问题
|
||||||
|
await using var writer = new StreamWriter(filePath, false, Encoding.UTF8);
|
||||||
|
|
||||||
|
// CSV Header
|
||||||
|
await writer.WriteLineAsync("id,scenario_id,tx_panel,tx_hardware,tx_software,rx_type,test_purpose,test_date,test_sequence,rx_type_field,rx_power,tx_power,vcoil,vin,isns,ploss,threshold,trigger_count,fod_result,protocol_type,pwm_duty,coil_index,margin");
|
||||||
|
|
||||||
|
// 使用 Select 投影避免 N+1 查询
|
||||||
|
var query = _context.PlossRecords
|
||||||
|
.Select(r => new
|
||||||
|
{
|
||||||
|
r.Id,
|
||||||
|
r.ScenarioId,
|
||||||
|
TxPanelName = r.Scenario!.TxPanel.Name,
|
||||||
|
TxHardwareVersion = r.Scenario.TxHardware.Version,
|
||||||
|
TxSoftwareVersion = r.Scenario.TxSoftware.Version,
|
||||||
|
RxTypeName = r.Scenario.RxType.Name,
|
||||||
|
r.Scenario.TestPurpose,
|
||||||
|
r.Scenario.TestDate,
|
||||||
|
r.Scenario.TestSequence,
|
||||||
|
r.RxType,
|
||||||
|
r.RxPower,
|
||||||
|
r.TxPower,
|
||||||
|
r.Vcoil,
|
||||||
|
r.Vin,
|
||||||
|
r.Isns,
|
||||||
|
r.Ploss,
|
||||||
|
r.Threshold,
|
||||||
|
r.TriggerCount,
|
||||||
|
r.FodResult,
|
||||||
|
r.ProtocolType,
|
||||||
|
r.PwmDuty,
|
||||||
|
r.CoilIndex,
|
||||||
|
Margin = r.Threshold - r.Ploss
|
||||||
|
})
|
||||||
|
.OrderBy(r => r.Id);
|
||||||
|
|
||||||
|
int count = 0;
|
||||||
|
await foreach (var r in query.AsAsyncEnumerable())
|
||||||
|
{
|
||||||
|
var line = string.Join(',',
|
||||||
|
EscapeCsvField(r.Id),
|
||||||
|
EscapeCsvField(r.ScenarioId),
|
||||||
|
EscapeCsvField(r.TxPanelName),
|
||||||
|
EscapeCsvField(r.TxHardwareVersion),
|
||||||
|
EscapeCsvField(r.TxSoftwareVersion),
|
||||||
|
EscapeCsvField(r.RxTypeName),
|
||||||
|
EscapeCsvField(r.TestPurpose),
|
||||||
|
EscapeCsvField(r.TestDate),
|
||||||
|
EscapeCsvField(r.TestSequence),
|
||||||
|
EscapeCsvField(r.RxType),
|
||||||
|
EscapeCsvField(r.RxPower),
|
||||||
|
EscapeCsvField(r.TxPower),
|
||||||
|
EscapeCsvField(r.Vcoil),
|
||||||
|
EscapeCsvField(r.Vin),
|
||||||
|
EscapeCsvField(r.Isns),
|
||||||
|
EscapeCsvField(r.Ploss),
|
||||||
|
EscapeCsvField(r.Threshold),
|
||||||
|
EscapeCsvField(r.TriggerCount),
|
||||||
|
EscapeCsvField(r.FodResult),
|
||||||
|
EscapeCsvField(r.ProtocolType),
|
||||||
|
EscapeCsvField(r.PwmDuty),
|
||||||
|
EscapeCsvField(r.CoilIndex),
|
||||||
|
EscapeCsvField(r.Margin)
|
||||||
|
);
|
||||||
|
await writer.WriteLineAsync(line);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Ploss CSV 导出完成: {FilePath}, 共 {Count} 条记录", filePath, count);
|
||||||
return filePath;
|
return filePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,18 +194,20 @@ public class ExportService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<string> ExportQfodToJsonAsync(string outputDir)
|
public async Task<string> ExportQfodToJsonAsync(string outputDir)
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(outputDir);
|
_logger.LogInformation("开始导出 Qfod 数据为 JSON");
|
||||||
var filePath = Path.Combine(outputDir, $"qfod_export_{DateTime.Now:yyyyMMdd_HHmmss}.json");
|
|
||||||
|
// 路径验证
|
||||||
|
var validationResult = _pathValidator.ValidateOutputDirectory(outputDir);
|
||||||
|
if (!validationResult.IsValid)
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"无效的输出目录: {validationResult.ErrorMessage}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var safeOutputDir = validationResult.NormalizedPath!;
|
||||||
|
Directory.CreateDirectory(safeOutputDir);
|
||||||
|
var filePath = Path.Combine(safeOutputDir, $"qfod_export_{DateTime.Now:yyyyMMdd_HHmmss}.json");
|
||||||
|
|
||||||
var records = await _context.QfodRecords
|
var records = await _context.QfodRecords
|
||||||
.Include(r => r.Scenario)
|
|
||||||
.ThenInclude(s => s!.TxPanel)
|
|
||||||
.Include(r => r.Scenario)
|
|
||||||
.ThenInclude(s => s!.TxHardware)
|
|
||||||
.Include(r => r.Scenario)
|
|
||||||
.ThenInclude(s => s!.TxSoftware)
|
|
||||||
.Include(r => r.Scenario)
|
|
||||||
.ThenInclude(s => s!.RxType)
|
|
||||||
.Select(r => new
|
.Select(r => new
|
||||||
{
|
{
|
||||||
r.Id,
|
r.Id,
|
||||||
@@ -122,6 +230,8 @@ public class ExportService
|
|||||||
|
|
||||||
var json = JsonSerializer.Serialize(records, new JsonSerializerOptions { WriteIndented = true });
|
var json = JsonSerializer.Serialize(records, new JsonSerializerOptions { WriteIndented = true });
|
||||||
await File.WriteAllTextAsync(filePath, json);
|
await File.WriteAllTextAsync(filePath, json);
|
||||||
|
|
||||||
|
_logger.LogInformation("Qfod JSON 导出完成: {FilePath}, 共 {Count} 条记录", filePath, records.Count);
|
||||||
return filePath;
|
return filePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,18 +240,20 @@ public class ExportService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<string> ExportPlossToJsonAsync(string outputDir)
|
public async Task<string> ExportPlossToJsonAsync(string outputDir)
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(outputDir);
|
_logger.LogInformation("开始导出 Ploss 数据为 JSON");
|
||||||
var filePath = Path.Combine(outputDir, $"ploss_export_{DateTime.Now:yyyyMMdd_HHmmss}.json");
|
|
||||||
|
// 路径验证
|
||||||
|
var validationResult = _pathValidator.ValidateOutputDirectory(outputDir);
|
||||||
|
if (!validationResult.IsValid)
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"无效的输出目录: {validationResult.ErrorMessage}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var safeOutputDir = validationResult.NormalizedPath!;
|
||||||
|
Directory.CreateDirectory(safeOutputDir);
|
||||||
|
var filePath = Path.Combine(safeOutputDir, $"ploss_export_{DateTime.Now:yyyyMMdd_HHmmss}.json");
|
||||||
|
|
||||||
var records = await _context.PlossRecords
|
var records = await _context.PlossRecords
|
||||||
.Include(r => r.Scenario)
|
|
||||||
.ThenInclude(s => s!.TxPanel)
|
|
||||||
.Include(r => r.Scenario)
|
|
||||||
.ThenInclude(s => s!.TxHardware)
|
|
||||||
.Include(r => r.Scenario)
|
|
||||||
.ThenInclude(s => s!.TxSoftware)
|
|
||||||
.Include(r => r.Scenario)
|
|
||||||
.ThenInclude(s => s!.RxType)
|
|
||||||
.Select(r => new
|
.Select(r => new
|
||||||
{
|
{
|
||||||
r.Id,
|
r.Id,
|
||||||
@@ -172,6 +284,47 @@ public class ExportService
|
|||||||
|
|
||||||
var json = JsonSerializer.Serialize(records, new JsonSerializerOptions { WriteIndented = true });
|
var json = JsonSerializer.Serialize(records, new JsonSerializerOptions { WriteIndented = true });
|
||||||
await File.WriteAllTextAsync(filePath, json);
|
await File.WriteAllTextAsync(filePath, json);
|
||||||
|
|
||||||
|
_logger.LogInformation("Ploss JSON 导出完成: {FilePath}, 共 {Count} 条记录", filePath, records.Count);
|
||||||
return filePath;
|
return filePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// RFC 4180 标准 CSV 字段转义
|
||||||
|
/// </summary>
|
||||||
|
private static string EscapeCsvField(object? value)
|
||||||
|
{
|
||||||
|
var field = value switch
|
||||||
|
{
|
||||||
|
null => string.Empty,
|
||||||
|
DateTime dt => dt.ToString("yyyy-MM-dd HH:mm:ss"),
|
||||||
|
DateOnly d => d.ToString("yyyy-MM-dd"),
|
||||||
|
IFormattable f => f.ToString(null, CultureInfo.InvariantCulture),
|
||||||
|
_ => value.ToString() ?? string.Empty
|
||||||
|
};
|
||||||
|
|
||||||
|
// 单次扫描检测所有特殊字符
|
||||||
|
bool needsEscape = false;
|
||||||
|
foreach (char c in field)
|
||||||
|
{
|
||||||
|
if (c == ',' || c == '"' || c == '\n' || c == '\r')
|
||||||
|
{
|
||||||
|
needsEscape = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!needsEscape) return field;
|
||||||
|
|
||||||
|
// 使用 StringBuilder 避免多次字符串分配
|
||||||
|
var sb = new StringBuilder(field.Length + 10);
|
||||||
|
sb.Append('"');
|
||||||
|
foreach (char c in field)
|
||||||
|
{
|
||||||
|
if (c == '"') sb.Append("\"\"");
|
||||||
|
else sb.Append(c);
|
||||||
|
}
|
||||||
|
sb.Append('"');
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user