refactor: 移除平台侧能力并收紧租户隔离

This commit is contained in:
root
2026-01-29 12:06:52 +00:00
parent 86ef0d6033
commit 3297ff26ab
124 changed files with 280 additions and 7231 deletions

View File

@@ -1,15 +0,0 @@
using MediatR;
using TakeoutSaaS.Application.App.Merchants.Dto;
namespace TakeoutSaaS.Application.App.Merchants.Commands;
/// <summary>
/// 领取商户审核命令。
/// </summary>
public sealed class ClaimMerchantReviewCommand : IRequest<ClaimInfoDto>
{
/// <summary>
/// 商户 ID。
/// </summary>
public long MerchantId { get; init; }
}

View File

@@ -1,15 +0,0 @@
using MediatR;
using TakeoutSaaS.Application.App.Merchants.Dto;
namespace TakeoutSaaS.Application.App.Merchants.Commands;
/// <summary>
/// 释放商户审核领取命令。
/// </summary>
public sealed class ReleaseClaimCommand : IRequest<ClaimInfoDto?>
{
/// <summary>
/// 商户 ID。
/// </summary>
public long MerchantId { get; init; }
}

View File

@@ -1,13 +0,0 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Application.App.Merchants.Dto;
namespace TakeoutSaaS.Application.App.Merchants.Commands;
/// <summary>
/// 审核商户入驻。
/// </summary>
public sealed record ReviewMerchantCommand(
[param: Required] long MerchantId,
bool Approve,
string? Remarks) : IRequest<MerchantDto>;

View File

@@ -1,14 +0,0 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Application.App.Merchants.Dto;
namespace TakeoutSaaS.Application.App.Merchants.Commands;
/// <summary>
/// 审核商户证照。
/// </summary>
public sealed record ReviewMerchantDocumentCommand(
[property: Required] long MerchantId,
[property: Required] long DocumentId,
bool Approve,
string? Remarks) : IRequest<MerchantDocumentDto>;

View File

@@ -1,19 +0,0 @@
using MediatR;
namespace TakeoutSaaS.Application.App.Merchants.Commands;
/// <summary>
/// 撤销商户审核命令。
/// </summary>
public sealed class RevokeMerchantReviewCommand : IRequest
{
/// <summary>
/// 商户 ID。
/// </summary>
public long MerchantId { get; init; }
/// <summary>
/// 撤销原因。
/// </summary>
public string Reason { get; init; } = string.Empty;
}

View File

@@ -1,37 +0,0 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Merchants.Dto;
/// <summary>
/// 审核领取信息 DTO。
/// </summary>
public sealed class ClaimInfoDto
{
/// <summary>
/// 商户 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long MerchantId { get; init; }
/// <summary>
/// 领取人 ID。
/// </summary>
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
public long? ClaimedBy { get; init; }
/// <summary>
/// 领取人名称。
/// </summary>
public string? ClaimedByName { get; init; }
/// <summary>
/// 领取时间。
/// </summary>
public DateTime? ClaimedAt { get; init; }
/// <summary>
/// 领取过期时间。
/// </summary>
public DateTime? ClaimExpiresAt { get; init; }
}

View File

@@ -1,69 +0,0 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Common.Enums;
using TakeoutSaaS.Domain.Merchants.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Merchants.Dto;
/// <summary>
/// 待审核商户列表项 DTO。
/// </summary>
public sealed class MerchantReviewListItemDto
{
/// <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 string? TenantName { get; init; }
/// <summary>
/// 商户名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 经营模式。
/// </summary>
public OperatingMode? OperatingMode { get; init; }
/// <summary>
/// 营业执照号。
/// </summary>
public string? LicenseNumber { get; init; }
/// <summary>
/// 审核状态。
/// </summary>
public MerchantStatus Status { get; init; }
/// <summary>
/// 领取人名称。
/// </summary>
public string? ClaimedByName { get; init; }
/// <summary>
/// 领取时间。
/// </summary>
public DateTime? ClaimedAt { get; init; }
/// <summary>
/// 领取过期时间。
/// </summary>
public DateTime? ClaimExpiresAt { get; init; }
/// <summary>
/// 创建时间。
/// </summary>
public DateTime CreatedAt { get; init; }
}

View File

@@ -1,77 +0,0 @@
using MediatR;
using TakeoutSaaS.Application.App.Merchants.Commands;
using TakeoutSaaS.Application.App.Merchants.Dto;
using TakeoutSaaS.Domain.Merchants.Entities;
using TakeoutSaaS.Domain.Merchants.Enums;
using TakeoutSaaS.Domain.Merchants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Security;
namespace TakeoutSaaS.Application.App.Merchants.Handlers;
/// <summary>
/// 领取商户审核处理器。
/// </summary>
public sealed class ClaimMerchantReviewHandler(
IMerchantRepository merchantRepository,
ICurrentUserAccessor currentUserAccessor)
: IRequestHandler<ClaimMerchantReviewCommand, ClaimInfoDto>
{
/// <inheritdoc />
public async Task<ClaimInfoDto> Handle(ClaimMerchantReviewCommand request, CancellationToken cancellationToken)
{
var merchant = await merchantRepository.FindByIdAsync(request.MerchantId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "商户不存在");
if (merchant.Status != MerchantStatus.Pending)
{
throw new BusinessException(ErrorCodes.Conflict, "商户不在待审核状态");
}
var now = DateTime.UtcNow;
if (merchant.ClaimedBy.HasValue && merchant.ClaimExpiresAt.HasValue && merchant.ClaimExpiresAt > now)
{
if (merchant.ClaimedBy != currentUserAccessor.UserId)
{
throw new BusinessException(ErrorCodes.Conflict, $"该审核已被 {merchant.ClaimedByName} 领取");
}
return ToDto(merchant);
}
var actorName = currentUserAccessor.IsAuthenticated
? $"user:{currentUserAccessor.UserId}"
: "system";
merchant.ClaimedBy = currentUserAccessor.UserId == 0 ? null : currentUserAccessor.UserId;
merchant.ClaimedByName = actorName;
merchant.ClaimedAt = now;
merchant.ClaimExpiresAt = now.AddMinutes(30);
await merchantRepository.UpdateMerchantAsync(merchant, cancellationToken);
await merchantRepository.AddAuditLogAsync(new MerchantAuditLog
{
TenantId = merchant.TenantId,
MerchantId = merchant.Id,
Action = MerchantAuditAction.ReviewClaimed,
Title = "领取审核",
Description = $"领取人:{actorName}",
OperatorId = currentUserAccessor.UserId == 0 ? null : currentUserAccessor.UserId,
OperatorName = actorName
}, cancellationToken);
await merchantRepository.SaveChangesAsync(cancellationToken);
return ToDto(merchant);
}
private static ClaimInfoDto ToDto(Domain.Merchants.Entities.Merchant merchant)
=> new()
{
MerchantId = merchant.Id,
ClaimedBy = merchant.ClaimedBy,
ClaimedByName = merchant.ClaimedByName,
ClaimedAt = merchant.ClaimedAt,
ClaimExpiresAt = merchant.ClaimExpiresAt
};
}

View File

@@ -1,41 +0,0 @@
using MediatR;
using TakeoutSaaS.Application.App.Merchants.Dto;
using TakeoutSaaS.Application.App.Merchants.Queries;
using TakeoutSaaS.Domain.Merchants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Application.App.Merchants.Handlers;
/// <summary>
/// 商户审核领取信息查询处理器。
/// </summary>
public sealed class GetMerchantReviewClaimQueryHandler(IMerchantRepository merchantRepository)
: IRequestHandler<GetMerchantReviewClaimQuery, ClaimInfoDto?>
{
/// <inheritdoc />
public async Task<ClaimInfoDto?> Handle(GetMerchantReviewClaimQuery request, CancellationToken cancellationToken)
{
var merchant = await merchantRepository.FindByIdAsync(request.MerchantId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "商户不存在");
if (!merchant.ClaimedBy.HasValue)
{
return null;
}
if (merchant.ClaimExpiresAt.HasValue && merchant.ClaimExpiresAt <= DateTime.UtcNow)
{
return null;
}
return new ClaimInfoDto
{
MerchantId = merchant.Id,
ClaimedBy = merchant.ClaimedBy,
ClaimedByName = merchant.ClaimedByName,
ClaimedAt = merchant.ClaimedAt,
ClaimExpiresAt = merchant.ClaimExpiresAt
};
}
}

View File

@@ -1,59 +0,0 @@
using MediatR;
using TakeoutSaaS.Application.App.Merchants.Dto;
using TakeoutSaaS.Application.App.Merchants.Queries;
using TakeoutSaaS.Domain.Merchants.Enums;
using TakeoutSaaS.Domain.Merchants.Repositories;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.Merchants.Handlers;
/// <summary>
/// 待审核商户列表处理器。
/// </summary>
public sealed class GetPendingReviewListQueryHandler(
IMerchantRepository merchantRepository,
ITenantRepository tenantRepository)
: IRequestHandler<GetPendingReviewListQuery, PagedResult<MerchantReviewListItemDto>>
{
/// <inheritdoc />
public async Task<PagedResult<MerchantReviewListItemDto>> Handle(
GetPendingReviewListQuery request,
CancellationToken cancellationToken)
{
var merchants = await merchantRepository.SearchAsync(
request.TenantId,
MerchantStatus.Pending,
request.OperatingMode,
request.Keyword,
cancellationToken);
var total = merchants.Count;
var paged = merchants
.OrderByDescending(x => x.CreatedAt)
.Skip((request.Page - 1) * request.PageSize)
.Take(request.PageSize)
.ToList();
var tenantIds = paged.Select(x => x.TenantId).Distinct().ToArray();
var tenants = await tenantRepository.FindByIdsAsync(tenantIds, cancellationToken);
var tenantLookup = tenants.ToDictionary(x => x.Id, x => x.Name);
var items = paged.Select(merchant => new MerchantReviewListItemDto
{
Id = merchant.Id,
TenantId = merchant.TenantId,
TenantName = tenantLookup.TryGetValue(merchant.TenantId, out var name) ? name : null,
Name = merchant.BrandName,
OperatingMode = merchant.OperatingMode,
LicenseNumber = merchant.BusinessLicenseNumber,
Status = merchant.Status,
ClaimedByName = merchant.ClaimedByName,
ClaimedAt = merchant.ClaimedAt,
ClaimExpiresAt = merchant.ClaimExpiresAt,
CreatedAt = merchant.CreatedAt
}).ToList();
return new PagedResult<MerchantReviewListItemDto>(items, request.Page, request.PageSize, total);
}
}

View File

@@ -1,64 +0,0 @@
using MediatR;
using TakeoutSaaS.Application.App.Merchants.Commands;
using TakeoutSaaS.Application.App.Merchants.Dto;
using TakeoutSaaS.Domain.Merchants.Entities;
using TakeoutSaaS.Domain.Merchants.Enums;
using TakeoutSaaS.Domain.Merchants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Security;
namespace TakeoutSaaS.Application.App.Merchants.Handlers;
/// <summary>
/// 释放审核领取处理器。
/// </summary>
public sealed class ReleaseClaimHandler(
IMerchantRepository merchantRepository,
ICurrentUserAccessor currentUserAccessor)
: IRequestHandler<ReleaseClaimCommand, ClaimInfoDto?>
{
/// <inheritdoc />
public async Task<ClaimInfoDto?> Handle(ReleaseClaimCommand request, CancellationToken cancellationToken)
{
var merchant = await merchantRepository.FindByIdAsync(request.MerchantId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "商户不存在");
if (!merchant.ClaimedBy.HasValue)
{
return null;
}
var now = DateTime.UtcNow;
var claimExpired = merchant.ClaimExpiresAt.HasValue && merchant.ClaimExpiresAt <= now;
if (!claimExpired && merchant.ClaimedBy != currentUserAccessor.UserId)
{
throw new BusinessException(ErrorCodes.Conflict, $"该审核已被 {merchant.ClaimedByName} 领取");
}
var actorName = currentUserAccessor.IsAuthenticated
? $"user:{currentUserAccessor.UserId}"
: "system";
merchant.ClaimedBy = null;
merchant.ClaimedByName = null;
merchant.ClaimedAt = null;
merchant.ClaimExpiresAt = null;
await merchantRepository.UpdateMerchantAsync(merchant, cancellationToken);
await merchantRepository.AddAuditLogAsync(new MerchantAuditLog
{
TenantId = merchant.TenantId,
MerchantId = merchant.Id,
Action = MerchantAuditAction.ReviewReleased,
Title = "释放审核",
Description = $"释放人:{actorName}",
OperatorId = currentUserAccessor.UserId == 0 ? null : currentUserAccessor.UserId,
OperatorName = actorName
}, cancellationToken);
await merchantRepository.SaveChangesAsync(cancellationToken);
return null;
}
}

View File

@@ -1,108 +0,0 @@
using MediatR;
using TakeoutSaaS.Application.App.Merchants.Commands;
using TakeoutSaaS.Application.App.Merchants.Dto;
using TakeoutSaaS.Domain.Merchants.Entities;
using TakeoutSaaS.Domain.Merchants.Enums;
using TakeoutSaaS.Domain.Merchants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Security;
namespace TakeoutSaaS.Application.App.Merchants.Handlers;
/// <summary>
/// 商户审核处理器。
/// </summary>
public sealed class ReviewMerchantCommandHandler(
IMerchantRepository merchantRepository,
ICurrentUserAccessor currentUserAccessor)
: IRequestHandler<ReviewMerchantCommand, MerchantDto>
{
/// <summary>
/// 审核商户。
/// </summary>
/// <param name="request">审核命令。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>商户 DTO。</returns>
public async Task<MerchantDto> Handle(ReviewMerchantCommand request, CancellationToken cancellationToken)
{
// 1. 读取商户
var merchant = await merchantRepository.FindByIdAsync(request.MerchantId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "商户不存在");
var now = DateTime.UtcNow;
if (!merchant.ClaimedBy.HasValue || !merchant.ClaimExpiresAt.HasValue || merchant.ClaimExpiresAt <= now)
{
throw new BusinessException(ErrorCodes.Conflict, "请先领取审核");
}
if (merchant.ClaimedBy != currentUserAccessor.UserId)
{
throw new BusinessException(ErrorCodes.Conflict, $"该审核已被 {merchant.ClaimedByName} 领取");
}
if (merchant.Status != MerchantStatus.Pending)
{
throw new BusinessException(ErrorCodes.Conflict, "商户不在待审核状态");
}
// 2. 更新审核状态
merchant.Status = request.Approve ? MerchantStatus.Approved : MerchantStatus.Rejected;
merchant.ReviewRemarks = request.Remarks;
merchant.LastReviewedAt = now;
merchant.LastReviewedBy = ResolveOperatorId();
if (request.Approve && merchant.JoinedAt == null)
{
merchant.JoinedAt = now;
}
if (request.Approve)
{
merchant.IsFrozen = false;
merchant.FrozenReason = null;
merchant.FrozenAt = null;
merchant.ApprovedAt = now;
merchant.ApprovedBy = ResolveOperatorId();
}
else
{
merchant.IsFrozen = false;
merchant.FrozenReason = null;
merchant.FrozenAt = null;
}
merchant.ClaimedBy = null;
merchant.ClaimedByName = null;
merchant.ClaimedAt = null;
merchant.ClaimExpiresAt = null;
// 3. 持久化与审计
await merchantRepository.UpdateMerchantAsync(merchant, cancellationToken);
await merchantRepository.AddAuditLogAsync(new MerchantAuditLog
{
TenantId = merchant.TenantId,
MerchantId = merchant.Id,
Action = request.Approve ? MerchantAuditAction.ReviewApproved : MerchantAuditAction.ReviewRejected,
Title = request.Approve ? "商户审核通过" : "商户审核驳回",
Description = request.Remarks,
OperatorId = ResolveOperatorId(),
OperatorName = ResolveOperatorName()
}, cancellationToken);
await merchantRepository.SaveChangesAsync(cancellationToken);
// 4. 返回 DTO
return MerchantMapping.ToDto(merchant);
}
private long? ResolveOperatorId()
{
var id = currentUserAccessor.UserId;
return id == 0 ? null : id;
}
private string ResolveOperatorName()
{
var id = currentUserAccessor.UserId;
return id == 0 ? "system" : $"user:{id}";
}
}

View File

@@ -1,76 +0,0 @@
using MediatR;
using TakeoutSaaS.Application.App.Merchants.Commands;
using TakeoutSaaS.Application.App.Merchants.Dto;
using TakeoutSaaS.Domain.Merchants.Entities;
using TakeoutSaaS.Domain.Merchants.Enums;
using TakeoutSaaS.Domain.Merchants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Security;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Merchants.Handlers;
/// <summary>
/// 审核证照处理器。
/// </summary>
public sealed class ReviewMerchantDocumentCommandHandler(
IMerchantRepository merchantRepository,
ITenantProvider tenantProvider,
ICurrentUserAccessor currentUserAccessor)
: IRequestHandler<ReviewMerchantDocumentCommand, MerchantDocumentDto>
{
/// <summary>
/// 审核商户证照。
/// </summary>
/// <param name="request">审核命令。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>证照 DTO。</returns>
public async Task<MerchantDocumentDto> Handle(ReviewMerchantDocumentCommand request, CancellationToken cancellationToken)
{
// 1. 读取证照
var tenantId = tenantProvider.GetCurrentTenantId();
var document = await merchantRepository.FindDocumentByIdAsync(request.MerchantId, tenantId, request.DocumentId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "证照不存在");
// 2. 若状态无变化且备注相同,直接返回
var targetStatus = request.Approve ? MerchantDocumentStatus.Approved : MerchantDocumentStatus.Rejected;
if (document.Status == targetStatus && document.Remarks == request.Remarks)
{
return MerchantMapping.ToDto(document);
}
// 3. 更新状态
document.Status = targetStatus;
document.Remarks = request.Remarks;
// 4. 持久化与审计
await merchantRepository.UpdateDocumentAsync(document, cancellationToken);
await merchantRepository.AddAuditLogAsync(new MerchantAuditLog
{
TenantId = tenantId,
MerchantId = document.MerchantId,
Action = MerchantAuditAction.DocumentReviewed,
Title = request.Approve ? "证照审核通过" : "证照审核驳回",
Description = request.Remarks,
OperatorId = ResolveOperatorId(),
OperatorName = ResolveOperatorName()
}, cancellationToken);
await merchantRepository.SaveChangesAsync(cancellationToken);
// 5. 返回 DTO
return MerchantMapping.ToDto(document);
}
private long? ResolveOperatorId()
{
var id = currentUserAccessor.UserId;
return id == 0 ? null : id;
}
private string ResolveOperatorName()
{
var id = currentUserAccessor.UserId;
return id == 0 ? "system" : $"user:{id}";
}
}

View File

@@ -1,61 +0,0 @@
using MediatR;
using TakeoutSaaS.Application.App.Merchants.Commands;
using TakeoutSaaS.Domain.Merchants.Entities;
using TakeoutSaaS.Domain.Merchants.Enums;
using TakeoutSaaS.Domain.Merchants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Security;
namespace TakeoutSaaS.Application.App.Merchants.Handlers;
/// <summary>
/// 撤销商户审核处理器。
/// </summary>
public sealed class RevokeMerchantReviewHandler(
IMerchantRepository merchantRepository,
ICurrentUserAccessor currentUserAccessor)
: IRequestHandler<RevokeMerchantReviewCommand>
{
/// <inheritdoc />
public async Task Handle(RevokeMerchantReviewCommand request, CancellationToken cancellationToken)
{
var merchant = await merchantRepository.FindByIdAsync(request.MerchantId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "商户不存在");
if (merchant.Status != MerchantStatus.Approved)
{
throw new BusinessException(ErrorCodes.Conflict, "商户不在已审核状态");
}
var now = DateTime.UtcNow;
var actorName = currentUserAccessor.IsAuthenticated
? $"user:{currentUserAccessor.UserId}"
: "system";
merchant.Status = MerchantStatus.Pending;
merchant.IsFrozen = true;
merchant.FrozenReason = request.Reason;
merchant.FrozenAt = now;
merchant.ReviewRemarks = request.Reason;
merchant.LastReviewedAt = now;
merchant.LastReviewedBy = currentUserAccessor.UserId == 0 ? null : currentUserAccessor.UserId;
merchant.ClaimedBy = null;
merchant.ClaimedByName = null;
merchant.ClaimedAt = null;
merchant.ClaimExpiresAt = null;
await merchantRepository.UpdateMerchantAsync(merchant, cancellationToken);
await merchantRepository.AddAuditLogAsync(new MerchantAuditLog
{
TenantId = merchant.TenantId,
MerchantId = merchant.Id,
Action = MerchantAuditAction.ReviewRevoked,
Title = "撤销审核",
Description = request.Reason,
OperatorId = currentUserAccessor.UserId == 0 ? null : currentUserAccessor.UserId,
OperatorName = actorName
}, cancellationToken);
await merchantRepository.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -1,9 +0,0 @@
using MediatR;
using TakeoutSaaS.Application.App.Merchants.Dto;
namespace TakeoutSaaS.Application.App.Merchants.Queries;
/// <summary>
/// 获取商户审核领取信息查询。
/// </summary>
public sealed record GetMerchantReviewClaimQuery(long MerchantId) : IRequest<ClaimInfoDto?>;

View File

@@ -1,37 +0,0 @@
using MediatR;
using TakeoutSaaS.Application.App.Merchants.Dto;
using TakeoutSaaS.Domain.Common.Enums;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.Merchants.Queries;
/// <summary>
/// 待审核商户列表查询。
/// </summary>
public sealed class GetPendingReviewListQuery : IRequest<PagedResult<MerchantReviewListItemDto>>
{
/// <summary>
/// 关键词(商户名称/营业执照号)。
/// </summary>
public string? Keyword { get; init; }
/// <summary>
/// 经营模式筛选。
/// </summary>
public OperatingMode? OperatingMode { get; init; }
/// <summary>
/// 租户筛选。
/// </summary>
public long? TenantId { get; init; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 20;
}

View File

@@ -1,24 +0,0 @@
using FluentValidation;
using TakeoutSaaS.Application.App.Merchants.Commands;
namespace TakeoutSaaS.Application.App.Merchants.Validators;
/// <summary>
/// 商户审核命令验证器。
/// </summary>
public sealed class ReviewMerchantValidator : AbstractValidator<ReviewMerchantCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public ReviewMerchantValidator()
{
RuleFor(x => x.MerchantId).GreaterThan(0);
RuleFor(x => x.Remarks)
.NotEmpty()
.When(x => !x.Approve);
RuleFor(x => x.Remarks)
.MaximumLength(500)
.When(x => !string.IsNullOrWhiteSpace(x.Remarks));
}
}

View File

@@ -1,20 +0,0 @@
using MediatR;
using TakeoutSaaS.Application.App.StoreAudits.Dto;
namespace TakeoutSaaS.Application.App.StoreAudits.Commands;
/// <summary>
/// 审核通过命令。
/// </summary>
public sealed record ApproveStoreCommand : IRequest<StoreAuditActionResultDto>
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 审核备注。
/// </summary>
public string? Remark { get; init; }
}

View File

@@ -1,25 +0,0 @@
using MediatR;
using TakeoutSaaS.Application.App.StoreAudits.Dto;
namespace TakeoutSaaS.Application.App.StoreAudits.Commands;
/// <summary>
/// 强制关闭门店命令。
/// </summary>
public sealed record ForceCloseStoreCommand : IRequest<StoreAuditActionResultDto>
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 关闭原因。
/// </summary>
public string Reason { get; init; } = string.Empty;
/// <summary>
/// 备注。
/// </summary>
public string? Remark { get; init; }
}

View File

@@ -1,30 +0,0 @@
using MediatR;
using TakeoutSaaS.Application.App.StoreAudits.Dto;
namespace TakeoutSaaS.Application.App.StoreAudits.Commands;
/// <summary>
/// 审核驳回命令。
/// </summary>
public sealed record RejectStoreCommand : IRequest<StoreAuditActionResultDto>
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 驳回原因 ID。
/// </summary>
public long RejectionReasonId { get; init; }
/// <summary>
/// 驳回原因补充说明。
/// </summary>
public string? RejectionReasonText { get; init; }
/// <summary>
/// 审核备注。
/// </summary>
public string? Remark { get; init; }
}

View File

@@ -1,20 +0,0 @@
using MediatR;
using TakeoutSaaS.Application.App.StoreAudits.Dto;
namespace TakeoutSaaS.Application.App.StoreAudits.Commands;
/// <summary>
/// 解除强制关闭命令。
/// </summary>
public sealed record ReopenStoreCommand : IRequest<StoreAuditActionResultDto>
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 备注。
/// </summary>
public string? Remark { get; init; }
}

View File

@@ -1,84 +0,0 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Stores.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.StoreAudits.Dto;
/// <summary>
/// 待审核门店 DTO。
/// </summary>
public sealed record PendingStoreAuditDto
{
/// <summary>
/// 门店 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long StoreId { get; init; }
/// <summary>
/// 门店名称。
/// </summary>
public string StoreName { get; init; } = string.Empty;
/// <summary>
/// 门店编码。
/// </summary>
public string StoreCode { get; init; } = string.Empty;
/// <summary>
/// 租户 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 租户名称。
/// </summary>
public string TenantName { get; init; } = string.Empty;
/// <summary>
/// 商户 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long MerchantId { get; init; }
/// <summary>
/// 商户名称。
/// </summary>
public string MerchantName { get; init; } = string.Empty;
/// <summary>
/// 门头招牌图。
/// </summary>
public string? SignboardImageUrl { get; init; }
/// <summary>
/// 完整地址。
/// </summary>
public string FullAddress { get; init; } = string.Empty;
/// <summary>
/// 主体类型。
/// </summary>
public StoreOwnershipType OwnershipType { get; init; }
/// <summary>
/// 提交时间。
/// </summary>
public DateTime? SubmittedAt { get; init; }
/// <summary>
/// 等待天数。
/// </summary>
public int WaitingDays { get; init; }
/// <summary>
/// 是否超时。
/// </summary>
public bool IsOverdue { get; init; }
/// <summary>
/// 资质数量。
/// </summary>
public int QualificationCount { get; init; }
}

View File

@@ -1,37 +0,0 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Stores.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.StoreAudits.Dto;
/// <summary>
/// 审核/风控操作结果 DTO。
/// </summary>
public sealed record StoreAuditActionResultDto
{
/// <summary>
/// 门店 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long StoreId { get; init; }
/// <summary>
/// 审核状态。
/// </summary>
public StoreAuditStatus AuditStatus { get; init; }
/// <summary>
/// 经营状态。
/// </summary>
public StoreBusinessStatus BusinessStatus { get; init; }
/// <summary>
/// 驳回原因。
/// </summary>
public string? RejectionReason { get; init; }
/// <summary>
/// 提示信息。
/// </summary>
public string? Message { get; init; }
}

View File

@@ -1,27 +0,0 @@
namespace TakeoutSaaS.Application.App.StoreAudits.Dto;
/// <summary>
/// 审核统计趋势项。
/// </summary>
public sealed record StoreAuditDailyTrendDto
{
/// <summary>
/// 日期。
/// </summary>
public DateOnly Date { get; init; }
/// <summary>
/// 提交数量。
/// </summary>
public int Submitted { get; init; }
/// <summary>
/// 通过数量。
/// </summary>
public int Approved { get; init; }
/// <summary>
/// 驳回数量。
/// </summary>
public int Rejected { get; init; }
}

View File

@@ -1,34 +0,0 @@
using TakeoutSaaS.Application.App.Stores.Dto;
namespace TakeoutSaaS.Application.App.StoreAudits.Dto;
/// <summary>
/// 门店审核详情 DTO。
/// </summary>
public sealed record StoreAuditDetailDto
{
/// <summary>
/// 门店信息。
/// </summary>
public StoreAuditStoreDto Store { get; init; } = new();
/// <summary>
/// 租户信息。
/// </summary>
public StoreAuditTenantDto Tenant { get; init; } = new();
/// <summary>
/// 商户信息。
/// </summary>
public StoreAuditMerchantDto Merchant { get; init; } = new();
/// <summary>
/// 资质列表。
/// </summary>
public IReadOnlyList<StoreQualificationDto> Qualifications { get; init; } = [];
/// <summary>
/// 审核记录。
/// </summary>
public IReadOnlyList<StoreAuditRecordDto> AuditHistory { get; init; } = [];
}

View File

@@ -1,31 +0,0 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.StoreAudits.Dto;
/// <summary>
/// 门店审核详情 - 商户信息。
/// </summary>
public sealed record StoreAuditMerchantDto
{
/// <summary>
/// 商户 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 商户名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 法人或主体名称。
/// </summary>
public string? LegalName { get; init; }
/// <summary>
/// 统一社会信用代码。
/// </summary>
public string? CreditCode { get; init; }
}

View File

@@ -1,69 +0,0 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Stores.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.StoreAudits.Dto;
/// <summary>
/// 门店审核记录 DTO。
/// </summary>
public sealed record StoreAuditRecordDto
{
/// <summary>
/// 记录 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 审核动作。
/// </summary>
public StoreAuditAction Action { get; init; }
/// <summary>
/// 动作名称。
/// </summary>
public string ActionName { get; init; } = string.Empty;
/// <summary>
/// 操作人 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long? OperatorId { get; init; }
/// <summary>
/// 操作人名称。
/// </summary>
public string OperatorName { get; init; } = string.Empty;
/// <summary>
/// 操作前状态。
/// </summary>
public StoreAuditStatus? PreviousStatus { get; init; }
/// <summary>
/// 操作后状态。
/// </summary>
public StoreAuditStatus NewStatus { get; init; }
/// <summary>
/// 驳回理由 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long? RejectionReasonId { get; init; }
/// <summary>
/// 驳回理由。
/// </summary>
public string? RejectionReasonText { get; init; }
/// <summary>
/// 备注。
/// </summary>
public string? Remark { get; init; }
/// <summary>
/// 创建时间。
/// </summary>
public DateTime CreatedAt { get; init; }
}

View File

@@ -1,37 +0,0 @@
namespace TakeoutSaaS.Application.App.StoreAudits.Dto;
/// <summary>
/// 审核统计 DTO。
/// </summary>
public sealed record StoreAuditStatisticsDto
{
/// <summary>
/// 待审核数量。
/// </summary>
public int PendingCount { get; init; }
/// <summary>
/// 超时数量。
/// </summary>
public int OverdueCount { get; init; }
/// <summary>
/// 审核通过数量。
/// </summary>
public int ApprovedCount { get; init; }
/// <summary>
/// 审核驳回数量。
/// </summary>
public int RejectedCount { get; init; }
/// <summary>
/// 平均处理时长(小时)。
/// </summary>
public double AvgProcessingHours { get; init; }
/// <summary>
/// 每日趋势。
/// </summary>
public IReadOnlyList<StoreAuditDailyTrendDto> DailyTrend { get; init; } = [];
}

View File

@@ -1,82 +0,0 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Stores.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.StoreAudits.Dto;
/// <summary>
/// 门店审核详情 - 门店信息。
/// </summary>
public sealed record StoreAuditStoreDto
{
/// <summary>
/// 门店 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 门店名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 门店编码。
/// </summary>
public string Code { get; init; } = string.Empty;
/// <summary>
/// 联系电话。
/// </summary>
public string? Phone { get; init; }
/// <summary>
/// 门头招牌图。
/// </summary>
public string? SignboardImageUrl { get; init; }
/// <summary>
/// 省份。
/// </summary>
public string? Province { get; init; }
/// <summary>
/// 城市。
/// </summary>
public string? City { get; init; }
/// <summary>
/// 区县。
/// </summary>
public string? District { get; init; }
/// <summary>
/// 详细地址。
/// </summary>
public string? Address { get; init; }
/// <summary>
/// 经度。
/// </summary>
public double? Longitude { get; init; }
/// <summary>
/// 纬度。
/// </summary>
public double? Latitude { get; init; }
/// <summary>
/// 主体类型。
/// </summary>
public StoreOwnershipType OwnershipType { get; init; }
/// <summary>
/// 审核状态。
/// </summary>
public StoreAuditStatus AuditStatus { get; init; }
/// <summary>
/// 提交时间。
/// </summary>
public DateTime? SubmittedAt { get; init; }
}

View File

@@ -1,31 +0,0 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.StoreAudits.Dto;
/// <summary>
/// 门店审核详情 - 租户信息。
/// </summary>
public sealed record StoreAuditTenantDto
{
/// <summary>
/// 租户 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 租户名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 联系人。
/// </summary>
public string? ContactName { get; init; }
/// <summary>
/// 联系电话。
/// </summary>
public string? ContactPhone { get; init; }
}

View File

@@ -1,158 +0,0 @@
using System.Data;
using System.Data.Common;
using MediatR;
using Microsoft.Extensions.Logging;
using TakeoutSaaS.Application.App.StoreAudits.Commands;
using TakeoutSaaS.Application.App.StoreAudits.Dto;
using TakeoutSaaS.Domain.Stores.Entities;
using TakeoutSaaS.Domain.Stores.Enums;
using TakeoutSaaS.Domain.Stores.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Data;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Security;
namespace TakeoutSaaS.Application.App.StoreAudits.Handlers;
/// <summary>
/// 审核通过处理器。
/// </summary>
public sealed class ApproveStoreCommandHandler(
IStoreRepository storeRepository,
IDapperExecutor dapperExecutor,
ICurrentUserAccessor currentUserAccessor,
ILogger<ApproveStoreCommandHandler> logger)
: IRequestHandler<ApproveStoreCommand, StoreAuditActionResultDto>
{
/// <inheritdoc />
public async Task<StoreAuditActionResultDto> Handle(ApproveStoreCommand request, CancellationToken cancellationToken)
{
// 1. 获取门店快照
var snapshot = await QueryStoreSnapshotAsync(request.StoreId, cancellationToken);
if (!snapshot.HasValue)
{
throw new BusinessException(ErrorCodes.NotFound, "门店不存在");
}
// 1.1 (空行后) 校验审核状态
if (snapshot.Value.AuditStatus != StoreAuditStatus.Pending)
{
throw new BusinessException(ErrorCodes.Conflict, "门店不处于待审核状态");
}
// 2. (空行后) 获取门店实体
var store = await storeRepository.FindByIdAsync(request.StoreId, snapshot.Value.TenantId, cancellationToken);
if (store is null)
{
throw new BusinessException(ErrorCodes.NotFound, "门店不存在");
}
// 3. (空行后) 更新状态并记录审核
var previousStatus = store.AuditStatus;
var now = DateTime.UtcNow;
store.AuditStatus = StoreAuditStatus.Activated;
store.BusinessStatus = StoreBusinessStatus.Resting;
store.ActivatedAt ??= now;
store.RejectionReason = null;
store.ClosureReason = null;
store.ClosureReasonText = null;
await storeRepository.UpdateStoreAsync(store, cancellationToken);
await storeRepository.AddAuditRecordAsync(new StoreAuditRecord
{
StoreId = store.Id,
Action = StoreAuditAction.Approve,
PreviousStatus = previousStatus,
NewStatus = store.AuditStatus,
OperatorId = ResolveOperatorId(),
OperatorName = ResolveOperatorName(),
Remarks = request.Remark
}, cancellationToken);
await storeRepository.SaveChangesAsync(cancellationToken);
logger.LogInformation("门店 {StoreId} 审核通过", store.Id);
// 4. (空行后) 返回结果
return new StoreAuditActionResultDto
{
StoreId = store.Id,
AuditStatus = store.AuditStatus,
BusinessStatus = store.BusinessStatus,
Message = "门店已激活"
};
}
private async Task<(long TenantId, StoreAuditStatus AuditStatus, StoreBusinessStatus BusinessStatus)?> QueryStoreSnapshotAsync(
long storeId,
CancellationToken cancellationToken)
{
// 1. 查询门店基础字段
return await dapperExecutor.QueryAsync<(long TenantId, StoreAuditStatus AuditStatus, StoreBusinessStatus BusinessStatus)?>(
DatabaseConstants.AppDataSource,
DatabaseConnectionRole.Read,
async (connection, token) =>
{
await using var command = CreateCommand(
connection,
BuildStoreSnapshotSql(),
[
("storeId", storeId)
]);
// 1.1 (空行后) 执行查询
await using var reader = await command.ExecuteReaderAsync(token);
if (!await reader.ReadAsync(token))
{
return null;
}
// 1.2 (空行后) 返回快照
return (
reader.GetInt64(reader.GetOrdinal("TenantId")),
(StoreAuditStatus)reader.GetInt32(reader.GetOrdinal("AuditStatus")),
(StoreBusinessStatus)reader.GetInt32(reader.GetOrdinal("BusinessStatus")));
},
cancellationToken);
}
private long? ResolveOperatorId()
{
var id = currentUserAccessor.UserId;
return id == 0 ? null : id;
}
private string ResolveOperatorName()
{
var id = currentUserAccessor.UserId;
return id == 0 ? "system" : $"user:{id}";
}
private static string BuildStoreSnapshotSql()
{
return """
select
s."TenantId",
s."AuditStatus",
s."BusinessStatus"
from public.stores s
where s."DeletedAt" is null
and s."Id" = @storeId;
""";
}
private static DbCommand CreateCommand(IDbConnection connection, string sql, (string Name, object? Value)[] parameters)
{
var command = connection.CreateCommand();
command.CommandText = sql;
// 1. (空行后) 绑定参数
foreach (var (name, value) in parameters)
{
var p = command.CreateParameter();
p.ParameterName = name;
p.Value = value ?? DBNull.Value;
command.Parameters.Add(p);
}
return (DbCommand)command;
}
}

View File

@@ -1,161 +0,0 @@
using System.Data;
using System.Data.Common;
using MediatR;
using Microsoft.Extensions.Logging;
using TakeoutSaaS.Application.App.StoreAudits.Commands;
using TakeoutSaaS.Application.App.StoreAudits.Dto;
using TakeoutSaaS.Domain.Stores.Entities;
using TakeoutSaaS.Domain.Stores.Enums;
using TakeoutSaaS.Domain.Stores.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Data;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Security;
namespace TakeoutSaaS.Application.App.StoreAudits.Handlers;
/// <summary>
/// 强制关闭门店处理器。
/// </summary>
public sealed class ForceCloseStoreCommandHandler(
IStoreRepository storeRepository,
IDapperExecutor dapperExecutor,
ICurrentUserAccessor currentUserAccessor,
ILogger<ForceCloseStoreCommandHandler> logger)
: IRequestHandler<ForceCloseStoreCommand, StoreAuditActionResultDto>
{
/// <inheritdoc />
public async Task<StoreAuditActionResultDto> Handle(ForceCloseStoreCommand request, CancellationToken cancellationToken)
{
// 1. 获取门店快照
var snapshot = await QueryStoreSnapshotAsync(request.StoreId, cancellationToken);
if (!snapshot.HasValue)
{
throw new BusinessException(ErrorCodes.NotFound, "门店不存在");
}
// 1.1 (空行后) 校验审核与经营状态
if (snapshot.Value.AuditStatus != StoreAuditStatus.Activated)
{
throw new BusinessException(ErrorCodes.Conflict, "门店未激活,无法强制关闭");
}
if (snapshot.Value.BusinessStatus == StoreBusinessStatus.ForceClosed)
{
throw new BusinessException(ErrorCodes.Conflict, "门店已处于强制关闭状态");
}
// 2. (空行后) 获取门店实体
var store = await storeRepository.FindByIdAsync(request.StoreId, snapshot.Value.TenantId, cancellationToken);
if (store is null)
{
throw new BusinessException(ErrorCodes.NotFound, "门店不存在");
}
// 3. (空行后) 更新状态并记录风控
var now = DateTime.UtcNow;
store.BusinessStatus = StoreBusinessStatus.ForceClosed;
store.ClosureReason = StoreClosureReason.PlatformSuspended;
store.ClosureReasonText = request.Reason;
store.ForceCloseReason = request.Reason;
store.ForceClosedAt = now;
await storeRepository.UpdateStoreAsync(store, cancellationToken);
await storeRepository.AddAuditRecordAsync(new StoreAuditRecord
{
StoreId = store.Id,
Action = StoreAuditAction.ForceClose,
PreviousStatus = store.AuditStatus,
NewStatus = store.AuditStatus,
OperatorId = ResolveOperatorId(),
OperatorName = ResolveOperatorName(),
Remarks = request.Remark
}, cancellationToken);
await storeRepository.SaveChangesAsync(cancellationToken);
logger.LogInformation("门店 {StoreId} 强制关闭", store.Id);
// 4. (空行后) 返回结果
return new StoreAuditActionResultDto
{
StoreId = store.Id,
AuditStatus = store.AuditStatus,
BusinessStatus = store.BusinessStatus,
Message = "门店已强制关闭"
};
}
private async Task<(long TenantId, StoreAuditStatus AuditStatus, StoreBusinessStatus BusinessStatus)?> QueryStoreSnapshotAsync(
long storeId,
CancellationToken cancellationToken)
{
// 1. 查询门店基础字段
return await dapperExecutor.QueryAsync<(long TenantId, StoreAuditStatus AuditStatus, StoreBusinessStatus BusinessStatus)?>(
DatabaseConstants.AppDataSource,
DatabaseConnectionRole.Read,
async (connection, token) =>
{
await using var command = CreateCommand(
connection,
BuildStoreSnapshotSql(),
[
("storeId", storeId)
]);
// 1.1 (空行后) 执行查询
await using var reader = await command.ExecuteReaderAsync(token);
if (!await reader.ReadAsync(token))
{
return null;
}
// 1.2 (空行后) 返回快照
return (
reader.GetInt64(reader.GetOrdinal("TenantId")),
(StoreAuditStatus)reader.GetInt32(reader.GetOrdinal("AuditStatus")),
(StoreBusinessStatus)reader.GetInt32(reader.GetOrdinal("BusinessStatus")));
},
cancellationToken);
}
private long? ResolveOperatorId()
{
var id = currentUserAccessor.UserId;
return id == 0 ? null : id;
}
private string ResolveOperatorName()
{
var id = currentUserAccessor.UserId;
return id == 0 ? "system" : $"user:{id}";
}
private static string BuildStoreSnapshotSql()
{
return """
select
s."TenantId",
s."AuditStatus",
s."BusinessStatus"
from public.stores s
where s."DeletedAt" is null
and s."Id" = @storeId;
""";
}
private static DbCommand CreateCommand(IDbConnection connection, string sql, (string Name, object? Value)[] parameters)
{
var command = connection.CreateCommand();
command.CommandText = sql;
// 1. (空行后) 绑定参数
foreach (var (name, value) in parameters)
{
var p = command.CreateParameter();
p.ParameterName = name;
p.Value = value ?? DBNull.Value;
command.Parameters.Add(p);
}
return (DbCommand)command;
}
}

View File

@@ -1,322 +0,0 @@
using System.Data;
using System.Data.Common;
using MediatR;
using TakeoutSaaS.Application.App.StoreAudits;
using TakeoutSaaS.Application.App.StoreAudits.Dto;
using TakeoutSaaS.Application.App.StoreAudits.Queries;
using TakeoutSaaS.Application.App.Stores.Dto;
using TakeoutSaaS.Domain.Stores.Enums;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Data;
namespace TakeoutSaaS.Application.App.StoreAudits.Handlers;
/// <summary>
/// 门店审核详情查询处理器。
/// </summary>
public sealed class GetStoreAuditDetailQueryHandler(
IDapperExecutor dapperExecutor)
: IRequestHandler<GetStoreAuditDetailQuery, StoreAuditDetailDto?>
{
/// <inheritdoc />
public async Task<StoreAuditDetailDto?> Handle(GetStoreAuditDetailQuery request, CancellationToken cancellationToken)
{
// 1. 查询门店与主体信息
return await dapperExecutor.QueryAsync(
DatabaseConstants.AppDataSource,
DatabaseConnectionRole.Read,
async (connection, token) =>
{
// 1.1 查询门店基础信息
await using var storeCommand = CreateCommand(
connection,
BuildStoreSql(),
[
("storeId", request.StoreId)
]);
await using var reader = await storeCommand.ExecuteReaderAsync(token);
if (!await reader.ReadAsync(token))
{
return null;
}
// 1.2 (空行后) 映射门店信息
var store = new StoreAuditStoreDto
{
Id = reader.GetInt64(reader.GetOrdinal("StoreId")),
Name = reader.GetString(reader.GetOrdinal("StoreName")),
Code = reader.GetString(reader.GetOrdinal("StoreCode")),
Phone = reader.IsDBNull(reader.GetOrdinal("Phone")) ? null : reader.GetString(reader.GetOrdinal("Phone")),
SignboardImageUrl = reader.IsDBNull(reader.GetOrdinal("SignboardImageUrl"))
? null
: reader.GetString(reader.GetOrdinal("SignboardImageUrl")),
Province = reader.IsDBNull(reader.GetOrdinal("Province")) ? null : reader.GetString(reader.GetOrdinal("Province")),
City = reader.IsDBNull(reader.GetOrdinal("City")) ? null : reader.GetString(reader.GetOrdinal("City")),
District = reader.IsDBNull(reader.GetOrdinal("District")) ? null : reader.GetString(reader.GetOrdinal("District")),
Address = reader.IsDBNull(reader.GetOrdinal("Address")) ? null : reader.GetString(reader.GetOrdinal("Address")),
Longitude = reader.IsDBNull(reader.GetOrdinal("Longitude")) ? null : reader.GetDouble(reader.GetOrdinal("Longitude")),
Latitude = reader.IsDBNull(reader.GetOrdinal("Latitude")) ? null : reader.GetDouble(reader.GetOrdinal("Latitude")),
OwnershipType = (StoreOwnershipType)reader.GetInt32(reader.GetOrdinal("OwnershipType")),
AuditStatus = (StoreAuditStatus)reader.GetInt32(reader.GetOrdinal("AuditStatus")),
SubmittedAt = reader.IsDBNull(reader.GetOrdinal("SubmittedAt"))
? null
: reader.GetDateTime(reader.GetOrdinal("SubmittedAt"))
};
// 1.3 (空行后) 映射租户信息
var tenant = new StoreAuditTenantDto
{
Id = reader.GetInt64(reader.GetOrdinal("TenantId")),
Name = reader.GetString(reader.GetOrdinal("TenantName")),
ContactName = reader.IsDBNull(reader.GetOrdinal("TenantContactName"))
? null
: reader.GetString(reader.GetOrdinal("TenantContactName")),
ContactPhone = reader.IsDBNull(reader.GetOrdinal("TenantContactPhone"))
? null
: reader.GetString(reader.GetOrdinal("TenantContactPhone"))
};
// 1.4 (空行后) 映射商户信息
var merchant = new StoreAuditMerchantDto
{
Id = reader.GetInt64(reader.GetOrdinal("MerchantId")),
Name = reader.GetString(reader.GetOrdinal("MerchantName")),
LegalName = reader.IsDBNull(reader.GetOrdinal("MerchantLegalName"))
? null
: reader.GetString(reader.GetOrdinal("MerchantLegalName")),
CreditCode = reader.IsDBNull(reader.GetOrdinal("MerchantCreditCode"))
? null
: reader.GetString(reader.GetOrdinal("MerchantCreditCode"))
};
// 2. (空行后) 查询资质列表
var qualifications = await QueryQualificationsAsync(connection, request.StoreId, token);
// 3. (空行后) 查询审核记录
var auditHistory = await QueryAuditRecordsAsync(connection, request.StoreId, token);
// 4. (空行后) 组装结果
return new StoreAuditDetailDto
{
Store = store,
Tenant = tenant,
Merchant = merchant,
Qualifications = qualifications,
AuditHistory = auditHistory
};
},
cancellationToken);
}
private static async Task<IReadOnlyList<StoreQualificationDto>> QueryQualificationsAsync(
IDbConnection connection,
long storeId,
CancellationToken cancellationToken)
{
// 1. 查询门店资质
await using var command = CreateCommand(
connection,
BuildQualificationSql(),
[
("storeId", storeId)
]);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
var items = new List<StoreQualificationDto>();
if (!reader.HasRows)
{
return items;
}
// 2. (空行后) 映射资质 DTO
var today = DateOnly.FromDateTime(DateTime.UtcNow);
while (await reader.ReadAsync(cancellationToken))
{
DateOnly? expiresAt = reader.IsDBNull(reader.GetOrdinal("ExpiresAt"))
? null
: DateOnly.FromDateTime(reader.GetDateTime(reader.GetOrdinal("ExpiresAt")));
int? daysUntilExpiry = expiresAt.HasValue
? expiresAt.Value.DayNumber - today.DayNumber
: null;
var isExpired = expiresAt.HasValue && expiresAt.Value < today;
var isExpiringSoon = expiresAt.HasValue
&& expiresAt.Value >= today
&& expiresAt.Value <= today.AddDays(30);
// 2.1 (空行后) 写入列表
items.Add(new StoreQualificationDto
{
Id = reader.GetInt64(reader.GetOrdinal("Id")),
StoreId = reader.GetInt64(reader.GetOrdinal("StoreId")),
QualificationType = (StoreQualificationType)reader.GetInt32(reader.GetOrdinal("QualificationType")),
FileUrl = reader.GetString(reader.GetOrdinal("FileUrl")),
DocumentNumber = reader.IsDBNull(reader.GetOrdinal("DocumentNumber"))
? null
: reader.GetString(reader.GetOrdinal("DocumentNumber")),
IssuedAt = reader.IsDBNull(reader.GetOrdinal("IssuedAt"))
? null
: DateOnly.FromDateTime(reader.GetDateTime(reader.GetOrdinal("IssuedAt"))),
ExpiresAt = expiresAt,
IsExpired = isExpired,
IsExpiringSoon = isExpiringSoon,
DaysUntilExpiry = daysUntilExpiry,
SortOrder = reader.GetInt32(reader.GetOrdinal("SortOrder")),
CreatedAt = reader.GetDateTime(reader.GetOrdinal("CreatedAt")),
UpdatedAt = reader.IsDBNull(reader.GetOrdinal("UpdatedAt"))
? null
: reader.GetDateTime(reader.GetOrdinal("UpdatedAt"))
});
}
// 3. (空行后) 返回结果
return items;
}
private static async Task<IReadOnlyList<StoreAuditRecordDto>> QueryAuditRecordsAsync(
IDbConnection connection,
long storeId,
CancellationToken cancellationToken)
{
// 1. 查询审核记录
await using var command = CreateCommand(
connection,
BuildAuditRecordSql(),
[
("storeId", storeId)
]);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
var items = new List<StoreAuditRecordDto>();
if (!reader.HasRows)
{
return items;
}
// 2. (空行后) 映射审核记录 DTO
while (await reader.ReadAsync(cancellationToken))
{
var action = (StoreAuditAction)reader.GetInt32(reader.GetOrdinal("Action"));
items.Add(new StoreAuditRecordDto
{
Id = reader.GetInt64(reader.GetOrdinal("Id")),
Action = action,
ActionName = StoreAuditActionNameResolver.Resolve(action),
OperatorId = reader.IsDBNull(reader.GetOrdinal("OperatorId"))
? null
: reader.GetInt64(reader.GetOrdinal("OperatorId")),
OperatorName = reader.GetString(reader.GetOrdinal("OperatorName")),
PreviousStatus = reader.IsDBNull(reader.GetOrdinal("PreviousStatus"))
? null
: (StoreAuditStatus)reader.GetInt32(reader.GetOrdinal("PreviousStatus")),
NewStatus = (StoreAuditStatus)reader.GetInt32(reader.GetOrdinal("NewStatus")),
RejectionReasonId = reader.IsDBNull(reader.GetOrdinal("RejectionReasonId"))
? null
: reader.GetInt64(reader.GetOrdinal("RejectionReasonId")),
RejectionReasonText = reader.IsDBNull(reader.GetOrdinal("RejectionReason"))
? null
: reader.GetString(reader.GetOrdinal("RejectionReason")),
Remark = reader.IsDBNull(reader.GetOrdinal("Remarks"))
? null
: reader.GetString(reader.GetOrdinal("Remarks")),
CreatedAt = reader.GetDateTime(reader.GetOrdinal("CreatedAt"))
});
}
// 3. (空行后) 返回结果
return items;
}
private static string BuildStoreSql()
{
return """
select
s."Id" as "StoreId",
s."Name" as "StoreName",
s."Code" as "StoreCode",
s."Phone",
s."SignboardImageUrl",
s."Province",
s."City",
s."District",
s."Address",
s."Longitude",
s."Latitude",
s."OwnershipType",
s."AuditStatus",
s."SubmittedAt",
s."TenantId",
t."Name" as "TenantName",
t."ContactName" as "TenantContactName",
t."ContactPhone" as "TenantContactPhone",
s."MerchantId",
m."BrandName" as "MerchantName",
m."LegalPerson" as "MerchantLegalName",
m."TaxNumber" as "MerchantCreditCode"
from public.stores s
join public.tenants t on t."Id" = s."TenantId" and t."DeletedAt" is null
join public.merchants m on m."Id" = s."MerchantId" and m."DeletedAt" is null
where s."DeletedAt" is null
and s."Id" = @storeId;
""";
}
private static string BuildQualificationSql()
{
return """
select
q."Id",
q."StoreId",
q."QualificationType",
q."FileUrl",
q."DocumentNumber",
q."IssuedAt",
q."ExpiresAt",
q."SortOrder",
q."CreatedAt",
q."UpdatedAt"
from public.store_qualifications q
where q."DeletedAt" is null
and q."StoreId" = @storeId
order by q."SortOrder", q."QualificationType";
""";
}
private static string BuildAuditRecordSql()
{
return """
select
r."Id",
r."Action",
r."OperatorId",
r."OperatorName",
r."PreviousStatus",
r."NewStatus",
r."RejectionReasonId",
r."RejectionReason",
r."Remarks",
r."CreatedAt"
from public.store_audit_records r
where r."DeletedAt" is null
and r."StoreId" = @storeId
order by r."CreatedAt" desc;
""";
}
private static DbCommand CreateCommand(IDbConnection connection, string sql, (string Name, object? Value)[] parameters)
{
var command = connection.CreateCommand();
command.CommandText = sql;
// 1. (空行后) 绑定参数
foreach (var (name, value) in parameters)
{
var p = command.CreateParameter();
p.ParameterName = name;
p.Value = value ?? DBNull.Value;
command.Parameters.Add(p);
}
return (DbCommand)command;
}
}

View File

@@ -1,279 +0,0 @@
using System.Data;
using System.Data.Common;
using MediatR;
using TakeoutSaaS.Application.App.StoreAudits.Dto;
using TakeoutSaaS.Application.App.StoreAudits.Queries;
using TakeoutSaaS.Domain.Stores.Enums;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Data;
namespace TakeoutSaaS.Application.App.StoreAudits.Handlers;
/// <summary>
/// 审核统计查询处理器。
/// </summary>
public sealed class GetStoreAuditStatisticsQueryHandler(
IDapperExecutor dapperExecutor)
: IRequestHandler<GetStoreAuditStatisticsQuery, StoreAuditStatisticsDto>
{
/// <inheritdoc />
public async Task<StoreAuditStatisticsDto> Handle(GetStoreAuditStatisticsQuery request, CancellationToken cancellationToken)
{
// 1. 规范化日期范围
var today = DateTime.UtcNow.Date;
var dateFrom = request.DateFrom?.Date ?? today.AddDays(-30);
var dateTo = request.DateTo?.Date ?? today;
if (dateFrom > dateTo)
{
(dateFrom, dateTo) = (dateTo, dateFrom);
}
// 1.1 (空行后) 计算统计边界
var dateToExclusive = dateTo.AddDays(1);
var overdueDeadline = DateTime.UtcNow.AddDays(-7);
// 2. (空行后) 查询统计
return await dapperExecutor.QueryAsync(
DatabaseConstants.AppDataSource,
DatabaseConnectionRole.Read,
async (connection, token) =>
{
// 2.1 读取待审核与超时数量
var pendingCount = await ExecuteScalarIntAsync(
connection,
BuildPendingCountSql(),
[
("pendingStatus", (int)StoreAuditStatus.Pending)
],
token);
var overdueCount = await ExecuteScalarIntAsync(
connection,
BuildOverdueCountSql(),
[
("pendingStatus", (int)StoreAuditStatus.Pending),
("overdueDeadline", overdueDeadline)
],
token);
// 2.2 (空行后) 读取通过/驳回数量
var (approvedCount, rejectedCount) = await QueryApproveRejectCountsAsync(
connection,
dateFrom,
dateToExclusive,
token);
// 2.3 (空行后) 读取平均处理时长
var avgProcessingHours = await ExecuteScalarDoubleAsync(
connection,
BuildAvgProcessingSql(),
[
("dateFrom", dateFrom),
("dateTo", dateToExclusive),
("approveAction", (int)StoreAuditAction.Approve),
("rejectAction", (int)StoreAuditAction.Reject)
],
token);
// 2.4 (空行后) 读取每日趋势
var dailyTrend = await QueryDailyTrendAsync(
connection,
dateFrom,
dateToExclusive,
token);
// 2.5 (空行后) 组装结果
return new StoreAuditStatisticsDto
{
PendingCount = pendingCount,
OverdueCount = overdueCount,
ApprovedCount = approvedCount,
RejectedCount = rejectedCount,
AvgProcessingHours = avgProcessingHours,
DailyTrend = dailyTrend
};
},
cancellationToken);
}
private static async Task<(int ApprovedCount, int RejectedCount)> QueryApproveRejectCountsAsync(
IDbConnection connection,
DateTime dateFrom,
DateTime dateTo,
CancellationToken cancellationToken)
{
// 1. 查询通过/驳回统计
await using var command = CreateCommand(
connection,
BuildApproveRejectCountSql(),
[
("dateFrom", dateFrom),
("dateTo", dateTo),
("approveAction", (int)StoreAuditAction.Approve),
("rejectAction", (int)StoreAuditAction.Reject)
]);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
if (!await reader.ReadAsync(cancellationToken))
{
return (0, 0);
}
// 2. (空行后) 返回统计
var approved = reader.IsDBNull(reader.GetOrdinal("ApprovedCount")) ? 0 : reader.GetInt32(reader.GetOrdinal("ApprovedCount"));
var rejected = reader.IsDBNull(reader.GetOrdinal("RejectedCount")) ? 0 : reader.GetInt32(reader.GetOrdinal("RejectedCount"));
return (approved, rejected);
}
private static async Task<IReadOnlyList<StoreAuditDailyTrendDto>> QueryDailyTrendAsync(
IDbConnection connection,
DateTime dateFrom,
DateTime dateTo,
CancellationToken cancellationToken)
{
// 1. 查询每日趋势
await using var command = CreateCommand(
connection,
BuildDailyTrendSql(),
[
("dateFrom", dateFrom),
("dateTo", dateTo),
("submitAction", (int)StoreAuditAction.Submit),
("resubmitAction", (int)StoreAuditAction.Resubmit),
("approveAction", (int)StoreAuditAction.Approve),
("rejectAction", (int)StoreAuditAction.Reject)
]);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
var items = new List<StoreAuditDailyTrendDto>();
if (!reader.HasRows)
{
return items;
}
// 2. (空行后) 映射趋势项
var dateOrdinal = reader.GetOrdinal("Date");
var submittedOrdinal = reader.GetOrdinal("SubmittedCount");
var approvedOrdinal = reader.GetOrdinal("ApprovedCount");
var rejectedOrdinal = reader.GetOrdinal("RejectedCount");
while (await reader.ReadAsync(cancellationToken))
{
var date = reader.GetDateTime(dateOrdinal);
items.Add(new StoreAuditDailyTrendDto
{
Date = DateOnly.FromDateTime(date),
Submitted = reader.GetInt32(submittedOrdinal),
Approved = reader.GetInt32(approvedOrdinal),
Rejected = reader.GetInt32(rejectedOrdinal)
});
}
// 3. (空行后) 返回趋势列表
return items;
}
private static string BuildPendingCountSql()
{
return """
select count(*)
from public.stores s
where s."DeletedAt" is null
and s."AuditStatus" = @pendingStatus;
""";
}
private static string BuildOverdueCountSql()
{
return """
select count(*)
from public.stores s
where s."DeletedAt" is null
and s."AuditStatus" = @pendingStatus
and s."SubmittedAt" <= @overdueDeadline;
""";
}
private static string BuildApproveRejectCountSql()
{
return """
select
sum(case when r."Action" = @approveAction then 1 else 0 end) as "ApprovedCount",
sum(case when r."Action" = @rejectAction then 1 else 0 end) as "RejectedCount"
from public.store_audit_records r
where r."DeletedAt" is null
and r."CreatedAt" >= @dateFrom
and r."CreatedAt" < @dateTo
and r."Action" in (@approveAction, @rejectAction);
""";
}
private static string BuildAvgProcessingSql()
{
return """
select
avg(extract(epoch from (r."CreatedAt" - s."SubmittedAt")) / 3600.0) as "AvgHours"
from public.store_audit_records r
join public.stores s on s."Id" = r."StoreId" and s."DeletedAt" is null
where r."DeletedAt" is null
and r."Action" in (@approveAction, @rejectAction)
and r."CreatedAt" >= @dateFrom
and r."CreatedAt" < @dateTo
and s."SubmittedAt" is not null;
""";
}
private static string BuildDailyTrendSql()
{
return """
select
date_trunc('day', r."CreatedAt") as "Date",
sum(case when r."Action" in (@submitAction, @resubmitAction) then 1 else 0 end) as "SubmittedCount",
sum(case when r."Action" = @approveAction then 1 else 0 end) as "ApprovedCount",
sum(case when r."Action" = @rejectAction then 1 else 0 end) as "RejectedCount"
from public.store_audit_records r
where r."DeletedAt" is null
and r."CreatedAt" >= @dateFrom
and r."CreatedAt" < @dateTo
group by date_trunc('day', r."CreatedAt")
order by date_trunc('day', r."CreatedAt");
""";
}
private static async Task<int> ExecuteScalarIntAsync(
IDbConnection connection,
string sql,
(string Name, object? Value)[] parameters,
CancellationToken cancellationToken)
{
await using var command = CreateCommand(connection, sql, parameters);
var result = await command.ExecuteScalarAsync(cancellationToken);
return result is null or DBNull ? 0 : Convert.ToInt32(result);
}
private static async Task<double> ExecuteScalarDoubleAsync(
IDbConnection connection,
string sql,
(string Name, object? Value)[] parameters,
CancellationToken cancellationToken)
{
await using var command = CreateCommand(connection, sql, parameters);
var result = await command.ExecuteScalarAsync(cancellationToken);
return result is null or DBNull ? 0d : Convert.ToDouble(result);
}
private static DbCommand CreateCommand(IDbConnection connection, string sql, (string Name, object? Value)[] parameters)
{
var command = connection.CreateCommand();
command.CommandText = sql;
// 1. (空行后) 绑定参数
foreach (var (name, value) in parameters)
{
var p = command.CreateParameter();
p.ParameterName = name;
p.Value = value ?? DBNull.Value;
command.Parameters.Add(p);
}
return (DbCommand)command;
}
}

View File

@@ -1,250 +0,0 @@
using System.Data;
using System.Data.Common;
using MediatR;
using TakeoutSaaS.Application.App.StoreAudits.Dto;
using TakeoutSaaS.Application.App.StoreAudits.Queries;
using TakeoutSaaS.Domain.Stores.Enums;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Data;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.StoreAudits.Handlers;
/// <summary>
/// 待审核门店列表查询处理器。
/// </summary>
public sealed class ListPendingStoreAuditsQueryHandler(
IDapperExecutor dapperExecutor)
: IRequestHandler<ListPendingStoreAuditsQuery, PagedResult<PendingStoreAuditDto>>
{
/// <inheritdoc />
public async Task<PagedResult<PendingStoreAuditDto>> Handle(ListPendingStoreAuditsQuery request, CancellationToken cancellationToken)
{
// 1. 参数规范化
var page = request.Page <= 0 ? 1 : request.Page;
var pageSize = request.PageSize is <= 0 or > 200 ? 20 : request.PageSize;
var keyword = string.IsNullOrWhiteSpace(request.Keyword) ? null : request.Keyword.Trim();
var offset = (page - 1) * pageSize;
var now = DateTime.UtcNow;
var overdueDeadline = now.AddDays(-7);
// 2. (空行后) 排序白名单
var orderBy = request.SortBy?.Trim() switch
{
"StoreName" => "s.\"Name\"",
"MerchantName" => "m.\"BrandName\"",
"TenantName" => "t.\"Name\"",
"SubmittedAt" => "s.\"SubmittedAt\"",
_ => "s.\"SubmittedAt\""
};
// 3. (空行后) 执行查询
return await dapperExecutor.QueryAsync(
DatabaseConstants.AppDataSource,
DatabaseConnectionRole.Read,
async (connection, token) =>
{
// 3.1 统计总数
var total = await ExecuteScalarIntAsync(
connection,
BuildCountSql(),
[
("tenantId", request.TenantId),
("keyword", keyword),
("submittedFrom", request.SubmittedFrom),
("submittedTo", request.SubmittedTo),
("overdueOnly", request.OverdueOnly),
("overdueDeadline", overdueDeadline),
("pendingStatus", (int)StoreAuditStatus.Pending)
],
token);
// 3.2 (空行后) 查询列表
var listSql = BuildListSql(orderBy, request.SortDesc);
await using var listCommand = CreateCommand(
connection,
listSql,
[
("tenantId", request.TenantId),
("keyword", keyword),
("submittedFrom", request.SubmittedFrom),
("submittedTo", request.SubmittedTo),
("overdueOnly", request.OverdueOnly),
("overdueDeadline", overdueDeadline),
("pendingStatus", (int)StoreAuditStatus.Pending),
("offset", offset),
("limit", pageSize)
]);
await using var reader = await listCommand.ExecuteReaderAsync(token);
// 3.3 (空行后) 读取并映射
var items = new List<PendingStoreAuditDto>();
if (!reader.HasRows)
{
return new PagedResult<PendingStoreAuditDto>(items, page, pageSize, total);
}
// 3.3.1 (空行后) 初始化字段序号
var storeIdOrdinal = reader.GetOrdinal("StoreId");
var storeNameOrdinal = reader.GetOrdinal("StoreName");
var storeCodeOrdinal = reader.GetOrdinal("StoreCode");
var tenantIdOrdinal = reader.GetOrdinal("TenantId");
var tenantNameOrdinal = reader.GetOrdinal("TenantName");
var merchantIdOrdinal = reader.GetOrdinal("MerchantId");
var merchantNameOrdinal = reader.GetOrdinal("MerchantName");
var signboardOrdinal = reader.GetOrdinal("SignboardImageUrl");
var provinceOrdinal = reader.GetOrdinal("Province");
var cityOrdinal = reader.GetOrdinal("City");
var districtOrdinal = reader.GetOrdinal("District");
var addressOrdinal = reader.GetOrdinal("Address");
var ownershipOrdinal = reader.GetOrdinal("OwnershipType");
var submittedAtOrdinal = reader.GetOrdinal("SubmittedAt");
var qualificationCountOrdinal = reader.GetOrdinal("QualificationCount");
while (await reader.ReadAsync(token))
{
DateTime? submittedAt = reader.IsDBNull(submittedAtOrdinal)
? null
: reader.GetDateTime(submittedAtOrdinal);
var waitingDays = submittedAt.HasValue
? (int)Math.Floor((now - submittedAt.Value).TotalDays)
: 0;
if (waitingDays < 0)
{
waitingDays = 0;
}
// 3.3.2 (空行后) 组装地址信息
var province = reader.IsDBNull(provinceOrdinal) ? null : reader.GetString(provinceOrdinal);
var city = reader.IsDBNull(cityOrdinal) ? null : reader.GetString(cityOrdinal);
var district = reader.IsDBNull(districtOrdinal) ? null : reader.GetString(districtOrdinal);
var address = reader.IsDBNull(addressOrdinal) ? null : reader.GetString(addressOrdinal);
var fullAddress = string.Concat(
province ?? string.Empty,
city ?? string.Empty,
district ?? string.Empty,
address ?? string.Empty);
items.Add(new PendingStoreAuditDto
{
StoreId = reader.GetInt64(storeIdOrdinal),
StoreName = reader.GetString(storeNameOrdinal),
StoreCode = reader.GetString(storeCodeOrdinal),
TenantId = reader.GetInt64(tenantIdOrdinal),
TenantName = reader.GetString(tenantNameOrdinal),
MerchantId = reader.GetInt64(merchantIdOrdinal),
MerchantName = reader.GetString(merchantNameOrdinal),
SignboardImageUrl = reader.IsDBNull(signboardOrdinal) ? null : reader.GetString(signboardOrdinal),
FullAddress = fullAddress,
OwnershipType = (StoreOwnershipType)reader.GetInt32(ownershipOrdinal),
SubmittedAt = submittedAt,
WaitingDays = waitingDays,
IsOverdue = submittedAt.HasValue && submittedAt.Value <= overdueDeadline,
QualificationCount = reader.GetInt32(qualificationCountOrdinal)
});
}
// 3.4 (空行后) 返回分页结果
return new PagedResult<PendingStoreAuditDto>(items, page, pageSize, total);
},
cancellationToken);
}
private static string BuildCountSql()
{
return """
select count(*)
from public.stores s
join public.merchants m on m."Id" = s."MerchantId" and m."DeletedAt" is null
join public.tenants t on t."Id" = s."TenantId" and t."DeletedAt" is null
where s."DeletedAt" is null
and s."AuditStatus" = @pendingStatus
and (@tenantId::bigint is null or s."TenantId" = @tenantId)
and (
@keyword::text is null
or s."Name" ilike ('%' || @keyword::text || '%')
or s."Code" ilike ('%' || @keyword::text || '%')
or m."BrandName" ilike ('%' || @keyword::text || '%')
)
and (@submittedFrom::timestamp with time zone is null or s."SubmittedAt" >= @submittedFrom)
and (@submittedTo::timestamp with time zone is null or s."SubmittedAt" <= @submittedTo)
and (@overdueOnly::boolean = false or s."SubmittedAt" <= @overdueDeadline);
""";
}
private static string BuildListSql(string orderBy, bool sortDesc)
{
var direction = sortDesc ? "desc" : "asc";
// 1. (空行后) 构造列表 SQL
return $"""
select
s."Id" as "StoreId",
s."Name" as "StoreName",
s."Code" as "StoreCode",
s."TenantId",
t."Name" as "TenantName",
s."MerchantId",
m."BrandName" as "MerchantName",
s."SignboardImageUrl",
s."Province",
s."City",
s."District",
s."Address",
s."OwnershipType",
s."SubmittedAt",
(
select count(1)
from public.store_qualifications q
where q."StoreId" = s."Id" and q."DeletedAt" is null
) as "QualificationCount"
from public.stores s
join public.merchants m on m."Id" = s."MerchantId" and m."DeletedAt" is null
join public.tenants t on t."Id" = s."TenantId" and t."DeletedAt" is null
where s."DeletedAt" is null
and s."AuditStatus" = @pendingStatus
and (@tenantId::bigint is null or s."TenantId" = @tenantId)
and (
@keyword::text is null
or s."Name" ilike ('%' || @keyword::text || '%')
or s."Code" ilike ('%' || @keyword::text || '%')
or m."BrandName" ilike ('%' || @keyword::text || '%')
)
and (@submittedFrom::timestamp with time zone is null or s."SubmittedAt" >= @submittedFrom)
and (@submittedTo::timestamp with time zone is null or s."SubmittedAt" <= @submittedTo)
and (@overdueOnly::boolean = false or s."SubmittedAt" <= @overdueDeadline)
order by {orderBy} {direction}
offset @offset
limit @limit;
""";
}
private static async Task<int> ExecuteScalarIntAsync(
IDbConnection connection,
string sql,
(string Name, object? Value)[] parameters,
CancellationToken cancellationToken)
{
await using var command = CreateCommand(connection, sql, parameters);
var result = await command.ExecuteScalarAsync(cancellationToken);
return result is null or DBNull ? 0 : Convert.ToInt32(result);
}
private static DbCommand CreateCommand(IDbConnection connection, string sql, (string Name, object? Value)[] parameters)
{
var command = connection.CreateCommand();
command.CommandText = sql;
// 1. (空行后) 绑定参数
foreach (var (name, value) in parameters)
{
var p = command.CreateParameter();
p.ParameterName = name;
p.Value = value ?? DBNull.Value;
command.Parameters.Add(p);
}
return (DbCommand)command;
}
}

View File

@@ -1,166 +0,0 @@
using System.Data;
using System.Data.Common;
using MediatR;
using TakeoutSaaS.Application.App.StoreAudits;
using TakeoutSaaS.Application.App.StoreAudits.Dto;
using TakeoutSaaS.Application.App.StoreAudits.Queries;
using TakeoutSaaS.Domain.Stores.Enums;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Data;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.StoreAudits.Handlers;
/// <summary>
/// 门店审核记录查询处理器。
/// </summary>
public sealed class ListStoreAuditRecordsQueryHandler(
IDapperExecutor dapperExecutor)
: IRequestHandler<ListStoreAuditRecordsQuery, PagedResult<StoreAuditRecordDto>>
{
/// <inheritdoc />
public async Task<PagedResult<StoreAuditRecordDto>> Handle(ListStoreAuditRecordsQuery request, CancellationToken cancellationToken)
{
// 1. 参数规范化
var page = request.Page <= 0 ? 1 : request.Page;
var pageSize = request.PageSize is <= 0 or > 200 ? 20 : request.PageSize;
var offset = (page - 1) * pageSize;
// 2. (空行后) 查询审核记录
return await dapperExecutor.QueryAsync(
DatabaseConstants.AppDataSource,
DatabaseConnectionRole.Read,
async (connection, token) =>
{
// 2.1 统计总数
var total = await ExecuteScalarIntAsync(
connection,
BuildCountSql(),
[
("storeId", request.StoreId)
],
token);
// 2.2 (空行后) 查询列表
await using var listCommand = CreateCommand(
connection,
BuildListSql(),
[
("storeId", request.StoreId),
("offset", offset),
("limit", pageSize)
]);
await using var reader = await listCommand.ExecuteReaderAsync(token);
// 2.3 (空行后) 映射列表
var items = new List<StoreAuditRecordDto>();
if (!reader.HasRows)
{
return new PagedResult<StoreAuditRecordDto>(items, page, pageSize, total);
}
// 2.3.1 (空行后) 初始化字段序号
var idOrdinal = reader.GetOrdinal("Id");
var actionOrdinal = reader.GetOrdinal("Action");
var operatorIdOrdinal = reader.GetOrdinal("OperatorId");
var operatorNameOrdinal = reader.GetOrdinal("OperatorName");
var previousStatusOrdinal = reader.GetOrdinal("PreviousStatus");
var newStatusOrdinal = reader.GetOrdinal("NewStatus");
var rejectionReasonIdOrdinal = reader.GetOrdinal("RejectionReasonId");
var rejectionReasonOrdinal = reader.GetOrdinal("RejectionReason");
var remarksOrdinal = reader.GetOrdinal("Remarks");
var createdAtOrdinal = reader.GetOrdinal("CreatedAt");
while (await reader.ReadAsync(token))
{
var action = (StoreAuditAction)reader.GetInt32(actionOrdinal);
items.Add(new StoreAuditRecordDto
{
Id = reader.GetInt64(idOrdinal),
Action = action,
ActionName = StoreAuditActionNameResolver.Resolve(action),
OperatorId = reader.IsDBNull(operatorIdOrdinal) ? null : reader.GetInt64(operatorIdOrdinal),
OperatorName = reader.GetString(operatorNameOrdinal),
PreviousStatus = reader.IsDBNull(previousStatusOrdinal)
? null
: (StoreAuditStatus)reader.GetInt32(previousStatusOrdinal),
NewStatus = (StoreAuditStatus)reader.GetInt32(newStatusOrdinal),
RejectionReasonId = reader.IsDBNull(rejectionReasonIdOrdinal)
? null
: reader.GetInt64(rejectionReasonIdOrdinal),
RejectionReasonText = reader.IsDBNull(rejectionReasonOrdinal)
? null
: reader.GetString(rejectionReasonOrdinal),
Remark = reader.IsDBNull(remarksOrdinal) ? null : reader.GetString(remarksOrdinal),
CreatedAt = reader.GetDateTime(createdAtOrdinal)
});
}
// 2.4 (空行后) 返回分页结果
return new PagedResult<StoreAuditRecordDto>(items, page, pageSize, total);
},
cancellationToken);
}
private static string BuildCountSql()
{
return """
select count(*)
from public.store_audit_records r
where r."DeletedAt" is null
and r."StoreId" = @storeId;
""";
}
private static string BuildListSql()
{
return """
select
r."Id",
r."Action",
r."OperatorId",
r."OperatorName",
r."PreviousStatus",
r."NewStatus",
r."RejectionReasonId",
r."RejectionReason",
r."Remarks",
r."CreatedAt"
from public.store_audit_records r
where r."DeletedAt" is null
and r."StoreId" = @storeId
order by r."CreatedAt" desc
offset @offset
limit @limit;
""";
}
private static async Task<int> ExecuteScalarIntAsync(
IDbConnection connection,
string sql,
(string Name, object? Value)[] parameters,
CancellationToken cancellationToken)
{
await using var command = CreateCommand(connection, sql, parameters);
var result = await command.ExecuteScalarAsync(cancellationToken);
return result is null or DBNull ? 0 : Convert.ToInt32(result);
}
private static DbCommand CreateCommand(IDbConnection connection, string sql, (string Name, object? Value)[] parameters)
{
var command = connection.CreateCommand();
command.CommandText = sql;
// 1. (空行后) 绑定参数
foreach (var (name, value) in parameters)
{
var p = command.CreateParameter();
p.ParameterName = name;
p.Value = value ?? DBNull.Value;
command.Parameters.Add(p);
}
return (DbCommand)command;
}
}

View File

@@ -1,157 +0,0 @@
using System.Data;
using System.Data.Common;
using MediatR;
using Microsoft.Extensions.Logging;
using TakeoutSaaS.Application.App.StoreAudits.Commands;
using TakeoutSaaS.Application.App.StoreAudits.Dto;
using TakeoutSaaS.Domain.Stores.Entities;
using TakeoutSaaS.Domain.Stores.Enums;
using TakeoutSaaS.Domain.Stores.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Data;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Security;
namespace TakeoutSaaS.Application.App.StoreAudits.Handlers;
/// <summary>
/// 审核驳回处理器。
/// </summary>
public sealed class RejectStoreCommandHandler(
IStoreRepository storeRepository,
IDapperExecutor dapperExecutor,
ICurrentUserAccessor currentUserAccessor,
ILogger<RejectStoreCommandHandler> logger)
: IRequestHandler<RejectStoreCommand, StoreAuditActionResultDto>
{
/// <inheritdoc />
public async Task<StoreAuditActionResultDto> Handle(RejectStoreCommand request, CancellationToken cancellationToken)
{
// 1. 获取门店快照
var snapshot = await QueryStoreSnapshotAsync(request.StoreId, cancellationToken);
if (!snapshot.HasValue)
{
throw new BusinessException(ErrorCodes.NotFound, "门店不存在");
}
// 1.1 (空行后) 校验审核状态
if (snapshot.Value.AuditStatus != StoreAuditStatus.Pending)
{
throw new BusinessException(ErrorCodes.Conflict, "门店不处于待审核状态");
}
// 2. (空行后) 获取门店实体
var store = await storeRepository.FindByIdAsync(request.StoreId, snapshot.Value.TenantId, cancellationToken);
if (store is null)
{
throw new BusinessException(ErrorCodes.NotFound, "门店不存在");
}
// 3. (空行后) 更新状态并记录审核
var previousStatus = store.AuditStatus;
store.AuditStatus = StoreAuditStatus.Rejected;
store.RejectionReason = request.RejectionReasonText;
store.BusinessStatus = StoreBusinessStatus.Resting;
await storeRepository.UpdateStoreAsync(store, cancellationToken);
await storeRepository.AddAuditRecordAsync(new StoreAuditRecord
{
StoreId = store.Id,
Action = StoreAuditAction.Reject,
PreviousStatus = previousStatus,
NewStatus = store.AuditStatus,
OperatorId = ResolveOperatorId(),
OperatorName = ResolveOperatorName(),
RejectionReasonId = request.RejectionReasonId,
RejectionReason = request.RejectionReasonText,
Remarks = request.Remark
}, cancellationToken);
await storeRepository.SaveChangesAsync(cancellationToken);
logger.LogInformation("门店 {StoreId} 审核驳回", store.Id);
// 4. (空行后) 返回结果
return new StoreAuditActionResultDto
{
StoreId = store.Id,
AuditStatus = store.AuditStatus,
BusinessStatus = store.BusinessStatus,
RejectionReason = store.RejectionReason,
Message = "已驳回,租户可修改后重新提交"
};
}
private async Task<(long TenantId, StoreAuditStatus AuditStatus, StoreBusinessStatus BusinessStatus)?> QueryStoreSnapshotAsync(
long storeId,
CancellationToken cancellationToken)
{
// 1. 查询门店基础字段
return await dapperExecutor.QueryAsync<(long TenantId, StoreAuditStatus AuditStatus, StoreBusinessStatus BusinessStatus)?>(
DatabaseConstants.AppDataSource,
DatabaseConnectionRole.Read,
async (connection, token) =>
{
await using var command = CreateCommand(
connection,
BuildStoreSnapshotSql(),
[
("storeId", storeId)
]);
// 1.1 (空行后) 执行查询
await using var reader = await command.ExecuteReaderAsync(token);
if (!await reader.ReadAsync(token))
{
return null;
}
// 1.2 (空行后) 返回快照
return (
reader.GetInt64(reader.GetOrdinal("TenantId")),
(StoreAuditStatus)reader.GetInt32(reader.GetOrdinal("AuditStatus")),
(StoreBusinessStatus)reader.GetInt32(reader.GetOrdinal("BusinessStatus")));
},
cancellationToken);
}
private long? ResolveOperatorId()
{
var id = currentUserAccessor.UserId;
return id == 0 ? null : id;
}
private string ResolveOperatorName()
{
var id = currentUserAccessor.UserId;
return id == 0 ? "system" : $"user:{id}";
}
private static string BuildStoreSnapshotSql()
{
return """
select
s."TenantId",
s."AuditStatus",
s."BusinessStatus"
from public.stores s
where s."DeletedAt" is null
and s."Id" = @storeId;
""";
}
private static DbCommand CreateCommand(IDbConnection connection, string sql, (string Name, object? Value)[] parameters)
{
var command = connection.CreateCommand();
command.CommandText = sql;
// 1. (空行后) 绑定参数
foreach (var (name, value) in parameters)
{
var p = command.CreateParameter();
p.ParameterName = name;
p.Value = value ?? DBNull.Value;
command.Parameters.Add(p);
}
return (DbCommand)command;
}
}

View File

@@ -1,160 +0,0 @@
using System.Data;
using System.Data.Common;
using MediatR;
using Microsoft.Extensions.Logging;
using TakeoutSaaS.Application.App.StoreAudits.Commands;
using TakeoutSaaS.Application.App.StoreAudits.Dto;
using TakeoutSaaS.Domain.Stores.Entities;
using TakeoutSaaS.Domain.Stores.Enums;
using TakeoutSaaS.Domain.Stores.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Data;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Security;
namespace TakeoutSaaS.Application.App.StoreAudits.Handlers;
/// <summary>
/// 解除强制关闭处理器。
/// </summary>
public sealed class ReopenStoreCommandHandler(
IStoreRepository storeRepository,
IDapperExecutor dapperExecutor,
ICurrentUserAccessor currentUserAccessor,
ILogger<ReopenStoreCommandHandler> logger)
: IRequestHandler<ReopenStoreCommand, StoreAuditActionResultDto>
{
/// <inheritdoc />
public async Task<StoreAuditActionResultDto> Handle(ReopenStoreCommand request, CancellationToken cancellationToken)
{
// 1. 获取门店快照
var snapshot = await QueryStoreSnapshotAsync(request.StoreId, cancellationToken);
if (!snapshot.HasValue)
{
throw new BusinessException(ErrorCodes.NotFound, "门店不存在");
}
// 1.1 (空行后) 校验审核与经营状态
if (snapshot.Value.AuditStatus != StoreAuditStatus.Activated)
{
throw new BusinessException(ErrorCodes.Conflict, "门店未激活,无法解除关闭");
}
if (snapshot.Value.BusinessStatus != StoreBusinessStatus.ForceClosed)
{
throw new BusinessException(ErrorCodes.Conflict, "门店未处于强制关闭状态");
}
// 2. (空行后) 获取门店实体
var store = await storeRepository.FindByIdAsync(request.StoreId, snapshot.Value.TenantId, cancellationToken);
if (store is null)
{
throw new BusinessException(ErrorCodes.NotFound, "门店不存在");
}
// 3. (空行后) 更新状态并记录风控
store.BusinessStatus = StoreBusinessStatus.Resting;
store.ClosureReason = null;
store.ClosureReasonText = null;
store.ForceCloseReason = null;
store.ForceClosedAt = null;
await storeRepository.UpdateStoreAsync(store, cancellationToken);
await storeRepository.AddAuditRecordAsync(new StoreAuditRecord
{
StoreId = store.Id,
Action = StoreAuditAction.Reopen,
PreviousStatus = store.AuditStatus,
NewStatus = store.AuditStatus,
OperatorId = ResolveOperatorId(),
OperatorName = ResolveOperatorName(),
Remarks = request.Remark
}, cancellationToken);
await storeRepository.SaveChangesAsync(cancellationToken);
logger.LogInformation("门店 {StoreId} 解除强制关闭", store.Id);
// 4. (空行后) 返回结果
return new StoreAuditActionResultDto
{
StoreId = store.Id,
AuditStatus = store.AuditStatus,
BusinessStatus = store.BusinessStatus,
Message = "强制关闭已解除,门店恢复为休息中状态"
};
}
private async Task<(long TenantId, StoreAuditStatus AuditStatus, StoreBusinessStatus BusinessStatus)?> QueryStoreSnapshotAsync(
long storeId,
CancellationToken cancellationToken)
{
// 1. 查询门店基础字段
return await dapperExecutor.QueryAsync<(long TenantId, StoreAuditStatus AuditStatus, StoreBusinessStatus BusinessStatus)?>(
DatabaseConstants.AppDataSource,
DatabaseConnectionRole.Read,
async (connection, token) =>
{
await using var command = CreateCommand(
connection,
BuildStoreSnapshotSql(),
[
("storeId", storeId)
]);
// 1.1 (空行后) 执行查询
await using var reader = await command.ExecuteReaderAsync(token);
if (!await reader.ReadAsync(token))
{
return null;
}
// 1.2 (空行后) 返回快照
return (
reader.GetInt64(reader.GetOrdinal("TenantId")),
(StoreAuditStatus)reader.GetInt32(reader.GetOrdinal("AuditStatus")),
(StoreBusinessStatus)reader.GetInt32(reader.GetOrdinal("BusinessStatus")));
},
cancellationToken);
}
private long? ResolveOperatorId()
{
var id = currentUserAccessor.UserId;
return id == 0 ? null : id;
}
private string ResolveOperatorName()
{
var id = currentUserAccessor.UserId;
return id == 0 ? "system" : $"user:{id}";
}
private static string BuildStoreSnapshotSql()
{
return """
select
s."TenantId",
s."AuditStatus",
s."BusinessStatus"
from public.stores s
where s."DeletedAt" is null
and s."Id" = @storeId;
""";
}
private static DbCommand CreateCommand(IDbConnection connection, string sql, (string Name, object? Value)[] parameters)
{
var command = connection.CreateCommand();
command.CommandText = sql;
// 1. (空行后) 绑定参数
foreach (var (name, value) in parameters)
{
var p = command.CreateParameter();
p.ParameterName = name;
p.Value = value ?? DBNull.Value;
command.Parameters.Add(p);
}
return (DbCommand)command;
}
}

View File

@@ -1,15 +0,0 @@
using MediatR;
using TakeoutSaaS.Application.App.StoreAudits.Dto;
namespace TakeoutSaaS.Application.App.StoreAudits.Queries;
/// <summary>
/// 获取门店审核详情查询。
/// </summary>
public sealed record GetStoreAuditDetailQuery : IRequest<StoreAuditDetailDto?>
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
}

View File

@@ -1,20 +0,0 @@
using MediatR;
using TakeoutSaaS.Application.App.StoreAudits.Dto;
namespace TakeoutSaaS.Application.App.StoreAudits.Queries;
/// <summary>
/// 获取审核统计查询。
/// </summary>
public sealed record GetStoreAuditStatisticsQuery : IRequest<StoreAuditStatisticsDto>
{
/// <summary>
/// 起始日期。
/// </summary>
public DateTime? DateFrom { get; init; }
/// <summary>
/// 截止日期。
/// </summary>
public DateTime? DateTo { get; init; }
}

View File

@@ -1,56 +0,0 @@
using MediatR;
using TakeoutSaaS.Application.App.StoreAudits.Dto;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.StoreAudits.Queries;
/// <summary>
/// 查询待审核门店列表。
/// </summary>
public sealed record ListPendingStoreAuditsQuery : IRequest<PagedResult<PendingStoreAuditDto>>
{
/// <summary>
/// 租户 ID。
/// </summary>
public long? TenantId { get; init; }
/// <summary>
/// 关键词。
/// </summary>
public string? Keyword { get; init; }
/// <summary>
/// 提交起始时间。
/// </summary>
public DateTime? SubmittedFrom { get; init; }
/// <summary>
/// 提交截止时间。
/// </summary>
public DateTime? SubmittedTo { get; init; }
/// <summary>
/// 是否只显示超时。
/// </summary>
public bool OverdueOnly { get; init; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页数量。
/// </summary>
public int PageSize { get; init; } = 20;
/// <summary>
/// 排序字段。
/// </summary>
public string? SortBy { get; init; }
/// <summary>
/// 是否降序。
/// </summary>
public bool SortDesc { get; init; }
}

View File

@@ -1,26 +0,0 @@
using MediatR;
using TakeoutSaaS.Application.App.StoreAudits.Dto;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.StoreAudits.Queries;
/// <summary>
/// 查询门店审核记录。
/// </summary>
public sealed record ListStoreAuditRecordsQuery : IRequest<PagedResult<StoreAuditRecordDto>>
{
/// <summary>
/// 门店 ID。
/// </summary>
public long StoreId { get; init; }
/// <summary>
/// 页码。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页数量。
/// </summary>
public int PageSize { get; init; } = 20;
}

View File

@@ -1,26 +0,0 @@
using TakeoutSaaS.Domain.Stores.Enums;
namespace TakeoutSaaS.Application.App.StoreAudits;
/// <summary>
/// 门店审核动作名称解析器。
/// </summary>
public static class StoreAuditActionNameResolver
{
/// <summary>
/// 获取动作名称。
/// </summary>
/// <param name="action">审核动作。</param>
/// <returns>动作名称。</returns>
public static string Resolve(StoreAuditAction action) => action switch
{
StoreAuditAction.Submit => "提交审核",
StoreAuditAction.Resubmit => "重新提交",
StoreAuditAction.Approve => "审核通过",
StoreAuditAction.Reject => "审核驳回",
StoreAuditAction.ForceClose => "强制关闭",
StoreAuditAction.Reopen => "解除关闭",
StoreAuditAction.AutoActivate => "自动激活",
_ => "未知操作"
};
}

View File

@@ -1,19 +0,0 @@
using FluentValidation;
using TakeoutSaaS.Application.App.StoreAudits.Commands;
namespace TakeoutSaaS.Application.App.StoreAudits.Validators;
/// <summary>
/// 审核通过命令验证器。
/// </summary>
public sealed class ApproveStoreCommandValidator : AbstractValidator<ApproveStoreCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public ApproveStoreCommandValidator()
{
RuleFor(x => x.StoreId).GreaterThan(0);
RuleFor(x => x.Remark).MaximumLength(500);
}
}

View File

@@ -1,20 +0,0 @@
using FluentValidation;
using TakeoutSaaS.Application.App.StoreAudits.Commands;
namespace TakeoutSaaS.Application.App.StoreAudits.Validators;
/// <summary>
/// 强制关闭命令验证器。
/// </summary>
public sealed class ForceCloseStoreCommandValidator : AbstractValidator<ForceCloseStoreCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public ForceCloseStoreCommandValidator()
{
RuleFor(x => x.StoreId).GreaterThan(0);
RuleFor(x => x.Reason).NotEmpty().MaximumLength(500);
RuleFor(x => x.Remark).MaximumLength(500);
}
}

View File

@@ -1,21 +0,0 @@
using FluentValidation;
using TakeoutSaaS.Application.App.StoreAudits.Commands;
namespace TakeoutSaaS.Application.App.StoreAudits.Validators;
/// <summary>
/// 审核驳回命令验证器。
/// </summary>
public sealed class RejectStoreCommandValidator : AbstractValidator<RejectStoreCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public RejectStoreCommandValidator()
{
RuleFor(x => x.StoreId).GreaterThan(0);
RuleFor(x => x.RejectionReasonId).GreaterThan(0);
RuleFor(x => x.RejectionReasonText).MaximumLength(500);
RuleFor(x => x.Remark).MaximumLength(500);
}
}

View File

@@ -1,19 +0,0 @@
using FluentValidation;
using TakeoutSaaS.Application.App.StoreAudits.Commands;
namespace TakeoutSaaS.Application.App.StoreAudits.Validators;
/// <summary>
/// 解除强制关闭命令验证器。
/// </summary>
public sealed class ReopenStoreCommandValidator : AbstractValidator<ReopenStoreCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public ReopenStoreCommandValidator()
{
RuleFor(x => x.StoreId).GreaterThan(0);
RuleFor(x => x.Remark).MaximumLength(500);
}
}

View File

@@ -1,29 +0,0 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Application.App.Tenants.Dto;
namespace TakeoutSaaS.Application.App.Tenants.Commands;
/// <summary>
/// 租户初次绑定订阅命令(默认 0 个月)。
/// </summary>
public sealed record BindInitialTenantSubscriptionCommand : IRequest<TenantSubscriptionDto>
{
/// <summary>
/// 租户 ID雪花算法
/// </summary>
[Required]
public long TenantId { get; init; }
/// <summary>
/// 套餐 ID。
/// </summary>
[Required]
public long TenantPackageId { get; init; }
/// <summary>
/// 是否自动续费。
/// </summary>
public bool AutoRenew { get; init; }
}

View File

@@ -1,39 +0,0 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Application.App.Tenants.Dto;
namespace TakeoutSaaS.Application.App.Tenants.Commands;
/// <summary>
/// 套餐升降配命令。
/// </summary>
public sealed record ChangeTenantSubscriptionPlanCommand : IRequest<TenantSubscriptionDto>
{
/// <summary>
/// 租户 ID雪花算法
/// </summary>
[Required]
public long TenantId { get; init; }
/// <summary>
/// 现有订阅 ID。
/// </summary>
[Required]
public long TenantSubscriptionId { get; init; }
/// <summary>
/// 目标套餐 ID。
/// </summary>
[Required]
public long TargetPackageId { get; init; }
/// <summary>
/// 是否立即生效,否则在下一结算周期生效。
/// </summary>
public bool Immediate { get; init; }
/// <summary>
/// 调整备注。
/// </summary>
public string? Notes { get; init; }
}

View File

@@ -1,17 +0,0 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Application.App.Tenants.Dto;
namespace TakeoutSaaS.Application.App.Tenants.Commands;
/// <summary>
/// 领取租户入驻审核命令。
/// </summary>
public sealed record ClaimTenantReviewCommand : IRequest<TenantReviewClaimDto>
{
/// <summary>
/// 租户 ID雪花算法
/// </summary>
[Required]
public long TenantId { get; init; }
}

View File

@@ -1,15 +0,0 @@
using MediatR;
namespace TakeoutSaaS.Application.App.Tenants.Commands;
/// <summary>
/// 生成租户主管理员重置链接令牌命令(平台超级管理员使用)。
/// </summary>
public sealed record CreateTenantAdminResetLinkTokenCommand : IRequest<string>
{
/// <summary>
/// 目标租户 ID。
/// </summary>
public required long TenantId { get; init; }
}

View File

@@ -1,56 +0,0 @@
using MediatR;
using TakeoutSaaS.Application.App.Tenants.Dto;
using TakeoutSaaS.Domain.Tenants.Enums;
namespace TakeoutSaaS.Application.App.Tenants.Commands;
/// <summary>
/// 创建租户账单命令。
/// </summary>
public sealed record CreateTenantBillingCommand : IRequest<TenantBillingDto>
{
/// <summary>
/// 租户 ID雪花算法
/// </summary>
public long TenantId { get; init; }
/// <summary>
/// 账单编号。
/// </summary>
public string StatementNo { get; init; } = string.Empty;
/// <summary>
/// 计费周期开始时间UTC
/// </summary>
public DateTime PeriodStart { get; init; }
/// <summary>
/// 计费周期结束时间UTC
/// </summary>
public DateTime PeriodEnd { get; init; }
/// <summary>
/// 应付金额。
/// </summary>
public decimal AmountDue { get; init; }
/// <summary>
/// 已付金额。
/// </summary>
public decimal AmountPaid { get; init; }
/// <summary>
/// 账单状态。
/// </summary>
public TenantBillingStatus Status { get; init; } = TenantBillingStatus.Pending;
/// <summary>
/// 到期日UTC
/// </summary>
public DateTime DueDate { get; init; }
/// <summary>
/// 账单明细 JSON。
/// </summary>
public string? LineItemsJson { get; init; }
}

View File

@@ -1,264 +0,0 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Application.App.Tenants.Dto;
using TakeoutSaaS.Domain.Tenants.Enums;
namespace TakeoutSaaS.Application.App.Tenants.Commands;
/// <summary>
/// 后台手动新增租户并直接入驻(创建租户 + 认证 + 订阅 + 管理员账号)。
/// </summary>
public sealed record CreateTenantManuallyCommand : IRequest<TenantDetailDto>
{
/// <summary>
/// 租户短编码,作为跨系统引用的唯一标识。
/// </summary>
[Required]
[StringLength(64)]
public string Code { get; init; } = string.Empty;
/// <summary>
/// 租户全称或品牌名称。
/// </summary>
[Required]
[StringLength(128)]
public string Name { get; init; } = string.Empty;
/// <summary>
/// 对外展示的简称。
/// </summary>
public string? ShortName { get; init; }
/// <summary>
/// 法人或公司主体名称。
/// </summary>
public string? LegalEntityName { get; init; }
/// <summary>
/// 所属行业,如餐饮、零售等。
/// </summary>
public string? Industry { get; init; }
/// <summary>
/// LOGO 图片地址。
/// </summary>
public string? LogoUrl { get; init; }
/// <summary>
/// 品牌海报或封面图。
/// </summary>
public string? CoverImageUrl { get; init; }
/// <summary>
/// 官网或主要宣传链接。
/// </summary>
public string? Website { get; init; }
/// <summary>
/// 所在国家/地区。
/// </summary>
public string? Country { get; init; }
/// <summary>
/// 所在省份或州。
/// </summary>
public string? Province { get; init; }
/// <summary>
/// 所在城市。
/// </summary>
public string? City { get; init; }
/// <summary>
/// 详细地址信息。
/// </summary>
public string? Address { 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 string? Tags { get; init; }
/// <summary>
/// 备注信息,用于运营记录特殊说明。
/// </summary>
public string? Remarks { get; init; }
/// <summary>
/// 最近一次暂停服务时间。
/// </summary>
public DateTime? SuspendedAt { get; init; }
/// <summary>
/// 暂停或终止的原因说明。
/// </summary>
public string? SuspensionReason { get; init; }
/// <summary>
/// 租户当前状态,默认 Active直接入驻
/// </summary>
public TenantStatus TenantStatus { get; init; } = TenantStatus.Active;
/// <summary>
/// 购买套餐 ID。
/// </summary>
[Required]
public long TenantPackageId { get; init; }
/// <summary>
/// 订阅时长(月)。
/// </summary>
[Range(1, int.MaxValue)]
public int DurationMonths { get; init; } = 12;
/// <summary>
/// 是否自动续费。
/// </summary>
public bool AutoRenew { get; init; } = false;
/// <summary>
/// 订阅生效时间UTC为空则立即生效。
/// </summary>
public DateTime? SubscriptionEffectiveFrom { get; init; }
/// <summary>
/// 下次计费时间UTC为空则默认等于到期时间。
/// </summary>
public DateTime? NextBillingDate { get; init; }
/// <summary>
/// 订阅状态,默认 Active。
/// </summary>
public SubscriptionStatus SubscriptionStatus { get; init; } = SubscriptionStatus.Active;
/// <summary>
/// 预定下次切换的套餐 ID。
/// </summary>
public long? ScheduledPackageId { get; init; }
/// <summary>
/// 订阅备注。
/// </summary>
public string? SubscriptionNotes { get; init; }
/// <summary>
/// 实名状态,默认 Approved直接通过
/// </summary>
public TenantVerificationStatus VerificationStatus { get; init; } = TenantVerificationStatus.Approved;
/// <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? LegalPersonIdFrontUrl { get; init; }
/// <summary>
/// 法人身份证国徽面图片地址。
/// </summary>
public string? LegalPersonIdBackUrl { get; init; }
/// <summary>
/// 对公账户户名。
/// </summary>
public string? BankAccountName { get; init; }
/// <summary>
/// 对公银行账号。
/// </summary>
public string? BankAccountNumber { get; init; }
/// <summary>
/// 开户行名称。
/// </summary>
public string? BankName { get; init; }
/// <summary>
/// 其他补充资料 JSON。
/// </summary>
public string? AdditionalDataJson { get; init; }
/// <summary>
/// 提交时间UTC为空则默认当前时间。
/// </summary>
public DateTime? SubmittedAt { get; init; }
/// <summary>
/// 审核时间UTC为空则默认当前时间。
/// </summary>
public DateTime? ReviewedAt { get; init; }
/// <summary>
/// 审核人姓名(展示用),为空则默认当前用户。
/// </summary>
public string? ReviewedByName { get; init; }
/// <summary>
/// 审核备注。
/// </summary>
public string? ReviewRemarks { get; init; }
/// <summary>
/// 租户管理员账号。
/// </summary>
[Required]
[StringLength(128)]
public string AdminAccount { get; init; } = string.Empty;
/// <summary>
/// 租户管理员显示名。
/// </summary>
[Required]
[StringLength(128)]
public string AdminDisplayName { get; init; } = string.Empty;
/// <summary>
/// 管理员初始密码(明文,仅用于创建时生成哈希,不会被持久化回传)。
/// </summary>
[Required]
[StringLength(128, MinimumLength = 6)]
public string AdminPassword { get; init; } = string.Empty;
/// <summary>
/// 管理员头像。
/// </summary>
public string? AdminAvatar { get; init; }
/// <summary>
/// 关联商户 ID若有
/// </summary>
public long? AdminMerchantId { get; init; }
}

View File

@@ -1,101 +0,0 @@
using MediatR;
using TakeoutSaaS.Application.App.Tenants.Dto;
using TakeoutSaaS.Domain.Tenants.Enums;
namespace TakeoutSaaS.Application.App.Tenants.Commands;
/// <summary>
/// 创建租户套餐命令。
/// </summary>
public sealed record CreateTenantPackageCommand : IRequest<TenantPackageDto>
{
/// <summary>
/// 套餐名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 套餐描述。
/// </summary>
public string? Description { get; init; }
/// <summary>
/// 套餐类型。
/// </summary>
public TenantPackageType PackageType { get; init; } = TenantPackageType.Standard;
/// <summary>
/// 月付价格。
/// </summary>
public decimal? MonthlyPrice { get; init; }
/// <summary>
/// 年付价格。
/// </summary>
public decimal? YearlyPrice { get; init; }
/// <summary>
/// 最大门店数。
/// </summary>
public int? MaxStoreCount { get; init; }
/// <summary>
/// 最大账号数。
/// </summary>
public int? MaxAccountCount { get; init; }
/// <summary>
/// 存储上限GB
/// </summary>
public int? MaxStorageGb { get; init; }
/// <summary>
/// 短信额度。
/// </summary>
public int? MaxSmsCredits { get; init; }
/// <summary>
/// 配送单上限。
/// </summary>
public int? MaxDeliveryOrders { get; init; }
/// <summary>
/// 权益明细 JSON。
/// </summary>
public string? FeaturePoliciesJson { get; init; }
/// <summary>
/// 是否仍启用(平台控制)。
/// </summary>
public bool IsActive { get; init; } = true;
/// <summary>
/// 是否对外可见(展示页/套餐列表可见性)。
/// </summary>
public bool IsPublicVisible { get; init; } = true;
/// <summary>
/// 是否允许新租户购买/选择(仅影响新购)。
/// </summary>
public bool IsAllowNewTenantPurchase { get; init; } = true;
/// <summary>
/// 发布状态(草稿/已发布)。
/// </summary>
public TenantPackagePublishStatus? PublishStatus { get; init; }
/// <summary>
/// 是否推荐展示(运营推荐标识)。
/// </summary>
public bool IsRecommended { get; init; }
/// <summary>
/// 套餐标签(用于展示与对比页)。
/// </summary>
public string[] Tags { get; init; } = [];
/// <summary>
/// 展示排序,数值越小越靠前。
/// </summary>
public int SortOrder { get; init; } = 0;
}

View File

@@ -1,38 +0,0 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Application.App.Tenants.Dto;
namespace TakeoutSaaS.Application.App.Tenants.Commands;
/// <summary>
/// 新建或续费订阅。
/// </summary>
public sealed record CreateTenantSubscriptionCommand : IRequest<TenantSubscriptionDto>
{
/// <summary>
/// 租户 ID雪花算法
/// </summary>
[Required]
public long TenantId { get; init; }
/// <summary>
/// 套餐 ID。
/// </summary>
[Required]
public long TenantPackageId { get; init; }
/// <summary>
/// 订阅时长(月)。
/// </summary>
public int DurationMonths { get; init; }
/// <summary>
/// 是否自动续费。
/// </summary>
public bool AutoRenew { get; init; }
/// <summary>
/// 备注信息。
/// </summary>
public string? Notes { get; init; }
}

View File

@@ -1,14 +0,0 @@
using MediatR;
namespace TakeoutSaaS.Application.App.Tenants.Commands;
/// <summary>
/// 删除租户套餐命令。
/// </summary>
public sealed record DeleteTenantPackageCommand : IRequest<bool>
{
/// <summary>
/// 套餐 ID。
/// </summary>
public long TenantPackageId { get; init; }
}

View File

@@ -1,30 +0,0 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Application.App.Tenants.Dto;
namespace TakeoutSaaS.Application.App.Tenants.Commands;
/// <summary>
/// 延期/赠送租户订阅时长(按当前订阅套餐续费)。
/// </summary>
public sealed record ExtendTenantSubscriptionCommand : IRequest<TenantSubscriptionDto>
{
/// <summary>
/// 租户 ID雪花算法
/// </summary>
[Required]
public long TenantId { get; init; }
/// <summary>
/// 赠送/延期时长(月)。
/// </summary>
[Range(1, 120)]
public int DurationMonths { get; init; }
/// <summary>
/// 备注信息。
/// </summary>
[MaxLength(256)]
public string? Notes { get; init; }
}

View File

@@ -1,17 +0,0 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Application.App.Tenants.Dto;
namespace TakeoutSaaS.Application.App.Tenants.Commands;
/// <summary>
/// 强制接管租户入驻审核命令(仅超级管理员可用)。
/// </summary>
public sealed record ForceClaimTenantReviewCommand : IRequest<TenantReviewClaimDto>
{
/// <summary>
/// 租户 ID雪花算法
/// </summary>
[Required]
public long TenantId { get; init; }
}

View File

@@ -1,25 +0,0 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Application.App.Tenants.Dto;
namespace TakeoutSaaS.Application.App.Tenants.Commands;
/// <summary>
/// 冻结租户(将租户状态置为暂停)。
/// </summary>
public sealed record FreezeTenantCommand : IRequest<TenantDto>
{
/// <summary>
/// 租户 ID雪花算法
/// </summary>
[Required]
public long TenantId { get; init; }
/// <summary>
/// 冻结原因。
/// </summary>
[Required]
[MaxLength(256)]
public string Reason { get; init; } = string.Empty;
}

View File

@@ -1,16 +0,0 @@
using MediatR;
using TakeoutSaaS.Application.Identity.Contracts;
namespace TakeoutSaaS.Application.App.Tenants.Commands;
/// <summary>
/// 伪装登录租户命令(平台超级管理员使用)。
/// </summary>
public sealed record ImpersonateTenantCommand : IRequest<TokenResponse>
{
/// <summary>
/// 目标租户 ID。
/// </summary>
public required long TenantId { get; init; }
}

View File

@@ -1,30 +0,0 @@
using MediatR;
using TakeoutSaaS.Application.App.Tenants.Dto;
namespace TakeoutSaaS.Application.App.Tenants.Commands;
/// <summary>
/// 标记租户账单已支付命令。
/// </summary>
public sealed record MarkTenantBillingPaidCommand : IRequest<TenantBillingDto?>
{
/// <summary>
/// 租户 ID雪花算法
/// </summary>
public long TenantId { get; init; }
/// <summary>
/// 账单 ID。
/// </summary>
public long BillingId { get; init; }
/// <summary>
/// 本次支付金额。
/// </summary>
public decimal AmountPaid { get; init; }
/// <summary>
/// 支付时间UTC
/// </summary>
public DateTime PaidAt { get; init; } = DateTime.UtcNow;
}

View File

@@ -1,71 +0,0 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Application.App.Tenants.Dto;
namespace TakeoutSaaS.Application.App.Tenants.Commands;
/// <summary>
/// 注册租户命令。
/// </summary>
public sealed record RegisterTenantCommand : IRequest<TenantDto>
{
/// <summary>
/// 唯一租户编码。
/// </summary>
[Required]
[StringLength(64)]
public string Code { get; init; } = string.Empty;
/// <summary>
/// 租户名称。
/// </summary>
[Required]
[StringLength(128)]
public string Name { get; init; } = string.Empty;
/// <summary>
/// 租户简称。
/// </summary>
public string? ShortName { get; init; }
/// <summary>
/// 行业类型。
/// </summary>
public string? Industry { get; init; }
/// <summary>
/// 联系人姓名。
/// </summary>
public string? ContactName { get; init; }
/// <summary>
/// 联系人电话。
/// </summary>
public string? ContactPhone { get; init; }
/// <summary>
/// 联系人邮箱。
/// </summary>
public string? ContactEmail { get; init; }
/// <summary>
/// 购买套餐 ID。
/// </summary>
[Required]
public long TenantPackageId { get; init; }
/// <summary>
/// 订阅时长(月),默认 12 个月。
/// </summary>
public int DurationMonths { get; init; } = 12;
/// <summary>
/// 是否自动续费。
/// </summary>
public bool AutoRenew { get; init; } = true;
/// <summary>
/// 生效时间UTC为空则立即生效。
/// </summary>
public DateTime? EffectiveFrom { get; init; }
}

View File

@@ -1,17 +0,0 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Application.App.Tenants.Dto;
namespace TakeoutSaaS.Application.App.Tenants.Commands;
/// <summary>
/// 释放租户入驻审核领取命令。
/// </summary>
public sealed record ReleaseTenantReviewClaimCommand : IRequest<TenantReviewClaimDto?>
{
/// <summary>
/// 租户 ID雪花算法
/// </summary>
[Required]
public long TenantId { get; init; }
}

View File

@@ -1,38 +0,0 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Application.App.Tenants.Dto;
using TakeoutSaaS.Domain.Common.Enums;
namespace TakeoutSaaS.Application.App.Tenants.Commands;
/// <summary>
/// 审核租户命令。
/// </summary>
public sealed record ReviewTenantCommand : IRequest<TenantDto>
{
/// <summary>
/// 租户 ID雪花算法
/// </summary>
[Required]
public long TenantId { get; init; }
/// <summary>
/// 是否通过审核。
/// </summary>
public bool Approve { get; init; }
/// <summary>
/// 审核备注或拒绝原因。
/// </summary>
public string? Reason { get; init; }
/// <summary>
/// 审核通过后续费时长(月)。
/// </summary>
public int? RenewMonths { get; init; }
/// <summary>
/// 经营模式(审核通过时必填)。
/// </summary>
public OperatingMode? OperatingMode { get; init; }
}

View File

@@ -1,24 +0,0 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Application.App.Tenants.Dto;
namespace TakeoutSaaS.Application.App.Tenants.Commands;
/// <summary>
/// 解冻租户(恢复租户状态)。
/// </summary>
public sealed record UnfreezeTenantCommand : IRequest<TenantDto>
{
/// <summary>
/// 租户 ID雪花算法
/// </summary>
[Required]
public long TenantId { get; init; }
/// <summary>
/// 解冻备注(可选)。
/// </summary>
[MaxLength(256)]
public string? Reason { get; init; }
}

View File

@@ -1,106 +0,0 @@
using MediatR;
using TakeoutSaaS.Application.App.Tenants.Dto;
using TakeoutSaaS.Domain.Tenants.Enums;
namespace TakeoutSaaS.Application.App.Tenants.Commands;
/// <summary>
/// 更新租户套餐命令。
/// </summary>
public sealed record UpdateTenantPackageCommand : IRequest<TenantPackageDto?>
{
/// <summary>
/// 套餐 ID。
/// </summary>
public long TenantPackageId { get; init; }
/// <summary>
/// 套餐名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 套餐描述。
/// </summary>
public string? Description { get; init; }
/// <summary>
/// 套餐类型。
/// </summary>
public TenantPackageType PackageType { get; init; } = TenantPackageType.Standard;
/// <summary>
/// 月付价格。
/// </summary>
public decimal? MonthlyPrice { get; init; }
/// <summary>
/// 年付价格。
/// </summary>
public decimal? YearlyPrice { get; init; }
/// <summary>
/// 最大门店数。
/// </summary>
public int? MaxStoreCount { get; init; }
/// <summary>
/// 最大账号数。
/// </summary>
public int? MaxAccountCount { get; init; }
/// <summary>
/// 存储上限GB
/// </summary>
public int? MaxStorageGb { get; init; }
/// <summary>
/// 短信额度。
/// </summary>
public int? MaxSmsCredits { get; init; }
/// <summary>
/// 配送单上限。
/// </summary>
public int? MaxDeliveryOrders { get; init; }
/// <summary>
/// 权益明细 JSON。
/// </summary>
public string? FeaturePoliciesJson { get; init; }
/// <summary>
/// 是否仍启用(平台控制)。
/// </summary>
public bool IsActive { get; init; } = true;
/// <summary>
/// 是否对外可见(展示页/套餐列表可见性)。
/// </summary>
public bool IsPublicVisible { get; init; } = true;
/// <summary>
/// 是否允许新租户购买/选择(仅影响新购)。
/// </summary>
public bool IsAllowNewTenantPurchase { get; init; } = true;
/// <summary>
/// 发布状态(草稿/已发布)。
/// </summary>
public TenantPackagePublishStatus? PublishStatus { get; init; }
/// <summary>
/// 是否推荐展示(运营推荐标识)。
/// </summary>
public bool IsRecommended { get; init; }
/// <summary>
/// 套餐标签(用于展示与对比页)。
/// </summary>
public string[] Tags { get; init; } = [];
/// <summary>
/// 展示排序,数值越小越靠前。
/// </summary>
public int SortOrder { get; init; } = 0;
}

View File

@@ -1,58 +0,0 @@
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

@@ -1,27 +0,0 @@
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; }
/// <summary>
/// 当前套餐详情。
/// </summary>
public TenantPackageDto? Package { get; init; }
}

View File

@@ -1,84 +0,0 @@
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.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>
/// 经营模式。
/// </summary>
public OperatingMode? OperatingMode { 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

@@ -1,50 +0,0 @@
using TakeoutSaaS.Domain.Tenants.Enums;
namespace TakeoutSaaS.Application.App.Tenants.Dto;
/// <summary>
/// 套餐使用租户 DTO用于平台查看套餐关联租户列表
/// </summary>
public sealed class TenantPackageTenantDto
{
/// <summary>
/// 租户 ID。
/// </summary>
public long TenantId { get; init; }
/// <summary>
/// 租户编码。
/// </summary>
public string Code { get; init; } = string.Empty;
/// <summary>
/// 租户名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 租户状态。
/// </summary>
public TenantStatus Status { get; init; }
/// <summary>
/// 联系人。
/// </summary>
public string? ContactName { get; init; }
/// <summary>
/// 联系电话。
/// </summary>
public string? ContactPhone { get; init; }
/// <summary>
/// 当前订阅生效时间UTC
/// </summary>
public DateTime SubscriptionEffectiveFrom { get; init; }
/// <summary>
/// 当前订阅到期时间UTC
/// </summary>
public DateTime SubscriptionEffectiveTo { get; init; }
}

View File

@@ -1,52 +0,0 @@
namespace TakeoutSaaS.Application.App.Tenants.Dto;
/// <summary>
/// 套餐使用统计 DTO订阅关联数量、使用租户数量
/// </summary>
public sealed class TenantPackageUsageDto
{
/// <summary>
/// 套餐 ID。
/// </summary>
public long TenantPackageId { get; init; }
/// <summary>
/// 当前有效订阅数量(以当前时间为准)。
/// </summary>
public int ActiveSubscriptionCount { get; init; }
/// <summary>
/// 当前使用租户数量(以当前时间为准,按租户去重)。
/// </summary>
public int ActiveTenantCount { get; init; }
/// <summary>
/// 历史总订阅记录数量(不含软删)。
/// </summary>
public int TotalSubscriptionCount { get; init; }
/// <summary>
/// MRRMonthly Recurring Revenue粗看按“当前有效订阅数 × 套餐月付等效价”估算。
/// </summary>
public decimal Mrr { get; init; }
/// <summary>
/// ARRAnnual Recurring Revenue粗看按“当前有效订阅数 × 套餐年付等效价”估算。
/// </summary>
public decimal Arr { get; init; }
/// <summary>
/// 未来 7 天内到期的使用租户数量(按租户去重,以当前时间为准,口径:有效订阅)。
/// </summary>
public int ExpiringTenantCount7Days { get; init; }
/// <summary>
/// 未来 15 天内到期的使用租户数量(按租户去重,以当前时间为准,口径:有效订阅)。
/// </summary>
public int ExpiringTenantCount15Days { get; init; }
/// <summary>
/// 未来 30 天内到期的使用租户数量(按租户去重,以当前时间为准,口径:有效订阅)。
/// </summary>
public int ExpiringTenantCount30Days { get; init; }
}

View File

@@ -1,38 +0,0 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Tenants.Dto;
/// <summary>
/// 租户审核领取信息 DTO。
/// </summary>
public sealed class TenantReviewClaimDto
{
/// <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 ClaimedBy { get; init; }
/// <summary>
/// 领取人名称。
/// </summary>
public string ClaimedByName { get; init; } = string.Empty;
/// <summary>
/// 领取时间。
/// </summary>
public DateTime ClaimedAt { get; init; }
}

View File

@@ -1,54 +0,0 @@
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

@@ -1,112 +0,0 @@
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;
/// <summary>
/// 租户初次绑定订阅处理器。
/// </summary>
public sealed class BindInitialTenantSubscriptionCommandHandler(
ITenantRepository tenantRepository,
IIdGenerator idGenerator,
ITenantProvider tenantProvider)
: IRequestHandler<BindInitialTenantSubscriptionCommand, TenantSubscriptionDto>
{
private static readonly ConcurrentDictionary<long, SemaphoreSlim> TenantLocks = new();
/// <inheritdoc />
public async Task<TenantSubscriptionDto> 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));
}

View File

@@ -1,76 +0,0 @@
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>
{
/// <inheritdoc />
public async Task<TenantSubscriptionDto> Handle(ChangeTenantSubscriptionPlanCommand request, CancellationToken cancellationToken)
{
// 1. 校验租户与订阅存在性
_ = 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;
// 2. 根据立即生效或排期设置目标套餐
if (request.Immediate)
{
subscription.TenantPackageId = request.TargetPackageId;
subscription.ScheduledPackageId = null;
}
else
{
subscription.ScheduledPackageId = request.TargetPackageId;
}
// 3. 更新订阅并记录变更历史
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);
// 4. 记录审计日志
await tenantRepository.AddAuditLogAsync(new TenantAuditLog
{
TenantId = subscription.TenantId,
Action = TenantAuditAction.SubscriptionPlanChanged,
Title = request.Immediate ? "套餐立即变更" : "套餐排期变更",
Description = request.Notes,
PreviousStatus = null,
CurrentStatus = null
}, cancellationToken);
// 5. 保存并返回 DTO
await tenantRepository.SaveChangesAsync(cancellationToken);
return subscription.ToSubscriptionDto()
?? throw new BusinessException(ErrorCodes.InternalServerError, "订阅更新失败");
}
}

View File

@@ -1,92 +0,0 @@
using MediatR;
using TakeoutSaaS.Application.App.Tenants.Commands;
using TakeoutSaaS.Application.App.Tenants.Dto;
using TakeoutSaaS.Application.Identity.Abstractions;
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;
/// <summary>
/// 领取租户入驻审核处理器。
/// </summary>
public sealed class ClaimTenantReviewCommandHandler(
ITenantRepository tenantRepository,
ICurrentUserAccessor currentUserAccessor,
IAdminAuthService adminAuthService)
: IRequestHandler<ClaimTenantReviewCommand, TenantReviewClaimDto>
{
/// <inheritdoc />
public async Task<TenantReviewClaimDto> Handle(ClaimTenantReviewCommand request, CancellationToken cancellationToken)
{
// 1. 校验租户存在
var tenant = await tenantRepository.FindByIdAsync(request.TenantId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在");
// 2. 查询是否已领取
var existingClaim = await tenantRepository.GetActiveReviewClaimAsync(request.TenantId, cancellationToken);
if (existingClaim != null)
{
if (existingClaim.ClaimedBy == currentUserAccessor.UserId)
{
return existingClaim.ToDto();
}
throw new BusinessException(ErrorCodes.Conflict, $"该审核已被 {existingClaim.ClaimedByName} 领取");
}
// 3. 获取当前用户显示名(用于展示快照)
var profile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
var displayName = string.IsNullOrWhiteSpace(profile.DisplayName)
? $"user:{currentUserAccessor.UserId}"
: profile.DisplayName;
// 4. 构造领取记录与审计日志
var now = DateTime.UtcNow;
var claim = new TenantReviewClaim
{
TenantId = request.TenantId,
ClaimedBy = currentUserAccessor.UserId,
ClaimedByName = displayName,
ClaimedAt = now,
ReleasedAt = null
};
var auditLog = new TenantAuditLog
{
TenantId = tenant.Id,
Action = TenantAuditAction.ReviewClaimed,
Title = "领取审核",
Description = $"领取人:{displayName}",
OperatorId = currentUserAccessor.UserId,
OperatorName = displayName,
PreviousStatus = tenant.Status,
CurrentStatus = tenant.Status
};
// 5. 写入领取记录(处理并发领取冲突)
var success = await tenantRepository.TryAddReviewClaimAsync(claim, auditLog, cancellationToken);
if (!success)
{
var current = await tenantRepository.GetActiveReviewClaimAsync(request.TenantId, cancellationToken);
if (current == null)
{
throw new BusinessException(ErrorCodes.Conflict, "审核领取失败,请刷新后重试");
}
if (current.ClaimedBy == currentUserAccessor.UserId)
{
return current.ToDto();
}
throw new BusinessException(ErrorCodes.Conflict, $"该审核已被 {current.ClaimedByName} 领取");
}
// 6. 返回领取结果
return claim.ToDto();
}
}

View File

@@ -1,94 +0,0 @@
using MediatR;
using TakeoutSaaS.Application.App.Tenants.Commands;
using TakeoutSaaS.Application.Identity.Abstractions;
using TakeoutSaaS.Domain.Identity.Repositories;
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;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
/// <summary>
/// 生成租户主管理员重置链接令牌处理器(平台超级管理员使用)。
/// </summary>
public sealed class CreateTenantAdminResetLinkTokenCommandHandler(
ITenantRepository tenantRepository,
ITenantProvider tenantProvider,
ITenantContextAccessor tenantContextAccessor,
IIdentityUserRepository identityUserRepository,
ICurrentUserAccessor currentUserAccessor,
IAdminAuthService adminAuthService,
IAdminPasswordResetTokenStore tokenStore)
: IRequestHandler<CreateTenantAdminResetLinkTokenCommand, string>
{
private const long PlatformRootTenantId = 1000000000001;
/// <inheritdoc />
public async Task<string> Handle(CreateTenantAdminResetLinkTokenCommand request, CancellationToken cancellationToken)
{
// 1. 校验仅允许平台超级管理员执行
var currentTenantId = tenantProvider.GetCurrentTenantId();
if (currentTenantId != PlatformRootTenantId)
{
throw new BusinessException(ErrorCodes.Forbidden, "仅平台超级管理员可生成重置链接");
}
// 2. 校验租户存在且存在主管理员
var tenant = await tenantRepository.FindByIdAsync(request.TenantId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在");
// 2.1 若缺少主管理员则自动回填(兼容历史数据)
if (!tenant.PrimaryOwnerUserId.HasValue || tenant.PrimaryOwnerUserId.Value == 0)
{
var originalContextForFix = tenantContextAccessor.Current;
tenantContextAccessor.Current = new TenantContext(tenant.Id, tenant.Code, "admin:reset-link:fix-owner");
try
{
var users = await identityUserRepository.SearchAsync(tenant.Id, keyword: null, cancellationToken);
var ownerCandidate = users.OrderBy(x => x.CreatedAt).FirstOrDefault();
if (ownerCandidate == null)
{
throw new BusinessException(ErrorCodes.BadRequest, "该租户未配置主管理员账号,且未找到可用管理员账号");
}
tenant.PrimaryOwnerUserId = ownerCandidate.Id;
await tenantRepository.UpdateTenantAsync(tenant, cancellationToken);
await tenantRepository.SaveChangesAsync(cancellationToken);
}
finally
{
tenantContextAccessor.Current = originalContextForFix;
}
}
// 3. 签发一次性重置令牌(默认 24 小时有效)
var token = await tokenStore.IssueAsync(tenant.PrimaryOwnerUserId.Value, DateTime.UtcNow.AddHours(24), cancellationToken);
// 4. 写入审计日志
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName)
? $"user:{currentUserAccessor.UserId}"
: operatorProfile.DisplayName;
var auditLog = new TenantAuditLog
{
TenantId = tenant.Id,
Action = TenantAuditAction.AdminResetLinkIssued,
Title = "生成重置链接",
Description = $"操作者:{operatorName}目标用户ID{tenant.PrimaryOwnerUserId.Value}",
OperatorId = currentUserAccessor.UserId,
OperatorName = operatorName,
PreviousStatus = tenant.Status,
CurrentStatus = tenant.Status
};
await tenantRepository.AddAuditLogAsync(auditLog, cancellationToken);
await tenantRepository.SaveChangesAsync(cancellationToken);
// 5. 返回令牌
return token;
}
}

View File

@@ -7,6 +7,7 @@ using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Security;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
@@ -15,6 +16,7 @@ namespace TakeoutSaaS.Application.App.Tenants.Handlers;
/// </summary>
public sealed class CreateTenantAnnouncementCommandHandler(
ITenantAnnouncementRepository announcementRepository,
ITenantProvider tenantProvider,
ICurrentUserAccessor currentUserAccessor)
: IRequestHandler<CreateTenantAnnouncementCommand, TenantAnnouncementDto>
{
@@ -26,12 +28,31 @@ public sealed class CreateTenantAnnouncementCommandHandler(
/// <returns>公告 DTO。</returns>
public async Task<TenantAnnouncementDto> Handle(CreateTenantAnnouncementCommand request, CancellationToken cancellationToken)
{
// 1. 校验标题与内容
// 1. 校验租户上下文(租户端禁止跨租户/平台公告)
var currentTenantId = tenantProvider.GetCurrentTenantId();
if (currentTenantId <= 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "缺少租户标识");
}
// 2. (空行后) 兼容旧调用:若传入 TenantId则必须与当前租户一致
if (request.TenantId > 0 && request.TenantId != currentTenantId)
{
throw new BusinessException(ErrorCodes.Forbidden, "无权创建其他租户公告");
}
// 3. (空行后) 校验标题与内容
if (string.IsNullOrWhiteSpace(request.Title) || string.IsNullOrWhiteSpace(request.Content))
{
throw new BusinessException(ErrorCodes.ValidationFailed, "公告标题和内容不能为空");
}
// 4. (空行后) 校验公告发布范围:租户端仅允许租户公告
if (request.PublisherScope != PublisherScope.Tenant)
{
throw new BusinessException(ErrorCodes.ValidationFailed, "租户端不允许创建平台公告");
}
if (string.IsNullOrWhiteSpace(request.TargetType))
{
throw new BusinessException(ErrorCodes.ValidationFailed, "目标受众类型不能为空");
@@ -42,13 +63,8 @@ public sealed class CreateTenantAnnouncementCommandHandler(
throw new BusinessException(ErrorCodes.ValidationFailed, "生效开始时间必须早于结束时间");
}
if (request.TenantId == 0 && request.PublisherScope != PublisherScope.Platform)
{
throw new BusinessException(ErrorCodes.ValidationFailed, "TenantId=0 仅允许平台公告");
}
// 2. 构建公告实体
var tenantId = request.PublisherScope == PublisherScope.Platform ? 0 : request.TenantId;
// 5. (空行后) 构建公告实体
var tenantId = currentTenantId;
var publisherUserId = currentUserAccessor.UserId == 0 ? (long?)null : currentUserAccessor.UserId;
var announcement = new TenantAnnouncement
{
@@ -66,7 +82,7 @@ public sealed class CreateTenantAnnouncementCommandHandler(
TargetParameters = request.TargetParameters
};
// 3. 持久化并返回 DTO
// 6. (空行后) 持久化并返回 DTO
await announcementRepository.AddAsync(announcement, cancellationToken);
await announcementRepository.SaveChangesAsync(cancellationToken);

View File

@@ -1,52 +0,0 @@
using MediatR;
using TakeoutSaaS.Application.App.Tenants.Commands;
using TakeoutSaaS.Application.App.Tenants.Dto;
using TakeoutSaaS.Domain.Tenants.Entities;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
/// <summary>
/// 创建租户账单处理器。
/// </summary>
public sealed class CreateTenantBillingCommandHandler(ITenantBillingRepository billingRepository)
: IRequestHandler<CreateTenantBillingCommand, TenantBillingDto>
{
/// <summary>
/// 处理创建租户账单请求。
/// </summary>
/// <param name="request">创建命令。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>账单 DTO。</returns>
public async Task<TenantBillingDto> Handle(CreateTenantBillingCommand request, CancellationToken cancellationToken)
{
// 1. 校验账单编号
if (string.IsNullOrWhiteSpace(request.StatementNo))
{
throw new BusinessException(ErrorCodes.BadRequest, "账单编号不能为空");
}
// 2. 构建账单实体
var bill = new TenantBillingStatement
{
TenantId = request.TenantId,
StatementNo = request.StatementNo.Trim(),
PeriodStart = request.PeriodStart,
PeriodEnd = request.PeriodEnd,
AmountDue = request.AmountDue,
AmountPaid = request.AmountPaid,
Status = request.Status,
DueDate = request.DueDate,
LineItemsJson = request.LineItemsJson
};
// 3. 持久化账单
await billingRepository.AddAsync(bill, cancellationToken);
await billingRepository.SaveChangesAsync(cancellationToken);
// 4. 返回 DTO
return bill.ToDto();
}
}

View File

@@ -1,269 +0,0 @@
using MediatR;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
using TakeoutSaaS.Application.App.Tenants.Commands;
using TakeoutSaaS.Application.App.Tenants.Dto;
using TakeoutSaaS.Application.Identity.Commands;
using TakeoutSaaS.Domain.Identity.Entities;
using TakeoutSaaS.Domain.Identity.Repositories;
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;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
/// <summary>
/// 后台手动新增租户处理器。
/// </summary>
public sealed class CreateTenantManuallyCommandHandler(
ITenantRepository tenantRepository,
ITenantPackageRepository tenantPackageRepository,
IIdentityUserRepository identityUserRepository,
IRoleRepository roleRepository,
IPasswordHasher<IdentityUser> passwordHasher,
IIdGenerator idGenerator,
IMediator mediator,
ITenantContextAccessor tenantContextAccessor,
ICurrentUserAccessor currentUserAccessor,
ILogger<CreateTenantManuallyCommandHandler> logger)
: IRequestHandler<CreateTenantManuallyCommand, TenantDetailDto>
{
/// <inheritdoc />
public async Task<TenantDetailDto> Handle(CreateTenantManuallyCommand request, CancellationToken cancellationToken)
{
// 1. 校验订阅时长
if (request.DurationMonths <= 0)
{
throw new BusinessException(ErrorCodes.ValidationFailed, "订阅时长必须大于 0");
}
// 2. 校验租户编码唯一性
var normalizedCode = request.Code.Trim();
if (await tenantRepository.ExistsByCodeAsync(normalizedCode, cancellationToken))
{
throw new BusinessException(ErrorCodes.Conflict, $"租户编码 {normalizedCode} 已存在");
}
// 3. 校验联系人手机号唯一性(仅当填写时)
if (!string.IsNullOrWhiteSpace(request.ContactPhone))
{
var normalizedPhone = request.ContactPhone.Trim();
if (await tenantRepository.ExistsByContactPhoneAsync(normalizedPhone, cancellationToken))
{
throw new BusinessException(ErrorCodes.Conflict, $"手机号 {normalizedPhone} 已注册");
}
}
// 4. 校验管理员账号唯一性
var normalizedAccount = request.AdminAccount.Trim();
if (await identityUserRepository.ExistsByAccountAsync(normalizedAccount, cancellationToken))
{
throw new BusinessException(ErrorCodes.Conflict, $"账号 {normalizedAccount} 已存在");
}
// 5. 校验套餐存在且可用
var package = await tenantPackageRepository.FindByIdAsync(request.TenantPackageId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "套餐不存在");
if (!package.IsActive)
{
throw new BusinessException(ErrorCodes.BadRequest, "套餐未启用,无法绑定订阅");
}
// 6. 计算订阅生效与到期时间UTC
var now = DateTime.UtcNow;
var subscriptionEffectiveFrom = request.SubscriptionEffectiveFrom ?? now;
var subscriptionEffectiveTo = subscriptionEffectiveFrom.AddMonths(request.DurationMonths);
// 7. 构建租户与订阅
var tenantId = idGenerator.NextId();
var tenant = new Tenant
{
Id = tenantId,
Code = normalizedCode,
Name = request.Name.Trim(),
ShortName = request.ShortName,
LegalEntityName = request.LegalEntityName,
Industry = request.Industry,
LogoUrl = request.LogoUrl,
CoverImageUrl = request.CoverImageUrl,
Website = request.Website,
Country = request.Country,
Province = request.Province,
City = request.City,
Address = request.Address,
ContactName = request.ContactName,
ContactPhone = string.IsNullOrWhiteSpace(request.ContactPhone) ? null : request.ContactPhone.Trim(),
ContactEmail = request.ContactEmail,
Status = request.TenantStatus,
EffectiveFrom = subscriptionEffectiveFrom,
EffectiveTo = subscriptionEffectiveTo,
SuspendedAt = request.SuspendedAt,
SuspensionReason = request.SuspensionReason,
Tags = request.Tags,
Remarks = request.Remarks
};
// 8. 构建订阅实体
var subscription = new TenantSubscription
{
Id = idGenerator.NextId(),
TenantId = tenantId,
TenantPackageId = request.TenantPackageId,
EffectiveFrom = subscriptionEffectiveFrom,
EffectiveTo = subscriptionEffectiveTo,
NextBillingDate = request.NextBillingDate ?? subscriptionEffectiveTo,
Status = request.SubscriptionStatus,
AutoRenew = request.AutoRenew,
ScheduledPackageId = request.ScheduledPackageId,
Notes = request.SubscriptionNotes
};
// 9. 构建认证资料(默认直接通过)
var actorName = currentUserAccessor.IsAuthenticated
? $"user:{currentUserAccessor.UserId}"
: "system";
var verification = new TenantVerificationProfile
{
Id = idGenerator.NextId(),
TenantId = tenantId,
Status = request.VerificationStatus,
BusinessLicenseNumber = request.BusinessLicenseNumber,
BusinessLicenseUrl = request.BusinessLicenseUrl,
LegalPersonName = request.LegalPersonName,
LegalPersonIdNumber = request.LegalPersonIdNumber,
LegalPersonIdFrontUrl = request.LegalPersonIdFrontUrl,
LegalPersonIdBackUrl = request.LegalPersonIdBackUrl,
BankAccountName = request.BankAccountName,
BankAccountNumber = request.BankAccountNumber,
BankName = request.BankName,
AdditionalDataJson = request.AdditionalDataJson,
SubmittedAt = request.SubmittedAt ?? now,
ReviewedAt = request.ReviewedAt ?? now,
ReviewedBy = currentUserAccessor.UserId == 0 ? null : currentUserAccessor.UserId,
ReviewedByName = string.IsNullOrWhiteSpace(request.ReviewedByName) ? actorName : request.ReviewedByName,
ReviewRemarks = request.ReviewRemarks
};
// 10. 写入审计日志与订阅历史
await tenantRepository.AddAuditLogAsync(new TenantAuditLog
{
TenantId = tenantId,
Action = TenantAuditAction.RegistrationSubmitted,
Title = "后台手动创建",
Description = $"绑定套餐 {request.TenantPackageId},订阅 {request.DurationMonths} 月",
OperatorId = currentUserAccessor.UserId == 0 ? null : currentUserAccessor.UserId,
OperatorName = actorName,
PreviousStatus = null,
CurrentStatus = tenant.Status
}, cancellationToken);
await tenantRepository.AddAuditLogAsync(new TenantAuditLog
{
TenantId = tenantId,
Action = TenantAuditAction.SubscriptionUpdated,
Title = "订阅初始化",
Description = $"生效:{subscription.EffectiveFrom:yyyy-MM-dd HH:mm:ss},到期:{subscription.EffectiveTo:yyyy-MM-dd HH:mm:ss}",
OperatorId = currentUserAccessor.UserId == 0 ? null : currentUserAccessor.UserId,
OperatorName = actorName,
PreviousStatus = null,
CurrentStatus = tenant.Status
}, cancellationToken);
await tenantRepository.AddAuditLogAsync(new TenantAuditLog
{
TenantId = tenantId,
Action = TenantAuditAction.VerificationApproved,
Title = "认证已通过",
Description = request.ReviewRemarks,
OperatorId = currentUserAccessor.UserId == 0 ? null : currentUserAccessor.UserId,
OperatorName = actorName,
PreviousStatus = null,
CurrentStatus = tenant.Status
}, cancellationToken);
await tenantRepository.AddSubscriptionHistoryAsync(new TenantSubscriptionHistory
{
TenantId = tenantId,
TenantSubscriptionId = subscription.Id,
FromPackageId = request.TenantPackageId,
ToPackageId = request.TenantPackageId,
ChangeType = SubscriptionChangeType.New,
EffectiveFrom = subscription.EffectiveFrom,
EffectiveTo = subscription.EffectiveTo,
Amount = null,
Currency = null,
Notes = request.SubscriptionNotes
}, cancellationToken);
// 11. 持久化租户、订阅与认证资料
await tenantRepository.AddTenantAsync(tenant, cancellationToken);
await tenantRepository.AddSubscriptionAsync(subscription, cancellationToken);
await tenantRepository.UpsertVerificationProfileAsync(verification, cancellationToken);
await tenantRepository.SaveChangesAsync(cancellationToken);
// 12. 临时切换租户上下文,保证身份与权限写入正确
var previousContext = tenantContextAccessor.Current;
tenantContextAccessor.Current = new TenantContext(tenant.Id, tenant.Code, "manual-create");
try
{
// 13. 创建租户管理员账号
var adminUser = new IdentityUser
{
TenantId = tenant.Id,
Account = normalizedAccount,
DisplayName = request.AdminDisplayName.Trim(),
PasswordHash = string.Empty,
Phone = string.IsNullOrWhiteSpace(request.ContactPhone) ? null : request.ContactPhone.Trim(),
Email = string.IsNullOrWhiteSpace(request.ContactEmail) ? null : request.ContactEmail.Trim(),
MerchantId = request.AdminMerchantId,
Avatar = request.AdminAvatar
};
adminUser.PasswordHash = passwordHasher.HashPassword(adminUser, request.AdminPassword);
await identityUserRepository.AddAsync(adminUser, cancellationToken);
await identityUserRepository.SaveChangesAsync(cancellationToken);
// 14. 初始化租户管理员角色模板并绑定角色
await mediator.Send(new InitializeRoleTemplatesCommand
{
TemplateCodes = new[] { "tenant-admin" }
}, cancellationToken);
var tenantAdminRole = await roleRepository.FindByCodeAsync("tenant-admin", tenant.Id, cancellationToken);
if (tenantAdminRole != null)
{
await mediator.Send(new AssignUserRolesCommand
{
UserId = adminUser.Id,
RoleIds = new[] { tenantAdminRole.Id }
}, cancellationToken);
}
// 15. 回写租户所有者账号
tenant.PrimaryOwnerUserId = adminUser.Id;
await tenantRepository.UpdateTenantAsync(tenant, cancellationToken);
await tenantRepository.SaveChangesAsync(cancellationToken);
}
finally
{
// 16. 恢复上下文
tenantContextAccessor.Current = previousContext;
}
// 17. 返回创建结果
logger.LogInformation("已后台手动创建租户 {TenantCode}", tenant.Code);
return new TenantDetailDto
{
Tenant = TenantMapping.ToDto(tenant, subscription, verification),
Verification = verification.ToVerificationDto(),
Subscription = subscription.ToSubscriptionDto(),
Package = package.ToDto()
};
}
}

View File

@@ -1,56 +0,0 @@
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;
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
/// <summary>
/// 创建租户套餐处理器。
/// </summary>
public sealed class CreateTenantPackageCommandHandler(ITenantPackageRepository packageRepository)
: IRequestHandler<CreateTenantPackageCommand, TenantPackageDto>
{
/// <inheritdoc />
public async Task<TenantPackageDto> Handle(CreateTenantPackageCommand request, CancellationToken cancellationToken)
{
// 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 ?? TenantPackagePublishStatus.Draft,
IsRecommended = request.IsRecommended,
Tags = request.Tags ?? [],
SortOrder = request.SortOrder
};
// 3. 持久化并返回
await packageRepository.AddAsync(package, cancellationToken);
await packageRepository.SaveChangesAsync(cancellationToken);
return package.ToDto();
}
}

View File

@@ -1,86 +0,0 @@
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>
{
/// <inheritdoc />
public async Task<TenantSubscriptionDto> Handle(CreateTenantSubscriptionCommand 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);
var from = current?.EffectiveTo ?? tenant.EffectiveTo ?? DateTime.UtcNow;
var effectiveFrom = from > DateTime.UtcNow ? from : DateTime.UtcNow;
var effectiveTo = effectiveFrom.AddMonths(request.DurationMonths);
// 3. 创建订阅实体
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
};
// 4. 记录订阅与历史
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);
// 5. 记录审计
await tenantRepository.AddAuditLogAsync(new TenantAuditLog
{
TenantId = tenant.Id,
Action = TenantAuditAction.SubscriptionUpdated,
Title = current == null ? "创建订阅" : "续费订阅",
Description = $"套餐 {request.TenantPackageId} 时长 {request.DurationMonths} 月"
}, cancellationToken);
// 6. 保存变更
await tenantRepository.SaveChangesAsync(cancellationToken);
// 7. 返回 DTO
return subscription.ToSubscriptionDto()
?? throw new BusinessException(ErrorCodes.InternalServerError, "订阅生成失败");
}
}

View File

@@ -1,13 +1,18 @@
using MediatR;
using TakeoutSaaS.Application.App.Tenants.Commands;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
/// <summary>
/// 删除公告处理器。
/// </summary>
public sealed class DeleteTenantAnnouncementCommandHandler(ITenantAnnouncementRepository announcementRepository)
public sealed class DeleteTenantAnnouncementCommandHandler(
ITenantAnnouncementRepository announcementRepository,
ITenantProvider tenantProvider)
: IRequestHandler<DeleteTenantAnnouncementCommand, bool>
{
/// <summary>
@@ -18,11 +23,24 @@ public sealed class DeleteTenantAnnouncementCommandHandler(ITenantAnnouncementRe
/// <returns>执行结果。</returns>
public async Task<bool> Handle(DeleteTenantAnnouncementCommand request, CancellationToken cancellationToken)
{
// 1. 删除公告
await announcementRepository.DeleteAsync(request.TenantId, request.AnnouncementId, cancellationToken);
// 1. 校验租户上下文(租户端禁止跨租户)
var currentTenantId = tenantProvider.GetCurrentTenantId();
if (currentTenantId <= 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "缺少租户标识");
}
// 2. (空行后) 兼容旧调用:若传入 TenantId则必须与当前租户一致
if (request.TenantId > 0 && request.TenantId != currentTenantId)
{
throw new BusinessException(ErrorCodes.Forbidden, "无权删除其他租户公告");
}
// 3. (空行后) 删除公告
await announcementRepository.DeleteAsync(currentTenantId, request.AnnouncementId, cancellationToken);
await announcementRepository.SaveChangesAsync(cancellationToken);
// 2. 返回执行结果
// 4. (空行后) 返回执行结果
return true;
}
}

View File

@@ -1,23 +0,0 @@
using MediatR;
using TakeoutSaaS.Application.App.Tenants.Commands;
using TakeoutSaaS.Domain.Tenants.Repositories;
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
/// <summary>
/// 删除租户套餐处理器。
/// </summary>
public sealed class DeleteTenantPackageCommandHandler(ITenantPackageRepository packageRepository)
: IRequestHandler<DeleteTenantPackageCommand, bool>
{
/// <inheritdoc />
public async Task<bool> Handle(DeleteTenantPackageCommand request, CancellationToken cancellationToken)
{
// 1. 删除套餐
await packageRepository.DeleteAsync(request.TenantPackageId, cancellationToken);
await packageRepository.SaveChangesAsync(cancellationToken);
// 2. 返回执行结果
return true;
}
}

View File

@@ -1,108 +0,0 @@
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;
/// <summary>
/// 延期/赠送订阅处理器(按当前订阅套餐续费)。
/// </summary>
public sealed class ExtendTenantSubscriptionCommandHandler(
ITenantRepository tenantRepository,
IIdGenerator idGenerator,
ICurrentUserAccessor currentUserAccessor)
: IRequestHandler<ExtendTenantSubscriptionCommand, TenantSubscriptionDto>
{
/// <inheritdoc />
public async Task<TenantSubscriptionDto> 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, "订阅生成失败");
}
}

View File

@@ -1,106 +0,0 @@
using MediatR;
using TakeoutSaaS.Application.App.Tenants.Commands;
using TakeoutSaaS.Application.App.Tenants.Dto;
using TakeoutSaaS.Application.Identity.Abstractions;
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;
/// <summary>
/// 强制接管租户入驻审核处理器。
/// </summary>
public sealed class ForceClaimTenantReviewCommandHandler(
ITenantRepository tenantRepository,
ICurrentUserAccessor currentUserAccessor,
IAdminAuthService adminAuthService)
: IRequestHandler<ForceClaimTenantReviewCommand, TenantReviewClaimDto>
{
/// <inheritdoc />
public async Task<TenantReviewClaimDto> Handle(ForceClaimTenantReviewCommand request, CancellationToken cancellationToken)
{
// 1. 校验租户存在
var tenant = await tenantRepository.FindByIdAsync(request.TenantId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在");
// 2. 获取当前用户显示名(用于展示快照)
var profile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
var displayName = string.IsNullOrWhiteSpace(profile.DisplayName)
? $"user:{currentUserAccessor.UserId}"
: profile.DisplayName;
// 3. 读取当前领取记录(可跟踪用于更新)
var claim = await tenantRepository.FindActiveReviewClaimAsync(request.TenantId, cancellationToken);
if (claim == null)
{
// 4. 未领取则直接创建(记录强制接管动作)
var now = DateTime.UtcNow;
var created = new TenantReviewClaim
{
TenantId = request.TenantId,
ClaimedBy = currentUserAccessor.UserId,
ClaimedByName = displayName,
ClaimedAt = now,
ReleasedAt = null
};
var auditLog = new TenantAuditLog
{
TenantId = tenant.Id,
Action = TenantAuditAction.ReviewForceClaimed,
Title = "强制接管审核",
Description = $"接管人:{displayName}",
OperatorId = currentUserAccessor.UserId,
OperatorName = displayName,
PreviousStatus = tenant.Status,
CurrentStatus = tenant.Status
};
var success = await tenantRepository.TryAddReviewClaimAsync(created, auditLog, cancellationToken);
if (!success)
{
var current = await tenantRepository.GetActiveReviewClaimAsync(request.TenantId, cancellationToken);
if (current != null)
{
return current.ToDto();
}
throw new BusinessException(ErrorCodes.Conflict, "审核接管失败,请刷新后重试");
}
return created.ToDto();
}
// 5. 已由自己领取则直接返回
if (claim.ClaimedBy == currentUserAccessor.UserId)
{
return claim.ToDto();
}
// 6. 更新领取人并记录审计
var previousOwner = claim.ClaimedByName;
claim.ClaimedBy = currentUserAccessor.UserId;
claim.ClaimedByName = displayName;
claim.ClaimedAt = DateTime.UtcNow;
await tenantRepository.UpdateReviewClaimAsync(claim, cancellationToken);
await tenantRepository.AddAuditLogAsync(new TenantAuditLog
{
TenantId = tenant.Id,
Action = TenantAuditAction.ReviewForceClaimed,
Title = "强制接管审核",
Description = $"原领取人:{previousOwner},接管人:{displayName}",
OperatorId = currentUserAccessor.UserId,
OperatorName = displayName,
PreviousStatus = tenant.Status,
CurrentStatus = tenant.Status
}, cancellationToken);
await tenantRepository.SaveChangesAsync(cancellationToken);
return claim.ToDto();
}
}

View File

@@ -1,78 +0,0 @@
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;
/// <summary>
/// 冻结租户处理器。
/// </summary>
public sealed class FreezeTenantCommandHandler(
ITenantRepository tenantRepository,
ICurrentUserAccessor currentUserAccessor)
: IRequestHandler<FreezeTenantCommand, TenantDto>
{
/// <inheritdoc />
public async Task<TenantDto> 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);
}
}

View File

@@ -1,32 +0,0 @@
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>>
{
/// <inheritdoc />
public async Task<PagedResult<TenantAuditLogDto>> Handle(GetTenantAuditLogsQuery request, CancellationToken cancellationToken)
{
// 1. 查询审核日志
var logs = await tenantRepository.GetAuditLogsAsync(request.TenantId, cancellationToken);
var total = logs.Count;
// 2. 分页映射
var paged = logs
.Skip((request.Page - 1) * request.PageSize)
.Take(request.PageSize)
.Select(TenantMapping.ToDto)
.ToList();
// 3. 返回分页结果
return new PagedResult<TenantAuditLogDto>(paged, request.Page, request.PageSize, total);
}
}

View File

@@ -2,13 +2,18 @@ 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;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
/// <summary>
/// 账单详情查询处理器。
/// </summary>
public sealed class GetTenantBillQueryHandler(ITenantBillingRepository billingRepository)
public sealed class GetTenantBillQueryHandler(
ITenantBillingRepository billingRepository,
ITenantProvider tenantProvider)
: IRequestHandler<GetTenantBillQuery, TenantBillingDto?>
{
/// <summary>
@@ -19,10 +24,23 @@ public sealed class GetTenantBillQueryHandler(ITenantBillingRepository billingRe
/// <returns>账单 DTO 或 null。</returns>
public async Task<TenantBillingDto?> Handle(GetTenantBillQuery request, CancellationToken cancellationToken)
{
// 1. 查询账单
var bill = await billingRepository.FindByIdAsync(request.TenantId, request.BillingId, cancellationToken);
// 1. 校验租户上下文(租户端禁止跨租户)
var currentTenantId = tenantProvider.GetCurrentTenantId();
if (currentTenantId <= 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "缺少租户标识");
}
// 2. 返回 DTO 或 null
// 2. (空行后) 兼容旧调用:若传入 TenantId则必须与当前租户一致
if (request.TenantId > 0 && request.TenantId != currentTenantId)
{
throw new BusinessException(ErrorCodes.Forbidden, "无权查询其他租户账单");
}
// 3. (空行后) 查询账单
var bill = await billingRepository.FindByIdAsync(currentTenantId, request.BillingId, cancellationToken);
// 4. (空行后) 返回 DTO 或 null
return bill?.ToDto();
}
}

View File

@@ -1,43 +0,0 @@
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,
ITenantPackageRepository tenantPackageRepository)
: IRequestHandler<GetTenantByIdQuery, TenantDetailDto>
{
/// <inheritdoc />
public async Task<TenantDetailDto> Handle(GetTenantByIdQuery request, CancellationToken cancellationToken)
{
// 1. 查询租户
var tenant = await tenantRepository.FindByIdAsync(request.TenantId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在");
// 2. 查询订阅与认证
var subscription = await tenantRepository.GetActiveSubscriptionAsync(request.TenantId, cancellationToken);
var verification = await tenantRepository.GetVerificationProfileAsync(request.TenantId, cancellationToken);
// 3. 查询当前套餐
var package = subscription == null
? null
: await tenantPackageRepository.FindByIdAsync(subscription.TenantPackageId, cancellationToken);
// 4. 组装返回
return new TenantDetailDto
{
Tenant = TenantMapping.ToDto(tenant, subscription, verification),
Verification = verification.ToVerificationDto(),
Subscription = subscription.ToSubscriptionDto(),
Package = package?.ToDto()
};
}
}

View File

@@ -1,230 +0,0 @@
using MediatR;
using System.Data;
using System.Data.Common;
using TakeoutSaaS.Application.App.Tenants.Dto;
using TakeoutSaaS.Application.App.Tenants.Queries;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Data;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
/// <summary>
/// 查询套餐当前使用租户列表处理器。
/// </summary>
public sealed class GetTenantPackageTenantsQueryHandler(IDapperExecutor dapperExecutor)
: IRequestHandler<GetTenantPackageTenantsQuery, PagedResult<TenantPackageTenantDto>>
{
/// <inheritdoc />
public async Task<PagedResult<TenantPackageTenantDto>> Handle(GetTenantPackageTenantsQuery request, CancellationToken cancellationToken)
{
// 1. 参数规范化
var page = request.Page <= 0 ? 1 : request.Page;
var pageSize = request.PageSize <= 0 ? 20 : request.PageSize;
var keyword = string.IsNullOrWhiteSpace(request.Keyword) ? null : request.Keyword.Trim();
// 2. 以当前时间为准筛选“有效订阅”
var now = DateTime.UtcNow;
var expiringDays = request.ExpiringWithinDays is > 0 ? request.ExpiringWithinDays : null;
var expiryEnd = expiringDays.HasValue ? now.AddDays(expiringDays.Value) : (DateTime?)null;
var offset = (page - 1) * pageSize;
// 3. 查询总数 + 列表
return await dapperExecutor.QueryAsync(
DatabaseConstants.AppDataSource,
DatabaseConnectionRole.Read,
async (connection, token) =>
{
// 3.1 统计总数
var total = await ExecuteScalarIntAsync(
connection,
BuildCountSql(),
[
("packageId", request.TenantPackageId),
("now", now),
("expiryEnd", expiryEnd),
("keyword", keyword)
],
token);
// 3.2 查询列表
var listSql = BuildListSql(expiryEnd.HasValue);
await using var listCommand = CreateCommand(
connection,
listSql,
[
("packageId", request.TenantPackageId),
("now", now),
("expiryEnd", expiryEnd),
("keyword", keyword),
("offset", offset),
("limit", pageSize)
]);
await using var reader = await listCommand.ExecuteReaderAsync(token);
var items = new List<TenantPackageTenantDto>();
while (await reader.ReadAsync(token))
{
items.Add(new TenantPackageTenantDto
{
TenantId = reader.GetInt64(0),
Code = reader.GetString(1),
Name = reader.GetString(2),
Status = (TenantStatus)reader.GetInt32(3),
ContactName = reader.IsDBNull(4) ? null : reader.GetString(4),
ContactPhone = reader.IsDBNull(5) ? null : reader.GetString(5),
SubscriptionEffectiveFrom = reader.GetDateTime(6),
SubscriptionEffectiveTo = reader.GetDateTime(7)
});
}
// 3.3 返回分页
return new PagedResult<TenantPackageTenantDto>(items, page, pageSize, total);
},
cancellationToken);
}
private static string BuildCountSql()
{
return """
select count(*)
from public.tenants t
where t."DeletedAt" is null
and (
@keyword::text is null
or t."Name" ilike ('%' || @keyword::text || '%')
or t."Code" ilike ('%' || @keyword::text || '%')
or coalesce(t."ContactName", '') ilike ('%' || @keyword::text || '%')
or coalesce(t."ContactPhone", '') ilike ('%' || @keyword::text || '%')
)
and exists (
select 1
from public.tenant_subscriptions s
where s."DeletedAt" is null
and s."TenantId" = t."Id"
and s."TenantPackageId" = @packageId
and s."Status" = 1
and s."EffectiveFrom" <= @now
and s."EffectiveTo" >= @now
and (
@expiryEnd::timestamp with time zone is null
or s."EffectiveTo" <= @expiryEnd
)
);
""";
}
private static string BuildListSql(bool orderByExpiryAsc)
{
if (orderByExpiryAsc)
{
return """
select
t."Id" as "TenantId",
t."Code",
t."Name",
t."Status",
t."ContactName",
t."ContactPhone",
s."EffectiveFrom",
s."EffectiveTo"
from public.tenants t
join lateral (
select s."EffectiveFrom", s."EffectiveTo"
from public.tenant_subscriptions s
where s."DeletedAt" is null
and s."TenantId" = t."Id"
and s."TenantPackageId" = @packageId
and s."Status" = 1
and s."EffectiveFrom" <= @now
and s."EffectiveTo" >= @now
and (
@expiryEnd::timestamp with time zone is null
or s."EffectiveTo" <= @expiryEnd
)
order by s."EffectiveTo" asc
limit 1
) s on true
where t."DeletedAt" is null
and (
@keyword::text is null
or t."Name" ilike ('%' || @keyword::text || '%')
or t."Code" ilike ('%' || @keyword::text || '%')
or coalesce(t."ContactName", '') ilike ('%' || @keyword::text || '%')
or coalesce(t."ContactPhone", '') ilike ('%' || @keyword::text || '%')
)
order by s."EffectiveTo" asc
offset @offset
limit @limit;
""";
}
return """
select
t."Id" as "TenantId",
t."Code",
t."Name",
t."Status",
t."ContactName",
t."ContactPhone",
s."EffectiveFrom",
s."EffectiveTo"
from public.tenants t
join lateral (
select s."EffectiveFrom", s."EffectiveTo"
from public.tenant_subscriptions s
where s."DeletedAt" is null
and s."TenantId" = t."Id"
and s."TenantPackageId" = @packageId
and s."Status" = 1
and s."EffectiveFrom" <= @now
and s."EffectiveTo" >= @now
and (
@expiryEnd::timestamp with time zone is null
or s."EffectiveTo" <= @expiryEnd
)
order by s."EffectiveTo" desc
limit 1
) s on true
where t."DeletedAt" is null
and (
@keyword::text is null
or t."Name" ilike ('%' || @keyword::text || '%')
or t."Code" ilike ('%' || @keyword::text || '%')
or coalesce(t."ContactName", '') ilike ('%' || @keyword::text || '%')
or coalesce(t."ContactPhone", '') ilike ('%' || @keyword::text || '%')
)
order by t."CreatedAt" desc
offset @offset
limit @limit;
""";
}
private static async Task<int> ExecuteScalarIntAsync(
IDbConnection connection,
string sql,
(string Name, object? Value)[] parameters,
CancellationToken cancellationToken)
{
await using var command = CreateCommand(connection, sql, parameters);
var result = await command.ExecuteScalarAsync(cancellationToken);
return result is null or DBNull ? 0 : Convert.ToInt32(result);
}
private static DbCommand CreateCommand(IDbConnection connection, string sql, (string Name, object? Value)[] parameters)
{
var command = connection.CreateCommand();
command.CommandText = sql;
foreach (var (name, value) in parameters)
{
var p = command.CreateParameter();
p.ParameterName = name;
p.Value = value ?? DBNull.Value;
command.Parameters.Add(p);
}
return (DbCommand)command;
}
}

View File

@@ -1,153 +0,0 @@
using MediatR;
using System.Data;
using System.Data.Common;
using TakeoutSaaS.Application.App.Tenants.Dto;
using TakeoutSaaS.Application.App.Tenants.Queries;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Data;
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
/// <summary>
/// 查询套餐使用统计处理器。
/// </summary>
public sealed class GetTenantPackageUsagesQueryHandler(IDapperExecutor dapperExecutor)
: IRequestHandler<GetTenantPackageUsagesQuery, IReadOnlyList<TenantPackageUsageDto>>
{
/// <inheritdoc />
public async Task<IReadOnlyList<TenantPackageUsageDto>> Handle(GetTenantPackageUsagesQuery request, CancellationToken cancellationToken)
{
// 1. 规范化输入
var ids = request.TenantPackageIds?
.Where(x => x > 0)
.Distinct()
.ToArray();
// 2. 构造 SQL以当前时间为准统计“有效订阅/使用租户/到期分布”)
var now = DateTime.UtcNow;
var date7 = now.AddDays(7);
var date15 = now.AddDays(15);
var date30 = now.AddDays(30);
var sql = BuildSql(ids, out var parameters, now, date7, date15, date30);
// 3. 查询统计结果
return await dapperExecutor.QueryAsync(
DatabaseConstants.AppDataSource,
DatabaseConnectionRole.Read,
async (connection, token) =>
{
await using var command = CreateCommand(connection, sql, parameters);
await using var reader = await command.ExecuteReaderAsync(token);
var list = new List<TenantPackageUsageDto>();
// 4. 逐行读取
while (await reader.ReadAsync(token))
{
list.Add(new TenantPackageUsageDto
{
TenantPackageId = reader.GetInt64(0),
ActiveSubscriptionCount = reader.GetInt32(1),
ActiveTenantCount = reader.GetInt32(2),
TotalSubscriptionCount = reader.GetInt32(3),
Mrr = reader.IsDBNull(4) ? 0m : reader.GetDecimal(4),
Arr = reader.IsDBNull(5) ? 0m : reader.GetDecimal(5),
ExpiringTenantCount7Days = reader.IsDBNull(6) ? 0 : reader.GetInt32(6),
ExpiringTenantCount15Days = reader.IsDBNull(7) ? 0 : reader.GetInt32(7),
ExpiringTenantCount30Days = reader.IsDBNull(8) ? 0 : reader.GetInt32(8)
});
}
return (IReadOnlyList<TenantPackageUsageDto>)list;
},
cancellationToken);
}
private static string BuildSql(
long[]? ids,
out (string Name, object? Value)[] parameters,
DateTime now,
DateTime date7,
DateTime date15,
DateTime date30)
{
// 1. 基础查询:先按订阅表聚合,再回连套餐表计算 MRR/ARR
var builder = new System.Text.StringBuilder();
builder.AppendLine("""
with stats as (
select
"TenantPackageId" as "TenantPackageId",
count(*) filter (where "Status" = 1 and "EffectiveFrom" <= @now and "EffectiveTo" >= @now) as "ActiveSubscriptionCount",
count(distinct "TenantId") filter (where "Status" = 1 and "EffectiveFrom" <= @now and "EffectiveTo" >= @now) as "ActiveTenantCount",
count(distinct "TenantId") filter (where "Status" = 1 and "EffectiveFrom" <= @now and "EffectiveTo" >= @now and "EffectiveTo" <= @date7) as "ExpiringTenantCount7Days",
count(distinct "TenantId") filter (where "Status" = 1 and "EffectiveFrom" <= @now and "EffectiveTo" >= @now and "EffectiveTo" <= @date15) as "ExpiringTenantCount15Days",
count(distinct "TenantId") filter (where "Status" = 1 and "EffectiveFrom" <= @now and "EffectiveTo" >= @now and "EffectiveTo" <= @date30) as "ExpiringTenantCount30Days",
count(*) as "TotalSubscriptionCount"
from public.tenant_subscriptions
where "DeletedAt" is null
""");
var list = new List<(string Name, object? Value)>
{
("now", now),
("date7", date7),
("date15", date15),
("date30", date30)
};
// 2. 可选按套餐 ID 过滤
if (ids is { Length: > 0 })
{
builder.Append(" and \"TenantPackageId\" in (");
for (var i = 0; i < ids.Length; i++)
{
if (i > 0)
{
builder.Append(',');
}
var name = $"p{i}";
builder.Append($"@{name}");
list.Add((name, ids[i]));
}
builder.AppendLine(")");
}
// 3. 分组与回连套餐表
builder.AppendLine("""
group by "TenantPackageId"
)
select
s."TenantPackageId" as "TenantPackageId",
s."ActiveSubscriptionCount" as "ActiveSubscriptionCount",
s."ActiveTenantCount" as "ActiveTenantCount",
s."TotalSubscriptionCount" as "TotalSubscriptionCount",
(s."ActiveSubscriptionCount"::numeric * coalesce(p."MonthlyPrice", (p."YearlyPrice" / 12.0), 0))::numeric(18, 2) as "Mrr",
(s."ActiveSubscriptionCount"::numeric * coalesce(p."YearlyPrice", (p."MonthlyPrice" * 12), 0))::numeric(18, 2) as "Arr",
s."ExpiringTenantCount7Days" as "ExpiringTenantCount7Days",
s."ExpiringTenantCount15Days" as "ExpiringTenantCount15Days",
s."ExpiringTenantCount30Days" as "ExpiringTenantCount30Days"
from stats s
left join public.tenant_packages p on p."Id" = s."TenantPackageId" and p."DeletedAt" is null;
""");
parameters = list.ToArray();
return builder.ToString();
}
private static DbCommand CreateCommand(IDbConnection connection, string sql, (string Name, object? Value)[] parameters)
{
var command = connection.CreateCommand();
command.CommandText = sql;
foreach (var (name, value) in parameters)
{
var p = command.CreateParameter();
p.ParameterName = name;
p.Value = value ?? DBNull.Value;
command.Parameters.Add(p);
}
return (DbCommand)command;
}
}

View File

@@ -5,27 +5,43 @@ using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
/// <summary>
/// 租户入住进度查询处理器。
/// </summary>
public sealed class GetTenantProgressQueryHandler(ITenantRepository tenantRepository)
public sealed class GetTenantProgressQueryHandler(
ITenantRepository tenantRepository,
ITenantProvider tenantProvider)
: IRequestHandler<GetTenantProgressQuery, TenantProgressDto>
{
/// <inheritdoc />
public async Task<TenantProgressDto> Handle(GetTenantProgressQuery request, CancellationToken cancellationToken)
{
// 1. 查询租户
var tenant = await tenantRepository.FindByIdAsync(request.TenantId, cancellationToken)
// 1. 校验租户上下文(租户端禁止跨租户
var currentTenantId = tenantProvider.GetCurrentTenantId();
if (currentTenantId <= 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "缺少租户标识");
}
// 2. (空行后) 兼容旧调用:若传入 TenantId则必须与当前租户一致
if (request.TenantId > 0 && request.TenantId != currentTenantId)
{
throw new BusinessException(ErrorCodes.Forbidden, "无权查询其他租户进度");
}
// 3. (空行后) 查询租户
var tenant = await tenantRepository.FindByIdAsync(currentTenantId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在");
// 2. 查询订阅与实名
var subscription = await tenantRepository.GetActiveSubscriptionAsync(request.TenantId, cancellationToken);
var verification = await tenantRepository.GetVerificationProfileAsync(request.TenantId, cancellationToken);
// 4. (空行后) 查询订阅与实名
var subscription = await tenantRepository.GetActiveSubscriptionAsync(currentTenantId, cancellationToken);
var verification = await tenantRepository.GetVerificationProfileAsync(currentTenantId, cancellationToken);
// 3. 组装进度信息
// 5. (空行后) 组装进度信息
return new TenantProgressDto
{
TenantId = tenant.Id,

View File

@@ -9,6 +9,7 @@ using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Data;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
@@ -17,22 +18,36 @@ namespace TakeoutSaaS.Application.App.Tenants.Handlers;
/// </summary>
public sealed class GetTenantQuotaUsageHistoryQueryHandler(
ITenantRepository tenantRepository,
ITenantProvider tenantProvider,
IDapperExecutor dapperExecutor)
: IRequestHandler<GetTenantQuotaUsageHistoryQuery, PagedResult<QuotaUsageHistoryDto>>
{
/// <inheritdoc />
public async Task<PagedResult<QuotaUsageHistoryDto>> Handle(GetTenantQuotaUsageHistoryQuery request, CancellationToken cancellationToken)
{
// 1. 校验租户存在
_ = await tenantRepository.FindByIdAsync(request.TenantId, cancellationToken)
// 1. 校验租户上下文(租户端禁止跨租户)
var currentTenantId = tenantProvider.GetCurrentTenantId();
if (currentTenantId <= 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "缺少租户标识");
}
// 2. (空行后) 兼容旧调用:若传入 TenantId则必须与当前租户一致
if (request.TenantId > 0 && request.TenantId != currentTenantId)
{
throw new BusinessException(ErrorCodes.Forbidden, "无权查询其他租户配额历史");
}
// 3. (空行后) 校验租户存在
_ = await tenantRepository.FindByIdAsync(currentTenantId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在");
// 2. 规范化分页
// 4. (空行后) 规范化分页
var page = request.Page <= 0 ? 1 : request.Page;
var pageSize = request.PageSize is <= 0 or > 100 ? 10 : request.PageSize;
var offset = (page - 1) * pageSize;
// 3. 查询总数 + 列表
// 5. (空行后) 查询总数 + 列表
return await dapperExecutor.QueryAsync(
DatabaseConstants.AppDataSource,
DatabaseConnectionRole.Read,
@@ -43,7 +58,7 @@ public sealed class GetTenantQuotaUsageHistoryQueryHandler(
connection,
BuildCountSql(),
[
("tenantId", request.TenantId),
("tenantId", currentTenantId),
("quotaType", request.QuotaType.HasValue ? (int)request.QuotaType.Value : null),
("startDate", request.StartDate),
("endDate", request.EndDate)
@@ -55,7 +70,7 @@ public sealed class GetTenantQuotaUsageHistoryQueryHandler(
connection,
BuildListSql(),
[
("tenantId", request.TenantId),
("tenantId", currentTenantId),
("quotaType", request.QuotaType.HasValue ? (int)request.QuotaType.Value : null),
("startDate", request.StartDate),
("endDate", request.EndDate),

View File

@@ -1,21 +0,0 @@
using MediatR;
using TakeoutSaaS.Application.App.Tenants.Dto;
using TakeoutSaaS.Application.App.Tenants.Queries;
using TakeoutSaaS.Domain.Tenants.Repositories;
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
/// <summary>
/// 获取租户审核领取信息查询处理器。
/// </summary>
public sealed class GetTenantReviewClaimQueryHandler(ITenantRepository tenantRepository)
: IRequestHandler<GetTenantReviewClaimQuery, TenantReviewClaimDto?>
{
/// <inheritdoc />
public async Task<TenantReviewClaimDto?> Handle(GetTenantReviewClaimQuery request, CancellationToken cancellationToken)
{
// 1. 查询当前领取信息(未领取返回 null
var claim = await tenantRepository.GetActiveReviewClaimAsync(request.TenantId, cancellationToken);
return claim?.ToDto();
}
}

View File

@@ -1,109 +0,0 @@
using MediatR;
using TakeoutSaaS.Application.App.Tenants.Commands;
using TakeoutSaaS.Application.Identity.Abstractions;
using TakeoutSaaS.Application.Identity.Contracts;
using TakeoutSaaS.Domain.Identity.Repositories;
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;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
/// <summary>
/// 伪装登录租户处理器(平台超级管理员使用)。
/// </summary>
public sealed class ImpersonateTenantCommandHandler(
ITenantRepository tenantRepository,
ITenantProvider tenantProvider,
ITenantContextAccessor tenantContextAccessor,
IIdentityUserRepository identityUserRepository,
ICurrentUserAccessor currentUserAccessor,
IAdminAuthService adminAuthService,
IJwtTokenService jwtTokenService)
: IRequestHandler<ImpersonateTenantCommand, TokenResponse>
{
private const long PlatformRootTenantId = 1000000000001;
/// <inheritdoc />
public async Task<TokenResponse> Handle(ImpersonateTenantCommand request, CancellationToken cancellationToken)
{
// 1. 校验仅允许平台超级管理员执行
var currentTenantId = tenantProvider.GetCurrentTenantId();
if (currentTenantId != PlatformRootTenantId)
{
throw new BusinessException(ErrorCodes.Forbidden, "仅平台超级管理员可执行伪装登录");
}
// 2. 读取操作者信息(在平台租户上下文内)
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName)
? $"user:{currentUserAccessor.UserId}"
: operatorProfile.DisplayName;
// 2. 校验租户存在且存在主管理员
var tenant = await tenantRepository.FindByIdAsync(request.TenantId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在");
// 2.1 若缺少主管理员则自动回填(兼容历史数据)
if (!tenant.PrimaryOwnerUserId.HasValue || tenant.PrimaryOwnerUserId.Value == 0)
{
var originalContextForFix = tenantContextAccessor.Current;
tenantContextAccessor.Current = new TenantContext(tenant.Id, tenant.Code, "admin:impersonate:fix-owner");
try
{
var users = await identityUserRepository.SearchAsync(tenant.Id, keyword: null, cancellationToken);
var ownerCandidate = users.OrderBy(x => x.CreatedAt).FirstOrDefault();
if (ownerCandidate == null)
{
throw new BusinessException(ErrorCodes.BadRequest, "该租户未配置主管理员账号,且未找到可用管理员账号");
}
tenant.PrimaryOwnerUserId = ownerCandidate.Id;
await tenantRepository.UpdateTenantAsync(tenant, cancellationToken);
await tenantRepository.SaveChangesAsync(cancellationToken);
}
finally
{
tenantContextAccessor.Current = originalContextForFix;
}
}
// 3. 进入目标租户上下文以读取租户内用户(避免多租户查询过滤导致找不到用户)
var originalTenantContext = tenantContextAccessor.Current;
tenantContextAccessor.Current = new TenantContext(tenant.Id, null, "admin:impersonate");
try
{
// 4. 为租户主管理员签发令牌
var targetProfile = await adminAuthService.GetProfileAsync(tenant.PrimaryOwnerUserId.Value, cancellationToken);
var token = await jwtTokenService.CreateTokensAsync(targetProfile, false, cancellationToken);
// 5. 恢复租户上下文后写入审计日志
tenantContextAccessor.Current = originalTenantContext;
var auditLog = new TenantAuditLog
{
TenantId = tenant.Id,
Action = TenantAuditAction.ImpersonatedLogin,
Title = "伪装登录",
Description = $"操作者:{operatorName},目标账号:{targetProfile.Account}",
OperatorId = currentUserAccessor.UserId,
OperatorName = operatorName,
PreviousStatus = tenant.Status,
CurrentStatus = tenant.Status
};
await tenantRepository.AddAuditLogAsync(auditLog, cancellationToken);
await tenantRepository.SaveChangesAsync(cancellationToken);
// 6. 返回令牌
return token;
}
finally
{
// 7. 确保恢复租户上下文
tenantContextAccessor.Current = originalTenantContext;
}
}
}

View File

@@ -1,42 +0,0 @@
using MediatR;
using TakeoutSaaS.Application.App.Tenants.Commands;
using TakeoutSaaS.Application.App.Tenants.Dto;
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Domain.Tenants.Repositories;
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
/// <summary>
/// 标记账单支付处理器。
/// </summary>
public sealed class MarkTenantBillingPaidCommandHandler(ITenantBillingRepository billingRepository)
: IRequestHandler<MarkTenantBillingPaidCommand, TenantBillingDto?>
{
/// <summary>
/// 标记账单支付。
/// </summary>
/// <param name="request">标记命令。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>账单 DTO 或 null。</returns>
public async Task<TenantBillingDto?> Handle(MarkTenantBillingPaidCommand request, CancellationToken cancellationToken)
{
// 1. 查询账单
var bill = await billingRepository.FindByIdAsync(request.TenantId, request.BillingId, cancellationToken);
if (bill == null)
{
return null;
}
// 2. 更新支付状态
bill.AmountPaid = request.AmountPaid;
bill.Status = TenantBillingStatus.Paid;
bill.DueDate = bill.DueDate;
// 3. 持久化变更
await billingRepository.UpdateAsync(bill, cancellationToken);
await billingRepository.SaveChangesAsync(cancellationToken);
// 4. 返回 DTO
return bill.ToDto();
}
}

View File

@@ -2,13 +2,18 @@ using MediatR;
using TakeoutSaaS.Application.App.Tenants.Commands;
using TakeoutSaaS.Application.App.Tenants.Dto;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
/// <summary>
/// 标记通知已读处理器。
/// </summary>
public sealed class MarkTenantNotificationReadCommandHandler(ITenantNotificationRepository notificationRepository)
public sealed class MarkTenantNotificationReadCommandHandler(
ITenantNotificationRepository notificationRepository,
ITenantProvider tenantProvider)
: IRequestHandler<MarkTenantNotificationReadCommand, TenantNotificationDto?>
{
/// <summary>
@@ -19,14 +24,27 @@ public sealed class MarkTenantNotificationReadCommandHandler(ITenantNotification
/// <returns>通知 DTO 或 null。</returns>
public async Task<TenantNotificationDto?> Handle(MarkTenantNotificationReadCommand request, CancellationToken cancellationToken)
{
// 1. 查询通知
var notification = await notificationRepository.FindByIdAsync(request.TenantId, request.NotificationId, cancellationToken);
// 1. 校验租户上下文(租户端禁止跨租户)
var currentTenantId = tenantProvider.GetCurrentTenantId();
if (currentTenantId <= 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "缺少租户标识");
}
// 2. (空行后) 兼容旧调用:若传入 TenantId则必须与当前租户一致
if (request.TenantId > 0 && request.TenantId != currentTenantId)
{
throw new BusinessException(ErrorCodes.Forbidden, "无权标记其他租户通知");
}
// 3. (空行后) 查询通知
var notification = await notificationRepository.FindByIdAsync(currentTenantId, request.NotificationId, cancellationToken);
if (notification == null)
{
return null;
}
// 2. 若未读则标记已读
// 4. (空行后) 若未读则标记已读
if (notification.ReadAt == null)
{
notification.ReadAt = DateTime.UtcNow;
@@ -34,7 +52,7 @@ public sealed class MarkTenantNotificationReadCommandHandler(ITenantNotification
await notificationRepository.SaveChangesAsync(cancellationToken);
}
// 3. 返回 DTO
// 5. (空行后) 返回 DTO
return notification.ToDto();
}
}

View File

@@ -1,92 +0,0 @@
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>
{
/// <inheritdoc />
public async Task<TenantDto> Handle(RegisterTenantCommand request, CancellationToken cancellationToken)
{
// 1. 校验订阅时长
if (request.DurationMonths <= 0)
{
throw new BusinessException(ErrorCodes.BadRequest, "订阅时长必须大于 0");
}
// 2. 检查租户编码唯一性
if (await tenantRepository.ExistsByCodeAsync(request.Code, cancellationToken))
{
throw new BusinessException(ErrorCodes.Conflict, $"租户编码 {request.Code} 已存在");
}
// 3. 计算生效时间
var now = DateTime.UtcNow;
var effectiveFrom = request.EffectiveFrom ?? now;
var effectiveTo = effectiveFrom.AddMonths(request.DurationMonths);
// 4. 构建租户实体
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
};
// 5. 构建订阅实体
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"
};
// 6. 持久化租户、订阅和审计日志
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);
// 7. 记录日志
logger.LogInformation("已注册租户 {TenantCode}", tenant.Code);
// 8. 返回 DTO
return TenantMapping.ToDto(tenant, subscription, null);
}
}

View File

@@ -1,67 +0,0 @@
using MediatR;
using TakeoutSaaS.Application.App.Tenants.Commands;
using TakeoutSaaS.Application.App.Tenants.Dto;
using TakeoutSaaS.Application.Identity.Abstractions;
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;
/// <summary>
/// 释放租户入驻审核领取处理器。
/// </summary>
public sealed class ReleaseTenantReviewClaimCommandHandler(
ITenantRepository tenantRepository,
ICurrentUserAccessor currentUserAccessor,
IAdminAuthService adminAuthService)
: IRequestHandler<ReleaseTenantReviewClaimCommand, TenantReviewClaimDto?>
{
/// <inheritdoc />
public async Task<TenantReviewClaimDto?> Handle(ReleaseTenantReviewClaimCommand request, CancellationToken cancellationToken)
{
// 1. 校验租户存在
var tenant = await tenantRepository.FindByIdAsync(request.TenantId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在");
// 2. 查询当前领取记录
var claim = await tenantRepository.FindActiveReviewClaimAsync(request.TenantId, cancellationToken);
if (claim == null)
{
return null;
}
// 3. 非领取人不允许释放(如需接管请使用强制接管)
if (claim.ClaimedBy != currentUserAccessor.UserId)
{
throw new BusinessException(ErrorCodes.Conflict, $"该审核已被 {claim.ClaimedByName} 领取");
}
// 4. 释放领取并记录审计
var profile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
var displayName = string.IsNullOrWhiteSpace(profile.DisplayName)
? $"user:{currentUserAccessor.UserId}"
: profile.DisplayName;
claim.ReleasedAt = DateTime.UtcNow;
await tenantRepository.UpdateReviewClaimAsync(claim, cancellationToken);
await tenantRepository.AddAuditLogAsync(new TenantAuditLog
{
TenantId = tenant.Id,
Action = TenantAuditAction.ReviewClaimReleased,
Title = "释放审核",
Description = $"释放人:{displayName}",
OperatorId = currentUserAccessor.UserId,
OperatorName = displayName,
PreviousStatus = tenant.Status,
CurrentStatus = tenant.Status
}, cancellationToken);
await tenantRepository.SaveChangesAsync(cancellationToken);
return claim.ToDto();
}
}

Some files were not shown because too many files have changed in this diff Show More