refactor: 管理端去租户过滤并Portal化RBAC菜单

This commit is contained in:
2026-01-29 10:46:49 +00:00
parent ea9c20d8a9
commit b3639ff34b
115 changed files with 1106 additions and 1092 deletions

View File

@@ -1,4 +1,3 @@
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Shared.Abstractions.Ids;
using TakeoutSaaS.Shared.Abstractions.Security;
@@ -13,9 +12,8 @@ public sealed class TakeoutAdminDbContext(
DbContextOptions<TakeoutAdminDbContext> options,
ITenantProvider tenantProvider,
ICurrentUserAccessor? currentUserAccessor = null,
IIdGenerator? idGenerator = null,
IHttpContextAccessor? httpContextAccessor = null)
: TakeoutAppDbContext(options, tenantProvider, currentUserAccessor, idGenerator, httpContextAccessor)
IIdGenerator? idGenerator = null)
: TakeoutAppDbContext(options, tenantProvider, currentUserAccessor, idGenerator)
{
/// <summary>
/// 配置实体映射关系(不启用租户过滤)。
@@ -27,4 +25,3 @@ public sealed class TakeoutAdminDbContext(
OnModelCreatingCore(modelBuilder);
}
}

View File

@@ -26,7 +26,6 @@ using TakeoutSaaS.Shared.Abstractions.Ids;
using TakeoutSaaS.Shared.Abstractions.Security;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
using TakeoutSaaS.Infrastructure.App.Persistence.Configurations;
using Microsoft.AspNetCore.Http;
namespace TakeoutSaaS.Infrastructure.App.Persistence;
@@ -37,9 +36,8 @@ public class TakeoutAppDbContext(
DbContextOptions options,
ITenantProvider tenantProvider,
ICurrentUserAccessor? currentUserAccessor = null,
IIdGenerator? idGenerator = null,
IHttpContextAccessor? httpContextAccessor = null)
: TenantAwareDbContext(options, tenantProvider, currentUserAccessor, idGenerator, httpContextAccessor)
IIdGenerator? idGenerator = null)
: TenantAwareDbContext(options, tenantProvider, currentUserAccessor, idGenerator)
{
/// <summary>
/// 租户聚合根。

View File

@@ -17,19 +17,23 @@ namespace TakeoutSaaS.Infrastructure.App.Repositories;
public sealed class EfStoreRepository(TakeoutAdminDbContext context) : IStoreRepository
{
/// <inheritdoc />
public Task<Store?> FindByIdAsync(long storeId, long tenantId, CancellationToken cancellationToken = default)
public Task<Store?> FindByIdAsync(long storeId, long? tenantId, CancellationToken cancellationToken = default, bool includeDeleted = false)
{
var query = context.Stores.AsNoTracking();
if (tenantId <= 0)
// 1. 包含软删除数据时忽略全局过滤
if (includeDeleted)
{
query = query.IgnoreQueryFilters()
.Where(x => x.DeletedAt == null);
}
else
{
query = query.Where(x => x.TenantId == tenantId);
query = query.IgnoreQueryFilters();
}
// 2. (空行后) 可选租户过滤
if (tenantId.HasValue)
{
query = query.Where(x => x.TenantId == tenantId.Value);
}
// 3. (空行后) 返回门店实体
return query
.Where(x => x.Id == storeId)
.FirstOrDefaultAsync(cancellationToken);
@@ -47,58 +51,68 @@ public sealed class EfStoreRepository(TakeoutAdminDbContext context) : IStoreRep
/// <inheritdoc />
public async Task<IReadOnlyList<Store>> SearchAsync(
long tenantId,
long? tenantId,
long? merchantId,
StoreStatus? status,
StoreAuditStatus? auditStatus,
StoreBusinessStatus? businessStatus,
StoreOwnershipType? ownershipType,
string? keyword,
bool ignoreTenantFilter = false,
bool includeDeleted = false,
CancellationToken cancellationToken = default)
{
var query = context.Stores.AsNoTracking();
if (ignoreTenantFilter)
// 1. 包含软删除数据时忽略全局过滤
if (includeDeleted)
{
query = query.IgnoreQueryFilters()
.Where(x => x.DeletedAt == null);
}
else
{
query = query.Where(x => x.TenantId == tenantId);
query = query.IgnoreQueryFilters();
}
// 2. (空行后) 可选租户过滤
if (tenantId.HasValue)
{
query = query.Where(x => x.TenantId == tenantId.Value);
}
// 3. (空行后) 可选过滤:商户
if (merchantId.HasValue)
{
query = query.Where(x => x.MerchantId == merchantId.Value);
}
// 4. (空行后) 可选过滤:状态
if (status.HasValue)
{
query = query.Where(x => x.Status == status.Value);
}
// 5. (空行后) 可选过滤:审核状态
if (auditStatus.HasValue)
{
query = query.Where(x => x.AuditStatus == auditStatus.Value);
}
// 6. (空行后) 可选过滤:经营状态
if (businessStatus.HasValue)
{
query = query.Where(x => x.BusinessStatus == businessStatus.Value);
}
// 7. (空行后) 可选过滤:主体类型
if (ownershipType.HasValue)
{
query = query.Where(x => x.OwnershipType == ownershipType.Value);
}
// 8. (空行后) 可选过滤:关键词
if (!string.IsNullOrWhiteSpace(keyword))
{
var trimmed = keyword.Trim();
query = query.Where(x => x.Name.Contains(trimmed) || x.Code.Contains(trimmed));
}
// 9. (空行后) 查询并返回结果
var stores = await query
.OrderBy(x => x.Name)
.ToListAsync(cancellationToken);
@@ -152,15 +166,14 @@ public sealed class EfStoreRepository(TakeoutAdminDbContext context) : IStoreRep
}
var query = context.Stores.AsNoTracking();
if (!tenantId.HasValue || tenantId.Value <= 0)
{
query = query.IgnoreQueryFilters();
}
else
// 1. 可选租户过滤
if (tenantId.HasValue)
{
query = query.Where(x => x.TenantId == tenantId.Value);
}
// 2. (空行后) 分组统计门店数量
return await query
.Where(x => merchantIds.Contains(x.MerchantId))
.GroupBy(x => x.MerchantId)
@@ -169,19 +182,23 @@ public sealed class EfStoreRepository(TakeoutAdminDbContext context) : IStoreRep
}
/// <inheritdoc />
public async Task<IReadOnlyList<StoreBusinessHour>> GetBusinessHoursAsync(long storeId, long tenantId, CancellationToken cancellationToken = default)
public async Task<IReadOnlyList<StoreBusinessHour>> GetBusinessHoursAsync(long storeId, long? tenantId, CancellationToken cancellationToken = default, bool includeDeleted = false)
{
var query = context.StoreBusinessHours.AsNoTracking();
if (tenantId <= 0)
// 1. 包含软删除数据时忽略全局过滤
if (includeDeleted)
{
query = query.IgnoreQueryFilters()
.Where(x => x.DeletedAt == null);
}
else
{
query = query.Where(x => x.TenantId == tenantId);
query = query.IgnoreQueryFilters();
}
// 2. (空行后) 可选租户过滤
if (tenantId.HasValue)
{
query = query.Where(x => x.TenantId == tenantId.Value);
}
// 3. (空行后) 查询并返回营业时段
var hours = await query
.Where(x => x.StoreId == storeId)
.OrderBy(x => x.DayOfWeek)
@@ -192,19 +209,23 @@ public sealed class EfStoreRepository(TakeoutAdminDbContext context) : IStoreRep
}
/// <inheritdoc />
public Task<StoreFee?> GetStoreFeeAsync(long storeId, long tenantId, CancellationToken cancellationToken = default)
public Task<StoreFee?> GetStoreFeeAsync(long storeId, long? tenantId, CancellationToken cancellationToken = default, bool includeDeleted = false)
{
var query = context.StoreFees.AsNoTracking();
if (tenantId <= 0)
// 1. 包含软删除数据时忽略全局过滤
if (includeDeleted)
{
query = query.IgnoreQueryFilters()
.Where(x => x.DeletedAt == null);
}
else
{
query = query.Where(x => x.TenantId == tenantId);
query = query.IgnoreQueryFilters();
}
// 2. (空行后) 可选租户过滤
if (tenantId.HasValue)
{
query = query.Where(x => x.TenantId == tenantId.Value);
}
// 3. (空行后) 返回费用配置
return query
.Where(x => x.StoreId == storeId)
.FirstOrDefaultAsync(cancellationToken);
@@ -224,19 +245,23 @@ public sealed class EfStoreRepository(TakeoutAdminDbContext context) : IStoreRep
}
/// <inheritdoc />
public async Task<IReadOnlyList<StoreQualification>> GetQualificationsAsync(long storeId, long tenantId, CancellationToken cancellationToken = default)
public async Task<IReadOnlyList<StoreQualification>> GetQualificationsAsync(long storeId, long? tenantId, CancellationToken cancellationToken = default, bool includeDeleted = false)
{
var query = context.StoreQualifications.AsNoTracking();
if (tenantId <= 0)
// 1. 包含软删除数据时忽略全局过滤
if (includeDeleted)
{
query = query.IgnoreQueryFilters()
.Where(x => x.DeletedAt == null);
}
else
{
query = query.Where(x => x.TenantId == tenantId);
query = query.IgnoreQueryFilters();
}
// 2. (空行后) 可选租户过滤
if (tenantId.HasValue)
{
query = query.Where(x => x.TenantId == tenantId.Value);
}
// 3. (空行后) 查询并返回资质列表
var qualifications = await query
.Where(x => x.StoreId == storeId)
.OrderBy(x => x.SortOrder)
@@ -305,19 +330,23 @@ public sealed class EfStoreRepository(TakeoutAdminDbContext context) : IStoreRep
}
/// <inheritdoc />
public async Task<IReadOnlyList<StoreDeliveryZone>> GetDeliveryZonesAsync(long storeId, long tenantId, CancellationToken cancellationToken = default)
public async Task<IReadOnlyList<StoreDeliveryZone>> GetDeliveryZonesAsync(long storeId, long? tenantId, CancellationToken cancellationToken = default, bool includeDeleted = false)
{
var query = context.StoreDeliveryZones.AsNoTracking();
if (tenantId <= 0)
// 1. 包含软删除数据时忽略全局过滤
if (includeDeleted)
{
query = query.IgnoreQueryFilters()
.Where(x => x.DeletedAt == null);
}
else
{
query = query.Where(x => x.TenantId == tenantId);
query = query.IgnoreQueryFilters();
}
// 2. (空行后) 可选租户过滤
if (tenantId.HasValue)
{
query = query.Where(x => x.TenantId == tenantId.Value);
}
// 3. (空行后) 查询并返回配送区域
var zones = await query
.Where(x => x.StoreId == storeId)
.OrderBy(x => x.SortOrder)
@@ -327,38 +356,46 @@ public sealed class EfStoreRepository(TakeoutAdminDbContext context) : IStoreRep
}
/// <inheritdoc />
public Task<StoreDeliveryZone?> FindDeliveryZoneByIdAsync(long deliveryZoneId, long tenantId, CancellationToken cancellationToken = default)
public Task<StoreDeliveryZone?> FindDeliveryZoneByIdAsync(long deliveryZoneId, long? tenantId, CancellationToken cancellationToken = default, bool includeDeleted = false)
{
var query = context.StoreDeliveryZones.AsQueryable();
if (tenantId <= 0)
// 1. 包含软删除数据时忽略全局过滤
if (includeDeleted)
{
query = query.IgnoreQueryFilters()
.Where(x => x.DeletedAt == null);
}
else
{
query = query.Where(x => x.TenantId == tenantId);
query = query.IgnoreQueryFilters();
}
// 2. (空行后) 可选租户过滤
if (tenantId.HasValue)
{
query = query.Where(x => x.TenantId == tenantId.Value);
}
// 3. (空行后) 返回配送区域实体
return query
.Where(x => x.Id == deliveryZoneId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<StoreHoliday>> GetHolidaysAsync(long storeId, long tenantId, CancellationToken cancellationToken = default)
public async Task<IReadOnlyList<StoreHoliday>> GetHolidaysAsync(long storeId, long? tenantId, CancellationToken cancellationToken = default, bool includeDeleted = false)
{
var query = context.StoreHolidays.AsNoTracking();
if (tenantId <= 0)
// 1. 包含软删除数据时忽略全局过滤
if (includeDeleted)
{
query = query.IgnoreQueryFilters()
.Where(x => x.DeletedAt == null);
}
else
{
query = query.Where(x => x.TenantId == tenantId);
query = query.IgnoreQueryFilters();
}
// 2. (空行后) 可选租户过滤
if (tenantId.HasValue)
{
query = query.Where(x => x.TenantId == tenantId.Value);
}
// 3. (空行后) 查询并返回节假日
var holidays = await query
.Where(x => x.StoreId == storeId)
.OrderBy(x => x.Date)
@@ -368,19 +405,23 @@ public sealed class EfStoreRepository(TakeoutAdminDbContext context) : IStoreRep
}
/// <inheritdoc />
public Task<StoreHoliday?> FindHolidayByIdAsync(long holidayId, long tenantId, CancellationToken cancellationToken = default)
public Task<StoreHoliday?> FindHolidayByIdAsync(long holidayId, long? tenantId, CancellationToken cancellationToken = default, bool includeDeleted = false)
{
var query = context.StoreHolidays.AsQueryable();
if (tenantId <= 0)
// 1. 包含软删除数据时忽略全局过滤
if (includeDeleted)
{
query = query.IgnoreQueryFilters()
.Where(x => x.DeletedAt == null);
}
else
{
query = query.Where(x => x.TenantId == tenantId);
query = query.IgnoreQueryFilters();
}
// 2. (空行后) 可选租户过滤
if (tenantId.HasValue)
{
query = query.Where(x => x.TenantId == tenantId.Value);
}
// 3. (空行后) 返回节假日实体
return query
.Where(x => x.Id == holidayId)
.FirstOrDefaultAsync(cancellationToken);
@@ -624,19 +665,16 @@ public sealed class EfStoreRepository(TakeoutAdminDbContext context) : IStoreRep
}
/// <inheritdoc />
public async Task DeleteDeliveryZoneAsync(long deliveryZoneId, long tenantId, CancellationToken cancellationToken = default)
public async Task DeleteDeliveryZoneAsync(long deliveryZoneId, long? tenantId, CancellationToken cancellationToken = default)
{
// 1. 查询目标配送区域
var query = context.StoreDeliveryZones.AsQueryable();
if (tenantId <= 0)
if (tenantId.HasValue)
{
query = query.IgnoreQueryFilters()
.Where(x => x.DeletedAt == null);
}
else
{
query = query.Where(x => x.TenantId == tenantId);
query = query.Where(x => x.TenantId == tenantId.Value);
}
// 2. (空行后) 执行软删除
var existing = await query
.Where(x => x.Id == deliveryZoneId)
.FirstOrDefaultAsync(cancellationToken);
@@ -648,19 +686,16 @@ public sealed class EfStoreRepository(TakeoutAdminDbContext context) : IStoreRep
}
/// <inheritdoc />
public async Task DeleteHolidayAsync(long holidayId, long tenantId, CancellationToken cancellationToken = default)
public async Task DeleteHolidayAsync(long holidayId, long? tenantId, CancellationToken cancellationToken = default)
{
// 1. 查询目标节假日
var query = context.StoreHolidays.AsQueryable();
if (tenantId <= 0)
if (tenantId.HasValue)
{
query = query.IgnoreQueryFilters()
.Where(x => x.DeletedAt == null);
}
else
{
query = query.Where(x => x.TenantId == tenantId);
query = query.Where(x => x.TenantId == tenantId.Value);
}
// 2. (空行后) 执行软删除
var existing = await query
.Where(x => x.Id == holidayId)
.FirstOrDefaultAsync(cancellationToken);

View File

@@ -18,9 +18,9 @@ public sealed class EfSubscriptionRepository(TakeoutAdminDbContext dbContext, Ta
public async Task<TenantSubscription?> FindByIdAsync(
long subscriptionId,
CancellationToken cancellationToken = default,
bool ignoreTenantFilter = false)
bool includeDeleted = false)
{
var query = ignoreTenantFilter
var query = includeDeleted
? dbContext.TenantSubscriptions.IgnoreQueryFilters()
: dbContext.TenantSubscriptions;
@@ -32,10 +32,10 @@ public sealed class EfSubscriptionRepository(TakeoutAdminDbContext dbContext, Ta
public async Task<IReadOnlyList<TenantSubscription>> FindByIdsAsync(
IEnumerable<long> subscriptionIds,
CancellationToken cancellationToken = default,
bool ignoreTenantFilter = false)
bool includeDeleted = false)
{
var ids = subscriptionIds.ToList();
var query = ignoreTenantFilter
var query = includeDeleted
? dbContext.TenantSubscriptions.IgnoreQueryFilters()
: dbContext.TenantSubscriptions;
@@ -48,10 +48,10 @@ public sealed class EfSubscriptionRepository(TakeoutAdminDbContext dbContext, Ta
public async Task<(IReadOnlyList<SubscriptionWithRelations> Items, int Total)> SearchPagedAsync(
SubscriptionSearchFilter filter,
CancellationToken cancellationToken = default,
bool ignoreTenantFilter = false)
bool includeDeleted = false)
{
// 1. 构建基础查询
var subscriptionQuery = ignoreTenantFilter
var subscriptionQuery = includeDeleted
? dbContext.TenantSubscriptions.IgnoreQueryFilters()
: dbContext.TenantSubscriptions;
@@ -134,9 +134,9 @@ public sealed class EfSubscriptionRepository(TakeoutAdminDbContext dbContext, Ta
public async Task<SubscriptionDetailInfo?> GetDetailAsync(
long subscriptionId,
CancellationToken cancellationToken = default,
bool ignoreTenantFilter = false)
bool includeDeleted = false)
{
var subscriptionQuery = ignoreTenantFilter
var subscriptionQuery = includeDeleted
? dbContext.TenantSubscriptions.IgnoreQueryFilters()
: dbContext.TenantSubscriptions;
@@ -173,11 +173,11 @@ public sealed class EfSubscriptionRepository(TakeoutAdminDbContext dbContext, Ta
public async Task<IReadOnlyList<SubscriptionWithTenant>> FindByIdsWithTenantAsync(
IEnumerable<long> subscriptionIds,
CancellationToken cancellationToken = default,
bool ignoreTenantFilter = false)
bool includeDeleted = false)
{
var ids = subscriptionIds.ToList();
var query = ignoreTenantFilter
var query = includeDeleted
? dbContext.TenantSubscriptions.IgnoreQueryFilters()
: dbContext.TenantSubscriptions;
@@ -201,10 +201,10 @@ public sealed class EfSubscriptionRepository(TakeoutAdminDbContext dbContext, Ta
DateTime now,
DateTime renewalThreshold,
CancellationToken cancellationToken = default,
bool ignoreTenantFilter = false)
bool includeDeleted = false)
{
// 1. 查询开启自动续费且即将到期的活跃订阅
var subscriptionQuery = ignoreTenantFilter
var subscriptionQuery = includeDeleted
? dbContext.TenantSubscriptions.IgnoreQueryFilters()
: dbContext.TenantSubscriptions;
@@ -232,10 +232,10 @@ public sealed class EfSubscriptionRepository(TakeoutAdminDbContext dbContext, Ta
DateTime startOfDay,
DateTime endOfDay,
CancellationToken cancellationToken = default,
bool ignoreTenantFilter = false)
bool includeDeleted = false)
{
// 1. 查询到期落在指定区间的订阅(且未开启自动续费)
var subscriptionQuery = ignoreTenantFilter
var subscriptionQuery = includeDeleted
? dbContext.TenantSubscriptions.IgnoreQueryFilters()
: dbContext.TenantSubscriptions;
@@ -268,9 +268,9 @@ public sealed class EfSubscriptionRepository(TakeoutAdminDbContext dbContext, Ta
public async Task<IReadOnlyList<TenantSubscription>> FindExpiredActiveSubscriptionsAsync(
DateTime now,
CancellationToken cancellationToken = default,
bool ignoreTenantFilter = false)
bool includeDeleted = false)
{
var query = ignoreTenantFilter
var query = includeDeleted
? dbContext.TenantSubscriptions.IgnoreQueryFilters()
: dbContext.TenantSubscriptions;
@@ -285,9 +285,9 @@ public sealed class EfSubscriptionRepository(TakeoutAdminDbContext dbContext, Ta
DateTime now,
int gracePeriodDays,
CancellationToken cancellationToken = default,
bool ignoreTenantFilter = false)
bool includeDeleted = false)
{
var query = ignoreTenantFilter
var query = includeDeleted
? dbContext.TenantSubscriptions.IgnoreQueryFilters()
: dbContext.TenantSubscriptions;
@@ -364,9 +364,9 @@ public sealed class EfSubscriptionRepository(TakeoutAdminDbContext dbContext, Ta
public async Task<IReadOnlyList<TenantQuotaUsage>> GetQuotaUsagesAsync(
long tenantId,
CancellationToken cancellationToken = default,
bool ignoreTenantFilter = false)
bool includeDeleted = false)
{
var query = ignoreTenantFilter
var query = includeDeleted
? dbContext.TenantQuotaUsages.IgnoreQueryFilters()
: dbContext.TenantQuotaUsages;

View File

@@ -1,11 +1,9 @@
using Microsoft.EntityFrameworkCore;
using System.Reflection;
using System.Linq;
using TakeoutSaaS.Shared.Abstractions.Entities;
using TakeoutSaaS.Shared.Abstractions.Ids;
using TakeoutSaaS.Shared.Abstractions.Security;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
using Microsoft.AspNetCore.Http;
namespace TakeoutSaaS.Infrastructure.Common.Persistence;
@@ -16,23 +14,12 @@ public abstract class TenantAwareDbContext(
DbContextOptions options,
ITenantProvider tenantProvider,
ICurrentUserAccessor? currentUserAccessor = null,
IIdGenerator? idGenerator = null,
IHttpContextAccessor? httpContextAccessor = null) : AppDbContext(options, currentUserAccessor, idGenerator)
IIdGenerator? idGenerator = null) : AppDbContext(options, currentUserAccessor, idGenerator)
{
private readonly ITenantProvider _tenantProvider = tenantProvider ?? throw new ArgumentNullException(nameof(tenantProvider));
private readonly IHttpContextAccessor? _httpContextAccessor = httpContextAccessor;
private static readonly string[] PlatformRoleCodes =
{
"super-admin",
"SUPER_ADMIN",
"PlatformAdmin",
"platform-admin"
};
/// <summary>
/// 当前请求租户 ID。
/// </summary>
protected long CurrentTenantId => _tenantProvider.GetCurrentTenantId();
protected long CurrentTenantId => tenantProvider.GetCurrentTenantId();
/// <summary>
/// 保存前填充租户元数据并执行基础处理。
@@ -86,22 +73,8 @@ public abstract class TenantAwareDbContext(
{
if (entry.State == EntityState.Added && entry.Entity.TenantId == 0 && tenantId != 0)
{
if (!IsPlatformAdmin())
{
entry.Entity.TenantId = tenantId;
}
entry.Entity.TenantId = tenantId;
}
}
}
private bool IsPlatformAdmin()
{
var user = _httpContextAccessor?.HttpContext?.User;
if (user?.Identity?.IsAuthenticated != true)
{
return false;
}
return PlatformRoleCodes.Any(user.IsInRole);
}
}

View File

@@ -7,7 +7,6 @@ using TakeoutSaaS.Infrastructure.Common.Persistence;
using TakeoutSaaS.Shared.Abstractions.Ids;
using TakeoutSaaS.Shared.Abstractions.Security;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
using Microsoft.AspNetCore.Http;
namespace TakeoutSaaS.Infrastructure.Dictionary.Persistence;
@@ -18,9 +17,8 @@ public sealed class DictionaryDbContext(
DbContextOptions<DictionaryDbContext> options,
ITenantProvider tenantProvider,
ICurrentUserAccessor? currentUserAccessor = null,
IIdGenerator? idGenerator = null,
IHttpContextAccessor? httpContextAccessor = null)
: TenantAwareDbContext(options, tenantProvider, currentUserAccessor, idGenerator, httpContextAccessor)
IIdGenerator? idGenerator = null)
: TenantAwareDbContext(options, tenantProvider, currentUserAccessor, idGenerator)
{
/// <summary>
/// 字典分组集合。

View File

@@ -125,19 +125,6 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
public Task<IdentityUser?> FindByIdAsync(long userId, CancellationToken cancellationToken = default)
=> dbContext.IdentityUsers.AsNoTracking().FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
/// <summary>
/// 根据 ID 获取后台用户(忽略租户过滤器,仅用于只读查询)。
/// </summary>
/// <param name="userId">用户 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>后台用户或 null。</returns>
public Task<IdentityUser?> FindByIdIgnoringTenantAsync(long userId, CancellationToken cancellationToken = default)
=> dbContext.IdentityUsers
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.DeletedAt == null)
.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
/// <summary>
/// 根据 ID 获取后台用户(用于更新,返回可跟踪实体)。
/// </summary>
@@ -147,43 +134,16 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
public Task<IdentityUser?> GetForUpdateAsync(long userId, CancellationToken cancellationToken = default)
=> dbContext.IdentityUsers.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
/// <summary>
/// 根据 ID 获取后台用户(用于更新,忽略租户过滤器)。
/// </summary>
/// <remarks>用于跨租户场景(如平台生成的重置密码链接)。</remarks>
/// <param name="userId">用户 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>后台用户或 null。</returns>
public Task<IdentityUser?> GetForUpdateIgnoringTenantAsync(long userId, CancellationToken cancellationToken = default)
=> dbContext.IdentityUsers
.IgnoreQueryFilters()
.Where(x => x.DeletedAt == null)
.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
/// <summary>
/// 根据 ID 获取后台用户(用于更新,包含已删除数据)。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="userId">用户 ID。</param>
/// <param name="ignoreTenantFilter">是否忽略租户过滤。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>后台用户或 null。</returns>
public Task<IdentityUser?> GetForUpdateIncludingDeletedAsync(
long tenantId,
long userId,
bool ignoreTenantFilter = false,
CancellationToken cancellationToken = default)
{
// 1. 构建查询(包含已删除数据)
var query = dbContext.IdentityUsers.IgnoreQueryFilters();
if (!ignoreTenantFilter)
{
query = query.Where(x => x.TenantId == tenantId);
}
// 2. 返回实体
return query.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
}
public Task<IdentityUser?> GetForUpdateIncludingDeletedAsync(long userId, CancellationToken cancellationToken = default)
=> dbContext.IdentityUsers
.IgnoreQueryFilters()
.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
/// <summary>
/// 按租户与关键字搜索后台用户(只读)。
@@ -214,40 +174,28 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
/// 分页查询后台用户列表。
/// </summary>
/// <param name="filter">查询过滤条件。</param>
/// <param name="ignoreTenantFilter">是否忽略租户过滤器。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>分页结果。</returns>
public async Task<(IReadOnlyList<IdentityUser> Items, int Total)> SearchPagedAsync(
IdentityUserSearchFilter filter,
bool ignoreTenantFilter = false,
CancellationToken cancellationToken = default)
{
// 1. 构建基础查询
var query = dbContext.IdentityUsers.AsNoTracking();
if (ignoreTenantFilter || filter.IncludeDeleted)
// 2. (空行后) 包含软删除数据时忽略全局过滤
if (filter.IncludeDeleted)
{
query = query.IgnoreQueryFilters();
}
// 2. 租户过滤
if (!ignoreTenantFilter)
{
if (filter.TenantId.HasValue && filter.TenantId.Value != 0)
{
query = query.Where(x => x.TenantId == filter.TenantId.Value);
}
}
else if (filter.TenantId.HasValue && filter.TenantId.Value != 0)
// 3. (空行后) 可选租户过滤
if (filter.TenantId.HasValue)
{
query = query.Where(x => x.TenantId == filter.TenantId.Value);
}
if (!filter.IncludeDeleted)
{
query = query.Where(x => x.DeletedAt == null);
}
// 3. 关键字筛选
// 4. (空行后) 关键字筛选
if (!string.IsNullOrWhiteSpace(filter.Keyword))
{
var normalized = filter.Keyword.Trim();
@@ -259,43 +207,35 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
|| (x.Email != null && EF.Functions.ILike(x.Email, likeValue)));
}
// 4. 状态过滤
// 5. (空行后) 状态过滤
if (filter.Status.HasValue)
{
query = query.Where(x => x.Status == filter.Status.Value);
}
// 5. 角色过滤
// 6. (空行后) 角色过滤
if (filter.RoleId.HasValue)
{
var roleId = filter.RoleId.Value;
var userRoles = dbContext.UserRoles.AsNoTracking();
if (ignoreTenantFilter || filter.IncludeDeleted)
// 6.1 包含软删除数据时忽略全局过滤
if (filter.IncludeDeleted)
{
userRoles = userRoles.IgnoreQueryFilters();
}
if (!ignoreTenantFilter)
{
if (filter.TenantId.HasValue && filter.TenantId.Value != 0)
{
userRoles = userRoles.Where(x => x.TenantId == filter.TenantId.Value);
}
}
else if (filter.TenantId.HasValue && filter.TenantId.Value != 0)
// 6.2 (空行后) 可选租户过滤
if (filter.TenantId.HasValue)
{
userRoles = userRoles.Where(x => x.TenantId == filter.TenantId.Value);
}
if (!filter.IncludeDeleted)
{
userRoles = userRoles.Where(x => x.DeletedAt == null);
}
// 6.3 (空行后) 用户角色关联过滤
query = query.Where(user => userRoles.Any(role => role.UserId == user.Id && role.RoleId == roleId));
}
// 6. 时间范围过滤
// 7. (空行后) 时间范围过滤
if (filter.CreatedAtFrom.HasValue)
{
query = query.Where(x => x.CreatedAt >= filter.CreatedAtFrom.Value);
@@ -316,7 +256,7 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
query = query.Where(x => x.LastLoginAt <= filter.LastLoginTo.Value);
}
// 7. 排序
// 8. (空行后) 排序
var sorted = filter.SortBy?.ToLowerInvariant() switch
{
"account" => filter.SortDescending
@@ -336,7 +276,7 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
: query.OrderBy(x => x.CreatedAt)
};
// 8. 分页
// 9. (空行后) 分页
var page = filter.Page <= 0 ? 1 : filter.Page;
var pageSize = filter.PageSize <= 0 ? 20 : filter.PageSize;
var total = await sorted.CountAsync(cancellationToken);
@@ -355,11 +295,21 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
/// <param name="userIds">用户 ID 集合。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>后台用户列表。</returns>
public Task<IReadOnlyList<IdentityUser>> GetByIdsAsync(long tenantId, IEnumerable<long> userIds, CancellationToken cancellationToken = default)
=> dbContext.IdentityUsers.AsNoTracking()
.Where(x => x.TenantId == tenantId && userIds.Contains(x.Id))
.ToListAsync(cancellationToken)
.ContinueWith(t => (IReadOnlyList<IdentityUser>)t.Result, cancellationToken);
public async Task<IReadOnlyList<IdentityUser>> GetByIdsAsync(long tenantId, IEnumerable<long> userIds, CancellationToken cancellationToken = default)
{
// 1. 去重并快速返回空集合
var ids = userIds.Distinct().ToArray();
if (ids.Length == 0)
{
return Array.Empty<IdentityUser>();
}
// 2. (空行后) 查询并返回列表
return await dbContext.IdentityUsers
.AsNoTracking()
.Where(x => x.TenantId == tenantId && ids.Contains(x.Id))
.ToListAsync(cancellationToken);
}
/// <summary>
/// 批量获取后台用户(可用于更新,支持包含已删除数据)。
@@ -367,42 +317,33 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
/// <param name="tenantId">租户 ID。</param>
/// <param name="userIds">用户 ID 集合。</param>
/// <param name="includeDeleted">是否包含已删除数据。</param>
/// <param name="ignoreTenantFilter">是否忽略租户过滤器。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>后台用户列表。</returns>
public Task<IReadOnlyList<IdentityUser>> GetForUpdateByIdsAsync(
public async Task<IReadOnlyList<IdentityUser>> GetForUpdateByIdsAsync(
long tenantId,
IEnumerable<long> userIds,
bool includeDeleted,
bool ignoreTenantFilter = false,
CancellationToken cancellationToken = default)
{
// 1. 构建基础查询
// 1. 去重并快速返回空集合
var ids = userIds.Distinct().ToArray();
if (ids.Length == 0)
{
return Task.FromResult<IReadOnlyList<IdentityUser>>(Array.Empty<IdentityUser>());
return Array.Empty<IdentityUser>();
}
var query = dbContext.IdentityUsers.Where(x => ids.Contains(x.Id));
if (ignoreTenantFilter || includeDeleted)
// 2. (空行后) 构建查询
var query = dbContext.IdentityUsers
.Where(x => x.TenantId == tenantId && ids.Contains(x.Id));
// 3. (空行后) 包含软删除数据时忽略全局过滤
if (includeDeleted)
{
query = query.IgnoreQueryFilters();
}
if (!ignoreTenantFilter)
{
query = query.Where(x => x.TenantId == tenantId);
}
if (!includeDeleted)
{
query = query.Where(x => x.DeletedAt == null);
}
// 2. 返回列表
return query.ToListAsync(cancellationToken)
.ContinueWith(t => (IReadOnlyList<IdentityUser>)t.Result, cancellationToken);
// 4. (空行后) 返回列表
return await query.ToListAsync(cancellationToken);
}
/// <summary>

View File

@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Identity.Entities;
using TakeoutSaaS.Domain.Identity.Enums;
using TakeoutSaaS.Domain.Identity.Repositories;
namespace TakeoutSaaS.Infrastructure.Identity.Persistence;
@@ -13,10 +14,9 @@ public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermi
/// 根据权限 ID 获取权限。
/// </summary>
/// <param name="permissionId">权限 ID。</param>
/// <param name="tenantId">租户 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>权限实体或 null。</returns>
public Task<Permission?> FindByIdAsync(long permissionId, long tenantId, CancellationToken cancellationToken = default)
public Task<Permission?> FindByIdAsync(long permissionId, CancellationToken cancellationToken = default)
=> dbContext.Permissions
.IgnoreQueryFilters()
.AsNoTracking()
@@ -26,10 +26,9 @@ public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermi
/// 根据权限编码获取权限。
/// </summary>
/// <param name="code">权限编码。</param>
/// <param name="tenantId">租户 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>权限实体或 null。</returns>
public Task<Permission?> FindByCodeAsync(string code, long tenantId, CancellationToken cancellationToken = default)
public Task<Permission?> FindByCodeAsync(string code, CancellationToken cancellationToken = default)
=> dbContext.Permissions
.IgnoreQueryFilters()
.AsNoTracking()
@@ -38,11 +37,10 @@ public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermi
/// <summary>
/// 根据权限编码集合批量获取权限。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="codes">权限编码集合。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>权限列表。</returns>
public Task<IReadOnlyList<Permission>> GetByCodesAsync(long tenantId, IEnumerable<string> codes, CancellationToken cancellationToken = default)
public Task<IReadOnlyList<Permission>> GetByCodesAsync(IEnumerable<string> codes, CancellationToken cancellationToken = default)
{
// 1. 规范化编码集合
var normalizedCodes = codes
@@ -63,11 +61,10 @@ public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermi
/// <summary>
/// 根据权限 ID 集合批量获取权限。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="permissionIds">权限 ID 集合。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>权限列表。</returns>
public Task<IReadOnlyList<Permission>> GetByIdsAsync(long tenantId, IEnumerable<long> permissionIds, CancellationToken cancellationToken = default)
public Task<IReadOnlyList<Permission>> GetByIdsAsync(IEnumerable<long> permissionIds, CancellationToken cancellationToken = default)
=> dbContext.Permissions
.IgnoreQueryFilters()
.AsNoTracking()
@@ -76,19 +73,19 @@ public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermi
.ContinueWith(t => (IReadOnlyList<Permission>)t.Result, cancellationToken);
/// <summary>
/// 按关键字搜索权限。
/// 按 Portal 与关键字搜索权限。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="portal">Portal 类型。</param>
/// <param name="keyword">搜索关键字。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>权限列表。</returns>
public Task<IReadOnlyList<Permission>> SearchAsync(long tenantId, string? keyword, CancellationToken cancellationToken = default)
public Task<IReadOnlyList<Permission>> SearchAsync(PortalType portal, string? keyword, CancellationToken cancellationToken = default)
{
// 1. 构建基础查询
var query = dbContext.Permissions
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.DeletedAt == null);
.Where(x => x.DeletedAt == null && x.Portal == portal);
if (!string.IsNullOrWhiteSpace(keyword))
{
// 2. 追加关键字过滤
@@ -133,10 +130,9 @@ public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermi
/// 删除指定权限。
/// </summary>
/// <param name="permissionId">权限 ID。</param>
/// <param name="tenantId">租户 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>异步任务。</returns>
public async Task DeleteAsync(long permissionId, long tenantId, CancellationToken cancellationToken = default)
public async Task DeleteAsync(long permissionId, CancellationToken cancellationToken = default)
{
// 1. 查询目标权限
var entity = await dbContext.Permissions.FirstOrDefaultAsync(x => x.Id == permissionId, cancellationToken);

View File

@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Identity.Entities;
using TakeoutSaaS.Domain.Identity.Enums;
using TakeoutSaaS.Domain.Identity.Repositories;
namespace TakeoutSaaS.Infrastructure.Identity.Persistence;
@@ -12,15 +13,20 @@ public sealed class EfRolePermissionRepository(IdentityDbContext dbContext) : IR
/// <summary>
/// 根据角色 ID 集合获取角色权限映射。
/// </summary>
/// <param name="portal">Portal 类型。</param>
/// <param name="tenantId">租户 ID。</param>
/// <param name="roleIds">角色 ID 集合。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>角色权限映射列表。</returns>
public Task<IReadOnlyList<RolePermission>> GetByRoleIdsAsync(long tenantId, IEnumerable<long> roleIds, CancellationToken cancellationToken = default)
public Task<IReadOnlyList<RolePermission>> GetByRoleIdsAsync(
PortalType portal,
long? tenantId,
IEnumerable<long> roleIds,
CancellationToken cancellationToken = default)
=> dbContext.RolePermissions
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.DeletedAt == null && roleIds.Contains(x.RoleId))
.Where(x => x.Portal == portal && x.TenantId == tenantId && x.DeletedAt == null && roleIds.Contains(x.RoleId))
.ToListAsync(cancellationToken)
.ContinueWith(t => (IReadOnlyList<RolePermission>)t.Result, cancellationToken);
@@ -46,32 +52,51 @@ public sealed class EfRolePermissionRepository(IdentityDbContext dbContext) : IR
/// <summary>
/// 替换指定角色的权限集合。
/// </summary>
/// <param name="portal">Portal 类型。</param>
/// <param name="tenantId">租户 ID。</param>
/// <param name="roleId">角色 ID。</param>
/// <param name="permissionIds">权限 ID 集合。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>异步任务。</returns>
public async Task ReplaceRolePermissionsAsync(long tenantId, long roleId, IEnumerable<long> permissionIds, CancellationToken cancellationToken = default)
public async Task ReplaceRolePermissionsAsync(
PortalType portal,
long? tenantId,
long roleId,
IEnumerable<long> permissionIds,
CancellationToken cancellationToken = default)
{
// 0. 防御性参数检查
if (portal == PortalType.Tenant && (!tenantId.HasValue || tenantId.Value == 0))
{
throw new ArgumentException("Portal=Tenant 时必须提供有效的 TenantId", nameof(tenantId));
}
// 1. 使用执行策略保证可靠性
var strategy = dbContext.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
await using var trx = await dbContext.Database.BeginTransactionAsync(cancellationToken);
// 1. 删除旧记录(原生 SQL避免跟踪干扰
await dbContext.Database.ExecuteSqlRawAsync(
"DELETE FROM \"role_permissions\" WHERE \"TenantId\" = {0} AND \"RoleId\" = {1};",
parameters: new object[] { tenantId, roleId },
cancellationToken: cancellationToken);
// 1. 删除旧记录
await dbContext.RolePermissions
.IgnoreQueryFilters()
.Where(x => x.Portal == portal && x.TenantId == tenantId && x.RoleId == roleId)
.ExecuteDeleteAsync(cancellationToken);
// 2. 插入新记录(防重复
foreach (var permissionId in permissionIds.Distinct())
// 2. 插入新记录(权限去重
var distinctPermissionIds = permissionIds.Distinct().ToArray();
if (distinctPermissionIds.Length > 0)
{
await dbContext.Database.ExecuteSqlRawAsync(
"INSERT INTO \"role_permissions\" (\"TenantId\",\"RoleId\",\"PermissionId\",\"CreatedAt\",\"CreatedBy\",\"UpdatedAt\",\"UpdatedBy\",\"DeletedAt\",\"DeletedBy\") VALUES ({0},{1},{2},NOW(),NULL,NULL,NULL,NULL,NULL) ON CONFLICT DO NOTHING;",
parameters: new object[] { tenantId, roleId, permissionId },
cancellationToken: cancellationToken);
var toAdd = distinctPermissionIds.Select(permissionId => new RolePermission
{
Portal = portal,
TenantId = tenantId,
RoleId = roleId,
PermissionId = permissionId
});
await dbContext.RolePermissions.AddRangeAsync(toAdd, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
}
await trx.CommitAsync(cancellationToken);

View File

@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Identity.Entities;
using TakeoutSaaS.Domain.Identity.Enums;
using TakeoutSaaS.Domain.Identity.Repositories;
namespace TakeoutSaaS.Infrastructure.Identity.Persistence;
@@ -12,58 +13,70 @@ public sealed class EfRoleRepository(IdentityDbContext dbContext) : IRoleReposit
/// <summary>
/// 根据角色 ID 获取角色。
/// </summary>
/// <param name="roleId">角色 ID。</param>
/// <param name="portal">Portal 类型。</param>
/// <param name="tenantId">租户 ID。</param>
/// <param name="roleId">角色 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>角色实体或 null。</returns>
public Task<Role?> FindByIdAsync(long roleId, long tenantId, CancellationToken cancellationToken = default)
public Task<Role?> FindByIdAsync(PortalType portal, long? tenantId, long roleId, CancellationToken cancellationToken = default)
=> dbContext.Roles
.IgnoreQueryFilters()
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == roleId && x.TenantId == tenantId && x.DeletedAt == null, cancellationToken);
.FirstOrDefaultAsync(
x => x.Id == roleId && x.Portal == portal && x.TenantId == tenantId && x.DeletedAt == null,
cancellationToken);
/// <summary>
/// 根据角色编码获取角色。
/// </summary>
/// <param name="code">角色编码。</param>
/// <param name="portal">Portal 类型。</param>
/// <param name="tenantId">租户 ID。</param>
/// <param name="code">角色编码。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>角色实体或 null。</returns>
public Task<Role?> FindByCodeAsync(string code, long tenantId, CancellationToken cancellationToken = default)
public Task<Role?> FindByCodeAsync(PortalType portal, long? tenantId, string code, CancellationToken cancellationToken = default)
=> dbContext.Roles
.IgnoreQueryFilters()
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Code == code && x.TenantId == tenantId && x.DeletedAt == null, cancellationToken);
.FirstOrDefaultAsync(
x => x.Code == code && x.Portal == portal && x.TenantId == tenantId && x.DeletedAt == null,
cancellationToken);
/// <summary>
/// 根据角色 ID 集合获取角色列表。
/// </summary>
/// <param name="portal">Portal 类型。</param>
/// <param name="tenantId">租户 ID。</param>
/// <param name="roleIds">角色 ID 集合。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>角色列表。</returns>
public Task<IReadOnlyList<Role>> GetByIdsAsync(long tenantId, IEnumerable<long> roleIds, CancellationToken cancellationToken = default)
public Task<IReadOnlyList<Role>> GetByIdsAsync(
PortalType portal,
long? tenantId,
IEnumerable<long> roleIds,
CancellationToken cancellationToken = default)
=> dbContext.Roles
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.TenantId == tenantId && roleIds.Contains(x.Id) && x.DeletedAt == null)
.Where(x => x.Portal == portal && x.TenantId == tenantId && roleIds.Contains(x.Id) && x.DeletedAt == null)
.ToListAsync(cancellationToken)
.ContinueWith(t => (IReadOnlyList<Role>)t.Result, cancellationToken);
/// <summary>
/// 按关键字搜索角色。
/// </summary>
/// <param name="portal">Portal 类型。</param>
/// <param name="tenantId">租户 ID。</param>
/// <param name="keyword">搜索关键字。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>角色列表。</returns>
public Task<IReadOnlyList<Role>> SearchAsync(long tenantId, string? keyword, CancellationToken cancellationToken = default)
public Task<IReadOnlyList<Role>> SearchAsync(PortalType portal, long? tenantId, string? keyword, CancellationToken cancellationToken = default)
{
// 1. 构建基础查询
var query = dbContext.Roles
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.DeletedAt == null);
.Where(x => x.Portal == portal && x.TenantId == tenantId && x.DeletedAt == null);
if (!string.IsNullOrWhiteSpace(keyword))
{
// 2. 追加关键字过滤
@@ -107,14 +120,15 @@ public sealed class EfRoleRepository(IdentityDbContext dbContext) : IRoleReposit
/// <summary>
/// 软删除角色。
/// </summary>
/// <param name="roleId">角色 ID。</param>
/// <param name="portal">Portal 类型。</param>
/// <param name="tenantId">租户 ID。</param>
/// <param name="roleId">角色 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>异步任务。</returns>
public async Task DeleteAsync(long roleId, long tenantId, CancellationToken cancellationToken = default)
public async Task DeleteAsync(PortalType portal, long? tenantId, long roleId, CancellationToken cancellationToken = default)
{
// 1. 查询目标角色
var entity = await dbContext.Roles.FirstOrDefaultAsync(x => x.Id == roleId && x.TenantId == tenantId, cancellationToken);
var entity = await dbContext.Roles.FirstOrDefaultAsync(x => x.Id == roleId && x.Portal == portal && x.TenantId == tenantId, cancellationToken);
if (entity != null)
{
// 2. 标记删除时间

View File

@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Identity.Entities;
using TakeoutSaaS.Domain.Identity.Enums;
using TakeoutSaaS.Domain.Identity.Repositories;
namespace TakeoutSaaS.Infrastructure.Identity.Persistence;
@@ -12,42 +13,58 @@ public sealed class EfUserRoleRepository(IdentityDbContext dbContext) : IUserRol
/// <summary>
/// 根据用户 ID 集合获取用户角色映射。
/// </summary>
/// <param name="portal">Portal 类型。</param>
/// <param name="tenantId">租户 ID。</param>
/// <param name="userIds">用户 ID 集合。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>用户角色映射列表。</returns>
public Task<IReadOnlyList<UserRole>> GetByUserIdsAsync(long tenantId, IEnumerable<long> userIds, CancellationToken cancellationToken = default)
public Task<IReadOnlyList<UserRole>> GetByUserIdsAsync(
PortalType portal,
long? tenantId,
IEnumerable<long> userIds,
CancellationToken cancellationToken = default)
=> dbContext.UserRoles
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.DeletedAt == null && userIds.Contains(x.UserId))
.Where(x => x.Portal == portal && x.TenantId == tenantId && x.DeletedAt == null && userIds.Contains(x.UserId))
.ToListAsync(cancellationToken)
.ContinueWith(t => (IReadOnlyList<UserRole>)t.Result, cancellationToken);
/// <summary>
/// 获取指定用户的角色集合。
/// </summary>
/// <param name="portal">Portal 类型。</param>
/// <param name="tenantId">租户 ID。</param>
/// <param name="userId">用户 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>用户角色列表。</returns>
public Task<IReadOnlyList<UserRole>> GetByUserIdAsync(long tenantId, long userId, CancellationToken cancellationToken = default)
public Task<IReadOnlyList<UserRole>> GetByUserIdAsync(
PortalType portal,
long? tenantId,
long userId,
CancellationToken cancellationToken = default)
=> dbContext.UserRoles
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.DeletedAt == null && x.UserId == userId)
.Where(x => x.Portal == portal && x.TenantId == tenantId && x.DeletedAt == null && x.UserId == userId)
.ToListAsync(cancellationToken)
.ContinueWith(t => (IReadOnlyList<UserRole>)t.Result, cancellationToken);
/// <summary>
/// 替换指定用户的角色集合。
/// </summary>
/// <param name="portal">Portal 类型。</param>
/// <param name="tenantId">租户 ID。</param>
/// <param name="userId">用户 ID。</param>
/// <param name="roleIds">角色 ID 集合。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>异步任务。</returns>
public async Task ReplaceUserRolesAsync(long tenantId, long userId, IEnumerable<long> roleIds, CancellationToken cancellationToken = default)
public async Task ReplaceUserRolesAsync(
PortalType portal,
long? tenantId,
long userId,
IEnumerable<long> roleIds,
CancellationToken cancellationToken = default)
{
// 1. 使用执行策略保障一致性
var strategy = dbContext.Database.CreateExecutionStrategy();
@@ -58,7 +75,7 @@ public sealed class EfUserRoleRepository(IdentityDbContext dbContext) : IUserRol
// 2. 读取当前角色映射
var existing = await dbContext.UserRoles
.IgnoreQueryFilters()
.Where(x => x.TenantId == tenantId && x.UserId == userId)
.Where(x => x.Portal == portal && x.TenantId == tenantId && x.UserId == userId)
.ToListAsync(cancellationToken);
// 3. 去重并构建目标集合
@@ -90,6 +107,7 @@ public sealed class EfUserRoleRepository(IdentityDbContext dbContext) : IUserRol
.Where(roleId => !existingRoleMap.ContainsKey(roleId))
.Select(roleId => new UserRole
{
Portal = portal,
TenantId = tenantId,
UserId = userId,
RoleId = roleId
@@ -105,15 +123,16 @@ public sealed class EfUserRoleRepository(IdentityDbContext dbContext) : IUserRol
/// <summary>
/// 统计指定角色下的用户数量。
/// </summary>
/// <param name="portal">Portal 类型。</param>
/// <param name="tenantId">租户 ID。</param>
/// <param name="roleId">角色 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>用户数量。</returns>
public Task<int> CountUsersByRoleAsync(long tenantId, long roleId, CancellationToken cancellationToken = default)
public Task<int> CountUsersByRoleAsync(PortalType portal, long? tenantId, long roleId, CancellationToken cancellationToken = default)
=> dbContext.UserRoles
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.DeletedAt == null && x.RoleId == roleId)
.Where(x => x.Portal == portal && x.TenantId == tenantId && x.DeletedAt == null && x.RoleId == roleId)
.CountAsync(cancellationToken);
/// <summary>

View File

@@ -2,11 +2,11 @@ using MassTransit;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using TakeoutSaaS.Domain.Identity.Entities;
using TakeoutSaaS.Domain.Identity.Enums;
using TakeoutSaaS.Infrastructure.Common.Persistence;
using TakeoutSaaS.Shared.Abstractions.Ids;
using TakeoutSaaS.Shared.Abstractions.Security;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
using Microsoft.AspNetCore.Http;
namespace TakeoutSaaS.Infrastructure.Identity.Persistence;
@@ -17,9 +17,8 @@ public sealed class IdentityDbContext(
DbContextOptions<IdentityDbContext> options,
ITenantProvider tenantProvider,
ICurrentUserAccessor? currentUserAccessor = null,
IIdGenerator? idGenerator = null,
IHttpContextAccessor? httpContextAccessor = null)
: TenantAwareDbContext(options, tenantProvider, currentUserAccessor, idGenerator, httpContextAccessor)
IIdGenerator? idGenerator = null)
: TenantAwareDbContext(options, tenantProvider, currentUserAccessor, idGenerator)
{
/// <summary>
/// 管理后台用户集合。
@@ -93,7 +92,12 @@ public sealed class IdentityDbContext(
/// <param name="builder">实体构建器。</param>
private static void ConfigureIdentityUser(EntityTypeBuilder<IdentityUser> builder)
{
builder.ToTable("identity_users");
builder.ToTable("identity_users", t =>
{
t.HasCheckConstraint(
"CK_identity_users_Portal_Tenant",
"(\"Portal\" = 0 AND \"TenantId\" IS NULL) OR (\"Portal\" = 1 AND \"TenantId\" IS NOT NULL)");
});
builder.HasKey(x => x.Id);
builder.Property(x => x.Account).HasMaxLength(64).IsRequired();
builder.Property(x => x.DisplayName).HasMaxLength(64).IsRequired();
@@ -110,18 +114,33 @@ public sealed class IdentityDbContext(
.IsRowVersion()
.IsConcurrencyToken()
.HasColumnType("bytea");
builder.Property(x => x.TenantId).IsRequired();
builder.Property(x => x.Portal).HasConversion<int>().IsRequired();
builder.Property(x => x.TenantId);
ConfigureAuditableEntity(builder);
ConfigureSoftDeleteEntity(builder);
builder.HasIndex(x => x.TenantId);
builder.HasIndex(x => new { x.TenantId, x.Account }).IsUnique();
builder.HasIndex(x => x.TenantId).HasFilter("\"Portal\" = 1");
builder.HasIndex(x => x.Account)
.IsUnique()
.HasFilter("\"Portal\" = 0");
builder.HasIndex(x => new { x.TenantId, x.Account })
.IsUnique()
.HasFilter("\"Portal\" = 1");
builder.HasIndex(x => x.Phone)
.IsUnique()
.HasFilter("\"Portal\" = 0 AND \"Phone\" IS NOT NULL");
builder.HasIndex(x => new { x.TenantId, x.Phone })
.IsUnique()
.HasFilter("\"Phone\" IS NOT NULL");
.HasFilter("\"Portal\" = 1 AND \"Phone\" IS NOT NULL");
builder.HasIndex(x => x.Email)
.IsUnique()
.HasFilter("\"Portal\" = 0 AND \"Email\" IS NOT NULL");
builder.HasIndex(x => new { x.TenantId, x.Email })
.IsUnique()
.HasFilter("\"Email\" IS NOT NULL");
.HasFilter("\"Portal\" = 1 AND \"Email\" IS NOT NULL");
}
/// <summary>
@@ -146,23 +165,36 @@ public sealed class IdentityDbContext(
private static void ConfigureRole(EntityTypeBuilder<Role> builder)
{
builder.ToTable("roles");
builder.ToTable("roles", t =>
{
t.HasCheckConstraint(
"CK_roles_Portal_Tenant",
"(\"Portal\" = 0 AND \"TenantId\" IS NULL) OR (\"Portal\" = 1 AND \"TenantId\" IS NOT NULL)");
});
builder.HasKey(x => x.Id);
builder.Property(x => x.TenantId).IsRequired();
builder.Property(x => x.Portal).HasConversion<int>().IsRequired();
builder.Property(x => x.TenantId);
builder.Property(x => x.Name).HasMaxLength(64).IsRequired();
builder.Property(x => x.Code).HasMaxLength(64).IsRequired();
builder.Property(x => x.Description).HasMaxLength(256);
ConfigureAuditableEntity(builder);
ConfigureSoftDeleteEntity(builder);
builder.HasIndex(x => x.TenantId);
builder.HasIndex(x => new { x.TenantId, x.Code }).IsUnique();
builder.HasIndex(x => x.Code)
.IsUnique()
.HasFilter("\"Portal\" = 0");
builder.HasIndex(x => new { x.TenantId, x.Code })
.IsUnique()
.HasFilter("\"Portal\" = 1");
builder.HasIndex(x => x.TenantId)
.HasFilter("\"Portal\" = 1");
}
private static void ConfigurePermission(EntityTypeBuilder<Permission> builder)
{
builder.ToTable("permissions");
builder.HasKey(x => x.Id);
builder.Property(x => x.TenantId).IsRequired();
builder.Property(x => x.Portal).HasConversion<int>().IsRequired();
builder.Property(x => x.ParentId).IsRequired();
builder.Property(x => x.SortOrder).IsRequired();
builder.Property(x => x.Type).HasMaxLength(16).IsRequired();
@@ -171,9 +203,8 @@ public sealed class IdentityDbContext(
builder.Property(x => x.Description).HasMaxLength(256);
ConfigureAuditableEntity(builder);
ConfigureSoftDeleteEntity(builder);
builder.HasIndex(x => x.TenantId);
builder.HasIndex(x => new { x.TenantId, x.ParentId, x.SortOrder });
builder.HasIndex(x => new { x.TenantId, x.Code }).IsUnique();
builder.HasIndex(x => new { x.Portal, x.ParentId, x.SortOrder });
builder.HasIndex(x => x.Code).IsUnique();
}
private static void ConfigureRoleTemplate(EntityTypeBuilder<RoleTemplate> builder)
@@ -200,35 +231,59 @@ public sealed class IdentityDbContext(
private static void ConfigureUserRole(EntityTypeBuilder<UserRole> builder)
{
builder.ToTable("user_roles");
builder.ToTable("user_roles", t =>
{
t.HasCheckConstraint(
"CK_user_roles_Portal_Tenant",
"(\"Portal\" = 0 AND \"TenantId\" IS NULL) OR (\"Portal\" = 1 AND \"TenantId\" IS NOT NULL)");
});
builder.HasKey(x => x.Id);
builder.Property(x => x.TenantId).IsRequired();
builder.Property(x => x.Portal).HasConversion<int>().IsRequired();
builder.Property(x => x.TenantId);
builder.Property(x => x.UserId).IsRequired();
builder.Property(x => x.RoleId).IsRequired();
ConfigureAuditableEntity(builder);
ConfigureSoftDeleteEntity(builder);
builder.HasIndex(x => x.TenantId);
builder.HasIndex(x => new { x.TenantId, x.UserId, x.RoleId }).IsUnique();
builder.HasIndex(x => new { x.UserId, x.RoleId })
.IsUnique()
.HasFilter("\"Portal\" = 0");
builder.HasIndex(x => new { x.TenantId, x.UserId, x.RoleId })
.IsUnique()
.HasFilter("\"Portal\" = 1");
builder.HasIndex(x => x.TenantId)
.HasFilter("\"Portal\" = 1");
}
private static void ConfigureRolePermission(EntityTypeBuilder<RolePermission> builder)
{
builder.ToTable("role_permissions");
builder.ToTable("role_permissions", t =>
{
t.HasCheckConstraint(
"CK_role_permissions_Portal_Tenant",
"(\"Portal\" = 0 AND \"TenantId\" IS NULL) OR (\"Portal\" = 1 AND \"TenantId\" IS NOT NULL)");
});
builder.HasKey(x => x.Id);
builder.Property(x => x.TenantId).IsRequired();
builder.Property(x => x.Portal).HasConversion<int>().IsRequired();
builder.Property(x => x.TenantId);
builder.Property(x => x.RoleId).IsRequired();
builder.Property(x => x.PermissionId).IsRequired();
ConfigureAuditableEntity(builder);
ConfigureSoftDeleteEntity(builder);
builder.HasIndex(x => x.TenantId);
builder.HasIndex(x => new { x.TenantId, x.RoleId, x.PermissionId }).IsUnique();
builder.HasIndex(x => new { x.RoleId, x.PermissionId })
.IsUnique()
.HasFilter("\"Portal\" = 0");
builder.HasIndex(x => new { x.TenantId, x.RoleId, x.PermissionId })
.IsUnique()
.HasFilter("\"Portal\" = 1");
builder.HasIndex(x => x.TenantId)
.HasFilter("\"Portal\" = 1");
}
private static void ConfigureMenuDefinition(EntityTypeBuilder<MenuDefinition> builder)
{
builder.ToTable("menu_definitions");
builder.HasKey(x => x.Id);
builder.Property(x => x.TenantId).IsRequired();
builder.Property(x => x.Portal).HasConversion<int>().IsRequired();
builder.Property(x => x.ParentId).IsRequired();
builder.Property(x => x.Name).HasMaxLength(64).IsRequired();
builder.Property(x => x.Path).HasMaxLength(256).IsRequired();
@@ -243,6 +298,6 @@ public sealed class IdentityDbContext(
builder.Property(x => x.AuthListJson).HasColumnType("text");
ConfigureAuditableEntity(builder);
ConfigureSoftDeleteEntity(builder);
builder.HasIndex(x => new { x.TenantId, x.ParentId, x.SortOrder });
builder.HasIndex(x => new { x.Portal, x.ParentId, x.SortOrder });
}
}

View File

@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Identity.Entities;
using TakeoutSaaS.Domain.Identity.Enums;
using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Infrastructure.Identity.Persistence;
@@ -10,15 +11,13 @@ namespace TakeoutSaaS.Infrastructure.Identity.Repositories;
/// </summary>
public sealed class EfMenuRepository(IdentityDbContext dbContext) : IMenuRepository
{
private const long PlatformRootTenantId = 1000000000001;
/// <inheritdoc />
public Task<IReadOnlyList<MenuDefinition>> GetByTenantAsync(long tenantId, CancellationToken cancellationToken = default)
public Task<IReadOnlyList<MenuDefinition>> GetByPortalAsync(PortalType portal, CancellationToken cancellationToken = default)
{
return dbContext.MenuDefinitions
.AsNoTracking()
.IgnoreQueryFilters()
.Where(x => x.TenantId == PlatformRootTenantId && x.DeletedAt == null)
.Where(x => x.Portal == portal && x.DeletedAt == null)
.OrderBy(x => x.ParentId)
.ThenBy(x => x.SortOrder)
.ToListAsync(cancellationToken)
@@ -26,13 +25,13 @@ public sealed class EfMenuRepository(IdentityDbContext dbContext) : IMenuReposit
}
/// <inheritdoc />
public Task<MenuDefinition?> FindByIdAsync(long id, long tenantId, CancellationToken cancellationToken = default)
public Task<MenuDefinition?> FindByIdAsync(long id, PortalType portal, CancellationToken cancellationToken = default)
{
return dbContext.MenuDefinitions
.AsNoTracking()
.IgnoreQueryFilters()
.FirstOrDefaultAsync(
x => x.Id == id && x.TenantId == PlatformRootTenantId && x.DeletedAt == null,
x => x.Id == id && x.Portal == portal && x.DeletedAt == null,
cancellationToken);
}
@@ -50,11 +49,11 @@ public sealed class EfMenuRepository(IdentityDbContext dbContext) : IMenuReposit
}
/// <inheritdoc />
public async Task DeleteAsync(long id, long tenantId, CancellationToken cancellationToken = default)
public async Task DeleteAsync(long id, PortalType portal, CancellationToken cancellationToken = default)
{
// 1. 查询目标
var entity = await dbContext.MenuDefinitions
.FirstOrDefaultAsync(x => x.Id == id && x.TenantId == tenantId, cancellationToken);
.FirstOrDefaultAsync(x => x.Id == id && x.Portal == portal, cancellationToken);
// 2. 存在则删除
if (entity is not null)

View File

@@ -71,25 +71,33 @@ public sealed class JwtTokenService(IRefreshTokenStore refreshTokenStore, IOptio
/// <returns>Claims 集合</returns>
private static List<Claim> BuildClaims(CurrentUserProfile profile)
{
// 1. 构建基础身份 Claim
var userId = profile.UserId.ToString();
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, userId),
new(ClaimTypes.NameIdentifier, userId),
new(JwtRegisteredClaimNames.UniqueName, profile.Account),
new("tenant_id", profile.TenantId.ToString()),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};
// 2. (空行后) 仅租户端账号写入 tenant_id Claim
if (profile.TenantId.HasValue)
{
claims.Add(new Claim("tenant_id", profile.TenantId.Value.ToString()));
}
// 3. (空行后) 可选写入商户上下文 Claim
if (profile.MerchantId.HasValue)
{
claims.Add(new Claim("merchant_id", profile.MerchantId.Value.ToString()));
}
// 4. (空行后) 写入角色与权限 Claim
claims.AddRange(profile.Roles.Select(role => new Claim(ClaimTypes.Role, role)));
claims.AddRange(profile.Permissions.Select(permission => new Claim("permission", permission)));
// 5. (空行后) 返回 Claim 集合
return claims;
}
}

View File

@@ -7,7 +7,6 @@ using TakeoutSaaS.Infrastructure.Common.Persistence;
using TakeoutSaaS.Shared.Abstractions.Ids;
using TakeoutSaaS.Shared.Abstractions.Security;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
using Microsoft.AspNetCore.Http;
namespace TakeoutSaaS.Infrastructure.Logs.Persistence;
@@ -18,9 +17,8 @@ public sealed class TakeoutLogsDbContext(
DbContextOptions<TakeoutLogsDbContext> options,
ITenantProvider tenantProvider,
ICurrentUserAccessor? currentUserAccessor = null,
IIdGenerator? idGenerator = null,
IHttpContextAccessor? httpContextAccessor = null)
: TenantAwareDbContext(options, tenantProvider, currentUserAccessor, idGenerator, httpContextAccessor)
IIdGenerator? idGenerator = null)
: TenantAwareDbContext(options, tenantProvider, currentUserAccessor, idGenerator)
{
/// <summary>
/// 租户审计日志集合。