From d64545dd26602a872ec1a78466ef993447f72dcb Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Mon, 15 Dec 2025 13:44:47 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=A7=9F=E6=88=B7=E5=86=BB=E7=BB=93?= =?UTF-8?q?=E8=A7=A3=E5=86=BB=E4=B8=8E=E8=AE=A2=E9=98=85=E5=BB=B6=E6=9C=9F?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/TenantsController.cs | 66 +++++++++++ .../ExtendTenantSubscriptionCommand.cs | 30 +++++ .../Tenants/Commands/FreezeTenantCommand.cs | 25 ++++ .../Tenants/Commands/UnfreezeTenantCommand.cs | 24 ++++ .../ExtendTenantSubscriptionCommandHandler.cs | 108 ++++++++++++++++++ .../Handlers/FreezeTenantCommandHandler.cs | 78 +++++++++++++ .../Handlers/UnfreezeTenantCommandHandler.cs | 76 ++++++++++++ 7 files changed, 407 insertions(+) create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Commands/ExtendTenantSubscriptionCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Commands/FreezeTenantCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Commands/UnfreezeTenantCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/ExtendTenantSubscriptionCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/FreezeTenantCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/UnfreezeTenantCommandHandler.cs diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs index 556ade8..c57b987 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs @@ -188,6 +188,50 @@ public sealed class TenantsController(IMediator mediator) : BaseApiController return ApiResponse.Ok(result); } + /// + /// 冻结租户(暂停服务)。 + /// + /// 冻结后的租户信息。 + [HttpPost("{tenantId:long}/freeze")] + [PermissionAuthorize("tenant:review")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Freeze( + long tenantId, + [FromBody] FreezeTenantCommand body, + CancellationToken cancellationToken) + { + // 1. 合并路由参数 + var command = body with { TenantId = tenantId }; + + // 2. 执行冻结 + var result = await mediator.Send(command, cancellationToken); + + // 3. 返回冻结结果 + return ApiResponse.Ok(result); + } + + /// + /// 解冻租户(恢复服务)。 + /// + /// 解冻后的租户信息。 + [HttpPost("{tenantId:long}/unfreeze")] + [PermissionAuthorize("tenant:review")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Unfreeze( + long tenantId, + [FromBody] UnfreezeTenantCommand body, + CancellationToken cancellationToken) + { + // 1. 合并路由参数 + var command = body with { TenantId = tenantId }; + + // 2. 执行解冻 + var result = await mediator.Send(command, cancellationToken); + + // 3. 返回解冻结果 + return ApiResponse.Ok(result); + } + /// /// 创建或续费租户订阅。 /// @@ -208,6 +252,28 @@ public sealed class TenantsController(IMediator mediator) : BaseApiController return ApiResponse.Ok(result); } + /// + /// 延期/赠送租户订阅时长(按当前订阅套餐续费)。 + /// + /// 续费后的订阅信息。 + [HttpPost("{tenantId:long}/subscriptions/extend")] + [PermissionAuthorize("tenant:subscription")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> ExtendSubscription( + long tenantId, + [FromBody] ExtendTenantSubscriptionCommand 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/ExtendTenantSubscriptionCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/ExtendTenantSubscriptionCommand.cs new file mode 100644 index 0000000..cd5c378 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/ExtendTenantSubscriptionCommand.cs @@ -0,0 +1,30 @@ +using MediatR; +using System.ComponentModel.DataAnnotations; +using TakeoutSaaS.Application.App.Tenants.Dto; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 延期/赠送租户订阅时长(按当前订阅套餐续费)。 +/// +public sealed record ExtendTenantSubscriptionCommand : IRequest +{ + /// + /// 租户 ID(雪花算法)。 + /// + [Required] + public long TenantId { get; init; } + + /// + /// 赠送/延期时长(月)。 + /// + [Range(1, 120)] + public int DurationMonths { get; init; } + + /// + /// 备注信息。 + /// + [MaxLength(256)] + public string? Notes { get; init; } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/FreezeTenantCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/FreezeTenantCommand.cs new file mode 100644 index 0000000..c8db6f6 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/FreezeTenantCommand.cs @@ -0,0 +1,25 @@ +using MediatR; +using System.ComponentModel.DataAnnotations; +using TakeoutSaaS.Application.App.Tenants.Dto; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 冻结租户(将租户状态置为暂停)。 +/// +public sealed record FreezeTenantCommand : IRequest +{ + /// + /// 租户 ID(雪花算法)。 + /// + [Required] + public long TenantId { get; init; } + + /// + /// 冻结原因。 + /// + [Required] + [MaxLength(256)] + public string Reason { get; init; } = string.Empty; +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/UnfreezeTenantCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/UnfreezeTenantCommand.cs new file mode 100644 index 0000000..6d99553 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/UnfreezeTenantCommand.cs @@ -0,0 +1,24 @@ +using MediatR; +using System.ComponentModel.DataAnnotations; +using TakeoutSaaS.Application.App.Tenants.Dto; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 解冻租户(恢复租户状态)。 +/// +public sealed record UnfreezeTenantCommand : IRequest +{ + /// + /// 租户 ID(雪花算法)。 + /// + [Required] + public long TenantId { get; init; } + + /// + /// 解冻备注(可选)。 + /// + [MaxLength(256)] + public string? Reason { get; init; } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/ExtendTenantSubscriptionCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/ExtendTenantSubscriptionCommandHandler.cs new file mode 100644 index 0000000..965cd77 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/ExtendTenantSubscriptionCommandHandler.cs @@ -0,0 +1,108 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Ids; +using TakeoutSaaS.Shared.Abstractions.Security; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 延期/赠送订阅处理器(按当前订阅套餐续费)。 +/// +public sealed class ExtendTenantSubscriptionCommandHandler( + ITenantRepository tenantRepository, + IIdGenerator idGenerator, + ICurrentUserAccessor currentUserAccessor) + : IRequestHandler +{ + /// + public async Task Handle(ExtendTenantSubscriptionCommand request, CancellationToken cancellationToken) + { + // 1. 校验时长 + if (request.DurationMonths <= 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "延期/赠送时长必须大于 0"); + } + + // 2. 获取租户与当前订阅 + var tenant = await tenantRepository.FindByIdAsync(request.TenantId, cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在"); + + var current = await tenantRepository.GetActiveSubscriptionAsync(request.TenantId, cancellationToken) + ?? throw new BusinessException(ErrorCodes.BadRequest, "订阅不存在,无法延期/赠送"); + + var now = DateTime.UtcNow; + var effectiveFrom = current.EffectiveTo > now ? current.EffectiveTo : now; + var effectiveTo = effectiveFrom.AddMonths(request.DurationMonths); + + var previousStatus = tenant.Status; + var actorName = currentUserAccessor.IsAuthenticated + ? $"user:{currentUserAccessor.UserId}" + : "system"; + + // 3. (空行后) 创建续费订阅 + var subscription = new TenantSubscription + { + Id = idGenerator.NextId(), + TenantId = tenant.Id, + TenantPackageId = current.TenantPackageId, + EffectiveFrom = effectiveFrom, + EffectiveTo = effectiveTo, + NextBillingDate = effectiveTo, + Status = SubscriptionStatus.Active, + AutoRenew = current.AutoRenew, + Notes = request.Notes + }; + + await tenantRepository.AddSubscriptionAsync(subscription, cancellationToken); + await tenantRepository.AddSubscriptionHistoryAsync(new TenantSubscriptionHistory + { + Id = idGenerator.NextId(), + TenantId = tenant.Id, + TenantSubscriptionId = subscription.Id, + FromPackageId = current.TenantPackageId, + ToPackageId = current.TenantPackageId, + ChangeType = SubscriptionChangeType.Renew, + EffectiveFrom = effectiveFrom, + EffectiveTo = effectiveTo, + Amount = null, + Currency = null, + Notes = request.Notes + }, cancellationToken); + + // 4. (空行后) 若租户处于到期状态则恢复为正常(冻结状态需先解冻) + if (tenant.Status == TenantStatus.Expired) + { + tenant.Status = TenantStatus.Active; + } + + tenant.EffectiveFrom = subscription.EffectiveFrom; + tenant.EffectiveTo = subscription.EffectiveTo; + + await tenantRepository.UpdateTenantAsync(tenant, cancellationToken); + + // 5. (空行后) 记录审计 + await tenantRepository.AddAuditLogAsync(new TenantAuditLog + { + TenantId = tenant.Id, + Action = TenantAuditAction.SubscriptionUpdated, + Title = "延期/赠送时长", + Description = $"续费 {request.DurationMonths} 月,到期时间:{current.EffectiveTo:yyyy-MM-dd HH:mm:ss} -> {subscription.EffectiveTo:yyyy-MM-dd HH:mm:ss}", + OperatorId = currentUserAccessor.UserId == 0 ? null : currentUserAccessor.UserId, + OperatorName = actorName, + PreviousStatus = previousStatus, + CurrentStatus = tenant.Status + }, cancellationToken); + + // 6. 保存并返回 + await tenantRepository.SaveChangesAsync(cancellationToken); + return subscription.ToSubscriptionDto() + ?? throw new BusinessException(ErrorCodes.InternalServerError, "订阅生成失败"); + } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/FreezeTenantCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/FreezeTenantCommandHandler.cs new file mode 100644 index 0000000..0435f8e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/FreezeTenantCommandHandler.cs @@ -0,0 +1,78 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Security; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 冻结租户处理器。 +/// +public sealed class FreezeTenantCommandHandler( + ITenantRepository tenantRepository, + ICurrentUserAccessor currentUserAccessor) + : IRequestHandler +{ + /// + public async Task Handle(FreezeTenantCommand request, CancellationToken cancellationToken) + { + // 1. 获取租户与订阅 + var tenant = await tenantRepository.FindByIdAsync(request.TenantId, cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在"); + + var subscription = await tenantRepository.GetActiveSubscriptionAsync(request.TenantId, cancellationToken); + var verification = await tenantRepository.GetVerificationProfileAsync(request.TenantId, cancellationToken); + + var previousStatus = tenant.Status; + if (tenant.Status == TenantStatus.Closed) + { + throw new BusinessException(ErrorCodes.BadRequest, "已注销租户不可冻结"); + } + + if (tenant.Status == TenantStatus.Suspended) + { + throw new BusinessException(ErrorCodes.Conflict, "租户已被冻结"); + } + + // 2. 更新租户状态 + tenant.Status = TenantStatus.Suspended; + tenant.SuspendedAt = DateTime.UtcNow; + tenant.SuspensionReason = request.Reason; + + // 3. (空行后) 同步暂停订阅 + if (subscription != null) + { + subscription.Status = SubscriptionStatus.Suspended; + await tenantRepository.UpdateSubscriptionAsync(subscription, cancellationToken); + } + + await tenantRepository.UpdateTenantAsync(tenant, cancellationToken); + + // 4. (空行后) 记录审计 + var actorName = currentUserAccessor.IsAuthenticated + ? $"user:{currentUserAccessor.UserId}" + : "system"; + + await tenantRepository.AddAuditLogAsync(new TenantAuditLog + { + TenantId = tenant.Id, + Action = TenantAuditAction.StatusChanged, + Title = "冻结租户", + Description = request.Reason, + OperatorId = currentUserAccessor.UserId == 0 ? null : currentUserAccessor.UserId, + OperatorName = actorName, + PreviousStatus = previousStatus, + CurrentStatus = tenant.Status + }, cancellationToken); + + // 5. 保存并返回 + await tenantRepository.SaveChangesAsync(cancellationToken); + return TenantMapping.ToDto(tenant, subscription, verification); + } +} + diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/UnfreezeTenantCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/UnfreezeTenantCommandHandler.cs new file mode 100644 index 0000000..29409f8 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/UnfreezeTenantCommandHandler.cs @@ -0,0 +1,76 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Security; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 解冻租户处理器。 +/// +public sealed class UnfreezeTenantCommandHandler( + ITenantRepository tenantRepository, + ICurrentUserAccessor currentUserAccessor) + : IRequestHandler +{ + /// + public async Task Handle(UnfreezeTenantCommand request, CancellationToken cancellationToken) + { + // 1. 获取租户与订阅 + var tenant = await tenantRepository.FindByIdAsync(request.TenantId, cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在"); + + var subscription = await tenantRepository.GetActiveSubscriptionAsync(request.TenantId, cancellationToken); + var verification = await tenantRepository.GetVerificationProfileAsync(request.TenantId, cancellationToken); + + var previousStatus = tenant.Status; + if (tenant.Status != TenantStatus.Suspended) + { + throw new BusinessException(ErrorCodes.BadRequest, "当前租户未处于冻结状态"); + } + + // 2. 计算恢复状态(到期则回到到期状态) + var now = DateTime.UtcNow; + var isExpired = subscription != null && subscription.EffectiveTo <= now; + + tenant.Status = isExpired ? TenantStatus.Expired : TenantStatus.Active; + tenant.SuspendedAt = null; + tenant.SuspensionReason = null; + + // 3. (空行后) 同步订阅状态 + if (subscription != null) + { + subscription.Status = isExpired ? SubscriptionStatus.GracePeriod : SubscriptionStatus.Active; + await tenantRepository.UpdateSubscriptionAsync(subscription, cancellationToken); + } + + await tenantRepository.UpdateTenantAsync(tenant, cancellationToken); + + // 4. (空行后) 记录审计 + var actorName = currentUserAccessor.IsAuthenticated + ? $"user:{currentUserAccessor.UserId}" + : "system"; + + await tenantRepository.AddAuditLogAsync(new TenantAuditLog + { + TenantId = tenant.Id, + Action = TenantAuditAction.StatusChanged, + Title = "解冻租户", + Description = request.Reason, + OperatorId = currentUserAccessor.UserId == 0 ? null : currentUserAccessor.UserId, + OperatorName = actorName, + PreviousStatus = previousStatus, + CurrentStatus = tenant.Status + }, cancellationToken); + + // 5. 保存并返回 + await tenantRepository.SaveChangesAsync(cancellationToken); + return TenantMapping.ToDto(tenant, subscription, verification); + } +} +