feat(admin-api): 实现 TD-001 和 TD-002 后端接口

TD-001 - PUT /api/admin/v1/tenants/{tenantId}:
- 新增 UpdateTenantCommand + UpdateTenantCommandHandler
- Controller 新增 Update 端点(tenant:update 权限)
- 校验:租户存在、name 非空、name/contactPhone 冲突返回 409
- 仓储扩展:ITenantRepository.ExistsByNameAsync

TD-002 - GET /api/admin/v1/tenants/{tenantId}/quota-usage-history:
- 新增 CQRS Query/Handler/DTO/Validator
- 支持分页(Page>=1, PageSize 1~100)
- 支持时间范围和 QuotaType 过滤
- 新增 tenant_quota_usage_histories 表(含迁移)
- 写入点:CheckTenantQuotaCommandHandler + PurchaseQuotaPackageCommandHandler

构建验证:dotnet build 通过
数据库迁移:已应用 20251218121053_AddTenantQuotaUsageHistories
This commit is contained in:
2025-12-18 20:19:33 +08:00
parent 40e914dc92
commit 907c9938ae
20 changed files with 8072 additions and 0 deletions

View File

@@ -50,6 +50,7 @@ public static class AppServiceCollectionExtensions
services.AddScoped<ITenantNotificationRepository, EfTenantNotificationRepository>();
services.AddScoped<ITenantPackageRepository, EfTenantPackageRepository>();
services.AddScoped<ITenantQuotaUsageRepository, EfTenantQuotaUsageRepository>();
services.AddScoped<ITenantQuotaUsageHistoryRepository, EfTenantQuotaUsageHistoryRepository>();
services.AddScoped<IInventoryRepository, EfInventoryRepository>();
services.AddScoped<IQuotaPackageRepository, EfQuotaPackageRepository>();
services.AddScoped<IStatisticsRepository, EfStatisticsRepository>();

View File

@@ -59,6 +59,10 @@ public sealed class TakeoutAppDbContext(
/// </summary>
public DbSet<TenantQuotaUsage> TenantQuotaUsages => Set<TenantQuotaUsage>();
/// <summary>
/// 租户配额使用历史记录。
/// </summary>
public DbSet<TenantQuotaUsageHistory> TenantQuotaUsageHistories => Set<TenantQuotaUsageHistory>();
/// <summary>
/// 租户账单。
/// </summary>
public DbSet<TenantBillingStatement> TenantBillingStatements => Set<TenantBillingStatement>();
@@ -390,6 +394,7 @@ public sealed class TakeoutAppDbContext(
ConfigureTenantSubscription(modelBuilder.Entity<TenantSubscription>());
ConfigureTenantSubscriptionHistory(modelBuilder.Entity<TenantSubscriptionHistory>());
ConfigureTenantQuotaUsage(modelBuilder.Entity<TenantQuotaUsage>());
ConfigureTenantQuotaUsageHistory(modelBuilder.Entity<TenantQuotaUsageHistory>());
ConfigureTenantBilling(modelBuilder.Entity<TenantBillingStatement>());
ConfigureTenantPayment(modelBuilder.Entity<TenantPayment>());
ConfigureTenantNotification(modelBuilder.Entity<TenantNotification>());
@@ -761,6 +766,18 @@ public sealed class TakeoutAppDbContext(
builder.HasIndex(x => new { x.TenantId, x.QuotaType }).IsUnique();
}
private static void ConfigureTenantQuotaUsageHistory(EntityTypeBuilder<TenantQuotaUsageHistory> builder)
{
builder.ToTable("tenant_quota_usage_histories");
builder.HasKey(x => x.Id);
builder.Property(x => x.QuotaType).HasConversion<int>();
builder.Property(x => x.ChangeType).HasConversion<int>();
builder.Property(x => x.ChangeReason).HasMaxLength(256);
builder.HasIndex(x => new { x.TenantId, x.QuotaType, x.RecordedAt });
builder.HasIndex(x => new { x.TenantId, x.RecordedAt });
}
private static void ConfigureTenantBilling(EntityTypeBuilder<TenantBillingStatement> builder)
{
new TenantBillingStatementConfiguration().Configure(builder);

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

@@ -162,6 +162,27 @@ public sealed class EfTenantRepository(TakeoutAppDbContext context) : ITenantRep
return context.Tenants.AnyAsync(x => x.Code == normalized, 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)
{