feat: 实现 FOD 日志数据采集 CLI 应用

This commit is contained in:
ssss
2026-07-02 18:13:56 +08:00
parent c4e5ac0360
commit 3dcd9f7c0b
52 changed files with 3144 additions and 354 deletions

View File

@@ -51,11 +51,16 @@
--- ---
## 已解决问题
| # | 问题 | 解决方案 |
|---|------|----------|
| 4 | Ploss 日志格式对应 | 修正正则表达式添加第3个忽略字段 |
## 待确认问题 ## 待确认问题
| # | 问题 | 来源 | | # | 问题 | 来源 |
|---|------|------| |---|------|------|
| 1 | 增量解析策略 | 日志是否持续追加? | | 1 | 增量解析策略 | 日志是否持续追加? |
| 2 | 重复解析处理 | 多次解析同一文件如何处理? | | 2 | 重复解析处理 | 多次解析同一文件如何处理? |
| 3 | FOD Type 编码含义 | 需求文档 QA 3.2 | | 3 | FOD Type 编码含义 | 需求文档 QA 3.2 |
| 4 | Ploss 日志格式对应 | 需求文档 QA 3.3 |

View File

@@ -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 = { '-', '_' };
/// <summary>
/// 解析日志文件名,提取场景信息
/// </summary>
/// <param name="fileName">文件名(不含路径)</param>
/// <returns>场景信息,若格式不匹配返回 null</returns>
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 | 返回 nullParseService 记录错误日志 |
| 文件名仅含扩展名(如 `.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);
// 继续解析文件内容...
}
```

View File

@@ -36,10 +36,13 @@
## 3. 正则表达式 ## 3. 正则表达式
> **修正说明**原设计稿正则只有2个忽略字段实际格式有3个X忽略字段已修正。
```csharp ```csharp
// Parsers/PlossParser.cs // Parsers/PlossParser.cs
// 格式: FOD-> (1) X X X (2) (3) ... (13)其中3个X需要3个 \d+ 忽略
private static readonly Regex PlossPattern = new( 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 RegexOptions.Compiled
); );
``` ```
@@ -82,7 +85,7 @@ public class PlossParser : IParser<PlossRecord>
public bool CanParse(string line) => PlossPattern.IsMatch(line); public bool CanParse(string line) => PlossPattern.IsMatch(line);
public ParseResult<PlossRecord> Parse(string line, int lineNumber, Guid logFileId) public ParseResult<PlossRecord> Parse(string line, Guid scenarioId)
{ {
var match = PlossPattern.Match(line); var match = PlossPattern.Match(line);
if (!match.Success) if (!match.Success)
@@ -97,8 +100,7 @@ public class PlossParser : IParser<PlossRecord>
{ {
var record = new PlossRecord var record = new PlossRecord
{ {
LogFileId = logFileId, ScenarioId = scenarioId,
LineNumber = lineNumber,
RxType = byte.Parse(match.Groups[1].Value), RxType = byte.Parse(match.Groups[1].Value),
RxPower = short.Parse(match.Groups[2].Value), RxPower = short.Parse(match.Groups[2].Value),
TxPower = short.Parse(match.Groups[3].Value), TxPower = short.Parse(match.Groups[3].Value),

View File

@@ -66,7 +66,7 @@ public class QfodParser : IParser<QfodRecord>
public bool CanParse(string line) => QfodPattern.IsMatch(line); public bool CanParse(string line) => QfodPattern.IsMatch(line);
public ParseResult<QfodRecord> Parse(string line, int lineNumber, Guid logFileId) public ParseResult<QfodRecord> Parse(string line, Guid scenarioId)
{ {
var match = QfodPattern.Match(line); var match = QfodPattern.Match(line);
if (!match.Success) if (!match.Success)
@@ -81,8 +81,7 @@ public class QfodParser : IParser<QfodRecord>
{ {
var record = new QfodRecord var record = new QfodRecord
{ {
LogFileId = logFileId, ScenarioId = scenarioId,
LineNumber = lineNumber,
ChargerIndex = byte.Parse(match.Groups[1].Value), ChargerIndex = byte.Parse(match.Groups[1].Value),
CoilIndex = byte.Parse(match.Groups[2].Value), CoilIndex = byte.Parse(match.Groups[2].Value),
DeltaQ = int.Parse(match.Groups[3].Value), DeltaQ = int.Parse(match.Groups[3].Value),

View File

@@ -8,6 +8,7 @@
| 文档 | 说明 | | 文档 | 说明 |
|------|------| |------|------|
| [文件名解析器](./FileNameParser.md) | 从文件名提取场景信息 |
| [Qfod 解析器](./Qfod解析器.md) | Qfod 格式日志解析设计 | | [Qfod 解析器](./Qfod解析器.md) | Qfod 格式日志解析设计 |
| [Ploss 解析器](./Ploss解析器.md) | Ploss FOD 格式日志解析设计 | | [Ploss 解析器](./Ploss解析器.md) | Ploss FOD 格式日志解析设计 |
@@ -29,7 +30,9 @@ public interface IParser<TRecord>
/// <summary> /// <summary>
/// 解析日志行 /// 解析日志行
/// </summary> /// </summary>
ParseResult<TRecord> Parse(string line, int lineNumber, Guid logFileId); /// <param name="line">日志行内容</param>
/// <param name="scenarioId">关联的测试场景ID用于外键关联由 ParseService 从 FileNameParser 获取后传入)</param>
ParseResult<TRecord> Parse(string line, Guid scenarioId);
} }
public record ParseResult<TRecord> public record ParseResult<TRecord>
@@ -75,4 +78,5 @@ flowchart TD
|-----------|------|----------| |-----------|------|----------|
| FORMAT_MISMATCH | 行格式不匹配正则表达式 | 跳过该行,记录原始内容 | | FORMAT_MISMATCH | 行格式不匹配正则表达式 | 跳过该行,记录原始内容 |
| TYPE_CONVERSION | 字段类型转换失败 | 跳过该行,记录错误字段 | | TYPE_CONVERSION | 字段类型转换失败 | 跳过该行,记录错误字段 |
| VALUE_OUT_OF_RANGE | 字段值超出有效范围 | 使用边界值或跳过 |
> **说明**VALUE_OUT_OF_RANGE 错误类型暂未实现,后续可根据需要添加字段值范围校验逻辑。

View File

@@ -10,47 +10,52 @@
sequenceDiagram sequenceDiagram
participant CLI as CLI Entry participant CLI as CLI Entry
participant PS as ParseService participant PS as ParseService
participant LR as LogFileReader participant SS as ScenarioService
participant FNP as FileNameParser
participant QP as QfodParser participant QP as QfodParser
participant PP as PlossParser participant PP as PlossParser
participant EHS as ErrorHandlingService
participant DB as Database participant DB as Database
participant Log as Serilog
CLI->>PS: 扫描日志目录
PS-->>CLI: 返回文件列表
CLI->>LR: 扫描日志目录
LR-->>CLI: 返回文件列表
loop 每个日志文件 loop 每个日志文件
CLI->>PS: ParseFileAsync(filePath) CLI->>PS: ParseFileAsync(filePath)
PS->>DB: INSERT log_file (status=processing) PS->>FNP: 解析文件名
DB-->>PS: 返回 file_id FNP-->>PS: 场景信息面板、版本、RX类型等
PS->>LR: 读取文件内容 PS->>SS: GetOrCreateScenarioAsync(场景信息)
LR-->>PS: 返回行列表 SS->>DB: 查询/创建维度记录
SS->>DB: 查询/创建场景记录
SS-->>PS: 返回 TestScenario
PS->>PS: 读取文件内容
loop 逐行遍历 loop 逐行遍历
alt 是 Qfod 行 alt 是 Qfod 行
PS->>QP: Parse(line) PS->>QP: Parse(line, scenarioId)
QP-->>PS: ParseResult QP-->>PS: ParseResult
else 是 Ploss 行 else 是 Ploss 行
PS->>PP: Parse(line) PS->>PP: Parse(line, scenarioId)
PP-->>PS: ParseResult PP-->>PS: ParseResult
end end
alt 解析成功 alt 解析成功
PS->>PS: 加入记录列表 PS->>PS: 加入记录列表
else 解析失败 else 解析失败
PS->>EHS: RecordError(line, error) PS->>Log: LogError(line, error)
end end
end end
PS->>DB: BATCH INSERT records PS->>DB: BATCH INSERT QfodRecords
PS->>DB: BATCH INSERT errors PS->>DB: BATCH INSERT PlossRecords
PS->>DB: UPDATE log_file (counts, status=completed) PS->>DB: UPDATE scenario (qfod_count, ploss_count)
PS-->>CLI: 返回 ParseReport PS-->>CLI: 返回 ParseReport
end end
CLI-->>CLI: 输出解析报告 CLI-->>CLI: 输出解析报告
``` ```
@@ -61,6 +66,7 @@ sequenceDiagram
```csharp ```csharp
// Services/ParseService.cs // Services/ParseService.cs
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Serilog;
using WCTDataMiner.Data; using WCTDataMiner.Data;
using WCTDataMiner.Models; using WCTDataMiner.Models;
using WCTDataMiner.Parsers; using WCTDataMiner.Parsers;
@@ -70,36 +76,47 @@ namespace WCTDataMiner.Services;
public class ParseService public class ParseService
{ {
private readonly WctMinerDbContext _context; private readonly WctMinerDbContext _context;
private readonly ScenarioService _scenarioService;
private readonly QfodParser _qfodParser; private readonly QfodParser _qfodParser;
private readonly PlossParser _plossParser; private readonly PlossParser _plossParser;
private readonly ErrorHandlingService _errorService; private readonly FileNameParser _fileNameParser;
private readonly ILogger<ParseService> _logger;
private readonly int _batchSize; private readonly int _batchSize;
public ParseService( public ParseService(
WctMinerDbContext context, WctMinerDbContext context,
ScenarioService scenarioService,
QfodParser qfodParser, QfodParser qfodParser,
PlossParser plossParser, PlossParser plossParser,
ErrorHandlingService errorService, FileNameParser fileNameParser,
ILogger<ParseService> logger,
IConfiguration config) IConfiguration config)
{ {
_context = context; _context = context;
_scenarioService = scenarioService;
_qfodParser = qfodParser; _qfodParser = qfodParser;
_plossParser = plossParser; _plossParser = plossParser;
_errorService = errorService; _fileNameParser = fileNameParser;
_logger = logger;
_batchSize = config.GetValue("Parser:BatchSize", 1000); _batchSize = config.GetValue("Parser:BatchSize", 1000);
} }
public async Task<ParseReport> ParseFileAsync(string filePath) public async Task<ParseReport> ParseFileAsync(string filePath)
{ {
var fileInfo = new FileInfo(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 lines = await File.ReadAllLinesAsync(filePath);
var qfodRecords = new List<QfodRecord>(); var qfodRecords = new List<QfodRecord>();
var plossRecords = new List<PlossRecord>(); var plossRecords = new List<PlossRecord>();
int errorCount = 0;
for (int i = 0; i < lines.Length; i++) for (int i = 0; i < lines.Length; i++)
{ {
@@ -108,53 +125,45 @@ public class ParseService
if (_qfodParser.CanParse(line)) if (_qfodParser.CanParse(line))
{ {
var result = _qfodParser.Parse(line, lineNumber, logFile.Id); var result = _qfodParser.Parse(line, scenario.Id);
if (result.IsSuccess) if (result.IsSuccess)
qfodRecords.Add(result.Record!); qfodRecords.Add(result.Record!);
else 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)) else if (_plossParser.CanParse(line))
{ {
var result = _plossParser.Parse(line, lineNumber, logFile.Id); var result = _plossParser.Parse(line, scenario.Id);
if (result.IsSuccess) if (result.IsSuccess)
plossRecords.Add(result.Record!); plossRecords.Add(result.Record!);
else 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 BatchInsertAsync(qfodRecords, plossRecords);
// 更新统计
await UpdateLogFileStatsAsync(logFile.Id, qfodRecords.Count, plossRecords.Count, _errorService.ErrorCount);
return new ParseReport(fileInfo.Name, qfodRecords.Count, plossRecords.Count, _errorService.ErrorCount); // 更新场景统计
} await UpdateScenarioStatsAsync(scenario.Id, qfodRecords.Count, plossRecords.Count);
private async Task<LogFile> CreateLogFileRecordAsync(FileInfo fileInfo) return new ParseReport(fileInfo.Name, qfodRecords.Count, plossRecords.Count, errorCount, null);
{
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;
} }
private async Task BatchInsertAsync( private async Task BatchInsertAsync(
List<QfodRecord> qfodRecords, List<QfodRecord> qfodRecords,
List<PlossRecord> plossRecords, List<PlossRecord> plossRecords)
List<ParseError> errors)
{ {
await using var transaction = await _context.Database.BeginTransactionAsync(); await using var transaction = await _context.Database.BeginTransactionAsync();
try try
{ {
// 批量插入 QfodRecord // 批量插入 QfodRecord
@@ -171,13 +180,6 @@ public class ParseService
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
} }
// 批量插入 ParseError
foreach (var batch in errors.Chunk(_batchSize))
{
_context.ParseErrors.AddRange(batch);
await _context.SaveChangesAsync();
}
await transaction.CommitAsync(); await transaction.CommitAsync();
} }
catch 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; scenario.QfodCount += qfodCount;
logFile.PlossCount = plossCount; scenario.PlossCount += plossCount;
logFile.ErrorCount = errorCount;
logFile.Status = ParseStatus.Completed;
await _context.SaveChangesAsync(); 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);
``` ```

View File

@@ -13,11 +13,11 @@
├─────────────────────────────────────────────────────────────┤ ├─────────────────────────────────────────────────────────────┤
│ Service Layer │ │ Service Layer │
├─────────────────────────────────────────────────────────────┤ ├─────────────────────────────────────────────────────────────┤
│ ParseService │ ErrorHandlingService │ ThresholdCalc │ │ ParseService │ ScenarioService │ DimensionService │ ThresholdCalc │
├─────────────────────────────────────────────────────────────┤ ├─────────────────────────────────────────────────────────────┤
│ Parser Layer │ │ Parser Layer │
├─────────────────────────────────────────────────────────────┤ ├─────────────────────────────────────────────────────────────┤
│ QfodParser │ PlossParser │ LogFileReader │ QfodParser │ PlossParser │ FileNameParser │
├─────────────────────────────────────────────────────────────┤ ├─────────────────────────────────────────────────────────────┤
│ Data Layer │ │ Data Layer │
├─────────────────────────────────────────────────────────────┤ ├─────────────────────────────────────────────────────────────┤
@@ -36,15 +36,18 @@
| 模块名 | 职责 | 依赖模块 | | 模块名 | 职责 | 依赖模块 |
|--------|------|----------| |--------|------|----------|
| CLI Entry | 接收命令行参数、调度解析流程 | ParseService, Infrastructure | | CLI Entry | 接收命令行参数、调度解析流程 | ParseService, Infrastructure |
| ParseService | 协调解析流程、批量写入、统计更新 | Parser Layer, DbContext, Infrastructure | | ParseService | 协调解析流程、批量写入、场景关联 | Parser Layer, ScenarioService, DbContext |
| ScenarioService | 创建/查询测试场景,解析文件名获取场景信息 | DimensionService, DbContext |
| DimensionService | 维度表 CRUD获取或创建维度记录 | DbContext |
| FileNameParser | 解析文件名提取场景信息面板、版本、RX类型等 | 无 |
| LogFileReader | 读取日志文件、行过滤、文件遍历 | 无 | | LogFileReader | 读取日志文件、行过滤、文件遍历 | 无 |
| QfodParser | 解析 Qfod 格式日志,返回 Record | LogFileReader | | QfodParser | 解析 Qfod 格式日志,返回 QfodRecord | |
| PlossParser | 解析 Ploss 格式日志,返回 Record | LogFileReader | | PlossParser | 解析 Ploss 格式日志,返回 PlossRecord | |
| Models | 定义数据实体结构 | 无 | | Models | 定义数据实体结构(维度表 + 事实表) | 无 |
| WctMinerDbContext | EF Core 数据库上下文 | Models | | WctMinerDbContext | EF Core 数据库上下文 | Models, Configurations |
| Configuration | 加载配置文件 | 无 | | Configuration | 加载配置文件 | 无 |
| Serilog | 结构化日志输出 | 无 | | Serilog | 结构化日志输出(含解析错误日志) | 无 |
| ErrorHandlingService | 解析错误记录、错误统计 | 无 | | ThresholdCalculator | 根据 RxPower 计算动态阈值 | 无 |
--- ---
@@ -55,10 +58,10 @@
| 层级 | 职责 | 允许调用 | | 层级 | 职责 | 允许调用 |
|------|------|----------| |------|------|----------|
| CLI Entry | 接收参数、流程调度、结果输出 | Service, Infrastructure | | CLI Entry | 接收参数、流程调度、结果输出 | Service, Infrastructure |
| Service | 协调解析流程、批量写入、统计管理 | Parser, Data, Infrastructure | | Service | 协调解析流程、场景管理、维度管理、批量写入 | Parser, Data, Infrastructure |
| Parser | 日志解析、数据提取、返回 Record | 无 | | Parser | 日志解析、文件名解析、数据提取 | 无 |
| Data | 数据持久化、DbContext 管理 | Infrastructure | | Data | 数据持久化、DbContext 管理、实体配置 | Infrastructure |
| Infrastructure | 配置、日志、错误处理 | 无 | | Infrastructure | 配置、日志 | 无 |
### 3.2 依赖规则 ### 3.2 依赖规则
@@ -66,6 +69,7 @@
- 下层不可调用上层 - 下层不可调用上层
- Parser 之间不可互相调用(独立解析器) - Parser 之间不可互相调用(独立解析器)
- Service 层协调 Parser 和 Data 层 - Service 层协调 Parser 和 Data 层
- 解析错误通过 Serilog 记录,不存入业务数据库
--- ---
@@ -75,35 +79,46 @@
graph TD graph TD
CLI[CLI Entry] --> PS[ParseService] CLI[CLI Entry] --> PS[ParseService]
CLI --> Serilog CLI --> Serilog
CLI --> EHS[ErrorHandlingService]
PS --> SS[ScenarioService]
PS --> QP[QfodParser] PS --> QP[QfodParser]
PS --> PP[PlossParser] PS --> PP[PlossParser]
PS --> DBM[WctMinerDbContext] PS --> DBM[WctMinerDbContext]
PS --> EHS
PS --> Serilog PS --> Serilog
PS --> Config[Configuration]
QP --> LFR[LogFileReader] SS --> DS[DimensionService]
PP --> LFR SS --> FNP[FileNameParser]
SS --> DBM
DS --> DBM
QP --> Serilog
PP --> Serilog
FNP --> Serilog
DBM --> Models[Models] DBM --> Models[Models]
DBM --> Configs[Configurations]
subgraph Parser Layer subgraph Parser Layer
QP QP
PP PP
LFR FNP
end end
subgraph Data Layer subgraph Data Layer
DBM DBM
Models Models
Configs
end end
subgraph Infrastructure subgraph Infrastructure
Config
Serilog Serilog
EHS end
subgraph Service Layer
PS
SS
DS
end end
``` ```
@@ -117,20 +132,32 @@ src/WCTDataMiner/
│ ├── IParser.cs │ ├── IParser.cs
│ ├── QfodParser.cs │ ├── QfodParser.cs
│ ├── PlossParser.cs │ ├── PlossParser.cs
── LogFileReader.cs ── FileNameParser.cs # 解析文件名获取场景信息Parser Layer
│ └── LogFileReader.cs # 读取日志文件(可选)
├── Models/ ├── Models/
│ ├── LogFile.cs │ ├── TxPanel.cs # TX面板维度
│ ├── QfodRecord.cs │ ├── TxHardware.cs # TX硬件版本维度
│ ├── PlossRecord.cs │ ├── TxSoftware.cs # TX软件版本维度
│ ├── ParseError.cs │ ├── RxType.cs # RX类型维度
── ParseStatus.cs ── TestScenario.cs # 测试场景
│ ├── QfodRecord.cs # Qfod记录
│ └── PlossRecord.cs # Ploss记录
├── Data/ ├── Data/
│ ├── WctMinerDbContext.cs │ ├── WctMinerDbContext.cs
── IDbContextFactory.cs ── IDbContextFactory.cs
│ └── Configurations/ # IEntityTypeConfiguration 配置类
│ ├── TxPanelConfiguration.cs
│ ├── TxHardwareConfiguration.cs
│ ├── TxSoftwareConfiguration.cs
│ ├── RxTypeConfiguration.cs
│ ├── TestScenarioConfiguration.cs
│ ├── QfodRecordConfiguration.cs
│ └── PlossRecordConfiguration.cs
├── Services/ ├── Services/
│ ├── ParseService.cs │ ├── ParseService.cs # 解析流程协调
│ ├── ThresholdCalculator.cs │ ├── ScenarioService.cs # 场景创建/查询
── ErrorHandlingService.cs ── DimensionService.cs # 维度表管理
│ └── ThresholdCalculator.cs # 阈值计算
├── Configuration/ ├── Configuration/
│ ├── AppSettings.cs │ ├── AppSettings.cs
│ └── appsettings.json │ └── appsettings.json

View File

@@ -14,22 +14,24 @@
## 2. 字段定义 ## 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 | 主键 | | id | UUID | PK, NOT NULL | 主键 |
| scenario_id | UUID | FK, NOT NULL | 关联测试场景 | | scenario_id | UUID | FK, NOT NULL | 关联测试场景 |
| rx_type | TINYINT | NOT NULL | 接收端类型标识 | | rx_type | TINYINT | NOT NULL | 接收端类型标识 |
| rx_power | SMALLINT | NOT NULL | 接收端功率mW | | rx_power | INT | NOT NULL | 接收端功率mW,原 uint16 |
| tx_power | SMALLINT | NOT NULL | 发射端功率mW | | tx_power | INT | NOT NULL | 发射端功率mW,原 uint16 |
| vcoil | SMALLINT | NOT NULL | 发射线圈电压 | | vcoil | INT | NOT NULL | 发射线圈电压,原 uint16 |
| vin | SMALLINT | NOT NULL | 输入电压 | | vin | INT | NOT NULL | 输入电压,原 uint16 |
| isns | SMALLINT | NOT NULL | 输入电流采样值 | | isns | INT | NOT NULL | 输入电流采样值,原 uint16 |
| ploss | INT | NOT NULL | **核心值**:计算损耗(负数=安全) | | ploss | INT | NOT NULL | **核心值**:计算损耗(负数=安全) |
| threshold | INT | NOT NULL | **安全红线**:当前功率段阈值 | | threshold | INT | NOT NULL | **安全红线**:当前功率段阈值 |
| trigger_count | SMALLINT | NOT NULL, DEFAULT 0 | 触发计数器 | | trigger_count | INT | NOT NULL, DEFAULT 0 | 触发计数器 |
| fod_result | SMALLINT | NOT NULL, DEFAULT 0 | FOD判定结果 | | fod_result | INT | NOT NULL, DEFAULT 0 | FOD判定结果 |
| protocol_type | TINYINT | NOT NULL | 充电协议类型 | | protocol_type | TINYINT | NOT NULL | 充电协议类型 |
| pwm_duty | SMALLINT | NOT NULL | PWM占空比 | | pwm_duty | INT | NOT NULL | PWM占空比,原 uint16 |
| coil_index | TINYINT | NOT NULL | 充电线圈索引 | | coil_index | TINYINT | NOT NULL | 充电线圈索引 |
| is_deleted | BOOLEAN | NOT NULL, DEFAULT FALSE | 软删除标记 | | is_deleted | BOOLEAN | NOT NULL, DEFAULT FALSE | 软删除标记 |
| created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 创建时间 | | created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | 创建时间 |
@@ -52,6 +54,8 @@
|------|----------|------| |------|----------|------|
| margin | threshold - ploss | 安全余量(>0 安全,<0 危险) | | margin | threshold - ploss | 安全余量(>0 安全,<0 危险) |
> **使用限制**`Margin` 属性通过 EF Core `Ignore()` 排除映射,仅用于内存计算。不可在数据库查询中直接使用(如 `WHERE Margin > 0`),需改用 `WHERE (threshold - ploss) > 0` 或先加载到内存再计算。
--- ---
## 5. 索引 ## 5. 索引
@@ -68,100 +72,151 @@
## 6. EF Core 模型 ## 6. EF Core 模型
### 实体类
```csharp ```csharp
// Models/PlossRecord.cs // Models/PlossRecord.cs
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace WCTDataMiner.Models; 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 public class PlossRecord
{ {
[Key]
[Column("id")]
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
[Required]
[Column("scenario_id")]
public Guid ScenarioId { get; set; } public Guid ScenarioId { get; set; }
[Required]
[Column("rx_type")]
public byte RxType { get; set; } public byte RxType { get; set; }
[Required]
[Column("rx_power")]
public short RxPower { get; set; } public short RxPower { get; set; }
[Required]
[Column("tx_power")]
public short TxPower { get; set; } public short TxPower { get; set; }
[Required]
[Column("vcoil")]
public short Vcoil { get; set; } public short Vcoil { get; set; }
[Required]
[Column("vin")]
public short Vin { get; set; } public short Vin { get; set; }
[Required]
[Column("isns")]
public short Isns { get; set; } public short Isns { get; set; }
/// <summary>核心判定值:负数表示安全</summary> /// <summary>核心判定值:负数表示安全</summary>
[Required]
[Column("ploss")]
public int Ploss { get; set; } public int Ploss { get; set; }
/// <summary>安全红线:当前功率段阈值</summary> /// <summary>安全红线:当前功率段阈值</summary>
[Required]
[Column("threshold")]
public int Threshold { get; set; } public int Threshold { get; set; }
[Required]
[Column("trigger_count")]
public short TriggerCount { get; set; } = 0; public short TriggerCount { get; set; } = 0;
[Required]
[Column("fod_result")]
public short FodResult { get; set; } = 0; public short FodResult { get; set; } = 0;
[Required]
[Column("protocol_type")]
public byte ProtocolType { get; set; } public byte ProtocolType { get; set; }
[Required]
[Column("pwm_duty")]
public short PwmDuty { get; set; } public short PwmDuty { get; set; }
[Required]
[Column("coil_index")]
public byte CoilIndex { get; set; } public byte CoilIndex { get; set; }
[Required]
[Column("is_deleted")]
public bool IsDeleted { get; set; } = false; public bool IsDeleted { get; set; } = false;
[Required]
[Column("created_at")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
[ForeignKey(nameof(ScenarioId))] // Navigation Properties
public TestScenario Scenario { get; set; } = null!; public TestScenario Scenario { get; set; } = null!;
/// <summary>派生属性:安全余量</summary> /// <summary>派生属性:安全余量(不落库)</summary>
[NotMapped]
public int Margin => Threshold - Ploss; 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<PlossRecord>
{
public void Configure(EntityTypeBuilder<PlossRecord> 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. 动态阈值表 ## 7. 动态阈值表

View File

@@ -55,69 +55,112 @@
| chk_qfod_charger | CHECK | charger_index IN (0, 1) | | chk_qfod_charger | CHECK | charger_index IN (0, 1) |
| chk_qfod_coil | CHECK | coil_index IN (0, 1, 2) | | chk_qfod_coil | CHECK | coil_index IN (0, 1, 2) |
> **数据库兼容性**`IN (0, 1)` 语法在 SQLite、PostgreSQL、MySQL 中均可使用。若使用其他数据库,需验证 CHECK 约束语法兼容性。
--- ---
## 6. EF Core 模型 ## 6. EF Core 模型
### 实体类
```csharp ```csharp
// Models/QfodRecord.cs // Models/QfodRecord.cs
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace WCTDataMiner.Models; 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 public class QfodRecord
{ {
[Key]
[Column("id")]
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
[Required]
[Column("scenario_id")]
public Guid ScenarioId { get; set; } public Guid ScenarioId { get; set; }
[Required]
[Column("charger_index")]
public byte ChargerIndex { get; set; } public byte ChargerIndex { get; set; }
[Required]
[Column("coil_index")]
public byte CoilIndex { get; set; } public byte CoilIndex { get; set; }
[Required]
[Column("delta_q")]
public int DeltaQ { get; set; } public int DeltaQ { get; set; }
[Required]
[Column("current_q")]
public float CurrentQ { get; set; } public float CurrentQ { get; set; }
[Required]
[Column("raw_q")]
public float RawQ { get; set; } public float RawQ { get; set; }
[Required]
[Column("fod_type")]
public byte FodType { get; set; } public byte FodType { get; set; }
[Required]
[Column("is_deleted")]
public bool IsDeleted { get; set; } = false; public bool IsDeleted { get; set; } = false;
[Required]
[Column("created_at")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
[ForeignKey(nameof(ScenarioId))] // Navigation Properties
public TestScenario Scenario { get; set; } = null!; 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<QfodRecord>
{
public void Configure(EntityTypeBuilder<QfodRecord> 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. 检测通道组合 ## 7. 检测通道组合

View File

@@ -34,31 +34,17 @@
## 4. EF Core 模型 ## 4. EF Core 模型
### 实体类
```csharp ```csharp
// Models/RxType.cs // Models/RxType.cs
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace WCTDataMiner.Models; namespace WCTDataMiner.Models;
[Table("rx_type")]
public class RxType public class RxType
{ {
[Key]
[Column("id")]
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
[Required]
[MaxLength(100)]
[Column("name")]
public string Name { get; set; } = null!; public string Name { get; set; } = null!;
[Required]
[Column("is_deleted")]
public bool IsDeleted { get; set; } = false; public bool IsDeleted { get; set; } = false;
[Required]
[Column("created_at")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Navigation Properties // 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<RxType>
{
public void Configure(EntityTypeBuilder<RxType> 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. 使用说明 ## 5. 使用说明

View File

@@ -25,31 +25,17 @@
## 3. EF Core 模型 ## 3. EF Core 模型
### 实体类
```csharp ```csharp
// Models/TxHardware.cs // Models/TxHardware.cs
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace WCTDataMiner.Models; namespace WCTDataMiner.Models;
[Table("tx_hardware")]
public class TxHardware public class TxHardware
{ {
[Key]
[Column("id")]
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
[Required]
[MaxLength(50)]
[Column("version")]
public string Version { get; set; } = null!; public string Version { get; set; } = null!;
[Required]
[Column("is_deleted")]
public bool IsDeleted { get; set; } = false; public bool IsDeleted { get; set; } = false;
[Required]
[Column("created_at")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Navigation Properties // 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<TxHardware>
{
public void Configure(EntityTypeBuilder<TxHardware> 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. 使用说明 ## 4. 使用说明

View File

@@ -39,31 +39,17 @@
## 4. EF Core 模型 ## 4. EF Core 模型
### 实体类
```csharp ```csharp
// Models/TxPanel.cs // Models/TxPanel.cs
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace WCTDataMiner.Models; namespace WCTDataMiner.Models;
[Table("tx_panel")]
public class TxPanel public class TxPanel
{ {
[Key]
[Column("id")]
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
[Required]
[MaxLength(50)]
[Column("name")]
public string Name { get; set; } = null!; public string Name { get; set; } = null!;
[Required]
[Column("is_deleted")]
public bool IsDeleted { get; set; } = false; public bool IsDeleted { get; set; } = false;
[Required]
[Column("created_at")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Navigation Properties // 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<TxPanel>
{
public void Configure(EntityTypeBuilder<TxPanel> 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. 初始化数据 ## 5. 初始化数据

View File

@@ -25,31 +25,17 @@
## 3. EF Core 模型 ## 3. EF Core 模型
### 实体类
```csharp ```csharp
// Models/TxSoftware.cs // Models/TxSoftware.cs
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace WCTDataMiner.Models; namespace WCTDataMiner.Models;
[Table("tx_software")]
public class TxSoftware public class TxSoftware
{ {
[Key]
[Column("id")]
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
[Required]
[MaxLength(50)]
[Column("version")]
public string Version { get; set; } = null!; public string Version { get; set; } = null!;
[Required]
[Column("is_deleted")]
public bool IsDeleted { get; set; } = false; public bool IsDeleted { get; set; } = false;
[Required]
[Column("created_at")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Navigation Properties // 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<TxSoftware>
{
public void Configure(EntityTypeBuilder<TxSoftware> 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. 使用说明 ## 4. 使用说明

View File

@@ -60,84 +60,42 @@
|--------|----------|------| |--------|----------|------|
| uq_scenario | (tx_panel_id, tx_hardware_id, tx_software_id, rx_type_id, test_purpose, test_date, test_sequence) | 相同属性组合不重复创建 | | 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 模型 ## 6. EF Core 模型
### 实体类
```csharp ```csharp
// Models/TestScenario.cs // Models/TestScenario.cs
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace WCTDataMiner.Models; 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 public class TestScenario
{ {
[Key]
[Column("id")]
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
[Required]
[Column("tx_panel_id")]
public Guid TxPanelId { get; set; } public Guid TxPanelId { get; set; }
[Required]
[Column("tx_hardware_id")]
public Guid TxHardwareId { get; set; } public Guid TxHardwareId { get; set; }
[Required]
[Column("tx_software_id")]
public Guid TxSoftwareId { get; set; } public Guid TxSoftwareId { get; set; }
[Required]
[Column("rx_type_id")]
public Guid RxTypeId { get; set; } public Guid RxTypeId { get; set; }
[MaxLength(200)]
[Column("test_purpose")]
public string? TestPurpose { get; set; } public string? TestPurpose { get; set; }
[Required]
[Column("test_date")]
public DateOnly TestDate { get; set; } public DateOnly TestDate { get; set; }
[Required]
[Column("test_sequence")]
public int TestSequence { get; set; } = 1; public int TestSequence { get; set; } = 1;
[Required]
[Column("qfod_count")]
public int QfodCount { get; set; } = 0; public int QfodCount { get; set; } = 0;
[Required]
[Column("ploss_count")]
public int PlossCount { get; set; } = 0; public int PlossCount { get; set; } = 0;
[Required]
[Column("is_deleted")]
public bool IsDeleted { get; set; } = false; public bool IsDeleted { get; set; } = false;
[Required]
[Column("created_at")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Navigation Properties - 维度表 // Navigation Properties - 维度表
[ForeignKey(nameof(TxPanelId))]
public TxPanel TxPanel { get; set; } = null!; public TxPanel TxPanel { get; set; } = null!;
[ForeignKey(nameof(TxHardwareId))]
public TxHardware TxHardware { get; set; } = null!; public TxHardware TxHardware { get; set; } = null!;
[ForeignKey(nameof(TxSoftwareId))]
public TxSoftware TxSoftware { get; set; } = null!; public TxSoftware TxSoftware { get; set; } = null!;
[ForeignKey(nameof(RxTypeId))]
public RxType RxType { get; set; } = null!; public RxType RxType { get; set; } = null!;
// Navigation Properties - 数据记录 // 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<TestScenario>
{
public void Configure(EntityTypeBuilder<TestScenario> 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. 场景创建流程 ## 7. 场景创建流程
@@ -154,45 +224,41 @@ public class TestScenario
```csharp ```csharp
// Services/ScenarioService.cs // Services/ScenarioService.cs
public async Task<TestScenario> GetOrCreateFromFileNameAsync(string fileName) public async Task<TestScenario> GetOrCreateScenarioAsync(ScenarioInfo info)
{ {
// 1. 解析文件名 // 1. 获取或创建各维度(通过 DimensionService
var parts = ParseFileName(fileName); var txPanel = await _dimensionService.GetOrCreateTxPanelAsync(info.TxPanel);
// 格式: TX面板-TX硬件-TX软件-RX类型-测试目的-年月日-测试次数.log var txHardware = await _dimensionService.GetOrCreateTxHardwareAsync(info.TxHardware);
var txSoftware = await _dimensionService.GetOrCreateTxSoftwareAsync(info.TxSoftware);
var rxType = await _dimensionService.GetOrCreateRxTypeAsync(info.RxType);
// 2. 获取或创建各维度 // 2. 检查场景是否已存在(利用唯一约束)
var txPanel = await txPanelService.GetOrCreateAsync(parts.TxPanel); var existing = await _context.TestScenarios
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
.FirstOrDefaultAsync(s => .FirstOrDefaultAsync(s =>
s.TxPanelId == txPanel.Id && s.TxPanelId == txPanel.Id &&
s.TxHardwareId == txHardware.Id && s.TxHardwareId == txHardware.Id &&
s.TxSoftwareId == txSoftware.Id && s.TxSoftwareId == txSoftware.Id &&
s.RxTypeId == rxType.Id && s.RxTypeId == rxType.Id &&
s.TestPurpose == parts.TestPurpose && s.TestPurpose == info.TestPurpose &&
s.TestDate == parts.TestDate && s.TestDate == info.TestDate &&
s.TestSequence == parts.TestSequence); s.TestSequence == info.TestSequence);
if (existing != null) return existing; if (existing != null) return existing;
// 4. 创建新场景 // 3. 创建新场景
var scenario = new TestScenario var scenario = new TestScenario
{ {
TxPanelId = txPanel.Id, TxPanelId = txPanel.Id,
TxHardwareId = txHardware.Id, TxHardwareId = txHardware.Id,
TxSoftwareId = txSoftware.Id, TxSoftwareId = txSoftware.Id,
RxTypeId = rxType.Id, RxTypeId = rxType.Id,
TestPurpose = parts.TestPurpose, TestPurpose = info.TestPurpose,
TestDate = parts.TestDate, TestDate = info.TestDate,
TestSequence = parts.TestSequence TestSequence = info.TestSequence
}; };
context.TestScenarios.Add(scenario); _context.TestScenarios.Add(scenario);
await context.SaveChangesAsync(); await _context.SaveChangesAsync();
return scenario; return scenario;
} }

View File

@@ -182,6 +182,60 @@ public async Task SoftDeleteAsync(Guid id)
} }
``` ```
### 实体配置方式
采用 `IEntityTypeConfiguration<T>` Fluent API 配置,实体类保持纯净 POCO
```csharp
// Data/WctMinerDbContext.cs
using Microsoft.EntityFrameworkCore;
using WCTDataMiner.Models;
namespace WCTDataMiner.Data;
public class WctMinerDbContext : DbContext
{
public WctMinerDbContext(DbContextOptions<WctMinerDbContext> options)
: base(options) { }
public DbSet<TxPanel> TxPanels => Set<TxPanel>();
public DbSet<TxHardware> TxHardwares => Set<TxHardware>();
public DbSet<TxSoftware> TxSoftwares => Set<TxSoftware>();
public DbSet<RxType> RxTypes => Set<RxType>();
public DbSet<TestScenario> TestScenarios => Set<TestScenario>();
public DbSet<QfodRecord> QfodRecords => Set<QfodRecord>();
public DbSet<PlossRecord> PlossRecords => Set<PlossRecord>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// 自动应用所有 IEntityTypeConfiguration<T> 配置
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 记录到系统日志文件,不存入业务数据库。 解析过程中的错误日志通过 Serilog 记录到系统日志文件,不存入业务数据库。

View File

@@ -0,0 +1,65 @@
using System.CommandLine;
using WCTDataMiner.Services;
namespace WCTDataMiner.Commands;
/// <summary>
/// 数据清理命令
/// </summary>
public class CleanCommand : Command
{
public CleanCommand(CleanService cleanService)
: base("clean", "Clean data from database (soft delete)")
{
var scenarioOption = new Option<string?>(
"--scenario",
"Scenario ID to clean"
);
var allOption = new Option<bool>(
"--all",
() => false,
"Clean all data"
);
var confirmOption = new Option<bool>(
"--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 <id> or --all");
}
}, scenarioOption, allOption, confirmOption);
}
}

View File

@@ -0,0 +1,84 @@
using System.CommandLine;
using WCTDataMiner.Services;
namespace WCTDataMiner.Commands;
/// <summary>
/// 数据导出命令
/// </summary>
public class ExportCommand : Command
{
public ExportCommand(ExportService exportService)
: base("export", "Export data to CSV or JSON")
{
var formatOption = new Option<string>(
"--format",
() => "csv",
"Export format: csv or json"
);
var outputOption = new Option<string>(
"--output",
() => "./data/output",
"Output directory path"
);
var typeOption = new Option<string>(
"--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);
}
}

View File

@@ -0,0 +1,80 @@
using System.CommandLine;
using WCTDataMiner.Services;
namespace WCTDataMiner.Commands;
/// <summary>
/// 解析日志文件命令
/// </summary>
public class ParseCommand : Command
{
public ParseCommand(ParseService parseService)
: base("parse", "Parse log files and store data to database")
{
var fileOption = new Option<string>(
"--file",
"Path to a single log file to parse"
);
var dirOption = new Option<string>(
"--dir",
"Path to directory containing log files"
);
var recursiveOption = new Option<bool>(
"--recursive",
() => false,
"Recursively search subdirectories"
);
var forceOption = new Option<bool>(
"--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}");
}
}
}

View File

@@ -0,0 +1,112 @@
using System.CommandLine;
using WCTDataMiner.Services;
namespace WCTDataMiner.Commands;
/// <summary>
/// 统计查询命令
/// </summary>
public class StatsCommand : Command
{
public StatsCommand(StatsService statsService)
: base("stats", "Query statistics from database")
{
var summaryOption = new Option<bool>(
"--summary",
() => false,
"Show overall statistics summary"
);
var byFodTypeOption = new Option<bool>(
"--by-fod-type",
() => false,
"Show statistics by FOD type"
);
var byPanelOption = new Option<bool>(
"--by-panel",
() => false,
"Show statistics by TX panel"
);
var byHardwareOption = new Option<bool>(
"--by-hardware",
() => false,
"Show statistics by TX hardware version"
);
var byRxTypeOption = new Option<bool>(
"--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);
}
}

View File

@@ -0,0 +1,44 @@
namespace WCTDataMiner.Configuration;
/// <summary>
/// 应用配置
/// </summary>
public class AppSettings
{
public DatabaseSettings Database { get; set; } = new();
public ParserSettings Parser { get; set; } = new();
public PathSettings Paths { get; set; } = new();
}
/// <summary>
/// 数据库配置
/// </summary>
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; }
}
/// <summary>
/// 解析器配置
/// </summary>
public class ParserSettings
{
public int BatchSize { get; set; } = 1000;
}
/// <summary>
/// 路径配置
/// </summary>
public class PathSettings
{
public string LogDir { get; set; } = "./data/logs";
public string OutputDir { get; set; } = "./data/output";
}

View File

@@ -0,0 +1,103 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using WCTDataMiner.Models;
namespace WCTDataMiner.Data.Configurations;
public class PlossRecordConfiguration : IEntityTypeConfiguration<PlossRecord>
{
public void Configure(EntityTypeBuilder<PlossRecord> 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);
}
}

View File

@@ -0,0 +1,73 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using WCTDataMiner.Models;
namespace WCTDataMiner.Data.Configurations;
public class QfodRecordConfiguration : IEntityTypeConfiguration<QfodRecord>
{
public void Configure(EntityTypeBuilder<QfodRecord> 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);
}
}

View File

@@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using WCTDataMiner.Models;
namespace WCTDataMiner.Data.Configurations;
public class RxTypeConfiguration : IEntityTypeConfiguration<RxType>
{
public void Configure(EntityTypeBuilder<RxType> 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);
}
}

View File

@@ -0,0 +1,112 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using WCTDataMiner.Models;
namespace WCTDataMiner.Data.Configurations;
public class TestScenarioConfiguration : IEntityTypeConfiguration<TestScenario>
{
public void Configure(EntityTypeBuilder<TestScenario> 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);
}
}

View File

@@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using WCTDataMiner.Models;
namespace WCTDataMiner.Data.Configurations;
public class TxHardwareConfiguration : IEntityTypeConfiguration<TxHardware>
{
public void Configure(EntityTypeBuilder<TxHardware> 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);
}
}

View File

@@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using WCTDataMiner.Models;
namespace WCTDataMiner.Data.Configurations;
public class TxPanelConfiguration : IEntityTypeConfiguration<TxPanel>
{
public void Configure(EntityTypeBuilder<TxPanel> 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);
}
}

View File

@@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using WCTDataMiner.Models;
namespace WCTDataMiner.Data.Configurations;
public class TxSoftwareConfiguration : IEntityTypeConfiguration<TxSoftware>
{
public void Configure(EntityTypeBuilder<TxSoftware> 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);
}
}

View File

@@ -0,0 +1,66 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
namespace WCTDataMiner.Data;
/// <summary>
/// 数据库上下文工厂实现 - 支持SQLite和PostgreSQL切换
/// </summary>
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<WctMinerDbContext>();
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);
}
}
}

View File

@@ -0,0 +1,9 @@
namespace WCTDataMiner.Data;
/// <summary>
/// 数据库上下文工厂接口
/// </summary>
public interface IDbContextFactory
{
WctMinerDbContext CreateDbContext();
}

View File

@@ -0,0 +1,36 @@
using WCTDataMiner.Models;
namespace WCTDataMiner.Data;
/// <summary>
/// 初始数据种子
/// </summary>
public static class SeedData
{
/// <summary>
/// TX面板类型预设数据
/// </summary>
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" }
];
/// <summary>
/// 初始化种子数据
/// </summary>
public static void Initialize(WctMinerDbContext context)
{
// 确保 TxPanel 表有预设数据
if (!context.TxPanels.Any())
{
context.TxPanels.AddRange(DefaultTxPanels);
context.SaveChanges();
}
}
}

View File

@@ -0,0 +1,32 @@
using Microsoft.EntityFrameworkCore;
using WCTDataMiner.Models;
namespace WCTDataMiner.Data;
/// <summary>
/// WCT数据采集数据库上下文
/// </summary>
public class WctMinerDbContext : DbContext
{
public WctMinerDbContext(DbContextOptions<WctMinerDbContext> options)
: base(options) { }
// 维度表
public DbSet<TxPanel> TxPanels => Set<TxPanel>();
public DbSet<TxHardware> TxHardwares => Set<TxHardware>();
public DbSet<TxSoftware> TxSoftwares => Set<TxSoftware>();
public DbSet<RxType> RxTypes => Set<RxType>();
// 事实表
public DbSet<TestScenario> TestScenarios => Set<TestScenario>();
public DbSet<QfodRecord> QfodRecords => Set<QfodRecord>();
public DbSet<PlossRecord> PlossRecords => Set<PlossRecord>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// 自动应用所有 IEntityTypeConfiguration<T> 配置
modelBuilder.ApplyConfigurationsFromAssembly(typeof(WctMinerDbContext).Assembly);
}
}

View File

@@ -0,0 +1,54 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using WCTDataMiner.Data;
using WCTDataMiner.Parsers;
using WCTDataMiner.Services;
namespace WCTDataMiner.Extensions;
/// <summary>
/// 服务注册扩展
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// 注册数据库服务
/// </summary>
public static IServiceCollection AddDatabaseServices(
this IServiceCollection services,
IConfiguration configuration)
{
// 注册 DbContext 工厂
services.AddScoped<IDbContextFactory, DbContextFactory>();
// 注册 DbContext通过工厂创建
services.AddScoped<WctMinerDbContext>(sp =>
{
var factory = sp.GetRequiredService<IDbContextFactory>();
return factory.CreateDbContext();
});
return services;
}
/// <summary>
/// 注册应用服务
/// </summary>
public static IServiceCollection AddApplicationServices(this IServiceCollection services)
{
// Parsers
services.AddScoped<QfodParser>();
services.AddScoped<PlossParser>();
services.AddSingleton<FileNameParser>();
// Services
services.AddScoped<DimensionService>();
services.AddScoped<ScenarioService>();
services.AddScoped<ParseService>();
services.AddScoped<StatsService>();
services.AddScoped<ExportService>();
services.AddScoped<CleanService>();
return services;
}
}

View File

@@ -0,0 +1,36 @@
namespace WCTDataMiner.Models;
/// <summary>
/// Ploss记录实体 - 存储Ploss FOD格式解析数据
/// </summary>
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 存储
/// <summary>核心判定值:负数表示安全</summary>
public int Ploss { get; set; }
/// <summary>安全红线:当前功率段阈值</summary>
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!;
/// <summary>派生属性:安全余量(不落库)</summary>
public int Margin => Threshold - Ploss;
}

View File

@@ -0,0 +1,21 @@
namespace WCTDataMiner.Models;
/// <summary>
/// Qfod记录实体 - 存储Qfod格式解析数据
/// </summary>
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!;
}

View File

@@ -0,0 +1,15 @@
namespace WCTDataMiner.Models;
/// <summary>
/// RX类型维度表
/// </summary>
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<TestScenario> TestScenarios { get; set; } = [];
}

View File

@@ -0,0 +1,30 @@
namespace WCTDataMiner.Models;
/// <summary>
/// 测试场景实体 - 通过外键关联各维度表
/// </summary>
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<QfodRecord> QfodRecords { get; set; } = [];
public ICollection<PlossRecord> PlossRecords { get; set; } = [];
}

View File

@@ -0,0 +1,15 @@
namespace WCTDataMiner.Models;
/// <summary>
/// TX硬件版本维度表
/// </summary>
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<TestScenario> TestScenarios { get; set; } = [];
}

View File

@@ -0,0 +1,15 @@
namespace WCTDataMiner.Models;
/// <summary>
/// TX面板类型维度表
/// </summary>
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<TestScenario> TestScenarios { get; set; } = [];
}

View File

@@ -0,0 +1,15 @@
namespace WCTDataMiner.Models;
/// <summary>
/// TX软件版本维度表
/// </summary>
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<TestScenario> TestScenarios { get; set; } = [];
}

View File

@@ -0,0 +1,68 @@
using System.Globalization;
namespace WCTDataMiner.Parsers;
/// <summary>
/// 文件名解析器 - 从文件名提取场景信息
/// 格式: TX面板-TX硬件-TX软件-RX类型-测试目的-年月日-测试次数.log
/// 示例: singleMold-v1.0-hex2_1-iPhone15-compatibility-20260702-1.log
/// </summary>
public class FileNameParser
{
private static readonly char[] Separators = { '-', '_' };
/// <summary>
/// 解析日志文件名,提取场景信息
/// </summary>
/// <param name="fileName">文件名(不含路径)</param>
/// <returns>场景信息,若格式不匹配返回 null</returns>
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);
}
}
/// <summary>
/// 场景信息 - 从文件名解析出的测试场景数据
/// </summary>
public record ScenarioInfo(
string TxPanel,
string TxHardware,
string TxSoftware,
string RxType,
string? TestPurpose,
DateOnly TestDate,
int TestSequence
);

View File

@@ -0,0 +1,36 @@
namespace WCTDataMiner.Parsers;
/// <summary>
/// 通用解析器接口
/// </summary>
public interface IParser<TRecord>
{
/// <summary>
/// 判断是否可以解析该行
/// </summary>
bool CanParse(string line);
/// <summary>
/// 解析日志行
/// </summary>
/// <param name="line">日志行内容</param>
/// <param name="scenarioId">关联的测试场景ID</param>
ParseResult<TRecord> Parse(string line, Guid scenarioId);
}
/// <summary>
/// 解析结果
/// </summary>
public record ParseResult<TRecord>
{
public bool IsSuccess { get; init; }
public TRecord? Record { get; init; }
public string? ErrorType { get; init; }
public string? ErrorMessage { get; init; }
public static ParseResult<TRecord> Success(TRecord record) =>
new() { IsSuccess = true, Record = record };
public static ParseResult<TRecord> Failure(string errorType, string errorMessage) =>
new() { IsSuccess = false, ErrorType = errorType, ErrorMessage = errorMessage };
}

View File

@@ -0,0 +1,62 @@
using System.Text.RegularExpressions;
using WCTDataMiner.Models;
namespace WCTDataMiner.Parsers;
/// <summary>
/// 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
/// </summary>
public class PlossParser : IParser<PlossRecord>
{
// 格式说明有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<PlossRecord> Parse(string line, Guid scenarioId)
{
var match = PlossPattern.Match(line);
if (!match.Success)
{
return ParseResult<PlossRecord>.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<PlossRecord>.Success(record);
}
catch (Exception ex)
{
return ParseResult<PlossRecord>.Failure(
"TYPE_CONVERSION",
$"Failed to convert field: {ex.Message}"
);
}
}
}

View File

@@ -0,0 +1,54 @@
using System.Text.RegularExpressions;
using WCTDataMiner.Models;
namespace WCTDataMiner.Parsers;
/// <summary>
/// Qfod日志解析器
/// 格式: X#:Q A->B C D E
/// 示例: 0#:Q 1->25 45.5 20.5 2
/// </summary>
public class QfodParser : IParser<QfodRecord>
{
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<QfodRecord> Parse(string line, Guid scenarioId)
{
var match = QfodPattern.Match(line);
if (!match.Success)
{
return ParseResult<QfodRecord>.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<QfodRecord>.Success(record);
}
catch (Exception ex)
{
return ParseResult<QfodRecord>.Failure(
"TYPE_CONVERSION",
$"Failed to convert field: {ex.Message}"
);
}
}
}

View File

@@ -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;
/// <summary>
/// WCTDataMiner - 无线充电FOD日志数据采集系统
/// </summary>
public class Program
{
public static async Task<int> 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<WctMinerDbContext>();
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<ParseService>()));
rootCommand.AddCommand(new StatsCommand(provider.GetRequiredService<StatsService>()));
rootCommand.AddCommand(new ExportCommand(provider.GetRequiredService<ExportService>()));
rootCommand.AddCommand(new CleanCommand(provider.GetRequiredService<CleanService>()));
return await rootCommand.InvokeAsync(args);
}
catch (Exception ex)
{
Log.Fatal(ex, "Application terminated unexpectedly");
return 1;
}
finally
{
await Log.CloseAndFlushAsync();
}
}
}

View File

@@ -0,0 +1,87 @@
using Microsoft.EntityFrameworkCore;
using WCTDataMiner.Data;
using WCTDataMiner.Models;
namespace WCTDataMiner.Services;
/// <summary>
/// 数据清理服务 - 支持软删除
/// </summary>
public class CleanService
{
private readonly WctMinerDbContext _context;
public CleanService(WctMinerDbContext context)
{
_context = context;
}
/// <summary>
/// 软删除指定场景及其关联记录
/// </summary>
public async Task<int> 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;
}
/// <summary>
/// 软删除所有数据
/// </summary>
public async Task<int> 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;
}
}

View File

@@ -0,0 +1,82 @@
using Microsoft.EntityFrameworkCore;
using WCTDataMiner.Data;
using WCTDataMiner.Models;
namespace WCTDataMiner.Services;
/// <summary>
/// 维度表管理服务 - 负责维度表的CRUD操作
/// </summary>
public class DimensionService
{
private readonly WctMinerDbContext _context;
public DimensionService(WctMinerDbContext context)
{
_context = context;
}
/// <summary>
/// 获取或创建TX面板维度
/// </summary>
public async Task<TxPanel> 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;
}
/// <summary>
/// 获取或创建TX硬件版本维度
/// </summary>
public async Task<TxHardware> 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;
}
/// <summary>
/// 获取或创建TX软件版本维度
/// </summary>
public async Task<TxSoftware> 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;
}
/// <summary>
/// 获取或创建RX类型维度
/// </summary>
public async Task<RxType> 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;
}
}

View File

@@ -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;
/// <summary>
/// 数据导出服务
/// </summary>
public class ExportService
{
private readonly WctMinerDbContext _context;
public ExportService(WctMinerDbContext context)
{
_context = context;
}
/// <summary>
/// 导出Qfod数据为CSV
/// </summary>
public async Task<string> 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;
}
/// <summary>
/// 导出Ploss数据为CSV
/// </summary>
public async Task<string> 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;
}
/// <summary>
/// 导出Qfod数据为JSON
/// </summary>
public async Task<string> 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;
}
/// <summary>
/// 导出Ploss数据为JSON
/// </summary>
public async Task<string> 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;
}
}

View File

@@ -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;
/// <summary>
/// 解析流程协调服务 - 协调文件名解析、行解析、批量写入
/// </summary>
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);
}
/// <summary>
/// 解析单个日志文件
/// </summary>
public async Task<ParseReport> 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<QfodRecord>();
var plossRecords = new List<PlossRecord>();
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);
}
/// <summary>
/// 解析目录下所有日志文件
/// </summary>
public async Task<List<ParseReport>> 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<ParseReport>();
foreach (var file in logFiles)
{
var report = await ParseFileAsync(file.FullName, force);
reports.Add(report);
}
return reports;
}
private async Task BatchInsertAsync(
List<QfodRecord> qfodRecords,
List<PlossRecord> 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();
}
}
}
/// <summary>
/// 解析报告
/// </summary>
public record ParseReport(
string FileName,
int QfodCount,
int PlossCount,
int ErrorCount,
string? ErrorMessage
)
{
public bool IsSuccess => ErrorMessage == null;
}

View File

@@ -0,0 +1,76 @@
using Microsoft.EntityFrameworkCore;
using WCTDataMiner.Data;
using WCTDataMiner.Models;
using WCTDataMiner.Parsers;
namespace WCTDataMiner.Services;
/// <summary>
/// 测试场景服务 - 负责测试场景的创建和查询
/// </summary>
public class ScenarioService
{
private readonly WctMinerDbContext _context;
private readonly DimensionService _dimensionService;
public ScenarioService(WctMinerDbContext context, DimensionService dimensionService)
{
_context = context;
_dimensionService = dimensionService;
}
/// <summary>
/// 根据场景信息获取或创建测试场景
/// </summary>
public async Task<TestScenario> 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;
}
/// <summary>
/// 获取场景详情(含维度属性)
/// </summary>
public async Task<TestScenario?> 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);
}
}

View File

@@ -0,0 +1,128 @@
using Microsoft.EntityFrameworkCore;
using WCTDataMiner.Data;
using WCTDataMiner.Models;
namespace WCTDataMiner.Services;
/// <summary>
/// 统计查询服务
/// </summary>
public class StatsService
{
private readonly WctMinerDbContext _context;
public StatsService(WctMinerDbContext context)
{
_context = context;
}
/// <summary>
/// 获取整体统计摘要
/// </summary>
public async Task<StatsSummary> 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
);
}
/// <summary>
/// 按FOD类型统计Qfod记录
/// </summary>
public async Task<List<FodTypeStats>> 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();
}
/// <summary>
/// 按面板类型统计
/// </summary>
public async Task<List<DimensionStats>> 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();
}
/// <summary>
/// 按硬件版本统计
/// </summary>
public async Task<List<DimensionStats>> 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();
}
/// <summary>
/// 按RX类型统计
/// </summary>
public async Task<List<DimensionStats>> 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();
}
}
/// <summary>
/// 整体统计摘要
/// </summary>
public record StatsSummary(
int ScenarioCount,
int QfodCount,
int PlossCount,
int PanelCount,
int HardwareCount,
int SoftwareCount,
int RxTypeCount
);
/// <summary>
/// FOD类型统计
/// </summary>
public record FodTypeStats(byte FodType, int Count);
/// <summary>
/// 维度统计
/// </summary>
public record DimensionStats(string Name, int Count);

View File

@@ -0,0 +1,43 @@
namespace WCTDataMiner.Services;
/// <summary>
/// 阈值计算器 - 根据接收功率查表获取动态阈值
/// </summary>
public static class ThresholdCalculator
{
/// <summary>
/// 根据RxPower获取对应的动态阈值
/// </summary>
/// <param name="rxPower">接收端功率mW</param>
/// <returns>当前功率段的阈值</returns>
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;
}
/// <summary>
/// 计算安全余量
/// </summary>
/// <param name="threshold">阈值</param>
/// <param name="ploss">计算损耗值</param>
/// <returns>安全余量(正值安全,负值危险)</returns>
public static int CalculateMargin(int threshold, int ploss)
{
return threshold - ploss;
}
/// <summary>
/// 判断是否触发FOD
/// </summary>
/// <param name="ploss">计算损耗值</param>
/// <param name="threshold">阈值</param>
/// <returns>是否触发FOD</returns>
public static bool IsFodTriggered(int ploss, int threshold)
{
return ploss > threshold;
}
}

View File

@@ -0,0 +1,43 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>12.0</LangVersion>
</PropertyGroup>
<ItemGroup>
<!-- ORM -->
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<!-- CLI -->
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
<!-- Configuration -->
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
<!-- Logging -->
<PackageReference Include="Serilog" Version="4.0.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>