fix: 统一逐租户上下文执行
This commit is contained in:
Submodule TakeoutSaaS.BuildingBlocks updated: bcf0a6bd7d...5b07973a39
@@ -3,6 +3,7 @@ using TakeoutSaaS.Application.App.Billings.Commands;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
||||
|
||||
@@ -10,7 +11,8 @@ namespace TakeoutSaaS.Application.App.Billings.Handlers;
|
||||
/// 批量更新账单状态处理器。
|
||||
/// </summary>
|
||||
public sealed class BatchUpdateStatusCommandHandler(
|
||||
ITenantBillingRepository billingRepository)
|
||||
ITenantBillingRepository billingRepository,
|
||||
ITenantContextAccessor tenantContextAccessor)
|
||||
: IRequestHandler<BatchUpdateStatusCommand, int>
|
||||
{
|
||||
/// <summary>
|
||||
@@ -34,33 +36,53 @@ public sealed class BatchUpdateStatusCommandHandler(
|
||||
throw new BusinessException(ErrorCodes.NotFound, "未找到任何匹配的账单");
|
||||
}
|
||||
|
||||
// 3. 批量更新状态
|
||||
// 3. 批量更新状态(逐租户上下文执行,避免跨租户写入)
|
||||
var now = DateTime.UtcNow;
|
||||
var updatedCount = 0;
|
||||
foreach (var billing in billings)
|
||||
var currentTenantId = tenantContextAccessor.Current?.TenantId ?? 0;
|
||||
if (currentTenantId != 0 && billings.Any(x => x.TenantId != currentTenantId))
|
||||
{
|
||||
// 业务规则检查:某些状态转换可能不允许
|
||||
if (CanTransitionStatus(billing.Status, request.NewStatus))
|
||||
{
|
||||
billing.Status = request.NewStatus;
|
||||
billing.UpdatedAt = now;
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户批量更新账单状态");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Notes))
|
||||
var updatedTotal = 0;
|
||||
var grouped = billings.GroupBy(x => x.TenantId).ToList();
|
||||
foreach (var group in grouped)
|
||||
{
|
||||
using (currentTenantId == 0 ? tenantContextAccessor.EnterTenantScope(group.Key, "billing:batch-update") : null)
|
||||
{
|
||||
var updatedCount = 0;
|
||||
foreach (var billing in group)
|
||||
{
|
||||
billing.Notes = string.IsNullOrWhiteSpace(billing.Notes)
|
||||
? $"[批量操作] {request.Notes}"
|
||||
: $"{billing.Notes}\n[批量操作] {request.Notes}";
|
||||
// 业务规则检查:某些状态转换可能不允许
|
||||
if (!CanTransitionStatus(billing.Status, request.NewStatus))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
billing.Status = request.NewStatus;
|
||||
billing.UpdatedAt = now;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Notes))
|
||||
{
|
||||
billing.Notes = string.IsNullOrWhiteSpace(billing.Notes)
|
||||
? $"[批量操作] {request.Notes}"
|
||||
: $"{billing.Notes}\n[批量操作] {request.Notes}";
|
||||
}
|
||||
|
||||
await billingRepository.UpdateAsync(billing, cancellationToken);
|
||||
updatedCount++;
|
||||
}
|
||||
|
||||
await billingRepository.UpdateAsync(billing, cancellationToken);
|
||||
updatedCount++;
|
||||
if (updatedCount > 0)
|
||||
{
|
||||
await billingRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
updatedTotal += updatedCount;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 持久化变更
|
||||
await billingRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return updatedCount;
|
||||
return updatedTotal;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -9,6 +9,7 @@ using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Infrastructure.App.Options;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
|
||||
@@ -38,10 +39,11 @@ public sealed class AppDataSeeder(
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var appDbContext = scope.ServiceProvider.GetRequiredService<TakeoutAppDbContext>();
|
||||
var dictionaryDbContext = scope.ServiceProvider.GetRequiredService<DictionaryDbContext>();
|
||||
var tenantContextAccessor = scope.ServiceProvider.GetRequiredService<ITenantContextAccessor>();
|
||||
|
||||
await EnsureSystemTenantAsync(appDbContext, cancellationToken);
|
||||
var defaultTenantId = await EnsureDefaultTenantAsync(appDbContext, cancellationToken);
|
||||
await EnsureDictionarySeedsAsync(dictionaryDbContext, defaultTenantId, cancellationToken);
|
||||
await EnsureDictionarySeedsAsync(dictionaryDbContext, tenantContextAccessor, defaultTenantId, cancellationToken);
|
||||
|
||||
logger.LogInformation("AppSeed 完成业务数据初始化");
|
||||
}
|
||||
@@ -54,6 +56,7 @@ public sealed class AppDataSeeder(
|
||||
/// </summary>
|
||||
private async Task<long?> EnsureDefaultTenantAsync(TakeoutAppDbContext dbContext, CancellationToken cancellationToken)
|
||||
{
|
||||
using var _ = dbContext.DisableSoftDeleteFilter();
|
||||
var tenantOptions = _options.DefaultTenant;
|
||||
if (tenantOptions == null || string.IsNullOrWhiteSpace(tenantOptions.Code) || string.IsNullOrWhiteSpace(tenantOptions.Name))
|
||||
{
|
||||
@@ -63,7 +66,6 @@ public sealed class AppDataSeeder(
|
||||
|
||||
var code = tenantOptions.Code.Trim();
|
||||
var existingTenant = await dbContext.Tenants
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(x => x.Code == code, cancellationToken);
|
||||
|
||||
if (existingTenant == null)
|
||||
@@ -87,6 +89,13 @@ public sealed class AppDataSeeder(
|
||||
|
||||
var updated = false;
|
||||
|
||||
if (existingTenant.DeletedAt.HasValue)
|
||||
{
|
||||
existingTenant.DeletedAt = null;
|
||||
existingTenant.DeletedBy = null;
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if (!string.Equals(existingTenant.Name, tenantOptions.Name, StringComparison.Ordinal))
|
||||
{
|
||||
existingTenant.Name = tenantOptions.Name.Trim();
|
||||
@@ -136,14 +145,21 @@ public sealed class AppDataSeeder(
|
||||
/// </summary>
|
||||
private async Task EnsureSystemTenantAsync(TakeoutAppDbContext dbContext, CancellationToken cancellationToken)
|
||||
{
|
||||
using var _ = dbContext.DisableSoftDeleteFilter();
|
||||
var existingTenant = await dbContext.Tenants
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(x => x.Id == 0, cancellationToken);
|
||||
|
||||
if (existingTenant != null)
|
||||
{
|
||||
// 1. (空行后) 若历史数据仍为 PLATFORM,则自动修正为 SYSTEM
|
||||
var updated = false;
|
||||
|
||||
if (existingTenant.DeletedAt.HasValue)
|
||||
{
|
||||
existingTenant.DeletedAt = null;
|
||||
existingTenant.DeletedBy = null;
|
||||
updated = true;
|
||||
}
|
||||
if (!string.Equals(existingTenant.Code, "SYSTEM", StringComparison.Ordinal))
|
||||
{
|
||||
existingTenant.Code = "SYSTEM";
|
||||
@@ -188,7 +204,11 @@ public sealed class AppDataSeeder(
|
||||
/// <summary>
|
||||
/// 确保基础字典存在。
|
||||
/// </summary>
|
||||
private async Task EnsureDictionarySeedsAsync(DictionaryDbContext dbContext, long? defaultTenantId, CancellationToken cancellationToken)
|
||||
private async Task EnsureDictionarySeedsAsync(
|
||||
DictionaryDbContext dbContext,
|
||||
ITenantContextAccessor tenantContextAccessor,
|
||||
long? defaultTenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var dictionaryGroups = _options.DictionaryGroups ?? new List<DictionarySeedGroupOptions>();
|
||||
var hasDictionaryGroups = dictionaryGroups.Count > 0;
|
||||
@@ -211,9 +231,10 @@ public sealed class AppDataSeeder(
|
||||
var tenantId = groupOptions.TenantId ?? defaultTenantId ?? 0;
|
||||
var code = groupOptions.Code.Trim();
|
||||
|
||||
using var tenantScope = tenantContextAccessor.EnterTenantScope(tenantId, "app-seed");
|
||||
using var disableSoftDeleteScope = dbContext.DisableSoftDeleteFilter();
|
||||
var group = await dbContext.DictionaryGroups
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Code == code, cancellationToken);
|
||||
.FirstOrDefaultAsync(x => x.Code == code, cancellationToken);
|
||||
|
||||
if (group == null)
|
||||
{
|
||||
@@ -235,6 +256,13 @@ public sealed class AppDataSeeder(
|
||||
{
|
||||
var groupUpdated = false;
|
||||
|
||||
if (group.DeletedAt.HasValue)
|
||||
{
|
||||
group.DeletedAt = null;
|
||||
group.DeletedBy = null;
|
||||
groupUpdated = true;
|
||||
}
|
||||
|
||||
if (!string.Equals(group.Name, groupOptions.Name, StringComparison.Ordinal))
|
||||
{
|
||||
group.Name = groupOptions.Name.Trim();
|
||||
@@ -269,7 +297,7 @@ public sealed class AppDataSeeder(
|
||||
}
|
||||
}
|
||||
|
||||
await EnsureSystemParametersAsync(dbContext, defaultTenantId, cancellationToken);
|
||||
await EnsureSystemParametersAsync(dbContext, tenantContextAccessor, defaultTenantId, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
@@ -277,7 +305,11 @@ public sealed class AppDataSeeder(
|
||||
/// <summary>
|
||||
/// 确保系统参数以独立表形式可重复种子。
|
||||
/// </summary>
|
||||
private async Task EnsureSystemParametersAsync(DictionaryDbContext dbContext, long? defaultTenantId, CancellationToken cancellationToken)
|
||||
private async Task EnsureSystemParametersAsync(
|
||||
DictionaryDbContext dbContext,
|
||||
ITenantContextAccessor tenantContextAccessor,
|
||||
long? defaultTenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var systemParameters = _options.SystemParameters ?? new List<SystemParameterSeedOptions>();
|
||||
|
||||
@@ -300,9 +332,9 @@ public sealed class AppDataSeeder(
|
||||
foreach (var group in grouped)
|
||||
{
|
||||
var tenantId = group.Key;
|
||||
using var tenantScope = tenantContextAccessor.EnterTenantScope(tenantId, "app-seed");
|
||||
using var disableSoftDeleteScope = dbContext.DisableSoftDeleteFilter();
|
||||
var existingParameters = await dbContext.SystemParameters
|
||||
.IgnoreQueryFilters()
|
||||
.Where(x => x.TenantId == tenantId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var seed in group)
|
||||
@@ -329,6 +361,13 @@ public sealed class AppDataSeeder(
|
||||
|
||||
var updated = false;
|
||||
|
||||
if (existing.DeletedAt.HasValue)
|
||||
{
|
||||
existing.DeletedAt = null;
|
||||
existing.DeletedBy = null;
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if (!string.Equals(existing.Value, seed.Value, StringComparison.Ordinal))
|
||||
{
|
||||
existing.Value = seed.Value.Trim();
|
||||
@@ -387,7 +426,6 @@ public sealed class AppDataSeeder(
|
||||
}
|
||||
|
||||
var existingItems = await dbContext.DictionaryItems
|
||||
.IgnoreQueryFilters()
|
||||
.Where(x => x.GroupId == group.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
@@ -416,6 +454,13 @@ public sealed class AppDataSeeder(
|
||||
|
||||
var updated = false;
|
||||
|
||||
if (existing.DeletedAt.HasValue)
|
||||
{
|
||||
existing.DeletedAt = null;
|
||||
existing.DeletedBy = null;
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if (!string.Equals(existing.Value, seed.Value, StringComparison.Ordinal))
|
||||
{
|
||||
existing.Value = seed.Value.Trim();
|
||||
|
||||
@@ -3,14 +3,25 @@ using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 租户账单仓储实现(EF Core)。
|
||||
/// </summary>
|
||||
public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITenantBillingRepository
|
||||
public sealed class TenantBillingRepository(TakeoutAppDbContext context, ITenantContextAccessor tenantContextAccessor) : ITenantBillingRepository
|
||||
{
|
||||
private long GetCurrentTenantId()
|
||||
=> tenantContextAccessor.Current?.TenantId ?? 0;
|
||||
|
||||
private Task<List<long>> GetActiveTenantIdsAsync(CancellationToken cancellationToken)
|
||||
=> context.Tenants
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && x.Id > 0)
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TenantBillingStatement>> SearchAsync(
|
||||
long tenantId,
|
||||
@@ -19,9 +30,8 @@ public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITena
|
||||
DateTime? to,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 构建基础查询:忽略全局过滤器,显式过滤租户与软删除
|
||||
// 1. 构建基础查询:在当前租户上下文内查询
|
||||
var query = context.TenantBillingStatements
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && x.TenantId == tenantId);
|
||||
|
||||
@@ -52,7 +62,6 @@ public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITena
|
||||
public Task<TenantBillingStatement?> FindByIdAsync(long tenantId, long billingId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantBillingStatements
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.TenantId == tenantId && x.Id == billingId, cancellationToken);
|
||||
}
|
||||
@@ -63,7 +72,6 @@ public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITena
|
||||
var normalized = statementNo.Trim();
|
||||
|
||||
return context.TenantBillingStatements
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.TenantId == tenantId && x.StatementNo == normalized, cancellationToken);
|
||||
}
|
||||
@@ -73,10 +81,11 @@ public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITena
|
||||
{
|
||||
var normalized = statementNo.Trim();
|
||||
|
||||
return context.TenantBillingStatements
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.StatementNo == normalized, cancellationToken);
|
||||
return GetCurrentTenantId() == 0
|
||||
? GetByStatementNoCrossTenantAsync(normalized, cancellationToken)
|
||||
: context.TenantBillingStatements
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.StatementNo == normalized, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -86,7 +95,6 @@ public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITena
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantBillingStatements
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.AnyAsync(
|
||||
x => x.DeletedAt == null
|
||||
@@ -101,16 +109,45 @@ public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITena
|
||||
{
|
||||
// 1. 以当前 UTC 时间作为逾期判断基准
|
||||
var now = DateTime.UtcNow;
|
||||
var currentTenantId = GetCurrentTenantId();
|
||||
if (currentTenantId != 0)
|
||||
{
|
||||
// 2. (空行后) 当前租户:仅查询本租户逾期账单
|
||||
return await context.TenantBillingStatements
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null
|
||||
&& x.DueDate < now
|
||||
&& x.Status == TenantBillingStatus.Pending)
|
||||
.OrderBy(x => x.DueDate)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
// 2. 查询逾期且仍处于待支付的账单(仅 Pending 才允许自动切换为 Overdue)
|
||||
return await context.TenantBillingStatements
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null
|
||||
&& x.DueDate < now
|
||||
&& x.Status == TenantBillingStatus.Pending)
|
||||
// 2. (空行后) 系统上下文:逐租户查询逾期账单并合并
|
||||
var tenantIds = await GetActiveTenantIdsAsync(cancellationToken);
|
||||
if (tenantIds.Count == 0)
|
||||
{
|
||||
return Array.Empty<TenantBillingStatement>();
|
||||
}
|
||||
|
||||
var results = new List<TenantBillingStatement>();
|
||||
foreach (var tenantId in tenantIds)
|
||||
{
|
||||
using (tenantContextAccessor.EnterTenantScope(tenantId, "billing"))
|
||||
{
|
||||
var items = await context.TenantBillingStatements
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null
|
||||
&& x.DueDate < now
|
||||
&& x.Status == TenantBillingStatus.Pending)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
results.AddRange(items);
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
.OrderBy(x => x.DueDate)
|
||||
.ToListAsync(cancellationToken);
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -119,24 +156,53 @@ public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITena
|
||||
// 1. 计算到期窗口
|
||||
var now = DateTime.UtcNow;
|
||||
var dueTo = now.AddDays(daysAhead);
|
||||
var currentTenantId = GetCurrentTenantId();
|
||||
if (currentTenantId != 0)
|
||||
{
|
||||
// 2. (空行后) 当前租户:仅查询本租户即将到期账单
|
||||
return await context.TenantBillingStatements
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null
|
||||
&& x.Status == TenantBillingStatus.Pending
|
||||
&& x.DueDate >= now
|
||||
&& x.DueDate <= dueTo)
|
||||
.OrderBy(x => x.DueDate)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
// 2. 仅查询待支付账单
|
||||
return await context.TenantBillingStatements
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null
|
||||
&& x.Status == TenantBillingStatus.Pending
|
||||
&& x.DueDate >= now
|
||||
&& x.DueDate <= dueTo)
|
||||
// 2. (空行后) 系统上下文:逐租户查询即将到期账单并合并
|
||||
var tenantIds = await GetActiveTenantIdsAsync(cancellationToken);
|
||||
if (tenantIds.Count == 0)
|
||||
{
|
||||
return Array.Empty<TenantBillingStatement>();
|
||||
}
|
||||
|
||||
var results = new List<TenantBillingStatement>();
|
||||
foreach (var tenantId in tenantIds)
|
||||
{
|
||||
using (tenantContextAccessor.EnterTenantScope(tenantId, "billing"))
|
||||
{
|
||||
var items = await context.TenantBillingStatements
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null
|
||||
&& x.Status == TenantBillingStatus.Pending
|
||||
&& x.DueDate >= now
|
||||
&& x.DueDate <= dueTo)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
results.AddRange(items);
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
.OrderBy(x => x.DueDate)
|
||||
.ToListAsync(cancellationToken);
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TenantBillingStatement>> GetByTenantIdAsync(long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await context.TenantBillingStatements
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && x.TenantId == tenantId)
|
||||
.OrderByDescending(x => x.PeriodEnd)
|
||||
@@ -151,13 +217,41 @@ public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITena
|
||||
return Array.Empty<TenantBillingStatement>();
|
||||
}
|
||||
|
||||
// 1. 忽略全局过滤器以支持系统任务跨租户导出/批量操作
|
||||
return await context.TenantBillingStatements
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && billingIds.Contains(x.Id))
|
||||
// 1. 系统上下文:逐租户查找匹配账单;租户上下文:仅返回本租户账单
|
||||
var ids = billingIds.Distinct().ToArray();
|
||||
var currentTenantId = GetCurrentTenantId();
|
||||
if (currentTenantId != 0)
|
||||
{
|
||||
return await context.TenantBillingStatements
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && ids.Contains(x.Id))
|
||||
.OrderByDescending(x => x.PeriodStart)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
var tenantIds = await GetActiveTenantIdsAsync(cancellationToken);
|
||||
if (tenantIds.Count == 0)
|
||||
{
|
||||
return Array.Empty<TenantBillingStatement>();
|
||||
}
|
||||
|
||||
var results = new List<TenantBillingStatement>();
|
||||
foreach (var tenantId in tenantIds)
|
||||
{
|
||||
using (tenantContextAccessor.EnterTenantScope(tenantId, "billing"))
|
||||
{
|
||||
var items = await context.TenantBillingStatements
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && ids.Contains(x.Id))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
results.AddRange(items);
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
.OrderByDescending(x => x.PeriodStart)
|
||||
.ToListAsync(cancellationToken);
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -192,74 +286,100 @@ public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITena
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 构建基础查询(系统任务跨租户查询,忽略过滤器)
|
||||
var query = context.TenantBillingStatements
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null);
|
||||
var normalizedPageNumber = pageNumber <= 0 ? 1 : pageNumber;
|
||||
var normalizedPageSize = pageSize <= 0 ? 20 : pageSize;
|
||||
var skip = (normalizedPageNumber - 1) * normalizedPageSize;
|
||||
var takePerTenant = normalizedPageNumber * normalizedPageSize;
|
||||
|
||||
// 2. 按租户过滤(可选)
|
||||
var currentTenantId = GetCurrentTenantId();
|
||||
if (currentTenantId != 0)
|
||||
{
|
||||
// 1. 当前租户:仅查询本租户数据(tenantId 为空则默认当前租户)
|
||||
var effectiveTenantId = tenantId ?? currentTenantId;
|
||||
var query = BuildTenantQuery(effectiveTenantId, status, from, to, minAmount, maxAmount, keyword);
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
var items = await query
|
||||
.OrderByDescending(x => x.PeriodEnd)
|
||||
.Skip(skip)
|
||||
.Take(normalizedPageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return (items, total);
|
||||
}
|
||||
|
||||
// 2. (空行后) 系统上下文:可按指定 tenantId 查询;tenantId 为空则跨租户聚合分页
|
||||
if (tenantId.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.TenantId == tenantId.Value);
|
||||
using (tenantContextAccessor.EnterTenantScope(tenantId.Value, "billing"))
|
||||
{
|
||||
var query = BuildTenantQuery(tenantId.Value, status, from, to, minAmount, maxAmount, keyword);
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
var items = await query
|
||||
.OrderByDescending(x => x.PeriodEnd)
|
||||
.Skip(skip)
|
||||
.Take(normalizedPageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return (items, total);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 按状态过滤(可选)
|
||||
if (status.HasValue)
|
||||
// 3. (空行后) 跨租户分页:逐租户取 Top(N) 后合并排序再分页
|
||||
var normalizedKeyword = string.IsNullOrWhiteSpace(keyword) ? null : keyword.Trim();
|
||||
var tenantIds = await GetActiveTenantIdsAsync(cancellationToken);
|
||||
if (tenantIds.Count == 0)
|
||||
{
|
||||
query = query.Where(x => x.Status == status.Value);
|
||||
return ([], 0);
|
||||
}
|
||||
|
||||
// 4. 按日期范围过滤(账单周期)
|
||||
if (from.HasValue)
|
||||
HashSet<long>? keywordMatchedTenantIds = null;
|
||||
if (!string.IsNullOrWhiteSpace(normalizedKeyword))
|
||||
{
|
||||
query = query.Where(x => x.PeriodStart >= from.Value);
|
||||
var matched = await context.Tenants
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && EF.Functions.ILike(x.Name, $"%{normalizedKeyword}%"))
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
keywordMatchedTenantIds = matched.Count == 0 ? null : matched.ToHashSet();
|
||||
}
|
||||
|
||||
if (to.HasValue)
|
||||
var totalCount = 0;
|
||||
var collected = new List<TenantBillingStatement>();
|
||||
foreach (var tid in tenantIds)
|
||||
{
|
||||
query = query.Where(x => x.PeriodEnd <= to.Value);
|
||||
using (tenantContextAccessor.EnterTenantScope(tid, "billing"))
|
||||
{
|
||||
var tenantQuery = BuildTenantQuery(tid, status, from, to, minAmount, maxAmount, keyword: null);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(normalizedKeyword))
|
||||
{
|
||||
// 3.1 租户名命中时视为匹配全部账单,否则按账单号筛选
|
||||
var tenantNameMatched = keywordMatchedTenantIds is not null && keywordMatchedTenantIds.Contains(tid);
|
||||
if (!tenantNameMatched)
|
||||
{
|
||||
tenantQuery = tenantQuery.Where(x => EF.Functions.ILike(x.StatementNo, $"%{normalizedKeyword}%"));
|
||||
}
|
||||
}
|
||||
|
||||
totalCount += await tenantQuery.CountAsync(cancellationToken);
|
||||
|
||||
var topItems = await tenantQuery
|
||||
.OrderByDescending(x => x.PeriodEnd)
|
||||
.Take(takePerTenant)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
collected.AddRange(topItems);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 按金额范围过滤(应付金额,包含边界)
|
||||
if (minAmount.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.AmountDue >= minAmount.Value);
|
||||
}
|
||||
|
||||
if (maxAmount.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.AmountDue <= maxAmount.Value);
|
||||
}
|
||||
|
||||
// 6. 关键字过滤(账单号或租户名)
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
var normalized = keyword.Trim();
|
||||
|
||||
query =
|
||||
from b in query
|
||||
join t in context.Tenants
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null)
|
||||
on b.TenantId equals t.Id
|
||||
where EF.Functions.ILike(b.StatementNo, $"%{normalized}%")
|
||||
|| EF.Functions.ILike(t.Name, $"%{normalized}%")
|
||||
select b;
|
||||
}
|
||||
|
||||
// 7. 统计总数
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
|
||||
// 8. 分页查询
|
||||
var items = await query
|
||||
var pageItems = collected
|
||||
.OrderByDescending(x => x.PeriodEnd)
|
||||
.Skip((pageNumber - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
.Skip(skip)
|
||||
.Take(normalizedPageSize)
|
||||
.ToList();
|
||||
|
||||
return (items, total);
|
||||
return (pageItems, totalCount);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -270,45 +390,90 @@ public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITena
|
||||
string groupBy,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 构建基础查询(忽略过滤器,显式过滤软删除/租户/时间范围)
|
||||
var query = context.TenantBillingStatements
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null
|
||||
&& (!tenantId.HasValue || x.TenantId == tenantId.Value)
|
||||
&& x.PeriodStart >= startDate
|
||||
&& x.PeriodEnd <= endDate);
|
||||
|
||||
// 2. 聚合统计(金额统一使用:应付 - 折扣 + 税费)
|
||||
// 1. 统一时间基准与分组方式
|
||||
var now = DateTime.UtcNow;
|
||||
var totalAmount = await query.SumAsync(x => x.AmountDue - x.DiscountAmount + x.TaxAmount, cancellationToken);
|
||||
var paidAmount = await query.Where(x => x.Status == TenantBillingStatus.Paid).SumAsync(x => x.AmountPaid, cancellationToken);
|
||||
var unpaidAmount = await query.SumAsync(x => (x.AmountDue - x.DiscountAmount + x.TaxAmount) - x.AmountPaid, cancellationToken);
|
||||
var overdueAmount = await query
|
||||
.Where(x => (x.Status == TenantBillingStatus.Pending || x.Status == TenantBillingStatus.Overdue) && x.DueDate < now)
|
||||
.SumAsync(x => (x.AmountDue - x.DiscountAmount + x.TaxAmount) - x.AmountPaid, cancellationToken);
|
||||
|
||||
// 3. 数量统计
|
||||
var totalCount = await query.CountAsync(cancellationToken);
|
||||
var paidCount = await query.CountAsync(x => x.Status == TenantBillingStatus.Paid, cancellationToken);
|
||||
var unpaidCount = await query.CountAsync(x => x.Status == TenantBillingStatus.Pending || x.Status == TenantBillingStatus.Overdue, cancellationToken);
|
||||
var overdueCount = await query.CountAsync(x => (x.Status == TenantBillingStatus.Pending || x.Status == TenantBillingStatus.Overdue) && x.DueDate < now, cancellationToken);
|
||||
|
||||
// 4. 趋势统计
|
||||
var normalizedGroupBy = NormalizeGroupBy(groupBy);
|
||||
var trendRaw = await query
|
||||
.Select(x => new
|
||||
{
|
||||
x.PeriodStart,
|
||||
x.AmountDue,
|
||||
x.DiscountAmount,
|
||||
x.TaxAmount,
|
||||
x.AmountPaid
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
var currentTenantId = GetCurrentTenantId();
|
||||
|
||||
// 4.1 在内存中按 Day/Week/Month 聚合(避免依赖特定数据库函数扩展)
|
||||
var trend = trendRaw
|
||||
// 2. (空行后) 构造待统计租户列表
|
||||
List<long> targetTenantIds;
|
||||
if (currentTenantId != 0)
|
||||
{
|
||||
targetTenantIds = [tenantId ?? currentTenantId];
|
||||
}
|
||||
else if (tenantId.HasValue)
|
||||
{
|
||||
targetTenantIds = [tenantId.Value];
|
||||
}
|
||||
else
|
||||
{
|
||||
targetTenantIds = await GetActiveTenantIdsAsync(cancellationToken);
|
||||
}
|
||||
|
||||
if (targetTenantIds.Count == 0)
|
||||
{
|
||||
return new TenantBillingStatistics();
|
||||
}
|
||||
|
||||
// 3. (空行后) 拉取统计字段(逐租户上下文执行)
|
||||
var rows = new List<BillingStatisticsRow>();
|
||||
foreach (var tid in targetTenantIds)
|
||||
{
|
||||
if (currentTenantId == 0)
|
||||
{
|
||||
using (tenantContextAccessor.EnterTenantScope(tid, "billing"))
|
||||
{
|
||||
var tenantRows = await BuildStatisticsQuery(startDate, endDate)
|
||||
.Select(x => new BillingStatisticsRow
|
||||
{
|
||||
PeriodStart = x.PeriodStart,
|
||||
AmountDue = x.AmountDue,
|
||||
DiscountAmount = x.DiscountAmount,
|
||||
TaxAmount = x.TaxAmount,
|
||||
AmountPaid = x.AmountPaid,
|
||||
Status = x.Status,
|
||||
DueDate = x.DueDate
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
rows.AddRange(tenantRows);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
var tenantRowsDirect = await BuildStatisticsQuery(startDate, endDate)
|
||||
.Where(x => x.TenantId == tid)
|
||||
.Select(x => new BillingStatisticsRow
|
||||
{
|
||||
PeriodStart = x.PeriodStart,
|
||||
AmountDue = x.AmountDue,
|
||||
DiscountAmount = x.DiscountAmount,
|
||||
TaxAmount = x.TaxAmount,
|
||||
AmountPaid = x.AmountPaid,
|
||||
Status = x.Status,
|
||||
DueDate = x.DueDate
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
rows.AddRange(tenantRowsDirect);
|
||||
}
|
||||
|
||||
// 4. (空行后) 汇总统计
|
||||
var totalAmount = rows.Sum(x => x.AmountDue - x.DiscountAmount + x.TaxAmount);
|
||||
var paidAmount = rows.Where(x => x.Status == TenantBillingStatus.Paid).Sum(x => x.AmountPaid);
|
||||
var unpaidAmount = rows.Sum(x => (x.AmountDue - x.DiscountAmount + x.TaxAmount) - x.AmountPaid);
|
||||
var overdueAmount = rows
|
||||
.Where(x => (x.Status == TenantBillingStatus.Pending || x.Status == TenantBillingStatus.Overdue) && x.DueDate < now)
|
||||
.Sum(x => (x.AmountDue - x.DiscountAmount + x.TaxAmount) - x.AmountPaid);
|
||||
|
||||
var totalCount = rows.Count;
|
||||
var paidCount = rows.Count(x => x.Status == TenantBillingStatus.Paid);
|
||||
var unpaidCount = rows.Count(x => x.Status == TenantBillingStatus.Pending || x.Status == TenantBillingStatus.Overdue);
|
||||
var overdueCount = rows.Count(x => (x.Status == TenantBillingStatus.Pending || x.Status == TenantBillingStatus.Overdue) && x.DueDate < now);
|
||||
|
||||
// 5. (空行后) 趋势统计
|
||||
var trend = rows
|
||||
.GroupBy(x => GetTrendBucket(x.PeriodStart, normalizedGroupBy))
|
||||
.Select(g => new TenantBillingTrendDataPoint
|
||||
{
|
||||
@@ -337,10 +502,122 @@ public sealed class TenantBillingRepository(TakeoutAppDbContext context) : ITena
|
||||
/// <inheritdoc />
|
||||
public Task<TenantBillingStatement?> FindByIdAsync(long billingId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantBillingStatements
|
||||
.IgnoreQueryFilters()
|
||||
return GetCurrentTenantId() == 0
|
||||
? FindByIdCrossTenantAsync(billingId, cancellationToken)
|
||||
: context.TenantBillingStatements
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.Id == billingId, cancellationToken);
|
||||
}
|
||||
|
||||
private IQueryable<TenantBillingStatement> BuildTenantQuery(
|
||||
long tenantId,
|
||||
TenantBillingStatus? status,
|
||||
DateTime? from,
|
||||
DateTime? to,
|
||||
decimal? minAmount,
|
||||
decimal? maxAmount,
|
||||
string? keyword)
|
||||
{
|
||||
var query = context.TenantBillingStatements
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.Id == billingId, cancellationToken);
|
||||
.Where(x => x.DeletedAt == null && x.TenantId == tenantId);
|
||||
|
||||
if (status.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.Status == status.Value);
|
||||
}
|
||||
|
||||
if (from.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.PeriodStart >= from.Value);
|
||||
}
|
||||
|
||||
if (to.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.PeriodEnd <= to.Value);
|
||||
}
|
||||
|
||||
if (minAmount.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.AmountDue >= minAmount.Value);
|
||||
}
|
||||
|
||||
if (maxAmount.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.AmountDue <= maxAmount.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
var normalized = keyword.Trim();
|
||||
query = query.Where(x => EF.Functions.ILike(x.StatementNo, $"%{normalized}%"));
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
private IQueryable<TenantBillingStatement> BuildStatisticsQuery(DateTime startDate, DateTime endDate)
|
||||
=> context.TenantBillingStatements
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null
|
||||
&& x.PeriodStart >= startDate
|
||||
&& x.PeriodEnd <= endDate);
|
||||
|
||||
private async Task<TenantBillingStatement?> FindByIdCrossTenantAsync(long billingId, CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantIds = await GetActiveTenantIdsAsync(cancellationToken);
|
||||
foreach (var tenantId in tenantIds)
|
||||
{
|
||||
using (tenantContextAccessor.EnterTenantScope(tenantId, "billing"))
|
||||
{
|
||||
var billing = await context.TenantBillingStatements
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.Id == billingId, cancellationToken);
|
||||
if (billing != null)
|
||||
{
|
||||
return billing;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<TenantBillingStatement?> GetByStatementNoCrossTenantAsync(string statementNo, CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantIds = await GetActiveTenantIdsAsync(cancellationToken);
|
||||
foreach (var tenantId in tenantIds)
|
||||
{
|
||||
using (tenantContextAccessor.EnterTenantScope(tenantId, "billing"))
|
||||
{
|
||||
var billing = await context.TenantBillingStatements
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.StatementNo == statementNo, cancellationToken);
|
||||
if (billing != null)
|
||||
{
|
||||
return billing;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private sealed record BillingStatisticsRow
|
||||
{
|
||||
public required DateTime PeriodStart { get; init; }
|
||||
|
||||
public required decimal AmountDue { get; init; }
|
||||
|
||||
public required decimal DiscountAmount { get; init; }
|
||||
|
||||
public required decimal TaxAmount { get; init; }
|
||||
|
||||
public required decimal AmountPaid { get; init; }
|
||||
|
||||
public required TenantBillingStatus Status { get; init; }
|
||||
|
||||
public required DateTime DueDate { get; init; }
|
||||
}
|
||||
|
||||
private static string NormalizeGroupBy(string groupBy)
|
||||
|
||||
@@ -15,7 +15,6 @@ public sealed class TenantPaymentRepository(TakeoutAppDbContext context) : ITena
|
||||
public async Task<IReadOnlyList<TenantPayment>> GetByBillingIdAsync(long billingStatementId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await context.TenantPayments
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && x.BillingStatementId == billingStatementId)
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
@@ -27,7 +26,6 @@ public sealed class TenantPaymentRepository(TakeoutAppDbContext context) : ITena
|
||||
{
|
||||
// 1. 仅统计支付成功的记录
|
||||
return await context.TenantPayments
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null
|
||||
&& x.BillingStatementId == billingStatementId
|
||||
@@ -39,7 +37,6 @@ public sealed class TenantPaymentRepository(TakeoutAppDbContext context) : ITena
|
||||
public Task<TenantPayment?> FindByIdAsync(long paymentId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantPayments
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.Id == paymentId, cancellationToken);
|
||||
}
|
||||
@@ -50,7 +47,6 @@ public sealed class TenantPaymentRepository(TakeoutAppDbContext context) : ITena
|
||||
var normalized = transactionNo.Trim();
|
||||
|
||||
return context.TenantPayments
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.TransactionNo == normalized, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -209,7 +209,6 @@ public sealed class EfMerchantRepository(TakeoutAppDbContext context, TakeoutLog
|
||||
public async Task<IReadOnlyList<MerchantAuditLog>> GetAuditLogsAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await logsContext.MerchantAuditLogs
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.MerchantId == merchantId)
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
@@ -266,7 +265,6 @@ public sealed class EfMerchantRepository(TakeoutAppDbContext context, TakeoutLog
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = logsContext.MerchantChangeLogs
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.MerchantId == merchantId);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -206,7 +206,6 @@ public sealed class EfTenantRepository(TakeoutAppDbContext context, TakeoutLogsD
|
||||
public Task<TenantVerificationProfile?> GetVerificationProfileAsync(long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantVerificationProfiles
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.TenantId == tenantId, cancellationToken);
|
||||
}
|
||||
@@ -224,7 +223,6 @@ public sealed class EfTenantRepository(TakeoutAppDbContext context, TakeoutLogsD
|
||||
|
||||
// 2. 批量查询实名资料
|
||||
return await context.TenantVerificationProfiles
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && tenantIds.Contains(x.TenantId))
|
||||
.ToListAsync(cancellationToken);
|
||||
@@ -235,7 +233,6 @@ public sealed class EfTenantRepository(TakeoutAppDbContext context, TakeoutLogsD
|
||||
{
|
||||
// 1. 查询现有实名资料
|
||||
var existing = await context.TenantVerificationProfiles
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.TenantId == profile.TenantId, cancellationToken);
|
||||
|
||||
if (existing == null)
|
||||
@@ -254,7 +251,6 @@ public sealed class EfTenantRepository(TakeoutAppDbContext context, TakeoutLogsD
|
||||
public Task<TenantSubscription?> GetActiveSubscriptionAsync(long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantSubscriptions
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && x.TenantId == tenantId)
|
||||
.OrderByDescending(x => x.EffectiveTo)
|
||||
@@ -274,7 +270,6 @@ public sealed class EfTenantRepository(TakeoutAppDbContext context, TakeoutLogsD
|
||||
|
||||
// 2. 批量查询订阅数据
|
||||
return await context.TenantSubscriptions
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && tenantIds.Contains(x.TenantId))
|
||||
.OrderByDescending(x => x.EffectiveTo)
|
||||
@@ -285,7 +280,6 @@ public sealed class EfTenantRepository(TakeoutAppDbContext context, TakeoutLogsD
|
||||
public Task<TenantSubscription?> FindSubscriptionByIdAsync(long tenantId, long subscriptionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantSubscriptions
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(
|
||||
x => x.DeletedAt == null && x.TenantId == tenantId && x.Id == subscriptionId,
|
||||
cancellationToken);
|
||||
@@ -314,7 +308,6 @@ public sealed class EfTenantRepository(TakeoutAppDbContext context, TakeoutLogsD
|
||||
public async Task<IReadOnlyList<TenantSubscriptionHistory>> GetSubscriptionHistoryAsync(long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await context.TenantSubscriptionHistories
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && x.TenantId == tenantId)
|
||||
.OrderByDescending(x => x.EffectiveFrom)
|
||||
@@ -331,7 +324,6 @@ public sealed class EfTenantRepository(TakeoutAppDbContext context, TakeoutLogsD
|
||||
public async Task<IReadOnlyList<TenantAuditLog>> GetAuditLogsAsync(long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await logsContext.TenantAuditLogs
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && x.TenantId == tenantId)
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
|
||||
@@ -5,6 +5,7 @@ using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Domain.Tenants.Services;
|
||||
using TakeoutSaaS.Shared.Abstractions.Ids;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Services;
|
||||
|
||||
@@ -13,6 +14,8 @@ namespace TakeoutSaaS.Infrastructure.App.Services;
|
||||
/// </summary>
|
||||
public sealed class BillingDomainService(
|
||||
ITenantBillingRepository billingRepository,
|
||||
ITenantRepository tenantRepository,
|
||||
ITenantContextAccessor tenantContextAccessor,
|
||||
ITenantPackageRepository tenantPackageRepository,
|
||||
IIdGenerator idGenerator) : IBillingDomainService
|
||||
{
|
||||
@@ -147,15 +150,43 @@ public sealed class BillingDomainService(
|
||||
/// <inheritdoc />
|
||||
public async Task<int> ProcessOverdueBillingsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 查询当前已超过到期日且仍处于待支付的账单(由仓储按 DueDate + Status 筛选)
|
||||
var processedAt = DateTime.UtcNow;
|
||||
var currentTenantId = tenantContextAccessor.Current?.TenantId ?? 0;
|
||||
if (currentTenantId != 0)
|
||||
{
|
||||
return await ProcessOverdueBillingsSingleTenantAsync(processedAt, cancellationToken);
|
||||
}
|
||||
|
||||
// 1. (空行后) 系统上下文:逐租户处理,避免跨租户写入
|
||||
var tenants = await tenantRepository.SearchAsync(null, null, cancellationToken);
|
||||
var targets = tenants.Where(x => x.Id > 0).ToList();
|
||||
if (targets.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var totalUpdated = 0;
|
||||
foreach (var tenant in targets)
|
||||
{
|
||||
using (tenantContextAccessor.EnterTenantScope(tenant.Id, "billing:overdue", tenant.Code))
|
||||
{
|
||||
totalUpdated += await ProcessOverdueBillingsSingleTenantAsync(processedAt, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
return totalUpdated;
|
||||
}
|
||||
|
||||
private async Task<int> ProcessOverdueBillingsSingleTenantAsync(DateTime processedAt, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询当前租户已超过到期日且仍处于待支付的账单(由仓储按 DueDate + Status 筛选)
|
||||
var overdueBillings = await billingRepository.GetOverdueBillingsAsync(cancellationToken);
|
||||
if (overdueBillings.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 2. 批量标记逾期(防御性:再次判断 Pending)
|
||||
var processedAt = DateTime.UtcNow;
|
||||
// 2. (空行后) 批量标记逾期(防御性:再次判断 Pending)
|
||||
var updated = 0;
|
||||
foreach (var billing in overdueBillings)
|
||||
{
|
||||
@@ -172,7 +203,7 @@ public sealed class BillingDomainService(
|
||||
updated++;
|
||||
}
|
||||
|
||||
// 3. 持久化
|
||||
// 3. (空行后) 持久化
|
||||
if (updated > 0)
|
||||
{
|
||||
await billingRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
@@ -4,6 +4,7 @@ using TakeoutSaaS.Application.App.Stores.Services;
|
||||
using TakeoutSaaS.Domain.Stores.Entities;
|
||||
using TakeoutSaaS.Domain.Stores.Enums;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Services;
|
||||
|
||||
@@ -12,11 +13,42 @@ namespace TakeoutSaaS.Infrastructure.App.Services;
|
||||
/// </summary>
|
||||
public sealed class StoreSchedulerService(
|
||||
TakeoutAppDbContext context,
|
||||
ITenantContextAccessor tenantContextAccessor,
|
||||
ILogger<StoreSchedulerService> logger)
|
||||
: IStoreSchedulerService
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<int> AutoSwitchBusinessStatusAsync(DateTime now, CancellationToken cancellationToken)
|
||||
{
|
||||
var currentTenantId = tenantContextAccessor.Current?.TenantId ?? 0;
|
||||
if (currentTenantId != 0)
|
||||
{
|
||||
return await AutoSwitchBusinessStatusSingleTenantAsync(now, cancellationToken);
|
||||
}
|
||||
|
||||
var tenants = await context.Tenants
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && x.Id > 0)
|
||||
.Select(x => new { x.Id, x.Code })
|
||||
.ToListAsync(cancellationToken);
|
||||
if (tenants.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var totalUpdated = 0;
|
||||
foreach (var tenant in tenants)
|
||||
{
|
||||
using (tenantContextAccessor.EnterTenantScope(tenant.Id, "scheduler", tenant.Code))
|
||||
{
|
||||
totalUpdated += await AutoSwitchBusinessStatusSingleTenantAsync(now, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
return totalUpdated;
|
||||
}
|
||||
|
||||
private async Task<int> AutoSwitchBusinessStatusSingleTenantAsync(DateTime now, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 读取候选门店
|
||||
var stores = await context.Stores
|
||||
@@ -129,6 +161,36 @@ public sealed class StoreSchedulerService(
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> CheckQualificationExpiryAsync(DateTime now, CancellationToken cancellationToken)
|
||||
{
|
||||
var currentTenantId = tenantContextAccessor.Current?.TenantId ?? 0;
|
||||
if (currentTenantId != 0)
|
||||
{
|
||||
return await CheckQualificationExpirySingleTenantAsync(now, cancellationToken);
|
||||
}
|
||||
|
||||
var tenants = await context.Tenants
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && x.Id > 0)
|
||||
.Select(x => new { x.Id, x.Code })
|
||||
.ToListAsync(cancellationToken);
|
||||
if (tenants.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var totalUpdated = 0;
|
||||
foreach (var tenant in tenants)
|
||||
{
|
||||
using (tenantContextAccessor.EnterTenantScope(tenant.Id, "scheduler", tenant.Code))
|
||||
{
|
||||
totalUpdated += await CheckQualificationExpirySingleTenantAsync(now, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
return totalUpdated;
|
||||
}
|
||||
|
||||
private async Task<int> CheckQualificationExpirySingleTenantAsync(DateTime now, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询过期门店
|
||||
var today = DateOnly.FromDateTime(now);
|
||||
|
||||
@@ -7,6 +7,7 @@ using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Ids;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.BackgroundServices;
|
||||
|
||||
@@ -70,86 +71,111 @@ public sealed class AutoRenewalService : BackgroundService
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TakeoutAppDbContext>();
|
||||
var idGenerator = scope.ServiceProvider.GetRequiredService<IIdGenerator>();
|
||||
var tenantContextAccessor = scope.ServiceProvider.GetRequiredService<ITenantContextAccessor>();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var renewalThreshold = now.AddDays(_options.RenewalDaysBeforeExpiry);
|
||||
var billsCreated = 0;
|
||||
|
||||
try
|
||||
{
|
||||
// 查询开启自动续费且即将到期的活跃订阅
|
||||
var autoRenewSubscriptions = await dbContext.TenantSubscriptions
|
||||
.Where(s => s.Status == SubscriptionStatus.Active
|
||||
&& s.AutoRenew
|
||||
&& s.EffectiveTo <= renewalThreshold
|
||||
&& s.EffectiveTo > now)
|
||||
.Join(
|
||||
dbContext.TenantPackages,
|
||||
sub => sub.TenantPackageId,
|
||||
package => package.Id,
|
||||
(sub, package) => new { Subscription = sub, Package = package }
|
||||
)
|
||||
var tenants = await dbContext.Tenants
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && x.Id > 0)
|
||||
.Select(x => new { x.Id, x.Code })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var item in autoRenewSubscriptions)
|
||||
if (tenants.Count == 0)
|
||||
{
|
||||
// 检查是否已为本次到期生成过账单
|
||||
var existingBill = await dbContext.TenantBillingStatements
|
||||
.AnyAsync(b => b.TenantId == item.Subscription.TenantId
|
||||
&& b.PeriodStart >= item.Subscription.EffectiveTo
|
||||
&& b.Status != TenantBillingStatus.Cancelled,
|
||||
cancellationToken);
|
||||
|
||||
if (existingBill)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"订阅 {SubscriptionId} 已存在续费账单,跳过",
|
||||
item.Subscription.Id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 生成续费账单
|
||||
var billNo = $"BILL-{DateTime.UtcNow:yyyyMMddHHmmss}-{item.Subscription.TenantId}";
|
||||
var periodStart = item.Subscription.EffectiveTo;
|
||||
|
||||
// 从当前订阅计算续费周期(月数)
|
||||
var currentDurationMonths = ((item.Subscription.EffectiveTo.Year - item.Subscription.EffectiveFrom.Year) * 12)
|
||||
+ item.Subscription.EffectiveTo.Month - item.Subscription.EffectiveFrom.Month;
|
||||
if (currentDurationMonths <= 0) currentDurationMonths = 1; // 至少1个月
|
||||
|
||||
var periodEnd = periodStart.AddMonths(currentDurationMonths);
|
||||
|
||||
// 根据续费周期计算价格(年付优惠)
|
||||
var renewalPrice = currentDurationMonths >= 12
|
||||
? (item.Package.YearlyPrice ?? item.Package.MonthlyPrice * 12 ?? 0)
|
||||
: (item.Package.MonthlyPrice ?? 0) * currentDurationMonths;
|
||||
|
||||
var bill = new TenantBillingStatement
|
||||
{
|
||||
Id = idGenerator.NextId(),
|
||||
TenantId = item.Subscription.TenantId,
|
||||
StatementNo = billNo,
|
||||
PeriodStart = periodStart,
|
||||
PeriodEnd = periodEnd,
|
||||
AmountDue = renewalPrice,
|
||||
AmountPaid = 0,
|
||||
Status = TenantBillingStatus.Pending,
|
||||
DueDate = periodStart.AddDays(-1), // 到期前一天为付款截止日
|
||||
LineItemsJson = $"{{\"套餐名称\":\"{item.Package.Name}\",\"续费周期\":\"{currentDurationMonths}个月\"}}",
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
dbContext.TenantBillingStatements.Add(bill);
|
||||
billsCreated++;
|
||||
|
||||
_logger.LogInformation(
|
||||
"为订阅 {SubscriptionId} (租户 {TenantId}) 生成自动续费账单 {BillNo},金额 {Amount}",
|
||||
item.Subscription.Id, item.Subscription.TenantId, billNo, renewalPrice);
|
||||
_logger.LogInformation("自动续费处理完成:未找到可处理租户");
|
||||
return;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
var billsCreatedTotal = 0;
|
||||
foreach (var tenant in tenants)
|
||||
{
|
||||
using (tenantContextAccessor.EnterTenantScope(tenant.Id, "background:auto-renewal", tenant.Code))
|
||||
{
|
||||
var billsCreated = 0;
|
||||
|
||||
_logger.LogInformation("自动续费处理完成,共生成 {Count} 张账单", billsCreated);
|
||||
// 查询开启自动续费且即将到期的活跃订阅
|
||||
var autoRenewSubscriptions = await dbContext.TenantSubscriptions
|
||||
.Where(s => s.Status == SubscriptionStatus.Active
|
||||
&& s.AutoRenew
|
||||
&& s.EffectiveTo <= renewalThreshold
|
||||
&& s.EffectiveTo > now)
|
||||
.Join(
|
||||
dbContext.TenantPackages,
|
||||
sub => sub.TenantPackageId,
|
||||
package => package.Id,
|
||||
(sub, package) => new { Subscription = sub, Package = package }
|
||||
)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var item in autoRenewSubscriptions)
|
||||
{
|
||||
// 检查是否已为本次到期生成过账单
|
||||
var existingBill = await dbContext.TenantBillingStatements
|
||||
.AnyAsync(b => b.TenantId == item.Subscription.TenantId
|
||||
&& b.PeriodStart >= item.Subscription.EffectiveTo
|
||||
&& b.Status != TenantBillingStatus.Cancelled,
|
||||
cancellationToken);
|
||||
|
||||
if (existingBill)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"订阅 {SubscriptionId} 已存在续费账单,跳过",
|
||||
item.Subscription.Id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 生成续费账单
|
||||
var billNo = $"BILL-{DateTime.UtcNow:yyyyMMddHHmmss}-{item.Subscription.TenantId}";
|
||||
var periodStart = item.Subscription.EffectiveTo;
|
||||
|
||||
// 从当前订阅计算续费周期(月数)
|
||||
var currentDurationMonths = ((item.Subscription.EffectiveTo.Year - item.Subscription.EffectiveFrom.Year) * 12)
|
||||
+ item.Subscription.EffectiveTo.Month - item.Subscription.EffectiveFrom.Month;
|
||||
if (currentDurationMonths <= 0) currentDurationMonths = 1; // 至少1个月
|
||||
|
||||
var periodEnd = periodStart.AddMonths(currentDurationMonths);
|
||||
|
||||
// 根据续费周期计算价格(年付优惠)
|
||||
var renewalPrice = currentDurationMonths >= 12
|
||||
? (item.Package.YearlyPrice ?? item.Package.MonthlyPrice * 12 ?? 0)
|
||||
: (item.Package.MonthlyPrice ?? 0) * currentDurationMonths;
|
||||
|
||||
var bill = new TenantBillingStatement
|
||||
{
|
||||
Id = idGenerator.NextId(),
|
||||
TenantId = item.Subscription.TenantId,
|
||||
StatementNo = billNo,
|
||||
PeriodStart = periodStart,
|
||||
PeriodEnd = periodEnd,
|
||||
AmountDue = renewalPrice,
|
||||
AmountPaid = 0,
|
||||
Status = TenantBillingStatus.Pending,
|
||||
DueDate = periodStart.AddDays(-1), // 到期前一天为付款截止日
|
||||
LineItemsJson = $"{{\"套餐名称\":\"{item.Package.Name}\",\"续费周期\":\"{currentDurationMonths}个月\"}}",
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
dbContext.TenantBillingStatements.Add(bill);
|
||||
billsCreated++;
|
||||
|
||||
_logger.LogInformation(
|
||||
"为订阅 {SubscriptionId} (租户 {TenantId}) 生成自动续费账单 {BillNo},金额 {Amount}",
|
||||
item.Subscription.Id, item.Subscription.TenantId, billNo, renewalPrice);
|
||||
}
|
||||
|
||||
if (billsCreated > 0)
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
billsCreatedTotal += billsCreated;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("自动续费处理完成,共生成 {Count} 张账单", billsCreatedTotal);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -7,6 +7,7 @@ using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Ids;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.BackgroundServices;
|
||||
|
||||
@@ -70,81 +71,100 @@ public sealed class RenewalReminderService : BackgroundService
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TakeoutAppDbContext>();
|
||||
var idGenerator = scope.ServiceProvider.GetRequiredService<IIdGenerator>();
|
||||
var tenantContextAccessor = scope.ServiceProvider.GetRequiredService<ITenantContextAccessor>();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var remindersSent = 0;
|
||||
|
||||
try
|
||||
{
|
||||
// 遍历配置的提醒时间点(例如:到期前7天、3天、1天)
|
||||
foreach (var daysBeforeExpiry in _options.ReminderDaysBeforeExpiry)
|
||||
var tenants = await dbContext.Tenants
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && x.Id > 0)
|
||||
.Select(x => new { x.Id, x.Code, x.Name })
|
||||
.ToListAsync(cancellationToken);
|
||||
if (tenants.Count == 0)
|
||||
{
|
||||
var targetDate = now.AddDays(daysBeforeExpiry);
|
||||
var startOfDay = targetDate.Date;
|
||||
var endOfDay = startOfDay.AddDays(1);
|
||||
_logger.LogInformation("续费提醒发送完成:未找到可处理租户");
|
||||
return;
|
||||
}
|
||||
|
||||
// 查询即将到期的活跃订阅(且未开启自动续费)
|
||||
var expiringSubscriptions = await dbContext.TenantSubscriptions
|
||||
.Where(s => s.Status == SubscriptionStatus.Active
|
||||
&& !s.AutoRenew
|
||||
&& s.EffectiveTo >= startOfDay
|
||||
&& s.EffectiveTo < endOfDay)
|
||||
.Join(
|
||||
dbContext.Tenants,
|
||||
sub => sub.TenantId,
|
||||
tenant => tenant.Id,
|
||||
(sub, tenant) => new { Subscription = sub, Tenant = tenant }
|
||||
)
|
||||
.Join(
|
||||
dbContext.TenantPackages,
|
||||
combined => combined.Subscription.TenantPackageId,
|
||||
package => package.Id,
|
||||
(combined, package) => new { combined.Subscription, combined.Tenant, Package = package }
|
||||
)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var item in expiringSubscriptions)
|
||||
var remindersSentTotal = 0;
|
||||
foreach (var tenant in tenants)
|
||||
{
|
||||
using (tenantContextAccessor.EnterTenantScope(tenant.Id, "background:renewal-reminder", tenant.Code))
|
||||
{
|
||||
// 检查是否已发送过相同天数的提醒(避免重复发送)
|
||||
var alreadySent = await dbContext.TenantNotifications
|
||||
.AnyAsync(n => n.TenantId == item.Subscription.TenantId
|
||||
&& n.Message.Contains($"{daysBeforeExpiry}天内到期")
|
||||
&& n.SentAt >= now.AddHours(-24), // 24小时内已发送过
|
||||
cancellationToken);
|
||||
var remindersSent = 0;
|
||||
|
||||
if (alreadySent)
|
||||
// 遍历配置的提醒时间点(例如:到期前7天、3天、1天)
|
||||
foreach (var daysBeforeExpiry in _options.ReminderDaysBeforeExpiry)
|
||||
{
|
||||
continue;
|
||||
var targetDate = now.AddDays(daysBeforeExpiry);
|
||||
var startOfDay = targetDate.Date;
|
||||
var endOfDay = startOfDay.AddDays(1);
|
||||
|
||||
// 查询即将到期的活跃订阅(且未开启自动续费)
|
||||
var expiringSubscriptions = await dbContext.TenantSubscriptions
|
||||
.Where(s => s.Status == SubscriptionStatus.Active
|
||||
&& !s.AutoRenew
|
||||
&& s.EffectiveTo >= startOfDay
|
||||
&& s.EffectiveTo < endOfDay)
|
||||
.Join(
|
||||
dbContext.TenantPackages,
|
||||
sub => sub.TenantPackageId,
|
||||
package => package.Id,
|
||||
(sub, package) => new { Subscription = sub, Package = package }
|
||||
)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var item in expiringSubscriptions)
|
||||
{
|
||||
// 检查是否已发送过相同天数的提醒(避免重复发送)
|
||||
var alreadySent = await dbContext.TenantNotifications
|
||||
.AnyAsync(n => n.TenantId == item.Subscription.TenantId
|
||||
&& n.Message.Contains($"{daysBeforeExpiry}天内到期")
|
||||
&& n.SentAt >= now.AddHours(-24), // 24小时内已发送过
|
||||
cancellationToken);
|
||||
|
||||
if (alreadySent)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// 创建续费提醒通知
|
||||
var notification = new TenantNotification
|
||||
{
|
||||
Id = idGenerator.NextId(),
|
||||
TenantId = item.Subscription.TenantId,
|
||||
Title = "订阅续费提醒",
|
||||
Message = $"您的订阅套餐「{item.Package.Name}」将在 {daysBeforeExpiry} 天内到期(到期时间:{item.Subscription.EffectiveTo:yyyy-MM-dd HH:mm}),请及时续费以免影响使用。",
|
||||
Severity = daysBeforeExpiry <= 1
|
||||
? TenantNotificationSeverity.Critical
|
||||
: TenantNotificationSeverity.Warning,
|
||||
Channel = TenantNotificationChannel.InApp,
|
||||
SentAt = DateTime.UtcNow,
|
||||
ReadAt = null,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
dbContext.TenantNotifications.Add(notification);
|
||||
remindersSent++;
|
||||
|
||||
_logger.LogInformation(
|
||||
"发送续费提醒: 租户 {TenantName} ({TenantId}), 套餐 {PackageName}, 剩余 {Days} 天",
|
||||
tenant.Name, item.Subscription.TenantId, item.Package.Name, daysBeforeExpiry);
|
||||
}
|
||||
}
|
||||
|
||||
// 创建续费提醒通知
|
||||
var notification = new TenantNotification
|
||||
if (remindersSent > 0)
|
||||
{
|
||||
Id = idGenerator.NextId(),
|
||||
TenantId = item.Subscription.TenantId,
|
||||
Title = "订阅续费提醒",
|
||||
Message = $"您的订阅套餐「{item.Package.Name}」将在 {daysBeforeExpiry} 天内到期(到期时间:{item.Subscription.EffectiveTo:yyyy-MM-dd HH:mm}),请及时续费以免影响使用。",
|
||||
Severity = daysBeforeExpiry <= 1
|
||||
? TenantNotificationSeverity.Critical
|
||||
: TenantNotificationSeverity.Warning,
|
||||
Channel = TenantNotificationChannel.InApp,
|
||||
SentAt = DateTime.UtcNow,
|
||||
ReadAt = null,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
dbContext.TenantNotifications.Add(notification);
|
||||
remindersSent++;
|
||||
|
||||
_logger.LogInformation(
|
||||
"发送续费提醒: 租户 {TenantName} ({TenantId}), 套餐 {PackageName}, 剩余 {Days} 天",
|
||||
item.Tenant.Name, item.Subscription.TenantId, item.Package.Name, daysBeforeExpiry);
|
||||
remindersSentTotal += remindersSent;
|
||||
}
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("续费提醒发送完成,共发送 {Count} 条提醒", remindersSent);
|
||||
_logger.LogInformation("续费提醒发送完成,共发送 {Count} 条提醒", remindersSentTotal);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.BackgroundServices;
|
||||
|
||||
@@ -67,45 +68,70 @@ public sealed class SubscriptionExpiryCheckService : BackgroundService
|
||||
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TakeoutAppDbContext>();
|
||||
var tenantContextAccessor = scope.ServiceProvider.GetRequiredService<ITenantContextAccessor>();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var gracePeriodDays = _options.GracePeriodDays;
|
||||
|
||||
try
|
||||
{
|
||||
// 1. 检查活跃订阅中已到期的,转为宽限期
|
||||
var expiredActive = await dbContext.TenantSubscriptions
|
||||
.Where(s => s.Status == SubscriptionStatus.Active && s.EffectiveTo < now)
|
||||
var tenants = await dbContext.Tenants
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && x.Id > 0)
|
||||
.Select(x => new { x.Id, x.Code })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var subscription in expiredActive)
|
||||
if (tenants.Count == 0)
|
||||
{
|
||||
subscription.Status = SubscriptionStatus.GracePeriod;
|
||||
_logger.LogInformation(
|
||||
"订阅 {SubscriptionId} (租户 {TenantId}) 已到期,进入宽限期",
|
||||
subscription.Id, subscription.TenantId);
|
||||
_logger.LogInformation("订阅到期检查完成:未找到可处理租户");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 检查宽限期订阅中超过宽限期的,转为暂停
|
||||
var gracePeriodExpired = await dbContext.TenantSubscriptions
|
||||
.Where(s => s.Status == SubscriptionStatus.GracePeriod
|
||||
&& s.EffectiveTo.AddDays(gracePeriodDays) < now)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var subscription in gracePeriodExpired)
|
||||
var changedTotal = 0;
|
||||
var expiredTotal = 0;
|
||||
var suspendedTotal = 0;
|
||||
foreach (var tenant in tenants)
|
||||
{
|
||||
subscription.Status = SubscriptionStatus.Suspended;
|
||||
_logger.LogInformation(
|
||||
"订阅 {SubscriptionId} (租户 {TenantId}) 宽限期已结束,已暂停",
|
||||
subscription.Id, subscription.TenantId);
|
||||
}
|
||||
using (tenantContextAccessor.EnterTenantScope(tenant.Id, "background:subscription-expiry", tenant.Code))
|
||||
{
|
||||
// 1. 检查活跃订阅中已到期的,转为宽限期
|
||||
var expiredActive = await dbContext.TenantSubscriptions
|
||||
.Where(s => s.Status == SubscriptionStatus.Active && s.EffectiveTo < now)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// 3. 保存更改
|
||||
var changedCount = await dbContext.SaveChangesAsync(cancellationToken);
|
||||
foreach (var subscription in expiredActive)
|
||||
{
|
||||
subscription.Status = SubscriptionStatus.GracePeriod;
|
||||
_logger.LogInformation(
|
||||
"订阅 {SubscriptionId} (租户 {TenantId}) 已到期,进入宽限期",
|
||||
subscription.Id, subscription.TenantId);
|
||||
}
|
||||
|
||||
// 2. 检查宽限期订阅中超过宽限期的,转为暂停
|
||||
var gracePeriodExpired = await dbContext.TenantSubscriptions
|
||||
.Where(s => s.Status == SubscriptionStatus.GracePeriod
|
||||
&& s.EffectiveTo.AddDays(gracePeriodDays) < now)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var subscription in gracePeriodExpired)
|
||||
{
|
||||
subscription.Status = SubscriptionStatus.Suspended;
|
||||
_logger.LogInformation(
|
||||
"订阅 {SubscriptionId} (租户 {TenantId}) 宽限期已结束,已暂停",
|
||||
subscription.Id, subscription.TenantId);
|
||||
}
|
||||
|
||||
// 3. 保存更改(逐租户保存,避免跨租户写入)
|
||||
var changedCount = await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
changedTotal += changedCount;
|
||||
expiredTotal += expiredActive.Count;
|
||||
suspendedTotal += gracePeriodExpired.Count;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"订阅到期检查完成,共更新 {Count} 条记录 (到期转宽限期: {ExpiredCount}, 宽限期转暂停: {SuspendedCount})",
|
||||
changedCount, expiredActive.Count, gracePeriodExpired.Count);
|
||||
changedTotal, expiredTotal, suspendedTotal);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -18,6 +18,26 @@ public abstract class AppDbContext(
|
||||
private readonly ICurrentUserAccessor? _currentUserAccessor = currentUserAccessor;
|
||||
private readonly IIdGenerator? _idGenerator = idGenerator;
|
||||
|
||||
/// <summary>
|
||||
/// 是否禁用软删除过滤器。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 仅允许在少数系统任务/恢复场景中临时关闭,默认应保持开启。
|
||||
/// </remarks>
|
||||
protected bool IsSoftDeleteFilterDisabled { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 临时禁用软删除过滤器(仅关闭软删除过滤,不影响租户过滤)。
|
||||
/// </summary>
|
||||
/// <returns>作用域对象,释放后恢复之前的过滤状态。</returns>
|
||||
public IDisposable DisableSoftDeleteFilter()
|
||||
{
|
||||
var previous = IsSoftDeleteFilterDisabled;
|
||||
IsSoftDeleteFilterDisabled = true;
|
||||
|
||||
return new SoftDeleteFilterScope(this, previous);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构建模型时应用软删除过滤器。
|
||||
/// </summary>
|
||||
@@ -179,7 +199,15 @@ public abstract class AppDbContext(
|
||||
private void SetSoftDeleteFilter<TEntity>(ModelBuilder modelBuilder)
|
||||
where TEntity : class, ISoftDeleteEntity
|
||||
{
|
||||
modelBuilder.Entity<TEntity>().HasQueryFilter(entity => entity.DeletedAt == null);
|
||||
QueryFilterCombiner.Combine<TEntity>(modelBuilder, "soft_delete", entity => IsSoftDeleteFilterDisabled || entity.DeletedAt == null);
|
||||
}
|
||||
|
||||
private sealed class SoftDeleteFilterScope(AppDbContext context, bool previous) : IDisposable
|
||||
{
|
||||
public void Dispose()
|
||||
{
|
||||
context.IsSoftDeleteFilterDisabled = previous;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.Linq.Expressions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Common.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// 查询过滤器合并器:用于追加具名 QueryFilter,避免覆盖已有过滤器。
|
||||
/// </summary>
|
||||
internal static class QueryFilterCombiner
|
||||
{
|
||||
/// <summary>
|
||||
/// 为指定实体追加具名查询过滤器。
|
||||
/// </summary>
|
||||
/// <typeparam name="TEntity">实体类型。</typeparam>
|
||||
/// <param name="modelBuilder">模型构建器。</param>
|
||||
/// <param name="filterKey">过滤器键。</param>
|
||||
/// <param name="filter">新增过滤器表达式。</param>
|
||||
internal static void Combine<TEntity>(ModelBuilder modelBuilder, string filterKey, Expression<Func<TEntity, bool>> filter)
|
||||
where TEntity : class
|
||||
{
|
||||
modelBuilder.Entity<TEntity>().HasQueryFilter(filterKey, filter);
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,7 @@ public abstract class TenantAwareDbContext(
|
||||
private void SetTenantFilter<TEntity>(ModelBuilder modelBuilder)
|
||||
where TEntity : class, IMultiTenantEntity
|
||||
{
|
||||
modelBuilder.Entity<TEntity>().HasQueryFilter(entity => entity.TenantId == CurrentTenantId);
|
||||
QueryFilterCombiner.Combine<TEntity>(modelBuilder, "tenant", entity => entity.TenantId == CurrentTenantId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -74,9 +74,30 @@ public abstract class TenantAwareDbContext(
|
||||
|
||||
foreach (var entry in ChangeTracker.Entries<IMultiTenantEntity>())
|
||||
{
|
||||
if (entry.State == EntityState.Added && entry.Entity.TenantId == 0 && tenantId != 0)
|
||||
if (entry.State is EntityState.Detached or EntityState.Unchanged)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tenantId == 0)
|
||||
{
|
||||
if (entry.Entity.TenantId != 0)
|
||||
{
|
||||
throw new InvalidOperationException("未进入租户上下文,禁止写入 TenantId 不为 0 的多租户数据。");
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.State == EntityState.Added && entry.Entity.TenantId == 0)
|
||||
{
|
||||
entry.Entity.TenantId = tenantId;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.Entity.TenantId != tenantId)
|
||||
{
|
||||
throw new InvalidOperationException("检测到跨租户写入,已阻止保存。");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ public sealed class DictionaryGroupRepository(DictionaryDbContext context) : IDi
|
||||
EF.CompileAsyncQuery((DictionaryDbContext db, long tenantId, DictionaryCode code) =>
|
||||
db.DictionaryGroups
|
||||
.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefault(group => group.TenantId == tenantId && group.DeletedAt == null && group.Code == code));
|
||||
|
||||
/// <summary>
|
||||
@@ -25,7 +24,7 @@ public sealed class DictionaryGroupRepository(DictionaryDbContext context) : IDi
|
||||
public Task<DictionaryGroup?> GetByIdAsync(long groupId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.DictionaryGroups
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(group => group.Id == groupId && group.DeletedAt == null, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -90,7 +89,6 @@ public sealed class DictionaryGroupRepository(DictionaryDbContext context) : IDi
|
||||
|
||||
return await context.DictionaryGroups
|
||||
.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(group => ids.Contains(group.Id) && group.DeletedAt == null)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
@@ -144,7 +142,6 @@ public sealed class DictionaryGroupRepository(DictionaryDbContext context) : IDi
|
||||
{
|
||||
var query = context.DictionaryGroups
|
||||
.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(group => group.TenantId == tenantId && group.DeletedAt == null);
|
||||
|
||||
if (scope.HasValue)
|
||||
|
||||
@@ -3,19 +3,19 @@ using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
using TakeoutSaaS.Domain.Dictionary.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 字典项仓储实现。
|
||||
/// </summary>
|
||||
public sealed class DictionaryItemRepository(DictionaryDbContext context) : IDictionaryItemRepository
|
||||
public sealed class DictionaryItemRepository(DictionaryDbContext context, ITenantContextAccessor tenantContextAccessor) : IDictionaryItemRepository
|
||||
{
|
||||
private static readonly Func<DictionaryDbContext, long, long, IEnumerable<DictionaryItem>> GetByGroupQuery =
|
||||
EF.CompileQuery((DictionaryDbContext db, long tenantId, long groupId) =>
|
||||
(IEnumerable<DictionaryItem>)db.DictionaryItems
|
||||
.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(item => item.GroupId == groupId && item.TenantId == tenantId && item.DeletedAt == null)
|
||||
.OrderBy(item => item.SortOrder));
|
||||
|
||||
@@ -25,7 +25,7 @@ public sealed class DictionaryItemRepository(DictionaryDbContext context) : IDic
|
||||
public Task<DictionaryItem?> GetByIdAsync(long itemId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.DictionaryItems
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == itemId && item.DeletedAt == null, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -51,25 +51,26 @@ public sealed class DictionaryItemRepository(DictionaryDbContext context) : IDic
|
||||
bool includeOverrides,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var systemGroup = await context.DictionaryGroups
|
||||
.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(group => group.Id == systemGroupId && group.DeletedAt == null, cancellationToken);
|
||||
|
||||
if (systemGroup == null)
|
||||
DictionaryGroup? systemGroup;
|
||||
List<DictionaryItem> systemItems;
|
||||
using (tenantContextAccessor.EnterTenantScope(0, "dictionary"))
|
||||
{
|
||||
return Array.Empty<DictionaryItem>();
|
||||
systemGroup = await context.DictionaryGroups
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(group => group.Id == systemGroupId && group.DeletedAt == null, cancellationToken);
|
||||
if (systemGroup == null)
|
||||
{
|
||||
return Array.Empty<DictionaryItem>();
|
||||
}
|
||||
|
||||
systemItems = await context.DictionaryItems
|
||||
.AsNoTracking()
|
||||
.Where(item => item.GroupId == systemGroupId && item.DeletedAt == null)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
var result = new List<DictionaryItem>();
|
||||
var systemItems = await context.DictionaryItems
|
||||
.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(item => item.GroupId == systemGroupId && item.TenantId == 0 && item.DeletedAt == null)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
result.AddRange(systemItems);
|
||||
var result = new List<DictionaryItem>(systemItems);
|
||||
|
||||
if (!includeOverrides || tenantId == 0)
|
||||
{
|
||||
@@ -78,7 +79,6 @@ public sealed class DictionaryItemRepository(DictionaryDbContext context) : IDic
|
||||
|
||||
var tenantGroup = await context.DictionaryGroups
|
||||
.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(group =>
|
||||
group.TenantId == tenantId &&
|
||||
group.DeletedAt == null &&
|
||||
@@ -92,7 +92,6 @@ public sealed class DictionaryItemRepository(DictionaryDbContext context) : IDic
|
||||
|
||||
var tenantItems = await context.DictionaryItems
|
||||
.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(item => item.GroupId == tenantGroup.Id && item.TenantId == tenantId && item.DeletedAt == null)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
@@ -17,7 +17,6 @@ public sealed class DictionaryLabelOverrideRepository(DictionaryDbContext contex
|
||||
public Task<DictionaryLabelOverride?> GetByIdAsync(long id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.DictionaryLabelOverrides
|
||||
.IgnoreQueryFilters()
|
||||
.Include(x => x.DictionaryItem)
|
||||
.FirstOrDefaultAsync(x => x.Id == id && x.DeletedAt == null, cancellationToken);
|
||||
}
|
||||
@@ -28,7 +27,6 @@ public sealed class DictionaryLabelOverrideRepository(DictionaryDbContext contex
|
||||
public Task<DictionaryLabelOverride?> GetByItemIdAsync(long tenantId, long dictionaryItemId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.DictionaryLabelOverrides
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(x =>
|
||||
x.TenantId == tenantId &&
|
||||
x.DictionaryItemId == dictionaryItemId &&
|
||||
@@ -46,7 +44,6 @@ public sealed class DictionaryLabelOverrideRepository(DictionaryDbContext contex
|
||||
{
|
||||
var query = context.DictionaryLabelOverrides
|
||||
.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.Include(x => x.DictionaryItem)
|
||||
.Where(x => x.TenantId == tenantId && x.DeletedAt == null);
|
||||
|
||||
@@ -71,7 +68,6 @@ public sealed class DictionaryLabelOverrideRepository(DictionaryDbContext contex
|
||||
|
||||
return await context.DictionaryLabelOverrides
|
||||
.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(x =>
|
||||
x.TenantId == tenantId &&
|
||||
ids.Contains(x.DictionaryItemId) &&
|
||||
|
||||
@@ -4,13 +4,14 @@ using TakeoutSaaS.Domain.Dictionary.Enums;
|
||||
using TakeoutSaaS.Domain.Dictionary.ValueObjects;
|
||||
using TakeoutSaaS.Domain.Dictionary.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core 字典仓储实现。
|
||||
/// </summary>
|
||||
public sealed class EfDictionaryRepository(DictionaryDbContext context) : IDictionaryRepository
|
||||
public sealed class EfDictionaryRepository(DictionaryDbContext context, ITenantContextAccessor tenantContextAccessor) : IDictionaryRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// 根据分组 ID 查询分组。
|
||||
@@ -163,19 +164,37 @@ public sealed class EfDictionaryRepository(DictionaryDbContext context) : IDicti
|
||||
return Array.Empty<DictionaryItem>();
|
||||
}
|
||||
|
||||
// 2. 构建查询并忽略 QueryFilter
|
||||
var query = context.DictionaryItems
|
||||
// 2. 查询当前租户条目
|
||||
var tenantItems = await context.DictionaryItems
|
||||
.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.Include(item => item.Group)
|
||||
.Where(item => normalizedCodes.Contains(item.Group!.Code) && item.DeletedAt == null);
|
||||
|
||||
// 3. 按租户或系统级过滤
|
||||
query = query.Where(item => item.TenantId == tenantId || (includeSystem && item.TenantId == 0));
|
||||
|
||||
// 4. 排序返回
|
||||
return await query
|
||||
.Where(item => item.TenantId == tenantId && normalizedCodes.Contains(item.Group!.Code) && item.DeletedAt == null)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (!includeSystem)
|
||||
{
|
||||
return tenantItems;
|
||||
}
|
||||
|
||||
// 3. (空行后) 查询系统级条目(TenantId=0)
|
||||
List<DictionaryItem> systemItems;
|
||||
using (tenantContextAccessor.EnterTenantScope(0, "dictionary"))
|
||||
{
|
||||
systemItems = await context.DictionaryItems
|
||||
.AsNoTracking()
|
||||
.Include(item => item.Group)
|
||||
.Where(item => item.TenantId == 0 && normalizedCodes.Contains(item.Group!.Code) && item.DeletedAt == null)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
// 4. (空行后) 合并返回(系统优先)
|
||||
if (systemItems.Count == 0)
|
||||
{
|
||||
return tenantItems;
|
||||
}
|
||||
|
||||
return [.. systemItems, .. tenantItems];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ public sealed class TenantDictionaryOverrideRepository(DictionaryDbContext conte
|
||||
public Task<TenantDictionaryOverride?> GetAsync(long tenantId, long systemGroupId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantDictionaryOverrides
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(config =>
|
||||
config.TenantId == tenantId &&
|
||||
config.SystemDictionaryGroupId == systemGroupId &&
|
||||
@@ -31,7 +30,6 @@ public sealed class TenantDictionaryOverrideRepository(DictionaryDbContext conte
|
||||
{
|
||||
return await context.TenantDictionaryOverrides
|
||||
.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(config => config.TenantId == tenantId && config.DeletedAt == null)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
@@ -49,15 +49,14 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
|
||||
/// <param name="excludeUserId">排除的用户 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>存在返回 true。</returns>
|
||||
public Task<bool> ExistsByAccountAsync(long tenantId, string account, long? excludeUserId = null, CancellationToken cancellationToken = default)
|
||||
public async Task<bool> ExistsByAccountAsync(long tenantId, string account, long? excludeUserId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 标准化账号
|
||||
var normalized = account.Trim();
|
||||
|
||||
// 2. 构建查询(包含已删除数据)
|
||||
var query = dbContext.IdentityUsers
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
// 2. 构建查询(包含已删除数据,但不放开租户过滤)
|
||||
using var disableSoftDeleteScope = dbContext.DisableSoftDeleteFilter();
|
||||
var query = dbContext.IdentityUsers.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.Account == normalized);
|
||||
|
||||
if (excludeUserId.HasValue)
|
||||
@@ -66,7 +65,7 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
|
||||
}
|
||||
|
||||
// 3. 返回是否存在
|
||||
return query.AnyAsync(cancellationToken);
|
||||
return await query.AnyAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -77,15 +76,14 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
|
||||
/// <param name="excludeUserId">排除的用户 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>存在返回 true。</returns>
|
||||
public Task<bool> ExistsByPhoneAsync(long tenantId, string phone, long? excludeUserId = null, CancellationToken cancellationToken = default)
|
||||
public async Task<bool> ExistsByPhoneAsync(long tenantId, string phone, long? excludeUserId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 标准化手机号
|
||||
var normalized = phone.Trim();
|
||||
|
||||
// 2. 构建查询(包含已删除数据)
|
||||
var query = dbContext.IdentityUsers
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
// 2. 构建查询(包含已删除数据,但不放开租户过滤)
|
||||
using var disableSoftDeleteScope = dbContext.DisableSoftDeleteFilter();
|
||||
var query = dbContext.IdentityUsers.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.Phone == normalized);
|
||||
|
||||
if (excludeUserId.HasValue)
|
||||
@@ -94,7 +92,7 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
|
||||
}
|
||||
|
||||
// 3. 返回是否存在
|
||||
return query.AnyAsync(cancellationToken);
|
||||
return await query.AnyAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -105,15 +103,14 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
|
||||
/// <param name="excludeUserId">排除的用户 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>存在返回 true。</returns>
|
||||
public Task<bool> ExistsByEmailAsync(long tenantId, string email, long? excludeUserId = null, CancellationToken cancellationToken = default)
|
||||
public async Task<bool> ExistsByEmailAsync(long tenantId, string email, long? excludeUserId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 标准化邮箱
|
||||
var normalized = email.Trim();
|
||||
|
||||
// 2. 构建查询(包含已删除数据)
|
||||
var query = dbContext.IdentityUsers
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
// 2. 构建查询(包含已删除数据,但不放开租户过滤)
|
||||
using var disableSoftDeleteScope = dbContext.DisableSoftDeleteFilter();
|
||||
var query = dbContext.IdentityUsers.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.Email == normalized);
|
||||
|
||||
if (excludeUserId.HasValue)
|
||||
@@ -122,7 +119,7 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
|
||||
}
|
||||
|
||||
// 3. 返回是否存在
|
||||
return query.AnyAsync(cancellationToken);
|
||||
return await query.AnyAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -150,14 +147,14 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
|
||||
/// <param name="userId">用户 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>后台用户或 null。</returns>
|
||||
public Task<IdentityUser?> GetForUpdateIncludingDeletedAsync(
|
||||
public async Task<IdentityUser?> GetForUpdateIncludingDeletedAsync(
|
||||
long tenantId,
|
||||
long userId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 构建查询(包含已删除数据,但强制租户隔离)
|
||||
return dbContext.IdentityUsers
|
||||
.IgnoreQueryFilters()
|
||||
using var disableSoftDeleteScope = dbContext.DisableSoftDeleteFilter();
|
||||
return await dbContext.IdentityUsers
|
||||
.Where(x => x.TenantId == tenantId)
|
||||
.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
|
||||
}
|
||||
@@ -204,21 +201,14 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
|
||||
|
||||
var tenantId = filter.TenantId.Value;
|
||||
|
||||
using var disableSoftDeleteScope = filter.IncludeDeleted ? dbContext.DisableSoftDeleteFilter() : null;
|
||||
|
||||
// 1. 构建基础查询
|
||||
var query = dbContext.IdentityUsers.AsNoTracking();
|
||||
if (filter.IncludeDeleted)
|
||||
{
|
||||
query = query.IgnoreQueryFilters();
|
||||
}
|
||||
|
||||
// 2. 租户过滤(强制)
|
||||
query = query.Where(x => x.TenantId == tenantId);
|
||||
|
||||
if (!filter.IncludeDeleted)
|
||||
{
|
||||
query = query.Where(x => x.DeletedAt == null);
|
||||
}
|
||||
|
||||
// 3. 关键字筛选
|
||||
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
||||
{
|
||||
@@ -242,18 +232,9 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
|
||||
{
|
||||
var roleId = filter.RoleId.Value;
|
||||
var userRoles = dbContext.UserRoles.AsNoTracking();
|
||||
if (filter.IncludeDeleted)
|
||||
{
|
||||
userRoles = userRoles.IgnoreQueryFilters();
|
||||
}
|
||||
|
||||
userRoles = userRoles.Where(x => x.TenantId == tenantId);
|
||||
|
||||
if (!filter.IncludeDeleted)
|
||||
{
|
||||
userRoles = userRoles.Where(x => x.DeletedAt == null);
|
||||
}
|
||||
|
||||
query = query.Where(user => userRoles.Any(role => role.UserId == user.Id && role.RoleId == roleId));
|
||||
}
|
||||
|
||||
@@ -346,18 +327,10 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
|
||||
}
|
||||
|
||||
var query = dbContext.IdentityUsers.Where(x => ids.Contains(x.Id));
|
||||
if (includeDeleted)
|
||||
{
|
||||
query = query.IgnoreQueryFilters();
|
||||
}
|
||||
using var disableSoftDeleteScope = includeDeleted ? dbContext.DisableSoftDeleteFilter() : null;
|
||||
|
||||
query = query.Where(x => x.TenantId == tenantId);
|
||||
|
||||
if (!includeDeleted)
|
||||
{
|
||||
query = query.Where(x => x.DeletedAt == null);
|
||||
}
|
||||
|
||||
// 2. 返回列表
|
||||
return await query.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
@@ -18,9 +18,8 @@ public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermi
|
||||
/// <returns>权限实体或 null。</returns>
|
||||
public Task<Permission?> FindByIdAsync(long permissionId, long tenantId, CancellationToken cancellationToken = default)
|
||||
=> dbContext.Permissions
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == permissionId && x.DeletedAt == null, cancellationToken);
|
||||
.FirstOrDefaultAsync(x => x.Id == permissionId && x.TenantId == tenantId && x.DeletedAt == null, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 根据权限编码获取权限。
|
||||
@@ -31,9 +30,8 @@ public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermi
|
||||
/// <returns>权限实体或 null。</returns>
|
||||
public Task<Permission?> FindByCodeAsync(string code, long tenantId, CancellationToken cancellationToken = default)
|
||||
=> dbContext.Permissions
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Code == code && x.DeletedAt == null, cancellationToken);
|
||||
.FirstOrDefaultAsync(x => x.Code == code && x.TenantId == tenantId && x.DeletedAt == null, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 根据权限编码集合批量获取权限。
|
||||
@@ -51,11 +49,10 @@ public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermi
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
|
||||
// 2. 读取全局权限(已固定)
|
||||
// 2. 读取租户权限
|
||||
return dbContext.Permissions
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && normalizedCodes.Contains(x.Code))
|
||||
.Where(x => x.TenantId == tenantId && x.DeletedAt == null && normalizedCodes.Contains(x.Code))
|
||||
.ToListAsync(cancellationToken)
|
||||
.ContinueWith(t => (IReadOnlyList<Permission>)t.Result, cancellationToken);
|
||||
}
|
||||
@@ -69,9 +66,8 @@ public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermi
|
||||
/// <returns>权限列表。</returns>
|
||||
public Task<IReadOnlyList<Permission>> GetByIdsAsync(long tenantId, IEnumerable<long> permissionIds, CancellationToken cancellationToken = default)
|
||||
=> dbContext.Permissions
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && permissionIds.Contains(x.Id))
|
||||
.Where(x => x.TenantId == tenantId && x.DeletedAt == null && permissionIds.Contains(x.Id))
|
||||
.ToListAsync(cancellationToken)
|
||||
.ContinueWith(t => (IReadOnlyList<Permission>)t.Result, cancellationToken);
|
||||
|
||||
@@ -86,9 +82,8 @@ public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermi
|
||||
{
|
||||
// 1. 构建基础查询
|
||||
var query = dbContext.Permissions
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null);
|
||||
.Where(x => x.TenantId == tenantId && x.DeletedAt == null);
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
// 2. 追加关键字过滤
|
||||
@@ -139,7 +134,7 @@ public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermi
|
||||
public async Task DeleteAsync(long permissionId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 查询目标权限
|
||||
var entity = await dbContext.Permissions.FirstOrDefaultAsync(x => x.Id == permissionId, cancellationToken);
|
||||
var entity = await dbContext.Permissions.FirstOrDefaultAsync(x => x.Id == permissionId && x.TenantId == tenantId, cancellationToken);
|
||||
if (entity != null)
|
||||
{
|
||||
// 2. 删除实体
|
||||
|
||||
@@ -16,13 +16,17 @@ public sealed class EfRolePermissionRepository(IdentityDbContext dbContext) : IR
|
||||
/// <param name="roleIds">角色 ID 集合。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>角色权限映射列表。</returns>
|
||||
public Task<IReadOnlyList<RolePermission>> GetByRoleIdsAsync(long tenantId, IEnumerable<long> roleIds, CancellationToken cancellationToken = default)
|
||||
=> dbContext.RolePermissions
|
||||
.IgnoreQueryFilters()
|
||||
public async Task<IReadOnlyList<RolePermission>> GetByRoleIdsAsync(long tenantId, IEnumerable<long> roleIds, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 查询角色权限映射
|
||||
var mappings = await dbContext.RolePermissions
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.DeletedAt == null && roleIds.Contains(x.RoleId))
|
||||
.ToListAsync(cancellationToken)
|
||||
.ContinueWith(t => (IReadOnlyList<RolePermission>)t.Result, cancellationToken);
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// 2. (空行后) 返回只读列表
|
||||
return mappings;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量新增角色权限。
|
||||
|
||||
@@ -18,7 +18,6 @@ public sealed class EfRoleRepository(IdentityDbContext dbContext) : IRoleReposit
|
||||
/// <returns>角色实体或 null。</returns>
|
||||
public Task<Role?> FindByIdAsync(long roleId, long tenantId, CancellationToken cancellationToken = default)
|
||||
=> dbContext.Roles
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == roleId && x.TenantId == tenantId && x.DeletedAt == null, cancellationToken);
|
||||
|
||||
@@ -31,7 +30,6 @@ public sealed class EfRoleRepository(IdentityDbContext dbContext) : IRoleReposit
|
||||
/// <returns>角色实体或 null。</returns>
|
||||
public Task<Role?> FindByCodeAsync(string code, long tenantId, CancellationToken cancellationToken = default)
|
||||
=> dbContext.Roles
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Code == code && x.TenantId == tenantId && x.DeletedAt == null, cancellationToken);
|
||||
|
||||
@@ -42,13 +40,17 @@ public sealed class EfRoleRepository(IdentityDbContext dbContext) : IRoleReposit
|
||||
/// <param name="roleIds">角色 ID 集合。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>角色列表。</returns>
|
||||
public Task<IReadOnlyList<Role>> GetByIdsAsync(long tenantId, IEnumerable<long> roleIds, CancellationToken cancellationToken = default)
|
||||
=> dbContext.Roles
|
||||
.IgnoreQueryFilters()
|
||||
public async Task<IReadOnlyList<Role>> GetByIdsAsync(long tenantId, IEnumerable<long> roleIds, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 查询角色列表
|
||||
var roles = await dbContext.Roles
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && roleIds.Contains(x.Id) && x.DeletedAt == null)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ContinueWith(t => (IReadOnlyList<Role>)t.Result, cancellationToken);
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// 2. (空行后) 返回只读列表
|
||||
return roles;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按关键字搜索角色。
|
||||
@@ -57,11 +59,10 @@ public sealed class EfRoleRepository(IdentityDbContext dbContext) : IRoleReposit
|
||||
/// <param name="keyword">搜索关键字。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>角色列表。</returns>
|
||||
public Task<IReadOnlyList<Role>> SearchAsync(long tenantId, string? keyword, CancellationToken cancellationToken = default)
|
||||
public async Task<IReadOnlyList<Role>> SearchAsync(long tenantId, string? keyword, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 构建基础查询
|
||||
var query = dbContext.Roles
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.DeletedAt == null);
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
@@ -72,8 +73,10 @@ public sealed class EfRoleRepository(IdentityDbContext dbContext) : IRoleReposit
|
||||
}
|
||||
|
||||
// 3. 返回列表
|
||||
return query.ToListAsync(cancellationToken)
|
||||
.ContinueWith(t => (IReadOnlyList<Role>)t.Result, cancellationToken);
|
||||
var roles = await query.ToListAsync(cancellationToken);
|
||||
|
||||
// 4. (空行后) 返回只读列表
|
||||
return roles;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -16,13 +16,17 @@ public sealed class EfUserRoleRepository(IdentityDbContext dbContext) : IUserRol
|
||||
/// <param name="userIds">用户 ID 集合。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>用户角色映射列表。</returns>
|
||||
public Task<IReadOnlyList<UserRole>> GetByUserIdsAsync(long tenantId, IEnumerable<long> userIds, CancellationToken cancellationToken = default)
|
||||
=> dbContext.UserRoles
|
||||
.IgnoreQueryFilters()
|
||||
public async Task<IReadOnlyList<UserRole>> GetByUserIdsAsync(long tenantId, IEnumerable<long> userIds, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 查询用户角色映射
|
||||
var mappings = await dbContext.UserRoles
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.DeletedAt == null && userIds.Contains(x.UserId))
|
||||
.ToListAsync(cancellationToken)
|
||||
.ContinueWith(t => (IReadOnlyList<UserRole>)t.Result, cancellationToken);
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// 2. (空行后) 返回只读列表
|
||||
return mappings;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定用户的角色集合。
|
||||
@@ -31,13 +35,17 @@ public sealed class EfUserRoleRepository(IdentityDbContext dbContext) : IUserRol
|
||||
/// <param name="userId">用户 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>用户角色列表。</returns>
|
||||
public Task<IReadOnlyList<UserRole>> GetByUserIdAsync(long tenantId, long userId, CancellationToken cancellationToken = default)
|
||||
=> dbContext.UserRoles
|
||||
.IgnoreQueryFilters()
|
||||
public async Task<IReadOnlyList<UserRole>> GetByUserIdAsync(long tenantId, long userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 查询用户角色映射
|
||||
var mappings = await dbContext.UserRoles
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.DeletedAt == null && x.UserId == userId)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ContinueWith(t => (IReadOnlyList<UserRole>)t.Result, cancellationToken);
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// 2. (空行后) 返回只读列表
|
||||
return mappings;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 替换指定用户的角色集合。
|
||||
@@ -56,8 +64,8 @@ public sealed class EfUserRoleRepository(IdentityDbContext dbContext) : IUserRol
|
||||
await using var trx = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
// 2. 读取当前角色映射
|
||||
using var disableSoftDeleteScope = dbContext.DisableSoftDeleteFilter();
|
||||
var existing = await dbContext.UserRoles
|
||||
.IgnoreQueryFilters()
|
||||
.Where(x => x.TenantId == tenantId && x.UserId == userId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
@@ -111,7 +119,6 @@ public sealed class EfUserRoleRepository(IdentityDbContext dbContext) : IUserRol
|
||||
/// <returns>用户数量。</returns>
|
||||
public Task<int> CountUsersByRoleAsync(long tenantId, long roleId, CancellationToken cancellationToken = default)
|
||||
=> dbContext.UserRoles
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.DeletedAt == null && x.RoleId == roleId)
|
||||
.CountAsync(cancellationToken);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,14 @@ using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 菜单仓储 EF 实现。
|
||||
/// </summary>
|
||||
public sealed class EfMenuRepository(IdentityDbContext dbContext) : IMenuRepository
|
||||
public sealed class EfMenuRepository(IdentityDbContext dbContext, ITenantContextAccessor tenantContextAccessor) : IMenuRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<MenuDefinition>> GetByTenantAsync(long tenantId, CancellationToken cancellationToken = default)
|
||||
@@ -26,15 +27,17 @@ public sealed class EfMenuRepository(IdentityDbContext dbContext) : IMenuReposit
|
||||
}
|
||||
|
||||
// 2. (空行后) 回退系统默认菜单(TenantId=0)
|
||||
var systemMenus = await dbContext.MenuDefinitions
|
||||
.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(x => x.TenantId == 0 && x.DeletedAt == null)
|
||||
.OrderBy(x => x.ParentId)
|
||||
.ThenBy(x => x.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
using (tenantContextAccessor.EnterTenantScope(0, "menu"))
|
||||
{
|
||||
var systemMenus = await dbContext.MenuDefinitions
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == 0 && x.DeletedAt == null)
|
||||
.OrderBy(x => x.ParentId)
|
||||
.ThenBy(x => x.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return systemMenus;
|
||||
return systemMenus;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -52,10 +55,12 @@ public sealed class EfMenuRepository(IdentityDbContext dbContext) : IMenuReposit
|
||||
}
|
||||
|
||||
// 2. (空行后) 回退查系统默认菜单(TenantId=0)
|
||||
return await dbContext.MenuDefinitions
|
||||
.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(x => x.Id == id && x.TenantId == 0 && x.DeletedAt == null, cancellationToken);
|
||||
using (tenantContextAccessor.EnterTenantScope(0, "menu"))
|
||||
{
|
||||
return await dbContext.MenuDefinitions
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == id && x.TenantId == 0 && x.DeletedAt == null, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -63,6 +63,7 @@ public sealed class TakeoutLogsDbContext(
|
||||
ConfigureOperationLog(modelBuilder.Entity<OperationLog>());
|
||||
ConfigureOperationLogInboxMessage(modelBuilder.Entity<OperationLogInboxMessage>());
|
||||
ConfigureMemberGrowthLog(modelBuilder.Entity<MemberGrowthLog>());
|
||||
ApplyTenantQueryFilters(modelBuilder);
|
||||
}
|
||||
|
||||
private static void ConfigureTenantAuditLog(EntityTypeBuilder<TenantAuditLog> builder)
|
||||
|
||||
Reference in New Issue
Block a user