diff --git a/docs/README.md b/docs/README.md
index deb685f..98064df 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -51,11 +51,16 @@
---
+## 已解决问题
+
+| # | 问题 | 解决方案 |
+|---|------|----------|
+| 4 | Ploss 日志格式对应 | 修正正则表达式,添加第3个忽略字段 |
+
## 待确认问题
| # | 问题 | 来源 |
|---|------|------|
| 1 | 增量解析策略 | 日志是否持续追加? |
| 2 | 重复解析处理 | 多次解析同一文件如何处理? |
-| 3 | FOD Type 编码含义 | 需求文档 QA 3.2 |
-| 4 | Ploss 日志格式对应 | 需求文档 QA 3.3 |
\ No newline at end of file
+| 3 | FOD Type 编码含义 | 需求文档 QA 3.2 |
\ No newline at end of file
diff --git a/docs/architecture/parser/FileNameParser.md b/docs/architecture/parser/FileNameParser.md
new file mode 100644
index 0000000..3ab462d
--- /dev/null
+++ b/docs/architecture/parser/FileNameParser.md
@@ -0,0 +1,123 @@
+# 文件名解析器
+
+> 所属模块:[解析器设计](./概览.md)
+
+---
+
+## 1. 文件名格式
+
+**格式约定**:`TX面板-TX硬件-TX软件-RX类型-测试目的-年月日-测试次数.log`
+
+**分隔符**:支持 `-`(短横线)和 `_`(下划线)两种分隔符,文件名解析时将同时拆分这两种字符。
+
+> ⚠️ **注意**:字段值内部不应包含分隔符字符。例如,若 TX面板值为 `single-mold`,解析时会拆分为 `single` 和 `mold` 两个字段,导致解析失败。请确保字段值本身不含 `-` 或 `_`。
+
+**示例**:
+- 标准:`singleMold-v1.0-hex2_1-iPhone15-compatibility-20260702-1.log`(字段值无分隔符)
+- 避免:`single-mold-v1.0-hex2.1-iPhone15-兼容性测试-20260702-1.log`(`single-mold` 会拆分)
+
+**位置说明**:表格中的"位置"采用 1-based 索引(从 1 开始计数),与代码中的 0-based 数组索引对应。
+
+---
+
+## 2. 字段映射
+
+| 位置 | 字段 | 对应维度表 | 示例值 |
+|------|------|------------|--------|
+| 1 | TxPanel | tx_panel.name | single-mold |
+| 2 | TxHardware | tx_hardware.version | v1.0 |
+| 3 | TxSoftware | tx_software.version | hex2.1 |
+| 4 | RxType | rx_type.name | iPhone15 |
+| 5 | TestPurpose | test_scenario.test_purpose | 兼容性测试 |
+| 6 | TestDate | test_scenario.test_date | 20260702 |
+| 7 | TestSequence | test_scenario.test_sequence | 1 |
+
+---
+
+## 3. 解析器实现
+
+```csharp
+// Parsers/FileNameParser.cs
+using System.Globalization;
+using WCTDataMiner.Services;
+
+namespace WCTDataMiner.Parsers;
+
+public class FileNameParser
+{
+ private static readonly char[] Separators = { '-', '_' };
+
+ ///
+ /// 解析日志文件名,提取场景信息
+ ///
+ /// 文件名(不含路径)
+ /// 场景信息,若格式不匹配返回 null
+ public ScenarioInfo? Parse(string fileName)
+ {
+ // 移除 .log 扩展名
+ var nameWithoutExt = Path.GetFileNameWithoutExtension(fileName);
+ if (string.IsNullOrEmpty(nameWithoutExt))
+ return null;
+
+ var parts = nameWithoutExt.Split(Separators);
+ if (parts.Length < 7)
+ return null;
+
+ // 解析日期
+ if (!TryParseDate(parts[5], out var testDate))
+ return null;
+
+ // 解析测试序号
+ if (!int.TryParse(parts[6], out var testSequence))
+ testSequence = 1;
+
+ return new ScenarioInfo(
+ TxPanel: parts[0],
+ TxHardware: parts[1],
+ TxSoftware: parts[2],
+ RxType: parts[3],
+ TestPurpose: parts.Length > 4 ? parts[4] : null,
+ TestDate: testDate,
+ TestSequence: testSequence
+ );
+ }
+
+ private static bool TryParseDate(string dateStr, out DateOnly date)
+ {
+ // 支持多种日期格式
+ var formats = new[] { "yyyyMMdd", "yyyy-MM-dd", "yyMMdd" };
+ return DateOnly.TryParseExact(dateStr, formats, CultureInfo.InvariantCulture, DateTimeStyles.None, out date);
+ }
+}
+```
+
+---
+
+## 4. 容错处理
+
+| 场景 | 处理方式 |
+|------|----------|
+| 文件名为空或 null | 返回 null,ParseService 记录错误日志 |
+| 文件名仅含扩展名(如 `.log`) | 返回 null |
+| 文件名少于 7 个字段 | 返回 null,缺少必要字段 |
+| 日期格式不匹配 | 返回 null,无法解析 test_date |
+| 测试序号解析失败 | 使用默认值 1 |
+| 文件名扩展名无效(非 `.log`) | 仍会解析,仅去除扩展名后尝试 |
+
+> **说明**:由于解析器要求 `parts.Length >= 7` 才会成功返回,"测试目的缺失"不会单独发生——当字段不足时整体解析失败。若需支持可选字段,需调整解析逻辑。
+
+---
+
+## 5. 使用示例
+
+```csharp
+// 在 ParseService 中使用
+var fileNameParser = new FileNameParser();
+var scenarioInfo = fileNameParser.Parse("single-mold-v1.0-hex2.1-iPhone15-兼容性测试-20260702-1.log");
+
+if (scenarioInfo != null)
+{
+ var scenario = await scenarioService.GetOrCreateScenarioAsync(scenarioInfo);
+ // 继续解析文件内容...
+}
+```
\ No newline at end of file
diff --git a/docs/architecture/parser/Ploss解析器.md b/docs/architecture/parser/Ploss解析器.md
index d7fb9bb..b7715c0 100644
--- a/docs/architecture/parser/Ploss解析器.md
+++ b/docs/architecture/parser/Ploss解析器.md
@@ -36,10 +36,13 @@
## 3. 正则表达式
+> **修正说明**:原设计稿正则只有2个忽略字段,实际格式有3个X(忽略字段),已修正。
+
```csharp
// Parsers/PlossParser.cs
+// 格式: FOD-> (1) X X X (2) (3) ... (13),其中3个X需要3个 \d+ 忽略
private static readonly Regex PlossPattern = new(
- @"^FOD->\s+(\d+)\s+\d+\s+\d+\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(-?\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)$",
+ @"^FOD->\s+(\d+)\s+\d+\s+\d+\s+\d+\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(-?\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)$",
RegexOptions.Compiled
);
```
@@ -82,7 +85,7 @@ public class PlossParser : IParser
public bool CanParse(string line) => PlossPattern.IsMatch(line);
- public ParseResult Parse(string line, int lineNumber, Guid logFileId)
+ public ParseResult Parse(string line, Guid scenarioId)
{
var match = PlossPattern.Match(line);
if (!match.Success)
@@ -97,8 +100,7 @@ public class PlossParser : IParser
{
var record = new PlossRecord
{
- LogFileId = logFileId,
- LineNumber = lineNumber,
+ ScenarioId = scenarioId,
RxType = byte.Parse(match.Groups[1].Value),
RxPower = short.Parse(match.Groups[2].Value),
TxPower = short.Parse(match.Groups[3].Value),
diff --git a/docs/architecture/parser/Qfod解析器.md b/docs/architecture/parser/Qfod解析器.md
index bf19710..f5eeee7 100644
--- a/docs/architecture/parser/Qfod解析器.md
+++ b/docs/architecture/parser/Qfod解析器.md
@@ -66,7 +66,7 @@ public class QfodParser : IParser
public bool CanParse(string line) => QfodPattern.IsMatch(line);
- public ParseResult Parse(string line, int lineNumber, Guid logFileId)
+ public ParseResult Parse(string line, Guid scenarioId)
{
var match = QfodPattern.Match(line);
if (!match.Success)
@@ -81,8 +81,7 @@ public class QfodParser : IParser
{
var record = new QfodRecord
{
- LogFileId = logFileId,
- LineNumber = lineNumber,
+ ScenarioId = scenarioId,
ChargerIndex = byte.Parse(match.Groups[1].Value),
CoilIndex = byte.Parse(match.Groups[2].Value),
DeltaQ = int.Parse(match.Groups[3].Value),
diff --git a/docs/architecture/parser/概览.md b/docs/architecture/parser/概览.md
index 3d3d36c..65144b0 100644
--- a/docs/architecture/parser/概览.md
+++ b/docs/architecture/parser/概览.md
@@ -8,6 +8,7 @@
| 文档 | 说明 |
|------|------|
+| [文件名解析器](./FileNameParser.md) | 从文件名提取场景信息 |
| [Qfod 解析器](./Qfod解析器.md) | Qfod 格式日志解析设计 |
| [Ploss 解析器](./Ploss解析器.md) | Ploss FOD 格式日志解析设计 |
@@ -29,7 +30,9 @@ public interface IParser
///
/// 解析日志行
///
- ParseResult Parse(string line, int lineNumber, Guid logFileId);
+ /// 日志行内容
+ /// 关联的测试场景ID,用于外键关联(由 ParseService 从 FileNameParser 获取后传入)
+ ParseResult Parse(string line, Guid scenarioId);
}
public record ParseResult
@@ -75,4 +78,5 @@ flowchart TD
|-----------|------|----------|
| FORMAT_MISMATCH | 行格式不匹配正则表达式 | 跳过该行,记录原始内容 |
| TYPE_CONVERSION | 字段类型转换失败 | 跳过该行,记录错误字段 |
-| VALUE_OUT_OF_RANGE | 字段值超出有效范围 | 使用边界值或跳过 |
\ No newline at end of file
+
+> **说明**:VALUE_OUT_OF_RANGE 错误类型暂未实现,后续可根据需要添加字段值范围校验逻辑。
\ No newline at end of file
diff --git a/docs/architecture/数据处理流程.md b/docs/architecture/数据处理流程.md
index 5f9c513..772b644 100644
--- a/docs/architecture/数据处理流程.md
+++ b/docs/architecture/数据处理流程.md
@@ -10,47 +10,52 @@
sequenceDiagram
participant CLI as CLI Entry
participant PS as ParseService
- participant LR as LogFileReader
+ participant SS as ScenarioService
+ participant FNP as FileNameParser
participant QP as QfodParser
participant PP as PlossParser
- participant EHS as ErrorHandlingService
participant DB as Database
+ participant Log as Serilog
+
+ CLI->>PS: 扫描日志目录
+ PS-->>CLI: 返回文件列表
- CLI->>LR: 扫描日志目录
- LR-->>CLI: 返回文件列表
-
loop 每个日志文件
CLI->>PS: ParseFileAsync(filePath)
-
- PS->>DB: INSERT log_file (status=processing)
- DB-->>PS: 返回 file_id
-
- PS->>LR: 读取文件内容
- LR-->>PS: 返回行列表
-
+
+ PS->>FNP: 解析文件名
+ FNP-->>PS: 场景信息(面板、版本、RX类型等)
+
+ PS->>SS: GetOrCreateScenarioAsync(场景信息)
+ SS->>DB: 查询/创建维度记录
+ SS->>DB: 查询/创建场景记录
+ SS-->>PS: 返回 TestScenario
+
+ PS->>PS: 读取文件内容
+
loop 逐行遍历
alt 是 Qfod 行
- PS->>QP: Parse(line)
+ PS->>QP: Parse(line, scenarioId)
QP-->>PS: ParseResult
else 是 Ploss 行
- PS->>PP: Parse(line)
+ PS->>PP: Parse(line, scenarioId)
PP-->>PS: ParseResult
end
-
+
alt 解析成功
PS->>PS: 加入记录列表
else 解析失败
- PS->>EHS: RecordError(line, error)
+ PS->>Log: LogError(line, error)
end
end
-
- PS->>DB: BATCH INSERT records
- PS->>DB: BATCH INSERT errors
- PS->>DB: UPDATE log_file (counts, status=completed)
-
+
+ PS->>DB: BATCH INSERT QfodRecords
+ PS->>DB: BATCH INSERT PlossRecords
+ PS->>DB: UPDATE scenario (qfod_count, ploss_count)
+
PS-->>CLI: 返回 ParseReport
end
-
+
CLI-->>CLI: 输出解析报告
```
@@ -61,6 +66,7 @@ sequenceDiagram
```csharp
// Services/ParseService.cs
using Microsoft.EntityFrameworkCore;
+using Serilog;
using WCTDataMiner.Data;
using WCTDataMiner.Models;
using WCTDataMiner.Parsers;
@@ -70,36 +76,47 @@ namespace WCTDataMiner.Services;
public class ParseService
{
private readonly WctMinerDbContext _context;
+ private readonly ScenarioService _scenarioService;
private readonly QfodParser _qfodParser;
private readonly PlossParser _plossParser;
- private readonly ErrorHandlingService _errorService;
- private readonly ILogger _logger;
+ private readonly FileNameParser _fileNameParser;
private readonly int _batchSize;
public ParseService(
WctMinerDbContext context,
+ ScenarioService scenarioService,
QfodParser qfodParser,
PlossParser plossParser,
- ErrorHandlingService errorService,
- ILogger logger,
+ FileNameParser fileNameParser,
IConfiguration config)
{
_context = context;
+ _scenarioService = scenarioService;
_qfodParser = qfodParser;
_plossParser = plossParser;
- _errorService = errorService;
- _logger = logger;
+ _fileNameParser = fileNameParser;
_batchSize = config.GetValue("Parser:BatchSize", 1000);
}
public async Task ParseFileAsync(string filePath)
{
var fileInfo = new FileInfo(filePath);
- var logFile = await CreateLogFileRecordAsync(fileInfo);
-
+
+ // 解析文件名获取场景信息
+ var scenarioInfo = _fileNameParser.Parse(fileInfo.Name);
+ if (scenarioInfo == null)
+ {
+ Log.Error("无法解析文件名: {FileName}", fileInfo.Name);
+ return new ParseReport(fileInfo.Name, 0, 0, 1, "文件名格式不匹配");
+ }
+
+ // 获取或创建测试场景
+ var scenario = await _scenarioService.GetOrCreateScenarioAsync(scenarioInfo);
+
var lines = await File.ReadAllLinesAsync(filePath);
var qfodRecords = new List();
var plossRecords = new List();
+ int errorCount = 0;
for (int i = 0; i < lines.Length; i++)
{
@@ -108,53 +125,45 @@ public class ParseService
if (_qfodParser.CanParse(line))
{
- var result = _qfodParser.Parse(line, lineNumber, logFile.Id);
+ var result = _qfodParser.Parse(line, scenario.Id);
if (result.IsSuccess)
qfodRecords.Add(result.Record!);
else
- _errorService.RecordError(logFile.Id, lineNumber, line, result.ErrorType!, result.ErrorMessage!);
+ {
+ Log.Error("Qfod解析失败 [{Line}] {Error}: {Message}",
+ lineNumber, result.ErrorType, result.ErrorMessage);
+ errorCount++;
+ }
}
else if (_plossParser.CanParse(line))
{
- var result = _plossParser.Parse(line, lineNumber, logFile.Id);
+ var result = _plossParser.Parse(line, scenario.Id);
if (result.IsSuccess)
plossRecords.Add(result.Record!);
else
- _errorService.RecordError(logFile.Id, lineNumber, line, result.ErrorType!, result.ErrorMessage!);
+ {
+ Log.Error("Ploss解析失败 [{Line}] {Error}: {Message}",
+ lineNumber, result.ErrorType, result.ErrorMessage);
+ errorCount++;
+ }
}
}
// 批量保存
- await BatchInsertAsync(qfodRecords, plossRecords, _errorService.GetErrors());
-
- // 更新统计
- await UpdateLogFileStatsAsync(logFile.Id, qfodRecords.Count, plossRecords.Count, _errorService.ErrorCount);
+ await BatchInsertAsync(qfodRecords, plossRecords);
- return new ParseReport(fileInfo.Name, qfodRecords.Count, plossRecords.Count, _errorService.ErrorCount);
- }
+ // 更新场景统计
+ await UpdateScenarioStatsAsync(scenario.Id, qfodRecords.Count, plossRecords.Count);
- private async Task CreateLogFileRecordAsync(FileInfo fileInfo)
- {
- var logFile = new LogFile
- {
- FileName = fileInfo.Name,
- FilePath = fileInfo.FullName,
- FileSize = fileInfo.Length,
- Status = ParseStatus.Processing
- };
-
- _context.LogFiles.Add(logFile);
- await _context.SaveChangesAsync();
- return logFile;
+ return new ParseReport(fileInfo.Name, qfodRecords.Count, plossRecords.Count, errorCount, null);
}
private async Task BatchInsertAsync(
List qfodRecords,
- List plossRecords,
- List errors)
+ List plossRecords)
{
await using var transaction = await _context.Database.BeginTransactionAsync();
-
+
try
{
// 批量插入 QfodRecord
@@ -171,13 +180,6 @@ public class ParseService
await _context.SaveChangesAsync();
}
- // 批量插入 ParseError
- foreach (var batch in errors.Chunk(_batchSize))
- {
- _context.ParseErrors.AddRange(batch);
- await _context.SaveChangesAsync();
- }
-
await transaction.CommitAsync();
}
catch
@@ -187,19 +189,28 @@ public class ParseService
}
}
- private async Task UpdateLogFileStatsAsync(Guid logFileId, int qfodCount, int plossCount, int errorCount)
+ private async Task UpdateScenarioStatsAsync(Guid scenarioId, int qfodCount, int plossCount)
{
- var logFile = await _context.LogFiles.FindAsync(logFileId);
- if (logFile != null)
+ // 使用累加更新(+=),支持同一场景的多次解析
+ // 若需幂等解析,应在 ParseFileAsync 开始时检查场景是否已处理
+ var scenario = await _context.TestScenarios.FindAsync(scenarioId);
+ if (scenario != null)
{
- logFile.QfodCount = qfodCount;
- logFile.PlossCount = plossCount;
- logFile.ErrorCount = errorCount;
- logFile.Status = ParseStatus.Completed;
+ scenario.QfodCount += qfodCount;
+ scenario.PlossCount += plossCount;
await _context.SaveChangesAsync();
}
}
}
-public record ParseReport(string FileName, int QfodCount, int PlossCount, int ErrorCount);
+public record ParseReport(string FileName, int QfodCount, int PlossCount, int ErrorCount, string? ErrorMessage);
+
+public record ScenarioInfo(
+ string TxPanel,
+ string TxHardware,
+ string TxSoftware,
+ string RxType,
+ string? TestPurpose,
+ DateOnly TestDate,
+ int TestSequence);
```
\ No newline at end of file
diff --git a/docs/architecture/模块划分.md b/docs/architecture/模块划分.md
index 1143269..d5ea2b0 100644
--- a/docs/architecture/模块划分.md
+++ b/docs/architecture/模块划分.md
@@ -13,11 +13,11 @@
├─────────────────────────────────────────────────────────────┤
│ Service Layer │
├─────────────────────────────────────────────────────────────┤
-│ ParseService │ ErrorHandlingService │ ThresholdCalc │
+│ ParseService │ ScenarioService │ DimensionService │ ThresholdCalc │
├─────────────────────────────────────────────────────────────┤
│ Parser Layer │
├─────────────────────────────────────────────────────────────┤
-│ QfodParser │ PlossParser │ LogFileReader │
+│ QfodParser │ PlossParser │ FileNameParser │
├─────────────────────────────────────────────────────────────┤
│ Data Layer │
├─────────────────────────────────────────────────────────────┤
@@ -36,15 +36,18 @@
| 模块名 | 职责 | 依赖模块 |
|--------|------|----------|
| CLI Entry | 接收命令行参数、调度解析流程 | ParseService, Infrastructure |
-| ParseService | 协调解析流程、批量写入、统计更新 | Parser Layer, DbContext, Infrastructure |
+| ParseService | 协调解析流程、批量写入、场景关联 | Parser Layer, ScenarioService, DbContext |
+| ScenarioService | 创建/查询测试场景,解析文件名获取场景信息 | DimensionService, DbContext |
+| DimensionService | 维度表 CRUD(获取或创建维度记录) | DbContext |
+| FileNameParser | 解析文件名提取场景信息(面板、版本、RX类型等) | 无 |
| LogFileReader | 读取日志文件、行过滤、文件遍历 | 无 |
-| QfodParser | 解析 Qfod 格式日志,返回 Record | LogFileReader |
-| PlossParser | 解析 Ploss 格式日志,返回 Record | LogFileReader |
-| Models | 定义数据实体结构 | 无 |
-| WctMinerDbContext | EF Core 数据库上下文 | Models |
+| QfodParser | 解析 Qfod 格式日志,返回 QfodRecord | 无 |
+| PlossParser | 解析 Ploss 格式日志,返回 PlossRecord | 无 |
+| Models | 定义数据实体结构(维度表 + 事实表) | 无 |
+| WctMinerDbContext | EF Core 数据库上下文 | Models, Configurations |
| Configuration | 加载配置文件 | 无 |
-| Serilog | 结构化日志输出 | 无 |
-| ErrorHandlingService | 解析错误记录、错误统计 | 无 |
+| Serilog | 结构化日志输出(含解析错误日志) | 无 |
+| ThresholdCalculator | 根据 RxPower 计算动态阈值 | 无 |
---
@@ -55,10 +58,10 @@
| 层级 | 职责 | 允许调用 |
|------|------|----------|
| CLI Entry | 接收参数、流程调度、结果输出 | Service, Infrastructure |
-| Service | 协调解析流程、批量写入、统计管理 | Parser, Data, Infrastructure |
-| Parser | 日志解析、数据提取、返回 Record | 无 |
-| Data | 数据持久化、DbContext 管理 | Infrastructure |
-| Infrastructure | 配置、日志、错误处理 | 无 |
+| Service | 协调解析流程、场景管理、维度管理、批量写入 | Parser, Data, Infrastructure |
+| Parser | 日志解析、文件名解析、数据提取 | 无 |
+| Data | 数据持久化、DbContext 管理、实体配置 | Infrastructure |
+| Infrastructure | 配置、日志 | 无 |
### 3.2 依赖规则
@@ -66,6 +69,7 @@
- 下层不可调用上层
- Parser 之间不可互相调用(独立解析器)
- Service 层协调 Parser 和 Data 层
+- 解析错误通过 Serilog 记录,不存入业务数据库
---
@@ -75,35 +79,46 @@
graph TD
CLI[CLI Entry] --> PS[ParseService]
CLI --> Serilog
- CLI --> EHS[ErrorHandlingService]
+ PS --> SS[ScenarioService]
PS --> QP[QfodParser]
PS --> PP[PlossParser]
PS --> DBM[WctMinerDbContext]
- PS --> EHS
PS --> Serilog
- PS --> Config[Configuration]
- QP --> LFR[LogFileReader]
- PP --> LFR
+ SS --> DS[DimensionService]
+ SS --> FNP[FileNameParser]
+ SS --> DBM
+
+ DS --> DBM
+
+ QP --> Serilog
+ PP --> Serilog
+ FNP --> Serilog
DBM --> Models[Models]
+ DBM --> Configs[Configurations]
subgraph Parser Layer
QP
PP
- LFR
+ FNP
end
subgraph Data Layer
DBM
Models
+ Configs
end
subgraph Infrastructure
- Config
Serilog
- EHS
+ end
+
+ subgraph Service Layer
+ PS
+ SS
+ DS
end
```
@@ -117,20 +132,32 @@ src/WCTDataMiner/
│ ├── IParser.cs
│ ├── QfodParser.cs
│ ├── PlossParser.cs
-│ └── LogFileReader.cs
+│ ├── FileNameParser.cs # 解析文件名获取场景信息(Parser Layer)
+│ └── LogFileReader.cs # 读取日志文件(可选)
├── Models/
-│ ├── LogFile.cs
-│ ├── QfodRecord.cs
-│ ├── PlossRecord.cs
-│ ├── ParseError.cs
-│ └── ParseStatus.cs
+│ ├── TxPanel.cs # TX面板维度
+│ ├── TxHardware.cs # TX硬件版本维度
+│ ├── TxSoftware.cs # TX软件版本维度
+│ ├── RxType.cs # RX类型维度
+│ ├── TestScenario.cs # 测试场景
+│ ├── QfodRecord.cs # Qfod记录
+│ └── PlossRecord.cs # Ploss记录
├── Data/
│ ├── WctMinerDbContext.cs
-│ └── IDbContextFactory.cs
+│ ├── IDbContextFactory.cs
+│ └── Configurations/ # IEntityTypeConfiguration 配置类
+│ ├── TxPanelConfiguration.cs
+│ ├── TxHardwareConfiguration.cs
+│ ├── TxSoftwareConfiguration.cs
+│ ├── RxTypeConfiguration.cs
+│ ├── TestScenarioConfiguration.cs
+│ ├── QfodRecordConfiguration.cs
+│ └── PlossRecordConfiguration.cs
├── Services/
-│ ├── ParseService.cs
-│ ├── ThresholdCalculator.cs
-│ └── ErrorHandlingService.cs
+│ ├── ParseService.cs # 解析流程协调
+│ ├── ScenarioService.cs # 场景创建/查询
+│ ├── DimensionService.cs # 维度表管理
+│ └── ThresholdCalculator.cs # 阈值计算
├── Configuration/
│ ├── AppSettings.cs
│ └── appsettings.json
diff --git a/docs/data-model/entities/Ploss记录.md b/docs/data-model/entities/Ploss记录.md
index 5fb5b0b..dbc023f 100644
--- a/docs/data-model/entities/Ploss记录.md
+++ b/docs/data-model/entities/Ploss记录.md
@@ -14,22 +14,24 @@
## 2. 字段定义
+> **类型修正说明**:需求文档中 rx_power, tx_power, vcoil, vin, isns, pwm_duty 为 `uint16`(0-65535),但 SQLite 不支持无符号类型。实际数据如 vcoil=35377 超出了 SMALLINT(-32768~32767)范围,因此这些字段使用 INT 类型存储。
+
| 列名 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | UUID | PK, NOT NULL | 主键 |
| scenario_id | UUID | FK, NOT NULL | 关联测试场景 |
| rx_type | TINYINT | NOT NULL | 接收端类型标识 |
-| rx_power | SMALLINT | NOT NULL | 接收端功率(mW) |
-| tx_power | SMALLINT | NOT NULL | 发射端功率(mW) |
-| vcoil | SMALLINT | NOT NULL | 发射线圈电压 |
-| vin | SMALLINT | NOT NULL | 输入电压 |
-| isns | SMALLINT | NOT NULL | 输入电流采样值 |
+| rx_power | INT | NOT NULL | 接收端功率(mW),原 uint16 |
+| tx_power | INT | NOT NULL | 发射端功率(mW),原 uint16 |
+| vcoil | INT | NOT NULL | 发射线圈电压,原 uint16 |
+| vin | INT | NOT NULL | 输入电压,原 uint16 |
+| isns | INT | NOT NULL | 输入电流采样值,原 uint16 |
| ploss | INT | NOT NULL | **核心值**:计算损耗(负数=安全) |
| threshold | INT | NOT NULL | **安全红线**:当前功率段阈值 |
-| trigger_count | SMALLINT | NOT NULL, DEFAULT 0 | 触发计数器 |
-| fod_result | SMALLINT | NOT NULL, DEFAULT 0 | FOD判定结果 |
+| trigger_count | INT | NOT NULL, DEFAULT 0 | 触发计数器 |
+| fod_result | INT | NOT NULL, DEFAULT 0 | FOD判定结果 |
| protocol_type | TINYINT | NOT NULL | 充电协议类型 |
-| pwm_duty | SMALLINT | NOT NULL | PWM占空比 |
+| pwm_duty | INT | NOT NULL | PWM占空比,原 uint16 |
| coil_index | TINYINT | NOT NULL | 充电线圈索引 |
| is_deleted | BOOLEAN | NOT NULL, DEFAULT FALSE | 软删除标记 |
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 创建时间 |
@@ -52,6 +54,8 @@
|------|----------|------|
| margin | threshold - ploss | 安全余量(>0 安全,<0 危险) |
+> **使用限制**:`Margin` 属性通过 EF Core `Ignore()` 排除映射,仅用于内存计算。不可在数据库查询中直接使用(如 `WHERE Margin > 0`),需改用 `WHERE (threshold - ploss) > 0` 或先加载到内存再计算。
+
---
## 5. 索引
@@ -68,100 +72,151 @@
## 6. EF Core 模型
+### 实体类
+
```csharp
// Models/PlossRecord.cs
-using System.ComponentModel.DataAnnotations;
-using System.ComponentModel.DataAnnotations.Schema;
-
namespace WCTDataMiner.Models;
-[Table("ploss_record")]
-[Index(nameof(ScenarioId), Name = "idx_ploss_scenario")]
-[Index(nameof(RxPower), Name = "idx_ploss_rx_power")]
-[Index(nameof(Ploss), Name = "idx_ploss_ploss")]
-[Index(nameof(Threshold), Name = "idx_ploss_threshold")]
-[Index(nameof(FodResult), Name = "idx_ploss_fod_result")]
public class PlossRecord
{
- [Key]
- [Column("id")]
public Guid Id { get; set; } = Guid.NewGuid();
-
- [Required]
- [Column("scenario_id")]
public Guid ScenarioId { get; set; }
-
- [Required]
- [Column("rx_type")]
public byte RxType { get; set; }
-
- [Required]
- [Column("rx_power")]
public short RxPower { get; set; }
-
- [Required]
- [Column("tx_power")]
public short TxPower { get; set; }
-
- [Required]
- [Column("vcoil")]
public short Vcoil { get; set; }
-
- [Required]
- [Column("vin")]
public short Vin { get; set; }
-
- [Required]
- [Column("isns")]
public short Isns { get; set; }
/// 核心判定值:负数表示安全
- [Required]
- [Column("ploss")]
public int Ploss { get; set; }
/// 安全红线:当前功率段阈值
- [Required]
- [Column("threshold")]
public int Threshold { get; set; }
- [Required]
- [Column("trigger_count")]
public short TriggerCount { get; set; } = 0;
-
- [Required]
- [Column("fod_result")]
public short FodResult { get; set; } = 0;
-
- [Required]
- [Column("protocol_type")]
public byte ProtocolType { get; set; }
-
- [Required]
- [Column("pwm_duty")]
public short PwmDuty { get; set; }
-
- [Required]
- [Column("coil_index")]
public byte CoilIndex { get; set; }
-
- [Required]
- [Column("is_deleted")]
public bool IsDeleted { get; set; } = false;
-
- [Required]
- [Column("created_at")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
- [ForeignKey(nameof(ScenarioId))]
+ // Navigation Properties
public TestScenario Scenario { get; set; } = null!;
- /// 派生属性:安全余量
- [NotMapped]
+ /// 派生属性:安全余量(不落库)
public int Margin => Threshold - Ploss;
}
```
+### 配置类
+
+```csharp
+// Data/Configurations/PlossRecordConfiguration.cs
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using WCTDataMiner.Models;
+
+namespace WCTDataMiner.Data.Configurations;
+
+public class PlossRecordConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("ploss_record");
+
+ builder.HasKey(e => e.Id);
+ builder.Property(e => e.Id).HasColumnName("id");
+
+ builder.Property(e => e.ScenarioId)
+ .IsRequired()
+ .HasColumnName("scenario_id");
+
+ builder.Property(e => e.RxType)
+ .IsRequired()
+ .HasColumnName("rx_type");
+
+ builder.Property(e => e.RxPower)
+ .IsRequired()
+ .HasColumnName("rx_power");
+
+ builder.Property(e => e.TxPower)
+ .IsRequired()
+ .HasColumnName("tx_power");
+
+ builder.Property(e => e.Vcoil)
+ .IsRequired()
+ .HasColumnName("vcoil");
+
+ builder.Property(e => e.Vin)
+ .IsRequired()
+ .HasColumnName("vin");
+
+ builder.Property(e => e.Isns)
+ .IsRequired()
+ .HasColumnName("isns");
+
+ builder.Property(e => e.Ploss)
+ .IsRequired()
+ .HasColumnName("ploss");
+
+ builder.Property(e => e.Threshold)
+ .IsRequired()
+ .HasColumnName("threshold");
+
+ builder.Property(e => e.TriggerCount)
+ .IsRequired()
+ .HasDefaultValue((short)0)
+ .HasColumnName("trigger_count");
+
+ builder.Property(e => e.FodResult)
+ .IsRequired()
+ .HasDefaultValue((short)0)
+ .HasColumnName("fod_result");
+
+ builder.Property(e => e.ProtocolType)
+ .IsRequired()
+ .HasColumnName("protocol_type");
+
+ builder.Property(e => e.PwmDuty)
+ .IsRequired()
+ .HasColumnName("pwm_duty");
+
+ builder.Property(e => e.CoilIndex)
+ .IsRequired()
+ .HasColumnName("coil_index");
+
+ builder.Property(e => e.IsDeleted)
+ .IsRequired()
+ .HasDefaultValue(false)
+ .HasColumnName("is_deleted");
+
+ builder.Property(e => e.CreatedAt)
+ .IsRequired()
+ .HasDefaultValueSql("NOW()")
+ .HasColumnName("created_at");
+
+ // 派生属性不落库
+ builder.Ignore(e => e.Margin);
+
+ // 索引
+ builder.HasIndex(e => e.ScenarioId).HasDatabaseName("idx_ploss_scenario");
+ builder.HasIndex(e => e.RxPower).HasDatabaseName("idx_ploss_rx_power");
+ builder.HasIndex(e => e.Ploss).HasDatabaseName("idx_ploss_ploss");
+ builder.HasIndex(e => e.Threshold).HasDatabaseName("idx_ploss_threshold");
+ builder.HasIndex(e => e.FodResult).HasDatabaseName("idx_ploss_fod_result");
+
+ // 关系配置
+ builder.HasOne(e => e.Scenario)
+ .WithMany(e => e.PlossRecords)
+ .HasForeignKey(e => e.ScenarioId)
+ .IsRequired();
+ }
+}
+```
+
---
## 7. 动态阈值表
diff --git a/docs/data-model/entities/Qfod记录.md b/docs/data-model/entities/Qfod记录.md
index 0a9f5f9..d8d9804 100644
--- a/docs/data-model/entities/Qfod记录.md
+++ b/docs/data-model/entities/Qfod记录.md
@@ -55,69 +55,112 @@
| chk_qfod_charger | CHECK | charger_index IN (0, 1) |
| chk_qfod_coil | CHECK | coil_index IN (0, 1, 2) |
+> **数据库兼容性**:`IN (0, 1)` 语法在 SQLite、PostgreSQL、MySQL 中均可使用。若使用其他数据库,需验证 CHECK 约束语法兼容性。
+
---
## 6. EF Core 模型
+### 实体类
+
```csharp
// Models/QfodRecord.cs
-using System.ComponentModel.DataAnnotations;
-using System.ComponentModel.DataAnnotations.Schema;
-
namespace WCTDataMiner.Models;
-[Table("qfod_record")]
-[Index(nameof(ScenarioId), Name = "idx_qfod_scenario")]
-[Index(nameof(ChargerIndex), nameof(CoilIndex), Name = "idx_qfod_charger_coil")]
-[Index(nameof(FodType), Name = "idx_qfod_fod_type")]
-[Index(nameof(DeltaQ), Name = "idx_qfod_delta_q")]
public class QfodRecord
{
- [Key]
- [Column("id")]
public Guid Id { get; set; } = Guid.NewGuid();
-
- [Required]
- [Column("scenario_id")]
public Guid ScenarioId { get; set; }
-
- [Required]
- [Column("charger_index")]
public byte ChargerIndex { get; set; }
-
- [Required]
- [Column("coil_index")]
public byte CoilIndex { get; set; }
-
- [Required]
- [Column("delta_q")]
public int DeltaQ { get; set; }
-
- [Required]
- [Column("current_q")]
public float CurrentQ { get; set; }
-
- [Required]
- [Column("raw_q")]
public float RawQ { get; set; }
-
- [Required]
- [Column("fod_type")]
public byte FodType { get; set; }
-
- [Required]
- [Column("is_deleted")]
public bool IsDeleted { get; set; } = false;
-
- [Required]
- [Column("created_at")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
- [ForeignKey(nameof(ScenarioId))]
+ // Navigation Properties
public TestScenario Scenario { get; set; } = null!;
}
```
+### 配置类
+
+```csharp
+// Data/Configurations/QfodRecordConfiguration.cs
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using WCTDataMiner.Models;
+
+namespace WCTDataMiner.Data.Configurations;
+
+public class QfodRecordConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("qfod_record");
+
+ builder.HasKey(e => e.Id);
+ builder.Property(e => e.Id).HasColumnName("id");
+
+ builder.Property(e => e.ScenarioId)
+ .IsRequired()
+ .HasColumnName("scenario_id");
+
+ builder.Property(e => e.ChargerIndex)
+ .IsRequired()
+ .HasColumnName("charger_index");
+
+ builder.Property(e => e.CoilIndex)
+ .IsRequired()
+ .HasColumnName("coil_index");
+
+ builder.Property(e => e.DeltaQ)
+ .IsRequired()
+ .HasColumnName("delta_q");
+
+ builder.Property(e => e.CurrentQ)
+ .IsRequired()
+ .HasColumnName("current_q");
+
+ builder.Property(e => e.RawQ)
+ .IsRequired()
+ .HasColumnName("raw_q");
+
+ builder.Property(e => e.FodType)
+ .IsRequired()
+ .HasColumnName("fod_type");
+
+ builder.Property(e => e.IsDeleted)
+ .IsRequired()
+ .HasDefaultValue(false)
+ .HasColumnName("is_deleted");
+
+ builder.Property(e => e.CreatedAt)
+ .IsRequired()
+ .HasDefaultValueSql("NOW()")
+ .HasColumnName("created_at");
+
+ // 索引
+ builder.HasIndex(e => e.ScenarioId).HasDatabaseName("idx_qfod_scenario");
+ builder.HasIndex(e => new { e.ChargerIndex, e.CoilIndex }).HasDatabaseName("idx_qfod_charger_coil");
+ builder.HasIndex(e => e.FodType).HasDatabaseName("idx_qfod_fod_type");
+ builder.HasIndex(e => e.DeltaQ).HasDatabaseName("idx_qfod_delta_q");
+
+ // CHECK 约束
+ builder.HasCheckConstraint("chk_qfod_charger", "charger_index IN (0, 1)");
+ builder.HasCheckConstraint("chk_qfod_coil", "coil_index IN (0, 1, 2)");
+
+ // 关系配置
+ builder.HasOne(e => e.Scenario)
+ .WithMany(e => e.QfodRecords)
+ .HasForeignKey(e => e.ScenarioId)
+ .IsRequired();
+ }
+}
+```
+
---
## 7. 检测通道组合
diff --git a/docs/data-model/entities/RxType维度.md b/docs/data-model/entities/RxType维度.md
index b78b3d2..607d158 100644
--- a/docs/data-model/entities/RxType维度.md
+++ b/docs/data-model/entities/RxType维度.md
@@ -34,31 +34,17 @@
## 4. EF Core 模型
+### 实体类
+
```csharp
// Models/RxType.cs
-using System.ComponentModel.DataAnnotations;
-using System.ComponentModel.DataAnnotations.Schema;
-
namespace WCTDataMiner.Models;
-[Table("rx_type")]
public class RxType
{
- [Key]
- [Column("id")]
public Guid Id { get; set; } = Guid.NewGuid();
-
- [Required]
- [MaxLength(100)]
- [Column("name")]
public string Name { get; set; } = null!;
-
- [Required]
- [Column("is_deleted")]
public bool IsDeleted { get; set; } = false;
-
- [Required]
- [Column("created_at")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Navigation Properties
@@ -66,6 +52,45 @@ public class RxType
}
```
+### 配置类
+
+```csharp
+// Data/Configurations/RxTypeConfiguration.cs
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using WCTDataMiner.Models;
+
+namespace WCTDataMiner.Data.Configurations;
+
+public class RxTypeConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("rx_type");
+
+ builder.HasKey(e => e.Id);
+ builder.Property(e => e.Id).HasColumnName("id");
+
+ builder.Property(e => e.Name)
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnName("name");
+
+ builder.Property(e => e.IsDeleted)
+ .IsRequired()
+ .HasDefaultValue(false)
+ .HasColumnName("is_deleted");
+
+ builder.Property(e => e.CreatedAt)
+ .IsRequired()
+ .HasDefaultValueSql("NOW()")
+ .HasColumnName("created_at");
+
+ builder.HasIndex(e => e.Name).IsUnique();
+ }
+}
+```
+
---
## 5. 使用说明
diff --git a/docs/data-model/entities/TxHardware维度.md b/docs/data-model/entities/TxHardware维度.md
index eb9e4e4..c37d785 100644
--- a/docs/data-model/entities/TxHardware维度.md
+++ b/docs/data-model/entities/TxHardware维度.md
@@ -25,31 +25,17 @@
## 3. EF Core 模型
+### 实体类
+
```csharp
// Models/TxHardware.cs
-using System.ComponentModel.DataAnnotations;
-using System.ComponentModel.DataAnnotations.Schema;
-
namespace WCTDataMiner.Models;
-[Table("tx_hardware")]
public class TxHardware
{
- [Key]
- [Column("id")]
public Guid Id { get; set; } = Guid.NewGuid();
-
- [Required]
- [MaxLength(50)]
- [Column("version")]
public string Version { get; set; } = null!;
-
- [Required]
- [Column("is_deleted")]
public bool IsDeleted { get; set; } = false;
-
- [Required]
- [Column("created_at")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Navigation Properties
@@ -57,6 +43,45 @@ public class TxHardware
}
```
+### 配置类
+
+```csharp
+// Data/Configurations/TxHardwareConfiguration.cs
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using WCTDataMiner.Models;
+
+namespace WCTDataMiner.Data.Configurations;
+
+public class TxHardwareConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("tx_hardware");
+
+ builder.HasKey(e => e.Id);
+ builder.Property(e => e.Id).HasColumnName("id");
+
+ builder.Property(e => e.Version)
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnName("version");
+
+ builder.Property(e => e.IsDeleted)
+ .IsRequired()
+ .HasDefaultValue(false)
+ .HasColumnName("is_deleted");
+
+ builder.Property(e => e.CreatedAt)
+ .IsRequired()
+ .HasDefaultValueSql("NOW()")
+ .HasColumnName("created_at");
+
+ builder.HasIndex(e => e.Version).IsUnique();
+ }
+}
+```
+
---
## 4. 使用说明
diff --git a/docs/data-model/entities/TxPanel维度.md b/docs/data-model/entities/TxPanel维度.md
index df2d9d5..6e6d38a 100644
--- a/docs/data-model/entities/TxPanel维度.md
+++ b/docs/data-model/entities/TxPanel维度.md
@@ -39,31 +39,17 @@
## 4. EF Core 模型
+### 实体类
+
```csharp
// Models/TxPanel.cs
-using System.ComponentModel.DataAnnotations;
-using System.ComponentModel.DataAnnotations.Schema;
-
namespace WCTDataMiner.Models;
-[Table("tx_panel")]
public class TxPanel
{
- [Key]
- [Column("id")]
public Guid Id { get; set; } = Guid.NewGuid();
-
- [Required]
- [MaxLength(50)]
- [Column("name")]
public string Name { get; set; } = null!;
-
- [Required]
- [Column("is_deleted")]
public bool IsDeleted { get; set; } = false;
-
- [Required]
- [Column("created_at")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Navigation Properties
@@ -71,6 +57,45 @@ public class TxPanel
}
```
+### 配置类
+
+```csharp
+// Data/Configurations/TxPanelConfiguration.cs
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using WCTDataMiner.Models;
+
+namespace WCTDataMiner.Data.Configurations;
+
+public class TxPanelConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("tx_panel");
+
+ builder.HasKey(e => e.Id);
+ builder.Property(e => e.Id).HasColumnName("id");
+
+ builder.Property(e => e.Name)
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnName("name");
+
+ builder.Property(e => e.IsDeleted)
+ .IsRequired()
+ .HasDefaultValue(false)
+ .HasColumnName("is_deleted");
+
+ builder.Property(e => e.CreatedAt)
+ .IsRequired()
+ .HasDefaultValueSql("NOW()")
+ .HasColumnName("created_at");
+
+ builder.HasIndex(e => e.Name).IsUnique();
+ }
+}
+```
+
---
## 5. 初始化数据
diff --git a/docs/data-model/entities/TxSoftware维度.md b/docs/data-model/entities/TxSoftware维度.md
index dd67e3b..f75de64 100644
--- a/docs/data-model/entities/TxSoftware维度.md
+++ b/docs/data-model/entities/TxSoftware维度.md
@@ -25,31 +25,17 @@
## 3. EF Core 模型
+### 实体类
+
```csharp
// Models/TxSoftware.cs
-using System.ComponentModel.DataAnnotations;
-using System.ComponentModel.DataAnnotations.Schema;
-
namespace WCTDataMiner.Models;
-[Table("tx_software")]
public class TxSoftware
{
- [Key]
- [Column("id")]
public Guid Id { get; set; } = Guid.NewGuid();
-
- [Required]
- [MaxLength(50)]
- [Column("version")]
public string Version { get; set; } = null!;
-
- [Required]
- [Column("is_deleted")]
public bool IsDeleted { get; set; } = false;
-
- [Required]
- [Column("created_at")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Navigation Properties
@@ -57,6 +43,45 @@ public class TxSoftware
}
```
+### 配置类
+
+```csharp
+// Data/Configurations/TxSoftwareConfiguration.cs
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using WCTDataMiner.Models;
+
+namespace WCTDataMiner.Data.Configurations;
+
+public class TxSoftwareConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("tx_software");
+
+ builder.HasKey(e => e.Id);
+ builder.Property(e => e.Id).HasColumnName("id");
+
+ builder.Property(e => e.Version)
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnName("version");
+
+ builder.Property(e => e.IsDeleted)
+ .IsRequired()
+ .HasDefaultValue(false)
+ .HasColumnName("is_deleted");
+
+ builder.Property(e => e.CreatedAt)
+ .IsRequired()
+ .HasDefaultValueSql("NOW()")
+ .HasColumnName("created_at");
+
+ builder.HasIndex(e => e.Version).IsUnique();
+ }
+}
+```
+
---
## 4. 使用说明
diff --git a/docs/data-model/entities/测试场景.md b/docs/data-model/entities/测试场景.md
index a2e0c24..a64475a 100644
--- a/docs/data-model/entities/测试场景.md
+++ b/docs/data-model/entities/测试场景.md
@@ -60,84 +60,42 @@
|--------|----------|------|
| uq_scenario | (tx_panel_id, tx_hardware_id, tx_software_id, rx_type_id, test_purpose, test_date, test_sequence) | 相同属性组合不重复创建 |
+> **关于 test_purpose 的 NULL 处理**:由于 `test_purpose` 是可空字段,含 NULL 的唯一约束在不同数据库中行为不同:
+> - **PostgreSQL**:默认允许多个 NULL(被视为不同值),可通过 `NULLS NOT DISTINCT` 约束行为
+> - **SQL Server**:只允许一个 NULL
+> - **SQLite/MySQL**:允多个 NULL
+>
+> 若需严格幂等(无论 test_purpose 是否为 NULL 都不重复),建议使用 filtered index 或将空值转为默认字符串(如 `(empty)`)。
+
---
## 6. EF Core 模型
+### 实体类
+
```csharp
// Models/TestScenario.cs
-using System.ComponentModel.DataAnnotations;
-using System.ComponentModel.DataAnnotations.Schema;
-
namespace WCTDataMiner.Models;
-[Table("test_scenario")]
-[Index(nameof(TxPanelId), Name = "idx_scenario_tx_panel")]
-[Index(nameof(TxHardwareId), Name = "idx_scenario_tx_hardware")]
-[Index(nameof(TxSoftwareId), Name = "idx_scenario_tx_software")]
-[Index(nameof(RxTypeId), Name = "idx_scenario_rx_type")]
-[Index(nameof(TestDate), Name = "idx_scenario_test_date")]
public class TestScenario
{
- [Key]
- [Column("id")]
public Guid Id { get; set; } = Guid.NewGuid();
-
- [Required]
- [Column("tx_panel_id")]
public Guid TxPanelId { get; set; }
-
- [Required]
- [Column("tx_hardware_id")]
public Guid TxHardwareId { get; set; }
-
- [Required]
- [Column("tx_software_id")]
public Guid TxSoftwareId { get; set; }
-
- [Required]
- [Column("rx_type_id")]
public Guid RxTypeId { get; set; }
-
- [MaxLength(200)]
- [Column("test_purpose")]
public string? TestPurpose { get; set; }
-
- [Required]
- [Column("test_date")]
public DateOnly TestDate { get; set; }
-
- [Required]
- [Column("test_sequence")]
public int TestSequence { get; set; } = 1;
-
- [Required]
- [Column("qfod_count")]
public int QfodCount { get; set; } = 0;
-
- [Required]
- [Column("ploss_count")]
public int PlossCount { get; set; } = 0;
-
- [Required]
- [Column("is_deleted")]
public bool IsDeleted { get; set; } = false;
-
- [Required]
- [Column("created_at")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Navigation Properties - 维度表
- [ForeignKey(nameof(TxPanelId))]
public TxPanel TxPanel { get; set; } = null!;
-
- [ForeignKey(nameof(TxHardwareId))]
public TxHardware TxHardware { get; set; } = null!;
-
- [ForeignKey(nameof(TxSoftwareId))]
public TxSoftware TxSoftware { get; set; } = null!;
-
- [ForeignKey(nameof(RxTypeId))]
public RxType RxType { get; set; } = null!;
// Navigation Properties - 数据记录
@@ -146,6 +104,118 @@ public class TestScenario
}
```
+### 配置类
+
+```csharp
+// Data/Configurations/TestScenarioConfiguration.cs
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using WCTDataMiner.Models;
+
+namespace WCTDataMiner.Data.Configurations;
+
+public class TestScenarioConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("test_scenario");
+
+ builder.HasKey(e => e.Id);
+ builder.Property(e => e.Id).HasColumnName("id");
+
+ builder.Property(e => e.TxPanelId)
+ .IsRequired()
+ .HasColumnName("tx_panel_id");
+
+ builder.Property(e => e.TxHardwareId)
+ .IsRequired()
+ .HasColumnName("tx_hardware_id");
+
+ builder.Property(e => e.TxSoftwareId)
+ .IsRequired()
+ .HasColumnName("tx_software_id");
+
+ builder.Property(e => e.RxTypeId)
+ .IsRequired()
+ .HasColumnName("rx_type_id");
+
+ builder.Property(e => e.TestPurpose)
+ .HasMaxLength(200)
+ .HasColumnName("test_purpose");
+
+ builder.Property(e => e.TestDate)
+ .IsRequired()
+ .HasColumnName("test_date");
+
+ builder.Property(e => e.TestSequence)
+ .IsRequired()
+ .HasDefaultValue(1)
+ .HasColumnName("test_sequence");
+
+ builder.Property(e => e.QfodCount)
+ .IsRequired()
+ .HasDefaultValue(0)
+ .HasColumnName("qfod_count");
+
+ builder.Property(e => e.PlossCount)
+ .IsRequired()
+ .HasDefaultValue(0)
+ .HasColumnName("ploss_count");
+
+ builder.Property(e => e.IsDeleted)
+ .IsRequired()
+ .HasDefaultValue(false)
+ .HasColumnName("is_deleted");
+
+ builder.Property(e => e.CreatedAt)
+ .IsRequired()
+ .HasDefaultValueSql("NOW()")
+ .HasColumnName("created_at");
+
+ // 索引
+ builder.HasIndex(e => e.TxPanelId).HasDatabaseName("idx_scenario_tx_panel");
+ builder.HasIndex(e => e.TxHardwareId).HasDatabaseName("idx_scenario_tx_hardware");
+ builder.HasIndex(e => e.TxSoftwareId).HasDatabaseName("idx_scenario_tx_software");
+ builder.HasIndex(e => e.RxTypeId).HasDatabaseName("idx_scenario_rx_type");
+ builder.HasIndex(e => e.TestDate).HasDatabaseName("idx_scenario_test_date");
+
+ // 唯一约束
+ builder.HasIndex(e => new { e.TxPanelId, e.TxHardwareId, e.TxSoftwareId, e.RxTypeId, e.TestPurpose, e.TestDate, e.TestSequence })
+ .IsUnique()
+ .HasDatabaseName("uq_scenario");
+
+ // 关系配置
+ builder.HasOne(e => e.TxPanel)
+ .WithMany(e => e.TestScenarios)
+ .HasForeignKey(e => e.TxPanelId)
+ .IsRequired();
+
+ builder.HasOne(e => e.TxHardware)
+ .WithMany(e => e.TestScenarios)
+ .HasForeignKey(e => e.TxHardwareId)
+ .IsRequired();
+
+ builder.HasOne(e => e.TxSoftware)
+ .WithMany(e => e.TestScenarios)
+ .HasForeignKey(e => e.TxSoftwareId)
+ .IsRequired();
+
+ builder.HasOne(e => e.RxType)
+ .WithMany(e => e.TestScenarios)
+ .HasForeignKey(e => e.RxTypeId)
+ .IsRequired();
+
+ builder.HasMany(e => e.QfodRecords)
+ .WithOne(e => e.Scenario)
+ .HasForeignKey(e => e.ScenarioId);
+
+ builder.HasMany(e => e.PlossRecords)
+ .WithOne(e => e.Scenario)
+ .HasForeignKey(e => e.ScenarioId);
+ }
+}
+```
+
---
## 7. 场景创建流程
@@ -154,45 +224,41 @@ public class TestScenario
```csharp
// Services/ScenarioService.cs
-public async Task GetOrCreateFromFileNameAsync(string fileName)
+public async Task GetOrCreateScenarioAsync(ScenarioInfo info)
{
- // 1. 解析文件名
- var parts = ParseFileName(fileName);
- // 格式: TX面板-TX硬件-TX软件-RX类型-测试目的-年月日-测试次数.log
+ // 1. 获取或创建各维度(通过 DimensionService)
+ var txPanel = await _dimensionService.GetOrCreateTxPanelAsync(info.TxPanel);
+ var txHardware = await _dimensionService.GetOrCreateTxHardwareAsync(info.TxHardware);
+ var txSoftware = await _dimensionService.GetOrCreateTxSoftwareAsync(info.TxSoftware);
+ var rxType = await _dimensionService.GetOrCreateRxTypeAsync(info.RxType);
- // 2. 获取或创建各维度
- var txPanel = await txPanelService.GetOrCreateAsync(parts.TxPanel);
- var txHardware = await txHardwareService.GetOrCreateAsync(parts.TxHardware);
- var txSoftware = await txSoftwareService.GetOrCreateAsync(parts.TxSoftware);
- var rxType = await rxTypeService.GetOrCreateAsync(parts.RxType);
-
- // 3. 检查场景是否已存在
- var existing = await context.TestScenarios
+ // 2. 检查场景是否已存在(利用唯一约束)
+ var existing = await _context.TestScenarios
.FirstOrDefaultAsync(s =>
s.TxPanelId == txPanel.Id &&
s.TxHardwareId == txHardware.Id &&
s.TxSoftwareId == txSoftware.Id &&
s.RxTypeId == rxType.Id &&
- s.TestPurpose == parts.TestPurpose &&
- s.TestDate == parts.TestDate &&
- s.TestSequence == parts.TestSequence);
+ s.TestPurpose == info.TestPurpose &&
+ s.TestDate == info.TestDate &&
+ s.TestSequence == info.TestSequence);
if (existing != null) return existing;
- // 4. 创建新场景
+ // 3. 创建新场景
var scenario = new TestScenario
{
TxPanelId = txPanel.Id,
TxHardwareId = txHardware.Id,
TxSoftwareId = txSoftware.Id,
RxTypeId = rxType.Id,
- TestPurpose = parts.TestPurpose,
- TestDate = parts.TestDate,
- TestSequence = parts.TestSequence
+ TestPurpose = info.TestPurpose,
+ TestDate = info.TestDate,
+ TestSequence = info.TestSequence
};
- context.TestScenarios.Add(scenario);
- await context.SaveChangesAsync();
+ _context.TestScenarios.Add(scenario);
+ await _context.SaveChangesAsync();
return scenario;
}
diff --git a/docs/data-model/概览.md b/docs/data-model/概览.md
index b6baf1e..225992d 100644
--- a/docs/data-model/概览.md
+++ b/docs/data-model/概览.md
@@ -182,6 +182,60 @@ public async Task SoftDeleteAsync(Guid id)
}
```
+### 实体配置方式
+
+采用 `IEntityTypeConfiguration` Fluent API 配置,实体类保持纯净 POCO:
+
+```csharp
+// Data/WctMinerDbContext.cs
+using Microsoft.EntityFrameworkCore;
+using WCTDataMiner.Models;
+
+namespace WCTDataMiner.Data;
+
+public class WctMinerDbContext : DbContext
+{
+ public WctMinerDbContext(DbContextOptions options)
+ : base(options) { }
+
+ public DbSet TxPanels => Set();
+ public DbSet TxHardwares => Set();
+ public DbSet TxSoftwares => Set();
+ public DbSet RxTypes => Set();
+ public DbSet TestScenarios => Set();
+ public DbSet QfodRecords => Set();
+ public DbSet PlossRecords => Set();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ base.OnModelCreating(modelBuilder);
+
+ // 自动应用所有 IEntityTypeConfiguration 配置
+ modelBuilder.ApplyConfigurationsFromAssembly(typeof(WctMinerDbContext).Assembly);
+ }
+}
+```
+
+> **数据库兼容性说明**:
+> - `HasDefaultValueSql("NOW()")` 适用于 PostgreSQL
+> - 若使用 SQLite,需改为 `HasDefaultValueSql("datetime('now')")`
+> - 若使用 MySQL,需改为 `HasDefaultValueSql("NOW()")`(MySQL 也支持)
+> - 若使用 SQL Server,需改为 `HasDefaultValueSql("GETDATE()")`
+
+**配置类列表**:
+
+| 配置类 | 文件路径 | 说明 |
+|--------|----------|------|
+| TxPanelConfiguration | Data/Configurations/TxPanelConfiguration.cs | TX面板维度 |
+| TxHardwareConfiguration | Data/Configurations/TxHardwareConfiguration.cs | TX硬件版本维度 |
+| TxSoftwareConfiguration | Data/Configurations/TxSoftwareConfiguration.cs | TX软件版本维度 |
+| RxTypeConfiguration | Data/Configurations/RxTypeConfiguration.cs | RX类型维度 |
+| TestScenarioConfiguration | Data/Configurations/TestScenarioConfiguration.cs | 测试场景 |
+| QfodRecordConfiguration | Data/Configurations/QfodRecordConfiguration.cs | Qfod记录 |
+| PlossRecordConfiguration | Data/Configurations/PlossRecordConfiguration.cs | Ploss记录 |
+
+> **迁移注意事项**:从 DataAnnotations `DateTime.UtcNow` 迁移到 `HasDefaultValueSql("NOW()")` 后,测试代码需注意:未显式设置 `CreatedAt` 时,值由数据库生成而非 C# 运行时。单元测试中需 mock 数据库或显式设置值。
+
### 系统日志处理
解析过程中的错误日志通过 Serilog 记录到系统日志文件,不存入业务数据库。
diff --git a/src/WCTDataMiner/Commands/CleanCommand.cs b/src/WCTDataMiner/Commands/CleanCommand.cs
new file mode 100644
index 0000000..3691b0c
--- /dev/null
+++ b/src/WCTDataMiner/Commands/CleanCommand.cs
@@ -0,0 +1,65 @@
+using System.CommandLine;
+using WCTDataMiner.Services;
+
+namespace WCTDataMiner.Commands;
+
+///
+/// 数据清理命令
+///
+public class CleanCommand : Command
+{
+ public CleanCommand(CleanService cleanService)
+ : base("clean", "Clean data from database (soft delete)")
+ {
+ var scenarioOption = new Option(
+ "--scenario",
+ "Scenario ID to clean"
+ );
+
+ var allOption = new Option(
+ "--all",
+ () => false,
+ "Clean all data"
+ );
+
+ var confirmOption = new Option(
+ "--confirm",
+ () => false,
+ "Confirm the clean operation"
+ );
+
+ AddOption(scenarioOption);
+ AddOption(allOption);
+ AddOption(confirmOption);
+
+ this.SetHandler(async (scenario, all, confirm) =>
+ {
+ if (!confirm)
+ {
+ Console.WriteLine("Please add --confirm to confirm the clean operation.");
+ return;
+ }
+
+ if (!string.IsNullOrEmpty(scenario))
+ {
+ if (!Guid.TryParse(scenario, out var scenarioId))
+ {
+ Console.WriteLine($"Invalid scenario ID: {scenario}");
+ return;
+ }
+
+ var count = await cleanService.CleanByScenarioAsync(scenarioId);
+ Console.WriteLine($"Cleaned {count} records for scenario {scenarioId}");
+ }
+ else if (all)
+ {
+ var count = await cleanService.CleanAllAsync();
+ Console.WriteLine($"Cleaned {count} records in total");
+ }
+ else
+ {
+ Console.WriteLine("Please specify --scenario or --all");
+ }
+ }, scenarioOption, allOption, confirmOption);
+ }
+}
\ No newline at end of file
diff --git a/src/WCTDataMiner/Commands/ExportCommand.cs b/src/WCTDataMiner/Commands/ExportCommand.cs
new file mode 100644
index 0000000..fcde7ca
--- /dev/null
+++ b/src/WCTDataMiner/Commands/ExportCommand.cs
@@ -0,0 +1,84 @@
+using System.CommandLine;
+using WCTDataMiner.Services;
+
+namespace WCTDataMiner.Commands;
+
+///
+/// 数据导出命令
+///
+public class ExportCommand : Command
+{
+ public ExportCommand(ExportService exportService)
+ : base("export", "Export data to CSV or JSON")
+ {
+ var formatOption = new Option(
+ "--format",
+ () => "csv",
+ "Export format: csv or json"
+ );
+
+ var outputOption = new Option(
+ "--output",
+ () => "./data/output",
+ "Output directory path"
+ );
+
+ var typeOption = new Option(
+ "--type",
+ () => "all",
+ "Data type to export: qfod, ploss, or all"
+ );
+
+ AddOption(formatOption);
+ AddOption(outputOption);
+ AddOption(typeOption);
+
+ this.SetHandler(async (format, output, type) =>
+ {
+ format = format.ToLower();
+ type = type.ToLower();
+
+ Console.WriteLine($"Exporting {type} data as {format.ToUpper()} to {output}...");
+
+ try
+ {
+ if (format == "csv")
+ {
+ if (type is "qfod" or "all")
+ {
+ var path = await exportService.ExportQfodToCsvAsync(output);
+ Console.WriteLine($" Qfod CSV exported: {path}");
+ }
+
+ if (type is "ploss" or "all")
+ {
+ var path = await exportService.ExportPlossToCsvAsync(output);
+ Console.WriteLine($" Ploss CSV exported: {path}");
+ }
+ }
+ else if (format == "json")
+ {
+ if (type is "qfod" or "all")
+ {
+ var path = await exportService.ExportQfodToJsonAsync(output);
+ Console.WriteLine($" Qfod JSON exported: {path}");
+ }
+
+ if (type is "ploss" or "all")
+ {
+ var path = await exportService.ExportPlossToJsonAsync(output);
+ Console.WriteLine($" Ploss JSON exported: {path}");
+ }
+ }
+ else
+ {
+ Console.WriteLine($"Unknown format: {format}. Use 'csv' or 'json'.");
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Export failed: {ex.Message}");
+ }
+ }, formatOption, outputOption, typeOption);
+ }
+}
\ No newline at end of file
diff --git a/src/WCTDataMiner/Commands/ParseCommand.cs b/src/WCTDataMiner/Commands/ParseCommand.cs
new file mode 100644
index 0000000..7a7a588
--- /dev/null
+++ b/src/WCTDataMiner/Commands/ParseCommand.cs
@@ -0,0 +1,80 @@
+using System.CommandLine;
+using WCTDataMiner.Services;
+
+namespace WCTDataMiner.Commands;
+
+///
+/// 解析日志文件命令
+///
+public class ParseCommand : Command
+{
+ public ParseCommand(ParseService parseService)
+ : base("parse", "Parse log files and store data to database")
+ {
+ var fileOption = new Option(
+ "--file",
+ "Path to a single log file to parse"
+ );
+
+ var dirOption = new Option(
+ "--dir",
+ "Path to directory containing log files"
+ );
+
+ var recursiveOption = new Option(
+ "--recursive",
+ () => false,
+ "Recursively search subdirectories"
+ );
+
+ var forceOption = new Option(
+ "--force",
+ () => false,
+ "Force re-parse even if file already parsed"
+ );
+
+ AddOption(fileOption);
+ AddOption(dirOption);
+ AddOption(recursiveOption);
+ AddOption(forceOption);
+
+ this.SetHandler(async (file, dir, recursive, force) =>
+ {
+ if (!string.IsNullOrEmpty(file))
+ {
+ var report = await parseService.ParseFileAsync(file, force);
+ PrintReport(report);
+ }
+ else if (!string.IsNullOrEmpty(dir))
+ {
+ var reports = await parseService.ParseDirectoryAsync(dir, recursive, force);
+ Console.WriteLine($"\n=== Summary: {reports.Count} files processed ===");
+ foreach (var report in reports)
+ {
+ PrintReport(report);
+ }
+
+ var totalQfod = reports.Sum(r => r.QfodCount);
+ var totalPloss = reports.Sum(r => r.PlossCount);
+ var totalErrors = reports.Sum(r => r.ErrorCount);
+ Console.WriteLine($"\nTotal: Qfod={totalQfod}, Ploss={totalPloss}, Errors={totalErrors}");
+ }
+ else
+ {
+ Console.WriteLine("Please specify --file or --dir");
+ }
+ }, fileOption, dirOption, recursiveOption, forceOption);
+ }
+
+ private static void PrintReport(ParseReport report)
+ {
+ if (report.IsSuccess)
+ {
+ Console.WriteLine($" [{report.FileName}] Qfod: {report.QfodCount}, Ploss: {report.PlossCount}, Errors: {report.ErrorCount}");
+ }
+ else
+ {
+ Console.WriteLine($" [{report.FileName}] FAILED: {report.ErrorMessage}");
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/WCTDataMiner/Commands/StatsCommand.cs b/src/WCTDataMiner/Commands/StatsCommand.cs
new file mode 100644
index 0000000..63fcdd3
--- /dev/null
+++ b/src/WCTDataMiner/Commands/StatsCommand.cs
@@ -0,0 +1,112 @@
+using System.CommandLine;
+using WCTDataMiner.Services;
+
+namespace WCTDataMiner.Commands;
+
+///
+/// 统计查询命令
+///
+public class StatsCommand : Command
+{
+ public StatsCommand(StatsService statsService)
+ : base("stats", "Query statistics from database")
+ {
+ var summaryOption = new Option(
+ "--summary",
+ () => false,
+ "Show overall statistics summary"
+ );
+
+ var byFodTypeOption = new Option(
+ "--by-fod-type",
+ () => false,
+ "Show statistics by FOD type"
+ );
+
+ var byPanelOption = new Option(
+ "--by-panel",
+ () => false,
+ "Show statistics by TX panel"
+ );
+
+ var byHardwareOption = new Option(
+ "--by-hardware",
+ () => false,
+ "Show statistics by TX hardware version"
+ );
+
+ var byRxTypeOption = new Option(
+ "--by-rx-type",
+ () => false,
+ "Show statistics by RX type"
+ );
+
+ AddOption(summaryOption);
+ AddOption(byFodTypeOption);
+ AddOption(byPanelOption);
+ AddOption(byHardwareOption);
+ AddOption(byRxTypeOption);
+
+ this.SetHandler(async (summary, byFodType, byPanel, byHardware, byRxType) =>
+ {
+ // 如果没有指定任何选项,默认显示摘要
+ if (!summary && !byFodType && !byPanel && !byHardware && !byRxType)
+ {
+ summary = true;
+ }
+
+ if (summary)
+ {
+ var s = await statsService.GetSummaryAsync();
+ Console.WriteLine("=== Statistics Summary ===");
+ Console.WriteLine($" Scenarios: {s.ScenarioCount}");
+ Console.WriteLine($" Qfod Records: {s.QfodCount}");
+ Console.WriteLine($" Ploss Records: {s.PlossCount}");
+ Console.WriteLine($" TX Panels: {s.PanelCount}");
+ Console.WriteLine($" TX Hardware: {s.HardwareCount}");
+ Console.WriteLine($" TX Software: {s.SoftwareCount}");
+ Console.WriteLine($" RX Types: {s.RxTypeCount}");
+ }
+
+ if (byFodType)
+ {
+ Console.WriteLine("\n=== By FOD Type ===");
+ var stats = await statsService.GetStatsByFodTypeAsync();
+ foreach (var s in stats)
+ {
+ Console.WriteLine($" FOD Type {s.FodType}: {s.Count}");
+ }
+ }
+
+ if (byPanel)
+ {
+ Console.WriteLine("\n=== By TX Panel ===");
+ var stats = await statsService.GetStatsByPanelAsync();
+ foreach (var s in stats)
+ {
+ Console.WriteLine($" {s.Name}: {s.Count}");
+ }
+ }
+
+ if (byHardware)
+ {
+ Console.WriteLine("\n=== By TX Hardware ===");
+ var stats = await statsService.GetStatsByHardwareAsync();
+ foreach (var s in stats)
+ {
+ Console.WriteLine($" {s.Name}: {s.Count}");
+ }
+ }
+
+ if (byRxType)
+ {
+ Console.WriteLine("\n=== By RX Type ===");
+ var stats = await statsService.GetStatsByRxTypeAsync();
+ foreach (var s in stats)
+ {
+ Console.WriteLine($" {s.Name}: {s.Count}");
+ }
+ }
+ }, summaryOption, byFodTypeOption, byPanelOption, byHardwareOption, byRxTypeOption);
+ }
+}
\ No newline at end of file
diff --git a/src/WCTDataMiner/Configuration/AppSettings.cs b/src/WCTDataMiner/Configuration/AppSettings.cs
new file mode 100644
index 0000000..fd532e5
--- /dev/null
+++ b/src/WCTDataMiner/Configuration/AppSettings.cs
@@ -0,0 +1,44 @@
+namespace WCTDataMiner.Configuration;
+
+///
+/// 应用配置
+///
+public class AppSettings
+{
+ public DatabaseSettings Database { get; set; } = new();
+ public ParserSettings Parser { get; set; } = new();
+ public PathSettings Paths { get; set; } = new();
+}
+
+///
+/// 数据库配置
+///
+public class DatabaseSettings
+{
+ public string Type { get; set; } = "sqlite";
+ public string Path { get; set; } = "./data/database/wctminer.db";
+
+ // PostgreSQL 配置(可选)
+ public string? Host { get; set; }
+ public int Port { get; set; } = 5432;
+ public string? Name { get; set; }
+ public string? User { get; set; }
+ public string? Password { get; set; }
+}
+
+///
+/// 解析器配置
+///
+public class ParserSettings
+{
+ public int BatchSize { get; set; } = 1000;
+}
+
+///
+/// 路径配置
+///
+public class PathSettings
+{
+ public string LogDir { get; set; } = "./data/logs";
+ public string OutputDir { get; set; } = "./data/output";
+}
\ No newline at end of file
diff --git a/src/WCTDataMiner/Data/Configurations/PlossRecordConfiguration.cs b/src/WCTDataMiner/Data/Configurations/PlossRecordConfiguration.cs
new file mode 100644
index 0000000..85fb8c7
--- /dev/null
+++ b/src/WCTDataMiner/Data/Configurations/PlossRecordConfiguration.cs
@@ -0,0 +1,103 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using WCTDataMiner.Models;
+
+namespace WCTDataMiner.Data.Configurations;
+
+public class PlossRecordConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("ploss_record");
+
+ builder.HasKey(e => e.Id);
+ builder.Property(e => e.Id).HasColumnName("id");
+
+ builder.Property(e => e.ScenarioId)
+ .IsRequired()
+ .HasColumnName("scenario_id");
+
+ builder.Property(e => e.RxType)
+ .IsRequired()
+ .HasColumnName("rx_type");
+
+ builder.Property(e => e.RxPower)
+ .IsRequired()
+ .HasColumnName("rx_power");
+
+ builder.Property(e => e.TxPower)
+ .IsRequired()
+ .HasColumnName("tx_power");
+
+ builder.Property(e => e.Vcoil)
+ .IsRequired()
+ .HasColumnName("vcoil");
+
+ builder.Property(e => e.Vin)
+ .IsRequired()
+ .HasColumnName("vin");
+
+ builder.Property(e => e.Isns)
+ .IsRequired()
+ .HasColumnName("isns");
+
+ builder.Property(e => e.Ploss)
+ .IsRequired()
+ .HasColumnName("ploss");
+
+ builder.Property(e => e.Threshold)
+ .IsRequired()
+ .HasColumnName("threshold");
+
+ builder.Property(e => e.TriggerCount)
+ .IsRequired()
+ .HasDefaultValue(0)
+ .HasColumnName("trigger_count");
+
+ builder.Property(e => e.FodResult)
+ .IsRequired()
+ .HasDefaultValue(0)
+ .HasColumnName("fod_result");
+
+ builder.Property(e => e.ProtocolType)
+ .IsRequired()
+ .HasColumnName("protocol_type");
+
+ builder.Property(e => e.PwmDuty)
+ .IsRequired()
+ .HasColumnName("pwm_duty");
+
+ builder.Property(e => e.CoilIndex)
+ .IsRequired()
+ .HasColumnName("coil_index");
+
+ builder.Property(e => e.IsDeleted)
+ .IsRequired()
+ .HasDefaultValue(false)
+ .HasColumnName("is_deleted");
+
+ builder.Property(e => e.CreatedAt)
+ .IsRequired()
+ .HasDefaultValueSql("datetime('now')")
+ .HasColumnName("created_at");
+
+ // 派生属性不落库
+ builder.Ignore(e => e.Margin);
+
+ // 索引
+ builder.HasIndex(e => e.ScenarioId).HasDatabaseName("idx_ploss_scenario");
+ builder.HasIndex(e => e.RxPower).HasDatabaseName("idx_ploss_rx_power");
+ builder.HasIndex(e => e.Ploss).HasDatabaseName("idx_ploss_ploss");
+ builder.HasIndex(e => e.Threshold).HasDatabaseName("idx_ploss_threshold");
+ builder.HasIndex(e => e.FodResult).HasDatabaseName("idx_ploss_fod_result");
+
+ // 关系配置
+ builder.HasOne(e => e.Scenario)
+ .WithMany(e => e.PlossRecords)
+ .HasForeignKey(e => e.ScenarioId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ // 全局查询过滤器
+ builder.HasQueryFilter(e => !e.IsDeleted);
+ }
+}
\ No newline at end of file
diff --git a/src/WCTDataMiner/Data/Configurations/QfodRecordConfiguration.cs b/src/WCTDataMiner/Data/Configurations/QfodRecordConfiguration.cs
new file mode 100644
index 0000000..565cc26
--- /dev/null
+++ b/src/WCTDataMiner/Data/Configurations/QfodRecordConfiguration.cs
@@ -0,0 +1,73 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using WCTDataMiner.Models;
+
+namespace WCTDataMiner.Data.Configurations;
+
+public class QfodRecordConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("qfod_record", t =>
+ {
+ t.HasCheckConstraint("chk_qfod_charger", "charger_index IN (0, 1)");
+ t.HasCheckConstraint("chk_qfod_coil", "coil_index IN (0, 1, 2)");
+ });
+
+ builder.HasKey(e => e.Id);
+ builder.Property(e => e.Id).HasColumnName("id");
+
+ builder.Property(e => e.ScenarioId)
+ .IsRequired()
+ .HasColumnName("scenario_id");
+
+ builder.Property(e => e.ChargerIndex)
+ .IsRequired()
+ .HasColumnName("charger_index");
+
+ builder.Property(e => e.CoilIndex)
+ .IsRequired()
+ .HasColumnName("coil_index");
+
+ builder.Property(e => e.DeltaQ)
+ .IsRequired()
+ .HasColumnName("delta_q");
+
+ builder.Property(e => e.CurrentQ)
+ .IsRequired()
+ .HasColumnName("current_q");
+
+ builder.Property(e => e.RawQ)
+ .IsRequired()
+ .HasColumnName("raw_q");
+
+ builder.Property(e => e.FodType)
+ .IsRequired()
+ .HasColumnName("fod_type");
+
+ builder.Property(e => e.IsDeleted)
+ .IsRequired()
+ .HasDefaultValue(false)
+ .HasColumnName("is_deleted");
+
+ builder.Property(e => e.CreatedAt)
+ .IsRequired()
+ .HasDefaultValueSql("datetime('now')")
+ .HasColumnName("created_at");
+
+ // 索引
+ builder.HasIndex(e => e.ScenarioId).HasDatabaseName("idx_qfod_scenario");
+ builder.HasIndex(e => new { e.ChargerIndex, e.CoilIndex }).HasDatabaseName("idx_qfod_charger_coil");
+ builder.HasIndex(e => e.FodType).HasDatabaseName("idx_qfod_fod_type");
+ builder.HasIndex(e => e.DeltaQ).HasDatabaseName("idx_qfod_delta_q");
+
+ // 关系配置
+ builder.HasOne(e => e.Scenario)
+ .WithMany(e => e.QfodRecords)
+ .HasForeignKey(e => e.ScenarioId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ // 全局查询过滤器
+ builder.HasQueryFilter(e => !e.IsDeleted);
+ }
+}
\ No newline at end of file
diff --git a/src/WCTDataMiner/Data/Configurations/RxTypeConfiguration.cs b/src/WCTDataMiner/Data/Configurations/RxTypeConfiguration.cs
new file mode 100644
index 0000000..bb81d29
--- /dev/null
+++ b/src/WCTDataMiner/Data/Configurations/RxTypeConfiguration.cs
@@ -0,0 +1,36 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using WCTDataMiner.Models;
+
+namespace WCTDataMiner.Data.Configurations;
+
+public class RxTypeConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("rx_type");
+
+ builder.HasKey(e => e.Id);
+ builder.Property(e => e.Id).HasColumnName("id");
+
+ builder.Property(e => e.Name)
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnName("name");
+
+ builder.Property(e => e.IsDeleted)
+ .IsRequired()
+ .HasDefaultValue(false)
+ .HasColumnName("is_deleted");
+
+ builder.Property(e => e.CreatedAt)
+ .IsRequired()
+ .HasDefaultValueSql("datetime('now')")
+ .HasColumnName("created_at");
+
+ builder.HasIndex(e => e.Name).IsUnique();
+
+ // 全局查询过滤器
+ builder.HasQueryFilter(e => !e.IsDeleted);
+ }
+}
\ No newline at end of file
diff --git a/src/WCTDataMiner/Data/Configurations/TestScenarioConfiguration.cs b/src/WCTDataMiner/Data/Configurations/TestScenarioConfiguration.cs
new file mode 100644
index 0000000..57ace69
--- /dev/null
+++ b/src/WCTDataMiner/Data/Configurations/TestScenarioConfiguration.cs
@@ -0,0 +1,112 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using WCTDataMiner.Models;
+
+namespace WCTDataMiner.Data.Configurations;
+
+public class TestScenarioConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("test_scenario");
+
+ builder.HasKey(e => e.Id);
+ builder.Property(e => e.Id).HasColumnName("id");
+
+ builder.Property(e => e.TxPanelId)
+ .IsRequired()
+ .HasColumnName("tx_panel_id");
+
+ builder.Property(e => e.TxHardwareId)
+ .IsRequired()
+ .HasColumnName("tx_hardware_id");
+
+ builder.Property(e => e.TxSoftwareId)
+ .IsRequired()
+ .HasColumnName("tx_software_id");
+
+ builder.Property(e => e.RxTypeId)
+ .IsRequired()
+ .HasColumnName("rx_type_id");
+
+ builder.Property(e => e.TestPurpose)
+ .HasMaxLength(200)
+ .HasColumnName("test_purpose");
+
+ builder.Property(e => e.TestDate)
+ .IsRequired()
+ .HasColumnName("test_date");
+
+ builder.Property(e => e.TestSequence)
+ .IsRequired()
+ .HasDefaultValue(1)
+ .HasColumnName("test_sequence");
+
+ builder.Property(e => e.QfodCount)
+ .IsRequired()
+ .HasDefaultValue(0)
+ .HasColumnName("qfod_count");
+
+ builder.Property(e => e.PlossCount)
+ .IsRequired()
+ .HasDefaultValue(0)
+ .HasColumnName("ploss_count");
+
+ builder.Property(e => e.IsDeleted)
+ .IsRequired()
+ .HasDefaultValue(false)
+ .HasColumnName("is_deleted");
+
+ builder.Property(e => e.CreatedAt)
+ .IsRequired()
+ .HasDefaultValueSql("datetime('now')")
+ .HasColumnName("created_at");
+
+ // 索引
+ builder.HasIndex(e => e.TxPanelId).HasDatabaseName("idx_scenario_tx_panel");
+ builder.HasIndex(e => e.TxHardwareId).HasDatabaseName("idx_scenario_tx_hardware");
+ builder.HasIndex(e => e.TxSoftwareId).HasDatabaseName("idx_scenario_tx_software");
+ builder.HasIndex(e => e.RxTypeId).HasDatabaseName("idx_scenario_rx_type");
+ builder.HasIndex(e => e.TestDate).HasDatabaseName("idx_scenario_test_date");
+
+ // 唯一约束
+ builder.HasIndex(e => new { e.TxPanelId, e.TxHardwareId, e.TxSoftwareId, e.RxTypeId, e.TestPurpose, e.TestDate, e.TestSequence })
+ .IsUnique()
+ .HasDatabaseName("uq_scenario");
+
+ // 关系配置 - 维度表
+ builder.HasOne(e => e.TxPanel)
+ .WithMany(e => e.TestScenarios)
+ .HasForeignKey(e => e.TxPanelId)
+ .OnDelete(DeleteBehavior.Restrict);
+
+ builder.HasOne(e => e.TxHardware)
+ .WithMany(e => e.TestScenarios)
+ .HasForeignKey(e => e.TxHardwareId)
+ .OnDelete(DeleteBehavior.Restrict);
+
+ builder.HasOne(e => e.TxSoftware)
+ .WithMany(e => e.TestScenarios)
+ .HasForeignKey(e => e.TxSoftwareId)
+ .OnDelete(DeleteBehavior.Restrict);
+
+ builder.HasOne(e => e.RxType)
+ .WithMany(e => e.TestScenarios)
+ .HasForeignKey(e => e.RxTypeId)
+ .OnDelete(DeleteBehavior.Restrict);
+
+ // 关系配置 - 数据记录
+ builder.HasMany(e => e.QfodRecords)
+ .WithOne(e => e.Scenario)
+ .HasForeignKey(e => e.ScenarioId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ builder.HasMany(e => e.PlossRecords)
+ .WithOne(e => e.Scenario)
+ .HasForeignKey(e => e.ScenarioId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ // 全局查询过滤器
+ builder.HasQueryFilter(e => !e.IsDeleted);
+ }
+}
\ No newline at end of file
diff --git a/src/WCTDataMiner/Data/Configurations/TxHardwareConfiguration.cs b/src/WCTDataMiner/Data/Configurations/TxHardwareConfiguration.cs
new file mode 100644
index 0000000..8405e2a
--- /dev/null
+++ b/src/WCTDataMiner/Data/Configurations/TxHardwareConfiguration.cs
@@ -0,0 +1,36 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using WCTDataMiner.Models;
+
+namespace WCTDataMiner.Data.Configurations;
+
+public class TxHardwareConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("tx_hardware");
+
+ builder.HasKey(e => e.Id);
+ builder.Property(e => e.Id).HasColumnName("id");
+
+ builder.Property(e => e.Version)
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnName("version");
+
+ builder.Property(e => e.IsDeleted)
+ .IsRequired()
+ .HasDefaultValue(false)
+ .HasColumnName("is_deleted");
+
+ builder.Property(e => e.CreatedAt)
+ .IsRequired()
+ .HasDefaultValueSql("datetime('now')")
+ .HasColumnName("created_at");
+
+ builder.HasIndex(e => e.Version).IsUnique();
+
+ // 全局查询过滤器
+ builder.HasQueryFilter(e => !e.IsDeleted);
+ }
+}
\ No newline at end of file
diff --git a/src/WCTDataMiner/Data/Configurations/TxPanelConfiguration.cs b/src/WCTDataMiner/Data/Configurations/TxPanelConfiguration.cs
new file mode 100644
index 0000000..9b9a768
--- /dev/null
+++ b/src/WCTDataMiner/Data/Configurations/TxPanelConfiguration.cs
@@ -0,0 +1,36 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using WCTDataMiner.Models;
+
+namespace WCTDataMiner.Data.Configurations;
+
+public class TxPanelConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("tx_panel");
+
+ builder.HasKey(e => e.Id);
+ builder.Property(e => e.Id).HasColumnName("id");
+
+ builder.Property(e => e.Name)
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnName("name");
+
+ builder.Property(e => e.IsDeleted)
+ .IsRequired()
+ .HasDefaultValue(false)
+ .HasColumnName("is_deleted");
+
+ builder.Property(e => e.CreatedAt)
+ .IsRequired()
+ .HasDefaultValueSql("datetime('now')")
+ .HasColumnName("created_at");
+
+ builder.HasIndex(e => e.Name).IsUnique();
+
+ // 全局查询过滤器
+ builder.HasQueryFilter(e => !e.IsDeleted);
+ }
+}
\ No newline at end of file
diff --git a/src/WCTDataMiner/Data/Configurations/TxSoftwareConfiguration.cs b/src/WCTDataMiner/Data/Configurations/TxSoftwareConfiguration.cs
new file mode 100644
index 0000000..144643b
--- /dev/null
+++ b/src/WCTDataMiner/Data/Configurations/TxSoftwareConfiguration.cs
@@ -0,0 +1,36 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using WCTDataMiner.Models;
+
+namespace WCTDataMiner.Data.Configurations;
+
+public class TxSoftwareConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("tx_software");
+
+ builder.HasKey(e => e.Id);
+ builder.Property(e => e.Id).HasColumnName("id");
+
+ builder.Property(e => e.Version)
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnName("version");
+
+ builder.Property(e => e.IsDeleted)
+ .IsRequired()
+ .HasDefaultValue(false)
+ .HasColumnName("is_deleted");
+
+ builder.Property(e => e.CreatedAt)
+ .IsRequired()
+ .HasDefaultValueSql("datetime('now')")
+ .HasColumnName("created_at");
+
+ builder.HasIndex(e => e.Version).IsUnique();
+
+ // 全局查询过滤器
+ builder.HasQueryFilter(e => !e.IsDeleted);
+ }
+}
\ No newline at end of file
diff --git a/src/WCTDataMiner/Data/DbContextFactory.cs b/src/WCTDataMiner/Data/DbContextFactory.cs
new file mode 100644
index 0000000..199a22e
--- /dev/null
+++ b/src/WCTDataMiner/Data/DbContextFactory.cs
@@ -0,0 +1,66 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
+
+namespace WCTDataMiner.Data;
+
+///
+/// 数据库上下文工厂实现 - 支持SQLite和PostgreSQL切换
+///
+public class DbContextFactory : IDbContextFactory
+{
+ private readonly IConfiguration _configuration;
+
+ public DbContextFactory(IConfiguration configuration)
+ {
+ _configuration = configuration;
+ }
+
+ public WctMinerDbContext CreateDbContext()
+ {
+ var dbType = _configuration["Database:Type"]?.ToLower() ?? "sqlite";
+ var optionsBuilder = new DbContextOptionsBuilder();
+
+ switch (dbType)
+ {
+ case "postgresql":
+ case "postgres":
+ var pgConnStr = BuildPostgreSqlConnectionString();
+ optionsBuilder.UseNpgsql(pgConnStr);
+ break;
+
+ case "sqlite":
+ default:
+ var dbPath = _configuration["Database:Path"] ?? "./data/database/wctminer.db";
+ EnsureDirectoryExists(dbPath);
+ optionsBuilder.UseSqlite($"Data Source={dbPath}");
+ break;
+ }
+
+#if DEBUG
+ optionsBuilder.EnableSensitiveDataLogging();
+ optionsBuilder.EnableDetailedErrors();
+#endif
+
+ return new WctMinerDbContext(optionsBuilder.Options);
+ }
+
+ private string BuildPostgreSqlConnectionString()
+ {
+ var host = _configuration["Database:Host"] ?? "localhost";
+ var port = _configuration.GetValue("Database:Port", 5432);
+ var name = _configuration["Database:Name"] ?? "wctminer";
+ var user = _configuration["Database:User"] ?? "postgres";
+ var password = _configuration["Database:Password"] ?? "";
+
+ return $"Host={host};Port={port};Database={name};Username={user};Password={password}";
+ }
+
+ private static void EnsureDirectoryExists(string dbPath)
+ {
+ var directory = Path.GetDirectoryName(dbPath);
+ if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/WCTDataMiner/Data/IDbContextFactory.cs b/src/WCTDataMiner/Data/IDbContextFactory.cs
new file mode 100644
index 0000000..5942d6b
--- /dev/null
+++ b/src/WCTDataMiner/Data/IDbContextFactory.cs
@@ -0,0 +1,9 @@
+namespace WCTDataMiner.Data;
+
+///
+/// 数据库上下文工厂接口
+///
+public interface IDbContextFactory
+{
+ WctMinerDbContext CreateDbContext();
+}
\ No newline at end of file
diff --git a/src/WCTDataMiner/Data/SeedData.cs b/src/WCTDataMiner/Data/SeedData.cs
new file mode 100644
index 0000000..afe650c
--- /dev/null
+++ b/src/WCTDataMiner/Data/SeedData.cs
@@ -0,0 +1,36 @@
+using WCTDataMiner.Models;
+
+namespace WCTDataMiner.Data;
+
+///
+/// 初始数据种子
+///
+public static class SeedData
+{
+ ///
+ /// TX面板类型预设数据
+ ///
+ public static readonly TxPanel[] DefaultTxPanels =
+ [
+ new() { Name = "none" },
+ new() { Name = "single-mold" },
+ new() { Name = "single-rapid" },
+ new() { Name = "single-3d" },
+ new() { Name = "dual-mold" },
+ new() { Name = "dual-rapid" },
+ new() { Name = "dual-3d" }
+ ];
+
+ ///
+ /// 初始化种子数据
+ ///
+ public static void Initialize(WctMinerDbContext context)
+ {
+ // 确保 TxPanel 表有预设数据
+ if (!context.TxPanels.Any())
+ {
+ context.TxPanels.AddRange(DefaultTxPanels);
+ context.SaveChanges();
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/WCTDataMiner/Data/WctMinerDbContext.cs b/src/WCTDataMiner/Data/WctMinerDbContext.cs
new file mode 100644
index 0000000..53550c9
--- /dev/null
+++ b/src/WCTDataMiner/Data/WctMinerDbContext.cs
@@ -0,0 +1,32 @@
+using Microsoft.EntityFrameworkCore;
+using WCTDataMiner.Models;
+
+namespace WCTDataMiner.Data;
+
+///
+/// WCT数据采集数据库上下文
+///
+public class WctMinerDbContext : DbContext
+{
+ public WctMinerDbContext(DbContextOptions options)
+ : base(options) { }
+
+ // 维度表
+ public DbSet TxPanels => Set();
+ public DbSet TxHardwares => Set();
+ public DbSet TxSoftwares => Set();
+ public DbSet RxTypes => Set();
+
+ // 事实表
+ public DbSet TestScenarios => Set();
+ public DbSet QfodRecords => Set();
+ public DbSet PlossRecords => Set();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ base.OnModelCreating(modelBuilder);
+
+ // 自动应用所有 IEntityTypeConfiguration 配置
+ modelBuilder.ApplyConfigurationsFromAssembly(typeof(WctMinerDbContext).Assembly);
+ }
+}
\ No newline at end of file
diff --git a/src/WCTDataMiner/Extensions/ServiceCollectionExtensions.cs b/src/WCTDataMiner/Extensions/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000..8b82175
--- /dev/null
+++ b/src/WCTDataMiner/Extensions/ServiceCollectionExtensions.cs
@@ -0,0 +1,54 @@
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using WCTDataMiner.Data;
+using WCTDataMiner.Parsers;
+using WCTDataMiner.Services;
+
+namespace WCTDataMiner.Extensions;
+
+///
+/// 服务注册扩展
+///
+public static class ServiceCollectionExtensions
+{
+ ///
+ /// 注册数据库服务
+ ///
+ public static IServiceCollection AddDatabaseServices(
+ this IServiceCollection services,
+ IConfiguration configuration)
+ {
+ // 注册 DbContext 工厂
+ services.AddScoped();
+
+ // 注册 DbContext(通过工厂创建)
+ services.AddScoped(sp =>
+ {
+ var factory = sp.GetRequiredService();
+ return factory.CreateDbContext();
+ });
+
+ return services;
+ }
+
+ ///
+ /// 注册应用服务
+ ///
+ public static IServiceCollection AddApplicationServices(this IServiceCollection services)
+ {
+ // Parsers
+ services.AddScoped();
+ services.AddScoped();
+ services.AddSingleton();
+
+ // Services
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+
+ return services;
+ }
+}
\ No newline at end of file
diff --git a/src/WCTDataMiner/Models/PlossRecord.cs b/src/WCTDataMiner/Models/PlossRecord.cs
new file mode 100644
index 0000000..556f8e5
--- /dev/null
+++ b/src/WCTDataMiner/Models/PlossRecord.cs
@@ -0,0 +1,36 @@
+namespace WCTDataMiner.Models;
+
+///
+/// Ploss记录实体 - 存储Ploss FOD格式解析数据
+///
+public class PlossRecord
+{
+ public Guid Id { get; set; } = Guid.NewGuid();
+ public Guid ScenarioId { get; set; }
+ public byte RxType { get; set; }
+ public int RxPower { get; set; } // uint16 范围 0-65535,使用 int 存储
+ public int TxPower { get; set; } // uint16 范围 0-65535,使用 int 存储
+ public int Vcoil { get; set; } // uint16 范围 0-65535,使用 int 存储
+ public int Vin { get; set; } // uint16 范围 0-65535,使用 int 存储
+ public int Isns { get; set; } // uint16 范围 0-65535,使用 int 存储
+
+ /// 核心判定值:负数表示安全
+ public int Ploss { get; set; }
+
+ /// 安全红线:当前功率段阈值
+ public int Threshold { get; set; }
+
+ public int TriggerCount { get; set; } = 0;
+ public int FodResult { get; set; } = 0;
+ public byte ProtocolType { get; set; }
+ public int PwmDuty { get; set; } // uint16 范围 0-65535,使用 int 存储
+ public byte CoilIndex { get; set; }
+ public bool IsDeleted { get; set; } = false;
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+
+ // Navigation Properties
+ public TestScenario Scenario { get; set; } = null!;
+
+ /// 派生属性:安全余量(不落库)
+ public int Margin => Threshold - Ploss;
+}
\ No newline at end of file
diff --git a/src/WCTDataMiner/Models/QfodRecord.cs b/src/WCTDataMiner/Models/QfodRecord.cs
new file mode 100644
index 0000000..87948d7
--- /dev/null
+++ b/src/WCTDataMiner/Models/QfodRecord.cs
@@ -0,0 +1,21 @@
+namespace WCTDataMiner.Models;
+
+///
+/// Qfod记录实体 - 存储Qfod格式解析数据
+///
+public class QfodRecord
+{
+ public Guid Id { get; set; } = Guid.NewGuid();
+ public Guid ScenarioId { get; set; }
+ public byte ChargerIndex { get; set; }
+ public byte CoilIndex { get; set; }
+ public int DeltaQ { get; set; }
+ public float CurrentQ { get; set; }
+ public float RawQ { get; set; }
+ public byte FodType { get; set; }
+ public bool IsDeleted { get; set; } = false;
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+
+ // Navigation Properties
+ public TestScenario Scenario { get; set; } = null!;
+}
\ No newline at end of file
diff --git a/src/WCTDataMiner/Models/RxType.cs b/src/WCTDataMiner/Models/RxType.cs
new file mode 100644
index 0000000..2b16e20
--- /dev/null
+++ b/src/WCTDataMiner/Models/RxType.cs
@@ -0,0 +1,15 @@
+namespace WCTDataMiner.Models;
+
+///
+/// RX类型维度表
+///
+public class RxType
+{
+ public Guid Id { get; set; } = Guid.NewGuid();
+ public string Name { get; set; } = null!;
+ public bool IsDeleted { get; set; } = false;
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+
+ // Navigation Properties
+ public ICollection TestScenarios { get; set; } = [];
+}
\ No newline at end of file
diff --git a/src/WCTDataMiner/Models/TestScenario.cs b/src/WCTDataMiner/Models/TestScenario.cs
new file mode 100644
index 0000000..859e0ea
--- /dev/null
+++ b/src/WCTDataMiner/Models/TestScenario.cs
@@ -0,0 +1,30 @@
+namespace WCTDataMiner.Models;
+
+///
+/// 测试场景实体 - 通过外键关联各维度表
+///
+public class TestScenario
+{
+ public Guid Id { get; set; } = Guid.NewGuid();
+ public Guid TxPanelId { get; set; }
+ public Guid TxHardwareId { get; set; }
+ public Guid TxSoftwareId { get; set; }
+ public Guid RxTypeId { get; set; }
+ public string? TestPurpose { get; set; }
+ public DateOnly TestDate { get; set; }
+ public int TestSequence { get; set; } = 1;
+ public int QfodCount { get; set; } = 0;
+ public int PlossCount { get; set; } = 0;
+ public bool IsDeleted { get; set; } = false;
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+
+ // Navigation Properties - 维度表
+ public TxPanel TxPanel { get; set; } = null!;
+ public TxHardware TxHardware { get; set; } = null!;
+ public TxSoftware TxSoftware { get; set; } = null!;
+ public RxType RxType { get; set; } = null!;
+
+ // Navigation Properties - 数据记录
+ public ICollection QfodRecords { get; set; } = [];
+ public ICollection PlossRecords { get; set; } = [];
+}
\ No newline at end of file
diff --git a/src/WCTDataMiner/Models/TxHardware.cs b/src/WCTDataMiner/Models/TxHardware.cs
new file mode 100644
index 0000000..7e1641a
--- /dev/null
+++ b/src/WCTDataMiner/Models/TxHardware.cs
@@ -0,0 +1,15 @@
+namespace WCTDataMiner.Models;
+
+///
+/// TX硬件版本维度表
+///
+public class TxHardware
+{
+ public Guid Id { get; set; } = Guid.NewGuid();
+ public string Version { get; set; } = null!;
+ public bool IsDeleted { get; set; } = false;
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+
+ // Navigation Properties
+ public ICollection TestScenarios { get; set; } = [];
+}
\ No newline at end of file
diff --git a/src/WCTDataMiner/Models/TxPanel.cs b/src/WCTDataMiner/Models/TxPanel.cs
new file mode 100644
index 0000000..d1ba1a7
--- /dev/null
+++ b/src/WCTDataMiner/Models/TxPanel.cs
@@ -0,0 +1,15 @@
+namespace WCTDataMiner.Models;
+
+///
+/// TX面板类型维度表
+///
+public class TxPanel
+{
+ public Guid Id { get; set; } = Guid.NewGuid();
+ public string Name { get; set; } = null!;
+ public bool IsDeleted { get; set; } = false;
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+
+ // Navigation Properties
+ public ICollection TestScenarios { get; set; } = [];
+}
\ No newline at end of file
diff --git a/src/WCTDataMiner/Models/TxSoftware.cs b/src/WCTDataMiner/Models/TxSoftware.cs
new file mode 100644
index 0000000..a3976dc
--- /dev/null
+++ b/src/WCTDataMiner/Models/TxSoftware.cs
@@ -0,0 +1,15 @@
+namespace WCTDataMiner.Models;
+
+///
+/// TX软件版本维度表
+///
+public class TxSoftware
+{
+ public Guid Id { get; set; } = Guid.NewGuid();
+ public string Version { get; set; } = null!;
+ public bool IsDeleted { get; set; } = false;
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+
+ // Navigation Properties
+ public ICollection TestScenarios { get; set; } = [];
+}
\ No newline at end of file
diff --git a/src/WCTDataMiner/Parsers/FileNameParser.cs b/src/WCTDataMiner/Parsers/FileNameParser.cs
new file mode 100644
index 0000000..86fc74f
--- /dev/null
+++ b/src/WCTDataMiner/Parsers/FileNameParser.cs
@@ -0,0 +1,68 @@
+using System.Globalization;
+
+namespace WCTDataMiner.Parsers;
+
+///
+/// 文件名解析器 - 从文件名提取场景信息
+/// 格式: TX面板-TX硬件-TX软件-RX类型-测试目的-年月日-测试次数.log
+/// 示例: singleMold-v1.0-hex2_1-iPhone15-compatibility-20260702-1.log
+///
+public class FileNameParser
+{
+ private static readonly char[] Separators = { '-', '_' };
+
+ ///
+ /// 解析日志文件名,提取场景信息
+ ///
+ /// 文件名(不含路径)
+ /// 场景信息,若格式不匹配返回 null
+ public ScenarioInfo? Parse(string fileName)
+ {
+ // 移除 .log 扩展名
+ var nameWithoutExt = Path.GetFileNameWithoutExtension(fileName);
+ if (string.IsNullOrEmpty(nameWithoutExt))
+ return null;
+
+ var parts = nameWithoutExt.Split(Separators);
+ if (parts.Length < 7)
+ return null;
+
+ // 解析日期
+ if (!TryParseDate(parts[5], out var testDate))
+ return null;
+
+ // 解析测试序号
+ if (!int.TryParse(parts[6], out var testSequence))
+ testSequence = 1;
+
+ return new ScenarioInfo(
+ TxPanel: parts[0],
+ TxHardware: parts[1],
+ TxSoftware: parts[2],
+ RxType: parts[3],
+ TestPurpose: parts.Length > 4 ? parts[4] : null,
+ TestDate: testDate,
+ TestSequence: testSequence
+ );
+ }
+
+ private static bool TryParseDate(string dateStr, out DateOnly date)
+ {
+ // 支持多种日期格式
+ var formats = new[] { "yyyyMMdd", "yyyy-MM-dd", "yyMMdd" };
+ return DateOnly.TryParseExact(dateStr, formats, CultureInfo.InvariantCulture, DateTimeStyles.None, out date);
+ }
+}
+
+///
+/// 场景信息 - 从文件名解析出的测试场景数据
+///
+public record ScenarioInfo(
+ string TxPanel,
+ string TxHardware,
+ string TxSoftware,
+ string RxType,
+ string? TestPurpose,
+ DateOnly TestDate,
+ int TestSequence
+);
\ No newline at end of file
diff --git a/src/WCTDataMiner/Parsers/IParser.cs b/src/WCTDataMiner/Parsers/IParser.cs
new file mode 100644
index 0000000..7910e15
--- /dev/null
+++ b/src/WCTDataMiner/Parsers/IParser.cs
@@ -0,0 +1,36 @@
+namespace WCTDataMiner.Parsers;
+
+///
+/// 通用解析器接口
+///
+public interface IParser
+{
+ ///
+ /// 判断是否可以解析该行
+ ///
+ bool CanParse(string line);
+
+ ///
+ /// 解析日志行
+ ///
+ /// 日志行内容
+ /// 关联的测试场景ID
+ ParseResult Parse(string line, Guid scenarioId);
+}
+
+///
+/// 解析结果
+///
+public record ParseResult
+{
+ public bool IsSuccess { get; init; }
+ public TRecord? Record { get; init; }
+ public string? ErrorType { get; init; }
+ public string? ErrorMessage { get; init; }
+
+ public static ParseResult Success(TRecord record) =>
+ new() { IsSuccess = true, Record = record };
+
+ public static ParseResult Failure(string errorType, string errorMessage) =>
+ new() { IsSuccess = false, ErrorType = errorType, ErrorMessage = errorMessage };
+}
\ No newline at end of file
diff --git a/src/WCTDataMiner/Parsers/PlossParser.cs b/src/WCTDataMiner/Parsers/PlossParser.cs
new file mode 100644
index 0000000..55a8a6f
--- /dev/null
+++ b/src/WCTDataMiner/Parsers/PlossParser.cs
@@ -0,0 +1,62 @@
+using System.Text.RegularExpressions;
+using WCTDataMiner.Models;
+
+namespace WCTDataMiner.Parsers;
+
+///
+/// Ploss FOD日志解析器
+/// 格式: FOD-> (1) X X X (2) (3) (4) (5) (6) (7) (8) (9) (10) (11) (12) (13)
+/// 示例: FOD-> 4 2 1 127 10690 12013 35377 9904 1213 -2882 750 0 0 1 500 1
+///
+public class PlossParser : IParser
+{
+ // 格式说明有3个X(忽略字段),正则需要3个 \d+ 来匹配
+ private static readonly Regex PlossPattern = new(
+ @"^FOD->\s+(\d+)\s+\d+\s+\d+\s+\d+\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(-?\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)$",
+ RegexOptions.Compiled
+ );
+
+ public bool CanParse(string line) => PlossPattern.IsMatch(line);
+
+ public ParseResult Parse(string line, Guid scenarioId)
+ {
+ var match = PlossPattern.Match(line);
+ if (!match.Success)
+ {
+ return ParseResult.Failure(
+ "FORMAT_MISMATCH",
+ "Line does not match Ploss pattern"
+ );
+ }
+
+ try
+ {
+ var record = new PlossRecord
+ {
+ ScenarioId = scenarioId,
+ RxType = byte.Parse(match.Groups[1].Value),
+ RxPower = int.Parse(match.Groups[2].Value),
+ TxPower = int.Parse(match.Groups[3].Value),
+ Vcoil = int.Parse(match.Groups[4].Value),
+ Vin = int.Parse(match.Groups[5].Value),
+ Isns = int.Parse(match.Groups[6].Value),
+ Ploss = int.Parse(match.Groups[7].Value),
+ Threshold = int.Parse(match.Groups[8].Value),
+ TriggerCount = int.Parse(match.Groups[9].Value),
+ FodResult = int.Parse(match.Groups[10].Value),
+ ProtocolType = byte.Parse(match.Groups[11].Value),
+ PwmDuty = int.Parse(match.Groups[12].Value),
+ CoilIndex = byte.Parse(match.Groups[13].Value)
+ };
+
+ return ParseResult.Success(record);
+ }
+ catch (Exception ex)
+ {
+ return ParseResult.Failure(
+ "TYPE_CONVERSION",
+ $"Failed to convert field: {ex.Message}"
+ );
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/WCTDataMiner/Parsers/QfodParser.cs b/src/WCTDataMiner/Parsers/QfodParser.cs
new file mode 100644
index 0000000..90d2b0d
--- /dev/null
+++ b/src/WCTDataMiner/Parsers/QfodParser.cs
@@ -0,0 +1,54 @@
+using System.Text.RegularExpressions;
+using WCTDataMiner.Models;
+
+namespace WCTDataMiner.Parsers;
+
+///
+/// Qfod日志解析器
+/// 格式: X#:Q A->B C D E
+/// 示例: 0#:Q 1->25 45.5 20.5 2
+///
+public class QfodParser : IParser
+{
+ private static readonly Regex QfodPattern = new(
+ @"^(\d)#:Q\s+(\d+)->(-?\d+)\s+([-\d.]+)\s+([-\d.]+)\s+(\d+)$",
+ RegexOptions.Compiled
+ );
+
+ public bool CanParse(string line) => QfodPattern.IsMatch(line);
+
+ public ParseResult Parse(string line, Guid scenarioId)
+ {
+ var match = QfodPattern.Match(line);
+ if (!match.Success)
+ {
+ return ParseResult.Failure(
+ "FORMAT_MISMATCH",
+ "Line does not match Qfod pattern"
+ );
+ }
+
+ try
+ {
+ var record = new QfodRecord
+ {
+ ScenarioId = scenarioId,
+ ChargerIndex = byte.Parse(match.Groups[1].Value),
+ CoilIndex = byte.Parse(match.Groups[2].Value),
+ DeltaQ = int.Parse(match.Groups[3].Value),
+ CurrentQ = float.Parse(match.Groups[4].Value),
+ RawQ = float.Parse(match.Groups[5].Value),
+ FodType = byte.Parse(match.Groups[6].Value)
+ };
+
+ return ParseResult.Success(record);
+ }
+ catch (Exception ex)
+ {
+ return ParseResult.Failure(
+ "TYPE_CONVERSION",
+ $"Failed to convert field: {ex.Message}"
+ );
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/WCTDataMiner/Program.cs b/src/WCTDataMiner/Program.cs
new file mode 100644
index 0000000..d4400a3
--- /dev/null
+++ b/src/WCTDataMiner/Program.cs
@@ -0,0 +1,83 @@
+using System.CommandLine;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Serilog;
+using WCTDataMiner.Commands;
+using WCTDataMiner.Data;
+using WCTDataMiner.Extensions;
+using WCTDataMiner.Services;
+
+namespace WCTDataMiner;
+
+///
+/// WCTDataMiner - 无线充电FOD日志数据采集系统
+///
+public class Program
+{
+ public static async Task Main(string[] args)
+ {
+ // 配置 Serilog
+ Log.Logger = new LoggerConfiguration()
+ .MinimumLevel.Information()
+ .MinimumLevel.Override("WCTDataMiner", Serilog.Events.LogEventLevel.Debug)
+ .WriteTo.Console()
+ .WriteTo.File(
+ "./data/logs/wctminer-.log",
+ rollingInterval: RollingInterval.Day,
+ outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}")
+ .CreateLogger();
+
+ try
+ {
+ var builder = Host.CreateApplicationBuilder(args);
+
+ // 配置
+ builder.Configuration.AddJsonFile("appsettings.json", optional: false);
+ builder.Configuration.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true);
+ builder.Configuration.AddEnvironmentVariables();
+ builder.Configuration.AddCommandLine(args);
+
+ // Serilog
+ builder.Services.AddSerilog();
+
+ // 注册服务
+ builder.Services.AddDatabaseServices(builder.Configuration);
+ builder.Services.AddApplicationServices();
+
+ var app = builder.Build();
+
+ // 初始化数据库
+ using (var scope = app.Services.CreateScope())
+ {
+ var dbContext = scope.ServiceProvider.GetRequiredService();
+ await dbContext.Database.EnsureCreatedAsync();
+
+ // 种子数据
+ SeedData.Initialize(dbContext);
+ }
+
+ // 创建 CLI 命令
+ using var commandScope = app.Services.CreateScope();
+ var provider = commandScope.ServiceProvider;
+
+ var rootCommand = new RootCommand("WCTDataMiner - FOD Log Data Parser");
+
+ rootCommand.AddCommand(new ParseCommand(provider.GetRequiredService()));
+ rootCommand.AddCommand(new StatsCommand(provider.GetRequiredService()));
+ rootCommand.AddCommand(new ExportCommand(provider.GetRequiredService()));
+ rootCommand.AddCommand(new CleanCommand(provider.GetRequiredService()));
+
+ return await rootCommand.InvokeAsync(args);
+ }
+ catch (Exception ex)
+ {
+ Log.Fatal(ex, "Application terminated unexpectedly");
+ return 1;
+ }
+ finally
+ {
+ await Log.CloseAndFlushAsync();
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/WCTDataMiner/Services/CleanService.cs b/src/WCTDataMiner/Services/CleanService.cs
new file mode 100644
index 0000000..2a15bec
--- /dev/null
+++ b/src/WCTDataMiner/Services/CleanService.cs
@@ -0,0 +1,87 @@
+using Microsoft.EntityFrameworkCore;
+using WCTDataMiner.Data;
+using WCTDataMiner.Models;
+
+namespace WCTDataMiner.Services;
+
+///
+/// 数据清理服务 - 支持软删除
+///
+public class CleanService
+{
+ private readonly WctMinerDbContext _context;
+
+ public CleanService(WctMinerDbContext context)
+ {
+ _context = context;
+ }
+
+ ///
+ /// 软删除指定场景及其关联记录
+ ///
+ public async Task CleanByScenarioAsync(Guid scenarioId)
+ {
+ var scenario = await _context.TestScenarios.FindAsync(scenarioId);
+ if (scenario == null) return 0;
+
+ // 软删除关联的Qfod记录
+ var qfodRecords = await _context.QfodRecords
+ .Where(r => r.ScenarioId == scenarioId)
+ .ToListAsync();
+ foreach (var record in qfodRecords)
+ {
+ record.IsDeleted = true;
+ }
+
+ // 软删除关联的Ploss记录
+ var plossRecords = await _context.PlossRecords
+ .Where(r => r.ScenarioId == scenarioId)
+ .ToListAsync();
+ foreach (var record in plossRecords)
+ {
+ record.IsDeleted = true;
+ }
+
+ // 软删除场景
+ scenario.IsDeleted = true;
+
+ await _context.SaveChangesAsync();
+
+ return qfodRecords.Count + plossRecords.Count + 1;
+ }
+
+ ///
+ /// 软删除所有数据
+ ///
+ public async Task CleanAllAsync()
+ {
+ int count = 0;
+
+ // 软删除所有Qfod记录
+ var qfodRecords = await _context.QfodRecords.IgnoreQueryFilters().ToListAsync();
+ foreach (var record in qfodRecords.Where(r => !r.IsDeleted))
+ {
+ record.IsDeleted = true;
+ count++;
+ }
+
+ // 软删除所有Ploss记录
+ var plossRecords = await _context.PlossRecords.IgnoreQueryFilters().ToListAsync();
+ foreach (var record in plossRecords.Where(r => !r.IsDeleted))
+ {
+ record.IsDeleted = true;
+ count++;
+ }
+
+ // 软删除所有场景
+ var scenarios = await _context.TestScenarios.IgnoreQueryFilters().ToListAsync();
+ foreach (var scenario in scenarios.Where(s => !s.IsDeleted))
+ {
+ scenario.IsDeleted = true;
+ count++;
+ }
+
+ await _context.SaveChangesAsync();
+ return count;
+ }
+}
\ No newline at end of file
diff --git a/src/WCTDataMiner/Services/DimensionService.cs b/src/WCTDataMiner/Services/DimensionService.cs
new file mode 100644
index 0000000..0aada21
--- /dev/null
+++ b/src/WCTDataMiner/Services/DimensionService.cs
@@ -0,0 +1,82 @@
+using Microsoft.EntityFrameworkCore;
+using WCTDataMiner.Data;
+using WCTDataMiner.Models;
+
+namespace WCTDataMiner.Services;
+
+///
+/// 维度表管理服务 - 负责维度表的CRUD操作
+///
+public class DimensionService
+{
+ private readonly WctMinerDbContext _context;
+
+ public DimensionService(WctMinerDbContext context)
+ {
+ _context = context;
+ }
+
+ ///
+ /// 获取或创建TX面板维度
+ ///
+ public async Task GetOrCreateTxPanelAsync(string name)
+ {
+ var existing = await _context.TxPanels
+ .FirstOrDefaultAsync(p => p.Name == name);
+
+ if (existing != null) return existing;
+
+ var newPanel = new TxPanel { Name = name };
+ _context.TxPanels.Add(newPanel);
+ await _context.SaveChangesAsync();
+ return newPanel;
+ }
+
+ ///
+ /// 获取或创建TX硬件版本维度
+ ///
+ public async Task GetOrCreateTxHardwareAsync(string version)
+ {
+ var existing = await _context.TxHardwares
+ .FirstOrDefaultAsync(h => h.Version == version);
+
+ if (existing != null) return existing;
+
+ var newHardware = new TxHardware { Version = version };
+ _context.TxHardwares.Add(newHardware);
+ await _context.SaveChangesAsync();
+ return newHardware;
+ }
+
+ ///
+ /// 获取或创建TX软件版本维度
+ ///
+ public async Task GetOrCreateTxSoftwareAsync(string version)
+ {
+ var existing = await _context.TxSoftwares
+ .FirstOrDefaultAsync(s => s.Version == version);
+
+ if (existing != null) return existing;
+
+ var newSoftware = new TxSoftware { Version = version };
+ _context.TxSoftwares.Add(newSoftware);
+ await _context.SaveChangesAsync();
+ return newSoftware;
+ }
+
+ ///
+ /// 获取或创建RX类型维度
+ ///
+ public async Task GetOrCreateRxTypeAsync(string name)
+ {
+ var existing = await _context.RxTypes
+ .FirstOrDefaultAsync(r => r.Name == name);
+
+ if (existing != null) return existing;
+
+ var newRxType = new RxType { Name = name };
+ _context.RxTypes.Add(newRxType);
+ await _context.SaveChangesAsync();
+ return newRxType;
+ }
+}
\ No newline at end of file
diff --git a/src/WCTDataMiner/Services/ExportService.cs b/src/WCTDataMiner/Services/ExportService.cs
new file mode 100644
index 0000000..f6f4a0f
--- /dev/null
+++ b/src/WCTDataMiner/Services/ExportService.cs
@@ -0,0 +1,177 @@
+using System.Globalization;
+using System.Text;
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using WCTDataMiner.Data;
+using WCTDataMiner.Models;
+
+namespace WCTDataMiner.Services;
+
+///
+/// 数据导出服务
+///
+public class ExportService
+{
+ private readonly WctMinerDbContext _context;
+
+ public ExportService(WctMinerDbContext context)
+ {
+ _context = context;
+ }
+
+ ///
+ /// 导出Qfod数据为CSV
+ ///
+ public async Task ExportQfodToCsvAsync(string outputDir)
+ {
+ Directory.CreateDirectory(outputDir);
+ var filePath = Path.Combine(outputDir, $"qfod_export_{DateTime.Now:yyyyMMdd_HHmmss}.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)
+ {
+ 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}");
+ }
+
+ await File.WriteAllTextAsync(filePath, sb.ToString());
+ return filePath;
+ }
+
+ ///
+ /// 导出Ploss数据为CSV
+ ///
+ public async Task ExportPlossToCsvAsync(string outputDir)
+ {
+ Directory.CreateDirectory(outputDir);
+ var filePath = Path.Combine(outputDir, $"ploss_export_{DateTime.Now:yyyyMMdd_HHmmss}.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)
+ {
+ 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}");
+ }
+
+ await File.WriteAllTextAsync(filePath, sb.ToString());
+ return filePath;
+ }
+
+ ///
+ /// 导出Qfod数据为JSON
+ ///
+ public async Task ExportQfodToJsonAsync(string outputDir)
+ {
+ Directory.CreateDirectory(outputDir);
+ var filePath = Path.Combine(outputDir, $"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,
+ r.ScenarioId,
+ TxPanel = r.Scenario!.TxPanel.Name,
+ TxHardware = r.Scenario.TxHardware.Version,
+ TxSoftware = r.Scenario.TxSoftware.Version,
+ RxType = r.Scenario.RxType.Name,
+ r.Scenario.TestPurpose,
+ TestDate = r.Scenario.TestDate.ToString("yyyy-MM-dd"),
+ r.Scenario.TestSequence,
+ r.ChargerIndex,
+ r.CoilIndex,
+ r.DeltaQ,
+ r.CurrentQ,
+ r.RawQ,
+ r.FodType
+ })
+ .ToListAsync();
+
+ var json = JsonSerializer.Serialize(records, new JsonSerializerOptions { WriteIndented = true });
+ await File.WriteAllTextAsync(filePath, json);
+ return filePath;
+ }
+
+ ///
+ /// 导出Ploss数据为JSON
+ ///
+ public async Task ExportPlossToJsonAsync(string outputDir)
+ {
+ Directory.CreateDirectory(outputDir);
+ var filePath = Path.Combine(outputDir, $"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,
+ r.ScenarioId,
+ TxPanel = r.Scenario!.TxPanel.Name,
+ TxHardware = r.Scenario.TxHardware.Version,
+ TxSoftware = r.Scenario.TxSoftware.Version,
+ ScenarioRxType = r.Scenario.RxType.Name,
+ r.Scenario.TestPurpose,
+ TestDate = r.Scenario.TestDate.ToString("yyyy-MM-dd"),
+ r.Scenario.TestSequence,
+ RecordRxType = 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
+ })
+ .ToListAsync();
+
+ var json = JsonSerializer.Serialize(records, new JsonSerializerOptions { WriteIndented = true });
+ await File.WriteAllTextAsync(filePath, json);
+ return filePath;
+ }
+}
\ No newline at end of file
diff --git a/src/WCTDataMiner/Services/ParseService.cs b/src/WCTDataMiner/Services/ParseService.cs
new file mode 100644
index 0000000..f031fdf
--- /dev/null
+++ b/src/WCTDataMiner/Services/ParseService.cs
@@ -0,0 +1,201 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
+using Serilog;
+using WCTDataMiner.Data;
+using WCTDataMiner.Models;
+using WCTDataMiner.Parsers;
+
+namespace WCTDataMiner.Services;
+
+///
+/// 解析流程协调服务 - 协调文件名解析、行解析、批量写入
+///
+public class ParseService
+{
+ private readonly WctMinerDbContext _context;
+ private readonly ScenarioService _scenarioService;
+ private readonly QfodParser _qfodParser;
+ private readonly PlossParser _plossParser;
+ private readonly FileNameParser _fileNameParser;
+ private readonly int _batchSize;
+
+ public ParseService(
+ WctMinerDbContext context,
+ ScenarioService scenarioService,
+ QfodParser qfodParser,
+ PlossParser plossParser,
+ FileNameParser fileNameParser,
+ IConfiguration config)
+ {
+ _context = context;
+ _scenarioService = scenarioService;
+ _qfodParser = qfodParser;
+ _plossParser = plossParser;
+ _fileNameParser = fileNameParser;
+ _batchSize = config.GetValue("Parser:BatchSize", 1000);
+ }
+
+ ///
+ /// 解析单个日志文件
+ ///
+ public async Task ParseFileAsync(string filePath, bool force = false)
+ {
+ var fileInfo = new FileInfo(filePath);
+
+ if (!fileInfo.Exists)
+ {
+ Log.Error("文件不存在: {FilePath}", filePath);
+ return new ParseReport(fileInfo.Name, 0, 0, 0, "文件不存在");
+ }
+
+ // 解析文件名获取场景信息
+ var scenarioInfo = _fileNameParser.Parse(fileInfo.Name);
+ if (scenarioInfo == null)
+ {
+ Log.Error("无法解析文件名: {FileName}", fileInfo.Name);
+ return new ParseReport(fileInfo.Name, 0, 0, 1, "文件名格式不匹配");
+ }
+
+ Log.Information("解析文件: {FileName} => 场景: {TxPanel}/{TxHardware}/{TxSoftware}/{RxType}/{TestDate}",
+ fileInfo.Name, scenarioInfo.TxPanel, scenarioInfo.TxHardware,
+ scenarioInfo.TxSoftware, scenarioInfo.RxType, scenarioInfo.TestDate);
+
+ // 获取或创建测试场景
+ var scenario = await _scenarioService.GetOrCreateScenarioAsync(scenarioInfo);
+
+ var lines = await File.ReadAllLinesAsync(filePath);
+ var qfodRecords = new List();
+ var plossRecords = new List();
+ int errorCount = 0;
+
+ for (int i = 0; i < lines.Length; i++)
+ {
+ var line = lines[i].Trim();
+ if (string.IsNullOrEmpty(line)) continue;
+
+ var lineNumber = i + 1;
+
+ if (_qfodParser.CanParse(line))
+ {
+ var result = _qfodParser.Parse(line, scenario.Id);
+ if (result.IsSuccess)
+ qfodRecords.Add(result.Record!);
+ else
+ {
+ Log.Error("Qfod解析失败 [{Line}] {Error}: {Message}",
+ lineNumber, result.ErrorType, result.ErrorMessage);
+ errorCount++;
+ }
+ }
+ else if (_plossParser.CanParse(line))
+ {
+ var result = _plossParser.Parse(line, scenario.Id);
+ if (result.IsSuccess)
+ plossRecords.Add(result.Record!);
+ else
+ {
+ Log.Error("Ploss解析失败 [{Line}] {Error}: {Message}",
+ lineNumber, result.ErrorType, result.ErrorMessage);
+ errorCount++;
+ }
+ }
+ }
+
+ // 批量保存
+ await BatchInsertAsync(qfodRecords, plossRecords);
+
+ // 更新场景统计
+ await UpdateScenarioStatsAsync(scenario.Id, qfodRecords.Count, plossRecords.Count);
+
+ Log.Information("解析完成: {FileName} => Qfod: {QfodCount}, Ploss: {PlossCount}, Errors: {ErrorCount}",
+ fileInfo.Name, qfodRecords.Count, plossRecords.Count, errorCount);
+
+ return new ParseReport(fileInfo.Name, qfodRecords.Count, plossRecords.Count, errorCount, null);
+ }
+
+ ///
+ /// 解析目录下所有日志文件
+ ///
+ public async Task> ParseDirectoryAsync(string dirPath, bool recursive = false, bool force = false)
+ {
+ var dirInfo = new DirectoryInfo(dirPath);
+ if (!dirInfo.Exists)
+ {
+ Log.Error("目录不存在: {DirPath}", dirPath);
+ return [new ParseReport(dirPath, 0, 0, 0, "目录不存在")];
+ }
+
+ var searchOption = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
+ var logFiles = dirInfo.GetFiles("*.log", searchOption);
+
+ Log.Information("发现 {FileCount} 个日志文件", logFiles.Length);
+
+ var reports = new List();
+ foreach (var file in logFiles)
+ {
+ var report = await ParseFileAsync(file.FullName, force);
+ reports.Add(report);
+ }
+
+ return reports;
+ }
+
+ private async Task BatchInsertAsync(
+ List qfodRecords,
+ List plossRecords)
+ {
+ if (qfodRecords.Count == 0 && plossRecords.Count == 0)
+ return;
+
+ await using var transaction = await _context.Database.BeginTransactionAsync();
+
+ try
+ {
+ // 批量插入 QfodRecord
+ foreach (var batch in qfodRecords.Chunk(_batchSize))
+ {
+ _context.QfodRecords.AddRange(batch);
+ await _context.SaveChangesAsync();
+ }
+
+ // 批量插入 PlossRecord
+ foreach (var batch in plossRecords.Chunk(_batchSize))
+ {
+ _context.PlossRecords.AddRange(batch);
+ await _context.SaveChangesAsync();
+ }
+
+ await transaction.CommitAsync();
+ }
+ catch
+ {
+ await transaction.RollbackAsync();
+ throw;
+ }
+ }
+
+ private async Task UpdateScenarioStatsAsync(Guid scenarioId, int qfodCount, int plossCount)
+ {
+ var scenario = await _context.TestScenarios.FindAsync(scenarioId);
+ if (scenario != null)
+ {
+ scenario.QfodCount += qfodCount;
+ scenario.PlossCount += plossCount;
+ await _context.SaveChangesAsync();
+ }
+ }
+}
+
+///
+/// 解析报告
+///
+public record ParseReport(
+ string FileName,
+ int QfodCount,
+ int PlossCount,
+ int ErrorCount,
+ string? ErrorMessage
+)
+{
+ public bool IsSuccess => ErrorMessage == null;
+}
\ No newline at end of file
diff --git a/src/WCTDataMiner/Services/ScenarioService.cs b/src/WCTDataMiner/Services/ScenarioService.cs
new file mode 100644
index 0000000..a3bfa77
--- /dev/null
+++ b/src/WCTDataMiner/Services/ScenarioService.cs
@@ -0,0 +1,76 @@
+using Microsoft.EntityFrameworkCore;
+using WCTDataMiner.Data;
+using WCTDataMiner.Models;
+using WCTDataMiner.Parsers;
+
+namespace WCTDataMiner.Services;
+
+///
+/// 测试场景服务 - 负责测试场景的创建和查询
+///
+public class ScenarioService
+{
+ private readonly WctMinerDbContext _context;
+ private readonly DimensionService _dimensionService;
+
+ public ScenarioService(WctMinerDbContext context, DimensionService dimensionService)
+ {
+ _context = context;
+ _dimensionService = dimensionService;
+ }
+
+ ///
+ /// 根据场景信息获取或创建测试场景
+ ///
+ public async Task GetOrCreateScenarioAsync(ScenarioInfo info)
+ {
+ // 1. 获取或创建各维度
+ var txPanel = await _dimensionService.GetOrCreateTxPanelAsync(info.TxPanel);
+ var txHardware = await _dimensionService.GetOrCreateTxHardwareAsync(info.TxHardware);
+ var txSoftware = await _dimensionService.GetOrCreateTxSoftwareAsync(info.TxSoftware);
+ var rxType = await _dimensionService.GetOrCreateRxTypeAsync(info.RxType);
+
+ // 2. 检查场景是否已存在(利用唯一约束)
+ var existing = await _context.TestScenarios
+ .FirstOrDefaultAsync(s =>
+ s.TxPanelId == txPanel.Id &&
+ s.TxHardwareId == txHardware.Id &&
+ s.TxSoftwareId == txSoftware.Id &&
+ s.RxTypeId == rxType.Id &&
+ s.TestPurpose == info.TestPurpose &&
+ s.TestDate == info.TestDate &&
+ s.TestSequence == info.TestSequence);
+
+ if (existing != null) return existing;
+
+ // 3. 创建新场景
+ var scenario = new TestScenario
+ {
+ TxPanelId = txPanel.Id,
+ TxHardwareId = txHardware.Id,
+ TxSoftwareId = txSoftware.Id,
+ RxTypeId = rxType.Id,
+ TestPurpose = info.TestPurpose,
+ TestDate = info.TestDate,
+ TestSequence = info.TestSequence
+ };
+
+ _context.TestScenarios.Add(scenario);
+ await _context.SaveChangesAsync();
+
+ return scenario;
+ }
+
+ ///
+ /// 获取场景详情(含维度属性)
+ ///
+ public async Task GetScenarioWithDimensionsAsync(Guid scenarioId)
+ {
+ return await _context.TestScenarios
+ .Include(s => s.TxPanel)
+ .Include(s => s.TxHardware)
+ .Include(s => s.TxSoftware)
+ .Include(s => s.RxType)
+ .FirstOrDefaultAsync(s => s.Id == scenarioId);
+ }
+}
\ No newline at end of file
diff --git a/src/WCTDataMiner/Services/StatsService.cs b/src/WCTDataMiner/Services/StatsService.cs
new file mode 100644
index 0000000..6cfd31c
--- /dev/null
+++ b/src/WCTDataMiner/Services/StatsService.cs
@@ -0,0 +1,128 @@
+using Microsoft.EntityFrameworkCore;
+using WCTDataMiner.Data;
+using WCTDataMiner.Models;
+
+namespace WCTDataMiner.Services;
+
+///
+/// 统计查询服务
+///
+public class StatsService
+{
+ private readonly WctMinerDbContext _context;
+
+ public StatsService(WctMinerDbContext context)
+ {
+ _context = context;
+ }
+
+ ///
+ /// 获取整体统计摘要
+ ///
+ public async Task GetSummaryAsync()
+ {
+ var scenarioCount = await _context.TestScenarios.CountAsync();
+ var qfodCount = await _context.QfodRecords.CountAsync();
+ var plossCount = await _context.PlossRecords.CountAsync();
+ var panelCount = await _context.TxPanels.CountAsync();
+ var hardwareCount = await _context.TxHardwares.CountAsync();
+ var softwareCount = await _context.TxSoftwares.CountAsync();
+ var rxTypeCount = await _context.RxTypes.CountAsync();
+
+ return new StatsSummary(
+ scenarioCount, qfodCount, plossCount,
+ panelCount, hardwareCount, softwareCount, rxTypeCount
+ );
+ }
+
+ ///
+ /// 按FOD类型统计Qfod记录
+ ///
+ public async Task> GetStatsByFodTypeAsync()
+ {
+ // 先加载到内存再分组(EF Core 对 GroupBy + 自定义类型有限制)
+ var data = await _context.QfodRecords
+ .Select(r => r.FodType)
+ .ToListAsync();
+
+ return data
+ .GroupBy(f => f)
+ .Select(g => new FodTypeStats(g.Key, g.Count()))
+ .OrderBy(s => s.FodType)
+ .ToList();
+ }
+
+ ///
+ /// 按面板类型统计
+ ///
+ public async Task> GetStatsByPanelAsync()
+ {
+ var data = await _context.TestScenarios
+ .Include(s => s.TxPanel)
+ .Select(s => s.TxPanel.Name)
+ .ToListAsync();
+
+ return data
+ .GroupBy(n => n)
+ .Select(g => new DimensionStats(g.Key, g.Count()))
+ .OrderByDescending(s => s.Count)
+ .ToList();
+ }
+
+ ///
+ /// 按硬件版本统计
+ ///
+ public async Task> GetStatsByHardwareAsync()
+ {
+ var data = await _context.TestScenarios
+ .Include(s => s.TxHardware)
+ .Select(s => s.TxHardware.Version)
+ .ToListAsync();
+
+ return data
+ .GroupBy(v => v)
+ .Select(g => new DimensionStats(g.Key, g.Count()))
+ .OrderByDescending(s => s.Count)
+ .ToList();
+ }
+
+ ///
+ /// 按RX类型统计
+ ///
+ public async Task> GetStatsByRxTypeAsync()
+ {
+ var data = await _context.TestScenarios
+ .Include(s => s.RxType)
+ .Select(s => s.RxType.Name)
+ .ToListAsync();
+
+ return data
+ .GroupBy(n => n)
+ .Select(g => new DimensionStats(g.Key, g.Count()))
+ .OrderByDescending(s => s.Count)
+ .ToList();
+ }
+}
+
+///
+/// 整体统计摘要
+///
+public record StatsSummary(
+ int ScenarioCount,
+ int QfodCount,
+ int PlossCount,
+ int PanelCount,
+ int HardwareCount,
+ int SoftwareCount,
+ int RxTypeCount
+);
+
+///
+/// FOD类型统计
+///
+public record FodTypeStats(byte FodType, int Count);
+
+///
+/// 维度统计
+///
+public record DimensionStats(string Name, int Count);
\ No newline at end of file
diff --git a/src/WCTDataMiner/Services/ThresholdCalculator.cs b/src/WCTDataMiner/Services/ThresholdCalculator.cs
new file mode 100644
index 0000000..a599907
--- /dev/null
+++ b/src/WCTDataMiner/Services/ThresholdCalculator.cs
@@ -0,0 +1,43 @@
+namespace WCTDataMiner.Services;
+
+///
+/// 阈值计算器 - 根据接收功率查表获取动态阈值
+///
+public static class ThresholdCalculator
+{
+ ///
+ /// 根据RxPower获取对应的动态阈值
+ ///
+ /// 接收端功率(mW)
+ /// 当前功率段的阈值
+ public static int GetThresholdByPower(int rxPower)
+ {
+ if (rxPower <= 5000) return 350;
+ if (rxPower <= 10000) return 500;
+ if (rxPower <= 15000) return 750;
+ if (rxPower <= 30000) return 1000;
+ return 1250;
+ }
+
+ ///
+ /// 计算安全余量
+ ///
+ /// 阈值
+ /// 计算损耗值
+ /// 安全余量(正值安全,负值危险)
+ public static int CalculateMargin(int threshold, int ploss)
+ {
+ return threshold - ploss;
+ }
+
+ ///
+ /// 判断是否触发FOD
+ ///
+ /// 计算损耗值
+ /// 阈值
+ /// 是否触发FOD
+ public static bool IsFodTriggered(int ploss, int threshold)
+ {
+ return ploss > threshold;
+ }
+}
\ No newline at end of file
diff --git a/src/WCTDataMiner/WCTDataMiner.csproj b/src/WCTDataMiner/WCTDataMiner.csproj
new file mode 100644
index 0000000..c1be1f2
--- /dev/null
+++ b/src/WCTDataMiner/WCTDataMiner.csproj
@@ -0,0 +1,43 @@
+
+
+
+ Exe
+ net8.0
+ enable
+ enable
+ 12.0
+
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+
+
\ No newline at end of file