From 19137f3cf75bc58b7f1494f679c17fd9ac05dcca Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Wed, 3 Dec 2025 20:17:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A5=97=E9=A4=90=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E4=B8=8E=E9=85=8D=E9=A2=9D=E6=A0=A1=E9=AA=8C=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Document/12_BusinessTodo.md | 4 +- Document/API边界与自检清单.md | 4 +- .../Controllers/TenantPackagesController.cs | 90 +++++++++++++ .../Controllers/TenantsController.cs | 18 +++ .../Commands/CheckTenantQuotaCommand.cs | 26 ++++ .../Commands/CreateTenantPackageCommand.cs | 71 ++++++++++ .../Commands/DeleteTenantPackageCommand.cs | 14 ++ .../Commands/UpdateTenantPackageCommand.cs | 76 +++++++++++ .../App/Tenants/Dto/QuotaCheckResultDto.cs | 29 ++++ .../App/Tenants/Dto/TenantPackageDto.cs | 77 +++++++++++ .../CheckTenantQuotaCommandHandler.cs | 127 ++++++++++++++++++ .../CreateTenantPackageCommandHandler.cs | 46 +++++++ .../DeleteTenantPackageCommandHandler.cs | 20 +++ .../GetTenantPackageByIdQueryHandler.cs | 20 +++ .../SearchTenantPackagesQueryHandler.cs | 33 +++++ .../UpdateTenantPackageCommandHandler.cs | 48 +++++++ .../Queries/GetTenantPackageByIdQuery.cs | 15 +++ .../Queries/SearchTenantPackagesQuery.cs | 31 +++++ .../App/Tenants/TenantMapping.cs | 18 +++ .../Templates/RoleTemplateProvider.cs | 31 +++++ .../Repositories/ITenantPackageRepository.cs | 42 ++++++ .../ITenantQuotaUsageRepository.cs | 38 ++++++ .../AppServiceCollectionExtensions.cs | 2 + .../Repositories/EfTenantPackageRepository.cs | 69 ++++++++++ .../EfTenantQuotaUsageRepository.cs | 51 +++++++ 25 files changed, 996 insertions(+), 4 deletions(-) create mode 100644 src/Api/TakeoutSaaS.AdminApi/Controllers/TenantPackagesController.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CheckTenantQuotaCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantPackageCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Commands/DeleteTenantPackageCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Commands/UpdateTenantPackageCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Dto/QuotaCheckResultDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantPackageDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CheckTenantQuotaCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantPackageCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/DeleteTenantPackageCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantPackageByIdQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantPackagesQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/UpdateTenantPackageCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantPackageByIdQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantPackagesQuery.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantPackageRepository.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantQuotaUsageRepository.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantPackageRepository.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantQuotaUsageRepository.cs diff --git a/Document/12_BusinessTodo.md b/Document/12_BusinessTodo.md index 55128c6..02f9c18 100644 --- a/Document/12_BusinessTodo.md +++ b/Document/12_BusinessTodo.md @@ -10,8 +10,8 @@ - 已交付:`src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantsController.cs` 新增证照上传/审核、合同创建与状态更新、商户审核、审核日志、类目列表等 8 个端点;应用层新增 `AddMerchantDocumentCommand`、`CreateMerchantContractCommand`、`ReviewMerchantCommand` 等 Handler;`MerchantDocument/Contract/Audit` DTO 完整返回详情,文件 URL 仍通过 `/api/admin/v1/files/upload` 上 COS。仓储实现扩展 `EfMerchantRepository` 支持文档/合同/AuditLog 持久化,`TakeoutAppDbContext` 新增 `merchant_audit_logs` 表实现状态机追踪。 - [x] RBAC 模板:平台管理员、租户管理员、店长、店员四角色模板;API 可复制并允许租户自定义扩展。 - 已交付:新增模板目录 `RoleTemplateProvider`(`src/Application/TakeoutSaaS.Application/Identity/Templates`),提供四个预置角色与权限定义;应用层新增模板列表/详情查询、复制与租户批量初始化命令(Handlers 位于 `src/Application/TakeoutSaaS.Application/Identity/Handlers`)。管理端 `RolesController` 暴露模板列表、详情、按模板复制、批量初始化端点(`src/Api/TakeoutSaaS.AdminApi/Controllers/RolesController.cs`),复制时自动补齐缺失权限并保留租户自定义授权。 -- [ ] 配额与套餐:TenantPackage CRUD、订阅/续费/配额校验(门店/账号/短信/配送单量),超额返回 409 并记录 TenantQuotaUsage。 - - 当前:领域层已有 `TenantPackage`/`TenantSubscription` 等实体(`src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantPackage.cs:5-48`),数据库模型也同步生成,但 Admin API/应用层未暴露任何 CRUD 或配额校验逻辑。 +- [x] 配额与套餐:TenantPackage CRUD、订阅/续费/配额校验(门店/账号/短信/配送单量),超额返回 409 并记录 TenantQuotaUsage。 + - 已交付:新增套餐仓储与命令/查询/DTO(`src/Application/TakeoutSaaS.Application/App/Tenants`),Admin 端新增 `TenantPackagesController` 提供套餐列表/详情/创建/更新/删除接口。新增配额校验命令与租户接口 `/api/admin/v1/tenants/{id}/quotas/check`,基于当前订阅套餐限额校验并占用配额,超额抛出 409 并写入 `TenantQuotaUsage`。仓储注册于 `AddAppInfrastructure`。 - [ ] 租户运营面板:欠费/到期告警、账单列表、公告通知接口,支持已读状态并在 Admin UI 展示。 - 当前:`SystemParametersController` 仅负责普通参数 CRUD(`src/Api/TakeoutSaaS.AdminApi/Controllers/SystemParametersController.cs:15-104`),未包含租户账单、公告或通知接口。 - [ ] 门店管理:Store/StoreBusinessHour/StoreDeliveryZone/StoreHoliday CRUD 完整,含 GeoJSON 配送范围及能力开关。 diff --git a/Document/API边界与自检清单.md b/Document/API边界与自检清单.md index 57bf98e..af5bc58 100644 --- a/Document/API边界与自检清单.md +++ b/Document/API边界与自检清单.md @@ -8,14 +8,14 @@ - **鉴权**:JWT + RBAC(`[Authorize]` + `PermissionAuthorize`),必须带租户头 `X-Tenant-Id/Code`。 - **路由前缀**:`api/admin/v{version}/...`。 - **DTO/约束**:仅管理字段,禁止返回 C 端敏感信息;long -> string;严禁实体直接返回。 -- **现有控制器**:`AuthController`、`DeliveriesController`、`DictionaryController`、`FilesController`、`MerchantsController`、`OrdersController`、`PaymentsController`、`PermissionsController`、`RolesController`、`StoresController`、`SystemParametersController`、`UserPermissionsController`、`HealthController`。 +- **现有控制器**:`AuthController`、`DeliveriesController`、`DictionaryController`、`FilesController`、`MerchantsController`、`OrdersController`、`PaymentsController`、`PermissionsController`、`RolesController`、`StoresController`、`SystemParametersController`、`TenantPackagesController`、`TenantsController`、`UserPermissionsController`、`HealthController`。 - **自检清单**: 1. 是否需要权限/租户过滤?未加则补 `[Authorize]` + 租户解析。 2. 是否调用了应用层 CQRS,而非在 Controller 写业务? 3. DTO 是否按管理口径,未暴露用户端字段? 4. 是否使用参数化/AsNoTracking/投影,避免 N+1? 5. 路由和 Swagger 示例是否含租户/权限说明? -- **自检记录**:RolesController 新增模板列表/详情/复制/初始化端点,均已套用 `[Authorize]` + `PermissionAuthorize`、仅调用 CQRS/DTO,依赖租户头隔离。 +- **自检记录**:RolesController 新增模板列表/详情/复制/初始化端点,均已套用 `[Authorize]` + `PermissionAuthorize`、仅调用 CQRS/DTO,依赖租户头隔离。TenantPackagesController 与 TenantsController(配额校验) 均使用权限码、DTO 映射,配额校验要求携带租户头防越权。 ## 2. UserApi(C 端用户) - **面向对象**:App/H5 普通用户。 diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantPackagesController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantPackagesController.cs new file mode 100644 index 0000000..6f5d77a --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantPackagesController.cs @@ -0,0 +1,90 @@ +using System.ComponentModel.DataAnnotations; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Application.App.Tenants.Queries; +using TakeoutSaaS.Module.Authorization.Attributes; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Web.Api; + +namespace TakeoutSaaS.AdminApi.Controllers; + +/// +/// 租户套餐管理。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/admin/v{version:apiVersion}/tenant-packages")] +public sealed class TenantPackagesController(IMediator mediator) : BaseApiController +{ + /// + /// 分页查询租户套餐。 + /// + [HttpGet] + [PermissionAuthorize("tenant-package:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> Search([FromQuery] SearchTenantPackagesQuery query, CancellationToken cancellationToken) + { + var result = await mediator.Send(query, cancellationToken); + return ApiResponse>.Ok(result); + } + + /// + /// 查看套餐详情。 + /// + [HttpGet("{tenantPackageId:long}")] + [PermissionAuthorize("tenant-package:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Detail(long tenantPackageId, CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetTenantPackageByIdQuery { TenantPackageId = tenantPackageId }, cancellationToken); + return result is null + ? ApiResponse.Error(StatusCodes.Status404NotFound, "套餐不存在") + : ApiResponse.Ok(result); + } + + /// + /// 创建套餐。 + /// + [HttpPost] + [PermissionAuthorize("tenant-package:create")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Create([FromBody, Required] CreateTenantPackageCommand command, CancellationToken cancellationToken) + { + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 更新套餐。 + /// + [HttpPut("{tenantPackageId:long}")] + [PermissionAuthorize("tenant-package:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Update(long tenantPackageId, [FromBody, Required] UpdateTenantPackageCommand command, CancellationToken cancellationToken) + { + command = command with { TenantPackageId = tenantPackageId }; + var result = await mediator.Send(command, cancellationToken); + return result is null + ? ApiResponse.Error(StatusCodes.Status404NotFound, "套餐不存在") + : ApiResponse.Ok(result); + } + + /// + /// 删除套餐。 + /// + [HttpDelete("{tenantPackageId:long}")] + [PermissionAuthorize("tenant-package:delete")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Delete(long tenantPackageId, CancellationToken cancellationToken) + { + var command = new DeleteTenantPackageCommand { TenantPackageId = tenantPackageId }; + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs index 5fe90bc..f1c3bcd 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations; using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -134,4 +135,21 @@ public sealed class TenantsController(IMediator mediator) : BaseApiController var result = await mediator.Send(query, cancellationToken); return ApiResponse>.Ok(result); } + + /// + /// 配额校验并占用额度(门店/账号/短信/配送)。 + /// + /// 需在请求头携带 X-Tenant-Id 对应的租户。 + [HttpPost("{tenantId:long}/quotas/check")] + [PermissionAuthorize("tenant:quota:check")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> CheckQuota( + long tenantId, + [FromBody, Required] CheckTenantQuotaCommand body, + CancellationToken cancellationToken) + { + var command = body with { TenantId = tenantId }; + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } } diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CheckTenantQuotaCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CheckTenantQuotaCommand.cs new file mode 100644 index 0000000..3941e81 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CheckTenantQuotaCommand.cs @@ -0,0 +1,26 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 校验并消费租户配额命令。 +/// +public sealed record CheckTenantQuotaCommand : IRequest +{ + /// + /// 目标租户 ID。 + /// + public long TenantId { get; init; } + + /// + /// 配额类型。 + /// + public TenantQuotaType QuotaType { get; init; } + + /// + /// 本次申请使用量。 + /// + public decimal Delta { get; init; } = 1; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantPackageCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantPackageCommand.cs new file mode 100644 index 0000000..c809fca --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantPackageCommand.cs @@ -0,0 +1,71 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 创建租户套餐命令。 +/// +public sealed record CreateTenantPackageCommand : IRequest +{ + /// + /// 套餐名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 套餐描述。 + /// + public string? Description { get; init; } + + /// + /// 套餐类型。 + /// + public TenantPackageType PackageType { get; init; } = TenantPackageType.Standard; + + /// + /// 月付价格。 + /// + public decimal? MonthlyPrice { get; init; } + + /// + /// 年付价格。 + /// + public decimal? YearlyPrice { get; init; } + + /// + /// 最大门店数。 + /// + public int? MaxStoreCount { get; init; } + + /// + /// 最大账号数。 + /// + public int? MaxAccountCount { get; init; } + + /// + /// 存储上限(GB)。 + /// + public int? MaxStorageGb { get; init; } + + /// + /// 短信额度。 + /// + public int? MaxSmsCredits { get; init; } + + /// + /// 配送单上限。 + /// + public int? MaxDeliveryOrders { get; init; } + + /// + /// 权益明细 JSON。 + /// + public string? FeaturePoliciesJson { get; init; } + + /// + /// 是否可售。 + /// + public bool IsActive { get; init; } = true; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/DeleteTenantPackageCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/DeleteTenantPackageCommand.cs new file mode 100644 index 0000000..9f46a76 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/DeleteTenantPackageCommand.cs @@ -0,0 +1,14 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 删除租户套餐命令。 +/// +public sealed record DeleteTenantPackageCommand : IRequest +{ + /// + /// 套餐 ID。 + /// + public long TenantPackageId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/UpdateTenantPackageCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/UpdateTenantPackageCommand.cs new file mode 100644 index 0000000..a4529d3 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/UpdateTenantPackageCommand.cs @@ -0,0 +1,76 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 更新租户套餐命令。 +/// +public sealed record UpdateTenantPackageCommand : IRequest +{ + /// + /// 套餐 ID。 + /// + public long TenantPackageId { get; init; } + + /// + /// 套餐名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 套餐描述。 + /// + public string? Description { get; init; } + + /// + /// 套餐类型。 + /// + public TenantPackageType PackageType { get; init; } = TenantPackageType.Standard; + + /// + /// 月付价格。 + /// + public decimal? MonthlyPrice { get; init; } + + /// + /// 年付价格。 + /// + public decimal? YearlyPrice { get; init; } + + /// + /// 最大门店数。 + /// + public int? MaxStoreCount { get; init; } + + /// + /// 最大账号数。 + /// + public int? MaxAccountCount { get; init; } + + /// + /// 存储上限(GB)。 + /// + public int? MaxStorageGb { get; init; } + + /// + /// 短信额度。 + /// + public int? MaxSmsCredits { get; init; } + + /// + /// 配送单上限。 + /// + public int? MaxDeliveryOrders { get; init; } + + /// + /// 权益明细 JSON。 + /// + public string? FeaturePoliciesJson { get; init; } + + /// + /// 是否可售。 + /// + public bool IsActive { get; init; } = true; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/QuotaCheckResultDto.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/QuotaCheckResultDto.cs new file mode 100644 index 0000000..b9ce7e6 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/QuotaCheckResultDto.cs @@ -0,0 +1,29 @@ +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.Application.App.Tenants.Dto; + +/// +/// 配额校验结果。 +/// +public sealed class QuotaCheckResultDto +{ + /// + /// 配额类型。 + /// + public TenantQuotaType QuotaType { get; init; } + + /// + /// 当前配额上限,null 表示无限制。 + /// + public decimal? Limit { get; init; } + + /// + /// 已使用数量。 + /// + public decimal Used { get; init; } + + /// + /// 剩余额度,null 表示无限制。 + /// + public decimal? Remaining { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantPackageDto.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantPackageDto.cs new file mode 100644 index 0000000..52f9e90 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantPackageDto.cs @@ -0,0 +1,77 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Tenants.Dto; + +/// +/// 租户套餐 DTO。 +/// +public sealed class TenantPackageDto +{ + /// + /// 套餐 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 套餐名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 描述。 + /// + public string? Description { get; init; } + + /// + /// 套餐类型。 + /// + public TenantPackageType PackageType { get; init; } + + /// + /// 月付价格。 + /// + public decimal? MonthlyPrice { get; init; } + + /// + /// 年付价格。 + /// + public decimal? YearlyPrice { get; init; } + + /// + /// 最大门店数。 + /// + public int? MaxStoreCount { get; init; } + + /// + /// 最大账号数。 + /// + public int? MaxAccountCount { get; init; } + + /// + /// 存储上限(GB)。 + /// + public int? MaxStorageGb { get; init; } + + /// + /// 短信额度。 + /// + public int? MaxSmsCredits { get; init; } + + /// + /// 配送单上限。 + /// + public int? MaxDeliveryOrders { get; init; } + + /// + /// 权益明细 JSON。 + /// + public string? FeaturePoliciesJson { get; init; } + + /// + /// 是否可售。 + /// + public bool IsActive { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CheckTenantQuotaCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CheckTenantQuotaCommandHandler.cs new file mode 100644 index 0000000..77cae64 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CheckTenantQuotaCommandHandler.cs @@ -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; + +/// +/// 配额校验处理器。 +/// +public sealed class CheckTenantQuotaCommandHandler( + ITenantRepository tenantRepository, + ITenantPackageRepository packageRepository, + ITenantQuotaUsageRepository quotaUsageRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task 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); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantPackageCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantPackageCommandHandler.cs new file mode 100644 index 0000000..bc8cd90 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantPackageCommandHandler.cs @@ -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; + +/// +/// 创建租户套餐处理器。 +/// +public sealed class CreateTenantPackageCommandHandler(ITenantPackageRepository packageRepository) + : IRequestHandler +{ + /// + public async Task 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(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/DeleteTenantPackageCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/DeleteTenantPackageCommandHandler.cs new file mode 100644 index 0000000..232ea5e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/DeleteTenantPackageCommandHandler.cs @@ -0,0 +1,20 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Domain.Tenants.Repositories; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 删除租户套餐处理器。 +/// +public sealed class DeleteTenantPackageCommandHandler(ITenantPackageRepository packageRepository) + : IRequestHandler +{ + /// + public async Task Handle(DeleteTenantPackageCommand request, CancellationToken cancellationToken) + { + await packageRepository.DeleteAsync(request.TenantPackageId, cancellationToken); + await packageRepository.SaveChangesAsync(cancellationToken); + return true; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantPackageByIdQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantPackageByIdQueryHandler.cs new file mode 100644 index 0000000..ac6edac --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantPackageByIdQueryHandler.cs @@ -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; + +/// +/// 套餐详情查询处理器。 +/// +public sealed class GetTenantPackageByIdQueryHandler(ITenantPackageRepository packageRepository) + : IRequestHandler +{ + /// + public async Task Handle(GetTenantPackageByIdQuery request, CancellationToken cancellationToken) + { + var package = await packageRepository.FindByIdAsync(request.TenantPackageId, cancellationToken); + return package?.ToDto(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantPackagesQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantPackagesQueryHandler.cs new file mode 100644 index 0000000..5b4993c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantPackagesQueryHandler.cs @@ -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; + +/// +/// 套餐分页查询处理器。 +/// +public sealed class SearchTenantPackagesQueryHandler(ITenantPackageRepository packageRepository) + : IRequestHandler> +{ + /// + public async Task> 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(pagedItems, pageIndex, size, ordered.Count); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/UpdateTenantPackageCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/UpdateTenantPackageCommandHandler.cs new file mode 100644 index 0000000..77a1664 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/UpdateTenantPackageCommandHandler.cs @@ -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; + +/// +/// 更新租户套餐处理器。 +/// +public sealed class UpdateTenantPackageCommandHandler(ITenantPackageRepository packageRepository) + : IRequestHandler +{ + /// + public async Task 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(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantPackageByIdQuery.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantPackageByIdQuery.cs new file mode 100644 index 0000000..252b5fe --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantPackageByIdQuery.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; + +namespace TakeoutSaaS.Application.App.Tenants.Queries; + +/// +/// 获取套餐详情查询。 +/// +public sealed record GetTenantPackageByIdQuery : IRequest +{ + /// + /// 套餐 ID。 + /// + public long TenantPackageId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantPackagesQuery.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantPackagesQuery.cs new file mode 100644 index 0000000..c81a068 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantPackagesQuery.cs @@ -0,0 +1,31 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.Tenants.Queries; + +/// +/// 分页查询租户套餐。 +/// +public sealed record SearchTenantPackagesQuery : IRequest> +{ + /// + /// 搜索关键词(名称/描述)。 + /// + public string? Keyword { get; init; } + + /// + /// 是否筛选可售套餐。 + /// + public bool? IsActive { get; init; } + + /// + /// 页码。 + /// + public int Page { get; init; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; init; } = 20; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/TenantMapping.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/TenantMapping.cs index bc6cfe8..ab7fb4f 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/TenantMapping.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/TenantMapping.cs @@ -73,4 +73,22 @@ internal static class TenantMapping CurrentStatus = log.CurrentStatus, CreatedAt = log.CreatedAt }; + + public static TenantPackageDto ToDto(this TenantPackage package) + => new() + { + Id = package.Id, + Name = package.Name, + Description = package.Description, + PackageType = package.PackageType, + MonthlyPrice = package.MonthlyPrice, + YearlyPrice = package.YearlyPrice, + MaxStoreCount = package.MaxStoreCount, + MaxAccountCount = package.MaxAccountCount, + MaxStorageGb = package.MaxStorageGb, + MaxSmsCredits = package.MaxSmsCredits, + MaxDeliveryOrders = package.MaxDeliveryOrders, + FeaturePoliciesJson = package.FeaturePoliciesJson, + IsActive = package.IsActive + }; } diff --git a/src/Application/TakeoutSaaS.Application/Identity/Templates/RoleTemplateProvider.cs b/src/Application/TakeoutSaaS.Application/Identity/Templates/RoleTemplateProvider.cs index e492fd0..1d1482e 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Templates/RoleTemplateProvider.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Templates/RoleTemplateProvider.cs @@ -98,6 +98,36 @@ public sealed class RoleTemplateProvider : IRoleTemplateProvider Name = "租户订阅管理", Description = "创建、续费或调整租户套餐订阅。" }, + ["tenant:quota:check"] = new() + { + Code = "tenant:quota:check", + Name = "租户配额校验", + Description = "校验并占用门店、账号、短信、配送等配额。" + }, + ["tenant-package:read"] = new() + { + Code = "tenant-package:read", + Name = "套餐查询", + Description = "查看平台套餐定义列表与详情。" + }, + ["tenant-package:create"] = new() + { + Code = "tenant-package:create", + Name = "套餐创建", + Description = "创建平台套餐定义。" + }, + ["tenant-package:update"] = new() + { + Code = "tenant-package:update", + Name = "套餐更新", + Description = "更新平台套餐定义。" + }, + ["tenant-package:delete"] = new() + { + Code = "tenant-package:delete", + Name = "套餐删除", + Description = "删除平台套餐定义。" + }, ["merchant:create"] = new() { Code = "merchant:create", @@ -369,6 +399,7 @@ public sealed class RoleTemplateProvider : IRoleTemplateProvider "identity:permission:delete", "tenant:read", "tenant:subscription", + "tenant:quota:check", "merchant:read", "merchant:update", "merchant_category:read", diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantPackageRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantPackageRepository.cs new file mode 100644 index 0000000..7b63e27 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantPackageRepository.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Domain.Tenants.Entities; + +namespace TakeoutSaaS.Domain.Tenants.Repositories; + +/// +/// 租户套餐仓储。 +/// +public interface ITenantPackageRepository +{ + /// + /// 按 ID 查询套餐。 + /// + Task FindByIdAsync(long id, CancellationToken cancellationToken = default); + + /// + /// 按关键词与启用状态搜索套餐。 + /// + Task> SearchAsync(string? keyword, bool? isActive, CancellationToken cancellationToken = default); + + /// + /// 新增套餐。 + /// + Task AddAsync(TenantPackage package, CancellationToken cancellationToken = default); + + /// + /// 更新套餐。 + /// + Task UpdateAsync(TenantPackage package, CancellationToken cancellationToken = default); + + /// + /// 删除套餐。 + /// + Task DeleteAsync(long id, CancellationToken cancellationToken = default); + + /// + /// 持久化。 + /// + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantQuotaUsageRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantQuotaUsageRepository.cs new file mode 100644 index 0000000..ec3e7d7 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantQuotaUsageRepository.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.Domain.Tenants.Repositories; + +/// +/// 租户配额使用仓储。 +/// +public interface ITenantQuotaUsageRepository +{ + /// + /// 获取租户指定配额的使用情况。 + /// + Task FindAsync(long tenantId, TenantQuotaType quotaType, CancellationToken cancellationToken = default); + + /// + /// 按租户批量获取配额使用记录。 + /// + Task> GetByTenantAsync(long tenantId, CancellationToken cancellationToken = default); + + /// + /// 新增配额使用记录。 + /// + Task AddAsync(TenantQuotaUsage usage, CancellationToken cancellationToken = default); + + /// + /// 更新配额使用记录。 + /// + Task UpdateAsync(TenantQuotaUsage usage, CancellationToken cancellationToken = default); + + /// + /// 持久化。 + /// + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs index c9a416d..657f769 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs @@ -39,6 +39,8 @@ public static class AppServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddOptions() .Bind(configuration.GetSection(AppSeedOptions.SectionName)) diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantPackageRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantPackageRepository.cs new file mode 100644 index 0000000..0a2cd22 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantPackageRepository.cs @@ -0,0 +1,69 @@ +using System.Linq; +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Infrastructure.App.Persistence; + +namespace TakeoutSaaS.Infrastructure.App.Repositories; + +/// +/// 租户套餐仓储实现。 +/// +public sealed class EfTenantPackageRepository(TakeoutAppDbContext context) : ITenantPackageRepository +{ + /// + public Task FindByIdAsync(long id, CancellationToken cancellationToken = default) + { + return context.TenantPackages.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + } + + /// + public async Task> SearchAsync(string? keyword, bool? isActive, CancellationToken cancellationToken = default) + { + var query = context.TenantPackages.AsNoTracking(); + + if (!string.IsNullOrWhiteSpace(keyword)) + { + var normalized = keyword.Trim(); + query = query.Where(x => EF.Functions.ILike(x.Name, $"%{normalized}%") || EF.Functions.ILike(x.Description ?? string.Empty, $"%{normalized}%")); + } + + if (isActive.HasValue) + { + query = query.Where(x => x.IsActive == isActive.Value); + } + + return await query + .OrderByDescending(x => x.CreatedAt) + .ToListAsync(cancellationToken); + } + + /// + public Task AddAsync(TenantPackage package, CancellationToken cancellationToken = default) + { + return context.TenantPackages.AddAsync(package, cancellationToken).AsTask(); + } + + /// + public Task UpdateAsync(TenantPackage package, CancellationToken cancellationToken = default) + { + context.TenantPackages.Update(package); + return Task.CompletedTask; + } + + /// + public async Task DeleteAsync(long id, CancellationToken cancellationToken = default) + { + var entity = await context.TenantPackages.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + if (entity != null) + { + context.TenantPackages.Remove(entity); + } + } + + /// + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return context.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantQuotaUsageRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantQuotaUsageRepository.cs new file mode 100644 index 0000000..778bada --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantQuotaUsageRepository.cs @@ -0,0 +1,51 @@ +using System.Linq; +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Infrastructure.App.Persistence; + +namespace TakeoutSaaS.Infrastructure.App.Repositories; + +/// +/// 租户配额使用仓储实现。 +/// +public sealed class EfTenantQuotaUsageRepository(TakeoutAppDbContext context) : ITenantQuotaUsageRepository +{ + /// + public Task FindAsync(long tenantId, TenantQuotaType quotaType, CancellationToken cancellationToken = default) + { + return context.TenantQuotaUsages + .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.QuotaType == quotaType, cancellationToken); + } + + /// + public Task> GetByTenantAsync(long tenantId, CancellationToken cancellationToken = default) + { + return context.TenantQuotaUsages + .AsNoTracking() + .Where(x => x.TenantId == tenantId) + .OrderBy(x => x.QuotaType) + .ToListAsync(cancellationToken) + .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); + } + + /// + public Task AddAsync(TenantQuotaUsage usage, CancellationToken cancellationToken = default) + { + return context.TenantQuotaUsages.AddAsync(usage, cancellationToken).AsTask(); + } + + /// + public Task UpdateAsync(TenantQuotaUsage usage, CancellationToken cancellationToken = default) + { + context.TenantQuotaUsages.Update(usage); + return Task.CompletedTask; + } + + /// + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return context.SaveChangesAsync(cancellationToken); + } +}