- 新增 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 追踪数据库结构 - 优化代码结构,解耦领域与持久层,提升扩展性与安全性
168 lines
7.4 KiB
C#
168 lines
7.4 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|
|
}
|