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

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

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

View File

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

View File

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

View File

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

View File

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

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)
{

View File

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

View File

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