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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,655 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Infrastructure.App.Persistence.Repositories;
/// <summary>
/// 租户账单仓储实现EF Core
/// </summary>
public sealed class TenantBillingRepository(TakeoutAppDbContext context, ITenantContextAccessor tenantContextAccessor) : ITenantBillingRepository
{
private long GetCurrentTenantId()
=> tenantContextAccessor.Current?.TenantId ?? 0;
private Task<List<long>> GetActiveTenantIdsAsync(CancellationToken cancellationToken)
=> context.Tenants
.AsNoTracking()
.Where(x => x.DeletedAt == null && x.Id > 0)
.Select(x => x.Id)
.ToListAsync(cancellationToken);
/// <inheritdoc />
public async Task<IReadOnlyList<TenantBillingStatement>> SearchAsync(
long tenantId,
TenantBillingStatus? status,
DateTime? from,
DateTime? to,
CancellationToken cancellationToken = default)
{
// 1. 构建基础查询:在当前租户上下文内查询
var query = context.TenantBillingStatements
.AsNoTracking()
.Where(x => x.DeletedAt == null && x.TenantId == tenantId);
// 2. 按状态过滤
if (status.HasValue)
{
query = query.Where(x => x.Status == status.Value);
}
// 3. 按日期范围过滤(账单周期)
if (from.HasValue)
{
query = query.Where(x => x.PeriodStart >= from.Value);
}
if (to.HasValue)
{
query = query.Where(x => x.PeriodEnd <= to.Value);
}
// 4. 排序返回
return await query
.OrderByDescending(x => x.PeriodEnd)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public Task<TenantBillingStatement?> FindByIdAsync(long tenantId, long billingId, CancellationToken cancellationToken = default)
{
return context.TenantBillingStatements
.AsNoTracking()
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.TenantId == tenantId && x.Id == billingId, cancellationToken);
}
/// <inheritdoc />
public Task<TenantBillingStatement?> FindByStatementNoAsync(long tenantId, string statementNo, CancellationToken cancellationToken = default)
{
var normalized = statementNo.Trim();
return context.TenantBillingStatements
.AsNoTracking()
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.TenantId == tenantId && x.StatementNo == normalized, cancellationToken);
}
/// <inheritdoc />
public Task<TenantBillingStatement?> GetByStatementNoAsync(string statementNo, CancellationToken cancellationToken = default)
{
var normalized = statementNo.Trim();
return GetCurrentTenantId() == 0
? GetByStatementNoCrossTenantAsync(normalized, cancellationToken)
: context.TenantBillingStatements
.AsNoTracking()
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.StatementNo == normalized, cancellationToken);
}
/// <inheritdoc />
public Task<bool> ExistsNotCancelledByPeriodStartAsync(
long tenantId,
DateTime periodStart,
CancellationToken cancellationToken = default)
{
return context.TenantBillingStatements
.AsNoTracking()
.AnyAsync(
x => x.DeletedAt == null
&& x.TenantId == tenantId
&& x.PeriodStart == periodStart
&& x.Status != TenantBillingStatus.Cancelled,
cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TenantBillingStatement>> GetOverdueBillingsAsync(CancellationToken cancellationToken = default)
{
// 1. 以当前 UTC 时间作为逾期判断基准
var now = DateTime.UtcNow;
var currentTenantId = GetCurrentTenantId();
if (currentTenantId != 0)
{
// 2. (空行后) 当前租户:仅查询本租户逾期账单
return await context.TenantBillingStatements
.AsNoTracking()
.Where(x => x.DeletedAt == null
&& x.DueDate < now
&& x.Status == TenantBillingStatus.Pending)
.OrderBy(x => x.DueDate)
.ToListAsync(cancellationToken);
}
// 2. (空行后) 系统上下文:逐租户查询逾期账单并合并
var tenantIds = await GetActiveTenantIdsAsync(cancellationToken);
if (tenantIds.Count == 0)
{
return Array.Empty<TenantBillingStatement>();
}
var results = new List<TenantBillingStatement>();
foreach (var tenantId in tenantIds)
{
using (tenantContextAccessor.EnterTenantScope(tenantId, "billing"))
{
var items = await context.TenantBillingStatements
.AsNoTracking()
.Where(x => x.DeletedAt == null
&& x.DueDate < now
&& x.Status == TenantBillingStatus.Pending)
.ToListAsync(cancellationToken);
results.AddRange(items);
}
}
return results
.OrderBy(x => x.DueDate)
.ToList();
}
/// <inheritdoc />
public async Task<IReadOnlyList<TenantBillingStatement>> GetBillingsDueSoonAsync(int daysAhead, CancellationToken cancellationToken = default)
{
// 1. 计算到期窗口
var now = DateTime.UtcNow;
var dueTo = now.AddDays(daysAhead);
var currentTenantId = GetCurrentTenantId();
if (currentTenantId != 0)
{
// 2. (空行后) 当前租户:仅查询本租户即将到期账单
return await context.TenantBillingStatements
.AsNoTracking()
.Where(x => x.DeletedAt == null
&& x.Status == TenantBillingStatus.Pending
&& x.DueDate >= now
&& x.DueDate <= dueTo)
.OrderBy(x => x.DueDate)
.ToListAsync(cancellationToken);
}
// 2. (空行后) 系统上下文:逐租户查询即将到期账单并合并
var tenantIds = await GetActiveTenantIdsAsync(cancellationToken);
if (tenantIds.Count == 0)
{
return Array.Empty<TenantBillingStatement>();
}
var results = new List<TenantBillingStatement>();
foreach (var tenantId in tenantIds)
{
using (tenantContextAccessor.EnterTenantScope(tenantId, "billing"))
{
var items = await context.TenantBillingStatements
.AsNoTracking()
.Where(x => x.DeletedAt == null
&& x.Status == TenantBillingStatus.Pending
&& x.DueDate >= now
&& x.DueDate <= dueTo)
.ToListAsync(cancellationToken);
results.AddRange(items);
}
}
return results
.OrderBy(x => x.DueDate)
.ToList();
}
/// <inheritdoc />
public async Task<IReadOnlyList<TenantBillingStatement>> GetByTenantIdAsync(long tenantId, CancellationToken cancellationToken = default)
{
return await context.TenantBillingStatements
.AsNoTracking()
.Where(x => x.DeletedAt == null && x.TenantId == tenantId)
.OrderByDescending(x => x.PeriodEnd)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TenantBillingStatement>> GetByIdsAsync(IReadOnlyCollection<long> billingIds, CancellationToken cancellationToken = default)
{
if (billingIds.Count == 0)
{
return Array.Empty<TenantBillingStatement>();
}
// 1. 系统上下文:逐租户查找匹配账单;租户上下文:仅返回本租户账单
var ids = billingIds.Distinct().ToArray();
var currentTenantId = GetCurrentTenantId();
if (currentTenantId != 0)
{
return await context.TenantBillingStatements
.AsNoTracking()
.Where(x => x.DeletedAt == null && ids.Contains(x.Id))
.OrderByDescending(x => x.PeriodStart)
.ToListAsync(cancellationToken);
}
var tenantIds = await GetActiveTenantIdsAsync(cancellationToken);
if (tenantIds.Count == 0)
{
return Array.Empty<TenantBillingStatement>();
}
var results = new List<TenantBillingStatement>();
foreach (var tenantId in tenantIds)
{
using (tenantContextAccessor.EnterTenantScope(tenantId, "billing"))
{
var items = await context.TenantBillingStatements
.AsNoTracking()
.Where(x => x.DeletedAt == null && ids.Contains(x.Id))
.ToListAsync(cancellationToken);
results.AddRange(items);
}
}
return results
.OrderByDescending(x => x.PeriodStart)
.ToList();
}
/// <inheritdoc />
public Task AddAsync(TenantBillingStatement bill, CancellationToken cancellationToken = default)
{
return context.TenantBillingStatements.AddAsync(bill, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateAsync(TenantBillingStatement bill, CancellationToken cancellationToken = default)
{
context.TenantBillingStatements.Update(bill);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
return context.SaveChangesAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<(IReadOnlyList<TenantBillingStatement> Items, int Total)> SearchPagedAsync(
long? tenantId,
TenantBillingStatus? status,
DateTime? from,
DateTime? to,
decimal? minAmount,
decimal? maxAmount,
string? keyword,
int pageNumber,
int pageSize,
CancellationToken cancellationToken = default)
{
var normalizedPageNumber = pageNumber <= 0 ? 1 : pageNumber;
var normalizedPageSize = pageSize <= 0 ? 20 : pageSize;
var skip = (normalizedPageNumber - 1) * normalizedPageSize;
var takePerTenant = normalizedPageNumber * normalizedPageSize;
var currentTenantId = GetCurrentTenantId();
if (currentTenantId != 0)
{
// 1. 当前租户仅查询本租户数据tenantId 为空则默认当前租户)
var effectiveTenantId = tenantId ?? currentTenantId;
var query = BuildTenantQuery(effectiveTenantId, status, from, to, minAmount, maxAmount, keyword);
var total = await query.CountAsync(cancellationToken);
var items = await query
.OrderByDescending(x => x.PeriodEnd)
.Skip(skip)
.Take(normalizedPageSize)
.ToListAsync(cancellationToken);
return (items, total);
}
// 2. (空行后) 系统上下文:可按指定 tenantId 查询tenantId 为空则跨租户聚合分页
if (tenantId.HasValue)
{
using (tenantContextAccessor.EnterTenantScope(tenantId.Value, "billing"))
{
var query = BuildTenantQuery(tenantId.Value, status, from, to, minAmount, maxAmount, keyword);
var total = await query.CountAsync(cancellationToken);
var items = await query
.OrderByDescending(x => x.PeriodEnd)
.Skip(skip)
.Take(normalizedPageSize)
.ToListAsync(cancellationToken);
return (items, total);
}
}
// 3. (空行后) 跨租户分页:逐租户取 Top(N) 后合并排序再分页
var normalizedKeyword = string.IsNullOrWhiteSpace(keyword) ? null : keyword.Trim();
var tenantIds = await GetActiveTenantIdsAsync(cancellationToken);
if (tenantIds.Count == 0)
{
return ([], 0);
}
HashSet<long>? keywordMatchedTenantIds = null;
if (!string.IsNullOrWhiteSpace(normalizedKeyword))
{
var matched = await context.Tenants
.AsNoTracking()
.Where(x => x.DeletedAt == null && EF.Functions.ILike(x.Name, $"%{normalizedKeyword}%"))
.Select(x => x.Id)
.ToListAsync(cancellationToken);
keywordMatchedTenantIds = matched.Count == 0 ? null : matched.ToHashSet();
}
var totalCount = 0;
var collected = new List<TenantBillingStatement>();
foreach (var tid in tenantIds)
{
using (tenantContextAccessor.EnterTenantScope(tid, "billing"))
{
var tenantQuery = BuildTenantQuery(tid, status, from, to, minAmount, maxAmount, keyword: null);
if (!string.IsNullOrWhiteSpace(normalizedKeyword))
{
// 3.1 租户名命中时视为匹配全部账单,否则按账单号筛选
var tenantNameMatched = keywordMatchedTenantIds is not null && keywordMatchedTenantIds.Contains(tid);
if (!tenantNameMatched)
{
tenantQuery = tenantQuery.Where(x => EF.Functions.ILike(x.StatementNo, $"%{normalizedKeyword}%"));
}
}
totalCount += await tenantQuery.CountAsync(cancellationToken);
var topItems = await tenantQuery
.OrderByDescending(x => x.PeriodEnd)
.Take(takePerTenant)
.ToListAsync(cancellationToken);
collected.AddRange(topItems);
}
}
var pageItems = collected
.OrderByDescending(x => x.PeriodEnd)
.Skip(skip)
.Take(normalizedPageSize)
.ToList();
return (pageItems, totalCount);
}
/// <inheritdoc />
public async Task<TenantBillingStatistics> GetStatisticsAsync(
long? tenantId,
DateTime startDate,
DateTime endDate,
string groupBy,
CancellationToken cancellationToken = default)
{
// 1. 统一时间基准与分组方式
var now = DateTime.UtcNow;
var normalizedGroupBy = NormalizeGroupBy(groupBy);
var currentTenantId = GetCurrentTenantId();
// 2. (空行后) 构造待统计租户列表
List<long> targetTenantIds;
if (currentTenantId != 0)
{
targetTenantIds = [tenantId ?? currentTenantId];
}
else if (tenantId.HasValue)
{
targetTenantIds = [tenantId.Value];
}
else
{
targetTenantIds = await GetActiveTenantIdsAsync(cancellationToken);
}
if (targetTenantIds.Count == 0)
{
return new TenantBillingStatistics();
}
// 3. (空行后) 拉取统计字段(逐租户上下文执行)
var rows = new List<BillingStatisticsRow>();
foreach (var tid in targetTenantIds)
{
if (currentTenantId == 0)
{
using (tenantContextAccessor.EnterTenantScope(tid, "billing"))
{
var tenantRows = await BuildStatisticsQuery(startDate, endDate)
.Select(x => new BillingStatisticsRow
{
PeriodStart = x.PeriodStart,
AmountDue = x.AmountDue,
DiscountAmount = x.DiscountAmount,
TaxAmount = x.TaxAmount,
AmountPaid = x.AmountPaid,
Status = x.Status,
DueDate = x.DueDate
})
.ToListAsync(cancellationToken);
rows.AddRange(tenantRows);
}
continue;
}
var tenantRowsDirect = await BuildStatisticsQuery(startDate, endDate)
.Where(x => x.TenantId == tid)
.Select(x => new BillingStatisticsRow
{
PeriodStart = x.PeriodStart,
AmountDue = x.AmountDue,
DiscountAmount = x.DiscountAmount,
TaxAmount = x.TaxAmount,
AmountPaid = x.AmountPaid,
Status = x.Status,
DueDate = x.DueDate
})
.ToListAsync(cancellationToken);
rows.AddRange(tenantRowsDirect);
}
// 4. (空行后) 汇总统计
var totalAmount = rows.Sum(x => x.AmountDue - x.DiscountAmount + x.TaxAmount);
var paidAmount = rows.Where(x => x.Status == TenantBillingStatus.Paid).Sum(x => x.AmountPaid);
var unpaidAmount = rows.Sum(x => (x.AmountDue - x.DiscountAmount + x.TaxAmount) - x.AmountPaid);
var overdueAmount = rows
.Where(x => (x.Status == TenantBillingStatus.Pending || x.Status == TenantBillingStatus.Overdue) && x.DueDate < now)
.Sum(x => (x.AmountDue - x.DiscountAmount + x.TaxAmount) - x.AmountPaid);
var totalCount = rows.Count;
var paidCount = rows.Count(x => x.Status == TenantBillingStatus.Paid);
var unpaidCount = rows.Count(x => x.Status == TenantBillingStatus.Pending || x.Status == TenantBillingStatus.Overdue);
var overdueCount = rows.Count(x => (x.Status == TenantBillingStatus.Pending || x.Status == TenantBillingStatus.Overdue) && x.DueDate < now);
// 5. (空行后) 趋势统计
var trend = rows
.GroupBy(x => GetTrendBucket(x.PeriodStart, normalizedGroupBy))
.Select(g => new TenantBillingTrendDataPoint
{
Period = g.Key,
Count = g.Count(),
TotalAmount = g.Sum(x => x.AmountDue - x.DiscountAmount + x.TaxAmount),
PaidAmount = g.Sum(x => x.AmountPaid)
})
.OrderBy(x => x.Period)
.ToList();
return new TenantBillingStatistics
{
TotalAmount = totalAmount,
PaidAmount = paidAmount,
UnpaidAmount = unpaidAmount,
OverdueAmount = overdueAmount,
TotalCount = totalCount,
PaidCount = paidCount,
UnpaidCount = unpaidCount,
OverdueCount = overdueCount,
TrendData = trend
};
}
/// <inheritdoc />
public Task<TenantBillingStatement?> FindByIdAsync(long billingId, CancellationToken cancellationToken = default)
{
return GetCurrentTenantId() == 0
? FindByIdCrossTenantAsync(billingId, cancellationToken)
: context.TenantBillingStatements
.AsNoTracking()
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.Id == billingId, cancellationToken);
}
private IQueryable<TenantBillingStatement> BuildTenantQuery(
long tenantId,
TenantBillingStatus? status,
DateTime? from,
DateTime? to,
decimal? minAmount,
decimal? maxAmount,
string? keyword)
{
var query = context.TenantBillingStatements
.AsNoTracking()
.Where(x => x.DeletedAt == null && x.TenantId == tenantId);
if (status.HasValue)
{
query = query.Where(x => x.Status == status.Value);
}
if (from.HasValue)
{
query = query.Where(x => x.PeriodStart >= from.Value);
}
if (to.HasValue)
{
query = query.Where(x => x.PeriodEnd <= to.Value);
}
if (minAmount.HasValue)
{
query = query.Where(x => x.AmountDue >= minAmount.Value);
}
if (maxAmount.HasValue)
{
query = query.Where(x => x.AmountDue <= maxAmount.Value);
}
if (!string.IsNullOrWhiteSpace(keyword))
{
var normalized = keyword.Trim();
query = query.Where(x => EF.Functions.ILike(x.StatementNo, $"%{normalized}%"));
}
return query;
}
private IQueryable<TenantBillingStatement> BuildStatisticsQuery(DateTime startDate, DateTime endDate)
=> context.TenantBillingStatements
.AsNoTracking()
.Where(x => x.DeletedAt == null
&& x.PeriodStart >= startDate
&& x.PeriodEnd <= endDate);
private async Task<TenantBillingStatement?> FindByIdCrossTenantAsync(long billingId, CancellationToken cancellationToken)
{
var tenantIds = await GetActiveTenantIdsAsync(cancellationToken);
foreach (var tenantId in tenantIds)
{
using (tenantContextAccessor.EnterTenantScope(tenantId, "billing"))
{
var billing = await context.TenantBillingStatements
.AsNoTracking()
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.Id == billingId, cancellationToken);
if (billing != null)
{
return billing;
}
}
}
return null;
}
private async Task<TenantBillingStatement?> GetByStatementNoCrossTenantAsync(string statementNo, CancellationToken cancellationToken)
{
var tenantIds = await GetActiveTenantIdsAsync(cancellationToken);
foreach (var tenantId in tenantIds)
{
using (tenantContextAccessor.EnterTenantScope(tenantId, "billing"))
{
var billing = await context.TenantBillingStatements
.AsNoTracking()
.FirstOrDefaultAsync(x => x.DeletedAt == null && x.StatementNo == statementNo, cancellationToken);
if (billing != null)
{
return billing;
}
}
}
return null;
}
private sealed record BillingStatisticsRow
{
public required DateTime PeriodStart { get; init; }
public required decimal AmountDue { get; init; }
public required decimal DiscountAmount { get; init; }
public required decimal TaxAmount { get; init; }
public required decimal AmountPaid { get; init; }
public required TenantBillingStatus Status { get; init; }
public required DateTime DueDate { get; init; }
}
private static string NormalizeGroupBy(string groupBy)
{
return groupBy.Trim() switch
{
"Week" => "Week",
"Month" => "Month",
_ => "Day"
};
}
private static DateTime GetTrendBucket(DateTime periodStart, string groupBy)
{
var date = periodStart.Date;
return groupBy switch
{
"Month" => new DateTime(date.Year, date.Month, 1, 0, 0, 0, DateTimeKind.Utc),
"Week" => GetWeekStart(date),
_ => new DateTime(date.Year, date.Month, date.Day, 0, 0, 0, DateTimeKind.Utc)
};
}
private static DateTime GetWeekStart(DateTime date)
{
// 1. 将周一作为一周起始(与 PostgreSQL date_trunc('week', ...) 对齐)
var dayOfWeek = (int)date.DayOfWeek; // Sunday=0, Monday=1, ...
var daysSinceMonday = (dayOfWeek + 6) % 7;
// 2. 回退到周一 00:00:00UTC
var monday = date.AddDays(-daysSinceMonday);
return new DateTime(monday.Year, monday.Month, monday.Day, 0, 0, 0, DateTimeKind.Utc);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,233 @@
using System.Globalization;
using System.Text.Json;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Domain.Tenants.Services;
using TakeoutSaaS.Shared.Abstractions.Ids;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Infrastructure.App.Services;
/// <summary>
/// 账单领域服务实现。
/// </summary>
public sealed class BillingDomainService(
ITenantBillingRepository billingRepository,
ITenantRepository tenantRepository,
ITenantContextAccessor tenantContextAccessor,
ITenantPackageRepository tenantPackageRepository,
IIdGenerator idGenerator) : IBillingDomainService
{
/// <inheritdoc />
public async Task<TenantBillingStatement> GenerateSubscriptionBillingAsync(
TenantSubscription subscription,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(subscription);
// 1. 校验幂等:同一周期开始时间只能存在一张未取消账单
var exists = await billingRepository.ExistsNotCancelledByPeriodStartAsync(
subscription.TenantId,
subscription.EffectiveFrom,
cancellationToken);
if (exists)
{
throw new InvalidOperationException("该订阅周期的账单已存在。");
}
// 2. 查询套餐价格信息
var package = await tenantPackageRepository.FindByIdAsync(subscription.TenantPackageId, cancellationToken);
if (package is null)
{
throw new InvalidOperationException("订阅未关联有效套餐,无法生成账单。");
}
// 3. 选择价格(简化规则:优先按年/按月)
var days = (subscription.EffectiveTo - subscription.EffectiveFrom).TotalDays;
var amountDue = days >= 300 ? package.YearlyPrice : package.MonthlyPrice;
if (!amountDue.HasValue)
{
throw new InvalidOperationException("套餐价格未配置,无法生成账单。");
}
// 4. 生成账单明细
var lineItems = new List<BillingLineItem>
{
BillingLineItem.Create(
itemType: "Subscription",
description: $"套餐 {package.Name} 订阅费用",
quantity: 1,
unitPrice: amountDue.Value)
};
// 5. 构建账单实体
var now = DateTime.UtcNow;
return new TenantBillingStatement
{
Id = idGenerator.NextId(),
TenantId = subscription.TenantId,
StatementNo = GenerateStatementNo(),
BillingType = BillingType.Subscription,
SubscriptionId = subscription.Id,
PeriodStart = subscription.EffectiveFrom,
PeriodEnd = subscription.EffectiveTo,
AmountDue = amountDue.Value,
DiscountAmount = 0m,
TaxAmount = 0m,
AmountPaid = 0m,
Currency = "CNY",
Status = TenantBillingStatus.Pending,
DueDate = now.AddDays(7),
LineItemsJson = JsonSerializer.Serialize(lineItems),
Notes = subscription.Notes
};
}
/// <inheritdoc />
public Task<TenantBillingStatement> GenerateQuotaPurchaseBillingAsync(
long tenantId,
QuotaPackage quotaPackage,
int quantity,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(quotaPackage);
if (quantity <= 0)
{
throw new ArgumentOutOfRangeException(nameof(quantity), "购买数量必须大于 0。");
}
// 1. 计算金额
var amountDue = quotaPackage.Price * quantity;
// 2. 生成账单明细
var lineItems = new List<BillingLineItem>
{
BillingLineItem.Create(
itemType: "QuotaPurchase",
description: $"配额包 {quotaPackage.Name} × {quantity}",
quantity: quantity,
unitPrice: quotaPackage.Price)
};
// 3. 构建账单实体
var now = DateTime.UtcNow;
var billing = new TenantBillingStatement
{
Id = idGenerator.NextId(),
TenantId = tenantId,
StatementNo = GenerateStatementNo(),
BillingType = BillingType.QuotaPurchase,
SubscriptionId = null,
PeriodStart = now,
PeriodEnd = now,
AmountDue = amountDue,
DiscountAmount = 0m,
TaxAmount = 0m,
AmountPaid = 0m,
Currency = "CNY",
Status = TenantBillingStatus.Pending,
DueDate = now.AddDays(7),
LineItemsJson = JsonSerializer.Serialize(lineItems),
Notes = quotaPackage.Description
};
return Task.FromResult(billing);
}
/// <inheritdoc />
public string GenerateStatementNo()
{
// 1. 账单号格式BILL-{yyyyMMdd}-{序号}
var date = DateTime.UtcNow.ToString("yyyyMMdd", CultureInfo.InvariantCulture);
// 2. 使用雪花 ID 作为全局递增序号,确保分布式唯一
var sequence = idGenerator.NextId();
return $"BILL-{date}-{sequence}";
}
/// <inheritdoc />
public async Task<int> ProcessOverdueBillingsAsync(CancellationToken cancellationToken = default)
{
var processedAt = DateTime.UtcNow;
var currentTenantId = tenantContextAccessor.Current?.TenantId ?? 0;
if (currentTenantId != 0)
{
return await ProcessOverdueBillingsSingleTenantAsync(processedAt, cancellationToken);
}
// 1. (空行后) 系统上下文:逐租户处理,避免跨租户写入
var tenants = await tenantRepository.SearchAsync(null, null, cancellationToken);
var targets = tenants.Where(x => x.Id > 0).ToList();
if (targets.Count == 0)
{
return 0;
}
var totalUpdated = 0;
foreach (var tenant in targets)
{
using (tenantContextAccessor.EnterTenantScope(tenant.Id, "billing:overdue", tenant.Code))
{
totalUpdated += await ProcessOverdueBillingsSingleTenantAsync(processedAt, cancellationToken);
}
}
return totalUpdated;
}
private async Task<int> ProcessOverdueBillingsSingleTenantAsync(DateTime processedAt, CancellationToken cancellationToken)
{
// 1. 查询当前租户已超过到期日且仍处于待支付的账单(由仓储按 DueDate + Status 筛选)
var overdueBillings = await billingRepository.GetOverdueBillingsAsync(cancellationToken);
if (overdueBillings.Count == 0)
{
return 0;
}
// 2. (空行后) 批量标记逾期(防御性:再次判断 Pending
var updated = 0;
foreach (var billing in overdueBillings)
{
if (billing.Status != TenantBillingStatus.Pending)
{
continue;
}
billing.MarkAsOverdue();
billing.OverdueNotifiedAt ??= processedAt;
billing.UpdatedAt = processedAt;
await billingRepository.UpdateAsync(billing, cancellationToken);
updated++;
}
// 3. (空行后) 持久化
if (updated > 0)
{
await billingRepository.SaveChangesAsync(cancellationToken);
}
return updated;
}
/// <inheritdoc />
public decimal CalculateTotalAmount(decimal baseAmount, decimal discountAmount, decimal taxAmount)
{
return baseAmount - discountAmount + taxAmount;
}
/// <inheritdoc />
public bool CanProcessPayment(TenantBillingStatement billing)
{
ArgumentNullException.ThrowIfNull(billing);
return billing.Status switch
{
TenantBillingStatus.Pending => true,
TenantBillingStatus.Overdue => true,
_ => false
};
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;
}

View File

@@ -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 };
}

View File

@@ -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;
}

View File

@@ -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);
});
}
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -0,0 +1,10 @@
namespace TakeoutSaaS.Infrastructure.Common.Persistence;
/// <summary>
/// 数据库连接信息(连接串与超时/重试设置)。
/// </summary>
public sealed record DatabaseConnectionDetails(
string ConnectionString,
int CommandTimeoutSeconds,
int MaxRetryCount,
int MaxRetryDelaySeconds);

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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("检测到跨租户写入,已阻止保存。");
}
}
}
}

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}

View File

@@ -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 中兜底并记录日志。
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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>();
}

View File

@@ -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 });
}
}

View File

@@ -0,0 +1,35 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Infrastructure.Common.Persistence.DesignTime;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Security;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Infrastructure.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);
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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];
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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()}";
}

View File

@@ -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 中兜底并记录日志。
}
}

View File

@@ -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:";
}

View File

@@ -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>();
}

View File

@@ -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;
}

View File

@@ -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:";
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
});
}
}

View File

@@ -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);
}

View File

@@ -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)];
}

View File

@@ -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 });
}
}

View File

@@ -0,0 +1,35 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Infrastructure.Common.Persistence.DesignTime;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Security;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Infrastructure.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);
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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. 写入缓存ValueuserId
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('=');
}
}

View File

@@ -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}";
}

View File

@@ -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}";
}

View File

@@ -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; }
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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; }
}

View File

@@ -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