feat: 套餐管理与配额校验能力
This commit is contained in:
@@ -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` 表实现状态机追踪。
|
- 已交付:`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 可复制并允许租户自定义扩展。
|
- [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`),复制时自动补齐缺失权限并保留租户自定义授权。
|
- 已交付:新增模板目录 `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。
|
- [x] 配额与套餐:TenantPackage CRUD、订阅/续费/配额校验(门店/账号/短信/配送单量),超额返回 409 并记录 TenantQuotaUsage。
|
||||||
- 当前:领域层已有 `TenantPackage`/`TenantSubscription` 等实体(`src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantPackage.cs:5-48`),数据库模型也同步生成,但 Admin API/应用层未暴露任何 CRUD 或配额校验逻辑。
|
- 已交付:新增套餐仓储与命令/查询/DTO(`src/Application/TakeoutSaaS.Application/App/Tenants`),Admin 端新增 `TenantPackagesController` 提供套餐列表/详情/创建/更新/删除接口。新增配额校验命令与租户接口 `/api/admin/v1/tenants/{id}/quotas/check`,基于当前订阅套餐限额校验并占用配额,超额抛出 409 并写入 `TenantQuotaUsage`。仓储注册于 `AddAppInfrastructure`。
|
||||||
- [ ] 租户运营面板:欠费/到期告警、账单列表、公告通知接口,支持已读状态并在 Admin UI 展示。
|
- [ ] 租户运营面板:欠费/到期告警、账单列表、公告通知接口,支持已读状态并在 Admin UI 展示。
|
||||||
- 当前:`SystemParametersController` 仅负责普通参数 CRUD(`src/Api/TakeoutSaaS.AdminApi/Controllers/SystemParametersController.cs:15-104`),未包含租户账单、公告或通知接口。
|
- 当前:`SystemParametersController` 仅负责普通参数 CRUD(`src/Api/TakeoutSaaS.AdminApi/Controllers/SystemParametersController.cs:15-104`),未包含租户账单、公告或通知接口。
|
||||||
- [ ] 门店管理:Store/StoreBusinessHour/StoreDeliveryZone/StoreHoliday CRUD 完整,含 GeoJSON 配送范围及能力开关。
|
- [ ] 门店管理:Store/StoreBusinessHour/StoreDeliveryZone/StoreHoliday CRUD 完整,含 GeoJSON 配送范围及能力开关。
|
||||||
|
|||||||
@@ -8,14 +8,14 @@
|
|||||||
- **鉴权**:JWT + RBAC(`[Authorize]` + `PermissionAuthorize`),必须带租户头 `X-Tenant-Id/Code`。
|
- **鉴权**:JWT + RBAC(`[Authorize]` + `PermissionAuthorize`),必须带租户头 `X-Tenant-Id/Code`。
|
||||||
- **路由前缀**:`api/admin/v{version}/...`。
|
- **路由前缀**:`api/admin/v{version}/...`。
|
||||||
- **DTO/约束**:仅管理字段,禁止返回 C 端敏感信息;long -> string;严禁实体直接返回。
|
- **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]` + 租户解析。
|
1. 是否需要权限/租户过滤?未加则补 `[Authorize]` + 租户解析。
|
||||||
2. 是否调用了应用层 CQRS,而非在 Controller 写业务?
|
2. 是否调用了应用层 CQRS,而非在 Controller 写业务?
|
||||||
3. DTO 是否按管理口径,未暴露用户端字段?
|
3. DTO 是否按管理口径,未暴露用户端字段?
|
||||||
4. 是否使用参数化/AsNoTracking/投影,避免 N+1?
|
4. 是否使用参数化/AsNoTracking/投影,避免 N+1?
|
||||||
5. 路由和 Swagger 示例是否含租户/权限说明?
|
5. 路由和 Swagger 示例是否含租户/权限说明?
|
||||||
- **自检记录**:RolesController 新增模板列表/详情/复制/初始化端点,均已套用 `[Authorize]` + `PermissionAuthorize`、仅调用 CQRS/DTO,依赖租户头隔离。
|
- **自检记录**:RolesController 新增模板列表/详情/复制/初始化端点,均已套用 `[Authorize]` + `PermissionAuthorize`、仅调用 CQRS/DTO,依赖租户头隔离。TenantPackagesController 与 TenantsController(配额校验) 均使用权限码、DTO 映射,配额校验要求携带租户头防越权。
|
||||||
|
|
||||||
## 2. UserApi(C 端用户)
|
## 2. UserApi(C 端用户)
|
||||||
- **面向对象**:App/H5 普通用户。
|
- **面向对象**:App/H5 普通用户。
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 租户套餐管理。
|
||||||
|
/// </summary>
|
||||||
|
[ApiVersion("1.0")]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/admin/v{version:apiVersion}/tenant-packages")]
|
||||||
|
public sealed class TenantPackagesController(IMediator mediator) : BaseApiController
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 分页查询租户套餐。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet]
|
||||||
|
[PermissionAuthorize("tenant-package:read")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<PagedResult<TenantPackageDto>>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<PagedResult<TenantPackageDto>>> Search([FromQuery] SearchTenantPackagesQuery query, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await mediator.Send(query, cancellationToken);
|
||||||
|
return ApiResponse<PagedResult<TenantPackageDto>>.Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查看套餐详情。
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("{tenantPackageId:long}")]
|
||||||
|
[PermissionAuthorize("tenant-package:read")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<TenantPackageDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<TenantPackageDto>), StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ApiResponse<TenantPackageDto>> Detail(long tenantPackageId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await mediator.Send(new GetTenantPackageByIdQuery { TenantPackageId = tenantPackageId }, cancellationToken);
|
||||||
|
return result is null
|
||||||
|
? ApiResponse<TenantPackageDto>.Error(StatusCodes.Status404NotFound, "套餐不存在")
|
||||||
|
: ApiResponse<TenantPackageDto>.Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 创建套餐。
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
[PermissionAuthorize("tenant-package:create")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<TenantPackageDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<TenantPackageDto>> Create([FromBody, Required] CreateTenantPackageCommand command, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await mediator.Send(command, cancellationToken);
|
||||||
|
return ApiResponse<TenantPackageDto>.Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新套餐。
|
||||||
|
/// </summary>
|
||||||
|
[HttpPut("{tenantPackageId:long}")]
|
||||||
|
[PermissionAuthorize("tenant-package:update")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<TenantPackageDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<TenantPackageDto>), StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ApiResponse<TenantPackageDto>> 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<TenantPackageDto>.Error(StatusCodes.Status404NotFound, "套餐不存在")
|
||||||
|
: ApiResponse<TenantPackageDto>.Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 删除套餐。
|
||||||
|
/// </summary>
|
||||||
|
[HttpDelete("{tenantPackageId:long}")]
|
||||||
|
[PermissionAuthorize("tenant-package:delete")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<bool>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<bool>> Delete(long tenantPackageId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var command = new DeleteTenantPackageCommand { TenantPackageId = tenantPackageId };
|
||||||
|
var result = await mediator.Send(command, cancellationToken);
|
||||||
|
return ApiResponse<bool>.Ok(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
@@ -134,4 +135,21 @@ public sealed class TenantsController(IMediator mediator) : BaseApiController
|
|||||||
var result = await mediator.Send(query, cancellationToken);
|
var result = await mediator.Send(query, cancellationToken);
|
||||||
return ApiResponse<PagedResult<TenantAuditLogDto>>.Ok(result);
|
return ApiResponse<PagedResult<TenantAuditLogDto>>.Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 配额校验并占用额度(门店/账号/短信/配送)。
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>需在请求头携带 X-Tenant-Id 对应的租户。</remarks>
|
||||||
|
[HttpPost("{tenantId:long}/quotas/check")]
|
||||||
|
[PermissionAuthorize("tenant:quota:check")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<QuotaCheckResultDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<QuotaCheckResultDto>> CheckQuota(
|
||||||
|
long tenantId,
|
||||||
|
[FromBody, Required] CheckTenantQuotaCommand body,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var command = body with { TenantId = tenantId };
|
||||||
|
var result = await mediator.Send(command, cancellationToken);
|
||||||
|
return ApiResponse<QuotaCheckResultDto>.Ok(result);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||||
|
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Tenants.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 校验并消费租户配额命令。
|
||||||
|
/// </summary>
|
||||||
|
public sealed record CheckTenantQuotaCommand : IRequest<QuotaCheckResultDto>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 目标租户 ID。
|
||||||
|
/// </summary>
|
||||||
|
public long TenantId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 配额类型。
|
||||||
|
/// </summary>
|
||||||
|
public TenantQuotaType QuotaType { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 本次申请使用量。
|
||||||
|
/// </summary>
|
||||||
|
public decimal Delta { get; init; } = 1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||||
|
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Tenants.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 创建租户套餐命令。
|
||||||
|
/// </summary>
|
||||||
|
public sealed record CreateTenantPackageCommand : IRequest<TenantPackageDto>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 套餐名称。
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 套餐描述。
|
||||||
|
/// </summary>
|
||||||
|
public string? Description { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 套餐类型。
|
||||||
|
/// </summary>
|
||||||
|
public TenantPackageType PackageType { get; init; } = TenantPackageType.Standard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 月付价格。
|
||||||
|
/// </summary>
|
||||||
|
public decimal? MonthlyPrice { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 年付价格。
|
||||||
|
/// </summary>
|
||||||
|
public decimal? YearlyPrice { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 最大门店数。
|
||||||
|
/// </summary>
|
||||||
|
public int? MaxStoreCount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 最大账号数。
|
||||||
|
/// </summary>
|
||||||
|
public int? MaxAccountCount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 存储上限(GB)。
|
||||||
|
/// </summary>
|
||||||
|
public int? MaxStorageGb { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 短信额度。
|
||||||
|
/// </summary>
|
||||||
|
public int? MaxSmsCredits { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 配送单上限。
|
||||||
|
/// </summary>
|
||||||
|
public int? MaxDeliveryOrders { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 权益明细 JSON。
|
||||||
|
/// </summary>
|
||||||
|
public string? FeaturePoliciesJson { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否可售。
|
||||||
|
/// </summary>
|
||||||
|
public bool IsActive { get; init; } = true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Tenants.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 删除租户套餐命令。
|
||||||
|
/// </summary>
|
||||||
|
public sealed record DeleteTenantPackageCommand : IRequest<bool>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 套餐 ID。
|
||||||
|
/// </summary>
|
||||||
|
public long TenantPackageId { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||||
|
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Tenants.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新租户套餐命令。
|
||||||
|
/// </summary>
|
||||||
|
public sealed record UpdateTenantPackageCommand : IRequest<TenantPackageDto?>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 套餐 ID。
|
||||||
|
/// </summary>
|
||||||
|
public long TenantPackageId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 套餐名称。
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 套餐描述。
|
||||||
|
/// </summary>
|
||||||
|
public string? Description { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 套餐类型。
|
||||||
|
/// </summary>
|
||||||
|
public TenantPackageType PackageType { get; init; } = TenantPackageType.Standard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 月付价格。
|
||||||
|
/// </summary>
|
||||||
|
public decimal? MonthlyPrice { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 年付价格。
|
||||||
|
/// </summary>
|
||||||
|
public decimal? YearlyPrice { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 最大门店数。
|
||||||
|
/// </summary>
|
||||||
|
public int? MaxStoreCount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 最大账号数。
|
||||||
|
/// </summary>
|
||||||
|
public int? MaxAccountCount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 存储上限(GB)。
|
||||||
|
/// </summary>
|
||||||
|
public int? MaxStorageGb { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 短信额度。
|
||||||
|
/// </summary>
|
||||||
|
public int? MaxSmsCredits { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 配送单上限。
|
||||||
|
/// </summary>
|
||||||
|
public int? MaxDeliveryOrders { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 权益明细 JSON。
|
||||||
|
/// </summary>
|
||||||
|
public string? FeaturePoliciesJson { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否可售。
|
||||||
|
/// </summary>
|
||||||
|
public bool IsActive { get; init; } = true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Tenants.Dto;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 配额校验结果。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class QuotaCheckResultDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 配额类型。
|
||||||
|
/// </summary>
|
||||||
|
public TenantQuotaType QuotaType { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 当前配额上限,null 表示无限制。
|
||||||
|
/// </summary>
|
||||||
|
public decimal? Limit { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 已使用数量。
|
||||||
|
/// </summary>
|
||||||
|
public decimal Used { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 剩余额度,null 表示无限制。
|
||||||
|
/// </summary>
|
||||||
|
public decimal? Remaining { get; init; }
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 租户套餐 DTO。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TenantPackageDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 套餐 ID。
|
||||||
|
/// </summary>
|
||||||
|
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||||
|
public long Id { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 套餐名称。
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 描述。
|
||||||
|
/// </summary>
|
||||||
|
public string? Description { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 套餐类型。
|
||||||
|
/// </summary>
|
||||||
|
public TenantPackageType PackageType { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 月付价格。
|
||||||
|
/// </summary>
|
||||||
|
public decimal? MonthlyPrice { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 年付价格。
|
||||||
|
/// </summary>
|
||||||
|
public decimal? YearlyPrice { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 最大门店数。
|
||||||
|
/// </summary>
|
||||||
|
public int? MaxStoreCount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 最大账号数。
|
||||||
|
/// </summary>
|
||||||
|
public int? MaxAccountCount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 存储上限(GB)。
|
||||||
|
/// </summary>
|
||||||
|
public int? MaxStorageGb { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 短信额度。
|
||||||
|
/// </summary>
|
||||||
|
public int? MaxSmsCredits { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 配送单上限。
|
||||||
|
/// </summary>
|
||||||
|
public int? MaxDeliveryOrders { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 权益明细 JSON。
|
||||||
|
/// </summary>
|
||||||
|
public string? FeaturePoliciesJson { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否可售。
|
||||||
|
/// </summary>
|
||||||
|
public bool IsActive { get; init; }
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Tenants.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取套餐详情查询。
|
||||||
|
/// </summary>
|
||||||
|
public sealed record GetTenantPackageByIdQuery : IRequest<TenantPackageDto?>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 套餐 ID。
|
||||||
|
/// </summary>
|
||||||
|
public long TenantPackageId { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using MediatR;
|
||||||
|
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||||
|
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Application.App.Tenants.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 分页查询租户套餐。
|
||||||
|
/// </summary>
|
||||||
|
public sealed record SearchTenantPackagesQuery : IRequest<PagedResult<TenantPackageDto>>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 搜索关键词(名称/描述)。
|
||||||
|
/// </summary>
|
||||||
|
public string? Keyword { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否筛选可售套餐。
|
||||||
|
/// </summary>
|
||||||
|
public bool? IsActive { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 页码。
|
||||||
|
/// </summary>
|
||||||
|
public int Page { get; init; } = 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 每页条数。
|
||||||
|
/// </summary>
|
||||||
|
public int PageSize { get; init; } = 20;
|
||||||
|
}
|
||||||
@@ -73,4 +73,22 @@ internal static class TenantMapping
|
|||||||
CurrentStatus = log.CurrentStatus,
|
CurrentStatus = log.CurrentStatus,
|
||||||
CreatedAt = log.CreatedAt
|
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
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,36 @@ public sealed class RoleTemplateProvider : IRoleTemplateProvider
|
|||||||
Name = "租户订阅管理",
|
Name = "租户订阅管理",
|
||||||
Description = "创建、续费或调整租户套餐订阅。"
|
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()
|
["merchant:create"] = new()
|
||||||
{
|
{
|
||||||
Code = "merchant:create",
|
Code = "merchant:create",
|
||||||
@@ -369,6 +399,7 @@ public sealed class RoleTemplateProvider : IRoleTemplateProvider
|
|||||||
"identity:permission:delete",
|
"identity:permission:delete",
|
||||||
"tenant:read",
|
"tenant:read",
|
||||||
"tenant:subscription",
|
"tenant:subscription",
|
||||||
|
"tenant:quota:check",
|
||||||
"merchant:read",
|
"merchant:read",
|
||||||
"merchant:update",
|
"merchant:update",
|
||||||
"merchant_category:read",
|
"merchant_category:read",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 租户套餐仓储。
|
||||||
|
/// </summary>
|
||||||
|
public interface ITenantPackageRepository
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 按 ID 查询套餐。
|
||||||
|
/// </summary>
|
||||||
|
Task<TenantPackage?> FindByIdAsync(long id, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 按关键词与启用状态搜索套餐。
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<TenantPackage>> SearchAsync(string? keyword, bool? isActive, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 新增套餐。
|
||||||
|
/// </summary>
|
||||||
|
Task AddAsync(TenantPackage package, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新套餐。
|
||||||
|
/// </summary>
|
||||||
|
Task UpdateAsync(TenantPackage package, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 删除套餐。
|
||||||
|
/// </summary>
|
||||||
|
Task DeleteAsync(long id, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 持久化。
|
||||||
|
/// </summary>
|
||||||
|
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 租户配额使用仓储。
|
||||||
|
/// </summary>
|
||||||
|
public interface ITenantQuotaUsageRepository
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 获取租户指定配额的使用情况。
|
||||||
|
/// </summary>
|
||||||
|
Task<TenantQuotaUsage?> FindAsync(long tenantId, TenantQuotaType quotaType, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 按租户批量获取配额使用记录。
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<TenantQuotaUsage>> GetByTenantAsync(long tenantId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 新增配额使用记录。
|
||||||
|
/// </summary>
|
||||||
|
Task AddAsync(TenantQuotaUsage usage, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新配额使用记录。
|
||||||
|
/// </summary>
|
||||||
|
Task UpdateAsync(TenantQuotaUsage usage, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 持久化。
|
||||||
|
/// </summary>
|
||||||
|
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -39,6 +39,8 @@ public static class AppServiceCollectionExtensions
|
|||||||
services.AddScoped<IPaymentRepository, EfPaymentRepository>();
|
services.AddScoped<IPaymentRepository, EfPaymentRepository>();
|
||||||
services.AddScoped<IDeliveryRepository, EfDeliveryRepository>();
|
services.AddScoped<IDeliveryRepository, EfDeliveryRepository>();
|
||||||
services.AddScoped<ITenantRepository, EfTenantRepository>();
|
services.AddScoped<ITenantRepository, EfTenantRepository>();
|
||||||
|
services.AddScoped<ITenantPackageRepository, EfTenantPackageRepository>();
|
||||||
|
services.AddScoped<ITenantQuotaUsageRepository, EfTenantQuotaUsageRepository>();
|
||||||
|
|
||||||
services.AddOptions<AppSeedOptions>()
|
services.AddOptions<AppSeedOptions>()
|
||||||
.Bind(configuration.GetSection(AppSeedOptions.SectionName))
|
.Bind(configuration.GetSection(AppSeedOptions.SectionName))
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 租户套餐仓储实现。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class EfTenantPackageRepository(TakeoutAppDbContext context) : ITenantPackageRepository
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<TenantPackage?> FindByIdAsync(long id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return context.TenantPackages.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IReadOnlyList<TenantPackage>> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task AddAsync(TenantPackage package, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return context.TenantPackages.AddAsync(package, cancellationToken).AsTask();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task UpdateAsync(TenantPackage package, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
context.TenantPackages.Update(package);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 租户配额使用仓储实现。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class EfTenantQuotaUsageRepository(TakeoutAppDbContext context) : ITenantQuotaUsageRepository
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<TenantQuotaUsage?> FindAsync(long tenantId, TenantQuotaType quotaType, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return context.TenantQuotaUsages
|
||||||
|
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.QuotaType == quotaType, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<IReadOnlyList<TenantQuotaUsage>> GetByTenantAsync(long tenantId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return context.TenantQuotaUsages
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(x => x.TenantId == tenantId)
|
||||||
|
.OrderBy(x => x.QuotaType)
|
||||||
|
.ToListAsync(cancellationToken)
|
||||||
|
.ContinueWith(t => (IReadOnlyList<TenantQuotaUsage>)t.Result, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task AddAsync(TenantQuotaUsage usage, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return context.TenantQuotaUsages.AddAsync(usage, cancellationToken).AsTask();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task UpdateAsync(TenantQuotaUsage usage, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
context.TenantQuotaUsages.Update(usage);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user