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; /// /// 全局模型约定:Guid/DateTimeOffset 字符串化、枚举存字符串、软删过滤、Version 并发令牌等。 /// public static class ModelConventionExtensions { public static void ApplyMiGuConventions(ModelBuilder modelBuilder) { // 与历史 Sqlite TEXT 列兼容:Guid / DateTimeOffset 均以字符串落库 var guid = new ValueConverter(v => v.ToString("D"), v => Guid.Parse(v)); var nullableGuid = new ValueConverter( v => v.HasValue ? v.Value.ToString("D") : null, v => string.IsNullOrWhiteSpace(v) ? null : Guid.Parse(v)); var dto = new ValueConverter( v => v.UtcDateTime.ToString("O"), v => DateTimeOffset.Parse(v)); var nullableDto = new ValueConverter( 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().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(ModelBuilder modelBuilder) where TEntity : class, ISoftDeletable => modelBuilder.Entity().HasQueryFilter(e => !e.IsDeleted); } public sealed class ActorContextAccessor : IActorContextAccessor { private static readonly AsyncLocal 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 SavingChanges(DbContextEventData eventData, InterceptionResult result) { Stamp(eventData.Context); return base.SavingChanges(eventData, result); } public override ValueTask> SavingChangesAsync( DbContextEventData eventData, InterceptionResult 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 { Id: var id } && id == Guid.Empty) ((IEntity)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; } } } }