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