feat: tenant门店管理首批接口落地
All checks were successful
Build and Deploy TenantApi / build-and-deploy (push) Successful in 30s

This commit is contained in:
2026-02-17 11:10:06 +08:00
parent 992930a821
commit 654b1ae3f7
31 changed files with 2731 additions and 14 deletions

View File

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

View File

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