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:
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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. 返回结果
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user