From 0f900e108d9e73c0d0513fc8e6e2768f357bb127 Mon Sep 17 00:00:00 2001 From: MSuMshk <173331402+msumshk@users.noreply.github.com> Date: Mon, 2 Feb 2026 09:11:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E6=96=B0=E5=A2=9E=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=91=98=E8=A7=92=E8=89=B2=E3=80=81=E8=B4=A6=E5=8D=95?= =?UTF-8?q?=E3=80=81=E8=AE=A2=E9=98=85=E3=80=81=E5=A5=97=E9=A4=90=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 AdminRolesController 实现角色 CRUD 和权限管理 - 新增 BillingsController 实现账单查询功能 - 新增 SubscriptionsController 实现订阅管理功能 - 新增 TenantPackagesController 实现套餐管理功能 - 新增租户详情、配额使用、账单列表等查询功能 - 新增 TenantPackage、TenantSubscription 等领域实体 - 新增相关枚举:SubscriptionStatus、TenantPackageType 等 - 更新 appsettings 配置文件 - 更新权限授权策略提供者 Co-Authored-By: Claude Opus 4.5 --- .../Controllers/AdminRolesController.cs | 197 ++++++++++ .../Controllers/BillingsController.cs | 147 +++++++ .../Controllers/SubscriptionsController.cs | 243 ++++++++++++ .../Controllers/TenantPackagesController.cs | 238 ++++++++++++ .../Controllers/TenantsController.cs | 82 ++++ .../appsettings.Development.json | 4 +- .../appsettings.Production.json | 4 +- .../Commands/ConfirmPaymentCommand.cs | 41 ++ .../Billings/Contracts/BillingDetailDto.cs | 197 ++++++++++ .../App/Billings/Contracts/BillingListDto.cs | 98 +++++ .../Handlers/ConfirmPaymentCommandHandler.cs | 92 +++++ .../Handlers/GetBillingDetailQueryHandler.cs | 54 +++ .../Handlers/ListBillingsQueryHandler.cs | 61 +++ .../Billings/Queries/GetBillingDetailQuery.cs | 15 + .../App/Billings/Queries/ListBillingsQuery.cs | 72 ++++ .../Commands/ChangePlanCommand.cs | 30 ++ .../Commands/ExtendSubscriptionCommand.cs | 25 ++ .../Commands/UpdateStatusCommand.cs | 26 ++ .../Commands/UpdateSubscriptionCommand.cs | 25 ++ .../Contracts/SubscriptionDetailDto.cs | 222 +++++++++++ .../Contracts/SubscriptionListDto.cs | 95 +++++ .../Handlers/ChangePlanCommandHandler.cs | 91 +++++ .../ExtendSubscriptionCommandHandler.cs | 83 ++++ .../GetSubscriptionDetailQueryHandler.cs | 152 ++++++++ .../Handlers/ListSubscriptionsQueryHandler.cs | 58 +++ .../Handlers/UpdateStatusCommandHandler.cs | 78 ++++ .../UpdateSubscriptionCommandHandler.cs | 75 ++++ .../Queries/GetSubscriptionDetailQuery.cs | 15 + .../Queries/ListSubscriptionsQuery.cs | 62 +++ .../Commands/CreateTenantPackageCommand.cs | 101 +++++ .../Commands/DeleteTenantPackageCommand.cs | 14 + .../Commands/UpdateTenantPackageCommand.cs | 106 +++++ .../Contracts/TenantPackageListDto.cs | 107 +++++ .../Contracts/TenantPackageTenantDto.cs | 52 +++ .../Contracts/TenantPackageUsageDto.cs | 56 +++ .../CreateTenantPackageCommandHandler.cs | 79 ++++ .../DeleteTenantPackageCommandHandler.cs | 36 ++ .../GetTenantPackageDetailQueryHandler.cs | 52 +++ .../GetTenantPackageTenantsQueryHandler.cs | 45 +++ .../GetTenantPackageUsagesQueryHandler.cs | 36 ++ .../ListTenantPackagesQueryHandler.cs | 55 +++ .../UpdateTenantPackageCommandHandler.cs | 75 ++++ .../Queries/GetTenantPackageDetailQuery.cs | 15 + .../Queries/GetTenantPackageTenantsQuery.cs | 36 ++ .../Queries/GetTenantPackageUsagesQuery.cs | 15 + .../Queries/ListTenantPackagesQuery.cs | 31 ++ .../Tenants/Contracts/TenantBillingListDto.cs | 98 +++++ .../App/Tenants/Contracts/TenantDetailDto.cs | 365 ++++++++++++++++++ .../Tenants/Contracts/TenantQuotaUsageDto.cs | 47 +++ .../Handlers/GetTenantBillingsQueryHandler.cs | 56 +++ .../Handlers/GetTenantDetailQueryHandler.cs | 128 ++++++ .../GetTenantQuotaUsageQueryHandler.cs | 34 ++ .../Tenants/Queries/GetTenantBillingsQuery.cs | 26 ++ .../Tenants/Queries/GetTenantDetailQuery.cs | 15 + .../Queries/GetTenantQuotaUsageQuery.cs | 15 + .../Commands/CloneAdminRoleCommand.cs | 35 ++ .../Commands/CreateAdminRoleCommand.cs | 25 ++ .../Commands/DeleteAdminRoleCommand.cs | 14 + .../Commands/UpdateAdminRoleCommand.cs | 25 ++ .../UpdateAdminRolePermissionsCommand.cs | 19 + .../Handlers/CloneAdminRoleCommandHandler.cs | 86 +++++ .../Handlers/CreateAdminRoleCommandHandler.cs | 59 +++ .../Handlers/DeleteAdminRoleCommandHandler.cs | 33 ++ .../GetAdminRolePermissionsQueryHandler.cs | 59 +++ .../Handlers/ListAdminRolesQueryHandler.cs | 48 +++ .../Handlers/UpdateAdminRoleCommandHandler.cs | 52 +++ ...pdateAdminRolePermissionsCommandHandler.cs | 36 ++ .../Queries/GetAdminRolePermissionsQuery.cs | 15 + .../Identity/Queries/ListAdminRolesQuery.cs | 26 ++ .../Entities/TenantBillingStatement.cs | 95 +++++ .../Billings/Entities/TenantPayment.cs | 75 ++++ .../Billings/Enums/TenantBillingStatus.cs | 27 ++ .../Billings/Enums/TenantBillingType.cs | 27 ++ .../Billings/Enums/TenantPaymentMethod.cs | 22 ++ .../Billings/Enums/TenantPaymentStatus.cs | 27 ++ .../Repositories/IBillingRepository.cs | 128 ++++++ .../Tenants/Entities/TenantPackage.cs | 100 +++++ .../Tenants/Entities/TenantQuotaUsage.cs | 40 ++ .../Tenants/Entities/TenantSubscription.cs | 60 +++ .../Entities/TenantSubscriptionHistory.cs | 60 +++ .../Entities/TenantVerificationProfile.cs | 95 +++++ .../Tenants/Enums/SubscriptionChangeType.cs | 47 +++ .../Tenants/Enums/SubscriptionStatus.cs | 27 ++ .../Enums/TenantPackagePublishStatus.cs | 17 + .../Tenants/Enums/TenantPackageType.cs | 27 ++ .../Tenants/Enums/TenantQuotaType.cs | 32 ++ .../Tenants/Enums/TenantVerificationStatus.cs | 27 ++ .../Repositories/ISubscriptionRepository.cs | 146 +++++++ .../Repositories/ITenantPackageRepository.cs | 120 ++++++ .../Tenants/Repositories/ITenantRepository.cs | 40 ++ .../AppServiceCollectionExtensions.cs | 4 + .../App/Persistence/TakeoutAppDbContext.cs | 157 ++++++++ .../App/Repositories/EfBillingRepository.cs | 241 ++++++++++++ .../Repositories/EfSubscriptionRepository.cs | 272 +++++++++++++ .../Repositories/EfTenantPackageRepository.cs | 234 +++++++++++ .../App/Repositories/EfTenantRepository.cs | 70 ++++ .../PermissionAuthorizationPolicyProvider.cs | 33 +- 97 files changed, 7047 insertions(+), 12 deletions(-) create mode 100644 src/Api/TakeoutSaaS.AdminApi/Controllers/AdminRolesController.cs create mode 100644 src/Api/TakeoutSaaS.AdminApi/Controllers/BillingsController.cs create mode 100644 src/Api/TakeoutSaaS.AdminApi/Controllers/SubscriptionsController.cs create mode 100644 src/Api/TakeoutSaaS.AdminApi/Controllers/TenantPackagesController.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Billings/Commands/ConfirmPaymentCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Billings/Contracts/BillingDetailDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Billings/Contracts/BillingListDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Billings/Handlers/ConfirmPaymentCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Billings/Handlers/GetBillingDetailQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Billings/Handlers/ListBillingsQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Billings/Queries/GetBillingDetailQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Billings/Queries/ListBillingsQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Subscriptions/Commands/ChangePlanCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Subscriptions/Commands/ExtendSubscriptionCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Subscriptions/Commands/UpdateStatusCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Subscriptions/Commands/UpdateSubscriptionCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Subscriptions/Contracts/SubscriptionDetailDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Subscriptions/Contracts/SubscriptionListDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/ChangePlanCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/ExtendSubscriptionCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/GetSubscriptionDetailQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/ListSubscriptionsQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/UpdateStatusCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/UpdateSubscriptionCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Subscriptions/Queries/GetSubscriptionDetailQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Subscriptions/Queries/ListSubscriptionsQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/TenantPackages/Commands/CreateTenantPackageCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/TenantPackages/Commands/DeleteTenantPackageCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/TenantPackages/Commands/UpdateTenantPackageCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/TenantPackages/Contracts/TenantPackageListDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/TenantPackages/Contracts/TenantPackageTenantDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/TenantPackages/Contracts/TenantPackageUsageDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/TenantPackages/Handlers/CreateTenantPackageCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/TenantPackages/Handlers/DeleteTenantPackageCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/TenantPackages/Handlers/GetTenantPackageDetailQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/TenantPackages/Handlers/GetTenantPackageTenantsQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/TenantPackages/Handlers/GetTenantPackageUsagesQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/TenantPackages/Handlers/ListTenantPackagesQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/TenantPackages/Handlers/UpdateTenantPackageCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/TenantPackages/Queries/GetTenantPackageDetailQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/TenantPackages/Queries/GetTenantPackageTenantsQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/TenantPackages/Queries/GetTenantPackageUsagesQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/TenantPackages/Queries/ListTenantPackagesQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Contracts/TenantBillingListDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Contracts/TenantDetailDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Contracts/TenantQuotaUsageDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantBillingsQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantDetailQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantQuotaUsageQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantBillingsQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantDetailQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantQuotaUsageQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Commands/CloneAdminRoleCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Commands/CreateAdminRoleCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Commands/DeleteAdminRoleCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Commands/UpdateAdminRoleCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Commands/UpdateAdminRolePermissionsCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Handlers/CloneAdminRoleCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Handlers/CreateAdminRoleCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Handlers/DeleteAdminRoleCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Handlers/GetAdminRolePermissionsQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Handlers/ListAdminRolesQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdateAdminRoleCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdateAdminRolePermissionsCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Queries/GetAdminRolePermissionsQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/Identity/Queries/ListAdminRolesQuery.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Billings/Entities/TenantBillingStatement.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Billings/Entities/TenantPayment.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Billings/Enums/TenantBillingStatus.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Billings/Enums/TenantBillingType.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Billings/Enums/TenantPaymentMethod.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Billings/Enums/TenantPaymentStatus.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Billings/Repositories/IBillingRepository.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantPackage.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantQuotaUsage.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantSubscription.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantSubscriptionHistory.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantVerificationProfile.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Enums/SubscriptionChangeType.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Enums/SubscriptionStatus.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantPackagePublishStatus.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantPackageType.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantQuotaType.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantVerificationStatus.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ISubscriptionRepository.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantPackageRepository.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfBillingRepository.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfSubscriptionRepository.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantPackageRepository.cs diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/AdminRolesController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/AdminRolesController.cs new file mode 100644 index 0000000..b8113a0 --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/AdminRolesController.cs @@ -0,0 +1,197 @@ +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using System.ComponentModel.DataAnnotations; +using TakeoutSaaS.Application.Identity.Commands; +using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Application.Identity.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}/roles")] +public sealed class AdminRolesController(IMediator mediator) : BaseApiController +{ + /// + /// 获取平台角色列表。 + /// + /// 关键字(角色名称/编码)。 + /// 页码(从 1 开始)。 + /// 每页条数。 + /// 取消标记。 + /// 角色分页列表。 + [HttpGet] + [PermissionAuthorize("identity:role:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] string? keyword, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + CancellationToken cancellationToken = default) + { + // 1. 构造查询 + var query = new ListAdminRolesQuery + { + Keyword = keyword, + Page = page, + PageSize = pageSize + }; + + // 2. 执行查询 + var result = await mediator.Send(query, cancellationToken); + + // 3. 返回分页结果 + return ApiResponse>.Ok(result); + } + + /// + /// 创建平台角色。 + /// + /// 创建命令。 + /// 取消标记。 + /// 创建后的角色。 + [HttpPost] + [PermissionAuthorize("identity:role:create")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Create( + [FromBody, Required] CreateAdminRoleCommand command, + CancellationToken cancellationToken) + { + // 1. 执行创建 + var result = await mediator.Send(command, cancellationToken); + + // 2. 返回创建结果 + return ApiResponse.Ok(result); + } + + /// + /// 更新平台角色。 + /// + /// 角色 ID。 + /// 更新命令。 + /// 取消标记。 + /// 更新后的角色。 + [HttpPut("{roleId:long}")] + [PermissionAuthorize("identity:role:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Update( + long roleId, + [FromBody, Required] UpdateAdminRoleCommand command, + CancellationToken cancellationToken) + { + // 1. 绑定路由参数 + command = command with { RoleId = roleId }; + + // 2. 执行更新 + var result = await mediator.Send(command, cancellationToken); + + // 3. 返回更新结果或 404 + return result is null + ? ApiResponse.Error(StatusCodes.Status404NotFound, "角色不存在") + : ApiResponse.Ok(result); + } + + /// + /// 获取平台角色权限列表。 + /// + /// 角色 ID。 + /// 取消标记。 + /// 权限集合。 + [HttpGet("{roleId:long}/permissions")] + [PermissionAuthorize("identity:role:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> GetPermissions( + long roleId, + CancellationToken cancellationToken) + { + // 1. 构造查询 + var query = new GetAdminRolePermissionsQuery { RoleId = roleId }; + + // 2. 执行查询 + var result = await mediator.Send(query, cancellationToken); + + // 3. 返回权限集合 + return ApiResponse>.Ok(result); + } + + /// + /// 更新平台角色权限。 + /// + /// 角色 ID。 + /// 更新命令。 + /// 取消标记。 + /// 更新结果。 + [HttpPut("{roleId:long}/permissions")] + [PermissionAuthorize("identity:role:bind-permission")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> UpdatePermissions( + long roleId, + [FromBody, Required] UpdateAdminRolePermissionsCommand command, + CancellationToken cancellationToken) + { + // 1. 绑定路由参数 + command = command with { RoleId = roleId }; + + // 2. 执行更新 + var result = await mediator.Send(command, cancellationToken); + + // 3. 返回更新结果 + return ApiResponse.Ok(result); + } + + /// + /// 删除平台角色。 + /// + /// 角色 ID。 + /// 取消标记。 + /// 删除结果。 + [HttpDelete("{roleId:long}")] + [PermissionAuthorize("identity:role:delete")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Delete( + long roleId, + CancellationToken cancellationToken) + { + // 1. 构造命令 + var command = new DeleteAdminRoleCommand { RoleId = roleId }; + + // 2. 执行删除 + var result = await mediator.Send(command, cancellationToken); + + // 3. 返回删除结果 + return ApiResponse.Ok(result); + } + + /// + /// 克隆平台角色。 + /// + /// 源角色 ID。 + /// 克隆命令。 + /// 取消标记。 + /// 克隆后的新角色。 + [HttpPost("{roleId:long}/clone")] + [PermissionAuthorize("identity:role:create")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Clone( + long roleId, + [FromBody, Required] CloneAdminRoleCommand command, + CancellationToken cancellationToken) + { + // 1. 绑定路由参数 + command = command with { SourceRoleId = roleId }; + + // 2. 执行克隆 + var result = await mediator.Send(command, cancellationToken); + + // 3. 返回克隆结果 + return ApiResponse.Ok(result); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/BillingsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/BillingsController.cs new file mode 100644 index 0000000..c34edac --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/BillingsController.cs @@ -0,0 +1,147 @@ +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using TakeoutSaaS.Application.App.Billings.Commands; +using TakeoutSaaS.Application.App.Billings.Contracts; +using TakeoutSaaS.Application.App.Billings.Queries; +using TakeoutSaaS.Domain.Billings.Enums; +using TakeoutSaaS.Module.Authorization.Attributes; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Web.Api; + +namespace TakeoutSaaS.AdminApi.Controllers; + +/// +/// 账单管理。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/admin/v{version:apiVersion}/billings")] +public sealed class BillingsController(IMediator mediator) : BaseApiController +{ + /// + /// 获取账单列表(分页)。 + /// + /// 租户 ID。 + /// 账单状态。 + /// 账单类型。 + /// 开始日期。 + /// 结束日期。 + /// 最小金额。 + /// 最大金额。 + /// 关键词(账单号、租户名)。 + /// 排序字段。 + /// 是否降序。 + /// 页码(从 1 开始)。 + /// 每页条数。 + /// 取消标记。 + /// 账单分页列表。 + [HttpGet] + [PermissionAuthorize("tenant:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] long? TenantId, + [FromQuery] TenantBillingStatus? Status, + [FromQuery] TenantBillingType? BillingType, + [FromQuery] DateTime? StartDate, + [FromQuery] DateTime? EndDate, + [FromQuery] decimal? MinAmount, + [FromQuery] decimal? MaxAmount, + [FromQuery] string? Keyword, + [FromQuery] string? SortBy, + [FromQuery] bool? SortDesc, + [FromQuery] int PageNumber = 1, + [FromQuery] int PageSize = 10, + CancellationToken cancellationToken = default) + { + // 1. 构造查询 + var query = new ListBillingsQuery + { + TenantId = TenantId, + Status = Status, + BillingType = BillingType, + StartDate = StartDate, + EndDate = EndDate, + MinAmount = MinAmount, + MaxAmount = MaxAmount, + Keyword = Keyword, + SortBy = SortBy, + SortDesc = SortDesc, + PageNumber = PageNumber, + PageSize = PageSize + }; + + // 2. 执行查询 + var result = await mediator.Send(query, cancellationToken); + + // 3. 返回账单分页列表 + return ApiResponse>.Ok(result); + } + + /// + /// 获取账单详情。 + /// + /// 账单 ID。 + /// 取消标记。 + /// 账单详情。 + [HttpGet("{id:long}")] + [PermissionAuthorize("tenant:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> GetDetail( + [FromRoute] long id, + CancellationToken cancellationToken = default) + { + // 1. 构造查询 + var query = new GetBillingDetailQuery { BillingId = id }; + + // 2. 执行查询 + var result = await mediator.Send(query, cancellationToken); + + // 3. 如果不存在,返回 404 + if (result is null) + { + return ApiResponse.Error( + ErrorCodes.NotFound, + "账单不存在"); + } + + // 4. 返回账单详情 + return ApiResponse.Ok(result); + } + + /// + /// 一键确认收款(记录支付 + 立即审核通过 + 同步更新账单状态)。 + /// + /// 账单 ID。 + /// 确认收款命令。 + /// 取消标记。 + /// 支付记录。 + [HttpPost("{id:long}/payments/confirm")] + [PermissionAuthorize("tenant:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> ConfirmPayment( + [FromRoute] long id, + [FromBody] ConfirmPaymentCommand command, + CancellationToken cancellationToken = default) + { + // 1. 构造命令(使用路由中的 ID) + var cmd = command with { BillingId = id }; + + // 2. 执行命令 + var result = await mediator.Send(cmd, cancellationToken); + + // 3. 如果不存在,返回 404 + if (result is null) + { + return ApiResponse.Error( + ErrorCodes.NotFound, + "账单不存在"); + } + + // 4. 返回支付记录 + return ApiResponse.Ok(result); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/SubscriptionsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/SubscriptionsController.cs new file mode 100644 index 0000000..0083789 --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/SubscriptionsController.cs @@ -0,0 +1,243 @@ +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using TakeoutSaaS.Application.App.Subscriptions.Commands; +using TakeoutSaaS.Application.App.Subscriptions.Contracts; +using TakeoutSaaS.Application.App.Subscriptions.Queries; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Module.Authorization.Attributes; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Web.Api; + +namespace TakeoutSaaS.AdminApi.Controllers; + +/// +/// 订阅管理。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/admin/v{version:apiVersion}/subscriptions")] +public sealed class SubscriptionsController(IMediator mediator) : BaseApiController +{ + /// + /// 获取订阅列表(分页)。 + /// + /// 订阅状态。 + /// 套餐 ID。 + /// 租户 ID。 + /// 租户关键词(名称或编码)。 + /// 即将到期天数筛选。 + /// 是否自动续费。 + /// 到期时间范围开始。 + /// 到期时间范围结束。 + /// 页码(从 1 开始)。 + /// 每页条数。 + /// 取消标记。 + /// 订阅分页列表。 + [HttpGet] + [PermissionAuthorize("tenant:subscription")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] SubscriptionStatus? Status, + [FromQuery] long? TenantPackageId, + [FromQuery] long? TenantId, + [FromQuery] string? TenantKeyword, + [FromQuery] int? ExpiringWithinDays, + [FromQuery] bool? AutoRenew, + [FromQuery] DateTime? ExpireFrom, + [FromQuery] DateTime? ExpireTo, + [FromQuery] int Page = 1, + [FromQuery] int PageSize = 10, + CancellationToken cancellationToken = default) + { + // 1. 构造查询 + var query = new ListSubscriptionsQuery + { + Status = Status, + TenantPackageId = TenantPackageId, + TenantId = TenantId, + TenantKeyword = TenantKeyword, + ExpiringWithinDays = ExpiringWithinDays, + AutoRenew = AutoRenew, + ExpireFrom = ExpireFrom, + ExpireTo = ExpireTo, + Page = Page, + PageSize = PageSize + }; + + // 2. 执行查询 + var result = await mediator.Send(query, cancellationToken); + + // 3. 返回订阅分页列表 + return ApiResponse>.Ok(result); + } + + /// + /// 获取订阅详情。 + /// + /// 订阅 ID。 + /// 取消标记。 + /// 订阅详情。 + [HttpGet("{id:long}")] + [PermissionAuthorize("tenant:subscription")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> GetDetail( + [FromRoute] long id, + CancellationToken cancellationToken = default) + { + // 1. 构造查询 + var query = new GetSubscriptionDetailQuery { SubscriptionId = id }; + + // 2. 执行查询 + var result = await mediator.Send(query, cancellationToken); + + // 3. 如果不存在,返回 404 + if (result is null) + { + return ApiResponse.Error( + ErrorCodes.NotFound, + "订阅不存在"); + } + + // 4. 返回订阅详情 + return ApiResponse.Ok(result); + } + + /// + /// 更新订阅。 + /// + /// 订阅 ID。 + /// 更新命令。 + /// 取消标记。 + /// 更新后的订阅信息。 + [HttpPut("{id:long}")] + [PermissionAuthorize("tenant:subscription")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Update( + [FromRoute] long id, + [FromBody] UpdateSubscriptionCommand command, + CancellationToken cancellationToken = default) + { + // 1. 构造命令(使用路由中的 ID) + var cmd = command with { SubscriptionId = id }; + + // 2. 执行命令 + var result = await mediator.Send(cmd, cancellationToken); + + // 3. 如果不存在,返回 404 + if (result is null) + { + return ApiResponse.Error( + ErrorCodes.NotFound, + "订阅不存在"); + } + + // 4. 返回更新后的订阅信息 + return ApiResponse.Ok(result); + } + + /// + /// 延期订阅。 + /// + /// 订阅 ID。 + /// 延期命令。 + /// 取消标记。 + /// 延期后的订阅信息。 + [HttpPost("{id:long}/extend")] + [PermissionAuthorize("tenant:subscription")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Extend( + [FromRoute] long id, + [FromBody] ExtendSubscriptionCommand command, + CancellationToken cancellationToken = default) + { + // 1. 构造命令(使用路由中的 ID) + var cmd = command with { SubscriptionId = id }; + + // 2. 执行命令 + var result = await mediator.Send(cmd, cancellationToken); + + // 3. 如果不存在,返回 404 + if (result is null) + { + return ApiResponse.Error( + ErrorCodes.NotFound, + "订阅不存在"); + } + + // 4. 返回延期后的订阅信息 + return ApiResponse.Ok(result); + } + + /// + /// 变更套餐。 + /// + /// 订阅 ID。 + /// 变更套餐命令。 + /// 取消标记。 + /// 变更后的订阅信息。 + [HttpPost("{id:long}/change-plan")] + [PermissionAuthorize("tenant:subscription")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> ChangePlan( + [FromRoute] long id, + [FromBody] ChangePlanCommand command, + CancellationToken cancellationToken = default) + { + // 1. 构造命令(使用路由中的 ID) + var cmd = command with { SubscriptionId = id }; + + // 2. 执行命令 + var result = await mediator.Send(cmd, cancellationToken); + + // 3. 如果不存在,返回 404 + if (result is null) + { + return ApiResponse.Error( + ErrorCodes.NotFound, + "订阅不存在"); + } + + // 4. 返回变更后的订阅信息 + return ApiResponse.Ok(result); + } + + /// + /// 更新订阅状态。 + /// + /// 订阅 ID。 + /// 更新状态命令。 + /// 取消标记。 + /// 更新后的订阅信息。 + [HttpPost("{id:long}/status")] + [PermissionAuthorize("tenant:subscription")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> UpdateStatus( + [FromRoute] long id, + [FromBody] UpdateStatusCommand command, + CancellationToken cancellationToken = default) + { + // 1. 构造命令(使用路由中的 ID) + var cmd = command with { SubscriptionId = id }; + + // 2. 执行命令 + var result = await mediator.Send(cmd, cancellationToken); + + // 3. 如果不存在,返回 404 + if (result is null) + { + return ApiResponse.Error( + ErrorCodes.NotFound, + "订阅不存在"); + } + + // 4. 返回更新后的订阅信息 + return ApiResponse.Ok(result); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantPackagesController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantPackagesController.cs new file mode 100644 index 0000000..59ddfb9 --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantPackagesController.cs @@ -0,0 +1,238 @@ +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using TakeoutSaaS.Application.App.TenantPackages.Commands; +using TakeoutSaaS.Application.App.TenantPackages.Contracts; +using TakeoutSaaS.Application.App.TenantPackages.Queries; +using TakeoutSaaS.Module.Authorization.Attributes; +using TakeoutSaaS.Shared.Abstractions.Constants; +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 +{ + /// + /// 获取租户套餐列表(分页)。 + /// + /// 关键字(套餐名称)。 + /// 是否启用。 + /// 页码(从 1 开始)。 + /// 每页条数。 + /// 取消标记。 + /// 套餐分页列表。 + [HttpGet] + [PermissionAuthorize("tenant-package:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] string? Keyword, + [FromQuery] bool? IsActive, + [FromQuery] int Page = 1, + [FromQuery] int PageSize = 10, + CancellationToken cancellationToken = default) + { + // 1. 构造查询 + var query = new ListTenantPackagesQuery + { + Keyword = Keyword, + IsActive = IsActive, + Page = Page, + PageSize = PageSize + }; + + // 2. 执行查询 + var result = await mediator.Send(query, cancellationToken); + + // 3. 返回套餐分页列表 + return ApiResponse>.Ok(result); + } + + /// + /// 获取租户套餐详情。 + /// + /// 套餐 ID。 + /// 取消标记。 + /// 套餐详情。 + [HttpGet("{tenantPackageId:long}")] + [PermissionAuthorize("tenant-package:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> GetDetail( + [FromRoute] long tenantPackageId, + CancellationToken cancellationToken = default) + { + // 1. 构造查询 + var query = new GetTenantPackageDetailQuery + { + TenantPackageId = tenantPackageId + }; + + // 2. 执行查询 + var result = await mediator.Send(query, cancellationToken); + + // 3. 如果不存在,返回 404 + if (result is null) + { + return ApiResponse.Error(ErrorCodes.NotFound, "套餐不存在"); + } + + // 4. 返回套餐详情 + return ApiResponse.Ok(result); + } + + /// + /// 获取租户套餐使用统计。 + /// + /// 套餐 ID 列表。 + /// 取消标记。 + /// 套餐使用统计列表。 + [HttpGet("usages")] + [PermissionAuthorize("tenant-package:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> GetUsages( + [FromQuery] long[] tenantPackageIds, + CancellationToken cancellationToken = default) + { + // 1. 构造查询 + var query = new GetTenantPackageUsagesQuery + { + TenantPackageIds = tenantPackageIds + }; + + // 2. 执行查询 + var result = await mediator.Send(query, cancellationToken); + + // 3. 返回套餐使用统计 + return ApiResponse>.Ok(result); + } + + /// + /// 创建租户套餐。 + /// + /// 创建命令。 + /// 取消标记。 + /// 创建的套餐。 + [HttpPost] + [PermissionAuthorize("tenant-package:create")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Create( + [FromBody] CreateTenantPackageCommand command, + CancellationToken cancellationToken = default) + { + // 1. 执行命令 + var result = await mediator.Send(command, cancellationToken); + + // 2. 返回创建的套餐 + return ApiResponse.Ok(result); + } + + /// + /// 更新租户套餐。 + /// + /// 套餐 ID。 + /// 更新命令。 + /// 取消标记。 + /// 更新后的套餐。 + [HttpPut("{tenantPackageId:long}")] + [PermissionAuthorize("tenant-package:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Update( + [FromRoute] long tenantPackageId, + [FromBody] UpdateTenantPackageCommand command, + CancellationToken cancellationToken = default) + { + // 1. 确保路由 ID 与命令 ID 一致 + var updatedCommand = command with { TenantPackageId = tenantPackageId }; + + // 2. 执行命令 + var result = await mediator.Send(updatedCommand, cancellationToken); + + // 3. 如果不存在,返回 404 + if (result is null) + { + return ApiResponse.Error(ErrorCodes.NotFound, "套餐不存在"); + } + + // 4. 返回更新后的套餐 + return ApiResponse.Ok(result); + } + + /// + /// 删除租户套餐(软删除)。 + /// + /// 套餐 ID。 + /// 取消标记。 + /// 是否删除成功。 + [HttpDelete("{tenantPackageId:long}")] + [PermissionAuthorize("tenant-package:delete")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Delete( + [FromRoute] long tenantPackageId, + CancellationToken cancellationToken = default) + { + // 1. 构造命令 + var command = new DeleteTenantPackageCommand + { + TenantPackageId = tenantPackageId + }; + + // 2. 执行命令 + var result = await mediator.Send(command, cancellationToken); + + // 3. 如果不存在,返回 404 + if (!result) + { + return ApiResponse.Error(ErrorCodes.NotFound, "套餐不存在"); + } + + // 4. 返回成功 + return ApiResponse.Ok(true); + } + + /// + /// 获取套餐当前使用租户列表(按有效订阅口径)。 + /// + /// 套餐 ID。 + /// 关键字(租户名称或编码)。 + /// 即将到期天数筛选。 + /// 页码(从 1 开始)。 + /// 每页条数。 + /// 取消标记。 + /// 租户分页列表。 + [HttpGet("{tenantPackageId:long}/tenants")] + [PermissionAuthorize("tenant-package:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> GetTenants( + [FromRoute] long tenantPackageId, + [FromQuery] string? Keyword, + [FromQuery] int? ExpiringWithinDays, + [FromQuery] int Page = 1, + [FromQuery] int PageSize = 20, + CancellationToken cancellationToken = default) + { + // 1. 构造查询 + var query = new GetTenantPackageTenantsQuery + { + TenantPackageId = tenantPackageId, + Keyword = Keyword, + ExpiringWithinDays = ExpiringWithinDays, + Page = Page, + PageSize = PageSize + }; + + // 2. 执行查询 + var result = await mediator.Send(query, cancellationToken); + + // 3. 返回租户分页列表 + return ApiResponse>.Ok(result); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs index 6ca9776..13c7a97 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs @@ -48,4 +48,86 @@ public sealed class TenantsController(IMediator mediator) : BaseApiController // 3. 返回租户分页列表 return ApiResponse>.Ok(result); } + + /// + /// 获取租户详情(包含认证、订阅、套餐信息)。 + /// + /// 租户 ID(雪花算法)。 + /// 取消标记。 + /// 租户详情。 + [HttpGet("{id:long}")] + [PermissionAuthorize("tenant:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task GetDetail(long id, CancellationToken cancellationToken = default) + { + // 1. 构造查询 + var query = new GetTenantDetailQuery { TenantId = id }; + + // 2. 执行查询 + var result = await mediator.Send(query, cancellationToken); + + // 3. 返回结果 + if (result is null) + { + return NotFound(ApiResponse.Error(404, "租户不存在")); + } + + return Ok(ApiResponse.Ok(result)); + } + + /// + /// 获取租户配额使用情况。 + /// + /// 租户 ID(雪花算法)。 + /// 取消标记。 + /// 配额使用情况列表。 + [HttpGet("{id:long}/quota-usage")] + [PermissionAuthorize("tenant:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> GetQuotaUsage( + long id, + CancellationToken cancellationToken = default) + { + // 1. 构造查询 + var query = new GetTenantQuotaUsageQuery { TenantId = id }; + + // 2. 执行查询 + var result = await mediator.Send(query, cancellationToken); + + // 3. 返回配额使用情况列表 + return ApiResponse>.Ok(result); + } + + /// + /// 获取租户账单列表(分页)。 + /// + /// 租户 ID(雪花算法)。 + /// 页码(从 1 开始)。 + /// 每页条数。 + /// 取消标记。 + /// 账单分页列表。 + [HttpGet("{id:long}/billings")] + [PermissionAuthorize("tenant:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> GetBillings( + long id, + [FromQuery] int Page = 1, + [FromQuery] int PageSize = 10, + CancellationToken cancellationToken = default) + { + // 1. 构造查询 + var query = new GetTenantBillingsQuery + { + TenantId = id, + Page = Page, + PageSize = PageSize + }; + + // 2. 执行查询 + var result = await mediator.Send(query, cancellationToken); + + // 3. 返回账单分页列表 + return ApiResponse>.Ok(result); + } } diff --git a/src/Api/TakeoutSaaS.AdminApi/appsettings.Development.json b/src/Api/TakeoutSaaS.AdminApi/appsettings.Development.json index f74da85..d01d88e 100644 --- a/src/Api/TakeoutSaaS.AdminApi/appsettings.Development.json +++ b/src/Api/TakeoutSaaS.AdminApi/appsettings.Development.json @@ -165,8 +165,8 @@ "RabbitMQ": { "Host": "49.232.6.45", "Port": 5672, - "Username": "Admin", - "Password": "MsuMshk112233", + "Username": "admin", + "Password": "msumshk112233", "VirtualHost": "/", "Exchange": "takeout.events", "ExchangeType": "topic", diff --git a/src/Api/TakeoutSaaS.AdminApi/appsettings.Production.json b/src/Api/TakeoutSaaS.AdminApi/appsettings.Production.json index aa0b8af..3a6f830 100644 --- a/src/Api/TakeoutSaaS.AdminApi/appsettings.Production.json +++ b/src/Api/TakeoutSaaS.AdminApi/appsettings.Production.json @@ -165,8 +165,8 @@ "RabbitMQ": { "Host": "49.232.6.45", "Port": 5672, - "Username": "Admin", - "Password": "MsuMshk112233", + "Username": "admin", + "Password": "msumshk112233", "VirtualHost": "/", "Exchange": "takeout.events", "ExchangeType": "topic", diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Commands/ConfirmPaymentCommand.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Commands/ConfirmPaymentCommand.cs new file mode 100644 index 0000000..c88f0f7 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Commands/ConfirmPaymentCommand.cs @@ -0,0 +1,41 @@ +using MediatR; +using TakeoutSaaS.Application.App.Billings.Contracts; +using TakeoutSaaS.Domain.Billings.Enums; + +namespace TakeoutSaaS.Application.App.Billings.Commands; + +/// +/// 确认收款命令(记录支付 + 立即审核通过 + 同步更新账单状态)。 +/// +public sealed record ConfirmPaymentCommand : IRequest +{ + /// + /// 账单 ID。 + /// + public long BillingId { get; init; } + + /// + /// 支付金额。 + /// + public decimal Amount { get; init; } + + /// + /// 支付方式。 + /// + public TenantPaymentMethod Method { get; init; } + + /// + /// 交易号。 + /// + public string? TransactionNo { get; init; } + + /// + /// 支付凭证 URL。 + /// + public string? ProofUrl { get; init; } + + /// + /// 备注。 + /// + public string? Notes { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Contracts/BillingDetailDto.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Contracts/BillingDetailDto.cs new file mode 100644 index 0000000..b823afd --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Contracts/BillingDetailDto.cs @@ -0,0 +1,197 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Billings.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Billings.Contracts; + +/// +/// 账单详情 DTO。 +/// +public sealed record BillingDetailDto +{ + /// + /// 账单 ID(雪花,序列化为字符串)。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户 ID(雪花,序列化为字符串)。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 租户名称。 + /// + public string? TenantName { get; init; } + + /// + /// 账单编号。 + /// + public string StatementNo { get; init; } = string.Empty; + + /// + /// 账单类型。 + /// + public TenantBillingType BillingType { get; init; } + + /// + /// 账单周期开始时间。 + /// + public DateTime PeriodStart { get; init; } + + /// + /// 账单周期结束时间。 + /// + public DateTime PeriodEnd { get; init; } + + /// + /// 应付金额。 + /// + public decimal AmountDue { get; init; } + + /// + /// 已付金额。 + /// + public decimal AmountPaid { get; init; } + + /// + /// 折扣金额。 + /// + public decimal DiscountAmount { get; init; } + + /// + /// 税额。 + /// + public decimal TaxAmount { get; init; } + + /// + /// 货币代码。 + /// + public string Currency { get; init; } = "CNY"; + + /// + /// 账单状态。 + /// + public TenantBillingStatus Status { get; init; } + + /// + /// 到期日期。 + /// + public DateTime DueDate { get; init; } + + /// + /// 账单明细 JSON。 + /// + public string? LineItemsJson { get; init; } + + /// + /// 备注。 + /// + public string? Notes { get; init; } + + /// + /// 逾期通知时间。 + /// + public DateTime? OverdueNotifiedAt { get; init; } + + /// + /// 提醒发送时间。 + /// + public DateTime? ReminderSentAt { get; init; } + + /// + /// 创建时间。 + /// + public DateTime CreatedAt { get; init; } + + /// + /// 更新时间。 + /// + public DateTime? UpdatedAt { get; init; } + + /// + /// 支付记录列表。 + /// + public IReadOnlyList Payments { get; init; } = []; +} + +/// +/// 支付记录 DTO。 +/// +public sealed record PaymentRecordDto +{ + /// + /// 支付记录 ID(雪花,序列化为字符串)。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 账单 ID(雪花,序列化为字符串)。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long BillingStatementId { get; init; } + + /// + /// 支付金额。 + /// + public decimal Amount { get; init; } + + /// + /// 支付方式。 + /// + public int Method { get; init; } + + /// + /// 支付状态。 + /// + public int Status { get; init; } + + /// + /// 交易号。 + /// + public string? TransactionNo { get; init; } + + /// + /// 支付凭证 URL。 + /// + public string? ProofUrl { get; init; } + + /// + /// 支付时间。 + /// + public DateTime? PaidAt { get; init; } + + /// + /// 备注。 + /// + public string? Notes { get; init; } + + /// + /// 审核人 ID。 + /// + [JsonConverter(typeof(NullableSnowflakeIdJsonConverter))] + public long? VerifiedBy { get; init; } + + /// + /// 审核时间。 + /// + public DateTime? VerifiedAt { get; init; } + + /// + /// 退款原因。 + /// + public string? RefundReason { get; init; } + + /// + /// 退款时间。 + /// + public DateTime? RefundedAt { get; init; } + + /// + /// 创建时间。 + /// + public DateTime CreatedAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Contracts/BillingListDto.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Contracts/BillingListDto.cs new file mode 100644 index 0000000..d5edc83 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Contracts/BillingListDto.cs @@ -0,0 +1,98 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Billings.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Billings.Contracts; + +/// +/// 账单列表项 DTO。 +/// +public sealed record BillingListDto +{ + /// + /// 账单 ID(雪花,序列化为字符串)。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户 ID(雪花,序列化为字符串)。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 租户名称。 + /// + public string? TenantName { get; init; } + + /// + /// 账单编号。 + /// + public string StatementNo { get; init; } = string.Empty; + + /// + /// 账单类型。 + /// + public TenantBillingType BillingType { get; init; } + + /// + /// 账单周期开始时间。 + /// + public DateTime PeriodStart { get; init; } + + /// + /// 账单周期结束时间。 + /// + public DateTime PeriodEnd { get; init; } + + /// + /// 应付金额。 + /// + public decimal AmountDue { get; init; } + + /// + /// 已付金额。 + /// + public decimal AmountPaid { get; init; } + + /// + /// 折扣金额。 + /// + public decimal DiscountAmount { get; init; } + + /// + /// 税额。 + /// + public decimal TaxAmount { get; init; } + + /// + /// 货币代码。 + /// + public string Currency { get; init; } = "CNY"; + + /// + /// 账单状态。 + /// + public TenantBillingStatus Status { get; init; } + + /// + /// 到期日期。 + /// + public DateTime DueDate { get; init; } + + /// + /// 逾期通知时间。 + /// + public DateTime? OverdueNotifiedAt { get; init; } + + /// + /// 创建时间。 + /// + public DateTime CreatedAt { get; init; } + + /// + /// 更新时间。 + /// + public DateTime? UpdatedAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/ConfirmPaymentCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/ConfirmPaymentCommandHandler.cs new file mode 100644 index 0000000..e7bb1c4 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/ConfirmPaymentCommandHandler.cs @@ -0,0 +1,92 @@ +using MediatR; +using TakeoutSaaS.Application.App.Billings.Commands; +using TakeoutSaaS.Application.App.Billings.Contracts; +using TakeoutSaaS.Domain.Billings.Entities; +using TakeoutSaaS.Domain.Billings.Enums; +using TakeoutSaaS.Domain.Billings.Repositories; +using TakeoutSaaS.Shared.Abstractions.Security; + +namespace TakeoutSaaS.Application.App.Billings.Handlers; + +/// +/// 确认收款命令处理器。 +/// +public sealed class ConfirmPaymentCommandHandler( + IBillingRepository billingRepository, + ICurrentUserAccessor currentUserAccessor) + : IRequestHandler +{ + /// + public async Task Handle( + ConfirmPaymentCommand request, + CancellationToken cancellationToken) + { + // 1. 获取账单(带跟踪) + var billing = await billingRepository.GetByIdForUpdateAsync(request.BillingId, cancellationToken); + + // 2. 如果不存在,返回 null + if (billing is null) + { + return null; + } + + // 3. 获取当前用户 ID + var currentUserId = currentUserAccessor.UserId; + var now = DateTime.UtcNow; + + // 4. 创建支付记录(已审核通过状态) + var payment = new TenantPayment + { + TenantId = billing.TenantId, + BillingStatementId = billing.Id, + Amount = request.Amount, + Method = request.Method, + Status = TenantPaymentStatus.Success, + TransactionNo = request.TransactionNo, + ProofUrl = request.ProofUrl, + Notes = request.Notes, + PaidAt = now, + VerifiedBy = currentUserId, + VerifiedAt = now, + CreatedAt = now, + CreatedBy = currentUserId + }; + + // 5. 添加支付记录 + await billingRepository.AddPaymentAsync(payment, cancellationToken); + + // 6. 更新账单已付金额 + billing.AmountPaid += request.Amount; + + // 7. 如果已付金额 >= 应付金额,更新账单状态为已支付 + if (billing.AmountPaid >= billing.AmountDue) + { + billing.Status = TenantBillingStatus.Paid; + } + + // 8. 更新账单时间戳 + billing.UpdatedAt = now; + + // 9. 保存变更 + await billingRepository.SaveChangesAsync(cancellationToken); + + // 10. 返回支付记录 DTO + return new PaymentRecordDto + { + Id = payment.Id, + BillingStatementId = payment.BillingStatementId, + Amount = payment.Amount, + Method = (int)payment.Method, + Status = (int)payment.Status, + TransactionNo = payment.TransactionNo, + ProofUrl = payment.ProofUrl, + PaidAt = payment.PaidAt, + Notes = payment.Notes, + VerifiedBy = payment.VerifiedBy, + VerifiedAt = payment.VerifiedAt, + RefundReason = payment.RefundReason, + RefundedAt = payment.RefundedAt, + CreatedAt = payment.CreatedAt + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/GetBillingDetailQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/GetBillingDetailQueryHandler.cs new file mode 100644 index 0000000..396bb1c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/GetBillingDetailQueryHandler.cs @@ -0,0 +1,54 @@ +using MediatR; +using TakeoutSaaS.Application.App.Billings.Contracts; +using TakeoutSaaS.Application.App.Billings.Queries; +using TakeoutSaaS.Domain.Billings.Repositories; + +namespace TakeoutSaaS.Application.App.Billings.Handlers; + +/// +/// 获取账单详情查询处理器。 +/// +public sealed class GetBillingDetailQueryHandler(IBillingRepository billingRepository) + : IRequestHandler +{ + /// + public async Task Handle( + GetBillingDetailQuery request, + CancellationToken cancellationToken) + { + // 1. 查询账单详情 + var detail = await billingRepository.GetDetailAsync(request.BillingId, cancellationToken); + + // 2. 如果不存在,返回 null + if (detail is null) + { + return null; + } + + // 3. 映射为 DTO 并返回(支付记录暂时返回空列表) + return new BillingDetailDto + { + Id = detail.Id, + TenantId = detail.TenantId, + TenantName = detail.TenantName, + StatementNo = detail.StatementNo, + BillingType = detail.BillingType, + PeriodStart = detail.PeriodStart, + PeriodEnd = detail.PeriodEnd, + AmountDue = detail.AmountDue, + AmountPaid = detail.AmountPaid, + DiscountAmount = detail.DiscountAmount, + TaxAmount = detail.TaxAmount, + Currency = detail.Currency, + Status = detail.Status, + DueDate = detail.DueDate, + LineItemsJson = detail.LineItemsJson, + Notes = detail.Notes, + OverdueNotifiedAt = detail.OverdueNotifiedAt, + ReminderSentAt = detail.ReminderSentAt, + CreatedAt = detail.CreatedAt, + UpdatedAt = detail.UpdatedAt, + Payments = [] + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/ListBillingsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/ListBillingsQueryHandler.cs new file mode 100644 index 0000000..d3a482b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Handlers/ListBillingsQueryHandler.cs @@ -0,0 +1,61 @@ +using MediatR; +using TakeoutSaaS.Application.App.Billings.Contracts; +using TakeoutSaaS.Application.App.Billings.Queries; +using TakeoutSaaS.Domain.Billings.Repositories; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.Billings.Handlers; + +/// +/// 获取账单列表查询处理器。 +/// +public sealed class ListBillingsQueryHandler(IBillingRepository billingRepository) + : IRequestHandler> +{ + /// + public async Task> Handle( + ListBillingsQuery request, + CancellationToken cancellationToken) + { + // 1. 查询账单列表 + var (items, totalCount) = await billingRepository.GetListAsync( + request.TenantId, + request.Status, + request.BillingType, + request.StartDate, + request.EndDate, + request.MinAmount, + request.MaxAmount, + request.Keyword, + request.SortBy, + request.SortDesc, + request.PageNumber, + request.PageSize, + cancellationToken); + + // 2. 映射为 DTO + var dtos = items.Select(b => new BillingListDto + { + Id = b.Id, + TenantId = b.TenantId, + TenantName = b.TenantName, + StatementNo = b.StatementNo, + BillingType = b.BillingType, + PeriodStart = b.PeriodStart, + PeriodEnd = b.PeriodEnd, + AmountDue = b.AmountDue, + AmountPaid = b.AmountPaid, + DiscountAmount = b.DiscountAmount, + TaxAmount = b.TaxAmount, + Currency = b.Currency, + Status = b.Status, + DueDate = b.DueDate, + OverdueNotifiedAt = b.OverdueNotifiedAt, + CreatedAt = b.CreatedAt, + UpdatedAt = b.UpdatedAt + }).ToList(); + + // 3. 返回分页结果 + return new PagedResult(dtos, totalCount, request.PageNumber, request.PageSize); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Queries/GetBillingDetailQuery.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Queries/GetBillingDetailQuery.cs new file mode 100644 index 0000000..6fa91a0 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Queries/GetBillingDetailQuery.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.App.Billings.Contracts; + +namespace TakeoutSaaS.Application.App.Billings.Queries; + +/// +/// 获取账单详情查询。 +/// +public sealed record GetBillingDetailQuery : IRequest +{ + /// + /// 账单 ID。 + /// + public long BillingId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Billings/Queries/ListBillingsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Billings/Queries/ListBillingsQuery.cs new file mode 100644 index 0000000..b62bc7e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Billings/Queries/ListBillingsQuery.cs @@ -0,0 +1,72 @@ +using MediatR; +using TakeoutSaaS.Application.App.Billings.Contracts; +using TakeoutSaaS.Domain.Billings.Enums; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.Billings.Queries; + +/// +/// 获取账单列表查询。 +/// +public sealed record ListBillingsQuery : IRequest> +{ + /// + /// 租户 ID。 + /// + public long? TenantId { get; init; } + + /// + /// 账单状态。 + /// + public TenantBillingStatus? Status { get; init; } + + /// + /// 账单类型。 + /// + public TenantBillingType? BillingType { get; init; } + + /// + /// 开始日期。 + /// + public DateTime? StartDate { get; init; } + + /// + /// 结束日期。 + /// + public DateTime? EndDate { get; init; } + + /// + /// 最小金额。 + /// + public decimal? MinAmount { get; init; } + + /// + /// 最大金额。 + /// + public decimal? MaxAmount { get; init; } + + /// + /// 关键词(账单号、租户名)。 + /// + public string? Keyword { get; init; } + + /// + /// 排序字段。 + /// + public string? SortBy { get; init; } + + /// + /// 是否降序。 + /// + public bool? SortDesc { get; init; } + + /// + /// 页码(从 1 开始)。 + /// + public int PageNumber { get; init; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; init; } = 10; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Commands/ChangePlanCommand.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Commands/ChangePlanCommand.cs new file mode 100644 index 0000000..0415610 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Commands/ChangePlanCommand.cs @@ -0,0 +1,30 @@ +using MediatR; +using TakeoutSaaS.Application.App.Subscriptions.Contracts; + +namespace TakeoutSaaS.Application.App.Subscriptions.Commands; + +/// +/// 变更套餐命令。 +/// +public sealed record ChangePlanCommand : IRequest +{ + /// + /// 订阅 ID。 + /// + public long SubscriptionId { get; init; } + + /// + /// 目标套餐 ID。 + /// + public long TargetPackageId { get; init; } + + /// + /// 是否立即生效。 + /// + public bool Immediate { get; init; } + + /// + /// 备注信息。 + /// + public string? Notes { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Commands/ExtendSubscriptionCommand.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Commands/ExtendSubscriptionCommand.cs new file mode 100644 index 0000000..31e6f9a --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Commands/ExtendSubscriptionCommand.cs @@ -0,0 +1,25 @@ +using MediatR; +using TakeoutSaaS.Application.App.Subscriptions.Contracts; + +namespace TakeoutSaaS.Application.App.Subscriptions.Commands; + +/// +/// 延期订阅命令。 +/// +public sealed record ExtendSubscriptionCommand : IRequest +{ + /// + /// 订阅 ID。 + /// + public long SubscriptionId { get; init; } + + /// + /// 延期月数。 + /// + public int DurationMonths { get; init; } + + /// + /// 备注信息。 + /// + public string? Notes { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Commands/UpdateStatusCommand.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Commands/UpdateStatusCommand.cs new file mode 100644 index 0000000..f25f485 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Commands/UpdateStatusCommand.cs @@ -0,0 +1,26 @@ +using MediatR; +using TakeoutSaaS.Application.App.Subscriptions.Contracts; +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.Application.App.Subscriptions.Commands; + +/// +/// 更新订阅状态命令。 +/// +public sealed record UpdateStatusCommand : IRequest +{ + /// + /// 订阅 ID。 + /// + public long SubscriptionId { get; init; } + + /// + /// 目标状态。 + /// + public SubscriptionStatus Status { get; init; } + + /// + /// 备注信息。 + /// + public string? Notes { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Commands/UpdateSubscriptionCommand.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Commands/UpdateSubscriptionCommand.cs new file mode 100644 index 0000000..7aef9c3 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Commands/UpdateSubscriptionCommand.cs @@ -0,0 +1,25 @@ +using MediatR; +using TakeoutSaaS.Application.App.Subscriptions.Contracts; + +namespace TakeoutSaaS.Application.App.Subscriptions.Commands; + +/// +/// 更新订阅命令。 +/// +public sealed record UpdateSubscriptionCommand : IRequest +{ + /// + /// 订阅 ID。 + /// + public long SubscriptionId { get; init; } + + /// + /// 是否自动续费。 + /// + public bool? AutoRenew { get; init; } + + /// + /// 备注信息。 + /// + public string? Notes { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Contracts/SubscriptionDetailDto.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Contracts/SubscriptionDetailDto.cs new file mode 100644 index 0000000..f0c3470 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Contracts/SubscriptionDetailDto.cs @@ -0,0 +1,222 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Application.App.TenantPackages.Contracts; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Subscriptions.Contracts; + +/// +/// 订阅详情 DTO。 +/// +public sealed record SubscriptionDetailDto +{ + /// + /// 订阅 ID(雪花,序列化为字符串)。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户 ID(雪花,序列化为字符串)。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 租户名称。 + /// + public string TenantName { get; init; } = string.Empty; + + /// + /// 租户编码。 + /// + public string TenantCode { get; init; } = string.Empty; + + /// + /// 当前套餐 ID(雪花,序列化为字符串)。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantPackageId { get; init; } + + /// + /// 当前套餐名称。 + /// + public string PackageName { get; init; } = string.Empty; + + /// + /// 排期套餐 ID(下周期生效,雪花,序列化为字符串)。 + /// + [JsonConverter(typeof(NullableSnowflakeIdJsonConverter))] + public long? ScheduledPackageId { get; init; } + + /// + /// 排期套餐名称。 + /// + public string? ScheduledPackageName { get; init; } + + /// + /// 订阅状态。 + /// + public SubscriptionStatus Status { get; init; } + + /// + /// 生效时间。 + /// + public DateTime EffectiveFrom { get; init; } + + /// + /// 到期时间。 + /// + public DateTime EffectiveTo { get; init; } + + /// + /// 下次计费时间。 + /// + public DateTime? NextBillingDate { get; init; } + + /// + /// 是否自动续费。 + /// + public bool AutoRenew { get; init; } + + /// + /// 备注信息。 + /// + public string? Notes { get; init; } + + /// + /// 创建时间。 + /// + public DateTime CreatedAt { get; init; } + + /// + /// 更新时间。 + /// + public DateTime? UpdatedAt { get; init; } + + /// + /// 当前套餐信息。 + /// + public TenantPackageListDto? Package { get; init; } + + /// + /// 排期套餐信息。 + /// + public TenantPackageListDto? ScheduledPackage { get; init; } + + /// + /// 配额使用情况列表。 + /// + public IReadOnlyList QuotaUsages { get; init; } = []; + + /// + /// 订阅变更历史列表。 + /// + public IReadOnlyList ChangeHistory { get; init; } = []; +} + +/// +/// 订阅配额使用情况 DTO。 +/// +public sealed record SubscriptionQuotaUsageDto +{ + /// + /// 配额类型。 + /// + public TenantQuotaType QuotaType { get; init; } + + /// + /// 配额名称。 + /// + public string QuotaName { get; init; } = string.Empty; + + /// + /// 配额上限。 + /// + public int? Limit { get; init; } + + /// + /// 已使用量。 + /// + public int Used { get; init; } + + /// + /// 剩余量。 + /// + public int? Remaining { get; init; } + + /// + /// 使用百分比。 + /// + public decimal? UsagePercentage { get; init; } +} + +/// +/// 订阅变更历史 DTO。 +/// +public sealed record SubscriptionHistoryDto +{ + /// + /// 历史记录 ID(雪花,序列化为字符串)。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 订阅 ID(雪花,序列化为字符串)。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long SubscriptionId { get; init; } + + /// + /// 变更类型。 + /// + public string ChangeType { get; init; } = string.Empty; + + /// + /// 变更前套餐 ID(雪花,序列化为字符串)。 + /// + [JsonConverter(typeof(NullableSnowflakeIdJsonConverter))] + public long? PreviousPackageId { get; init; } + + /// + /// 变更前套餐名称。 + /// + public string? PreviousPackageName { get; init; } + + /// + /// 变更后套餐 ID(雪花,序列化为字符串)。 + /// + [JsonConverter(typeof(NullableSnowflakeIdJsonConverter))] + public long? NewPackageId { get; init; } + + /// + /// 变更后套餐名称。 + /// + public string? NewPackageName { get; init; } + + /// + /// 变更前到期时间。 + /// + public DateTime? PreviousEffectiveTo { get; init; } + + /// + /// 变更后到期时间。 + /// + public DateTime? NewEffectiveTo { get; init; } + + /// + /// 备注。 + /// + public string? Notes { get; init; } + + /// + /// 创建时间。 + /// + public DateTime CreatedAt { get; init; } + + /// + /// 创建人。 + /// + public string? CreatedBy { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Contracts/SubscriptionListDto.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Contracts/SubscriptionListDto.cs new file mode 100644 index 0000000..8e76770 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Contracts/SubscriptionListDto.cs @@ -0,0 +1,95 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Subscriptions.Contracts; + +/// +/// 订阅列表项 DTO。 +/// +public sealed record SubscriptionListDto +{ + /// + /// 订阅 ID(雪花,序列化为字符串)。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户 ID(雪花,序列化为字符串)。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 租户名称。 + /// + public string TenantName { get; init; } = string.Empty; + + /// + /// 租户编码。 + /// + public string TenantCode { get; init; } = string.Empty; + + /// + /// 当前套餐 ID(雪花,序列化为字符串)。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantPackageId { get; init; } + + /// + /// 当前套餐名称。 + /// + public string PackageName { get; init; } = string.Empty; + + /// + /// 排期套餐 ID(下周期生效,雪花,序列化为字符串)。 + /// + [JsonConverter(typeof(NullableSnowflakeIdJsonConverter))] + public long? ScheduledPackageId { get; init; } + + /// + /// 排期套餐名称。 + /// + public string? ScheduledPackageName { get; init; } + + /// + /// 订阅状态。 + /// + public SubscriptionStatus Status { get; init; } + + /// + /// 生效时间。 + /// + public DateTime EffectiveFrom { get; init; } + + /// + /// 到期时间。 + /// + public DateTime EffectiveTo { get; init; } + + /// + /// 下次计费时间。 + /// + public DateTime? NextBillingDate { get; init; } + + /// + /// 是否自动续费。 + /// + public bool AutoRenew { get; init; } + + /// + /// 备注信息。 + /// + public string? Notes { get; init; } + + /// + /// 创建时间。 + /// + public DateTime CreatedAt { get; init; } + + /// + /// 更新时间。 + /// + public DateTime? UpdatedAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/ChangePlanCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/ChangePlanCommandHandler.cs new file mode 100644 index 0000000..92c9482 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/ChangePlanCommandHandler.cs @@ -0,0 +1,91 @@ +using MediatR; +using TakeoutSaaS.Application.App.Subscriptions.Commands; +using TakeoutSaaS.Application.App.Subscriptions.Contracts; +using TakeoutSaaS.Domain.Tenants.Repositories; + +namespace TakeoutSaaS.Application.App.Subscriptions.Handlers; + +/// +/// 变更套餐命令处理器。 +/// +public sealed class ChangePlanCommandHandler(ISubscriptionRepository subscriptionRepository) + : IRequestHandler +{ + /// + public async Task Handle( + ChangePlanCommand request, + CancellationToken cancellationToken) + { + // 1. 获取订阅(带跟踪) + var subscription = await subscriptionRepository.GetByIdForUpdateAsync(request.SubscriptionId, cancellationToken); + + // 2. 如果不存在,返回 null + if (subscription is null) + { + return null; + } + + // 3. 根据是否立即生效处理套餐变更 + if (request.Immediate) + { + // 3.1 立即生效:直接更新当前套餐 + subscription.TenantPackageId = request.TargetPackageId; + + // 3.2 清除排期套餐(如果有) + subscription.ScheduledPackageId = null; + } + else + { + // 3.3 下周期生效:设置排期套餐 + subscription.ScheduledPackageId = request.TargetPackageId; + } + + // 4. 更新备注(如果提供) + if (!string.IsNullOrWhiteSpace(request.Notes)) + { + var existingNotes = subscription.Notes ?? string.Empty; + var changeNote = request.Immediate + ? $"[立即变更套餐] {request.Notes}" + : $"[排期变更套餐] {request.Notes}"; + subscription.Notes = string.IsNullOrWhiteSpace(existingNotes) + ? changeNote + : $"{existingNotes}\n{changeNote}"; + } + + // 5. 更新时间戳 + subscription.UpdatedAt = DateTime.UtcNow; + + // 6. 保存变更 + await subscriptionRepository.SaveChangesAsync(cancellationToken); + + // 7. 获取更新后的订阅信息 + var result = await subscriptionRepository.GetListResultByIdAsync(request.SubscriptionId, cancellationToken); + + // 8. 如果不存在,返回 null + if (result is null) + { + return null; + } + + // 9. 映射为 DTO 并返回 + return new SubscriptionListDto + { + Id = result.Id, + TenantId = result.TenantId, + TenantName = result.TenantName, + TenantCode = result.TenantCode, + TenantPackageId = result.TenantPackageId, + PackageName = result.PackageName, + ScheduledPackageId = result.ScheduledPackageId, + ScheduledPackageName = result.ScheduledPackageName, + Status = result.Status, + EffectiveFrom = result.EffectiveFrom, + EffectiveTo = result.EffectiveTo, + NextBillingDate = result.NextBillingDate, + AutoRenew = result.AutoRenew, + Notes = result.Notes, + CreatedAt = result.CreatedAt, + UpdatedAt = result.UpdatedAt + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/ExtendSubscriptionCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/ExtendSubscriptionCommandHandler.cs new file mode 100644 index 0000000..e9835f3 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/ExtendSubscriptionCommandHandler.cs @@ -0,0 +1,83 @@ +using MediatR; +using TakeoutSaaS.Application.App.Subscriptions.Commands; +using TakeoutSaaS.Application.App.Subscriptions.Contracts; +using TakeoutSaaS.Domain.Tenants.Repositories; + +namespace TakeoutSaaS.Application.App.Subscriptions.Handlers; + +/// +/// 延期订阅命令处理器。 +/// +public sealed class ExtendSubscriptionCommandHandler(ISubscriptionRepository subscriptionRepository) + : IRequestHandler +{ + /// + public async Task Handle( + ExtendSubscriptionCommand request, + CancellationToken cancellationToken) + { + // 1. 获取订阅(带跟踪) + var subscription = await subscriptionRepository.GetByIdForUpdateAsync(request.SubscriptionId, cancellationToken); + + // 2. 如果不存在,返回 null + if (subscription is null) + { + return null; + } + + // 3. 延期到期时间 + subscription.EffectiveTo = subscription.EffectiveTo.AddMonths(request.DurationMonths); + + // 4. 更新下次计费时间(如果有) + if (subscription.NextBillingDate.HasValue) + { + subscription.NextBillingDate = subscription.NextBillingDate.Value.AddMonths(request.DurationMonths); + } + + // 5. 更新备注(如果提供) + if (!string.IsNullOrWhiteSpace(request.Notes)) + { + var existingNotes = subscription.Notes ?? string.Empty; + var extendNote = $"[延期 {request.DurationMonths} 个月] {request.Notes}"; + subscription.Notes = string.IsNullOrWhiteSpace(existingNotes) + ? extendNote + : $"{existingNotes}\n{extendNote}"; + } + + // 6. 更新时间戳 + subscription.UpdatedAt = DateTime.UtcNow; + + // 7. 保存变更 + await subscriptionRepository.SaveChangesAsync(cancellationToken); + + // 8. 获取更新后的订阅信息 + var result = await subscriptionRepository.GetListResultByIdAsync(request.SubscriptionId, cancellationToken); + + // 9. 如果不存在,返回 null + if (result is null) + { + return null; + } + + // 10. 映射为 DTO 并返回 + return new SubscriptionListDto + { + Id = result.Id, + TenantId = result.TenantId, + TenantName = result.TenantName, + TenantCode = result.TenantCode, + TenantPackageId = result.TenantPackageId, + PackageName = result.PackageName, + ScheduledPackageId = result.ScheduledPackageId, + ScheduledPackageName = result.ScheduledPackageName, + Status = result.Status, + EffectiveFrom = result.EffectiveFrom, + EffectiveTo = result.EffectiveTo, + NextBillingDate = result.NextBillingDate, + AutoRenew = result.AutoRenew, + Notes = result.Notes, + CreatedAt = result.CreatedAt, + UpdatedAt = result.UpdatedAt + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/GetSubscriptionDetailQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/GetSubscriptionDetailQueryHandler.cs new file mode 100644 index 0000000..1f39401 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/GetSubscriptionDetailQueryHandler.cs @@ -0,0 +1,152 @@ +using MediatR; +using TakeoutSaaS.Application.App.Subscriptions.Contracts; +using TakeoutSaaS.Application.App.Subscriptions.Queries; +using TakeoutSaaS.Application.App.TenantPackages.Contracts; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Domain.Tenants.Repositories; + +namespace TakeoutSaaS.Application.App.Subscriptions.Handlers; + +/// +/// 获取订阅详情查询处理器。 +/// +public sealed class GetSubscriptionDetailQueryHandler( + ISubscriptionRepository subscriptionRepository, + ITenantRepository tenantRepository) + : IRequestHandler +{ + /// + public async Task Handle( + GetSubscriptionDetailQuery request, + CancellationToken cancellationToken) + { + // 1. 查询订阅详情 + var detail = await subscriptionRepository.GetDetailAsync(request.SubscriptionId, cancellationToken); + + // 2. 如果不存在,返回 null + if (detail is null) + { + return null; + } + + // 3. 查询变更历史 + var histories = await subscriptionRepository.GetHistoriesAsync(request.SubscriptionId, cancellationToken); + + // 4. 查询配额使用情况 + var quotaUsages = await tenantRepository.GetQuotaUsagesAsync(detail.TenantId, cancellationToken); + + // 5. 映射套餐信息 + var packageDto = MapToPackageDto(detail.Package); + var scheduledPackageDto = detail.ScheduledPackage is not null + ? MapToPackageDto(detail.ScheduledPackage) + : null; + + // 6. 映射配额使用情况 + var quotaUsageDtos = quotaUsages.Select(u => + { + var limit = (int?)u.LimitValue; + var used = (int)u.UsedValue; + var remaining = limit.HasValue ? Math.Max(0, limit.Value - used) : (int?)null; + var usagePercentage = limit.HasValue && limit.Value > 0 + ? Math.Round((decimal)used / limit.Value * 100, 2) + : (decimal?)null; + + return new SubscriptionQuotaUsageDto + { + QuotaType = u.QuotaType, + QuotaName = GetQuotaTypeName(u.QuotaType), + Limit = limit, + Used = used, + Remaining = remaining, + UsagePercentage = usagePercentage + }; + }).ToList(); + + // 7. 映射变更历史 + var historyDtos = histories.Select(h => new SubscriptionHistoryDto + { + Id = h.Id, + SubscriptionId = h.SubscriptionId, + ChangeType = h.ChangeType.ToString(), + PreviousPackageId = h.FromPackageId, + PreviousPackageName = h.FromPackageName, + NewPackageId = h.ToPackageId, + NewPackageName = h.ToPackageName, + PreviousEffectiveTo = h.EffectiveFrom, + NewEffectiveTo = h.EffectiveTo, + Notes = h.Notes, + CreatedAt = h.CreatedAt, + CreatedBy = h.CreatedBy?.ToString() + }).ToList(); + + // 8. 返回订阅详情 DTO + return new SubscriptionDetailDto + { + Id = detail.Id, + TenantId = detail.TenantId, + TenantName = detail.TenantName, + TenantCode = detail.TenantCode, + TenantPackageId = detail.TenantPackageId, + PackageName = detail.PackageName, + ScheduledPackageId = detail.ScheduledPackageId, + ScheduledPackageName = detail.ScheduledPackageName, + Status = detail.Status, + EffectiveFrom = detail.EffectiveFrom, + EffectiveTo = detail.EffectiveTo, + NextBillingDate = detail.NextBillingDate, + AutoRenew = detail.AutoRenew, + Notes = detail.Notes, + CreatedAt = detail.CreatedAt, + UpdatedAt = detail.UpdatedAt, + Package = packageDto, + ScheduledPackage = scheduledPackageDto, + QuotaUsages = quotaUsageDtos, + ChangeHistory = historyDtos + }; + } + + /// + /// 映射套餐实体为 DTO。 + /// + private static TenantPackageListDto MapToPackageDto(TakeoutSaaS.Domain.Tenants.Entities.TenantPackage package) + { + return new TenantPackageListDto + { + 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, + IsPublicVisible = package.IsPublicVisible, + IsAllowNewTenantPurchase = package.IsAllowNewTenantPurchase, + PublishStatus = package.PublishStatus, + IsRecommended = package.IsRecommended, + Tags = package.Tags ?? [], + SortOrder = package.SortOrder + }; + } + + /// + /// 获取配额类型名称。 + /// + private static string GetQuotaTypeName(TenantQuotaType quotaType) + { + return quotaType switch + { + TenantQuotaType.Store => "门店数量", + TenantQuotaType.Account => "账号数量", + TenantQuotaType.StorageGb => "存储空间(GB)", + TenantQuotaType.SmsCredits => "短信额度", + TenantQuotaType.DeliveryOrders => "配送订单数", + _ => quotaType.ToString() + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/ListSubscriptionsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/ListSubscriptionsQueryHandler.cs new file mode 100644 index 0000000..6c2f74a --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/ListSubscriptionsQueryHandler.cs @@ -0,0 +1,58 @@ +using MediatR; +using TakeoutSaaS.Application.App.Subscriptions.Contracts; +using TakeoutSaaS.Application.App.Subscriptions.Queries; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.Subscriptions.Handlers; + +/// +/// 获取订阅列表查询处理器。 +/// +public sealed class ListSubscriptionsQueryHandler(ISubscriptionRepository subscriptionRepository) + : IRequestHandler> +{ + /// + public async Task> Handle( + ListSubscriptionsQuery request, + CancellationToken cancellationToken) + { + // 1. 查询订阅列表 + var (items, totalCount) = await subscriptionRepository.GetListAsync( + request.Status, + request.TenantPackageId, + request.TenantId, + request.TenantKeyword, + request.ExpiringWithinDays, + request.AutoRenew, + request.ExpireFrom, + request.ExpireTo, + request.Page, + request.PageSize, + cancellationToken); + + // 2. 映射为 DTO + var dtos = items.Select(s => new SubscriptionListDto + { + Id = s.Id, + TenantId = s.TenantId, + TenantName = s.TenantName, + TenantCode = s.TenantCode, + TenantPackageId = s.TenantPackageId, + PackageName = s.PackageName, + ScheduledPackageId = s.ScheduledPackageId, + ScheduledPackageName = s.ScheduledPackageName, + Status = s.Status, + EffectiveFrom = s.EffectiveFrom, + EffectiveTo = s.EffectiveTo, + NextBillingDate = s.NextBillingDate, + AutoRenew = s.AutoRenew, + Notes = s.Notes, + CreatedAt = s.CreatedAt, + UpdatedAt = s.UpdatedAt + }).ToList(); + + // 3. 返回分页结果 + return new PagedResult(dtos, totalCount, request.Page, request.PageSize); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/UpdateStatusCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/UpdateStatusCommandHandler.cs new file mode 100644 index 0000000..ad25065 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/UpdateStatusCommandHandler.cs @@ -0,0 +1,78 @@ +using MediatR; +using TakeoutSaaS.Application.App.Subscriptions.Commands; +using TakeoutSaaS.Application.App.Subscriptions.Contracts; +using TakeoutSaaS.Domain.Tenants.Repositories; + +namespace TakeoutSaaS.Application.App.Subscriptions.Handlers; + +/// +/// 更新订阅状态命令处理器。 +/// +public sealed class UpdateStatusCommandHandler(ISubscriptionRepository subscriptionRepository) + : IRequestHandler +{ + /// + public async Task Handle( + UpdateStatusCommand request, + CancellationToken cancellationToken) + { + // 1. 获取订阅(带跟踪) + var subscription = await subscriptionRepository.GetByIdForUpdateAsync(request.SubscriptionId, cancellationToken); + + // 2. 如果不存在,返回 null + if (subscription is null) + { + return null; + } + + // 3. 更新状态 + var oldStatus = subscription.Status; + subscription.Status = request.Status; + + // 4. 更新备注(如果提供) + if (!string.IsNullOrWhiteSpace(request.Notes)) + { + var existingNotes = subscription.Notes ?? string.Empty; + var statusNote = $"[状态变更: {oldStatus} -> {request.Status}] {request.Notes}"; + subscription.Notes = string.IsNullOrWhiteSpace(existingNotes) + ? statusNote + : $"{existingNotes}\n{statusNote}"; + } + + // 5. 更新时间戳 + subscription.UpdatedAt = DateTime.UtcNow; + + // 6. 保存变更 + await subscriptionRepository.SaveChangesAsync(cancellationToken); + + // 7. 获取更新后的订阅信息 + var result = await subscriptionRepository.GetListResultByIdAsync(request.SubscriptionId, cancellationToken); + + // 8. 如果不存在,返回 null + if (result is null) + { + return null; + } + + // 9. 映射为 DTO 并返回 + return new SubscriptionListDto + { + Id = result.Id, + TenantId = result.TenantId, + TenantName = result.TenantName, + TenantCode = result.TenantCode, + TenantPackageId = result.TenantPackageId, + PackageName = result.PackageName, + ScheduledPackageId = result.ScheduledPackageId, + ScheduledPackageName = result.ScheduledPackageName, + Status = result.Status, + EffectiveFrom = result.EffectiveFrom, + EffectiveTo = result.EffectiveTo, + NextBillingDate = result.NextBillingDate, + AutoRenew = result.AutoRenew, + Notes = result.Notes, + CreatedAt = result.CreatedAt, + UpdatedAt = result.UpdatedAt + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/UpdateSubscriptionCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/UpdateSubscriptionCommandHandler.cs new file mode 100644 index 0000000..4d775ca --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Handlers/UpdateSubscriptionCommandHandler.cs @@ -0,0 +1,75 @@ +using MediatR; +using TakeoutSaaS.Application.App.Subscriptions.Commands; +using TakeoutSaaS.Application.App.Subscriptions.Contracts; +using TakeoutSaaS.Domain.Tenants.Repositories; + +namespace TakeoutSaaS.Application.App.Subscriptions.Handlers; + +/// +/// 更新订阅命令处理器。 +/// +public sealed class UpdateSubscriptionCommandHandler(ISubscriptionRepository subscriptionRepository) + : IRequestHandler +{ + /// + public async Task Handle( + UpdateSubscriptionCommand request, + CancellationToken cancellationToken) + { + // 1. 获取订阅(带跟踪) + var subscription = await subscriptionRepository.GetByIdForUpdateAsync(request.SubscriptionId, cancellationToken); + + // 2. 如果不存在,返回 null + if (subscription is null) + { + return null; + } + + // 3. 更新字段 + if (request.AutoRenew.HasValue) + { + subscription.AutoRenew = request.AutoRenew.Value; + } + + if (request.Notes is not null) + { + subscription.Notes = request.Notes; + } + + // 4. 更新时间戳 + subscription.UpdatedAt = DateTime.UtcNow; + + // 5. 保存变更 + await subscriptionRepository.SaveChangesAsync(cancellationToken); + + // 6. 获取更新后的订阅信息 + var result = await subscriptionRepository.GetListResultByIdAsync(request.SubscriptionId, cancellationToken); + + // 7. 如果不存在,返回 null + if (result is null) + { + return null; + } + + // 8. 映射为 DTO 并返回 + return new SubscriptionListDto + { + Id = result.Id, + TenantId = result.TenantId, + TenantName = result.TenantName, + TenantCode = result.TenantCode, + TenantPackageId = result.TenantPackageId, + PackageName = result.PackageName, + ScheduledPackageId = result.ScheduledPackageId, + ScheduledPackageName = result.ScheduledPackageName, + Status = result.Status, + EffectiveFrom = result.EffectiveFrom, + EffectiveTo = result.EffectiveTo, + NextBillingDate = result.NextBillingDate, + AutoRenew = result.AutoRenew, + Notes = result.Notes, + CreatedAt = result.CreatedAt, + UpdatedAt = result.UpdatedAt + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Queries/GetSubscriptionDetailQuery.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Queries/GetSubscriptionDetailQuery.cs new file mode 100644 index 0000000..a44484b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Queries/GetSubscriptionDetailQuery.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.App.Subscriptions.Contracts; + +namespace TakeoutSaaS.Application.App.Subscriptions.Queries; + +/// +/// 获取订阅详情查询。 +/// +public sealed record GetSubscriptionDetailQuery : IRequest +{ + /// + /// 订阅 ID。 + /// + public long SubscriptionId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Subscriptions/Queries/ListSubscriptionsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Queries/ListSubscriptionsQuery.cs new file mode 100644 index 0000000..7959595 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Subscriptions/Queries/ListSubscriptionsQuery.cs @@ -0,0 +1,62 @@ +using MediatR; +using TakeoutSaaS.Application.App.Subscriptions.Contracts; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.Subscriptions.Queries; + +/// +/// 获取订阅列表查询。 +/// +public sealed record ListSubscriptionsQuery : IRequest> +{ + /// + /// 订阅状态。 + /// + public SubscriptionStatus? Status { get; init; } + + /// + /// 套餐 ID。 + /// + public long? TenantPackageId { get; init; } + + /// + /// 租户 ID。 + /// + public long? TenantId { get; init; } + + /// + /// 租户关键词(名称或编码)。 + /// + public string? TenantKeyword { get; init; } + + /// + /// 即将到期天数筛选(N 天内到期)。 + /// + public int? ExpiringWithinDays { get; init; } + + /// + /// 是否自动续费。 + /// + public bool? AutoRenew { get; init; } + + /// + /// 到期时间范围开始。 + /// + public DateTime? ExpireFrom { get; init; } + + /// + /// 到期时间范围结束。 + /// + public DateTime? ExpireTo { get; init; } + + /// + /// 页码(从 1 开始)。 + /// + public int Page { get; init; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; init; } = 10; +} diff --git a/src/Application/TakeoutSaaS.Application/App/TenantPackages/Commands/CreateTenantPackageCommand.cs b/src/Application/TakeoutSaaS.Application/App/TenantPackages/Commands/CreateTenantPackageCommand.cs new file mode 100644 index 0000000..a9c060c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/TenantPackages/Commands/CreateTenantPackageCommand.cs @@ -0,0 +1,101 @@ +using MediatR; +using TakeoutSaaS.Application.App.TenantPackages.Contracts; +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.Application.App.TenantPackages.Commands; + +/// +/// 创建租户套餐命令。 +/// +public sealed record CreateTenantPackageCommand : IRequest +{ + /// + /// 套餐名称。 + /// + 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; } + + /// + /// 是否公开可见。 + /// + public bool IsPublicVisible { get; init; } + + /// + /// 是否允许新租户购买。 + /// + public bool IsAllowNewTenantPurchase { get; init; } + + /// + /// 发布状态。 + /// + public TenantPackagePublishStatus PublishStatus { get; init; } + + /// + /// 是否推荐。 + /// + public bool IsRecommended { get; init; } + + /// + /// 标签列表。 + /// + public string[] Tags { get; init; } = []; + + /// + /// 排序序号。 + /// + public int SortOrder { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/TenantPackages/Commands/DeleteTenantPackageCommand.cs b/src/Application/TakeoutSaaS.Application/App/TenantPackages/Commands/DeleteTenantPackageCommand.cs new file mode 100644 index 0000000..f77650e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/TenantPackages/Commands/DeleteTenantPackageCommand.cs @@ -0,0 +1,14 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.TenantPackages.Commands; + +/// +/// 删除租户套餐命令(软删除)。 +/// +public sealed record DeleteTenantPackageCommand : IRequest +{ + /// + /// 套餐 ID。 + /// + public long TenantPackageId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/TenantPackages/Commands/UpdateTenantPackageCommand.cs b/src/Application/TakeoutSaaS.Application/App/TenantPackages/Commands/UpdateTenantPackageCommand.cs new file mode 100644 index 0000000..a15d676 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/TenantPackages/Commands/UpdateTenantPackageCommand.cs @@ -0,0 +1,106 @@ +using MediatR; +using TakeoutSaaS.Application.App.TenantPackages.Contracts; +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.Application.App.TenantPackages.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; } + + /// + /// 月付价格。 + /// + 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; } + + /// + /// 是否公开可见。 + /// + public bool IsPublicVisible { get; init; } + + /// + /// 是否允许新租户购买。 + /// + public bool IsAllowNewTenantPurchase { get; init; } + + /// + /// 发布状态。 + /// + public TenantPackagePublishStatus PublishStatus { get; init; } + + /// + /// 是否推荐。 + /// + public bool IsRecommended { get; init; } + + /// + /// 标签列表。 + /// + public string[] Tags { get; init; } = []; + + /// + /// 排序序号。 + /// + public int SortOrder { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/TenantPackages/Contracts/TenantPackageListDto.cs b/src/Application/TakeoutSaaS.Application/App/TenantPackages/Contracts/TenantPackageListDto.cs new file mode 100644 index 0000000..6ef4720 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/TenantPackages/Contracts/TenantPackageListDto.cs @@ -0,0 +1,107 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.TenantPackages.Contracts; + +/// +/// 租户套餐列表项 DTO。 +/// +public sealed record TenantPackageListDto +{ + /// + /// 套餐 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; } + + /// + /// 是否公开可见。 + /// + public bool IsPublicVisible { get; init; } + + /// + /// 是否允许新租户购买。 + /// + public bool IsAllowNewTenantPurchase { get; init; } + + /// + /// 发布状态。 + /// + public TenantPackagePublishStatus PublishStatus { get; init; } + + /// + /// 是否推荐。 + /// + public bool IsRecommended { get; init; } + + /// + /// 标签列表。 + /// + public string[] Tags { get; init; } = []; + + /// + /// 排序序号。 + /// + public int SortOrder { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/TenantPackages/Contracts/TenantPackageTenantDto.cs b/src/Application/TakeoutSaaS.Application/App/TenantPackages/Contracts/TenantPackageTenantDto.cs new file mode 100644 index 0000000..878c722 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/TenantPackages/Contracts/TenantPackageTenantDto.cs @@ -0,0 +1,52 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.TenantPackages.Contracts; + +/// +/// 套餐当前使用租户 DTO。 +/// +public sealed record TenantPackageTenantDto +{ + /// + /// 租户 ID(雪花,序列化为字符串)。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 租户编码。 + /// + public string Code { get; init; } = string.Empty; + + /// + /// 租户名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 租户状态。 + /// + public TenantStatus Status { get; init; } + + /// + /// 联系人姓名。 + /// + public string? ContactName { get; init; } + + /// + /// 联系人电话。 + /// + public string? ContactPhone { get; init; } + + /// + /// 订阅生效时间。 + /// + public DateTime SubscriptionEffectiveFrom { get; init; } + + /// + /// 订阅到期时间。 + /// + public DateTime SubscriptionEffectiveTo { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/TenantPackages/Contracts/TenantPackageUsageDto.cs b/src/Application/TakeoutSaaS.Application/App/TenantPackages/Contracts/TenantPackageUsageDto.cs new file mode 100644 index 0000000..eeaff81 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/TenantPackages/Contracts/TenantPackageUsageDto.cs @@ -0,0 +1,56 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.TenantPackages.Contracts; + +/// +/// 租户套餐使用统计 DTO。 +/// +public sealed record TenantPackageUsageDto +{ + /// + /// 套餐 ID(雪花,序列化为字符串)。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantPackageId { get; init; } + + /// + /// 活跃订阅数。 + /// + public int ActiveSubscriptionCount { get; init; } + + /// + /// 活跃租户数。 + /// + public int ActiveTenantCount { get; init; } + + /// + /// 总订阅数。 + /// + public int TotalSubscriptionCount { get; init; } + + /// + /// 月度经常性收入(MRR)。 + /// + public decimal Mrr { get; init; } + + /// + /// 年度经常性收入(ARR)。 + /// + public decimal Arr { get; init; } + + /// + /// 7 天内到期租户数。 + /// + public int ExpiringTenantCount7Days { get; init; } + + /// + /// 15 天内到期租户数。 + /// + public int ExpiringTenantCount15Days { get; init; } + + /// + /// 30 天内到期租户数。 + /// + public int ExpiringTenantCount30Days { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/TenantPackages/Handlers/CreateTenantPackageCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/TenantPackages/Handlers/CreateTenantPackageCommandHandler.cs new file mode 100644 index 0000000..3d37e36 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/TenantPackages/Handlers/CreateTenantPackageCommandHandler.cs @@ -0,0 +1,79 @@ +using MediatR; +using TakeoutSaaS.Application.App.TenantPackages.Commands; +using TakeoutSaaS.Application.App.TenantPackages.Contracts; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; + +namespace TakeoutSaaS.Application.App.TenantPackages.Handlers; + +/// +/// 创建租户套餐命令处理器。 +/// +public sealed class CreateTenantPackageCommandHandler(ITenantPackageRepository tenantPackageRepository) + : IRequestHandler +{ + /// + public async Task Handle( + CreateTenantPackageCommand request, + CancellationToken cancellationToken) + { + // 1. 校验必填字段 + if (string.IsNullOrWhiteSpace(request.Name)) + { + throw new BusinessException(ErrorCodes.BadRequest, "套餐名称不能为空"); + } + + // 2. 构建套餐实体 + 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, + IsPublicVisible = request.IsPublicVisible, + IsAllowNewTenantPurchase = request.IsAllowNewTenantPurchase, + PublishStatus = request.PublishStatus, + IsRecommended = request.IsRecommended, + Tags = request.Tags, + SortOrder = request.SortOrder + }; + + // 3. 持久化 + await tenantPackageRepository.AddAsync(package, cancellationToken); + await tenantPackageRepository.SaveChangesAsync(cancellationToken); + + // 4. 返回 DTO + return new TenantPackageListDto + { + 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, + IsPublicVisible = package.IsPublicVisible, + IsAllowNewTenantPurchase = package.IsAllowNewTenantPurchase, + PublishStatus = package.PublishStatus, + IsRecommended = package.IsRecommended, + Tags = package.Tags, + SortOrder = package.SortOrder + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/TenantPackages/Handlers/DeleteTenantPackageCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/TenantPackages/Handlers/DeleteTenantPackageCommandHandler.cs new file mode 100644 index 0000000..a6b2b28 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/TenantPackages/Handlers/DeleteTenantPackageCommandHandler.cs @@ -0,0 +1,36 @@ +using MediatR; +using TakeoutSaaS.Application.App.TenantPackages.Commands; +using TakeoutSaaS.Domain.Tenants.Repositories; + +namespace TakeoutSaaS.Application.App.TenantPackages.Handlers; + +/// +/// 删除租户套餐命令处理器(软删除)。 +/// +public sealed class DeleteTenantPackageCommandHandler(ITenantPackageRepository tenantPackageRepository) + : IRequestHandler +{ + /// + public async Task Handle( + DeleteTenantPackageCommand request, + CancellationToken cancellationToken) + { + // 1. 查询套餐(带跟踪用于更新) + var package = await tenantPackageRepository.GetByIdForUpdateAsync(request.TenantPackageId, cancellationToken); + + // 2. 如果不存在,返回 false + if (package is null) + { + return false; + } + + // 3. 软删除 + await tenantPackageRepository.SoftDeleteAsync(package, cancellationToken); + + // 4. 保存变更 + await tenantPackageRepository.SaveChangesAsync(cancellationToken); + + // 5. 返回成功 + return true; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/TenantPackages/Handlers/GetTenantPackageDetailQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/TenantPackages/Handlers/GetTenantPackageDetailQueryHandler.cs new file mode 100644 index 0000000..bcab34a --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/TenantPackages/Handlers/GetTenantPackageDetailQueryHandler.cs @@ -0,0 +1,52 @@ +using MediatR; +using TakeoutSaaS.Application.App.TenantPackages.Contracts; +using TakeoutSaaS.Application.App.TenantPackages.Queries; +using TakeoutSaaS.Domain.Tenants.Repositories; + +namespace TakeoutSaaS.Application.App.TenantPackages.Handlers; + +/// +/// 获取租户套餐详情查询处理器。 +/// +public sealed class GetTenantPackageDetailQueryHandler(ITenantPackageRepository tenantPackageRepository) + : IRequestHandler +{ + /// + public async Task Handle( + GetTenantPackageDetailQuery request, + CancellationToken cancellationToken) + { + // 1. 查询套餐详情 + var package = await tenantPackageRepository.GetByIdAsync(request.TenantPackageId, cancellationToken); + + // 2. 如果不存在,返回 null + if (package is null) + { + return null; + } + + // 3. 映射为 DTO + return new TenantPackageListDto + { + 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, + IsPublicVisible = package.IsPublicVisible, + IsAllowNewTenantPurchase = package.IsAllowNewTenantPurchase, + PublishStatus = package.PublishStatus, + IsRecommended = package.IsRecommended, + Tags = package.Tags, + SortOrder = package.SortOrder + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/TenantPackages/Handlers/GetTenantPackageTenantsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/TenantPackages/Handlers/GetTenantPackageTenantsQueryHandler.cs new file mode 100644 index 0000000..6cc1cb6 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/TenantPackages/Handlers/GetTenantPackageTenantsQueryHandler.cs @@ -0,0 +1,45 @@ +using MediatR; +using TakeoutSaaS.Application.App.TenantPackages.Contracts; +using TakeoutSaaS.Application.App.TenantPackages.Queries; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.TenantPackages.Handlers; + +/// +/// 获取套餐当前使用租户列表查询处理器。 +/// +public sealed class GetTenantPackageTenantsQueryHandler(ITenantPackageRepository tenantPackageRepository) + : IRequestHandler> +{ + /// + public async Task> Handle( + GetTenantPackageTenantsQuery request, + CancellationToken cancellationToken) + { + // 1. 查询套餐使用租户列表 + var (items, totalCount) = await tenantPackageRepository.GetTenantsAsync( + request.TenantPackageId, + request.Keyword, + request.ExpiringWithinDays, + request.Page, + request.PageSize, + cancellationToken); + + // 2. 映射为 DTO + var dtos = items.Select(t => new TenantPackageTenantDto + { + TenantId = t.TenantId, + Code = t.Code, + Name = t.Name, + Status = t.Status, + ContactName = t.ContactName, + ContactPhone = t.ContactPhone, + SubscriptionEffectiveFrom = t.SubscriptionEffectiveFrom, + SubscriptionEffectiveTo = t.SubscriptionEffectiveTo + }).ToList(); + + // 3. 返回分页结果 + return new PagedResult(dtos, totalCount, request.Page, request.PageSize); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/TenantPackages/Handlers/GetTenantPackageUsagesQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/TenantPackages/Handlers/GetTenantPackageUsagesQueryHandler.cs new file mode 100644 index 0000000..920a34e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/TenantPackages/Handlers/GetTenantPackageUsagesQueryHandler.cs @@ -0,0 +1,36 @@ +using MediatR; +using TakeoutSaaS.Application.App.TenantPackages.Contracts; +using TakeoutSaaS.Application.App.TenantPackages.Queries; +using TakeoutSaaS.Domain.Tenants.Repositories; + +namespace TakeoutSaaS.Application.App.TenantPackages.Handlers; + +/// +/// 获取租户套餐使用统计查询处理器。 +/// +public sealed class GetTenantPackageUsagesQueryHandler(ITenantPackageRepository tenantPackageRepository) + : IRequestHandler> +{ + /// + public async Task> Handle( + GetTenantPackageUsagesQuery request, + CancellationToken cancellationToken) + { + // 1. 查询套餐使用统计 + var usages = await tenantPackageRepository.GetUsagesAsync(request.TenantPackageIds, cancellationToken); + + // 2. 映射为 DTO + return usages.Select(u => new TenantPackageUsageDto + { + TenantPackageId = u.TenantPackageId, + ActiveSubscriptionCount = u.ActiveSubscriptionCount, + ActiveTenantCount = u.ActiveTenantCount, + TotalSubscriptionCount = u.TotalSubscriptionCount, + Mrr = u.Mrr, + Arr = u.Arr, + ExpiringTenantCount7Days = u.ExpiringTenantCount7Days, + ExpiringTenantCount15Days = u.ExpiringTenantCount15Days, + ExpiringTenantCount30Days = u.ExpiringTenantCount30Days + }).ToList(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/TenantPackages/Handlers/ListTenantPackagesQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/TenantPackages/Handlers/ListTenantPackagesQueryHandler.cs new file mode 100644 index 0000000..426c29b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/TenantPackages/Handlers/ListTenantPackagesQueryHandler.cs @@ -0,0 +1,55 @@ +using MediatR; +using TakeoutSaaS.Application.App.TenantPackages.Contracts; +using TakeoutSaaS.Application.App.TenantPackages.Queries; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.TenantPackages.Handlers; + +/// +/// 获取租户套餐列表查询处理器。 +/// +public sealed class ListTenantPackagesQueryHandler(ITenantPackageRepository tenantPackageRepository) + : IRequestHandler> +{ + /// + public async Task> Handle( + ListTenantPackagesQuery request, + CancellationToken cancellationToken) + { + // 1. 查询租户套餐(分页) + var (packages, totalCount) = await tenantPackageRepository.GetListAsync( + request.Keyword, + request.IsActive, + request.Page, + request.PageSize, + cancellationToken); + + // 2. 映射为 DTO + var items = packages.Select(p => new TenantPackageListDto + { + Id = p.Id, + Name = p.Name, + Description = p.Description, + PackageType = p.PackageType, + MonthlyPrice = p.MonthlyPrice, + YearlyPrice = p.YearlyPrice, + MaxStoreCount = p.MaxStoreCount, + MaxAccountCount = p.MaxAccountCount, + MaxStorageGb = p.MaxStorageGb, + MaxSmsCredits = p.MaxSmsCredits, + MaxDeliveryOrders = p.MaxDeliveryOrders, + FeaturePoliciesJson = p.FeaturePoliciesJson, + IsActive = p.IsActive, + IsPublicVisible = p.IsPublicVisible, + IsAllowNewTenantPurchase = p.IsAllowNewTenantPurchase, + PublishStatus = p.PublishStatus, + IsRecommended = p.IsRecommended, + Tags = p.Tags, + SortOrder = p.SortOrder + }).ToList(); + + // 3. 返回分页结果 + return new PagedResult(items, totalCount, request.Page, request.PageSize); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/TenantPackages/Handlers/UpdateTenantPackageCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/TenantPackages/Handlers/UpdateTenantPackageCommandHandler.cs new file mode 100644 index 0000000..39b5dc2 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/TenantPackages/Handlers/UpdateTenantPackageCommandHandler.cs @@ -0,0 +1,75 @@ +using MediatR; +using TakeoutSaaS.Application.App.TenantPackages.Commands; +using TakeoutSaaS.Application.App.TenantPackages.Contracts; +using TakeoutSaaS.Domain.Tenants.Repositories; + +namespace TakeoutSaaS.Application.App.TenantPackages.Handlers; + +/// +/// 更新租户套餐命令处理器。 +/// +public sealed class UpdateTenantPackageCommandHandler(ITenantPackageRepository tenantPackageRepository) + : IRequestHandler +{ + /// + public async Task Handle( + UpdateTenantPackageCommand request, + CancellationToken cancellationToken) + { + // 1. 查询套餐(带跟踪用于更新) + var package = await tenantPackageRepository.GetByIdForUpdateAsync(request.TenantPackageId, cancellationToken); + + // 2. 如果不存在,返回 null + if (package is null) + { + return null; + } + + // 3. 更新套餐属性 + 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; + package.IsPublicVisible = request.IsPublicVisible; + package.IsAllowNewTenantPurchase = request.IsAllowNewTenantPurchase; + package.PublishStatus = request.PublishStatus; + package.IsRecommended = request.IsRecommended; + package.Tags = request.Tags; + package.SortOrder = request.SortOrder; + + // 4. 保存变更 + await tenantPackageRepository.SaveChangesAsync(cancellationToken); + + // 5. 返回 DTO + return new TenantPackageListDto + { + 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, + IsPublicVisible = package.IsPublicVisible, + IsAllowNewTenantPurchase = package.IsAllowNewTenantPurchase, + PublishStatus = package.PublishStatus, + IsRecommended = package.IsRecommended, + Tags = package.Tags, + SortOrder = package.SortOrder + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/TenantPackages/Queries/GetTenantPackageDetailQuery.cs b/src/Application/TakeoutSaaS.Application/App/TenantPackages/Queries/GetTenantPackageDetailQuery.cs new file mode 100644 index 0000000..8069086 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/TenantPackages/Queries/GetTenantPackageDetailQuery.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.App.TenantPackages.Contracts; + +namespace TakeoutSaaS.Application.App.TenantPackages.Queries; + +/// +/// 获取租户套餐详情查询。 +/// +public sealed record GetTenantPackageDetailQuery : IRequest +{ + /// + /// 套餐 ID。 + /// + public long TenantPackageId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/TenantPackages/Queries/GetTenantPackageTenantsQuery.cs b/src/Application/TakeoutSaaS.Application/App/TenantPackages/Queries/GetTenantPackageTenantsQuery.cs new file mode 100644 index 0000000..d11d0e2 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/TenantPackages/Queries/GetTenantPackageTenantsQuery.cs @@ -0,0 +1,36 @@ +using MediatR; +using TakeoutSaaS.Application.App.TenantPackages.Contracts; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.TenantPackages.Queries; + +/// +/// 获取套餐当前使用租户列表查询。 +/// +public sealed record GetTenantPackageTenantsQuery : IRequest> +{ + /// + /// 套餐 ID。 + /// + public long TenantPackageId { get; init; } + + /// + /// 关键字(租户名称或编码)。 + /// + public string? Keyword { get; init; } + + /// + /// 即将到期天数筛选(N 天内到期)。 + /// + public int? ExpiringWithinDays { get; init; } + + /// + /// 页码(从 1 开始)。 + /// + public int Page { get; init; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; init; } = 20; +} diff --git a/src/Application/TakeoutSaaS.Application/App/TenantPackages/Queries/GetTenantPackageUsagesQuery.cs b/src/Application/TakeoutSaaS.Application/App/TenantPackages/Queries/GetTenantPackageUsagesQuery.cs new file mode 100644 index 0000000..84e1fd0 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/TenantPackages/Queries/GetTenantPackageUsagesQuery.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.App.TenantPackages.Contracts; + +namespace TakeoutSaaS.Application.App.TenantPackages.Queries; + +/// +/// 获取租户套餐使用统计查询。 +/// +public sealed record GetTenantPackageUsagesQuery : IRequest> +{ + /// + /// 套餐 ID 列表。 + /// + public IReadOnlyList TenantPackageIds { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/TenantPackages/Queries/ListTenantPackagesQuery.cs b/src/Application/TakeoutSaaS.Application/App/TenantPackages/Queries/ListTenantPackagesQuery.cs new file mode 100644 index 0000000..3d9f8ea --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/TenantPackages/Queries/ListTenantPackagesQuery.cs @@ -0,0 +1,31 @@ +using MediatR; +using TakeoutSaaS.Application.App.TenantPackages.Contracts; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.TenantPackages.Queries; + +/// +/// 获取租户套餐列表查询。 +/// +public sealed record ListTenantPackagesQuery : IRequest> +{ + /// + /// 关键字(套餐名称)。 + /// + public string? Keyword { get; init; } + + /// + /// 是否启用。 + /// + public bool? IsActive { get; init; } + + /// + /// 页码(从 1 开始)。 + /// + public int Page { get; init; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; init; } = 10; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Contracts/TenantBillingListDto.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Contracts/TenantBillingListDto.cs new file mode 100644 index 0000000..11c3424 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Contracts/TenantBillingListDto.cs @@ -0,0 +1,98 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Billings.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Tenants.Contracts; + +/// +/// 租户账单列表项 DTO。 +/// +public sealed record TenantBillingListDto +{ + /// + /// 账单 ID(雪花,序列化为字符串)。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户 ID(雪花,序列化为字符串)。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 租户名称。 + /// + public string? TenantName { get; init; } + + /// + /// 账单编号。 + /// + public string StatementNo { get; init; } = string.Empty; + + /// + /// 账单类型。 + /// + public TenantBillingType BillingType { get; init; } + + /// + /// 账单周期开始时间。 + /// + public DateTime PeriodStart { get; init; } + + /// + /// 账单周期结束时间。 + /// + public DateTime PeriodEnd { get; init; } + + /// + /// 应付金额。 + /// + public decimal AmountDue { get; init; } + + /// + /// 已付金额。 + /// + public decimal AmountPaid { get; init; } + + /// + /// 折扣金额。 + /// + public decimal DiscountAmount { get; init; } + + /// + /// 税额。 + /// + public decimal TaxAmount { get; init; } + + /// + /// 货币代码。 + /// + public string Currency { get; init; } = "CNY"; + + /// + /// 账单状态。 + /// + public TenantBillingStatus Status { get; init; } + + /// + /// 到期日期。 + /// + public DateTime DueDate { get; init; } + + /// + /// 逾期通知时间。 + /// + public DateTime? OverdueNotifiedAt { get; init; } + + /// + /// 创建时间。 + /// + public DateTime CreatedAt { get; init; } + + /// + /// 更新时间。 + /// + public DateTime? UpdatedAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Contracts/TenantDetailDto.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Contracts/TenantDetailDto.cs new file mode 100644 index 0000000..330c976 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Contracts/TenantDetailDto.cs @@ -0,0 +1,365 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Common.Enums; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Tenants.Contracts; + +/// +/// 租户详情 DTO(包含认证、订阅、套餐信息)。 +/// +public sealed record TenantDetailDto +{ + /// + /// 租户基本信息。 + /// + public required TenantDto Tenant { get; init; } + + /// + /// 认证信息。 + /// + public TenantVerificationDto? Verification { get; init; } + + /// + /// 订阅信息。 + /// + public TenantSubscriptionDto? Subscription { get; init; } + + /// + /// 套餐信息。 + /// + public TenantPackageDto? Package { get; init; } +} + +/// +/// 租户基本信息 DTO。 +/// +public sealed record TenantDto +{ + /// + /// 租户 ID(雪花,序列化为字符串)。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户编码。 + /// + public string Code { get; init; } = string.Empty; + + /// + /// 租户名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 租户简称。 + /// + public string? ShortName { get; init; } + + /// + /// 所属行业。 + /// + public string? Industry { get; init; } + + /// + /// 联系人姓名。 + /// + public string? ContactName { get; init; } + + /// + /// 联系人电话。 + /// + public string? ContactPhone { get; init; } + + /// + /// 联系人邮箱。 + /// + public string? ContactEmail { get; init; } + + /// + /// 租户状态。 + /// + public TenantStatus Status { get; init; } + + /// + /// 认证状态。 + /// + public TenantVerificationStatus VerificationStatus { get; init; } + + /// + /// 经营模式。 + /// + public OperatingMode? OperatingMode { get; init; } + + /// + /// 当前套餐 ID。 + /// + [JsonConverter(typeof(NullableSnowflakeIdJsonConverter))] + public long? CurrentPackageId { get; init; } + + /// + /// 服务生效时间。 + /// + public DateTime? EffectiveFrom { get; init; } + + /// + /// 服务到期时间。 + /// + public DateTime? EffectiveTo { get; init; } + + /// + /// 是否自动续费。 + /// + public bool AutoRenew { get; init; } +} + +/// +/// 租户认证信息 DTO。 +/// +public sealed record TenantVerificationDto +{ + /// + /// 认证记录 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 认证状态。 + /// + public TenantVerificationStatus Status { get; init; } + + /// + /// 营业执照号。 + /// + public string? BusinessLicenseNumber { get; init; } + + /// + /// 营业执照图片 URL。 + /// + public string? BusinessLicenseUrl { get; init; } + + /// + /// 法人姓名。 + /// + public string? LegalPersonName { get; init; } + + /// + /// 法人身份证号。 + /// + public string? LegalPersonIdNumber { get; init; } + + /// + /// 法人身份证正面 URL。 + /// + public string? LegalPersonIdFrontUrl { get; init; } + + /// + /// 法人身份证背面 URL。 + /// + public string? LegalPersonIdBackUrl { get; init; } + + /// + /// 银行账户名。 + /// + public string? BankAccountName { get; init; } + + /// + /// 银行账号。 + /// + public string? BankAccountNumber { get; init; } + + /// + /// 开户银行。 + /// + public string? BankName { get; init; } + + /// + /// 附加数据 JSON。 + /// + public string? AdditionalDataJson { get; init; } + + /// + /// 提交时间。 + /// + public DateTime? SubmittedAt { get; init; } + + /// + /// 审核人 ID。 + /// + [JsonConverter(typeof(NullableSnowflakeIdJsonConverter))] + public long? ReviewedBy { get; init; } + + /// + /// 审核备注。 + /// + public string? ReviewRemarks { get; init; } + + /// + /// 审核人姓名。 + /// + public string? ReviewedByName { get; init; } + + /// + /// 审核时间。 + /// + public DateTime? ReviewedAt { get; init; } +} + +/// +/// 租户订阅信息 DTO。 +/// +public sealed record TenantSubscriptionDto +{ + /// + /// 订阅 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 套餐 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantPackageId { get; init; } + + /// + /// 订阅状态。 + /// + public SubscriptionStatus Status { get; init; } + + /// + /// 生效时间。 + /// + public DateTime EffectiveFrom { get; init; } + + /// + /// 到期时间。 + /// + public DateTime EffectiveTo { get; init; } + + /// + /// 下次计费日期。 + /// + public DateTime? NextBillingDate { get; init; } + + /// + /// 是否自动续费。 + /// + public bool AutoRenew { get; init; } +} + +/// +/// 租户套餐信息 DTO。 +/// +public sealed record 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; } + + /// + /// 是否公开可见。 + /// + public bool IsPublicVisible { get; init; } + + /// + /// 是否允许新租户购买。 + /// + public bool IsAllowNewTenantPurchase { get; init; } + + /// + /// 发布状态。 + /// + public TenantPackagePublishStatus PublishStatus { get; init; } + + /// + /// 是否推荐。 + /// + public bool IsRecommended { get; init; } + + /// + /// 标签列表。 + /// + public string[] Tags { get; init; } = []; + + /// + /// 排序序号。 + /// + public int SortOrder { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Contracts/TenantQuotaUsageDto.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Contracts/TenantQuotaUsageDto.cs new file mode 100644 index 0000000..93a7b3d --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Contracts/TenantQuotaUsageDto.cs @@ -0,0 +1,47 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Tenants.Contracts; + +/// +/// 租户配额使用情况 DTO。 +/// +public sealed record TenantQuotaUsageDto +{ + /// + /// 租户 ID(雪花,序列化为字符串)。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 配额类型。 + /// + public TenantQuotaType QuotaType { get; init; } + + /// + /// 配额上限值。 + /// + public decimal LimitValue { get; init; } + + /// + /// 已使用值。 + /// + public decimal UsedValue { get; init; } + + /// + /// 剩余可用值。 + /// + public decimal RemainingValue { get; init; } + + /// + /// 重置周期(如 monthly、yearly)。 + /// + public string? ResetCycle { get; init; } + + /// + /// 上次重置时间。 + /// + public DateTime? LastResetAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantBillingsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantBillingsQueryHandler.cs new file mode 100644 index 0000000..8f28059 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantBillingsQueryHandler.cs @@ -0,0 +1,56 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Contracts; +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 GetTenantBillingsQueryHandler(ITenantRepository tenantRepository) + : IRequestHandler> +{ + /// + public async Task> Handle( + GetTenantBillingsQuery request, + CancellationToken cancellationToken) + { + // 1. 查询租户账单(分页) + var (billings, totalCount) = await tenantRepository.GetBillingsAsync( + request.TenantId, + request.Page, + request.PageSize, + cancellationToken); + + // 2. 获取租户名称 + var tenant = await tenantRepository.FindByIdAsync(request.TenantId, cancellationToken); + var tenantName = tenant?.Name; + + // 3. 映射为 DTO + var items = billings.Select(b => new TenantBillingListDto + { + Id = b.Id, + TenantId = b.TenantId, + TenantName = tenantName, + StatementNo = b.StatementNo, + BillingType = b.BillingType, + PeriodStart = b.PeriodStart, + PeriodEnd = b.PeriodEnd, + AmountDue = b.AmountDue, + AmountPaid = b.AmountPaid, + DiscountAmount = b.DiscountAmount, + TaxAmount = b.TaxAmount, + Currency = b.Currency, + Status = b.Status, + DueDate = b.DueDate, + OverdueNotifiedAt = b.OverdueNotifiedAt, + CreatedAt = b.CreatedAt, + UpdatedAt = b.UpdatedAt + }).ToList(); + + // 4. 返回分页结果 + return new PagedResult(items, totalCount, request.Page, request.PageSize); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantDetailQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantDetailQueryHandler.cs new file mode 100644 index 0000000..7551060 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantDetailQueryHandler.cs @@ -0,0 +1,128 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Contracts; +using TakeoutSaaS.Application.App.Tenants.Queries; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Domain.Tenants.Repositories; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 获取租户详情查询处理器。 +/// +public sealed class GetTenantDetailQueryHandler(ITenantRepository tenantRepository) + : IRequestHandler +{ + /// + public async Task Handle(GetTenantDetailQuery request, CancellationToken cancellationToken) + { + // 1. 查询租户详情 + var result = await tenantRepository.GetDetailAsync(request.TenantId, cancellationToken); + if (result is null) + { + return null; + } + + var (tenant, verification, subscription, package) = result; + + // 2. 映射租户 DTO + var tenantDto = new Contracts.TenantDto + { + Id = tenant.Id, + Code = tenant.Code, + Name = tenant.Name, + ShortName = tenant.ShortName, + Industry = tenant.Industry, + ContactName = tenant.ContactName, + ContactPhone = tenant.ContactPhone, + ContactEmail = tenant.ContactEmail, + Status = tenant.Status, + VerificationStatus = verification?.Status ?? TenantVerificationStatus.Draft, + OperatingMode = tenant.OperatingMode, + CurrentPackageId = subscription?.TenantPackageId, + EffectiveFrom = tenant.EffectiveFrom, + EffectiveTo = tenant.EffectiveTo, + AutoRenew = subscription?.AutoRenew ?? false + }; + + // 3. 映射认证 DTO + Contracts.TenantVerificationDto? verificationDto = null; + if (verification is not null) + { + verificationDto = new Contracts.TenantVerificationDto + { + Id = verification.Id, + TenantId = verification.TenantId, + Status = verification.Status, + BusinessLicenseNumber = verification.BusinessLicenseNumber, + BusinessLicenseUrl = verification.BusinessLicenseUrl, + LegalPersonName = verification.LegalPersonName, + LegalPersonIdNumber = verification.LegalPersonIdNumber, + LegalPersonIdFrontUrl = verification.LegalPersonIdFrontUrl, + LegalPersonIdBackUrl = verification.LegalPersonIdBackUrl, + BankAccountName = verification.BankAccountName, + BankAccountNumber = verification.BankAccountNumber, + BankName = verification.BankName, + AdditionalDataJson = verification.AdditionalDataJson, + SubmittedAt = verification.SubmittedAt, + ReviewedBy = verification.ReviewedBy, + ReviewRemarks = verification.ReviewRemarks, + ReviewedByName = verification.ReviewedByName, + ReviewedAt = verification.ReviewedAt + }; + } + + // 4. 映射订阅 DTO + Contracts.TenantSubscriptionDto? subscriptionDto = null; + if (subscription is not null) + { + subscriptionDto = new Contracts.TenantSubscriptionDto + { + Id = subscription.Id, + TenantId = subscription.TenantId, + TenantPackageId = subscription.TenantPackageId, + Status = subscription.Status, + EffectiveFrom = subscription.EffectiveFrom, + EffectiveTo = subscription.EffectiveTo, + NextBillingDate = subscription.NextBillingDate, + AutoRenew = subscription.AutoRenew + }; + } + + // 5. 映射套餐 DTO + Contracts.TenantPackageDto? packageDto = null; + if (package is not null) + { + packageDto = new Contracts.TenantPackageDto + { + 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, + IsPublicVisible = package.IsPublicVisible, + IsAllowNewTenantPurchase = package.IsAllowNewTenantPurchase, + PublishStatus = package.PublishStatus, + IsRecommended = package.IsRecommended, + Tags = package.Tags, + SortOrder = package.SortOrder + }; + } + + // 6. 组装返回结果 + return new TenantDetailDto + { + Tenant = tenantDto, + Verification = verificationDto, + Subscription = subscriptionDto, + Package = packageDto + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantQuotaUsageQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantQuotaUsageQueryHandler.cs new file mode 100644 index 0000000..e6a5269 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantQuotaUsageQueryHandler.cs @@ -0,0 +1,34 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Contracts; +using TakeoutSaaS.Application.App.Tenants.Queries; +using TakeoutSaaS.Domain.Tenants.Repositories; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 获取租户配额使用情况查询处理器。 +/// +public sealed class GetTenantQuotaUsageQueryHandler(ITenantRepository tenantRepository) + : IRequestHandler> +{ + /// + public async Task> Handle( + GetTenantQuotaUsageQuery request, + CancellationToken cancellationToken) + { + // 1. 查询租户配额使用情况 + var usages = await tenantRepository.GetQuotaUsagesAsync(request.TenantId, cancellationToken); + + // 2. 映射为 DTO + return usages.Select(u => new TenantQuotaUsageDto + { + TenantId = u.TenantId, + QuotaType = u.QuotaType, + LimitValue = u.LimitValue, + UsedValue = u.UsedValue, + RemainingValue = Math.Max(0, u.LimitValue - u.UsedValue), + ResetCycle = u.ResetCycle, + LastResetAt = u.LastResetAt + }).ToList(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantBillingsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantBillingsQuery.cs new file mode 100644 index 0000000..99bb303 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantBillingsQuery.cs @@ -0,0 +1,26 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Contracts; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.Tenants.Queries; + +/// +/// 获取租户账单列表查询。 +/// +public sealed record GetTenantBillingsQuery : IRequest> +{ + /// + /// 租户 ID(雪花算法)。 + /// + public required long TenantId { get; init; } + + /// + /// 页码(从 1 开始)。 + /// + public int Page { get; init; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; init; } = 10; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantDetailQuery.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantDetailQuery.cs new file mode 100644 index 0000000..b5efb4b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantDetailQuery.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Contracts; + +namespace TakeoutSaaS.Application.App.Tenants.Queries; + +/// +/// 获取租户详情查询。 +/// +public sealed record GetTenantDetailQuery : IRequest +{ + /// + /// 租户 ID(雪花算法)。 + /// + public long TenantId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantQuotaUsageQuery.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantQuotaUsageQuery.cs new file mode 100644 index 0000000..c4a315a --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantQuotaUsageQuery.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Contracts; + +namespace TakeoutSaaS.Application.App.Tenants.Queries; + +/// +/// 获取租户配额使用情况查询。 +/// +public sealed record GetTenantQuotaUsageQuery : IRequest> +{ + /// + /// 租户 ID(雪花算法)。 + /// + public required long TenantId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Commands/CloneAdminRoleCommand.cs b/src/Application/TakeoutSaaS.Application/Identity/Commands/CloneAdminRoleCommand.cs new file mode 100644 index 0000000..1eb97bb --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Commands/CloneAdminRoleCommand.cs @@ -0,0 +1,35 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Contracts; + +namespace TakeoutSaaS.Application.Identity.Commands; + +/// +/// 克隆平台角色命令。 +/// +public sealed record CloneAdminRoleCommand : IRequest +{ + /// + /// 源角色 ID(由路由绑定)。 + /// + public long SourceRoleId { get; init; } + + /// + /// 新角色编码(平台范围内唯一)。 + /// + public string NewCode { get; init; } = string.Empty; + + /// + /// 新角色名称。 + /// + public string NewName { get; init; } = string.Empty; + + /// + /// 新角色描述(为空则沿用源角色)。 + /// + public string? NewDescription { get; init; } + + /// + /// 是否复制权限(默认 true)。 + /// + public bool CopyPermissions { get; init; } = true; +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Commands/CreateAdminRoleCommand.cs b/src/Application/TakeoutSaaS.Application/Identity/Commands/CreateAdminRoleCommand.cs new file mode 100644 index 0000000..252f151 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Commands/CreateAdminRoleCommand.cs @@ -0,0 +1,25 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Contracts; + +namespace TakeoutSaaS.Application.Identity.Commands; + +/// +/// 创建平台角色命令。 +/// +public sealed record CreateAdminRoleCommand : IRequest +{ + /// + /// 角色编码(平台范围内唯一)。 + /// + public string Code { get; init; } = string.Empty; + + /// + /// 角色名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 角色描述。 + /// + public string? Description { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Commands/DeleteAdminRoleCommand.cs b/src/Application/TakeoutSaaS.Application/Identity/Commands/DeleteAdminRoleCommand.cs new file mode 100644 index 0000000..15eae8c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Commands/DeleteAdminRoleCommand.cs @@ -0,0 +1,14 @@ +using MediatR; + +namespace TakeoutSaaS.Application.Identity.Commands; + +/// +/// 删除平台角色命令。 +/// +public sealed record DeleteAdminRoleCommand : IRequest +{ + /// + /// 角色 ID(由路由绑定)。 + /// + public long RoleId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Commands/UpdateAdminRoleCommand.cs b/src/Application/TakeoutSaaS.Application/Identity/Commands/UpdateAdminRoleCommand.cs new file mode 100644 index 0000000..8f9276e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Commands/UpdateAdminRoleCommand.cs @@ -0,0 +1,25 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Contracts; + +namespace TakeoutSaaS.Application.Identity.Commands; + +/// +/// 更新平台角色命令。 +/// +public sealed record UpdateAdminRoleCommand : IRequest +{ + /// + /// 角色 ID(由路由绑定)。 + /// + public long RoleId { get; init; } + + /// + /// 角色名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 角色描述。 + /// + public string? Description { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Commands/UpdateAdminRolePermissionsCommand.cs b/src/Application/TakeoutSaaS.Application/Identity/Commands/UpdateAdminRolePermissionsCommand.cs new file mode 100644 index 0000000..bb3d696 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Commands/UpdateAdminRolePermissionsCommand.cs @@ -0,0 +1,19 @@ +using MediatR; + +namespace TakeoutSaaS.Application.Identity.Commands; + +/// +/// 更新平台角色权限命令。 +/// +public sealed record UpdateAdminRolePermissionsCommand : IRequest +{ + /// + /// 角色 ID(由路由绑定)。 + /// + public long RoleId { get; init; } + + /// + /// 权限 ID 集合(替换模式)。 + /// + public IReadOnlyCollection PermissionIds { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/CloneAdminRoleCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/CloneAdminRoleCommandHandler.cs new file mode 100644 index 0000000..2501830 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/CloneAdminRoleCommandHandler.cs @@ -0,0 +1,86 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Commands; +using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Domain.Identity.Entities; +using TakeoutSaaS.Domain.Identity.Enums; +using TakeoutSaaS.Domain.Identity.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; + +namespace TakeoutSaaS.Application.Identity.Handlers; + +/// +/// 克隆平台角色命令处理器。 +/// +public sealed class CloneAdminRoleCommandHandler( + IRoleRepository roleRepository, + IRolePermissionRepository rolePermissionRepository) + : IRequestHandler +{ + /// + public async Task Handle(CloneAdminRoleCommand request, CancellationToken cancellationToken) + { + // 1. 校验新角色编码与名称不能为空 + if (string.IsNullOrWhiteSpace(request.NewCode) || string.IsNullOrWhiteSpace(request.NewName)) + { + throw new BusinessException(ErrorCodes.BadRequest, "新角色编码与名称不能为空"); + } + + // 2. 校验源角色存在 + var source = await roleRepository.FindByIdAsync(PortalType.Admin, null, request.SourceRoleId, cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "源角色不存在"); + + // 3. 校验新角色编码唯一性(平台范围内) + var existing = await roleRepository.FindByCodeAsync(PortalType.Admin, null, request.NewCode, cancellationToken); + if (existing is not null) + { + throw new BusinessException(ErrorCodes.Conflict, $"角色编码 {request.NewCode} 已存在"); + } + + // 4. 构造新角色实体 + var newRole = new Role + { + Portal = PortalType.Admin, + TenantId = null, + Code = request.NewCode.Trim(), + Name = request.NewName.Trim(), + Description = request.NewDescription ?? source.Description + }; + + // 5. 持久化新角色 + await roleRepository.AddAsync(newRole, cancellationToken); + await roleRepository.SaveChangesAsync(cancellationToken); + + // 6. 复制权限关系 + if (request.CopyPermissions) + { + var sourcePermissions = await rolePermissionRepository.GetByRoleIdsAsync( + PortalType.Admin, + null, + [request.SourceRoleId], + cancellationToken); + + var permissionIds = sourcePermissions.Select(p => p.PermissionId).Distinct().ToArray(); + if (permissionIds.Length > 0) + { + await rolePermissionRepository.ReplaceRolePermissionsAsync( + PortalType.Admin, + null, + newRole.Id, + permissionIds, + cancellationToken); + } + } + + // 7. 返回 DTO + return new RoleDto + { + Portal = newRole.Portal, + Id = newRole.Id, + TenantId = newRole.TenantId, + Code = newRole.Code, + Name = newRole.Name, + Description = newRole.Description + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreateAdminRoleCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreateAdminRoleCommandHandler.cs new file mode 100644 index 0000000..d162c25 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreateAdminRoleCommandHandler.cs @@ -0,0 +1,59 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Commands; +using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Domain.Identity.Entities; +using TakeoutSaaS.Domain.Identity.Enums; +using TakeoutSaaS.Domain.Identity.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; + +namespace TakeoutSaaS.Application.Identity.Handlers; + +/// +/// 创建平台角色命令处理器。 +/// +public sealed class CreateAdminRoleCommandHandler(IRoleRepository roleRepository) + : IRequestHandler +{ + /// + public async Task Handle(CreateAdminRoleCommand request, CancellationToken cancellationToken) + { + // 1. 校验必填字段 + if (string.IsNullOrWhiteSpace(request.Code) || string.IsNullOrWhiteSpace(request.Name)) + { + throw new BusinessException(ErrorCodes.BadRequest, "角色编码与名称不能为空"); + } + + // 2. 检查编码唯一性(平台范围内) + var existing = await roleRepository.FindByCodeAsync(PortalType.Admin, null, request.Code, cancellationToken); + if (existing is not null) + { + throw new BusinessException(ErrorCodes.Conflict, $"角色编码 {request.Code} 已存在"); + } + + // 3. 构建角色实体 + var role = new Role + { + Portal = PortalType.Admin, + TenantId = null, + Code = request.Code.Trim(), + Name = request.Name.Trim(), + Description = request.Description + }; + + // 4. 持久化 + await roleRepository.AddAsync(role, cancellationToken); + await roleRepository.SaveChangesAsync(cancellationToken); + + // 5. 返回 DTO + return new RoleDto + { + Portal = role.Portal, + Id = role.Id, + TenantId = role.TenantId, + Code = role.Code, + Name = role.Name, + Description = role.Description + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/DeleteAdminRoleCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/DeleteAdminRoleCommandHandler.cs new file mode 100644 index 0000000..d3d67f6 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/DeleteAdminRoleCommandHandler.cs @@ -0,0 +1,33 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Commands; +using TakeoutSaaS.Domain.Identity.Enums; +using TakeoutSaaS.Domain.Identity.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; + +namespace TakeoutSaaS.Application.Identity.Handlers; + +/// +/// 删除平台角色命令处理器。 +/// +public sealed class DeleteAdminRoleCommandHandler(IRoleRepository roleRepository) + : IRequestHandler +{ + /// + public async Task Handle(DeleteAdminRoleCommand request, CancellationToken cancellationToken) + { + // 1. 校验角色存在 + var role = await roleRepository.FindByIdAsync(PortalType.Admin, null, request.RoleId, cancellationToken); + if (role is null) + { + throw new BusinessException(ErrorCodes.NotFound, "角色不存在"); + } + + // 2. 执行软删除 + await roleRepository.DeleteAsync(PortalType.Admin, null, request.RoleId, cancellationToken); + await roleRepository.SaveChangesAsync(cancellationToken); + + // 3. 返回成功 + return true; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetAdminRolePermissionsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetAdminRolePermissionsQueryHandler.cs new file mode 100644 index 0000000..3c0f927 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetAdminRolePermissionsQueryHandler.cs @@ -0,0 +1,59 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Application.Identity.Queries; +using TakeoutSaaS.Domain.Identity.Enums; +using TakeoutSaaS.Domain.Identity.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; + +namespace TakeoutSaaS.Application.Identity.Handlers; + +/// +/// 获取平台角色权限列表查询处理器。 +/// +public sealed class GetAdminRolePermissionsQueryHandler( + IRoleRepository roleRepository, + IRolePermissionRepository rolePermissionRepository, + IPermissionRepository permissionRepository) + : IRequestHandler> +{ + /// + public async Task> Handle(GetAdminRolePermissionsQuery request, CancellationToken cancellationToken) + { + // 1. 校验角色存在 + var role = await roleRepository.FindByIdAsync(PortalType.Admin, null, request.RoleId, cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "角色不存在"); + + // 2. 查询角色权限关系 + var rolePermissions = await rolePermissionRepository.GetByRoleIdsAsync( + PortalType.Admin, + null, + [request.RoleId], + cancellationToken); + + // 3. 提取权限 ID 集合 + var permissionIds = rolePermissions.Select(rp => rp.PermissionId).Distinct().ToArray(); + if (permissionIds.Length == 0) + { + return []; + } + + // 4. 查询权限详情 + var permissions = await permissionRepository.GetByIdsAsync(permissionIds, cancellationToken); + + // 5. 映射 DTO + return permissions + .Select(p => new PermissionDto + { + Portal = p.Portal, + Id = p.Id, + ParentId = p.ParentId, + SortOrder = p.SortOrder, + Type = p.Type, + Name = p.Name, + Code = p.Code, + Description = p.Description + }) + .ToArray(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/ListAdminRolesQueryHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/ListAdminRolesQueryHandler.cs new file mode 100644 index 0000000..272f55e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/ListAdminRolesQueryHandler.cs @@ -0,0 +1,48 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Application.Identity.Queries; +using TakeoutSaaS.Domain.Identity.Enums; +using TakeoutSaaS.Domain.Identity.Repositories; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.Identity.Handlers; + +/// +/// 获取平台角色列表查询处理器。 +/// +public sealed class ListAdminRolesQueryHandler(IRoleRepository roleRepository) + : IRequestHandler> +{ + /// + public async Task> Handle(ListAdminRolesQuery request, CancellationToken cancellationToken) + { + // 1. 查询平台下所有角色 + var roles = await roleRepository.SearchAsync(PortalType.Admin, null, request.Keyword, cancellationToken); + + // 2. 计算分页参数 + var totalCount = roles.Count; + var page = Math.Max(1, request.Page); + var pageSize = Math.Clamp(request.PageSize, 1, 100); + + // 3. 应用分页 + var pagedRoles = roles + .OrderBy(r => r.Code) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToArray(); + + // 4. 映射 DTO + var dtos = pagedRoles.Select(role => new RoleDto + { + Portal = role.Portal, + Id = role.Id, + TenantId = role.TenantId, + Code = role.Code, + Name = role.Name, + Description = role.Description + }).ToArray(); + + // 5. 返回分页结果 + return new PagedResult(dtos, page, pageSize, totalCount); + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdateAdminRoleCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdateAdminRoleCommandHandler.cs new file mode 100644 index 0000000..dd86aa2 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdateAdminRoleCommandHandler.cs @@ -0,0 +1,52 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Commands; +using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Domain.Identity.Enums; +using TakeoutSaaS.Domain.Identity.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; + +namespace TakeoutSaaS.Application.Identity.Handlers; + +/// +/// 更新平台角色命令处理器。 +/// +public sealed class UpdateAdminRoleCommandHandler(IRoleRepository roleRepository) + : IRequestHandler +{ + /// + public async Task Handle(UpdateAdminRoleCommand request, CancellationToken cancellationToken) + { + // 1. 校验必填字段 + if (string.IsNullOrWhiteSpace(request.Name)) + { + throw new BusinessException(ErrorCodes.BadRequest, "角色名称不能为空"); + } + + // 2. 查询目标角色 + var role = await roleRepository.FindByIdAsync(PortalType.Admin, null, request.RoleId, cancellationToken); + if (role is null) + { + return null; + } + + // 3. 更新字段 + role.Name = request.Name.Trim(); + role.Description = request.Description; + + // 4. 持久化 + await roleRepository.UpdateAsync(role, cancellationToken); + await roleRepository.SaveChangesAsync(cancellationToken); + + // 5. 返回 DTO + return new RoleDto + { + Portal = role.Portal, + Id = role.Id, + TenantId = role.TenantId, + Code = role.Code, + Name = role.Name, + Description = role.Description + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdateAdminRolePermissionsCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdateAdminRolePermissionsCommandHandler.cs new file mode 100644 index 0000000..1a0b9ca --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdateAdminRolePermissionsCommandHandler.cs @@ -0,0 +1,36 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Commands; +using TakeoutSaaS.Domain.Identity.Enums; +using TakeoutSaaS.Domain.Identity.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; + +namespace TakeoutSaaS.Application.Identity.Handlers; + +/// +/// 更新平台角色权限命令处理器。 +/// +public sealed class UpdateAdminRolePermissionsCommandHandler( + IRoleRepository roleRepository, + IRolePermissionRepository rolePermissionRepository) + : IRequestHandler +{ + /// + public async Task Handle(UpdateAdminRolePermissionsCommand request, CancellationToken cancellationToken) + { + // 1. 校验角色存在 + var role = await roleRepository.FindByIdAsync(PortalType.Admin, null, request.RoleId, cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "角色不存在"); + + // 2. 替换角色权限(事务保证) + await rolePermissionRepository.ReplaceRolePermissionsAsync( + PortalType.Admin, + null, + request.RoleId, + request.PermissionIds, + cancellationToken); + + // 3. 返回成功 + return true; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Queries/GetAdminRolePermissionsQuery.cs b/src/Application/TakeoutSaaS.Application/Identity/Queries/GetAdminRolePermissionsQuery.cs new file mode 100644 index 0000000..af93a7b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Queries/GetAdminRolePermissionsQuery.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Contracts; + +namespace TakeoutSaaS.Application.Identity.Queries; + +/// +/// 获取平台角色权限列表查询。 +/// +public sealed record GetAdminRolePermissionsQuery : IRequest> +{ + /// + /// 角色 ID。 + /// + public long RoleId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Queries/ListAdminRolesQuery.cs b/src/Application/TakeoutSaaS.Application/Identity/Queries/ListAdminRolesQuery.cs new file mode 100644 index 0000000..59e1e9c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Queries/ListAdminRolesQuery.cs @@ -0,0 +1,26 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.Identity.Queries; + +/// +/// 获取平台角色列表查询。 +/// +public sealed record ListAdminRolesQuery : IRequest> +{ + /// + /// 关键字(角色名称/编码)。 + /// + public string? Keyword { get; init; } + + /// + /// 页码(从 1 开始)。 + /// + public int Page { get; init; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; init; } = 20; +} diff --git a/src/Domain/TakeoutSaaS.Domain/Billings/Entities/TenantBillingStatement.cs b/src/Domain/TakeoutSaaS.Domain/Billings/Entities/TenantBillingStatement.cs new file mode 100644 index 0000000..55c8e98 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Billings/Entities/TenantBillingStatement.cs @@ -0,0 +1,95 @@ +using TakeoutSaaS.Domain.Billings.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Billings.Entities; + +/// +/// 租户账单。 +/// +public sealed class TenantBillingStatement : AuditableEntityBase +{ + /// + /// 租户 ID(雪花算法)。 + /// + public long TenantId { get; set; } + + /// + /// 账单编号。 + /// + public string StatementNo { get; set; } = string.Empty; + + /// + /// 账单类型。 + /// + public TenantBillingType BillingType { get; set; } + + /// + /// 账单周期开始时间。 + /// + public DateTime PeriodStart { get; set; } + + /// + /// 账单周期结束时间。 + /// + public DateTime PeriodEnd { get; set; } + + /// + /// 应付金额。 + /// + public decimal AmountDue { get; set; } + + /// + /// 已付金额。 + /// + public decimal AmountPaid { get; set; } + + /// + /// 折扣金额。 + /// + public decimal DiscountAmount { get; set; } + + /// + /// 税额。 + /// + public decimal TaxAmount { get; set; } + + /// + /// 货币代码。 + /// + public string Currency { get; set; } = "CNY"; + + /// + /// 账单状态。 + /// + public TenantBillingStatus Status { get; set; } + + /// + /// 到期日期。 + /// + public DateTime DueDate { get; set; } + + /// + /// 账单明细 JSON。 + /// + public string? LineItemsJson { get; set; } + + /// + /// 备注。 + /// + public string? Notes { get; set; } + + /// + /// 逾期通知时间。 + /// + public DateTime? OverdueNotifiedAt { get; set; } + + /// + /// 提醒发送时间。 + /// + public DateTime? ReminderSentAt { get; set; } + + /// + /// 关联订阅 ID。 + /// + public long? SubscriptionId { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Billings/Entities/TenantPayment.cs b/src/Domain/TakeoutSaaS.Domain/Billings/Entities/TenantPayment.cs new file mode 100644 index 0000000..b2424fa --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Billings/Entities/TenantPayment.cs @@ -0,0 +1,75 @@ +using TakeoutSaaS.Domain.Billings.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Billings.Entities; + +/// +/// 租户支付记录。 +/// +public sealed class TenantPayment : AuditableEntityBase +{ + /// + /// 租户 ID(雪花算法)。 + /// + public long TenantId { get; set; } + + /// + /// 账单 ID(雪花算法)。 + /// + public long BillingStatementId { get; set; } + + /// + /// 支付金额。 + /// + public decimal Amount { get; set; } + + /// + /// 支付方式。 + /// + public TenantPaymentMethod Method { get; set; } + + /// + /// 支付状态。 + /// + public TenantPaymentStatus Status { get; set; } + + /// + /// 交易号。 + /// + public string? TransactionNo { get; set; } + + /// + /// 支付凭证 URL。 + /// + public string? ProofUrl { get; set; } + + /// + /// 支付时间。 + /// + public DateTime? PaidAt { get; set; } + + /// + /// 备注。 + /// + public string? Notes { get; set; } + + /// + /// 审核人 ID。 + /// + public long? VerifiedBy { get; set; } + + /// + /// 审核时间。 + /// + public DateTime? VerifiedAt { get; set; } + + /// + /// 退款原因。 + /// + public string? RefundReason { get; set; } + + /// + /// 退款时间。 + /// + public DateTime? RefundedAt { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Billings/Enums/TenantBillingStatus.cs b/src/Domain/TakeoutSaaS.Domain/Billings/Enums/TenantBillingStatus.cs new file mode 100644 index 0000000..66f694e --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Billings/Enums/TenantBillingStatus.cs @@ -0,0 +1,27 @@ +namespace TakeoutSaaS.Domain.Billings.Enums; + +/// +/// 租户账单状态。 +/// +public enum TenantBillingStatus +{ + /// + /// 待支付。 + /// + Pending = 0, + + /// + /// 已支付。 + /// + Paid = 1, + + /// + /// 已逾期。 + /// + Overdue = 2, + + /// + /// 已取消。 + /// + Cancelled = 3 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Billings/Enums/TenantBillingType.cs b/src/Domain/TakeoutSaaS.Domain/Billings/Enums/TenantBillingType.cs new file mode 100644 index 0000000..da7beb1 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Billings/Enums/TenantBillingType.cs @@ -0,0 +1,27 @@ +namespace TakeoutSaaS.Domain.Billings.Enums; + +/// +/// 租户账单类型。 +/// +public enum TenantBillingType +{ + /// + /// 订阅账单。 + /// + Subscription = 0, + + /// + /// 配额包购买。 + /// + QuotaPurchase = 1, + + /// + /// 手动创建。 + /// + Manual = 2, + + /// + /// 续费账单。 + /// + Renewal = 3 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Billings/Enums/TenantPaymentMethod.cs b/src/Domain/TakeoutSaaS.Domain/Billings/Enums/TenantPaymentMethod.cs new file mode 100644 index 0000000..65d9cda --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Billings/Enums/TenantPaymentMethod.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Domain.Billings.Enums; + +/// +/// 租户支付方式。 +/// +public enum TenantPaymentMethod +{ + /// + /// 在线支付。 + /// + Online = 0, + + /// + /// 银行转账。 + /// + BankTransfer = 1, + + /// + /// 其他方式。 + /// + Other = 2 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Billings/Enums/TenantPaymentStatus.cs b/src/Domain/TakeoutSaaS.Domain/Billings/Enums/TenantPaymentStatus.cs new file mode 100644 index 0000000..0256e95 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Billings/Enums/TenantPaymentStatus.cs @@ -0,0 +1,27 @@ +namespace TakeoutSaaS.Domain.Billings.Enums; + +/// +/// 租户支付状态。 +/// +public enum TenantPaymentStatus +{ + /// + /// 待处理。 + /// + Pending = 0, + + /// + /// 成功。 + /// + Success = 1, + + /// + /// 失败。 + /// + Failed = 2, + + /// + /// 已退款。 + /// + Refunded = 3 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Billings/Repositories/IBillingRepository.cs b/src/Domain/TakeoutSaaS.Domain/Billings/Repositories/IBillingRepository.cs new file mode 100644 index 0000000..0a6f6cf --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Billings/Repositories/IBillingRepository.cs @@ -0,0 +1,128 @@ +using TakeoutSaaS.Domain.Billings.Entities; +using TakeoutSaaS.Domain.Billings.Enums; + +namespace TakeoutSaaS.Domain.Billings.Repositories; + +/// +/// 账单仓储(AdminApi 使用)。 +/// +public interface IBillingRepository +{ + /// + /// 获取账单列表(分页)。 + /// + /// 租户 ID。 + /// 账单状态。 + /// 账单类型。 + /// 开始日期。 + /// 结束日期。 + /// 最小金额。 + /// 最大金额。 + /// 关键词(账单号、租户名)。 + /// 排序字段。 + /// 是否降序。 + /// 页码(从 1 开始)。 + /// 每页条数。 + /// 取消标记。 + /// 账单列表和总数。 + Task<(IReadOnlyList Items, int TotalCount)> GetListAsync( + long? tenantId, + TenantBillingStatus? status, + TenantBillingType? billingType, + DateTime? startDate, + DateTime? endDate, + decimal? minAmount, + decimal? maxAmount, + string? keyword, + string? sortBy, + bool? sortDesc, + int page, + int pageSize, + CancellationToken cancellationToken = default); + + /// + /// 根据 ID 获取账单。 + /// + /// 账单 ID。 + /// 取消标记。 + /// 账单实体,不存在则返回 null。 + Task GetByIdAsync(long billingId, CancellationToken cancellationToken = default); + + /// + /// 根据 ID 获取账单(用于更新,带跟踪)。 + /// + /// 账单 ID。 + /// 取消标记。 + /// 账单实体,不存在则返回 null。 + Task GetByIdForUpdateAsync(long billingId, CancellationToken cancellationToken = default); + + /// + /// 获取账单详情(带租户信息)。 + /// + /// 账单 ID。 + /// 取消标记。 + /// 账单详情结果,不存在则返回 null。 + Task GetDetailAsync(long billingId, CancellationToken cancellationToken = default); + + /// + /// 保存仓储变更。 + /// + /// 取消标记。 + /// 异步操作任务。 + Task SaveChangesAsync(CancellationToken cancellationToken = default); + + /// + /// 添加支付记录。 + /// + /// 支付记录实体。 + /// 取消标记。 + /// 异步操作任务。 + Task AddPaymentAsync(TenantPayment payment, CancellationToken cancellationToken = default); +} + +/// +/// 账单列表结果。 +/// +public sealed record BillingListResult( + long Id, + long TenantId, + string? TenantName, + string StatementNo, + TenantBillingType BillingType, + DateTime PeriodStart, + DateTime PeriodEnd, + decimal AmountDue, + decimal AmountPaid, + decimal DiscountAmount, + decimal TaxAmount, + string Currency, + TenantBillingStatus Status, + DateTime DueDate, + DateTime? OverdueNotifiedAt, + DateTime CreatedAt, + DateTime? UpdatedAt); + +/// +/// 账单详情结果。 +/// +public sealed record BillingDetailResult( + long Id, + long TenantId, + string? TenantName, + string StatementNo, + TenantBillingType BillingType, + DateTime PeriodStart, + DateTime PeriodEnd, + decimal AmountDue, + decimal AmountPaid, + decimal DiscountAmount, + decimal TaxAmount, + string Currency, + TenantBillingStatus Status, + DateTime DueDate, + string? LineItemsJson, + string? Notes, + DateTime? OverdueNotifiedAt, + DateTime? ReminderSentAt, + DateTime CreatedAt, + DateTime? UpdatedAt); diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantPackage.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantPackage.cs new file mode 100644 index 0000000..22aeb87 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantPackage.cs @@ -0,0 +1,100 @@ +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Tenants.Entities; + +/// +/// 租户套餐定义,描述不同等级的服务套餐。 +/// +public sealed class TenantPackage : AuditableEntityBase +{ + /// + /// 套餐名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 套餐描述。 + /// + public string? Description { get; set; } + + /// + /// 套餐类型。 + /// + public TenantPackageType PackageType { get; set; } = TenantPackageType.Free; + + /// + /// 月付价格。 + /// + public decimal? MonthlyPrice { get; set; } + + /// + /// 年付价格。 + /// + public decimal? YearlyPrice { get; set; } + + /// + /// 最大门店数。 + /// + public int? MaxStoreCount { get; set; } + + /// + /// 最大账号数。 + /// + public int? MaxAccountCount { get; set; } + + /// + /// 最大存储空间(GB)。 + /// + public int? MaxStorageGb { get; set; } + + /// + /// 最大短信额度。 + /// + public int? MaxSmsCredits { get; set; } + + /// + /// 最大配送订单数。 + /// + public int? MaxDeliveryOrders { get; set; } + + /// + /// 功能策略 JSON。 + /// + public string? FeaturePoliciesJson { get; set; } + + /// + /// 是否启用。 + /// + public bool IsActive { get; set; } = true; + + /// + /// 排序序号。 + /// + public int SortOrder { get; set; } + + /// + /// 是否允许新租户购买。 + /// + public bool IsAllowNewTenantPurchase { get; set; } = true; + + /// + /// 是否公开可见。 + /// + public bool IsPublicVisible { get; set; } = true; + + /// + /// 发布状态。 + /// + public TenantPackagePublishStatus PublishStatus { get; set; } = TenantPackagePublishStatus.Draft; + + /// + /// 是否推荐。 + /// + public bool IsRecommended { get; set; } + + /// + /// 标签列表。 + /// + public string[] Tags { get; set; } = []; +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantQuotaUsage.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantQuotaUsage.cs new file mode 100644 index 0000000..72747c1 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantQuotaUsage.cs @@ -0,0 +1,40 @@ +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Tenants.Entities; + +/// +/// 租户配额使用情况。 +/// +public sealed class TenantQuotaUsage : AuditableEntityBase +{ + /// + /// 租户 ID(雪花算法)。 + /// + public long TenantId { get; set; } + + /// + /// 配额类型。 + /// + public TenantQuotaType QuotaType { get; set; } + + /// + /// 配额上限值。 + /// + public decimal LimitValue { get; set; } + + /// + /// 已使用值。 + /// + public decimal UsedValue { get; set; } + + /// + /// 重置周期(如 monthly、yearly)。 + /// + public string? ResetCycle { get; set; } + + /// + /// 上次重置时间。 + /// + public DateTime? LastResetAt { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantSubscription.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantSubscription.cs new file mode 100644 index 0000000..3db732a --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantSubscription.cs @@ -0,0 +1,60 @@ +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Tenants.Entities; + +/// +/// 租户订阅记录,描述租户当前的套餐订阅状态。 +/// +public sealed class TenantSubscription : AuditableEntityBase +{ + /// + /// 关联的租户 ID。 + /// + public long TenantId { get; set; } + + /// + /// 订阅的套餐 ID。 + /// + public long TenantPackageId { get; set; } + + /// + /// 订阅状态。 + /// + public SubscriptionStatus Status { get; set; } = SubscriptionStatus.Active; + + /// + /// 生效时间。 + /// + public DateTime EffectiveFrom { get; set; } + + /// + /// 到期时间。 + /// + public DateTime EffectiveTo { get; set; } + + /// + /// 下次计费日期。 + /// + public DateTime? NextBillingDate { get; set; } + + /// + /// 是否自动续费。 + /// + public bool AutoRenew { get; set; } + + /// + /// 预约变更的套餐 ID(下个周期生效)。 + /// + public long? ScheduledPackageId { get; set; } + + /// + /// 备注。 + /// + public string? Notes { get; set; } + + /// + /// 关联的套餐(导航属性)。 + /// + public TenantPackage? TenantPackage { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantSubscriptionHistory.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantSubscriptionHistory.cs new file mode 100644 index 0000000..96fe9d4 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantSubscriptionHistory.cs @@ -0,0 +1,60 @@ +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Tenants.Entities; + +/// +/// 租户订阅变更历史记录。 +/// +public sealed class TenantSubscriptionHistory : AuditableEntityBase +{ + /// + /// 关联的租户 ID。 + /// + public long TenantId { get; set; } + + /// + /// 关联的订阅 ID。 + /// + public long TenantSubscriptionId { get; set; } + + /// + /// 变更前套餐 ID。 + /// + public long FromPackageId { get; set; } + + /// + /// 变更后套餐 ID。 + /// + public long ToPackageId { get; set; } + + /// + /// 变更类型。 + /// + public SubscriptionChangeType ChangeType { get; set; } + + /// + /// 生效时间。 + /// + public DateTime EffectiveFrom { get; set; } + + /// + /// 到期时间。 + /// + public DateTime EffectiveTo { get; set; } + + /// + /// 金额。 + /// + public decimal? Amount { get; set; } + + /// + /// 货币。 + /// + public string? Currency { get; set; } + + /// + /// 备注。 + /// + public string? Notes { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantVerificationProfile.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantVerificationProfile.cs new file mode 100644 index 0000000..957b9b3 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantVerificationProfile.cs @@ -0,0 +1,95 @@ +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Tenants.Entities; + +/// +/// 租户认证资料,用于企业资质审核。 +/// +public sealed class TenantVerificationProfile : AuditableEntityBase +{ + /// + /// 关联的租户 ID。 + /// + public long TenantId { get; set; } + + /// + /// 认证状态。 + /// + public TenantVerificationStatus Status { get; set; } = TenantVerificationStatus.Draft; + + /// + /// 营业执照号。 + /// + public string? BusinessLicenseNumber { get; set; } + + /// + /// 营业执照图片 URL。 + /// + public string? BusinessLicenseUrl { get; set; } + + /// + /// 法人姓名。 + /// + public string? LegalPersonName { get; set; } + + /// + /// 法人身份证号。 + /// + public string? LegalPersonIdNumber { get; set; } + + /// + /// 法人身份证正面 URL。 + /// + public string? LegalPersonIdFrontUrl { get; set; } + + /// + /// 法人身份证背面 URL。 + /// + public string? LegalPersonIdBackUrl { get; set; } + + /// + /// 银行账户名。 + /// + public string? BankAccountName { get; set; } + + /// + /// 银行账号。 + /// + public string? BankAccountNumber { get; set; } + + /// + /// 开户银行。 + /// + public string? BankName { get; set; } + + /// + /// 附加数据 JSON。 + /// + public string? AdditionalDataJson { get; set; } + + /// + /// 提交时间。 + /// + public DateTime? SubmittedAt { get; set; } + + /// + /// 审核时间。 + /// + public DateTime? ReviewedAt { get; set; } + + /// + /// 审核人 ID。 + /// + public long? ReviewedBy { get; set; } + + /// + /// 审核人姓名。 + /// + public string? ReviewedByName { get; set; } + + /// + /// 审核备注。 + /// + public string? ReviewRemarks { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/SubscriptionChangeType.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/SubscriptionChangeType.cs new file mode 100644 index 0000000..494328c --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/SubscriptionChangeType.cs @@ -0,0 +1,47 @@ +namespace TakeoutSaaS.Domain.Tenants.Enums; + +/// +/// 订阅变更类型枚举。 +/// +public enum SubscriptionChangeType +{ + /// + /// 新建订阅。 + /// + Created = 0, + + /// + /// 续费。 + /// + Renewed = 1, + + /// + /// 升级套餐。 + /// + Upgraded = 2, + + /// + /// 降级套餐。 + /// + Downgraded = 3, + + /// + /// 延期。 + /// + Extended = 4, + + /// + /// 取消。 + /// + Cancelled = 5, + + /// + /// 暂停。 + /// + Suspended = 6, + + /// + /// 恢复。 + /// + Resumed = 7 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/SubscriptionStatus.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/SubscriptionStatus.cs new file mode 100644 index 0000000..4a5a0a6 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/SubscriptionStatus.cs @@ -0,0 +1,27 @@ +namespace TakeoutSaaS.Domain.Tenants.Enums; + +/// +/// 订阅状态枚举。 +/// +public enum SubscriptionStatus +{ + /// + /// 活跃。 + /// + Active = 0, + + /// + /// 已过期。 + /// + Expired = 1, + + /// + /// 已取消。 + /// + Cancelled = 2, + + /// + /// 已暂停。 + /// + Suspended = 3 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantPackagePublishStatus.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantPackagePublishStatus.cs new file mode 100644 index 0000000..979da24 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantPackagePublishStatus.cs @@ -0,0 +1,17 @@ +namespace TakeoutSaaS.Domain.Tenants.Enums; + +/// +/// 套餐发布状态枚举。 +/// +public enum TenantPackagePublishStatus +{ + /// + /// 草稿。 + /// + Draft = 0, + + /// + /// 已发布。 + /// + Published = 1 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantPackageType.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantPackageType.cs new file mode 100644 index 0000000..4b7f351 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantPackageType.cs @@ -0,0 +1,27 @@ +namespace TakeoutSaaS.Domain.Tenants.Enums; + +/// +/// 套餐类型枚举。 +/// +public enum TenantPackageType +{ + /// + /// 免费版。 + /// + Free = 0, + + /// + /// 标准版。 + /// + Standard = 1, + + /// + /// 专业版。 + /// + Professional = 2, + + /// + /// 企业版。 + /// + Enterprise = 3 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantQuotaType.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantQuotaType.cs new file mode 100644 index 0000000..ded5765 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantQuotaType.cs @@ -0,0 +1,32 @@ +namespace TakeoutSaaS.Domain.Tenants.Enums; + +/// +/// 租户配额类型。 +/// +public enum TenantQuotaType +{ + /// + /// 门店数量。 + /// + Store = 0, + + /// + /// 账号数量。 + /// + Account = 1, + + /// + /// 存储空间(GB)。 + /// + StorageGb = 2, + + /// + /// 短信额度。 + /// + SmsCredits = 3, + + /// + /// 配送订单数。 + /// + DeliveryOrders = 4 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantVerificationStatus.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantVerificationStatus.cs new file mode 100644 index 0000000..2f95f3e --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantVerificationStatus.cs @@ -0,0 +1,27 @@ +namespace TakeoutSaaS.Domain.Tenants.Enums; + +/// +/// 租户认证状态枚举。 +/// +public enum TenantVerificationStatus +{ + /// + /// 草稿。 + /// + Draft = 0, + + /// + /// 待审核。 + /// + Pending = 1, + + /// + /// 已通过。 + /// + Approved = 2, + + /// + /// 已驳回。 + /// + Rejected = 3 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ISubscriptionRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ISubscriptionRepository.cs new file mode 100644 index 0000000..ed4f56c --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ISubscriptionRepository.cs @@ -0,0 +1,146 @@ +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.Domain.Tenants.Repositories; + +/// +/// 订阅仓储(AdminApi 使用)。 +/// +public interface ISubscriptionRepository +{ + /// + /// 获取订阅列表(分页)。 + /// + /// 订阅状态。 + /// 套餐 ID。 + /// 租户 ID。 + /// 租户关键词(名称或编码)。 + /// 即将到期天数筛选。 + /// 是否自动续费。 + /// 到期时间范围开始。 + /// 到期时间范围结束。 + /// 页码(从 1 开始)。 + /// 每页条数。 + /// 取消标记。 + /// 订阅列表和总数。 + Task<(IReadOnlyList Items, int TotalCount)> GetListAsync( + SubscriptionStatus? status, + long? tenantPackageId, + long? tenantId, + string? tenantKeyword, + int? expiringWithinDays, + bool? autoRenew, + DateTime? expireFrom, + DateTime? expireTo, + int page, + int pageSize, + CancellationToken cancellationToken = default); + + /// + /// 根据 ID 获取订阅。 + /// + /// 订阅 ID。 + /// 取消标记。 + /// 订阅实体,不存在则返回 null。 + Task GetByIdAsync(long subscriptionId, CancellationToken cancellationToken = default); + + /// + /// 根据 ID 获取订阅(用于更新,带跟踪)。 + /// + /// 订阅 ID。 + /// 取消标记。 + /// 订阅实体,不存在则返回 null。 + Task GetByIdForUpdateAsync(long subscriptionId, CancellationToken cancellationToken = default); + + /// + /// 保存仓储变更。 + /// + /// 取消标记。 + /// 异步操作任务。 + Task SaveChangesAsync(CancellationToken cancellationToken = default); + + /// + /// 获取订阅详情。 + /// + /// 订阅 ID。 + /// 取消标记。 + /// 订阅详情结果,不存在则返回 null。 + Task GetDetailAsync(long subscriptionId, CancellationToken cancellationToken = default); + + /// + /// 获取订阅变更历史。 + /// + /// 订阅 ID。 + /// 取消标记。 + /// 变更历史列表。 + Task> GetHistoriesAsync(long subscriptionId, CancellationToken cancellationToken = default); + + /// + /// 获取订阅列表结果(单条,用于更新后返回)。 + /// + /// 订阅 ID。 + /// 取消标记。 + /// 订阅列表结果,不存在则返回 null。 + Task GetListResultByIdAsync(long subscriptionId, CancellationToken cancellationToken = default); +} + +/// +/// 订阅列表结果。 +/// +public sealed record SubscriptionListResult( + long Id, + long TenantId, + string TenantName, + string TenantCode, + long TenantPackageId, + string PackageName, + long? ScheduledPackageId, + string? ScheduledPackageName, + SubscriptionStatus Status, + DateTime EffectiveFrom, + DateTime EffectiveTo, + DateTime? NextBillingDate, + bool AutoRenew, + string? Notes, + DateTime CreatedAt, + DateTime? UpdatedAt); + +/// +/// 订阅详情结果。 +/// +public sealed record SubscriptionDetailResult( + long Id, + long TenantId, + string TenantName, + string TenantCode, + long TenantPackageId, + string PackageName, + long? ScheduledPackageId, + string? ScheduledPackageName, + SubscriptionStatus Status, + DateTime EffectiveFrom, + DateTime EffectiveTo, + DateTime? NextBillingDate, + bool AutoRenew, + string? Notes, + DateTime CreatedAt, + DateTime? UpdatedAt, + TenantPackage Package, + TenantPackage? ScheduledPackage); + +/// +/// 订阅变更历史结果。 +/// +public sealed record SubscriptionHistoryResult( + long Id, + long SubscriptionId, + SubscriptionChangeType ChangeType, + long FromPackageId, + string FromPackageName, + long ToPackageId, + string ToPackageName, + DateTime EffectiveFrom, + DateTime EffectiveTo, + string? Notes, + DateTime CreatedAt, + long? CreatedBy); 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..45c6f45 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantPackageRepository.cs @@ -0,0 +1,120 @@ +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.Domain.Tenants.Repositories; + +/// +/// 租户套餐仓储(AdminApi 使用)。 +/// +public interface ITenantPackageRepository +{ + /// + /// 根据 ID 获取租户套餐。 + /// + /// 套餐 ID。 + /// 取消标记。 + /// 套餐实体,不存在则返回 null。 + Task GetByIdAsync(long tenantPackageId, CancellationToken cancellationToken = default); + + /// + /// 根据 ID 获取租户套餐(用于更新,带跟踪)。 + /// + /// 套餐 ID。 + /// 取消标记。 + /// 套餐实体,不存在则返回 null。 + Task GetByIdForUpdateAsync(long tenantPackageId, CancellationToken cancellationToken = default); + + /// + /// 获取租户套餐列表(分页)。 + /// + /// 关键字(套餐名称)。 + /// 是否启用。 + /// 页码(从 1 开始)。 + /// 每页条数。 + /// 取消标记。 + /// 套餐列表和总数。 + Task<(IReadOnlyList Items, int TotalCount)> GetListAsync( + string? keyword, + bool? isActive, + int page, + int pageSize, + CancellationToken cancellationToken = default); + + /// + /// 获取套餐使用统计。 + /// + /// 套餐 ID 列表。 + /// 取消标记。 + /// 套餐使用统计列表。 + Task> GetUsagesAsync( + IReadOnlyList tenantPackageIds, + CancellationToken cancellationToken = default); + + /// + /// 新增租户套餐。 + /// + /// 套餐实体。 + /// 取消标记。 + /// 异步操作任务。 + Task AddAsync(TenantPackage package, CancellationToken cancellationToken = default); + + /// + /// 软删除租户套餐。 + /// + /// 套餐实体。 + /// 取消标记。 + /// 异步操作任务。 + Task SoftDeleteAsync(TenantPackage package, CancellationToken cancellationToken = default); + + /// + /// 保存仓储变更。 + /// + /// 取消标记。 + /// 异步操作任务。 + Task SaveChangesAsync(CancellationToken cancellationToken = default); + + /// + /// 获取套餐当前使用租户列表(按有效订阅口径)。 + /// + /// 套餐 ID。 + /// 关键字(租户名称或编码)。 + /// 即将到期天数筛选。 + /// 页码(从 1 开始)。 + /// 每页条数。 + /// 取消标记。 + /// 租户列表和总数。 + Task<(IReadOnlyList Items, int TotalCount)> GetTenantsAsync( + long tenantPackageId, + string? keyword, + int? expiringWithinDays, + int page, + int pageSize, + CancellationToken cancellationToken = default); +} + +/// +/// 套餐使用统计结果。 +/// +public sealed record TenantPackageUsageResult( + long TenantPackageId, + int ActiveSubscriptionCount, + int ActiveTenantCount, + int TotalSubscriptionCount, + decimal Mrr, + decimal Arr, + int ExpiringTenantCount7Days, + int ExpiringTenantCount15Days, + int ExpiringTenantCount30Days); + +/// +/// 套餐使用租户结果。 +/// +public sealed record TenantPackageTenantResult( + long TenantId, + string Code, + string Name, + TenantStatus Status, + string? ContactName, + string? ContactPhone, + DateTime SubscriptionEffectiveFrom, + DateTime SubscriptionEffectiveTo); diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantRepository.cs index e766c55..dd86329 100644 --- a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantRepository.cs @@ -1,3 +1,4 @@ +using TakeoutSaaS.Domain.Billings.Entities; using TakeoutSaaS.Domain.Tenants.Entities; namespace TakeoutSaaS.Domain.Tenants.Repositories; @@ -30,4 +31,43 @@ public interface ITenantRepository /// 取消标记。 /// 租户列表。 Task> GetAllAsync(string? keyword, CancellationToken cancellationToken = default); + + /// + /// 获取租户详情(包含认证、订阅、套餐信息)。 + /// + /// 租户 ID。 + /// 取消标记。 + /// 租户详情元组,未找到返回 null。 + Task GetDetailAsync(long tenantId, CancellationToken cancellationToken = default); + + /// + /// 获取租户配额使用情况列表。 + /// + /// 租户 ID。 + /// 取消标记。 + /// 配额使用情况列表。 + Task> GetQuotaUsagesAsync(long tenantId, CancellationToken cancellationToken = default); + + /// + /// 获取租户账单列表(分页)。 + /// + /// 租户 ID。 + /// 页码(从 1 开始)。 + /// 每页条数。 + /// 取消标记。 + /// 账单列表和总数。 + Task<(IReadOnlyList Items, int TotalCount)> GetBillingsAsync( + long tenantId, + int page, + int pageSize, + CancellationToken cancellationToken = default); } + +/// +/// 租户详情查询结果。 +/// +public sealed record TenantDetailResult( + Tenant Tenant, + TenantVerificationProfile? Verification, + TenantSubscription? Subscription, + TenantPackage? Package); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs index a40f578..7517239 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using TakeoutSaaS.Application.App.Stores.Services; +using TakeoutSaaS.Domain.Billings.Repositories; using TakeoutSaaS.Domain.Deliveries.Repositories; using TakeoutSaaS.Domain.Inventory.Repositories; using TakeoutSaaS.Domain.Merchants.Repositories; @@ -46,6 +47,9 @@ public static class AppServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs index bd04bc9..b24e59f 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; using TakeoutSaaS.Domain.Analytics.Entities; +using TakeoutSaaS.Domain.Billings.Entities; using TakeoutSaaS.Domain.Coupons.Entities; using TakeoutSaaS.Domain.CustomerService.Entities; using TakeoutSaaS.Domain.Deliveries.Entities; @@ -40,6 +41,34 @@ public class TakeoutAppDbContext( /// public DbSet Tenants => Set(); /// + /// 租户认证资料。 + /// + public DbSet TenantVerificationProfiles => Set(); + /// + /// 租户订阅记录。 + /// + public DbSet TenantSubscriptions => Set(); + /// + /// 租户套餐定义。 + /// + public DbSet TenantPackages => Set(); + /// + /// 租户配额使用情况。 + /// + public DbSet TenantQuotaUsages => Set(); + /// + /// 租户账单。 + /// + public DbSet TenantBillingStatements => Set(); + /// + /// 租户订阅变更历史。 + /// + public DbSet TenantSubscriptionHistories => Set(); + /// + /// 租户支付记录。 + /// + public DbSet TenantPayments => Set(); + /// /// 商户实体。 /// public DbSet Merchants => Set(); @@ -341,6 +370,13 @@ public class TakeoutAppDbContext( internal static void ConfigureModel(ModelBuilder modelBuilder) { ConfigureTenant(modelBuilder.Entity()); + ConfigureTenantVerificationProfile(modelBuilder.Entity()); + ConfigureTenantSubscription(modelBuilder.Entity()); + ConfigureTenantPackage(modelBuilder.Entity()); + ConfigureTenantQuotaUsage(modelBuilder.Entity()); + ConfigureTenantBillingStatement(modelBuilder.Entity()); + ConfigureTenantSubscriptionHistory(modelBuilder.Entity()); + ConfigureTenantPayment(modelBuilder.Entity()); ConfigureMerchant(modelBuilder.Entity()); ConfigureStore(modelBuilder.Entity()); ConfigureMerchantDocument(modelBuilder.Entity()); @@ -430,6 +466,127 @@ public class TakeoutAppDbContext( builder.HasIndex(x => x.ContactPhone).IsUnique(); } + private static void ConfigureTenantVerificationProfile(EntityTypeBuilder builder) + { + builder.ToTable("tenant_verification_profiles"); + builder.HasKey(x => x.Id); + builder.Property(x => x.TenantId).IsRequired(); + builder.Property(x => x.Status).HasConversion(); + builder.Property(x => x.BusinessLicenseNumber).HasMaxLength(64); + builder.Property(x => x.BusinessLicenseUrl).HasMaxLength(512); + builder.Property(x => x.LegalPersonName).HasMaxLength(64); + builder.Property(x => x.LegalPersonIdNumber).HasMaxLength(32); + builder.Property(x => x.LegalPersonIdFrontUrl).HasMaxLength(512); + builder.Property(x => x.LegalPersonIdBackUrl).HasMaxLength(512); + builder.Property(x => x.BankAccountName).HasMaxLength(128); + builder.Property(x => x.BankAccountNumber).HasMaxLength(64); + builder.Property(x => x.BankName).HasMaxLength(128); + builder.Property(x => x.AdditionalDataJson).HasColumnType("text"); + builder.Property(x => x.ReviewedByName).HasMaxLength(64); + builder.Property(x => x.ReviewRemarks).HasMaxLength(512); + builder.HasIndex(x => x.TenantId); + } + + private static void ConfigureTenantSubscription(EntityTypeBuilder builder) + { + builder.ToTable("tenant_subscriptions"); + builder.HasKey(x => x.Id); + builder.Property(x => x.TenantId).IsRequired(); + builder.Property(x => x.TenantPackageId).IsRequired(); + builder.Property(x => x.Status).HasConversion(); + builder.Property(x => x.EffectiveFrom).IsRequired(); + builder.Property(x => x.EffectiveTo).IsRequired(); + builder.Property(x => x.Notes).HasColumnType("text"); + builder.HasIndex(x => x.TenantId); + builder.HasOne(x => x.TenantPackage) + .WithMany() + .HasForeignKey(x => x.TenantPackageId) + .OnDelete(DeleteBehavior.Restrict); + } + + private static void ConfigureTenantPackage(EntityTypeBuilder builder) + { + builder.ToTable("tenant_packages"); + builder.HasKey(x => x.Id); + builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); + builder.Property(x => x.Description).HasMaxLength(512); + builder.Property(x => x.PackageType).HasConversion(); + builder.Property(x => x.MonthlyPrice).HasPrecision(18, 2); + builder.Property(x => x.YearlyPrice).HasPrecision(18, 2); + builder.Property(x => x.FeaturePoliciesJson).HasColumnType("text"); + builder.Property(x => x.PublishStatus).HasConversion(); + builder.HasIndex(x => x.Name); + } + + private static void ConfigureTenantQuotaUsage(EntityTypeBuilder builder) + { + builder.ToTable("tenant_quota_usages"); + builder.HasKey(x => x.Id); + builder.Property(x => x.TenantId).IsRequired(); + builder.Property(x => x.QuotaType).HasConversion(); + builder.Property(x => x.LimitValue).HasPrecision(18, 2); + builder.Property(x => x.UsedValue).HasPrecision(18, 2); + builder.Property(x => x.ResetCycle).HasColumnType("text"); + builder.HasIndex(x => new { x.TenantId, x.QuotaType }).IsUnique(); + } + + private static void ConfigureTenantBillingStatement(EntityTypeBuilder builder) + { + builder.ToTable("tenant_billing_statements"); + builder.HasKey(x => x.Id); + builder.Property(x => x.TenantId).IsRequired(); + builder.Property(x => x.StatementNo).HasMaxLength(64).IsRequired(); + builder.Property(x => x.BillingType).HasConversion(); + builder.Property(x => x.PeriodStart).IsRequired(); + builder.Property(x => x.PeriodEnd).IsRequired(); + builder.Property(x => x.AmountDue).HasPrecision(18, 2); + builder.Property(x => x.AmountPaid).HasPrecision(18, 2); + builder.Property(x => x.DiscountAmount).HasPrecision(18, 2); + builder.Property(x => x.TaxAmount).HasPrecision(18, 2); + builder.Property(x => x.Currency).HasMaxLength(8).IsRequired(); + builder.Property(x => x.Status).HasConversion(); + builder.Property(x => x.DueDate).IsRequired(); + builder.Property(x => x.LineItemsJson).HasColumnType("text"); + builder.Property(x => x.Notes).HasMaxLength(512); + builder.HasIndex(x => new { x.TenantId, x.StatementNo }).IsUnique(); + builder.HasIndex(x => new { x.TenantId, x.Status, x.DueDate }); + } + + private static void ConfigureTenantSubscriptionHistory(EntityTypeBuilder builder) + { + builder.ToTable("tenant_subscription_histories"); + builder.HasKey(x => x.Id); + builder.Property(x => x.TenantId).IsRequired(); + builder.Property(x => x.TenantSubscriptionId).IsRequired(); + builder.Property(x => x.FromPackageId).IsRequired(); + builder.Property(x => x.ToPackageId).IsRequired(); + builder.Property(x => x.ChangeType).HasConversion(); + builder.Property(x => x.EffectiveFrom).IsRequired(); + builder.Property(x => x.EffectiveTo).IsRequired(); + builder.Property(x => x.Amount).HasPrecision(18, 2); + builder.Property(x => x.Currency).HasMaxLength(8); + builder.Property(x => x.Notes).HasMaxLength(512); + builder.HasIndex(x => x.TenantSubscriptionId); + builder.HasIndex(x => x.TenantId); + } + + private static void ConfigureTenantPayment(EntityTypeBuilder builder) + { + builder.ToTable("tenant_payments"); + builder.HasKey(x => x.Id); + builder.Property(x => x.TenantId).IsRequired(); + builder.Property(x => x.BillingStatementId).IsRequired(); + builder.Property(x => x.Amount).HasPrecision(18, 2).IsRequired(); + builder.Property(x => x.Method).HasConversion(); + builder.Property(x => x.Status).HasConversion(); + builder.Property(x => x.TransactionNo).HasMaxLength(128); + builder.Property(x => x.ProofUrl).HasMaxLength(512); + builder.Property(x => x.Notes).HasMaxLength(512); + builder.Property(x => x.RefundReason).HasMaxLength(512); + builder.HasIndex(x => x.BillingStatementId); + builder.HasIndex(x => x.TenantId); + } + private static void ConfigureMerchant(EntityTypeBuilder builder) { builder.ToTable("merchants"); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfBillingRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfBillingRepository.cs new file mode 100644 index 0000000..b71f161 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfBillingRepository.cs @@ -0,0 +1,241 @@ +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Domain.Billings.Entities; +using TakeoutSaaS.Domain.Billings.Enums; +using TakeoutSaaS.Domain.Billings.Repositories; +using TakeoutSaaS.Infrastructure.App.Persistence; + +namespace TakeoutSaaS.Infrastructure.App.Repositories; + +/// +/// 账单仓储实现(AdminApi 使用)。 +/// +public sealed class EfBillingRepository(TakeoutAdminDbContext context) : IBillingRepository +{ + /// + public async Task<(IReadOnlyList Items, int TotalCount)> GetListAsync( + long? tenantId, + TenantBillingStatus? status, + TenantBillingType? billingType, + DateTime? startDate, + DateTime? endDate, + decimal? minAmount, + decimal? maxAmount, + string? keyword, + string? sortBy, + bool? sortDesc, + int page, + int pageSize, + CancellationToken cancellationToken = default) + { + // 1. 构建基础查询 + var query = from b in context.TenantBillingStatements.AsNoTracking() + join t in context.Tenants.AsNoTracking() on b.TenantId equals t.Id into tenants + from t in tenants.DefaultIfEmpty() + where b.DeletedAt == null + select new { Billing = b, TenantName = t != null ? t.Name : (string?)null }; + + // 2. 应用租户 ID 过滤 + if (tenantId.HasValue) + { + query = query.Where(x => x.Billing.TenantId == tenantId.Value); + } + + // 3. 应用状态过滤 + if (status.HasValue) + { + query = query.Where(x => x.Billing.Status == status.Value); + } + + // 4. 应用账单类型过滤 + if (billingType.HasValue) + { + query = query.Where(x => x.Billing.BillingType == billingType.Value); + } + + // 5. 应用日期范围过滤 + if (startDate.HasValue) + { + query = query.Where(x => x.Billing.CreatedAt >= startDate.Value); + } + + if (endDate.HasValue) + { + query = query.Where(x => x.Billing.CreatedAt <= endDate.Value); + } + + // 6. 应用金额范围过滤 + if (minAmount.HasValue) + { + query = query.Where(x => x.Billing.AmountDue >= minAmount.Value); + } + + if (maxAmount.HasValue) + { + query = query.Where(x => x.Billing.AmountDue <= maxAmount.Value); + } + + // 7. 应用关键词过滤 + if (!string.IsNullOrWhiteSpace(keyword)) + { + var normalized = keyword.Trim(); + query = query.Where(x => x.Billing.StatementNo.Contains(normalized) || + (x.TenantName != null && x.TenantName.Contains(normalized))); + } + + // 8. 获取总数 + var totalCount = await query.CountAsync(cancellationToken); + + // 9. 应用排序并分页查询 + var isDesc = sortDesc ?? true; + var sortField = sortBy?.ToLowerInvariant() ?? "createdat"; + + List items; + + if (sortField == "duedate") + { + items = isDesc + ? await query.OrderByDescending(x => x.Billing.DueDate).ThenBy(x => x.Billing.Id) + .Skip((page - 1) * pageSize).Take(pageSize) + .Select(x => new BillingListResult( + x.Billing.Id, x.Billing.TenantId, x.TenantName, x.Billing.StatementNo, + x.Billing.BillingType, x.Billing.PeriodStart, x.Billing.PeriodEnd, + x.Billing.AmountDue, x.Billing.AmountPaid, x.Billing.DiscountAmount, + x.Billing.TaxAmount, x.Billing.Currency, x.Billing.Status, x.Billing.DueDate, + x.Billing.OverdueNotifiedAt, x.Billing.CreatedAt, x.Billing.UpdatedAt)) + .ToListAsync(cancellationToken) + : await query.OrderBy(x => x.Billing.DueDate).ThenBy(x => x.Billing.Id) + .Skip((page - 1) * pageSize).Take(pageSize) + .Select(x => new BillingListResult( + x.Billing.Id, x.Billing.TenantId, x.TenantName, x.Billing.StatementNo, + x.Billing.BillingType, x.Billing.PeriodStart, x.Billing.PeriodEnd, + x.Billing.AmountDue, x.Billing.AmountPaid, x.Billing.DiscountAmount, + x.Billing.TaxAmount, x.Billing.Currency, x.Billing.Status, x.Billing.DueDate, + x.Billing.OverdueNotifiedAt, x.Billing.CreatedAt, x.Billing.UpdatedAt)) + .ToListAsync(cancellationToken); + } + else if (sortField == "amountdue") + { + items = isDesc + ? await query.OrderByDescending(x => x.Billing.AmountDue).ThenBy(x => x.Billing.Id) + .Skip((page - 1) * pageSize).Take(pageSize) + .Select(x => new BillingListResult( + x.Billing.Id, x.Billing.TenantId, x.TenantName, x.Billing.StatementNo, + x.Billing.BillingType, x.Billing.PeriodStart, x.Billing.PeriodEnd, + x.Billing.AmountDue, x.Billing.AmountPaid, x.Billing.DiscountAmount, + x.Billing.TaxAmount, x.Billing.Currency, x.Billing.Status, x.Billing.DueDate, + x.Billing.OverdueNotifiedAt, x.Billing.CreatedAt, x.Billing.UpdatedAt)) + .ToListAsync(cancellationToken) + : await query.OrderBy(x => x.Billing.AmountDue).ThenBy(x => x.Billing.Id) + .Skip((page - 1) * pageSize).Take(pageSize) + .Select(x => new BillingListResult( + x.Billing.Id, x.Billing.TenantId, x.TenantName, x.Billing.StatementNo, + x.Billing.BillingType, x.Billing.PeriodStart, x.Billing.PeriodEnd, + x.Billing.AmountDue, x.Billing.AmountPaid, x.Billing.DiscountAmount, + x.Billing.TaxAmount, x.Billing.Currency, x.Billing.Status, x.Billing.DueDate, + x.Billing.OverdueNotifiedAt, x.Billing.CreatedAt, x.Billing.UpdatedAt)) + .ToListAsync(cancellationToken); + } + else + { + items = isDesc + ? await query.OrderByDescending(x => x.Billing.CreatedAt).ThenBy(x => x.Billing.Id) + .Skip((page - 1) * pageSize).Take(pageSize) + .Select(x => new BillingListResult( + x.Billing.Id, x.Billing.TenantId, x.TenantName, x.Billing.StatementNo, + x.Billing.BillingType, x.Billing.PeriodStart, x.Billing.PeriodEnd, + x.Billing.AmountDue, x.Billing.AmountPaid, x.Billing.DiscountAmount, + x.Billing.TaxAmount, x.Billing.Currency, x.Billing.Status, x.Billing.DueDate, + x.Billing.OverdueNotifiedAt, x.Billing.CreatedAt, x.Billing.UpdatedAt)) + .ToListAsync(cancellationToken) + : await query.OrderBy(x => x.Billing.CreatedAt).ThenBy(x => x.Billing.Id) + .Skip((page - 1) * pageSize).Take(pageSize) + .Select(x => new BillingListResult( + x.Billing.Id, x.Billing.TenantId, x.TenantName, x.Billing.StatementNo, + x.Billing.BillingType, x.Billing.PeriodStart, x.Billing.PeriodEnd, + x.Billing.AmountDue, x.Billing.AmountPaid, x.Billing.DiscountAmount, + x.Billing.TaxAmount, x.Billing.Currency, x.Billing.Status, x.Billing.DueDate, + x.Billing.OverdueNotifiedAt, x.Billing.CreatedAt, x.Billing.UpdatedAt)) + .ToListAsync(cancellationToken); + } + + // 10. 返回结果 + return (items, totalCount); + } + + /// + public async Task GetByIdAsync(long billingId, CancellationToken cancellationToken = default) + { + // 1. 查询账单(排除已删除,无跟踪) + return await context.TenantBillingStatements + .AsNoTracking() + .Where(b => b.Id == billingId && b.DeletedAt == null) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public async Task GetByIdForUpdateAsync(long billingId, CancellationToken cancellationToken = default) + { + // 1. 查询账单(排除已删除,带跟踪用于更新) + return await context.TenantBillingStatements + .Where(b => b.Id == billingId && b.DeletedAt == null) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public async Task GetDetailAsync(long billingId, CancellationToken cancellationToken = default) + { + // 1. 查询账单详情(带租户信息) + var result = await (from b in context.TenantBillingStatements.AsNoTracking() + join t in context.Tenants.AsNoTracking() on b.TenantId equals t.Id into tenants + from t in tenants.DefaultIfEmpty() + where b.Id == billingId && b.DeletedAt == null + select new + { + Billing = b, + TenantName = t != null ? t.Name : (string?)null + }).FirstOrDefaultAsync(cancellationToken); + + // 2. 如果不存在,返回 null + if (result is null) + { + return null; + } + + // 3. 返回详情结果 + return new BillingDetailResult( + result.Billing.Id, + result.Billing.TenantId, + result.TenantName, + result.Billing.StatementNo, + result.Billing.BillingType, + result.Billing.PeriodStart, + result.Billing.PeriodEnd, + result.Billing.AmountDue, + result.Billing.AmountPaid, + result.Billing.DiscountAmount, + result.Billing.TaxAmount, + result.Billing.Currency, + result.Billing.Status, + result.Billing.DueDate, + result.Billing.LineItemsJson, + result.Billing.Notes, + result.Billing.OverdueNotifiedAt, + result.Billing.ReminderSentAt, + result.Billing.CreatedAt, + result.Billing.UpdatedAt); + } + + /// + public async Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + // 1. 保存变更 + await context.SaveChangesAsync(cancellationToken); + } + + /// + public async Task AddPaymentAsync(TenantPayment payment, CancellationToken cancellationToken = default) + { + // 1. 添加支付记录 + await context.TenantPayments.AddAsync(payment, cancellationToken); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfSubscriptionRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfSubscriptionRepository.cs new file mode 100644 index 0000000..4bdfbcf --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfSubscriptionRepository.cs @@ -0,0 +1,272 @@ +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; + +/// +/// 订阅仓储实现(AdminApi 使用)。 +/// +public sealed class EfSubscriptionRepository(TakeoutAdminDbContext context) : ISubscriptionRepository +{ + /// + public async Task<(IReadOnlyList Items, int TotalCount)> GetListAsync( + SubscriptionStatus? status, + long? tenantPackageId, + long? tenantId, + string? tenantKeyword, + int? expiringWithinDays, + bool? autoRenew, + DateTime? expireFrom, + DateTime? expireTo, + int page, + int pageSize, + CancellationToken cancellationToken = default) + { + // 1. 获取当前时间 + var now = DateTime.UtcNow; + + // 2. 构建基础查询 + var query = from s in context.TenantSubscriptions.AsNoTracking() + join t in context.Tenants.AsNoTracking() on s.TenantId equals t.Id + join p in context.TenantPackages.AsNoTracking() on s.TenantPackageId equals p.Id + where s.DeletedAt == null && t.DeletedAt == null + select new + { + Subscription = s, + Tenant = t, + Package = p, + ScheduledPackage = context.TenantPackages + .Where(sp => sp.Id == s.ScheduledPackageId) + .Select(sp => new { sp.Id, sp.Name }) + .FirstOrDefault() + }; + + // 3. 应用订阅状态过滤 + if (status.HasValue) + { + query = query.Where(x => x.Subscription.Status == status.Value); + } + + // 4. 应用套餐 ID 过滤 + if (tenantPackageId.HasValue) + { + query = query.Where(x => x.Subscription.TenantPackageId == tenantPackageId.Value); + } + + // 5. 应用租户 ID 过滤 + if (tenantId.HasValue) + { + query = query.Where(x => x.Subscription.TenantId == tenantId.Value); + } + + // 6. 应用租户关键词过滤 + if (!string.IsNullOrWhiteSpace(tenantKeyword)) + { + var normalized = tenantKeyword.Trim(); + query = query.Where(x => x.Tenant.Name.Contains(normalized) || x.Tenant.Code.Contains(normalized)); + } + + // 7. 应用到期天数筛选 + if (expiringWithinDays.HasValue) + { + var expiryDate = now.AddDays(expiringWithinDays.Value); + query = query.Where(x => x.Subscription.EffectiveTo <= expiryDate && x.Subscription.EffectiveTo > now); + } + + // 8. 应用自动续费筛选 + if (autoRenew.HasValue) + { + query = query.Where(x => x.Subscription.AutoRenew == autoRenew.Value); + } + + // 9. 应用到期时间范围筛选 + if (expireFrom.HasValue) + { + query = query.Where(x => x.Subscription.EffectiveTo >= expireFrom.Value); + } + + if (expireTo.HasValue) + { + query = query.Where(x => x.Subscription.EffectiveTo <= expireTo.Value); + } + + // 10. 获取总数 + var totalCount = await query.CountAsync(cancellationToken); + + // 11. 分页查询(按到期时间升序) + var items = await query + .OrderBy(x => x.Subscription.EffectiveTo) + .ThenBy(x => x.Subscription.Id) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(x => new SubscriptionListResult( + x.Subscription.Id, + x.Tenant.Id, + x.Tenant.Name, + x.Tenant.Code, + x.Package.Id, + x.Package.Name, + x.ScheduledPackage != null ? x.ScheduledPackage.Id : (long?)null, + x.ScheduledPackage != null ? x.ScheduledPackage.Name : null, + x.Subscription.Status, + x.Subscription.EffectiveFrom, + x.Subscription.EffectiveTo, + x.Subscription.NextBillingDate, + x.Subscription.AutoRenew, + x.Subscription.Notes, + x.Subscription.CreatedAt, + x.Subscription.UpdatedAt)) + .ToListAsync(cancellationToken); + + // 12. 返回结果 + return (items, totalCount); + } + + /// + public async Task GetByIdAsync(long subscriptionId, CancellationToken cancellationToken = default) + { + // 1. 查询订阅(排除已删除,无跟踪) + return await context.TenantSubscriptions + .AsNoTracking() + .Where(s => s.Id == subscriptionId && s.DeletedAt == null) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public async Task GetByIdForUpdateAsync(long subscriptionId, CancellationToken cancellationToken = default) + { + // 1. 查询订阅(排除已删除,带跟踪用于更新) + return await context.TenantSubscriptions + .Where(s => s.Id == subscriptionId && s.DeletedAt == null) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public async Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + // 1. 保存变更 + await context.SaveChangesAsync(cancellationToken); + } + + /// + public async Task GetDetailAsync(long subscriptionId, CancellationToken cancellationToken = default) + { + // 1. 查询订阅详情 + var result = await (from s in context.TenantSubscriptions.AsNoTracking() + join t in context.Tenants.AsNoTracking() on s.TenantId equals t.Id + join p in context.TenantPackages.AsNoTracking() on s.TenantPackageId equals p.Id + where s.Id == subscriptionId && s.DeletedAt == null && t.DeletedAt == null + select new + { + Subscription = s, + Tenant = t, + Package = p, + ScheduledPackage = context.TenantPackages + .Where(sp => sp.Id == s.ScheduledPackageId) + .FirstOrDefault() + }).FirstOrDefaultAsync(cancellationToken); + + // 2. 如果不存在,返回 null + if (result is null) + { + return null; + } + + // 3. 返回详情结果 + return new SubscriptionDetailResult( + result.Subscription.Id, + result.Tenant.Id, + result.Tenant.Name, + result.Tenant.Code, + result.Package.Id, + result.Package.Name, + result.ScheduledPackage?.Id, + result.ScheduledPackage?.Name, + result.Subscription.Status, + result.Subscription.EffectiveFrom, + result.Subscription.EffectiveTo, + result.Subscription.NextBillingDate, + result.Subscription.AutoRenew, + result.Subscription.Notes, + result.Subscription.CreatedAt, + result.Subscription.UpdatedAt, + result.Package, + result.ScheduledPackage); + } + + /// + public async Task> GetHistoriesAsync(long subscriptionId, CancellationToken cancellationToken = default) + { + // 1. 查询变更历史 + var histories = await (from h in context.TenantSubscriptionHistories.AsNoTracking() + join fp in context.TenantPackages.AsNoTracking() on h.FromPackageId equals fp.Id + join tp in context.TenantPackages.AsNoTracking() on h.ToPackageId equals tp.Id + where h.TenantSubscriptionId == subscriptionId && h.DeletedAt == null + orderby h.CreatedAt descending + select new SubscriptionHistoryResult( + h.Id, + h.TenantSubscriptionId, + h.ChangeType, + h.FromPackageId, + fp.Name, + h.ToPackageId, + tp.Name, + h.EffectiveFrom, + h.EffectiveTo, + h.Notes, + h.CreatedAt, + h.CreatedBy)) + .ToListAsync(cancellationToken); + + // 2. 返回结果 + return histories; + } + + /// + public async Task GetListResultByIdAsync(long subscriptionId, CancellationToken cancellationToken = default) + { + // 1. 查询订阅(带租户和套餐信息) + var result = await (from s in context.TenantSubscriptions.AsNoTracking() + join t in context.Tenants.AsNoTracking() on s.TenantId equals t.Id + join p in context.TenantPackages.AsNoTracking() on s.TenantPackageId equals p.Id + where s.Id == subscriptionId && s.DeletedAt == null && t.DeletedAt == null + select new + { + Subscription = s, + Tenant = t, + Package = p, + ScheduledPackage = context.TenantPackages + .Where(sp => sp.Id == s.ScheduledPackageId) + .Select(sp => new { sp.Id, sp.Name }) + .FirstOrDefault() + }).FirstOrDefaultAsync(cancellationToken); + + // 2. 如果不存在,返回 null + if (result is null) + { + return null; + } + + // 3. 返回列表结果 + return new SubscriptionListResult( + result.Subscription.Id, + result.Tenant.Id, + result.Tenant.Name, + result.Tenant.Code, + result.Package.Id, + result.Package.Name, + result.ScheduledPackage != null ? result.ScheduledPackage.Id : (long?)null, + result.ScheduledPackage != null ? result.ScheduledPackage.Name : null, + result.Subscription.Status, + result.Subscription.EffectiveFrom, + result.Subscription.EffectiveTo, + result.Subscription.NextBillingDate, + result.Subscription.AutoRenew, + result.Subscription.Notes, + result.Subscription.CreatedAt, + result.Subscription.UpdatedAt); + } +} 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..39318de --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantPackageRepository.cs @@ -0,0 +1,234 @@ +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; + +/// +/// 租户套餐仓储实现(AdminApi 使用)。 +/// +public sealed class EfTenantPackageRepository(TakeoutAdminDbContext context) : ITenantPackageRepository +{ + /// + public async Task GetByIdAsync(long tenantPackageId, CancellationToken cancellationToken = default) + { + // 1. 查询套餐(排除已删除,无跟踪) + return await context.TenantPackages + .AsNoTracking() + .Where(p => p.Id == tenantPackageId && p.DeletedAt == null) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public async Task GetByIdForUpdateAsync(long tenantPackageId, CancellationToken cancellationToken = default) + { + // 1. 查询套餐(排除已删除,带跟踪用于更新) + return await context.TenantPackages + .Where(p => p.Id == tenantPackageId && p.DeletedAt == null) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public async Task<(IReadOnlyList Items, int TotalCount)> GetListAsync( + string? keyword, + bool? isActive, + int page, + int pageSize, + CancellationToken cancellationToken = default) + { + // 1. 构建基础查询 + var query = context.TenantPackages + .AsNoTracking() + .Where(p => p.DeletedAt == null); + + // 2. 应用关键字过滤 + if (!string.IsNullOrWhiteSpace(keyword)) + { + var normalized = keyword.Trim(); + query = query.Where(p => p.Name.Contains(normalized)); + } + + // 3. 应用启用状态过滤 + if (isActive.HasValue) + { + query = query.Where(p => p.IsActive == isActive.Value); + } + + // 4. 获取总数 + var totalCount = await query.CountAsync(cancellationToken); + + // 5. 分页查询(按排序序号升序) + var items = await query + .OrderBy(p => p.SortOrder) + .ThenBy(p => p.Id) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + + // 6. 返回结果 + return (items, totalCount); + } + + /// + public async Task> GetUsagesAsync( + IReadOnlyList tenantPackageIds, + CancellationToken cancellationToken = default) + { + // 1. 如果没有传入套餐 ID,返回空列表 + if (tenantPackageIds.Count == 0) + { + return []; + } + + // 2. 获取当前时间用于计算到期天数 + var now = DateTime.UtcNow; + var in7Days = now.AddDays(7); + var in15Days = now.AddDays(15); + var in30Days = now.AddDays(30); + + // 3. 查询订阅数据并按套餐分组统计 + var subscriptions = await context.TenantSubscriptions + .AsNoTracking() + .Where(s => tenantPackageIds.Contains(s.TenantPackageId) && s.DeletedAt == null) + .Select(s => new + { + s.TenantPackageId, + s.TenantId, + s.Status, + s.EffectiveTo, + MonthlyPrice = context.TenantPackages + .Where(p => p.Id == s.TenantPackageId) + .Select(p => p.MonthlyPrice) + .FirstOrDefault() + }) + .ToListAsync(cancellationToken); + + // 4. 按套餐 ID 分组统计 + var results = tenantPackageIds.Select(packageId => + { + var packageSubscriptions = subscriptions.Where(s => s.TenantPackageId == packageId).ToList(); + + // 4.1 活跃订阅(状态为 Active 且未过期) + var activeSubscriptions = packageSubscriptions + .Where(s => s.Status == SubscriptionStatus.Active && s.EffectiveTo > now) + .ToList(); + + // 4.2 活跃租户数(去重) + var activeTenantCount = activeSubscriptions.Select(s => s.TenantId).Distinct().Count(); + + // 4.3 总订阅数 + var totalSubscriptionCount = packageSubscriptions.Count; + + // 4.4 计算 MRR(月度经常性收入) + var mrr = activeSubscriptions.Sum(s => s.MonthlyPrice ?? 0); + + // 4.5 计算 ARR(年度经常性收入) + var arr = mrr * 12; + + // 4.6 计算到期租户数(基于活跃订阅) + var expiringIn7Days = activeSubscriptions.Count(s => s.EffectiveTo <= in7Days); + var expiringIn15Days = activeSubscriptions.Count(s => s.EffectiveTo <= in15Days); + var expiringIn30Days = activeSubscriptions.Count(s => s.EffectiveTo <= in30Days); + + return new TenantPackageUsageResult( + packageId, + activeSubscriptions.Count, + activeTenantCount, + totalSubscriptionCount, + mrr, + arr, + expiringIn7Days, + expiringIn15Days, + expiringIn30Days); + }).ToList(); + + // 5. 返回结果 + return results; + } + + /// + public async Task AddAsync(TenantPackage package, CancellationToken cancellationToken = default) + { + // 1. 添加套餐实体 + await context.TenantPackages.AddAsync(package, cancellationToken); + } + + /// + public Task SoftDeleteAsync(TenantPackage package, CancellationToken cancellationToken = default) + { + // 1. 设置软删除时间 + package.DeletedAt = DateTime.UtcNow; + + // 2. 返回已完成任务 + return Task.CompletedTask; + } + + /// + public async Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + // 1. 保存变更 + await context.SaveChangesAsync(cancellationToken); + } + + /// + public async Task<(IReadOnlyList Items, int TotalCount)> GetTenantsAsync( + long tenantPackageId, + string? keyword, + int? expiringWithinDays, + int page, + int pageSize, + CancellationToken cancellationToken = default) + { + // 1. 获取当前时间 + var now = DateTime.UtcNow; + + // 2. 构建基础查询:活跃订阅(状态为 Active 且未过期) + var query = from s in context.TenantSubscriptions.AsNoTracking() + join t in context.Tenants.AsNoTracking() on s.TenantId equals t.Id + where s.TenantPackageId == tenantPackageId + && s.DeletedAt == null + && t.DeletedAt == null + && s.Status == SubscriptionStatus.Active + && s.EffectiveTo > now + select new { Subscription = s, Tenant = t }; + + // 3. 应用关键字过滤 + if (!string.IsNullOrWhiteSpace(keyword)) + { + var normalized = keyword.Trim(); + query = query.Where(x => x.Tenant.Name.Contains(normalized) || x.Tenant.Code.Contains(normalized)); + } + + // 4. 应用到期天数筛选 + if (expiringWithinDays.HasValue) + { + var expiryDate = now.AddDays(expiringWithinDays.Value); + query = query.Where(x => x.Subscription.EffectiveTo <= expiryDate); + } + + // 5. 获取总数 + var totalCount = await query.CountAsync(cancellationToken); + + // 6. 分页查询(按到期时间升序,即将到期的排前面) + var items = await query + .OrderBy(x => x.Subscription.EffectiveTo) + .ThenBy(x => x.Tenant.Id) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(x => new TenantPackageTenantResult( + x.Tenant.Id, + x.Tenant.Code, + x.Tenant.Name, + x.Tenant.Status, + x.Tenant.ContactName, + x.Tenant.ContactPhone, + x.Subscription.EffectiveFrom, + x.Subscription.EffectiveTo)) + .ToListAsync(cancellationToken); + + // 7. 返回结果 + return (items, totalCount); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantRepository.cs index ae4a260..f67dee5 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantRepository.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Domain.Billings.Entities; using TakeoutSaaS.Domain.Tenants.Entities; using TakeoutSaaS.Domain.Tenants.Repositories; using TakeoutSaaS.Infrastructure.App.Persistence; @@ -53,4 +54,73 @@ public sealed class EfTenantRepository(TakeoutAdminDbContext context) : ITenantR // 3. 返回列表 return await query.OrderBy(x => x.Code).ToListAsync(cancellationToken); } + + /// + public async Task GetDetailAsync(long tenantId, CancellationToken cancellationToken = default) + { + // 1. 查询租户基本信息 + var tenant = await context.Tenants + .AsNoTracking() + .FirstOrDefaultAsync(t => t.Id == tenantId, cancellationToken); + + if (tenant is null) + { + return null; + } + + // 2. 查询认证信息(取最新一条) + var verification = await context.TenantVerificationProfiles + .AsNoTracking() + .Where(v => v.TenantId == tenantId) + .OrderByDescending(v => v.CreatedAt) + .FirstOrDefaultAsync(cancellationToken); + + // 3. 查询订阅信息(取最新一条,包含套餐) + var subscription = await context.TenantSubscriptions + .AsNoTracking() + .Include(s => s.TenantPackage) + .Where(s => s.TenantId == tenantId) + .OrderByDescending(s => s.CreatedAt) + .FirstOrDefaultAsync(cancellationToken); + + // 4. 返回结果 + return new TenantDetailResult(tenant, verification, subscription, subscription?.TenantPackage); + } + + /// + public async Task> GetQuotaUsagesAsync(long tenantId, CancellationToken cancellationToken = default) + { + // 1. 查询租户配额使用情况 + return await context.TenantQuotaUsages + .AsNoTracking() + .Where(q => q.TenantId == tenantId && q.DeletedAt == null) + .OrderBy(q => q.QuotaType) + .ToListAsync(cancellationToken); + } + + /// + public async Task<(IReadOnlyList Items, int TotalCount)> GetBillingsAsync( + long tenantId, + int page, + int pageSize, + CancellationToken cancellationToken = default) + { + // 1. 构建基础查询 + var query = context.TenantBillingStatements + .AsNoTracking() + .Where(b => b.TenantId == tenantId && b.DeletedAt == null); + + // 2. 获取总数 + var totalCount = await query.CountAsync(cancellationToken); + + // 3. 分页查询(按创建时间倒序) + var items = await query + .OrderByDescending(b => b.CreatedAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + + // 4. 返回结果 + return (items, totalCount); + } } diff --git a/src/Modules/TakeoutSaaS.Module.Authorization/Policies/PermissionAuthorizationPolicyProvider.cs b/src/Modules/TakeoutSaaS.Module.Authorization/Policies/PermissionAuthorizationPolicyProvider.cs index 4f1329c..9b638f9 100644 --- a/src/Modules/TakeoutSaaS.Module.Authorization/Policies/PermissionAuthorizationPolicyProvider.cs +++ b/src/Modules/TakeoutSaaS.Module.Authorization/Policies/PermissionAuthorizationPolicyProvider.cs @@ -1,6 +1,9 @@ +using System.Collections.Concurrent; using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.Options; + namespace TakeoutSaaS.Module.Authorization.Policies; + /// /// 权限策略提供者(按需动态构建策略)。 /// @@ -10,7 +13,12 @@ public sealed class PermissionAuthorizationPolicyProvider(IOptions public const string PolicyPrefix = "PERMISSION:"; - private readonly AuthorizationOptions _options = options.Value; + + /// + /// 线程安全的策略缓存。 + /// + private static readonly ConcurrentDictionary PolicyCache = new(StringComparer.OrdinalIgnoreCase); + /// /// 获取或构建指定名称的权限策略。 /// @@ -23,26 +31,32 @@ public sealed class PermissionAuthorizationPolicyProvider(IOptions(existingPolicy); + return Task.FromResult(cachedPolicy); } + // 3. 解析策略携带的权限列表 var permissions = ParsePermissions(policyName); if (permissions.Length == 0) { return Task.FromResult(null); } - // 4. 动态构建策略并缓存 + + // 4. 动态构建策略 var policy = new AuthorizationPolicyBuilder() .AddRequirements(new PermissionRequirement(permissions)) .Build(); - _options.AddPolicy(policyName, policy); - // 5. 返回构建好的策略 + + // 5. 添加到线程安全缓存(如果已存在则返回已存在的值) + policy = PolicyCache.GetOrAdd(policyName, policy); + + // 6. 返回构建好的策略 return Task.FromResult(policy); } + /// /// 根据权限集合构建策略名称。 /// @@ -50,13 +64,16 @@ public sealed class PermissionAuthorizationPolicyProvider(IOptions策略名称。 public static string BuildPolicyName(IEnumerable permissions) => $"{PolicyPrefix}{string.Join('|', NormalizePermissions(permissions))}"; + private static string[] ParsePermissions(string policyName) { // 1. 拆分策略名称得到原始权限列表 var raw = policyName[PolicyPrefix.Length..]; + // 2. 规范化并过滤权限 return NormalizePermissions(raw.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); } + private static string[] NormalizePermissions(IEnumerable permissions) => [.. permissions .Where(p => !string.IsNullOrWhiteSpace(p))