feat: 实现租户管理及套餐流程

This commit is contained in:
2025-12-03 16:37:50 +08:00
parent 151f64d41a
commit a536a554c2
34 changed files with 1732 additions and 2 deletions

View File

@@ -0,0 +1,15 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Application.App.Tenants.Dto;
namespace TakeoutSaaS.Application.App.Tenants.Commands;
/// <summary>
/// 套餐升降配命令。
/// </summary>
public sealed record ChangeTenantSubscriptionPlanCommand(
[property: Required] long TenantId,
[property: Required] long TenantSubscriptionId,
[property: Required] long TargetPackageId,
bool Immediate,
string? Notes) : IRequest<TenantSubscriptionDto>;

View File

@@ -0,0 +1,15 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Application.App.Tenants.Dto;
namespace TakeoutSaaS.Application.App.Tenants.Commands;
/// <summary>
/// 新建或续费订阅。
/// </summary>
public sealed record CreateTenantSubscriptionCommand(
[property: Required] long TenantId,
[property: Required] long TenantPackageId,
int DurationMonths,
bool AutoRenew,
string? Notes) : IRequest<TenantSubscriptionDto>;

View File

@@ -0,0 +1,21 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Application.App.Tenants.Dto;
namespace TakeoutSaaS.Application.App.Tenants.Commands;
/// <summary>
/// 注册租户命令。
/// </summary>
public sealed record RegisterTenantCommand(
[property: Required, StringLength(64)] string Code,
[property: Required, StringLength(128)] string Name,
string? ShortName,
string? Industry,
string? ContactName,
string? ContactPhone,
string? ContactEmail,
[property: Required] long TenantPackageId,
int DurationMonths = 12,
bool AutoRenew = true,
DateTime? EffectiveFrom = null) : IRequest<TenantDto>;

View File

@@ -0,0 +1,13 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Application.App.Tenants.Dto;
namespace TakeoutSaaS.Application.App.Tenants.Commands;
/// <summary>
/// 审核租户命令。
/// </summary>
public sealed record ReviewTenantCommand(
[property: Required] long TenantId,
bool Approve,
string? Reason) : IRequest<TenantDto>;

View File

@@ -0,0 +1,21 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Application.App.Tenants.Dto;
namespace TakeoutSaaS.Application.App.Tenants.Commands;
/// <summary>
/// 提交租户实名认证资料。
/// </summary>
public sealed record SubmitTenantVerificationCommand(
[property: Required] long TenantId,
string? BusinessLicenseNumber,
string? BusinessLicenseUrl,
string? LegalPersonName,
string? LegalPersonIdNumber,
string? LegalPersonIdFrontUrl,
string? LegalPersonIdBackUrl,
string? BankAccountName,
string? BankAccountNumber,
string? BankName,
string? AdditionalDataJson) : IRequest<TenantVerificationDto>;

View File

@@ -0,0 +1,58 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Tenants.Dto;
/// <summary>
/// 租户审核日志 DTO。
/// </summary>
public sealed class TenantAuditLogDto
{
/// <summary>
/// 日志 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 租户 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 动作。
/// </summary>
public TenantAuditAction Action { get; init; }
/// <summary>
/// 标题。
/// </summary>
public string Title { get; init; } = string.Empty;
/// <summary>
/// 描述。
/// </summary>
public string? Description { get; init; }
/// <summary>
/// 操作人。
/// </summary>
public string? OperatorName { get; init; }
/// <summary>
/// 原状态。
/// </summary>
public TenantStatus? PreviousStatus { get; init; }
/// <summary>
/// 新状态。
/// </summary>
public TenantStatus? CurrentStatus { get; init; }
/// <summary>
/// 创建时间。
/// </summary>
public DateTime CreatedAt { get; init; }
}

View File

@@ -0,0 +1,22 @@
namespace TakeoutSaaS.Application.App.Tenants.Dto;
/// <summary>
/// 租户详情 DTO。
/// </summary>
public sealed class TenantDetailDto
{
/// <summary>
/// 基础信息。
/// </summary>
public TenantDto Tenant { get; init; } = new();
/// <summary>
/// 实名信息。
/// </summary>
public TenantVerificationDto? Verification { get; init; }
/// <summary>
/// 当前订阅。
/// </summary>
public TenantSubscriptionDto? Subscription { get; init; }
}

View File

@@ -0,0 +1,78 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Tenants.Dto;
/// <summary>
/// 租户基础信息 DTO。
/// </summary>
public sealed class TenantDto
{
/// <summary>
/// 租户 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 租户编码。
/// </summary>
public string Code { get; init; } = string.Empty;
/// <summary>
/// 名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 简称。
/// </summary>
public string? ShortName { get; init; }
/// <summary>
/// 联系人。
/// </summary>
public string? ContactName { get; init; }
/// <summary>
/// 联系电话。
/// </summary>
public string? ContactPhone { get; init; }
/// <summary>
/// 邮箱。
/// </summary>
public string? ContactEmail { get; init; }
/// <summary>
/// 当前状态。
/// </summary>
public TenantStatus Status { get; init; }
/// <summary>
/// 实名状态。
/// </summary>
public TenantVerificationStatus VerificationStatus { get; init; }
/// <summary>
/// 当前套餐 ID。
/// </summary>
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
public long? CurrentPackageId { get; init; }
/// <summary>
/// 当前订阅有效期开始。
/// </summary>
public DateTime? EffectiveFrom { get; init; }
/// <summary>
/// 当前订阅有效期结束。
/// </summary>
public DateTime? EffectiveTo { get; init; }
/// <summary>
/// 是否自动续费。
/// </summary>
public bool AutoRenew { get; init; }
}

View File

@@ -0,0 +1,54 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Tenants.Dto;
/// <summary>
/// 租户订阅 DTO。
/// </summary>
public sealed class TenantSubscriptionDto
{
/// <summary>
/// 订阅 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 租户 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 套餐 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantPackageId { get; init; }
/// <summary>
/// 状态。
/// </summary>
public SubscriptionStatus Status { get; init; }
/// <summary>
/// 生效时间。
/// </summary>
public DateTime EffectiveFrom { get; init; }
/// <summary>
/// 到期时间。
/// </summary>
public DateTime EffectiveTo { get; init; }
/// <summary>
/// 下次扣费时间。
/// </summary>
public DateTime? NextBillingDate { get; init; }
/// <summary>
/// 是否自动续费。
/// </summary>
public bool AutoRenew { get; init; }
}

View File

@@ -0,0 +1,73 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Tenants.Dto;
/// <summary>
/// 租户实名认证 DTO。
/// </summary>
public sealed class TenantVerificationDto
{
/// <summary>
/// 主键。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 租户标识。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 状态。
/// </summary>
public TenantVerificationStatus Status { get; init; }
/// <summary>
/// 营业执照号。
/// </summary>
public string? BusinessLicenseNumber { get; init; }
/// <summary>
/// 营业执照图片。
/// </summary>
public string? BusinessLicenseUrl { get; init; }
/// <summary>
/// 法人姓名。
/// </summary>
public string? LegalPersonName { get; init; }
/// <summary>
/// 法人身份证号。
/// </summary>
public string? LegalPersonIdNumber { get; init; }
/// <summary>
/// 银行账号。
/// </summary>
public string? BankAccountNumber { get; init; }
/// <summary>
/// 银行名称。
/// </summary>
public string? BankName { get; init; }
/// <summary>
/// 审核备注。
/// </summary>
public string? ReviewRemarks { get; init; }
/// <summary>
/// 最新审核人。
/// </summary>
public string? ReviewedByName { get; init; }
/// <summary>
/// 审核时间。
/// </summary>
public DateTime? ReviewedAt { get; init; }
}

View File

@@ -0,0 +1,74 @@
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;
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
/// <summary>
/// 套餐升降配处理器。
/// </summary>
public sealed class ChangeTenantSubscriptionPlanCommandHandler(
ITenantRepository tenantRepository,
IIdGenerator idGenerator)
: IRequestHandler<ChangeTenantSubscriptionPlanCommand, TenantSubscriptionDto>
{
private readonly ITenantRepository _tenantRepository = tenantRepository;
private readonly IIdGenerator _idGenerator = idGenerator;
/// <inheritdoc />
public async Task<TenantSubscriptionDto> Handle(ChangeTenantSubscriptionPlanCommand request, CancellationToken cancellationToken)
{
_ = await _tenantRepository.FindByIdAsync(request.TenantId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在");
var subscription = await _tenantRepository.FindSubscriptionByIdAsync(request.TenantId, request.TenantSubscriptionId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "订阅不存在");
var previousPackage = subscription.TenantPackageId;
if (request.Immediate)
{
subscription.TenantPackageId = request.TargetPackageId;
subscription.ScheduledPackageId = null;
}
else
{
subscription.ScheduledPackageId = request.TargetPackageId;
}
await _tenantRepository.UpdateSubscriptionAsync(subscription, cancellationToken);
await _tenantRepository.AddSubscriptionHistoryAsync(new TenantSubscriptionHistory
{
Id = _idGenerator.NextId(),
TenantId = subscription.TenantId,
TenantSubscriptionId = subscription.Id,
FromPackageId = previousPackage,
ToPackageId = request.TargetPackageId,
ChangeType = SubscriptionChangeType.Upgrade,
EffectiveFrom = subscription.EffectiveFrom,
EffectiveTo = subscription.EffectiveTo,
Notes = request.Notes
}, cancellationToken);
await _tenantRepository.AddAuditLogAsync(new TenantAuditLog
{
TenantId = subscription.TenantId,
Action = TenantAuditAction.SubscriptionPlanChanged,
Title = request.Immediate ? "套餐立即变更" : "套餐排期变更",
Description = request.Notes,
PreviousStatus = null,
CurrentStatus = null
}, cancellationToken);
await _tenantRepository.SaveChangesAsync(cancellationToken);
return subscription.ToSubscriptionDto()
?? throw new BusinessException(ErrorCodes.InternalServerError, "订阅更新失败");
}
}

View File

@@ -0,0 +1,82 @@
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;
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
/// <summary>
/// 新建/续费订阅处理器。
/// </summary>
public sealed class CreateTenantSubscriptionCommandHandler(
ITenantRepository tenantRepository,
IIdGenerator idGenerator)
: IRequestHandler<CreateTenantSubscriptionCommand, TenantSubscriptionDto>
{
private readonly ITenantRepository _tenantRepository = tenantRepository;
private readonly IIdGenerator _idGenerator = idGenerator;
/// <inheritdoc />
public async Task<TenantSubscriptionDto> Handle(CreateTenantSubscriptionCommand request, CancellationToken cancellationToken)
{
if (request.DurationMonths <= 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "订阅时长必须大于 0");
}
var tenant = await _tenantRepository.FindByIdAsync(request.TenantId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在");
var current = await _tenantRepository.GetActiveSubscriptionAsync(request.TenantId, cancellationToken);
var from = current?.EffectiveTo ?? tenant.EffectiveTo ?? DateTime.UtcNow;
var effectiveFrom = from > DateTime.UtcNow ? from : DateTime.UtcNow;
var effectiveTo = effectiveFrom.AddMonths(request.DurationMonths);
var subscription = new TenantSubscription
{
Id = _idGenerator.NextId(),
TenantId = tenant.Id,
TenantPackageId = request.TenantPackageId,
EffectiveFrom = effectiveFrom,
EffectiveTo = effectiveTo,
NextBillingDate = effectiveTo,
Status = SubscriptionStatus.Active,
AutoRenew = request.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 ?? request.TenantPackageId,
ToPackageId = request.TenantPackageId,
ChangeType = current == null ? SubscriptionChangeType.New : SubscriptionChangeType.Renew,
EffectiveFrom = effectiveFrom,
EffectiveTo = effectiveTo,
Amount = null,
Currency = null,
Notes = request.Notes
}, cancellationToken);
await _tenantRepository.AddAuditLogAsync(new TenantAuditLog
{
TenantId = tenant.Id,
Action = TenantAuditAction.SubscriptionUpdated,
Title = current == null ? "创建订阅" : "续费订阅",
Description = $"套餐 {request.TenantPackageId} 时长 {request.DurationMonths} 月"
}, cancellationToken);
await _tenantRepository.SaveChangesAsync(cancellationToken);
return subscription.ToSubscriptionDto()
?? throw new BusinessException(ErrorCodes.InternalServerError, "订阅生成失败");
}
}

View File

@@ -0,0 +1,32 @@
using System.Linq;
using MediatR;
using TakeoutSaaS.Application.App.Tenants.Dto;
using TakeoutSaaS.Application.App.Tenants.Queries;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
/// <summary>
/// 审核日志查询。
/// </summary>
public sealed class GetTenantAuditLogsQueryHandler(ITenantRepository tenantRepository)
: IRequestHandler<GetTenantAuditLogsQuery, PagedResult<TenantAuditLogDto>>
{
private readonly ITenantRepository _tenantRepository = tenantRepository;
/// <inheritdoc />
public async Task<PagedResult<TenantAuditLogDto>> Handle(GetTenantAuditLogsQuery request, CancellationToken cancellationToken)
{
var logs = await _tenantRepository.GetAuditLogsAsync(request.TenantId, cancellationToken);
var total = logs.Count;
var paged = logs
.Skip((request.Page - 1) * request.PageSize)
.Take(request.PageSize)
.Select(TenantMapping.ToDto)
.ToList();
return new PagedResult<TenantAuditLogDto>(paged, request.Page, request.PageSize, total);
}
}

View File

@@ -0,0 +1,34 @@
using MediatR;
using TakeoutSaaS.Application.App.Tenants.Dto;
using TakeoutSaaS.Application.App.Tenants.Queries;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
/// <summary>
/// 租户详情查询处理器。
/// </summary>
public sealed class GetTenantByIdQueryHandler(ITenantRepository tenantRepository)
: IRequestHandler<GetTenantByIdQuery, TenantDetailDto>
{
private readonly ITenantRepository _tenantRepository = tenantRepository;
/// <inheritdoc />
public async Task<TenantDetailDto> Handle(GetTenantByIdQuery request, CancellationToken cancellationToken)
{
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);
return new TenantDetailDto
{
Tenant = TenantMapping.ToDto(tenant, subscription, verification),
Verification = verification.ToVerificationDto(),
Subscription = subscription.ToSubscriptionDto()
};
}
}

View File

@@ -0,0 +1,88 @@
using MediatR;
using Microsoft.Extensions.Logging;
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;
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
/// <summary>
/// 租户注册处理器。
/// </summary>
public sealed class RegisterTenantCommandHandler(
ITenantRepository tenantRepository,
IIdGenerator idGenerator,
ILogger<RegisterTenantCommandHandler> logger)
: IRequestHandler<RegisterTenantCommand, TenantDto>
{
private readonly ITenantRepository _tenantRepository = tenantRepository;
private readonly IIdGenerator _idGenerator = idGenerator;
private readonly ILogger<RegisterTenantCommandHandler> _logger = logger;
/// <inheritdoc />
public async Task<TenantDto> Handle(RegisterTenantCommand request, CancellationToken cancellationToken)
{
if (request.DurationMonths <= 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "订阅时长必须大于 0");
}
if (await _tenantRepository.ExistsByCodeAsync(request.Code, cancellationToken))
{
throw new BusinessException(ErrorCodes.Conflict, $"租户编码 {request.Code} 已存在");
}
var now = DateTime.UtcNow;
var effectiveFrom = request.EffectiveFrom ?? now;
var effectiveTo = effectiveFrom.AddMonths(request.DurationMonths);
var tenant = new Tenant
{
Id = _idGenerator.NextId(),
Code = request.Code.Trim(),
Name = request.Name,
ShortName = request.ShortName,
Industry = request.Industry,
ContactName = request.ContactName,
ContactPhone = request.ContactPhone,
ContactEmail = request.ContactEmail,
Status = TenantStatus.PendingReview,
EffectiveFrom = effectiveFrom,
EffectiveTo = effectiveTo
};
var subscription = new TenantSubscription
{
Id = _idGenerator.NextId(),
TenantId = tenant.Id,
TenantPackageId = request.TenantPackageId,
EffectiveFrom = effectiveFrom,
EffectiveTo = effectiveTo,
NextBillingDate = effectiveTo,
Status = SubscriptionStatus.Pending,
AutoRenew = request.AutoRenew,
Notes = "Init subscription"
};
await _tenantRepository.AddTenantAsync(tenant, cancellationToken);
await _tenantRepository.AddSubscriptionAsync(subscription, cancellationToken);
await _tenantRepository.AddAuditLogAsync(new TenantAuditLog
{
TenantId = tenant.Id,
Action = TenantAuditAction.RegistrationSubmitted,
Title = "租户注册",
Description = $"提交套餐 {request.TenantPackageId},时长 {request.DurationMonths} 月"
}, cancellationToken);
await _tenantRepository.SaveChangesAsync(cancellationToken);
_logger.LogInformation("已注册租户 {TenantCode}", tenant.Code);
return TenantMapping.ToDto(tenant, subscription, null);
}
}

View File

@@ -0,0 +1,87 @@
using MediatR;
using TakeoutSaaS.Application.App.Tenants.Commands;
using TakeoutSaaS.Application.App.Tenants.Dto;
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;
/// <summary>
/// 租户审核处理器。
/// </summary>
public sealed class ReviewTenantCommandHandler(
ITenantRepository tenantRepository,
ICurrentUserAccessor currentUserAccessor)
: IRequestHandler<ReviewTenantCommand, TenantDto>
{
private readonly ITenantRepository _tenantRepository = tenantRepository;
private readonly ICurrentUserAccessor _currentUserAccessor = currentUserAccessor;
/// <inheritdoc />
public async Task<TenantDto> Handle(ReviewTenantCommand request, CancellationToken cancellationToken)
{
var tenant = await _tenantRepository.FindByIdAsync(request.TenantId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在");
var verification = await _tenantRepository.GetVerificationProfileAsync(request.TenantId, cancellationToken)
?? throw new BusinessException(ErrorCodes.BadRequest, "请先提交实名认证资料");
var subscription = await _tenantRepository.GetActiveSubscriptionAsync(request.TenantId, cancellationToken);
var actorName = _currentUserAccessor.IsAuthenticated
? $"user:{_currentUserAccessor.UserId}"
: "system";
verification.ReviewedAt = DateTime.UtcNow;
verification.ReviewedBy = _currentUserAccessor.UserId == 0 ? null : _currentUserAccessor.UserId;
verification.ReviewedByName = actorName;
verification.ReviewRemarks = request.Reason;
var previousStatus = tenant.Status;
if (request.Approve)
{
verification.Status = TenantVerificationStatus.Approved;
tenant.Status = TenantStatus.Active;
if (subscription != null)
{
subscription.Status = SubscriptionStatus.Active;
}
}
else
{
verification.Status = TenantVerificationStatus.Rejected;
tenant.Status = TenantStatus.PendingReview;
if (subscription != null)
{
subscription.Status = SubscriptionStatus.Suspended;
}
}
await _tenantRepository.UpdateTenantAsync(tenant, cancellationToken);
await _tenantRepository.UpsertVerificationProfileAsync(verification, cancellationToken);
if (subscription != null)
{
await _tenantRepository.UpdateSubscriptionAsync(subscription, cancellationToken);
}
await _tenantRepository.AddAuditLogAsync(new Domain.Tenants.Entities.TenantAuditLog
{
TenantId = tenant.Id,
Action = request.Approve ? TenantAuditAction.VerificationApproved : TenantAuditAction.VerificationRejected,
Title = request.Approve ? "审核通过" : "审核驳回",
Description = request.Reason,
OperatorId = _currentUserAccessor.UserId == 0 ? null : _currentUserAccessor.UserId,
OperatorName = actorName,
PreviousStatus = previousStatus,
CurrentStatus = tenant.Status
}, cancellationToken);
await _tenantRepository.SaveChangesAsync(cancellationToken);
return TenantMapping.ToDto(tenant, subscription, verification);
}
}

View File

@@ -0,0 +1,39 @@
using System.Linq;
using MediatR;
using TakeoutSaaS.Application.App.Tenants.Dto;
using TakeoutSaaS.Application.App.Tenants.Queries;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
/// <summary>
/// 租户分页查询处理器。
/// </summary>
public sealed class SearchTenantsQueryHandler(ITenantRepository tenantRepository)
: IRequestHandler<SearchTenantsQuery, PagedResult<TenantDto>>
{
private readonly ITenantRepository _tenantRepository = tenantRepository;
/// <inheritdoc />
public async Task<PagedResult<TenantDto>> Handle(SearchTenantsQuery request, CancellationToken cancellationToken)
{
var tenants = await _tenantRepository.SearchAsync(request.Status, request.Keyword, cancellationToken);
var total = tenants.Count;
var paged = tenants
.Skip((request.Page - 1) * request.PageSize)
.Take(request.PageSize)
.ToList();
var result = new List<TenantDto>(paged.Count);
foreach (var tenant in paged)
{
var subscription = await _tenantRepository.GetActiveSubscriptionAsync(tenant.Id, cancellationToken);
var verification = await _tenantRepository.GetVerificationProfileAsync(tenant.Id, cancellationToken);
result.Add(TenantMapping.ToDto(tenant, subscription, verification));
}
return new PagedResult<TenantDto>(result, request.Page, request.PageSize, total);
}
}

View File

@@ -0,0 +1,63 @@
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;
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
/// <summary>
/// 实名资料提交流程。
/// </summary>
public sealed class SubmitTenantVerificationCommandHandler(
ITenantRepository tenantRepository,
IIdGenerator idGenerator)
: IRequestHandler<SubmitTenantVerificationCommand, TenantVerificationDto>
{
private readonly ITenantRepository _tenantRepository = tenantRepository;
private readonly IIdGenerator _idGenerator = idGenerator;
/// <inheritdoc />
public async Task<TenantVerificationDto> Handle(SubmitTenantVerificationCommand request, CancellationToken cancellationToken)
{
var tenant = await _tenantRepository.FindByIdAsync(request.TenantId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在");
var profile = await _tenantRepository.GetVerificationProfileAsync(request.TenantId, cancellationToken)
?? new TenantVerificationProfile { Id = _idGenerator.NextId(), TenantId = tenant.Id };
profile.BusinessLicenseNumber = request.BusinessLicenseNumber;
profile.BusinessLicenseUrl = request.BusinessLicenseUrl;
profile.LegalPersonName = request.LegalPersonName;
profile.LegalPersonIdNumber = request.LegalPersonIdNumber;
profile.LegalPersonIdFrontUrl = request.LegalPersonIdFrontUrl;
profile.LegalPersonIdBackUrl = request.LegalPersonIdBackUrl;
profile.BankAccountName = request.BankAccountName;
profile.BankAccountNumber = request.BankAccountNumber;
profile.BankName = request.BankName;
profile.AdditionalDataJson = request.AdditionalDataJson;
profile.Status = TenantVerificationStatus.Pending;
profile.SubmittedAt = DateTime.UtcNow;
profile.ReviewedAt = null;
profile.ReviewRemarks = null;
profile.ReviewedBy = null;
profile.ReviewedByName = null;
await _tenantRepository.UpsertVerificationProfileAsync(profile, cancellationToken);
await _tenantRepository.AddAuditLogAsync(new TenantAuditLog
{
TenantId = tenant.Id,
Action = TenantAuditAction.VerificationSubmitted,
Title = "提交实名认证资料",
Description = request.BusinessLicenseNumber
}, cancellationToken);
await _tenantRepository.SaveChangesAsync(cancellationToken);
return profile.ToVerificationDto()
?? throw new BusinessException(ErrorCodes.InternalServerError, "实名资料保存失败");
}
}

View File

@@ -0,0 +1,13 @@
using MediatR;
using TakeoutSaaS.Application.App.Tenants.Dto;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.Tenants.Queries;
/// <summary>
/// 租户审核日志查询。
/// </summary>
public sealed record GetTenantAuditLogsQuery(
long TenantId,
int Page = 1,
int PageSize = 20) : IRequest<PagedResult<TenantAuditLogDto>>;

View File

@@ -0,0 +1,9 @@
using MediatR;
using TakeoutSaaS.Application.App.Tenants.Dto;
namespace TakeoutSaaS.Application.App.Tenants.Queries;
/// <summary>
/// 单个租户查询。
/// </summary>
public sealed record GetTenantByIdQuery(long TenantId) : IRequest<TenantDetailDto>;

View File

@@ -0,0 +1,15 @@
using MediatR;
using TakeoutSaaS.Application.App.Tenants.Dto;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.Tenants.Queries;
/// <summary>
/// 租户分页查询。
/// </summary>
public sealed record SearchTenantsQuery(
TenantStatus? Status,
string? Keyword,
int Page = 1,
int PageSize = 20) : IRequest<PagedResult<TenantDto>>;

View File

@@ -0,0 +1,76 @@
using TakeoutSaaS.Application.App.Tenants.Dto;
using TakeoutSaaS.Domain.Tenants.Entities;
namespace TakeoutSaaS.Application.App.Tenants;
/// <summary>
/// 租户 DTO 映射助手。
/// </summary>
internal static class TenantMapping
{
public static TenantDto ToDto(Tenant tenant, TenantSubscription? subscription, TenantVerificationProfile? verification)
=> new()
{
Id = tenant.Id,
Code = tenant.Code,
Name = tenant.Name,
ShortName = tenant.ShortName,
ContactName = tenant.ContactName,
ContactPhone = tenant.ContactPhone,
ContactEmail = tenant.ContactEmail,
Status = tenant.Status,
VerificationStatus = verification?.Status ?? Domain.Tenants.Enums.TenantVerificationStatus.Draft,
CurrentPackageId = subscription?.TenantPackageId,
EffectiveFrom = subscription?.EffectiveFrom ?? tenant.EffectiveFrom,
EffectiveTo = subscription?.EffectiveTo ?? tenant.EffectiveTo,
AutoRenew = subscription?.AutoRenew ?? false
};
public static TenantVerificationDto? ToVerificationDto(this TenantVerificationProfile? profile)
=> profile == null
? null
: new TenantVerificationDto
{
Id = profile.Id,
TenantId = profile.TenantId,
Status = profile.Status,
BusinessLicenseNumber = profile.BusinessLicenseNumber,
BusinessLicenseUrl = profile.BusinessLicenseUrl,
LegalPersonName = profile.LegalPersonName,
LegalPersonIdNumber = profile.LegalPersonIdNumber,
BankAccountNumber = profile.BankAccountNumber,
BankName = profile.BankName,
ReviewRemarks = profile.ReviewRemarks,
ReviewedByName = profile.ReviewedByName,
ReviewedAt = profile.ReviewedAt
};
public static TenantSubscriptionDto? ToSubscriptionDto(this TenantSubscription? subscription)
=> subscription == null
? null
: new 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
};
public static TenantAuditLogDto ToDto(this TenantAuditLog log)
=> new()
{
Id = log.Id,
TenantId = log.TenantId,
Action = log.Action,
Title = log.Title,
Description = log.Description,
OperatorName = log.OperatorName,
PreviousStatus = log.PreviousStatus,
CurrentStatus = log.CurrentStatus,
CreatedAt = log.CreatedAt
};
}