refactor: 移除平台侧能力并收紧租户隔离
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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?>;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 => "自动激活",
|
||||
_ => "未知操作"
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
/// MRR(Monthly Recurring Revenue)粗看:按“当前有效订阅数 × 套餐月付等效价”估算。
|
||||
/// </summary>
|
||||
public decimal Mrr { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// ARR(Annual 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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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, "订阅更新失败");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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, "订阅生成失败");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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, "订阅生成失败");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user