Files
Migu2.0/MiGu.DB/Kernel/Conventions/Conventions.cs
T
wei.wu 51c3fc1994 持久层重构为独立 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 追踪数据库结构
- 优化代码结构,解耦领域与持久层,提升扩展性与安全性
2026-07-27 14:26:04 +08:00

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;
}
}
}
}