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

@@ -2,6 +2,7 @@ using MediatR;
using TakeoutSaaS.Application.App.QuotaPackages.Commands;
using TakeoutSaaS.Application.App.QuotaPackages.Dto;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Ids;
@@ -12,6 +13,7 @@ namespace TakeoutSaaS.Application.App.QuotaPackages.Handlers;
/// </summary>
public sealed class PurchaseQuotaPackageCommandHandler(
IQuotaPackageRepository quotaPackageRepository,
ITenantQuotaUsageHistoryRepository quotaUsageHistoryRepository,
IIdGenerator idGenerator)
: IRequestHandler<PurchaseQuotaPackageCommand, TenantQuotaPurchaseDto>
{
@@ -48,8 +50,22 @@ public sealed class PurchaseQuotaPackageCommandHandler(
if (quotaUsage != null)
{
var beforeLimit = quotaUsage.LimitValue;
quotaUsage.LimitValue += quotaPackage.QuotaValue;
await quotaPackageRepository.UpdateUsageAsync(quotaUsage, cancellationToken);
// 4.1 (空行后) 记录配额变更历史(购买配额包视为“剩余增加”)
await quotaUsageHistoryRepository.AddAsync(new TenantQuotaUsageHistory
{
TenantId = request.TenantId,
QuotaType = quotaPackage.QuotaType,
UsedValue = quotaUsage.UsedValue,
LimitValue = quotaUsage.LimitValue,
RecordedAt = DateTime.UtcNow,
ChangeType = TenantQuotaUsageHistoryChangeType.Increase,
ChangeAmount = quotaUsage.LimitValue - beforeLimit,
ChangeReason = $"购买配额包:{quotaPackage.Name}"
}, cancellationToken);
}
await quotaPackageRepository.SaveChangesAsync(cancellationToken);

View File

@@ -0,0 +1,53 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
namespace TakeoutSaaS.Application.App.Tenants.Commands;
/// <summary>
/// 更新租户基础信息命令。
/// </summary>
public sealed record UpdateTenantCommand : IRequest<Unit>
{
/// <summary>
/// 租户 ID雪花算法
/// </summary>
public long TenantId { get; init; }
/// <summary>
/// 租户名称。
/// </summary>
[Required]
[StringLength(128)]
public string Name { get; init; } = string.Empty;
/// <summary>
/// 租户简称。
/// </summary>
[StringLength(64)]
public string? ShortName { get; init; }
/// <summary>
/// 所属行业。
/// </summary>
[StringLength(64)]
public string? Industry { get; init; }
/// <summary>
/// 联系人姓名。
/// </summary>
[StringLength(64)]
public string? ContactName { get; init; }
/// <summary>
/// 联系人电话。
/// </summary>
[StringLength(32)]
public string? ContactPhone { get; init; }
/// <summary>
/// 联系人邮箱。
/// </summary>
[StringLength(128)]
public string? ContactEmail { get; init; }
}

View File

@@ -0,0 +1,45 @@
using TakeoutSaaS.Domain.Tenants.Enums;
namespace TakeoutSaaS.Application.App.Tenants.Dto;
/// <summary>
/// 租户配额使用历史 DTO。
/// </summary>
public sealed record QuotaUsageHistoryDto
{
/// <summary>
/// 配额类型。
/// </summary>
public TenantQuotaType QuotaType { get; init; }
/// <summary>
/// 已使用值。
/// </summary>
public decimal UsedValue { get; init; }
/// <summary>
/// 限额值。
/// </summary>
public decimal LimitValue { get; init; }
/// <summary>
/// 记录时间UTC
/// </summary>
public DateTime RecordedAt { get; init; }
/// <summary>
/// 变更类型increase | decrease | init | snapshot。
/// </summary>
public string ChangeType { get; init; } = "snapshot";
/// <summary>
/// 变更量(可选)。
/// </summary>
public decimal? ChangeAmount { get; init; }
/// <summary>
/// 变更原因(可选)。
/// </summary>
public string? ChangeReason { get; init; }
}

View File

@@ -17,6 +17,7 @@ public sealed class CheckTenantQuotaCommandHandler(
ITenantRepository tenantRepository,
ITenantPackageRepository packageRepository,
ITenantQuotaUsageRepository quotaUsageRepository,
ITenantQuotaUsageHistoryRepository quotaUsageHistoryRepository,
ITenantProvider tenantProvider)
: IRequestHandler<CheckTenantQuotaCommand, QuotaCheckResultDto>
{
@@ -62,6 +63,9 @@ public sealed class CheckTenantQuotaCommandHandler(
ResetCycle = ResolveResetCycle(request.QuotaType)
};
// 4.1 (空行后) 记录是否为首次初始化(用于落库历史)
var isNewUsage = usage.Id == 0;
var usedAfter = usage.UsedValue + request.Delta;
if (limit.HasValue && usedAfter > (decimal)limit.Value)
{
@@ -75,6 +79,34 @@ public sealed class CheckTenantQuotaCommandHandler(
usage.UsedValue = usedAfter;
usage.ResetCycle ??= ResolveResetCycle(request.QuotaType);
// 5.1 (空行后) 落库历史(初始化 + 本次消耗)
var now = DateTime.UtcNow;
if (isNewUsage)
{
await quotaUsageHistoryRepository.AddAsync(new TenantQuotaUsageHistory
{
TenantId = request.TenantId,
QuotaType = request.QuotaType,
UsedValue = 0,
LimitValue = usage.LimitValue,
RecordedAt = now,
ChangeType = TenantQuotaUsageHistoryChangeType.Init,
ChangeReason = "初始化配额使用记录"
}, cancellationToken);
}
await quotaUsageHistoryRepository.AddAsync(new TenantQuotaUsageHistory
{
TenantId = request.TenantId,
QuotaType = request.QuotaType,
UsedValue = usage.UsedValue,
LimitValue = usage.LimitValue,
RecordedAt = now,
ChangeType = TenantQuotaUsageHistoryChangeType.Decrease,
ChangeAmount = request.Delta,
ChangeReason = "消耗配额"
}, cancellationToken);
await PersistUsageAsync(usage, quotaUsageRepository, cancellationToken);
// 6. 返回结果

View File

@@ -0,0 +1,171 @@
using MediatR;
using System.Data;
using System.Data.Common;
using TakeoutSaaS.Application.App.Tenants.Dto;
using TakeoutSaaS.Application.App.Tenants.Queries;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Data;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
/// <summary>
/// 分页查询租户配额使用历史处理器。
/// </summary>
public sealed class GetTenantQuotaUsageHistoryQueryHandler(
ITenantRepository tenantRepository,
IDapperExecutor dapperExecutor)
: IRequestHandler<GetTenantQuotaUsageHistoryQuery, PagedResult<QuotaUsageHistoryDto>>
{
/// <inheritdoc />
public async Task<PagedResult<QuotaUsageHistoryDto>> Handle(GetTenantQuotaUsageHistoryQuery request, CancellationToken cancellationToken)
{
// 1. 校验租户存在
_ = await tenantRepository.FindByIdAsync(request.TenantId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在");
// 2. (空行后) 规范化分页
var page = request.Page <= 0 ? 1 : request.Page;
var pageSize = request.PageSize is <= 0 or > 100 ? 10 : request.PageSize;
var offset = (page - 1) * pageSize;
// 3. (空行后) 查询总数 + 列表
return await dapperExecutor.QueryAsync(
DatabaseConstants.AppDataSource,
DatabaseConnectionRole.Read,
async (connection, token) =>
{
// 3.1 统计总数
var total = await ExecuteScalarIntAsync(
connection,
BuildCountSql(),
[
("tenantId", request.TenantId),
("quotaType", request.QuotaType.HasValue ? (int)request.QuotaType.Value : null),
("startDate", request.StartDate),
("endDate", request.EndDate)
],
token);
// 3.2 (空行后) 查询列表
await using var listCommand = CreateCommand(
connection,
BuildListSql(),
[
("tenantId", request.TenantId),
("quotaType", request.QuotaType.HasValue ? (int)request.QuotaType.Value : null),
("startDate", request.StartDate),
("endDate", request.EndDate),
("offset", offset),
("limit", pageSize)
]);
await using var reader = await listCommand.ExecuteReaderAsync(token);
var items = new List<QuotaUsageHistoryDto>();
while (await reader.ReadAsync(token))
{
var quotaType = (TenantQuotaType)reader.GetInt32(0);
var usedValue = reader.GetDecimal(1);
var limitValue = reader.GetDecimal(2);
var recordedAt = reader.GetDateTime(3);
var changeType = (TenantQuotaUsageHistoryChangeType)reader.GetInt32(4);
decimal? changeAmount = reader.IsDBNull(5) ? null : reader.GetDecimal(5);
var changeReason = reader.IsDBNull(6) ? null : reader.GetString(6);
// 3.2.1 (空行后) 映射 DTO
items.Add(new QuotaUsageHistoryDto
{
QuotaType = quotaType,
UsedValue = usedValue,
LimitValue = limitValue,
RecordedAt = recordedAt,
ChangeType = MapChangeType(changeType),
ChangeAmount = changeAmount,
ChangeReason = changeReason
});
}
// 3.3 (空行后) 返回分页
return new PagedResult<QuotaUsageHistoryDto>(items, page, pageSize, total);
},
cancellationToken);
}
private static string BuildCountSql()
{
return """
select count(*)
from public.tenant_quota_usage_histories h
where h."DeletedAt" is null
and h."TenantId" = @tenantId
and (@quotaType::int is null or h."QuotaType" = @quotaType)
and (@startDate::timestamp with time zone is null or h."RecordedAt" >= @startDate)
and (@endDate::timestamp with time zone is null or h."RecordedAt" <= @endDate);
""";
}
private static string BuildListSql()
{
return """
select
h."QuotaType",
h."UsedValue",
h."LimitValue",
h."RecordedAt",
h."ChangeType",
h."ChangeAmount",
h."ChangeReason"
from public.tenant_quota_usage_histories h
where h."DeletedAt" is null
and h."TenantId" = @tenantId
and (@quotaType::int is null or h."QuotaType" = @quotaType)
and (@startDate::timestamp with time zone is null or h."RecordedAt" >= @startDate)
and (@endDate::timestamp with time zone is null or h."RecordedAt" <= @endDate)
order by h."RecordedAt" desc, h."Id" desc
offset @offset
limit @limit;
""";
}
private static string MapChangeType(TenantQuotaUsageHistoryChangeType changeType)
{
return changeType switch
{
TenantQuotaUsageHistoryChangeType.Init => "init",
TenantQuotaUsageHistoryChangeType.Snapshot => "snapshot",
TenantQuotaUsageHistoryChangeType.Increase => "increase",
TenantQuotaUsageHistoryChangeType.Decrease => "decrease",
_ => "snapshot"
};
}
private static async Task<int> ExecuteScalarIntAsync(
IDbConnection connection,
string sql,
(string Name, object? Value)[] parameters,
CancellationToken cancellationToken)
{
await using var command = CreateCommand(connection, sql, parameters);
var result = await command.ExecuteScalarAsync(cancellationToken);
return result is null or DBNull ? 0 : Convert.ToInt32(result);
}
private static DbCommand CreateCommand(IDbConnection connection, string sql, (string Name, object? Value)[] parameters)
{
var command = connection.CreateCommand();
command.CommandText = sql;
foreach (var (name, value) in parameters)
{
var p = command.CreateParameter();
p.ParameterName = name;
p.Value = value ?? DBNull.Value;
command.Parameters.Add(p);
}
return (DbCommand)command;
}
}

View File

@@ -0,0 +1,72 @@
using MediatR;
using Microsoft.Extensions.Logging;
using TakeoutSaaS.Application.App.Tenants.Commands;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
/// <summary>
/// 更新租户基础信息处理器。
/// </summary>
public sealed class UpdateTenantCommandHandler(
ITenantRepository tenantRepository,
ILogger<UpdateTenantCommandHandler> logger)
: IRequestHandler<UpdateTenantCommand, Unit>
{
/// <inheritdoc />
public async Task<Unit> Handle(UpdateTenantCommand request, CancellationToken cancellationToken)
{
// 1. 参数校验
if (request.TenantId <= 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "tenantId 不能为空");
}
if (string.IsNullOrWhiteSpace(request.Name))
{
throw new BusinessException(ErrorCodes.BadRequest, "租户名称不能为空");
}
// 2. (空行后) 查询租户
var tenant = await tenantRepository.FindByIdAsync(request.TenantId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在");
// 3. (空行后) 校验租户名称唯一性(排除自身)
var normalizedName = request.Name.Trim();
if (await tenantRepository.ExistsByNameAsync(normalizedName, excludeTenantId: request.TenantId, cancellationToken))
{
throw new BusinessException(ErrorCodes.Conflict, $"租户名称 {normalizedName} 已存在");
}
// 4. (空行后) 校验联系人手机号唯一性(仅当填写时)
if (!string.IsNullOrWhiteSpace(request.ContactPhone))
{
var normalizedPhone = request.ContactPhone.Trim();
var existingTenantId = await tenantRepository.FindTenantIdByContactPhoneAsync(normalizedPhone, cancellationToken);
if (existingTenantId.HasValue && existingTenantId.Value != request.TenantId)
{
throw new BusinessException(ErrorCodes.Conflict, $"手机号 {normalizedPhone} 已注册");
}
}
// 5. (空行后) 更新基础信息(禁止修改 Code
tenant.Name = normalizedName;
tenant.ShortName = string.IsNullOrWhiteSpace(request.ShortName) ? null : request.ShortName.Trim();
tenant.Industry = string.IsNullOrWhiteSpace(request.Industry) ? null : request.Industry.Trim();
tenant.ContactName = string.IsNullOrWhiteSpace(request.ContactName) ? null : request.ContactName.Trim();
tenant.ContactPhone = string.IsNullOrWhiteSpace(request.ContactPhone) ? null : request.ContactPhone.Trim();
tenant.ContactEmail = string.IsNullOrWhiteSpace(request.ContactEmail) ? null : request.ContactEmail.Trim();
// 6. (空行后) 持久化更新
await tenantRepository.UpdateTenantAsync(tenant, cancellationToken);
await tenantRepository.SaveChangesAsync(cancellationToken);
// 7. (空行后) 记录日志
logger.LogInformation("已更新租户基础信息 {TenantId}", tenant.Id);
return Unit.Value;
}
}

View File

@@ -0,0 +1,43 @@
using MediatR;
using TakeoutSaaS.Application.App.Tenants.Dto;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.Tenants.Queries;
/// <summary>
/// 分页查询租户配额使用历史。
/// </summary>
public sealed record GetTenantQuotaUsageHistoryQuery : IRequest<PagedResult<QuotaUsageHistoryDto>>
{
/// <summary>
/// 租户 ID。
/// </summary>
public long TenantId { get; init; }
/// <summary>
/// 页码(从 1 开始)。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页大小。
/// </summary>
public int PageSize { get; init; } = 10;
/// <summary>
/// 开始时间UTC/带时区均可),为空不限制。
/// </summary>
public DateTime? StartDate { get; init; }
/// <summary>
/// 结束时间UTC/带时区均可),为空不限制。
/// </summary>
public DateTime? EndDate { get; init; }
/// <summary>
/// 配额类型过滤,为空不过滤。
/// </summary>
public TenantQuotaType? QuotaType { get; init; }
}

View File

@@ -0,0 +1,27 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Tenants.Queries;
namespace TakeoutSaaS.Application.App.Tenants.Validators;
/// <summary>
/// 租户配额使用历史查询验证器。
/// </summary>
public sealed class GetTenantQuotaUsageHistoryQueryValidator : AbstractValidator<GetTenantQuotaUsageHistoryQuery>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public GetTenantQuotaUsageHistoryQueryValidator()
{
RuleFor(x => x.TenantId).GreaterThan(0);
RuleFor(x => x.Page).GreaterThanOrEqualTo(1);
RuleFor(x => x.PageSize).InclusiveBetween(1, 100);
// (空行后) 时间范围校验
When(x => x.StartDate.HasValue && x.EndDate.HasValue, () =>
{
RuleFor(x => x.EndDate!.Value).GreaterThanOrEqualTo(x => x.StartDate!.Value);
});
}
}