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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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