feat(db): 扩展TestScenario维度模型
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
// 仅处理 WctMinerDbContext(ReleaseDbContext 无本次 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 无法原地 ALTER,EF 后续 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};");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>();
|
||||
|
||||
Reference in New Issue
Block a user