Revert "refactor: 清理租户API旧模块代码"

This reverts commit 992930a821.
This commit is contained in:
2026-02-17 12:12:01 +08:00
parent 654b1ae3f7
commit c032608a57
910 changed files with 189923 additions and 266 deletions

View File

@@ -1,57 +1,86 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using TakeoutSaaS.Application.App.Stores.Services;
using TakeoutSaaS.Domain.Deliveries.Repositories;
using TakeoutSaaS.Domain.Inventory.Repositories;
using TakeoutSaaS.Domain.Merchants.Repositories;
using TakeoutSaaS.Domain.Merchants.Services;
using TakeoutSaaS.Domain.Orders.Repositories;
using TakeoutSaaS.Domain.Payments.Repositories;
using TakeoutSaaS.Domain.Products.Repositories;
using TakeoutSaaS.Domain.Stores.Repositories;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Domain.Tenants.Services;
using TakeoutSaaS.Infrastructure.App.Options;
using TakeoutSaaS.Infrastructure.App.Persistence;
using TakeoutSaaS.Infrastructure.Logs.Persistence;
using TakeoutSaaS.Infrastructure.Logs.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence.Repositories;
using TakeoutSaaS.Infrastructure.App.Repositories;
using TakeoutSaaS.Infrastructure.App.Services;
using TakeoutSaaS.Infrastructure.Common.Extensions;
using TakeoutSaaS.Shared.Abstractions.Constants;
namespace TakeoutSaaS.Infrastructure.App.Extensions;
/// <summary>
/// 门店模块基础设施注扩展。
/// 业务主库基础设施注扩展。
/// </summary>
public static class AppServiceCollectionExtensions
{
/// <summary>
/// 注册门店模块所需的 DbContext 与仓储。
/// 注册业务主库 DbContext 与仓储。
/// </summary>
/// <param name="services">服务集合。</param>
/// <param name="configuration">配置源。</param>
/// <returns>服务集合。</returns>
public static IServiceCollection AddAppInfrastructure(this IServiceCollection services, IConfiguration configuration)
{
// 1. 读取业务库连接串
var connectionString = ResolveAppDatabaseConnectionString(configuration);
services.AddDatabaseInfrastructure(configuration);
services.AddPostgresDbContext<TakeoutAppDbContext>(DatabaseConstants.AppDataSource);
services.AddPostgresDbContext<TakeoutLogsDbContext>(DatabaseConstants.LogsDataSource);
// 2. 注册门店业务 DbContext
services.AddDbContext<TakeoutTenantAppDbContext>(options =>
{
options.UseNpgsql(connectionString);
});
// 3. 注册门店仓储
services.AddScoped<IMerchantRepository, EfMerchantRepository>();
services.AddScoped<IMerchantCategoryRepository, EfMerchantCategoryRepository>();
services.AddScoped<IStoreRepository, EfStoreRepository>();
services.AddScoped<IProductRepository, EfProductRepository>();
services.AddScoped<IOrderRepository, EfOrderRepository>();
services.AddScoped<IPaymentRepository, EfPaymentRepository>();
services.AddScoped<IDeliveryRepository, EfDeliveryRepository>();
services.AddScoped<ITenantRepository, EfTenantRepository>();
services.AddScoped<ITenantBillingRepository, TenantBillingRepository>();
services.AddScoped<ITenantPaymentRepository, TenantPaymentRepository>();
services.AddScoped<ITenantAnnouncementRepository, EfTenantAnnouncementRepository>();
services.AddScoped<ITenantAnnouncementReadRepository, EfTenantAnnouncementReadRepository>();
services.AddScoped<ITenantNotificationRepository, EfTenantNotificationRepository>();
services.AddScoped<ITenantPackageRepository, EfTenantPackageRepository>();
services.AddScoped<ITenantQuotaUsageRepository, EfTenantQuotaUsageRepository>();
services.AddScoped<ITenantQuotaUsageHistoryRepository, EfTenantQuotaUsageHistoryRepository>();
services.AddScoped<ITenantVisibilityRoleRuleRepository, TenantVisibilityRoleRuleRepository>();
services.AddScoped<IInventoryRepository, EfInventoryRepository>();
services.AddScoped<IQuotaPackageRepository, EfQuotaPackageRepository>();
services.AddScoped<IStatisticsRepository, EfStatisticsRepository>();
services.AddScoped<ISubscriptionRepository, EfSubscriptionRepository>();
services.AddScoped<IOperationLogRepository, EfOperationLogRepository>();
// 1. 账单领域/导出服务
services.AddScoped<IBillingDomainService, BillingDomainService>();
services.AddScoped<IBillingExportService, BillingExportService>();
services.AddScoped<IMerchantExportService, MerchantExportService>();
// 2. (空行后) 门店配置服务
services.AddScoped<IGeoJsonValidationService, GeoJsonValidationService>();
services.AddScoped<IDeliveryZoneService, DeliveryZoneService>();
services.AddScoped<IStoreFeeCalculationService, StoreFeeCalculationService>();
services.AddScoped<IStoreSchedulerService, StoreSchedulerService>();
// 3. (空行后) 初始化配置与种子
services.AddOptions<AppSeedOptions>()
.Bind(configuration.GetSection(AppSeedOptions.SectionName))
.ValidateDataAnnotations();
services.AddHostedService<AppDataSeeder>();
return services;
}
private static string ResolveAppDatabaseConnectionString(IConfiguration configuration)
{
// 1. 优先读取新结构配置
var writeConnection = configuration["Database:DataSources:AppDatabase:Write"];
if (!string.IsNullOrWhiteSpace(writeConnection))
{
return writeConnection;
}
// 2. 兼容 ConnectionStrings 配置
var fallbackConnection = configuration.GetConnectionString("AppDatabase");
if (!string.IsNullOrWhiteSpace(fallbackConnection))
{
return fallbackConnection;
}
// 3. 未配置时抛出异常
throw new InvalidOperationException("缺少业务库连接配置Database:DataSources:AppDatabase:Write");
}
}

View File

@@ -0,0 +1,32 @@
namespace TakeoutSaaS.Infrastructure.App.Options;
/// <summary>
/// 业务数据种子配置。
/// </summary>
public sealed class AppSeedOptions
{
/// <summary>
/// 配置节名称。
/// </summary>
public const string SectionName = "App:Seed";
/// <summary>
/// 是否启用业务数据种子。
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// 默认租户配置。
/// </summary>
public TenantSeedOptions? DefaultTenant { get; set; }
/// <summary>
/// 基础字典分组。
/// </summary>
public List<DictionarySeedGroupOptions> DictionaryGroups { get; set; } = new();
/// <summary>
/// 系统参数配置。
/// </summary>
public List<SystemParameterSeedOptions> SystemParameters { get; set; } = new();
}

View File

@@ -0,0 +1,50 @@
using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Domain.Dictionary.Enums;
namespace TakeoutSaaS.Infrastructure.App.Options;
/// <summary>
/// 字典分组种子配置。
/// </summary>
public sealed class DictionarySeedGroupOptions
{
/// <summary>
/// 所属租户,不填则使用默认租户或系统租户。
/// </summary>
public long? TenantId { get; set; }
/// <summary>
/// 分组编码。
/// </summary>
[Required]
[MaxLength(64)]
public string Code { get; set; } = string.Empty;
/// <summary>
/// 分组名称。
/// </summary>
[Required]
[MaxLength(128)]
public string Name { get; set; } = string.Empty;
/// <summary>
/// 分组作用域。
/// </summary>
public DictionaryScope Scope { get; set; } = DictionaryScope.Business;
/// <summary>
/// 描述信息。
/// </summary>
[MaxLength(512)]
public string? Description { get; set; }
/// <summary>
/// 是否启用。
/// </summary>
public bool IsEnabled { get; set; } = true;
/// <summary>
/// 字典项集合。
/// </summary>
public List<DictionarySeedItemOptions> Items { get; set; } = new();
}

View File

@@ -0,0 +1,39 @@
using System.ComponentModel.DataAnnotations;
namespace TakeoutSaaS.Infrastructure.App.Options;
/// <summary>
/// 字典项种子配置。
/// </summary>
public sealed class DictionarySeedItemOptions
{
/// <summary>
/// 字典项键。
/// </summary>
[Required]
[MaxLength(64)]
public string Key { get; set; } = string.Empty;
/// <summary>
/// 字典项值。
/// </summary>
[Required]
[MaxLength(256)]
public string Value { get; set; } = string.Empty;
/// <summary>
/// 描述。
/// </summary>
[MaxLength(512)]
public string? Description { get; set; }
/// <summary>
/// 排序。
/// </summary>
public int SortOrder { get; set; } = 100;
/// <summary>
/// 是否启用。
/// </summary>
public bool IsEnabled { get; set; } = true;
}

View File

@@ -0,0 +1,37 @@
namespace TakeoutSaaS.Infrastructure.App.Options;
/// <summary>
/// 系统参数种子配置项。
/// </summary>
public sealed class SystemParameterSeedOptions
{
/// <summary>
/// 目标租户null 时使用默认租户或 0。
/// </summary>
public long? TenantId { get; set; }
/// <summary>
/// 参数键。
/// </summary>
public string Key { get; set; } = string.Empty;
/// <summary>
/// 参数值。
/// </summary>
public string Value { get; set; } = string.Empty;
/// <summary>
/// 说明。
/// </summary>
public string? Description { get; set; }
/// <summary>
/// 排序。
/// </summary>
public int SortOrder { get; set; }
/// <summary>
/// 是否启用。
/// </summary>
public bool IsEnabled { get; set; } = true;
}

View File

@@ -0,0 +1,46 @@
using System.ComponentModel.DataAnnotations;
namespace TakeoutSaaS.Infrastructure.App.Options;
/// <summary>
/// 默认租户种子配置。
/// </summary>
public sealed class TenantSeedOptions
{
/// <summary>
/// 自定义租户标识,不填则自动生成。
/// </summary>
public long TenantId { get; set; }
/// <summary>
/// 租户编码。
/// </summary>
[Required]
[MaxLength(64)]
public string Code { get; set; } = string.Empty;
/// <summary>
/// 租户名称。
/// </summary>
[Required]
[MaxLength(128)]
public string Name { get; set; } = string.Empty;
/// <summary>
/// 租户简称。
/// </summary>
[MaxLength(128)]
public string? ShortName { get; set; }
/// <summary>
/// 联系人姓名。
/// </summary>
[MaxLength(64)]
public string? ContactName { get; set; }
/// <summary>
/// 联系电话。
/// </summary>
[MaxLength(32)]
public string? ContactPhone { get; set; }
}

View File

@@ -0,0 +1,494 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using TakeoutSaaS.Domain.Dictionary.Entities;
using TakeoutSaaS.Domain.SystemParameters.Entities;
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;
/// <summary>
/// 业务数据种子,确保默认租户与基础字典可重复执行。
/// </summary>
/// <remarks>
/// 初始化种子服务。
/// </remarks>
public sealed class AppDataSeeder(
IServiceProvider serviceProvider,
ILogger<AppDataSeeder> logger,
IOptions<AppSeedOptions> options) : IHostedService
{
private readonly AppSeedOptions _options = options.Value;
/// <inheritdoc />
public async Task StartAsync(CancellationToken cancellationToken)
{
if (!_options.Enabled)
{
logger.LogInformation("AppSeed 未启用,跳过业务数据初始化");
return;
}
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, tenantContextAccessor, defaultTenantId, cancellationToken);
logger.LogInformation("AppSeed 完成业务数据初始化");
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <summary>
/// 确保默认租户存在。
/// </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))
{
logger.LogInformation("AppSeed 未配置默认租户,跳过租户种子");
return null;
}
var code = tenantOptions.Code.Trim();
var existingTenant = await dbContext.Tenants
.FirstOrDefaultAsync(x => x.Code == code, cancellationToken);
if (existingTenant == null)
{
var tenant = new Tenant
{
Id = tenantOptions.TenantId,
Code = code,
Name = tenantOptions.Name.Trim(),
ShortName = tenantOptions.ShortName?.Trim(),
ContactName = tenantOptions.ContactName?.Trim(),
ContactPhone = tenantOptions.ContactPhone?.Trim(),
Status = TenantStatus.Active
};
await dbContext.Tenants.AddAsync(tenant, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
logger.LogInformation("AppSeed 已创建默认租户 {TenantCode}", code);
return tenant.Id;
}
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();
updated = true;
}
if (!string.Equals(existingTenant.ShortName, tenantOptions.ShortName, StringComparison.Ordinal))
{
existingTenant.ShortName = tenantOptions.ShortName?.Trim();
updated = true;
}
if (!string.Equals(existingTenant.ContactName, tenantOptions.ContactName, StringComparison.Ordinal))
{
existingTenant.ContactName = tenantOptions.ContactName?.Trim();
updated = true;
}
if (!string.Equals(existingTenant.ContactPhone, tenantOptions.ContactPhone, StringComparison.Ordinal))
{
existingTenant.ContactPhone = tenantOptions.ContactPhone?.Trim();
updated = true;
}
if (existingTenant.Status != TenantStatus.Active)
{
existingTenant.Status = TenantStatus.Active;
updated = true;
}
if (updated)
{
dbContext.Tenants.Update(existingTenant);
await dbContext.SaveChangesAsync(cancellationToken);
logger.LogInformation("AppSeed 已更新默认租户 {TenantCode}", code);
}
else
{
logger.LogInformation("AppSeed 默认租户 {TenantCode} 已存在且无需更新", code);
}
return existingTenant.Id;
}
/// <summary>
/// 确保系统租户存在TenantId=0用于系统级数据归属
/// </summary>
private async Task EnsureSystemTenantAsync(TakeoutAppDbContext dbContext, CancellationToken cancellationToken)
{
using var _ = dbContext.DisableSoftDeleteFilter();
var existingTenant = await dbContext.Tenants
.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";
updated = true;
}
if (!string.Equals(existingTenant.Name, "System", StringComparison.Ordinal))
{
existingTenant.Name = "System";
updated = true;
}
if (existingTenant.Status != TenantStatus.Active)
{
existingTenant.Status = TenantStatus.Active;
updated = true;
}
if (updated)
{
dbContext.Tenants.Update(existingTenant);
await dbContext.SaveChangesAsync(cancellationToken);
logger.LogInformation("AppSeed 已更新系统租户 SYSTEM");
}
return;
}
var tenant = new Tenant
{
Id = 0,
Code = "SYSTEM",
Name = "System",
Status = TenantStatus.Active
};
await dbContext.Tenants.AddAsync(tenant, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
logger.LogInformation("AppSeed 已创建系统租户 SYSTEM");
}
/// <summary>
/// 确保基础字典存在。
/// </summary>
private async Task EnsureDictionarySeedsAsync(
DictionaryDbContext dbContext,
ITenantContextAccessor tenantContextAccessor,
long? defaultTenantId,
CancellationToken cancellationToken)
{
var dictionaryGroups = _options.DictionaryGroups ?? new List<DictionarySeedGroupOptions>();
var hasDictionaryGroups = dictionaryGroups.Count > 0;
if (!hasDictionaryGroups)
{
logger.LogInformation("AppSeed 未配置基础字典,跳过字典种子");
}
if (hasDictionaryGroups)
{
foreach (var groupOptions in dictionaryGroups)
{
if (string.IsNullOrWhiteSpace(groupOptions.Code) || string.IsNullOrWhiteSpace(groupOptions.Name))
{
logger.LogWarning("AppSeed 跳过字典分组Code 或 Name 为空");
continue;
}
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
.FirstOrDefaultAsync(x => x.Code == code, cancellationToken);
if (group == null)
{
group = new DictionaryGroup
{
Id = 0,
TenantId = tenantId,
Code = code,
Name = groupOptions.Name.Trim(),
Scope = groupOptions.Scope,
Description = groupOptions.Description?.Trim(),
IsEnabled = groupOptions.IsEnabled
};
await dbContext.DictionaryGroups.AddAsync(group, cancellationToken);
logger.LogInformation("AppSeed 创建字典分组 {GroupCode} (Tenant: {TenantId})", code, tenantId);
}
else
{
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();
groupUpdated = true;
}
if (!string.Equals(group.Description, groupOptions.Description, StringComparison.Ordinal))
{
group.Description = groupOptions.Description?.Trim();
groupUpdated = true;
}
if (group.Scope != groupOptions.Scope)
{
group.Scope = groupOptions.Scope;
groupUpdated = true;
}
if (group.IsEnabled != groupOptions.IsEnabled)
{
group.IsEnabled = groupOptions.IsEnabled;
groupUpdated = true;
}
if (groupUpdated)
{
dbContext.DictionaryGroups.Update(group);
}
}
await UpsertDictionaryItemsAsync(dbContext, group, groupOptions.Items, tenantId, cancellationToken);
}
}
await EnsureSystemParametersAsync(dbContext, tenantContextAccessor, defaultTenantId, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
}
/// <summary>
/// 确保系统参数以独立表形式可重复种子。
/// </summary>
private async Task EnsureSystemParametersAsync(
DictionaryDbContext dbContext,
ITenantContextAccessor tenantContextAccessor,
long? defaultTenantId,
CancellationToken cancellationToken)
{
var systemParameters = _options.SystemParameters ?? new List<SystemParameterSeedOptions>();
if (systemParameters.Count == 0)
{
logger.LogInformation("AppSeed 未配置系统参数,跳过系统参数种子");
return;
}
var grouped = systemParameters
.Where(x => !string.IsNullOrWhiteSpace(x.Key) && !string.IsNullOrWhiteSpace(x.Value))
.GroupBy(x => x.TenantId ?? defaultTenantId ?? 0);
if (!grouped.Any())
{
logger.LogInformation("AppSeed 系统参数配置为空,跳过系统参数种子");
return;
}
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
.ToListAsync(cancellationToken);
foreach (var seed in group)
{
var key = seed.Key.Trim();
var existing = existingParameters.FirstOrDefault(x => x.Key.Equals(key, StringComparison.OrdinalIgnoreCase));
if (existing == null)
{
var parameter = new SystemParameter
{
Id = 0,
TenantId = tenantId,
Key = key,
Value = seed.Value.Trim(),
Description = seed.Description?.Trim(),
SortOrder = seed.SortOrder,
IsEnabled = seed.IsEnabled
};
await dbContext.SystemParameters.AddAsync(parameter, cancellationToken);
continue;
}
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();
updated = true;
}
if (!string.Equals(existing.Description, seed.Description, StringComparison.Ordinal))
{
existing.Description = seed.Description?.Trim();
updated = true;
}
if (existing.SortOrder != seed.SortOrder)
{
existing.SortOrder = seed.SortOrder;
updated = true;
}
if (existing.IsEnabled != seed.IsEnabled)
{
existing.IsEnabled = seed.IsEnabled;
updated = true;
}
if (updated)
{
dbContext.SystemParameters.Update(existing);
}
}
}
}
/// <summary>
/// 合并字典项。
/// </summary>
private static async Task UpsertDictionaryItemsAsync(
DictionaryDbContext dbContext,
DictionaryGroup group,
IEnumerable<DictionarySeedItemOptions> seedItems,
long tenantId,
CancellationToken cancellationToken)
{
// 确保分组已持久化以获取正确的主键,避免 FK 约束报错。
if (!dbContext.Entry(group).IsKeySet || group.Id == 0)
{
await dbContext.SaveChangesAsync(cancellationToken);
}
var materializedItems = seedItems
.Where(item => !string.IsNullOrWhiteSpace(item.Key) && !string.IsNullOrWhiteSpace(item.Value))
.ToList();
if (materializedItems.Count == 0)
{
return;
}
var existingItems = await dbContext.DictionaryItems
.Where(x => x.GroupId == group.Id)
.ToListAsync(cancellationToken);
foreach (var seed in materializedItems)
{
var key = seed.Key.Trim();
var existing = existingItems.FirstOrDefault(x => x.Key.Equals(key, StringComparison.OrdinalIgnoreCase));
if (existing == null)
{
var newItem = new DictionaryItem
{
Id = 0,
TenantId = tenantId,
GroupId = group.Id,
Key = key,
Value = seed.Value.Trim(),
Description = seed.Description?.Trim(),
SortOrder = seed.SortOrder,
IsEnabled = seed.IsEnabled
};
await dbContext.DictionaryItems.AddAsync(newItem, cancellationToken);
continue;
}
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();
updated = true;
}
if (!string.Equals(existing.Description, seed.Description, StringComparison.Ordinal))
{
existing.Description = seed.Description?.Trim();
updated = true;
}
if (existing.SortOrder != seed.SortOrder)
{
existing.SortOrder = seed.SortOrder;
updated = true;
}
if (existing.IsEnabled != seed.IsEnabled)
{
existing.IsEnabled = seed.IsEnabled;
updated = true;
}
if (updated)
{
dbContext.DictionaryItems.Update(existing);
}
}
}
}

View File

@@ -0,0 +1,51 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
namespace TakeoutSaaS.Infrastructure.App.Persistence.Configurations;
/// <summary>
/// <see cref="TenantBillingStatement"/> EF Core 映射配置。
/// </summary>
public sealed class TenantBillingStatementConfiguration : IEntityTypeConfiguration<TenantBillingStatement>
{
/// <inheritdoc />
public void Configure(EntityTypeBuilder<TenantBillingStatement> builder)
{
builder.ToTable("tenant_billing_statements");
builder.HasKey(x => x.Id);
// 1. 字段约束
builder.Property(x => x.StatementNo).HasMaxLength(64).IsRequired();
builder.Property(x => x.BillingType).HasConversion<int>();
builder.Property(x => x.AmountDue).HasPrecision(18, 2);
builder.Property(x => x.DiscountAmount).HasPrecision(18, 2);
builder.Property(x => x.TaxAmount).HasPrecision(18, 2);
builder.Property(x => x.AmountPaid).HasPrecision(18, 2);
builder.Property(x => x.Currency).HasMaxLength(8).HasDefaultValue("CNY");
builder.Property(x => x.Status).HasConversion<int>();
// 2. JSON 字段(当前以 text 存储 JSON 字符串,便于兼容历史迁移)
builder.Property(x => x.LineItemsJson).HasColumnType("text");
// 3. 备注字段
builder.Property(x => x.Notes).HasMaxLength(512);
// 4. 唯一约束与索引
builder.HasIndex(x => new { x.TenantId, x.StatementNo }).IsUnique();
// 5. 性能索引(高频查询:租户+状态+到期日)
builder.HasIndex(x => new { x.TenantId, x.Status, x.DueDate })
.HasDatabaseName("idx_billing_tenant_status_duedate");
// 6. 逾期扫描索引(仅索引 Pending/Overdue
builder.HasIndex(x => new { x.Status, x.DueDate })
.HasDatabaseName("idx_billing_status_duedate")
.HasFilter($"\"Status\" IN ({(int)TenantBillingStatus.Pending}, {(int)TenantBillingStatus.Overdue})");
// 7. 创建时间索引(支持列表倒序)
builder.HasIndex(x => x.CreatedAt)
.HasDatabaseName("idx_billing_created_at");
}
}

View File

@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using TakeoutSaaS.Domain.Tenants.Entities;
namespace TakeoutSaaS.Infrastructure.App.Persistence.Configurations;
/// <summary>
/// <see cref="TenantPayment"/> EF Core 映射配置。
/// </summary>
public sealed class TenantPaymentConfiguration : IEntityTypeConfiguration<TenantPayment>
{
/// <inheritdoc />
public void Configure(EntityTypeBuilder<TenantPayment> builder)
{
builder.ToTable("tenant_payments");
builder.HasKey(x => x.Id);
// 1. 字段约束
builder.Property(x => x.BillingStatementId).IsRequired();
builder.Property(x => x.Amount).HasPrecision(18, 2).IsRequired();
builder.Property(x => x.Method).HasConversion<int>();
builder.Property(x => x.Status).HasConversion<int>();
builder.Property(x => x.TransactionNo).HasMaxLength(64);
builder.Property(x => x.ProofUrl).HasMaxLength(512);
builder.Property(x => x.RefundReason).HasMaxLength(512);
builder.Property(x => x.Notes).HasMaxLength(512);
// 2. 复合索引:租户+账单
builder.HasIndex(x => new { x.TenantId, x.BillingStatementId });
// 3. 支付记录时间排序索引
builder.HasIndex(x => new { x.BillingStatementId, x.PaidAt })
.HasDatabaseName("idx_payment_billing_paidat");
// 4. 交易号索引(部分索引:仅非空)
builder.HasIndex(x => x.TransactionNo)
.HasDatabaseName("idx_payment_transaction_no")
.HasFilter("\"TransactionNo\" IS NOT NULL");
}
}

View File

@@ -0,0 +1,655 @@
using Microsoft.EntityFrameworkCore;
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, 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,
TenantBillingStatus? status,
DateTime? from,
DateTime? to,
CancellationToken cancellationToken = default)
{
// 1. 构建基础查询:在当前租户上下文内查询
var query = context.TenantBillingStatements
.AsNoTracking()
.Where(x => x.DeletedAt == null && x.TenantId == tenantId);
// 2. 按状态过滤
if (status.HasValue)
{
query = query.Where(x => x.Status == status.Value);
}
// 3. 按日期范围过滤(账单周期)
if (from.HasValue)
{
query = query.Where(x => x.PeriodStart >= from.Value);
}
if (to.HasValue)
{
query = query.Where(x => x.PeriodEnd <= to.Value);
}
// 4. 排序返回
return await query
.OrderByDescending(x => x.PeriodEnd)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public Task<TenantBillingStatement?> FindByIdAsync(long tenantId, long billingId, CancellationToken cancellationToken = default)
{
return context.TenantBillingStatements
.AsNoTracking()
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.TenantId == tenantId && x.Id == billingId, cancellationToken);
}
/// <inheritdoc />
public Task<TenantBillingStatement?> FindByStatementNoAsync(long tenantId, string statementNo, CancellationToken cancellationToken = default)
{
var normalized = statementNo.Trim();
return context.TenantBillingStatements
.AsNoTracking()
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.TenantId == tenantId && x.StatementNo == normalized, cancellationToken);
}
/// <inheritdoc />
public Task<TenantBillingStatement?> GetByStatementNoAsync(string statementNo, CancellationToken cancellationToken = default)
{
var normalized = statementNo.Trim();
return GetCurrentTenantId() == 0
? GetByStatementNoCrossTenantAsync(normalized, cancellationToken)
: context.TenantBillingStatements
.AsNoTracking()
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.StatementNo == normalized, cancellationToken);
}
/// <inheritdoc />
public Task<bool> ExistsNotCancelledByPeriodStartAsync(
long tenantId,
DateTime periodStart,
CancellationToken cancellationToken = default)
{
return context.TenantBillingStatements
.AsNoTracking()
.AnyAsync(
x => x.DeletedAt == null
&& x.TenantId == tenantId
&& x.PeriodStart == periodStart
&& x.Status != TenantBillingStatus.Cancelled,
cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TenantBillingStatement>> GetOverdueBillingsAsync(CancellationToken cancellationToken = default)
{
// 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. (空行后) 系统上下文:逐租户查询逾期账单并合并
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)
.ToList();
}
/// <inheritdoc />
public async Task<IReadOnlyList<TenantBillingStatement>> GetBillingsDueSoonAsync(int daysAhead, CancellationToken cancellationToken = default)
{
// 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. (空行后) 系统上下文:逐租户查询即将到期账单并合并
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)
.ToList();
}
/// <inheritdoc />
public async Task<IReadOnlyList<TenantBillingStatement>> GetByTenantIdAsync(long tenantId, CancellationToken cancellationToken = default)
{
return await context.TenantBillingStatements
.AsNoTracking()
.Where(x => x.DeletedAt == null && x.TenantId == tenantId)
.OrderByDescending(x => x.PeriodEnd)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TenantBillingStatement>> GetByIdsAsync(IReadOnlyCollection<long> billingIds, CancellationToken cancellationToken = default)
{
if (billingIds.Count == 0)
{
return Array.Empty<TenantBillingStatement>();
}
// 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)
.ToList();
}
/// <inheritdoc />
public Task AddAsync(TenantBillingStatement bill, CancellationToken cancellationToken = default)
{
return context.TenantBillingStatements.AddAsync(bill, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateAsync(TenantBillingStatement bill, CancellationToken cancellationToken = default)
{
context.TenantBillingStatements.Update(bill);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
return context.SaveChangesAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<(IReadOnlyList<TenantBillingStatement> Items, int Total)> SearchPagedAsync(
long? tenantId,
TenantBillingStatus? status,
DateTime? from,
DateTime? to,
decimal? minAmount,
decimal? maxAmount,
string? keyword,
int pageNumber,
int pageSize,
CancellationToken cancellationToken = default)
{
var normalizedPageNumber = pageNumber <= 0 ? 1 : pageNumber;
var normalizedPageSize = pageSize <= 0 ? 20 : pageSize;
var skip = (normalizedPageNumber - 1) * normalizedPageSize;
var takePerTenant = normalizedPageNumber * normalizedPageSize;
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)
{
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. (空行后) 跨租户分页:逐租户取 Top(N) 后合并排序再分页
var normalizedKeyword = string.IsNullOrWhiteSpace(keyword) ? null : keyword.Trim();
var tenantIds = await GetActiveTenantIdsAsync(cancellationToken);
if (tenantIds.Count == 0)
{
return ([], 0);
}
HashSet<long>? keywordMatchedTenantIds = null;
if (!string.IsNullOrWhiteSpace(normalizedKeyword))
{
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();
}
var totalCount = 0;
var collected = new List<TenantBillingStatement>();
foreach (var tid in tenantIds)
{
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);
}
}
var pageItems = collected
.OrderByDescending(x => x.PeriodEnd)
.Skip(skip)
.Take(normalizedPageSize)
.ToList();
return (pageItems, totalCount);
}
/// <inheritdoc />
public async Task<TenantBillingStatistics> GetStatisticsAsync(
long? tenantId,
DateTime startDate,
DateTime endDate,
string groupBy,
CancellationToken cancellationToken = default)
{
// 1. 统一时间基准与分组方式
var now = DateTime.UtcNow;
var normalizedGroupBy = NormalizeGroupBy(groupBy);
var currentTenantId = GetCurrentTenantId();
// 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
{
Period = g.Key,
Count = g.Count(),
TotalAmount = g.Sum(x => x.AmountDue - x.DiscountAmount + x.TaxAmount),
PaidAmount = g.Sum(x => x.AmountPaid)
})
.OrderBy(x => x.Period)
.ToList();
return new TenantBillingStatistics
{
TotalAmount = totalAmount,
PaidAmount = paidAmount,
UnpaidAmount = unpaidAmount,
OverdueAmount = overdueAmount,
TotalCount = totalCount,
PaidCount = paidCount,
UnpaidCount = unpaidCount,
OverdueCount = overdueCount,
TrendData = trend
};
}
/// <inheritdoc />
public Task<TenantBillingStatement?> FindByIdAsync(long billingId, CancellationToken cancellationToken = default)
{
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()
.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)
{
return groupBy.Trim() switch
{
"Week" => "Week",
"Month" => "Month",
_ => "Day"
};
}
private static DateTime GetTrendBucket(DateTime periodStart, string groupBy)
{
var date = periodStart.Date;
return groupBy switch
{
"Month" => new DateTime(date.Year, date.Month, 1, 0, 0, 0, DateTimeKind.Utc),
"Week" => GetWeekStart(date),
_ => new DateTime(date.Year, date.Month, date.Day, 0, 0, 0, DateTimeKind.Utc)
};
}
private static DateTime GetWeekStart(DateTime date)
{
// 1. 将周一作为一周起始(与 PostgreSQL date_trunc('week', ...) 对齐)
var dayOfWeek = (int)date.DayOfWeek; // Sunday=0, Monday=1, ...
var daysSinceMonday = (dayOfWeek + 6) % 7;
// 2. 回退到周一 00:00:00UTC
var monday = date.AddDays(-daysSinceMonday);
return new DateTime(monday.Year, monday.Month, monday.Day, 0, 0, 0, DateTimeKind.Utc);
}
}

View File

@@ -0,0 +1,105 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Persistence.Repositories;
/// <summary>
/// 租户支付记录仓储实现EF Core
/// </summary>
public sealed class TenantPaymentRepository(TakeoutAppDbContext context) : ITenantPaymentRepository
{
/// <inheritdoc />
public async Task<(IReadOnlyList<TenantPayment> Items, int Total)> SearchPagedAsync(
long tenantId,
DateTime from,
DateTime to,
int page,
int pageSize,
CancellationToken cancellationToken = default)
{
// 1. 归一化分页参数
var normalizedPage = page <= 0 ? 1 : page;
var normalizedPageSize = pageSize <= 0 ? 20 : pageSize;
// 2. 构建查询(按支付时间倒序)
var query = context.TenantPayments
.AsNoTracking()
.Where(x => x.DeletedAt == null
&& x.TenantId == tenantId
&& (x.PaidAt ?? x.CreatedAt) >= from
&& (x.PaidAt ?? x.CreatedAt) <= to);
// 3. 执行分页
var total = await query.CountAsync(cancellationToken);
var items = await query
.OrderByDescending(x => x.PaidAt ?? x.CreatedAt)
.Skip((normalizedPage - 1) * normalizedPageSize)
.Take(normalizedPageSize)
.ToListAsync(cancellationToken);
// 4. 返回分页结果
return (items, total);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TenantPayment>> GetByBillingIdAsync(long billingStatementId, CancellationToken cancellationToken = default)
{
return await context.TenantPayments
.AsNoTracking()
.Where(x => x.DeletedAt == null && x.BillingStatementId == billingStatementId)
.OrderByDescending(x => x.CreatedAt)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<decimal> GetTotalPaidAmountAsync(long billingStatementId, CancellationToken cancellationToken = default)
{
// 1. 仅统计支付成功的记录
return await context.TenantPayments
.AsNoTracking()
.Where(x => x.DeletedAt == null
&& x.BillingStatementId == billingStatementId
&& x.Status == TenantPaymentStatus.Success)
.SumAsync(x => x.Amount, cancellationToken);
}
/// <inheritdoc />
public Task<TenantPayment?> FindByIdAsync(long paymentId, CancellationToken cancellationToken = default)
{
return context.TenantPayments
.AsNoTracking()
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.Id == paymentId, cancellationToken);
}
/// <inheritdoc />
public Task<TenantPayment?> GetByTransactionNoAsync(string transactionNo, CancellationToken cancellationToken = default)
{
var normalized = transactionNo.Trim();
return context.TenantPayments
.AsNoTracking()
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.TransactionNo == normalized, cancellationToken);
}
/// <inheritdoc />
public Task AddAsync(TenantPayment payment, CancellationToken cancellationToken = default)
{
return context.TenantPayments.AddAsync(payment, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateAsync(TenantPayment payment, CancellationToken cancellationToken = default)
{
context.TenantPayments.Update(payment);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
return context.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,58 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Persistence.Repositories;
/// <summary>
/// 租户可见角色规则仓储实现。
/// </summary>
public sealed class TenantVisibilityRoleRuleRepository(TakeoutAppDbContext context) : ITenantVisibilityRoleRuleRepository
{
/// <summary>
/// 按租户获取规则。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>规则实体或 null。</returns>
public Task<TenantVisibilityRoleRule?> FindByTenantIdAsync(long tenantId, CancellationToken cancellationToken = default)
{
return context.TenantVisibilityRoleRules
.AsNoTracking()
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.DeletedAt == null, cancellationToken);
}
/// <summary>
/// 新增规则。
/// </summary>
/// <param name="rule">规则实体。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>异步任务。</returns>
public Task AddAsync(TenantVisibilityRoleRule rule, CancellationToken cancellationToken = default)
{
return context.TenantVisibilityRoleRules.AddAsync(rule, cancellationToken).AsTask();
}
/// <summary>
/// 更新规则。
/// </summary>
/// <param name="rule">规则实体。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>异步任务。</returns>
public Task UpdateAsync(TenantVisibilityRoleRule rule, CancellationToken cancellationToken = default)
{
context.TenantVisibilityRoleRules.Update(rule);
return Task.CompletedTask;
}
/// <summary>
/// 保存变更。
/// </summary>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>异步任务。</returns>
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
return context.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,35 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Infrastructure.Common.Persistence.DesignTime;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Security;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Infrastructure.App.Persistence;
/// <summary>
/// 设计时工厂,供 EF CLI 使用。
/// </summary>
internal sealed class TakeoutAppDesignTimeDbContextFactory
: DesignTimeDbContextFactoryBase<TakeoutAppDbContext>
{
/// <summary>
/// 初始化业务库设计时上下文工厂。
/// </summary>
public TakeoutAppDesignTimeDbContextFactory()
: base(DatabaseConstants.AppDataSource, "TAKEOUTSAAS_APP_CONNECTION")
{
}
// 创建设计时上下文
/// <summary>
/// 创建设计时的业务库 DbContext。
/// </summary>
/// <param name="options">上下文选项。</param>
/// <param name="tenantProvider">租户提供器。</param>
/// <param name="currentUserAccessor">当前用户访问器。</param>
/// <returns>业务库上下文实例。</returns>
protected override TakeoutAppDbContext CreateContext(
DbContextOptions<TakeoutAppDbContext> options,
ITenantProvider tenantProvider,
ICurrentUserAccessor currentUserAccessor)
=> new(options, tenantProvider, currentUserAccessor);
}

View File

@@ -0,0 +1,117 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Deliveries.Entities;
using TakeoutSaaS.Domain.Deliveries.Enums;
using TakeoutSaaS.Domain.Deliveries.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Repositories;
/// <summary>
/// 配送聚合的 EF Core 仓储实现。
/// </summary>
/// <remarks>
/// 初始化仓储。
/// </remarks>
public sealed class EfDeliveryRepository(TakeoutAppDbContext context) : IDeliveryRepository
{
/// <inheritdoc />
public Task<DeliveryOrder?> FindByIdAsync(long deliveryOrderId, long tenantId, CancellationToken cancellationToken = default)
{
return context.DeliveryOrders
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.Id == deliveryOrderId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public Task<DeliveryOrder?> FindByOrderIdAsync(long orderId, long tenantId, CancellationToken cancellationToken = default)
{
return context.DeliveryOrders
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.OrderId == orderId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<DeliveryEvent>> GetEventsAsync(long deliveryOrderId, long tenantId, CancellationToken cancellationToken = default)
{
var events = await context.DeliveryEvents
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.DeliveryOrderId == deliveryOrderId)
.OrderBy(x => x.CreatedAt)
.ToListAsync(cancellationToken);
return events;
}
/// <inheritdoc />
public Task AddDeliveryOrderAsync(DeliveryOrder deliveryOrder, CancellationToken cancellationToken = default)
{
return context.DeliveryOrders.AddAsync(deliveryOrder, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task AddEventAsync(DeliveryEvent deliveryEvent, CancellationToken cancellationToken = default)
{
return context.DeliveryEvents.AddAsync(deliveryEvent, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
return context.SaveChangesAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<DeliveryOrder>> SearchAsync(long tenantId, DeliveryStatus? status, long? orderId, CancellationToken cancellationToken = default)
{
var query = context.DeliveryOrders
.AsNoTracking()
.Where(x => x.TenantId == tenantId);
if (status.HasValue)
{
query = query.Where(x => x.Status == status.Value);
}
if (orderId.HasValue)
{
query = query.Where(x => x.OrderId == orderId.Value);
}
return await query
.OrderByDescending(x => x.CreatedAt)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public Task UpdateDeliveryOrderAsync(DeliveryOrder deliveryOrder, CancellationToken cancellationToken = default)
{
context.DeliveryOrders.Update(deliveryOrder);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task DeleteDeliveryOrderAsync(long deliveryOrderId, long tenantId, CancellationToken cancellationToken = default)
{
var events = await context.DeliveryEvents
.Where(x => x.TenantId == tenantId && x.DeliveryOrderId == deliveryOrderId)
.ToListAsync(cancellationToken);
if (events.Count > 0)
{
context.DeliveryEvents.RemoveRange(events);
}
var existing = await context.DeliveryOrders
.Where(x => x.TenantId == tenantId && x.Id == deliveryOrderId)
.FirstOrDefaultAsync(cancellationToken);
if (existing == null)
{
return;
}
context.DeliveryOrders.Remove(existing);
}
}

View File

@@ -0,0 +1,145 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Inventory.Entities;
using TakeoutSaaS.Domain.Inventory.Enums;
using TakeoutSaaS.Domain.Inventory.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Repositories;
/// <summary>
/// 库存仓储 EF 实现。
/// </summary>
/// <remarks>
/// 提供库存与批次的读写能力。
/// </remarks>
public sealed class EfInventoryRepository(TakeoutAppDbContext context) : IInventoryRepository
{
/// <inheritdoc />
public Task<InventoryItem?> FindByIdAsync(long inventoryItemId, long tenantId, CancellationToken cancellationToken = default)
{
return context.InventoryItems
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.Id == inventoryItemId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public Task<InventoryItem?> FindBySkuAsync(long tenantId, long storeId, long productSkuId, CancellationToken cancellationToken = default)
{
return context.InventoryItems
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.StoreId == storeId && x.ProductSkuId == productSkuId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public Task<InventoryItem?> GetForUpdateAsync(long tenantId, long storeId, long productSkuId, CancellationToken cancellationToken = default)
{
return context.InventoryItems
.Where(x => x.TenantId == tenantId && x.StoreId == storeId && x.ProductSkuId == productSkuId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public Task AddItemAsync(InventoryItem item, CancellationToken cancellationToken = default)
{
return context.InventoryItems.AddAsync(item, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateItemAsync(InventoryItem item, CancellationToken cancellationToken = default)
{
context.InventoryItems.Update(item);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task AddAdjustmentAsync(InventoryAdjustment adjustment, CancellationToken cancellationToken = default)
{
return context.InventoryAdjustments.AddAsync(adjustment, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task AddLockAsync(InventoryLockRecord lockRecord, CancellationToken cancellationToken = default)
{
return context.InventoryLockRecords.AddAsync(lockRecord, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task<InventoryLockRecord?> FindLockByKeyAsync(long tenantId, string idempotencyKey, CancellationToken cancellationToken = default)
{
return context.InventoryLockRecords
.Where(x => x.TenantId == tenantId && x.IdempotencyKey == idempotencyKey)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public Task MarkLockStatusAsync(InventoryLockRecord lockRecord, InventoryLockStatus status, CancellationToken cancellationToken = default)
{
lockRecord.Status = status;
context.InventoryLockRecords.Update(lockRecord);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task<IReadOnlyList<InventoryLockRecord>> FindExpiredLocksAsync(long tenantId, DateTime utcNow, CancellationToken cancellationToken = default)
{
var locks = await context.InventoryLockRecords
.Where(x => x.TenantId == tenantId && x.Status == InventoryLockStatus.Locked && x.ExpiresAt != null && x.ExpiresAt <= utcNow)
.ToListAsync(cancellationToken);
return locks;
}
/// <inheritdoc />
public async Task<IReadOnlyList<InventoryBatch>> GetBatchesForConsumeAsync(long tenantId, long storeId, long productSkuId, InventoryBatchConsumeStrategy strategy, CancellationToken cancellationToken = default)
{
var query = context.InventoryBatches
.Where(x => x.TenantId == tenantId && x.StoreId == storeId && x.ProductSkuId == productSkuId);
query = strategy == InventoryBatchConsumeStrategy.Fefo
? query.OrderBy(x => x.ExpireDate ?? DateTime.MaxValue).ThenBy(x => x.BatchNumber)
: query.OrderBy(x => x.BatchNumber);
return await query.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<InventoryBatch>> GetBatchesAsync(long tenantId, long storeId, long productSkuId, CancellationToken cancellationToken = default)
{
var batches = await context.InventoryBatches
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.StoreId == storeId && x.ProductSkuId == productSkuId)
.OrderBy(x => x.ExpireDate ?? DateTime.MaxValue)
.ThenBy(x => x.BatchNumber)
.ToListAsync(cancellationToken);
return batches;
}
/// <inheritdoc />
public Task<InventoryBatch?> GetBatchForUpdateAsync(long tenantId, long storeId, long productSkuId, string batchNumber, CancellationToken cancellationToken = default)
{
return context.InventoryBatches
.Where(x => x.TenantId == tenantId && x.StoreId == storeId && x.ProductSkuId == productSkuId && x.BatchNumber == batchNumber)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public Task AddBatchAsync(InventoryBatch batch, CancellationToken cancellationToken = default)
{
return context.InventoryBatches.AddAsync(batch, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateBatchAsync(InventoryBatch batch, CancellationToken cancellationToken = default)
{
context.InventoryBatches.Update(batch);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
return context.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,66 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Merchants.Entities;
using TakeoutSaaS.Domain.Merchants.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Repositories;
/// <summary>
/// 商户类目的 EF Core 仓储实现。
/// </summary>
public sealed class EfMerchantCategoryRepository(TakeoutAppDbContext context)
: IMerchantCategoryRepository
{
/// <inheritdoc />
public async Task<IReadOnlyList<MerchantCategory>> ListAsync(long tenantId, CancellationToken cancellationToken = default)
{
var items = await context.MerchantCategories
.AsNoTracking()
.Where(x => x.TenantId == tenantId)
.OrderBy(x => x.DisplayOrder)
.ThenBy(x => x.CreatedAt)
.ToListAsync(cancellationToken);
return items;
}
/// <inheritdoc />
public Task<bool> ExistsAsync(string name, long tenantId, CancellationToken cancellationToken = default)
{
return context.MerchantCategories.AnyAsync(
x => x.TenantId == tenantId && x.Name == name, cancellationToken);
}
/// <inheritdoc />
public Task<MerchantCategory?> FindByIdAsync(long id, long tenantId, CancellationToken cancellationToken = default)
{
return context.MerchantCategories
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == id, cancellationToken);
}
/// <inheritdoc />
public Task AddAsync(MerchantCategory category, CancellationToken cancellationToken = default)
{
return context.MerchantCategories.AddAsync(category, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task RemoveAsync(MerchantCategory category, CancellationToken cancellationToken = default)
{
context.MerchantCategories.Remove(category);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task UpdateRangeAsync(IEnumerable<MerchantCategory> categories, CancellationToken cancellationToken = default)
{
context.MerchantCategories.UpdateRange(categories);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
return context.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,288 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Common.Enums;
using TakeoutSaaS.Domain.Merchants.Entities;
using TakeoutSaaS.Domain.Merchants.Enums;
using TakeoutSaaS.Domain.Merchants.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence;
using TakeoutSaaS.Infrastructure.Logs.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Repositories;
/// <summary>
/// 商户聚合的 EF Core 仓储实现。
/// </summary>
/// <remarks>
/// 初始化仓储。
/// </remarks>
public sealed class EfMerchantRepository(TakeoutAppDbContext context, TakeoutLogsDbContext logsContext) : IMerchantRepository
{
/// <inheritdoc />
public Task<Merchant?> FindByIdAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default)
{
return context.Merchants
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.Id == merchantId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public Task<Merchant?> GetForUpdateAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default)
{
return context.Merchants
.Where(x => x.TenantId == tenantId && x.Id == merchantId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<Merchant>> SearchAsync(long tenantId, MerchantStatus? status, CancellationToken cancellationToken = default)
{
var query = context.Merchants
.AsNoTracking()
.Where(x => x.TenantId == tenantId);
if (status.HasValue)
{
query = query.Where(x => x.Status == status.Value);
}
return await query
.OrderByDescending(x => x.CreatedAt)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<MerchantStaff>> GetStaffAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default)
{
var staffs = await context.MerchantStaff
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.MerchantId == merchantId)
.OrderBy(x => x.Name)
.ToListAsync(cancellationToken);
return staffs;
}
/// <inheritdoc />
public async Task<IReadOnlyList<MerchantStaff>> GetStaffByStoreAsync(long storeId, long tenantId, CancellationToken cancellationToken = default)
{
var staffs = await context.MerchantStaff
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.StoreId == storeId)
.OrderBy(x => x.Name)
.ToListAsync(cancellationToken);
return staffs;
}
/// <inheritdoc />
public Task<MerchantStaff?> FindStaffByIdAsync(long staffId, long tenantId, CancellationToken cancellationToken = default)
{
return context.MerchantStaff
.Where(x => x.TenantId == tenantId && x.Id == staffId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<MerchantContract>> GetContractsAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default)
{
var contracts = await context.MerchantContracts
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.MerchantId == merchantId)
.OrderByDescending(x => x.CreatedAt)
.ToListAsync(cancellationToken);
return contracts;
}
/// <inheritdoc />
public Task<MerchantContract?> FindContractByIdAsync(long merchantId, long tenantId, long contractId, CancellationToken cancellationToken = default)
{
return context.MerchantContracts
.Where(x => x.TenantId == tenantId && x.MerchantId == merchantId && x.Id == contractId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<MerchantDocument>> GetDocumentsAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default)
{
var documents = await context.MerchantDocuments
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.MerchantId == merchantId)
.OrderBy(x => x.CreatedAt)
.ToListAsync(cancellationToken);
return documents;
}
/// <inheritdoc />
public Task<MerchantDocument?> FindDocumentByIdAsync(long merchantId, long tenantId, long documentId, CancellationToken cancellationToken = default)
{
return context.MerchantDocuments
.Where(x => x.TenantId == tenantId && x.MerchantId == merchantId && x.Id == documentId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public Task AddMerchantAsync(Merchant merchant, CancellationToken cancellationToken = default)
{
return context.Merchants.AddAsync(merchant, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task AddStaffAsync(MerchantStaff staff, CancellationToken cancellationToken = default)
{
return context.MerchantStaff.AddAsync(staff, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task AddContractAsync(MerchantContract contract, CancellationToken cancellationToken = default)
{
return context.MerchantContracts.AddAsync(contract, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateContractAsync(MerchantContract contract, CancellationToken cancellationToken = default)
{
context.MerchantContracts.Update(contract);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task AddDocumentAsync(MerchantDocument document, CancellationToken cancellationToken = default)
{
return context.MerchantDocuments.AddAsync(document, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateDocumentAsync(MerchantDocument document, CancellationToken cancellationToken = default)
{
context.MerchantDocuments.Update(document);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
// 1. 保存业务库变更
await context.SaveChangesAsync(cancellationToken);
// 2. 保存日志库变更
await logsContext.SaveChangesAsync(cancellationToken);
}
/// <inheritdoc />
public Task UpdateMerchantAsync(Merchant merchant, CancellationToken cancellationToken = default)
{
context.Merchants.Update(merchant);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task DeleteMerchantAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default)
{
var existing = await context.Merchants
.Where(x => x.TenantId == tenantId && x.Id == merchantId)
.FirstOrDefaultAsync(cancellationToken);
if (existing == null)
{
return;
}
context.Merchants.Remove(existing);
}
/// <inheritdoc />
public async Task DeleteStaffAsync(long staffId, long tenantId, CancellationToken cancellationToken = default)
{
var existing = await context.MerchantStaff
.Where(x => x.TenantId == tenantId && x.Id == staffId)
.FirstOrDefaultAsync(cancellationToken);
if (existing == null)
{
return;
}
context.MerchantStaff.Remove(existing);
}
/// <inheritdoc />
public Task AddAuditLogAsync(MerchantAuditLog log, CancellationToken cancellationToken = default)
{
return logsContext.MerchantAuditLogs.AddAsync(log, cancellationToken).AsTask();
}
/// <inheritdoc />
public async Task<IReadOnlyList<MerchantAuditLog>> GetAuditLogsAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default)
{
return await logsContext.MerchantAuditLogs
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.MerchantId == merchantId)
.OrderByDescending(x => x.CreatedAt)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<Merchant>> SearchAsync(
long tenantId,
MerchantStatus? status,
OperatingMode? operatingMode,
string? keyword,
CancellationToken cancellationToken = default)
{
var query = context.Merchants
.AsNoTracking()
.Where(x => x.TenantId == tenantId)
.AsQueryable();
if (status.HasValue)
{
query = query.Where(x => x.Status == status.Value);
}
if (operatingMode.HasValue)
{
query = query.Where(x => x.OperatingMode == operatingMode.Value);
}
if (!string.IsNullOrWhiteSpace(keyword))
{
var normalized = keyword.Trim();
query = query.Where(x =>
EF.Functions.ILike(x.BrandName, $"%{normalized}%") ||
EF.Functions.ILike(x.BusinessLicenseNumber ?? string.Empty, $"%{normalized}%"));
}
return await query
.OrderByDescending(x => x.CreatedAt)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public Task AddChangeLogAsync(MerchantChangeLog log, CancellationToken cancellationToken = default)
{
return logsContext.MerchantChangeLogs.AddAsync(log, cancellationToken).AsTask();
}
/// <inheritdoc />
public async Task<IReadOnlyList<MerchantChangeLog>> GetChangeLogsAsync(
long merchantId,
long tenantId,
string? fieldName = null,
CancellationToken cancellationToken = default)
{
var query = logsContext.MerchantChangeLogs
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.MerchantId == merchantId);
if (!string.IsNullOrWhiteSpace(fieldName))
{
query = query.Where(x => x.FieldName == fieldName);
}
return await query
.OrderByDescending(x => x.CreatedAt)
.ToListAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,170 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Orders.Entities;
using TakeoutSaaS.Domain.Orders.Enums;
using TakeoutSaaS.Domain.Orders.Repositories;
using TakeoutSaaS.Domain.Payments.Enums;
using TakeoutSaaS.Infrastructure.App.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Repositories;
/// <summary>
/// 订单聚合的 EF Core 仓储实现。
/// </summary>
/// <remarks>
/// 初始化仓储。
/// </remarks>
public sealed class EfOrderRepository(TakeoutAppDbContext context) : IOrderRepository
{
/// <inheritdoc />
public Task<Order?> FindByIdAsync(long orderId, long tenantId, CancellationToken cancellationToken = default)
{
return context.Orders
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.Id == orderId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public Task<Order?> FindByOrderNoAsync(string orderNo, long tenantId, CancellationToken cancellationToken = default)
{
return context.Orders
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.OrderNo == orderNo)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<Order>> SearchAsync(long tenantId, OrderStatus? status, PaymentStatus? paymentStatus, CancellationToken cancellationToken = default)
{
var query = context.Orders
.AsNoTracking()
.Where(x => x.TenantId == tenantId);
if (status.HasValue)
{
query = query.Where(x => x.Status == status.Value);
}
if (paymentStatus.HasValue)
{
query = query.Where(x => x.PaymentStatus == paymentStatus.Value);
}
var orders = await query
.OrderByDescending(x => x.CreatedAt)
.ToListAsync(cancellationToken);
return orders;
}
/// <inheritdoc />
public async Task<IReadOnlyList<OrderItem>> GetItemsAsync(long orderId, long tenantId, CancellationToken cancellationToken = default)
{
var items = await context.OrderItems
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.OrderId == orderId)
.OrderBy(x => x.Id)
.ToListAsync(cancellationToken);
return items;
}
/// <inheritdoc />
public async Task<IReadOnlyList<OrderStatusHistory>> GetStatusHistoryAsync(long orderId, long tenantId, CancellationToken cancellationToken = default)
{
var histories = await context.OrderStatusHistories
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.OrderId == orderId)
.OrderBy(x => x.CreatedAt)
.ToListAsync(cancellationToken);
return histories;
}
/// <inheritdoc />
public async Task<IReadOnlyList<RefundRequest>> GetRefundsAsync(long orderId, long tenantId, CancellationToken cancellationToken = default)
{
var refunds = await context.RefundRequests
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.OrderId == orderId)
.OrderByDescending(x => x.CreatedAt)
.ToListAsync(cancellationToken);
return refunds;
}
/// <inheritdoc />
public Task AddOrderAsync(Order order, CancellationToken cancellationToken = default)
{
return context.Orders.AddAsync(order, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task AddItemsAsync(IEnumerable<OrderItem> items, CancellationToken cancellationToken = default)
{
return context.OrderItems.AddRangeAsync(items, cancellationToken);
}
/// <inheritdoc />
public Task AddStatusHistoryAsync(OrderStatusHistory history, CancellationToken cancellationToken = default)
{
return context.OrderStatusHistories.AddAsync(history, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task AddRefundAsync(RefundRequest refund, CancellationToken cancellationToken = default)
{
return context.RefundRequests.AddAsync(refund, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
return context.SaveChangesAsync(cancellationToken);
}
/// <inheritdoc />
public Task UpdateOrderAsync(Order order, CancellationToken cancellationToken = default)
{
context.Orders.Update(order);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task DeleteOrderAsync(long orderId, long tenantId, CancellationToken cancellationToken = default)
{
var items = await context.OrderItems
.Where(x => x.TenantId == tenantId && x.OrderId == orderId)
.ToListAsync(cancellationToken);
if (items.Count > 0)
{
context.OrderItems.RemoveRange(items);
}
var histories = await context.OrderStatusHistories
.Where(x => x.TenantId == tenantId && x.OrderId == orderId)
.ToListAsync(cancellationToken);
if (histories.Count > 0)
{
context.OrderStatusHistories.RemoveRange(histories);
}
var refunds = await context.RefundRequests
.Where(x => x.TenantId == tenantId && x.OrderId == orderId)
.ToListAsync(cancellationToken);
if (refunds.Count > 0)
{
context.RefundRequests.RemoveRange(refunds);
}
var existing = await context.Orders
.Where(x => x.TenantId == tenantId && x.Id == orderId)
.FirstOrDefaultAsync(cancellationToken);
if (existing == null)
{
return;
}
context.Orders.Remove(existing);
}
}

View File

@@ -0,0 +1,110 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Payments.Entities;
using TakeoutSaaS.Domain.Payments.Enums;
using TakeoutSaaS.Domain.Payments.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Repositories;
/// <summary>
/// 支付记录的 EF Core 仓储实现。
/// </summary>
/// <remarks>
/// 初始化仓储。
/// </remarks>
public sealed class EfPaymentRepository(TakeoutAppDbContext context) : IPaymentRepository
{
/// <inheritdoc />
public Task<PaymentRecord?> FindByIdAsync(long paymentId, long tenantId, CancellationToken cancellationToken = default)
{
return context.PaymentRecords
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.Id == paymentId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public Task<PaymentRecord?> FindByOrderIdAsync(long orderId, long tenantId, CancellationToken cancellationToken = default)
{
return context.PaymentRecords
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.OrderId == orderId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<PaymentRefundRecord>> GetRefundsAsync(long paymentId, long tenantId, CancellationToken cancellationToken = default)
{
var refunds = await context.PaymentRefundRecords
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.PaymentRecordId == paymentId)
.OrderByDescending(x => x.CreatedAt)
.ToListAsync(cancellationToken);
return refunds;
}
/// <inheritdoc />
public Task AddPaymentAsync(PaymentRecord payment, CancellationToken cancellationToken = default)
{
return context.PaymentRecords.AddAsync(payment, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task AddRefundAsync(PaymentRefundRecord refund, CancellationToken cancellationToken = default)
{
return context.PaymentRefundRecords.AddAsync(refund, cancellationToken).AsTask();
}
/// <inheritdoc />
public async Task<IReadOnlyList<PaymentRecord>> SearchAsync(long tenantId, PaymentStatus? status, CancellationToken cancellationToken = default)
{
var query = context.PaymentRecords
.AsNoTracking()
.Where(x => x.TenantId == tenantId);
if (status.HasValue)
{
query = query.Where(x => x.Status == status.Value);
}
return await query
.OrderByDescending(x => x.CreatedAt)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
return context.SaveChangesAsync(cancellationToken);
}
/// <inheritdoc />
public Task UpdatePaymentAsync(PaymentRecord payment, CancellationToken cancellationToken = default)
{
context.PaymentRecords.Update(payment);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task DeletePaymentAsync(long paymentId, long tenantId, CancellationToken cancellationToken = default)
{
var refunds = await context.PaymentRefundRecords
.Where(x => x.TenantId == tenantId && x.PaymentRecordId == paymentId)
.ToListAsync(cancellationToken);
if (refunds.Count > 0)
{
context.PaymentRefundRecords.RemoveRange(refunds);
}
var existing = await context.PaymentRecords
.Where(x => x.TenantId == tenantId && x.Id == paymentId)
.FirstOrDefaultAsync(cancellationToken);
if (existing == null)
{
return;
}
context.PaymentRecords.Remove(existing);
}
}

View File

@@ -0,0 +1,511 @@
using System;
using System.Linq;
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Products.Entities;
using TakeoutSaaS.Domain.Products.Enums;
using TakeoutSaaS.Domain.Products.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Repositories;
/// <summary>
/// 商品聚合的 EF Core 仓储实现。
/// </summary>
/// <remarks>
/// 初始化仓储。
/// </remarks>
public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductRepository
{
/// <inheritdoc />
public Task<Product?> FindByIdAsync(long productId, long tenantId, CancellationToken cancellationToken = default)
{
return context.Products
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.Id == productId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<Product>> SearchAsync(long tenantId, long? storeId, long? categoryId, ProductStatus? status, CancellationToken cancellationToken = default, DateTime? updatedAfter = null)
{
var query = context.Products
.AsNoTracking()
.Where(x => x.TenantId == tenantId);
if (storeId.HasValue)
{
query = query.Where(x => x.StoreId == storeId.Value);
}
if (categoryId.HasValue)
{
query = query.Where(x => x.CategoryId == categoryId.Value);
}
if (status.HasValue)
{
query = query.Where(x => x.Status == status.Value);
}
if (updatedAfter.HasValue)
{
query = query.Where(x => (x.UpdatedAt ?? x.CreatedAt) >= updatedAfter.Value);
}
var products = await query
.OrderBy(x => x.Name)
.ToListAsync(cancellationToken);
return products;
}
/// <inheritdoc />
public async Task<IReadOnlyList<ProductCategory>> GetCategoriesAsync(long tenantId, CancellationToken cancellationToken = default)
{
var categories = await context.ProductCategories
.AsNoTracking()
.Where(x => x.TenantId == tenantId)
.OrderBy(x => x.SortOrder)
.ToListAsync(cancellationToken);
return categories;
}
/// <inheritdoc />
public async Task<IReadOnlyList<ProductCategory>> GetCategoriesByStoreAsync(long tenantId, long storeId, bool onlyEnabled = true, CancellationToken cancellationToken = default)
{
var query = context.ProductCategories
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.StoreId == storeId);
if (onlyEnabled)
{
query = query.Where(x => x.IsEnabled);
}
var categories = await query
.OrderBy(x => x.SortOrder)
.ToListAsync(cancellationToken);
return categories;
}
/// <inheritdoc />
public async Task<IReadOnlyList<ProductSku>> GetSkusAsync(long productId, long tenantId, CancellationToken cancellationToken = default)
{
var skus = await context.ProductSkus
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.ProductId == productId)
.OrderBy(x => x.SortOrder)
.ToListAsync(cancellationToken);
return skus;
}
/// <inheritdoc />
public async Task<IReadOnlyList<ProductSku>> GetSkusByProductIdsAsync(IReadOnlyCollection<long> productIds, long tenantId, CancellationToken cancellationToken = default)
{
if (productIds.Count == 0)
{
return Array.Empty<ProductSku>();
}
var skus = await context.ProductSkus
.AsNoTracking()
.Where(x => x.TenantId == tenantId && productIds.Contains(x.ProductId))
.OrderBy(x => x.SortOrder)
.ToListAsync(cancellationToken);
return skus;
}
/// <inheritdoc />
public async Task<IReadOnlyList<ProductAddonGroup>> GetAddonGroupsAsync(long productId, long tenantId, CancellationToken cancellationToken = default)
{
var groups = await context.ProductAddonGroups
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.ProductId == productId)
.OrderBy(x => x.SortOrder)
.ToListAsync(cancellationToken);
return groups;
}
/// <inheritdoc />
public async Task<IReadOnlyList<ProductAddonGroup>> GetAddonGroupsByProductIdsAsync(IReadOnlyCollection<long> productIds, long tenantId, CancellationToken cancellationToken = default)
{
if (productIds.Count == 0)
{
return Array.Empty<ProductAddonGroup>();
}
var groups = await context.ProductAddonGroups
.AsNoTracking()
.Where(x => x.TenantId == tenantId && productIds.Contains(x.ProductId))
.OrderBy(x => x.SortOrder)
.ToListAsync(cancellationToken);
return groups;
}
/// <inheritdoc />
public async Task<IReadOnlyList<ProductAddonOption>> GetAddonOptionsAsync(long productId, long tenantId, CancellationToken cancellationToken = default)
{
var groupIds = await context.ProductAddonGroups
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.ProductId == productId)
.Select(x => x.Id)
.ToListAsync(cancellationToken);
if (groupIds.Count == 0)
{
return Array.Empty<ProductAddonOption>();
}
var options = await context.ProductAddonOptions
.AsNoTracking()
.Where(x => x.TenantId == tenantId && groupIds.Contains(x.AddonGroupId))
.OrderBy(x => x.SortOrder)
.ToListAsync(cancellationToken);
return options;
}
/// <inheritdoc />
public async Task<IReadOnlyList<ProductAddonOption>> GetAddonOptionsByGroupIdsAsync(IReadOnlyCollection<long> addonGroupIds, long tenantId, CancellationToken cancellationToken = default)
{
if (addonGroupIds.Count == 0)
{
return Array.Empty<ProductAddonOption>();
}
var options = await context.ProductAddonOptions
.AsNoTracking()
.Where(x => x.TenantId == tenantId && addonGroupIds.Contains(x.AddonGroupId))
.OrderBy(x => x.SortOrder)
.ToListAsync(cancellationToken);
return options;
}
/// <inheritdoc />
public async Task<IReadOnlyList<ProductAttributeGroup>> GetAttributeGroupsAsync(long productId, long tenantId, CancellationToken cancellationToken = default)
{
var groups = await context.ProductAttributeGroups
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.ProductId == productId)
.OrderBy(x => x.SortOrder)
.ToListAsync(cancellationToken);
return groups;
}
/// <inheritdoc />
public async Task<IReadOnlyList<ProductAttributeGroup>> GetAttributeGroupsByProductIdsAsync(IReadOnlyCollection<long> productIds, long tenantId, CancellationToken cancellationToken = default)
{
if (productIds.Count == 0)
{
return Array.Empty<ProductAttributeGroup>();
}
var groups = await context.ProductAttributeGroups
.AsNoTracking()
.Where(x => x.TenantId == tenantId && productIds.Contains(x.ProductId))
.OrderBy(x => x.SortOrder)
.ToListAsync(cancellationToken);
return groups;
}
/// <inheritdoc />
public async Task<IReadOnlyList<ProductAttributeOption>> GetAttributeOptionsAsync(long productId, long tenantId, CancellationToken cancellationToken = default)
{
var groupIds = await context.ProductAttributeGroups
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.ProductId == productId)
.Select(x => x.Id)
.ToListAsync(cancellationToken);
if (groupIds.Count == 0)
{
return Array.Empty<ProductAttributeOption>();
}
var options = await context.ProductAttributeOptions
.AsNoTracking()
.Where(x => x.TenantId == tenantId && groupIds.Contains(x.AttributeGroupId))
.OrderBy(x => x.SortOrder)
.ToListAsync(cancellationToken);
return options;
}
/// <inheritdoc />
public async Task<IReadOnlyList<ProductAttributeOption>> GetAttributeOptionsByGroupIdsAsync(IReadOnlyCollection<long> attributeGroupIds, long tenantId, CancellationToken cancellationToken = default)
{
if (attributeGroupIds.Count == 0)
{
return Array.Empty<ProductAttributeOption>();
}
var options = await context.ProductAttributeOptions
.AsNoTracking()
.Where(x => x.TenantId == tenantId && attributeGroupIds.Contains(x.AttributeGroupId))
.OrderBy(x => x.SortOrder)
.ToListAsync(cancellationToken);
return options;
}
/// <inheritdoc />
public async Task<IReadOnlyList<ProductMediaAsset>> GetMediaAssetsAsync(long productId, long tenantId, CancellationToken cancellationToken = default)
{
var assets = await context.ProductMediaAssets
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.ProductId == productId)
.OrderBy(x => x.SortOrder)
.ToListAsync(cancellationToken);
return assets;
}
/// <inheritdoc />
public async Task<IReadOnlyList<ProductMediaAsset>> GetMediaAssetsByProductIdsAsync(IReadOnlyCollection<long> productIds, long tenantId, CancellationToken cancellationToken = default)
{
if (productIds.Count == 0)
{
return Array.Empty<ProductMediaAsset>();
}
var assets = await context.ProductMediaAssets
.AsNoTracking()
.Where(x => x.TenantId == tenantId && productIds.Contains(x.ProductId))
.OrderBy(x => x.SortOrder)
.ToListAsync(cancellationToken);
return assets;
}
/// <inheritdoc />
public async Task<IReadOnlyList<ProductPricingRule>> GetPricingRulesAsync(long productId, long tenantId, CancellationToken cancellationToken = default)
{
var rules = await context.ProductPricingRules
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.ProductId == productId)
.OrderBy(x => x.SortOrder)
.ToListAsync(cancellationToken);
return rules;
}
/// <inheritdoc />
public async Task<IReadOnlyList<ProductPricingRule>> GetPricingRulesByProductIdsAsync(IReadOnlyCollection<long> productIds, long tenantId, CancellationToken cancellationToken = default)
{
if (productIds.Count == 0)
{
return Array.Empty<ProductPricingRule>();
}
var rules = await context.ProductPricingRules
.AsNoTracking()
.Where(x => x.TenantId == tenantId && productIds.Contains(x.ProductId))
.OrderBy(x => x.SortOrder)
.ToListAsync(cancellationToken);
return rules;
}
/// <inheritdoc />
public Task AddCategoryAsync(ProductCategory category, CancellationToken cancellationToken = default)
{
return context.ProductCategories.AddAsync(category, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task AddProductAsync(Product product, CancellationToken cancellationToken = default)
{
return context.Products.AddAsync(product, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task AddSkusAsync(IEnumerable<ProductSku> skus, CancellationToken cancellationToken = default)
{
return context.ProductSkus.AddRangeAsync(skus, cancellationToken);
}
/// <inheritdoc />
public Task AddAddonGroupsAsync(IEnumerable<ProductAddonGroup> groups, IEnumerable<ProductAddonOption> options, CancellationToken cancellationToken = default)
{
var addGroupsTask = context.ProductAddonGroups.AddRangeAsync(groups, cancellationToken);
var addOptionsTask = context.ProductAddonOptions.AddRangeAsync(options, cancellationToken);
return Task.WhenAll(addGroupsTask, addOptionsTask);
}
/// <inheritdoc />
public Task AddAttributeGroupsAsync(IEnumerable<ProductAttributeGroup> groups, IEnumerable<ProductAttributeOption> options, CancellationToken cancellationToken = default)
{
var addGroupsTask = context.ProductAttributeGroups.AddRangeAsync(groups, cancellationToken);
var addOptionsTask = context.ProductAttributeOptions.AddRangeAsync(options, cancellationToken);
return Task.WhenAll(addGroupsTask, addOptionsTask);
}
/// <inheritdoc />
public Task AddMediaAssetsAsync(IEnumerable<ProductMediaAsset> assets, CancellationToken cancellationToken = default)
{
return context.ProductMediaAssets.AddRangeAsync(assets, cancellationToken);
}
/// <inheritdoc />
public Task AddPricingRulesAsync(IEnumerable<ProductPricingRule> rules, CancellationToken cancellationToken = default)
{
return context.ProductPricingRules.AddRangeAsync(rules, cancellationToken);
}
/// <inheritdoc />
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
return context.SaveChangesAsync(cancellationToken);
}
/// <inheritdoc />
public Task UpdateProductAsync(Product product, CancellationToken cancellationToken = default)
{
context.Products.Update(product);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task DeleteProductAsync(long productId, long tenantId, CancellationToken cancellationToken = default)
{
await RemovePricingRulesAsync(productId, tenantId, cancellationToken);
await RemoveMediaAssetsAsync(productId, tenantId, cancellationToken);
await RemoveAttributeGroupsAsync(productId, tenantId, cancellationToken);
await RemoveAddonGroupsAsync(productId, tenantId, cancellationToken);
await RemoveSkusAsync(productId, tenantId, cancellationToken);
var existing = await context.Products
.Where(x => x.TenantId == tenantId && x.Id == productId)
.FirstOrDefaultAsync(cancellationToken);
if (existing == null)
{
return;
}
context.Products.Remove(existing);
}
/// <inheritdoc />
public Task UpdateCategoryAsync(ProductCategory category, CancellationToken cancellationToken = default)
{
context.ProductCategories.Update(category);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task DeleteCategoryAsync(long categoryId, long tenantId, CancellationToken cancellationToken = default)
{
var existing = await context.ProductCategories
.Where(x => x.TenantId == tenantId && x.Id == categoryId)
.FirstOrDefaultAsync(cancellationToken);
if (existing == null)
{
return;
}
context.ProductCategories.Remove(existing);
}
/// <inheritdoc />
public async Task RemoveSkusAsync(long productId, long tenantId, CancellationToken cancellationToken = default)
{
var skus = await context.ProductSkus
.Where(x => x.TenantId == tenantId && x.ProductId == productId)
.ToListAsync(cancellationToken);
if (skus.Count == 0)
{
return;
}
context.ProductSkus.RemoveRange(skus);
}
/// <inheritdoc />
public async Task RemoveAddonGroupsAsync(long productId, long tenantId, CancellationToken cancellationToken = default)
{
var groupIds = await context.ProductAddonGroups
.Where(x => x.TenantId == tenantId && x.ProductId == productId)
.Select(x => x.Id)
.ToListAsync(cancellationToken);
if (groupIds.Count == 0)
{
return;
}
var options = await context.ProductAddonOptions
.Where(x => x.TenantId == tenantId && groupIds.Contains(x.AddonGroupId))
.ToListAsync(cancellationToken);
if (options.Count > 0)
{
context.ProductAddonOptions.RemoveRange(options);
}
var groups = await context.ProductAddonGroups
.Where(x => groupIds.Contains(x.Id))
.ToListAsync(cancellationToken);
if (groups.Count > 0)
{
context.ProductAddonGroups.RemoveRange(groups);
}
}
/// <inheritdoc />
public async Task RemoveAttributeGroupsAsync(long productId, long tenantId, CancellationToken cancellationToken = default)
{
var groupIds = await context.ProductAttributeGroups
.Where(x => x.TenantId == tenantId && x.ProductId == productId)
.Select(x => x.Id)
.ToListAsync(cancellationToken);
if (groupIds.Count == 0)
{
return;
}
var options = await context.ProductAttributeOptions
.Where(x => x.TenantId == tenantId && groupIds.Contains(x.AttributeGroupId))
.ToListAsync(cancellationToken);
if (options.Count > 0)
{
context.ProductAttributeOptions.RemoveRange(options);
}
var groups = await context.ProductAttributeGroups
.Where(x => groupIds.Contains(x.Id))
.ToListAsync(cancellationToken);
if (groups.Count > 0)
{
context.ProductAttributeGroups.RemoveRange(groups);
}
}
/// <inheritdoc />
public async Task RemoveMediaAssetsAsync(long productId, long tenantId, CancellationToken cancellationToken = default)
{
var assets = await context.ProductMediaAssets
.Where(x => x.TenantId == tenantId && x.ProductId == productId)
.ToListAsync(cancellationToken);
if (assets.Count == 0)
{
return;
}
context.ProductMediaAssets.RemoveRange(assets);
}
/// <inheritdoc />
public async Task RemovePricingRulesAsync(long productId, long tenantId, CancellationToken cancellationToken = default)
{
var rules = await context.ProductPricingRules
.Where(x => x.TenantId == tenantId && x.ProductId == productId)
.ToListAsync(cancellationToken);
if (rules.Count == 0)
{
return;
}
context.ProductPricingRules.RemoveRange(rules);
}
}

View File

@@ -0,0 +1,166 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Repositories;
/// <summary>
/// EF 配额包仓储实现。
/// </summary>
public sealed class EfQuotaPackageRepository(TakeoutAppDbContext context) : IQuotaPackageRepository
{
#region
/// <inheritdoc />
public Task<QuotaPackage?> FindByIdAsync(long id, CancellationToken cancellationToken = default)
{
return context.QuotaPackages
.FirstOrDefaultAsync(x => x.Id == id && x.DeletedAt == null, cancellationToken);
}
/// <inheritdoc />
public async Task<(IReadOnlyList<QuotaPackage> Items, int Total)> SearchPagedAsync(
TenantQuotaType? quotaType,
bool? isActive,
int page,
int pageSize,
CancellationToken cancellationToken = default)
{
var query = context.QuotaPackages.AsNoTracking()
.Where(x => x.DeletedAt == null);
if (quotaType.HasValue)
{
query = query.Where(x => x.QuotaType == quotaType.Value);
}
if (isActive.HasValue)
{
query = query.Where(x => x.IsActive == isActive.Value);
}
var total = await query.CountAsync(cancellationToken);
var items = await query
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(cancellationToken);
return (items, total);
}
/// <inheritdoc />
public Task AddAsync(QuotaPackage quotaPackage, CancellationToken cancellationToken = default)
{
return context.QuotaPackages.AddAsync(quotaPackage, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateAsync(QuotaPackage quotaPackage, CancellationToken cancellationToken = default)
{
context.QuotaPackages.Update(quotaPackage);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task<bool> SoftDeleteAsync(long id, CancellationToken cancellationToken = default)
{
var quotaPackage = await context.QuotaPackages
.FirstOrDefaultAsync(x => x.Id == id && x.DeletedAt == null, cancellationToken);
if (quotaPackage == null)
{
return false;
}
quotaPackage.DeletedAt = DateTime.UtcNow;
return true;
}
#endregion
#region
/// <inheritdoc />
public async Task<(IReadOnlyList<(TenantQuotaPackagePurchase Purchase, QuotaPackage Package)> Items, int Total)> GetPurchasesPagedAsync(
long tenantId,
int page,
int pageSize,
CancellationToken cancellationToken = default)
{
var query = context.TenantQuotaPackagePurchases
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.DeletedAt == null);
var total = await query.CountAsync(cancellationToken);
var items = await query
.OrderByDescending(x => x.PurchasedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Join(context.QuotaPackages.AsNoTracking(),
purchase => purchase.QuotaPackageId,
package => package.Id,
(purchase, package) => new { Purchase = purchase, Package = package })
.ToListAsync(cancellationToken);
return (items.Select(x => (x.Purchase, x.Package)).ToList(), total);
}
/// <inheritdoc />
public Task AddPurchaseAsync(TenantQuotaPackagePurchase purchase, CancellationToken cancellationToken = default)
{
return context.TenantQuotaPackagePurchases.AddAsync(purchase, cancellationToken).AsTask();
}
#endregion
#region 使
/// <inheritdoc />
public async Task<IReadOnlyList<TenantQuotaUsage>> GetUsageByTenantAsync(
long tenantId,
TenantQuotaType? quotaType,
CancellationToken cancellationToken = default)
{
var query = context.TenantQuotaUsages
.AsNoTracking()
.Where(x => x.TenantId == tenantId);
if (quotaType.HasValue)
{
query = query.Where(x => x.QuotaType == quotaType.Value);
}
return await query.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public Task<TenantQuotaUsage?> FindUsageAsync(
long tenantId,
TenantQuotaType quotaType,
CancellationToken cancellationToken = default)
{
return context.TenantQuotaUsages
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.QuotaType == quotaType, cancellationToken);
}
/// <inheritdoc />
public Task UpdateUsageAsync(TenantQuotaUsage usage, CancellationToken cancellationToken = default)
{
context.TenantQuotaUsages.Update(usage);
return Task.CompletedTask;
}
#endregion
/// <inheritdoc />
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
return context.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,116 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Repositories;
/// <summary>
/// 统计数据仓储实现。
/// </summary>
public sealed class EfStatisticsRepository(TakeoutAppDbContext dbContext) : IStatisticsRepository
{
#region
/// <inheritdoc />
public async Task<IReadOnlyList<TenantSubscription>> GetAllSubscriptionsAsync(CancellationToken cancellationToken = default)
{
return await dbContext.TenantSubscriptions
.AsNoTracking()
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<ExpiringSubscriptionInfo>> GetExpiringSubscriptionsAsync(
int daysAhead,
bool onlyWithoutAutoRenew,
CancellationToken cancellationToken = default)
{
var now = DateTime.UtcNow;
var targetDate = now.AddDays(daysAhead);
// 构建基础查询
var query = dbContext.TenantSubscriptions
.AsNoTracking()
.Where(s => s.Status == SubscriptionStatus.Active
&& s.EffectiveTo >= now
&& s.EffectiveTo <= targetDate);
// 如果只查询未开启自动续费的
if (onlyWithoutAutoRenew)
{
query = query.Where(s => !s.AutoRenew);
}
// 连接租户和套餐信息
var result = await query
.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 ExpiringSubscriptionInfo
{
Subscription = combined.Subscription,
TenantName = combined.Tenant.Name,
PackageName = package.Name
}
)
.OrderBy(x => x.Subscription.EffectiveTo)
.ToListAsync(cancellationToken);
return result;
}
#endregion
#region
/// <inheritdoc />
public async Task<IReadOnlyList<TenantBillingStatement>> GetPaidBillsAsync(CancellationToken cancellationToken = default)
{
return await dbContext.TenantBillingStatements
.AsNoTracking()
.Where(b => b.Status == TenantBillingStatus.Paid)
.ToListAsync(cancellationToken);
}
#endregion
#region 使
/// <inheritdoc />
public async Task<IReadOnlyList<QuotaUsageRankInfo>> GetQuotaUsageRankingAsync(
TenantQuotaType quotaType,
int topN,
CancellationToken cancellationToken = default)
{
return await dbContext.TenantQuotaUsages
.AsNoTracking()
.Where(q => q.QuotaType == quotaType && q.LimitValue > 0)
.Join(
dbContext.Tenants,
quota => quota.TenantId,
tenant => tenant.Id,
(quota, tenant) => new QuotaUsageRankInfo
{
TenantId = quota.TenantId,
TenantName = tenant.Name,
UsedValue = quota.UsedValue,
LimitValue = quota.LimitValue,
UsagePercentage = quota.LimitValue > 0 ? (quota.UsedValue / quota.LimitValue * 100) : 0
}
)
.OrderByDescending(x => x.UsagePercentage)
.Take(topN)
.ToListAsync(cancellationToken);
}
#endregion
}

View File

@@ -14,28 +14,14 @@ namespace TakeoutSaaS.Infrastructure.App.Repositories;
/// <remarks>
/// 初始化仓储。
/// </remarks>
public sealed class EfStoreRepository(TakeoutTenantAppDbContext context) : IStoreRepository
public sealed class EfStoreRepository(TakeoutAppDbContext context) : IStoreRepository
{
/// <inheritdoc />
public Task<Store?> FindByIdAsync(long storeId, long? tenantId, CancellationToken cancellationToken = default, bool includeDeleted = false)
public Task<Store?> FindByIdAsync(long storeId, long tenantId, CancellationToken cancellationToken = default)
{
var query = context.Stores.AsNoTracking();
// 1. 包含软删除数据时忽略全局过滤
if (includeDeleted)
{
query = query.IgnoreQueryFilters();
}
// 2. (空行后) 可选租户过滤
if (tenantId.HasValue)
{
query = query.Where(x => x.TenantId == tenantId.Value);
}
// 3. (空行后) 返回门店实体
return query
.Where(x => x.Id == storeId)
return context.Stores
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.Id == storeId)
.FirstOrDefaultAsync(cancellationToken);
}
@@ -51,71 +37,50 @@ public sealed class EfStoreRepository(TakeoutTenantAppDbContext context) : IStor
/// <inheritdoc />
public async Task<IReadOnlyList<Store>> SearchAsync(
long? tenantId,
long tenantId,
long? merchantId,
StoreStatus? status,
StoreAuditStatus? auditStatus,
StoreBusinessStatus? businessStatus,
StoreOwnershipType? ownershipType,
string? keyword,
bool includeDeleted = false,
CancellationToken cancellationToken = default)
{
var query = context.Stores.AsNoTracking();
var query = context.Stores
.AsNoTracking()
.Where(x => x.TenantId == tenantId);
// 1. 包含软删除数据时忽略全局过滤
if (includeDeleted)
{
query = query.IgnoreQueryFilters();
}
// 2. (空行后) 可选租户过滤
if (tenantId.HasValue)
{
query = query.Where(x => x.TenantId == tenantId.Value);
}
// 3. (空行后) 可选过滤:商户
if (merchantId.HasValue)
{
query = query.Where(x => x.MerchantId == merchantId.Value);
}
// 4. (空行后) 可选过滤:状态
if (status.HasValue)
{
query = query.Where(x => x.Status == status.Value);
}
// 5. (空行后) 可选过滤:审核状态
if (auditStatus.HasValue)
{
query = query.Where(x => x.AuditStatus == auditStatus.Value);
}
// 6. (空行后) 可选过滤:经营状态
if (businessStatus.HasValue)
{
query = query.Where(x => x.BusinessStatus == businessStatus.Value);
}
// 7. (空行后) 可选过滤:主体类型
if (ownershipType.HasValue)
{
query = query.Where(x => x.OwnershipType == ownershipType.Value);
}
// 8. (空行后) 可选过滤:关键词
if (!string.IsNullOrWhiteSpace(keyword))
{
var trimmed = keyword.Trim();
query = query.Where(x =>
x.Name.Contains(trimmed) ||
x.Code.Contains(trimmed) ||
(x.Phone != null && x.Phone.Contains(trimmed)));
query = query.Where(x => x.Name.Contains(trimmed) || x.Code.Contains(trimmed));
}
// 9. (空行后) 查询并返回结果
var stores = await query
.OrderBy(x => x.Name)
.ToListAsync(cancellationToken);
@@ -161,22 +126,17 @@ public sealed class EfStoreRepository(TakeoutTenantAppDbContext context) : IStor
}
/// <inheritdoc />
public async Task<Dictionary<long, int>> GetStoreCountsAsync(long? tenantId, IReadOnlyCollection<long> merchantIds, CancellationToken cancellationToken = default)
public async Task<Dictionary<long, int>> GetStoreCountsAsync(long tenantId, IReadOnlyCollection<long> merchantIds, CancellationToken cancellationToken = default)
{
if (merchantIds.Count == 0)
{
return new Dictionary<long, int>();
}
var query = context.Stores.AsNoTracking();
var query = context.Stores
.AsNoTracking()
.Where(x => x.TenantId == tenantId);
// 1. 可选租户过滤
if (tenantId.HasValue)
{
query = query.Where(x => x.TenantId == tenantId.Value);
}
// 2. (空行后) 分组统计门店数量
return await query
.Where(x => merchantIds.Contains(x.MerchantId))
.GroupBy(x => x.MerchantId)
@@ -185,25 +145,11 @@ public sealed class EfStoreRepository(TakeoutTenantAppDbContext context) : IStor
}
/// <inheritdoc />
public async Task<IReadOnlyList<StoreBusinessHour>> GetBusinessHoursAsync(long storeId, long? tenantId, CancellationToken cancellationToken = default, bool includeDeleted = false)
public async Task<IReadOnlyList<StoreBusinessHour>> GetBusinessHoursAsync(long storeId, long tenantId, CancellationToken cancellationToken = default)
{
var query = context.StoreBusinessHours.AsNoTracking();
// 1. 包含软删除数据时忽略全局过滤
if (includeDeleted)
{
query = query.IgnoreQueryFilters();
}
// 2. (空行后) 可选租户过滤
if (tenantId.HasValue)
{
query = query.Where(x => x.TenantId == tenantId.Value);
}
// 3. (空行后) 查询并返回营业时段
var hours = await query
.Where(x => x.StoreId == storeId)
var hours = await context.StoreBusinessHours
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.StoreId == storeId)
.OrderBy(x => x.DayOfWeek)
.ThenBy(x => x.StartTime)
.ToListAsync(cancellationToken);
@@ -212,25 +158,11 @@ public sealed class EfStoreRepository(TakeoutTenantAppDbContext context) : IStor
}
/// <inheritdoc />
public Task<StoreFee?> GetStoreFeeAsync(long storeId, long? tenantId, CancellationToken cancellationToken = default, bool includeDeleted = false)
public Task<StoreFee?> GetStoreFeeAsync(long storeId, long tenantId, CancellationToken cancellationToken = default)
{
var query = context.StoreFees.AsNoTracking();
// 1. 包含软删除数据时忽略全局过滤
if (includeDeleted)
{
query = query.IgnoreQueryFilters();
}
// 2. (空行后) 可选租户过滤
if (tenantId.HasValue)
{
query = query.Where(x => x.TenantId == tenantId.Value);
}
// 3. (空行后) 返回费用配置
return query
.Where(x => x.StoreId == storeId)
return context.StoreFees
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.StoreId == storeId)
.FirstOrDefaultAsync(cancellationToken);
}
@@ -248,25 +180,11 @@ public sealed class EfStoreRepository(TakeoutTenantAppDbContext context) : IStor
}
/// <inheritdoc />
public async Task<IReadOnlyList<StoreQualification>> GetQualificationsAsync(long storeId, long? tenantId, CancellationToken cancellationToken = default, bool includeDeleted = false)
public async Task<IReadOnlyList<StoreQualification>> GetQualificationsAsync(long storeId, long tenantId, CancellationToken cancellationToken = default)
{
var query = context.StoreQualifications.AsNoTracking();
// 1. 包含软删除数据时忽略全局过滤
if (includeDeleted)
{
query = query.IgnoreQueryFilters();
}
// 2. (空行后) 可选租户过滤
if (tenantId.HasValue)
{
query = query.Where(x => x.TenantId == tenantId.Value);
}
// 3. (空行后) 查询并返回资质列表
var qualifications = await query
.Where(x => x.StoreId == storeId)
var qualifications = await context.StoreQualifications
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.StoreId == storeId)
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.QualificationType)
.ToListAsync(cancellationToken);
@@ -333,25 +251,11 @@ public sealed class EfStoreRepository(TakeoutTenantAppDbContext context) : IStor
}
/// <inheritdoc />
public async Task<IReadOnlyList<StoreDeliveryZone>> GetDeliveryZonesAsync(long storeId, long? tenantId, CancellationToken cancellationToken = default, bool includeDeleted = false)
public async Task<IReadOnlyList<StoreDeliveryZone>> GetDeliveryZonesAsync(long storeId, long tenantId, CancellationToken cancellationToken = default)
{
var query = context.StoreDeliveryZones.AsNoTracking();
// 1. 包含软删除数据时忽略全局过滤
if (includeDeleted)
{
query = query.IgnoreQueryFilters();
}
// 2. (空行后) 可选租户过滤
if (tenantId.HasValue)
{
query = query.Where(x => x.TenantId == tenantId.Value);
}
// 3. (空行后) 查询并返回配送区域
var zones = await query
.Where(x => x.StoreId == storeId)
var zones = await context.StoreDeliveryZones
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.StoreId == storeId)
.OrderBy(x => x.SortOrder)
.ToListAsync(cancellationToken);
@@ -359,48 +263,19 @@ public sealed class EfStoreRepository(TakeoutTenantAppDbContext context) : IStor
}
/// <inheritdoc />
public Task<StoreDeliveryZone?> FindDeliveryZoneByIdAsync(long deliveryZoneId, long? tenantId, CancellationToken cancellationToken = default, bool includeDeleted = false)
public Task<StoreDeliveryZone?> FindDeliveryZoneByIdAsync(long deliveryZoneId, long tenantId, CancellationToken cancellationToken = default)
{
var query = context.StoreDeliveryZones.AsQueryable();
// 1. 包含软删除数据时忽略全局过滤
if (includeDeleted)
{
query = query.IgnoreQueryFilters();
}
// 2. (空行后) 可选租户过滤
if (tenantId.HasValue)
{
query = query.Where(x => x.TenantId == tenantId.Value);
}
// 3. (空行后) 返回配送区域实体
return query
.Where(x => x.Id == deliveryZoneId)
return context.StoreDeliveryZones
.Where(x => x.TenantId == tenantId && x.Id == deliveryZoneId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<StoreHoliday>> GetHolidaysAsync(long storeId, long? tenantId, CancellationToken cancellationToken = default, bool includeDeleted = false)
public async Task<IReadOnlyList<StoreHoliday>> GetHolidaysAsync(long storeId, long tenantId, CancellationToken cancellationToken = default)
{
var query = context.StoreHolidays.AsNoTracking();
// 1. 包含软删除数据时忽略全局过滤
if (includeDeleted)
{
query = query.IgnoreQueryFilters();
}
// 2. (空行后) 可选租户过滤
if (tenantId.HasValue)
{
query = query.Where(x => x.TenantId == tenantId.Value);
}
// 3. (空行后) 查询并返回节假日
var holidays = await query
.Where(x => x.StoreId == storeId)
var holidays = await context.StoreHolidays
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.StoreId == storeId)
.OrderBy(x => x.Date)
.ToListAsync(cancellationToken);
@@ -408,25 +283,10 @@ public sealed class EfStoreRepository(TakeoutTenantAppDbContext context) : IStor
}
/// <inheritdoc />
public Task<StoreHoliday?> FindHolidayByIdAsync(long holidayId, long? tenantId, CancellationToken cancellationToken = default, bool includeDeleted = false)
public Task<StoreHoliday?> FindHolidayByIdAsync(long holidayId, long tenantId, CancellationToken cancellationToken = default)
{
var query = context.StoreHolidays.AsQueryable();
// 1. 包含软删除数据时忽略全局过滤
if (includeDeleted)
{
query = query.IgnoreQueryFilters();
}
// 2. (空行后) 可选租户过滤
if (tenantId.HasValue)
{
query = query.Where(x => x.TenantId == tenantId.Value);
}
// 3. (空行后) 返回节假日实体
return query
.Where(x => x.Id == holidayId)
return context.StoreHolidays
.Where(x => x.TenantId == tenantId && x.Id == holidayId)
.FirstOrDefaultAsync(cancellationToken);
}
@@ -668,18 +528,10 @@ public sealed class EfStoreRepository(TakeoutTenantAppDbContext context) : IStor
}
/// <inheritdoc />
public async Task DeleteDeliveryZoneAsync(long deliveryZoneId, long? tenantId, CancellationToken cancellationToken = default)
public async Task DeleteDeliveryZoneAsync(long deliveryZoneId, long tenantId, CancellationToken cancellationToken = default)
{
// 1. 查询目标配送区域
var query = context.StoreDeliveryZones.AsQueryable();
if (tenantId.HasValue)
{
query = query.Where(x => x.TenantId == tenantId.Value);
}
// 2. (空行后) 执行软删除
var existing = await query
.Where(x => x.Id == deliveryZoneId)
var existing = await context.StoreDeliveryZones
.Where(x => x.TenantId == tenantId && x.Id == deliveryZoneId)
.FirstOrDefaultAsync(cancellationToken);
if (existing != null)
@@ -689,18 +541,10 @@ public sealed class EfStoreRepository(TakeoutTenantAppDbContext context) : IStor
}
/// <inheritdoc />
public async Task DeleteHolidayAsync(long holidayId, long? tenantId, CancellationToken cancellationToken = default)
public async Task DeleteHolidayAsync(long holidayId, long tenantId, CancellationToken cancellationToken = default)
{
// 1. 查询目标节假日
var query = context.StoreHolidays.AsQueryable();
if (tenantId.HasValue)
{
query = query.Where(x => x.TenantId == tenantId.Value);
}
// 2. (空行后) 执行软删除
var existing = await query
.Where(x => x.Id == holidayId)
var existing = await context.StoreHolidays
.Where(x => x.TenantId == tenantId && x.Id == holidayId)
.FirstOrDefaultAsync(cancellationToken);
if (existing != null)

View File

@@ -0,0 +1,362 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence;
using TakeoutSaaS.Infrastructure.Logs.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Repositories;
/// <summary>
/// 订阅管理仓储实现。
/// </summary>
public sealed class EfSubscriptionRepository(TakeoutAppDbContext dbContext, TakeoutLogsDbContext logsContext) : ISubscriptionRepository
{
#region
/// <inheritdoc />
public async Task<TenantSubscription?> FindByIdAsync(
long subscriptionId,
CancellationToken cancellationToken = default)
{
return await dbContext.TenantSubscriptions
.FirstOrDefaultAsync(s => s.Id == subscriptionId, cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TenantSubscription>> FindByIdsAsync(
IEnumerable<long> subscriptionIds,
CancellationToken cancellationToken = default)
{
var ids = subscriptionIds.ToList();
return await dbContext.TenantSubscriptions
.Where(s => ids.Contains(s.Id))
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<(IReadOnlyList<SubscriptionWithRelations> Items, int Total)> SearchPagedAsync(
SubscriptionSearchFilter filter,
CancellationToken cancellationToken = default)
{
// 1. 构建基础查询
var query = dbContext.TenantSubscriptions
.AsNoTracking()
.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 }
)
.GroupJoin(
dbContext.TenantPackages,
combined => combined.Subscription.ScheduledPackageId,
scheduledPackage => scheduledPackage.Id,
(combined, scheduledPackages) => new { combined.Subscription, combined.Tenant, combined.Package, ScheduledPackage = scheduledPackages.FirstOrDefault() }
);
// 2. 应用过滤条件
if (filter.Status.HasValue)
{
query = query.Where(x => x.Subscription.Status == filter.Status.Value);
}
if (filter.TenantPackageId.HasValue)
{
query = query.Where(x => x.Subscription.TenantPackageId == filter.TenantPackageId.Value);
}
if (filter.TenantId.HasValue)
{
query = query.Where(x => x.Subscription.TenantId == filter.TenantId.Value);
}
if (!string.IsNullOrWhiteSpace(filter.TenantKeyword))
{
var keyword = filter.TenantKeyword.Trim().ToLower();
query = query.Where(x => x.Tenant.Name.ToLower().Contains(keyword) || x.Tenant.Code.ToLower().Contains(keyword));
}
if (filter.ExpiringWithinDays.HasValue)
{
var expiryDate = DateTime.UtcNow.AddDays(filter.ExpiringWithinDays.Value);
query = query.Where(x => x.Subscription.EffectiveTo <= expiryDate && x.Subscription.EffectiveTo >= DateTime.UtcNow);
}
if (filter.AutoRenew.HasValue)
{
query = query.Where(x => x.Subscription.AutoRenew == filter.AutoRenew.Value);
}
// 3. 获取总数
var total = await query.CountAsync(cancellationToken);
// 4. 排序和分页
var items = await query
.OrderByDescending(x => x.Subscription.CreatedAt)
.Skip((filter.Page - 1) * filter.PageSize)
.Take(filter.PageSize)
.Select(x => new SubscriptionWithRelations
{
Subscription = x.Subscription,
TenantName = x.Tenant.Name,
TenantCode = x.Tenant.Code,
PackageName = x.Package.Name,
ScheduledPackageName = x.ScheduledPackage != null ? x.ScheduledPackage.Name : null
})
.ToListAsync(cancellationToken);
return (items, total);
}
/// <inheritdoc />
public async Task<SubscriptionDetailInfo?> GetDetailAsync(
long subscriptionId,
CancellationToken cancellationToken = default)
{
var result = await dbContext.TenantSubscriptions
.AsNoTracking()
.Where(s => s.Id == subscriptionId)
.Select(s => new
{
Subscription = s,
Tenant = dbContext.Tenants.FirstOrDefault(t => t.Id == s.TenantId),
Package = dbContext.TenantPackages.FirstOrDefault(p => p.Id == s.TenantPackageId),
ScheduledPackage = s.ScheduledPackageId.HasValue
? dbContext.TenantPackages.FirstOrDefault(p => p.Id == s.ScheduledPackageId)
: null
})
.FirstOrDefaultAsync(cancellationToken);
if (result == null)
{
return null;
}
return new SubscriptionDetailInfo
{
Subscription = result.Subscription,
TenantName = result.Tenant?.Name ?? "",
TenantCode = result.Tenant?.Code ?? "",
Package = result.Package,
ScheduledPackage = result.ScheduledPackage
};
}
/// <inheritdoc />
public async Task<IReadOnlyList<SubscriptionWithTenant>> FindByIdsWithTenantAsync(
IEnumerable<long> subscriptionIds,
CancellationToken cancellationToken = default)
{
var ids = subscriptionIds.ToList();
return await dbContext.TenantSubscriptions
.Where(s => ids.Contains(s.Id))
.Join(
dbContext.Tenants,
sub => sub.TenantId,
tenant => tenant.Id,
(sub, tenant) => new SubscriptionWithTenant
{
Subscription = sub,
Tenant = tenant
}
)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<AutoRenewalCandidate>> FindAutoRenewalCandidatesAsync(
DateTime now,
DateTime renewalThreshold,
CancellationToken cancellationToken = default)
{
// 1. 查询开启自动续费且即将到期的活跃订阅
var query = dbContext.TenantSubscriptions
.Where(s => s.Status == SubscriptionStatus.Active
&& s.AutoRenew
&& s.EffectiveTo <= renewalThreshold
&& s.EffectiveTo > now)
.Join(
dbContext.TenantPackages,
subscription => subscription.TenantPackageId,
package => package.Id,
(subscription, package) => new AutoRenewalCandidate
{
Subscription = subscription,
Package = package
});
// 2. 返回候选列表
return await query.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<RenewalReminderCandidate>> FindRenewalReminderCandidatesAsync(
DateTime startOfDay,
DateTime endOfDay,
CancellationToken cancellationToken = default)
{
// 1. 查询到期落在指定区间的订阅(且未开启自动续费)
var query = dbContext.TenantSubscriptions
.Where(s => s.Status == SubscriptionStatus.Active
&& !s.AutoRenew
&& s.EffectiveTo >= startOfDay
&& s.EffectiveTo < endOfDay)
.Join(
dbContext.Tenants,
subscription => subscription.TenantId,
tenant => tenant.Id,
(subscription, tenant) => new { Subscription = subscription, Tenant = tenant })
.Join(
dbContext.TenantPackages,
combined => combined.Subscription.TenantPackageId,
package => package.Id,
(combined, package) => new RenewalReminderCandidate
{
Subscription = combined.Subscription,
Tenant = combined.Tenant,
Package = package
});
// 2. 返回候选列表
return await query.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TenantSubscription>> FindExpiredActiveSubscriptionsAsync(
DateTime now,
CancellationToken cancellationToken = default)
{
// 1. 查询已到期仍为 Active 的订阅
return await dbContext.TenantSubscriptions
.Where(s => s.Status == SubscriptionStatus.Active && s.EffectiveTo < now)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TenantSubscription>> FindGracePeriodExpiredSubscriptionsAsync(
DateTime now,
int gracePeriodDays,
CancellationToken cancellationToken = default)
{
// 1. 查询宽限期已结束的订阅
return await dbContext.TenantSubscriptions
.Where(s => s.Status == SubscriptionStatus.GracePeriod
&& s.EffectiveTo.AddDays(gracePeriodDays) < now)
.ToListAsync(cancellationToken);
}
#endregion
#region
/// <inheritdoc />
public async Task<TenantPackage?> FindPackageByIdAsync(long packageId, CancellationToken cancellationToken = default)
{
return await dbContext.TenantPackages
.AsNoTracking()
.FirstOrDefaultAsync(p => p.Id == packageId, cancellationToken);
}
#endregion
#region
/// <inheritdoc />
public Task UpdateAsync(TenantSubscription subscription, CancellationToken cancellationToken = default)
{
dbContext.TenantSubscriptions.Update(subscription);
return Task.CompletedTask;
}
#endregion
#region
/// <inheritdoc />
public Task AddHistoryAsync(TenantSubscriptionHistory history, CancellationToken cancellationToken = default)
{
dbContext.TenantSubscriptionHistories.Add(history);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task<IReadOnlyList<SubscriptionHistoryWithPackageNames>> GetHistoryAsync(
long subscriptionId,
CancellationToken cancellationToken = default)
{
return await dbContext.TenantSubscriptionHistories
.AsNoTracking()
.Where(h => h.TenantSubscriptionId == subscriptionId)
.OrderByDescending(h => h.CreatedAt)
.Select(h => new SubscriptionHistoryWithPackageNames
{
History = h,
FromPackageName = dbContext.TenantPackages
.Where(p => p.Id == h.FromPackageId)
.Select(p => p.Name)
.FirstOrDefault() ?? "",
ToPackageName = dbContext.TenantPackages
.Where(p => p.Id == h.ToPackageId)
.Select(p => p.Name)
.FirstOrDefault() ?? ""
})
.ToListAsync(cancellationToken);
}
#endregion
#region 使
/// <inheritdoc />
public async Task<IReadOnlyList<TenantQuotaUsage>> GetQuotaUsagesAsync(
long tenantId,
CancellationToken cancellationToken = default)
{
return await dbContext.TenantQuotaUsages
.AsNoTracking()
.Where(q => q.TenantId == tenantId)
.ToListAsync(cancellationToken);
}
#endregion
#region
/// <inheritdoc />
public Task AddNotificationAsync(TenantNotification notification, CancellationToken cancellationToken = default)
{
dbContext.TenantNotifications.Add(notification);
return Task.CompletedTask;
}
#endregion
#region
/// <inheritdoc />
public Task AddOperationLogAsync(OperationLog log, CancellationToken cancellationToken = default)
{
logsContext.OperationLogs.Add(log);
return Task.CompletedTask;
}
#endregion
/// <inheritdoc />
public async Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
// 1. 保存业务库变更
await dbContext.SaveChangesAsync(cancellationToken);
// 2. 保存日志库变更
await logsContext.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,68 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Repositories;
/// <summary>
/// EF 公告已读仓储。
/// </summary>
public sealed class EfTenantAnnouncementReadRepository(TakeoutAppDbContext context) : ITenantAnnouncementReadRepository
{
/// <inheritdoc />
public Task<IReadOnlyList<TenantAnnouncementRead>> GetByAnnouncementAsync(long tenantId, long announcementId, CancellationToken cancellationToken = default)
{
return context.TenantAnnouncementReads.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.AnnouncementId == announcementId)
.OrderBy(x => x.ReadAt)
.ToListAsync(cancellationToken)
.ContinueWith(t => (IReadOnlyList<TenantAnnouncementRead>)t.Result, cancellationToken);
}
/// <inheritdoc />
public Task<IReadOnlyList<TenantAnnouncementRead>> GetByAnnouncementAsync(long tenantId, IEnumerable<long> announcementIds, long? userId, CancellationToken cancellationToken = default)
{
var ids = announcementIds.Distinct().ToArray();
if (ids.Length == 0)
{
return Task.FromResult<IReadOnlyList<TenantAnnouncementRead>>(Array.Empty<TenantAnnouncementRead>());
}
var query = context.TenantAnnouncementReads.AsNoTracking()
.Where(x => x.TenantId == tenantId && ids.Contains(x.AnnouncementId));
if (userId.HasValue)
{
query = query.Where(x => x.UserId == userId.Value);
}
else
{
query = query.Where(x => x.UserId == null);
}
return query
.OrderByDescending(x => x.ReadAt)
.ToListAsync(cancellationToken)
.ContinueWith(t => (IReadOnlyList<TenantAnnouncementRead>)t.Result, cancellationToken);
}
/// <inheritdoc />
public Task<TenantAnnouncementRead?> FindAsync(long tenantId, long announcementId, long? userId, CancellationToken cancellationToken = default)
{
return context.TenantAnnouncementReads
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.AnnouncementId == announcementId && x.UserId == userId, cancellationToken);
}
/// <inheritdoc />
public Task AddAsync(TenantAnnouncementRead record, CancellationToken cancellationToken = default)
{
return context.TenantAnnouncementReads.AddAsync(record, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
return context.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,167 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Repositories;
/// <summary>
/// EF 租户公告仓储。
/// </summary>
public sealed class EfTenantAnnouncementRepository(TakeoutAppDbContext context) : ITenantAnnouncementRepository
{
/// <inheritdoc />
public async Task<IReadOnlyList<TenantAnnouncement>> SearchAsync(
long tenantId,
string? keyword,
AnnouncementStatus? status,
TenantAnnouncementType? type,
bool? isActive,
DateTime? effectiveFrom,
DateTime? effectiveTo,
DateTime? effectiveAt,
bool orderByPriority = false,
int? limit = null,
CancellationToken cancellationToken = default)
{
var query = context.TenantAnnouncements.AsNoTracking()
.Where(x => x.TenantId == tenantId);
if (!string.IsNullOrWhiteSpace(keyword))
{
var normalized = keyword.Trim();
query = query.Where(x =>
EF.Functions.ILike(x.Title, $"%{normalized}%")
|| EF.Functions.ILike(x.Content, $"%{normalized}%"));
}
if (status.HasValue)
{
query = query.Where(x => x.Status == status.Value);
}
if (type.HasValue)
{
query = query.Where(x => x.AnnouncementType == type.Value);
}
if (isActive.HasValue)
{
query = isActive.Value
? query.Where(x => x.Status == AnnouncementStatus.Published)
: query.Where(x => x.Status != AnnouncementStatus.Published);
}
if (effectiveFrom.HasValue)
{
query = query.Where(x => x.EffectiveFrom >= effectiveFrom.Value);
}
if (effectiveTo.HasValue)
{
query = query.Where(x => x.EffectiveTo == null || x.EffectiveTo <= effectiveTo.Value);
}
if (effectiveAt.HasValue)
{
var at = effectiveAt.Value;
query = query.Where(x => x.EffectiveFrom <= at && (x.EffectiveTo == null || x.EffectiveTo >= at));
}
// 应用排序(如果启用)
if (orderByPriority)
{
query = query.OrderByDescending(x => x.Priority).ThenByDescending(x => x.EffectiveFrom);
}
// 应用限制(如果指定)
if (limit.HasValue && limit.Value > 0)
{
query = query.Take(limit.Value);
}
return await query.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TenantAnnouncement>> SearchUnreadAsync(
long tenantId,
long? userId,
AnnouncementStatus? status,
bool? isActive,
DateTime? effectiveAt,
CancellationToken cancellationToken = default)
{
var announcementQuery = context.TenantAnnouncements.AsNoTracking()
.Where(x => x.TenantId == tenantId);
if (status.HasValue)
{
announcementQuery = announcementQuery.Where(x => x.Status == status.Value);
}
if (isActive.HasValue)
{
announcementQuery = isActive.Value
? announcementQuery.Where(x => x.Status == AnnouncementStatus.Published)
: announcementQuery.Where(x => x.Status != AnnouncementStatus.Published);
}
if (effectiveAt.HasValue)
{
var at = effectiveAt.Value;
announcementQuery = announcementQuery.Where(x => x.EffectiveFrom <= at && (x.EffectiveTo == null || x.EffectiveTo >= at));
}
var readQuery = context.TenantAnnouncementReads.AsNoTracking()
.Where(x => x.TenantId == tenantId);
readQuery = userId.HasValue
? readQuery.Where(x => x.UserId == null || x.UserId == userId.Value)
: readQuery.Where(x => x.UserId == null);
var query = from announcement in announcementQuery
join read in readQuery on announcement.Id equals read.AnnouncementId into readGroup
where !readGroup.Any()
select announcement;
return await query.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public Task<TenantAnnouncement?> FindByIdAsync(long tenantId, long announcementId, CancellationToken cancellationToken = default)
{
return context.TenantAnnouncements.AsNoTracking()
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == announcementId, cancellationToken);
}
/// <inheritdoc />
public Task AddAsync(TenantAnnouncement announcement, CancellationToken cancellationToken = default)
{
return context.TenantAnnouncements.AddAsync(announcement, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateAsync(TenantAnnouncement announcement, CancellationToken cancellationToken = default)
{
context.TenantAnnouncements.Update(announcement);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task DeleteAsync(long tenantId, long announcementId, CancellationToken cancellationToken = default)
{
var entity = await context.TenantAnnouncements.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == announcementId, cancellationToken);
if (entity != null)
{
context.TenantAnnouncements.Remove(entity);
}
}
/// <inheritdoc />
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
return context.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,94 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Repositories;
/// <summary>
/// EF 租户通知仓储。
/// </summary>
public sealed class EfTenantNotificationRepository(TakeoutAppDbContext context) : ITenantNotificationRepository
{
/// <inheritdoc />
public Task<IReadOnlyList<TenantNotification>> SearchAsync(
long tenantId,
TenantNotificationSeverity? severity,
bool? unreadOnly,
DateTime? from,
DateTime? to,
CancellationToken cancellationToken = default)
{
var query = context.TenantNotifications.AsNoTracking()
.Where(x => x.TenantId == tenantId);
if (severity.HasValue)
{
query = query.Where(x => x.Severity == severity.Value);
}
if (unreadOnly == true)
{
query = query.Where(x => x.ReadAt == null);
}
if (from.HasValue)
{
query = query.Where(x => x.SentAt >= from.Value);
}
if (to.HasValue)
{
query = query.Where(x => x.SentAt <= to.Value);
}
return query
.OrderByDescending(x => x.SentAt)
.ToListAsync(cancellationToken)
.ContinueWith(t => (IReadOnlyList<TenantNotification>)t.Result, cancellationToken);
}
/// <inheritdoc />
public Task<TenantNotification?> FindByIdAsync(long tenantId, long notificationId, CancellationToken cancellationToken = default)
{
return context.TenantNotifications
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == notificationId, cancellationToken);
}
/// <inheritdoc />
public Task<bool> ExistsByMetadataAsync(
long tenantId,
string title,
string metadataJson,
DateTime sentAfter,
CancellationToken cancellationToken = default)
{
return context.TenantNotifications.AsNoTracking()
.AnyAsync(
x => x.TenantId == tenantId
&& x.Title == title
&& x.MetadataJson == metadataJson
&& x.SentAt >= sentAfter,
cancellationToken);
}
/// <inheritdoc />
public Task AddAsync(TenantNotification notification, CancellationToken cancellationToken = default)
{
return context.TenantNotifications.AddAsync(notification, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateAsync(TenantNotification notification, CancellationToken cancellationToken = default)
{
context.TenantNotifications.Update(notification);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
return context.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,89 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Repositories;
/// <summary>
/// 租户套餐仓储实现。
/// </summary>
public sealed class EfTenantPackageRepository(TakeoutAppDbContext context) : ITenantPackageRepository
{
/// <inheritdoc />
public Task<TenantPackage?> FindByIdAsync(long id, CancellationToken cancellationToken = default)
{
return context.TenantPackages.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TenantPackage>> SearchAsync(string? keyword, bool? isActive, CancellationToken cancellationToken = default)
{
// 1. 构建基础查询
var query = context.TenantPackages.AsNoTracking();
// 2. 关键字过滤
if (!string.IsNullOrWhiteSpace(keyword))
{
var normalized = keyword.Trim();
query = query.Where(x => EF.Functions.ILike(x.Name, $"%{normalized}%") || EF.Functions.ILike(x.Description ?? string.Empty, $"%{normalized}%"));
}
// 3. 状态过滤
if (isActive.HasValue)
{
query = query.Where(x => x.IsActive == isActive.Value);
}
// 4. 排序返回
return await query
.OrderBy(x => x.SortOrder)
.ThenByDescending(x => x.CreatedAt)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TenantPackage>> SearchPublicPurchasableAsync(CancellationToken cancellationToken = default)
{
// 1. 公共可选购套餐仅返回:已发布 + 对外可见 + 允许新购 + 启用
return await context.TenantPackages.AsNoTracking()
.Where(x =>
x.IsActive
&& x.PublishStatus == TenantPackagePublishStatus.Published
&& x.IsPublicVisible
&& x.IsAllowNewTenantPurchase)
.OrderBy(x => x.SortOrder)
.ThenByDescending(x => x.CreatedAt)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public Task AddAsync(TenantPackage package, CancellationToken cancellationToken = default)
{
return context.TenantPackages.AddAsync(package, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateAsync(TenantPackage package, CancellationToken cancellationToken = default)
{
context.TenantPackages.Update(package);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task DeleteAsync(long id, CancellationToken cancellationToken = default)
{
var entity = await context.TenantPackages.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (entity != null)
{
context.TenantPackages.Remove(entity);
}
}
/// <inheritdoc />
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
return context.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,24 @@
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Repositories;
/// <summary>
/// 租户配额使用历史仓储实现。
/// </summary>
public sealed class EfTenantQuotaUsageHistoryRepository(TakeoutAppDbContext context) : ITenantQuotaUsageHistoryRepository
{
/// <inheritdoc />
public Task AddAsync(TenantQuotaUsageHistory history, CancellationToken cancellationToken = default)
{
return context.TenantQuotaUsageHistories.AddAsync(history, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
return context.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,50 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Repositories;
/// <summary>
/// 租户配额使用仓储实现。
/// </summary>
public sealed class EfTenantQuotaUsageRepository(TakeoutAppDbContext context) : ITenantQuotaUsageRepository
{
/// <inheritdoc />
public Task<TenantQuotaUsage?> FindAsync(long tenantId, TenantQuotaType quotaType, CancellationToken cancellationToken = default)
{
return context.TenantQuotaUsages
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.QuotaType == quotaType, cancellationToken);
}
/// <inheritdoc />
public Task<IReadOnlyList<TenantQuotaUsage>> GetByTenantAsync(long tenantId, CancellationToken cancellationToken = default)
{
return context.TenantQuotaUsages
.AsNoTracking()
.Where(x => x.TenantId == tenantId)
.OrderBy(x => x.QuotaType)
.ToListAsync(cancellationToken)
.ContinueWith(t => (IReadOnlyList<TenantQuotaUsage>)t.Result, cancellationToken);
}
/// <inheritdoc />
public Task AddAsync(TenantQuotaUsage usage, CancellationToken cancellationToken = default)
{
return context.TenantQuotaUsages.AddAsync(usage, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateAsync(TenantQuotaUsage usage, CancellationToken cancellationToken = default)
{
context.TenantQuotaUsages.Update(usage);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
return context.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,356 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence;
using TakeoutSaaS.Infrastructure.Logs.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Repositories;
/// <summary>
/// 租户聚合的 EF Core 仓储实现。
/// </summary>
public sealed class EfTenantRepository(TakeoutAppDbContext context, TakeoutLogsDbContext logsContext) : ITenantRepository
{
/// <inheritdoc />
public Task<Tenant?> FindByIdAsync(long tenantId, CancellationToken cancellationToken = default)
{
return context.Tenants
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == tenantId, cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<Tenant>> FindByIdsAsync(IReadOnlyCollection<long> tenantIds, CancellationToken cancellationToken = default)
{
if (tenantIds.Count == 0)
{
return Array.Empty<Tenant>();
}
return await context.Tenants
.AsNoTracking()
.Where(x => tenantIds.Contains(x.Id))
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<Tenant>> SearchAsync(
TenantStatus? status,
string? keyword,
CancellationToken cancellationToken = default)
{
// 1. 构建基础查询
var query = context.Tenants.AsNoTracking();
// 2. 按状态过滤
if (status.HasValue)
{
query = query.Where(x => x.Status == status.Value);
}
// 3. 按关键字过滤
if (!string.IsNullOrWhiteSpace(keyword))
{
keyword = keyword.Trim();
query = query.Where(x =>
EF.Functions.ILike(x.Name, $"%{keyword}%") ||
EF.Functions.ILike(x.Code, $"%{keyword}%") ||
EF.Functions.ILike(x.ContactName ?? string.Empty, $"%{keyword}%") ||
EF.Functions.ILike(x.ContactPhone ?? string.Empty, $"%{keyword}%"));
}
// 4. 排序返回
return await query
.OrderByDescending(x => x.CreatedAt)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<(IReadOnlyList<Tenant> Items, int Total)> SearchPagedAsync(
TenantStatus? status,
TenantVerificationStatus? verificationStatus,
string? name,
string? contactName,
string? contactPhone,
string? keyword,
int page,
int pageSize,
CancellationToken cancellationToken = default)
{
var query = context.Tenants.AsNoTracking();
// 1. 按租户状态过滤
if (status.HasValue)
{
query = query.Where(x => x.Status == status.Value);
}
// 2. 按实名认证状态过滤(未提交视为 Draft
if (verificationStatus.HasValue)
{
query = from tenant in query
join profile in context.TenantVerificationProfiles.AsNoTracking()
on tenant.Id equals profile.TenantId into profiles
from profile in profiles.DefaultIfEmpty()
where (profile == null ? TenantVerificationStatus.Draft : profile.Status) == verificationStatus.Value
select tenant;
}
// 3. 按名称/联系人/电话过滤(模糊匹配)
if (!string.IsNullOrWhiteSpace(name))
{
var normalizedName = name.Trim();
query = query.Where(x => EF.Functions.ILike(x.Name, $"%{normalizedName}%"));
}
// 4. 按联系人过滤(模糊匹配)
if (!string.IsNullOrWhiteSpace(contactName))
{
var normalizedContactName = contactName.Trim();
query = query.Where(x => EF.Functions.ILike(x.ContactName ?? string.Empty, $"%{normalizedContactName}%"));
}
// 5. 按联系电话过滤(模糊匹配)
if (!string.IsNullOrWhiteSpace(contactPhone))
{
var normalizedContactPhone = contactPhone.Trim();
query = query.Where(x => EF.Functions.ILike(x.ContactPhone ?? string.Empty, $"%{normalizedContactPhone}%"));
}
// 6. 兼容关键字查询:名称/编码/联系人/电话
if (!string.IsNullOrWhiteSpace(keyword))
{
var normalizedKeyword = keyword.Trim();
query = query.Where(x =>
EF.Functions.ILike(x.Name, $"%{normalizedKeyword}%") ||
EF.Functions.ILike(x.Code, $"%{normalizedKeyword}%") ||
EF.Functions.ILike(x.ContactName ?? string.Empty, $"%{normalizedKeyword}%") ||
EF.Functions.ILike(x.ContactPhone ?? string.Empty, $"%{normalizedKeyword}%"));
}
// 7. 先统计总数,再按创建时间倒序分页
var total = await query.CountAsync(cancellationToken);
// 8. 查询当前页数据
var items = await query
.OrderByDescending(x => x.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(cancellationToken);
return (items, total);
}
/// <inheritdoc />
public Task AddTenantAsync(Tenant tenant, CancellationToken cancellationToken = default)
{
return context.Tenants.AddAsync(tenant, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateTenantAsync(Tenant tenant, CancellationToken cancellationToken = default)
{
context.Tenants.Update(tenant);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<bool> ExistsByCodeAsync(string code, CancellationToken cancellationToken = default)
{
var normalized = code.Trim();
return context.Tenants.AnyAsync(x => x.Code == normalized, cancellationToken);
}
/// <inheritdoc />
public Task<long?> FindIdByCodeAsync(string code, CancellationToken cancellationToken = default)
{
// 1. 标准化编码
var normalized = code.Trim();
// 2. 查询租户 ID仅查询未删除且状态正常的租户
return context.Tenants
.AsNoTracking()
.Where(x => x.Code == normalized && x.DeletedAt == null)
.Select(x => (long?)x.Id)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public Task<bool> ExistsByNameAsync(string name, long? excludeTenantId = null, CancellationToken cancellationToken = default)
{
// 1. 标准化名称
var normalized = name.Trim();
// 2. 构建查询(名称使用 ILike 做不区分大小写的等值匹配)
var query = context.Tenants
.AsNoTracking()
.Where(x => EF.Functions.ILike(x.Name, normalized));
// 3. 更新场景排除自身
if (excludeTenantId.HasValue)
{
query = query.Where(x => x.Id != excludeTenantId.Value);
}
// 4. 判断是否存在
return query.AnyAsync(cancellationToken);
}
/// <inheritdoc />
public Task<bool> ExistsByContactPhoneAsync(string phone, CancellationToken cancellationToken = default)
{
var normalized = phone.Trim();
return context.Tenants.AnyAsync(x => x.ContactPhone == normalized, cancellationToken);
}
/// <inheritdoc />
public Task<long?> FindTenantIdByContactPhoneAsync(string phone, CancellationToken cancellationToken = default)
{
// 1. 标准化手机号
var normalized = phone.Trim();
// 2. 查询租户 ID
return context.Tenants.AsNoTracking()
.Where(x => x.ContactPhone == normalized)
.Select(x => (long?)x.Id)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public Task<TenantVerificationProfile?> GetVerificationProfileAsync(long tenantId, CancellationToken cancellationToken = default)
{
return context.TenantVerificationProfiles
.AsNoTracking()
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.TenantId == tenantId, cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TenantVerificationProfile>> GetVerificationProfilesAsync(
IReadOnlyCollection<long> tenantIds,
CancellationToken cancellationToken = default)
{
// 1. tenantIds 为空直接返回
if (tenantIds.Count == 0)
{
return Array.Empty<TenantVerificationProfile>();
}
// 2. 批量查询实名资料
return await context.TenantVerificationProfiles
.AsNoTracking()
.Where(x => x.DeletedAt == null && tenantIds.Contains(x.TenantId))
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task UpsertVerificationProfileAsync(TenantVerificationProfile profile, CancellationToken cancellationToken = default)
{
// 1. 查询现有实名资料
var existing = await context.TenantVerificationProfiles
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.TenantId == profile.TenantId, cancellationToken);
if (existing == null)
{
// 2. 不存在则新增
await context.TenantVerificationProfiles.AddAsync(profile, cancellationToken);
return;
}
// 3. 存在则更新当前值
profile.Id = existing.Id;
context.Entry(existing).CurrentValues.SetValues(profile);
}
/// <inheritdoc />
public Task<TenantSubscription?> GetActiveSubscriptionAsync(long tenantId, CancellationToken cancellationToken = default)
{
return context.TenantSubscriptions
.AsNoTracking()
.Where(x => x.DeletedAt == null && x.TenantId == tenantId)
.OrderByDescending(x => x.EffectiveTo)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TenantSubscription>> GetSubscriptionsAsync(
IReadOnlyCollection<long> tenantIds,
CancellationToken cancellationToken = default)
{
// 1. tenantIds 为空直接返回
if (tenantIds.Count == 0)
{
return Array.Empty<TenantSubscription>();
}
// 2. 批量查询订阅数据
return await context.TenantSubscriptions
.AsNoTracking()
.Where(x => x.DeletedAt == null && tenantIds.Contains(x.TenantId))
.OrderByDescending(x => x.EffectiveTo)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public Task<TenantSubscription?> FindSubscriptionByIdAsync(long tenantId, long subscriptionId, CancellationToken cancellationToken = default)
{
return context.TenantSubscriptions
.FirstOrDefaultAsync(
x => x.DeletedAt == null && x.TenantId == tenantId && x.Id == subscriptionId,
cancellationToken);
}
/// <inheritdoc />
public Task AddSubscriptionAsync(TenantSubscription subscription, CancellationToken cancellationToken = default)
{
return context.TenantSubscriptions.AddAsync(subscription, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateSubscriptionAsync(TenantSubscription subscription, CancellationToken cancellationToken = default)
{
context.TenantSubscriptions.Update(subscription);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task AddSubscriptionHistoryAsync(TenantSubscriptionHistory history, CancellationToken cancellationToken = default)
{
return context.TenantSubscriptionHistories.AddAsync(history, cancellationToken).AsTask();
}
/// <inheritdoc />
public async Task<IReadOnlyList<TenantSubscriptionHistory>> GetSubscriptionHistoryAsync(long tenantId, CancellationToken cancellationToken = default)
{
return await context.TenantSubscriptionHistories
.AsNoTracking()
.Where(x => x.DeletedAt == null && x.TenantId == tenantId)
.OrderByDescending(x => x.EffectiveFrom)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public Task AddAuditLogAsync(TenantAuditLog log, CancellationToken cancellationToken = default)
{
return logsContext.TenantAuditLogs.AddAsync(log, cancellationToken).AsTask();
}
/// <inheritdoc />
public async Task<IReadOnlyList<TenantAuditLog>> GetAuditLogsAsync(long tenantId, CancellationToken cancellationToken = default)
{
return await logsContext.TenantAuditLogs
.AsNoTracking()
.Where(x => x.DeletedAt == null && x.TenantId == tenantId)
.OrderByDescending(x => x.CreatedAt)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
// 1. 保存业务库变更
await context.SaveChangesAsync(cancellationToken);
// 2. 保存日志库变更
await logsContext.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,233 @@
using System.Globalization;
using System.Text.Json;
using TakeoutSaaS.Domain.Tenants.Entities;
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;
/// <summary>
/// 账单领域服务实现。
/// </summary>
public sealed class BillingDomainService(
ITenantBillingRepository billingRepository,
ITenantRepository tenantRepository,
ITenantContextAccessor tenantContextAccessor,
ITenantPackageRepository tenantPackageRepository,
IIdGenerator idGenerator) : IBillingDomainService
{
/// <inheritdoc />
public async Task<TenantBillingStatement> GenerateSubscriptionBillingAsync(
TenantSubscription subscription,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(subscription);
// 1. 校验幂等:同一周期开始时间只能存在一张未取消账单
var exists = await billingRepository.ExistsNotCancelledByPeriodStartAsync(
subscription.TenantId,
subscription.EffectiveFrom,
cancellationToken);
if (exists)
{
throw new InvalidOperationException("该订阅周期的账单已存在。");
}
// 2. 查询套餐价格信息
var package = await tenantPackageRepository.FindByIdAsync(subscription.TenantPackageId, cancellationToken);
if (package is null)
{
throw new InvalidOperationException("订阅未关联有效套餐,无法生成账单。");
}
// 3. 选择价格(简化规则:优先按年/按月)
var days = (subscription.EffectiveTo - subscription.EffectiveFrom).TotalDays;
var amountDue = days >= 300 ? package.YearlyPrice : package.MonthlyPrice;
if (!amountDue.HasValue)
{
throw new InvalidOperationException("套餐价格未配置,无法生成账单。");
}
// 4. 生成账单明细
var lineItems = new List<BillingLineItem>
{
BillingLineItem.Create(
itemType: "Subscription",
description: $"套餐 {package.Name} 订阅费用",
quantity: 1,
unitPrice: amountDue.Value)
};
// 5. 构建账单实体
var now = DateTime.UtcNow;
return new TenantBillingStatement
{
Id = idGenerator.NextId(),
TenantId = subscription.TenantId,
StatementNo = GenerateStatementNo(),
BillingType = BillingType.Subscription,
SubscriptionId = subscription.Id,
PeriodStart = subscription.EffectiveFrom,
PeriodEnd = subscription.EffectiveTo,
AmountDue = amountDue.Value,
DiscountAmount = 0m,
TaxAmount = 0m,
AmountPaid = 0m,
Currency = "CNY",
Status = TenantBillingStatus.Pending,
DueDate = now.AddDays(7),
LineItemsJson = JsonSerializer.Serialize(lineItems),
Notes = subscription.Notes
};
}
/// <inheritdoc />
public Task<TenantBillingStatement> GenerateQuotaPurchaseBillingAsync(
long tenantId,
QuotaPackage quotaPackage,
int quantity,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(quotaPackage);
if (quantity <= 0)
{
throw new ArgumentOutOfRangeException(nameof(quantity), "购买数量必须大于 0。");
}
// 1. 计算金额
var amountDue = quotaPackage.Price * quantity;
// 2. 生成账单明细
var lineItems = new List<BillingLineItem>
{
BillingLineItem.Create(
itemType: "QuotaPurchase",
description: $"配额包 {quotaPackage.Name} × {quantity}",
quantity: quantity,
unitPrice: quotaPackage.Price)
};
// 3. 构建账单实体
var now = DateTime.UtcNow;
var billing = new TenantBillingStatement
{
Id = idGenerator.NextId(),
TenantId = tenantId,
StatementNo = GenerateStatementNo(),
BillingType = BillingType.QuotaPurchase,
SubscriptionId = null,
PeriodStart = now,
PeriodEnd = now,
AmountDue = amountDue,
DiscountAmount = 0m,
TaxAmount = 0m,
AmountPaid = 0m,
Currency = "CNY",
Status = TenantBillingStatus.Pending,
DueDate = now.AddDays(7),
LineItemsJson = JsonSerializer.Serialize(lineItems),
Notes = quotaPackage.Description
};
return Task.FromResult(billing);
}
/// <inheritdoc />
public string GenerateStatementNo()
{
// 1. 账单号格式BILL-{yyyyMMdd}-{序号}
var date = DateTime.UtcNow.ToString("yyyyMMdd", CultureInfo.InvariantCulture);
// 2. 使用雪花 ID 作为全局递增序号,确保分布式唯一
var sequence = idGenerator.NextId();
return $"BILL-{date}-{sequence}";
}
/// <inheritdoc />
public async Task<int> ProcessOverdueBillingsAsync(CancellationToken cancellationToken = default)
{
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 updated = 0;
foreach (var billing in overdueBillings)
{
if (billing.Status != TenantBillingStatus.Pending)
{
continue;
}
billing.MarkAsOverdue();
billing.OverdueNotifiedAt ??= processedAt;
billing.UpdatedAt = processedAt;
await billingRepository.UpdateAsync(billing, cancellationToken);
updated++;
}
// 3. (空行后) 持久化
if (updated > 0)
{
await billingRepository.SaveChangesAsync(cancellationToken);
}
return updated;
}
/// <inheritdoc />
public decimal CalculateTotalAmount(decimal baseAmount, decimal discountAmount, decimal taxAmount)
{
return baseAmount - discountAmount + taxAmount;
}
/// <inheritdoc />
public bool CanProcessPayment(TenantBillingStatement billing)
{
ArgumentNullException.ThrowIfNull(billing);
return billing.Status switch
{
TenantBillingStatus.Pending => true,
TenantBillingStatus.Overdue => true,
_ => false
};
}
}

View File

@@ -0,0 +1,203 @@
using ClosedXML.Excel;
using CsvHelper;
using CsvHelper.Configuration;
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
using System.Globalization;
using System.Text;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Services;
namespace TakeoutSaaS.Infrastructure.App.Services;
/// <summary>
/// 账单导出服务实现Excel/PDF/CSV
/// </summary>
public sealed class BillingExportService : IBillingExportService
{
/// <summary>
/// 初始化导出服务并配置 QuestPDF 许可证。
/// </summary>
public BillingExportService()
{
QuestPDF.Settings.License = LicenseType.Community;
}
/// <inheritdoc />
public Task<byte[]> ExportToExcelAsync(IReadOnlyList<TenantBillingStatement> billings, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(billings);
// 1. 创建工作簿与工作表
using var workbook = new XLWorkbook();
var worksheet = workbook.Worksheets.Add("Billings");
// 2. 写入表头
var headers = new[]
{
"Id", "TenantId", "StatementNo", "BillingType", "Status",
"PeriodStart", "PeriodEnd", "AmountDue", "DiscountAmount", "TaxAmount", "TotalAmount",
"AmountPaid", "Currency", "DueDate", "Notes", "LineItemsJson"
};
for (var i = 0; i < headers.Length; i++)
{
worksheet.Cell(1, i + 1).Value = headers[i];
}
// 3. 写入数据行
for (var rowIndex = 0; rowIndex < billings.Count; rowIndex++)
{
cancellationToken.ThrowIfCancellationRequested();
var billing = billings[rowIndex];
var totalAmount = billing.CalculateTotalAmount();
var r = rowIndex + 2;
worksheet.Cell(r, 1).Value = billing.Id;
worksheet.Cell(r, 2).Value = billing.TenantId;
worksheet.Cell(r, 3).Value = billing.StatementNo;
worksheet.Cell(r, 4).Value = billing.BillingType.ToString();
worksheet.Cell(r, 5).Value = billing.Status.ToString();
worksheet.Cell(r, 6).Value = billing.PeriodStart.ToString("O", CultureInfo.InvariantCulture);
worksheet.Cell(r, 7).Value = billing.PeriodEnd.ToString("O", CultureInfo.InvariantCulture);
worksheet.Cell(r, 8).Value = billing.AmountDue;
worksheet.Cell(r, 9).Value = billing.DiscountAmount;
worksheet.Cell(r, 10).Value = billing.TaxAmount;
worksheet.Cell(r, 11).Value = totalAmount;
worksheet.Cell(r, 12).Value = billing.AmountPaid;
worksheet.Cell(r, 13).Value = billing.Currency;
worksheet.Cell(r, 14).Value = billing.DueDate.ToString("O", CultureInfo.InvariantCulture);
worksheet.Cell(r, 15).Value = billing.Notes ?? string.Empty;
worksheet.Cell(r, 16).Value = billing.LineItemsJson ?? string.Empty;
}
// 4. 自动调整列宽并输出
worksheet.Columns().AdjustToContents();
using var stream = new MemoryStream();
workbook.SaveAs(stream);
return Task.FromResult(stream.ToArray());
}
/// <inheritdoc />
public Task<byte[]> ExportToPdfAsync(IReadOnlyList<TenantBillingStatement> billings, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(billings);
// 1. 生成 PDF 文档(避免复杂表格,按条目输出)
var document = Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.A4);
page.Margin(20);
page.DefaultTextStyle(x => x.FontSize(10));
page.Content().Column(column =>
{
column.Spacing(6);
// 2. 标题
column.Item().Text("Billings Export").FontSize(16).SemiBold();
// 3. 逐条输出
for (var i = 0; i < billings.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var b = billings[i];
var total = b.CalculateTotalAmount();
column.Item().Border(1).BorderColor(Colors.Grey.Lighten2).Padding(8).Column(item =>
{
item.Spacing(2);
item.Item().Text($"StatementNo: {b.StatementNo}");
item.Item().Text($"TenantId: {b.TenantId} BillingType: {b.BillingType} Status: {b.Status}");
item.Item().Text($"Period: {b.PeriodStart:yyyy-MM-dd} ~ {b.PeriodEnd:yyyy-MM-dd} DueDate: {b.DueDate:yyyy-MM-dd}");
item.Item().Text($"AmountDue: {b.AmountDue:0.##} Discount: {b.DiscountAmount:0.##} Tax: {b.TaxAmount:0.##}");
item.Item().Text($"Total: {total:0.##} Paid: {b.AmountPaid:0.##} Currency: {b.Currency}");
if (!string.IsNullOrWhiteSpace(b.Notes))
{
item.Item().Text($"Notes: {b.Notes}");
}
});
}
});
});
});
// 4. 输出字节
var bytes = document.GeneratePdf();
return Task.FromResult(bytes);
}
/// <inheritdoc />
public async Task<byte[]> ExportToCsvAsync(IReadOnlyList<TenantBillingStatement> billings, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(billings);
// 1. 使用 UTF-8 BOM便于 Excel 直接打开
await using var stream = new MemoryStream();
await using var writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), leaveOpen: true);
var config = new CsvConfiguration(CultureInfo.InvariantCulture)
{
HasHeaderRecord = true
};
await using var csv = new CsvWriter(writer, config);
// 2. 写入表头
csv.WriteField("Id");
csv.WriteField("TenantId");
csv.WriteField("StatementNo");
csv.WriteField("BillingType");
csv.WriteField("Status");
csv.WriteField("PeriodStart");
csv.WriteField("PeriodEnd");
csv.WriteField("AmountDue");
csv.WriteField("DiscountAmount");
csv.WriteField("TaxAmount");
csv.WriteField("TotalAmount");
csv.WriteField("AmountPaid");
csv.WriteField("Currency");
csv.WriteField("DueDate");
csv.WriteField("Notes");
csv.WriteField("LineItemsJson");
await csv.NextRecordAsync();
// 3. 写入数据行
foreach (var b in billings)
{
cancellationToken.ThrowIfCancellationRequested();
var total = b.CalculateTotalAmount();
csv.WriteField(b.Id);
csv.WriteField(b.TenantId);
csv.WriteField(b.StatementNo);
csv.WriteField(b.BillingType.ToString());
csv.WriteField(b.Status.ToString());
csv.WriteField(b.PeriodStart.ToString("O", CultureInfo.InvariantCulture));
csv.WriteField(b.PeriodEnd.ToString("O", CultureInfo.InvariantCulture));
csv.WriteField(b.AmountDue);
csv.WriteField(b.DiscountAmount);
csv.WriteField(b.TaxAmount);
csv.WriteField(total);
csv.WriteField(b.AmountPaid);
csv.WriteField(b.Currency);
csv.WriteField(b.DueDate.ToString("O", CultureInfo.InvariantCulture));
csv.WriteField(b.Notes ?? string.Empty);
csv.WriteField(b.LineItemsJson ?? string.Empty);
await csv.NextRecordAsync();
}
// 4. Flush 并返回字节
await writer.FlushAsync(cancellationToken);
return stream.ToArray();
}
}

View File

@@ -0,0 +1,375 @@
using System.Globalization;
using System.Linq;
using System.Text.Json;
using TakeoutSaaS.Application.App.Stores.Dto;
using TakeoutSaaS.Application.App.Stores.Services;
using TakeoutSaaS.Domain.Stores.Entities;
namespace TakeoutSaaS.Infrastructure.App.Services;
/// <summary>
/// 配送范围检测服务实现。
/// </summary>
public sealed class DeliveryZoneService : IDeliveryZoneService
{
private const double CoordinateTolerance = 1e-6;
/// <inheritdoc />
public StoreDeliveryCheckResultDto CheckPointInZones(
IReadOnlyList<StoreDeliveryZone> zones,
double longitude,
double latitude)
{
// 1. 无配送区域直接返回
if (zones is null || zones.Count == 0)
{
return new StoreDeliveryCheckResultDto { InRange = false };
}
// 2. (空行后) 逐个检测多边形命中
foreach (var zone in zones)
{
if (!TryReadPolygons(zone.PolygonGeoJson, out var polygons))
{
continue;
}
foreach (var polygon in polygons)
{
if (!IsPointInPolygon(polygon, longitude, latitude))
{
continue;
}
return new StoreDeliveryCheckResultDto
{
InRange = true,
DeliveryZoneId = zone.Id,
DeliveryZoneName = zone.ZoneName
};
}
}
// 3. (空行后) 未命中任何区域
return new StoreDeliveryCheckResultDto { InRange = false };
}
private static bool TryReadPolygons(string geoJson, out List<Polygon> polygons)
{
polygons = [];
if (string.IsNullOrWhiteSpace(geoJson))
{
return false;
}
try
{
using var document = JsonDocument.Parse(geoJson);
if (!TryReadPolygons(document.RootElement, polygons))
{
return false;
}
return polygons.Count > 0;
}
catch (JsonException)
{
return false;
}
}
private static bool TryReadPolygons(JsonElement root, ICollection<Polygon> polygons)
{
if (root.ValueKind == JsonValueKind.String)
{
var inner = root.GetString();
if (string.IsNullOrWhiteSpace(inner))
{
return false;
}
using var innerDocument = JsonDocument.Parse(inner);
return TryReadPolygons(innerDocument.RootElement, polygons);
}
if (root.ValueKind != JsonValueKind.Object)
{
return false;
}
if (!TryGetPropertyIgnoreCase(root, "type", out var typeElement)
|| typeElement.ValueKind != JsonValueKind.String)
{
return false;
}
var type = typeElement.GetString();
if (string.Equals(type, "FeatureCollection", StringComparison.OrdinalIgnoreCase))
{
if (!TryGetPropertyIgnoreCase(root, "features", out var featuresElement)
|| featuresElement.ValueKind != JsonValueKind.Array)
{
return false;
}
foreach (var featureElement in featuresElement.EnumerateArray())
{
if (featureElement.ValueKind != JsonValueKind.Object)
{
continue;
}
if (!TryGetPropertyIgnoreCase(featureElement, "geometry", out var geometryElement))
{
continue;
}
TryReadPolygons(geometryElement, polygons);
}
return polygons.Count > 0;
}
if (string.Equals(type, "Feature", StringComparison.OrdinalIgnoreCase))
{
if (!TryGetPropertyIgnoreCase(root, "geometry", out var geometryElement))
{
return false;
}
return TryReadPolygons(geometryElement, polygons);
}
if (string.Equals(type, "Polygon", StringComparison.OrdinalIgnoreCase))
{
if (!TryGetPropertyIgnoreCase(root, "coordinates", out var coordinatesElement))
{
return false;
}
if (!TryReadPolygonFromCoordinates(coordinatesElement, out var polygon))
{
return false;
}
polygons.Add(polygon);
return true;
}
if (string.Equals(type, "MultiPolygon", StringComparison.OrdinalIgnoreCase))
{
if (!TryGetPropertyIgnoreCase(root, "coordinates", out var coordinatesElement)
|| coordinatesElement.ValueKind != JsonValueKind.Array)
{
return false;
}
foreach (var polygonElement in coordinatesElement.EnumerateArray())
{
if (!TryReadPolygonFromCoordinates(polygonElement, out var polygon))
{
continue;
}
polygons.Add(polygon);
}
return polygons.Count > 0;
}
return false;
}
private static bool TryReadPolygonFromCoordinates(JsonElement coordinatesElement, out Polygon polygon)
{
polygon = default!;
if (!TryReadRings(coordinatesElement, out var rings) || rings.Count == 0)
{
return false;
}
var outer = rings[0];
if (outer.Count < 3)
{
return false;
}
var holes = rings.Count > 1 ? rings.Skip(1).ToList() : [];
polygon = new Polygon(outer, holes);
return true;
}
private static bool TryReadRings(JsonElement element, out List<List<Point>> rings)
{
rings = [];
if (element.ValueKind != JsonValueKind.Array || element.GetArrayLength() == 0)
{
return false;
}
if (IsPositionArray(element[0]))
{
if (!TryReadRing(element, out var ring))
{
return false;
}
rings.Add(ring);
return true;
}
foreach (var ringElement in element.EnumerateArray())
{
if (!TryReadRing(ringElement, out var ring))
{
return false;
}
rings.Add(ring);
}
return rings.Count > 0;
}
private static bool TryReadRing(JsonElement ringElement, out List<Point> ring)
{
ring = [];
if (ringElement.ValueKind != JsonValueKind.Array)
{
return false;
}
foreach (var pointElement in ringElement.EnumerateArray())
{
if (!TryReadPosition(pointElement, out var point))
{
return false;
}
ring.Add(point);
}
if (ring.Count >= 2 && AreSamePoint(ring[0], ring[^1]))
{
ring.RemoveAt(ring.Count - 1);
}
return ring.Count >= 3;
}
private static bool TryReadPosition(JsonElement pointElement, out Point point)
{
point = default;
if (pointElement.ValueKind != JsonValueKind.Array || pointElement.GetArrayLength() < 2)
{
return false;
}
if (!TryGetCoordinate(pointElement[0], out var longitude)
|| !TryGetCoordinate(pointElement[1], out var latitude))
{
return false;
}
point = new Point(longitude, latitude);
return true;
}
private static bool TryGetCoordinate(JsonElement element, out double value)
{
value = 0;
if (element.ValueKind == JsonValueKind.Number)
{
return element.TryGetDouble(out value);
}
if (element.ValueKind == JsonValueKind.String)
{
return double.TryParse(element.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out value);
}
return false;
}
private static bool IsPositionArray(JsonElement element)
=> element.ValueKind == JsonValueKind.Array
&& element.GetArrayLength() >= 2
&& TryGetCoordinate(element[0], out _)
&& TryGetCoordinate(element[1], out _);
private static bool TryGetPropertyIgnoreCase(JsonElement element, string propertyName, out JsonElement value)
{
if (element.TryGetProperty(propertyName, out value))
{
return true;
}
foreach (var property in element.EnumerateObject())
{
if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase))
{
value = property.Value;
return true;
}
}
value = default;
return false;
}
private static bool IsPointInPolygon(Polygon polygon, double x, double y)
{
if (!IsPointInRing(polygon.Outer, x, y))
{
return false;
}
foreach (var hole in polygon.Holes)
{
if (IsPointInRing(hole, x, y))
{
return false;
}
}
return true;
}
private static bool IsPointInRing(IReadOnlyList<Point> ring, double x, double y)
{
if (IsPointOnBoundary(ring, x, y))
{
return true;
}
var inside = false;
for (var i = 0; i < ring.Count; i++)
{
var j = i == 0 ? ring.Count - 1 : i - 1;
var xi = ring[i].Longitude;
var yi = ring[i].Latitude;
var xj = ring[j].Longitude;
var yj = ring[j].Latitude;
var intersects = ((yi > y) != (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi + double.Epsilon) + xi);
if (intersects)
{
inside = !inside;
}
}
return inside;
}
private static bool IsPointOnBoundary(IReadOnlyList<Point> ring, double x, double y)
{
for (var i = 0; i < ring.Count; i++)
{
var j = i == ring.Count - 1 ? 0 : i + 1;
if (IsPointOnSegment(ring[i], ring[j], x, y))
{
return true;
}
}
return false;
}
private static bool IsPointOnSegment(Point start, Point end, double x, double y)
{
var cross = (end.Longitude - start.Longitude) * (y - start.Latitude)
- (end.Latitude - start.Latitude) * (x - start.Longitude);
if (Math.Abs(cross) > CoordinateTolerance)
{
return false;
}
var dot = (x - start.Longitude) * (x - end.Longitude)
+ (y - start.Latitude) * (y - end.Latitude);
return dot <= CoordinateTolerance;
}
private static bool AreSamePoint(Point first, Point second)
=> Math.Abs(first.Longitude - second.Longitude) <= CoordinateTolerance
&& Math.Abs(first.Latitude - second.Latitude) <= CoordinateTolerance;
private readonly record struct Point(double Longitude, double Latitude);
private sealed record Polygon(IReadOnlyList<Point> Outer, IReadOnlyList<IReadOnlyList<Point>> Holes);
}

View File

@@ -0,0 +1,221 @@
using System.Text.Json;
using TakeoutSaaS.Application.App.Stores.Services;
namespace TakeoutSaaS.Infrastructure.App.Services;
/// <summary>
/// GeoJSON 校验服务实现。
/// </summary>
public sealed class GeoJsonValidationService : IGeoJsonValidationService
{
private const double CoordinateTolerance = 1e-6;
/// <inheritdoc />
public GeoJsonValidationResult ValidatePolygon(string geoJson)
{
// 1. 基础校验
if (string.IsNullOrWhiteSpace(geoJson))
{
return BuildInvalid("GeoJSON 不能为空");
}
// 2. (空行后) 解析与验证结构
try
{
using var document = JsonDocument.Parse(geoJson);
var root = document.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
return BuildInvalid("GeoJSON 格式错误");
}
if (!root.TryGetProperty("type", out var typeElement) || typeElement.ValueKind != JsonValueKind.String)
{
return BuildInvalid("GeoJSON 缺少 type");
}
var type = typeElement.GetString();
if (!string.Equals(type, "Polygon", StringComparison.OrdinalIgnoreCase))
{
return BuildInvalid("仅支持 Polygon 类型");
}
if (!root.TryGetProperty("coordinates", out var coordinatesElement) || coordinatesElement.ValueKind != JsonValueKind.Array)
{
return BuildInvalid("GeoJSON 缺少 coordinates");
}
if (coordinatesElement.GetArrayLength() == 0)
{
return BuildInvalid("GeoJSON coordinates 为空");
}
var ringElement = coordinatesElement[0];
if (ringElement.ValueKind != JsonValueKind.Array)
{
return BuildInvalid("GeoJSON 坐标格式错误");
}
var points = new List<Point>();
foreach (var pointElement in ringElement.EnumerateArray())
{
if (pointElement.ValueKind != JsonValueKind.Array || pointElement.GetArrayLength() < 2)
{
return BuildInvalid("坐标点格式错误");
}
if (!pointElement[0].TryGetDouble(out var longitude) || !pointElement[1].TryGetDouble(out var latitude))
{
return BuildInvalid("坐标点必须为数值");
}
points.Add(new Point(longitude, latitude));
}
if (points.Count < 3)
{
return BuildInvalid("多边形至少需要 3 个点");
}
var distinctCount = CountDistinct(points);
if (distinctCount < 3)
{
return BuildInvalid("多边形坐标点不足");
}
var normalized = Normalize(points, out var normalizedJson);
if (normalized.Count < 4)
{
return BuildInvalid("多边形至少需要 4 个点(含闭合点)");
}
if (HasSelfIntersection(normalized))
{
return BuildInvalid("多边形存在自相交");
}
return new GeoJsonValidationResult
{
IsValid = true,
NormalizedGeoJson = normalizedJson
};
}
catch (JsonException)
{
return BuildInvalid("GeoJSON 解析失败");
}
}
private static GeoJsonValidationResult BuildInvalid(string message) => new()
{
IsValid = false,
ErrorMessage = message
};
private static int CountDistinct(IReadOnlyList<Point> points)
{
var distinct = new List<Point>();
foreach (var point in points)
{
if (distinct.Any(existing => AreSamePoint(existing, point)))
{
continue;
}
distinct.Add(point);
}
return distinct.Count;
}
private static List<Point> Normalize(IReadOnlyList<Point> points, out string? normalizedJson)
{
var normalized = new List<Point>(points);
if (!AreSamePoint(normalized[0], normalized[^1]))
{
normalized.Add(normalized[0]);
normalizedJson = BuildGeoJson(normalized);
return normalized;
}
normalizedJson = null;
return normalized;
}
private static string BuildGeoJson(IReadOnlyList<Point> points)
{
var coordinates = points
.Select(point => new[] { point.Longitude, point.Latitude })
.ToArray();
var payload = new Dictionary<string, object?>
{
{ "type", "Polygon" },
{ "coordinates", new[] { coordinates } }
};
return JsonSerializer.Serialize(payload);
}
private static bool HasSelfIntersection(IReadOnlyList<Point> points)
{
var segmentCount = points.Count - 1;
for (var i = 0; i < segmentCount; i++)
{
var a1 = points[i];
var a2 = points[i + 1];
for (var j = i + 1; j < segmentCount; j++)
{
if (Math.Abs(i - j) <= 1)
{
continue;
}
if (i == 0 && j == segmentCount - 1)
{
continue;
}
var b1 = points[j];
var b2 = points[j + 1];
if (SegmentsIntersect(a1, a2, b1, b2))
{
return true;
}
}
}
return false;
}
private static bool SegmentsIntersect(Point p1, Point q1, Point p2, Point q2)
{
var o1 = Orientation(p1, q1, p2);
var o2 = Orientation(p1, q1, q2);
var o3 = Orientation(p2, q2, p1);
var o4 = Orientation(p2, q2, q1);
if (o1 != o2 && o3 != o4)
{
return true;
}
if (o1 == 0 && OnSegment(p1, p2, q1))
{
return true;
}
if (o2 == 0 && OnSegment(p1, q2, q1))
{
return true;
}
if (o3 == 0 && OnSegment(p2, p1, q2))
{
return true;
}
if (o4 == 0 && OnSegment(p2, q1, q2))
{
return true;
}
return false;
}
private static int Orientation(Point p, Point q, Point r)
{
var value = (q.Latitude - p.Latitude) * (r.Longitude - q.Longitude)
- (q.Longitude - p.Longitude) * (r.Latitude - q.Latitude);
if (Math.Abs(value) <= CoordinateTolerance)
{
return 0;
}
return value > 0 ? 1 : 2;
}
private static bool OnSegment(Point p, Point q, Point r)
=> q.Longitude <= Math.Max(p.Longitude, r.Longitude) + CoordinateTolerance
&& q.Longitude >= Math.Min(p.Longitude, r.Longitude) - CoordinateTolerance
&& q.Latitude <= Math.Max(p.Latitude, r.Latitude) + CoordinateTolerance
&& q.Latitude >= Math.Min(p.Latitude, r.Latitude) - CoordinateTolerance;
private static bool AreSamePoint(Point first, Point second)
=> Math.Abs(first.Longitude - second.Longitude) <= CoordinateTolerance
&& Math.Abs(first.Latitude - second.Latitude) <= CoordinateTolerance;
private readonly record struct Point(double Longitude, double Latitude);
}

View File

@@ -0,0 +1,152 @@
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
using System.Globalization;
using TakeoutSaaS.Domain.Common.Enums;
using TakeoutSaaS.Domain.Merchants.Entities;
using TakeoutSaaS.Domain.Merchants.Enums;
using TakeoutSaaS.Domain.Merchants.Services;
using TakeoutSaaS.Domain.Stores.Entities;
using TakeoutSaaS.Domain.Stores.Enums;
namespace TakeoutSaaS.Infrastructure.App.Services;
/// <summary>
/// 商户导出服务实现PDF
/// </summary>
public sealed class MerchantExportService : IMerchantExportService
{
public MerchantExportService()
{
QuestPDF.Settings.License = LicenseType.Community;
}
/// <inheritdoc />
public Task<byte[]> ExportToPdfAsync(
Merchant merchant,
string? tenantName,
IReadOnlyList<Store> stores,
IReadOnlyList<MerchantAuditLog> auditLogs,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(merchant);
var safeStores = stores ?? Array.Empty<Store>();
var safeAuditLogs = auditLogs ?? Array.Empty<MerchantAuditLog>();
var document = Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.A4);
page.Margin(24);
page.DefaultTextStyle(x => x.FontSize(10));
page.Content().Column(column =>
{
column.Spacing(10);
column.Item().Text("Merchant Export").FontSize(16).SemiBold();
column.Item().Element(section => BuildBasicSection(section, merchant, tenantName));
column.Item().Element(section => BuildStoresSection(section, safeStores, cancellationToken));
column.Item().Element(section => BuildAuditSection(section, safeAuditLogs, cancellationToken));
});
});
});
return Task.FromResult(document.GeneratePdf());
}
private static void BuildBasicSection(IContainer container, Merchant merchant, string? tenantName)
{
container.Border(1).BorderColor(Colors.Grey.Lighten2).Padding(10).Column(column =>
{
column.Spacing(4);
column.Item().Text("Basic Information").SemiBold();
column.Item().Text($"Merchant: {merchant.BrandName}");
column.Item().Text($"Tenant: {tenantName ?? "-"} (ID: {merchant.TenantId})");
column.Item().Text($"Operating Mode: {ResolveOperatingMode(merchant.OperatingMode)}");
column.Item().Text($"Status: {merchant.Status}");
column.Item().Text($"Frozen: {(merchant.IsFrozen ? "Yes" : "No")}");
column.Item().Text($"License Number: {merchant.BusinessLicenseNumber ?? "-"}");
column.Item().Text($"Legal Representative: {merchant.LegalPerson ?? "-"}");
column.Item().Text($"Registered Address: {merchant.Address ?? "-"}");
column.Item().Text($"Contact Phone: {merchant.ContactPhone}");
column.Item().Text($"Contact Email: {merchant.ContactEmail ?? "-"}");
column.Item().Text($"Approved At: {FormatDateTime(merchant.ApprovedAt)}");
column.Item().Text($"Approved By: {merchant.ApprovedBy?.ToString() ?? "-"}");
});
}
private static void BuildStoresSection(IContainer container, IReadOnlyList<Store> stores, CancellationToken cancellationToken)
{
container.Border(1).BorderColor(Colors.Grey.Lighten2).Padding(10).Column(column =>
{
column.Spacing(4);
column.Item().Text("Stores").SemiBold();
if (stores.Count == 0)
{
column.Item().Text("No stores.");
return;
}
for (var i = 0; i < stores.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var store = stores[i];
column.Item().Text($"{i + 1}. {store.Name} | {ResolveStoreStatus(store.Status)} | {store.Address ?? "-"} | {store.Phone ?? "-"}");
}
});
}
private static void BuildAuditSection(IContainer container, IReadOnlyList<MerchantAuditLog> auditLogs, CancellationToken cancellationToken)
{
container.Border(1).BorderColor(Colors.Grey.Lighten2).Padding(10).Column(column =>
{
column.Spacing(4);
column.Item().Text("Audit History").SemiBold();
if (auditLogs.Count == 0)
{
column.Item().Text("No audit records.");
return;
}
for (var i = 0; i < auditLogs.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var log = auditLogs[i];
var title = string.IsNullOrWhiteSpace(log.Title) ? log.Action.ToString() : log.Title;
column.Item().Text($"{i + 1}. {title} | {log.OperatorName ?? "-"} | {FormatDateTime(log.CreatedAt)}");
if (!string.IsNullOrWhiteSpace(log.Description))
{
column.Item().Text($" {log.Description}");
}
}
});
}
private static string ResolveOperatingMode(OperatingMode? mode)
=> mode switch
{
OperatingMode.SameEntity => "SameEntity",
OperatingMode.DifferentEntity => "DifferentEntity",
_ => "-"
};
private static string ResolveStoreStatus(StoreStatus status)
=> status switch
{
StoreStatus.Closed => "Closed",
StoreStatus.Preparing => "Preparing",
StoreStatus.Operating => "Operating",
StoreStatus.Suspended => "Suspended",
_ => status.ToString()
};
private static string FormatDateTime(DateTime? value)
=> value.HasValue ? value.Value.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) : "-";
}

View File

@@ -0,0 +1,125 @@
using System.Globalization;
using TakeoutSaaS.Application.App.Stores;
using TakeoutSaaS.Application.App.Stores.Dto;
using TakeoutSaaS.Application.App.Stores.Services;
using TakeoutSaaS.Domain.Stores.Entities;
using TakeoutSaaS.Domain.Stores.Enums;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Infrastructure.App.Services;
/// <summary>
/// 门店费用计算服务实现。
/// </summary>
public sealed class StoreFeeCalculationService : IStoreFeeCalculationService
{
/// <inheritdoc />
public StoreFeeCalculationResultDto Calculate(StoreFee fee, StoreFeeCalculationRequestDto request)
{
// 1. 计算起送费满足情况
var minimum = fee.MinimumOrderAmount;
if (request.OrderAmount < minimum)
{
var shortfall = minimum - request.OrderAmount;
var message = $"还差{shortfall.ToString("0.##", CultureInfo.InvariantCulture)}元起送";
return new StoreFeeCalculationResultDto
{
OrderAmount = request.OrderAmount,
MinimumOrderAmount = minimum,
MeetsMinimum = false,
Shortfall = shortfall,
DeliveryFee = 0m,
PackagingFee = 0m,
PackagingFeeMode = fee.PackagingFeeMode,
OrderPackagingFeeMode = fee.OrderPackagingFeeMode,
TotalFee = 0m,
TotalAmount = request.OrderAmount,
Message = message
};
}
// 2. (空行后) 计算配送费
var deliveryFee = fee.BaseDeliveryFee;
if (fee.FreeDeliveryThreshold.HasValue && request.OrderAmount >= fee.FreeDeliveryThreshold.Value)
{
deliveryFee = 0m;
}
// 3. (空行后) 计算打包费
var packagingFee = 0m;
IReadOnlyList<StoreFeeCalculationBreakdownDto>? breakdown = null;
if (fee.PackagingFeeMode == PackagingFeeMode.Fixed)
{
if (fee.OrderPackagingFeeMode == OrderPackagingFeeMode.Tiered)
{
var tiers = StoreFeeTierHelper.Deserialize(fee.PackagingFeeTiersJson);
if (tiers.Count == 0)
{
throw new BusinessException(ErrorCodes.ValidationFailed, "阶梯打包费配置缺失");
}
packagingFee = ResolveTieredFee(request.OrderAmount, tiers);
}
else
{
packagingFee = fee.FixedPackagingFee;
}
}
else
{
if (request.Items.Count == 0)
{
throw new BusinessException(ErrorCodes.ValidationFailed, "商品计费模式必须提供商品列表");
}
var list = new List<StoreFeeCalculationBreakdownDto>(request.Items.Count);
foreach (var item in request.Items)
{
var subtotal = item.PackagingFee * item.Quantity;
packagingFee += subtotal;
list.Add(new StoreFeeCalculationBreakdownDto
{
SkuId = item.SkuId,
Quantity = item.Quantity,
UnitFee = item.PackagingFee,
Subtotal = subtotal
});
}
breakdown = list;
}
// 4. (空行后) 汇总结果
var totalFee = deliveryFee + packagingFee;
var totalAmount = request.OrderAmount + totalFee;
return new StoreFeeCalculationResultDto
{
OrderAmount = request.OrderAmount,
MinimumOrderAmount = minimum,
MeetsMinimum = true,
DeliveryFee = deliveryFee,
PackagingFee = packagingFee,
PackagingFeeMode = fee.PackagingFeeMode,
OrderPackagingFeeMode = fee.OrderPackagingFeeMode,
PackagingFeeBreakdown = breakdown,
TotalFee = totalFee,
TotalAmount = totalAmount
};
}
private static decimal ResolveTieredFee(decimal orderAmount, IReadOnlyList<StoreFeeTierDto> tiers)
{
foreach (var tier in tiers)
{
if (orderAmount < tier.MinPrice)
{
continue;
}
if (!tier.MaxPrice.HasValue || orderAmount <= tier.MaxPrice.Value)
{
return tier.Fee;
}
}
return tiers[^1].Fee;
}
}

View File

@@ -0,0 +1,302 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
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;
/// <summary>
/// 门店定时任务服务实现。
/// </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
.Where(store => store.DeletedAt == null
&& store.AuditStatus == StoreAuditStatus.Activated
&& store.BusinessStatus != StoreBusinessStatus.ForceClosed)
.ToListAsync(cancellationToken);
if (stores.Count == 0)
{
return 0;
}
// 2. (空行后) 读取营业时段与休息日
var storeIds = stores.Select(store => store.Id).ToArray();
var hours = await context.StoreBusinessHours
.AsNoTracking()
.Where(hour => storeIds.Contains(hour.StoreId))
.ToListAsync(cancellationToken);
var today = now.Date;
var holidays = await context.StoreHolidays
.AsNoTracking()
.Where(holiday => storeIds.Contains(holiday.StoreId)
&& holiday.Date <= today
&& (holiday.EndDate == null || holiday.EndDate >= today))
.ToListAsync(cancellationToken);
// 3. (空行后) 构造查找表
var hoursLookup = hours
.GroupBy(hour => hour.StoreId)
.ToDictionary(group => group.Key, group => (IReadOnlyList<StoreBusinessHour>)group.ToList());
var holidayLookup = holidays
.GroupBy(holiday => holiday.StoreId)
.ToDictionary(group => group.Key, group => (IReadOnlyList<StoreHoliday>)group.ToList());
// 4. (空行后) 判定状态并更新
var updated = 0;
foreach (var store in stores)
{
// 4.1 跳过强制关闭门店
if (store.BusinessStatus == StoreBusinessStatus.ForceClosed)
{
continue;
}
// 4.2 (空行后) 尊重手动歇业原因
if (store.ClosureReason.HasValue && store.ClosureReason != StoreClosureReason.OutOfBusinessHours)
{
continue;
}
// 4.3 (空行后) 计算营业状态
var storeHolidays = holidayLookup.TryGetValue(store.Id, out var matched) ? matched : [];
var nowTime = now.TimeOfDay;
var isHolidayClosed = storeHolidays.Any(holiday =>
holiday.OverrideType == OverrideType.Closed && IsWithinHolidayTime(holiday, nowTime));
var hasModifiedHours = storeHolidays.Any(holiday => holiday.OverrideType == OverrideType.ModifiedHours);
var isModifiedOpen = hasModifiedHours && storeHolidays.Any(holiday =>
holiday.OverrideType == OverrideType.ModifiedHours && IsWithinHolidayTime(holiday, nowTime));
var isTemporaryOpen = storeHolidays.Any(holiday =>
holiday.OverrideType == OverrideType.TemporaryOpen && IsWithinHolidayTime(holiday, nowTime));
var hasHours = hoursLookup.TryGetValue(store.Id, out var storeHours) && storeHours.Count > 0;
var isOpen = false;
if (isHolidayClosed)
{
isOpen = false;
}
else if (hasModifiedHours)
{
isOpen = isModifiedOpen;
}
else
{
isOpen = hasHours && IsWithinBusinessHours(storeHours ?? [], now);
if (!isOpen && isTemporaryOpen)
{
isOpen = true;
}
}
if (isOpen)
{
if (store.BusinessStatus != StoreBusinessStatus.Open)
{
store.BusinessStatus = StoreBusinessStatus.Open;
store.ClosureReason = null;
store.ClosureReasonText = null;
updated++;
}
continue;
}
// 4.4 (空行后) 非营业时段切换为休息
if (store.BusinessStatus != StoreBusinessStatus.Resting || store.ClosureReason != StoreClosureReason.OutOfBusinessHours)
{
store.BusinessStatus = StoreBusinessStatus.Resting;
store.ClosureReason = StoreClosureReason.OutOfBusinessHours;
store.ClosureReasonText = "非营业时间自动休息";
updated++;
}
}
// 5. (空行后) 保存变更并记录日志
if (updated > 0)
{
await context.SaveChangesAsync(cancellationToken);
}
logger.LogInformation("定时任务:营业状态自动切换完成,更新 {UpdatedCount} 家门店", updated);
return updated;
}
/// <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);
var expiredStoreIds = await context.StoreQualifications
.AsNoTracking()
.Where(qualification => qualification.DeletedAt == null
&& qualification.ExpiresAt.HasValue
&& qualification.ExpiresAt.Value < today)
.Select(qualification => qualification.StoreId)
.Distinct()
.ToListAsync(cancellationToken);
if (expiredStoreIds.Count == 0)
{
return 0;
}
// 2. (空行后) 加载门店并更新状态
var stores = await context.Stores
.Where(store => expiredStoreIds.Contains(store.Id)
&& store.DeletedAt == null
&& store.AuditStatus == StoreAuditStatus.Activated
&& store.BusinessStatus != StoreBusinessStatus.ForceClosed)
.ToListAsync(cancellationToken);
var updated = 0;
foreach (var store in stores)
{
// 2.1 跳过已标记过期门店
if (store.BusinessStatus == StoreBusinessStatus.Resting && store.ClosureReason == StoreClosureReason.LicenseExpired)
{
continue;
}
// 2.2 (空行后) 设置资质过期状态
store.BusinessStatus = StoreBusinessStatus.Resting;
store.ClosureReason = StoreClosureReason.LicenseExpired;
store.ClosureReasonText = "证照过期自动休息";
updated++;
}
// 3. (空行后) 保存变更并记录日志
if (updated > 0)
{
await context.SaveChangesAsync(cancellationToken);
}
logger.LogInformation("定时任务:资质过期检查完成,更新 {UpdatedCount} 家门店", updated);
return updated;
}
private static bool IsWithinBusinessHours(IReadOnlyList<StoreBusinessHour> hours, DateTime now)
{
// 1. 提取当前时间
var day = now.DayOfWeek;
var time = now.TimeOfDay;
foreach (var hour in hours)
{
if (hour.HourType == BusinessHourType.Closed)
{
continue;
}
if (hour.StartTime == hour.EndTime)
{
continue;
}
if (hour.StartTime < hour.EndTime)
{
if (hour.DayOfWeek == day && time >= hour.StartTime && time < hour.EndTime)
{
return true;
}
continue;
}
var nextDay = NextDay(hour.DayOfWeek);
if (hour.DayOfWeek == day && time >= hour.StartTime)
{
return true;
}
if (nextDay == day && time < hour.EndTime)
{
return true;
}
}
return false;
}
private static bool IsWithinHolidayTime(StoreHoliday holiday, TimeSpan time)
{
if (holiday.IsAllDay)
{
return true;
}
if (!holiday.StartTime.HasValue || !holiday.EndTime.HasValue)
{
return false;
}
return time >= holiday.StartTime.Value && time < holiday.EndTime.Value;
}
private static DayOfWeek NextDay(DayOfWeek day)
{
var next = (int)day + 1;
return next > 6 ? DayOfWeek.Sunday : (DayOfWeek)next;
}
}