持久层重构为独立 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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>启动建库/迁移入口(内部即 MigrateMiGuDbAsync:Migrate + 基线 + DataMigrator)。</summary>
|
||||
public static Task EnsurePlatformDatabaseAsync(this IServiceProvider services)
|
||||
=> services.MigrateMiGuDbAsync();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user