refactor(core): 重构核心分层结构并添加发布库聚合

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scottxjw
2026-08-13 15:00:39 +08:00
parent 6e197a0cce
commit a3100c73c5
45 changed files with 501 additions and 77 deletions

View File

@@ -0,0 +1,46 @@
namespace Gpulse.WCT.DataAnalyzer.Core.Infrastructure.Configuration;
/// <summary>
/// 应用配置
/// </summary>
public class AppSettings
{
public DatabaseSettings Database { get; set; } = new();
public ParserSettings Parser { get; set; } = new();
public PathSettings Paths { get; set; } = new();
}
/// <summary>
/// 数据库配置
/// </summary>
public class DatabaseSettings
{
public string Type { get; set; } = "sqlite";
public string Path { get; set; } = "./data/database/local.db";
public string LocalPath { get; set; } = "./data/database/local.db";
public string ReleasePath { get; set; } = "./data/database/release.db";
// PostgreSQL 配置(可选)
public string? Host { get; set; }
public int Port { get; set; } = 5432;
public string? Name { get; set; }
public string? User { get; set; }
public string? Password { get; set; }
}
/// <summary>
/// 解析器配置
/// </summary>
public class ParserSettings
{
public int BatchSize { get; set; } = 1000;
}
/// <summary>
/// 路径配置
/// </summary>
public class PathSettings
{
public string LogDir { get; set; } = "./data/logs";
public string OutputDir { get; set; } = "./data/output";
}

View File

@@ -0,0 +1,64 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Gpulse.WCT.DataAnalyzer.Core.Infrastructure.LocalData;
using Gpulse.WCT.DataAnalyzer.Core.Application.Parsing;
using Gpulse.WCT.DataAnalyzer.Core.Application;
namespace Gpulse.WCT.DataAnalyzer.Core.Infrastructure.Extensions;
/// <summary>
/// 服务注册扩展
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// 注册数据库服务
/// </summary>
public static IServiceCollection AddDatabaseServices(
this IServiceCollection services,
IConfiguration configuration)
{
// 注册 DbContext 工厂
services.AddScoped<IDbContextFactory, DbContextFactory>();
// 注册 DbContext通过工厂创建
services.AddScoped<WctMinerDbContext>(sp =>
{
var factory = sp.GetRequiredService<IDbContextFactory>();
return factory.CreateDbContext();
});
services.AddScoped<IReleaseDbContextFactory, ReleaseDbContextFactory>();
// 注册正式发布数据库上下文(与本地解析数据库完全独立)
services.AddScoped<ReleaseDbContext>(sp =>
{
var factory = sp.GetRequiredService<IReleaseDbContextFactory>();
return factory.CreateDbContext();
});
return services;
}
/// <summary>
/// 注册应用服务
/// </summary>
public static IServiceCollection AddApplicationServices(this IServiceCollection services)
{
// Parsers
services.AddScoped<QfodParser>();
services.AddScoped<PlossParser>();
services.AddSingleton<FileNameParser>();
// Services
services.AddScoped<DimensionService>();
services.AddScoped<ScenarioService>();
services.AddScoped<ParseService>();
services.AddScoped<StatsService>();
services.AddScoped<ExportService>();
services.AddScoped<CleanService>();
services.AddScoped<AggregationService>();
return services;
}
}

View File

@@ -0,0 +1,130 @@
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 PlossRecordConfiguration : IEntityTypeConfiguration<PlossRecord>
{
public void Configure(EntityTypeBuilder<PlossRecord> builder)
{
builder.ToTable("ploss_record");
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).HasColumnName("id");
builder.Property(e => e.ScenarioId)
.IsRequired()
.HasColumnName("scenario_id");
// ===== 第一行字段nullable =====
builder.Property(e => e.PowLoss)
.IsRequired(false)
.HasColumnName("pow_loss");
builder.Property(e => e.DeltaP)
.IsRequired(false)
.HasColumnName("delta_p");
// ===== 第二行字段(通用命名) =====
builder.Property(e => e.Field1)
.IsRequired()
.HasColumnName("field_1");
builder.Property(e => e.Field2)
.IsRequired()
.HasColumnName("field_2");
builder.Property(e => e.Field3)
.IsRequired()
.HasColumnName("field_3");
builder.Property(e => e.Field4)
.IsRequired()
.HasColumnName("field_4");
builder.Property(e => e.Field5)
.IsRequired()
.HasColumnName("field_5");
builder.Property(e => e.Field6)
.IsRequired()
.HasColumnName("field_6");
builder.Property(e => e.Field7)
.IsRequired()
.HasColumnName("field_7");
builder.Property(e => e.Field8)
.IsRequired()
.HasColumnName("field_8");
builder.Property(e => e.Field9)
.IsRequired()
.HasDefaultValue(0)
.HasColumnName("field_9");
builder.Property(e => e.Field10)
.IsRequired()
.HasDefaultValue(0)
.HasColumnName("field_10");
builder.Property(e => e.Field11)
.IsRequired()
.HasColumnName("field_11");
builder.Property(e => e.Field12)
.IsRequired()
.HasColumnName("field_12");
builder.Property(e => e.Field13)
.IsRequired()
.HasColumnName("field_13");
builder.Property(e => e.Field14)
.IsRequired()
.HasDefaultValue(0)
.HasColumnName("field_14");
builder.Property(e => e.Field15)
.IsRequired()
.HasDefaultValue(0)
.HasColumnName("field_15");
builder.Property(e => e.Field16)
.IsRequired()
.HasDefaultValue(0)
.HasColumnName("field_16");
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.Field2).HasDatabaseName("idx_ploss_field_2"); // 原 rx_power
builder.HasIndex(e => e.Field7).HasDatabaseName("idx_ploss_field_7"); // 原 ploss
builder.HasIndex(e => e.Field8).HasDatabaseName("idx_ploss_field_8"); // 原 threshold
builder.HasIndex(e => e.Field10).HasDatabaseName("idx_ploss_field_10"); // 原 fod_result
builder.HasIndex(e => e.PowLoss).HasDatabaseName("idx_ploss_pow_loss"); // 新增
builder.HasIndex(e => e.DeltaP).HasDatabaseName("idx_ploss_delta_p"); // 新增
// 关系配置
builder.HasOne(e => e.Scenario)
.WithMany(e => e.PlossRecords)
.HasForeignKey(e => e.ScenarioId)
.OnDelete(DeleteBehavior.Cascade);
// 全局查询过滤器
builder.HasQueryFilter(e => !e.IsDeleted);
}
}

View File

@@ -0,0 +1,78 @@
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 QfodRecordConfiguration : IEntityTypeConfiguration<QfodRecord>
{
public void Configure(EntityTypeBuilder<QfodRecord> builder)
{
builder.ToTable("qfod_record", t =>
{
t.HasCheckConstraint("chk_qfod_charger", "charger_index IN (0, 1)");
t.HasCheckConstraint("chk_qfod_coil", "coil_index IN (0, 1, 2)");
});
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).HasColumnName("id");
builder.Property(e => e.ScenarioId)
.IsRequired()
.HasColumnName("scenario_id");
builder.Property(e => e.ChargerIndex)
.IsRequired()
.HasColumnName("charger_index");
builder.Property(e => e.CoilIndex)
.IsRequired()
.HasColumnName("coil_index");
builder.Property(e => e.DeltaQ)
.IsRequired()
.HasColumnName("delta_q");
builder.Property(e => e.CurrentQ)
.IsRequired()
.HasColumnName("current_q");
builder.Property(e => e.RawQ)
.IsRequired()
.HasColumnName("raw_q");
builder.Property(e => e.FodType)
.IsRequired()
.HasColumnName("fod_type");
builder.Property(e => e.FieldF)
.IsRequired()
.HasDefaultValue((byte)0)
.HasColumnName("field_f");
builder.Property(e => e.IsDeleted)
.IsRequired()
.HasDefaultValue(false)
.HasColumnName("is_deleted");
builder.Property(e => e.CreatedAt)
.IsRequired()
.HasDefaultValueSql("datetime('now')")
.HasColumnName("created_at");
// 索引
builder.HasIndex(e => e.ScenarioId).HasDatabaseName("idx_qfod_scenario");
builder.HasIndex(e => new { e.ChargerIndex, e.CoilIndex }).HasDatabaseName("idx_qfod_charger_coil");
builder.HasIndex(e => e.FodType).HasDatabaseName("idx_qfod_fod_type");
builder.HasIndex(e => e.DeltaQ).HasDatabaseName("idx_qfod_delta_q");
// 关系配置
builder.HasOne(e => e.Scenario)
.WithMany(e => e.QfodRecords)
.HasForeignKey(e => e.ScenarioId)
.OnDelete(DeleteBehavior.Cascade);
// 全局查询过滤器
builder.HasQueryFilter(e => !e.IsDeleted);
}
}

View File

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

View File

@@ -0,0 +1,112 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Gpulse.WCT.DataAnalyzer.Core.Domain.Local;
namespace Gpulse.WCT.DataAnalyzer.Core.Infrastructure.LocalData.Configurations;
public class TestScenarioConfiguration : IEntityTypeConfiguration<TestScenario>
{
public void Configure(EntityTypeBuilder<TestScenario> builder)
{
builder.ToTable("test_scenario");
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).HasColumnName("id");
builder.Property(e => e.TxPanelId)
.IsRequired()
.HasColumnName("tx_panel_id");
builder.Property(e => e.TxHardwareId)
.IsRequired()
.HasColumnName("tx_hardware_id");
builder.Property(e => e.TxSoftwareId)
.IsRequired()
.HasColumnName("tx_software_id");
builder.Property(e => e.RxTypeId)
.IsRequired()
.HasColumnName("rx_type_id");
builder.Property(e => e.TestPurpose)
.HasMaxLength(200)
.HasColumnName("test_purpose");
builder.Property(e => e.TestDate)
.IsRequired()
.HasColumnName("test_date");
builder.Property(e => e.TestSequence)
.IsRequired()
.HasDefaultValue(1)
.HasColumnName("test_sequence");
builder.Property(e => e.QfodCount)
.IsRequired()
.HasDefaultValue(0)
.HasColumnName("qfod_count");
builder.Property(e => e.PlossCount)
.IsRequired()
.HasDefaultValue(0)
.HasColumnName("ploss_count");
builder.Property(e => e.IsDeleted)
.IsRequired()
.HasDefaultValue(false)
.HasColumnName("is_deleted");
builder.Property(e => e.CreatedAt)
.IsRequired()
.HasDefaultValueSql("datetime('now')")
.HasColumnName("created_at");
// 索引
builder.HasIndex(e => e.TxPanelId).HasDatabaseName("idx_scenario_tx_panel");
builder.HasIndex(e => e.TxHardwareId).HasDatabaseName("idx_scenario_tx_hardware");
builder.HasIndex(e => e.TxSoftwareId).HasDatabaseName("idx_scenario_tx_software");
builder.HasIndex(e => e.RxTypeId).HasDatabaseName("idx_scenario_rx_type");
builder.HasIndex(e => e.TestDate).HasDatabaseName("idx_scenario_test_date");
// 唯一约束
builder.HasIndex(e => new { e.TxPanelId, e.TxHardwareId, e.TxSoftwareId, e.RxTypeId, e.TestPurpose, e.TestDate, e.TestSequence })
.IsUnique()
.HasDatabaseName("uq_scenario");
// 关系配置 - 维度表
builder.HasOne(e => e.TxPanel)
.WithMany(e => e.TestScenarios)
.HasForeignKey(e => e.TxPanelId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(e => e.TxHardware)
.WithMany(e => e.TestScenarios)
.HasForeignKey(e => e.TxHardwareId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(e => e.TxSoftware)
.WithMany(e => e.TestScenarios)
.HasForeignKey(e => e.TxSoftwareId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(e => e.RxType)
.WithMany(e => e.TestScenarios)
.HasForeignKey(e => e.RxTypeId)
.OnDelete(DeleteBehavior.Restrict);
// 关系配置 - 数据记录
builder.HasMany(e => e.QfodRecords)
.WithOne(e => e.Scenario)
.HasForeignKey(e => e.ScenarioId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(e => e.PlossRecords)
.WithOne(e => e.Scenario)
.HasForeignKey(e => e.ScenarioId)
.OnDelete(DeleteBehavior.Cascade);
// 全局查询过滤器
builder.HasQueryFilter(e => !e.IsDeleted);
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,73 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
namespace Gpulse.WCT.DataAnalyzer.Core.Infrastructure.LocalData;
/// <summary>
/// 数据库上下文工厂实现 - 支持SQLite和PostgreSQL切换
/// </summary>
public class DbContextFactory : IDbContextFactory
{
private readonly IConfiguration _configuration;
public DbContextFactory(IConfiguration configuration)
{
_configuration = configuration;
}
public WctMinerDbContext CreateDbContext()
{
var dbType = _configuration["Database:Type"]?.ToLower() ?? "sqlite";
var optionsBuilder = new DbContextOptionsBuilder<WctMinerDbContext>();
switch (dbType)
{
case "postgresql":
case "postgres":
var pgConnStr = BuildPostgreSqlConnectionString();
optionsBuilder.UseNpgsql(pgConnStr);
break;
case "sqlite":
default:
var dbPath = _configuration["Database:LocalPath"]
?? _configuration["Database:Path"]
?? "./data/database/local.db";
EnsureDirectoryExists(dbPath);
optionsBuilder.UseSqlite($"Data Source={dbPath}");
break;
}
#if DEBUG
optionsBuilder.EnableSensitiveDataLogging();
optionsBuilder.EnableDetailedErrors();
#endif
var context = new WctMinerDbContext(optionsBuilder.Options);
// 确保数据库和表结构已创建(适用于 SQLite
context.Database.EnsureCreated();
return context;
}
private string BuildPostgreSqlConnectionString()
{
var host = _configuration["Database:Host"] ?? "localhost";
var port = _configuration.GetValue("Database:Port", 5432);
var name = _configuration["Database:Name"] ?? "wctminer";
var user = _configuration["Database:User"] ?? "postgres";
var password = _configuration["Database:Password"] ?? "";
return $"Host={host};Port={port};Database={name};Username={user};Password={password}";
}
private static void EnsureDirectoryExists(string dbPath)
{
var directory = Path.GetDirectoryName(dbPath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
}
}

View File

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

View File

@@ -0,0 +1,36 @@
using Gpulse.WCT.DataAnalyzer.Core.Domain.Local;
namespace Gpulse.WCT.DataAnalyzer.Core.Infrastructure.LocalData;
/// <summary>
/// 初始数据种子
/// </summary>
public static class SeedData
{
/// <summary>
/// TX面板类型预设数据
/// </summary>
public static readonly TxPanel[] DefaultTxPanels =
[
new() { Name = "none" },
new() { Name = "single-mold" },
new() { Name = "single-rapid" },
new() { Name = "single-3d" },
new() { Name = "dual-mold" },
new() { Name = "dual-rapid" },
new() { Name = "dual-3d" }
];
/// <summary>
/// 初始化种子数据
/// </summary>
public static void Initialize(WctMinerDbContext context)
{
// 确保 TxPanel 表有预设数据
if (!context.TxPanels.Any())
{
context.TxPanels.AddRange(DefaultTxPanels);
context.SaveChanges();
}
}
}

View File

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

View File

@@ -0,0 +1,57 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Gpulse.WCT.DataAnalyzer.Core.Domain.Release;
namespace Gpulse.WCT.DataAnalyzer.Core.Infrastructure.LocalData.Configurations;
public class ChargingParameterRecordConfiguration : IEntityTypeConfiguration<ChargingParameterRecord>
{
public void Configure(EntityTypeBuilder<ChargingParameterRecord> builder)
{
builder.ToTable("charging_parameter");
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).HasColumnName("id");
ConfigureText(builder, e => e.CarFactory, "car_factory");
ConfigureText(builder, e => e.CarModel, "car_model");
ConfigureText(builder, e => e.PhoneBrand, "phone_brand");
ConfigureText(builder, e => e.PhoneModel, "phone_model");
ConfigureNumber(builder, e => e.Power350mW, "power_350mw");
ConfigureNumber(builder, e => e.Power500mW, "power_500mw");
ConfigureNumber(builder, e => e.Power750mW, "power_750mw");
ConfigureNumber(builder, e => e.Power1000mW, "power_1000mw");
ConfigureNumber(builder, e => e.Power1250mW, "power_1250mw");
ConfigureNumber(builder, e => e.Power1500mW, "power_1500mw");
ConfigureNumber(builder, e => e.Power1750mW, "power_1750mw");
ConfigureNumber(builder, e => e.Power2000mW, "power_2000mw");
ConfigureNumber(builder, e => e.Power2250mW, "power_2250mw");
ConfigureNumber(builder, e => e.QValue, "q_value");
ConfigureNumber(builder, e => e.QBaseValue, "q_base_value");
ConfigureNumber(builder, e => e.PqCoefficient, "pq_coefficient");
ConfigureNumber(builder, e => e.ResonanceFrequency, "resonance_frequency");
builder.Property(e => e.CreatedAt).IsRequired().HasColumnName("created_at");
builder.Property(e => e.UpdatedAt).IsRequired().HasColumnName("updated_at");
builder.HasIndex(e => new { e.CarFactory, e.CarModel, e.PhoneBrand, e.PhoneModel })
.IsUnique()
.HasDatabaseName("uq_charging_parameter_business_key");
}
private static void ConfigureText(
EntityTypeBuilder<ChargingParameterRecord> builder,
System.Linq.Expressions.Expression<Func<ChargingParameterRecord, string>> property,
string columnName)
{
builder.Property(property).IsRequired().HasMaxLength(128).HasColumnName(columnName);
}
private static void ConfigureNumber(
EntityTypeBuilder<ChargingParameterRecord> builder,
System.Linq.Expressions.Expression<Func<ChargingParameterRecord, double?>> property,
string columnName)
{
builder.Property(property).IsRequired(false).HasColumnName(columnName).HasPrecision(18, 6);
}
}

View File

@@ -0,0 +1,6 @@
namespace Gpulse.WCT.DataAnalyzer.Core.Infrastructure.LocalData;
public interface IReleaseDbContextFactory
{
ReleaseDbContext CreateDbContext();
}

View File

@@ -0,0 +1,21 @@
using Microsoft.EntityFrameworkCore;
using Gpulse.WCT.DataAnalyzer.Core.Domain.Release;
namespace Gpulse.WCT.DataAnalyzer.Core.Infrastructure.LocalData;
/// <summary>
/// 正式发布数据库上下文。只包含最终充电参数记录。
/// </summary>
public class ReleaseDbContext : DbContext
{
public ReleaseDbContext(DbContextOptions<ReleaseDbContext> options)
: base(options) { }
public DbSet<ChargingParameterRecord> ChargingParameters => Set<ChargingParameterRecord>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ApplyConfigurationsFromAssembly(typeof(ReleaseDbContext).Assembly);
}
}

View File

@@ -0,0 +1,58 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
namespace Gpulse.WCT.DataAnalyzer.Core.Infrastructure.LocalData;
/// <summary>
/// 正式发布数据库上下文工厂。
/// </summary>
public class ReleaseDbContextFactory : IReleaseDbContextFactory
{
private readonly IConfiguration _configuration;
public ReleaseDbContextFactory(IConfiguration configuration)
{
_configuration = configuration;
}
public ReleaseDbContext CreateDbContext()
{
var dbType = _configuration["Database:Type"]?.ToLowerInvariant() ?? "sqlite";
var optionsBuilder = new DbContextOptionsBuilder<ReleaseDbContext>();
if (dbType is "postgresql" or "postgres")
{
optionsBuilder.UseNpgsql(BuildPostgreSqlConnectionString());
}
else
{
var dbPath = _configuration["Database:ReleasePath"] ?? "./data/database/release.db";
EnsureDirectoryExists(dbPath);
optionsBuilder.UseSqlite($"Data Source={dbPath}");
}
#if DEBUG
optionsBuilder.EnableSensitiveDataLogging();
optionsBuilder.EnableDetailedErrors();
#endif
return new ReleaseDbContext(optionsBuilder.Options);
}
private string BuildPostgreSqlConnectionString()
{
var host = _configuration["Database:ReleaseHost"] ?? _configuration["Database:Host"] ?? "localhost";
var port = _configuration.GetValue("Database:ReleasePort", _configuration.GetValue("Database:Port", 5432));
var name = _configuration["Database:ReleaseName"] ?? "wct_charging_parameters";
var user = _configuration["Database:ReleaseUser"] ?? _configuration["Database:User"] ?? "postgres";
var password = _configuration["Database:ReleasePassword"] ?? _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.CreateDirectory(directory);
}
}

View File

@@ -0,0 +1,157 @@
using System.Text.RegularExpressions;
namespace Gpulse.WCT.DataAnalyzer.Core.Infrastructure.Security;
/// <summary>
/// 路径验证结果
/// </summary>
public class PathValidationResult
{
public bool IsValid { get; }
public string? NormalizedPath { get; }
public string? ErrorMessage { get; }
private PathValidationResult(bool isValid, string? normalizedPath, string? errorMessage)
{
IsValid = isValid;
NormalizedPath = normalizedPath;
ErrorMessage = errorMessage;
}
public static PathValidationResult Success(string normalizedPath)
=> new(true, normalizedPath, null);
public static PathValidationResult Fail(string errorMessage)
=> new(false, null, errorMessage);
}
/// <summary>
/// 路径验证器 - 防止路径遍历攻击
/// </summary>
public class PathValidator
{
private readonly string _applicationBasePath;
private readonly HashSet<string> _allowedDirectories;
/// <summary>
/// 默认允许的输出目录
/// </summary>
private static readonly string[] DefaultAllowedDirectories = ["exports", "output", "data/exports", "data/output"];
public PathValidator(string applicationBasePath, IEnumerable<string>? allowedDirectories = null)
{
_applicationBasePath = Path.GetFullPath(applicationBasePath);
_allowedDirectories = new HashSet<string>(
(allowedDirectories ?? DefaultAllowedDirectories).Select(d => d.Replace('/', Path.DirectorySeparatorChar)),
StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// 验证输出目录路径
/// </summary>
/// <param name="outputDir">用户输入的输出目录路径</param>
/// <returns>验证结果</returns>
public PathValidationResult ValidateOutputDirectory(string outputDir)
{
// 1. 基础验证
if (string.IsNullOrWhiteSpace(outputDir))
return PathValidationResult.Fail("输出目录不能为空");
// 2. 禁止的模式检查
var forbiddenPatterns = new[]
{
"..", // 路径遍历
"~", // 用户主目录
"\\\\", // UNC 路径
};
foreach (var pattern in forbiddenPatterns)
{
if (outputDir.Contains(pattern))
{
return PathValidationResult.Fail($"路径包含禁止的模式: {pattern}");
}
}
// 3. 检查是否为绝对路径Windows 和 Unix
if (Path.IsPathRooted(outputDir) ||
Regex.IsMatch(outputDir, @"^[A-Za-z]:") || // Windows 驱动器
outputDir.StartsWith("/")) // Unix 绝对路径
{
return PathValidationResult.Fail("仅允许相对路径");
}
// 4. 规范化并验证
string normalizedPath;
try
{
normalizedPath = Path.GetFullPath(Path.Combine(_applicationBasePath, outputDir));
}
catch (Exception ex)
{
return PathValidationResult.Fail($"路径格式无效: {ex.Message}");
}
// 5. 严格验证必须在应用目录内
if (!normalizedPath.StartsWith(_applicationBasePath, StringComparison.OrdinalIgnoreCase))
{
return PathValidationResult.Fail("路径必须在应用程序目录内");
}
// 6. 白名单检查
var relative = normalizedPath
.Substring(_applicationBasePath.Length)
.TrimStart(Path.DirectorySeparatorChar);
bool inAllowedDir = _allowedDirectories
.Any(allowed => relative.StartsWith(allowed + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) ||
string.Equals(relative, allowed, StringComparison.OrdinalIgnoreCase));
if (!inAllowedDir)
{
var allowedList = string.Join(", ", _allowedDirectories);
return PathValidationResult.Fail($"输出目录必须在允许列表中: {allowedList}");
}
return PathValidationResult.Success(normalizedPath);
}
/// <summary>
/// 简化版验证 - 仅检查路径安全性,不做白名单检查
/// </summary>
public PathValidationResult ValidatePathSafety(string path)
{
if (string.IsNullOrWhiteSpace(path))
return PathValidationResult.Fail("路径不能为空");
// 检查路径遍历
if (path.Contains(".."))
return PathValidationResult.Fail("路径包含非法的遍历字符");
// 检查绝对路径
if (Path.IsPathRooted(path) ||
Regex.IsMatch(path, @"^[A-Za-z]:") ||
path.StartsWith("/"))
{
return PathValidationResult.Fail("仅允许相对路径");
}
// 检查 UNC 路径
if (path.StartsWith("\\\\"))
return PathValidationResult.Fail("不允许 UNC 路径");
// 规范化验证
try
{
var normalized = Path.GetFullPath(Path.Combine(_applicationBasePath, path));
if (!normalized.StartsWith(_applicationBasePath, StringComparison.OrdinalIgnoreCase))
return PathValidationResult.Fail("路径超出应用程序目录范围");
}
catch (Exception ex)
{
return PathValidationResult.Fail($"路径格式无效: {ex.Message}");
}
return PathValidationResult.Success(path);
}
}

View File

@@ -0,0 +1,54 @@
namespace Gpulse.WCT.DataAnalyzer.Core.Infrastructure.Security;
/// <summary>
/// 安全相关常量定义
/// </summary>
public static class SecurityConstants
{
/// <summary>
/// 默认允许的输出目录
/// </summary>
public static readonly string[] DefaultAllowedExportDirectories =
[
"exports",
"output",
"data/exports",
"data/output"
];
/// <summary>
/// 路径最大长度限制
/// </summary>
public const int MaxPathLength = 260;
/// <summary>
/// 文件名最大长度限制
/// </summary>
public const int MaxFileNameLength = 255;
/// <summary>
/// 禁止的路径模式
/// </summary>
public static readonly string[] ForbiddenPathPatterns =
[
"..", // 路径遍历
"~", // 用户主目录
"\\\\", // UNC 路径开始
"|", // 管道字符Windows 禁止)
">", // 重定向字符
"<", // 重定向字符
"*", // 通配符(用于文件名时)
"?" // 通配符(用于文件名时)
];
/// <summary>
/// 安全的文件扩展名白名单
/// </summary>
public static readonly string[] AllowedExportExtensions =
[
".csv",
".json",
".txt",
".log"
];
}