From aa81dddc16df35f5351b57c3982cbfe63bffc7fd Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Fri, 12 Dec 2025 20:00:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=85=AC=E5=9F=9F=E7=A7=9F=E6=88=B7?= =?UTF-8?q?=E8=AE=A2=E9=98=85=E8=87=AA=E5=8A=A9=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PublicTenantSubscriptionsController.cs | 49 ++++++++ .../BindInitialTenantSubscriptionCommand.cs | 29 +++++ ...InitialTenantSubscriptionCommandHandler.cs | 112 ++++++++++++++++++ 3 files changed, 190 insertions(+) create mode 100644 src/Api/TakeoutSaaS.AdminApi/Controllers/PublicTenantSubscriptionsController.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Commands/BindInitialTenantSubscriptionCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/BindInitialTenantSubscriptionCommandHandler.cs diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/PublicTenantSubscriptionsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/PublicTenantSubscriptionsController.cs new file mode 100644 index 0000000..3952b9b --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/PublicTenantSubscriptionsController.cs @@ -0,0 +1,49 @@ +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using System.ComponentModel.DataAnnotations; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Web.Api; + +namespace TakeoutSaaS.AdminApi.Controllers; + +/// +/// 公域租户订阅自助接口(需登录,无权限校验)。 +/// +[ApiVersion("1.0")] +[Authorize] +[EnableRateLimiting("public-self-service")] +[Route("api/public/v{version:apiVersion}/tenants")] +public sealed class PublicTenantSubscriptionsController(IMediator mediator) : BaseApiController +{ + /// + /// 初次绑定租户订阅(默认 0 个月)。 + /// + /// 租户 ID。 + /// 绑定请求。 + /// 取消标记。 + /// 绑定后的订阅信息。 + [HttpPost("{tenantId:long}/subscriptions/initial")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status403Forbidden)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status409Conflict)] + public async Task> BindInitialSubscription( + long tenantId, + [FromBody, Required] BindInitialTenantSubscriptionCommand body, + CancellationToken cancellationToken) + { + // 1. 合并路由租户标识 + var command = body with { TenantId = tenantId }; + + // 2. 执行初次订阅绑定 + var result = await mediator.Send(command, cancellationToken); + + // 3. 返回绑定结果 + return ApiResponse.Ok(result); + } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/BindInitialTenantSubscriptionCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/BindInitialTenantSubscriptionCommand.cs new file mode 100644 index 0000000..56192f5 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/BindInitialTenantSubscriptionCommand.cs @@ -0,0 +1,29 @@ +using MediatR; +using System.ComponentModel.DataAnnotations; +using TakeoutSaaS.Application.App.Tenants.Dto; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 租户初次绑定订阅命令(默认 0 个月)。 +/// +public sealed record BindInitialTenantSubscriptionCommand : IRequest +{ + /// + /// 租户 ID(雪花算法)。 + /// + [Required] + public long TenantId { get; init; } + + /// + /// 套餐 ID。 + /// + [Required] + public long TenantPackageId { get; init; } + + /// + /// 是否自动续费。 + /// + public bool AutoRenew { get; init; } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/BindInitialTenantSubscriptionCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/BindInitialTenantSubscriptionCommandHandler.cs new file mode 100644 index 0000000..4da9d2c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/BindInitialTenantSubscriptionCommandHandler.cs @@ -0,0 +1,112 @@ +using MediatR; +using System.Collections.Concurrent; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Application.App.Tenants; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Ids; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 租户初次绑定订阅处理器。 +/// +public sealed class BindInitialTenantSubscriptionCommandHandler( + ITenantRepository tenantRepository, + IIdGenerator idGenerator, + ITenantProvider tenantProvider) + : IRequestHandler +{ + private static readonly ConcurrentDictionary TenantLocks = new(); + + /// + public async Task Handle(BindInitialTenantSubscriptionCommand request, CancellationToken cancellationToken) + { + // 1. 校验租户上下文 + var currentTenantId = tenantProvider.GetCurrentTenantId(); + if (currentTenantId == 0 || currentTenantId != request.TenantId) + { + throw new BusinessException(ErrorCodes.Forbidden, "租户上下文不匹配,请在请求头 X-Tenant-Id 指定目标租户"); + } + + // 1.2 获取租户级幂等锁,避免并发重复创建 + var tenantLock = GetTenantLock(request.TenantId); + await tenantLock.WaitAsync(cancellationToken); + try + { + // 2. 获取租户 + var tenant = await tenantRepository.FindByIdAsync(request.TenantId, cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在"); + + // 3. 幂等校验:若已存在订阅则直接返回 + var existing = await tenantRepository.GetActiveSubscriptionAsync(request.TenantId, cancellationToken); + if (existing is not null) + { + return existing.ToSubscriptionDto() + ?? throw new BusinessException(ErrorCodes.InternalServerError, "订阅读取失败"); + } + + // 4. 创建 0 个月订阅(待支付/待生效) + var now = DateTime.UtcNow; + var subscription = new TenantSubscription + { + Id = idGenerator.NextId(), + TenantId = tenant.Id, + TenantPackageId = request.TenantPackageId, + EffectiveFrom = now, + EffectiveTo = now, + NextBillingDate = now, + Status = SubscriptionStatus.Pending, + AutoRenew = request.AutoRenew, + Notes = "初次绑定订阅" + }; + + // 5. 记录订阅与历史 + await tenantRepository.AddSubscriptionAsync(subscription, cancellationToken); + await tenantRepository.AddSubscriptionHistoryAsync(new TenantSubscriptionHistory + { + Id = idGenerator.NextId(), + TenantId = tenant.Id, + TenantSubscriptionId = subscription.Id, + FromPackageId = request.TenantPackageId, + ToPackageId = request.TenantPackageId, + ChangeType = SubscriptionChangeType.New, + EffectiveFrom = now, + EffectiveTo = now, + Amount = null, + Currency = null, + Notes = "初次绑定订阅(0 个月)" + }, cancellationToken); + + // 6. 记录审计日志 + await tenantRepository.AddAuditLogAsync(new TenantAuditLog + { + TenantId = tenant.Id, + Action = TenantAuditAction.SubscriptionUpdated, + Title = "初次绑定订阅", + Description = $"套餐 {request.TenantPackageId},时长 0 月" + }, cancellationToken); + + // 7. 保存变更 + await tenantRepository.SaveChangesAsync(cancellationToken); + + // 8. 返回 DTO + return subscription.ToSubscriptionDto() + ?? throw new BusinessException(ErrorCodes.InternalServerError, "订阅绑定失败"); + } + finally + { + // 9. 释放幂等锁 + tenantLock.Release(); + } + } + + // 获取或创建租户级幂等锁实例。 + private static SemaphoreSlim GetTenantLock(long tenantId) + => TenantLocks.GetOrAdd(tenantId, _ => new SemaphoreSlim(1, 1)); +}