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