feat: 套餐管理与配额校验能力

This commit is contained in:
2025-12-03 20:17:55 +08:00
parent ea33e6fefe
commit 19137f3cf7
25 changed files with 996 additions and 4 deletions

View File

@@ -0,0 +1,127 @@
using System;
using MediatR;
using TakeoutSaaS.Application.App.Tenants.Commands;
using TakeoutSaaS.Application.App.Tenants.Dto;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
/// <summary>
/// 配额校验处理器。
/// </summary>
public sealed class CheckTenantQuotaCommandHandler(
ITenantRepository tenantRepository,
ITenantPackageRepository packageRepository,
ITenantQuotaUsageRepository quotaUsageRepository,
ITenantProvider tenantProvider)
: IRequestHandler<CheckTenantQuotaCommand, QuotaCheckResultDto>
{
/// <inheritdoc />
public async Task<QuotaCheckResultDto> Handle(CheckTenantQuotaCommand request, CancellationToken cancellationToken)
{
if (request.Delta <= 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "配额消耗量必须大于 0");
}
var currentTenantId = tenantProvider.GetCurrentTenantId();
if (currentTenantId == 0 || currentTenantId != request.TenantId)
{
throw new BusinessException(ErrorCodes.Forbidden, "租户上下文不匹配,请在请求头 X-Tenant-Id 指定目标租户");
}
// 1. 获取租户与当前订阅。
_ = await tenantRepository.FindByIdAsync(request.TenantId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在");
var subscription = await tenantRepository.GetActiveSubscriptionAsync(request.TenantId, cancellationToken);
if (subscription == null || subscription.EffectiveTo <= DateTime.UtcNow)
{
throw new BusinessException(ErrorCodes.Conflict, "订阅不存在或已到期");
}
var package = await packageRepository.FindByIdAsync(subscription.TenantPackageId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "套餐不存在");
var limit = ResolveLimit(package, request.QuotaType);
// 2. 加载配额使用记录并计算。
var usage = await quotaUsageRepository.FindAsync(request.TenantId, request.QuotaType, cancellationToken)
?? new TenantQuotaUsage
{
TenantId = request.TenantId,
QuotaType = request.QuotaType,
LimitValue = limit ?? 0,
UsedValue = 0,
ResetCycle = ResolveResetCycle(request.QuotaType)
};
var usedAfter = usage.UsedValue + request.Delta;
if (limit.HasValue && usedAfter > (decimal)limit.Value)
{
usage.LimitValue = limit.Value;
await PersistUsageAsync(usage, quotaUsageRepository, cancellationToken);
throw new BusinessException(ErrorCodes.Conflict, $"{request.QuotaType} 配额不足");
}
usage.LimitValue = limit ?? usage.LimitValue;
usage.UsedValue = usedAfter;
usage.ResetCycle ??= ResolveResetCycle(request.QuotaType);
await PersistUsageAsync(usage, quotaUsageRepository, cancellationToken);
return new QuotaCheckResultDto
{
QuotaType = request.QuotaType,
Limit = limit,
Used = usage.UsedValue,
Remaining = limit.HasValue ? limit.Value - usage.UsedValue : null
};
}
private static decimal? ResolveLimit(TenantPackage package, TenantQuotaType quotaType)
{
return quotaType switch
{
TenantQuotaType.StoreCount => package.MaxStoreCount,
TenantQuotaType.AccountCount => package.MaxAccountCount,
TenantQuotaType.Storage => package.MaxStorageGb,
TenantQuotaType.SmsCredits => package.MaxSmsCredits,
TenantQuotaType.DeliveryOrders => package.MaxDeliveryOrders,
_ => null
};
}
private static string ResolveResetCycle(TenantQuotaType quotaType)
{
return quotaType switch
{
TenantQuotaType.SmsCredits => "monthly",
TenantQuotaType.DeliveryOrders => "monthly",
_ => "lifetime"
};
}
private static async Task PersistUsageAsync(
TenantQuotaUsage usage,
ITenantQuotaUsageRepository quotaUsageRepository,
CancellationToken cancellationToken)
{
// 判断是否为新增。
if (usage.Id == 0)
{
await quotaUsageRepository.AddAsync(usage, cancellationToken);
}
else
{
await quotaUsageRepository.UpdateAsync(usage, cancellationToken);
}
await quotaUsageRepository.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,46 @@
using MediatR;
using TakeoutSaaS.Application.App.Tenants.Commands;
using TakeoutSaaS.Application.App.Tenants.Dto;
using TakeoutSaaS.Domain.Tenants.Entities;
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 CreateTenantPackageCommandHandler(ITenantPackageRepository packageRepository)
: IRequestHandler<CreateTenantPackageCommand, TenantPackageDto>
{
/// <inheritdoc />
public async Task<TenantPackageDto> Handle(CreateTenantPackageCommand request, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.Name))
{
throw new BusinessException(ErrorCodes.BadRequest, "套餐名称不能为空");
}
var package = new TenantPackage
{
Name = request.Name.Trim(),
Description = request.Description,
PackageType = request.PackageType,
MonthlyPrice = request.MonthlyPrice,
YearlyPrice = request.YearlyPrice,
MaxStoreCount = request.MaxStoreCount,
MaxAccountCount = request.MaxAccountCount,
MaxStorageGb = request.MaxStorageGb,
MaxSmsCredits = request.MaxSmsCredits,
MaxDeliveryOrders = request.MaxDeliveryOrders,
FeaturePoliciesJson = request.FeaturePoliciesJson,
IsActive = request.IsActive
};
await packageRepository.AddAsync(package, cancellationToken);
await packageRepository.SaveChangesAsync(cancellationToken);
return package.ToDto();
}
}

View File

@@ -0,0 +1,20 @@
using MediatR;
using TakeoutSaaS.Application.App.Tenants.Commands;
using TakeoutSaaS.Domain.Tenants.Repositories;
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
/// <summary>
/// 删除租户套餐处理器。
/// </summary>
public sealed class DeleteTenantPackageCommandHandler(ITenantPackageRepository packageRepository)
: IRequestHandler<DeleteTenantPackageCommand, bool>
{
/// <inheritdoc />
public async Task<bool> Handle(DeleteTenantPackageCommand request, CancellationToken cancellationToken)
{
await packageRepository.DeleteAsync(request.TenantPackageId, cancellationToken);
await packageRepository.SaveChangesAsync(cancellationToken);
return true;
}
}

View File

@@ -0,0 +1,20 @@
using MediatR;
using TakeoutSaaS.Application.App.Tenants.Dto;
using TakeoutSaaS.Application.App.Tenants.Queries;
using TakeoutSaaS.Domain.Tenants.Repositories;
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
/// <summary>
/// 套餐详情查询处理器。
/// </summary>
public sealed class GetTenantPackageByIdQueryHandler(ITenantPackageRepository packageRepository)
: IRequestHandler<GetTenantPackageByIdQuery, TenantPackageDto?>
{
/// <inheritdoc />
public async Task<TenantPackageDto?> Handle(GetTenantPackageByIdQuery request, CancellationToken cancellationToken)
{
var package = await packageRepository.FindByIdAsync(request.TenantPackageId, cancellationToken);
return package?.ToDto();
}
}

View File

@@ -0,0 +1,33 @@
using System.Linq;
using MediatR;
using TakeoutSaaS.Application.App.Tenants.Dto;
using TakeoutSaaS.Application.App.Tenants.Queries;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
/// <summary>
/// 套餐分页查询处理器。
/// </summary>
public sealed class SearchTenantPackagesQueryHandler(ITenantPackageRepository packageRepository)
: IRequestHandler<SearchTenantPackagesQuery, PagedResult<TenantPackageDto>>
{
/// <inheritdoc />
public async Task<PagedResult<TenantPackageDto>> Handle(SearchTenantPackagesQuery request, CancellationToken cancellationToken)
{
var packages = await packageRepository.SearchAsync(request.Keyword, request.IsActive, cancellationToken);
var ordered = packages.OrderByDescending(x => x.CreatedAt).ToList();
var pageIndex = request.Page <= 0 ? 1 : request.Page;
var size = request.PageSize <= 0 ? 20 : request.PageSize;
var pagedItems = ordered
.Skip((pageIndex - 1) * size)
.Take(size)
.Select(x => x.ToDto())
.ToList();
return new PagedResult<TenantPackageDto>(pagedItems, pageIndex, size, ordered.Count);
}
}

View File

@@ -0,0 +1,48 @@
using MediatR;
using TakeoutSaaS.Application.App.Tenants.Commands;
using TakeoutSaaS.Application.App.Tenants.Dto;
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 UpdateTenantPackageCommandHandler(ITenantPackageRepository packageRepository)
: IRequestHandler<UpdateTenantPackageCommand, TenantPackageDto?>
{
/// <inheritdoc />
public async Task<TenantPackageDto?> Handle(UpdateTenantPackageCommand request, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.Name))
{
throw new BusinessException(ErrorCodes.BadRequest, "套餐名称不能为空");
}
var package = await packageRepository.FindByIdAsync(request.TenantPackageId, cancellationToken);
if (package == null)
{
return null;
}
package.Name = request.Name.Trim();
package.Description = request.Description;
package.PackageType = request.PackageType;
package.MonthlyPrice = request.MonthlyPrice;
package.YearlyPrice = request.YearlyPrice;
package.MaxStoreCount = request.MaxStoreCount;
package.MaxAccountCount = request.MaxAccountCount;
package.MaxStorageGb = request.MaxStorageGb;
package.MaxSmsCredits = request.MaxSmsCredits;
package.MaxDeliveryOrders = request.MaxDeliveryOrders;
package.FeaturePoliciesJson = request.FeaturePoliciesJson;
package.IsActive = request.IsActive;
await packageRepository.UpdateAsync(package, cancellationToken);
await packageRepository.SaveChangesAsync(cancellationToken);
return package.ToDto();
}
}