diff --git a/TakeoutSaaS.BuildingBlocks b/TakeoutSaaS.BuildingBlocks index bcf0a6b..5b07973 160000 --- a/TakeoutSaaS.BuildingBlocks +++ b/TakeoutSaaS.BuildingBlocks @@ -1 +1 @@ -Subproject commit bcf0a6bd7dbcbef19e630ca768ccb5d617373c7d +Subproject commit 5b07973a39fc3bdb52f2e7c870aed7b6ea3ab388 diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/BatchUpdateStatusCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/BatchUpdateStatusCommandHandler.cs index d5d63ce..84cd172 100644 --- a/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/BatchUpdateStatusCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/BatchUpdateStatusCommandHandler.cs @@ -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; /// 批量更新账单状态处理器。 /// public sealed class BatchUpdateStatusCommandHandler( - ITenantBillingRepository billingRepository) + ITenantBillingRepository billingRepository, + ITenantContextAccessor tenantContextAccessor) : IRequestHandler { /// @@ -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; } /// diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/AppDataSeeder.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/AppDataSeeder.cs index be264e3..079a573 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/AppDataSeeder.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/AppDataSeeder.cs @@ -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(); var dictionaryDbContext = scope.ServiceProvider.GetRequiredService(); + var tenantContextAccessor = scope.ServiceProvider.GetRequiredService(); 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( /// private async Task 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( /// 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( /// /// 确保基础字典存在。 /// - 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(); 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( /// /// 确保系统参数以独立表形式可重复种子。 /// - 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(); @@ -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(); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/Repositories/TenantBillingRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/Repositories/TenantBillingRepository.cs index 02284a1..07b6eb5 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/Repositories/TenantBillingRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/Repositories/TenantBillingRepository.cs @@ -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; /// /// 租户账单仓储实现(EF Core)。 /// -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> GetActiveTenantIdsAsync(CancellationToken cancellationToken) + => context.Tenants + .AsNoTracking() + .Where(x => x.DeletedAt == null && x.Id > 0) + .Select(x => x.Id) + .ToListAsync(cancellationToken); + /// public async Task> 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 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); } /// @@ -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(); + } + + var results = new List(); + 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(); } /// @@ -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(); + } + + var results = new List(); + 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(); } /// public async Task> 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(); } - // 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(); + } + + var results = new List(); + 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(); } /// @@ -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? 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(); + 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); } /// @@ -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 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(); + 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 /// public Task 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 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 BuildStatisticsQuery(DateTime startDate, DateTime endDate) + => context.TenantBillingStatements + .AsNoTracking() + .Where(x => x.DeletedAt == null + && x.PeriodStart >= startDate + && x.PeriodEnd <= endDate); + + private async Task 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 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) diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/Repositories/TenantPaymentRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/Repositories/TenantPaymentRepository.cs index d9ca04c..9b20ecb 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/Repositories/TenantPaymentRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/Repositories/TenantPaymentRepository.cs @@ -15,7 +15,6 @@ public sealed class TenantPaymentRepository(TakeoutAppDbContext context) : ITena public async Task> 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 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); } diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMerchantRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMerchantRepository.cs index 8a02952..17e9fc4 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMerchantRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMerchantRepository.cs @@ -209,7 +209,6 @@ public sealed class EfMerchantRepository(TakeoutAppDbContext context, TakeoutLog public async Task> 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); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfQuotaPackageRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfQuotaPackageRepository.cs index 2e3e60e..1299a31 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfQuotaPackageRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfQuotaPackageRepository.cs @@ -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); } diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantRepository.cs index a25bae8..2b091a0 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantRepository.cs @@ -206,7 +206,6 @@ public sealed class EfTenantRepository(TakeoutAppDbContext context, TakeoutLogsD public Task 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 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 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> 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> GetAuditLogsAsync(long tenantId, CancellationToken cancellationToken = default) { return await logsContext.TenantAuditLogs - .IgnoreQueryFilters() .AsNoTracking() .Where(x => x.DeletedAt == null && x.TenantId == tenantId) .OrderByDescending(x => x.CreatedAt) diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Services/BillingDomainService.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Services/BillingDomainService.cs index 76da8b6..4fd3346 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Services/BillingDomainService.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Services/BillingDomainService.cs @@ -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; /// public sealed class BillingDomainService( ITenantBillingRepository billingRepository, + ITenantRepository tenantRepository, + ITenantContextAccessor tenantContextAccessor, ITenantPackageRepository tenantPackageRepository, IIdGenerator idGenerator) : IBillingDomainService { @@ -147,15 +150,43 @@ public sealed class BillingDomainService( /// public async Task 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 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); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Services/StoreSchedulerService.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Services/StoreSchedulerService.cs index 7a183ff..621b02a 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Services/StoreSchedulerService.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Services/StoreSchedulerService.cs @@ -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; /// public sealed class StoreSchedulerService( TakeoutAppDbContext context, + ITenantContextAccessor tenantContextAccessor, ILogger logger) : IStoreSchedulerService { /// public async Task 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 AutoSwitchBusinessStatusSingleTenantAsync(DateTime now, CancellationToken cancellationToken) { // 1. 读取候选门店 var stores = await context.Stores @@ -129,6 +161,36 @@ public sealed class StoreSchedulerService( /// public async Task 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 CheckQualificationExpirySingleTenantAsync(DateTime now, CancellationToken cancellationToken) { // 1. 查询过期门店 var today = DateOnly.FromDateTime(now); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/BackgroundServices/AutoRenewalService.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/BackgroundServices/AutoRenewalService.cs index 9eb39db..bd0944e 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/BackgroundServices/AutoRenewalService.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/BackgroundServices/AutoRenewalService.cs @@ -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(); var idGenerator = scope.ServiceProvider.GetRequiredService(); + var tenantContextAccessor = scope.ServiceProvider.GetRequiredService(); 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) { diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/BackgroundServices/RenewalReminderService.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/BackgroundServices/RenewalReminderService.cs index ced3034..c587830 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/BackgroundServices/RenewalReminderService.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/BackgroundServices/RenewalReminderService.cs @@ -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(); var idGenerator = scope.ServiceProvider.GetRequiredService(); + var tenantContextAccessor = scope.ServiceProvider.GetRequiredService(); 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) { diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/BackgroundServices/SubscriptionExpiryCheckService.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/BackgroundServices/SubscriptionExpiryCheckService.cs index a743e66..3057243 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/BackgroundServices/SubscriptionExpiryCheckService.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/BackgroundServices/SubscriptionExpiryCheckService.cs @@ -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(); + var tenantContextAccessor = scope.ServiceProvider.GetRequiredService(); 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) { diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/AppDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/AppDbContext.cs index 1f06200..8511816 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/AppDbContext.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/AppDbContext.cs @@ -18,6 +18,26 @@ public abstract class AppDbContext( private readonly ICurrentUserAccessor? _currentUserAccessor = currentUserAccessor; private readonly IIdGenerator? _idGenerator = idGenerator; + /// + /// 是否禁用软删除过滤器。 + /// + /// + /// 仅允许在少数系统任务/恢复场景中临时关闭,默认应保持开启。 + /// + protected bool IsSoftDeleteFilterDisabled { get; private set; } + + /// + /// 临时禁用软删除过滤器(仅关闭软删除过滤,不影响租户过滤)。 + /// + /// 作用域对象,释放后恢复之前的过滤状态。 + public IDisposable DisableSoftDeleteFilter() + { + var previous = IsSoftDeleteFilterDisabled; + IsSoftDeleteFilterDisabled = true; + + return new SoftDeleteFilterScope(this, previous); + } + /// /// 构建模型时应用软删除过滤器。 /// @@ -179,7 +199,15 @@ public abstract class AppDbContext( private void SetSoftDeleteFilter(ModelBuilder modelBuilder) where TEntity : class, ISoftDeleteEntity { - modelBuilder.Entity().HasQueryFilter(entity => entity.DeletedAt == null); + QueryFilterCombiner.Combine(modelBuilder, "soft_delete", entity => IsSoftDeleteFilterDisabled || entity.DeletedAt == null); + } + + private sealed class SoftDeleteFilterScope(AppDbContext context, bool previous) : IDisposable + { + public void Dispose() + { + context.IsSoftDeleteFilterDisabled = previous; + } } /// diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/QueryFilterCombiner.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/QueryFilterCombiner.cs new file mode 100644 index 0000000..d9fabf7 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/QueryFilterCombiner.cs @@ -0,0 +1,23 @@ +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore; + +namespace TakeoutSaaS.Infrastructure.Common.Persistence; + +/// +/// 查询过滤器合并器:用于追加具名 QueryFilter,避免覆盖已有过滤器。 +/// +internal static class QueryFilterCombiner +{ + /// + /// 为指定实体追加具名查询过滤器。 + /// + /// 实体类型。 + /// 模型构建器。 + /// 过滤器键。 + /// 新增过滤器表达式。 + internal static void Combine(ModelBuilder modelBuilder, string filterKey, Expression> filter) + where TEntity : class + { + modelBuilder.Entity().HasQueryFilter(filterKey, filter); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/TenantAwareDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/TenantAwareDbContext.cs index e657c3c..af8e9f8 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/TenantAwareDbContext.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/TenantAwareDbContext.cs @@ -62,7 +62,7 @@ public abstract class TenantAwareDbContext( private void SetTenantFilter(ModelBuilder modelBuilder) where TEntity : class, IMultiTenantEntity { - modelBuilder.Entity().HasQueryFilter(entity => entity.TenantId == CurrentTenantId); + QueryFilterCombiner.Combine(modelBuilder, "tenant", entity => entity.TenantId == CurrentTenantId); } /// @@ -74,9 +74,30 @@ public abstract class TenantAwareDbContext( foreach (var entry in ChangeTracker.Entries()) { - 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("检测到跨租户写入,已阻止保存。"); } } } diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/DictionaryGroupRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/DictionaryGroupRepository.cs index a3abdc0..c73b37b 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/DictionaryGroupRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/DictionaryGroupRepository.cs @@ -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)); /// @@ -25,7 +24,7 @@ public sealed class DictionaryGroupRepository(DictionaryDbContext context) : IDi public Task 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) diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/DictionaryItemRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/DictionaryItemRepository.cs index 7c543ee..ed869bb 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/DictionaryItemRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/DictionaryItemRepository.cs @@ -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; /// /// 字典项仓储实现。 /// -public sealed class DictionaryItemRepository(DictionaryDbContext context) : IDictionaryItemRepository +public sealed class DictionaryItemRepository(DictionaryDbContext context, ITenantContextAccessor tenantContextAccessor) : IDictionaryItemRepository { private static readonly Func> GetByGroupQuery = EF.CompileQuery((DictionaryDbContext db, long tenantId, long groupId) => (IEnumerable)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 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 systemItems; + using (tenantContextAccessor.EnterTenantScope(0, "dictionary")) { - return Array.Empty(); + systemGroup = await context.DictionaryGroups + .AsNoTracking() + .FirstOrDefaultAsync(group => group.Id == systemGroupId && group.DeletedAt == null, cancellationToken); + if (systemGroup == null) + { + return Array.Empty(); + } + + systemItems = await context.DictionaryItems + .AsNoTracking() + .Where(item => item.GroupId == systemGroupId && item.DeletedAt == null) + .OrderBy(item => item.SortOrder) + .ToListAsync(cancellationToken); } - var result = new List(); - 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(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); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/DictionaryLabelOverrideRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/DictionaryLabelOverrideRepository.cs index eefccbe..8dc3b8f 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/DictionaryLabelOverrideRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/DictionaryLabelOverrideRepository.cs @@ -17,7 +17,6 @@ public sealed class DictionaryLabelOverrideRepository(DictionaryDbContext contex public Task 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 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) && diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/EfDictionaryRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/EfDictionaryRepository.cs index 50aec15..0d2d6d2 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/EfDictionaryRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/EfDictionaryRepository.cs @@ -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; /// /// EF Core 字典仓储实现。 /// -public sealed class EfDictionaryRepository(DictionaryDbContext context) : IDictionaryRepository +public sealed class EfDictionaryRepository(DictionaryDbContext context, ITenantContextAccessor tenantContextAccessor) : IDictionaryRepository { /// /// 根据分组 ID 查询分组。 @@ -163,19 +164,37 @@ public sealed class EfDictionaryRepository(DictionaryDbContext context) : IDicti return Array.Empty(); } - // 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 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]; } } diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/TenantDictionaryOverrideRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/TenantDictionaryOverrideRepository.cs index 84b7ebe..71aec4c 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/TenantDictionaryOverrideRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/TenantDictionaryOverrideRepository.cs @@ -16,7 +16,6 @@ public sealed class TenantDictionaryOverrideRepository(DictionaryDbContext conte public Task 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); } diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfIdentityUserRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfIdentityUserRepository.cs index c7718fa..dc05a67 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfIdentityUserRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfIdentityUserRepository.cs @@ -49,15 +49,14 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde /// 排除的用户 ID。 /// 取消标记。 /// 存在返回 true。 - public Task ExistsByAccountAsync(long tenantId, string account, long? excludeUserId = null, CancellationToken cancellationToken = default) + public async Task 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); } /// @@ -77,15 +76,14 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde /// 排除的用户 ID。 /// 取消标记。 /// 存在返回 true。 - public Task ExistsByPhoneAsync(long tenantId, string phone, long? excludeUserId = null, CancellationToken cancellationToken = default) + public async Task 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); } /// @@ -105,15 +103,14 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde /// 排除的用户 ID。 /// 取消标记。 /// 存在返回 true。 - public Task ExistsByEmailAsync(long tenantId, string email, long? excludeUserId = null, CancellationToken cancellationToken = default) + public async Task 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); } /// @@ -150,14 +147,14 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde /// 用户 ID。 /// 取消标记。 /// 后台用户或 null。 - public Task GetForUpdateIncludingDeletedAsync( + public async Task 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); } diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfPermissionRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfPermissionRepository.cs index e5e95bd..e150c3b 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfPermissionRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfPermissionRepository.cs @@ -18,9 +18,8 @@ public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermi /// 权限实体或 null。 public Task 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); /// /// 根据权限编码获取权限。 @@ -31,9 +30,8 @@ public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermi /// 权限实体或 null。 public Task 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); /// /// 根据权限编码集合批量获取权限。 @@ -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)t.Result, cancellationToken); } @@ -69,9 +66,8 @@ public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermi /// 权限列表。 public Task> GetByIdsAsync(long tenantId, IEnumerable 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)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. 删除实体 diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfRolePermissionRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfRolePermissionRepository.cs index 8edaa7b..7367c64 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfRolePermissionRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfRolePermissionRepository.cs @@ -16,13 +16,17 @@ public sealed class EfRolePermissionRepository(IdentityDbContext dbContext) : IR /// 角色 ID 集合。 /// 取消标记。 /// 角色权限映射列表。 - public Task> GetByRoleIdsAsync(long tenantId, IEnumerable roleIds, CancellationToken cancellationToken = default) - => dbContext.RolePermissions - .IgnoreQueryFilters() + public async Task> GetByRoleIdsAsync(long tenantId, IEnumerable 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)t.Result, cancellationToken); + .ToListAsync(cancellationToken); + + // 2. (空行后) 返回只读列表 + return mappings; + } /// /// 批量新增角色权限。 diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfRoleRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfRoleRepository.cs index 0916a90..16127ea 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfRoleRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfRoleRepository.cs @@ -18,7 +18,6 @@ public sealed class EfRoleRepository(IdentityDbContext dbContext) : IRoleReposit /// 角色实体或 null。 public Task 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 /// 角色实体或 null。 public Task 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 /// 角色 ID 集合。 /// 取消标记。 /// 角色列表。 - public Task> GetByIdsAsync(long tenantId, IEnumerable roleIds, CancellationToken cancellationToken = default) - => dbContext.Roles - .IgnoreQueryFilters() + public async Task> GetByIdsAsync(long tenantId, IEnumerable 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)t.Result, cancellationToken); + .ToListAsync(cancellationToken); + + // 2. (空行后) 返回只读列表 + return roles; + } /// /// 按关键字搜索角色。 @@ -57,11 +59,10 @@ public sealed class EfRoleRepository(IdentityDbContext dbContext) : IRoleReposit /// 搜索关键字。 /// 取消标记。 /// 角色列表。 - public Task> SearchAsync(long tenantId, string? keyword, CancellationToken cancellationToken = default) + public async Task> 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)t.Result, cancellationToken); + var roles = await query.ToListAsync(cancellationToken); + + // 4. (空行后) 返回只读列表 + return roles; } /// diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfUserRoleRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfUserRoleRepository.cs index 3bb62af..c5398c2 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfUserRoleRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfUserRoleRepository.cs @@ -16,13 +16,17 @@ public sealed class EfUserRoleRepository(IdentityDbContext dbContext) : IUserRol /// 用户 ID 集合。 /// 取消标记。 /// 用户角色映射列表。 - public Task> GetByUserIdsAsync(long tenantId, IEnumerable userIds, CancellationToken cancellationToken = default) - => dbContext.UserRoles - .IgnoreQueryFilters() + public async Task> GetByUserIdsAsync(long tenantId, IEnumerable 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)t.Result, cancellationToken); + .ToListAsync(cancellationToken); + + // 2. (空行后) 返回只读列表 + return mappings; + } /// /// 获取指定用户的角色集合。 @@ -31,13 +35,17 @@ public sealed class EfUserRoleRepository(IdentityDbContext dbContext) : IUserRol /// 用户 ID。 /// 取消标记。 /// 用户角色列表。 - public Task> GetByUserIdAsync(long tenantId, long userId, CancellationToken cancellationToken = default) - => dbContext.UserRoles - .IgnoreQueryFilters() + public async Task> 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)t.Result, cancellationToken); + .ToListAsync(cancellationToken); + + // 2. (空行后) 返回只读列表 + return mappings; + } /// /// 替换指定用户的角色集合。 @@ -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 /// 用户数量。 public Task 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); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDataSeeder.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDataSeeder.cs index ef0681f..cf2c903 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDataSeeder.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDataSeeder.cs @@ -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(); - } } diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Repositories/EfMenuRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Repositories/EfMenuRepository.cs index fcacf21..7f6ad99 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Repositories/EfMenuRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Repositories/EfMenuRepository.cs @@ -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; /// /// 菜单仓储 EF 实现。 /// -public sealed class EfMenuRepository(IdentityDbContext dbContext) : IMenuRepository +public sealed class EfMenuRepository(IdentityDbContext dbContext, ITenantContextAccessor tenantContextAccessor) : IMenuRepository { /// public async Task> 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; + } } /// @@ -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); + } } /// diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Logs/Persistence/TakeoutLogsDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Logs/Persistence/TakeoutLogsDbContext.cs index a34444d..570c019 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Logs/Persistence/TakeoutLogsDbContext.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Logs/Persistence/TakeoutLogsDbContext.cs @@ -63,6 +63,7 @@ public sealed class TakeoutLogsDbContext( ConfigureOperationLog(modelBuilder.Entity()); ConfigureOperationLogInboxMessage(modelBuilder.Entity()); ConfigureMemberGrowthLog(modelBuilder.Entity()); + ApplyTenantQueryFilters(modelBuilder); } private static void ConfigureTenantAuditLog(EntityTypeBuilder builder)