持久层重构为独立 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
@@ -0,0 +1,45 @@
namespace MiGu.DB.Abstractions.Entities;
public interface IEntity<TKey>
{
TKey Id { get; set; }
}
public interface IAuditable
{
DateTimeOffset CreatedAt { get; set; }
DateTimeOffset UpdatedAt { get; set; }
string CreatedBy { get; set; }
string UpdatedBy { get; set; }
}
public interface ISoftDeletable
{
bool IsDeleted { get; set; }
DateTimeOffset? DeletedAt { get; set; }
string DeletedBy { get; set; }
}
public interface IVersioned
{
long Version { get; set; }
}
public interface ILockable
{
bool IsLock { get; set; }
}
public interface IRemarkable
{
string Remark { get; set; }
}
public interface IExtendable
{
string Extend { get; set; }
}
public interface IHistoryEntry
{
}
@@ -0,0 +1,42 @@
namespace MiGu.DB.Abstractions.Exceptions;
public sealed class ConcurrencyConflictException : Exception
{
public string EntityType { get; }
public object? EntityId { get; }
public long? ExpectedVersion { get; }
public ConcurrencyConflictException(string entityType, object? entityId, long? expectedVersion = null)
: base("数据已被其他用户修改,请刷新后重试")
{
EntityType = entityType;
EntityId = entityId;
ExpectedVersion = expectedVersion;
}
}
public sealed class EntityLockedException : Exception
{
public string EntityType { get; }
public object? EntityId { get; }
public EntityLockedException(string entityType, object? entityId)
: base("数据已锁定,不能修改")
{
EntityType = entityType;
EntityId = entityId;
}
}
public sealed class EntityNotFoundException : Exception
{
public string EntityType { get; }
public object? EntityId { get; }
public EntityNotFoundException(string entityType, object? entityId)
: base("数据不存在")
{
EntityType = entityType;
EntityId = entityId;
}
}
@@ -0,0 +1,10 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace MiGu.DB.Abstractions.Modules;
public interface IEntityModule
{
void ConfigureModel(ModelBuilder modelBuilder);
void RegisterServices(IServiceCollection services);
}
@@ -0,0 +1,32 @@
using System.Linq.Expressions;
using MiGu.DB.Abstractions.Entities;
namespace MiGu.DB.Abstractions.Persistence;
public interface IRepository<TEntity, TKey> where TEntity : class, IEntity<TKey>
{
IQueryable<TEntity> Query(bool asNoTracking = true);
Task<TEntity?> FindAsync(TKey id, CancellationToken ct = default);
Task AddAsync(TEntity entity, CancellationToken ct = default);
void Update(TEntity entity);
}
public interface IEditableRepository<TEntity> : IRepository<TEntity, Guid>
where TEntity : class, IEntity<Guid>, ISoftDeletable, IVersioned, ILockable
{
Task<TEntity> GetEditableAsync(Guid id, long? expectedVersion, CancellationToken ct = default);
Task SoftDeleteAsync(Guid id, long? expectedVersion, CancellationToken ct = default);
Task EnsureUniqueAsync(Expression<Func<TEntity, bool>> predicate, string errorMessage, CancellationToken ct = default);
}
public interface IHistoryRepository<TEntity> : IRepository<TEntity, Guid>
where TEntity : class, IEntity<Guid>, IHistoryEntry
{
Task AppendAsync(TEntity entry, CancellationToken ct = default);
}
public interface IUnitOfWork
{
Task<int> SaveChangesAsync(CancellationToken ct = default);
Task ExecuteInTransactionAsync(Func<CancellationToken, Task> action, CancellationToken ct = default);
}
@@ -0,0 +1,11 @@
using Microsoft.EntityFrameworkCore;
namespace MiGu.DB.Abstractions.Providers;
public interface IDbProviderSetup
{
string Name { get; }
void Configure(DbContextOptionsBuilder builder, string connectionString);
string MigrationsAssemblyName { get; }
string MigrationsNamespace { get; }
}
@@ -0,0 +1,44 @@
using Microsoft.EntityFrameworkCore;
namespace MiGu.DB.Abstractions.Runtime;
public interface IActorContext
{
string Name { get; }
}
public interface IActorContextAccessor
{
IActorContext Current { get; set; }
}
public interface IDataMigrator
{
int Order { get; }
string Name { get; }
Task MigrateAsync(DbContext db, CancellationToken ct = default);
}
/// <summary>
/// 启动期 Schema 初始化策略。
/// <see cref="Migrate"/>:版本化迁移(发版/现场);
/// <see cref="EnsureCreated"/>:按当前模型建库(开发期,改模型需删库重建)。
/// </summary>
public enum MiGuSchemaMode
{
Migrate = 0,
EnsureCreated = 1
}
public sealed class MiGuDbOptions
{
public string Provider { get; set; } = "sqlite";
public string ConnectionString { get; set; } = "";
public string ContentRootPath { get; set; } = "";
/// <summary>Schema 初始化模式,对应配置 Database:SchemaMode。</summary>
public MiGuSchemaMode SchemaMode { get; set; } = MiGuSchemaMode.Migrate;
/// <summary>为 true 时,Schema 初始化后按 Order 执行全部 IDataMigrator(默认开启)。</summary>
public bool ApplyDataMigratorsOnStartup { get; set; } = true;
}
View File
@@ -0,0 +1,9 @@
namespace MiGu.DB.Domains.Dashboard;
public sealed class UserDashboardShortcut
{
public string UserId { get; set; } = "";
public string Scope { get; set; } = "";
public string KeysJson { get; set; } = "[]";
public DateTimeOffset UpdatedAt { get; set; }
}
+105
View File
@@ -0,0 +1,105 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using MiGu.DB.Abstractions.Runtime;
using MiGu.DB.Domains.Wms;
using MiGu.DB.Kernel.Context;
namespace MiGu.DB.Domains.Migrators;
// 启动期数据修补:Schema 初始化之后按 Order 执行,须幂等。
// 优先 LINQ / ExecuteUpdateAsync;枚举 converter 无法匹配旧字符串时允许定点 raw UPDATESqlite)。
/// <summary>将 simple_fields.other 回填到 car_type(替代原 PlatformPersistence raw UPDATE)。</summary>
public sealed class SimpleFieldsCarTypeBackfillMigrator : IDataMigrator
{
public int Order => 10;
public string Name => "SimpleFields.CarTypeBackfill";
public async Task MigrateAsync(DbContext db, CancellationToken ct = default)
{
if (db is not MiGuDbContext ctx) return;
await ctx.SimpleFields
.Where(x => (x.CarType == null || x.CarType == "") && x.Other != "")
.ExecuteUpdateAsync(s => s.SetProperty(x => x.CarType, x => x.Other), ct);
await ctx.SimpleFields
.Where(x => x.Other != "" && x.Other == x.CarType)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.Other, ""), ct);
}
}
/// <summary>
/// 把库内旧状态字符串刷成规范枚举名(Available/Idle→EmptyOccupied→FullContainer)。
/// 仅 Sqlite 表名/列名;绕过枚举 HasConversionExecuteUpdate + 字符串比较会 InvalidCast)。
/// </summary>
public sealed class LegacyStatusNormalizationMigrator : IDataMigrator
{
public int Order => 15;
public string Name => "Wms.LegacyStatusNormalization";
public async Task MigrateAsync(DbContext db, CancellationToken ct = default)
{
if (db is not MiGuDbContext) return;
await db.Database.ExecuteSqlRawAsync(
"""
UPDATE wms_storages
SET Status = 'Empty'
WHERE Status IN ('Available', 'Idle')
""",
ct);
await db.Database.ExecuteSqlRawAsync(
"""
UPDATE wms_storages
SET Status = 'FullContainer'
WHERE Status = 'Occupied'
""",
ct);
await db.Database.ExecuteSqlRawAsync(
"""
UPDATE wms_areas
SET LayoutMode = 'Flat'
WHERE LayoutMode IS NULL OR LayoutMode = ''
""",
ct);
}
}
/// <summary>
/// LocationType=Storage 时回填 StorageId,供库存 join;写路径 BindOrTransferLocation 也会维护该字段。
/// </summary>
public sealed class ContainerLocationStorageIdBackfillMigrator : IDataMigrator
{
public int Order => 20;
public string Name => "Wms.ContainerLocation.StorageId";
public async Task MigrateAsync(DbContext db, CancellationToken ct = default)
{
if (db is not MiGuDbContext ctx) return;
var rows = await ctx.ContainerLocations
.Where(x => x.LocationType == ContainerLocationType.Storage && x.StorageId == null)
.ToListAsync(ct);
foreach (var row in rows)
{
if (Guid.TryParse(row.LocationId, out var sid))
row.StorageId = sid;
}
if (rows.Count > 0)
await ctx.SaveChangesAsync(ct);
}
}
public static class DataMigratorRegistration
{
public static IServiceCollection AddMiGuDataMigrators(this IServiceCollection services)
{
services.AddSingleton<IDataMigrator, SimpleFieldsCarTypeBackfillMigrator>();
services.AddSingleton<IDataMigrator, LegacyStatusNormalizationMigrator>();
services.AddSingleton<IDataMigrator, ContainerLocationStorageIdBackfillMigrator>();
return services;
}
}
@@ -0,0 +1,32 @@
using System.ComponentModel.DataAnnotations;
using MiGu.DB.Kernel.Entities;
namespace MiGu.DB.Domains.SimpleFields;
public sealed class SimpleField : Entity<Guid>
{
[MaxLength(64)] public string CarType { get; set; } = "";
[MaxLength(64)] public string FieldType { get; set; } = "";
[MaxLength(128)] public string Key { get; set; } = "";
public string Value { get; set; } = "";
[MaxLength(128)] public string DataType { get; set; } = "";
[MaxLength(256)] public string? Chinese { get; set; }
[MaxLength(256)] public string? English { get; set; }
[MaxLength(512)] public string Other { get; set; } = "";
public bool IsDefault { get; set; }
public DateTimeOffset CreateTime { get; set; }
public DateTimeOffset UpdateTime { get; set; }
}
public static class SimpleFieldDateTime
{
public const string Format = "yyyy-MM-dd HH:mm:ss";
public static DateTimeOffset Now => DateTimeOffset.UtcNow;
public static string ToStorage(DateTimeOffset value)
=> value.UtcDateTime.ToString(Format);
public static DateTimeOffset FromStorage(string value)
=> DateTimeOffset.Parse(value);
}
@@ -0,0 +1,104 @@
using System.ComponentModel.DataAnnotations;
using MiGu.DB.Kernel.Entities;
namespace MiGu.DB.Domains.Transport;
// 运输规则/任务/预占实体。状态字段为枚举;复数 *Statuses 辅助类供 Server 解析 DTO 字符串。
public sealed class WmsTransportRule : AggregateRoot
{
[MaxLength(64)] public string Code { get; set; } = "";
[MaxLength(128)] public string Name { get; set; } = "";
public WmsTransportTriggerType TriggerType { get; set; } = WmsTransportTriggerType.MaterialCall;
public bool Enabled { get; set; } = true;
public int Priority { get; set; }
public string SourceSelectorJson { get; set; } = "{}";
public string TargetSelectorJson { get; set; } = "{}";
public string TaskOptionsJson { get; set; } = "{}";
}
public sealed class WmsTransportTask : AggregateRoot
{
[MaxLength(64)] public string BusinessType { get; set; } = "";
public Guid? RuleId { get; set; }
public Guid SourceStorageId { get; set; }
public Guid TargetStorageId { get; set; }
public Guid ContainerId { get; set; }
public Guid? MaterialId { get; set; }
public decimal? Quantity { get; set; }
public WmsTransportTaskStatus Status { get; set; } = WmsTransportTaskStatus.Pending;
[MaxLength(64)] public string DispatchMissionId { get; set; } = "";
[MaxLength(64)] public string DeliveryId { get; set; } = "";
[MaxLength(32)] public string DispatchStatus { get; set; } = "";
[MaxLength(500)] public string Reason { get; set; } = "";
public string SnapshotJson { get; set; } = "{}";
[MaxLength(1000)] public string ErrorMessage { get; set; } = "";
public int TaskPriority { get; set; }
}
public sealed class WmsTransportReservation : AggregateRoot
{
public Guid TaskId { get; set; }
public Guid ContainerId { get; set; }
public Guid SourceStorageId { get; set; }
public Guid TargetStorageId { get; set; }
public WmsReservationStatus Status { get; set; } = WmsReservationStatus.Active;
public DateTimeOffset? ExpiresAt { get; set; }
}
public sealed class WmsTransportTaskHistory : Entity<Guid>
{
public Guid TaskId { get; set; }
[MaxLength(32)] public string FromStatus { get; set; } = "";
[MaxLength(32)] public string ToStatus { get; set; } = "";
[MaxLength(128)] public string Operator { get; set; } = "";
public DateTimeOffset OperatedAt { get; set; }
[MaxLength(500)] public string Reason { get; set; } = "";
[MaxLength(1000)] public string ErrorMessage { get; set; } = "";
public string SnapshotJson { get; set; } = "{}";
}
public static class WmsTransportTriggerTypes
{
// 复数辅助类:与枚举分离,避免与属性/类型同名冲突,并提供 ParseOr / All
public const WmsTransportTriggerType MaterialCall = WmsTransportTriggerType.MaterialCall;
public const WmsTransportTriggerType FinishedGoodsOffline = WmsTransportTriggerType.FinishedGoodsOffline;
public const WmsTransportTriggerType AutoTransfer = WmsTransportTriggerType.AutoTransfer;
public static readonly HashSet<WmsTransportTriggerType> All = new()
{ MaterialCall, FinishedGoodsOffline, AutoTransfer };
public static bool IsDefined(string? value) =>
Enum.TryParse<WmsTransportTriggerType>(value, true, out var e) && All.Contains(e);
public static WmsTransportTriggerType ParseOr(string? value, WmsTransportTriggerType fallback = WmsTransportTriggerType.MaterialCall) =>
Enum.TryParse<WmsTransportTriggerType>(value, true, out var e) && All.Contains(e) ? e : fallback;
}
public static class WmsTransportTaskStatuses
{
public const WmsTransportTaskStatus Pending = WmsTransportTaskStatus.Pending;
public const WmsTransportTaskStatus Reserved = WmsTransportTaskStatus.Reserved;
public const WmsTransportTaskStatus Dispatched = WmsTransportTaskStatus.Dispatched;
public const WmsTransportTaskStatus InTransit = WmsTransportTaskStatus.InTransit;
public const WmsTransportTaskStatus Completed = WmsTransportTaskStatus.Completed;
public const WmsTransportTaskStatus Failed = WmsTransportTaskStatus.Failed;
public const WmsTransportTaskStatus Cancelled = WmsTransportTaskStatus.Cancelled;
public static readonly HashSet<WmsTransportTaskStatus> Active = new()
{ Pending, Reserved, Dispatched, InTransit };
public static readonly HashSet<WmsTransportTaskStatus> All = new()
{ Pending, Reserved, Dispatched, InTransit, Completed, Failed, Cancelled };
}
public static class WmsReservationStatuses
{
public const WmsReservationStatus Active = WmsReservationStatus.Active;
public const WmsReservationStatus Released = WmsReservationStatus.Released;
public const WmsReservationStatus Expired = WmsReservationStatus.Expired;
}
public static class WmsDispatchStatuses
{
public const WmsDispatchStatus Dispatched = WmsDispatchStatus.Dispatched;
public const WmsDispatchStatus Failed = WmsDispatchStatus.Failed;
}
@@ -0,0 +1,35 @@
namespace MiGu.DB.Domains.Transport;
/// <summary>
/// 运输域状态/触发类型枚举。存储规则同 WmsHasConversion&lt;string&gt;,成员名 = 列值。
/// </summary>
public enum WmsTransportTriggerType
{
MaterialCall,
FinishedGoodsOffline,
AutoTransfer
}
public enum WmsTransportTaskStatus
{
Pending,
Reserved,
Dispatched,
InTransit,
Completed,
Failed,
Cancelled
}
public enum WmsReservationStatus
{
Active,
Released,
Expired
}
public enum WmsDispatchStatus
{
Dispatched,
Failed
}
@@ -0,0 +1,112 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using MiGu.DB.Domains.Dashboard;
using MiGu.DB.Domains.SimpleFields;
using MiGu.DB.Domains.Transport;
namespace MiGu.DB.Domains.Transport
{
public sealed class WmsTransportRuleConfiguration : IEntityTypeConfiguration<WmsTransportRule>
{
public void Configure(EntityTypeBuilder<WmsTransportRule> b)
{
b.ToTable("wms_transport_rules");
b.HasKey(x => x.Id);
b.HasIndex(x => x.Code).IsUnique();
b.HasIndex(x => new { x.TriggerType, x.Enabled, x.Priority });
b.Property(x => x.SourceSelectorJson).HasColumnType("text");
b.Property(x => x.TargetSelectorJson).HasColumnType("text");
b.Property(x => x.TaskOptionsJson).HasColumnType("text");
}
}
public sealed class WmsTransportTaskConfiguration : IEntityTypeConfiguration<WmsTransportTask>
{
public void Configure(EntityTypeBuilder<WmsTransportTask> b)
{
b.ToTable("wms_transport_tasks");
b.HasKey(x => x.Id);
b.HasIndex(x => x.Status);
b.HasIndex(x => x.ContainerId);
b.HasIndex(x => x.TargetStorageId);
b.Property(x => x.Quantity).HasPrecision(18, 4);
b.Property(x => x.SnapshotJson).HasColumnType("text");
}
}
public sealed class WmsTransportReservationConfiguration : IEntityTypeConfiguration<WmsTransportReservation>
{
public void Configure(EntityTypeBuilder<WmsTransportReservation> b)
{
b.ToTable("wms_transport_reservations");
b.HasKey(x => x.Id);
b.HasIndex(x => new { x.ContainerId, x.Status });
b.HasIndex(x => new { x.TargetStorageId, x.Status });
}
}
public sealed class WmsTransportTaskHistoryConfiguration : IEntityTypeConfiguration<WmsTransportTaskHistory>
{
public void Configure(EntityTypeBuilder<WmsTransportTaskHistory> b)
{
b.ToTable("wms_transport_task_history");
b.HasKey(x => x.Id);
b.Property(x => x.FromStatus).HasMaxLength(32);
b.Property(x => x.ToStatus).HasMaxLength(32);
b.Property(x => x.Operator).HasMaxLength(128);
b.Property(x => x.Reason).HasMaxLength(500);
b.Property(x => x.ErrorMessage).HasMaxLength(1000);
b.Property(x => x.SnapshotJson).HasColumnType("text");
b.HasIndex(x => x.TaskId);
b.HasIndex(x => x.OperatedAt);
}
}
}
namespace MiGu.DB.Domains.SimpleFields
{
public sealed class SimpleFieldConfiguration : IEntityTypeConfiguration<SimpleField>
{
public void Configure(EntityTypeBuilder<SimpleField> b)
{
b.ToTable("simple_fields");
b.HasKey(x => x.Id);
b.Property(x => x.Id).HasColumnName("id");
b.Property(x => x.CarType).HasColumnName("car_type").HasMaxLength(64);
b.Property(x => x.FieldType).HasColumnName("field_type").HasMaxLength(64);
b.Property(x => x.Key).HasColumnName("key").HasMaxLength(128);
b.Property(x => x.Value).HasColumnName("value");
b.Property(x => x.DataType).HasColumnName("data_type").HasMaxLength(128);
b.Property(x => x.Chinese).HasColumnName("chinese").HasMaxLength(256).IsRequired(false);
b.Property(x => x.English).HasColumnName("english").HasMaxLength(256).IsRequired(false);
b.Property(x => x.Other).HasColumnName("other").HasMaxLength(512);
b.Property(x => x.IsDefault).HasColumnName("is_default");
var dateTime = new ValueConverter<DateTimeOffset, string>(
v => SimpleFieldDateTime.ToStorage(v),
v => SimpleFieldDateTime.FromStorage(v));
b.Property(x => x.CreateTime).HasColumnName("create_time").HasConversion(dateTime).HasMaxLength(19);
b.Property(x => x.UpdateTime).HasColumnName("update_time").HasConversion(dateTime).HasMaxLength(19);
b.HasIndex(x => new { x.CarType, x.FieldType, x.Key }).IsUnique();
}
}
}
namespace MiGu.DB.Domains.Dashboard
{
public sealed class UserDashboardShortcutConfiguration : IEntityTypeConfiguration<UserDashboardShortcut>
{
public void Configure(EntityTypeBuilder<UserDashboardShortcut> b)
{
b.ToTable("user_dashboard_shortcuts");
b.HasKey(x => new { x.UserId, x.Scope });
b.Property(x => x.UserId).HasColumnName("user_id").HasMaxLength(64);
b.Property(x => x.Scope).HasColumnName("scope").HasMaxLength(32);
b.Property(x => x.KeysJson).HasColumnName("keys_json").HasColumnType("text");
var dateTime = new ValueConverter<DateTimeOffset, string>(
v => v.UtcDateTime.ToString("O"),
v => DateTimeOffset.Parse(v));
b.Property(x => x.UpdatedAt).HasColumnName("updated_at").HasConversion(dateTime).HasMaxLength(40);
}
}
}
+170
View File
@@ -0,0 +1,170 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using MiGu.DB.Domains.Dashboard;
using MiGu.DB.Domains.SimpleFields;
using MiGu.DB.Domains.Transport;
using MiGu.DB.Domains.Wms;
using MiGu.DB.Kernel.Entities;
namespace MiGu.DB.Domains.Wms;
public sealed class WarehouseConfiguration : IEntityTypeConfiguration<Warehouse>
{
public void Configure(EntityTypeBuilder<Warehouse> b)
{
b.ToTable("wms_warehouses");
b.HasKey(x => x.Id);
b.HasIndex(x => x.Code).IsUnique();
}
}
public sealed class WarehouseAreaConfiguration : IEntityTypeConfiguration<WarehouseArea>
{
public void Configure(EntityTypeBuilder<WarehouseArea> b)
{
b.ToTable("wms_areas");
b.HasKey(x => x.Id);
b.HasIndex(x => x.Code).IsUnique();
b.HasIndex(x => x.WarehouseId);
}
}
public sealed class StorageConfiguration : IEntityTypeConfiguration<Storage>
{
public void Configure(EntityTypeBuilder<Storage> b)
{
b.ToTable("wms_storages");
b.HasKey(x => x.Id);
b.HasIndex(x => x.Code).IsUnique();
b.HasIndex(x => x.AreaId);
b.HasIndex(x => x.Barcode);
}
}
public sealed class ContainerConfiguration : IEntityTypeConfiguration<Container>
{
public void Configure(EntityTypeBuilder<Container> b)
{
b.ToTable("wms_containers");
b.HasKey(x => x.Id);
b.HasIndex(x => x.Code).IsUnique();
}
}
public sealed class MaterialTypeConfiguration : IEntityTypeConfiguration<MaterialType>
{
public void Configure(EntityTypeBuilder<MaterialType> b)
{
b.ToTable("wms_material_types");
b.HasKey(x => x.Id);
b.HasIndex(x => x.Code).IsUnique();
}
}
public sealed class MaterialConfiguration : IEntityTypeConfiguration<Material>
{
public void Configure(EntityTypeBuilder<Material> b)
{
b.ToTable("wms_materials");
b.HasKey(x => x.Id);
b.HasIndex(x => x.Code).IsUnique();
b.HasIndex(x => x.Barcode);
b.HasIndex(x => new { x.LifecycleStatus, x.UpdatedAt });
b.HasIndex(x => x.TypeCode);
}
}
public sealed class ContainerLocationConfiguration : IEntityTypeConfiguration<ContainerLocation>
{
public void Configure(EntityTypeBuilder<ContainerLocation> b)
{
b.ToTable("wms_container_locations");
b.HasKey(x => x.Id);
b.HasIndex(x => x.ContainerId).IsUnique();
b.HasIndex(x => new { x.LocationType, x.LocationId });
b.HasIndex(x => x.StorageId);
}
}
public sealed class ContainerMaterialConfiguration : IEntityTypeConfiguration<ContainerMaterial>
{
public void Configure(EntityTypeBuilder<ContainerMaterial> b)
{
b.ToTable("wms_container_materials");
b.HasKey(x => x.Id);
b.HasIndex(x => x.MaterialId).IsUnique();
b.HasIndex(x => x.ContainerId);
b.Property(x => x.Quantity).HasPrecision(18, 4);
}
}
public sealed class StockEventConfiguration : IEntityTypeConfiguration<StockEvent>
{
public void Configure(EntityTypeBuilder<StockEvent> b)
{
b.ToTable("wms_stock_events");
b.HasKey(x => x.Id);
b.Property(x => x.EventType).HasMaxLength(32);
b.Property(x => x.MaterialCode).HasMaxLength(64);
b.Property(x => x.MaterialName).HasMaxLength(128);
b.Property(x => x.MaterialBarcode).HasMaxLength(128);
b.Property(x => x.MaterialTypeCode).HasMaxLength(64);
b.Property(x => x.ContainerCode).HasMaxLength(64);
b.Property(x => x.ContainerName).HasMaxLength(128);
b.Property(x => x.StorageCode).HasMaxLength(64);
b.Property(x => x.StorageName).HasMaxLength(128);
b.Property(x => x.AreaCode).HasMaxLength(64);
b.Property(x => x.FromStorageCode).HasMaxLength(64);
b.Property(x => x.FromStorageName).HasMaxLength(128);
b.Property(x => x.ToStorageCode).HasMaxLength(64);
b.Property(x => x.ToStorageName).HasMaxLength(128);
b.Property(x => x.RefType).HasMaxLength(64);
b.Property(x => x.RefCode).HasMaxLength(64);
b.Property(x => x.Operator).HasMaxLength(128);
b.Property(x => x.Reason).HasMaxLength(500);
b.HasIndex(x => x.OperatedAt);
b.HasIndex(x => x.EventType);
b.HasIndex(x => x.MaterialId);
b.HasIndex(x => x.ContainerId);
}
}
public abstract class HistoryConfigurationBase<T> : IEntityTypeConfiguration<T> where T : HistoryEntity
{
private readonly string _table;
protected HistoryConfigurationBase(string table) => _table = table;
public virtual void Configure(EntityTypeBuilder<T> b)
{
b.ToTable(_table);
b.HasKey(x => x.Id);
b.Property(x => x.EventType).HasMaxLength(64);
b.Property(x => x.BeforeJson).HasColumnType("text");
b.Property(x => x.AfterJson).HasColumnType("text");
b.Property(x => x.Operator).HasMaxLength(128);
b.Property(x => x.Source).HasMaxLength(32);
b.Property(x => x.Reason).HasMaxLength(500);
b.Property(x => x.Remark).HasMaxLength(1000);
b.Property(x => x.Extend).HasColumnType("text");
b.HasIndex(x => x.ContainerId);
b.HasIndex(x => x.OperatedAt);
b.HasIndex(x => x.EventType);
}
}
public sealed class ContainerLocationHistoryConfiguration : HistoryConfigurationBase<ContainerLocationHistory>
{
public ContainerLocationHistoryConfiguration() : base("wms_container_location_history") { }
}
public sealed class ContainerMaterialHistoryConfiguration : HistoryConfigurationBase<ContainerMaterialHistory>
{
public ContainerMaterialHistoryConfiguration() : base("wms_container_material_history") { }
public override void Configure(EntityTypeBuilder<ContainerMaterialHistory> b)
{
base.Configure(b);
b.Property(x => x.QuantityDelta).HasPrecision(18, 4);
}
}
+174
View File
@@ -0,0 +1,174 @@
using System.ComponentModel.DataAnnotations;
using MiGu.DB.Kernel.Entities;
namespace MiGu.DB.Domains.Wms;
// WMS 聚合实体。状态/类型字段为枚举(库内 TEXT,见全局 HasConversion);DTO 仍用字符串,由 *Statuses 辅助类解析。
public sealed class Warehouse : AggregateRoot
{
[MaxLength(64)] public string Code { get; set; } = "";
[MaxLength(128)] public string Name { get; set; } = "";
[MaxLength(64)] public string Type { get; set; } = "Default";
public bool Enabled { get; set; } = true;
public int SortOrder { get; set; }
}
public sealed class WarehouseArea : AggregateRoot
{
public Guid WarehouseId { get; set; }
[MaxLength(64)] public string Code { get; set; } = "";
[MaxLength(128)] public string Name { get; set; } = "";
[MaxLength(64)] public string Type { get; set; } = "Storage";
public AreaLayoutMode LayoutMode { get; set; } = AreaLayoutMode.Flat;
[MaxLength(32)] public string State { get; set; } = "Default";
public bool Enabled { get; set; } = true;
public int SortOrder { get; set; }
}
public sealed class Storage : AggregateRoot
{
public Guid AreaId { get; set; }
[MaxLength(64)] public string Code { get; set; } = "";
[MaxLength(128)] public string Name { get; set; } = "";
[MaxLength(64)] public string StorageType { get; set; } = StorageTypeCodes.Storage;
// 属性名与枚举类型同名合法;默认值引用枚举成员 LocationKind.Station(勿再引入同名静态类)
public LocationKind LocationKind { get; set; } = LocationKind.Station;
public int ColumnNo { get; set; }
public int LevelNo { get; set; } = 1;
public int DepthNo { get; set; } = 1;
[MaxLength(64)] public string SiteId { get; set; } = "";
[MaxLength(64)] public string SiteCode { get; set; } = "";
[MaxLength(128)] public string Barcode { get; set; } = "";
public int Capacity { get; set; }
public StorageStatus Status { get; set; } = StorageStatus.Empty;
[MaxLength(64)] public string Usage { get; set; } = "";
public int Priority { get; set; }
[MaxLength(64)] public string ZoneCode { get; set; } = "";
public bool AllowInbound { get; set; } = true;
public bool AllowOutbound { get; set; } = true;
public bool Enabled { get; set; } = true;
}
public sealed class Container : AggregateRoot
{
public Guid? AreaId { get; set; }
[MaxLength(64)] public string Code { get; set; } = "";
[MaxLength(128)] public string Name { get; set; } = "";
[MaxLength(64)] public string ContainerType { get; set; } = "Box";
public ContainerStatus Status { get; set; } = ContainerStatus.EmptyMaterial;
[MaxLength(128)] public string Barcode { get; set; } = "";
public double Length { get; set; }
public double Width { get; set; }
public double Height { get; set; }
public bool Enabled { get; set; } = true;
}
public sealed class MaterialType : AggregateRoot
{
[MaxLength(64)] public string Code { get; set; } = "";
[MaxLength(128)] public string Name { get; set; } = "";
[MaxLength(128)] public string Spec { get; set; } = "";
[MaxLength(32)] public string Unit { get; set; } = "pcs";
[MaxLength(64)] public string Category { get; set; } = "";
[MaxLength(64)] public string BarcodePrefix { get; set; } = "";
public bool Enabled { get; set; } = true;
}
public sealed class Material : AggregateRoot
{
[MaxLength(64)] public string Code { get; set; } = "";
[MaxLength(128)] public string Name { get; set; } = "";
[MaxLength(64)] public string TypeCode { get; set; } = "";
[MaxLength(128)] public string Barcode { get; set; } = "";
[MaxLength(128)] public string Spec { get; set; } = "";
[MaxLength(32)] public string Unit { get; set; } = "pcs";
[MaxLength(64)] public string Category { get; set; } = "";
public MaterialLifecycle LifecycleStatus { get; set; } = MaterialLifecycle.Active;
public DateTimeOffset? UnboundAt { get; set; }
public bool Enabled { get; set; } = true;
}
public sealed class ContainerLocation : AggregateRoot
{
public Guid ContainerId { get; set; }
public ContainerLocationType LocationType { get; set; } = ContainerLocationType.Storage;
[MaxLength(64)] public string LocationId { get; set; } = "";
/// <summary>LocationType=Storage 时的库位 Id;由 LocationId 回填,供库存 join 下推。</summary>
public Guid? StorageId { get; set; }
[MaxLength(64)] public string LocationCode { get; set; } = "";
[MaxLength(128)] public string LocationName { get; set; } = "";
public ContainerLocationStatus Status { get; set; } = ContainerLocationStatus.Active;
public DateTimeOffset EnteredAt { get; set; }
}
public sealed class ContainerMaterial : AggregateRoot
{
public Guid ContainerId { get; set; }
public Guid MaterialId { get; set; }
public decimal Quantity { get; set; } = 1;
[MaxLength(64)] public string BatchNo { get; set; } = "";
[MaxLength(64)] public string SerialNo { get; set; } = "";
public ContainerMaterialStatus Status { get; set; } = ContainerMaterialStatus.Bound;
public DateTimeOffset BoundAt { get; set; }
public DateTimeOffset LoadedAt { get; set; }
public DateTimeOffset? UnloadedAt { get; set; }
}
public sealed class StockEvent : Kernel.Entities.Entity<Guid>
{
public StockEventType EventType { get; set; }
public Guid? MaterialId { get; set; }
[MaxLength(64)] public string MaterialCode { get; set; } = "";
[MaxLength(128)] public string MaterialName { get; set; } = "";
[MaxLength(128)] public string MaterialBarcode { get; set; } = "";
[MaxLength(64)] public string MaterialTypeCode { get; set; } = "";
public Guid? ContainerId { get; set; }
[MaxLength(64)] public string ContainerCode { get; set; } = "";
[MaxLength(128)] public string ContainerName { get; set; } = "";
public Guid? StorageId { get; set; }
[MaxLength(64)] public string StorageCode { get; set; } = "";
[MaxLength(128)] public string StorageName { get; set; } = "";
[MaxLength(64)] public string AreaCode { get; set; } = "";
public Guid? FromStorageId { get; set; }
[MaxLength(64)] public string FromStorageCode { get; set; } = "";
[MaxLength(128)] public string FromStorageName { get; set; } = "";
public Guid? ToStorageId { get; set; }
[MaxLength(64)] public string ToStorageCode { get; set; } = "";
[MaxLength(128)] public string ToStorageName { get; set; } = "";
[MaxLength(64)] public string RefType { get; set; } = "Manual";
public Guid? RefId { get; set; }
[MaxLength(64)] public string RefCode { get; set; } = "";
[MaxLength(128)] public string Operator { get; set; } = "";
public DateTimeOffset OperatedAt { get; set; }
[MaxLength(500)] public string Reason { get; set; } = "";
}
public sealed class ContainerLocationHistory : HistoryEntity
{
[MaxLength(32)] public string FromLocationType { get; set; } = "";
[MaxLength(64)] public string FromLocationId { get; set; } = "";
[MaxLength(32)] public string ToLocationType { get; set; } = "";
[MaxLength(64)] public string ToLocationId { get; set; } = "";
}
public sealed class ContainerMaterialHistory : HistoryEntity
{
public Guid? MaterialId { get; set; }
public decimal QuantityDelta { get; set; }
}
public static class StorageTypeCodes
{
public const string Storage = "Storage";
public const string LineSide = "LineSide";
public const string OfflinePoint = "OfflinePoint";
public const string Buffer = "Buffer";
public const string FinishedGoods = "FinishedGoods";
}
public static class WmsDefaults
{
public const string DefaultWarehouseCode = "DEFAULT";
public const string DefaultWarehouseName = "默认仓库";
}
+66
View File
@@ -0,0 +1,66 @@
namespace MiGu.DB.Domains.Wms;
/// <summary>
/// WMS 状态/类型枚举。列以字符串存储(见约定 HasConversion&lt;string&gt;),成员名即库内合法值。
/// 旧别名(Available/Idle/Occupied 等)不进入枚举,由 LegacyStatusNormalizationMigrator 刷库。
/// </summary>
public enum AreaLayoutMode
{
Flat,
Grid
}
public enum LocationKind
{
Grid,
Station
}
public enum StorageStatus
{
Empty,
EmptyContainer,
FullContainer,
Disabled
}
public enum ContainerStatus
{
EmptyMaterial,
FullMaterial
}
public enum ContainerLocationType
{
Storage,
Car
}
public enum ContainerLocationStatus
{
Active,
Locked,
Exception
}
public enum ContainerMaterialStatus
{
Bound,
Loaded,
Unloaded,
Adjusted,
Frozen
}
public enum MaterialLifecycle
{
Active,
Archived
}
public enum StockEventType
{
Bind,
Unbind,
ContainerMove
}
+116
View File
@@ -0,0 +1,116 @@
namespace MiGu.DB.Domains.Wms;
/// <summary>
/// 枚举辅助类(复数命名):承接 API/DTO 的字符串入参,解析为实体上的枚举。
/// 与同名枚举分离,避免「属性 LocationKind 初始值引用实例属性」一类编译冲突。
/// Server 侧通过 global using 别名(如 StorageStatuses)引用本文件类型。
/// </summary>
public static class AreaLayoutModes
{
public static readonly HashSet<AreaLayoutMode> All = new() { AreaLayoutMode.Flat, AreaLayoutMode.Grid };
public static AreaLayoutMode ParseOr(string? value, AreaLayoutMode fallback = AreaLayoutMode.Flat) =>
Enum.TryParse<AreaLayoutMode>(value, true, out var e) && All.Contains(e) ? e : fallback;
}
public static class LocationKinds
{
public const LocationKind Grid = LocationKind.Grid;
public const LocationKind Station = LocationKind.Station;
public static readonly HashSet<LocationKind> All = new() { Grid, Station };
public static LocationKind ParseOr(string? value, LocationKind fallback = LocationKind.Station) =>
Enum.TryParse<LocationKind>(value, true, out var e) && All.Contains(e) ? e : fallback;
}
public static class StorageStatuses
{
public const StorageStatus Empty = StorageStatus.Empty;
public const StorageStatus EmptyContainer = StorageStatus.EmptyContainer;
public const StorageStatus FullContainer = StorageStatus.FullContainer;
public const StorageStatus Disabled = StorageStatus.Disabled;
public static readonly HashSet<StorageStatus> All = new()
{ Empty, EmptyContainer, FullContainer, Disabled };
/// <summary>
/// 写路径归一:兼容历史字符串 Available/Idle/Occupied。
/// 库内残留旧值须靠 DataMigrator 刷掉,不能依赖 converter 读侧归一(WHERE 会漏行)。
/// </summary>
public static StorageStatus Normalize(string? status) => status switch
{
"Available" or "Idle" => Empty,
"Occupied" => FullContainer,
_ when Enum.TryParse<StorageStatus>(status, true, out var e) && All.Contains(e) => e,
_ => Empty
};
}
public static class ContainerStatuses
{
public const ContainerStatus EmptyMaterial = ContainerStatus.EmptyMaterial;
public const ContainerStatus FullMaterial = ContainerStatus.FullMaterial;
public static readonly HashSet<ContainerStatus> All = new() { EmptyMaterial, FullMaterial };
public static ContainerStatus ParseOr(string? value, ContainerStatus fallback = ContainerStatus.EmptyMaterial) =>
Enum.TryParse<ContainerStatus>(value, true, out var e) && All.Contains(e) ? e : fallback;
}
public static class ContainerLocationTypes
{
public const ContainerLocationType Storage = ContainerLocationType.Storage;
public const ContainerLocationType Car = ContainerLocationType.Car;
public static readonly HashSet<ContainerLocationType> All = new() { Storage, Car };
public static bool IsDefined(string? value) =>
Enum.TryParse<ContainerLocationType>(value, true, out var e) && All.Contains(e);
public static ContainerLocationType ParseOr(string? value, ContainerLocationType fallback = ContainerLocationType.Storage) =>
Enum.TryParse<ContainerLocationType>(value, true, out var e) && All.Contains(e) ? e : fallback;
public static bool EqualsString(ContainerLocationType value, string? other) =>
Enum.TryParse<ContainerLocationType>(other, true, out var e) && e == value;
}
public static class ContainerLocationStatuses
{
public const ContainerLocationStatus Active = ContainerLocationStatus.Active;
public const ContainerLocationStatus Locked = ContainerLocationStatus.Locked;
public const ContainerLocationStatus Exception = ContainerLocationStatus.Exception;
public static readonly HashSet<ContainerLocationStatus> All = new() { Active, Locked, Exception };
public static bool IsDefined(string? value) =>
Enum.TryParse<ContainerLocationStatus>(value, true, out var e) && All.Contains(e);
public static ContainerLocationStatus ParseOr(string? value, ContainerLocationStatus fallback = ContainerLocationStatus.Active) =>
Enum.TryParse<ContainerLocationStatus>(value, true, out var e) && All.Contains(e) ? e : fallback;
}
public static class ContainerMaterialStatuses
{
public const ContainerMaterialStatus Bound = ContainerMaterialStatus.Bound;
public const ContainerMaterialStatus Loaded = ContainerMaterialStatus.Loaded;
public const ContainerMaterialStatus Unloaded = ContainerMaterialStatus.Unloaded;
public const ContainerMaterialStatus Adjusted = ContainerMaterialStatus.Adjusted;
public const ContainerMaterialStatus Frozen = ContainerMaterialStatus.Frozen;
public static readonly HashSet<ContainerMaterialStatus> ActiveBind = new()
{ Bound, Loaded, Adjusted, Frozen };
}
public static class MaterialLifecycles
{
public const MaterialLifecycle Active = MaterialLifecycle.Active;
public const MaterialLifecycle Archived = MaterialLifecycle.Archived;
public static readonly HashSet<MaterialLifecycle> All = new() { Active, Archived };
public static MaterialLifecycle ParseOr(string? value, MaterialLifecycle fallback = MaterialLifecycle.Active) =>
Enum.TryParse<MaterialLifecycle>(value, true, out var e) && All.Contains(e) ? e : fallback;
}
public static class StockEventTypes
{
public const StockEventType Bind = StockEventType.Bind;
public const StockEventType Unbind = StockEventType.Unbind;
public const StockEventType ContainerMove = StockEventType.ContainerMove;
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore;
using MiGu.DB.Domains.Dashboard;
using MiGu.DB.Domains.SimpleFields;
using MiGu.DB.Domains.Transport;
using MiGu.DB.Domains.Wms;
namespace MiGu.DB.Kernel.Context;
public partial class MiGuDbContext
{
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>();
}
+25
View File
@@ -0,0 +1,25 @@
using Microsoft.EntityFrameworkCore;
using MiGu.DB.Abstractions.Modules;
using MiGu.DB.Kernel.Conventions;
namespace MiGu.DB.Kernel.Context;
public partial class MiGuDbContext : DbContext
{
private readonly IEnumerable<IEntityModule> _modules;
public MiGuDbContext(DbContextOptions<MiGuDbContext> options, IEnumerable<IEntityModule>? modules = null)
: base(options)
{
_modules = modules ?? Array.Empty<IEntityModule>();
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// 先约定、后 Configuration,让实体级配置覆盖全局 Guid/时间转换(如 SimpleField
ModelConventionExtensions.ApplyMiGuConventions(modelBuilder);
modelBuilder.ApplyConfigurationsFromAssembly(typeof(MiGuDbContext).Assembly);
foreach (var module in _modules)
module.ConfigureModel(modelBuilder);
}
}
+167
View File
@@ -0,0 +1,167 @@
using System.Linq.Expressions;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using MiGu.DB.Abstractions.Entities;
using MiGu.DB.Abstractions.Runtime;
namespace MiGu.DB.Kernel.Conventions;
/// <summary>
/// 全局模型约定:Guid/DateTimeOffset 字符串化、枚举存字符串、软删过滤、Version 并发令牌等。
/// </summary>
public static class ModelConventionExtensions
{
public static void ApplyMiGuConventions(ModelBuilder modelBuilder)
{
// 与历史 Sqlite TEXT 列兼容:Guid / DateTimeOffset 均以字符串落库
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 dto = new ValueConverter<DateTimeOffset, string>(
v => v.UtcDateTime.ToString("O"),
v => DateTimeOffset.Parse(v));
var nullableDto = new ValueConverter<DateTimeOffset?, string?>(
v => v.HasValue ? v.Value.UtcDateTime.ToString("O") : null,
v => string.IsNullOrWhiteSpace(v) ? null : DateTimeOffset.Parse(v));
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
var clr = entityType.ClrType;
foreach (var p in clr.GetProperties().Where(p => p.PropertyType == typeof(Guid)))
modelBuilder.Entity(clr).Property(p.Name).HasConversion(guid).HasMaxLength(36);
foreach (var p in clr.GetProperties().Where(p => p.PropertyType == typeof(Guid?)))
modelBuilder.Entity(clr).Property(p.Name).HasConversion(nullableGuid).HasMaxLength(36);
foreach (var p in clr.GetProperties().Where(p => p.PropertyType == typeof(DateTimeOffset)))
modelBuilder.Entity(clr).Property(p.Name).HasConversion(dto).HasMaxLength(40);
foreach (var p in clr.GetProperties().Where(p => p.PropertyType == typeof(DateTimeOffset?)))
modelBuilder.Entity(clr).Property(p.Name).HasConversion(nullableDto).HasMaxLength(40);
// 严格 1:1 字符串转换;旧值兼容靠 IDataMigrator,禁止在 converter 读侧 Normalize
foreach (var p in clr.GetProperties().Where(p => p.PropertyType.IsEnum))
modelBuilder.Entity(clr).Property(p.Name).HasConversion<string>().HasMaxLength(32);
if (typeof(ISoftDeletable).IsAssignableFrom(clr))
{
var method = typeof(ModelConventionExtensions)
.GetMethod(nameof(SetSoftDeleteFilter), System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!
.MakeGenericMethod(clr);
method.Invoke(null, new object[] { modelBuilder });
}
// Version:业务乐观锁(非数据库 rowversion 字节数组)
if (typeof(IVersioned).IsAssignableFrom(clr))
modelBuilder.Entity(clr).Property(nameof(IVersioned.Version)).IsConcurrencyToken();
if (typeof(ILockable).IsAssignableFrom(clr))
modelBuilder.Entity(clr).Property(nameof(ILockable.IsLock)).HasColumnName("IsLock");
if (typeof(IAuditable).IsAssignableFrom(clr))
{
modelBuilder.Entity(clr).Property(nameof(IAuditable.CreatedBy)).HasMaxLength(128);
modelBuilder.Entity(clr).Property(nameof(IAuditable.UpdatedBy)).HasMaxLength(128);
}
if (typeof(ISoftDeletable).IsAssignableFrom(clr))
modelBuilder.Entity(clr).Property(nameof(ISoftDeletable.DeletedBy)).HasMaxLength(128);
if (typeof(IRemarkable).IsAssignableFrom(clr))
modelBuilder.Entity(clr).Property(nameof(IRemarkable.Remark)).HasMaxLength(1000);
if (typeof(IExtendable).IsAssignableFrom(clr))
modelBuilder.Entity(clr).Property(nameof(IExtendable.Extend)).HasColumnType("text");
}
}
private static void SetSoftDeleteFilter<TEntity>(ModelBuilder modelBuilder)
where TEntity : class, ISoftDeletable
=> modelBuilder.Entity<TEntity>().HasQueryFilter(e => !e.IsDeleted);
}
public sealed class ActorContextAccessor : IActorContextAccessor
{
private static readonly AsyncLocal<IActorContext?> CurrentContext = new();
public IActorContext Current
{
get => CurrentContext.Value ?? SystemActorContext.Instance;
set => CurrentContext.Value = value;
}
}
public sealed class SystemActorContext : IActorContext
{
public static readonly SystemActorContext Instance = new();
public string Name => "system";
}
public sealed class AuditSaveChangesInterceptor : SaveChangesInterceptor
{
private readonly IActorContextAccessor _actors;
public AuditSaveChangesInterceptor(IActorContextAccessor actors) => _actors = actors;
public override InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
{
Stamp(eventData.Context);
return base.SavingChanges(eventData, result);
}
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
{
Stamp(eventData.Context);
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
private void Stamp(DbContext? db)
{
if (db is null) return;
var now = DateTimeOffset.UtcNow;
var actor = _actors.Current.Name;
foreach (var entry in db.ChangeTracker.Entries())
{
if (entry.Entity is IAuditable auditable)
{
if (entry.State == EntityState.Added)
{
if (entry.Entity is IEntity<Guid> { Id: var id } && id == Guid.Empty)
((IEntity<Guid>)entry.Entity).Id = Guid.NewGuid();
auditable.CreatedAt = now;
auditable.UpdatedAt = now;
if (string.IsNullOrWhiteSpace(auditable.CreatedBy)) auditable.CreatedBy = actor;
if (string.IsNullOrWhiteSpace(auditable.UpdatedBy)) auditable.UpdatedBy = actor;
}
else if (entry.State == EntityState.Modified)
{
auditable.UpdatedAt = now;
auditable.UpdatedBy = actor;
}
}
if (entry.Entity is IVersioned versioned)
{
if (entry.State == EntityState.Added)
versioned.Version = Math.Max(1, versioned.Version);
else if (entry.State == EntityState.Modified)
versioned.Version += 1;
}
if (entry.Entity is IExtendable extendable && string.IsNullOrWhiteSpace(extendable.Extend))
extendable.Extend = "{}";
if (entry.Entity is ISoftDeletable soft
&& entry.State == EntityState.Modified
&& entry.Property(nameof(ISoftDeletable.IsDeleted)).IsModified
&& soft.IsDeleted)
{
soft.DeletedAt ??= now;
if (string.IsNullOrWhiteSpace(soft.DeletedBy)) soft.DeletedBy = actor;
}
}
}
}
@@ -0,0 +1,20 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using MiGu.DB.Kernel.Context;
namespace MiGu.DB.Kernel.Design;
/// <summary>
/// design-time 工厂(dotnet ef migrations add)。指定 MigrationsAssembly,与运行时 SqliteProviderSetup 一致。
/// </summary>
public sealed class MiGuDbContextFactory : IDesignTimeDbContextFactory<MiGuDbContext>
{
public MiGuDbContext CreateDbContext(string[] args)
{
var options = new DbContextOptionsBuilder<MiGuDbContext>()
.UseSqlite("Data Source=platform.db", o =>
o.MigrationsAssembly(typeof(MiGuDbContext).Assembly.GetName().Name))
.Options;
return new MiGuDbContext(options);
}
}
+46
View File
@@ -0,0 +1,46 @@
using MiGu.DB.Abstractions.Entities;
namespace MiGu.DB.Kernel.Entities;
public abstract class Entity<TKey> : IEntity<TKey>
{
public TKey Id { get; set; } = default!;
}
public abstract class AuditedEntity : Entity<Guid>, IAuditable
{
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public string CreatedBy { get; set; } = "";
public string UpdatedBy { get; set; } = "";
}
public abstract class SoftDeletableEntity : AuditedEntity, ISoftDeletable, IVersioned, ILockable
{
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 abstract class AggregateRoot : SoftDeletableEntity, IRemarkable, IExtendable
{
public string Remark { get; set; } = "";
public string Extend { get; set; } = "{}";
}
public abstract class HistoryEntity : Entity<Guid>, IHistoryEntry
{
public Guid? RelationId { get; set; }
public string EventType { get; set; } = "";
public string BeforeJson { get; set; } = "{}";
public string AfterJson { get; set; } = "{}";
public Guid? ContainerId { get; set; }
public string Operator { get; set; } = "";
public DateTimeOffset OperatedAt { get; set; }
public string Source { get; set; } = "Manual";
public string Reason { get; set; } = "";
public string Remark { get; set; } = "";
public string Extend { get; set; } = "{}";
}
@@ -0,0 +1,224 @@
using Microsoft.Extensions.Logging;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using MiGu.DB.Abstractions.Modules;
using MiGu.DB.Abstractions.Persistence;
using MiGu.DB.Abstractions.Providers;
using MiGu.DB.Abstractions.Runtime;
using MiGu.DB.Kernel.Context;
using MiGu.DB.Kernel.Conventions;
using MiGu.DB.Kernel.Providers;
using MiGu.DB.Kernel.Repositories;
using MiGu.DB.Domains.Migrators;
namespace MiGu.DB.Kernel.Hosting;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddMiGuDb(
this IServiceCollection services,
IConfiguration configuration,
Action<MiGuDbOptions>? configure = null)
{
var options = new MiGuDbOptions
{
Provider = configuration["Database:Provider"] ?? "sqlite",
ConnectionString = configuration.GetConnectionString("Platform")
?? configuration.GetConnectionString(ConnectionKey(configuration["Database:Provider"] ?? "sqlite"))
?? "",
SchemaMode = ParseSchemaMode(configuration["Database:SchemaMode"]),
ApplyDataMigratorsOnStartup = configuration.GetValue("Database:ApplyDataMigratorsOnStartup", true)
};
configure?.Invoke(options);
services.AddSingleton(options);
services.TryAddSingleton<IActorContextAccessor, ActorContextAccessor>();
services.TryAddScoped<IActorContext>(sp => sp.GetRequiredService<IActorContextAccessor>().Current);
services.AddSingleton<AuditSaveChangesInterceptor>();
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDbProviderSetup, SqliteProviderSetup>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDbProviderSetup, MySqlProviderSetup>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDbProviderSetup, NpgsqlProviderSetup>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDbProviderSetup, SqlServerProviderSetup>());
services.AddDbContext<MiGuDbContext>((sp, builder) =>
{
var opt = sp.GetRequiredService<MiGuDbOptions>();
if (string.IsNullOrWhiteSpace(opt.ContentRootPath))
{
var env = sp.GetService<IHostEnvironment>();
opt.ContentRootPath = env?.ContentRootPath ?? AppContext.BaseDirectory;
}
var providerName = NormalizeProviderName(opt.Provider);
var setup = sp.GetServices<IDbProviderSetup>()
.FirstOrDefault(p => string.Equals(p.Name, providerName, StringComparison.OrdinalIgnoreCase))
?? throw new InvalidOperationException($"未知数据库 Provider: {opt.Provider}");
setup.Configure(builder, ResolveConnectionString(opt, providerName));
builder.AddInterceptors(sp.GetRequiredService<AuditSaveChangesInterceptor>());
});
services.AddScoped<IUnitOfWork, UnitOfWork>();
services.AddScoped(typeof(IRepository<,>), typeof(Repository<,>));
services.AddScoped(typeof(IEditableRepository<>), typeof(EditableRepository<>));
services.AddScoped(typeof(IHistoryRepository<>), typeof(HistoryRepository<>));
services.AddMiGuDataMigrators();
return services;
}
public static IServiceCollection AddMiGuEntityModule<TModule>(this IServiceCollection services)
where TModule : class, IEntityModule, new()
{
services.TryAddEnumerable(ServiceDescriptor.Singleton<IEntityModule, TModule>());
new TModule().RegisterServices(services);
return services;
}
private static string NormalizeProviderName(string provider) => provider.Trim().ToLowerInvariant() switch
{
"postgres" or "postgresql" => "npgsql",
"mssql" => "sqlserver",
var x => x
};
private static MiGuSchemaMode ParseSchemaMode(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return MiGuSchemaMode.Migrate;
if (Enum.TryParse<MiGuSchemaMode>(value.Trim(), ignoreCase: true, out var mode))
return mode;
// 兼容简写
return value.Trim().ToLowerInvariant() switch
{
"ensure" or "created" or "ensure-created" => MiGuSchemaMode.EnsureCreated,
"migrations" or "ef" => MiGuSchemaMode.Migrate,
_ => throw new InvalidOperationException(
$"未知 Database:SchemaMode '{value}',允许值:Migrate、EnsureCreated")
};
}
private static string ConnectionKey(string provider) => NormalizeProviderName(provider) switch
{
"npgsql" => "PostgreSQL",
"sqlserver" => "SqlServer",
"mysql" => "MySql",
_ => "Sqlite"
};
private static string ResolveConnectionString(MiGuDbOptions options, string providerName)
{
if (!string.IsNullOrWhiteSpace(options.ConnectionString))
{
return providerName == "sqlite"
? NormalizeSqliteConnection(options.ConnectionString, options.ContentRootPath)
: options.ConnectionString;
}
var dataDir = Path.Combine(options.ContentRootPath, "data");
Directory.CreateDirectory(dataDir);
return $"Data Source={Path.Combine(dataDir, "platform.db")}";
}
private static string NormalizeSqliteConnection(string connection, string contentRoot)
{
var builder = new SqliteConnectionStringBuilder(connection);
if (string.IsNullOrWhiteSpace(builder.DataSource) || builder.DataSource is ":memory:")
return connection;
if (!Path.IsPathRooted(builder.DataSource))
builder.DataSource = Path.Combine(contentRoot, builder.DataSource);
var dir = Path.GetDirectoryName(builder.DataSource);
if (!string.IsNullOrWhiteSpace(dir)) Directory.CreateDirectory(dir);
return builder.ToString();
}
}
public static class DatabaseInitializer
{
/// <summary>
/// 启动期数据库初始化:按 SchemaMode 建库/迁移,再可选执行 DataMigrator。
/// </summary>
public static async Task MigrateMiGuDbAsync(this IServiceProvider services, CancellationToken ct = default)
{
using var scope = services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<MiGuDbContext>();
var options = scope.ServiceProvider.GetRequiredService<MiGuDbOptions>();
var logger = scope.ServiceProvider.GetService<ILoggerFactory>()?.CreateLogger("MiGu.DB.DatabaseInitializer");
if (options.SchemaMode == MiGuSchemaMode.EnsureCreated)
{
logger?.LogWarning(
"Database:SchemaMode=EnsureCreated:仅按当前模型建库,不应用 Migrations。" +
"改实体后若结构未更新,请删除平台库文件后重启。发版请改用 Migrate。");
await db.Database.EnsureCreatedAsync(ct);
}
else
{
var applied = (await db.Database.GetAppliedMigrationsAsync(ct)).ToList();
var pending = (await db.Database.GetPendingMigrationsAsync(ct)).ToList();
if (applied.Count == 0 && pending.Count == 0)
{
logger?.LogWarning("程序集内无 Migration,回退 EnsureCreated。");
await db.Database.EnsureCreatedAsync(ct);
}
else
{
// 旧库(EnsureCreated)无历史表行时写入基线,再 Migrate
await BaselineExistingDatabaseAsync(db, logger, ct);
await db.Database.MigrateAsync(ct);
}
}
if (!options.ApplyDataMigratorsOnStartup) return;
foreach (var migrator in scope.ServiceProvider.GetServices<IDataMigrator>().OrderBy(m => m.Order))
await migrator.MigrateAsync(db, ct);
}
/// <summary>
/// 已有表结构但无 __EFMigrationsHistory 时:将<strong>全部</strong> pending Migration 记为已应用。
/// 前提:库由 EnsureCreated 按「当前模型 tip」建成,与最新 Snapshot 一致;否则应删库后走 Migrate,或手工对齐。
/// </summary>
private static async Task BaselineExistingDatabaseAsync(
MiGuDbContext db, ILogger? logger, CancellationToken ct)
{
var applied = await db.Database.GetAppliedMigrationsAsync(ct);
if (applied.Any()) return;
var pending = (await db.Database.GetPendingMigrationsAsync(ct)).ToList();
if (pending.Count == 0) return;
var creator = db.GetService<IRelationalDatabaseCreator>();
if (!await creator.ExistsAsync(ct) || !await creator.HasTablesAsync(ct))
return;
var history = db.GetService<IHistoryRepository>();
var productVersion = ProductInfo.GetEFCoreVersion();
logger?.LogWarning(
"检测到已有表且无迁移历史,将 {Count} 个 pending Migration 全部写入基线(假定库结构已对齐模型 tip):{Ids}",
pending.Count, string.Join(", ", pending));
foreach (var migrationId in pending)
{
var sql = history.GetInsertScript(new HistoryRow(migrationId, productVersion));
await db.Database.ExecuteSqlRawAsync(sql, ct);
}
}
}
internal static class ProductInfo
{
public static string GetEFCoreVersion()
{
var asm = typeof(DbContext).Assembly.GetName().Version;
return asm?.ToString(3) ?? "8.0.10";
}
}
@@ -0,0 +1,47 @@
using Microsoft.EntityFrameworkCore;
using MiGu.DB.Abstractions.Providers;
using MiGu.DB.Kernel.Context;
namespace MiGu.DB.Kernel.Providers;
public sealed class SqliteProviderSetup : IDbProviderSetup
{
public string Name => "sqlite";
public string MigrationsAssemblyName => typeof(MiGuDbContext).Assembly.GetName().Name!;
public string MigrationsNamespace => "MiGu.DB.Migrations.Sqlite";
public void Configure(DbContextOptionsBuilder builder, string connectionString)
=> builder.UseSqlite(connectionString, o => o.MigrationsAssembly(MigrationsAssemblyName)
.MigrationsHistoryTable("__EFMigrationsHistory"));
}
public sealed class MySqlProviderSetup : IDbProviderSetup
{
public string Name => "mysql";
public string MigrationsAssemblyName => typeof(MiGuDbContext).Assembly.GetName().Name!;
public string MigrationsNamespace => "MiGu.DB.Migrations.MySql";
public void Configure(DbContextOptionsBuilder builder, string connectionString)
=> builder.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString),
o => o.MigrationsAssembly(MigrationsAssemblyName));
}
public sealed class NpgsqlProviderSetup : IDbProviderSetup
{
public string Name => "npgsql";
public string MigrationsAssemblyName => typeof(MiGuDbContext).Assembly.GetName().Name!;
public string MigrationsNamespace => "MiGu.DB.Migrations.Npgsql";
public void Configure(DbContextOptionsBuilder builder, string connectionString)
=> builder.UseNpgsql(connectionString, o => o.MigrationsAssembly(MigrationsAssemblyName));
}
public sealed class SqlServerProviderSetup : IDbProviderSetup
{
public string Name => "sqlserver";
public string MigrationsAssemblyName => typeof(MiGuDbContext).Assembly.GetName().Name!;
public string MigrationsNamespace => "MiGu.DB.Migrations.SqlServer";
public void Configure(DbContextOptionsBuilder builder, string connectionString)
=> builder.UseSqlServer(connectionString, o => o.MigrationsAssembly(MigrationsAssemblyName));
}
+116
View File
@@ -0,0 +1,116 @@
using System.Linq.Expressions;
using Microsoft.EntityFrameworkCore;
using MiGu.DB.Abstractions.Entities;
using MiGu.DB.Abstractions.Exceptions;
using MiGu.DB.Abstractions.Persistence;
using MiGu.DB.Abstractions.Runtime;
using MiGu.DB.Kernel.Context;
namespace MiGu.DB.Kernel.Repositories;
public class Repository<TEntity, TKey> : IRepository<TEntity, TKey>
where TEntity : class, IEntity<TKey>
{
protected readonly MiGuDbContext Db;
protected DbSet<TEntity> Set => Db.Set<TEntity>();
public Repository(MiGuDbContext db) => Db = db;
public virtual IQueryable<TEntity> Query(bool asNoTracking = true)
=> asNoTracking ? Set.AsNoTracking() : Set.AsQueryable();
public virtual Task<TEntity?> FindAsync(TKey id, CancellationToken ct = default)
=> Set.FindAsync([id], ct).AsTask();
public virtual Task AddAsync(TEntity entity, CancellationToken ct = default)
=> Set.AddAsync(entity, ct).AsTask();
public virtual void Update(TEntity entity) => Set.Update(entity);
}
public sealed class EditableRepository<TEntity> : Repository<TEntity, Guid>, IEditableRepository<TEntity>
where TEntity : class, IEntity<Guid>, ISoftDeletable, IVersioned, ILockable
{
private readonly IActorContextAccessor _actors;
public EditableRepository(MiGuDbContext db, IActorContextAccessor actors) : base(db)
=> _actors = actors;
public async Task<TEntity> GetEditableAsync(Guid id, long? expectedVersion, CancellationToken ct = default)
{
var entity = await Set.FirstOrDefaultAsync(x => x.Id.Equals(id), ct)
?? throw new EntityNotFoundException(typeof(TEntity).Name, id);
if (entity.IsLock)
throw new EntityLockedException(typeof(TEntity).Name, id);
if (expectedVersion.HasValue && entity.Version != expectedVersion.Value)
throw new ConcurrencyConflictException(typeof(TEntity).Name, id, expectedVersion);
return entity;
}
public async Task SoftDeleteAsync(Guid id, long? expectedVersion, CancellationToken ct = default)
{
var entity = await GetEditableAsync(id, expectedVersion, ct);
entity.IsDeleted = true;
entity.DeletedAt = DateTimeOffset.UtcNow;
entity.DeletedBy = _actors.Current.Name;
if (entity is IAuditable auditable)
auditable.UpdatedBy = _actors.Current.Name;
}
public async Task EnsureUniqueAsync(Expression<Func<TEntity, bool>> predicate, string errorMessage, CancellationToken ct = default)
{
if (await Set.AnyAsync(predicate, ct))
throw new InvalidOperationException(errorMessage);
}
}
public sealed class HistoryRepository<TEntity> : Repository<TEntity, Guid>, IHistoryRepository<TEntity>
where TEntity : class, IEntity<Guid>, IHistoryEntry
{
public HistoryRepository(MiGuDbContext db) : base(db) { }
public Task AppendAsync(TEntity entry, CancellationToken ct = default)
=> AddAsync(entry, ct);
}
public sealed class UnitOfWork : IUnitOfWork
{
private readonly MiGuDbContext _db;
public UnitOfWork(MiGuDbContext db) => _db = db;
public async Task<int> SaveChangesAsync(CancellationToken ct = default)
{
try
{
return await _db.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException ex)
{
var entry = ex.Entries.FirstOrDefault();
throw new ConcurrencyConflictException(
entry?.Entity.GetType().Name ?? "Unknown",
entry?.Property("Id")?.CurrentValue);
}
}
public Task ExecuteInTransactionAsync(Func<CancellationToken, Task> action, CancellationToken ct = default)
{
var strategy = _db.Database.CreateExecutionStrategy();
return strategy.ExecuteAsync(async () =>
{
await using var tx = await _db.Database.BeginTransactionAsync(ct);
try
{
await action(ct);
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
}
catch
{
await tx.RollbackAsync(ct);
throw;
}
});
}
}
+221
View File
@@ -0,0 +1,221 @@
# MiGu.DB Migrations 使用手册
面向日常改表、发版与排错。当前默认 Provider 为 **Sqlite**,迁移目录为 `Migrations/Sqlite/`
---
## 1. 概念速览
| 概念 | 说明 |
|------|------|
| Migration | 一次 Schema 变更(建表/加列/索引等),对应一对 `*_Name.cs` + `*_Name.Designer.cs` |
| ModelSnapshot | `MiGuDbContextModelSnapshot.cs`,当前模型总快照;下次 `add` 时与代码模型做 diff |
| `__EFMigrationsHistory` | 数据库内表,记录已应用的 Migration Id |
| DataMigrator | **数据**修补(刷旧状态、回填列),不是 Schema;启动时在 Migrate 之后执行 |
**原则:Schema 只走 Migrations;业务代码禁止手写 CREATE/ALTER。**
三个文件职责:
- `YYYYMMDDHHMMSS_Name.cs``Up()`/`Down()`,真正改库
- `YYYYMMDDHHMMSS_Name.Designer.cs` → 该次迁移的目标模型元数据(勿手改)
- `MiGuDbContextModelSnapshot.cs` → 全库最新快照(勿手改,除非处理合并冲突)
---
## 2. 环境准备
### 2.1 工具
仓库根目录(`Migu2.0`)执行:
```powershell
# 全局工具(任选)
dotnet tool install --global dotnet-ef --version 8.0.10
# 或本地工具目录(本仓库曾用此方式)
dotnet tool install dotnet-ef --version 8.0.10 --tool-path .\.tools
.\.tools\dotnet-ef --version
```
版本需与项目 EF Core **8.0.x** 对齐。
### 2.2 工程关系
| 参数 | 值 |
|------|-----|
| 迁移所在工程 | `MiGu.DB` |
| 启动工程 | `MiGu.Server`(提供配置与 Design 包) |
| DbContext | `MiGu.DB.Kernel.Context.MiGuDbContext` |
| Design-time 工厂 | `MiGu.DB.Kernel.Design.MiGuDbContextFactory` |
`MiGu.Server.csproj` 已引用 `Microsoft.EntityFrameworkCore.Design``MiGu.DB` 含 Sqlite 等 Provider 包。
---
## 3. 生成 Migration(日常流程)
### 3.1 改模型
`MiGu.DB/Domains` 改实体或 `IEntityTypeConfiguration`,保存后编译通过:
```powershell
dotnet build .\MiGu.DB\MiGu.DB.csproj
```
### 3.2 添加迁移
在**解决方案根目录**执行(PowerShell):
```powershell
dotnet ef migrations add <迁移名称> `
--project .\MiGu.DB\MiGu.DB.csproj `
--startup-project .\MiGu.Server\MiGu.Server.csproj `
--output-dir Migrations/Sqlite `
--namespace MiGu.DB.Migrations.Sqlite `
--context MiGuDbContext
```
命名建议(PascalCase,无空格):
| 场景 | 示例名 |
|------|--------|
| 首库 | `InitialPlatform`(已存在,勿重复) |
| 加表 | `AddWmsXxxTable` |
| 加列 | `AddStoragePriorityColumn` |
| 加索引 | `AddStockEventOperatedAtIndex` |
### 3.3 生成后检查(必做)
1. 新文件应在:`MiGu.DB/Migrations/Sqlite/`
2. 打开 `*_Name.cs`,确认 `Up()` 只包含**本次预期**变更(无误删表、无多余重建)
3. 确认 `MiGuDbContextModelSnapshot.cs` 仍在 `Migrations/Sqlite/`
- 若出现在 `MiGu.DB/MiGu/DB/Migrations/Sqlite/` 等错误路径:把 Snapshot **移回**正确目录并删掉空目录(`--namespace` 偶发路径问题)
### 3.4 应用到本地库
启动 `MiGu.Server` 即可(`EnsurePlatformDatabaseAsync``MigrateMiGuDbAsync`),或:
```powershell
dotnet ef database update `
--project .\MiGu.DB\MiGu.DB.csproj `
--startup-project .\MiGu.Server\MiGu.Server.csproj `
--context MiGuDbContext
```
---
## 4. 常用命令
```powershell
# 列出迁移
dotnet ef migrations list `
--project .\MiGu.DB\MiGu.DB.csproj `
--startup-project .\MiGu.Server\MiGu.Server.csproj `
--context MiGuDbContext
# 生成 SQL 脚本(发版/DBA 审阅,不直接连库执行也可)
dotnet ef migrations script `
--project .\MiGu.DB\MiGu.DB.csproj `
--startup-project .\MiGu.Server\MiGu.Server.csproj `
--context MiGuDbContext `
--output .\MiGu.DB\Migrations\Sqlite\script.sql
# 从某迁移到最新(含幂等脚本时加 --idempotent
dotnet ef migrations script FromMigration ToMigration `
--project .\MiGu.DB\MiGu.DB.csproj `
--startup-project .\MiGu.Server\MiGu.Server.csproj `
--context MiGuDbContext `
--idempotent
# 删除「尚未应用到任何重要库」的最后一次迁移(仅开发)
dotnet ef migrations remove `
--project .\MiGu.DB\MiGu.DB.csproj `
--startup-project .\MiGu.Server\MiGu.Server.csproj `
--context MiGuDbContext
# 回滚到指定迁移(会执行 Down,生产慎用)
dotnet ef database update <目标迁移名> `
--project .\MiGu.DB\MiGu.DB.csproj `
--startup-project .\MiGu.Server\MiGu.Server.csproj `
--context MiGuDbContext
```
使用本地工具时,将 `dotnet ef` 换成 `.\.tools\dotnet-ef`
---
## 5. 启动时发生了什么
**完整说明(顺序、配置、SchemaMode、开发注意)见:**
**[MiGu.Server/README.md · 数据库启动流程](../MiGu.Server/README.md#数据库启动流程)**
摘要:`EnsurePlatformDatabaseAsync``MigrateMiGuDbAsync``Database:SchemaMode``EnsureCreated``Migrate`;之后按需跑 `IDataMigrator`
**Migrate 基线:** 已有表、无 History 时,会把**全部** pending Migration 写入 `__EFMigrationsHistory`(假定 EnsureCreated 库已对齐模型 tip)。发版前若开发期改过模型,须先 `migrations add` 再切 `Migrate`
---
## 6. Schema 变更 vs 数据修补
| 需求 | 做法 |
|------|------|
| 新表/新列/索引/改列类型 | `migrations add` → 提交迁移文件 |
| 刷旧枚举字符串、回填列 | 新增 `IDataMigrator`,注册到 `AddMiGuDataMigrators` |
| 开发机整库清空重来 | 删 `data/platform.db*` 后启动(等同空库 Migrate);**不要**在生产用 EnsureDeleted |
注意:带 `HasConversion` 的枚举列,用 `ExecuteUpdate` + 原始字符串比较可能触发转换异常;旧值刷库需绕过 converter(参见 `LegacyStatusNormalizationMigrator`)。
---
## 7. 协作与发版
1. **迁移文件必须入库**(含 Designer、Snapshot
2. 多人同时改模型易冲突 Snapshot:保留一方迁移,另一方 `remove` 后基于最新代码重新 `add`
3. 已合并到主分支并可能已应用到共享库的迁移:**不要** `migrations remove` 或改写历史 `Up()`
4. 发版包随程序集带上 Migration;现场首次升级靠启动 Migrate(或预执行 `migrations script`
---
## 8. 其他 Provider(预留)
`IDbProviderSetup` 已预留 MySql / Npgsql / SqlServer。首轮只有 Sqlite 迁移套。
将来为企业库生成独立套时:
```powershell
# 示例:输出到 Migrations/MySqlnamespace 同步修改
dotnet ef migrations add InitialPlatform `
--project .\MiGu.DB\MiGu.DB.csproj `
--startup-project .\MiGu.Server\MiGu.Server.csproj `
--output-dir Migrations/MySql `
--namespace MiGu.DB.Migrations.MySql `
--context MiGuDbContext
```
并确保对应 Provider 的 `MigrationsAssembly` / 运行时能发现该套迁移(按需拆程序集或过滤命名空间)。
---
## 9. 常见问题
| 现象 | 处理 |
|------|------|
| `dotnet ef` 找不到 | 安装 8.0.10 工具,或用 `.\.tools\dotnet-ef` |
| Design-time 连错库 | 检查 `MiGuDbContextFactory`(默认 `platform.db`);运行时以 Server 配置为准 |
| Snapshot 生成到奇怪目录 | 移回 `Migrations/Sqlite/` |
| 旧库启动重复建表失败 | 确认基线逻辑是否写入 History;备份后必要时手工插入 Initial 行 |
| 改完实体 `add` 生成空迁移 | 模型无差异或未编译;先 `dotnet build` |
| 想撤销未提交的迁移 | `migrations remove`(确认未被他人/现场应用) |
---
## 10. 检查清单(每次提 PR)
- [ ] `dotnet build` 通过
- [ ] 新迁移仅含预期 DDL
- [ ] 文件在 `Migrations/Sqlite/`Snapshot 路径正确
- [ ] 本地启动一次,确认 Migrate 成功
- [ ] 若有数据刷库,已加幂等 `IDataMigrator` 并注明 Order
- [ ] 未改写已发布的历史 Migration
+30
View File
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>MiGu.DB</RootNamespace>
<AssemblyName>MiGu.DB</AssemblyName>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.10" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.10" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.10">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.2" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.10" />
</ItemGroup>
</Project>
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,754 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MiGu.DB.Migrations.Sqlite
{
/// <summary>
/// Sqlite 初始 Schema(平台库全量表)。旧 EnsureCreated 库启动时会先基线本迁移名,再应用后续增量。
/// </summary>
public partial class InitialPlatform : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "simple_fields",
columns: table => new
{
id = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
car_type = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
field_type = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
key = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
value = table.Column<string>(type: "TEXT", nullable: false),
data_type = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
chinese = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
english = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
other = table.Column<string>(type: "TEXT", maxLength: 512, nullable: false),
is_default = table.Column<bool>(type: "INTEGER", nullable: false),
create_time = table.Column<string>(type: "TEXT", maxLength: 19, nullable: false),
update_time = table.Column<string>(type: "TEXT", maxLength: 19, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_simple_fields", x => x.id);
});
migrationBuilder.CreateTable(
name: "user_dashboard_shortcuts",
columns: table => new
{
user_id = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
scope = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
keys_json = table.Column<string>(type: "text", nullable: false),
updated_at = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_user_dashboard_shortcuts", x => new { x.user_id, x.scope });
});
migrationBuilder.CreateTable(
name: "wms_areas",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
WarehouseId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
Code = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
Type = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
LayoutMode = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
State = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
Enabled = table.Column<bool>(type: "INTEGER", nullable: false),
SortOrder = table.Column<int>(type: "INTEGER", nullable: false),
CreatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
UpdatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
CreatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
UpdatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
IsDeleted = table.Column<bool>(type: "INTEGER", nullable: false),
DeletedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
DeletedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
Version = table.Column<long>(type: "INTEGER", nullable: false),
IsLock = table.Column<bool>(type: "INTEGER", nullable: false),
Remark = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: false),
Extend = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_wms_areas", x => x.Id);
});
migrationBuilder.CreateTable(
name: "wms_container_location_history",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
FromLocationType = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
FromLocationId = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
ToLocationType = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
ToLocationId = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
RelationId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: true),
EventType = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
BeforeJson = table.Column<string>(type: "text", nullable: false),
AfterJson = table.Column<string>(type: "text", nullable: false),
ContainerId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: true),
Operator = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
OperatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
Source = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
Reason = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
Remark = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: false),
Extend = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_wms_container_location_history", x => x.Id);
});
migrationBuilder.CreateTable(
name: "wms_container_locations",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
ContainerId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
LocationType = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
LocationId = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
StorageId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: true),
LocationCode = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
LocationName = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
Status = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
EnteredAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
CreatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
UpdatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
CreatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
UpdatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
IsDeleted = table.Column<bool>(type: "INTEGER", nullable: false),
DeletedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
DeletedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
Version = table.Column<long>(type: "INTEGER", nullable: false),
IsLock = table.Column<bool>(type: "INTEGER", nullable: false),
Remark = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: false),
Extend = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_wms_container_locations", x => x.Id);
});
migrationBuilder.CreateTable(
name: "wms_container_material_history",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
MaterialId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: true),
QuantityDelta = table.Column<decimal>(type: "TEXT", precision: 18, scale: 4, nullable: false),
RelationId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: true),
EventType = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
BeforeJson = table.Column<string>(type: "text", nullable: false),
AfterJson = table.Column<string>(type: "text", nullable: false),
ContainerId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: true),
Operator = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
OperatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
Source = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
Reason = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
Remark = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: false),
Extend = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_wms_container_material_history", x => x.Id);
});
migrationBuilder.CreateTable(
name: "wms_container_materials",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
ContainerId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
MaterialId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
Quantity = table.Column<decimal>(type: "TEXT", precision: 18, scale: 4, nullable: false),
BatchNo = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
SerialNo = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
Status = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
BoundAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
LoadedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
UnloadedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
CreatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
UpdatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
CreatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
UpdatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
IsDeleted = table.Column<bool>(type: "INTEGER", nullable: false),
DeletedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
DeletedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
Version = table.Column<long>(type: "INTEGER", nullable: false),
IsLock = table.Column<bool>(type: "INTEGER", nullable: false),
Remark = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: false),
Extend = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_wms_container_materials", x => x.Id);
});
migrationBuilder.CreateTable(
name: "wms_containers",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
AreaId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: true),
Code = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
ContainerType = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
Status = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
Barcode = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
Length = table.Column<double>(type: "REAL", nullable: false),
Width = table.Column<double>(type: "REAL", nullable: false),
Height = table.Column<double>(type: "REAL", nullable: false),
Enabled = table.Column<bool>(type: "INTEGER", nullable: false),
CreatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
UpdatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
CreatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
UpdatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
IsDeleted = table.Column<bool>(type: "INTEGER", nullable: false),
DeletedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
DeletedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
Version = table.Column<long>(type: "INTEGER", nullable: false),
IsLock = table.Column<bool>(type: "INTEGER", nullable: false),
Remark = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: false),
Extend = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_wms_containers", x => x.Id);
});
migrationBuilder.CreateTable(
name: "wms_material_types",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
Code = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
Spec = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
Unit = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
Category = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
BarcodePrefix = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
Enabled = table.Column<bool>(type: "INTEGER", nullable: false),
CreatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
UpdatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
CreatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
UpdatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
IsDeleted = table.Column<bool>(type: "INTEGER", nullable: false),
DeletedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
DeletedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
Version = table.Column<long>(type: "INTEGER", nullable: false),
IsLock = table.Column<bool>(type: "INTEGER", nullable: false),
Remark = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: false),
Extend = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_wms_material_types", x => x.Id);
});
migrationBuilder.CreateTable(
name: "wms_materials",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
Code = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
TypeCode = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
Barcode = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
Spec = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
Unit = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
Category = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
LifecycleStatus = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
UnboundAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
Enabled = table.Column<bool>(type: "INTEGER", nullable: false),
CreatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
UpdatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
CreatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
UpdatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
IsDeleted = table.Column<bool>(type: "INTEGER", nullable: false),
DeletedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
DeletedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
Version = table.Column<long>(type: "INTEGER", nullable: false),
IsLock = table.Column<bool>(type: "INTEGER", nullable: false),
Remark = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: false),
Extend = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_wms_materials", x => x.Id);
});
migrationBuilder.CreateTable(
name: "wms_stock_events",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
EventType = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
MaterialId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: true),
MaterialCode = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
MaterialName = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
MaterialBarcode = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
MaterialTypeCode = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
ContainerId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: true),
ContainerCode = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
ContainerName = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
StorageId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: true),
StorageCode = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
StorageName = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
AreaCode = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
FromStorageId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: true),
FromStorageCode = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
FromStorageName = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
ToStorageId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: true),
ToStorageCode = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
ToStorageName = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
RefType = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
RefId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: true),
RefCode = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
Operator = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
OperatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
Reason = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_wms_stock_events", x => x.Id);
});
migrationBuilder.CreateTable(
name: "wms_storages",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
AreaId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
Code = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
StorageType = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
LocationKind = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
ColumnNo = table.Column<int>(type: "INTEGER", nullable: false),
LevelNo = table.Column<int>(type: "INTEGER", nullable: false),
DepthNo = table.Column<int>(type: "INTEGER", nullable: false),
SiteId = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
SiteCode = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
Barcode = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
Capacity = table.Column<int>(type: "INTEGER", nullable: false),
Status = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
Usage = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
Priority = table.Column<int>(type: "INTEGER", nullable: false),
ZoneCode = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
AllowInbound = table.Column<bool>(type: "INTEGER", nullable: false),
AllowOutbound = table.Column<bool>(type: "INTEGER", nullable: false),
Enabled = table.Column<bool>(type: "INTEGER", nullable: false),
CreatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
UpdatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
CreatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
UpdatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
IsDeleted = table.Column<bool>(type: "INTEGER", nullable: false),
DeletedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
DeletedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
Version = table.Column<long>(type: "INTEGER", nullable: false),
IsLock = table.Column<bool>(type: "INTEGER", nullable: false),
Remark = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: false),
Extend = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_wms_storages", x => x.Id);
});
migrationBuilder.CreateTable(
name: "wms_transport_reservations",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
TaskId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
ContainerId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
SourceStorageId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
TargetStorageId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
Status = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
ExpiresAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
CreatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
UpdatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
CreatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
UpdatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
IsDeleted = table.Column<bool>(type: "INTEGER", nullable: false),
DeletedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
DeletedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
Version = table.Column<long>(type: "INTEGER", nullable: false),
IsLock = table.Column<bool>(type: "INTEGER", nullable: false),
Remark = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: false),
Extend = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_wms_transport_reservations", x => x.Id);
});
migrationBuilder.CreateTable(
name: "wms_transport_rules",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
Code = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
TriggerType = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
Enabled = table.Column<bool>(type: "INTEGER", nullable: false),
Priority = table.Column<int>(type: "INTEGER", nullable: false),
SourceSelectorJson = table.Column<string>(type: "text", nullable: false),
TargetSelectorJson = table.Column<string>(type: "text", nullable: false),
TaskOptionsJson = table.Column<string>(type: "text", nullable: false),
CreatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
UpdatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
CreatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
UpdatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
IsDeleted = table.Column<bool>(type: "INTEGER", nullable: false),
DeletedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
DeletedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
Version = table.Column<long>(type: "INTEGER", nullable: false),
IsLock = table.Column<bool>(type: "INTEGER", nullable: false),
Remark = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: false),
Extend = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_wms_transport_rules", x => x.Id);
});
migrationBuilder.CreateTable(
name: "wms_transport_task_history",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
TaskId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
FromStatus = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
ToStatus = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
Operator = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
OperatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
Reason = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
ErrorMessage = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: false),
SnapshotJson = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_wms_transport_task_history", x => x.Id);
});
migrationBuilder.CreateTable(
name: "wms_transport_tasks",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
BusinessType = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
RuleId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: true),
SourceStorageId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
TargetStorageId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
ContainerId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
MaterialId = table.Column<string>(type: "TEXT", maxLength: 36, nullable: true),
Quantity = table.Column<decimal>(type: "TEXT", precision: 18, scale: 4, nullable: true),
Status = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
DispatchMissionId = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
DeliveryId = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
DispatchStatus = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
Reason = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
SnapshotJson = table.Column<string>(type: "text", nullable: false),
ErrorMessage = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: false),
TaskPriority = table.Column<int>(type: "INTEGER", nullable: false),
CreatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
UpdatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
CreatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
UpdatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
IsDeleted = table.Column<bool>(type: "INTEGER", nullable: false),
DeletedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
DeletedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
Version = table.Column<long>(type: "INTEGER", nullable: false),
IsLock = table.Column<bool>(type: "INTEGER", nullable: false),
Remark = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: false),
Extend = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_wms_transport_tasks", x => x.Id);
});
migrationBuilder.CreateTable(
name: "wms_warehouses",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", maxLength: 36, nullable: false),
Code = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
Type = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
Enabled = table.Column<bool>(type: "INTEGER", nullable: false),
SortOrder = table.Column<int>(type: "INTEGER", nullable: false),
CreatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
UpdatedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
CreatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
UpdatedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
IsDeleted = table.Column<bool>(type: "INTEGER", nullable: false),
DeletedAt = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
DeletedBy = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
Version = table.Column<long>(type: "INTEGER", nullable: false),
IsLock = table.Column<bool>(type: "INTEGER", nullable: false),
Remark = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: false),
Extend = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_wms_warehouses", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_simple_fields_car_type_field_type_key",
table: "simple_fields",
columns: new[] { "car_type", "field_type", "key" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_wms_areas_Code",
table: "wms_areas",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_wms_areas_WarehouseId",
table: "wms_areas",
column: "WarehouseId");
migrationBuilder.CreateIndex(
name: "IX_wms_container_location_history_ContainerId",
table: "wms_container_location_history",
column: "ContainerId");
migrationBuilder.CreateIndex(
name: "IX_wms_container_location_history_EventType",
table: "wms_container_location_history",
column: "EventType");
migrationBuilder.CreateIndex(
name: "IX_wms_container_location_history_OperatedAt",
table: "wms_container_location_history",
column: "OperatedAt");
migrationBuilder.CreateIndex(
name: "IX_wms_container_locations_ContainerId",
table: "wms_container_locations",
column: "ContainerId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_wms_container_locations_LocationType_LocationId",
table: "wms_container_locations",
columns: new[] { "LocationType", "LocationId" });
migrationBuilder.CreateIndex(
name: "IX_wms_container_locations_StorageId",
table: "wms_container_locations",
column: "StorageId");
migrationBuilder.CreateIndex(
name: "IX_wms_container_material_history_ContainerId",
table: "wms_container_material_history",
column: "ContainerId");
migrationBuilder.CreateIndex(
name: "IX_wms_container_material_history_EventType",
table: "wms_container_material_history",
column: "EventType");
migrationBuilder.CreateIndex(
name: "IX_wms_container_material_history_OperatedAt",
table: "wms_container_material_history",
column: "OperatedAt");
migrationBuilder.CreateIndex(
name: "IX_wms_container_materials_ContainerId",
table: "wms_container_materials",
column: "ContainerId");
migrationBuilder.CreateIndex(
name: "IX_wms_container_materials_MaterialId",
table: "wms_container_materials",
column: "MaterialId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_wms_containers_Code",
table: "wms_containers",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_wms_material_types_Code",
table: "wms_material_types",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_wms_materials_Barcode",
table: "wms_materials",
column: "Barcode");
migrationBuilder.CreateIndex(
name: "IX_wms_materials_Code",
table: "wms_materials",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_wms_materials_LifecycleStatus_UpdatedAt",
table: "wms_materials",
columns: new[] { "LifecycleStatus", "UpdatedAt" });
migrationBuilder.CreateIndex(
name: "IX_wms_materials_TypeCode",
table: "wms_materials",
column: "TypeCode");
migrationBuilder.CreateIndex(
name: "IX_wms_stock_events_ContainerId",
table: "wms_stock_events",
column: "ContainerId");
migrationBuilder.CreateIndex(
name: "IX_wms_stock_events_EventType",
table: "wms_stock_events",
column: "EventType");
migrationBuilder.CreateIndex(
name: "IX_wms_stock_events_MaterialId",
table: "wms_stock_events",
column: "MaterialId");
migrationBuilder.CreateIndex(
name: "IX_wms_stock_events_OperatedAt",
table: "wms_stock_events",
column: "OperatedAt");
migrationBuilder.CreateIndex(
name: "IX_wms_storages_AreaId",
table: "wms_storages",
column: "AreaId");
migrationBuilder.CreateIndex(
name: "IX_wms_storages_Barcode",
table: "wms_storages",
column: "Barcode");
migrationBuilder.CreateIndex(
name: "IX_wms_storages_Code",
table: "wms_storages",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_wms_transport_reservations_ContainerId_Status",
table: "wms_transport_reservations",
columns: new[] { "ContainerId", "Status" });
migrationBuilder.CreateIndex(
name: "IX_wms_transport_reservations_TargetStorageId_Status",
table: "wms_transport_reservations",
columns: new[] { "TargetStorageId", "Status" });
migrationBuilder.CreateIndex(
name: "IX_wms_transport_rules_Code",
table: "wms_transport_rules",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_wms_transport_rules_TriggerType_Enabled_Priority",
table: "wms_transport_rules",
columns: new[] { "TriggerType", "Enabled", "Priority" });
migrationBuilder.CreateIndex(
name: "IX_wms_transport_task_history_OperatedAt",
table: "wms_transport_task_history",
column: "OperatedAt");
migrationBuilder.CreateIndex(
name: "IX_wms_transport_task_history_TaskId",
table: "wms_transport_task_history",
column: "TaskId");
migrationBuilder.CreateIndex(
name: "IX_wms_transport_tasks_ContainerId",
table: "wms_transport_tasks",
column: "ContainerId");
migrationBuilder.CreateIndex(
name: "IX_wms_transport_tasks_Status",
table: "wms_transport_tasks",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_wms_transport_tasks_TargetStorageId",
table: "wms_transport_tasks",
column: "TargetStorageId");
migrationBuilder.CreateIndex(
name: "IX_wms_warehouses_Code",
table: "wms_warehouses",
column: "Code",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "simple_fields");
migrationBuilder.DropTable(
name: "user_dashboard_shortcuts");
migrationBuilder.DropTable(
name: "wms_areas");
migrationBuilder.DropTable(
name: "wms_container_location_history");
migrationBuilder.DropTable(
name: "wms_container_locations");
migrationBuilder.DropTable(
name: "wms_container_material_history");
migrationBuilder.DropTable(
name: "wms_container_materials");
migrationBuilder.DropTable(
name: "wms_containers");
migrationBuilder.DropTable(
name: "wms_material_types");
migrationBuilder.DropTable(
name: "wms_materials");
migrationBuilder.DropTable(
name: "wms_stock_events");
migrationBuilder.DropTable(
name: "wms_storages");
migrationBuilder.DropTable(
name: "wms_transport_reservations");
migrationBuilder.DropTable(
name: "wms_transport_rules");
migrationBuilder.DropTable(
name: "wms_transport_task_history");
migrationBuilder.DropTable(
name: "wms_transport_tasks");
migrationBuilder.DropTable(
name: "wms_warehouses");
}
}
}
File diff suppressed because it is too large Load Diff
+57
View File
@@ -0,0 +1,57 @@
# MiGu.DB
迷毂平台持久化框架(EF Core 8)。由 `MiGu.Server` 引用;本工程不依赖 ASP.NET。
## 分层
| 目录 | 职责 |
|------|------|
| `Abstractions/` | 对外契约:能力接口、仓储/UoW、Provider、Module、Actor、异常 |
| `Kernel/` | 框架实现:实体基类族、MiGuDbContext、约定/Interceptor、仓储、Hosting |
| `Domains/` | 业务实体、枚举、辅助类与 `IEntityTypeConfiguration` |
| `Migrations/Sqlite/` | Schema 版本化来源(发版用;开发可用 EnsureCreated 不跑迁移) |
## 使用
`MiGu.Server` 调用:
```csharp
builder.Services.AddPlatformPersistence(builder.Configuration); // 内部 AddMiGuDb
await app.Services.EnsurePlatformDatabaseAsync(); // 内部 MigrateMiGuDbAsync
```
**启动流程、SchemaMode、开发/发版配置说明见 [MiGu.Server/README.md · 数据库启动流程](../MiGu.Server/README.md#数据库启动流程)。**
配置键(`Database:*` / `ConnectionStrings:Platform`)由 Server 的 `appsettings*.json` 提供;本库通过 `AddMiGuDb(IConfiguration)` 读取。
## 实体与枚举
- 业务状态字段为 **enum**,约定自动 `HasConversion<string>()`(严格 1:1,不做读侧归一)。
- 旧库值(如 `Available`/`Idle`)由 `LegacyStatusNormalizationMigrator` 一次性刷成规范名。
- `Version` 为乐观并发令牌;软删走全局 `HasQueryFilter`
- 复数辅助类(`StorageStatuses``LocationKinds`…)负责 API 字符串解析与集合校验。
## 扩展
- **新 Provider**:实现 `IDbProviderSetup` + `Migrations/{Name}/`
- **新领域**`Domains/Xxx` 实体 + Configuration;可选 `IEntityModule`
- **数据修补**:实现 `IDataMigrator`(优先 LINQ;枚举旧值刷库等特例可定点 raw UPDATE)
## 生成 MigrationSqlite
完整步骤、命令说明与排错见 **[MIGRATIONS.md](./MIGRATIONS.md)**。
快速命令:
```powershell
dotnet ef migrations add <Name> `
--project .\MiGu.DB\MiGu.DB.csproj `
--startup-project .\MiGu.Server\MiGu.Server.csproj `
--output-dir Migrations/Sqlite `
--namespace MiGu.DB.Migrations.Sqlite `
--context MiGuDbContext
```
生成后请确认 `MiGuDbContextModelSnapshot.cs` 落在 `Migrations/Sqlite/`
开发期使用 `EnsureCreated` 时仍可保留/继续提交 Migration 文件,启动不会应用它们,直到 `SchemaMode` 改回 `Migrate`(见 Server README)。
Binary file not shown.