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.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using WCTDataMiner.Core.Data;
|
||||
using WCTDataMiner.Core.Models;
|
||||
using WCTDataMiner.Core.Security;
|
||||
|
||||
namespace WCTDataMiner.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 数据导出服务
|
||||
/// 数据导出服务 - 支持安全的路径验证和 RFC 4180 标准 CSV 转义
|
||||
/// </summary>
|
||||
public class ExportService
|
||||
{
|
||||
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;
|
||||
_logger = logger;
|
||||
_pathValidator = new PathValidator(AppDomain.CurrentDomain.BaseDirectory);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -24,30 +30,72 @@ public class ExportService
|
||||
/// </summary>
|
||||
public async Task<string> ExportQfodToCsvAsync(string outputDir)
|
||||
{
|
||||
Directory.CreateDirectory(outputDir);
|
||||
var filePath = Path.Combine(outputDir, $"qfod_export_{DateTime.Now:yyyyMMdd_HHmmss}.csv");
|
||||
_logger.LogInformation("开始导出 Qfod 数据为 CSV");
|
||||
|
||||
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)
|
||||
.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)
|
||||
// 路径验证
|
||||
var validationResult = _pathValidator.ValidateOutputDirectory(outputDir);
|
||||
if (!validationResult.IsValid)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -56,30 +104,88 @@ public class ExportService
|
||||
/// </summary>
|
||||
public async Task<string> ExportPlossToCsvAsync(string outputDir)
|
||||
{
|
||||
Directory.CreateDirectory(outputDir);
|
||||
var filePath = Path.Combine(outputDir, $"ploss_export_{DateTime.Now:yyyyMMdd_HHmmss}.csv");
|
||||
_logger.LogInformation("开始导出 Ploss 数据为 CSV");
|
||||
|
||||
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)
|
||||
.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)
|
||||
// 路径验证
|
||||
var validationResult = _pathValidator.ValidateOutputDirectory(outputDir);
|
||||
if (!validationResult.IsValid)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -88,18 +194,20 @@ public class ExportService
|
||||
/// </summary>
|
||||
public async Task<string> ExportQfodToJsonAsync(string outputDir)
|
||||
{
|
||||
Directory.CreateDirectory(outputDir);
|
||||
var filePath = Path.Combine(outputDir, $"qfod_export_{DateTime.Now:yyyyMMdd_HHmmss}.json");
|
||||
_logger.LogInformation("开始导出 Qfod 数据为 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
|
||||
.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
|
||||
{
|
||||
r.Id,
|
||||
@@ -122,6 +230,8 @@ public class ExportService
|
||||
|
||||
var json = JsonSerializer.Serialize(records, new JsonSerializerOptions { WriteIndented = true });
|
||||
await File.WriteAllTextAsync(filePath, json);
|
||||
|
||||
_logger.LogInformation("Qfod JSON 导出完成: {FilePath}, 共 {Count} 条记录", filePath, records.Count);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
@@ -130,18 +240,20 @@ public class ExportService
|
||||
/// </summary>
|
||||
public async Task<string> ExportPlossToJsonAsync(string outputDir)
|
||||
{
|
||||
Directory.CreateDirectory(outputDir);
|
||||
var filePath = Path.Combine(outputDir, $"ploss_export_{DateTime.Now:yyyyMMdd_HHmmss}.json");
|
||||
_logger.LogInformation("开始导出 Ploss 数据为 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
|
||||
.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
|
||||
{
|
||||
r.Id,
|
||||
@@ -172,6 +284,47 @@ public class ExportService
|
||||
|
||||
var json = JsonSerializer.Serialize(records, new JsonSerializerOptions { WriteIndented = true });
|
||||
await File.WriteAllTextAsync(filePath, json);
|
||||
|
||||
_logger.LogInformation("Ploss JSON 导出完成: {FilePath}, 共 {Count} 条记录", filePath, records.Count);
|
||||
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