持久层重构为独立 MiGu.DB 工程并优化集成

- 新增 MiGu.DB 项目,迁移所有领域实体与枚举,统一模型约定
- 实现 Entity/Repository/UoW/Provider/Exception 等接口与实现
- 支持数据修补机制,完善 Sqlite 初始迁移与数据库管理
- Server 侧移除 EF Core 相关,依赖 MiGu.DB,PlatformPersistence 适配
- 业务服务注入 UoW/Repository,状态字段统一用 enum 及辅助类
- 统一异常处理,Controller 映射 HTTP 状态码
- 配置项与文档补充数据库启动、SchemaMode、迁移说明
- 新增 GlobalUsings.Db.cs、WmsStatusAliases.cs 简化类型引用
- 新增 HttpActorContextMiddleware 支持操作者上下文一致性
- 新增 MiGuDbContextModelSnapshot 追踪数据库结构
- 优化代码结构,解耦领域与持久层,提升扩展性与安全性
This commit is contained in:
2026-07-27 14:26:04 +08:00
parent bdcd88608c
commit 51c3fc1994
57 changed files with 6663 additions and 1396 deletions
-17
View File
@@ -1,17 +0,0 @@
namespace MiGu.Server.Persistence;
public abstract class EntityBase
{
public Guid Id { get; set; } = Guid.NewGuid();
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public bool IsDeleted { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
public string DeletedBy { get; set; } = "";
public long Version { get; set; }
public bool IsLock { get; set; }
public string CreatedBy { get; set; } = "";
public string UpdatedBy { get; set; } = "";
public string Remark { get; set; } = "";
public string Extend { get; set; } = "{}";
}
@@ -0,0 +1,36 @@
using Microsoft.AspNetCore.Http;
using MiGu.DB.Abstractions.Runtime;
using MiGu.DB.Kernel.Conventions;
namespace MiGu.Server.Persistence;
/// <summary>从 JWT Claims 解析当前操作者,写入 IActorContextAccessor。</summary>
public sealed class HttpActorContextMiddleware
{
private readonly RequestDelegate _next;
public HttpActorContextMiddleware(RequestDelegate next) => _next = next;
public async Task InvokeAsync(HttpContext http, IActorContextAccessor actors)
{
var name = http.User?.Identity?.Name
?? http.User?.FindFirst("sub")?.Value
?? http.User?.FindFirst("unique_name")?.Value
?? "system";
actors.Current = new HttpActorContext(name);
try
{
await _next(http);
}
finally
{
actors.Current = SystemActorContext.Instance;
}
}
private sealed class HttpActorContext : IActorContext
{
public HttpActorContext(string name) => Name = name;
public string Name { get; }
}
}
@@ -1,253 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using MiGu.Server.Dashboard;
using MiGu.Server.Wms;
using MiGu.Server.SimpleFields;
namespace MiGu.Server.Persistence;
public sealed class PlatformDbContext : DbContext
{
public PlatformDbContext(DbContextOptions<PlatformDbContext> options) : base(options) { }
public DbSet<Warehouse> Warehouses => Set<Warehouse>();
public DbSet<WarehouseArea> WarehouseAreas => Set<WarehouseArea>();
public DbSet<Storage> Storages => Set<Storage>();
public DbSet<Container> Containers => Set<Container>();
public DbSet<MaterialType> MaterialTypes => Set<MaterialType>();
public DbSet<Material> Materials => Set<Material>();
public DbSet<ContainerLocation> ContainerLocations => Set<ContainerLocation>();
public DbSet<ContainerMaterial> ContainerMaterials => Set<ContainerMaterial>();
public DbSet<StockEvent> StockEvents => Set<StockEvent>();
public DbSet<ContainerLocationHistory> ContainerLocationHistories => Set<ContainerLocationHistory>();
public DbSet<ContainerMaterialHistory> ContainerMaterialHistories => Set<ContainerMaterialHistory>();
public DbSet<WmsTransportRule> WmsTransportRules => Set<WmsTransportRule>();
public DbSet<WmsTransportTask> WmsTransportTasks => Set<WmsTransportTask>();
public DbSet<WmsTransportReservation> WmsTransportReservations => Set<WmsTransportReservation>();
public DbSet<WmsTransportTaskHistory> WmsTransportTaskHistories => Set<WmsTransportTaskHistory>();
public DbSet<SimpleField> SimpleFields => Set<SimpleField>();
public DbSet<UserDashboardShortcut> UserDashboardShortcuts => Set<UserDashboardShortcut>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
var guid = new ValueConverter<Guid, string>(
v => v.ToString("D"),
v => Guid.Parse(v));
var nullableGuid = new ValueConverter<Guid?, string?>(
v => v.HasValue ? v.Value.ToString("D") : null,
v => string.IsNullOrWhiteSpace(v) ? null : Guid.Parse(v));
var dateTimeOffset = new ValueConverter<DateTimeOffset, string>(
v => v.UtcDateTime.ToString("O"),
v => DateTimeOffset.Parse(v));
var nullableDateTimeOffset = new ValueConverter<DateTimeOffset?, string?>(
v => v.HasValue ? v.Value.UtcDateTime.ToString("O") : null,
v => string.IsNullOrWhiteSpace(v) ? null : DateTimeOffset.Parse(v));
foreach (var entity in modelBuilder.Model.GetEntityTypes())
{
foreach (var p in entity.ClrType.GetProperties().Where(p => p.PropertyType == typeof(Guid)))
modelBuilder.Entity(entity.ClrType).Property(p.Name).HasConversion(guid).HasMaxLength(36);
foreach (var p in entity.ClrType.GetProperties().Where(p => p.PropertyType == typeof(Guid?)))
modelBuilder.Entity(entity.ClrType).Property(p.Name).HasConversion(nullableGuid).HasMaxLength(36);
foreach (var p in entity.ClrType.GetProperties().Where(p => p.PropertyType == typeof(DateTimeOffset)))
modelBuilder.Entity(entity.ClrType).Property(p.Name).HasConversion(dateTimeOffset).HasMaxLength(40);
foreach (var p in entity.ClrType.GetProperties().Where(p => p.PropertyType == typeof(DateTimeOffset?)))
modelBuilder.Entity(entity.ClrType).Property(p.Name).HasConversion(nullableDateTimeOffset).HasMaxLength(40);
}
ConfigureEntityBase<Warehouse>(modelBuilder, "wms_warehouses");
ConfigureEntityBase<WarehouseArea>(modelBuilder, "wms_areas");
ConfigureEntityBase<Storage>(modelBuilder, "wms_storages");
ConfigureEntityBase<Container>(modelBuilder, "wms_containers");
ConfigureEntityBase<MaterialType>(modelBuilder, "wms_material_types");
ConfigureEntityBase<Material>(modelBuilder, "wms_materials");
ConfigureEntityBase<ContainerLocation>(modelBuilder, "wms_container_locations");
ConfigureEntityBase<ContainerMaterial>(modelBuilder, "wms_container_materials");
ConfigureEntityBase<WmsTransportRule>(modelBuilder, "wms_transport_rules");
ConfigureEntityBase<WmsTransportTask>(modelBuilder, "wms_transport_tasks");
ConfigureEntityBase<WmsTransportReservation>(modelBuilder, "wms_transport_reservations");
ConfigureHistory<ContainerLocationHistory>(modelBuilder, "wms_container_location_history");
ConfigureHistory<ContainerMaterialHistory>(modelBuilder, "wms_container_material_history");
ConfigureStockEvent(modelBuilder);
ConfigureTransportTaskHistory(modelBuilder);
modelBuilder.Entity<Warehouse>().HasIndex(x => x.Code).IsUnique();
modelBuilder.Entity<WarehouseArea>().HasIndex(x => x.Code).IsUnique();
modelBuilder.Entity<WarehouseArea>().HasIndex(x => x.WarehouseId);
modelBuilder.Entity<Storage>().HasIndex(x => x.Code).IsUnique();
modelBuilder.Entity<Storage>().HasIndex(x => x.AreaId);
modelBuilder.Entity<Storage>().HasIndex(x => x.Barcode);
modelBuilder.Entity<Container>().HasIndex(x => x.Code).IsUnique();
modelBuilder.Entity<MaterialType>().HasIndex(x => x.Code).IsUnique();
modelBuilder.Entity<Material>().HasIndex(x => x.Code).IsUnique();
modelBuilder.Entity<Material>().HasIndex(x => x.Barcode);
modelBuilder.Entity<Material>().HasIndex(x => new { x.LifecycleStatus, x.UpdatedAt });
modelBuilder.Entity<Material>().HasIndex(x => x.TypeCode);
modelBuilder.Entity<ContainerLocation>().HasIndex(x => x.ContainerId).IsUnique();
modelBuilder.Entity<ContainerLocation>().HasIndex(x => new { x.LocationType, x.LocationId });
modelBuilder.Entity<ContainerMaterial>().HasIndex(x => x.MaterialId).IsUnique();
modelBuilder.Entity<ContainerMaterial>().HasIndex(x => x.ContainerId);
modelBuilder.Entity<WmsTransportRule>().HasIndex(x => x.Code).IsUnique();
modelBuilder.Entity<WmsTransportRule>().HasIndex(x => new { x.TriggerType, x.Enabled, x.Priority });
modelBuilder.Entity<WmsTransportTask>().HasIndex(x => x.Status);
modelBuilder.Entity<WmsTransportTask>().HasIndex(x => x.ContainerId);
modelBuilder.Entity<WmsTransportTask>().HasIndex(x => x.TargetStorageId);
modelBuilder.Entity<WmsTransportReservation>().HasIndex(x => new { x.ContainerId, x.Status });
modelBuilder.Entity<WmsTransportReservation>().HasIndex(x => new { x.TargetStorageId, x.Status });
modelBuilder.Entity<WmsTransportTask>().Property(x => x.Quantity).HasPrecision(18, 4);
modelBuilder.Entity<ContainerMaterial>().Property(x => x.Quantity).HasPrecision(18, 4);
modelBuilder.Entity<ContainerMaterialHistory>().Property(x => x.QuantityDelta).HasPrecision(18, 4);
ConfigureSimpleField(modelBuilder);
ConfigureUserDashboardShortcut(modelBuilder);
}
private static void ConfigureUserDashboardShortcut(ModelBuilder modelBuilder)
{
var e = modelBuilder.Entity<UserDashboardShortcut>();
e.ToTable("user_dashboard_shortcuts");
e.HasKey(x => new { x.UserId, x.Scope });
e.Property(x => x.UserId).HasColumnName("user_id").HasMaxLength(64);
e.Property(x => x.Scope).HasColumnName("scope").HasMaxLength(32);
e.Property(x => x.KeysJson).HasColumnName("keys_json").HasColumnType("text");
var dateTime = new ValueConverter<DateTimeOffset, string>(
v => v.UtcDateTime.ToString("O"),
v => DateTimeOffset.Parse(v));
e.Property(x => x.UpdatedAt).HasColumnName("updated_at").HasConversion(dateTime).HasMaxLength(40);
}
private static void ConfigureSimpleField(ModelBuilder modelBuilder)
{
var e = modelBuilder.Entity<SimpleField>();
e.ToTable("simple_fields");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id");
e.Property(x => x.CarType).HasColumnName("car_type").HasMaxLength(64);
e.Property(x => x.FieldType).HasColumnName("field_type").HasMaxLength(64);
e.Property(x => x.Key).HasColumnName("key").HasMaxLength(128);
e.Property(x => x.Value).HasColumnName("value");
e.Property(x => x.DataType).HasColumnName("data_type").HasMaxLength(128);
e.Property(x => x.Chinese).HasColumnName("chinese").HasMaxLength(256).IsRequired(false);
e.Property(x => x.English).HasColumnName("english").HasMaxLength(256).IsRequired(false);
e.Property(x => x.Other).HasColumnName("other").HasMaxLength(512);
e.Property(x => x.IsDefault).HasColumnName("is_default");
var dateTime = new ValueConverter<DateTimeOffset, string>(
v => SimpleFieldDateTime.ToStorage(v),
v => SimpleFieldDateTime.FromStorage(v));
e.Property(x => x.CreateTime).HasColumnName("create_time").HasConversion(dateTime).HasMaxLength(19);
e.Property(x => x.UpdateTime).HasColumnName("update_time").HasConversion(dateTime).HasMaxLength(19);
e.HasIndex(x => new { x.CarType, x.FieldType, x.Key }).IsUnique();
}
public override int SaveChanges(bool acceptAllChangesOnSuccess)
{
StampEntities();
return base.SaveChanges(acceptAllChangesOnSuccess);
}
public override Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
{
StampEntities();
return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
}
private void StampEntities()
{
var now = DateTimeOffset.UtcNow;
foreach (var e in ChangeTracker.Entries<EntityBase>())
{
if (e.State == EntityState.Added)
{
if (e.Entity.Id == Guid.Empty) e.Entity.Id = Guid.NewGuid();
e.Entity.CreatedAt = now;
e.Entity.UpdatedAt = now;
e.Entity.Version = Math.Max(1, e.Entity.Version);
if (string.IsNullOrWhiteSpace(e.Entity.Extend)) e.Entity.Extend = "{}";
}
else if (e.State == EntityState.Modified)
{
e.Entity.UpdatedAt = now;
e.Entity.Version += 1;
if (string.IsNullOrWhiteSpace(e.Entity.Extend)) e.Entity.Extend = "{}";
}
}
}
private static void ConfigureEntityBase<T>(ModelBuilder modelBuilder, string table) where T : EntityBase
{
var e = modelBuilder.Entity<T>();
e.ToTable(table);
e.HasKey(x => x.Id);
e.Property(x => x.CreatedBy).HasMaxLength(128);
e.Property(x => x.UpdatedBy).HasMaxLength(128);
e.Property(x => x.DeletedBy).HasMaxLength(128);
e.Property(x => x.Remark).HasMaxLength(1000);
e.Property(x => x.Extend).HasColumnType("text");
e.HasQueryFilter(x => !x.IsDeleted);
}
private static void ConfigureHistory<T>(ModelBuilder modelBuilder, string table) where T : WarehouseHistoryBase
{
var e = modelBuilder.Entity<T>();
e.ToTable(table);
e.HasKey(x => x.Id);
e.Property(x => x.EventType).HasMaxLength(64);
e.Property(x => x.BeforeJson).HasColumnType("text");
e.Property(x => x.AfterJson).HasColumnType("text");
e.Property(x => x.Operator).HasMaxLength(128);
e.Property(x => x.Source).HasMaxLength(32);
e.Property(x => x.Reason).HasMaxLength(500);
e.Property(x => x.Remark).HasMaxLength(1000);
e.Property(x => x.Extend).HasColumnType("text");
e.HasIndex(x => x.ContainerId);
e.HasIndex(x => x.OperatedAt);
e.HasIndex(x => x.EventType);
}
private static void ConfigureStockEvent(ModelBuilder modelBuilder)
{
var e = modelBuilder.Entity<StockEvent>();
e.ToTable("wms_stock_events");
e.HasKey(x => x.Id);
e.Property(x => x.EventType).HasMaxLength(32);
e.Property(x => x.MaterialCode).HasMaxLength(64);
e.Property(x => x.MaterialName).HasMaxLength(128);
e.Property(x => x.MaterialBarcode).HasMaxLength(128);
e.Property(x => x.MaterialTypeCode).HasMaxLength(64);
e.Property(x => x.ContainerCode).HasMaxLength(64);
e.Property(x => x.ContainerName).HasMaxLength(128);
e.Property(x => x.StorageCode).HasMaxLength(64);
e.Property(x => x.StorageName).HasMaxLength(128);
e.Property(x => x.AreaCode).HasMaxLength(64);
e.Property(x => x.FromStorageCode).HasMaxLength(64);
e.Property(x => x.FromStorageName).HasMaxLength(128);
e.Property(x => x.ToStorageCode).HasMaxLength(64);
e.Property(x => x.ToStorageName).HasMaxLength(128);
e.Property(x => x.RefType).HasMaxLength(64);
e.Property(x => x.RefCode).HasMaxLength(64);
e.Property(x => x.Operator).HasMaxLength(128);
e.Property(x => x.Reason).HasMaxLength(500);
e.HasIndex(x => x.OperatedAt);
e.HasIndex(x => x.EventType);
e.HasIndex(x => x.MaterialId);
e.HasIndex(x => x.ContainerId);
}
private static void ConfigureTransportTaskHistory(ModelBuilder modelBuilder)
{
var e = modelBuilder.Entity<WmsTransportTaskHistory>();
e.ToTable("wms_transport_task_history");
e.HasKey(x => x.Id);
e.Property(x => x.FromStatus).HasMaxLength(32);
e.Property(x => x.ToStatus).HasMaxLength(32);
e.Property(x => x.Operator).HasMaxLength(128);
e.Property(x => x.Reason).HasMaxLength(500);
e.Property(x => x.ErrorMessage).HasMaxLength(1000);
e.Property(x => x.SnapshotJson).HasColumnType("text");
e.HasIndex(x => x.TaskId);
e.HasIndex(x => x.OperatedAt);
}
}
+16 -452
View File
@@ -1,464 +1,28 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.Data.Sqlite;
using MiGu.Server.Wms;
using MiGu.Server.SimpleFields;
using MiGu.DB.Kernel.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace MiGu.Server.Persistence;
/// <summary>兼容入口:转调 MiGu.DB Hosting,并注册本进程业务服务。</summary>
public static class PlatformPersistence
{
public static IServiceCollection AddPlatformPersistence(this IServiceCollection services, IConfiguration configuration)
{
services.AddDbContext<PlatformDbContext>((sp, options) =>
{
var env = sp.GetRequiredService<IWebHostEnvironment>();
var provider = configuration["Database:Provider"] ?? "sqlite";
var connection = ResolveConnectionString(configuration, env, provider);
services.AddMiGuDb(configuration);
switch (provider.Trim().ToLowerInvariant())
{
case "sqlite":
options.UseSqlite(connection);
break;
case "mysql":
options.UseMySql(connection, ServerVersion.AutoDetect(connection));
break;
case "postgres":
case "postgresql":
case "npgsql":
options.UseNpgsql(connection);
break;
case "sqlserver":
case "mssql":
options.UseSqlServer(connection);
break;
default:
throw new InvalidOperationException($"未知数据库 Provider: {provider}");
}
});
services.AddScoped<WmsReferenceValidator>();
services.AddScoped<WmsService>();
services.AddScoped<WmsTransportRuleService>();
services.AddScoped<WmsTransportPlanner>();
services.AddScoped<WmsTransportTaskService>();
services.AddScoped<IWmsDispatchAdapter, NoopWmsDispatchAdapter>();
services.AddScoped<SimpleFieldService>();
services.AddScoped<MiGu.Server.Dashboard.DashboardShortcutService>();
services.AddScoped<Wms.WmsReferenceValidator>();
services.AddScoped<Wms.WmsService>();
services.AddScoped<Wms.WmsTransportRuleService>();
services.AddScoped<Wms.WmsTransportPlanner>();
services.AddScoped<Wms.WmsTransportTaskService>();
services.AddScoped<Wms.IWmsDispatchAdapter, Wms.NoopWmsDispatchAdapter>();
services.AddScoped<SimpleFields.SimpleFieldService>();
services.AddScoped<Dashboard.DashboardShortcutService>();
return services;
}
public static async Task EnsurePlatformDatabaseAsync(this IServiceProvider services)
{
using var scope = services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<PlatformDbContext>();
await db.Database.EnsureCreatedAsync();
// EnsureCreated 只在「库文件不存在」时建表;已有 platform.db 时新增实体不会自动补表。
await EnsureSimpleFieldsTableAsync(db);
await EnsureUserDashboardShortcutsTableAsync(db);
await EnsureWmsTransportSchemaAsync(db);
await EnsureWmsStructureSchemaAsync(db);
await MigrateWmsLegacyAsync(scope.ServiceProvider);
}
private static async Task MigrateWmsLegacyAsync(IServiceProvider sp)
{
try
{
var wms = sp.GetRequiredService<WmsService>();
await wms.MigrateLegacyAsync("system");
}
catch
{
// 首次建库或缺列时忽略,后续请求可再触发
}
}
/// <summary>补建仓库/物料类型/库存事件及结构扩展列(幂等,SQLite)。</summary>
private static async Task EnsureWmsStructureSchemaAsync(PlatformDbContext db)
{
if (!db.Database.IsSqlite()) return;
await db.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS wms_warehouses (
Id TEXT NOT NULL CONSTRAINT PK_wms_warehouses PRIMARY KEY,
Code TEXT NOT NULL,
Name TEXT NOT NULL,
Type TEXT NOT NULL,
Enabled INTEGER NOT NULL,
SortOrder INTEGER NOT NULL,
CreatedAt TEXT NOT NULL,
UpdatedAt TEXT NOT NULL,
IsDeleted INTEGER NOT NULL,
DeletedAt TEXT,
DeletedBy TEXT NOT NULL DEFAULT '',
Version INTEGER NOT NULL,
IsLock INTEGER NOT NULL,
CreatedBy TEXT NOT NULL DEFAULT '',
UpdatedBy TEXT NOT NULL DEFAULT '',
Remark TEXT NOT NULL DEFAULT '',
Extend TEXT NOT NULL DEFAULT '{{}}'
);
""");
await db.Database.ExecuteSqlRawAsync("CREATE UNIQUE INDEX IF NOT EXISTS IX_wms_warehouses_Code ON wms_warehouses (Code);");
await db.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS wms_material_types (
Id TEXT NOT NULL CONSTRAINT PK_wms_material_types PRIMARY KEY,
Code TEXT NOT NULL,
Name TEXT NOT NULL,
Spec TEXT NOT NULL DEFAULT '',
Unit TEXT NOT NULL DEFAULT 'pcs',
Category TEXT NOT NULL DEFAULT '',
BarcodePrefix TEXT NOT NULL DEFAULT '',
Enabled INTEGER NOT NULL,
CreatedAt TEXT NOT NULL,
UpdatedAt TEXT NOT NULL,
IsDeleted INTEGER NOT NULL,
DeletedAt TEXT,
DeletedBy TEXT NOT NULL DEFAULT '',
Version INTEGER NOT NULL,
IsLock INTEGER NOT NULL,
CreatedBy TEXT NOT NULL DEFAULT '',
UpdatedBy TEXT NOT NULL DEFAULT '',
Remark TEXT NOT NULL DEFAULT '',
Extend TEXT NOT NULL DEFAULT '{{}}'
);
""");
await db.Database.ExecuteSqlRawAsync("CREATE UNIQUE INDEX IF NOT EXISTS IX_wms_material_types_Code ON wms_material_types (Code);");
await db.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS wms_stock_events (
Id TEXT NOT NULL CONSTRAINT PK_wms_stock_events PRIMARY KEY,
EventType TEXT NOT NULL,
MaterialId TEXT,
MaterialCode TEXT NOT NULL DEFAULT '',
MaterialName TEXT NOT NULL DEFAULT '',
MaterialBarcode TEXT NOT NULL DEFAULT '',
MaterialTypeCode TEXT NOT NULL DEFAULT '',
ContainerId TEXT,
ContainerCode TEXT NOT NULL DEFAULT '',
ContainerName TEXT NOT NULL DEFAULT '',
StorageId TEXT,
StorageCode TEXT NOT NULL DEFAULT '',
StorageName TEXT NOT NULL DEFAULT '',
AreaCode TEXT NOT NULL DEFAULT '',
FromStorageId TEXT,
FromStorageCode TEXT NOT NULL DEFAULT '',
FromStorageName TEXT NOT NULL DEFAULT '',
ToStorageId TEXT,
ToStorageCode TEXT NOT NULL DEFAULT '',
ToStorageName TEXT NOT NULL DEFAULT '',
RefType TEXT NOT NULL DEFAULT 'Manual',
RefId TEXT,
RefCode TEXT NOT NULL DEFAULT '',
Operator TEXT NOT NULL DEFAULT '',
OperatedAt TEXT NOT NULL,
Reason TEXT NOT NULL DEFAULT ''
);
""");
await db.Database.ExecuteSqlRawAsync("CREATE INDEX IF NOT EXISTS IX_wms_stock_events_OperatedAt ON wms_stock_events (OperatedAt);");
await EnsureSqliteColumnAsync(db, "wms_areas", "WarehouseId", "TEXT NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000'");
await EnsureSqliteColumnAsync(db, "wms_areas", "LayoutMode", "TEXT NOT NULL DEFAULT 'Flat'");
await EnsureSqliteColumnAsync(db, "wms_areas", "State", "TEXT NOT NULL DEFAULT 'Default'");
await EnsureSqliteColumnAsync(db, "wms_storages", "LocationKind", "TEXT NOT NULL DEFAULT 'Station'");
await EnsureSqliteColumnAsync(db, "wms_storages", "ColumnNo", "INTEGER NOT NULL DEFAULT 0");
await EnsureSqliteColumnAsync(db, "wms_storages", "LevelNo", "INTEGER NOT NULL DEFAULT 1");
await EnsureSqliteColumnAsync(db, "wms_storages", "DepthNo", "INTEGER NOT NULL DEFAULT 1");
await EnsureSqliteColumnAsync(db, "wms_storages", "SiteCode", "TEXT NOT NULL DEFAULT ''");
await EnsureSqliteColumnAsync(db, "wms_storages", "Barcode", "TEXT NOT NULL DEFAULT ''");
await EnsureSqliteColumnAsync(db, "wms_containers", "AreaId", "TEXT");
await EnsureSqliteColumnAsync(db, "wms_containers", "Barcode", "TEXT NOT NULL DEFAULT ''");
await EnsureSqliteColumnAsync(db, "wms_containers", "Length", "REAL NOT NULL DEFAULT 0");
await EnsureSqliteColumnAsync(db, "wms_containers", "Width", "REAL NOT NULL DEFAULT 0");
await EnsureSqliteColumnAsync(db, "wms_containers", "Height", "REAL NOT NULL DEFAULT 0");
await EnsureSqliteColumnAsync(db, "wms_materials", "TypeCode", "TEXT NOT NULL DEFAULT ''");
await EnsureSqliteColumnAsync(db, "wms_materials", "Barcode", "TEXT NOT NULL DEFAULT ''");
await EnsureSqliteColumnAsync(db, "wms_materials", "LifecycleStatus", "TEXT NOT NULL DEFAULT 'Active'");
await EnsureSqliteColumnAsync(db, "wms_materials", "UnboundAt", "TEXT");
await EnsureSqliteColumnAsync(db, "wms_container_materials", "BoundAt", "TEXT NOT NULL DEFAULT ''");
}
/// <summary>为已存在的数据库补建 simple_fields 表(幂等)。</summary>
private static async Task EnsureSimpleFieldsTableAsync(PlatformDbContext db)
{
if (db.Database.IsSqlite())
{
await db.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS simple_fields (
id TEXT NOT NULL CONSTRAINT PK_simple_fields PRIMARY KEY,
car_type TEXT NOT NULL DEFAULT '',
field_type TEXT NOT NULL,
"key" TEXT NOT NULL,
value TEXT NOT NULL DEFAULT '',
data_type TEXT NOT NULL DEFAULT '',
chinese TEXT,
english TEXT,
other TEXT NOT NULL DEFAULT '',
is_default INTEGER NOT NULL,
create_time TEXT NOT NULL,
update_time TEXT NOT NULL
);
""");
// 须先删旧索引 (field_type, other, key):把 other 清空为「其他语言」后会与旧唯一约束冲突。
await db.Database.ExecuteSqlRawAsync("DROP INDEX IF EXISTS IX_simple_fields_field_type_other_key;");
await db.Database.ExecuteSqlRawAsync("""
UPDATE simple_fields SET car_type = other
WHERE (car_type IS NULL OR car_type = '') AND other <> '';
""");
await db.Database.ExecuteSqlRawAsync("""
UPDATE simple_fields SET other = ''
WHERE other <> '' AND other = car_type;
""");
await db.Database.ExecuteSqlRawAsync("""
CREATE UNIQUE INDEX IF NOT EXISTS IX_simple_fields_car_type_field_type_key
ON simple_fields (car_type, field_type, "key");
""");
return;
}
// 非 SQLite:表不存在时尝试按当前模型创建(已有库不会走 EnsureCreated)。
if (!await TableExistsAsync(db, "simple_fields"))
{
var creator = db.GetService<Microsoft.EntityFrameworkCore.Storage.IRelationalDatabaseCreator>();
await creator.CreateTablesAsync();
}
}
/// <summary>为已存在的数据库补建 WMS 搬运规则/任务相关表及库位扩展列(幂等)。</summary>
private static async Task EnsureWmsTransportSchemaAsync(PlatformDbContext db)
{
if (!db.Database.IsSqlite()) return;
await EnsureSqliteColumnAsync(db, "wms_storages", "Status", "TEXT NOT NULL DEFAULT 'Available'");
await EnsureSqliteColumnAsync(db, "wms_storages", "Usage", "TEXT NOT NULL DEFAULT ''");
await EnsureSqliteColumnAsync(db, "wms_storages", "Priority", "INTEGER NOT NULL DEFAULT 0");
await EnsureSqliteColumnAsync(db, "wms_storages", "ZoneCode", "TEXT NOT NULL DEFAULT ''");
await EnsureSqliteColumnAsync(db, "wms_storages", "AllowInbound", "INTEGER NOT NULL DEFAULT 1");
await EnsureSqliteColumnAsync(db, "wms_storages", "AllowOutbound", "INTEGER NOT NULL DEFAULT 1");
await db.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS wms_transport_rules (
Id TEXT NOT NULL CONSTRAINT PK_wms_transport_rules PRIMARY KEY,
CreatedAt TEXT NOT NULL,
UpdatedAt TEXT NOT NULL,
IsDeleted INTEGER NOT NULL,
DeletedAt TEXT,
DeletedBy TEXT,
Version INTEGER NOT NULL,
IsLock INTEGER NOT NULL,
CreatedBy TEXT NOT NULL,
UpdatedBy TEXT NOT NULL,
Remark TEXT NOT NULL,
Extend TEXT NOT NULL,
Code TEXT NOT NULL,
Name TEXT NOT NULL,
TriggerType TEXT NOT NULL,
Enabled INTEGER NOT NULL,
Priority INTEGER NOT NULL,
SourceSelectorJson TEXT NOT NULL,
TargetSelectorJson TEXT NOT NULL,
TaskOptionsJson TEXT NOT NULL
);
""");
await db.Database.ExecuteSqlRawAsync("""
CREATE UNIQUE INDEX IF NOT EXISTS IX_wms_transport_rules_Code ON wms_transport_rules (Code);
""");
await db.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS wms_transport_tasks (
Id TEXT NOT NULL CONSTRAINT PK_wms_transport_tasks PRIMARY KEY,
CreatedAt TEXT NOT NULL,
UpdatedAt TEXT NOT NULL,
IsDeleted INTEGER NOT NULL,
DeletedAt TEXT,
DeletedBy TEXT,
Version INTEGER NOT NULL,
IsLock INTEGER NOT NULL,
CreatedBy TEXT NOT NULL,
UpdatedBy TEXT NOT NULL,
Remark TEXT NOT NULL,
Extend TEXT NOT NULL,
BusinessType TEXT NOT NULL,
RuleId TEXT,
SourceStorageId TEXT NOT NULL,
TargetStorageId TEXT NOT NULL,
ContainerId TEXT NOT NULL,
MaterialId TEXT,
Quantity REAL,
Status TEXT NOT NULL,
DispatchMissionId TEXT NOT NULL DEFAULT '',
DeliveryId TEXT NOT NULL DEFAULT '',
DispatchStatus TEXT NOT NULL DEFAULT '',
Reason TEXT NOT NULL DEFAULT '',
SnapshotJson TEXT NOT NULL,
ErrorMessage TEXT NOT NULL DEFAULT '',
TaskPriority INTEGER NOT NULL DEFAULT 0
);
""");
await db.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS wms_transport_reservations (
Id TEXT NOT NULL CONSTRAINT PK_wms_transport_reservations PRIMARY KEY,
CreatedAt TEXT NOT NULL,
UpdatedAt TEXT NOT NULL,
IsDeleted INTEGER NOT NULL,
DeletedAt TEXT,
DeletedBy TEXT,
Version INTEGER NOT NULL,
IsLock INTEGER NOT NULL,
CreatedBy TEXT NOT NULL,
UpdatedBy TEXT NOT NULL,
Remark TEXT NOT NULL,
Extend TEXT NOT NULL,
TaskId TEXT NOT NULL,
ContainerId TEXT NOT NULL,
SourceStorageId TEXT NOT NULL,
TargetStorageId TEXT NOT NULL,
Status TEXT NOT NULL,
ExpiresAt TEXT
);
""");
await db.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS wms_transport_task_history (
Id TEXT NOT NULL CONSTRAINT PK_wms_transport_task_history PRIMARY KEY,
TaskId TEXT NOT NULL,
FromStatus TEXT NOT NULL,
ToStatus TEXT NOT NULL,
Operator TEXT NOT NULL,
OperatedAt TEXT NOT NULL,
Reason TEXT NOT NULL,
ErrorMessage TEXT NOT NULL,
SnapshotJson TEXT NOT NULL
);
""");
}
private static async Task EnsureSqliteColumnAsync(PlatformDbContext db, string table, string column, string definition)
{
var conn = db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open)
await conn.OpenAsync();
await using var cmd = conn.CreateCommand();
cmd.CommandText = $"PRAGMA table_info({table});";
await using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
var name = reader.GetString(1);
if (string.Equals(name, column, StringComparison.OrdinalIgnoreCase))
return;
}
await db.Database.ExecuteSqlRawAsync($"ALTER TABLE {table} ADD COLUMN {column} {definition};");
}
/// <summary>为已存在的数据库补建 user_dashboard_shortcuts 表(幂等)。</summary>
private static async Task EnsureUserDashboardShortcutsTableAsync(PlatformDbContext db)
{
if (db.Database.IsSqlite())
{
await db.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS user_dashboard_shortcuts (
user_id TEXT NOT NULL,
scope TEXT NOT NULL,
keys_json TEXT NOT NULL DEFAULT '[]',
updated_at TEXT NOT NULL,
CONSTRAINT PK_user_dashboard_shortcuts PRIMARY KEY (user_id, scope)
);
""");
return;
}
if (!await TableExistsAsync(db, "user_dashboard_shortcuts"))
{
var creator = db.GetService<Microsoft.EntityFrameworkCore.Storage.IRelationalDatabaseCreator>();
await creator.CreateTablesAsync();
}
}
/// <summary>
/// 检查表是否存在
/// </summary>
/// <param name="db">数据库上下文</param>
/// <param name="table">表名</param>
/// <returns>表是否存在</returns>
private static async Task<bool> TableExistsAsync(PlatformDbContext db, string table)
{
var conn = db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open)
await conn.OpenAsync();
try
{
await using var cmd = conn.CreateCommand();
if (db.Database.IsSqlServer())
{
cmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = @t";
var p = cmd.CreateParameter(); p.ParameterName = "@t"; p.Value = table; cmd.Parameters.Add(p);
}
else if (db.Database.IsNpgsql())
{
cmd.CommandText = "SELECT 1 FROM information_schema.tables WHERE table_name = @t";
var p = cmd.CreateParameter(); p.ParameterName = "@t"; p.Value = table; cmd.Parameters.Add(p);
}
else if (db.Database.IsMySql())
{
cmd.CommandText = "SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = @t";
var p = cmd.CreateParameter(); p.ParameterName = "@t"; p.Value = table; cmd.Parameters.Add(p);
}
else
{
return false;
}
var result = await cmd.ExecuteScalarAsync();
return result != null;
}
finally
{
if (conn.State == System.Data.ConnectionState.Open)
await conn.CloseAsync();
}
}
private static string ResolveConnectionString(IConfiguration configuration, IWebHostEnvironment env, string provider)
{
var key = provider.Trim().ToLowerInvariant() switch
{
"postgres" or "postgresql" or "npgsql" => "PostgreSQL",
"mssql" => "SqlServer",
_ => provider
};
var configured = configuration.GetConnectionString(key) ?? configuration.GetConnectionString("Platform");
if (!string.IsNullOrWhiteSpace(configured))
{
return IsSqlite(provider) ? NormalizeSqliteConnection(configured, env) : configured;
}
var dataDir = Path.Combine(env.ContentRootPath, "data");
Directory.CreateDirectory(dataDir);
return $"Data Source={Path.Combine(dataDir, "platform.db")}";
}
private static bool IsSqlite(string provider) =>
string.Equals(provider.Trim(), "sqlite", StringComparison.OrdinalIgnoreCase);
private static string NormalizeSqliteConnection(string connection, IWebHostEnvironment env)
{
var builder = new SqliteConnectionStringBuilder(connection);
if (string.IsNullOrWhiteSpace(builder.DataSource)) return connection;
if (builder.DataSource is ":memory:") return connection;
if (!Path.IsPathRooted(builder.DataSource))
{
builder.DataSource = Path.Combine(env.ContentRootPath, builder.DataSource);
}
var dir = Path.GetDirectoryName(builder.DataSource);
if (!string.IsNullOrWhiteSpace(dir)) Directory.CreateDirectory(dir);
return builder.ToString();
}
/// <summary>启动建库/迁移入口(内部即 MigrateMiGuDbAsyncMigrate + 基线 + DataMigrator)。</summary>
public static Task EnsurePlatformDatabaseAsync(this IServiceProvider services)
=> services.MigrateMiGuDbAsync();
}