fix: 统一逐租户上下文执行

This commit is contained in:
root
2026-01-30 01:04:51 +00:00
parent 41cfd2e2e8
commit cf697b3889
29 changed files with 1037 additions and 490 deletions

View File

@@ -3,6 +3,7 @@ using TakeoutSaaS.Application.App.Billings.Commands;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Billings.Handlers;
@@ -10,7 +11,8 @@ namespace TakeoutSaaS.Application.App.Billings.Handlers;
/// 批量更新账单状态处理器。
/// </summary>
public sealed class BatchUpdateStatusCommandHandler(
ITenantBillingRepository billingRepository)
ITenantBillingRepository billingRepository,
ITenantContextAccessor tenantContextAccessor)
: IRequestHandler<BatchUpdateStatusCommand, int>
{
/// <summary>
@@ -34,33 +36,53 @@ public sealed class BatchUpdateStatusCommandHandler(
throw new BusinessException(ErrorCodes.NotFound, "未找到任何匹配的账单");
}
// 3. 批量更新状态
// 3. 批量更新状态(逐租户上下文执行,避免跨租户写入)
var now = DateTime.UtcNow;
var updatedCount = 0;
foreach (var billing in billings)
var currentTenantId = tenantContextAccessor.Current?.TenantId ?? 0;
if (currentTenantId != 0 && billings.Any(x => x.TenantId != currentTenantId))
{
// 业务规则检查:某些状态转换可能不允许
if (CanTransitionStatus(billing.Status, request.NewStatus))
{
billing.Status = request.NewStatus;
billing.UpdatedAt = now;
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户批量更新账单状态");
}
if (!string.IsNullOrWhiteSpace(request.Notes))
var updatedTotal = 0;
var grouped = billings.GroupBy(x => x.TenantId).ToList();
foreach (var group in grouped)
{
using (currentTenantId == 0 ? tenantContextAccessor.EnterTenantScope(group.Key, "billing:batch-update") : null)
{
var updatedCount = 0;
foreach (var billing in group)
{
billing.Notes = string.IsNullOrWhiteSpace(billing.Notes)
? $"[批量操作] {request.Notes}"
: $"{billing.Notes}\n[批量操作] {request.Notes}";
// 业务规则检查:某些状态转换可能不允许
if (!CanTransitionStatus(billing.Status, request.NewStatus))
{
continue;
}
billing.Status = request.NewStatus;
billing.UpdatedAt = now;
if (!string.IsNullOrWhiteSpace(request.Notes))
{
billing.Notes = string.IsNullOrWhiteSpace(billing.Notes)
? $"[批量操作] {request.Notes}"
: $"{billing.Notes}\n[批量操作] {request.Notes}";
}
await billingRepository.UpdateAsync(billing, cancellationToken);
updatedCount++;
}
await billingRepository.UpdateAsync(billing, cancellationToken);
updatedCount++;
if (updatedCount > 0)
{
await billingRepository.SaveChangesAsync(cancellationToken);
}
updatedTotal += updatedCount;
}
}
// 4. 持久化变更
await billingRepository.SaveChangesAsync(cancellationToken);
return updatedCount;
return updatedTotal;
}
/// <summary>

View File

@@ -9,6 +9,7 @@ using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Infrastructure.App.Options;
using TakeoutSaaS.Infrastructure.Dictionary.Persistence;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Infrastructure.App.Persistence;
@@ -38,10 +39,11 @@ public sealed class AppDataSeeder(
using var scope = serviceProvider.CreateScope();
var appDbContext = scope.ServiceProvider.GetRequiredService<TakeoutAppDbContext>();
var dictionaryDbContext = scope.ServiceProvider.GetRequiredService<DictionaryDbContext>();
var tenantContextAccessor = scope.ServiceProvider.GetRequiredService<ITenantContextAccessor>();
await EnsureSystemTenantAsync(appDbContext, cancellationToken);
var defaultTenantId = await EnsureDefaultTenantAsync(appDbContext, cancellationToken);
await EnsureDictionarySeedsAsync(dictionaryDbContext, defaultTenantId, cancellationToken);
await EnsureDictionarySeedsAsync(dictionaryDbContext, tenantContextAccessor, defaultTenantId, cancellationToken);
logger.LogInformation("AppSeed 完成业务数据初始化");
}
@@ -54,6 +56,7 @@ public sealed class AppDataSeeder(
/// </summary>
private async Task<long?> EnsureDefaultTenantAsync(TakeoutAppDbContext dbContext, CancellationToken cancellationToken)
{
using var _ = dbContext.DisableSoftDeleteFilter();
var tenantOptions = _options.DefaultTenant;
if (tenantOptions == null || string.IsNullOrWhiteSpace(tenantOptions.Code) || string.IsNullOrWhiteSpace(tenantOptions.Name))
{
@@ -63,7 +66,6 @@ public sealed class AppDataSeeder(
var code = tenantOptions.Code.Trim();
var existingTenant = await dbContext.Tenants
.IgnoreQueryFilters()
.FirstOrDefaultAsync(x => x.Code == code, cancellationToken);
if (existingTenant == null)
@@ -87,6 +89,13 @@ public sealed class AppDataSeeder(
var updated = false;
if (existingTenant.DeletedAt.HasValue)
{
existingTenant.DeletedAt = null;
existingTenant.DeletedBy = null;
updated = true;
}
if (!string.Equals(existingTenant.Name, tenantOptions.Name, StringComparison.Ordinal))
{
existingTenant.Name = tenantOptions.Name.Trim();
@@ -136,14 +145,21 @@ public sealed class AppDataSeeder(
/// </summary>
private async Task EnsureSystemTenantAsync(TakeoutAppDbContext dbContext, CancellationToken cancellationToken)
{
using var _ = dbContext.DisableSoftDeleteFilter();
var existingTenant = await dbContext.Tenants
.IgnoreQueryFilters()
.FirstOrDefaultAsync(x => x.Id == 0, cancellationToken);
if (existingTenant != null)
{
// 1. (空行后) 若历史数据仍为 PLATFORM则自动修正为 SYSTEM
var updated = false;
if (existingTenant.DeletedAt.HasValue)
{
existingTenant.DeletedAt = null;
existingTenant.DeletedBy = null;
updated = true;
}
if (!string.Equals(existingTenant.Code, "SYSTEM", StringComparison.Ordinal))
{
existingTenant.Code = "SYSTEM";
@@ -188,7 +204,11 @@ public sealed class AppDataSeeder(
/// <summary>
/// 确保基础字典存在。
/// </summary>
private async Task EnsureDictionarySeedsAsync(DictionaryDbContext dbContext, long? defaultTenantId, CancellationToken cancellationToken)
private async Task EnsureDictionarySeedsAsync(
DictionaryDbContext dbContext,
ITenantContextAccessor tenantContextAccessor,
long? defaultTenantId,
CancellationToken cancellationToken)
{
var dictionaryGroups = _options.DictionaryGroups ?? new List<DictionarySeedGroupOptions>();
var hasDictionaryGroups = dictionaryGroups.Count > 0;
@@ -211,9 +231,10 @@ public sealed class AppDataSeeder(
var tenantId = groupOptions.TenantId ?? defaultTenantId ?? 0;
var code = groupOptions.Code.Trim();
using var tenantScope = tenantContextAccessor.EnterTenantScope(tenantId, "app-seed");
using var disableSoftDeleteScope = dbContext.DisableSoftDeleteFilter();
var group = await dbContext.DictionaryGroups
.IgnoreQueryFilters()
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Code == code, cancellationToken);
.FirstOrDefaultAsync(x => x.Code == code, cancellationToken);
if (group == null)
{
@@ -235,6 +256,13 @@ public sealed class AppDataSeeder(
{
var groupUpdated = false;
if (group.DeletedAt.HasValue)
{
group.DeletedAt = null;
group.DeletedBy = null;
groupUpdated = true;
}
if (!string.Equals(group.Name, groupOptions.Name, StringComparison.Ordinal))
{
group.Name = groupOptions.Name.Trim();
@@ -269,7 +297,7 @@ public sealed class AppDataSeeder(
}
}
await EnsureSystemParametersAsync(dbContext, defaultTenantId, cancellationToken);
await EnsureSystemParametersAsync(dbContext, tenantContextAccessor, defaultTenantId, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
}
@@ -277,7 +305,11 @@ public sealed class AppDataSeeder(
/// <summary>
/// 确保系统参数以独立表形式可重复种子。
/// </summary>
private async Task EnsureSystemParametersAsync(DictionaryDbContext dbContext, long? defaultTenantId, CancellationToken cancellationToken)
private async Task EnsureSystemParametersAsync(
DictionaryDbContext dbContext,
ITenantContextAccessor tenantContextAccessor,
long? defaultTenantId,
CancellationToken cancellationToken)
{
var systemParameters = _options.SystemParameters ?? new List<SystemParameterSeedOptions>();
@@ -300,9 +332,9 @@ public sealed class AppDataSeeder(
foreach (var group in grouped)
{
var tenantId = group.Key;
using var tenantScope = tenantContextAccessor.EnterTenantScope(tenantId, "app-seed");
using var disableSoftDeleteScope = dbContext.DisableSoftDeleteFilter();
var existingParameters = await dbContext.SystemParameters
.IgnoreQueryFilters()
.Where(x => x.TenantId == tenantId)
.ToListAsync(cancellationToken);
foreach (var seed in group)
@@ -329,6 +361,13 @@ public sealed class AppDataSeeder(
var updated = false;
if (existing.DeletedAt.HasValue)
{
existing.DeletedAt = null;
existing.DeletedBy = null;
updated = true;
}
if (!string.Equals(existing.Value, seed.Value, StringComparison.Ordinal))
{
existing.Value = seed.Value.Trim();
@@ -387,7 +426,6 @@ public sealed class AppDataSeeder(
}
var existingItems = await dbContext.DictionaryItems
.IgnoreQueryFilters()
.Where(x => x.GroupId == group.Id)
.ToListAsync(cancellationToken);
@@ -416,6 +454,13 @@ public sealed class AppDataSeeder(
var updated = false;
if (existing.DeletedAt.HasValue)
{
existing.DeletedAt = null;
existing.DeletedBy = null;
updated = true;
}
if (!string.Equals(existing.Value, seed.Value, StringComparison.Ordinal))
{
existing.Value = seed.Value.Trim();

View File

@@ -3,14 +3,25 @@ using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Infrastructure.App.Persistence.Repositories;
/// <summary>
/// 租户账单仓储实现EF Core
/// </summary>
public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITenantBillingRepository
public sealed class TenantBillingRepository(TakeoutAppDbContext context, ITenantContextAccessor tenantContextAccessor) : ITenantBillingRepository
{
private long GetCurrentTenantId()
=> tenantContextAccessor.Current?.TenantId ?? 0;
private Task<List<long>> GetActiveTenantIdsAsync(CancellationToken cancellationToken)
=> context.Tenants
.AsNoTracking()
.Where(x => x.DeletedAt == null && x.Id > 0)
.Select(x => x.Id)
.ToListAsync(cancellationToken);
/// <inheritdoc />
public async Task<IReadOnlyList<TenantBillingStatement>> SearchAsync(
long tenantId,
@@ -19,9 +30,8 @@ public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITena
DateTime? to,
CancellationToken cancellationToken = default)
{
// 1. 构建基础查询:忽略全局过滤器,显式过滤租户与软删除
// 1. 构建基础查询:在当前租户上下文内查询
var query = context.TenantBillingStatements
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.DeletedAt == null && x.TenantId == tenantId);
@@ -52,7 +62,6 @@ public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITena
public Task<TenantBillingStatement?> FindByIdAsync(long tenantId, long billingId, CancellationToken cancellationToken = default)
{
return context.TenantBillingStatements
.IgnoreQueryFilters()
.AsNoTracking()
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.TenantId == tenantId && x.Id == billingId, cancellationToken);
}
@@ -63,7 +72,6 @@ public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITena
var normalized = statementNo.Trim();
return context.TenantBillingStatements
.IgnoreQueryFilters()
.AsNoTracking()
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.TenantId == tenantId && x.StatementNo == normalized, cancellationToken);
}
@@ -73,10 +81,11 @@ public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITena
{
var normalized = statementNo.Trim();
return context.TenantBillingStatements
.IgnoreQueryFilters()
.AsNoTracking()
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.StatementNo == normalized, cancellationToken);
return GetCurrentTenantId() == 0
? GetByStatementNoCrossTenantAsync(normalized, cancellationToken)
: context.TenantBillingStatements
.AsNoTracking()
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.StatementNo == normalized, cancellationToken);
}
/// <inheritdoc />
@@ -86,7 +95,6 @@ public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITena
CancellationToken cancellationToken = default)
{
return context.TenantBillingStatements
.IgnoreQueryFilters()
.AsNoTracking()
.AnyAsync(
x => x.DeletedAt == null
@@ -101,16 +109,45 @@ public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITena
{
// 1. 以当前 UTC 时间作为逾期判断基准
var now = DateTime.UtcNow;
var currentTenantId = GetCurrentTenantId();
if (currentTenantId != 0)
{
// 2. (空行后) 当前租户:仅查询本租户逾期账单
return await context.TenantBillingStatements
.AsNoTracking()
.Where(x => x.DeletedAt == null
&& x.DueDate < now
&& x.Status == TenantBillingStatus.Pending)
.OrderBy(x => x.DueDate)
.ToListAsync(cancellationToken);
}
// 2. 查询逾期且仍处于待支付的账单(仅 Pending 才允许自动切换为 Overdue
return await context.TenantBillingStatements
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.DeletedAt == null
&& x.DueDate < now
&& x.Status == TenantBillingStatus.Pending)
// 2. (空行后) 系统上下文:逐租户查询逾期账单并合并
var tenantIds = await GetActiveTenantIdsAsync(cancellationToken);
if (tenantIds.Count == 0)
{
return Array.Empty<TenantBillingStatement>();
}
var results = new List<TenantBillingStatement>();
foreach (var tenantId in tenantIds)
{
using (tenantContextAccessor.EnterTenantScope(tenantId, "billing"))
{
var items = await context.TenantBillingStatements
.AsNoTracking()
.Where(x => x.DeletedAt == null
&& x.DueDate < now
&& x.Status == TenantBillingStatus.Pending)
.ToListAsync(cancellationToken);
results.AddRange(items);
}
}
return results
.OrderBy(x => x.DueDate)
.ToListAsync(cancellationToken);
.ToList();
}
/// <inheritdoc />
@@ -119,24 +156,53 @@ public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITena
// 1. 计算到期窗口
var now = DateTime.UtcNow;
var dueTo = now.AddDays(daysAhead);
var currentTenantId = GetCurrentTenantId();
if (currentTenantId != 0)
{
// 2. (空行后) 当前租户:仅查询本租户即将到期账单
return await context.TenantBillingStatements
.AsNoTracking()
.Where(x => x.DeletedAt == null
&& x.Status == TenantBillingStatus.Pending
&& x.DueDate >= now
&& x.DueDate <= dueTo)
.OrderBy(x => x.DueDate)
.ToListAsync(cancellationToken);
}
// 2. 仅查询待支付账单
return await context.TenantBillingStatements
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.DeletedAt == null
&& x.Status == TenantBillingStatus.Pending
&& x.DueDate >= now
&& x.DueDate <= dueTo)
// 2. (空行后) 系统上下文:逐租户查询即将到期账单并合并
var tenantIds = await GetActiveTenantIdsAsync(cancellationToken);
if (tenantIds.Count == 0)
{
return Array.Empty<TenantBillingStatement>();
}
var results = new List<TenantBillingStatement>();
foreach (var tenantId in tenantIds)
{
using (tenantContextAccessor.EnterTenantScope(tenantId, "billing"))
{
var items = await context.TenantBillingStatements
.AsNoTracking()
.Where(x => x.DeletedAt == null
&& x.Status == TenantBillingStatus.Pending
&& x.DueDate >= now
&& x.DueDate <= dueTo)
.ToListAsync(cancellationToken);
results.AddRange(items);
}
}
return results
.OrderBy(x => x.DueDate)
.ToListAsync(cancellationToken);
.ToList();
}
/// <inheritdoc />
public async Task<IReadOnlyList<TenantBillingStatement>> GetByTenantIdAsync(long tenantId, CancellationToken cancellationToken = default)
{
return await context.TenantBillingStatements
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.DeletedAt == null && x.TenantId == tenantId)
.OrderByDescending(x => x.PeriodEnd)
@@ -151,13 +217,41 @@ public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITena
return Array.Empty<TenantBillingStatement>();
}
// 1. 忽略全局过滤器以支持系统任务跨租户导出/批量操作
return await context.TenantBillingStatements
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.DeletedAt == null && billingIds.Contains(x.Id))
// 1. 系统上下文:逐租户查找匹配账单;租户上下文:仅返回本租户账单
var ids = billingIds.Distinct().ToArray();
var currentTenantId = GetCurrentTenantId();
if (currentTenantId != 0)
{
return await context.TenantBillingStatements
.AsNoTracking()
.Where(x => x.DeletedAt == null && ids.Contains(x.Id))
.OrderByDescending(x => x.PeriodStart)
.ToListAsync(cancellationToken);
}
var tenantIds = await GetActiveTenantIdsAsync(cancellationToken);
if (tenantIds.Count == 0)
{
return Array.Empty<TenantBillingStatement>();
}
var results = new List<TenantBillingStatement>();
foreach (var tenantId in tenantIds)
{
using (tenantContextAccessor.EnterTenantScope(tenantId, "billing"))
{
var items = await context.TenantBillingStatements
.AsNoTracking()
.Where(x => x.DeletedAt == null && ids.Contains(x.Id))
.ToListAsync(cancellationToken);
results.AddRange(items);
}
}
return results
.OrderByDescending(x => x.PeriodStart)
.ToListAsync(cancellationToken);
.ToList();
}
/// <inheritdoc />
@@ -192,74 +286,100 @@ public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITena
int pageSize,
CancellationToken cancellationToken = default)
{
// 1. 构建基础查询(系统任务跨租户查询,忽略过滤器)
var query = context.TenantBillingStatements
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.DeletedAt == null);
var normalizedPageNumber = pageNumber <= 0 ? 1 : pageNumber;
var normalizedPageSize = pageSize <= 0 ? 20 : pageSize;
var skip = (normalizedPageNumber - 1) * normalizedPageSize;
var takePerTenant = normalizedPageNumber * normalizedPageSize;
// 2. 按租户过滤(可选)
var currentTenantId = GetCurrentTenantId();
if (currentTenantId != 0)
{
// 1. 当前租户仅查询本租户数据tenantId 为空则默认当前租户)
var effectiveTenantId = tenantId ?? currentTenantId;
var query = BuildTenantQuery(effectiveTenantId, status, from, to, minAmount, maxAmount, keyword);
var total = await query.CountAsync(cancellationToken);
var items = await query
.OrderByDescending(x => x.PeriodEnd)
.Skip(skip)
.Take(normalizedPageSize)
.ToListAsync(cancellationToken);
return (items, total);
}
// 2. (空行后) 系统上下文:可按指定 tenantId 查询tenantId 为空则跨租户聚合分页
if (tenantId.HasValue)
{
query = query.Where(x => x.TenantId == tenantId.Value);
using (tenantContextAccessor.EnterTenantScope(tenantId.Value, "billing"))
{
var query = BuildTenantQuery(tenantId.Value, status, from, to, minAmount, maxAmount, keyword);
var total = await query.CountAsync(cancellationToken);
var items = await query
.OrderByDescending(x => x.PeriodEnd)
.Skip(skip)
.Take(normalizedPageSize)
.ToListAsync(cancellationToken);
return (items, total);
}
}
// 3. 按状态过滤(可选)
if (status.HasValue)
// 3. (空行后) 跨租户分页:逐租户取 Top(N) 后合并排序再分页
var normalizedKeyword = string.IsNullOrWhiteSpace(keyword) ? null : keyword.Trim();
var tenantIds = await GetActiveTenantIdsAsync(cancellationToken);
if (tenantIds.Count == 0)
{
query = query.Where(x => x.Status == status.Value);
return ([], 0);
}
// 4. 按日期范围过滤(账单周期)
if (from.HasValue)
HashSet<long>? keywordMatchedTenantIds = null;
if (!string.IsNullOrWhiteSpace(normalizedKeyword))
{
query = query.Where(x => x.PeriodStart >= from.Value);
var matched = await context.Tenants
.AsNoTracking()
.Where(x => x.DeletedAt == null && EF.Functions.ILike(x.Name, $"%{normalizedKeyword}%"))
.Select(x => x.Id)
.ToListAsync(cancellationToken);
keywordMatchedTenantIds = matched.Count == 0 ? null : matched.ToHashSet();
}
if (to.HasValue)
var totalCount = 0;
var collected = new List<TenantBillingStatement>();
foreach (var tid in tenantIds)
{
query = query.Where(x => x.PeriodEnd <= to.Value);
using (tenantContextAccessor.EnterTenantScope(tid, "billing"))
{
var tenantQuery = BuildTenantQuery(tid, status, from, to, minAmount, maxAmount, keyword: null);
if (!string.IsNullOrWhiteSpace(normalizedKeyword))
{
// 3.1 租户名命中时视为匹配全部账单,否则按账单号筛选
var tenantNameMatched = keywordMatchedTenantIds is not null && keywordMatchedTenantIds.Contains(tid);
if (!tenantNameMatched)
{
tenantQuery = tenantQuery.Where(x => EF.Functions.ILike(x.StatementNo, $"%{normalizedKeyword}%"));
}
}
totalCount += await tenantQuery.CountAsync(cancellationToken);
var topItems = await tenantQuery
.OrderByDescending(x => x.PeriodEnd)
.Take(takePerTenant)
.ToListAsync(cancellationToken);
collected.AddRange(topItems);
}
}
// 5. 按金额范围过滤(应付金额,包含边界)
if (minAmount.HasValue)
{
query = query.Where(x => x.AmountDue >= minAmount.Value);
}
if (maxAmount.HasValue)
{
query = query.Where(x => x.AmountDue <= maxAmount.Value);
}
// 6. 关键字过滤(账单号或租户名)
if (!string.IsNullOrWhiteSpace(keyword))
{
var normalized = keyword.Trim();
query =
from b in query
join t in context.Tenants
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.DeletedAt == null)
on b.TenantId equals t.Id
where EF.Functions.ILike(b.StatementNo, $"%{normalized}%")
|| EF.Functions.ILike(t.Name, $"%{normalized}%")
select b;
}
// 7. 统计总数
var total = await query.CountAsync(cancellationToken);
// 8. 分页查询
var items = await query
var pageItems = collected
.OrderByDescending(x => x.PeriodEnd)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync(cancellationToken);
.Skip(skip)
.Take(normalizedPageSize)
.ToList();
return (items, total);
return (pageItems, totalCount);
}
/// <inheritdoc />
@@ -270,45 +390,90 @@ public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITena
string groupBy,
CancellationToken cancellationToken = default)
{
// 1. 构建基础查询(忽略过滤器,显式过滤软删除/租户/时间范围)
var query = context.TenantBillingStatements
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.DeletedAt == null
&& (!tenantId.HasValue || x.TenantId == tenantId.Value)
&& x.PeriodStart >= startDate
&& x.PeriodEnd <= endDate);
// 2. 聚合统计(金额统一使用:应付 - 折扣 + 税费)
// 1. 统一时间基准与分组方式
var now = DateTime.UtcNow;
var totalAmount = await query.SumAsync(x => x.AmountDue - x.DiscountAmount + x.TaxAmount, cancellationToken);
var paidAmount = await query.Where(x => x.Status == TenantBillingStatus.Paid).SumAsync(x => x.AmountPaid, cancellationToken);
var unpaidAmount = await query.SumAsync(x => (x.AmountDue - x.DiscountAmount + x.TaxAmount) - x.AmountPaid, cancellationToken);
var overdueAmount = await query
.Where(x => (x.Status == TenantBillingStatus.Pending || x.Status == TenantBillingStatus.Overdue) && x.DueDate < now)
.SumAsync(x => (x.AmountDue - x.DiscountAmount + x.TaxAmount) - x.AmountPaid, cancellationToken);
// 3. 数量统计
var totalCount = await query.CountAsync(cancellationToken);
var paidCount = await query.CountAsync(x => x.Status == TenantBillingStatus.Paid, cancellationToken);
var unpaidCount = await query.CountAsync(x => x.Status == TenantBillingStatus.Pending || x.Status == TenantBillingStatus.Overdue, cancellationToken);
var overdueCount = await query.CountAsync(x => (x.Status == TenantBillingStatus.Pending || x.Status == TenantBillingStatus.Overdue) && x.DueDate < now, cancellationToken);
// 4. 趋势统计
var normalizedGroupBy = NormalizeGroupBy(groupBy);
var trendRaw = await query
.Select(x => new
{
x.PeriodStart,
x.AmountDue,
x.DiscountAmount,
x.TaxAmount,
x.AmountPaid
})
.ToListAsync(cancellationToken);
var currentTenantId = GetCurrentTenantId();
// 4.1 在内存中按 Day/Week/Month 聚合(避免依赖特定数据库函数扩展)
var trend = trendRaw
// 2. (空行后) 构造待统计租户列表
List<long> targetTenantIds;
if (currentTenantId != 0)
{
targetTenantIds = [tenantId ?? currentTenantId];
}
else if (tenantId.HasValue)
{
targetTenantIds = [tenantId.Value];
}
else
{
targetTenantIds = await GetActiveTenantIdsAsync(cancellationToken);
}
if (targetTenantIds.Count == 0)
{
return new TenantBillingStatistics();
}
// 3. (空行后) 拉取统计字段(逐租户上下文执行)
var rows = new List<BillingStatisticsRow>();
foreach (var tid in targetTenantIds)
{
if (currentTenantId == 0)
{
using (tenantContextAccessor.EnterTenantScope(tid, "billing"))
{
var tenantRows = await BuildStatisticsQuery(startDate, endDate)
.Select(x => new BillingStatisticsRow
{
PeriodStart = x.PeriodStart,
AmountDue = x.AmountDue,
DiscountAmount = x.DiscountAmount,
TaxAmount = x.TaxAmount,
AmountPaid = x.AmountPaid,
Status = x.Status,
DueDate = x.DueDate
})
.ToListAsync(cancellationToken);
rows.AddRange(tenantRows);
}
continue;
}
var tenantRowsDirect = await BuildStatisticsQuery(startDate, endDate)
.Where(x => x.TenantId == tid)
.Select(x => new BillingStatisticsRow
{
PeriodStart = x.PeriodStart,
AmountDue = x.AmountDue,
DiscountAmount = x.DiscountAmount,
TaxAmount = x.TaxAmount,
AmountPaid = x.AmountPaid,
Status = x.Status,
DueDate = x.DueDate
})
.ToListAsync(cancellationToken);
rows.AddRange(tenantRowsDirect);
}
// 4. (空行后) 汇总统计
var totalAmount = rows.Sum(x => x.AmountDue - x.DiscountAmount + x.TaxAmount);
var paidAmount = rows.Where(x => x.Status == TenantBillingStatus.Paid).Sum(x => x.AmountPaid);
var unpaidAmount = rows.Sum(x => (x.AmountDue - x.DiscountAmount + x.TaxAmount) - x.AmountPaid);
var overdueAmount = rows
.Where(x => (x.Status == TenantBillingStatus.Pending || x.Status == TenantBillingStatus.Overdue) && x.DueDate < now)
.Sum(x => (x.AmountDue - x.DiscountAmount + x.TaxAmount) - x.AmountPaid);
var totalCount = rows.Count;
var paidCount = rows.Count(x => x.Status == TenantBillingStatus.Paid);
var unpaidCount = rows.Count(x => x.Status == TenantBillingStatus.Pending || x.Status == TenantBillingStatus.Overdue);
var overdueCount = rows.Count(x => (x.Status == TenantBillingStatus.Pending || x.Status == TenantBillingStatus.Overdue) && x.DueDate < now);
// 5. (空行后) 趋势统计
var trend = rows
.GroupBy(x => GetTrendBucket(x.PeriodStart, normalizedGroupBy))
.Select(g => new TenantBillingTrendDataPoint
{
@@ -337,10 +502,122 @@ public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITena
/// <inheritdoc />
public Task<TenantBillingStatement?> FindByIdAsync(long billingId, CancellationToken cancellationToken = default)
{
return context.TenantBillingStatements
.IgnoreQueryFilters()
return GetCurrentTenantId() == 0
? FindByIdCrossTenantAsync(billingId, cancellationToken)
: context.TenantBillingStatements
.AsNoTracking()
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.Id == billingId, cancellationToken);
}
private IQueryable<TenantBillingStatement> BuildTenantQuery(
long tenantId,
TenantBillingStatus? status,
DateTime? from,
DateTime? to,
decimal? minAmount,
decimal? maxAmount,
string? keyword)
{
var query = context.TenantBillingStatements
.AsNoTracking()
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.Id == billingId, cancellationToken);
.Where(x => x.DeletedAt == null && x.TenantId == tenantId);
if (status.HasValue)
{
query = query.Where(x => x.Status == status.Value);
}
if (from.HasValue)
{
query = query.Where(x => x.PeriodStart >= from.Value);
}
if (to.HasValue)
{
query = query.Where(x => x.PeriodEnd <= to.Value);
}
if (minAmount.HasValue)
{
query = query.Where(x => x.AmountDue >= minAmount.Value);
}
if (maxAmount.HasValue)
{
query = query.Where(x => x.AmountDue <= maxAmount.Value);
}
if (!string.IsNullOrWhiteSpace(keyword))
{
var normalized = keyword.Trim();
query = query.Where(x => EF.Functions.ILike(x.StatementNo, $"%{normalized}%"));
}
return query;
}
private IQueryable<TenantBillingStatement> BuildStatisticsQuery(DateTime startDate, DateTime endDate)
=> context.TenantBillingStatements
.AsNoTracking()
.Where(x => x.DeletedAt == null
&& x.PeriodStart >= startDate
&& x.PeriodEnd <= endDate);
private async Task<TenantBillingStatement?> FindByIdCrossTenantAsync(long billingId, CancellationToken cancellationToken)
{
var tenantIds = await GetActiveTenantIdsAsync(cancellationToken);
foreach (var tenantId in tenantIds)
{
using (tenantContextAccessor.EnterTenantScope(tenantId, "billing"))
{
var billing = await context.TenantBillingStatements
.AsNoTracking()
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.Id == billingId, cancellationToken);
if (billing != null)
{
return billing;
}
}
}
return null;
}
private async Task<TenantBillingStatement?> GetByStatementNoCrossTenantAsync(string statementNo, CancellationToken cancellationToken)
{
var tenantIds = await GetActiveTenantIdsAsync(cancellationToken);
foreach (var tenantId in tenantIds)
{
using (tenantContextAccessor.EnterTenantScope(tenantId, "billing"))
{
var billing = await context.TenantBillingStatements
.AsNoTracking()
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.StatementNo == statementNo, cancellationToken);
if (billing != null)
{
return billing;
}
}
}
return null;
}
private sealed record BillingStatisticsRow
{
public required DateTime PeriodStart { get; init; }
public required decimal AmountDue { get; init; }
public required decimal DiscountAmount { get; init; }
public required decimal TaxAmount { get; init; }
public required decimal AmountPaid { get; init; }
public required TenantBillingStatus Status { get; init; }
public required DateTime DueDate { get; init; }
}
private static string NormalizeGroupBy(string groupBy)

View File

@@ -15,7 +15,6 @@ public sealed class TenantPaymentRepository(TakeoutAppDbContext context) : ITena
public async Task<IReadOnlyList<TenantPayment>> GetByBillingIdAsync(long billingStatementId, CancellationToken cancellationToken = default)
{
return await context.TenantPayments
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.DeletedAt == null && x.BillingStatementId == billingStatementId)
.OrderByDescending(x => x.CreatedAt)
@@ -27,7 +26,6 @@ public sealed class TenantPaymentRepository(TakeoutAppDbContext context) : ITena
{
// 1. 仅统计支付成功的记录
return await context.TenantPayments
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.DeletedAt == null
&& x.BillingStatementId == billingStatementId
@@ -39,7 +37,6 @@ public sealed class TenantPaymentRepository(TakeoutAppDbContext context) : ITena
public Task<TenantPayment?> FindByIdAsync(long paymentId, CancellationToken cancellationToken = default)
{
return context.TenantPayments
.IgnoreQueryFilters()
.AsNoTracking()
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.Id == paymentId, cancellationToken);
}
@@ -50,7 +47,6 @@ public sealed class TenantPaymentRepository(TakeoutAppDbContext context) : ITena
var normalized = transactionNo.Trim();
return context.TenantPayments
.IgnoreQueryFilters()
.AsNoTracking()
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.TransactionNo == normalized, cancellationToken);
}

View File

@@ -209,7 +209,6 @@ public sealed class EfMerchantRepository(TakeoutAppDbContext context, TakeoutLog
public async Task<IReadOnlyList<MerchantAuditLog>> GetAuditLogsAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default)
{
return await logsContext.MerchantAuditLogs
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.MerchantId == merchantId)
.OrderByDescending(x => x.CreatedAt)
@@ -266,7 +265,6 @@ public sealed class EfMerchantRepository(TakeoutAppDbContext context, TakeoutLog
CancellationToken cancellationToken = default)
{
var query = logsContext.MerchantChangeLogs
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.MerchantId == merchantId);

View File

@@ -93,7 +93,6 @@ public sealed class EfQuotaPackageRepository(TakeoutAppDbContext context) : IQuo
CancellationToken cancellationToken = default)
{
var query = context.TenantQuotaPackagePurchases
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.DeletedAt == null);
@@ -129,7 +128,6 @@ public sealed class EfQuotaPackageRepository(TakeoutAppDbContext context) : IQuo
CancellationToken cancellationToken = default)
{
var query = context.TenantQuotaUsages
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.TenantId == tenantId);
@@ -148,7 +146,6 @@ public sealed class EfQuotaPackageRepository(TakeoutAppDbContext context) : IQuo
CancellationToken cancellationToken = default)
{
return context.TenantQuotaUsages
.IgnoreQueryFilters()
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.QuotaType == quotaType, cancellationToken);
}

View File

@@ -206,7 +206,6 @@ public sealed class EfTenantRepository(TakeoutAppDbContext context, TakeoutLogsD
public Task<TenantVerificationProfile?> GetVerificationProfileAsync(long tenantId, CancellationToken cancellationToken = default)
{
return context.TenantVerificationProfiles
.IgnoreQueryFilters()
.AsNoTracking()
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.TenantId == tenantId, cancellationToken);
}
@@ -224,7 +223,6 @@ public sealed class EfTenantRepository(TakeoutAppDbContext context, TakeoutLogsD
// 2. 批量查询实名资料
return await context.TenantVerificationProfiles
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.DeletedAt == null && tenantIds.Contains(x.TenantId))
.ToListAsync(cancellationToken);
@@ -235,7 +233,6 @@ public sealed class EfTenantRepository(TakeoutAppDbContext context, TakeoutLogsD
{
// 1. 查询现有实名资料
var existing = await context.TenantVerificationProfiles
.IgnoreQueryFilters()
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.TenantId == profile.TenantId, cancellationToken);
if (existing == null)
@@ -254,7 +251,6 @@ public sealed class EfTenantRepository(TakeoutAppDbContext context, TakeoutLogsD
public Task<TenantSubscription?> GetActiveSubscriptionAsync(long tenantId, CancellationToken cancellationToken = default)
{
return context.TenantSubscriptions
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.DeletedAt == null && x.TenantId == tenantId)
.OrderByDescending(x => x.EffectiveTo)
@@ -274,7 +270,6 @@ public sealed class EfTenantRepository(TakeoutAppDbContext context, TakeoutLogsD
// 2. 批量查询订阅数据
return await context.TenantSubscriptions
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.DeletedAt == null && tenantIds.Contains(x.TenantId))
.OrderByDescending(x => x.EffectiveTo)
@@ -285,7 +280,6 @@ public sealed class EfTenantRepository(TakeoutAppDbContext context, TakeoutLogsD
public Task<TenantSubscription?> FindSubscriptionByIdAsync(long tenantId, long subscriptionId, CancellationToken cancellationToken = default)
{
return context.TenantSubscriptions
.IgnoreQueryFilters()
.FirstOrDefaultAsync(
x => x.DeletedAt == null && x.TenantId == tenantId && x.Id == subscriptionId,
cancellationToken);
@@ -314,7 +308,6 @@ public sealed class EfTenantRepository(TakeoutAppDbContext context, TakeoutLogsD
public async Task<IReadOnlyList<TenantSubscriptionHistory>> GetSubscriptionHistoryAsync(long tenantId, CancellationToken cancellationToken = default)
{
return await context.TenantSubscriptionHistories
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.DeletedAt == null && x.TenantId == tenantId)
.OrderByDescending(x => x.EffectiveFrom)
@@ -331,7 +324,6 @@ public sealed class EfTenantRepository(TakeoutAppDbContext context, TakeoutLogsD
public async Task<IReadOnlyList<TenantAuditLog>> GetAuditLogsAsync(long tenantId, CancellationToken cancellationToken = default)
{
return await logsContext.TenantAuditLogs
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.DeletedAt == null && x.TenantId == tenantId)
.OrderByDescending(x => x.CreatedAt)

View File

@@ -5,6 +5,7 @@ using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Domain.Tenants.Services;
using TakeoutSaaS.Shared.Abstractions.Ids;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Infrastructure.App.Services;
@@ -13,6 +14,8 @@ namespace TakeoutSaaS.Infrastructure.App.Services;
/// </summary>
public sealed class BillingDomainService(
ITenantBillingRepository billingRepository,
ITenantRepository tenantRepository,
ITenantContextAccessor tenantContextAccessor,
ITenantPackageRepository tenantPackageRepository,
IIdGenerator idGenerator) : IBillingDomainService
{
@@ -147,15 +150,43 @@ public sealed class BillingDomainService(
/// <inheritdoc />
public async Task<int> ProcessOverdueBillingsAsync(CancellationToken cancellationToken = default)
{
// 1. 查询当前已超过到期日且仍处于待支付的账单(由仓储按 DueDate + Status 筛选)
var processedAt = DateTime.UtcNow;
var currentTenantId = tenantContextAccessor.Current?.TenantId ?? 0;
if (currentTenantId != 0)
{
return await ProcessOverdueBillingsSingleTenantAsync(processedAt, cancellationToken);
}
// 1. (空行后) 系统上下文:逐租户处理,避免跨租户写入
var tenants = await tenantRepository.SearchAsync(null, null, cancellationToken);
var targets = tenants.Where(x => x.Id > 0).ToList();
if (targets.Count == 0)
{
return 0;
}
var totalUpdated = 0;
foreach (var tenant in targets)
{
using (tenantContextAccessor.EnterTenantScope(tenant.Id, "billing:overdue", tenant.Code))
{
totalUpdated += await ProcessOverdueBillingsSingleTenantAsync(processedAt, cancellationToken);
}
}
return totalUpdated;
}
private async Task<int> ProcessOverdueBillingsSingleTenantAsync(DateTime processedAt, CancellationToken cancellationToken)
{
// 1. 查询当前租户已超过到期日且仍处于待支付的账单(由仓储按 DueDate + Status 筛选)
var overdueBillings = await billingRepository.GetOverdueBillingsAsync(cancellationToken);
if (overdueBillings.Count == 0)
{
return 0;
}
// 2. 批量标记逾期(防御性:再次判断 Pending
var processedAt = DateTime.UtcNow;
// 2. (空行后) 批量标记逾期(防御性:再次判断 Pending
var updated = 0;
foreach (var billing in overdueBillings)
{
@@ -172,7 +203,7 @@ public sealed class BillingDomainService(
updated++;
}
// 3. 持久化
// 3. (空行后) 持久化
if (updated > 0)
{
await billingRepository.SaveChangesAsync(cancellationToken);

View File

@@ -4,6 +4,7 @@ using TakeoutSaaS.Application.App.Stores.Services;
using TakeoutSaaS.Domain.Stores.Entities;
using TakeoutSaaS.Domain.Stores.Enums;
using TakeoutSaaS.Infrastructure.App.Persistence;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Infrastructure.App.Services;
@@ -12,11 +13,42 @@ namespace TakeoutSaaS.Infrastructure.App.Services;
/// </summary>
public sealed class StoreSchedulerService(
TakeoutAppDbContext context,
ITenantContextAccessor tenantContextAccessor,
ILogger<StoreSchedulerService> logger)
: IStoreSchedulerService
{
/// <inheritdoc />
public async Task<int> AutoSwitchBusinessStatusAsync(DateTime now, CancellationToken cancellationToken)
{
var currentTenantId = tenantContextAccessor.Current?.TenantId ?? 0;
if (currentTenantId != 0)
{
return await AutoSwitchBusinessStatusSingleTenantAsync(now, cancellationToken);
}
var tenants = await context.Tenants
.AsNoTracking()
.Where(x => x.DeletedAt == null && x.Id > 0)
.Select(x => new { x.Id, x.Code })
.ToListAsync(cancellationToken);
if (tenants.Count == 0)
{
return 0;
}
var totalUpdated = 0;
foreach (var tenant in tenants)
{
using (tenantContextAccessor.EnterTenantScope(tenant.Id, "scheduler", tenant.Code))
{
totalUpdated += await AutoSwitchBusinessStatusSingleTenantAsync(now, cancellationToken);
}
}
return totalUpdated;
}
private async Task<int> AutoSwitchBusinessStatusSingleTenantAsync(DateTime now, CancellationToken cancellationToken)
{
// 1. 读取候选门店
var stores = await context.Stores
@@ -129,6 +161,36 @@ public sealed class StoreSchedulerService(
/// <inheritdoc />
public async Task<int> CheckQualificationExpiryAsync(DateTime now, CancellationToken cancellationToken)
{
var currentTenantId = tenantContextAccessor.Current?.TenantId ?? 0;
if (currentTenantId != 0)
{
return await CheckQualificationExpirySingleTenantAsync(now, cancellationToken);
}
var tenants = await context.Tenants
.AsNoTracking()
.Where(x => x.DeletedAt == null && x.Id > 0)
.Select(x => new { x.Id, x.Code })
.ToListAsync(cancellationToken);
if (tenants.Count == 0)
{
return 0;
}
var totalUpdated = 0;
foreach (var tenant in tenants)
{
using (tenantContextAccessor.EnterTenantScope(tenant.Id, "scheduler", tenant.Code))
{
totalUpdated += await CheckQualificationExpirySingleTenantAsync(now, cancellationToken);
}
}
return totalUpdated;
}
private async Task<int> CheckQualificationExpirySingleTenantAsync(DateTime now, CancellationToken cancellationToken)
{
// 1. 查询过期门店
var today = DateOnly.FromDateTime(now);

View File

@@ -7,6 +7,7 @@ using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Infrastructure.App.Persistence;
using TakeoutSaaS.Shared.Abstractions.Ids;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Infrastructure.BackgroundServices;
@@ -70,86 +71,111 @@ public sealed class AutoRenewalService : BackgroundService
using var scope = _serviceProvider.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<TakeoutAppDbContext>();
var idGenerator = scope.ServiceProvider.GetRequiredService<IIdGenerator>();
var tenantContextAccessor = scope.ServiceProvider.GetRequiredService<ITenantContextAccessor>();
var now = DateTime.UtcNow;
var renewalThreshold = now.AddDays(_options.RenewalDaysBeforeExpiry);
var billsCreated = 0;
try
{
// 查询开启自动续费且即将到期的活跃订阅
var autoRenewSubscriptions = await dbContext.TenantSubscriptions
.Where(s => s.Status == SubscriptionStatus.Active
&& s.AutoRenew
&& s.EffectiveTo <= renewalThreshold
&& s.EffectiveTo > now)
.Join(
dbContext.TenantPackages,
sub => sub.TenantPackageId,
package => package.Id,
(sub, package) => new { Subscription = sub, Package = package }
)
var tenants = await dbContext.Tenants
.AsNoTracking()
.Where(x => x.DeletedAt == null && x.Id > 0)
.Select(x => new { x.Id, x.Code })
.ToListAsync(cancellationToken);
foreach (var item in autoRenewSubscriptions)
if (tenants.Count == 0)
{
// 检查是否已为本次到期生成过账单
var existingBill = await dbContext.TenantBillingStatements
.AnyAsync(b => b.TenantId == item.Subscription.TenantId
&& b.PeriodStart >= item.Subscription.EffectiveTo
&& b.Status != TenantBillingStatus.Cancelled,
cancellationToken);
if (existingBill)
{
_logger.LogInformation(
"订阅 {SubscriptionId} 已存在续费账单,跳过",
item.Subscription.Id);
continue;
}
// 生成续费账单
var billNo = $"BILL-{DateTime.UtcNow:yyyyMMddHHmmss}-{item.Subscription.TenantId}";
var periodStart = item.Subscription.EffectiveTo;
// 从当前订阅计算续费周期(月数)
var currentDurationMonths = ((item.Subscription.EffectiveTo.Year - item.Subscription.EffectiveFrom.Year) * 12)
+ item.Subscription.EffectiveTo.Month - item.Subscription.EffectiveFrom.Month;
if (currentDurationMonths <= 0) currentDurationMonths = 1; // 至少1个月
var periodEnd = periodStart.AddMonths(currentDurationMonths);
// 根据续费周期计算价格(年付优惠)
var renewalPrice = currentDurationMonths >= 12
? (item.Package.YearlyPrice ?? item.Package.MonthlyPrice * 12 ?? 0)
: (item.Package.MonthlyPrice ?? 0) * currentDurationMonths;
var bill = new TenantBillingStatement
{
Id = idGenerator.NextId(),
TenantId = item.Subscription.TenantId,
StatementNo = billNo,
PeriodStart = periodStart,
PeriodEnd = periodEnd,
AmountDue = renewalPrice,
AmountPaid = 0,
Status = TenantBillingStatus.Pending,
DueDate = periodStart.AddDays(-1), // 到期前一天为付款截止日
LineItemsJson = $"{{\"\":\"{item.Package.Name}\",\"续费周期\":\"{currentDurationMonths}个月\"}}",
CreatedAt = DateTime.UtcNow
};
dbContext.TenantBillingStatements.Add(bill);
billsCreated++;
_logger.LogInformation(
"为订阅 {SubscriptionId} (租户 {TenantId}) 生成自动续费账单 {BillNo},金额 {Amount}",
item.Subscription.Id, item.Subscription.TenantId, billNo, renewalPrice);
_logger.LogInformation("自动续费处理完成:未找到可处理租户");
return;
}
await dbContext.SaveChangesAsync(cancellationToken);
var billsCreatedTotal = 0;
foreach (var tenant in tenants)
{
using (tenantContextAccessor.EnterTenantScope(tenant.Id, "background:auto-renewal", tenant.Code))
{
var billsCreated = 0;
_logger.LogInformation("自动续费处理完成,共生成 {Count} 张账单", billsCreated);
// 查询开启自动续费且即将到期的活跃订阅
var autoRenewSubscriptions = await dbContext.TenantSubscriptions
.Where(s => s.Status == SubscriptionStatus.Active
&& s.AutoRenew
&& s.EffectiveTo <= renewalThreshold
&& s.EffectiveTo > now)
.Join(
dbContext.TenantPackages,
sub => sub.TenantPackageId,
package => package.Id,
(sub, package) => new { Subscription = sub, Package = package }
)
.ToListAsync(cancellationToken);
foreach (var item in autoRenewSubscriptions)
{
// 检查是否已为本次到期生成过账单
var existingBill = await dbContext.TenantBillingStatements
.AnyAsync(b => b.TenantId == item.Subscription.TenantId
&& b.PeriodStart >= item.Subscription.EffectiveTo
&& b.Status != TenantBillingStatus.Cancelled,
cancellationToken);
if (existingBill)
{
_logger.LogInformation(
"订阅 {SubscriptionId} 已存在续费账单,跳过",
item.Subscription.Id);
continue;
}
// 生成续费账单
var billNo = $"BILL-{DateTime.UtcNow:yyyyMMddHHmmss}-{item.Subscription.TenantId}";
var periodStart = item.Subscription.EffectiveTo;
// 从当前订阅计算续费周期(月数)
var currentDurationMonths = ((item.Subscription.EffectiveTo.Year - item.Subscription.EffectiveFrom.Year) * 12)
+ item.Subscription.EffectiveTo.Month - item.Subscription.EffectiveFrom.Month;
if (currentDurationMonths <= 0) currentDurationMonths = 1; // 至少1个月
var periodEnd = periodStart.AddMonths(currentDurationMonths);
// 根据续费周期计算价格(年付优惠)
var renewalPrice = currentDurationMonths >= 12
? (item.Package.YearlyPrice ?? item.Package.MonthlyPrice * 12 ?? 0)
: (item.Package.MonthlyPrice ?? 0) * currentDurationMonths;
var bill = new TenantBillingStatement
{
Id = idGenerator.NextId(),
TenantId = item.Subscription.TenantId,
StatementNo = billNo,
PeriodStart = periodStart,
PeriodEnd = periodEnd,
AmountDue = renewalPrice,
AmountPaid = 0,
Status = TenantBillingStatus.Pending,
DueDate = periodStart.AddDays(-1), // 到期前一天为付款截止日
LineItemsJson = $"{{\"\":\"{item.Package.Name}\",\"续费周期\":\"{currentDurationMonths}个月\"}}",
CreatedAt = DateTime.UtcNow
};
dbContext.TenantBillingStatements.Add(bill);
billsCreated++;
_logger.LogInformation(
"为订阅 {SubscriptionId} (租户 {TenantId}) 生成自动续费账单 {BillNo},金额 {Amount}",
item.Subscription.Id, item.Subscription.TenantId, billNo, renewalPrice);
}
if (billsCreated > 0)
{
await dbContext.SaveChangesAsync(cancellationToken);
}
billsCreatedTotal += billsCreated;
}
}
_logger.LogInformation("自动续费处理完成,共生成 {Count} 张账单", billsCreatedTotal);
}
catch (Exception ex)
{

View File

@@ -7,6 +7,7 @@ using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Infrastructure.App.Persistence;
using TakeoutSaaS.Shared.Abstractions.Ids;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Infrastructure.BackgroundServices;
@@ -70,81 +71,100 @@ public sealed class RenewalReminderService : BackgroundService
using var scope = _serviceProvider.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<TakeoutAppDbContext>();
var idGenerator = scope.ServiceProvider.GetRequiredService<IIdGenerator>();
var tenantContextAccessor = scope.ServiceProvider.GetRequiredService<ITenantContextAccessor>();
var now = DateTime.UtcNow;
var remindersSent = 0;
try
{
// 遍历配置的提醒时间点例如到期前7天、3天、1天
foreach (var daysBeforeExpiry in _options.ReminderDaysBeforeExpiry)
var tenants = await dbContext.Tenants
.AsNoTracking()
.Where(x => x.DeletedAt == null && x.Id > 0)
.Select(x => new { x.Id, x.Code, x.Name })
.ToListAsync(cancellationToken);
if (tenants.Count == 0)
{
var targetDate = now.AddDays(daysBeforeExpiry);
var startOfDay = targetDate.Date;
var endOfDay = startOfDay.AddDays(1);
_logger.LogInformation("续费提醒发送完成:未找到可处理租户");
return;
}
// 查询即将到期的活跃订阅(且未开启自动续费)
var expiringSubscriptions = await dbContext.TenantSubscriptions
.Where(s => s.Status == SubscriptionStatus.Active
&& !s.AutoRenew
&& s.EffectiveTo >= startOfDay
&& s.EffectiveTo < endOfDay)
.Join(
dbContext.Tenants,
sub => sub.TenantId,
tenant => tenant.Id,
(sub, tenant) => new { Subscription = sub, Tenant = tenant }
)
.Join(
dbContext.TenantPackages,
combined => combined.Subscription.TenantPackageId,
package => package.Id,
(combined, package) => new { combined.Subscription, combined.Tenant, Package = package }
)
.ToListAsync(cancellationToken);
foreach (var item in expiringSubscriptions)
var remindersSentTotal = 0;
foreach (var tenant in tenants)
{
using (tenantContextAccessor.EnterTenantScope(tenant.Id, "background:renewal-reminder", tenant.Code))
{
// 检查是否已发送过相同天数的提醒(避免重复发送)
var alreadySent = await dbContext.TenantNotifications
.AnyAsync(n => n.TenantId == item.Subscription.TenantId
&& n.Message.Contains($"{daysBeforeExpiry}天内到期")
&& n.SentAt >= now.AddHours(-24), // 24小时内已发送过
cancellationToken);
var remindersSent = 0;
if (alreadySent)
// 遍历配置的提醒时间点例如到期前7天、3天、1天
foreach (var daysBeforeExpiry in _options.ReminderDaysBeforeExpiry)
{
continue;
var targetDate = now.AddDays(daysBeforeExpiry);
var startOfDay = targetDate.Date;
var endOfDay = startOfDay.AddDays(1);
// 查询即将到期的活跃订阅(且未开启自动续费)
var expiringSubscriptions = await dbContext.TenantSubscriptions
.Where(s => s.Status == SubscriptionStatus.Active
&& !s.AutoRenew
&& s.EffectiveTo >= startOfDay
&& s.EffectiveTo < endOfDay)
.Join(
dbContext.TenantPackages,
sub => sub.TenantPackageId,
package => package.Id,
(sub, package) => new { Subscription = sub, Package = package }
)
.ToListAsync(cancellationToken);
foreach (var item in expiringSubscriptions)
{
// 检查是否已发送过相同天数的提醒(避免重复发送)
var alreadySent = await dbContext.TenantNotifications
.AnyAsync(n => n.TenantId == item.Subscription.TenantId
&& n.Message.Contains($"{daysBeforeExpiry}天内到期")
&& n.SentAt >= now.AddHours(-24), // 24小时内已发送过
cancellationToken);
if (alreadySent)
{
continue;
}
// 创建续费提醒通知
var notification = new TenantNotification
{
Id = idGenerator.NextId(),
TenantId = item.Subscription.TenantId,
Title = "订阅续费提醒",
Message = $"您的订阅套餐「{item.Package.Name}」将在 {daysBeforeExpiry} 天内到期(到期时间:{item.Subscription.EffectiveTo:yyyy-MM-dd HH:mm}),请及时续费以免影响使用。",
Severity = daysBeforeExpiry <= 1
? TenantNotificationSeverity.Critical
: TenantNotificationSeverity.Warning,
Channel = TenantNotificationChannel.InApp,
SentAt = DateTime.UtcNow,
ReadAt = null,
CreatedAt = DateTime.UtcNow
};
dbContext.TenantNotifications.Add(notification);
remindersSent++;
_logger.LogInformation(
"发送续费提醒: 租户 {TenantName} ({TenantId}), 套餐 {PackageName}, 剩余 {Days} 天",
tenant.Name, item.Subscription.TenantId, item.Package.Name, daysBeforeExpiry);
}
}
// 创建续费提醒通知
var notification = new TenantNotification
if (remindersSent > 0)
{
Id = idGenerator.NextId(),
TenantId = item.Subscription.TenantId,
Title = "订阅续费提醒",
Message = $"您的订阅套餐「{item.Package.Name}」将在 {daysBeforeExpiry} 天内到期(到期时间:{item.Subscription.EffectiveTo:yyyy-MM-dd HH:mm}),请及时续费以免影响使用。",
Severity = daysBeforeExpiry <= 1
? TenantNotificationSeverity.Critical
: TenantNotificationSeverity.Warning,
Channel = TenantNotificationChannel.InApp,
SentAt = DateTime.UtcNow,
ReadAt = null,
CreatedAt = DateTime.UtcNow
};
await dbContext.SaveChangesAsync(cancellationToken);
}
dbContext.TenantNotifications.Add(notification);
remindersSent++;
_logger.LogInformation(
"发送续费提醒: 租户 {TenantName} ({TenantId}), 套餐 {PackageName}, 剩余 {Days} 天",
item.Tenant.Name, item.Subscription.TenantId, item.Package.Name, daysBeforeExpiry);
remindersSentTotal += remindersSent;
}
}
await dbContext.SaveChangesAsync(cancellationToken);
_logger.LogInformation("续费提醒发送完成,共发送 {Count} 条提醒", remindersSent);
_logger.LogInformation("续费提醒发送完成,共发送 {Count} 条提醒", remindersSentTotal);
}
catch (Exception ex)
{

View File

@@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Infrastructure.App.Persistence;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Infrastructure.BackgroundServices;
@@ -67,45 +68,70 @@ public sealed class SubscriptionExpiryCheckService : BackgroundService
using var scope = _serviceProvider.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<TakeoutAppDbContext>();
var tenantContextAccessor = scope.ServiceProvider.GetRequiredService<ITenantContextAccessor>();
var now = DateTime.UtcNow;
var gracePeriodDays = _options.GracePeriodDays;
try
{
// 1. 检查活跃订阅中已到期的,转为宽限期
var expiredActive = await dbContext.TenantSubscriptions
.Where(s => s.Status == SubscriptionStatus.Active && s.EffectiveTo < now)
var tenants = await dbContext.Tenants
.AsNoTracking()
.Where(x => x.DeletedAt == null && x.Id > 0)
.Select(x => new { x.Id, x.Code })
.ToListAsync(cancellationToken);
foreach (var subscription in expiredActive)
if (tenants.Count == 0)
{
subscription.Status = SubscriptionStatus.GracePeriod;
_logger.LogInformation(
"订阅 {SubscriptionId} (租户 {TenantId}) 已到期,进入宽限期",
subscription.Id, subscription.TenantId);
_logger.LogInformation("订阅到期检查完成:未找到可处理租户");
return;
}
// 2. 检查宽限期订阅中超过宽限期的,转为暂停
var gracePeriodExpired = await dbContext.TenantSubscriptions
.Where(s => s.Status == SubscriptionStatus.GracePeriod
&& s.EffectiveTo.AddDays(gracePeriodDays) < now)
.ToListAsync(cancellationToken);
foreach (var subscription in gracePeriodExpired)
var changedTotal = 0;
var expiredTotal = 0;
var suspendedTotal = 0;
foreach (var tenant in tenants)
{
subscription.Status = SubscriptionStatus.Suspended;
_logger.LogInformation(
"订阅 {SubscriptionId} (租户 {TenantId}) 宽限期已结束,已暂停",
subscription.Id, subscription.TenantId);
}
using (tenantContextAccessor.EnterTenantScope(tenant.Id, "background:subscription-expiry", tenant.Code))
{
// 1. 检查活跃订阅中已到期的,转为宽限期
var expiredActive = await dbContext.TenantSubscriptions
.Where(s => s.Status == SubscriptionStatus.Active && s.EffectiveTo < now)
.ToListAsync(cancellationToken);
// 3. 保存更改
var changedCount = await dbContext.SaveChangesAsync(cancellationToken);
foreach (var subscription in expiredActive)
{
subscription.Status = SubscriptionStatus.GracePeriod;
_logger.LogInformation(
"订阅 {SubscriptionId} (租户 {TenantId}) 已到期,进入宽限期",
subscription.Id, subscription.TenantId);
}
// 2. 检查宽限期订阅中超过宽限期的,转为暂停
var gracePeriodExpired = await dbContext.TenantSubscriptions
.Where(s => s.Status == SubscriptionStatus.GracePeriod
&& s.EffectiveTo.AddDays(gracePeriodDays) < now)
.ToListAsync(cancellationToken);
foreach (var subscription in gracePeriodExpired)
{
subscription.Status = SubscriptionStatus.Suspended;
_logger.LogInformation(
"订阅 {SubscriptionId} (租户 {TenantId}) 宽限期已结束,已暂停",
subscription.Id, subscription.TenantId);
}
// 3. 保存更改(逐租户保存,避免跨租户写入)
var changedCount = await dbContext.SaveChangesAsync(cancellationToken);
changedTotal += changedCount;
expiredTotal += expiredActive.Count;
suspendedTotal += gracePeriodExpired.Count;
}
}
_logger.LogInformation(
"订阅到期检查完成,共更新 {Count} 条记录 (到期转宽限期: {ExpiredCount}, 宽限期转暂停: {SuspendedCount})",
changedCount, expiredActive.Count, gracePeriodExpired.Count);
changedTotal, expiredTotal, suspendedTotal);
}
catch (Exception ex)
{

View File

@@ -18,6 +18,26 @@ public abstract class AppDbContext(
private readonly ICurrentUserAccessor? _currentUserAccessor = currentUserAccessor;
private readonly IIdGenerator? _idGenerator = idGenerator;
/// <summary>
/// 是否禁用软删除过滤器。
/// </summary>
/// <remarks>
/// 仅允许在少数系统任务/恢复场景中临时关闭,默认应保持开启。
/// </remarks>
protected bool IsSoftDeleteFilterDisabled { get; private set; }
/// <summary>
/// 临时禁用软删除过滤器(仅关闭软删除过滤,不影响租户过滤)。
/// </summary>
/// <returns>作用域对象,释放后恢复之前的过滤状态。</returns>
public IDisposable DisableSoftDeleteFilter()
{
var previous = IsSoftDeleteFilterDisabled;
IsSoftDeleteFilterDisabled = true;
return new SoftDeleteFilterScope(this, previous);
}
/// <summary>
/// 构建模型时应用软删除过滤器。
/// </summary>
@@ -179,7 +199,15 @@ public abstract class AppDbContext(
private void SetSoftDeleteFilter<TEntity>(ModelBuilder modelBuilder)
where TEntity : class, ISoftDeleteEntity
{
modelBuilder.Entity<TEntity>().HasQueryFilter(entity => entity.DeletedAt == null);
QueryFilterCombiner.Combine<TEntity>(modelBuilder, "soft_delete", entity => IsSoftDeleteFilterDisabled || entity.DeletedAt == null);
}
private sealed class SoftDeleteFilterScope(AppDbContext context, bool previous) : IDisposable
{
public void Dispose()
{
context.IsSoftDeleteFilterDisabled = previous;
}
}
/// <summary>

View File

@@ -0,0 +1,23 @@
using System.Linq.Expressions;
using Microsoft.EntityFrameworkCore;
namespace TakeoutSaaS.Infrastructure.Common.Persistence;
/// <summary>
/// 查询过滤器合并器:用于追加具名 QueryFilter避免覆盖已有过滤器。
/// </summary>
internal static class QueryFilterCombiner
{
/// <summary>
/// 为指定实体追加具名查询过滤器。
/// </summary>
/// <typeparam name="TEntity">实体类型。</typeparam>
/// <param name="modelBuilder">模型构建器。</param>
/// <param name="filterKey">过滤器键。</param>
/// <param name="filter">新增过滤器表达式。</param>
internal static void Combine<TEntity>(ModelBuilder modelBuilder, string filterKey, Expression<Func<TEntity, bool>> filter)
where TEntity : class
{
modelBuilder.Entity<TEntity>().HasQueryFilter(filterKey, filter);
}
}

View File

@@ -62,7 +62,7 @@ public abstract class TenantAwareDbContext(
private void SetTenantFilter<TEntity>(ModelBuilder modelBuilder)
where TEntity : class, IMultiTenantEntity
{
modelBuilder.Entity<TEntity>().HasQueryFilter(entity => entity.TenantId == CurrentTenantId);
QueryFilterCombiner.Combine<TEntity>(modelBuilder, "tenant", entity => entity.TenantId == CurrentTenantId);
}
/// <summary>
@@ -74,9 +74,30 @@ public abstract class TenantAwareDbContext(
foreach (var entry in ChangeTracker.Entries<IMultiTenantEntity>())
{
if (entry.State == EntityState.Added && entry.Entity.TenantId == 0 && tenantId != 0)
if (entry.State is EntityState.Detached or EntityState.Unchanged)
{
continue;
}
if (tenantId == 0)
{
if (entry.Entity.TenantId != 0)
{
throw new InvalidOperationException("未进入租户上下文,禁止写入 TenantId 不为 0 的多租户数据。");
}
continue;
}
if (entry.State == EntityState.Added && entry.Entity.TenantId == 0)
{
entry.Entity.TenantId = tenantId;
continue;
}
if (entry.Entity.TenantId != tenantId)
{
throw new InvalidOperationException("检测到跨租户写入,已阻止保存。");
}
}
}

View File

@@ -16,7 +16,6 @@ public sealed class DictionaryGroupRepository(DictionaryDbContext context) : IDi
EF.CompileAsyncQuery((DictionaryDbContext db, long tenantId, DictionaryCode code) =>
db.DictionaryGroups
.AsNoTracking()
.IgnoreQueryFilters()
.FirstOrDefault(group => group.TenantId == tenantId && group.DeletedAt == null && group.Code == code));
/// <summary>
@@ -25,7 +24,7 @@ public sealed class DictionaryGroupRepository(DictionaryDbContext context) : IDi
public Task<DictionaryGroup?> GetByIdAsync(long groupId, CancellationToken cancellationToken = default)
{
return context.DictionaryGroups
.IgnoreQueryFilters()
.AsNoTracking()
.FirstOrDefaultAsync(group => group.Id == groupId && group.DeletedAt == null, cancellationToken);
}
@@ -90,7 +89,6 @@ public sealed class DictionaryGroupRepository(DictionaryDbContext context) : IDi
return await context.DictionaryGroups
.AsNoTracking()
.IgnoreQueryFilters()
.Where(group => ids.Contains(group.Id) && group.DeletedAt == null)
.ToListAsync(cancellationToken);
}
@@ -144,7 +142,6 @@ public sealed class DictionaryGroupRepository(DictionaryDbContext context) : IDi
{
var query = context.DictionaryGroups
.AsNoTracking()
.IgnoreQueryFilters()
.Where(group => group.TenantId == tenantId && group.DeletedAt == null);
if (scope.HasValue)

View File

@@ -3,19 +3,19 @@ using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Dictionary.Entities;
using TakeoutSaaS.Domain.Dictionary.Repositories;
using TakeoutSaaS.Infrastructure.Dictionary.Persistence;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Infrastructure.Dictionary.Repositories;
/// <summary>
/// 字典项仓储实现。
/// </summary>
public sealed class DictionaryItemRepository(DictionaryDbContext context) : IDictionaryItemRepository
public sealed class DictionaryItemRepository(DictionaryDbContext context, ITenantContextAccessor tenantContextAccessor) : IDictionaryItemRepository
{
private static readonly Func<DictionaryDbContext, long, long, IEnumerable<DictionaryItem>> GetByGroupQuery =
EF.CompileQuery((DictionaryDbContext db, long tenantId, long groupId) =>
(IEnumerable<DictionaryItem>)db.DictionaryItems
.AsNoTracking()
.IgnoreQueryFilters()
.Where(item => item.GroupId == groupId && item.TenantId == tenantId && item.DeletedAt == null)
.OrderBy(item => item.SortOrder));
@@ -25,7 +25,7 @@ public sealed class DictionaryItemRepository(DictionaryDbContext context) : IDic
public Task<DictionaryItem?> GetByIdAsync(long itemId, CancellationToken cancellationToken = default)
{
return context.DictionaryItems
.IgnoreQueryFilters()
.AsNoTracking()
.FirstOrDefaultAsync(item => item.Id == itemId && item.DeletedAt == null, cancellationToken);
}
@@ -51,25 +51,26 @@ public sealed class DictionaryItemRepository(DictionaryDbContext context) : IDic
bool includeOverrides,
CancellationToken cancellationToken = default)
{
var systemGroup = await context.DictionaryGroups
.AsNoTracking()
.IgnoreQueryFilters()
.FirstOrDefaultAsync(group => group.Id == systemGroupId && group.DeletedAt == null, cancellationToken);
if (systemGroup == null)
DictionaryGroup? systemGroup;
List<DictionaryItem> systemItems;
using (tenantContextAccessor.EnterTenantScope(0, "dictionary"))
{
return Array.Empty<DictionaryItem>();
systemGroup = await context.DictionaryGroups
.AsNoTracking()
.FirstOrDefaultAsync(group => group.Id == systemGroupId && group.DeletedAt == null, cancellationToken);
if (systemGroup == null)
{
return Array.Empty<DictionaryItem>();
}
systemItems = await context.DictionaryItems
.AsNoTracking()
.Where(item => item.GroupId == systemGroupId && item.DeletedAt == null)
.OrderBy(item => item.SortOrder)
.ToListAsync(cancellationToken);
}
var result = new List<DictionaryItem>();
var systemItems = await context.DictionaryItems
.AsNoTracking()
.IgnoreQueryFilters()
.Where(item => item.GroupId == systemGroupId && item.TenantId == 0 && item.DeletedAt == null)
.OrderBy(item => item.SortOrder)
.ToListAsync(cancellationToken);
result.AddRange(systemItems);
var result = new List<DictionaryItem>(systemItems);
if (!includeOverrides || tenantId == 0)
{
@@ -78,7 +79,6 @@ public sealed class DictionaryItemRepository(DictionaryDbContext context) : IDic
var tenantGroup = await context.DictionaryGroups
.AsNoTracking()
.IgnoreQueryFilters()
.FirstOrDefaultAsync(group =>
group.TenantId == tenantId &&
group.DeletedAt == null &&
@@ -92,7 +92,6 @@ public sealed class DictionaryItemRepository(DictionaryDbContext context) : IDic
var tenantItems = await context.DictionaryItems
.AsNoTracking()
.IgnoreQueryFilters()
.Where(item => item.GroupId == tenantGroup.Id && item.TenantId == tenantId && item.DeletedAt == null)
.OrderBy(item => item.SortOrder)
.ToListAsync(cancellationToken);

View File

@@ -17,7 +17,6 @@ public sealed class DictionaryLabelOverrideRepository(DictionaryDbContext contex
public Task<DictionaryLabelOverride?> GetByIdAsync(long id, CancellationToken cancellationToken = default)
{
return context.DictionaryLabelOverrides
.IgnoreQueryFilters()
.Include(x => x.DictionaryItem)
.FirstOrDefaultAsync(x => x.Id == id && x.DeletedAt == null, cancellationToken);
}
@@ -28,7 +27,6 @@ public sealed class DictionaryLabelOverrideRepository(DictionaryDbContext contex
public Task<DictionaryLabelOverride?> GetByItemIdAsync(long tenantId, long dictionaryItemId, CancellationToken cancellationToken = default)
{
return context.DictionaryLabelOverrides
.IgnoreQueryFilters()
.FirstOrDefaultAsync(x =>
x.TenantId == tenantId &&
x.DictionaryItemId == dictionaryItemId &&
@@ -46,7 +44,6 @@ public sealed class DictionaryLabelOverrideRepository(DictionaryDbContext contex
{
var query = context.DictionaryLabelOverrides
.AsNoTracking()
.IgnoreQueryFilters()
.Include(x => x.DictionaryItem)
.Where(x => x.TenantId == tenantId && x.DeletedAt == null);
@@ -71,7 +68,6 @@ public sealed class DictionaryLabelOverrideRepository(DictionaryDbContext contex
return await context.DictionaryLabelOverrides
.AsNoTracking()
.IgnoreQueryFilters()
.Where(x =>
x.TenantId == tenantId &&
ids.Contains(x.DictionaryItemId) &&

View File

@@ -4,13 +4,14 @@ using TakeoutSaaS.Domain.Dictionary.Enums;
using TakeoutSaaS.Domain.Dictionary.ValueObjects;
using TakeoutSaaS.Domain.Dictionary.Repositories;
using TakeoutSaaS.Infrastructure.Dictionary.Persistence;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Infrastructure.Dictionary.Repositories;
/// <summary>
/// EF Core 字典仓储实现。
/// </summary>
public sealed class EfDictionaryRepository(DictionaryDbContext context) : IDictionaryRepository
public sealed class EfDictionaryRepository(DictionaryDbContext context, ITenantContextAccessor tenantContextAccessor) : IDictionaryRepository
{
/// <summary>
/// 根据分组 ID 查询分组。
@@ -163,19 +164,37 @@ public sealed class EfDictionaryRepository(DictionaryDbContext context) : IDicti
return Array.Empty<DictionaryItem>();
}
// 2. 构建查询并忽略 QueryFilter
var query = context.DictionaryItems
// 2. 查询当前租户条目
var tenantItems = await context.DictionaryItems
.AsNoTracking()
.IgnoreQueryFilters()
.Include(item => item.Group)
.Where(item => normalizedCodes.Contains(item.Group!.Code) && item.DeletedAt == null);
// 3. 按租户或系统级过滤
query = query.Where(item => item.TenantId == tenantId || (includeSystem && item.TenantId == 0));
// 4. 排序返回
return await query
.Where(item => item.TenantId == tenantId && normalizedCodes.Contains(item.Group!.Code) && item.DeletedAt == null)
.OrderBy(item => item.SortOrder)
.ToListAsync(cancellationToken);
if (!includeSystem)
{
return tenantItems;
}
// 3. (空行后) 查询系统级条目TenantId=0
List<DictionaryItem> systemItems;
using (tenantContextAccessor.EnterTenantScope(0, "dictionary"))
{
systemItems = await context.DictionaryItems
.AsNoTracking()
.Include(item => item.Group)
.Where(item => item.TenantId == 0 && normalizedCodes.Contains(item.Group!.Code) && item.DeletedAt == null)
.OrderBy(item => item.SortOrder)
.ToListAsync(cancellationToken);
}
// 4. (空行后) 合并返回(系统优先)
if (systemItems.Count == 0)
{
return tenantItems;
}
return [.. systemItems, .. tenantItems];
}
}

View File

@@ -16,7 +16,6 @@ public sealed class TenantDictionaryOverrideRepository(DictionaryDbContext conte
public Task<TenantDictionaryOverride?> GetAsync(long tenantId, long systemGroupId, CancellationToken cancellationToken = default)
{
return context.TenantDictionaryOverrides
.IgnoreQueryFilters()
.FirstOrDefaultAsync(config =>
config.TenantId == tenantId &&
config.SystemDictionaryGroupId == systemGroupId &&
@@ -31,7 +30,6 @@ public sealed class TenantDictionaryOverrideRepository(DictionaryDbContext conte
{
return await context.TenantDictionaryOverrides
.AsNoTracking()
.IgnoreQueryFilters()
.Where(config => config.TenantId == tenantId && config.DeletedAt == null)
.ToListAsync(cancellationToken);
}

View File

@@ -49,15 +49,14 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
/// <param name="excludeUserId">排除的用户 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>存在返回 true。</returns>
public Task<bool> ExistsByAccountAsync(long tenantId, string account, long? excludeUserId = null, CancellationToken cancellationToken = default)
public async Task<bool> ExistsByAccountAsync(long tenantId, string account, long? excludeUserId = null, CancellationToken cancellationToken = default)
{
// 1. 标准化账号
var normalized = account.Trim();
// 2. 构建查询(包含已删除数据)
var query = dbContext.IdentityUsers
.IgnoreQueryFilters()
.AsNoTracking()
// 2. 构建查询(包含已删除数据,但不放开租户过滤
using var disableSoftDeleteScope = dbContext.DisableSoftDeleteFilter();
var query = dbContext.IdentityUsers.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.Account == normalized);
if (excludeUserId.HasValue)
@@ -66,7 +65,7 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
}
// 3. 返回是否存在
return query.AnyAsync(cancellationToken);
return await query.AnyAsync(cancellationToken);
}
/// <summary>
@@ -77,15 +76,14 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
/// <param name="excludeUserId">排除的用户 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>存在返回 true。</returns>
public Task<bool> ExistsByPhoneAsync(long tenantId, string phone, long? excludeUserId = null, CancellationToken cancellationToken = default)
public async Task<bool> ExistsByPhoneAsync(long tenantId, string phone, long? excludeUserId = null, CancellationToken cancellationToken = default)
{
// 1. 标准化手机号
var normalized = phone.Trim();
// 2. 构建查询(包含已删除数据)
var query = dbContext.IdentityUsers
.IgnoreQueryFilters()
.AsNoTracking()
// 2. 构建查询(包含已删除数据,但不放开租户过滤
using var disableSoftDeleteScope = dbContext.DisableSoftDeleteFilter();
var query = dbContext.IdentityUsers.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.Phone == normalized);
if (excludeUserId.HasValue)
@@ -94,7 +92,7 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
}
// 3. 返回是否存在
return query.AnyAsync(cancellationToken);
return await query.AnyAsync(cancellationToken);
}
/// <summary>
@@ -105,15 +103,14 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
/// <param name="excludeUserId">排除的用户 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>存在返回 true。</returns>
public Task<bool> ExistsByEmailAsync(long tenantId, string email, long? excludeUserId = null, CancellationToken cancellationToken = default)
public async Task<bool> ExistsByEmailAsync(long tenantId, string email, long? excludeUserId = null, CancellationToken cancellationToken = default)
{
// 1. 标准化邮箱
var normalized = email.Trim();
// 2. 构建查询(包含已删除数据)
var query = dbContext.IdentityUsers
.IgnoreQueryFilters()
.AsNoTracking()
// 2. 构建查询(包含已删除数据,但不放开租户过滤
using var disableSoftDeleteScope = dbContext.DisableSoftDeleteFilter();
var query = dbContext.IdentityUsers.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.Email == normalized);
if (excludeUserId.HasValue)
@@ -122,7 +119,7 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
}
// 3. 返回是否存在
return query.AnyAsync(cancellationToken);
return await query.AnyAsync(cancellationToken);
}
/// <summary>
@@ -150,14 +147,14 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
/// <param name="userId">用户 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>后台用户或 null。</returns>
public Task<IdentityUser?> GetForUpdateIncludingDeletedAsync(
public async Task<IdentityUser?> GetForUpdateIncludingDeletedAsync(
long tenantId,
long userId,
CancellationToken cancellationToken = default)
{
// 1. 构建查询(包含已删除数据,但强制租户隔离)
return dbContext.IdentityUsers
.IgnoreQueryFilters()
using var disableSoftDeleteScope = dbContext.DisableSoftDeleteFilter();
return await dbContext.IdentityUsers
.Where(x => x.TenantId == tenantId)
.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
}
@@ -204,21 +201,14 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
var tenantId = filter.TenantId.Value;
using var disableSoftDeleteScope = filter.IncludeDeleted ? dbContext.DisableSoftDeleteFilter() : null;
// 1. 构建基础查询
var query = dbContext.IdentityUsers.AsNoTracking();
if (filter.IncludeDeleted)
{
query = query.IgnoreQueryFilters();
}
// 2. 租户过滤(强制)
query = query.Where(x => x.TenantId == tenantId);
if (!filter.IncludeDeleted)
{
query = query.Where(x => x.DeletedAt == null);
}
// 3. 关键字筛选
if (!string.IsNullOrWhiteSpace(filter.Keyword))
{
@@ -242,18 +232,9 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
{
var roleId = filter.RoleId.Value;
var userRoles = dbContext.UserRoles.AsNoTracking();
if (filter.IncludeDeleted)
{
userRoles = userRoles.IgnoreQueryFilters();
}
userRoles = userRoles.Where(x => x.TenantId == tenantId);
if (!filter.IncludeDeleted)
{
userRoles = userRoles.Where(x => x.DeletedAt == null);
}
query = query.Where(user => userRoles.Any(role => role.UserId == user.Id && role.RoleId == roleId));
}
@@ -346,18 +327,10 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
}
var query = dbContext.IdentityUsers.Where(x => ids.Contains(x.Id));
if (includeDeleted)
{
query = query.IgnoreQueryFilters();
}
using var disableSoftDeleteScope = includeDeleted ? dbContext.DisableSoftDeleteFilter() : null;
query = query.Where(x => x.TenantId == tenantId);
if (!includeDeleted)
{
query = query.Where(x => x.DeletedAt == null);
}
// 2. 返回列表
return await query.ToListAsync(cancellationToken);
}

View File

@@ -18,9 +18,8 @@ public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermi
/// <returns>权限实体或 null。</returns>
public Task<Permission?> FindByIdAsync(long permissionId, long tenantId, CancellationToken cancellationToken = default)
=> dbContext.Permissions
.IgnoreQueryFilters()
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == permissionId && x.DeletedAt == null, cancellationToken);
.FirstOrDefaultAsync(x => x.Id == permissionId && x.TenantId == tenantId && x.DeletedAt == null, cancellationToken);
/// <summary>
/// 根据权限编码获取权限。
@@ -31,9 +30,8 @@ public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermi
/// <returns>权限实体或 null。</returns>
public Task<Permission?> FindByCodeAsync(string code, long tenantId, CancellationToken cancellationToken = default)
=> dbContext.Permissions
.IgnoreQueryFilters()
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Code == code && x.DeletedAt == null, cancellationToken);
.FirstOrDefaultAsync(x => x.Code == code && x.TenantId == tenantId && x.DeletedAt == null, cancellationToken);
/// <summary>
/// 根据权限编码集合批量获取权限。
@@ -51,11 +49,10 @@ public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermi
.Distinct()
.ToArray();
// 2. 读取全局权限(已固定)
// 2. 读取租户权限
return dbContext.Permissions
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.DeletedAt == null && normalizedCodes.Contains(x.Code))
.Where(x => x.TenantId == tenantId && x.DeletedAt == null && normalizedCodes.Contains(x.Code))
.ToListAsync(cancellationToken)
.ContinueWith(t => (IReadOnlyList<Permission>)t.Result, cancellationToken);
}
@@ -69,9 +66,8 @@ public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermi
/// <returns>权限列表。</returns>
public Task<IReadOnlyList<Permission>> GetByIdsAsync(long tenantId, IEnumerable<long> permissionIds, CancellationToken cancellationToken = default)
=> dbContext.Permissions
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.DeletedAt == null && permissionIds.Contains(x.Id))
.Where(x => x.TenantId == tenantId && x.DeletedAt == null && permissionIds.Contains(x.Id))
.ToListAsync(cancellationToken)
.ContinueWith(t => (IReadOnlyList<Permission>)t.Result, cancellationToken);
@@ -86,9 +82,8 @@ public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermi
{
// 1. 构建基础查询
var query = dbContext.Permissions
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.DeletedAt == null);
.Where(x => x.TenantId == tenantId && x.DeletedAt == null);
if (!string.IsNullOrWhiteSpace(keyword))
{
// 2. 追加关键字过滤
@@ -139,7 +134,7 @@ public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermi
public async Task DeleteAsync(long permissionId, long tenantId, CancellationToken cancellationToken = default)
{
// 1. 查询目标权限
var entity = await dbContext.Permissions.FirstOrDefaultAsync(x => x.Id == permissionId, cancellationToken);
var entity = await dbContext.Permissions.FirstOrDefaultAsync(x => x.Id == permissionId && x.TenantId == tenantId, cancellationToken);
if (entity != null)
{
// 2. 删除实体

View File

@@ -16,13 +16,17 @@ public sealed class EfRolePermissionRepository(IdentityDbContext dbContext) : IR
/// <param name="roleIds">角色 ID 集合。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>角色权限映射列表。</returns>
public Task<IReadOnlyList<RolePermission>> GetByRoleIdsAsync(long tenantId, IEnumerable<long> roleIds, CancellationToken cancellationToken = default)
=> dbContext.RolePermissions
.IgnoreQueryFilters()
public async Task<IReadOnlyList<RolePermission>> GetByRoleIdsAsync(long tenantId, IEnumerable<long> roleIds, CancellationToken cancellationToken = default)
{
// 1. 查询角色权限映射
var mappings = await dbContext.RolePermissions
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.DeletedAt == null && roleIds.Contains(x.RoleId))
.ToListAsync(cancellationToken)
.ContinueWith(t => (IReadOnlyList<RolePermission>)t.Result, cancellationToken);
.ToListAsync(cancellationToken);
// 2. (空行后) 返回只读列表
return mappings;
}
/// <summary>
/// 批量新增角色权限。

View File

@@ -18,7 +18,6 @@ public sealed class EfRoleRepository(IdentityDbContext dbContext) : IRoleReposit
/// <returns>角色实体或 null。</returns>
public Task<Role?> FindByIdAsync(long roleId, long tenantId, CancellationToken cancellationToken = default)
=> dbContext.Roles
.IgnoreQueryFilters()
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == roleId && x.TenantId == tenantId && x.DeletedAt == null, cancellationToken);
@@ -31,7 +30,6 @@ public sealed class EfRoleRepository(IdentityDbContext dbContext) : IRoleReposit
/// <returns>角色实体或 null。</returns>
public Task<Role?> FindByCodeAsync(string code, long tenantId, CancellationToken cancellationToken = default)
=> dbContext.Roles
.IgnoreQueryFilters()
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Code == code && x.TenantId == tenantId && x.DeletedAt == null, cancellationToken);
@@ -42,13 +40,17 @@ public sealed class EfRoleRepository(IdentityDbContext dbContext) : IRoleReposit
/// <param name="roleIds">角色 ID 集合。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>角色列表。</returns>
public Task<IReadOnlyList<Role>> GetByIdsAsync(long tenantId, IEnumerable<long> roleIds, CancellationToken cancellationToken = default)
=> dbContext.Roles
.IgnoreQueryFilters()
public async Task<IReadOnlyList<Role>> GetByIdsAsync(long tenantId, IEnumerable<long> roleIds, CancellationToken cancellationToken = default)
{
// 1. 查询角色列表
var roles = await dbContext.Roles
.AsNoTracking()
.Where(x => x.TenantId == tenantId && roleIds.Contains(x.Id) && x.DeletedAt == null)
.ToListAsync(cancellationToken)
.ContinueWith(t => (IReadOnlyList<Role>)t.Result, cancellationToken);
.ToListAsync(cancellationToken);
// 2. (空行后) 返回只读列表
return roles;
}
/// <summary>
/// 按关键字搜索角色。
@@ -57,11 +59,10 @@ public sealed class EfRoleRepository(IdentityDbContext dbContext) : IRoleReposit
/// <param name="keyword">搜索关键字。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>角色列表。</returns>
public Task<IReadOnlyList<Role>> SearchAsync(long tenantId, string? keyword, CancellationToken cancellationToken = default)
public async Task<IReadOnlyList<Role>> SearchAsync(long tenantId, string? keyword, CancellationToken cancellationToken = default)
{
// 1. 构建基础查询
var query = dbContext.Roles
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.DeletedAt == null);
if (!string.IsNullOrWhiteSpace(keyword))
@@ -72,8 +73,10 @@ public sealed class EfRoleRepository(IdentityDbContext dbContext) : IRoleReposit
}
// 3. 返回列表
return query.ToListAsync(cancellationToken)
.ContinueWith(t => (IReadOnlyList<Role>)t.Result, cancellationToken);
var roles = await query.ToListAsync(cancellationToken);
// 4. (空行后) 返回只读列表
return roles;
}
/// <summary>

View File

@@ -16,13 +16,17 @@ public sealed class EfUserRoleRepository(IdentityDbContext dbContext) : IUserRol
/// <param name="userIds">用户 ID 集合。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>用户角色映射列表。</returns>
public Task<IReadOnlyList<UserRole>> GetByUserIdsAsync(long tenantId, IEnumerable<long> userIds, CancellationToken cancellationToken = default)
=> dbContext.UserRoles
.IgnoreQueryFilters()
public async Task<IReadOnlyList<UserRole>> GetByUserIdsAsync(long tenantId, IEnumerable<long> userIds, CancellationToken cancellationToken = default)
{
// 1. 查询用户角色映射
var mappings = await dbContext.UserRoles
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.DeletedAt == null && userIds.Contains(x.UserId))
.ToListAsync(cancellationToken)
.ContinueWith(t => (IReadOnlyList<UserRole>)t.Result, cancellationToken);
.ToListAsync(cancellationToken);
// 2. (空行后) 返回只读列表
return mappings;
}
/// <summary>
/// 获取指定用户的角色集合。
@@ -31,13 +35,17 @@ public sealed class EfUserRoleRepository(IdentityDbContext dbContext) : IUserRol
/// <param name="userId">用户 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>用户角色列表。</returns>
public Task<IReadOnlyList<UserRole>> GetByUserIdAsync(long tenantId, long userId, CancellationToken cancellationToken = default)
=> dbContext.UserRoles
.IgnoreQueryFilters()
public async Task<IReadOnlyList<UserRole>> GetByUserIdAsync(long tenantId, long userId, CancellationToken cancellationToken = default)
{
// 1. 查询用户角色映射
var mappings = await dbContext.UserRoles
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.DeletedAt == null && x.UserId == userId)
.ToListAsync(cancellationToken)
.ContinueWith(t => (IReadOnlyList<UserRole>)t.Result, cancellationToken);
.ToListAsync(cancellationToken);
// 2. (空行后) 返回只读列表
return mappings;
}
/// <summary>
/// 替换指定用户的角色集合。
@@ -56,8 +64,8 @@ public sealed class EfUserRoleRepository(IdentityDbContext dbContext) : IUserRol
await using var trx = await dbContext.Database.BeginTransactionAsync(cancellationToken);
// 2. 读取当前角色映射
using var disableSoftDeleteScope = dbContext.DisableSoftDeleteFilter();
var existing = await dbContext.UserRoles
.IgnoreQueryFilters()
.Where(x => x.TenantId == tenantId && x.UserId == userId)
.ToListAsync(cancellationToken);
@@ -111,7 +119,6 @@ public sealed class EfUserRoleRepository(IdentityDbContext dbContext) : IUserRol
/// <returns>用户数量。</returns>
public Task<int> CountUsersByRoleAsync(long tenantId, long roleId, CancellationToken cancellationToken = default)
=> dbContext.UserRoles
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.DeletedAt == null && x.RoleId == roleId)
.CountAsync(cancellationToken);

View File

@@ -59,7 +59,7 @@ public sealed class IdentityDataSeeder(IServiceProvider serviceProvider, ILogger
foreach (var userOptions in options.Users)
{
// 6.1 进入租户作用域
using var tenantScope = EnterTenantScope(tenantContextAccessor, userOptions.TenantId);
using var tenantScope = tenantContextAccessor.EnterTenantScope(userOptions.TenantId, "admin-seed");
// 6.2 查询账号并收集配置
var user = await context.IdentityUsers.FirstOrDefaultAsync(x => x.Account == userOptions.Account, cancellationToken);
var roles = NormalizeValues(userOptions.Roles);
@@ -112,9 +112,8 @@ public sealed class IdentityDataSeeder(IServiceProvider serviceProvider, ILogger
});
}
// 6.6 读取全局权限定义(固定权限,不再按租户生成)
// 6.6 读取当前租户权限定义
var existingPermissions = await context.Permissions
.IgnoreQueryFilters()
.AsNoTracking()
.Where(p => permissions.Contains(p.Code))
.ToListAsync(cancellationToken);
@@ -324,17 +323,4 @@ public sealed class IdentityDataSeeder(IServiceProvider serviceProvider, ILogger
.Where(v => !string.IsNullOrWhiteSpace(v))
.Select(v => v.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)];
private static IDisposable EnterTenantScope(ITenantContextAccessor accessor, long tenantId)
{
var previous = accessor.Current;
accessor.Current = new TenantContext(tenantId, null, "admin-seed");
return new Scope(() => accessor.Current = previous);
}
private sealed class Scope(Action disposeAction) : IDisposable
{
private readonly Action _disposeAction = disposeAction;
public void Dispose() => _disposeAction();
}
}

View File

@@ -2,13 +2,14 @@ using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Identity.Entities;
using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Infrastructure.Identity.Persistence;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Infrastructure.Identity.Repositories;
/// <summary>
/// 菜单仓储 EF 实现。
/// </summary>
public sealed class EfMenuRepository(IdentityDbContext dbContext) : IMenuRepository
public sealed class EfMenuRepository(IdentityDbContext dbContext, ITenantContextAccessor tenantContextAccessor) : IMenuRepository
{
/// <inheritdoc />
public async Task<IReadOnlyList<MenuDefinition>> GetByTenantAsync(long tenantId, CancellationToken cancellationToken = default)
@@ -26,15 +27,17 @@ public sealed class EfMenuRepository(IdentityDbContext dbContext) : IMenuReposit
}
// 2. (空行后) 回退系统默认菜单TenantId=0
var systemMenus = await dbContext.MenuDefinitions
.AsNoTracking()
.IgnoreQueryFilters()
.Where(x => x.TenantId == 0 && x.DeletedAt == null)
.OrderBy(x => x.ParentId)
.ThenBy(x => x.SortOrder)
.ToListAsync(cancellationToken);
using (tenantContextAccessor.EnterTenantScope(0, "menu"))
{
var systemMenus = await dbContext.MenuDefinitions
.AsNoTracking()
.Where(x => x.TenantId == 0 && x.DeletedAt == null)
.OrderBy(x => x.ParentId)
.ThenBy(x => x.SortOrder)
.ToListAsync(cancellationToken);
return systemMenus;
return systemMenus;
}
}
/// <inheritdoc />
@@ -52,10 +55,12 @@ public sealed class EfMenuRepository(IdentityDbContext dbContext) : IMenuReposit
}
// 2. (空行后) 回退查系统默认菜单TenantId=0
return await dbContext.MenuDefinitions
.AsNoTracking()
.IgnoreQueryFilters()
.FirstOrDefaultAsync(x => x.Id == id && x.TenantId == 0 && x.DeletedAt == null, cancellationToken);
using (tenantContextAccessor.EnterTenantScope(0, "menu"))
{
return await dbContext.MenuDefinitions
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == id && x.TenantId == 0 && x.DeletedAt == null, cancellationToken);
}
}
/// <inheritdoc />

View File

@@ -63,6 +63,7 @@ public sealed class TakeoutLogsDbContext(
ConfigureOperationLog(modelBuilder.Entity<OperationLog>());
ConfigureOperationLogInboxMessage(modelBuilder.Entity<OperationLogInboxMessage>());
ConfigureMemberGrowthLog(modelBuilder.Entity<MemberGrowthLog>());
ApplyTenantQueryFilters(modelBuilder);
}
private static void ConfigureTenantAuditLog(EntityTypeBuilder<TenantAuditLog> builder)