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:
@@ -84,6 +84,35 @@ public sealed class TenantsController(IMediator mediator) : BaseApiController
|
||||
return ApiResponse<TenantDetailDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新租户基础信息。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="body">更新命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>更新结果。</returns>
|
||||
[HttpPut("{tenantId:long}")]
|
||||
[PermissionAuthorize("tenant:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> Update(
|
||||
long tenantId,
|
||||
[FromBody, Required] UpdateTenantCommand body,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 校验路由与请求体租户标识一致
|
||||
if (body.TenantId != 0 && body.TenantId != tenantId)
|
||||
{
|
||||
return ApiResponse<object>.Error(StatusCodes.Status400BadRequest, "路由 tenantId 与请求体 tenantId 不一致");
|
||||
}
|
||||
|
||||
// 2. (空行后) 绑定租户标识并执行更新(若不存在或冲突则抛出业务异常,由全局异常处理转换为 404/409)
|
||||
var command = body with { TenantId = tenantId };
|
||||
await mediator.Send(command, cancellationToken);
|
||||
|
||||
// 3. (空行后) 返回成功结果
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 提交或更新实名认证资料。
|
||||
/// </summary>
|
||||
@@ -382,4 +411,29 @@ public sealed class TenantsController(IMediator mediator) : BaseApiController
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
return ApiResponse<QuotaCheckResultDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询租户配额使用历史。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="query">查询条件。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>配额使用历史分页结果。</returns>
|
||||
[HttpGet("{tenantId:long}/quota-usage-history")]
|
||||
[PermissionAuthorize("tenant:quota:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<QuotaUsageHistoryDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PagedResult<QuotaUsageHistoryDto>>> GetQuotaUsageHistory(
|
||||
long tenantId,
|
||||
[FromQuery] GetTenantQuotaUsageHistoryQuery query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 绑定租户标识
|
||||
query = query with { TenantId = tenantId };
|
||||
|
||||
// 2. (空行后) 查询配额使用历史
|
||||
var result = await mediator.Send(query, cancellationToken);
|
||||
|
||||
// 3. (空行后) 返回分页结果
|
||||
return ApiResponse<PagedResult<QuotaUsageHistoryDto>>.Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Tenants.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 租户配额使用历史记录(用于追踪配额上下限与使用量的时间序列变化)。
|
||||
/// </summary>
|
||||
public sealed class TenantQuotaUsageHistory : MultiTenantEntityBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 配额类型。
|
||||
/// </summary>
|
||||
public TenantQuotaType QuotaType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已使用值(记录时刻的快照)。
|
||||
/// </summary>
|
||||
public decimal UsedValue { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 限额值(记录时刻的快照)。
|
||||
/// </summary>
|
||||
public decimal LimitValue { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 记录时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime RecordedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 变更类型。
|
||||
/// </summary>
|
||||
public TenantQuotaUsageHistoryChangeType ChangeType { get; set; } = TenantQuotaUsageHistoryChangeType.Snapshot;
|
||||
|
||||
/// <summary>
|
||||
/// 变更量(可选)。
|
||||
/// </summary>
|
||||
public decimal? ChangeAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 变更原因(可选)。
|
||||
/// </summary>
|
||||
public string? ChangeReason { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace TakeoutSaaS.Domain.Tenants.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 租户配额使用历史变更类型。
|
||||
/// </summary>
|
||||
public enum TenantQuotaUsageHistoryChangeType
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化(首次建立配额基线)。
|
||||
/// </summary>
|
||||
Init = 0,
|
||||
|
||||
/// <summary>
|
||||
/// 快照(定时或手动记录的当前状态)。
|
||||
/// </summary>
|
||||
Snapshot = 1,
|
||||
|
||||
/// <summary>
|
||||
/// 增加(剩余可用额度增加,例如购买配额包、重置等)。
|
||||
/// </summary>
|
||||
Increase = 2,
|
||||
|
||||
/// <summary>
|
||||
/// 减少(剩余可用额度减少,例如配额消耗等)。
|
||||
/// </summary>
|
||||
Decrease = 3
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 租户配额使用历史仓储。
|
||||
/// </summary>
|
||||
public interface ITenantQuotaUsageHistoryRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// 新增历史记录。
|
||||
/// </summary>
|
||||
/// <param name="history">历史记录实体。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
Task AddAsync(TenantQuotaUsageHistory history, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 持久化。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -84,6 +84,15 @@ public interface ITenantRepository
|
||||
/// <returns>存在返回 true,否则 false。</returns>
|
||||
Task<bool> ExistsByCodeAsync(string code, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 判断租户名称是否存在(支持排除指定租户)。
|
||||
/// </summary>
|
||||
/// <param name="name">租户名称。</param>
|
||||
/// <param name="excludeTenantId">排除的租户 ID(用于更新场景)。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>存在返回 true,否则 false。</returns>
|
||||
Task<bool> ExistsByNameAsync(string name, long? excludeTenantId = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 判断联系人手机号是否存在。
|
||||
/// </summary>
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,60 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddTenantQuotaUsageHistories : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "tenant_quota_usage_histories",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
QuotaType = table.Column<int>(type: "integer", nullable: false, comment: "配额类型。"),
|
||||
UsedValue = table.Column<decimal>(type: "numeric", nullable: false, comment: "已使用值(记录时刻的快照)。"),
|
||||
LimitValue = table.Column<decimal>(type: "numeric", nullable: false, comment: "限额值(记录时刻的快照)。"),
|
||||
RecordedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "记录时间(UTC)。"),
|
||||
ChangeType = table.Column<int>(type: "integer", nullable: false, comment: "变更类型。"),
|
||||
ChangeAmount = table.Column<decimal>(type: "numeric", nullable: true, comment: "变更量(可选)。"),
|
||||
ChangeReason = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true, comment: "变更原因(可选)。"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"),
|
||||
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"),
|
||||
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"),
|
||||
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"),
|
||||
DeletedBy = table.Column<long>(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"),
|
||||
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID。")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_tenant_quota_usage_histories", x => x.Id);
|
||||
},
|
||||
comment: "租户配额使用历史记录(用于追踪配额上下限与使用量的时间序列变化)。");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_tenant_quota_usage_histories_TenantId_QuotaType_RecordedAt",
|
||||
table: "tenant_quota_usage_histories",
|
||||
columns: new[] { "TenantId", "QuotaType", "RecordedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_tenant_quota_usage_histories_TenantId_RecordedAt",
|
||||
table: "tenant_quota_usage_histories",
|
||||
columns: new[] { "TenantId", "RecordedAt" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "tenant_quota_usage_histories");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6801,6 +6801,84 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TakeoutSaaS.Domain.Tenants.Entities.TenantQuotaUsageHistory", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<decimal?>("ChangeAmount")
|
||||
.HasColumnType("numeric")
|
||||
.HasComment("变更量(可选)。");
|
||||
|
||||
b.Property<string>("ChangeReason")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasComment("变更原因(可选)。");
|
||||
|
||||
b.Property<int>("ChangeType")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("变更类型。");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<decimal>("LimitValue")
|
||||
.HasColumnType("numeric")
|
||||
.HasComment("限额值(记录时刻的快照)。");
|
||||
|
||||
b.Property<int>("QuotaType")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("配额类型。");
|
||||
|
||||
b.Property<DateTime>("RecordedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("记录时间(UTC)。");
|
||||
|
||||
b.Property<long>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("UpdatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<decimal>("UsedValue")
|
||||
.HasColumnType("numeric")
|
||||
.HasComment("已使用值(记录时刻的快照)。");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId", "RecordedAt");
|
||||
|
||||
b.HasIndex("TenantId", "QuotaType", "RecordedAt");
|
||||
|
||||
b.ToTable("tenant_quota_usage_histories", null, t =>
|
||||
{
|
||||
t.HasComment("租户配额使用历史记录(用于追踪配额上下限与使用量的时间序列变化)。");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TakeoutSaaS.Domain.Tenants.Entities.TenantReviewClaim", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
|
||||
Reference in New Issue
Block a user