持久层重构为独立 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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user