feat(db): 扩展TestScenario维度模型

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scottxjw
2026-08-25 11:29:30 +08:00
parent 3951606cfa
commit abaa9cb6d1
19 changed files with 4537 additions and 7 deletions

View File

@@ -4,6 +4,7 @@ using Gpulse.WCT.DataAnalyzer.Core.Infrastructure.LocalData;
using Gpulse.WCT.DataAnalyzer.Core.Application.Parsing;
using Gpulse.WCT.DataAnalyzer.Core.Application.Analysis;
using Gpulse.WCT.DataAnalyzer.Core.Application;
using Gpulse.WCT.DataAnalyzer.Core.Application.Exporting;
namespace Gpulse.WCT.DataAnalyzer.Core.Infrastructure.Extensions;
@@ -63,6 +64,9 @@ public static class ServiceCollectionExtensions
services.AddScoped<PlossAnalysisService>();
services.AddScoped<QfodCalibrationService>();
// Excel Export
services.AddScoped<AnalyzeExcelExporter>();
return services;
}
}

View File

@@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Gpulse.WCT.DataAnalyzer.Core.Domain.Local;
namespace Gpulse.WCT.DataAnalyzer.Core.Infrastructure.LocalData.Configurations;
public class CarModelConfiguration : IEntityTypeConfiguration<CarModel>
{
public void Configure(EntityTypeBuilder<CarModel> builder)
{
builder.ToTable("car_model");
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).HasColumnName("id");
builder.Property(e => e.Name)
.IsRequired()
.HasMaxLength(50)
.HasColumnName("name");
builder.Property(e => e.IsDeleted)
.IsRequired()
.HasDefaultValue(false)
.HasColumnName("is_deleted");
builder.Property(e => e.CreatedAt)
.IsRequired()
.HasDefaultValueSql("datetime('now')")
.HasColumnName("created_at");
builder.HasIndex(e => e.Name).IsUnique();
// 全局查询过滤器
builder.HasQueryFilter(e => !e.IsDeleted);
}
}

View File

@@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Gpulse.WCT.DataAnalyzer.Core.Domain.Local;
namespace Gpulse.WCT.DataAnalyzer.Core.Infrastructure.LocalData.Configurations;
public class ForeignObjectConfiguration : IEntityTypeConfiguration<ForeignObject>
{
public void Configure(EntityTypeBuilder<ForeignObject> builder)
{
builder.ToTable("foreign_object");
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).HasColumnName("id");
builder.Property(e => e.Name)
.IsRequired()
.HasMaxLength(50)
.HasColumnName("name");
builder.Property(e => e.IsDeleted)
.IsRequired()
.HasDefaultValue(false)
.HasColumnName("is_deleted");
builder.Property(e => e.CreatedAt)
.IsRequired()
.HasDefaultValueSql("datetime('now')")
.HasColumnName("created_at");
builder.HasIndex(e => e.Name).IsUnique();
// 全局查询过滤器
builder.HasQueryFilter(e => !e.IsDeleted);
}
}

View File

@@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Gpulse.WCT.DataAnalyzer.Core.Domain.Local;
namespace Gpulse.WCT.DataAnalyzer.Core.Infrastructure.LocalData.Configurations;
public class ManufacturerConfiguration : IEntityTypeConfiguration<Manufacturer>
{
public void Configure(EntityTypeBuilder<Manufacturer> builder)
{
builder.ToTable("manufacturer");
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).HasColumnName("id");
builder.Property(e => e.Name)
.IsRequired()
.HasMaxLength(100)
.HasColumnName("name");
builder.Property(e => e.IsDeleted)
.IsRequired()
.HasDefaultValue(false)
.HasColumnName("is_deleted");
builder.Property(e => e.CreatedAt)
.IsRequired()
.HasDefaultValueSql("datetime('now')")
.HasColumnName("created_at");
builder.HasIndex(e => e.Name).IsUnique();
// 全局查询过滤器
builder.HasQueryFilter(e => !e.IsDeleted);
}
}

View File

@@ -13,6 +13,15 @@ public class TestScenarioConfiguration : IEntityTypeConfiguration<TestScenario>
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).HasColumnName("id");
// 新增维度字段
builder.Property(e => e.ManufacturerId)
.IsRequired()
.HasColumnName("manufacturer_id");
builder.Property(e => e.CarModelId)
.IsRequired()
.HasColumnName("car_model_id");
builder.Property(e => e.TxPanelId)
.IsRequired()
.HasColumnName("tx_panel_id");
@@ -29,6 +38,10 @@ public class TestScenarioConfiguration : IEntityTypeConfiguration<TestScenario>
.IsRequired()
.HasColumnName("rx_type_id");
builder.Property(e => e.ForeignObjectId)
.IsRequired()
.HasColumnName("foreign_object_id");
builder.Property(e => e.TestPurpose)
.HasMaxLength(200)
.HasColumnName("test_purpose");
@@ -63,18 +76,31 @@ public class TestScenarioConfiguration : IEntityTypeConfiguration<TestScenario>
.HasColumnName("created_at");
// 索引
builder.HasIndex(e => e.ManufacturerId).HasDatabaseName("idx_scenario_manufacturer");
builder.HasIndex(e => e.CarModelId).HasDatabaseName("idx_scenario_car_model");
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.ForeignObjectId).HasDatabaseName("idx_scenario_foreign_object");
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 })
// 唯一约束 - 包含所有维度字段
builder.HasIndex(e => new { e.ManufacturerId, e.CarModelId, e.TxPanelId, e.TxHardwareId, e.TxSoftwareId, e.RxTypeId, e.ForeignObjectId, e.TestPurpose, e.TestDate, e.TestSequence })
.IsUnique()
.HasDatabaseName("uq_scenario");
// 关系配置 - 维度表
// 关系配置 - 新增维度表
builder.HasOne(e => e.Manufacturer)
.WithMany(e => e.TestScenarios)
.HasForeignKey(e => e.ManufacturerId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(e => e.CarModel)
.WithMany(e => e.TestScenarios)
.HasForeignKey(e => e.CarModelId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(e => e.TxPanel)
.WithMany(e => e.TestScenarios)
.HasForeignKey(e => e.TxPanelId)
@@ -95,6 +121,11 @@ public class TestScenarioConfiguration : IEntityTypeConfiguration<TestScenario>
.HasForeignKey(e => e.RxTypeId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(e => e.ForeignObject)
.WithMany(e => e.TestScenarios)
.HasForeignKey(e => e.ForeignObjectId)
.OnDelete(DeleteBehavior.Restrict);
// 关系配置 - 数据记录
builder.HasMany(e => e.QfodRecords)
.WithOne(e => e.Scenario)
@@ -109,4 +140,4 @@ public class TestScenarioConfiguration : IEntityTypeConfiguration<TestScenario>
// 全局查询过滤器
builder.HasQueryFilter(e => !e.IsDeleted);
}
}
}

View File

@@ -0,0 +1,186 @@
using Microsoft.EntityFrameworkCore;
using System.Linq;
namespace Gpulse.WCT.DataAnalyzer.Core.Infrastructure.LocalData;
/// <summary>
/// 数据库迁移初始化器。
/// 替代直接调用 <c>EnsureCreated()</c>,统一处理两类场景:
/// <list type="bullet">
/// <item>全新库EF <c>MigrateAsync()</c> 按 InitialCreate 建全部表。</item>
/// <item>既有 <c>EnsureCreated()</c> 库(无 <c>__EFMigrationsHistory</c>,但表已存在):
/// 若直接 <c>MigrateAsync()</c> 会因 <c>CREATE TABLE</c> 撞既有表而失败,
/// 故先检测既有表并补齐本变更新增的 3 张维度表 + <c>test_scenario</c> 的 3 列 NOT NULL FK
/// 再把 InitialCreate 标记为已应用baseline使后续迁移可增量执行。</item>
/// </list>
/// </summary>
public static class DatabaseMigrationInitializer
{
/// <summary>
/// 迁移或升级既有库。对 <typeparamref name="TContext"/> 的库执行迁移;
/// 若检测到既有 EnsureCreated 库(无迁移历史但有表),执行基线升级后标记迁移已应用。
/// </summary>
public static async Task MigrateOrUpgradeAsync<TContext>(TContext context)
where TContext : DbContext
{
var database = context.Database;
// 既有 EnsureCreated 库:无迁移历史表,但已有业务表 → 视为已建表但未迁移。
if (await IsLegacyEnsureCreatedDatabaseAsync(context))
{
await UpgradeLegacyDatabaseAsync(context);
await MarkAllMigrationsAsAppliedAsync(context);
}
// 常规迁移:全新库直接建表;已基线标记的既有库跳过已应用迁移。
await database.MigrateAsync();
}
/// <summary>
/// 判断是否为既有的 EnsureCreated 库:无 __EFMigrationsHistory 表,且至少有一张业务表。
/// </summary>
private static async Task<bool> IsLegacyEnsureCreatedDatabaseAsync<TContext>(TContext context)
where TContext : DbContext
{
// 库不存在视为全新库,交给 MigrateAsync 建表
if (!await context.Database.CanConnectAsync())
return false;
// 存在迁移历史表 → 非 legacy常规 Migrate
var historyCount = await context.Database.SqlQueryRaw<long>(
"SELECT count(*) AS Value FROM sqlite_master WHERE type='table' AND name='__EFMigrationsHistory'").FirstOrDefaultAsync();
if (historyCount > 0)
return false;
// 无历史表但有任意业务表(非 __EFMigrationsHistory→ EnsureCreated 既有库
var tableCount = await context.Database.SqlQueryRaw<long>(
"SELECT count(*) AS Value FROM sqlite_master WHERE type='table' AND name NOT LIKE '__EF%'").FirstOrDefaultAsync();
return tableCount > 0;
}
/// <summary>
/// 升级既有库:补齐本变更新增的维度表与 test_scenario 的 NOT NULL FK 列。
/// 按 "可空加列 → 回填默认维度行 → 改 NOT NULL → 重建唯一索引" 顺序,
/// 兼容 SQLite 无法原地 ALTER COLUMN 的限制(新建默认维度行后回填既有场景)。
/// </summary>
private static async Task UpgradeLegacyDatabaseAsync<TContext>(TContext context)
where TContext : DbContext
{
var db = context.Database;
// 仅处理 WctMinerDbContextReleaseDbContext 无本次 schema 变更)
if (context is not WctMinerDbContext)
return;
// 1. 若已升级(存在 foreign_object 表),直接返回,避免重复
var alreadyUpgraded = await TableExistsAsync(context, "foreign_object");
if (alreadyUpgraded)
return;
// 2. 建三张维度表(结构照搬 ManufacturerConfiguration 等id/name/is_deleted/created_at + 唯一索引)
await db.ExecuteSqlRawAsync(@"
CREATE TABLE IF NOT EXISTS manufacturer (
id TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL,
is_deleted INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_manufacturer_name ON manufacturer (name) WHERE is_deleted = 0;
CREATE TABLE IF NOT EXISTS car_model (
id TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL,
is_deleted INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_car_model_name ON car_model (name) WHERE is_deleted = 0;
CREATE TABLE IF NOT EXISTS foreign_object (
id TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL,
is_deleted INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_foreign_object_name ON foreign_object (name) WHERE is_deleted = 0;
");
// 3. 给 test_scenario 加可空 FK 列(若已存在则跳过 —— SQLite 不报错时静默)
await AddColumnIfNotExistsAsync(context, "test_scenario", "manufacturer_id", "TEXT");
await AddColumnIfNotExistsAsync(context, "test_scenario", "car_model_id", "TEXT");
await AddColumnIfNotExistsAsync(context, "test_scenario", "foreign_object_id", "TEXT");
// 4. 插入默认维度行("未知")并回填既有 test_scenario 行
await db.ExecuteSqlRawAsync(@"
INSERT OR IGNORE INTO manufacturer (id, name) VALUES (lower(hex(randomblob(16))), '未知');
INSERT OR IGNORE INTO car_model (id, name) VALUES (lower(hex(randomblob(16))), '未知');
INSERT OR IGNORE INTO foreign_object (id, name) VALUES (lower(hex(randomblob(16))), '未知');
UPDATE test_scenario
SET manufacturer_id = (SELECT id FROM manufacturer WHERE name = '未知')
WHERE manufacturer_id IS NULL;
UPDATE test_scenario
SET car_model_id = (SELECT id FROM car_model WHERE name = '未知')
WHERE car_model_id IS NULL;
UPDATE test_scenario
SET foreign_object_id = (SELECT id FROM foreign_object WHERE name = '未知')
WHERE foreign_object_id IS NULL;
");
// 5. 重建唯一索引 uq_scenario 以包含新列(先删后建;既有索引名可能存在)
await db.ExecuteSqlRawAsync("DROP INDEX IF EXISTS uq_scenario;");
await db.ExecuteSqlRawAsync(@"
CREATE UNIQUE INDEX uq_scenario ON test_scenario (
manufacturer_id, car_model_id, tx_panel_id, tx_hardware_id, tx_software_id,
rx_type_id, foreign_object_id, test_purpose, test_date, test_sequence
) WHERE is_deleted = 0;
");
// 6. NOT NULL 约束SQLite 无法原地 ALTEREF 后续 Migrate 的 baseline 会跳过列重建,
// 此处通过重建 test_scenario 表加约束成本高且风险大;既有行已全部回填非空,
// 保留可空状态不影响查询正确性,由后续迁移统一收口。
// (如需严格 NOT NULL需 SQLite 表重建,此处留注释说明取舍。)
}
/// <summary>把当前 context 的所有迁移标记为已应用(写入迁移历史表,跳过执行)。</summary>
private static async Task MarkAllMigrationsAsAppliedAsync<TContext>(TContext context)
where TContext : DbContext
{
var database = context.Database;
// 确保迁移历史表存在(既有 EnsureCreated 库尚无此表),否则 GetPendingMigrationsAsync 会抛
// "no such table: __EFMigrationsHistory"。
await database.ExecuteSqlRawAsync(@"
CREATE TABLE IF NOT EXISTS ""__EFMigrationsHistory"" (
""MigrationId"" TEXT NOT NULL CONSTRAINT ""PK___EFMigrationsHistory"" PRIMARY KEY,
""ProductVersion"" TEXT NOT NULL
);
");
// 列出模型定义的全部迁移,逐条写入历史表(视为已应用),使后续 Migrate 跳过它们。
var migrations = database.GetMigrations().ToList();
var productVersion = typeof(DbContext).Assembly.GetName().Version?.ToString() ?? "8.0.0";
foreach (var migration in migrations)
{
await database.ExecuteSqlRawAsync(
"INSERT OR IGNORE INTO __EFMigrationsHistory (MigrationId, ProductVersion) VALUES ({0}, {1})",
migration, productVersion);
}
}
private static async Task<bool> TableExistsAsync<TContext>(TContext context, string tableName)
where TContext : DbContext
{
var count = await context.Database.SqlQueryRaw<long>(
$"SELECT count(*) AS Value FROM sqlite_master WHERE type='table' AND name = '{tableName}'").FirstOrDefaultAsync();
return count > 0;
}
private static async Task AddColumnIfNotExistsAsync<TContext>(TContext context, string table, string column, string type)
where TContext : DbContext
{
var exists = (await context.Database.SqlQueryRaw<long>(
$"SELECT count(*) AS Value FROM pragma_table_info('{table}') WHERE name = '{column}'").FirstOrDefaultAsync()) > 0;
if (!exists)
await context.Database.ExecuteSqlRawAsync($"ALTER TABLE {table} ADD COLUMN {column} {type};");
}
}

View File

@@ -45,9 +45,8 @@ public class DbContextFactory : IDbContextFactory
var context = new WctMinerDbContext(optionsBuilder.Options);
// 确保数据库和表结构已创建(适用于 SQLite
context.Database.EnsureCreated();
// 迁移Migrate只在启动期由 Program.Main / CliTestHost.CreateAsync 触发,
// 不在每次 scope 解析时执行(避免每条命令都跑迁移)。
return context;
}

View File

@@ -12,10 +12,13 @@ public class WctMinerDbContext : DbContext
: base(options) { }
// 维度表
public DbSet<Manufacturer> Manufacturers => Set<Manufacturer>();
public DbSet<CarModel> CarModels => Set<CarModel>();
public DbSet<TxPanel> TxPanels => Set<TxPanel>();
public DbSet<TxHardware> TxHardwares => Set<TxHardware>();
public DbSet<TxSoftware> TxSoftwares => Set<TxSoftware>();
public DbSet<RxType> RxTypes => Set<RxType>();
public DbSet<ForeignObject> ForeignObjects => Set<ForeignObject>();
// 事实表
public DbSet<TestScenario> TestScenarios => Set<TestScenario>();