@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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:00(UTC)
|
||||
var monday = date.AddDays(-daysSinceMonday);
|
||||
return new DateTime(monday.Year, monday.Month, monday.Day, 0, 0, 0, DateTimeKind.Utc);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) : "-";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Ids;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.BackgroundServices;
|
||||
|
||||
/// <summary>
|
||||
/// 自动续费后台服务。
|
||||
/// 定期检查开启自动续费的订阅,在到期前自动生成续费账单。
|
||||
/// </summary>
|
||||
public sealed class AutoRenewalService : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<AutoRenewalService> _logger;
|
||||
private readonly AutoRenewalOptions _options;
|
||||
|
||||
public AutoRenewalService(
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<AutoRenewalService> logger,
|
||||
IOptions<AutoRenewalOptions> options)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("自动续费服务已启动");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 计算下次执行时间(每天执行)
|
||||
var now = DateTime.UtcNow;
|
||||
var nextRun = now.Date.AddDays(1).AddHours(_options.ExecuteHour);
|
||||
var delay = nextRun - now;
|
||||
|
||||
_logger.LogInformation("自动续费服务将在 {NextRun} 执行,等待 {Delay}", nextRun, delay);
|
||||
|
||||
await Task.Delay(delay, stoppingToken);
|
||||
|
||||
if (stoppingToken.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
await ProcessAutoRenewalsAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "自动续费服务执行异常");
|
||||
// 出错后等待一段时间再重试
|
||||
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("自动续费服务已停止");
|
||||
}
|
||||
|
||||
private async Task ProcessAutoRenewalsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("开始处理自动续费");
|
||||
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TakeoutAppDbContext>();
|
||||
var idGenerator = scope.ServiceProvider.GetRequiredService<IIdGenerator>();
|
||||
var tenantContextAccessor = scope.ServiceProvider.GetRequiredService<ITenantContextAccessor>();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var renewalThreshold = now.AddDays(_options.RenewalDaysBeforeExpiry);
|
||||
|
||||
try
|
||||
{
|
||||
var tenants = await dbContext.Tenants
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && x.Id > 0)
|
||||
.Select(x => new { x.Id, x.Code })
|
||||
.ToListAsync(cancellationToken);
|
||||
if (tenants.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("自动续费处理完成:未找到可处理租户");
|
||||
return;
|
||||
}
|
||||
|
||||
var billsCreatedTotal = 0;
|
||||
foreach (var tenant in tenants)
|
||||
{
|
||||
using (tenantContextAccessor.EnterTenantScope(tenant.Id, "background:auto-renewal", tenant.Code))
|
||||
{
|
||||
var billsCreated = 0;
|
||||
|
||||
// 查询开启自动续费且即将到期的活跃订阅
|
||||
var autoRenewSubscriptions = await dbContext.TenantSubscriptions
|
||||
.Where(s => s.Status == SubscriptionStatus.Active
|
||||
&& s.AutoRenew
|
||||
&& s.EffectiveTo <= renewalThreshold
|
||||
&& s.EffectiveTo > now)
|
||||
.Join(
|
||||
dbContext.TenantPackages,
|
||||
sub => sub.TenantPackageId,
|
||||
package => package.Id,
|
||||
(sub, package) => new { Subscription = sub, Package = package }
|
||||
)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var item in autoRenewSubscriptions)
|
||||
{
|
||||
// 检查是否已为本次到期生成过账单
|
||||
var existingBill = await dbContext.TenantBillingStatements
|
||||
.AnyAsync(b => b.TenantId == item.Subscription.TenantId
|
||||
&& b.PeriodStart >= item.Subscription.EffectiveTo
|
||||
&& b.Status != TenantBillingStatus.Cancelled,
|
||||
cancellationToken);
|
||||
|
||||
if (existingBill)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"订阅 {SubscriptionId} 已存在续费账单,跳过",
|
||||
item.Subscription.Id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 生成续费账单
|
||||
var billNo = $"BILL-{DateTime.UtcNow:yyyyMMddHHmmss}-{item.Subscription.TenantId}";
|
||||
var periodStart = item.Subscription.EffectiveTo;
|
||||
|
||||
// 从当前订阅计算续费周期(月数)
|
||||
var currentDurationMonths = ((item.Subscription.EffectiveTo.Year - item.Subscription.EffectiveFrom.Year) * 12)
|
||||
+ item.Subscription.EffectiveTo.Month - item.Subscription.EffectiveFrom.Month;
|
||||
if (currentDurationMonths <= 0) currentDurationMonths = 1; // 至少1个月
|
||||
|
||||
var periodEnd = periodStart.AddMonths(currentDurationMonths);
|
||||
|
||||
// 根据续费周期计算价格(年付优惠)
|
||||
var renewalPrice = currentDurationMonths >= 12
|
||||
? (item.Package.YearlyPrice ?? item.Package.MonthlyPrice * 12 ?? 0)
|
||||
: (item.Package.MonthlyPrice ?? 0) * currentDurationMonths;
|
||||
|
||||
var bill = new TenantBillingStatement
|
||||
{
|
||||
Id = idGenerator.NextId(),
|
||||
TenantId = item.Subscription.TenantId,
|
||||
StatementNo = billNo,
|
||||
PeriodStart = periodStart,
|
||||
PeriodEnd = periodEnd,
|
||||
AmountDue = renewalPrice,
|
||||
AmountPaid = 0,
|
||||
Status = TenantBillingStatus.Pending,
|
||||
DueDate = periodStart.AddDays(-1), // 到期前一天为付款截止日
|
||||
LineItemsJson = $"{{\"套餐名称\":\"{item.Package.Name}\",\"续费周期\":\"{currentDurationMonths}个月\"}}",
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
dbContext.TenantBillingStatements.Add(bill);
|
||||
billsCreated++;
|
||||
|
||||
_logger.LogInformation(
|
||||
"为订阅 {SubscriptionId} (租户 {TenantId}) 生成自动续费账单 {BillNo},金额 {Amount}",
|
||||
item.Subscription.Id, item.Subscription.TenantId, billNo, renewalPrice);
|
||||
}
|
||||
|
||||
if (billsCreated > 0)
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
billsCreatedTotal += billsCreated;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("自动续费处理完成,共生成 {Count} 张账单", billsCreatedTotal);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "自动续费处理失败");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 自动续费配置选项。
|
||||
/// </summary>
|
||||
public sealed class AutoRenewalOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 执行时间(小时,UTC时间),默认凌晨1点。
|
||||
/// </summary>
|
||||
public int ExecuteHour { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 在到期前N天生成续费账单,默认3天。
|
||||
/// </summary>
|
||||
public int RenewalDaysBeforeExpiry { get; set; } = 3;
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Ids;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.BackgroundServices;
|
||||
|
||||
/// <summary>
|
||||
/// 续费提醒后台服务。
|
||||
/// 定期检查即将到期的订阅,发送续费提醒通知。
|
||||
/// </summary>
|
||||
public sealed class RenewalReminderService : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<RenewalReminderService> _logger;
|
||||
private readonly RenewalReminderOptions _options;
|
||||
|
||||
public RenewalReminderService(
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<RenewalReminderService> logger,
|
||||
IOptions<RenewalReminderOptions> options)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("续费提醒服务已启动");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 计算下次执行时间(每天执行)
|
||||
var now = DateTime.UtcNow;
|
||||
var nextRun = now.Date.AddDays(1).AddHours(_options.ExecuteHour);
|
||||
var delay = nextRun - now;
|
||||
|
||||
_logger.LogInformation("续费提醒服务将在 {NextRun} 执行,等待 {Delay}", nextRun, delay);
|
||||
|
||||
await Task.Delay(delay, stoppingToken);
|
||||
|
||||
if (stoppingToken.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
await SendRenewalRemindersAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "续费提醒服务执行异常");
|
||||
// 出错后等待一段时间再重试
|
||||
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("续费提醒服务已停止");
|
||||
}
|
||||
|
||||
private async Task SendRenewalRemindersAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("开始发送续费提醒");
|
||||
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TakeoutAppDbContext>();
|
||||
var idGenerator = scope.ServiceProvider.GetRequiredService<IIdGenerator>();
|
||||
var tenantContextAccessor = scope.ServiceProvider.GetRequiredService<ITenantContextAccessor>();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
var tenants = await dbContext.Tenants
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && x.Id > 0)
|
||||
.Select(x => new { x.Id, x.Code, x.Name })
|
||||
.ToListAsync(cancellationToken);
|
||||
if (tenants.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("续费提醒发送完成:未找到可处理租户");
|
||||
return;
|
||||
}
|
||||
|
||||
var remindersSentTotal = 0;
|
||||
foreach (var tenant in tenants)
|
||||
{
|
||||
using (tenantContextAccessor.EnterTenantScope(tenant.Id, "background:renewal-reminder", tenant.Code))
|
||||
{
|
||||
var remindersSent = 0;
|
||||
|
||||
// 遍历配置的提醒时间点(例如:到期前7天、3天、1天)
|
||||
foreach (var daysBeforeExpiry in _options.ReminderDaysBeforeExpiry)
|
||||
{
|
||||
var targetDate = now.AddDays(daysBeforeExpiry);
|
||||
var startOfDay = targetDate.Date;
|
||||
var endOfDay = startOfDay.AddDays(1);
|
||||
|
||||
// 查询即将到期的活跃订阅(且未开启自动续费)
|
||||
var expiringSubscriptions = await dbContext.TenantSubscriptions
|
||||
.Where(s => s.Status == SubscriptionStatus.Active
|
||||
&& !s.AutoRenew
|
||||
&& s.EffectiveTo >= startOfDay
|
||||
&& s.EffectiveTo < endOfDay)
|
||||
.Join(
|
||||
dbContext.TenantPackages,
|
||||
sub => sub.TenantPackageId,
|
||||
package => package.Id,
|
||||
(sub, package) => new { Subscription = sub, Package = package }
|
||||
)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var item in expiringSubscriptions)
|
||||
{
|
||||
// 检查是否已发送过相同天数的提醒(避免重复发送)
|
||||
var alreadySent = await dbContext.TenantNotifications
|
||||
.AnyAsync(n => n.TenantId == item.Subscription.TenantId
|
||||
&& n.Message.Contains($"{daysBeforeExpiry}天内到期")
|
||||
&& n.SentAt >= now.AddHours(-24), // 24小时内已发送过
|
||||
cancellationToken);
|
||||
|
||||
if (alreadySent)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// 创建续费提醒通知
|
||||
var notification = new TenantNotification
|
||||
{
|
||||
Id = idGenerator.NextId(),
|
||||
TenantId = item.Subscription.TenantId,
|
||||
Title = "订阅续费提醒",
|
||||
Message = $"您的订阅套餐「{item.Package.Name}」将在 {daysBeforeExpiry} 天内到期(到期时间:{item.Subscription.EffectiveTo:yyyy-MM-dd HH:mm}),请及时续费以免影响使用。",
|
||||
Severity = daysBeforeExpiry <= 1
|
||||
? TenantNotificationSeverity.Critical
|
||||
: TenantNotificationSeverity.Warning,
|
||||
Channel = TenantNotificationChannel.InApp,
|
||||
SentAt = DateTime.UtcNow,
|
||||
ReadAt = null,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
dbContext.TenantNotifications.Add(notification);
|
||||
remindersSent++;
|
||||
|
||||
_logger.LogInformation(
|
||||
"发送续费提醒: 租户 {TenantName} ({TenantId}), 套餐 {PackageName}, 剩余 {Days} 天",
|
||||
tenant.Name, item.Subscription.TenantId, item.Package.Name, daysBeforeExpiry);
|
||||
}
|
||||
}
|
||||
|
||||
if (remindersSent > 0)
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
remindersSentTotal += remindersSent;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("续费提醒发送完成,共发送 {Count} 条提醒", remindersSentTotal);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "发送续费提醒失败");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 续费提醒配置选项。
|
||||
/// </summary>
|
||||
public sealed class RenewalReminderOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 执行时间(小时,UTC时间),默认上午10点。
|
||||
/// </summary>
|
||||
public int ExecuteHour { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// 提醒时间点(到期前N天),默认7天、3天、1天。
|
||||
/// </summary>
|
||||
public int[] ReminderDaysBeforeExpiry { get; set; } = { 7, 3, 1 };
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.BackgroundServices;
|
||||
|
||||
/// <summary>
|
||||
/// 订阅到期检查后台服务。
|
||||
/// 每天凌晨执行,检查即将到期和已到期的订阅,自动更新状态。
|
||||
/// </summary>
|
||||
public sealed class SubscriptionExpiryCheckService : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<SubscriptionExpiryCheckService> _logger;
|
||||
private readonly SubscriptionExpiryCheckOptions _options;
|
||||
|
||||
public SubscriptionExpiryCheckService(
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<SubscriptionExpiryCheckService> logger,
|
||||
IOptions<SubscriptionExpiryCheckOptions> options)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("订阅到期检查服务已启动");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 计算下次执行时间(每天凌晨)
|
||||
var now = DateTime.UtcNow;
|
||||
var nextRun = now.Date.AddDays(1).AddHours(_options.ExecuteHour);
|
||||
var delay = nextRun - now;
|
||||
|
||||
_logger.LogInformation("订阅到期检查服务将在 {NextRun} 执行,等待 {Delay}", nextRun, delay);
|
||||
|
||||
await Task.Delay(delay, stoppingToken);
|
||||
|
||||
if (stoppingToken.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
await CheckExpiringSubscriptionsAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "订阅到期检查服务执行异常");
|
||||
// 出错后等待一段时间再重试
|
||||
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("订阅到期检查服务已停止");
|
||||
}
|
||||
|
||||
private async Task CheckExpiringSubscriptionsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("开始执行订阅到期检查");
|
||||
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TakeoutAppDbContext>();
|
||||
var tenantContextAccessor = scope.ServiceProvider.GetRequiredService<ITenantContextAccessor>();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var gracePeriodDays = _options.GracePeriodDays;
|
||||
|
||||
try
|
||||
{
|
||||
var tenants = await dbContext.Tenants
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && x.Id > 0)
|
||||
.Select(x => new { x.Id, x.Code })
|
||||
.ToListAsync(cancellationToken);
|
||||
if (tenants.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("订阅到期检查完成:未找到可处理租户");
|
||||
return;
|
||||
}
|
||||
|
||||
var changedTotal = 0;
|
||||
var expiredTotal = 0;
|
||||
var suspendedTotal = 0;
|
||||
foreach (var tenant in tenants)
|
||||
{
|
||||
using (tenantContextAccessor.EnterTenantScope(tenant.Id, "background:subscription-expiry", tenant.Code))
|
||||
{
|
||||
// 1. 检查活跃订阅中已到期的,转为宽限期
|
||||
var expiredActive = await dbContext.TenantSubscriptions
|
||||
.Where(s => s.Status == SubscriptionStatus.Active && s.EffectiveTo < now)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var subscription in expiredActive)
|
||||
{
|
||||
subscription.Status = SubscriptionStatus.GracePeriod;
|
||||
_logger.LogInformation(
|
||||
"订阅 {SubscriptionId} (租户 {TenantId}) 已到期,进入宽限期",
|
||||
subscription.Id, subscription.TenantId);
|
||||
}
|
||||
|
||||
// 2. 检查宽限期订阅中超过宽限期的,转为暂停
|
||||
var gracePeriodExpired = await dbContext.TenantSubscriptions
|
||||
.Where(s => s.Status == SubscriptionStatus.GracePeriod
|
||||
&& s.EffectiveTo.AddDays(gracePeriodDays) < now)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var subscription in gracePeriodExpired)
|
||||
{
|
||||
subscription.Status = SubscriptionStatus.Suspended;
|
||||
_logger.LogInformation(
|
||||
"订阅 {SubscriptionId} (租户 {TenantId}) 宽限期已结束,已暂停",
|
||||
subscription.Id, subscription.TenantId);
|
||||
}
|
||||
|
||||
// 3. 保存更改(逐租户保存,避免跨租户写入)
|
||||
var changedCount = await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
changedTotal += changedCount;
|
||||
expiredTotal += expiredActive.Count;
|
||||
suspendedTotal += gracePeriodExpired.Count;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"订阅到期检查完成,共更新 {Count} 条记录 (到期转宽限期: {ExpiredCount}, 宽限期转暂停: {SuspendedCount})",
|
||||
changedTotal, expiredTotal, suspendedTotal);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "订阅到期检查失败");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 订阅到期检查配置选项。
|
||||
/// </summary>
|
||||
public sealed class SubscriptionExpiryCheckOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 执行时间(小时,UTC时间),默认凌晨2点。
|
||||
/// </summary>
|
||||
public int ExecuteHour { get; set; } = 2;
|
||||
|
||||
/// <summary>
|
||||
/// 宽限期天数,默认7天。
|
||||
/// </summary>
|
||||
public int GracePeriodDays { get; set; } = 7;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using TakeoutSaaS.Infrastructure.Common.Options;
|
||||
using TakeoutSaaS.Infrastructure.Common.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Data;
|
||||
using TakeoutSaaS.Shared.Abstractions.Ids;
|
||||
using TakeoutSaaS.Shared.Kernel.Ids;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Common.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// 数据访问与多数据源相关的服务注册扩展。
|
||||
/// </summary>
|
||||
public static class DatabaseServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 注册数据库基础设施(多数据源配置、读写分离、Dapper 执行器)。
|
||||
/// </summary>
|
||||
/// <param name="services">服务集合。</param>
|
||||
/// <param name="configuration">配置源。</param>
|
||||
/// <returns>服务集合。</returns>
|
||||
public static IServiceCollection AddDatabaseInfrastructure(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddOptions<DatabaseOptions>()
|
||||
.Bind(configuration.GetSection(DatabaseOptions.SectionName))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddOptions<IdGeneratorOptions>()
|
||||
.Bind(configuration.GetSection(IdGeneratorOptions.SectionName))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddSingleton<IIdGenerator>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<IdGeneratorOptions>>().Value;
|
||||
return new SnowflakeIdGenerator(options.WorkerId, options.DatacenterId);
|
||||
});
|
||||
|
||||
services.AddSingleton<IDatabaseConnectionFactory, DatabaseConnectionFactory>();
|
||||
services.AddScoped<IDapperExecutor, DapperExecutor>();
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为指定 DbContext 注册读写分离的 PostgreSQL 配置,同时提供读上下文工厂。
|
||||
/// </summary>
|
||||
/// <typeparam name="TContext">上下文类型。</typeparam>
|
||||
/// <param name="services">服务集合。</param>
|
||||
/// <param name="dataSourceName">逻辑数据源名称。</param>
|
||||
/// <returns>服务集合。</returns>
|
||||
public static IServiceCollection AddPostgresDbContext<TContext>(
|
||||
this IServiceCollection services,
|
||||
string dataSourceName)
|
||||
where TContext : DbContext
|
||||
{
|
||||
services.AddDbContext<TContext>(
|
||||
(sp, options) =>
|
||||
{
|
||||
ConfigureDbContextOptions(sp, options, dataSourceName, DatabaseConnectionRole.Write);
|
||||
},
|
||||
contextLifetime: ServiceLifetime.Scoped,
|
||||
optionsLifetime: ServiceLifetime.Singleton);
|
||||
|
||||
services.AddDbContextFactory<TContext>((sp, options) =>
|
||||
{
|
||||
ConfigureDbContextOptions(sp, options, dataSourceName, DatabaseConnectionRole.Read);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 配置 DbContextOptions,应用连接串、命令超时与重试策略。
|
||||
/// </summary>
|
||||
/// <param name="serviceProvider">服务提供程序。</param>
|
||||
/// <param name="optionsBuilder">上下文配置器。</param>
|
||||
/// <param name="dataSourceName">数据源名称。</param>
|
||||
/// <param name="role">连接角色。</param>
|
||||
private static void ConfigureDbContextOptions(
|
||||
IServiceProvider serviceProvider,
|
||||
DbContextOptionsBuilder optionsBuilder,
|
||||
string dataSourceName,
|
||||
DatabaseConnectionRole role)
|
||||
{
|
||||
var connection = serviceProvider
|
||||
.GetRequiredService<IDatabaseConnectionFactory>()
|
||||
.GetConnection(dataSourceName, role);
|
||||
|
||||
optionsBuilder.UseNpgsql(
|
||||
connection.ConnectionString,
|
||||
npgsqlOptions =>
|
||||
{
|
||||
npgsqlOptions.CommandTimeout(connection.CommandTimeoutSeconds);
|
||||
npgsqlOptions.EnableRetryOnFailure(
|
||||
connection.MaxRetryCount,
|
||||
TimeSpan.FromSeconds(connection.MaxRetryDelaySeconds),
|
||||
null);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Common.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 单个数据源的连接配置,支持主写与多个从读。
|
||||
/// </summary>
|
||||
public sealed class DatabaseDataSourceOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 主写连接串,读写分离缺省回退到此连接。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string? Write { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 从读连接串集合,可为空。
|
||||
/// </summary>
|
||||
public IList<string> Reads { get; init; } = new List<string>();
|
||||
|
||||
/// <summary>
|
||||
/// 默认命令超时(秒),未设置时使用框架默认值。
|
||||
/// </summary>
|
||||
[Range(1, 600)]
|
||||
public int CommandTimeoutSeconds { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// 数据库重试次数。
|
||||
/// </summary>
|
||||
[Range(0, 10)]
|
||||
public int MaxRetryCount { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// 数据库重试最大延迟(秒)。
|
||||
/// </summary>
|
||||
[Range(1, 60)]
|
||||
public int MaxRetryDelaySeconds { get; set; } = 5;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace TakeoutSaaS.Infrastructure.Common.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 数据源配置集合,键为逻辑数据源名称。
|
||||
/// </summary>
|
||||
public sealed class DatabaseOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 配置节名称。
|
||||
/// </summary>
|
||||
public const string SectionName = "Database";
|
||||
|
||||
/// <summary>
|
||||
/// 数据源配置字典,键为数据源名称。
|
||||
/// </summary>
|
||||
public IDictionary<string, DatabaseDataSourceOptions> DataSources { get; init; } =
|
||||
new Dictionary<string, DatabaseDataSourceOptions>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定名称的数据源配置,不存在时返回 null。
|
||||
/// </summary>
|
||||
/// <param name="name">逻辑数据源名称。</param>
|
||||
/// <returns>数据源配置或 null。</returns>
|
||||
public DatabaseDataSourceOptions? Find(string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return DataSources.TryGetValue(name, out var options) ? options : null;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,26 @@ public abstract class AppDbContext(
|
||||
private readonly ICurrentUserAccessor? _currentUserAccessor = currentUserAccessor;
|
||||
private readonly IIdGenerator? _idGenerator = idGenerator;
|
||||
|
||||
/// <summary>
|
||||
/// 是否禁用软删除过滤器。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 仅允许在少数系统任务/恢复场景中临时关闭,默认应保持开启。
|
||||
/// </remarks>
|
||||
protected bool IsSoftDeleteFilterDisabled { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 临时禁用软删除过滤器(仅关闭软删除过滤,不影响租户过滤)。
|
||||
/// </summary>
|
||||
/// <returns>作用域对象,释放后恢复之前的过滤状态。</returns>
|
||||
public IDisposable DisableSoftDeleteFilter()
|
||||
{
|
||||
var previous = IsSoftDeleteFilterDisabled;
|
||||
IsSoftDeleteFilterDisabled = true;
|
||||
|
||||
return new SoftDeleteFilterScope(this, previous);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构建模型时应用软删除过滤器。
|
||||
/// </summary>
|
||||
@@ -179,7 +199,15 @@ public abstract class AppDbContext(
|
||||
private void SetSoftDeleteFilter<TEntity>(ModelBuilder modelBuilder)
|
||||
where TEntity : class, ISoftDeleteEntity
|
||||
{
|
||||
modelBuilder.Entity<TEntity>().HasQueryFilter(entity => entity.DeletedAt == null);
|
||||
QueryFilterCombiner.Combine<TEntity>(modelBuilder, "soft_delete", entity => IsSoftDeleteFilterDisabled || entity.DeletedAt == null);
|
||||
}
|
||||
|
||||
private sealed class SoftDeleteFilterScope(AppDbContext context, bool previous) : IDisposable
|
||||
{
|
||||
public void Dispose()
|
||||
{
|
||||
context.IsSoftDeleteFilterDisabled = previous;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using System.Data;
|
||||
using TakeoutSaaS.Shared.Abstractions.Data;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Common.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// 基于 Dapper 的执行器实现,封装连接创建与读写分离。
|
||||
/// </summary>
|
||||
public sealed class DapperExecutor(
|
||||
IDatabaseConnectionFactory connectionFactory,
|
||||
ILogger<DapperExecutor> logger) : IDapperExecutor
|
||||
{
|
||||
/// <summary>
|
||||
/// 使用指定数据源与读写角色执行异步查询。
|
||||
/// </summary>
|
||||
public async Task<TResult> QueryAsync<TResult>(
|
||||
string dataSourceName,
|
||||
DatabaseConnectionRole role,
|
||||
Func<IDbConnection, CancellationToken, Task<TResult>> query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await ExecuteAsync(
|
||||
dataSourceName,
|
||||
role,
|
||||
async (connection, token) => await query(connection, token),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用指定数据源与读写角色执行异步命令。
|
||||
/// </summary>
|
||||
public async Task ExecuteAsync(
|
||||
string dataSourceName,
|
||||
DatabaseConnectionRole role,
|
||||
Func<IDbConnection, CancellationToken, Task> command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await ExecuteAsync(
|
||||
dataSourceName,
|
||||
role,
|
||||
async (connection, token) =>
|
||||
{
|
||||
await command(connection, token);
|
||||
return true;
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取默认命令超时时间(秒)。
|
||||
/// </summary>
|
||||
public int GetDefaultCommandTimeoutSeconds(string dataSourceName, DatabaseConnectionRole role = DatabaseConnectionRole.Read)
|
||||
{
|
||||
var details = connectionFactory.GetConnection(dataSourceName, role);
|
||||
return details.CommandTimeoutSeconds;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 核心执行逻辑:创建连接、打开并执行委托。
|
||||
/// </summary>
|
||||
private async Task<TResult> ExecuteAsync<TResult>(
|
||||
string dataSourceName,
|
||||
DatabaseConnectionRole role,
|
||||
Func<IDbConnection, CancellationToken, Task<TResult>> action,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var details = connectionFactory.GetConnection(dataSourceName, role);
|
||||
await using var connection = new NpgsqlConnection(details.ConnectionString);
|
||||
|
||||
logger.LogDebug(
|
||||
"打开数据库连接:DataSource={DataSource} Role={Role}",
|
||||
dataSourceName,
|
||||
role);
|
||||
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
return await action(connection, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace TakeoutSaaS.Infrastructure.Common.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// 数据库连接信息(连接串与超时/重试设置)。
|
||||
/// </summary>
|
||||
public sealed record DatabaseConnectionDetails(
|
||||
string ConnectionString,
|
||||
int CommandTimeoutSeconds,
|
||||
int MaxRetryCount,
|
||||
int MaxRetryDelaySeconds);
|
||||
@@ -0,0 +1,120 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Security.Cryptography;
|
||||
using TakeoutSaaS.Infrastructure.Common.Options;
|
||||
using TakeoutSaaS.Shared.Abstractions.Data;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Common.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// 数据库连接工厂,支持读写分离及连接配置校验。
|
||||
/// </summary>
|
||||
public sealed class DatabaseConnectionFactory(
|
||||
IOptionsMonitor<DatabaseOptions> optionsMonitor,
|
||||
IConfiguration configuration,
|
||||
ILogger<DatabaseConnectionFactory> logger) : IDatabaseConnectionFactory
|
||||
{
|
||||
private const int DefaultCommandTimeoutSeconds = 30;
|
||||
private const int DefaultMaxRetryCount = 3;
|
||||
private const int DefaultMaxRetryDelaySeconds = 5;
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定数据源与读写角色的连接信息。
|
||||
/// </summary>
|
||||
/// <param name="dataSourceName">逻辑数据源名称。</param>
|
||||
/// <param name="role">连接角色。</param>
|
||||
/// <returns>连接串与超时/重试配置。</returns>
|
||||
public DatabaseConnectionDetails GetConnection(string dataSourceName, DatabaseConnectionRole role)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dataSourceName))
|
||||
{
|
||||
logger.LogWarning("请求的数据源名称为空,使用默认连接。");
|
||||
return BuildFallbackConnection();
|
||||
}
|
||||
|
||||
var options = optionsMonitor.CurrentValue.Find(dataSourceName);
|
||||
if (options != null)
|
||||
{
|
||||
if (!ValidateOptions(dataSourceName, options))
|
||||
{
|
||||
return BuildFallbackConnection();
|
||||
}
|
||||
|
||||
var connectionString = ResolveConnectionString(options, role);
|
||||
return new DatabaseConnectionDetails(
|
||||
connectionString,
|
||||
options.CommandTimeoutSeconds,
|
||||
options.MaxRetryCount,
|
||||
options.MaxRetryDelaySeconds);
|
||||
}
|
||||
|
||||
var fallback = configuration.GetConnectionString(dataSourceName);
|
||||
if (string.IsNullOrWhiteSpace(fallback))
|
||||
{
|
||||
logger.LogError("缺少数据源 {DataSource} 的连接配置,回退到默认本地连接。", dataSourceName);
|
||||
return BuildFallbackConnection();
|
||||
}
|
||||
|
||||
logger.LogWarning("未找到数据源 {DataSource} 的 Database 节配置,回退使用 ConnectionStrings。", dataSourceName);
|
||||
return new DatabaseConnectionDetails(
|
||||
fallback,
|
||||
DefaultCommandTimeoutSeconds,
|
||||
DefaultMaxRetryCount,
|
||||
DefaultMaxRetryDelaySeconds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 校验数据源配置完整性。
|
||||
/// </summary>
|
||||
/// <param name="dataSourceName">数据源名称。</param>
|
||||
/// <param name="options">数据源配置。</param>
|
||||
/// <exception cref="InvalidOperationException">配置不合法时抛出。</exception>
|
||||
private bool ValidateOptions(string dataSourceName, DatabaseDataSourceOptions options)
|
||||
{
|
||||
var results = new List<ValidationResult>();
|
||||
var context = new ValidationContext(options);
|
||||
if (!Validator.TryValidateObject(options, context, results, validateAllProperties: true))
|
||||
{
|
||||
var errorMessages = string.Join("; ", results.Select(result => result.ErrorMessage));
|
||||
logger.LogError("数据源 {DataSource} 配置非法:{Errors},回退到默认连接。", dataSourceName, errorMessages);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据读写角色选择连接串,从读连接随机分配。
|
||||
/// </summary>
|
||||
/// <param name="options">数据源配置。</param>
|
||||
/// <param name="role">连接角色。</param>
|
||||
/// <returns>可用连接串。</returns>
|
||||
private string ResolveConnectionString(DatabaseDataSourceOptions options, DatabaseConnectionRole role)
|
||||
{
|
||||
if (role == DatabaseConnectionRole.Read && options.Reads.Count > 0)
|
||||
{
|
||||
var index = RandomNumberGenerator.GetInt32(options.Reads.Count);
|
||||
return options.Reads[index];
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.Write))
|
||||
{
|
||||
return BuildFallbackConnection().ConnectionString;
|
||||
}
|
||||
|
||||
return options.Write;
|
||||
}
|
||||
|
||||
private DatabaseConnectionDetails BuildFallbackConnection()
|
||||
{
|
||||
const string fallback = "Host=120.53.222.17;Port=5432;Database=postgres;Username=postgres;Password=MsuMshk112233;Pooling=true;Minimum Pool Size=1;Maximum Pool Size=20";
|
||||
logger.LogWarning("使用默认回退连接串:{Connection}", fallback);
|
||||
return new DatabaseConnectionDetails(
|
||||
fallback,
|
||||
DefaultCommandTimeoutSeconds,
|
||||
DefaultMaxRetryCount,
|
||||
DefaultMaxRetryDelaySeconds);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using TakeoutSaaS.Infrastructure.Common.Options;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Common.Persistence.DesignTime;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core 设计时 DbContext 工厂基类,统一读取 appsettings 中的数据库配置。
|
||||
/// </summary>
|
||||
internal abstract class DesignTimeDbContextFactoryBase<TContext> : IDesignTimeDbContextFactory<TContext>
|
||||
where TContext : TenantAwareDbContext
|
||||
{
|
||||
private readonly string _dataSourceName;
|
||||
private readonly string? _connectionStringEnvVar;
|
||||
|
||||
/// <summary>
|
||||
/// 初始化设计时工厂基类。
|
||||
/// </summary>
|
||||
/// <param name="dataSourceName">数据源名称。</param>
|
||||
/// <param name="connectionStringEnvVar">连接串环境变量名。</param>
|
||||
protected DesignTimeDbContextFactoryBase(string dataSourceName, string? connectionStringEnvVar = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dataSourceName))
|
||||
{
|
||||
throw new ArgumentException("数据源名称不能为空。", nameof(dataSourceName));
|
||||
}
|
||||
|
||||
_dataSourceName = dataSourceName;
|
||||
_connectionStringEnvVar = connectionStringEnvVar;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建设计时 DbContext。
|
||||
/// </summary>
|
||||
/// <param name="args">命令行参数。</param>
|
||||
/// <returns>DbContext 实例。</returns>
|
||||
public TContext CreateDbContext(string[] args)
|
||||
{
|
||||
// 1. 构建 DbContextOptions
|
||||
var optionsBuilder = new DbContextOptionsBuilder<TContext>();
|
||||
optionsBuilder.UseNpgsql(
|
||||
ResolveConnectionString(),
|
||||
npgsql =>
|
||||
{
|
||||
npgsql.CommandTimeout(30);
|
||||
npgsql.EnableRetryOnFailure();
|
||||
});
|
||||
|
||||
// 2. 创建上下文
|
||||
return CreateContext(
|
||||
optionsBuilder.Options,
|
||||
new DesignTimeTenantProvider(),
|
||||
new DesignTimeCurrentUserAccessor());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 由子类实现的上下文工厂方法。
|
||||
/// </summary>
|
||||
/// <param name="options">上下文选项。</param>
|
||||
/// <param name="tenantProvider">租户提供器。</param>
|
||||
/// <param name="currentUserAccessor">当前用户访问器。</param>
|
||||
/// <returns>DbContext 实例。</returns>
|
||||
protected abstract TContext CreateContext(
|
||||
DbContextOptions<TContext> options,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor);
|
||||
|
||||
private string ResolveConnectionString()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_connectionStringEnvVar))
|
||||
{
|
||||
var envValue = Environment.GetEnvironmentVariable(_connectionStringEnvVar);
|
||||
if (!string.IsNullOrWhiteSpace(envValue))
|
||||
{
|
||||
return envValue;
|
||||
}
|
||||
}
|
||||
|
||||
var configuration = BuildConfiguration();
|
||||
var writeConnection = configuration[$"{DatabaseOptions.SectionName}:DataSources:{_dataSourceName}:Write"];
|
||||
if (string.IsNullOrWhiteSpace(writeConnection))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"未在配置中找到数据源 '{_dataSourceName}' 的 Write 连接字符串,请检查 appsettings 或设置 {_connectionStringEnvVar ?? "相应"} 环境变量。");
|
||||
}
|
||||
|
||||
return writeConnection;
|
||||
}
|
||||
|
||||
private static IConfigurationRoot BuildConfiguration()
|
||||
{
|
||||
var basePath = ResolveConfigurationDirectory();
|
||||
var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Development";
|
||||
|
||||
return new ConfigurationBuilder()
|
||||
.SetBasePath(basePath)
|
||||
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: false)
|
||||
.AddJsonFile($"appsettings.{environment}.json", optional: true, reloadOnChange: false)
|
||||
.AddEnvironmentVariables()
|
||||
.Build();
|
||||
}
|
||||
|
||||
private static string ResolveConfigurationDirectory()
|
||||
{
|
||||
var explicitDir = Environment.GetEnvironmentVariable("TAKEOUTSAAS_APPSETTINGS_DIR");
|
||||
if (!string.IsNullOrWhiteSpace(explicitDir) && Directory.Exists(explicitDir))
|
||||
{
|
||||
return explicitDir;
|
||||
}
|
||||
|
||||
// 1. (空行后) 尝试从当前目录定位解决方案根目录
|
||||
var currentDir = Directory.GetCurrentDirectory();
|
||||
var solutionRoot = LocateSolutionRoot(currentDir);
|
||||
|
||||
// 2. (空行后) 依次尝试常见 appsettings 目录(仅保留租户管理端 TenantApi)
|
||||
var candidateDirs = new[]
|
||||
{
|
||||
currentDir,
|
||||
solutionRoot,
|
||||
solutionRoot is null ? null : Path.Combine(solutionRoot, "src", "Api", "TakeoutSaaS.TenantApi")
|
||||
}.Where(dir => !string.IsNullOrWhiteSpace(dir));
|
||||
|
||||
foreach (var dir in candidateDirs)
|
||||
{
|
||||
if (dir != null && Directory.Exists(dir) && HasAppSettings(dir))
|
||||
{
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"未找到 appsettings 配置文件,请设置 TAKEOUTSAAS_APPSETTINGS_DIR 环境变量指向包含 appsettings*.json 的目录。");
|
||||
}
|
||||
|
||||
private static string? LocateSolutionRoot(string currentPath)
|
||||
{
|
||||
var directoryInfo = new DirectoryInfo(currentPath);
|
||||
while (directoryInfo != null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(directoryInfo.FullName, "TakeoutSaaS.sln")))
|
||||
{
|
||||
return directoryInfo.FullName;
|
||||
}
|
||||
|
||||
directoryInfo = directoryInfo.Parent;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool HasAppSettings(string directory) =>
|
||||
File.Exists(Path.Combine(directory, "appsettings.json")) ||
|
||||
Directory.GetFiles(directory, "appsettings.*.json").Length > 0;
|
||||
|
||||
private sealed class DesignTimeTenantProvider : ITenantProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// 设计时返回默认租户 ID。
|
||||
/// </summary>
|
||||
/// <returns>默认租户 ID。</returns>
|
||||
public long GetCurrentTenantId() => 0;
|
||||
}
|
||||
|
||||
private sealed class DesignTimeCurrentUserAccessor : ICurrentUserAccessor
|
||||
{
|
||||
/// <summary>
|
||||
/// 设计时用户标识。
|
||||
/// </summary>
|
||||
public long UserId => 0;
|
||||
/// <summary>
|
||||
/// 设计时用户鉴权标识。
|
||||
/// </summary>
|
||||
public bool IsAuthenticated => false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using TakeoutSaaS.Shared.Abstractions.Data;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Common.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// 数据库连接工厂,负责按读写角色选择对应连接串及配置。
|
||||
/// </summary>
|
||||
public interface IDatabaseConnectionFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取指定数据源与读写角色的连接信息。
|
||||
/// </summary>
|
||||
/// <param name="dataSourceName">逻辑数据源名称。</param>
|
||||
/// <param name="role">连接角色(读/写)。</param>
|
||||
/// <returns>连接串与相关配置。</returns>
|
||||
DatabaseConnectionDetails GetConnection(string dataSourceName, DatabaseConnectionRole role);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.Linq.Expressions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Common.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// 查询过滤器合并器:用于追加具名 QueryFilter,避免覆盖已有过滤器。
|
||||
/// </summary>
|
||||
internal static class QueryFilterCombiner
|
||||
{
|
||||
/// <summary>
|
||||
/// 为指定实体追加具名查询过滤器。
|
||||
/// </summary>
|
||||
/// <typeparam name="TEntity">实体类型。</typeparam>
|
||||
/// <param name="modelBuilder">模型构建器。</param>
|
||||
/// <param name="filterKey">过滤器键。</param>
|
||||
/// <param name="filter">新增过滤器表达式。</param>
|
||||
internal static void Combine<TEntity>(ModelBuilder modelBuilder, string filterKey, Expression<Func<TEntity, bool>> filter)
|
||||
where TEntity : class
|
||||
{
|
||||
modelBuilder.Entity<TEntity>().HasQueryFilter(filterKey, filter);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Reflection;
|
||||
using System.Linq;
|
||||
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
using TakeoutSaaS.Shared.Abstractions.Ids;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Common.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// 多租户感知 DbContext:自动应用租户过滤并填充租户字段。
|
||||
/// </summary>
|
||||
public abstract class TenantAwareDbContext(
|
||||
DbContextOptions options,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor? currentUserAccessor = null,
|
||||
IIdGenerator? idGenerator = null) : AppDbContext(options, currentUserAccessor, idGenerator)
|
||||
{
|
||||
private readonly ITenantProvider _tenantProvider = tenantProvider ?? throw new ArgumentNullException(nameof(tenantProvider));
|
||||
|
||||
/// <summary>
|
||||
/// 当前请求租户 ID。
|
||||
/// </summary>
|
||||
protected long CurrentTenantId => _tenantProvider.GetCurrentTenantId();
|
||||
|
||||
/// <summary>
|
||||
/// 保存前填充租户元数据并执行基础处理。
|
||||
/// </summary>
|
||||
protected override void OnBeforeSaving()
|
||||
{
|
||||
ApplyTenantMetadata();
|
||||
base.OnBeforeSaving();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 应用租户过滤器到所有实现 <see cref="IMultiTenantEntity"/> 的实体。
|
||||
/// </summary>
|
||||
/// <param name="modelBuilder">模型构建器。</param>
|
||||
protected void ApplyTenantQueryFilters(ModelBuilder modelBuilder)
|
||||
{
|
||||
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
|
||||
{
|
||||
if (!typeof(IMultiTenantEntity).IsAssignableFrom(entityType.ClrType))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var methodInfo = typeof(TenantAwareDbContext)
|
||||
.GetMethod(nameof(SetTenantFilter), BindingFlags.Instance | BindingFlags.NonPublic)!
|
||||
.MakeGenericMethod(entityType.ClrType);
|
||||
|
||||
methodInfo.Invoke(this, new object[] { modelBuilder });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为具体实体设置租户过滤器。
|
||||
/// </summary>
|
||||
/// <typeparam name="TEntity">实体类型。</typeparam>
|
||||
/// <param name="modelBuilder">模型构建器。</param>
|
||||
private void SetTenantFilter<TEntity>(ModelBuilder modelBuilder)
|
||||
where TEntity : class, IMultiTenantEntity
|
||||
{
|
||||
QueryFilterCombiner.Combine<TEntity>(modelBuilder, "tenant", entity => entity.TenantId == CurrentTenantId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为新增实体填充租户 ID。
|
||||
/// </summary>
|
||||
private void ApplyTenantMetadata()
|
||||
{
|
||||
var tenantId = CurrentTenantId;
|
||||
|
||||
foreach (var entry in ChangeTracker.Entries<IMultiTenantEntity>())
|
||||
{
|
||||
if (entry.State is EntityState.Detached or EntityState.Unchanged)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tenantId == 0)
|
||||
{
|
||||
if (entry.Entity.TenantId != 0)
|
||||
{
|
||||
throw new InvalidOperationException("未进入租户上下文,禁止写入 TenantId 不为 0 的多租户数据。");
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.State == EntityState.Added && entry.Entity.TenantId == 0)
|
||||
{
|
||||
entry.Entity.TenantId = tenantId;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.Entity.TenantId != tenantId)
|
||||
{
|
||||
throw new InvalidOperationException("检测到跨租户写入,已阻止保存。");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// 缓存命中/耗时指标采集器。
|
||||
/// </summary>
|
||||
public sealed class CacheMetricsCollector
|
||||
{
|
||||
private const string MeterName = "TakeoutSaaS.DictionaryCache";
|
||||
private static readonly Meter Meter = new(MeterName, "1.0.0");
|
||||
|
||||
private readonly Counter<long> _hitCounter;
|
||||
private readonly Counter<long> _missCounter;
|
||||
private readonly Counter<long> _invalidationCounter;
|
||||
private readonly Histogram<double> _durationHistogram;
|
||||
private readonly ConcurrentQueue<CacheQueryRecord> _queries = new();
|
||||
private readonly TimeSpan _retention = TimeSpan.FromDays(7);
|
||||
|
||||
private long _hitTotal;
|
||||
private long _missTotal;
|
||||
|
||||
/// <summary>
|
||||
/// 初始化指标采集器。
|
||||
/// </summary>
|
||||
public CacheMetricsCollector()
|
||||
{
|
||||
_hitCounter = Meter.CreateCounter<long>("cache_hit_count");
|
||||
_missCounter = Meter.CreateCounter<long>("cache_miss_count");
|
||||
_invalidationCounter = Meter.CreateCounter<long>("cache_invalidation_count");
|
||||
_durationHistogram = Meter.CreateHistogram<double>("cache_query_duration_ms");
|
||||
|
||||
Meter.CreateObservableGauge(
|
||||
"cache_hit_ratio",
|
||||
() => new Measurement<double>(CalculateHitRatio()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 记录缓存命中。
|
||||
/// </summary>
|
||||
public void RecordHit(string cacheLevel, string dictionaryCode)
|
||||
{
|
||||
Interlocked.Increment(ref _hitTotal);
|
||||
_hitCounter.Add(1, new TagList
|
||||
{
|
||||
{ "cache_level", cacheLevel },
|
||||
{ "dictionary_code", NormalizeCode(dictionaryCode) }
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 记录缓存未命中。
|
||||
/// </summary>
|
||||
public void RecordMiss(string cacheLevel, string dictionaryCode)
|
||||
{
|
||||
Interlocked.Increment(ref _missTotal);
|
||||
_missCounter.Add(1, new TagList
|
||||
{
|
||||
{ "cache_level", cacheLevel },
|
||||
{ "dictionary_code", NormalizeCode(dictionaryCode) }
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 记录缓存查询耗时。
|
||||
/// </summary>
|
||||
public void RecordDuration(string dictionaryCode, double durationMs)
|
||||
{
|
||||
_durationHistogram.Record(durationMs, new TagList
|
||||
{
|
||||
{ "dictionary_code", NormalizeCode(dictionaryCode) }
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 记录查询详情,用于统计窗口分析。
|
||||
/// </summary>
|
||||
public void RecordQuery(string dictionaryCode, bool l1Hit, bool l2Hit, double durationMs)
|
||||
{
|
||||
var record = new CacheQueryRecord(DateTime.UtcNow, NormalizeCode(dictionaryCode), l1Hit, l2Hit, durationMs);
|
||||
_queries.Enqueue(record);
|
||||
PruneOldRecords();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 记录缓存失效事件。
|
||||
/// </summary>
|
||||
public void RecordInvalidation(string dictionaryCode)
|
||||
{
|
||||
_invalidationCounter.Add(1, new TagList
|
||||
{
|
||||
{ "dictionary_code", NormalizeCode(dictionaryCode) }
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定时间范围内的统计快照。
|
||||
/// </summary>
|
||||
public CacheStatsSnapshot GetSnapshot(TimeSpan window)
|
||||
{
|
||||
var since = DateTime.UtcNow.Subtract(window);
|
||||
var records = _queries.Where(record => record.Timestamp >= since).ToList();
|
||||
|
||||
var l1Hits = records.Count(record => record.L1Hit);
|
||||
var l1Misses = records.Count(record => !record.L1Hit);
|
||||
var l2Hits = records.Count(record => record.L2Hit);
|
||||
var l2Misses = records.Count(record => !record.L1Hit && !record.L2Hit);
|
||||
|
||||
var totalHits = l1Hits + l2Hits;
|
||||
var totalMisses = l1Misses + l2Misses;
|
||||
var hitRatio = totalHits + totalMisses == 0 ? 0 : totalHits / (double)(totalHits + totalMisses);
|
||||
var averageDuration = records.Count == 0 ? 0 : records.Average(record => record.DurationMs);
|
||||
|
||||
var topQueried = records
|
||||
.GroupBy(record => record.DictionaryCode)
|
||||
.Select(group => new DictionaryQueryCount(group.Key, group.Count()))
|
||||
.OrderByDescending(item => item.QueryCount)
|
||||
.Take(5)
|
||||
.ToList();
|
||||
|
||||
return new CacheStatsSnapshot(
|
||||
totalHits,
|
||||
totalMisses,
|
||||
hitRatio,
|
||||
new CacheLevelStats(l1Hits, l2Hits),
|
||||
new CacheLevelStats(l1Misses, l2Misses),
|
||||
averageDuration,
|
||||
topQueried);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从缓存键解析字典编码。
|
||||
/// </summary>
|
||||
public static string ExtractDictionaryCode(string cacheKey)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cacheKey))
|
||||
{
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
if (cacheKey.StartsWith("dict:groups:", StringComparison.Ordinal))
|
||||
{
|
||||
return "groups";
|
||||
}
|
||||
|
||||
if (cacheKey.StartsWith("dict:items:", StringComparison.Ordinal))
|
||||
{
|
||||
return "items";
|
||||
}
|
||||
|
||||
if (cacheKey.StartsWith("dict:", StringComparison.Ordinal))
|
||||
{
|
||||
var parts = cacheKey.Split(':', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length >= 3)
|
||||
{
|
||||
return parts[2];
|
||||
}
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
private static string NormalizeCode(string? code)
|
||||
=> string.IsNullOrWhiteSpace(code) ? "unknown" : code.Trim().ToLowerInvariant();
|
||||
|
||||
private double CalculateHitRatio()
|
||||
{
|
||||
var hits = Interlocked.Read(ref _hitTotal);
|
||||
var misses = Interlocked.Read(ref _missTotal);
|
||||
return hits + misses == 0 ? 0 : hits / (double)(hits + misses);
|
||||
}
|
||||
|
||||
private void PruneOldRecords()
|
||||
{
|
||||
var cutoff = DateTime.UtcNow.Subtract(_retention);
|
||||
while (_queries.TryPeek(out var record) && record.Timestamp < cutoff)
|
||||
{
|
||||
_queries.TryDequeue(out _);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record CacheQueryRecord(
|
||||
DateTime Timestamp,
|
||||
string DictionaryCode,
|
||||
bool L1Hit,
|
||||
bool L2Hit,
|
||||
double DurationMs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 缓存统计快照。
|
||||
/// </summary>
|
||||
public sealed record CacheStatsSnapshot(
|
||||
long TotalHits,
|
||||
long TotalMisses,
|
||||
double HitRatio,
|
||||
CacheLevelStats HitsByLevel,
|
||||
CacheLevelStats MissesByLevel,
|
||||
double AverageQueryDurationMs,
|
||||
IReadOnlyList<DictionaryQueryCount> TopQueriedDictionaries);
|
||||
|
||||
/// <summary>
|
||||
/// 命中统计。
|
||||
/// </summary>
|
||||
public sealed record CacheLevelStats(long L1, long L2);
|
||||
|
||||
/// <summary>
|
||||
/// 字典查询次数统计。
|
||||
/// </summary>
|
||||
public sealed record DictionaryQueryCount(string Code, int QueryCount);
|
||||
@@ -0,0 +1,57 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TakeoutSaaS.Application.Dictionary.Services;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Options;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// 字典缓存预热服务。
|
||||
/// </summary>
|
||||
public sealed class CacheWarmupService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptions<DictionaryCacheWarmupOptions> options,
|
||||
ILogger<CacheWarmupService> logger) : IHostedService
|
||||
{
|
||||
private const int MaxWarmupCount = 10;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var codes = options.Value.DictionaryCodes
|
||||
.Where(code => !string.IsNullOrWhiteSpace(code))
|
||||
.Select(code => code.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Take(MaxWarmupCount)
|
||||
.ToArray();
|
||||
|
||||
if (codes.Length == 0)
|
||||
{
|
||||
logger.LogInformation("未配置字典缓存预热列表。");
|
||||
return;
|
||||
}
|
||||
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var queryService = scope.ServiceProvider.GetRequiredService<DictionaryQueryService>();
|
||||
|
||||
foreach (var code in codes)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
await queryService.GetMergedDictionaryAsync(code, cancellationToken);
|
||||
logger.LogInformation("字典缓存预热完成: {DictionaryCode}", code);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "字典缓存预热失败: {DictionaryCode}", code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StackExchange.Redis;
|
||||
using TakeoutSaaS.Application.Dictionary.Abstractions;
|
||||
using TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
using TakeoutSaaS.Domain.Dictionary.Enums;
|
||||
using TakeoutSaaS.Domain.Dictionary.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// 两级缓存封装:L1 内存 + L2 Redis。
|
||||
/// </summary>
|
||||
public sealed class HybridCacheService : IDictionaryHybridCache
|
||||
{
|
||||
private static readonly RedisChannel InvalidationChannel = RedisChannel.Literal("dictionary:cache:invalidate");
|
||||
|
||||
private readonly MemoryCacheService _memoryCache;
|
||||
private readonly RedisCacheService _redisCache;
|
||||
private readonly ISubscriber? _subscriber;
|
||||
private readonly ILogger<HybridCacheService>? _logger;
|
||||
private readonly CacheMetricsCollector? _metrics;
|
||||
private readonly IServiceScopeFactory? _scopeFactory;
|
||||
|
||||
/// <summary>
|
||||
/// 初始化两级缓存服务。
|
||||
/// </summary>
|
||||
public HybridCacheService(
|
||||
MemoryCacheService memoryCache,
|
||||
RedisCacheService redisCache,
|
||||
IConnectionMultiplexer? multiplexer = null,
|
||||
ILogger<HybridCacheService>? logger = null,
|
||||
CacheMetricsCollector? metrics = null,
|
||||
IServiceScopeFactory? scopeFactory = null)
|
||||
{
|
||||
_memoryCache = memoryCache;
|
||||
_redisCache = redisCache;
|
||||
_logger = logger;
|
||||
_subscriber = multiplexer?.GetSubscriber();
|
||||
_metrics = metrics;
|
||||
_scopeFactory = scopeFactory;
|
||||
|
||||
if (_subscriber != null)
|
||||
{
|
||||
_subscriber.Subscribe(InvalidationChannel, (_, value) =>
|
||||
{
|
||||
var prefix = value.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(prefix))
|
||||
{
|
||||
_memoryCache.RemoveByPrefix(prefix);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取缓存,如果不存在则创建并回填。
|
||||
/// </summary>
|
||||
public async Task<T?> GetOrCreateAsync<T>(
|
||||
string key,
|
||||
TimeSpan ttl,
|
||||
Func<CancellationToken, Task<T?>> factory,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var dictionaryCode = CacheMetricsCollector.ExtractDictionaryCode(key);
|
||||
var l1Hit = false;
|
||||
var l2Hit = false;
|
||||
|
||||
var cached = await _memoryCache.GetAsync<T>(key, cancellationToken);
|
||||
if (cached != null)
|
||||
{
|
||||
l1Hit = true;
|
||||
_metrics?.RecordHit("L1", dictionaryCode);
|
||||
_metrics?.RecordDuration(dictionaryCode, stopwatch.Elapsed.TotalMilliseconds);
|
||||
_metrics?.RecordQuery(dictionaryCode, l1Hit, l2Hit, stopwatch.Elapsed.TotalMilliseconds);
|
||||
return cached;
|
||||
}
|
||||
|
||||
_metrics?.RecordMiss("L1", dictionaryCode);
|
||||
|
||||
try
|
||||
{
|
||||
cached = await _redisCache.GetAsync<T>(key, cancellationToken);
|
||||
if (cached != null)
|
||||
{
|
||||
l2Hit = true;
|
||||
_metrics?.RecordHit("L2", dictionaryCode);
|
||||
await _memoryCache.SetAsync(key, cached, ttl, cancellationToken);
|
||||
_metrics?.RecordDuration(dictionaryCode, stopwatch.Elapsed.TotalMilliseconds);
|
||||
_metrics?.RecordQuery(dictionaryCode, l1Hit, l2Hit, stopwatch.Elapsed.TotalMilliseconds);
|
||||
return cached;
|
||||
}
|
||||
|
||||
_metrics?.RecordMiss("L2", dictionaryCode);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_metrics?.RecordMiss("L2", dictionaryCode);
|
||||
_logger?.LogWarning(ex, "读取 Redis 缓存失败,降级为数据库查询。");
|
||||
}
|
||||
|
||||
var created = await factory(cancellationToken);
|
||||
if (created == null)
|
||||
{
|
||||
_metrics?.RecordDuration(dictionaryCode, stopwatch.Elapsed.TotalMilliseconds);
|
||||
_metrics?.RecordQuery(dictionaryCode, l1Hit, l2Hit, stopwatch.Elapsed.TotalMilliseconds);
|
||||
return default;
|
||||
}
|
||||
|
||||
await _memoryCache.SetAsync(key, created, ttl, cancellationToken);
|
||||
try
|
||||
{
|
||||
await _redisCache.SetAsync(key, created, ttl, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "写入 Redis 缓存失败。");
|
||||
}
|
||||
|
||||
_metrics?.RecordDuration(dictionaryCode, stopwatch.Elapsed.TotalMilliseconds);
|
||||
_metrics?.RecordQuery(dictionaryCode, l1Hit, l2Hit, stopwatch.Elapsed.TotalMilliseconds);
|
||||
return created;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 失效指定前缀的缓存键。
|
||||
/// </summary>
|
||||
public async Task InvalidateAsync(
|
||||
string prefix,
|
||||
CacheInvalidationOperation operation = CacheInvalidationOperation.Update,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var dictionaryCode = CacheMetricsCollector.ExtractDictionaryCode(prefix);
|
||||
_metrics?.RecordInvalidation(dictionaryCode);
|
||||
|
||||
var removedCount = _memoryCache.RemoveByPrefixWithCount(prefix);
|
||||
long redisRemoved = 0;
|
||||
try
|
||||
{
|
||||
redisRemoved = await _redisCache.RemoveByPrefixWithCountAsync(prefix, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "删除 Redis 缓存失败。");
|
||||
}
|
||||
|
||||
var totalRemoved = removedCount + (int)Math.Min(redisRemoved, int.MaxValue);
|
||||
|
||||
if (_subscriber != null && !string.IsNullOrWhiteSpace(prefix))
|
||||
{
|
||||
await _subscriber.PublishAsync(InvalidationChannel, prefix);
|
||||
}
|
||||
|
||||
_ = WriteInvalidationLogAsync(prefix, dictionaryCode, totalRemoved, operation);
|
||||
}
|
||||
|
||||
private async Task WriteInvalidationLogAsync(
|
||||
string prefix,
|
||||
string dictionaryCode,
|
||||
int removedCount,
|
||||
CacheInvalidationOperation operation)
|
||||
{
|
||||
if (_scopeFactory == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var repo = scope.ServiceProvider.GetService<ICacheInvalidationLogRepository>();
|
||||
if (repo == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var currentUser = scope.ServiceProvider.GetService<ICurrentUserAccessor>();
|
||||
var tenantId = TryExtractTenantId(prefix) ?? 0;
|
||||
var scopeType = tenantId == 0 ? DictionaryScope.System : DictionaryScope.Business;
|
||||
|
||||
var log = new CacheInvalidationLog
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Timestamp = DateTime.UtcNow,
|
||||
DictionaryCode = dictionaryCode,
|
||||
Scope = scopeType,
|
||||
AffectedCacheKeyCount = removedCount,
|
||||
OperatorId = currentUser?.IsAuthenticated == true ? currentUser.UserId : 0,
|
||||
Operation = operation
|
||||
};
|
||||
|
||||
await repo.AddAsync(log);
|
||||
await repo.SaveChangesAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "写入缓存失效日志失败。");
|
||||
}
|
||||
}
|
||||
|
||||
private static long? TryExtractTenantId(string prefix)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(prefix))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (prefix.StartsWith("dict:groups:", StringComparison.Ordinal))
|
||||
{
|
||||
var token = prefix.Replace("dict:groups:", string.Empty, StringComparison.Ordinal).Trim(':');
|
||||
return long.TryParse(token.Split(':', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(), out var tenantId)
|
||||
? tenantId
|
||||
: null;
|
||||
}
|
||||
|
||||
if (prefix.StartsWith("dict:", StringComparison.Ordinal) && !prefix.StartsWith("dict:items:", StringComparison.Ordinal))
|
||||
{
|
||||
var token = prefix.Replace("dict:", string.Empty, StringComparison.Ordinal);
|
||||
return long.TryParse(token.Split(':', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(), out var tenantId)
|
||||
? tenantId
|
||||
: null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// 本地内存缓存封装。
|
||||
/// </summary>
|
||||
public sealed class MemoryCacheService(IMemoryCache cache)
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, byte> _keys = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// 读取缓存。
|
||||
/// </summary>
|
||||
public Task<T?> GetAsync<T>(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(cache.TryGetValue(key, out T? value) ? value : default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入缓存。
|
||||
/// </summary>
|
||||
public Task SetAsync<T>(string key, T value, TimeSpan ttl, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cache.Set(key, value, new MemoryCacheEntryOptions
|
||||
{
|
||||
SlidingExpiration = ttl
|
||||
});
|
||||
_keys.TryAdd(key, 0);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除缓存键。
|
||||
/// </summary>
|
||||
public void Remove(string key)
|
||||
{
|
||||
cache.Remove(key);
|
||||
_keys.TryRemove(key, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按前缀删除缓存键。
|
||||
/// </summary>
|
||||
public void RemoveByPrefix(string prefix)
|
||||
=> RemoveByPrefixWithCount(prefix);
|
||||
|
||||
/// <summary>
|
||||
/// 按前缀删除缓存键并返回数量。
|
||||
/// </summary>
|
||||
public int RemoveByPrefixWithCount(string prefix)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(prefix))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var removed = 0;
|
||||
foreach (var key in _keys.Keys)
|
||||
{
|
||||
if (key.StartsWith(prefix, StringComparison.Ordinal))
|
||||
{
|
||||
Remove(key);
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清理所有缓存。
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
foreach (var key in _keys.Keys)
|
||||
{
|
||||
Remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using System.Text.Json;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// Redis 缓存访问封装。
|
||||
/// </summary>
|
||||
public sealed class RedisCacheService(IDistributedCache cache, IConnectionMultiplexer? multiplexer = null)
|
||||
{
|
||||
private readonly JsonSerializerOptions _serializerOptions = new(JsonSerializerDefaults.Web);
|
||||
private readonly IDatabase? _database = multiplexer?.GetDatabase();
|
||||
private readonly IConnectionMultiplexer? _multiplexer = multiplexer;
|
||||
|
||||
/// <summary>
|
||||
/// 读取缓存。
|
||||
/// </summary>
|
||||
public async Task<T?> GetAsync<T>(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var payload = await cache.GetAsync(key, cancellationToken);
|
||||
if (payload == null || payload.Length == 0)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<T>(payload, _serializerOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入缓存。
|
||||
/// </summary>
|
||||
public Task SetAsync<T>(string key, T value, TimeSpan ttl, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var payload = JsonSerializer.SerializeToUtf8Bytes(value, _serializerOptions);
|
||||
var options = new DistributedCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = ttl
|
||||
};
|
||||
return cache.SetAsync(key, payload, options, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除缓存键。
|
||||
/// </summary>
|
||||
public Task RemoveAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> cache.RemoveAsync(key, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 按前缀删除缓存键。
|
||||
/// </summary>
|
||||
public async Task RemoveByPrefixAsync(string prefix, CancellationToken cancellationToken = default)
|
||||
=> await RemoveByPrefixWithCountAsync(prefix, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
/// <summary>
|
||||
/// 按前缀删除缓存键并返回数量。
|
||||
/// </summary>
|
||||
public async Task<long> RemoveByPrefixWithCountAsync(string prefix, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_multiplexer == null || _database == null || string.IsNullOrWhiteSpace(prefix))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var pattern = prefix.EndsWith('*') ? prefix : $"{prefix}*";
|
||||
long removed = 0;
|
||||
foreach (var endpoint in _multiplexer.GetEndPoints())
|
||||
{
|
||||
var server = _multiplexer.GetServer(endpoint);
|
||||
foreach (var key in server.Keys(pattern: pattern))
|
||||
{
|
||||
await _database.KeyDeleteAsync(key).ConfigureAwait(false);
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.Caching.StackExchangeRedis;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StackExchange.Redis;
|
||||
using TakeoutSaaS.Application.Dictionary.Abstractions;
|
||||
using TakeoutSaaS.Domain.Dictionary.Repositories;
|
||||
using TakeoutSaaS.Domain.SystemParameters.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.Common.Extensions;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Caching;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.ImportExport;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Options;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Persistence;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Services;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// 字典基础设施注册扩展。
|
||||
/// </summary>
|
||||
public static class DictionaryServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 注册字典模块基础设施。
|
||||
/// </summary>
|
||||
/// <param name="services">服务集合。</param>
|
||||
/// <param name="configuration">配置源。</param>
|
||||
/// <returns>服务集合。</returns>
|
||||
/// <exception cref="InvalidOperationException">缺少数据库配置时抛出。</exception>
|
||||
public static IServiceCollection AddDictionaryInfrastructure(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddDatabaseInfrastructure(configuration);
|
||||
services.AddPostgresDbContext<DictionaryDbContext>(DatabaseConstants.DictionaryDataSource);
|
||||
|
||||
services.AddScoped<IDictionaryRepository, EfDictionaryRepository>();
|
||||
services.AddScoped<IDictionaryGroupRepository, DictionaryGroupRepository>();
|
||||
services.AddScoped<IDictionaryItemRepository, DictionaryItemRepository>();
|
||||
services.AddScoped<ITenantDictionaryOverrideRepository, TenantDictionaryOverrideRepository>();
|
||||
services.AddScoped<IDictionaryLabelOverrideRepository, DictionaryLabelOverrideRepository>();
|
||||
services.AddScoped<IDictionaryImportLogRepository, DictionaryImportLogRepository>();
|
||||
services.AddScoped<ICacheInvalidationLogRepository, CacheInvalidationLogRepository>();
|
||||
services.AddScoped<ISystemParameterRepository, EfSystemParameterRepository>();
|
||||
services.AddScoped<IDictionaryCache, DistributedDictionaryCache>();
|
||||
services.AddScoped<ICsvDictionaryParser, CsvDictionaryParser>();
|
||||
services.AddScoped<IJsonDictionaryParser, JsonDictionaryParser>();
|
||||
|
||||
services.AddMemoryCache();
|
||||
|
||||
var redisConnection = configuration.GetConnectionString("Redis");
|
||||
var hasDistributedCache = services.Any(descriptor => descriptor.ServiceType == typeof(IDistributedCache));
|
||||
if (!hasDistributedCache)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(redisConnection))
|
||||
{
|
||||
services.AddStackExchangeRedisCache(options =>
|
||||
{
|
||||
options.Configuration = redisConnection;
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
services.AddDistributedMemoryCache();
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(redisConnection) && !services.Any(descriptor => descriptor.ServiceType == typeof(IConnectionMultiplexer)))
|
||||
{
|
||||
services.AddSingleton<IConnectionMultiplexer>(_ => ConnectionMultiplexer.Connect(redisConnection));
|
||||
}
|
||||
|
||||
services.AddSingleton<MemoryCacheService>();
|
||||
services.AddSingleton<CacheMetricsCollector>();
|
||||
services.AddSingleton(sp => new RedisCacheService(
|
||||
sp.GetRequiredService<IDistributedCache>(),
|
||||
sp.GetService<IConnectionMultiplexer>()));
|
||||
services.AddSingleton(sp => new HybridCacheService(
|
||||
sp.GetRequiredService<MemoryCacheService>(),
|
||||
sp.GetRequiredService<RedisCacheService>(),
|
||||
sp.GetService<IConnectionMultiplexer>(),
|
||||
sp.GetService<ILogger<HybridCacheService>>(),
|
||||
sp.GetService<CacheMetricsCollector>(),
|
||||
sp.GetService<IServiceScopeFactory>()));
|
||||
services.AddSingleton<IDictionaryHybridCache>(sp => sp.GetRequiredService<HybridCacheService>());
|
||||
|
||||
services.AddOptions<DictionaryCacheOptions>()
|
||||
.Bind(configuration.GetSection("Dictionary:Cache"))
|
||||
.ValidateDataAnnotations();
|
||||
|
||||
services.AddOptions<DictionaryCacheWarmupOptions>()
|
||||
.Bind(configuration.GetSection("CacheWarmup"))
|
||||
.ValidateDataAnnotations();
|
||||
|
||||
services.AddHostedService<CacheWarmupService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 确保数据库连接已配置(Database 节或 ConnectionStrings)。
|
||||
/// </summary>
|
||||
/// <param name="configuration">配置源。</param>
|
||||
/// <param name="dataSourceName">数据源名称。</param>
|
||||
/// <exception cref="InvalidOperationException">未配置时抛出。</exception>
|
||||
private static void EnsureDatabaseConnectionConfigured(IConfiguration configuration, string dataSourceName)
|
||||
{
|
||||
// 保留兼容接口,当前逻辑在 DatabaseConnectionFactory 中兜底并记录日志。
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using CsvHelper;
|
||||
using CsvHelper.Configuration;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using TakeoutSaaS.Application.Dictionary.Abstractions;
|
||||
using TakeoutSaaS.Application.Dictionary.Models;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.ImportExport;
|
||||
|
||||
/// <summary>
|
||||
/// CSV 字典导入解析器。
|
||||
/// </summary>
|
||||
public sealed class CsvDictionaryParser : ICsvDictionaryParser
|
||||
{
|
||||
private static readonly CsvConfiguration CsvConfiguration = new(CultureInfo.InvariantCulture)
|
||||
{
|
||||
HasHeaderRecord = true,
|
||||
MissingFieldFound = null,
|
||||
BadDataFound = null,
|
||||
DetectColumnCountChanges = false,
|
||||
TrimOptions = TrimOptions.Trim,
|
||||
PrepareHeaderForMatch = args => args.Header?.Trim().ToLowerInvariant() ?? string.Empty
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<DictionaryImportRow>> ParseAsync(Stream stream, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
|
||||
if (stream.CanSeek)
|
||||
{
|
||||
stream.Position = 0;
|
||||
}
|
||||
|
||||
var rows = new List<DictionaryImportRow>();
|
||||
using var reader = new StreamReader(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), detectEncodingFromByteOrderMarks: true, leaveOpen: true);
|
||||
using var csv = new CsvReader(reader, CsvConfiguration);
|
||||
|
||||
if (!await csv.ReadAsync() || !csv.ReadHeader())
|
||||
{
|
||||
return rows;
|
||||
}
|
||||
|
||||
while (await csv.ReadAsync())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var rowNumber = csv.Context?.Parser?.Row ?? 0;
|
||||
rows.Add(new DictionaryImportRow
|
||||
{
|
||||
RowNumber = rowNumber,
|
||||
Code = ReadString(csv, "code"),
|
||||
Key = ReadString(csv, "key"),
|
||||
Value = ReadString(csv, "value"),
|
||||
SortOrder = ReadInt(csv, "sortorder"),
|
||||
IsEnabled = ReadBool(csv, "isenabled"),
|
||||
Description = ReadString(csv, "description"),
|
||||
Source = ReadString(csv, "source")
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static string? ReadString(CsvReader csv, string name)
|
||||
{
|
||||
return csv.TryGetField(name, out string? value)
|
||||
? string.IsNullOrWhiteSpace(value) ? null : value
|
||||
: null;
|
||||
}
|
||||
|
||||
private static int? ReadInt(CsvReader csv, string name)
|
||||
{
|
||||
if (csv.TryGetField(name, out string? value) && int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var number))
|
||||
{
|
||||
return number;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool? ReadBool(CsvReader csv, string name)
|
||||
{
|
||||
if (csv.TryGetField(name, out string? value) && bool.TryParse(value, out var flag))
|
||||
{
|
||||
return flag;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Application.Dictionary.Abstractions;
|
||||
using TakeoutSaaS.Application.Dictionary.Models;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.ImportExport;
|
||||
|
||||
/// <summary>
|
||||
/// JSON 字典导入解析器。
|
||||
/// </summary>
|
||||
public sealed class JsonDictionaryParser : IJsonDictionaryParser
|
||||
{
|
||||
private static readonly JsonDocumentOptions DocumentOptions = new()
|
||||
{
|
||||
AllowTrailingCommas = true
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<DictionaryImportRow>> ParseAsync(Stream stream, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
|
||||
if (stream.CanSeek)
|
||||
{
|
||||
stream.Position = 0;
|
||||
}
|
||||
|
||||
using var document = await JsonDocument.ParseAsync(stream, DocumentOptions, cancellationToken);
|
||||
if (document.RootElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return Array.Empty<DictionaryImportRow>();
|
||||
}
|
||||
|
||||
var rows = new List<DictionaryImportRow>();
|
||||
var index = 0;
|
||||
|
||||
foreach (var element in document.RootElement.EnumerateArray())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
index++;
|
||||
|
||||
rows.Add(new DictionaryImportRow
|
||||
{
|
||||
RowNumber = index,
|
||||
Code = ReadString(element, "code"),
|
||||
Key = ReadString(element, "key"),
|
||||
Value = ReadValue(element, "value"),
|
||||
SortOrder = ReadInt(element, "sortOrder"),
|
||||
IsEnabled = ReadBool(element, "isEnabled"),
|
||||
Description = ReadString(element, "description"),
|
||||
Source = ReadString(element, "source")
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static string? ReadString(JsonElement element, string propertyName)
|
||||
{
|
||||
if (!TryGetProperty(element, propertyName, out var value) || value.ValueKind == JsonValueKind.Null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.ValueKind == JsonValueKind.String ? value.GetString() : value.GetRawText();
|
||||
}
|
||||
|
||||
private static string? ReadValue(JsonElement element, string propertyName)
|
||||
{
|
||||
if (!TryGetProperty(element, propertyName, out var value) || value.ValueKind == JsonValueKind.Null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.ValueKind == JsonValueKind.String ? value.GetString() : value.GetRawText();
|
||||
}
|
||||
|
||||
private static int? ReadInt(JsonElement element, string propertyName)
|
||||
{
|
||||
if (!TryGetProperty(element, propertyName, out var value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number))
|
||||
{
|
||||
return number;
|
||||
}
|
||||
|
||||
if (value.ValueKind == JsonValueKind.String && int.TryParse(value.GetString(), out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool? ReadBool(JsonElement element, string propertyName)
|
||||
{
|
||||
if (!TryGetProperty(element, propertyName, out var value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value.ValueKind == JsonValueKind.True || value.ValueKind == JsonValueKind.False)
|
||||
{
|
||||
return value.GetBoolean();
|
||||
}
|
||||
|
||||
if (value.ValueKind == JsonValueKind.String && bool.TryParse(value.GetString(), out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryGetProperty(JsonElement element, string propertyName, out JsonElement value)
|
||||
{
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = property.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 字典缓存配置。
|
||||
/// </summary>
|
||||
public sealed class DictionaryCacheOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 缓存滑动过期时间。
|
||||
/// </summary>
|
||||
public TimeSpan SlidingExpiration { get; set; } = TimeSpan.FromMinutes(30);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 字典缓存预热配置。
|
||||
/// </summary>
|
||||
public sealed class DictionaryCacheWarmupOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 预热字典编码列表(最多前 10 个)。
|
||||
/// </summary>
|
||||
public string[] DictionaryCodes { get; set; } = Array.Empty<string>();
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
using TakeoutSaaS.Domain.Dictionary.ValueObjects;
|
||||
using TakeoutSaaS.Domain.SystemParameters.Entities;
|
||||
using TakeoutSaaS.Infrastructure.Common.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Ids;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// 参数字典 DbContext:承载字典与系统参数。
|
||||
/// </summary>
|
||||
public sealed class DictionaryDbContext(
|
||||
DbContextOptions<DictionaryDbContext> options,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor? currentUserAccessor = null,
|
||||
IIdGenerator? idGenerator = null)
|
||||
: TenantAwareDbContext(options, tenantProvider, currentUserAccessor, idGenerator)
|
||||
{
|
||||
/// <summary>
|
||||
/// 字典分组集合。
|
||||
/// </summary>
|
||||
public DbSet<DictionaryGroup> DictionaryGroups => Set<DictionaryGroup>();
|
||||
|
||||
/// <summary>
|
||||
/// 字典项集合。
|
||||
/// </summary>
|
||||
public DbSet<DictionaryItem> DictionaryItems => Set<DictionaryItem>();
|
||||
|
||||
/// <summary>
|
||||
/// 租户字典覆盖集合。
|
||||
/// </summary>
|
||||
public DbSet<TenantDictionaryOverride> TenantDictionaryOverrides => Set<TenantDictionaryOverride>();
|
||||
|
||||
/// <summary>
|
||||
/// 字典导入日志集合。
|
||||
/// </summary>
|
||||
public DbSet<DictionaryImportLog> DictionaryImportLogs => Set<DictionaryImportLog>();
|
||||
|
||||
/// <summary>
|
||||
/// 缓存失效日志集合。
|
||||
/// </summary>
|
||||
public DbSet<CacheInvalidationLog> CacheInvalidationLogs => Set<CacheInvalidationLog>();
|
||||
|
||||
/// <summary>
|
||||
/// 字典标签覆盖集合。
|
||||
/// </summary>
|
||||
public DbSet<DictionaryLabelOverride> DictionaryLabelOverrides => Set<DictionaryLabelOverride>();
|
||||
|
||||
/// <summary>
|
||||
/// 系统参数集合。
|
||||
/// </summary>
|
||||
public DbSet<SystemParameter> SystemParameters => Set<SystemParameter>();
|
||||
|
||||
/// <summary>
|
||||
/// 配置实体模型。
|
||||
/// </summary>
|
||||
/// <param name="modelBuilder">模型构建器。</param>
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
var provider = Database.ProviderName;
|
||||
var isSqlite = provider != null && provider.Contains("Sqlite", StringComparison.OrdinalIgnoreCase);
|
||||
ConfigureGroup(modelBuilder.Entity<DictionaryGroup>(), isSqlite);
|
||||
ConfigureItem(modelBuilder.Entity<DictionaryItem>(), isSqlite);
|
||||
ConfigureOverride(modelBuilder.Entity<TenantDictionaryOverride>());
|
||||
ConfigureLabelOverride(modelBuilder.Entity<DictionaryLabelOverride>());
|
||||
ConfigureImportLog(modelBuilder.Entity<DictionaryImportLog>());
|
||||
ConfigureCacheInvalidationLog(modelBuilder.Entity<CacheInvalidationLog>());
|
||||
ConfigureSystemParameter(modelBuilder.Entity<SystemParameter>());
|
||||
ApplyTenantQueryFilters(modelBuilder);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 配置字典分组。
|
||||
/// </summary>
|
||||
/// <param name="builder">实体构建器。</param>
|
||||
private static void ConfigureGroup(EntityTypeBuilder<DictionaryGroup> builder, bool isSqlite)
|
||||
{
|
||||
builder.ToTable("dictionary_groups");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.TenantId).IsRequired();
|
||||
builder.Property(x => x.Code)
|
||||
.HasConversion(code => code.Value, value => new DictionaryCode(value))
|
||||
.HasMaxLength(64)
|
||||
.IsRequired();
|
||||
builder.Property(x => x.Name).HasMaxLength(128).IsRequired();
|
||||
builder.Property(x => x.Scope).HasConversion<int>().IsRequired();
|
||||
builder.Property(x => x.AllowOverride).HasDefaultValue(false);
|
||||
builder.Property(x => x.Description).HasMaxLength(512);
|
||||
builder.Property(x => x.IsEnabled).HasDefaultValue(true);
|
||||
ConfigureAuditableEntity(builder);
|
||||
ConfigureSoftDeleteEntity(builder);
|
||||
|
||||
var rowVersion = builder.Property(x => x.RowVersion)
|
||||
.IsConcurrencyToken();
|
||||
|
||||
if (isSqlite)
|
||||
{
|
||||
rowVersion.ValueGeneratedNever();
|
||||
rowVersion.HasColumnType("BLOB");
|
||||
}
|
||||
else
|
||||
{
|
||||
rowVersion.IsRowVersion().HasColumnType("bytea");
|
||||
}
|
||||
|
||||
builder.HasIndex(x => x.TenantId);
|
||||
builder.HasIndex(x => new { x.TenantId, x.Code })
|
||||
.IsUnique()
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
builder.HasIndex(x => new { x.TenantId, x.Scope, x.IsEnabled });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 配置字典项。
|
||||
/// </summary>
|
||||
/// <param name="builder">实体构建器。</param>
|
||||
private static void ConfigureItem(EntityTypeBuilder<DictionaryItem> builder, bool isSqlite)
|
||||
{
|
||||
builder.ToTable("dictionary_items");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.TenantId).IsRequired();
|
||||
builder.Property(x => x.GroupId).IsRequired();
|
||||
builder.Property(x => x.Key).HasMaxLength(128).IsRequired();
|
||||
builder.Property(x => x.Value).HasColumnType("jsonb").IsRequired();
|
||||
builder.Property(x => x.Description).HasMaxLength(512);
|
||||
builder.Property(x => x.SortOrder).HasDefaultValue(100);
|
||||
builder.Property(x => x.IsEnabled).HasDefaultValue(true);
|
||||
ConfigureAuditableEntity(builder);
|
||||
ConfigureSoftDeleteEntity(builder);
|
||||
|
||||
var rowVersion = builder.Property(x => x.RowVersion)
|
||||
.IsConcurrencyToken();
|
||||
|
||||
if (isSqlite)
|
||||
{
|
||||
rowVersion.ValueGeneratedNever();
|
||||
rowVersion.HasColumnType("BLOB");
|
||||
}
|
||||
else
|
||||
{
|
||||
rowVersion.IsRowVersion().HasColumnType("bytea");
|
||||
}
|
||||
|
||||
builder.HasOne(x => x.Group)
|
||||
.WithMany(g => g.Items)
|
||||
.HasForeignKey(x => x.GroupId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasIndex(x => x.TenantId);
|
||||
builder.HasIndex(x => new { x.TenantId, x.GroupId, x.Key })
|
||||
.IsUnique()
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
builder.HasIndex(x => new { x.GroupId, x.IsEnabled, x.SortOrder });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 配置租户字典覆盖。
|
||||
/// </summary>
|
||||
/// <param name="builder">实体构建器。</param>
|
||||
private static void ConfigureOverride(EntityTypeBuilder<TenantDictionaryOverride> builder)
|
||||
{
|
||||
builder.ToTable("tenant_dictionary_overrides");
|
||||
builder.HasKey(x => new { x.TenantId, x.SystemDictionaryGroupId });
|
||||
builder.Property(x => x.OverrideEnabled).HasDefaultValue(false);
|
||||
builder.Property(x => x.HiddenSystemItemIds).HasColumnType("bigint[]");
|
||||
builder.Property(x => x.CustomSortOrder).HasColumnType("jsonb");
|
||||
ConfigureAuditableEntity(builder);
|
||||
ConfigureSoftDeleteEntity(builder);
|
||||
|
||||
builder.HasIndex(x => x.HiddenSystemItemIds).HasMethod("gin");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 配置字典导入日志。
|
||||
/// </summary>
|
||||
/// <param name="builder">实体构建器。</param>
|
||||
private static void ConfigureImportLog(EntityTypeBuilder<DictionaryImportLog> builder)
|
||||
{
|
||||
builder.ToTable("dictionary_import_logs");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.OperatorId).IsRequired();
|
||||
builder.Property(x => x.DictionaryGroupCode).HasMaxLength(64).IsRequired();
|
||||
builder.Property(x => x.FileName).HasMaxLength(256).IsRequired();
|
||||
builder.Property(x => x.Format).HasMaxLength(16).IsRequired();
|
||||
builder.Property(x => x.ErrorDetails).HasColumnType("jsonb");
|
||||
builder.Property(x => x.ProcessedAt).IsRequired();
|
||||
builder.Property(x => x.Duration).HasColumnType("interval");
|
||||
ConfigureAuditableEntity(builder);
|
||||
ConfigureSoftDeleteEntity(builder);
|
||||
|
||||
builder.HasIndex(x => new { x.TenantId, x.ProcessedAt });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 配置缓存失效日志。
|
||||
/// </summary>
|
||||
/// <param name="builder">实体构建器。</param>
|
||||
private static void ConfigureCacheInvalidationLog(EntityTypeBuilder<CacheInvalidationLog> builder)
|
||||
{
|
||||
builder.ToTable("dictionary_cache_invalidation_logs");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.DictionaryCode).HasMaxLength(64).IsRequired();
|
||||
builder.Property(x => x.Scope).HasConversion<int>().IsRequired();
|
||||
builder.Property(x => x.Operation).HasConversion<int>().IsRequired();
|
||||
builder.Property(x => x.Timestamp).IsRequired();
|
||||
ConfigureAuditableEntity(builder);
|
||||
ConfigureSoftDeleteEntity(builder);
|
||||
|
||||
builder.HasIndex(x => new { x.TenantId, x.Timestamp });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 配置系统参数。
|
||||
/// </summary>
|
||||
/// <param name="builder">实体构建器。</param>
|
||||
private static void ConfigureSystemParameter(EntityTypeBuilder<SystemParameter> builder)
|
||||
{
|
||||
builder.ToTable("system_parameters");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.TenantId).IsRequired();
|
||||
builder.Property(x => x.Key).HasMaxLength(128).IsRequired();
|
||||
builder.Property(x => x.Value).HasColumnType("text").IsRequired();
|
||||
builder.Property(x => x.Description).HasMaxLength(512);
|
||||
builder.Property(x => x.SortOrder).HasDefaultValue(100);
|
||||
builder.Property(x => x.IsEnabled).HasDefaultValue(true);
|
||||
ConfigureAuditableEntity(builder);
|
||||
ConfigureSoftDeleteEntity(builder);
|
||||
|
||||
builder.HasIndex(x => x.TenantId);
|
||||
builder.HasIndex(x => new { x.TenantId, x.Key }).IsUnique();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 配置字典标签覆盖。
|
||||
/// </summary>
|
||||
/// <param name="builder">实体构建器。</param>
|
||||
private static void ConfigureLabelOverride(EntityTypeBuilder<DictionaryLabelOverride> builder)
|
||||
{
|
||||
builder.ToTable("dictionary_label_overrides", t => t.HasComment("字典标签覆盖配置。"));
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.Id).HasComment("实体唯一标识。");
|
||||
builder.Property(x => x.TenantId).IsRequired().HasComment("所属租户 ID(覆盖目标租户)。");
|
||||
builder.Property(x => x.DictionaryItemId).IsRequired().HasComment("被覆盖的字典项 ID。");
|
||||
builder.Property(x => x.OriginalValue).HasColumnType("jsonb").IsRequired().HasComment("原始显示值(JSON 格式,多语言)。");
|
||||
builder.Property(x => x.OverrideValue).HasColumnType("jsonb").IsRequired().HasComment("覆盖后的显示值(JSON 格式,多语言)。");
|
||||
builder.Property(x => x.OverrideType).HasConversion<int>().IsRequired().HasComment("覆盖类型。");
|
||||
builder.Property(x => x.Reason).HasMaxLength(512).HasComment("覆盖原因/备注。");
|
||||
ConfigureAuditableEntity(builder);
|
||||
ConfigureSoftDeleteEntity(builder);
|
||||
|
||||
builder.HasOne(x => x.DictionaryItem)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.DictionaryItemId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasIndex(x => x.TenantId);
|
||||
builder.HasIndex(x => new { x.TenantId, x.DictionaryItemId })
|
||||
.IsUnique()
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
builder.HasIndex(x => new { x.TenantId, x.OverrideType });
|
||||
}
|
||||
}
|
||||
@@ -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.Dictionary.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// 设计时 DictionaryDbContext 工厂。
|
||||
/// </summary>
|
||||
internal sealed class DictionaryDesignTimeDbContextFactory
|
||||
: DesignTimeDbContextFactoryBase<DictionaryDbContext>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化字典库设计时上下文工厂。
|
||||
/// </summary>
|
||||
public DictionaryDesignTimeDbContextFactory()
|
||||
: base(DatabaseConstants.DictionaryDataSource, "TAKEOUTSAAS_DICTIONARY_CONNECTION")
|
||||
{
|
||||
}
|
||||
// 创建设计时上下文
|
||||
/// <summary>
|
||||
/// 创建设计时的 DictionaryDbContext。
|
||||
/// </summary>
|
||||
/// <param name="options">上下文配置。</param>
|
||||
/// <param name="tenantProvider">租户提供器。</param>
|
||||
/// <param name="currentUserAccessor">当前用户访问器。</param>
|
||||
/// <returns>DictionaryDbContext 实例。</returns>
|
||||
protected override DictionaryDbContext CreateContext(
|
||||
DbContextOptions<DictionaryDbContext> options,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor)
|
||||
=> new(options, tenantProvider, currentUserAccessor);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
using TakeoutSaaS.Domain.Dictionary.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Persistence;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 缓存失效日志仓储实现。
|
||||
/// </summary>
|
||||
public sealed class CacheInvalidationLogRepository(DictionaryDbContext context) : ICacheInvalidationLogRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// 新增失效日志。
|
||||
/// </summary>
|
||||
public Task AddAsync(CacheInvalidationLog log, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.CacheInvalidationLogs.Add(log);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询失效日志。
|
||||
/// </summary>
|
||||
public async Task<(IReadOnlyList<CacheInvalidationLog> Items, int TotalCount)> GetPagedAsync(
|
||||
int page,
|
||||
int pageSize,
|
||||
DateTime? startDate,
|
||||
DateTime? endDate,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = context.CacheInvalidationLogs.AsNoTracking();
|
||||
|
||||
if (startDate.HasValue)
|
||||
{
|
||||
query = query.Where(log => log.Timestamp >= startDate.Value);
|
||||
}
|
||||
|
||||
if (endDate.HasValue)
|
||||
{
|
||||
query = query.Where(log => log.Timestamp <= endDate.Value);
|
||||
}
|
||||
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
var items = await query
|
||||
.OrderByDescending(log => log.Timestamp)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return (items, total);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存变更。
|
||||
/// </summary>
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
=> context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
using TakeoutSaaS.Domain.Dictionary.Enums;
|
||||
using TakeoutSaaS.Domain.Dictionary.Repositories;
|
||||
using TakeoutSaaS.Domain.Dictionary.ValueObjects;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Persistence;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 字典分组仓储实现。
|
||||
/// </summary>
|
||||
public sealed class DictionaryGroupRepository(DictionaryDbContext context) : IDictionaryGroupRepository
|
||||
{
|
||||
private static readonly Func<DictionaryDbContext, long, DictionaryCode, Task<DictionaryGroup?>> GetByCodeQuery =
|
||||
EF.CompileAsyncQuery((DictionaryDbContext db, long tenantId, DictionaryCode code) =>
|
||||
db.DictionaryGroups
|
||||
.AsNoTracking()
|
||||
.FirstOrDefault(group => group.TenantId == tenantId && group.DeletedAt == null && group.Code == code));
|
||||
|
||||
/// <summary>
|
||||
/// 按 ID 获取字典分组。
|
||||
/// </summary>
|
||||
public Task<DictionaryGroup?> GetByIdAsync(long groupId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.DictionaryGroups
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(group => group.Id == groupId && group.DeletedAt == null, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按编码获取字典分组。
|
||||
/// </summary>
|
||||
public Task<DictionaryGroup?> GetByCodeAsync(long tenantId, DictionaryCode code, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_ = cancellationToken;
|
||||
return GetByCodeQuery(context, tenantId, code);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分页获取字典分组。
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<DictionaryGroup>> GetPagedAsync(
|
||||
long tenantId,
|
||||
DictionaryScope? scope,
|
||||
string? keyword,
|
||||
bool? isEnabled,
|
||||
int page,
|
||||
int pageSize,
|
||||
string? sortBy,
|
||||
bool sortDescending,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = BuildQuery(tenantId, scope, keyword, isEnabled);
|
||||
|
||||
var skip = Math.Max(page - 1, 0) * Math.Max(pageSize, 1);
|
||||
query = ApplyOrdering(query, sortBy, sortDescending);
|
||||
|
||||
return await query
|
||||
.Skip(skip)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取满足条件的分组数量。
|
||||
/// </summary>
|
||||
public Task<int> CountAsync(
|
||||
long tenantId,
|
||||
DictionaryScope? scope,
|
||||
string? keyword,
|
||||
bool? isEnabled,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return BuildQuery(tenantId, scope, keyword, isEnabled)
|
||||
.CountAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量获取字典分组。
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<DictionaryGroup>> GetByIdsAsync(IEnumerable<long> groupIds, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var ids = groupIds?.Distinct().ToArray() ?? Array.Empty<long>();
|
||||
if (ids.Length == 0)
|
||||
{
|
||||
return Array.Empty<DictionaryGroup>();
|
||||
}
|
||||
|
||||
return await context.DictionaryGroups
|
||||
.AsNoTracking()
|
||||
.Where(group => ids.Contains(group.Id) && group.DeletedAt == null)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static IQueryable<DictionaryGroup> ApplyOrdering(IQueryable<DictionaryGroup> query, string? sortBy, bool sortDescending)
|
||||
{
|
||||
var normalized = sortBy?.Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"name" => sortDescending ? query.OrderByDescending(group => group.Name) : query.OrderBy(group => group.Name),
|
||||
"createdat" => sortDescending ? query.OrderByDescending(group => group.CreatedAt) : query.OrderBy(group => group.CreatedAt),
|
||||
"updatedat" => sortDescending ? query.OrderByDescending(group => group.UpdatedAt) : query.OrderBy(group => group.UpdatedAt),
|
||||
_ => sortDescending ? query.OrderByDescending(group => group.Code) : query.OrderBy(group => group.Code)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增分组。
|
||||
/// </summary>
|
||||
public Task AddAsync(DictionaryGroup group, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.DictionaryGroups.Add(group);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新分组。
|
||||
/// </summary>
|
||||
public Task UpdateAsync(DictionaryGroup group, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.DictionaryGroups.Update(group);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除分组。
|
||||
/// </summary>
|
||||
public Task RemoveAsync(DictionaryGroup group, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.DictionaryGroups.Remove(group);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 持久化更改。
|
||||
/// </summary>
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
=> context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
private IQueryable<DictionaryGroup> BuildQuery(long tenantId, DictionaryScope? scope, string? keyword, bool? isEnabled)
|
||||
{
|
||||
var query = context.DictionaryGroups
|
||||
.AsNoTracking()
|
||||
.Where(group => group.TenantId == tenantId && group.DeletedAt == null);
|
||||
|
||||
if (scope.HasValue)
|
||||
{
|
||||
query = query.Where(group => group.Scope == scope.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
var trimmed = keyword.Trim();
|
||||
if (DictionaryCode.IsValid(trimmed))
|
||||
{
|
||||
var code = new DictionaryCode(trimmed);
|
||||
query = query.Where(group => group.Code == code || group.Name.Contains(trimmed));
|
||||
}
|
||||
else
|
||||
{
|
||||
query = query.Where(group => group.Name.Contains(trimmed));
|
||||
}
|
||||
}
|
||||
|
||||
if (isEnabled.HasValue)
|
||||
{
|
||||
query = query.Where(group => group.IsEnabled == isEnabled.Value);
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
using TakeoutSaaS.Domain.Dictionary.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Persistence;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 字典导入日志仓储实现。
|
||||
/// </summary>
|
||||
public sealed class DictionaryImportLogRepository(DictionaryDbContext context) : IDictionaryImportLogRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// 新增导入日志。
|
||||
/// </summary>
|
||||
public Task AddAsync(DictionaryImportLog log, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.DictionaryImportLogs.Add(log);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 持久化更改。
|
||||
/// </summary>
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
=> context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
using TakeoutSaaS.Domain.Dictionary.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 字典项仓储实现。
|
||||
/// </summary>
|
||||
public sealed class DictionaryItemRepository(DictionaryDbContext context, ITenantContextAccessor tenantContextAccessor) : IDictionaryItemRepository
|
||||
{
|
||||
private static readonly Func<DictionaryDbContext, long, long, IEnumerable<DictionaryItem>> GetByGroupQuery =
|
||||
EF.CompileQuery((DictionaryDbContext db, long tenantId, long groupId) =>
|
||||
(IEnumerable<DictionaryItem>)db.DictionaryItems
|
||||
.AsNoTracking()
|
||||
.Where(item => item.GroupId == groupId && item.TenantId == tenantId && item.DeletedAt == null)
|
||||
.OrderBy(item => item.SortOrder));
|
||||
|
||||
/// <summary>
|
||||
/// 根据 ID 获取字典项。
|
||||
/// </summary>
|
||||
public Task<DictionaryItem?> GetByIdAsync(long itemId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.DictionaryItems
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == itemId && item.DeletedAt == null, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取分组下字典项列表。
|
||||
/// </summary>
|
||||
public Task<IReadOnlyList<DictionaryItem>> GetByGroupIdAsync(
|
||||
long tenantId,
|
||||
long groupId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_ = cancellationToken;
|
||||
return Task.FromResult<IReadOnlyList<DictionaryItem>>(
|
||||
GetByGroupQuery(context, tenantId, groupId).ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取系统与租户合并的字典项列表。
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<DictionaryItem>> GetMergedItemsAsync(
|
||||
long tenantId,
|
||||
long systemGroupId,
|
||||
bool includeOverrides,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
DictionaryGroup? systemGroup;
|
||||
List<DictionaryItem> systemItems;
|
||||
using (tenantContextAccessor.EnterTenantScope(0, "dictionary"))
|
||||
{
|
||||
systemGroup = await context.DictionaryGroups
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(group => group.Id == systemGroupId && group.DeletedAt == null, cancellationToken);
|
||||
if (systemGroup == null)
|
||||
{
|
||||
return Array.Empty<DictionaryItem>();
|
||||
}
|
||||
|
||||
systemItems = await context.DictionaryItems
|
||||
.AsNoTracking()
|
||||
.Where(item => item.GroupId == systemGroupId && item.DeletedAt == null)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
var result = new List<DictionaryItem>(systemItems);
|
||||
|
||||
if (!includeOverrides || tenantId == 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var tenantGroup = await context.DictionaryGroups
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(group =>
|
||||
group.TenantId == tenantId &&
|
||||
group.DeletedAt == null &&
|
||||
group.Code == systemGroup.Code,
|
||||
cancellationToken);
|
||||
|
||||
if (tenantGroup == null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var tenantItems = await context.DictionaryItems
|
||||
.AsNoTracking()
|
||||
.Where(item => item.GroupId == tenantGroup.Id && item.TenantId == tenantId && item.DeletedAt == null)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
result.AddRange(tenantItems);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增字典项。
|
||||
/// </summary>
|
||||
public Task AddAsync(DictionaryItem item, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.DictionaryItems.Add(item);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新字典项。
|
||||
/// </summary>
|
||||
public Task UpdateAsync(DictionaryItem item, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entry = context.Entry(item);
|
||||
if (entry.State == EntityState.Detached)
|
||||
{
|
||||
context.DictionaryItems.Attach(item);
|
||||
entry = context.Entry(item);
|
||||
}
|
||||
|
||||
entry.State = EntityState.Modified;
|
||||
var originalVersion = item.RowVersion;
|
||||
var nextVersion = RandomNumberGenerator.GetBytes(16);
|
||||
entry.Property(x => x.RowVersion).OriginalValue = originalVersion;
|
||||
entry.Property(x => x.RowVersion).CurrentValue = nextVersion;
|
||||
item.RowVersion = nextVersion;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除字典项。
|
||||
/// </summary>
|
||||
public Task RemoveAsync(DictionaryItem item, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.DictionaryItems.Remove(item);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 持久化更改。
|
||||
/// </summary>
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
=> context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
using TakeoutSaaS.Domain.Dictionary.Enums;
|
||||
using TakeoutSaaS.Domain.Dictionary.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Persistence;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 字典标签覆盖仓储实现。
|
||||
/// </summary>
|
||||
public sealed class DictionaryLabelOverrideRepository(DictionaryDbContext context) : IDictionaryLabelOverrideRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// 根据 ID 获取覆盖配置。
|
||||
/// </summary>
|
||||
public Task<DictionaryLabelOverride?> GetByIdAsync(long id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.DictionaryLabelOverrides
|
||||
.Include(x => x.DictionaryItem)
|
||||
.FirstOrDefaultAsync(x => x.Id == id && x.DeletedAt == null, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定字典项的覆盖配置。
|
||||
/// </summary>
|
||||
public Task<DictionaryLabelOverride?> GetByItemIdAsync(long tenantId, long dictionaryItemId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.DictionaryLabelOverrides
|
||||
.FirstOrDefaultAsync(x =>
|
||||
x.TenantId == tenantId &&
|
||||
x.DictionaryItemId == dictionaryItemId &&
|
||||
x.DeletedAt == null,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取租户的所有覆盖配置。
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<DictionaryLabelOverride>> ListByTenantAsync(
|
||||
long tenantId,
|
||||
OverrideType? overrideType = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = context.DictionaryLabelOverrides
|
||||
.AsNoTracking()
|
||||
.Include(x => x.DictionaryItem)
|
||||
.Where(x => x.TenantId == tenantId && x.DeletedAt == null);
|
||||
|
||||
if (overrideType.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.OverrideType == overrideType.Value);
|
||||
}
|
||||
|
||||
return await query.OrderByDescending(x => x.CreatedAt).ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量获取多个字典项的覆盖配置。
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<DictionaryLabelOverride>> GetByItemIdsAsync(
|
||||
long tenantId,
|
||||
IEnumerable<long> dictionaryItemIds,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var ids = dictionaryItemIds.ToArray();
|
||||
if (ids.Length == 0) return Array.Empty<DictionaryLabelOverride>();
|
||||
|
||||
return await context.DictionaryLabelOverrides
|
||||
.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.TenantId == tenantId &&
|
||||
ids.Contains(x.DictionaryItemId) &&
|
||||
x.DeletedAt == null)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增覆盖配置。
|
||||
/// </summary>
|
||||
public Task AddAsync(DictionaryLabelOverride entity, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.DictionaryLabelOverrides.Add(entity);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新覆盖配置。
|
||||
/// </summary>
|
||||
public Task UpdateAsync(DictionaryLabelOverride entity, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.DictionaryLabelOverrides.Update(entity);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除覆盖配置。
|
||||
/// </summary>
|
||||
public Task DeleteAsync(DictionaryLabelOverride entity, CancellationToken cancellationToken = default)
|
||||
{
|
||||
entity.DeletedAt = DateTime.UtcNow;
|
||||
context.DictionaryLabelOverrides.Update(entity);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 持久化更改。
|
||||
/// </summary>
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
=> context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
using TakeoutSaaS.Domain.Dictionary.Enums;
|
||||
using TakeoutSaaS.Domain.Dictionary.ValueObjects;
|
||||
using TakeoutSaaS.Domain.Dictionary.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core 字典仓储实现。
|
||||
/// </summary>
|
||||
public sealed class EfDictionaryRepository(DictionaryDbContext context, ITenantContextAccessor tenantContextAccessor) : IDictionaryRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// 根据分组 ID 查询分组。
|
||||
/// </summary>
|
||||
/// <param name="id">分组 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>匹配分组或 null。</returns>
|
||||
public Task<DictionaryGroup?> FindGroupByIdAsync(long id, CancellationToken cancellationToken = default)
|
||||
=> context.DictionaryGroups.FirstOrDefaultAsync(group => group.Id == id, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 根据分组编码查询分组。
|
||||
/// </summary>
|
||||
/// <param name="code">分组编码。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>匹配分组或 null。</returns>
|
||||
public Task<DictionaryGroup?> FindGroupByCodeAsync(string code, CancellationToken cancellationToken = default)
|
||||
=> context.DictionaryGroups.FirstOrDefaultAsync(group => group.Code == new DictionaryCode(code), cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 搜索分组列表。
|
||||
/// </summary>
|
||||
/// <param name="scope">字典作用域。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>分组列表。</returns>
|
||||
public async Task<IReadOnlyList<DictionaryGroup>> SearchGroupsAsync(DictionaryScope? scope, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 构建分组查询
|
||||
var query = context.DictionaryGroups.AsNoTracking();
|
||||
if (scope.HasValue)
|
||||
{
|
||||
// 2. 按作用域过滤
|
||||
query = query.Where(group => group.Scope == scope.Value);
|
||||
}
|
||||
|
||||
// 3. 排序返回
|
||||
return await query
|
||||
.OrderBy(group => group.Code)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增分组。
|
||||
/// </summary>
|
||||
/// <param name="group">分组实体。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public Task AddGroupAsync(DictionaryGroup group, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 添加分组
|
||||
context.DictionaryGroups.Add(group);
|
||||
// 2. 返回完成任务
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除分组。
|
||||
/// </summary>
|
||||
/// <param name="group">分组实体。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public Task RemoveGroupAsync(DictionaryGroup group, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 移除分组
|
||||
context.DictionaryGroups.Remove(group);
|
||||
// 2. 返回完成任务
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据条目 ID 查询字典项。
|
||||
/// </summary>
|
||||
/// <param name="id">条目 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>匹配条目或 null。</returns>
|
||||
public Task<DictionaryItem?> FindItemByIdAsync(long id, CancellationToken cancellationToken = default)
|
||||
=> context.DictionaryItems.FirstOrDefaultAsync(item => item.Id == id, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定分组下的条目列表。
|
||||
/// </summary>
|
||||
/// <param name="groupId">分组 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>条目列表。</returns>
|
||||
public async Task<IReadOnlyList<DictionaryItem>> GetItemsByGroupIdAsync(long groupId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 过滤分组
|
||||
return await context.DictionaryItems
|
||||
.AsNoTracking()
|
||||
.Where(item => item.GroupId == groupId)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增字典项。
|
||||
/// </summary>
|
||||
/// <param name="item">字典项。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public Task AddItemAsync(DictionaryItem item, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 添加条目
|
||||
context.DictionaryItems.Add(item);
|
||||
// 2. 返回完成任务
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除字典项。
|
||||
/// </summary>
|
||||
/// <param name="item">字典项。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public Task RemoveItemAsync(DictionaryItem item, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 移除条目
|
||||
context.DictionaryItems.Remove(item);
|
||||
// 2. 返回完成任务
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 持久化变更。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>保存任务。</returns>
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
=> context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 根据编码集合获取条目列表,可包含系统级条目。
|
||||
/// </summary>
|
||||
/// <param name="codes">分组编码集合。</param>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="includeSystem">是否包含系统级。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>条目列表。</returns>
|
||||
public async Task<IReadOnlyList<DictionaryItem>> GetItemsByCodesAsync(IEnumerable<string> codes, long tenantId, bool includeSystem, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 规范化编码
|
||||
var normalizedCodes = codes
|
||||
.Where(code => !string.IsNullOrWhiteSpace(code))
|
||||
.Select(code => new DictionaryCode(code))
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
|
||||
if (normalizedCodes.Length == 0)
|
||||
{
|
||||
return Array.Empty<DictionaryItem>();
|
||||
}
|
||||
|
||||
// 2. 查询当前租户条目
|
||||
var tenantItems = await context.DictionaryItems
|
||||
.AsNoTracking()
|
||||
.Include(item => item.Group)
|
||||
.Where(item => item.TenantId == tenantId && normalizedCodes.Contains(item.Group!.Code) && item.DeletedAt == null)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (!includeSystem)
|
||||
{
|
||||
return tenantItems;
|
||||
}
|
||||
|
||||
// 3. (空行后) 查询系统级条目(TenantId=0)
|
||||
List<DictionaryItem> systemItems;
|
||||
using (tenantContextAccessor.EnterTenantScope(0, "dictionary"))
|
||||
{
|
||||
systemItems = await context.DictionaryItems
|
||||
.AsNoTracking()
|
||||
.Include(item => item.Group)
|
||||
.Where(item => item.TenantId == 0 && normalizedCodes.Contains(item.Group!.Code) && item.DeletedAt == null)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
// 4. (空行后) 合并返回(系统优先)
|
||||
if (systemItems.Count == 0)
|
||||
{
|
||||
return tenantItems;
|
||||
}
|
||||
|
||||
return [.. systemItems, .. tenantItems];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.SystemParameters.Entities;
|
||||
using TakeoutSaaS.Domain.SystemParameters.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Persistence;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 系统参数 EF Core 仓储实现。
|
||||
/// </summary>
|
||||
public sealed class EfSystemParameterRepository(DictionaryDbContext context) : ISystemParameterRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<SystemParameter?> FindByIdAsync(long id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.SystemParameters
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SystemParameter?> FindByKeyAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedKey = key.Trim();
|
||||
return context.SystemParameters
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Key == normalizedKey, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<SystemParameter>> SearchAsync(string? keyword, bool? isEnabled, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = context.SystemParameters.AsNoTracking();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
var normalized = keyword.Trim();
|
||||
query = query.Where(x => x.Key.Contains(normalized) || (x.Description != null && x.Description.Contains(normalized)));
|
||||
}
|
||||
|
||||
if (isEnabled.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.IsEnabled == isEnabled.Value);
|
||||
}
|
||||
|
||||
var parameters = await query
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ThenBy(x => x.Key)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return parameters;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddAsync(SystemParameter parameter, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.SystemParameters.AddAsync(parameter, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RemoveAsync(SystemParameter parameter, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.SystemParameters.Remove(parameter);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateAsync(SystemParameter parameter, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.SystemParameters.Update(parameter);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
using TakeoutSaaS.Domain.Dictionary.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Persistence;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 租户字典覆盖仓储实现。
|
||||
/// </summary>
|
||||
public sealed class TenantDictionaryOverrideRepository(DictionaryDbContext context) : ITenantDictionaryOverrideRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取租户覆盖配置。
|
||||
/// </summary>
|
||||
public Task<TenantDictionaryOverride?> GetAsync(long tenantId, long systemGroupId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.TenantDictionaryOverrides
|
||||
.FirstOrDefaultAsync(config =>
|
||||
config.TenantId == tenantId &&
|
||||
config.SystemDictionaryGroupId == systemGroupId &&
|
||||
config.DeletedAt == null,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取租户全部覆盖配置。
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<TenantDictionaryOverride>> ListAsync(long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await context.TenantDictionaryOverrides
|
||||
.AsNoTracking()
|
||||
.Where(config => config.TenantId == tenantId && config.DeletedAt == null)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增覆盖配置。
|
||||
/// </summary>
|
||||
public Task AddAsync(TenantDictionaryOverride overrideConfig, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.TenantDictionaryOverrides.Add(overrideConfig);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新覆盖配置。
|
||||
/// </summary>
|
||||
public Task UpdateAsync(TenantDictionaryOverride overrideConfig, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.TenantDictionaryOverrides.Update(overrideConfig);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 持久化更改。
|
||||
/// </summary>
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
=> context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Application.Dictionary.Abstractions;
|
||||
using TakeoutSaaS.Application.Dictionary.Models;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Options;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 基于 IDistributedCache 的字典缓存实现。
|
||||
/// </summary>
|
||||
public sealed class DistributedDictionaryCache(IDistributedCache cache, IOptions<DictionaryCacheOptions> options) : IDictionaryCache
|
||||
{
|
||||
private readonly DictionaryCacheOptions _options = options.Value;
|
||||
private readonly JsonSerializerOptions _serializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
/// <summary>
|
||||
/// 读取指定租户与编码的字典缓存。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="code">字典编码。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>字典项集合或 null。</returns>
|
||||
public async Task<IReadOnlyList<DictionaryItemDto>?> GetAsync(long tenantId, string code, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 拼装缓存键
|
||||
var cacheKey = BuildKey(tenantId, code);
|
||||
var payload = await cache.GetAsync(cacheKey, cancellationToken);
|
||||
if (payload == null || payload.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. 反序列化
|
||||
return JsonSerializer.Deserialize<List<DictionaryItemDto>>(payload, _serializerOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置指定租户与编码的字典缓存。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="code">字典编码。</param>
|
||||
/// <param name="items">字典项集合。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public Task SetAsync(long tenantId, string code, IReadOnlyList<DictionaryItemDto> items, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 序列化并写入缓存
|
||||
var cacheKey = BuildKey(tenantId, code);
|
||||
var payload = JsonSerializer.SerializeToUtf8Bytes(items, _serializerOptions);
|
||||
var options = new DistributedCacheEntryOptions
|
||||
{
|
||||
SlidingExpiration = _options.SlidingExpiration
|
||||
};
|
||||
return cache.SetAsync(cacheKey, payload, options, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 移除指定租户与编码的缓存。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="code">字典编码。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public Task RemoveAsync(long tenantId, string code, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 删除缓存键
|
||||
var cacheKey = BuildKey(tenantId, code);
|
||||
return cache.RemoveAsync(cacheKey, cancellationToken);
|
||||
}
|
||||
|
||||
private static string BuildKey(long tenantId, string code)
|
||||
=> $"dictionary:{tenantId}:{code.ToLowerInvariant()}";
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.Common.Extensions;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Options;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Persistence;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Services;
|
||||
using TakeoutSaaS.Infrastructure.Logs.Publishers;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using DomainIdentityUser = TakeoutSaaS.Domain.Identity.Entities.IdentityUser;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// 身份认证基础设施注入。
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 注册身份认证基础设施(数据库、Redis、JWT、限流等)。
|
||||
/// </summary>
|
||||
/// <param name="services">服务集合。</param>
|
||||
/// <param name="configuration">配置源。</param>
|
||||
/// <param name="enableMiniFeatures">是否启用小程序相关依赖(如微信登录)。</param>
|
||||
/// <param name="enableAdminSeed">是否启用后台账号初始化。</param>
|
||||
/// <returns>服务集合。</returns>
|
||||
/// <exception cref="InvalidOperationException">配置缺失时抛出。</exception>
|
||||
public static IServiceCollection AddIdentityInfrastructure(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
bool enableMiniFeatures = false,
|
||||
bool enableAdminSeed = false)
|
||||
{
|
||||
services.AddDatabaseInfrastructure(configuration);
|
||||
services.AddPostgresDbContext<IdentityDbContext>(DatabaseConstants.IdentityDataSource);
|
||||
|
||||
var redisConnection = configuration.GetConnectionString("Redis");
|
||||
if (string.IsNullOrWhiteSpace(redisConnection))
|
||||
{
|
||||
throw new InvalidOperationException("缺少 Redis 连接字符串配置。");
|
||||
}
|
||||
|
||||
services.AddStackExchangeRedisCache(options =>
|
||||
{
|
||||
options.Configuration = redisConnection;
|
||||
});
|
||||
|
||||
services.AddScoped<IIdentityUserRepository, EfIdentityUserRepository>();
|
||||
services.AddScoped<IMiniUserRepository, EfMiniUserRepository>();
|
||||
services.AddScoped<IRoleRepository, EfRoleRepository>();
|
||||
services.AddScoped<IPermissionRepository, EfPermissionRepository>();
|
||||
services.AddScoped<IUserRoleRepository, EfUserRoleRepository>();
|
||||
services.AddScoped<IRolePermissionRepository, EfRolePermissionRepository>();
|
||||
services.AddScoped<IRoleTemplateRepository, EfRoleTemplateRepository>();
|
||||
services.AddScoped<IMenuRepository, EfMenuRepository>();
|
||||
services.AddScoped<IJwtTokenService, JwtTokenService>();
|
||||
services.AddScoped<IRefreshTokenStore, RedisRefreshTokenStore>();
|
||||
services.AddScoped<IAdminPasswordResetTokenStore, RedisAdminPasswordResetTokenStore>();
|
||||
services.AddScoped<ILoginRateLimiter, RedisLoginRateLimiter>();
|
||||
services.AddScoped<IPasswordHasher<DomainIdentityUser>, PasswordHasher<DomainIdentityUser>>();
|
||||
services.AddScoped<IIdentityOperationLogPublisher, IdentityOperationLogPublisher>();
|
||||
|
||||
services.AddOptions<JwtOptions>()
|
||||
.Bind(configuration.GetSection("Identity:Jwt"))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddOptions<LoginRateLimitOptions>()
|
||||
.Bind(configuration.GetSection("Identity:LoginRateLimit"))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddOptions<RefreshTokenStoreOptions>()
|
||||
.Bind(configuration.GetSection("Identity:RefreshTokenStore"));
|
||||
|
||||
services.AddOptions<AdminPasswordResetOptions>()
|
||||
.Bind(configuration.GetSection("Identity:AdminPasswordReset"));
|
||||
|
||||
if (enableMiniFeatures)
|
||||
{
|
||||
services.AddOptions<WeChatMiniOptions>()
|
||||
.Bind(configuration.GetSection("Identity:WeChatMini"))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddHttpClient<IWeChatAuthService, WeChatAuthService>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri("https://api.weixin.qq.com/");
|
||||
client.Timeout = TimeSpan.FromSeconds(10);
|
||||
});
|
||||
}
|
||||
|
||||
if (enableAdminSeed)
|
||||
{
|
||||
services.AddOptions<AdminSeedOptions>()
|
||||
.Bind(configuration.GetSection("Identity:AdminSeed"))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddHostedService<IdentityDataSeeder>();
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 确保数据库连接已配置(Database 节或 ConnectionStrings)。
|
||||
/// </summary>
|
||||
/// <param name="configuration">配置源。</param>
|
||||
/// <param name="dataSourceName">数据源名称。</param>
|
||||
/// <exception cref="InvalidOperationException">未配置时抛出。</exception>
|
||||
private static void EnsureDatabaseConnectionConfigured(IConfiguration configuration, string dataSourceName)
|
||||
{
|
||||
// 保留兼容接口,当前逻辑在 DatabaseConnectionFactory 中兜底并记录日志。
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 管理后台重置密码链接令牌配置。
|
||||
/// </summary>
|
||||
public sealed class AdminPasswordResetOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Redis Key 前缀。
|
||||
/// </summary>
|
||||
public string Prefix { get; init; } = "identity:admin:pwdreset:";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 管理后台初始账号配置。
|
||||
/// </summary>
|
||||
public sealed class AdminSeedOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 是否启用后台账号与权限种子。
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 初始用户列表。
|
||||
/// </summary>
|
||||
public List<SeedUserOptions> Users { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 角色模板种子列表。
|
||||
/// </summary>
|
||||
public List<RoleTemplateSeedOptions> RoleTemplates { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 种子用户配置:用于初始化管理后台账号。
|
||||
/// </summary>
|
||||
public sealed class SeedUserOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 登录账号。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string Account { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 登录密码(明文,将在初始化时进行哈希处理)。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string Password { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 展示名称。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 所属租户 ID。
|
||||
/// </summary>
|
||||
public long TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 所属商户 ID(租户管理员为空)。
|
||||
/// </summary>
|
||||
public long? MerchantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 角色集合。
|
||||
/// </summary>
|
||||
public string[] Roles { get; set; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// 权限集合。
|
||||
/// </summary>
|
||||
public string[] Permissions { get; set; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 角色模板种子配置。
|
||||
/// </summary>
|
||||
public sealed class RoleTemplateSeedOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 模板编码。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string TemplateCode { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 模板名称。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 模板描述。
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// </summary>
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 权限编码集合。
|
||||
/// </summary>
|
||||
public string[] Permissions { get; set; } = Array.Empty<string>();
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 登录限流配置选项。
|
||||
/// </summary>
|
||||
public sealed class LoginRateLimitOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 时间窗口(秒),范围:1-3600。
|
||||
/// </summary>
|
||||
[Range(1, 3600)]
|
||||
public int WindowSeconds { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// 时间窗口内允许的最大尝试次数,范围:1-100。
|
||||
/// </summary>
|
||||
[Range(1, 100)]
|
||||
public int MaxAttempts { get; set; } = 5;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 刷新令牌存储配置选项。
|
||||
/// </summary>
|
||||
public sealed class RefreshTokenStoreOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Redis 键前缀,用于存储刷新令牌。
|
||||
/// </summary>
|
||||
public string Prefix { get; set; } = "identity:refresh:";
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 微信小程序配置选项。
|
||||
/// </summary>
|
||||
public sealed class WeChatMiniOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 微信小程序 AppId。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string AppId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 微信小程序 AppSecret。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string Secret { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core 后台用户仓储实现。
|
||||
/// </summary>
|
||||
public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIdentityUserRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// 根据租户与账号获取后台用户。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="account">账号。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>后台用户或 null。</returns>
|
||||
public Task<IdentityUser?> FindByAccountAsync(long tenantId, string account, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 标准化账号
|
||||
var normalized = account.Trim();
|
||||
|
||||
// 2. 查询用户(强制租户隔离)
|
||||
return dbContext.IdentityUsers
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Account == normalized, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断账号是否存在。
|
||||
/// </summary>
|
||||
/// <param name="account">账号。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>存在返回 true。</returns>
|
||||
public Task<bool> ExistsByAccountAsync(string account, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 标准化账号
|
||||
var normalized = account.Trim();
|
||||
// 2. 查询是否存在
|
||||
return dbContext.IdentityUsers.AnyAsync(x => x.Account == normalized, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断账号是否存在(租户内,可排除指定用户)。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="account">账号。</param>
|
||||
/// <param name="excludeUserId">排除的用户 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>存在返回 true。</returns>
|
||||
public async Task<bool> ExistsByAccountAsync(long tenantId, string account, long? excludeUserId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 标准化账号
|
||||
var normalized = account.Trim();
|
||||
|
||||
// 2. 构建查询(包含已删除数据,但不放开租户过滤)
|
||||
using var disableSoftDeleteScope = dbContext.DisableSoftDeleteFilter();
|
||||
var query = dbContext.IdentityUsers.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.Account == normalized);
|
||||
|
||||
if (excludeUserId.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.Id != excludeUserId.Value);
|
||||
}
|
||||
|
||||
// 3. 返回是否存在
|
||||
return await query.AnyAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断手机号是否存在(租户内,可排除指定用户)。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="phone">手机号。</param>
|
||||
/// <param name="excludeUserId">排除的用户 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>存在返回 true。</returns>
|
||||
public async Task<bool> ExistsByPhoneAsync(long tenantId, string phone, long? excludeUserId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 标准化手机号
|
||||
var normalized = phone.Trim();
|
||||
|
||||
// 2. 构建查询(包含已删除数据,但不放开租户过滤)
|
||||
using var disableSoftDeleteScope = dbContext.DisableSoftDeleteFilter();
|
||||
var query = dbContext.IdentityUsers.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.Phone == normalized);
|
||||
|
||||
if (excludeUserId.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.Id != excludeUserId.Value);
|
||||
}
|
||||
|
||||
// 3. 返回是否存在
|
||||
return await query.AnyAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断邮箱是否存在(租户内,可排除指定用户)。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="email">邮箱。</param>
|
||||
/// <param name="excludeUserId">排除的用户 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>存在返回 true。</returns>
|
||||
public async Task<bool> ExistsByEmailAsync(long tenantId, string email, long? excludeUserId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 标准化邮箱
|
||||
var normalized = email.Trim();
|
||||
|
||||
// 2. 构建查询(包含已删除数据,但不放开租户过滤)
|
||||
using var disableSoftDeleteScope = dbContext.DisableSoftDeleteFilter();
|
||||
var query = dbContext.IdentityUsers.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.Email == normalized);
|
||||
|
||||
if (excludeUserId.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.Id != excludeUserId.Value);
|
||||
}
|
||||
|
||||
// 3. 返回是否存在
|
||||
return await query.AnyAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据 ID 获取后台用户。
|
||||
/// </summary>
|
||||
/// <param name="userId">用户 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>后台用户或 null。</returns>
|
||||
public Task<IdentityUser?> FindByIdAsync(long userId, CancellationToken cancellationToken = default)
|
||||
=> dbContext.IdentityUsers.AsNoTracking().FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 根据 ID 获取后台用户(用于更新,返回可跟踪实体)。
|
||||
/// </summary>
|
||||
/// <param name="userId">用户 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>后台用户或 null。</returns>
|
||||
public Task<IdentityUser?> GetForUpdateAsync(long userId, CancellationToken cancellationToken = default)
|
||||
=> dbContext.IdentityUsers.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 根据 ID 获取后台用户(用于更新,包含已删除数据)。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="userId">用户 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>后台用户或 null。</returns>
|
||||
public async Task<IdentityUser?> GetForUpdateIncludingDeletedAsync(
|
||||
long tenantId,
|
||||
long userId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 构建查询(包含已删除数据,但强制租户隔离)
|
||||
using var disableSoftDeleteScope = dbContext.DisableSoftDeleteFilter();
|
||||
return await dbContext.IdentityUsers
|
||||
.Where(x => x.TenantId == tenantId)
|
||||
.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按租户与关键字搜索后台用户(只读)。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="keyword">关键字(账号/名称)。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>后台用户列表。</returns>
|
||||
public async Task<IReadOnlyList<IdentityUser>> SearchAsync(long tenantId, string? keyword, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 构建基础查询
|
||||
var query = dbContext.IdentityUsers
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId);
|
||||
|
||||
// 2. 关键字过滤
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
var normalized = keyword.Trim();
|
||||
query = query.Where(x => x.Account.Contains(normalized) || x.DisplayName.Contains(normalized));
|
||||
}
|
||||
|
||||
// 3. 返回列表
|
||||
return await query.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询后台用户列表。
|
||||
/// </summary>
|
||||
/// <param name="filter">查询过滤条件。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>分页结果。</returns>
|
||||
public async Task<(IReadOnlyList<IdentityUser> Items, int Total)> SearchPagedAsync(
|
||||
IdentityUserSearchFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!filter.TenantId.HasValue || filter.TenantId.Value <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("TenantId 不能为空且必须大于 0");
|
||||
}
|
||||
|
||||
var tenantId = filter.TenantId.Value;
|
||||
|
||||
using var disableSoftDeleteScope = filter.IncludeDeleted ? dbContext.DisableSoftDeleteFilter() : null;
|
||||
|
||||
// 1. 构建基础查询
|
||||
var query = dbContext.IdentityUsers.AsNoTracking();
|
||||
|
||||
// 2. 租户过滤(强制)
|
||||
query = query.Where(x => x.TenantId == tenantId);
|
||||
|
||||
// 3. 关键字筛选
|
||||
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
||||
{
|
||||
var normalized = filter.Keyword.Trim();
|
||||
var likeValue = $"%{normalized}%";
|
||||
query = query.Where(x =>
|
||||
EF.Functions.ILike(x.Account, likeValue)
|
||||
|| EF.Functions.ILike(x.DisplayName, likeValue)
|
||||
|| (x.Phone != null && EF.Functions.ILike(x.Phone, likeValue))
|
||||
|| (x.Email != null && EF.Functions.ILike(x.Email, likeValue)));
|
||||
}
|
||||
|
||||
// 4. 状态过滤
|
||||
if (filter.Status.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.Status == filter.Status.Value);
|
||||
}
|
||||
|
||||
// 5. 角色过滤
|
||||
if (filter.RoleId.HasValue)
|
||||
{
|
||||
var roleId = filter.RoleId.Value;
|
||||
var userRoles = dbContext.UserRoles.AsNoTracking();
|
||||
|
||||
userRoles = userRoles.Where(x => x.TenantId == tenantId);
|
||||
|
||||
query = query.Where(user => userRoles.Any(role => role.UserId == user.Id && role.RoleId == roleId));
|
||||
}
|
||||
|
||||
// 6. 时间范围过滤
|
||||
if (filter.CreatedAtFrom.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.CreatedAt >= filter.CreatedAtFrom.Value);
|
||||
}
|
||||
|
||||
if (filter.CreatedAtTo.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.CreatedAt <= filter.CreatedAtTo.Value);
|
||||
}
|
||||
|
||||
if (filter.LastLoginFrom.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.LastLoginAt >= filter.LastLoginFrom.Value);
|
||||
}
|
||||
|
||||
if (filter.LastLoginTo.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.LastLoginAt <= filter.LastLoginTo.Value);
|
||||
}
|
||||
|
||||
// 7. 排序
|
||||
var sorted = filter.SortBy?.ToLowerInvariant() switch
|
||||
{
|
||||
"account" => filter.SortDescending
|
||||
? query.OrderByDescending(x => x.Account)
|
||||
: query.OrderBy(x => x.Account),
|
||||
"displayname" => filter.SortDescending
|
||||
? query.OrderByDescending(x => x.DisplayName)
|
||||
: query.OrderBy(x => x.DisplayName),
|
||||
"status" => filter.SortDescending
|
||||
? query.OrderByDescending(x => x.Status)
|
||||
: query.OrderBy(x => x.Status),
|
||||
"lastloginat" => filter.SortDescending
|
||||
? query.OrderByDescending(x => x.LastLoginAt)
|
||||
: query.OrderBy(x => x.LastLoginAt),
|
||||
_ => filter.SortDescending
|
||||
? query.OrderByDescending(x => x.CreatedAt)
|
||||
: query.OrderBy(x => x.CreatedAt)
|
||||
};
|
||||
|
||||
// 8. 分页
|
||||
var page = filter.Page <= 0 ? 1 : filter.Page;
|
||||
var pageSize = filter.PageSize <= 0 ? 20 : filter.PageSize;
|
||||
var total = await sorted.CountAsync(cancellationToken);
|
||||
var items = await sorted
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return (items, total);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据 ID 集合批量获取后台用户(只读)。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="userIds">用户 ID 集合。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>后台用户列表。</returns>
|
||||
public async Task<IReadOnlyList<IdentityUser>> GetByIdsAsync(long tenantId, IEnumerable<long> userIds, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await dbContext.IdentityUsers.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && userIds.Contains(x.Id))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量获取后台用户(可用于更新,支持包含已删除数据)。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="userIds">用户 ID 集合。</param>
|
||||
/// <param name="includeDeleted">是否包含已删除数据。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>后台用户列表。</returns>
|
||||
public async Task<IReadOnlyList<IdentityUser>> GetForUpdateByIdsAsync(
|
||||
long tenantId,
|
||||
IEnumerable<long> userIds,
|
||||
bool includeDeleted,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 构建基础查询
|
||||
var ids = userIds.Distinct().ToArray();
|
||||
if (ids.Length == 0)
|
||||
{
|
||||
return Array.Empty<IdentityUser>();
|
||||
}
|
||||
|
||||
var query = dbContext.IdentityUsers.Where(x => ids.Contains(x.Id));
|
||||
using var disableSoftDeleteScope = includeDeleted ? dbContext.DisableSoftDeleteFilter() : null;
|
||||
|
||||
query = query.Where(x => x.TenantId == tenantId);
|
||||
|
||||
// 2. 返回列表
|
||||
return await query.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增后台用户。
|
||||
/// </summary>
|
||||
/// <param name="user">后台用户实体。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public Task AddAsync(IdentityUser user, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 添加实体
|
||||
dbContext.IdentityUsers.Add(user);
|
||||
// 2. 返回完成任务
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除后台用户(软删除)。
|
||||
/// </summary>
|
||||
/// <param name="user">后台用户实体。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public Task RemoveAsync(IdentityUser user, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 标记删除
|
||||
dbContext.IdentityUsers.Remove(user);
|
||||
// 2. 返回完成任务
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 持久化仓储变更。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>保存任务。</returns>
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
=> dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core 小程序用户仓储实现。
|
||||
/// </summary>
|
||||
public sealed class EfMiniUserRepository(IdentityDbContext dbContext) : IMiniUserRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// 根据 OpenId 获取小程序用户。
|
||||
/// </summary>
|
||||
/// <param name="openId">微信 OpenId。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>匹配的小程序用户或 null。</returns>
|
||||
public Task<MiniUser?> FindByOpenIdAsync(string openId, CancellationToken cancellationToken = default)
|
||||
=> dbContext.MiniUsers.AsNoTracking().FirstOrDefaultAsync(x => x.OpenId == openId, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 根据用户 ID 获取小程序用户。
|
||||
/// </summary>
|
||||
/// <param name="id">用户 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>匹配的小程序用户或 null。</returns>
|
||||
public Task<MiniUser?> FindByIdAsync(long id, CancellationToken cancellationToken = default)
|
||||
=> dbContext.MiniUsers.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 创建或更新小程序用户信息。
|
||||
/// </summary>
|
||||
/// <param name="openId">微信 OpenId。</param>
|
||||
/// <param name="unionId">微信 UnionId。</param>
|
||||
/// <param name="nickname">昵称。</param>
|
||||
/// <param name="avatar">头像地址。</param>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>创建或更新后的小程序用户。</returns>
|
||||
public async Task<MiniUser> CreateOrUpdateAsync(string openId, string? unionId, string? nickname, string? avatar, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 查询现有用户
|
||||
var user = await dbContext.MiniUsers.FirstOrDefaultAsync(x => x.OpenId == openId, cancellationToken);
|
||||
if (user == null)
|
||||
{
|
||||
// 2. 未找到则创建
|
||||
user = new MiniUser
|
||||
{
|
||||
Id = 0,
|
||||
OpenId = openId,
|
||||
UnionId = unionId,
|
||||
Nickname = nickname ?? "小程序用户",
|
||||
Avatar = avatar,
|
||||
TenantId = tenantId
|
||||
};
|
||||
dbContext.MiniUsers.Add(user);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 3. 已存在则更新可变字段
|
||||
user.UnionId = unionId ?? user.UnionId;
|
||||
user.Nickname = nickname ?? user.Nickname;
|
||||
user.Avatar = avatar ?? user.Avatar;
|
||||
}
|
||||
|
||||
// 4. 保存更改
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF 权限仓储。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 权限是系统级数据,使用 IgnoreQueryFilters 忽略多租户过滤。
|
||||
/// </remarks>
|
||||
public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermissionRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// 根据权限 ID 获取权限。
|
||||
/// </summary>
|
||||
/// <param name="permissionId">权限 ID。</param>
|
||||
/// <param name="tenantId">租户 ID(保留参数,实际不使用)。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>权限实体或 null。</returns>
|
||||
public Task<Permission?> FindByIdAsync(long permissionId, long tenantId, CancellationToken cancellationToken = default)
|
||||
=> dbContext.Permissions
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == permissionId && x.DeletedAt == null, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 根据权限编码获取权限。
|
||||
/// </summary>
|
||||
/// <param name="code">权限编码。</param>
|
||||
/// <param name="tenantId">租户 ID(保留参数,实际不使用)。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>权限实体或 null。</returns>
|
||||
public Task<Permission?> FindByCodeAsync(string code, long tenantId, CancellationToken cancellationToken = default)
|
||||
=> dbContext.Permissions
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Code == code && x.DeletedAt == null, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 根据权限编码集合批量获取权限。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID(保留参数,实际不使用)。</param>
|
||||
/// <param name="codes">权限编码集合。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>权限列表。</returns>
|
||||
public Task<IReadOnlyList<Permission>> GetByCodesAsync(long tenantId, IEnumerable<string> codes, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 规范化编码集合
|
||||
var normalizedCodes = codes
|
||||
.Where(code => !string.IsNullOrWhiteSpace(code))
|
||||
.Select(code => code.Trim())
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
|
||||
// 2. 读取权限(忽略租户过滤)
|
||||
return dbContext.Permissions
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && normalizedCodes.Contains(x.Code))
|
||||
.ToListAsync(cancellationToken)
|
||||
.ContinueWith(t => (IReadOnlyList<Permission>)t.Result, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据权限 ID 集合批量获取权限。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID(保留参数,实际不使用)。</param>
|
||||
/// <param name="permissionIds">权限 ID 集合。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>权限列表。</returns>
|
||||
public Task<IReadOnlyList<Permission>> GetByIdsAsync(long tenantId, IEnumerable<long> permissionIds, CancellationToken cancellationToken = default)
|
||||
=> dbContext.Permissions
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && permissionIds.Contains(x.Id))
|
||||
.ToListAsync(cancellationToken)
|
||||
.ContinueWith(t => (IReadOnlyList<Permission>)t.Result, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 按关键字搜索权限。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID(保留参数,实际不使用)。</param>
|
||||
/// <param name="keyword">搜索关键字。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>权限列表。</returns>
|
||||
public Task<IReadOnlyList<Permission>> SearchAsync(long tenantId, string? keyword, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 构建基础查询(忽略租户过滤)
|
||||
var query = dbContext.Permissions
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null);
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
// 2. 追加关键字过滤
|
||||
var normalized = keyword.Trim();
|
||||
query = query.Where(x => x.Name.Contains(normalized) || x.Code.Contains(normalized));
|
||||
}
|
||||
|
||||
// 3. 返回列表
|
||||
return query.ToListAsync(cancellationToken)
|
||||
.ContinueWith(t => (IReadOnlyList<Permission>)t.Result, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增权限。
|
||||
/// </summary>
|
||||
/// <param name="permission">权限实体。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public Task AddAsync(Permission permission, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 添加实体
|
||||
dbContext.Permissions.Add(permission);
|
||||
// 2. 返回完成任务
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新权限。
|
||||
/// </summary>
|
||||
/// <param name="permission">权限实体。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public Task UpdateAsync(Permission permission, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 标记实体更新
|
||||
dbContext.Permissions.Update(permission);
|
||||
// 2. 返回完成任务
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除指定权限。
|
||||
/// </summary>
|
||||
/// <param name="permissionId">权限 ID。</param>
|
||||
/// <param name="tenantId">租户 ID(保留参数,实际不使用)。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public async Task DeleteAsync(long permissionId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 查询目标权限
|
||||
var entity = await dbContext.Permissions
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(x => x.Id == permissionId, cancellationToken);
|
||||
if (entity != null)
|
||||
{
|
||||
// 2. 删除实体
|
||||
dbContext.Permissions.Remove(entity);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存仓储变更。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>保存任务。</returns>
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
=> dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF 角色-权限仓储。
|
||||
/// </summary>
|
||||
public sealed class EfRolePermissionRepository(IdentityDbContext dbContext) : IRolePermissionRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// 根据角色 ID 集合获取角色权限映射。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="roleIds">角色 ID 集合。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>角色权限映射列表。</returns>
|
||||
public async Task<IReadOnlyList<RolePermission>> GetByRoleIdsAsync(long tenantId, IEnumerable<long> roleIds, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 查询角色权限映射
|
||||
var mappings = await dbContext.RolePermissions
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.DeletedAt == null && roleIds.Contains(x.RoleId))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// 2. (空行后) 返回只读列表
|
||||
return mappings;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量新增角色权限。
|
||||
/// </summary>
|
||||
/// <param name="rolePermissions">角色权限集合。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public async Task AddRangeAsync(IEnumerable<RolePermission> rolePermissions, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 转为数组便于计数
|
||||
var toAdd = rolePermissions as RolePermission[] ?? rolePermissions.ToArray();
|
||||
if (toAdd.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 批量插入
|
||||
await dbContext.RolePermissions.AddRangeAsync(toAdd, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 替换指定角色的权限集合。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="roleId">角色 ID。</param>
|
||||
/// <param name="permissionIds">权限 ID 集合。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public async Task ReplaceRolePermissionsAsync(long tenantId, long roleId, IEnumerable<long> permissionIds, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 使用执行策略保证可靠性
|
||||
var strategy = dbContext.Database.CreateExecutionStrategy();
|
||||
await strategy.ExecuteAsync(async () =>
|
||||
{
|
||||
await using var trx = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
// 1. 删除旧记录(原生 SQL,避免跟踪干扰)
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"DELETE FROM \"role_permissions\" WHERE \"TenantId\" = {0} AND \"RoleId\" = {1};",
|
||||
parameters: new object[] { tenantId, roleId },
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
// 2. 插入新记录(防重复)
|
||||
foreach (var permissionId in permissionIds.Distinct())
|
||||
{
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"INSERT INTO \"role_permissions\" (\"TenantId\",\"RoleId\",\"PermissionId\",\"CreatedAt\",\"CreatedBy\",\"UpdatedAt\",\"UpdatedBy\",\"DeletedAt\",\"DeletedBy\") VALUES ({0},{1},{2},NOW(),NULL,NULL,NULL,NULL,NULL) ON CONFLICT DO NOTHING;",
|
||||
parameters: new object[] { tenantId, roleId, permissionId },
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
await trx.CommitAsync(cancellationToken);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存仓储变更。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>保存任务。</returns>
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
=> dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF 角色仓储。
|
||||
/// </summary>
|
||||
public sealed class EfRoleRepository(IdentityDbContext dbContext) : IRoleRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// 根据角色 ID 获取角色。
|
||||
/// </summary>
|
||||
/// <param name="roleId">角色 ID。</param>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>角色实体或 null。</returns>
|
||||
public Task<Role?> FindByIdAsync(long roleId, long tenantId, CancellationToken cancellationToken = default)
|
||||
=> dbContext.Roles
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == roleId && x.TenantId == tenantId && x.DeletedAt == null, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 根据角色编码获取角色。
|
||||
/// </summary>
|
||||
/// <param name="code">角色编码。</param>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>角色实体或 null。</returns>
|
||||
public Task<Role?> FindByCodeAsync(string code, long tenantId, CancellationToken cancellationToken = default)
|
||||
=> dbContext.Roles
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Code == code && x.TenantId == tenantId && x.DeletedAt == null, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 根据角色 ID 集合获取角色列表。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="roleIds">角色 ID 集合。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>角色列表。</returns>
|
||||
public async Task<IReadOnlyList<Role>> GetByIdsAsync(long tenantId, IEnumerable<long> roleIds, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 查询角色列表
|
||||
var roles = await dbContext.Roles
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && roleIds.Contains(x.Id) && x.DeletedAt == null)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// 2. (空行后) 返回只读列表
|
||||
return roles;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按关键字搜索角色。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="keyword">搜索关键字。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>角色列表。</returns>
|
||||
public async Task<IReadOnlyList<Role>> SearchAsync(long tenantId, string? keyword, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 构建基础查询
|
||||
var query = dbContext.Roles
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.DeletedAt == null);
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
// 2. 追加关键字过滤
|
||||
var normalized = keyword.Trim();
|
||||
query = query.Where(x => x.Name.Contains(normalized) || x.Code.Contains(normalized));
|
||||
}
|
||||
|
||||
// 3. 返回列表
|
||||
var roles = await query.ToListAsync(cancellationToken);
|
||||
|
||||
// 4. (空行后) 返回只读列表
|
||||
return roles;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增角色。
|
||||
/// </summary>
|
||||
/// <param name="role">角色实体。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public Task AddAsync(Role role, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 添加实体
|
||||
dbContext.Roles.Add(role);
|
||||
// 2. 返回完成任务
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新角色。
|
||||
/// </summary>
|
||||
/// <param name="role">角色实体。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public Task UpdateAsync(Role role, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 标记更新
|
||||
dbContext.Roles.Update(role);
|
||||
// 2. 返回完成任务
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 软删除角色。
|
||||
/// </summary>
|
||||
/// <param name="roleId">角色 ID。</param>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public async Task DeleteAsync(long roleId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 查询目标角色
|
||||
var entity = await dbContext.Roles.FirstOrDefaultAsync(x => x.Id == roleId && x.TenantId == tenantId, cancellationToken);
|
||||
if (entity != null)
|
||||
{
|
||||
// 2. 标记删除时间
|
||||
entity.DeletedAt = DateTime.UtcNow;
|
||||
dbContext.Roles.Update(entity);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存仓储变更。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>保存任务。</returns>
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
=> dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// 角色模板仓储实现。
|
||||
/// </summary>
|
||||
public sealed class EfRoleTemplateRepository(IdentityDbContext dbContext) : IRoleTemplateRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取全部角色模板,可选按启用状态过滤。
|
||||
/// </summary>
|
||||
/// <param name="isActive">是否启用过滤。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>角色模板列表。</returns>
|
||||
public Task<IReadOnlyList<RoleTemplate>> GetAllAsync(bool? isActive, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 构建基础查询
|
||||
var query = dbContext.RoleTemplates.AsNoTracking();
|
||||
if (isActive.HasValue)
|
||||
{
|
||||
// 2. 按启用状态过滤
|
||||
query = query.Where(x => x.IsActive == isActive.Value);
|
||||
}
|
||||
|
||||
// 3. 排序并返回
|
||||
return query
|
||||
.OrderBy(x => x.TemplateCode)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ContinueWith(t => (IReadOnlyList<RoleTemplate>)t.Result, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据模板编码获取角色模板。
|
||||
/// </summary>
|
||||
/// <param name="templateCode">模板编码。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>角色模板或 null。</returns>
|
||||
public Task<RoleTemplate?> FindByCodeAsync(string templateCode, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 规范化编码
|
||||
var normalized = templateCode.Trim();
|
||||
// 2. 查询模板
|
||||
return dbContext.RoleTemplates.AsNoTracking().FirstOrDefaultAsync(x => x.TemplateCode == normalized, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定模板的权限集合。
|
||||
/// </summary>
|
||||
/// <param name="roleTemplateId">模板 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>模板权限列表。</returns>
|
||||
public Task<IReadOnlyList<RoleTemplatePermission>> GetPermissionsAsync(long roleTemplateId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 查询模板权限
|
||||
return dbContext.RoleTemplatePermissions.AsNoTracking()
|
||||
.Where(x => x.RoleTemplateId == roleTemplateId)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ContinueWith(t => (IReadOnlyList<RoleTemplatePermission>)t.Result, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取多个模板的权限集合。
|
||||
/// </summary>
|
||||
/// <param name="roleTemplateIds">模板 ID 集合。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>模板到权限的字典。</returns>
|
||||
public async Task<IDictionary<long, IReadOnlyList<RoleTemplatePermission>>> GetPermissionsAsync(IEnumerable<long> roleTemplateIds, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 去重 ID
|
||||
var ids = roleTemplateIds.Distinct().ToArray();
|
||||
if (ids.Length == 0)
|
||||
{
|
||||
return new Dictionary<long, IReadOnlyList<RoleTemplatePermission>>();
|
||||
}
|
||||
|
||||
// 2. 批量查询权限
|
||||
var permissions = await dbContext.RoleTemplatePermissions.AsNoTracking()
|
||||
.Where(x => ids.Contains(x.RoleTemplateId))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// 3. 组装字典
|
||||
return permissions
|
||||
.GroupBy(x => x.RoleTemplateId)
|
||||
.ToDictionary(g => g.Key, g => (IReadOnlyList<RoleTemplatePermission>)g.ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增角色模板并配置权限。
|
||||
/// </summary>
|
||||
/// <param name="template">角色模板实体。</param>
|
||||
/// <param name="permissionCodes">权限编码集合。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public async Task AddAsync(RoleTemplate template, IEnumerable<string> permissionCodes, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 规范化模板字段
|
||||
template.TemplateCode = template.TemplateCode.Trim();
|
||||
template.Name = template.Name.Trim();
|
||||
// 2. 保存模板
|
||||
await dbContext.RoleTemplates.AddAsync(template, cancellationToken);
|
||||
// 3. 替换权限
|
||||
await ReplacePermissionsInternalAsync(template, permissionCodes, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新角色模板并重置权限。
|
||||
/// </summary>
|
||||
/// <param name="template">角色模板实体。</param>
|
||||
/// <param name="permissionCodes">权限编码集合。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public async Task UpdateAsync(RoleTemplate template, IEnumerable<string> permissionCodes, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 规范化模板字段
|
||||
template.TemplateCode = template.TemplateCode.Trim();
|
||||
template.Name = template.Name.Trim();
|
||||
// 2. 更新模板
|
||||
dbContext.RoleTemplates.Update(template);
|
||||
// 3. 重置权限
|
||||
await ReplacePermissionsInternalAsync(template, permissionCodes, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除角色模板及其权限。
|
||||
/// </summary>
|
||||
/// <param name="roleTemplateId">模板 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public async Task DeleteAsync(long roleTemplateId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 查询模板
|
||||
var entity = await dbContext.RoleTemplates.FirstOrDefaultAsync(x => x.Id == roleTemplateId, cancellationToken);
|
||||
if (entity != null)
|
||||
{
|
||||
// 2. 删除关联权限
|
||||
var permissions = dbContext.RoleTemplatePermissions.Where(x => x.RoleTemplateId == roleTemplateId);
|
||||
dbContext.RoleTemplatePermissions.RemoveRange(permissions);
|
||||
// 3. 删除模板
|
||||
dbContext.RoleTemplates.Remove(entity);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存仓储变更。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>保存任务。</returns>
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
=> dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
private async Task ReplacePermissionsInternalAsync(RoleTemplate template, IEnumerable<string> permissionCodes, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 使用执行策略保证一致性
|
||||
var strategy = dbContext.Database.CreateExecutionStrategy();
|
||||
await strategy.ExecuteAsync(async () =>
|
||||
{
|
||||
await using var trx = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
// 1. 确保模板已持久化,便于 FK 正确填充
|
||||
if (!dbContext.Entry(template).IsKeySet || template.Id == 0)
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
// 2. 归一化权限编码
|
||||
var normalized = permissionCodes
|
||||
.Where(code => !string.IsNullOrWhiteSpace(code))
|
||||
.Select(code => code.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
// 3. 清空旧权限(原生 SQL 避免跟踪干扰)
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"DELETE FROM \"role_template_permissions\" WHERE \"RoleTemplateId\" = {0};",
|
||||
parameters: new object[] { template.Id },
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
// 4. 插入新权限(ON CONFLICT DO NOTHING 防御重复)
|
||||
foreach (var code in normalized)
|
||||
{
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"INSERT INTO \"role_template_permissions\" (\"RoleTemplateId\",\"PermissionCode\",\"CreatedAt\",\"CreatedBy\",\"UpdatedAt\",\"UpdatedBy\",\"DeletedAt\",\"DeletedBy\") VALUES ({0},{1},NOW(),NULL,NULL,NULL,NULL,NULL) ON CONFLICT DO NOTHING;",
|
||||
parameters: new object[] { template.Id, code },
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
await trx.CommitAsync(cancellationToken);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF 用户-角色仓储。
|
||||
/// </summary>
|
||||
public sealed class EfUserRoleRepository(IdentityDbContext dbContext) : IUserRoleRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// 根据用户 ID 集合获取用户角色映射。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="userIds">用户 ID 集合。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>用户角色映射列表。</returns>
|
||||
public async Task<IReadOnlyList<UserRole>> GetByUserIdsAsync(long tenantId, IEnumerable<long> userIds, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 查询用户角色映射
|
||||
var mappings = await dbContext.UserRoles
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.DeletedAt == null && userIds.Contains(x.UserId))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// 2. (空行后) 返回只读列表
|
||||
return mappings;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定用户的角色集合。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="userId">用户 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>用户角色列表。</returns>
|
||||
public async Task<IReadOnlyList<UserRole>> GetByUserIdAsync(long tenantId, long userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 查询用户角色映射
|
||||
var mappings = await dbContext.UserRoles
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.DeletedAt == null && x.UserId == userId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// 2. (空行后) 返回只读列表
|
||||
return mappings;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 替换指定用户的角色集合。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="userId">用户 ID。</param>
|
||||
/// <param name="roleIds">角色 ID 集合。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public async Task ReplaceUserRolesAsync(long tenantId, long userId, IEnumerable<long> roleIds, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 使用执行策略保障一致性
|
||||
var strategy = dbContext.Database.CreateExecutionStrategy();
|
||||
await strategy.ExecuteAsync(async () =>
|
||||
{
|
||||
await using var trx = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
// 2. 读取当前角色映射
|
||||
using var disableSoftDeleteScope = dbContext.DisableSoftDeleteFilter();
|
||||
var existing = await dbContext.UserRoles
|
||||
.Where(x => x.TenantId == tenantId && x.UserId == userId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// 3. 去重并构建目标集合
|
||||
var targetRoleIds = roleIds.Distinct().ToArray();
|
||||
var targetRoleSet = targetRoleIds.ToHashSet();
|
||||
var existingRoleMap = existing.ToDictionary(x => x.RoleId);
|
||||
|
||||
// 4. 同步现有映射状态(软删除或恢复)
|
||||
foreach (var mapping in existing)
|
||||
{
|
||||
if (targetRoleSet.Contains(mapping.RoleId))
|
||||
{
|
||||
if (mapping.DeletedAt.HasValue)
|
||||
{
|
||||
mapping.DeletedAt = null;
|
||||
mapping.DeletedBy = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!mapping.DeletedAt.HasValue)
|
||||
{
|
||||
dbContext.UserRoles.Remove(mapping);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 补齐新增角色映射
|
||||
var toAdd = targetRoleIds
|
||||
.Where(roleId => !existingRoleMap.ContainsKey(roleId))
|
||||
.Select(roleId => new UserRole
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
RoleId = roleId
|
||||
});
|
||||
|
||||
await dbContext.UserRoles.AddRangeAsync(toAdd, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await trx.CommitAsync(cancellationToken);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 统计指定角色下的用户数量。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="roleId">角色 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>用户数量。</returns>
|
||||
public Task<int> CountUsersByRoleAsync(long tenantId, long roleId, CancellationToken cancellationToken = default)
|
||||
=> dbContext.UserRoles
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.DeletedAt == null && x.RoleId == roleId)
|
||||
.CountAsync(cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 保存仓储变更。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>保存任务。</returns>
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
=> dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Options;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
using DomainIdentityUser = TakeoutSaaS.Domain.Identity.Entities.IdentityUser;
|
||||
using DomainPermission = TakeoutSaaS.Domain.Identity.Entities.Permission;
|
||||
using DomainRole = TakeoutSaaS.Domain.Identity.Entities.Role;
|
||||
using DomainRolePermission = TakeoutSaaS.Domain.Identity.Entities.RolePermission;
|
||||
using DomainRoleTemplate = TakeoutSaaS.Domain.Identity.Entities.RoleTemplate;
|
||||
using DomainRoleTemplatePermission = TakeoutSaaS.Domain.Identity.Entities.RoleTemplatePermission;
|
||||
using DomainUserRole = TakeoutSaaS.Domain.Identity.Entities.UserRole;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// 后台账号初始化种子任务
|
||||
/// </summary>
|
||||
public sealed class IdentityDataSeeder(IServiceProvider serviceProvider, ILogger<IdentityDataSeeder> logger) : IHostedService
|
||||
{
|
||||
/// <summary>
|
||||
/// 执行后台账号与权限种子。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 创建作用域并解析依赖
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var context = scope.ServiceProvider.GetRequiredService<IdentityDbContext>();
|
||||
var options = scope.ServiceProvider.GetRequiredService<IOptions<AdminSeedOptions>>().Value;
|
||||
var passwordHasher = scope.ServiceProvider.GetRequiredService<IPasswordHasher<DomainIdentityUser>>();
|
||||
var tenantContextAccessor = scope.ServiceProvider.GetRequiredService<ITenantContextAccessor>();
|
||||
|
||||
// 2. 校验功能开关
|
||||
if (!options.Enabled)
|
||||
{
|
||||
logger.LogInformation("AdminSeed 已禁用,跳过后台账号初始化");
|
||||
return;
|
||||
}
|
||||
// 3. 确保数据库已迁移
|
||||
await context.Database.MigrateAsync(cancellationToken);
|
||||
|
||||
// 4. 校验账号配置
|
||||
if (options.Users is null or { Count: 0 })
|
||||
{
|
||||
logger.LogInformation("AdminSeed 未配置账号,跳过后台账号初始化");
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. 写入角色模板
|
||||
await SeedRoleTemplatesAsync(context, options.RoleTemplates, cancellationToken);
|
||||
|
||||
// 6. 逐个账号处理
|
||||
foreach (var userOptions in options.Users)
|
||||
{
|
||||
// 6.1 进入租户作用域
|
||||
using var tenantScope = tenantContextAccessor.EnterTenantScope(userOptions.TenantId, "admin-seed");
|
||||
// 6.2 查询账号并收集配置
|
||||
var user = await context.IdentityUsers.FirstOrDefaultAsync(x => x.Account == userOptions.Account, cancellationToken);
|
||||
var roles = NormalizeValues(userOptions.Roles);
|
||||
var permissions = NormalizeValues(userOptions.Permissions);
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
// 6.3 创建新账号
|
||||
user = new DomainIdentityUser
|
||||
{
|
||||
Id = 0,
|
||||
Account = userOptions.Account,
|
||||
DisplayName = userOptions.DisplayName,
|
||||
TenantId = userOptions.TenantId,
|
||||
MerchantId = userOptions.MerchantId,
|
||||
Avatar = null
|
||||
};
|
||||
user.PasswordHash = passwordHasher.HashPassword(user, userOptions.Password);
|
||||
context.IdentityUsers.Add(user);
|
||||
logger.LogInformation("已创建后台账号 {Account}", user.Account);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 6.4 更新既有账号
|
||||
user.DisplayName = userOptions.DisplayName;
|
||||
user.TenantId = userOptions.TenantId;
|
||||
user.MerchantId = userOptions.MerchantId;
|
||||
user.PasswordHash = passwordHasher.HashPassword(user, userOptions.Password);
|
||||
logger.LogInformation("已更新后台账号 {Account}", user.Account);
|
||||
}
|
||||
|
||||
// 6.5 确保角色存在
|
||||
var existingRoles = await context.Roles
|
||||
.Where(r => r.TenantId == userOptions.TenantId && roles.Contains(r.Code))
|
||||
.ToListAsync(cancellationToken);
|
||||
var existingRoleCodes = existingRoles.Select(r => r.Code).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var code in roles)
|
||||
{
|
||||
if (existingRoleCodes.Contains(code))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
context.Roles.Add(new DomainRole
|
||||
{
|
||||
TenantId = userOptions.TenantId,
|
||||
Code = code,
|
||||
Name = code,
|
||||
Description = $"Seed role {code}"
|
||||
});
|
||||
}
|
||||
|
||||
// 6.6 读取当前租户权限定义
|
||||
var existingPermissions = await context.Permissions
|
||||
.AsNoTracking()
|
||||
.Where(p => permissions.Contains(p.Code))
|
||||
.ToListAsync(cancellationToken);
|
||||
var existingPermissionCodes = existingPermissions
|
||||
.Select(p => p.Code)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var missingPermissionCodes = permissions
|
||||
.Where(code => !existingPermissionCodes.Contains(code))
|
||||
.ToArray();
|
||||
if (missingPermissionCodes.Length > 0)
|
||||
{
|
||||
logger.LogWarning("发现未配置的全局权限编码,已忽略:{Codes}", string.Join(", ", missingPermissionCodes));
|
||||
}
|
||||
|
||||
// 6.7 保存基础角色/权限
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 6.8 重新加载角色/权限以获取 Id
|
||||
var roleEntities = await context.Roles
|
||||
.Where(r => r.TenantId == userOptions.TenantId && roles.Contains(r.Code))
|
||||
.ToListAsync(cancellationToken);
|
||||
var permissionEntities = existingPermissions;
|
||||
|
||||
// 6.9 重置用户角色
|
||||
var existingUserRoles = await context.UserRoles
|
||||
.Where(ur => ur.TenantId == userOptions.TenantId && ur.UserId == user.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
context.UserRoles.RemoveRange(existingUserRoles);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var roleIds = roleEntities.Select(r => r.Id).Distinct().ToArray();
|
||||
foreach (var roleId in roleIds)
|
||||
{
|
||||
try
|
||||
{
|
||||
var alreadyExists = await context.UserRoles.AnyAsync(
|
||||
ur => ur.TenantId == userOptions.TenantId && ur.UserId == user.Id && ur.RoleId == roleId,
|
||||
cancellationToken);
|
||||
if (alreadyExists)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
await context.UserRoles.AddAsync(new DomainUserRole
|
||||
{
|
||||
TenantId = userOptions.TenantId,
|
||||
UserId = user.Id,
|
||||
RoleId = roleId
|
||||
}, cancellationToken);
|
||||
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException ex) when (ex.InnerException is PostgresException pg && pg.SqlState == PostgresErrorCodes.UniqueViolation)
|
||||
{
|
||||
context.ChangeTracker.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
// 为种子角色绑定种子权限
|
||||
if (permissions.Length > 0 && roleIds.Length > 0)
|
||||
{
|
||||
var permissionIds = permissionEntities.Select(p => p.Id).Distinct().ToArray();
|
||||
var existingRolePermissions = await context.RolePermissions
|
||||
.Where(rp => rp.TenantId == userOptions.TenantId && roleIds.Contains(rp.RoleId))
|
||||
.ToListAsync(cancellationToken);
|
||||
context.RolePermissions.RemoveRange(existingRolePermissions);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var distinctRoleIds = roleIds.Distinct().ToArray();
|
||||
var distinctPermissionIds = permissionIds.Distinct().ToArray();
|
||||
foreach (var roleId in distinctRoleIds)
|
||||
{
|
||||
foreach (var permissionId in distinctPermissionIds)
|
||||
{
|
||||
try
|
||||
{
|
||||
var exists = await context.RolePermissions.AnyAsync(
|
||||
rp => rp.TenantId == userOptions.TenantId
|
||||
&& rp.RoleId == roleId
|
||||
&& rp.PermissionId == permissionId,
|
||||
cancellationToken);
|
||||
if (exists)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// 6.10 绑定角色与权限
|
||||
await context.RolePermissions.AddAsync(new DomainRolePermission
|
||||
{
|
||||
TenantId = userOptions.TenantId,
|
||||
RoleId = roleId,
|
||||
PermissionId = permissionId
|
||||
}, cancellationToken);
|
||||
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException ex) when (ex.InnerException is PostgresException pg && pg.SqlState == PostgresErrorCodes.UniqueViolation)
|
||||
{
|
||||
context.ChangeTracker.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 7. 最终保存
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 停止生命周期时的清理(此处无需处理)。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>已完成任务。</returns>
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
private static async Task SeedRoleTemplatesAsync(
|
||||
IdentityDbContext context,
|
||||
IList<RoleTemplateSeedOptions> templates,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 空集合直接返回
|
||||
if (templates is null || templates.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 逐个处理模板
|
||||
foreach (var templateOptions in templates)
|
||||
{
|
||||
// 2.1 校验必填字段
|
||||
if (string.IsNullOrWhiteSpace(templateOptions.TemplateCode) || string.IsNullOrWhiteSpace(templateOptions.Name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2.2 查询现有模板
|
||||
var code = templateOptions.TemplateCode.Trim();
|
||||
var existing = await context.RoleTemplates.FirstOrDefaultAsync(x => x.TemplateCode == code, cancellationToken);
|
||||
|
||||
if (existing == null)
|
||||
{
|
||||
// 2.3 新增模板
|
||||
existing = new DomainRoleTemplate
|
||||
{
|
||||
TemplateCode = code,
|
||||
Name = templateOptions.Name.Trim(),
|
||||
Description = templateOptions.Description,
|
||||
IsActive = templateOptions.IsActive
|
||||
};
|
||||
|
||||
await context.RoleTemplates.AddAsync(existing, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 2.4 更新模板
|
||||
existing.Name = templateOptions.Name.Trim();
|
||||
existing.Description = templateOptions.Description;
|
||||
existing.IsActive = templateOptions.IsActive;
|
||||
context.RoleTemplates.Update(existing);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
// 2.5 重置模板权限
|
||||
var permissionCodes = NormalizeValues(templateOptions.Permissions);
|
||||
var existingPermissions = await context.RoleTemplatePermissions
|
||||
.Where(x => x.RoleTemplateId == existing.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
// 2.6 清空旧权限并保存
|
||||
context.RoleTemplatePermissions.RemoveRange(existingPermissions);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
// 2.7 去重后的权限编码
|
||||
var distinctPermissionCodes = permissionCodes.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
foreach (var permissionCode in distinctPermissionCodes)
|
||||
{
|
||||
try
|
||||
{
|
||||
var alreadyExists = await context.RoleTemplatePermissions.AnyAsync(
|
||||
x => x.RoleTemplateId == existing.Id && x.PermissionCode == permissionCode,
|
||||
cancellationToken);
|
||||
if (alreadyExists)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
await context.RoleTemplatePermissions.AddAsync(new DomainRoleTemplatePermission
|
||||
{
|
||||
RoleTemplateId = existing.Id,
|
||||
PermissionCode = permissionCode
|
||||
}, cancellationToken);
|
||||
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException ex) when (ex.InnerException is PostgresException pg && pg.SqlState == PostgresErrorCodes.UniqueViolation)
|
||||
{
|
||||
context.ChangeTracker.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string[] NormalizeValues(string[]? values)
|
||||
=> values == null
|
||||
? []
|
||||
: [.. values
|
||||
.Where(v => !string.IsNullOrWhiteSpace(v))
|
||||
.Select(v => v.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)];
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
using MassTransit;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
using TakeoutSaaS.Infrastructure.Common.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Ids;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// 身份认证 DbContext,带多租户过滤与审计字段处理。
|
||||
/// </summary>
|
||||
public sealed class IdentityDbContext(
|
||||
DbContextOptions<IdentityDbContext> options,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor? currentUserAccessor = null,
|
||||
IIdGenerator? idGenerator = null)
|
||||
: TenantAwareDbContext(options, tenantProvider, currentUserAccessor, idGenerator)
|
||||
{
|
||||
/// <summary>
|
||||
/// 管理后台用户集合。
|
||||
/// </summary>
|
||||
public DbSet<IdentityUser> IdentityUsers => Set<IdentityUser>();
|
||||
|
||||
/// <summary>
|
||||
/// 小程序用户集合。
|
||||
/// </summary>
|
||||
public DbSet<MiniUser> MiniUsers => Set<MiniUser>();
|
||||
|
||||
/// <summary>
|
||||
/// 角色集合。
|
||||
/// </summary>
|
||||
public DbSet<Role> Roles => Set<Role>();
|
||||
|
||||
/// <summary>
|
||||
/// 角色模板集合(系统级)。
|
||||
/// </summary>
|
||||
public DbSet<RoleTemplate> RoleTemplates => Set<RoleTemplate>();
|
||||
|
||||
/// <summary>
|
||||
/// 角色模板权限集合。
|
||||
/// </summary>
|
||||
public DbSet<RoleTemplatePermission> RoleTemplatePermissions => Set<RoleTemplatePermission>();
|
||||
|
||||
/// <summary>
|
||||
/// 权限集合。
|
||||
/// </summary>
|
||||
public DbSet<Permission> Permissions => Set<Permission>();
|
||||
|
||||
/// <summary>
|
||||
/// 用户-角色关系。
|
||||
/// </summary>
|
||||
public DbSet<UserRole> UserRoles => Set<UserRole>();
|
||||
|
||||
/// <summary>
|
||||
/// 角色-权限关系。
|
||||
/// </summary>
|
||||
public DbSet<RolePermission> RolePermissions => Set<RolePermission>();
|
||||
|
||||
/// <summary>
|
||||
/// 菜单定义集合。
|
||||
/// </summary>
|
||||
public DbSet<MenuDefinition> MenuDefinitions => Set<MenuDefinition>();
|
||||
|
||||
/// <summary>
|
||||
/// 配置实体模型。
|
||||
/// </summary>
|
||||
/// <param name="modelBuilder">模型构建器。</param>
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
ConfigureIdentityUser(modelBuilder.Entity<IdentityUser>());
|
||||
ConfigureMiniUser(modelBuilder.Entity<MiniUser>());
|
||||
ConfigureRole(modelBuilder.Entity<Role>());
|
||||
ConfigureRoleTemplate(modelBuilder.Entity<RoleTemplate>());
|
||||
ConfigureRoleTemplatePermission(modelBuilder.Entity<RoleTemplatePermission>());
|
||||
ConfigurePermission(modelBuilder.Entity<Permission>());
|
||||
ConfigureUserRole(modelBuilder.Entity<UserRole>());
|
||||
ConfigureRolePermission(modelBuilder.Entity<RolePermission>());
|
||||
ConfigureMenuDefinition(modelBuilder.Entity<MenuDefinition>());
|
||||
modelBuilder.AddOutboxMessageEntity();
|
||||
modelBuilder.AddOutboxStateEntity();
|
||||
ApplyTenantQueryFilters(modelBuilder);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 配置管理后台用户实体。
|
||||
/// </summary>
|
||||
/// <param name="builder">实体构建器。</param>
|
||||
private static void ConfigureIdentityUser(EntityTypeBuilder<IdentityUser> builder)
|
||||
{
|
||||
builder.ToTable("identity_users");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.Account).HasMaxLength(64).IsRequired();
|
||||
builder.Property(x => x.DisplayName).HasMaxLength(64).IsRequired();
|
||||
builder.Property(x => x.PasswordHash).HasMaxLength(256).IsRequired();
|
||||
builder.Property(x => x.Phone).HasMaxLength(32);
|
||||
builder.Property(x => x.Email).HasMaxLength(128);
|
||||
builder.Property(x => x.Status).HasConversion<int>();
|
||||
builder.Property(x => x.FailedLoginCount).IsRequired();
|
||||
builder.Property(x => x.LockedUntil);
|
||||
builder.Property(x => x.LastLoginAt);
|
||||
builder.Property(x => x.MustChangePassword).IsRequired();
|
||||
builder.Property(x => x.Avatar).HasColumnType("text");
|
||||
builder.Ignore(x => x.RowVersion);
|
||||
builder.Property<uint>("xmin")
|
||||
.HasColumnName("xmin")
|
||||
.HasColumnType("xid")
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.IsConcurrencyToken();
|
||||
builder.Property(x => x.TenantId).IsRequired();
|
||||
ConfigureAuditableEntity(builder);
|
||||
ConfigureSoftDeleteEntity(builder);
|
||||
|
||||
builder.HasIndex(x => x.TenantId);
|
||||
builder.HasIndex(x => new { x.TenantId, x.Account }).IsUnique();
|
||||
builder.HasIndex(x => new { x.TenantId, x.Phone })
|
||||
.IsUnique()
|
||||
.HasFilter("\"Phone\" IS NOT NULL");
|
||||
builder.HasIndex(x => new { x.TenantId, x.Email })
|
||||
.IsUnique()
|
||||
.HasFilter("\"Email\" IS NOT NULL");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 配置小程序用户实体。
|
||||
/// </summary>
|
||||
/// <param name="builder">实体构建器。</param>
|
||||
private static void ConfigureMiniUser(EntityTypeBuilder<MiniUser> builder)
|
||||
{
|
||||
builder.ToTable("mini_users");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.TenantId).IsRequired();
|
||||
builder.Property(x => x.OpenId).HasMaxLength(128).IsRequired();
|
||||
builder.Property(x => x.UnionId).HasMaxLength(128);
|
||||
builder.Property(x => x.Nickname).HasMaxLength(64).IsRequired();
|
||||
builder.Property(x => x.Avatar).HasColumnType("text");
|
||||
ConfigureAuditableEntity(builder);
|
||||
ConfigureSoftDeleteEntity(builder);
|
||||
|
||||
builder.HasIndex(x => x.TenantId);
|
||||
builder.HasIndex(x => new { x.TenantId, x.OpenId }).IsUnique();
|
||||
}
|
||||
|
||||
private static void ConfigureRole(EntityTypeBuilder<Role> builder)
|
||||
{
|
||||
builder.ToTable("roles");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.TenantId).IsRequired();
|
||||
builder.Property(x => x.Name).HasMaxLength(64).IsRequired();
|
||||
builder.Property(x => x.Code).HasMaxLength(64).IsRequired();
|
||||
builder.Property(x => x.Description).HasMaxLength(256);
|
||||
ConfigureAuditableEntity(builder);
|
||||
ConfigureSoftDeleteEntity(builder);
|
||||
builder.HasIndex(x => x.TenantId);
|
||||
builder.HasIndex(x => new { x.TenantId, x.Code }).IsUnique();
|
||||
}
|
||||
|
||||
private static void ConfigurePermission(EntityTypeBuilder<Permission> builder)
|
||||
{
|
||||
builder.ToTable("permissions");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.ParentId).IsRequired();
|
||||
builder.Property(x => x.SortOrder).IsRequired();
|
||||
builder.Property(x => x.Type).HasMaxLength(16).IsRequired();
|
||||
builder.Property(x => x.Name).HasMaxLength(64).IsRequired();
|
||||
builder.Property(x => x.Code).HasMaxLength(128).IsRequired();
|
||||
builder.Property(x => x.Description).HasMaxLength(256);
|
||||
ConfigureAuditableEntity(builder);
|
||||
ConfigureSoftDeleteEntity(builder);
|
||||
builder.HasIndex(x => x.Code).IsUnique();
|
||||
builder.HasIndex(x => new { x.Portal, x.ParentId, x.SortOrder });
|
||||
}
|
||||
|
||||
private static void ConfigureRoleTemplate(EntityTypeBuilder<RoleTemplate> builder)
|
||||
{
|
||||
builder.ToTable("role_templates");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.TemplateCode).HasMaxLength(64).IsRequired();
|
||||
builder.Property(x => x.Name).HasMaxLength(128).IsRequired();
|
||||
builder.Property(x => x.Description).HasMaxLength(256);
|
||||
builder.Property(x => x.IsActive).IsRequired();
|
||||
ConfigureAuditableEntity(builder);
|
||||
builder.HasIndex(x => x.TemplateCode).IsUnique();
|
||||
}
|
||||
|
||||
private static void ConfigureRoleTemplatePermission(EntityTypeBuilder<RoleTemplatePermission> builder)
|
||||
{
|
||||
builder.ToTable("role_template_permissions");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.RoleTemplateId).IsRequired();
|
||||
builder.Property(x => x.PermissionCode).HasMaxLength(128).IsRequired();
|
||||
ConfigureAuditableEntity(builder);
|
||||
builder.HasIndex(x => new { x.RoleTemplateId, x.PermissionCode }).IsUnique();
|
||||
}
|
||||
|
||||
private static void ConfigureUserRole(EntityTypeBuilder<UserRole> builder)
|
||||
{
|
||||
builder.ToTable("user_roles");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.TenantId).IsRequired();
|
||||
builder.Property(x => x.UserId).IsRequired();
|
||||
builder.Property(x => x.RoleId).IsRequired();
|
||||
ConfigureAuditableEntity(builder);
|
||||
ConfigureSoftDeleteEntity(builder);
|
||||
builder.HasIndex(x => x.TenantId);
|
||||
builder.HasIndex(x => new { x.TenantId, x.UserId, x.RoleId }).IsUnique();
|
||||
}
|
||||
|
||||
private static void ConfigureRolePermission(EntityTypeBuilder<RolePermission> builder)
|
||||
{
|
||||
builder.ToTable("role_permissions");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.TenantId).IsRequired();
|
||||
builder.Property(x => x.RoleId).IsRequired();
|
||||
builder.Property(x => x.PermissionId).IsRequired();
|
||||
ConfigureAuditableEntity(builder);
|
||||
ConfigureSoftDeleteEntity(builder);
|
||||
builder.HasIndex(x => x.TenantId);
|
||||
builder.HasIndex(x => new { x.TenantId, x.RoleId, x.PermissionId }).IsUnique();
|
||||
}
|
||||
|
||||
private static void ConfigureMenuDefinition(EntityTypeBuilder<MenuDefinition> builder)
|
||||
{
|
||||
builder.ToTable("menu_definitions");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.Portal).HasConversion<int>().IsRequired();
|
||||
builder.Property(x => x.ParentId).IsRequired();
|
||||
builder.Property(x => x.Name).HasMaxLength(64).IsRequired();
|
||||
builder.Property(x => x.Path).HasMaxLength(256).IsRequired();
|
||||
builder.Property(x => x.Component).HasMaxLength(256).IsRequired();
|
||||
builder.Property(x => x.Title).HasMaxLength(128).IsRequired();
|
||||
builder.Property(x => x.Icon).HasMaxLength(64);
|
||||
builder.Property(x => x.Link).HasMaxLength(512);
|
||||
builder.Property(x => x.SortOrder).IsRequired();
|
||||
builder.Property(x => x.RequiredPermissions).HasMaxLength(1024);
|
||||
builder.Property(x => x.MetaPermissions).HasMaxLength(1024);
|
||||
builder.Property(x => x.MetaRoles).HasMaxLength(1024);
|
||||
builder.Property(x => x.AuthListJson).HasColumnType("text");
|
||||
ConfigureAuditableEntity(builder);
|
||||
ConfigureSoftDeleteEntity(builder);
|
||||
builder.HasIndex(x => new { x.Portal, x.ParentId, x.SortOrder });
|
||||
}
|
||||
}
|
||||
@@ -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.Identity.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// 设计时 IdentityDbContext 工厂,供 EF Core CLI 生成迁移使用。
|
||||
/// </summary>
|
||||
internal sealed class IdentityDesignTimeDbContextFactory
|
||||
: DesignTimeDbContextFactoryBase<IdentityDbContext>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化 Identity 设计时上下文工厂。
|
||||
/// </summary>
|
||||
public IdentityDesignTimeDbContextFactory()
|
||||
: base(DatabaseConstants.IdentityDataSource, "TAKEOUTSAAS_IDENTITY_CONNECTION")
|
||||
{
|
||||
}
|
||||
// 创建设计时上下文实例
|
||||
/// <summary>
|
||||
/// 创建设计时的 IdentityDbContext。
|
||||
/// </summary>
|
||||
/// <param name="options">DbContext 配置。</param>
|
||||
/// <param name="tenantProvider">租户提供器。</param>
|
||||
/// <param name="currentUserAccessor">当前用户访问器。</param>
|
||||
/// <returns>IdentityDbContext 实例。</returns>
|
||||
protected override IdentityDbContext CreateContext(
|
||||
DbContextOptions<IdentityDbContext> options,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor)
|
||||
=> new(options, tenantProvider, currentUserAccessor);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
using TakeoutSaaS.Domain.Identity.Enums;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Persistence;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 菜单仓储 EF 实现。
|
||||
/// </summary>
|
||||
public sealed class EfMenuRepository(IdentityDbContext dbContext) : IMenuRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<MenuDefinition>> GetByPortalAsync(PortalType portal, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 按门户类型查询菜单(忽略租户过滤器)
|
||||
var menus = await dbContext.MenuDefinitions
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.Portal == portal && x.DeletedAt == null)
|
||||
.OrderBy(x => x.ParentId)
|
||||
.ThenBy(x => x.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return menus;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<MenuDefinition?> FindByIdAsync(long id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 按 ID 查询菜单(忽略租户过滤器)
|
||||
return await dbContext.MenuDefinitions
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == id && x.DeletedAt == null, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddAsync(MenuDefinition menu, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return dbContext.MenuDefinitions.AddAsync(menu, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateAsync(MenuDefinition menu, CancellationToken cancellationToken = default)
|
||||
{
|
||||
dbContext.MenuDefinitions.Update(menu);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteAsync(long id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 查询目标(忽略租户过滤器)
|
||||
var entity = await dbContext.MenuDefinitions
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
|
||||
// 2. 存在则删除
|
||||
if (entity is not null)
|
||||
{
|
||||
dbContext.MenuDefinitions.Remove(entity);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Options;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Services;
|
||||
|
||||
/// <summary>
|
||||
/// JWT 令牌生成器。
|
||||
/// </summary>
|
||||
public sealed class JwtTokenService(IRefreshTokenStore refreshTokenStore, IOptions<JwtOptions> options) : IJwtTokenService
|
||||
{
|
||||
private readonly JwtSecurityTokenHandler _tokenHandler = new();
|
||||
private readonly JwtOptions _options = options.Value;
|
||||
|
||||
/// <summary>
|
||||
/// 创建访问令牌和刷新令牌对。
|
||||
/// </summary>
|
||||
/// <param name="profile">用户档案</param>
|
||||
/// <param name="isNewUser">是否为新用户(首次登录)</param>
|
||||
/// <param name="cancellationToken">取消令牌</param>
|
||||
/// <returns>令牌响应</returns>
|
||||
public async Task<TokenResponse> CreateTokensAsync(CurrentUserProfile profile, bool isNewUser = false, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var accessExpires = now.AddMinutes(_options.AccessTokenExpirationMinutes);
|
||||
var refreshExpires = now.AddMinutes(_options.RefreshTokenExpirationMinutes);
|
||||
|
||||
// 1. 构建 JWT Claims(包含用户 ID、账号、租户 ID、商户 ID、角色、权限等)
|
||||
var claims = BuildClaims(profile);
|
||||
|
||||
// 2. 创建签名凭据(使用 HMAC SHA256 算法)
|
||||
var signingCredentials = new SigningCredentials(
|
||||
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.Secret)),
|
||||
SecurityAlgorithms.HmacSha256);
|
||||
|
||||
// 3. 创建 JWT 安全令牌
|
||||
var jwt = new JwtSecurityToken(
|
||||
issuer: _options.Issuer,
|
||||
audience: _options.Audience,
|
||||
claims: claims,
|
||||
notBefore: now,
|
||||
expires: accessExpires,
|
||||
signingCredentials: signingCredentials);
|
||||
|
||||
// 4. 序列化 JWT 为字符串
|
||||
var accessToken = _tokenHandler.WriteToken(jwt);
|
||||
|
||||
// 5. 生成刷新令牌并存储到 Redis
|
||||
var refreshDescriptor = await refreshTokenStore.IssueAsync(profile.UserId, refreshExpires, cancellationToken);
|
||||
|
||||
return new TokenResponse
|
||||
{
|
||||
AccessToken = accessToken,
|
||||
AccessTokenExpiresAt = accessExpires,
|
||||
RefreshToken = refreshDescriptor.Token,
|
||||
RefreshTokenExpiresAt = refreshDescriptor.ExpiresAt,
|
||||
User = profile,
|
||||
IsNewUser = isNewUser
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构建 JWT Claims:将用户档案转换为 Claims 集合。
|
||||
/// </summary>
|
||||
/// <param name="profile">用户档案</param>
|
||||
/// <returns>Claims 集合</returns>
|
||||
private static List<Claim> BuildClaims(CurrentUserProfile profile)
|
||||
{
|
||||
var userId = profile.UserId.ToString();
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(JwtRegisteredClaimNames.Sub, userId),
|
||||
new(ClaimTypes.NameIdentifier, userId),
|
||||
new(JwtRegisteredClaimNames.UniqueName, profile.Account),
|
||||
new("tenant_id", profile.TenantId.ToString()),
|
||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
|
||||
};
|
||||
|
||||
if (profile.MerchantId.HasValue)
|
||||
{
|
||||
claims.Add(new Claim("merchant_id", profile.MerchantId.Value.ToString()));
|
||||
}
|
||||
|
||||
claims.AddRange(profile.Roles.Select(role => new Claim(ClaimTypes.Role, role)));
|
||||
|
||||
claims.AddRange(profile.Permissions.Select(permission => new Claim("permission", permission)));
|
||||
|
||||
return claims;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Security.Cryptography;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Options;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Redis 管理后台重置密码链接令牌存储。
|
||||
/// </summary>
|
||||
public sealed class RedisAdminPasswordResetTokenStore(
|
||||
IDistributedCache cache,
|
||||
IOptions<AdminPasswordResetOptions> options)
|
||||
: IAdminPasswordResetTokenStore
|
||||
{
|
||||
private readonly AdminPasswordResetOptions _options = options.Value;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string> IssueAsync(long userId, DateTime expiresAt, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 生成 URL 安全的随机令牌
|
||||
var token = GenerateUrlSafeToken(48);
|
||||
|
||||
// 2. 写入缓存(Value:userId)
|
||||
await cache.SetStringAsync(BuildKey(token), userId.ToString(), new DistributedCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpiration = expiresAt
|
||||
}, cancellationToken);
|
||||
|
||||
// 3. 返回令牌
|
||||
return token;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<long?> ConsumeAsync(string token, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 读取缓存
|
||||
var key = BuildKey(token);
|
||||
var value = await cache.GetStringAsync(key, cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. 删除缓存(一次性令牌)
|
||||
await cache.RemoveAsync(key, cancellationToken);
|
||||
|
||||
// 3. 解析用户 ID
|
||||
return long.TryParse(value, out var userId) ? userId : null;
|
||||
}
|
||||
|
||||
private string BuildKey(string token) => $"{_options.Prefix}{token}";
|
||||
|
||||
private static string GenerateUrlSafeToken(int bytesLength)
|
||||
{
|
||||
var bytes = RandomNumberGenerator.GetBytes(bytesLength);
|
||||
var token = Convert.ToBase64String(bytes);
|
||||
|
||||
return token
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_')
|
||||
.TrimEnd('=');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Options;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Redis 登录限流实现。
|
||||
/// </summary>
|
||||
public sealed class RedisLoginRateLimiter(IDistributedCache cache, IOptions<LoginRateLimitOptions> options) : ILoginRateLimiter
|
||||
{
|
||||
private readonly LoginRateLimitOptions _options = options.Value;
|
||||
|
||||
/// <summary>
|
||||
/// 校验指定键的登录尝试次数,超限将抛出业务异常。
|
||||
/// </summary>
|
||||
/// <param name="key">限流键(如账号或 IP)。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public async Task EnsureAllowedAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 读取当前计数
|
||||
var cacheKey = BuildKey(key);
|
||||
var current = await cache.GetStringAsync(cacheKey, cancellationToken);
|
||||
var count = string.IsNullOrWhiteSpace(current) ? 0 : int.Parse(current);
|
||||
if (count >= _options.MaxAttempts)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "尝试次数过多,请稍后再试");
|
||||
}
|
||||
|
||||
// 2. 累加计数并回写缓存
|
||||
count++;
|
||||
await cache.SetStringAsync(
|
||||
cacheKey,
|
||||
count.ToString(),
|
||||
new DistributedCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(_options.WindowSeconds)
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 重置指定键的登录计数。
|
||||
/// </summary>
|
||||
/// <param name="key">限流键(如账号或 IP)。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public Task ResetAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> cache.RemoveAsync(BuildKey(key), cancellationToken);
|
||||
|
||||
private static string BuildKey(string key) => $"identity:login:{key}";
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Application.Identity.Models;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Options;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Redis 刷新令牌存储。
|
||||
/// </summary>
|
||||
public sealed class RedisRefreshTokenStore(IDistributedCache cache, IOptions<RefreshTokenStoreOptions> options) : IRefreshTokenStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
private readonly RefreshTokenStoreOptions _options = options.Value;
|
||||
|
||||
/// <summary>
|
||||
/// 签发刷新令牌并写入缓存。
|
||||
/// </summary>
|
||||
/// <param name="userId">用户 ID。</param>
|
||||
/// <param name="expiresAt">过期时间。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>刷新令牌描述。</returns>
|
||||
public async Task<RefreshTokenDescriptor> IssueAsync(long userId, DateTime expiresAt, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 生成随机令牌
|
||||
var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(48));
|
||||
var descriptor = new RefreshTokenDescriptor(token, userId, expiresAt, false);
|
||||
|
||||
// 2. 写入缓存
|
||||
var key = BuildKey(token);
|
||||
var entryOptions = new DistributedCacheEntryOptions { AbsoluteExpiration = expiresAt };
|
||||
await cache.SetStringAsync(key, JsonSerializer.Serialize(descriptor, JsonOptions), entryOptions, cancellationToken);
|
||||
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取刷新令牌描述。
|
||||
/// </summary>
|
||||
/// <param name="refreshToken">刷新令牌值。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>刷新令牌描述或 null。</returns>
|
||||
public async Task<RefreshTokenDescriptor?> GetAsync(string refreshToken, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 读取缓存
|
||||
var json = await cache.GetStringAsync(BuildKey(refreshToken), cancellationToken);
|
||||
return string.IsNullOrWhiteSpace(json)
|
||||
? null
|
||||
: JsonSerializer.Deserialize<RefreshTokenDescriptor>(json, JsonOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 吊销刷新令牌。
|
||||
/// </summary>
|
||||
/// <param name="refreshToken">刷新令牌值。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public async Task RevokeAsync(string refreshToken, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 读取令牌
|
||||
var descriptor = await GetAsync(refreshToken, cancellationToken);
|
||||
if (descriptor == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 标记吊销并回写缓存
|
||||
var updated = descriptor with { Revoked = true };
|
||||
var entryOptions = new DistributedCacheEntryOptions { AbsoluteExpiration = updated.ExpiresAt };
|
||||
await cache.SetStringAsync(BuildKey(refreshToken), JsonSerializer.Serialize(updated, JsonOptions), entryOptions, cancellationToken);
|
||||
}
|
||||
|
||||
private string BuildKey(string token) => $"{_options.Prefix}{token}";
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Options;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 微信 code2Session 实现
|
||||
/// </summary>
|
||||
public sealed class WeChatAuthService(HttpClient httpClient, IOptions<WeChatMiniOptions> options) : IWeChatAuthService
|
||||
{
|
||||
private readonly WeChatMiniOptions _options = options.Value;
|
||||
|
||||
/// <summary>
|
||||
/// 调用微信接口完成 code2Session。
|
||||
/// </summary>
|
||||
/// <param name="code">临时登录凭证 code。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>微信会话信息。</returns>
|
||||
public async Task<WeChatSessionInfo> Code2SessionAsync(string code, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 拼装请求地址
|
||||
var requestUri = $"sns/jscode2session?appid={Uri.EscapeDataString(_options.AppId)}&secret={Uri.EscapeDataString(_options.Secret)}&js_code={Uri.EscapeDataString(code)}&grant_type=authorization_code";
|
||||
using var response = await httpClient.GetAsync(requestUri, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
// 2. 读取响应
|
||||
var payload = await response.Content.ReadFromJsonAsync<WeChatSessionResponse>(cancellationToken: cancellationToken);
|
||||
if (payload == null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Unauthorized, "微信登录失败:响应为空");
|
||||
}
|
||||
|
||||
// 3. 校验错误码
|
||||
if (payload.ErrorCode.HasValue && payload.ErrorCode.Value != 0)
|
||||
{
|
||||
var message = string.IsNullOrWhiteSpace(payload.ErrorMessage)
|
||||
? $"微信登录失败,错误码:{payload.ErrorCode}"
|
||||
: payload.ErrorMessage;
|
||||
throw new BusinessException(ErrorCodes.Unauthorized, message);
|
||||
}
|
||||
|
||||
// 4. 校验必要字段
|
||||
if (string.IsNullOrWhiteSpace(payload.OpenId) || string.IsNullOrWhiteSpace(payload.SessionKey))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Unauthorized, "微信登录失败:返回数据无效");
|
||||
}
|
||||
|
||||
// 5. 组装会话信息
|
||||
return new WeChatSessionInfo
|
||||
{
|
||||
OpenId = payload.OpenId,
|
||||
UnionId = payload.UnionId,
|
||||
SessionKey = payload.SessionKey
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class WeChatSessionResponse
|
||||
{
|
||||
[JsonPropertyName("openid")]
|
||||
public string? OpenId { get; set; }
|
||||
|
||||
[JsonPropertyName("unionid")]
|
||||
public string? UnionId { get; set; }
|
||||
|
||||
[JsonPropertyName("session_key")]
|
||||
public string? SessionKey { get; set; }
|
||||
|
||||
[JsonPropertyName("errcode")]
|
||||
public int? ErrorCode { get; set; }
|
||||
|
||||
[JsonPropertyName("errmsg")]
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using MassTransit;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
using TakeoutSaaS.Application.Identity.Events;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Infrastructure.Logs.Persistence;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Logs.Consumers;
|
||||
|
||||
/// <summary>
|
||||
/// 身份用户操作日志消费者。
|
||||
/// </summary>
|
||||
public sealed class IdentityUserOperationLogConsumer(TakeoutLogsDbContext logsContext) : IConsumer<IdentityUserOperationLogMessage>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task Consume(ConsumeContext<IdentityUserOperationLogMessage> context)
|
||||
{
|
||||
// 1. 校验消息标识并进行幂等检查
|
||||
var messageId = context.MessageId;
|
||||
if (!messageId.HasValue)
|
||||
{
|
||||
throw new InvalidOperationException("缺少 MessageId,无法进行日志幂等处理。");
|
||||
}
|
||||
|
||||
var exists = await logsContext.OperationLogInboxMessages
|
||||
.AsNoTracking()
|
||||
.AnyAsync(x => x.MessageId == messageId.Value, context.CancellationToken);
|
||||
if (exists)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 构建日志实体与去重记录
|
||||
var message = context.Message;
|
||||
var log = new OperationLog
|
||||
{
|
||||
OperationType = message.OperationType,
|
||||
TargetType = message.TargetType,
|
||||
TargetIds = message.TargetIds,
|
||||
OperatorId = message.OperatorId,
|
||||
OperatorName = message.OperatorName,
|
||||
Parameters = message.Parameters,
|
||||
Result = message.Result,
|
||||
Success = message.Success
|
||||
};
|
||||
logsContext.OperationLogInboxMessages.Add(new OperationLogInboxMessage
|
||||
{
|
||||
MessageId = messageId.Value,
|
||||
ConsumedAt = DateTime.UtcNow
|
||||
});
|
||||
logsContext.OperationLogs.Add(log);
|
||||
|
||||
// 3. 保存并处理并发去重冲突
|
||||
try
|
||||
{
|
||||
await logsContext.SaveChangesAsync(context.CancellationToken);
|
||||
}
|
||||
catch (DbUpdateException ex) when (IsDuplicateMessage(ex))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsDuplicateMessage(DbUpdateException exception)
|
||||
{
|
||||
if (exception.InnerException is PostgresException postgresException)
|
||||
{
|
||||
return postgresException.SqlState == PostgresErrorCodes.UniqueViolation;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using MassTransit;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Persistence;
|
||||
using TakeoutSaaS.Infrastructure.Logs.Consumers;
|
||||
using TakeoutSaaS.Module.Messaging.Options;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Logs.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// 操作日志 Outbox 注册扩展。
|
||||
/// </summary>
|
||||
public static class OperationLogOutboxServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 注册操作日志 Outbox 与消费者。
|
||||
/// </summary>
|
||||
/// <param name="services">服务集合。</param>
|
||||
/// <param name="configuration">配置源。</param>
|
||||
/// <returns>服务集合。</returns>
|
||||
public static IServiceCollection AddOperationLogOutbox(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
// 1. 读取 RabbitMQ 配置
|
||||
var options = configuration.GetSection("RabbitMQ").Get<RabbitMqOptions>();
|
||||
if (options == null)
|
||||
{
|
||||
throw new InvalidOperationException("缺少 RabbitMQ 配置。");
|
||||
}
|
||||
|
||||
// 2. 注册 MassTransit 与 Outbox
|
||||
services.AddMassTransit(configurator =>
|
||||
{
|
||||
configurator.AddConsumer<IdentityUserOperationLogConsumer>();
|
||||
configurator.AddEntityFrameworkOutbox<IdentityDbContext>(outbox =>
|
||||
{
|
||||
outbox.UsePostgres();
|
||||
outbox.UseBusOutbox();
|
||||
});
|
||||
configurator.UsingRabbitMq((context, cfg) =>
|
||||
{
|
||||
var virtualHost = string.IsNullOrWhiteSpace(options.VirtualHost) ? "/" : options.VirtualHost.Trim();
|
||||
var virtualHostPath = virtualHost == "/" ? "/" : $"/{virtualHost.TrimStart('/')}";
|
||||
var hostUri = new Uri($"rabbitmq://{options.Host}:{options.Port}{virtualHostPath}");
|
||||
cfg.Host(hostUri, host =>
|
||||
{
|
||||
host.Username(options.Username);
|
||||
host.Password(options.Password);
|
||||
});
|
||||
cfg.PrefetchCount = options.PrefetchCount;
|
||||
cfg.ConfigureEndpoints(context);
|
||||
});
|
||||
});
|
||||
// 3. 返回服务集合
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Logs.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// 操作日志消息消费去重记录。
|
||||
/// </summary>
|
||||
public sealed class OperationLogInboxMessage : EntityBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 消息唯一标识。
|
||||
/// </summary>
|
||||
public Guid MessageId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 消费时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime ConsumedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using TakeoutSaaS.Domain.Membership.Entities;
|
||||
using TakeoutSaaS.Domain.Merchants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Infrastructure.Common.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Ids;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Logs.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// 日志库 DbContext。
|
||||
/// </summary>
|
||||
public sealed class TakeoutLogsDbContext(
|
||||
DbContextOptions<TakeoutLogsDbContext> options,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor? currentUserAccessor = null,
|
||||
IIdGenerator? idGenerator = null)
|
||||
: TenantAwareDbContext(options, tenantProvider, currentUserAccessor, idGenerator)
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户审计日志集合。
|
||||
/// </summary>
|
||||
public DbSet<TenantAuditLog> TenantAuditLogs => Set<TenantAuditLog>();
|
||||
|
||||
/// <summary>
|
||||
/// 商户审计日志集合。
|
||||
/// </summary>
|
||||
public DbSet<MerchantAuditLog> MerchantAuditLogs => Set<MerchantAuditLog>();
|
||||
|
||||
/// <summary>
|
||||
/// 商户变更日志集合。
|
||||
/// </summary>
|
||||
public DbSet<MerchantChangeLog> MerchantChangeLogs => Set<MerchantChangeLog>();
|
||||
|
||||
/// <summary>
|
||||
/// 运营操作日志集合。
|
||||
/// </summary>
|
||||
public DbSet<OperationLog> OperationLogs => Set<OperationLog>();
|
||||
|
||||
/// <summary>
|
||||
/// 操作日志消息去重集合。
|
||||
/// </summary>
|
||||
public DbSet<OperationLogInboxMessage> OperationLogInboxMessages => Set<OperationLogInboxMessage>();
|
||||
|
||||
/// <summary>
|
||||
/// 成长值日志集合。
|
||||
/// </summary>
|
||||
public DbSet<MemberGrowthLog> MemberGrowthLogs => Set<MemberGrowthLog>();
|
||||
|
||||
/// <summary>
|
||||
/// 配置实体模型。
|
||||
/// </summary>
|
||||
/// <param name="modelBuilder">模型构建器。</param>
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
ConfigureTenantAuditLog(modelBuilder.Entity<TenantAuditLog>());
|
||||
ConfigureMerchantAuditLog(modelBuilder.Entity<MerchantAuditLog>());
|
||||
ConfigureMerchantChangeLog(modelBuilder.Entity<MerchantChangeLog>());
|
||||
ConfigureOperationLog(modelBuilder.Entity<OperationLog>());
|
||||
ConfigureOperationLogInboxMessage(modelBuilder.Entity<OperationLogInboxMessage>());
|
||||
ConfigureMemberGrowthLog(modelBuilder.Entity<MemberGrowthLog>());
|
||||
ApplyTenantQueryFilters(modelBuilder);
|
||||
}
|
||||
|
||||
private static void ConfigureTenantAuditLog(EntityTypeBuilder<TenantAuditLog> builder)
|
||||
{
|
||||
builder.ToTable("tenant_audit_logs");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.TenantId).IsRequired();
|
||||
builder.Property(x => x.Title).HasMaxLength(128).IsRequired();
|
||||
builder.Property(x => x.Description).HasMaxLength(1024);
|
||||
builder.Property(x => x.OperatorName).HasMaxLength(64);
|
||||
builder.HasIndex(x => x.TenantId);
|
||||
}
|
||||
|
||||
private static void ConfigureMerchantAuditLog(EntityTypeBuilder<MerchantAuditLog> builder)
|
||||
{
|
||||
builder.ToTable("merchant_audit_logs");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.MerchantId).IsRequired();
|
||||
builder.Property(x => x.Action).HasConversion<int>().IsRequired();
|
||||
builder.Property(x => x.Title).HasMaxLength(200).IsRequired();
|
||||
builder.Property(x => x.Description).HasMaxLength(1024);
|
||||
builder.Property(x => x.OperatorName).HasMaxLength(100);
|
||||
builder.Property(x => x.IpAddress).HasMaxLength(50);
|
||||
builder.HasIndex(x => new { x.TenantId, x.MerchantId });
|
||||
builder.HasIndex(x => new { x.MerchantId, x.CreatedAt });
|
||||
builder.HasIndex(x => new { x.TenantId, x.CreatedAt });
|
||||
}
|
||||
|
||||
private static void ConfigureMerchantChangeLog(EntityTypeBuilder<MerchantChangeLog> builder)
|
||||
{
|
||||
builder.ToTable("merchant_change_logs");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.MerchantId).IsRequired();
|
||||
builder.Property(x => x.FieldName).HasMaxLength(100).IsRequired();
|
||||
builder.Property(x => x.OldValue).HasColumnType("text");
|
||||
builder.Property(x => x.NewValue).HasColumnType("text");
|
||||
builder.Property(x => x.ChangeType).HasMaxLength(20).IsRequired();
|
||||
builder.Property(x => x.ChangedByName).HasMaxLength(100);
|
||||
builder.Property(x => x.ChangeReason).HasMaxLength(512);
|
||||
builder.HasIndex(x => new { x.MerchantId, x.CreatedAt });
|
||||
builder.HasIndex(x => new { x.TenantId, x.CreatedAt });
|
||||
}
|
||||
|
||||
private static void ConfigureOperationLog(EntityTypeBuilder<OperationLog> builder)
|
||||
{
|
||||
builder.ToTable("operation_logs");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.OperationType).HasMaxLength(64).IsRequired();
|
||||
builder.Property(x => x.TargetType).HasMaxLength(64).IsRequired();
|
||||
builder.Property(x => x.TargetIds).HasColumnType("text");
|
||||
builder.Property(x => x.OperatorId).HasMaxLength(64);
|
||||
builder.Property(x => x.OperatorName).HasMaxLength(128);
|
||||
builder.Property(x => x.Parameters).HasColumnType("text");
|
||||
builder.Property(x => x.Result).HasColumnType("text");
|
||||
builder.Property(x => x.Success).IsRequired();
|
||||
builder.HasIndex(x => new { x.OperationType, x.CreatedAt });
|
||||
builder.HasIndex(x => x.CreatedAt);
|
||||
}
|
||||
|
||||
private static void ConfigureOperationLogInboxMessage(EntityTypeBuilder<OperationLogInboxMessage> builder)
|
||||
{
|
||||
builder.ToTable("operation_log_inbox_messages");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.MessageId).IsRequired();
|
||||
builder.Property(x => x.ConsumedAt).IsRequired();
|
||||
builder.HasIndex(x => x.MessageId).IsUnique();
|
||||
}
|
||||
|
||||
private static void ConfigureMemberGrowthLog(EntityTypeBuilder<MemberGrowthLog> builder)
|
||||
{
|
||||
builder.ToTable("member_growth_logs");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.MemberId).IsRequired();
|
||||
builder.Property(x => x.Notes).HasMaxLength(256);
|
||||
builder.HasIndex(x => new { x.TenantId, x.MemberId, x.OccurredAt });
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user