feat: tenant门店管理首批接口落地
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 30s
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 30s
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using System.Reflection;
|
||||
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
using TakeoutSaaS.Shared.Abstractions.Ids;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Common.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// 应用基础 DbContext,统一处理审计字段、软删除与全局查询过滤。
|
||||
/// </summary>
|
||||
public abstract class AppDbContext(
|
||||
DbContextOptions options,
|
||||
ICurrentUserAccessor? currentUserAccessor = null,
|
||||
IIdGenerator? idGenerator = null) : DbContext(options)
|
||||
{
|
||||
private readonly ICurrentUserAccessor? _currentUserAccessor = currentUserAccessor;
|
||||
private readonly IIdGenerator? _idGenerator = idGenerator;
|
||||
|
||||
/// <summary>
|
||||
/// 构建模型时应用软删除过滤器。
|
||||
/// </summary>
|
||||
/// <param name="modelBuilder">模型构建器。</param>
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
ApplySoftDeleteQueryFilters(modelBuilder);
|
||||
modelBuilder.ApplyXmlComments();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存更改前应用元数据填充。
|
||||
/// </summary>
|
||||
/// <returns>受影响行数。</returns>
|
||||
public override int SaveChanges()
|
||||
{
|
||||
OnBeforeSaving();
|
||||
return base.SaveChanges();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 异步保存更改前应用元数据填充。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>受影响行数。</returns>
|
||||
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
OnBeforeSaving();
|
||||
return base.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存前处理审计、软删除等元数据,可在子类中扩展。
|
||||
/// </summary>
|
||||
protected virtual void OnBeforeSaving()
|
||||
{
|
||||
ApplyIdGeneration();
|
||||
ApplySoftDeleteMetadata();
|
||||
ApplyAuditMetadata();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为新增实体生成雪花 ID。
|
||||
/// </summary>
|
||||
private void ApplyIdGeneration()
|
||||
{
|
||||
if (_idGenerator == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var entry in ChangeTracker.Entries<EntityBase>())
|
||||
{
|
||||
if (entry.State != EntityState.Added)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.Entity.Id == 0)
|
||||
{
|
||||
entry.Entity.Id = _idGenerator.NextId();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将软删除实体的删除操作转换为设置 DeletedAt。
|
||||
/// </summary>
|
||||
private void ApplySoftDeleteMetadata()
|
||||
{
|
||||
var utcNow = DateTime.UtcNow;
|
||||
var actor = GetCurrentUserIdOrNull();
|
||||
foreach (var entry in ChangeTracker.Entries<ISoftDeleteEntity>())
|
||||
{
|
||||
if (entry.State == EntityState.Added && entry.Entity.DeletedAt.HasValue)
|
||||
{
|
||||
entry.Entity.DeletedAt = null;
|
||||
}
|
||||
|
||||
if (entry.State != EntityState.Deleted)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
entry.State = EntityState.Modified;
|
||||
entry.Entity.DeletedAt = utcNow;
|
||||
if (entry.Entity is IAuditableEntity auditable)
|
||||
{
|
||||
auditable.DeletedBy = actor;
|
||||
if (!auditable.UpdatedAt.HasValue)
|
||||
{
|
||||
auditable.UpdatedAt = utcNow;
|
||||
auditable.UpdatedBy = actor;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 对审计实体填充创建与更新时间。
|
||||
/// </summary>
|
||||
private void ApplyAuditMetadata()
|
||||
{
|
||||
var utcNow = DateTime.UtcNow;
|
||||
var actor = GetCurrentUserIdOrNull();
|
||||
|
||||
foreach (var entry in ChangeTracker.Entries<IAuditableEntity>())
|
||||
{
|
||||
if (entry.State == EntityState.Added)
|
||||
{
|
||||
entry.Entity.CreatedAt = utcNow;
|
||||
entry.Entity.UpdatedAt = null;
|
||||
entry.Entity.CreatedBy ??= actor;
|
||||
entry.Entity.UpdatedBy = null;
|
||||
entry.Entity.DeletedBy = null;
|
||||
entry.Entity.DeletedAt = null;
|
||||
}
|
||||
else if (entry.State == EntityState.Modified)
|
||||
{
|
||||
entry.Entity.UpdatedAt = utcNow;
|
||||
entry.Entity.UpdatedBy = actor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private long? GetCurrentUserIdOrNull()
|
||||
{
|
||||
var userId = _currentUserAccessor?.UserId ?? 0;
|
||||
return userId == 0 ? null : userId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 应用软删除查询过滤器,自动排除 DeletedAt 不为 null 的记录。
|
||||
/// </summary>
|
||||
/// <param name="modelBuilder">模型构建器。</param>
|
||||
protected void ApplySoftDeleteQueryFilters(ModelBuilder modelBuilder)
|
||||
{
|
||||
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
|
||||
{
|
||||
if (!typeof(ISoftDeleteEntity).IsAssignableFrom(entityType.ClrType))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var methodInfo = typeof(AppDbContext)
|
||||
.GetMethod(nameof(SetSoftDeleteFilter), BindingFlags.Instance | BindingFlags.NonPublic)!
|
||||
.MakeGenericMethod(entityType.ClrType);
|
||||
|
||||
methodInfo.Invoke(this, new object[] { modelBuilder });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置软删除查询过滤器。
|
||||
/// </summary>
|
||||
/// <typeparam name="TEntity">实体类型。</typeparam>
|
||||
/// <param name="modelBuilder">模型构建器。</param>
|
||||
private void SetSoftDeleteFilter<TEntity>(ModelBuilder modelBuilder)
|
||||
where TEntity : class, ISoftDeleteEntity
|
||||
{
|
||||
modelBuilder.Entity<TEntity>().HasQueryFilter(entity => entity.DeletedAt == null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 配置审计字段的通用约束。
|
||||
/// </summary>
|
||||
/// <typeparam name="TEntity">实体类型。</typeparam>
|
||||
/// <param name="builder">实体构建器。</param>
|
||||
protected static void ConfigureAuditableEntity<TEntity>(EntityTypeBuilder<TEntity> builder)
|
||||
where TEntity : class, IAuditableEntity
|
||||
{
|
||||
builder.Property(x => x.CreatedAt).IsRequired();
|
||||
builder.Property(x => x.UpdatedAt);
|
||||
builder.Property(x => x.DeletedAt);
|
||||
builder.Property(x => x.CreatedBy);
|
||||
builder.Property(x => x.UpdatedBy);
|
||||
builder.Property(x => x.DeletedBy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 配置软删除字段的通用约束。
|
||||
/// </summary>
|
||||
/// <typeparam name="TEntity">实体类型。</typeparam>
|
||||
/// <param name="builder">实体构建器。</param>
|
||||
protected static void ConfigureSoftDeleteEntity<TEntity>(EntityTypeBuilder<TEntity> builder)
|
||||
where TEntity : class, ISoftDeleteEntity
|
||||
{
|
||||
builder.Property(x => x.DeletedAt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Reflection;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Common.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Applies XML documentation summaries to EF Core entities/columns as comments.
|
||||
/// </summary>
|
||||
internal static class ModelBuilderCommentExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 将 XML 注释应用到实体与属性的 Comment。
|
||||
/// </summary>
|
||||
/// <param name="modelBuilder">模型构建器。</param>
|
||||
public static void ApplyXmlComments(this ModelBuilder modelBuilder)
|
||||
{
|
||||
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
|
||||
{
|
||||
ApplyEntityComment(entityType);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyEntityComment(IMutableEntityType entityType)
|
||||
{
|
||||
var clrType = entityType.ClrType;
|
||||
if (clrType == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (XmlDocCommentProvider.TryGetSummary(clrType, out var typeComment))
|
||||
{
|
||||
entityType.SetComment(typeComment);
|
||||
}
|
||||
|
||||
foreach (var property in entityType.GetProperties())
|
||||
{
|
||||
var propertyInfo = property.PropertyInfo;
|
||||
if (propertyInfo == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (XmlDocCommentProvider.TryGetSummary(propertyInfo, out var propertyComment))
|
||||
{
|
||||
property.SetComment(propertyComment);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static class XmlDocCommentProvider
|
||||
{
|
||||
private static readonly ConcurrentDictionary<Assembly, IReadOnlyDictionary<string, string>> Cache = new();
|
||||
|
||||
/// <summary>
|
||||
/// 尝试获取成员的摘要注释。
|
||||
/// </summary>
|
||||
/// <param name="member">反射成员。</param>
|
||||
/// <param name="summary">输出的摘要文本。</param>
|
||||
/// <returns>存在摘要则返回 true。</returns>
|
||||
public static bool TryGetSummary(MemberInfo member, out string? summary)
|
||||
{
|
||||
summary = null;
|
||||
var assembly = member switch
|
||||
{
|
||||
Type type => type.Assembly,
|
||||
_ => member.DeclaringType?.Assembly
|
||||
};
|
||||
|
||||
if (assembly == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var map = Cache.GetOrAdd(assembly, LoadComments);
|
||||
if (map.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var key = GetMemberKey(member);
|
||||
if (key == null || !map.TryGetValue(key, out var text))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
summary = text;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> LoadComments(Assembly assembly)
|
||||
{
|
||||
var dictionary = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
var xmlPath = Path.ChangeExtension(assembly.Location, ".xml");
|
||||
if (string.IsNullOrWhiteSpace(xmlPath) || !File.Exists(xmlPath))
|
||||
{
|
||||
return dictionary;
|
||||
}
|
||||
|
||||
var document = XDocument.Load(xmlPath);
|
||||
foreach (var member in document.Descendants("member"))
|
||||
{
|
||||
var name = member.Attribute("name")?.Value;
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var summary = member.Element("summary")?.Value;
|
||||
if (string.IsNullOrWhiteSpace(summary))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalized = Normalize(summary);
|
||||
if (!string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
dictionary[name] = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return dictionary;
|
||||
}
|
||||
|
||||
private static string? GetMemberKey(MemberInfo member) =>
|
||||
member switch
|
||||
{
|
||||
Type type => $"T:{GetFullName(type)}",
|
||||
PropertyInfo property => $"P:{GetFullName(property.DeclaringType!)}.{property.Name}",
|
||||
FieldInfo field => $"F:{GetFullName(field.DeclaringType!)}.{field.Name}",
|
||||
_ => null
|
||||
};
|
||||
|
||||
private static string GetFullName(Type type) =>
|
||||
(type.FullName ?? type.Name).Replace('+', '.');
|
||||
|
||||
private static string Normalize(string text)
|
||||
{
|
||||
var chars = text.Replace('\r', ' ').Replace('\n', ' ').Replace('\t', ' ');
|
||||
return string.Join(' ', chars.Split(' ', StringSplitOptions.RemoveEmptyEntries));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user