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

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

View File

@@ -0,0 +1,123 @@
# 文件名解析器
> 所属模块:[解析器设计](./概览.md)
---
## 1. 文件名格式
**格式约定**`TX面板-TX硬件-TX软件-RX类型-测试目的-年月日-测试次数.log`
**分隔符**:支持 `-`(短横线)和 `_`(下划线)两种分隔符,文件名解析时将同时拆分这两种字符。
> ⚠️ **注意**:字段值内部不应包含分隔符字符。例如,若 TX面板值为 `single-mold`,解析时会拆分为 `single` 和 `mold` 两个字段,导致解析失败。请确保字段值本身不含 `-` 或 `_`。
**示例**
- 标准:`singleMold-v1.0-hex2_1-iPhone15-compatibility-20260702-1.log`(字段值无分隔符)
- 避免:`single-mold-v1.0-hex2.1-iPhone15-兼容性测试-20260702-1.log``single-mold` 会拆分)
**位置说明**:表格中的"位置"采用 1-based 索引(从 1 开始计数),与代码中的 0-based 数组索引对应。
---
## 2. 字段映射
| 位置 | 字段 | 对应维度表 | 示例值 |
|------|------|------------|--------|
| 1 | TxPanel | tx_panel.name | single-mold |
| 2 | TxHardware | tx_hardware.version | v1.0 |
| 3 | TxSoftware | tx_software.version | hex2.1 |
| 4 | RxType | rx_type.name | iPhone15 |
| 5 | TestPurpose | test_scenario.test_purpose | 兼容性测试 |
| 6 | TestDate | test_scenario.test_date | 20260702 |
| 7 | TestSequence | test_scenario.test_sequence | 1 |
---
## 3. 解析器实现
```csharp
// Parsers/FileNameParser.cs
using System.Globalization;
using WCTDataMiner.Services;
namespace WCTDataMiner.Parsers;
public class FileNameParser
{
private static readonly char[] Separators = { '-', '_' };
/// <summary>
/// 解析日志文件名,提取场景信息
/// </summary>
/// <param name="fileName">文件名(不含路径)</param>
/// <returns>场景信息,若格式不匹配返回 null</returns>
public ScenarioInfo? Parse(string fileName)
{
// 移除 .log 扩展名
var nameWithoutExt = Path.GetFileNameWithoutExtension(fileName);
if (string.IsNullOrEmpty(nameWithoutExt))
return null;
var parts = nameWithoutExt.Split(Separators);
if (parts.Length < 7)
return null;
// 解析日期
if (!TryParseDate(parts[5], out var testDate))
return null;
// 解析测试序号
if (!int.TryParse(parts[6], out var testSequence))
testSequence = 1;
return new ScenarioInfo(
TxPanel: parts[0],
TxHardware: parts[1],
TxSoftware: parts[2],
RxType: parts[3],
TestPurpose: parts.Length > 4 ? parts[4] : null,
TestDate: testDate,
TestSequence: testSequence
);
}
private static bool TryParseDate(string dateStr, out DateOnly date)
{
// 支持多种日期格式
var formats = new[] { "yyyyMMdd", "yyyy-MM-dd", "yyMMdd" };
return DateOnly.TryParseExact(dateStr, formats, CultureInfo.InvariantCulture, DateTimeStyles.None, out date);
}
}
```
---
## 4. 容错处理
| 场景 | 处理方式 |
|------|----------|
| 文件名为空或 null | 返回 nullParseService 记录错误日志 |
| 文件名仅含扩展名(如 `.log` | 返回 null |
| 文件名少于 7 个字段 | 返回 null缺少必要字段 |
| 日期格式不匹配 | 返回 null无法解析 test_date |
| 测试序号解析失败 | 使用默认值 1 |
| 文件名扩展名无效(非 `.log` | 仍会解析,仅去除扩展名后尝试 |
> **说明**:由于解析器要求 `parts.Length >= 7` 才会成功返回,"测试目的缺失"不会单独发生——当字段不足时整体解析失败。若需支持可选字段,需调整解析逻辑。
---
## 5. 使用示例
```csharp
// 在 ParseService 中使用
var fileNameParser = new FileNameParser();
var scenarioInfo = fileNameParser.Parse("single-mold-v1.0-hex2.1-iPhone15-兼容性测试-20260702-1.log");
if (scenarioInfo != null)
{
var scenario = await scenarioService.GetOrCreateScenarioAsync(scenarioInfo);
// 继续解析文件内容...
}
```

View File

@@ -36,10 +36,13 @@
## 3. 正则表达式
> **修正说明**原设计稿正则只有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<PlossRecord>
public bool CanParse(string line) => PlossPattern.IsMatch(line);
public ParseResult<PlossRecord> Parse(string line, int lineNumber, Guid logFileId)
public ParseResult<PlossRecord> Parse(string line, Guid scenarioId)
{
var match = PlossPattern.Match(line);
if (!match.Success)
@@ -97,8 +100,7 @@ public class PlossParser : IParser<PlossRecord>
{
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),

View File

@@ -66,7 +66,7 @@ public class QfodParser : IParser<QfodRecord>
public bool CanParse(string line) => QfodPattern.IsMatch(line);
public ParseResult<QfodRecord> Parse(string line, int lineNumber, Guid logFileId)
public ParseResult<QfodRecord> Parse(string line, Guid scenarioId)
{
var match = QfodPattern.Match(line);
if (!match.Success)
@@ -81,8 +81,7 @@ public class QfodParser : IParser<QfodRecord>
{
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),

View File

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

View File

@@ -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<ParseService> _logger;
private readonly FileNameParser _fileNameParser;
private readonly int _batchSize;
public ParseService(
WctMinerDbContext context,
ScenarioService scenarioService,
QfodParser qfodParser,
PlossParser plossParser,
ErrorHandlingService errorService,
ILogger<ParseService> 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<ParseReport> 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<QfodRecord>();
var plossRecords = new List<PlossRecord>();
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<LogFile> 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<QfodRecord> qfodRecords,
List<PlossRecord> plossRecords,
List<ParseError> errors)
List<PlossRecord> 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);
```

View File

@@ -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