feat:商户管理
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
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,13 +1,12 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Merchants.Dto;
|
||||
using TakeoutSaaS.Domain.Merchants.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Merchants.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 更新商户命令。
|
||||
/// </summary>
|
||||
public sealed record UpdateMerchantCommand : IRequest<MerchantDto?>
|
||||
public sealed record UpdateMerchantCommand : IRequest<UpdateMerchantResultDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 商户 ID。
|
||||
@@ -15,29 +14,29 @@ public sealed record UpdateMerchantCommand : IRequest<MerchantDto?>
|
||||
public long MerchantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 品牌名称。
|
||||
/// 商户名称。
|
||||
/// </summary>
|
||||
public string BrandName { get; init; } = string.Empty;
|
||||
public string? Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 品牌简称。
|
||||
/// 营业执照号。
|
||||
/// </summary>
|
||||
public string? BrandAlias { get; init; }
|
||||
public string? LicenseNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Logo 地址。
|
||||
/// 法人或负责人。
|
||||
/// </summary>
|
||||
public string? LogoUrl { get; init; }
|
||||
public string? LegalRepresentative { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 品类。
|
||||
/// 注册地址。
|
||||
/// </summary>
|
||||
public string? Category { get; init; }
|
||||
public string? RegisteredAddress { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 联系电话。
|
||||
/// </summary>
|
||||
public string ContactPhone { get; init; } = string.Empty;
|
||||
public string? ContactPhone { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 联系邮箱。
|
||||
@@ -45,7 +44,7 @@ public sealed record UpdateMerchantCommand : IRequest<MerchantDto?>
|
||||
public string? ContactEmail { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 入驻状态。
|
||||
/// 并发控制版本。
|
||||
/// </summary>
|
||||
public MerchantStatus Status { get; init; }
|
||||
public byte[] RowVersion { get; init; } = Array.Empty<byte>();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
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; }
|
||||
}
|
||||
@@ -26,6 +26,12 @@ public sealed class MerchantAuditLogDto
|
||||
/// </summary>
|
||||
public MerchantAuditAction Action { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 操作人 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
|
||||
public long? OperatorId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 标题。
|
||||
/// </summary>
|
||||
@@ -41,6 +47,11 @@ public sealed class MerchantAuditLogDto
|
||||
/// </summary>
|
||||
public string? OperatorName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 操作 IP。
|
||||
/// </summary>
|
||||
public string? IpAddress { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间。
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Merchants.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 商户变更日志 DTO。
|
||||
/// </summary>
|
||||
public sealed class MerchantChangeLogDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 日志 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 变更字段。
|
||||
/// </summary>
|
||||
public string FieldName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 变更前值。
|
||||
/// </summary>
|
||||
public string? OldValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 变更后值。
|
||||
/// </summary>
|
||||
public string? NewValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 变更人 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
|
||||
public long? ChangedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 变更人名称。
|
||||
/// </summary>
|
||||
public string? ChangedByName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 变更时间。
|
||||
/// </summary>
|
||||
public DateTime ChangedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 变更原因。
|
||||
/// </summary>
|
||||
public string? ChangeReason { get; init; }
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
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>
|
||||
@@ -6,17 +11,117 @@ namespace TakeoutSaaS.Application.App.Merchants.Dto;
|
||||
public sealed class MerchantDetailDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 基础信息。
|
||||
/// 商户 ID。
|
||||
/// </summary>
|
||||
public MerchantDto Merchant { get; init; } = new();
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 证照列表。
|
||||
/// 租户 ID。
|
||||
/// </summary>
|
||||
public IReadOnlyList<MerchantDocumentDto> Documents { get; init; } = [];
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 合同列表。
|
||||
/// 租户名称。
|
||||
/// </summary>
|
||||
public IReadOnlyList<MerchantContractDto> Contracts { get; init; } = [];
|
||||
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 string? LegalRepresentative { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 注册地址。
|
||||
/// </summary>
|
||||
public string? RegisteredAddress { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 联系电话。
|
||||
/// </summary>
|
||||
public string? ContactPhone { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 联系邮箱。
|
||||
/// </summary>
|
||||
public string? ContactEmail { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 审核状态。
|
||||
/// </summary>
|
||||
public MerchantStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 业务冻结标记。
|
||||
/// </summary>
|
||||
public bool IsFrozen { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 冻结原因。
|
||||
/// </summary>
|
||||
public string? FrozenReason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 冻结时间。
|
||||
/// </summary>
|
||||
public DateTime? FrozenAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 审核通过人。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
|
||||
public long? ApprovedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 审核通过时间。
|
||||
/// </summary>
|
||||
public DateTime? ApprovedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 门店列表。
|
||||
/// </summary>
|
||||
public IReadOnlyList<StoreDto> Stores { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 并发控制版本。
|
||||
/// </summary>
|
||||
public byte[] RowVersion { get; init; } = Array.Empty<byte>();
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间。
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建人。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
|
||||
public long? CreatedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间。
|
||||
/// </summary>
|
||||
public DateTime? UpdatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 更新人。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
|
||||
public long? UpdatedBy { get; init; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
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 MerchantListItemDto
|
||||
{
|
||||
/// <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 bool IsFrozen { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 门店数量。
|
||||
/// </summary>
|
||||
public int StoreCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间。
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间。
|
||||
/// </summary>
|
||||
public DateTime? UpdatedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using TakeoutSaaS.Domain.Stores.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Merchants.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 商户详情门店 DTO。
|
||||
/// </summary>
|
||||
public sealed class StoreDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 门店名称。
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 营业执照号(主体不一致模式使用)。
|
||||
/// </summary>
|
||||
public string? LicenseNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 联系电话。
|
||||
/// </summary>
|
||||
public string? ContactPhone { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 门店地址。
|
||||
/// </summary>
|
||||
public string? Address { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 门店状态。
|
||||
/// </summary>
|
||||
public StoreStatus Status { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace TakeoutSaaS.Application.App.Merchants.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 商户更新结果 DTO。
|
||||
/// </summary>
|
||||
public sealed class UpdateMerchantResultDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 更新后的商户详情。
|
||||
/// </summary>
|
||||
public MerchantDetailDto Merchant { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 是否触发重新审核。
|
||||
/// </summary>
|
||||
public bool RequiresReview { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Merchants.Queries;
|
||||
using TakeoutSaaS.Application.Identity;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Domain.Merchants.Repositories;
|
||||
using TakeoutSaaS.Domain.Merchants.Services;
|
||||
using TakeoutSaaS.Domain.Stores.Repositories;
|
||||
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.Merchants.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 导出商户 PDF 处理器。
|
||||
/// </summary>
|
||||
public sealed class ExportMerchantPdfQueryHandler(
|
||||
IMerchantRepository merchantRepository,
|
||||
IStoreRepository storeRepository,
|
||||
ITenantRepository tenantRepository,
|
||||
IMerchantExportService exportService,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
IAdminAuthService adminAuthService)
|
||||
: IRequestHandler<ExportMerchantPdfQuery, byte[]>
|
||||
{
|
||||
public async Task<byte[]> Handle(ExportMerchantPdfQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
|
||||
|
||||
var merchant = isSuperAdmin
|
||||
? await merchantRepository.FindByIdAsync(request.MerchantId, cancellationToken)
|
||||
: await merchantRepository.FindByIdAsync(request.MerchantId, currentTenantId, cancellationToken);
|
||||
|
||||
if (merchant == null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "商户不存在");
|
||||
}
|
||||
|
||||
if (!isSuperAdmin && merchant.TenantId != currentTenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "禁止导出其他租户商户");
|
||||
}
|
||||
|
||||
var stores = await storeRepository.GetByMerchantIdAsync(merchant.Id, merchant.TenantId, cancellationToken);
|
||||
var auditLogs = await merchantRepository.GetAuditLogsAsync(merchant.Id, merchant.TenantId, cancellationToken);
|
||||
var tenant = await tenantRepository.FindByIdAsync(merchant.TenantId, cancellationToken);
|
||||
|
||||
return await exportService.ExportToPdfAsync(merchant, tenant?.Name, stores, auditLogs, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Merchants.Dto;
|
||||
using TakeoutSaaS.Application.App.Merchants.Queries;
|
||||
using TakeoutSaaS.Application.Identity;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
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 GetMerchantAuditHistoryQueryHandler(
|
||||
IMerchantRepository merchantRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
IAdminAuthService adminAuthService)
|
||||
: IRequestHandler<GetMerchantAuditHistoryQuery, IReadOnlyList<MerchantAuditLogDto>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<MerchantAuditLogDto>> Handle(
|
||||
GetMerchantAuditHistoryQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
|
||||
|
||||
var merchant = isSuperAdmin
|
||||
? await merchantRepository.FindByIdAsync(request.MerchantId, cancellationToken)
|
||||
: await merchantRepository.FindByIdAsync(request.MerchantId, currentTenantId, cancellationToken);
|
||||
|
||||
if (merchant == null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "商户不存在");
|
||||
}
|
||||
|
||||
if (!isSuperAdmin && merchant.TenantId != currentTenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "禁止访问其他租户的商户审核历史");
|
||||
}
|
||||
|
||||
var logs = await merchantRepository.GetAuditLogsAsync(merchant.Id, merchant.TenantId, cancellationToken);
|
||||
return logs.Select(MerchantMapping.ToDto).ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Merchants.Dto;
|
||||
using TakeoutSaaS.Application.App.Merchants.Queries;
|
||||
using TakeoutSaaS.Application.Identity;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
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 GetMerchantChangeHistoryQueryHandler(
|
||||
IMerchantRepository merchantRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
IAdminAuthService adminAuthService)
|
||||
: IRequestHandler<GetMerchantChangeHistoryQuery, IReadOnlyList<MerchantChangeLogDto>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<MerchantChangeLogDto>> Handle(
|
||||
GetMerchantChangeHistoryQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
|
||||
|
||||
var merchant = isSuperAdmin
|
||||
? await merchantRepository.FindByIdAsync(request.MerchantId, cancellationToken)
|
||||
: await merchantRepository.FindByIdAsync(request.MerchantId, currentTenantId, cancellationToken);
|
||||
|
||||
if (merchant == null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "商户不存在");
|
||||
}
|
||||
|
||||
if (!isSuperAdmin && merchant.TenantId != currentTenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "禁止访问其他租户的商户变更历史");
|
||||
}
|
||||
|
||||
var logs = await merchantRepository.GetChangeLogsAsync(merchant.Id, merchant.TenantId, request.FieldName, cancellationToken);
|
||||
return logs.Select(MerchantMapping.ToDto).ToList();
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Merchants.Dto;
|
||||
using TakeoutSaaS.Application.App.Merchants.Queries;
|
||||
using TakeoutSaaS.Application.Identity;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Domain.Merchants.Repositories;
|
||||
using TakeoutSaaS.Domain.Stores.Repositories;
|
||||
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.Merchants.Handlers;
|
||||
@@ -13,7 +18,11 @@ namespace TakeoutSaaS.Application.App.Merchants.Handlers;
|
||||
/// </summary>
|
||||
public sealed class GetMerchantDetailQueryHandler(
|
||||
IMerchantRepository merchantRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
IStoreRepository storeRepository,
|
||||
ITenantRepository tenantRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
IAdminAuthService adminAuthService)
|
||||
: IRequestHandler<GetMerchantDetailQuery, MerchantDetailDto>
|
||||
{
|
||||
/// <summary>
|
||||
@@ -24,21 +33,31 @@ public sealed class GetMerchantDetailQueryHandler(
|
||||
/// <returns>商户详情 DTO。</returns>
|
||||
public async Task<MerchantDetailDto> Handle(GetMerchantDetailQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取租户上下文并查询商户
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var merchant = await merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken)
|
||||
?? throw new BusinessException(ErrorCodes.NotFound, "商户不存在");
|
||||
// 1. 获取权限与商户
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
|
||||
|
||||
// 2. 查询证照与合同
|
||||
var documents = await merchantRepository.GetDocumentsAsync(request.MerchantId, tenantId, cancellationToken);
|
||||
var contracts = await merchantRepository.GetContractsAsync(request.MerchantId, tenantId, cancellationToken);
|
||||
var merchant = isSuperAdmin
|
||||
? await merchantRepository.FindByIdAsync(request.MerchantId, cancellationToken)
|
||||
: await merchantRepository.FindByIdAsync(request.MerchantId, currentTenantId, cancellationToken);
|
||||
|
||||
if (merchant == null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "商户不存在");
|
||||
}
|
||||
|
||||
if (!isSuperAdmin && merchant.TenantId != currentTenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "禁止访问其他租户的商户");
|
||||
}
|
||||
|
||||
// 2. 查询门店与租户信息
|
||||
var stores = await storeRepository.GetByMerchantIdAsync(merchant.Id, merchant.TenantId, cancellationToken);
|
||||
var storeDtos = MerchantMapping.ToStoreDtos(stores);
|
||||
var tenant = await tenantRepository.FindByIdAsync(merchant.TenantId, cancellationToken);
|
||||
|
||||
// 3. 返回明细 DTO
|
||||
return new MerchantDetailDto
|
||||
{
|
||||
Merchant = MerchantMapping.ToDto(merchant),
|
||||
Documents = MerchantMapping.ToDocumentDtos(documents),
|
||||
Contracts = MerchantMapping.ToContractDtos(contracts)
|
||||
};
|
||||
return MerchantMapping.ToDetailDto(merchant, tenant?.Name, storeDtos);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Merchants.Dto;
|
||||
using TakeoutSaaS.Application.App.Merchants.Queries;
|
||||
using TakeoutSaaS.Application.Identity;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Domain.Merchants.Repositories;
|
||||
using TakeoutSaaS.Domain.Stores.Repositories;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Merchants.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 商户列表查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetMerchantListQueryHandler(
|
||||
IMerchantRepository merchantRepository,
|
||||
IStoreRepository storeRepository,
|
||||
ITenantRepository tenantRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
IAdminAuthService adminAuthService)
|
||||
: IRequestHandler<GetMerchantListQuery, PagedResult<MerchantListItemDto>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<PagedResult<MerchantListItemDto>> Handle(
|
||||
GetMerchantListQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 校验跨租户访问权限
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
|
||||
|
||||
if (!isSuperAdmin && request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户查询商户");
|
||||
}
|
||||
|
||||
var effectiveTenantId = isSuperAdmin ? request.TenantId : currentTenantId;
|
||||
|
||||
// 2. 查询商户列表
|
||||
var merchants = await merchantRepository.SearchAsync(
|
||||
effectiveTenantId,
|
||||
request.Status,
|
||||
request.OperatingMode,
|
||||
request.Keyword,
|
||||
cancellationToken);
|
||||
|
||||
if (merchants.Count == 0)
|
||||
{
|
||||
return new PagedResult<MerchantListItemDto>(Array.Empty<MerchantListItemDto>(), request.Page, request.PageSize, 0);
|
||||
}
|
||||
|
||||
// 3. 排序 & 分页
|
||||
var sorted = ApplySorting(merchants, request.SortBy, request.SortOrder);
|
||||
var total = sorted.Count;
|
||||
var paged = sorted
|
||||
.Skip((request.Page - 1) * request.PageSize)
|
||||
.Take(request.PageSize)
|
||||
.ToList();
|
||||
|
||||
if (paged.Count == 0)
|
||||
{
|
||||
return new PagedResult<MerchantListItemDto>(Array.Empty<MerchantListItemDto>(), request.Page, request.PageSize, total);
|
||||
}
|
||||
|
||||
// 4. 批量查询租户名称
|
||||
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);
|
||||
|
||||
// 5. 批量查询门店数量
|
||||
var merchantIds = paged.Select(x => x.Id).ToArray();
|
||||
var storeCounts = await storeRepository.GetStoreCountsAsync(effectiveTenantId, merchantIds, cancellationToken);
|
||||
|
||||
// 6. 组装 DTO
|
||||
var items = paged.Select(merchant =>
|
||||
{
|
||||
var tenantName = tenantLookup.TryGetValue(merchant.TenantId, out var name) ? name : null;
|
||||
var count = storeCounts.TryGetValue(merchant.Id, out var value) ? value : 0;
|
||||
return MerchantMapping.ToListItemDto(merchant, tenantName, count);
|
||||
}).ToList();
|
||||
|
||||
return new PagedResult<MerchantListItemDto>(items, request.Page, request.PageSize, total);
|
||||
}
|
||||
|
||||
private static List<Domain.Merchants.Entities.Merchant> ApplySorting(
|
||||
IReadOnlyList<Domain.Merchants.Entities.Merchant> merchants,
|
||||
string? sortBy,
|
||||
string? sortOrder)
|
||||
{
|
||||
var descending = !string.Equals(sortOrder, "asc", StringComparison.OrdinalIgnoreCase);
|
||||
return (sortBy ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"name" => descending ? merchants.OrderByDescending(x => x.BrandName).ToList() : merchants.OrderBy(x => x.BrandName).ToList(),
|
||||
"status" => descending ? merchants.OrderByDescending(x => x.Status).ToList() : merchants.OrderBy(x => x.Status).ToList(),
|
||||
"updatedat" => descending ? merchants.OrderByDescending(x => x.UpdatedAt ?? x.CreatedAt).ToList() : merchants.OrderBy(x => x.UpdatedAt ?? x.CreatedAt).ToList(),
|
||||
_ => descending ? merchants.OrderByDescending(x => x.CreatedAt).ToList() : merchants.OrderBy(x => x.CreatedAt).ToList()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ 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;
|
||||
|
||||
@@ -16,7 +15,6 @@ namespace TakeoutSaaS.Application.App.Merchants.Handlers;
|
||||
/// </summary>
|
||||
public sealed class ReviewMerchantCommandHandler(
|
||||
IMerchantRepository merchantRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor)
|
||||
: IRequestHandler<ReviewMerchantCommand, MerchantDto>
|
||||
{
|
||||
@@ -29,33 +27,62 @@ public sealed class ReviewMerchantCommandHandler(
|
||||
public async Task<MerchantDto> Handle(ReviewMerchantCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 读取商户
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var merchant = await merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken)
|
||||
var merchant = await merchantRepository.FindByIdAsync(request.MerchantId, cancellationToken)
|
||||
?? throw new BusinessException(ErrorCodes.NotFound, "商户不存在");
|
||||
|
||||
// 2. 已审核通过则直接返回
|
||||
if (request.Approve && merchant.Status == MerchantStatus.Approved)
|
||||
var now = DateTime.UtcNow;
|
||||
if (!merchant.ClaimedBy.HasValue || !merchant.ClaimExpiresAt.HasValue || merchant.ClaimExpiresAt <= now)
|
||||
{
|
||||
return MerchantMapping.ToDto(merchant);
|
||||
throw new BusinessException(ErrorCodes.Conflict, "请先领取审核");
|
||||
}
|
||||
|
||||
// 3. 更新审核状态
|
||||
var previousStatus = merchant.Status;
|
||||
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 = DateTime.UtcNow;
|
||||
merchant.LastReviewedAt = now;
|
||||
merchant.LastReviewedBy = ResolveOperatorId();
|
||||
if (request.Approve && merchant.JoinedAt == null)
|
||||
{
|
||||
merchant.JoinedAt = DateTime.UtcNow;
|
||||
merchant.JoinedAt = now;
|
||||
}
|
||||
|
||||
// 4. 持久化与审计
|
||||
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 = tenantId,
|
||||
TenantId = merchant.TenantId,
|
||||
MerchantId = merchant.Id,
|
||||
Action = MerchantAuditAction.MerchantReviewed,
|
||||
Action = request.Approve ? MerchantAuditAction.ReviewApproved : MerchantAuditAction.ReviewRejected,
|
||||
Title = request.Approve ? "商户审核通过" : "商户审核驳回",
|
||||
Description = request.Remarks,
|
||||
OperatorId = ResolveOperatorId(),
|
||||
@@ -63,7 +90,7 @@ public sealed class ReviewMerchantCommandHandler(
|
||||
}, cancellationToken);
|
||||
await merchantRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 5. 返回 DTO
|
||||
// 4. 返回 DTO
|
||||
return MerchantMapping.ToDto(merchant);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,16 @@ using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TakeoutSaaS.Application.App.Merchants.Commands;
|
||||
using TakeoutSaaS.Application.App.Merchants.Dto;
|
||||
using TakeoutSaaS.Application.Identity;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Domain.Merchants.Entities;
|
||||
using TakeoutSaaS.Domain.Merchants.Enums;
|
||||
using TakeoutSaaS.Domain.Merchants.Repositories;
|
||||
using TakeoutSaaS.Domain.Stores.Repositories;
|
||||
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.Merchants.Handlers;
|
||||
@@ -12,51 +21,175 @@ namespace TakeoutSaaS.Application.App.Merchants.Handlers;
|
||||
/// </summary>
|
||||
public sealed class UpdateMerchantCommandHandler(
|
||||
IMerchantRepository merchantRepository,
|
||||
IStoreRepository storeRepository,
|
||||
ITenantRepository tenantRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
IAdminAuthService adminAuthService,
|
||||
ILogger<UpdateMerchantCommandHandler> logger)
|
||||
: IRequestHandler<UpdateMerchantCommand, MerchantDto?>
|
||||
: IRequestHandler<UpdateMerchantCommand, UpdateMerchantResultDto?>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<MerchantDto?> Handle(UpdateMerchantCommand request, CancellationToken cancellationToken)
|
||||
public async Task<UpdateMerchantResultDto?> Handle(UpdateMerchantCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 读取现有商户
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var existing = await merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken);
|
||||
if (existing == null)
|
||||
if (request.RowVersion == null || request.RowVersion.Length == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "RowVersion 不能为空");
|
||||
}
|
||||
|
||||
// 1. 获取操作者权限
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
|
||||
|
||||
// 2. 读取商户信息
|
||||
var merchant = isSuperAdmin
|
||||
? await merchantRepository.FindByIdAsync(request.MerchantId, cancellationToken)
|
||||
: await merchantRepository.FindByIdAsync(request.MerchantId, currentTenantId, cancellationToken);
|
||||
|
||||
if (merchant == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. 更新字段
|
||||
existing.BrandName = request.BrandName.Trim();
|
||||
existing.BrandAlias = request.BrandAlias?.Trim();
|
||||
existing.LogoUrl = request.LogoUrl?.Trim();
|
||||
existing.Category = request.Category?.Trim();
|
||||
existing.ContactPhone = request.ContactPhone.Trim();
|
||||
existing.ContactEmail = request.ContactEmail?.Trim();
|
||||
existing.Status = request.Status;
|
||||
if (!isSuperAdmin && merchant.TenantId != currentTenantId)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3. 持久化
|
||||
await merchantRepository.UpdateMerchantAsync(existing, cancellationToken);
|
||||
await merchantRepository.SaveChangesAsync(cancellationToken);
|
||||
logger.LogInformation("更新商户 {MerchantId} - {BrandName}", existing.Id, existing.BrandName);
|
||||
// 3. 规范化输入
|
||||
var name = NormalizeRequired(request.Name, "商户名称");
|
||||
var contactPhone = NormalizeRequired(request.ContactPhone, "联系电话");
|
||||
var licenseNumber = NormalizeOptional(request.LicenseNumber);
|
||||
var legalRepresentative = NormalizeOptional(request.LegalRepresentative);
|
||||
var registeredAddress = NormalizeOptional(request.RegisteredAddress);
|
||||
var contactEmail = NormalizeOptional(request.ContactEmail);
|
||||
|
||||
// 4. 返回 DTO
|
||||
return MapToDto(existing);
|
||||
var now = DateTime.UtcNow;
|
||||
var actorId = currentUserAccessor.UserId == 0 ? (long?)null : currentUserAccessor.UserId;
|
||||
var actorName = ResolveActorName();
|
||||
var changes = new List<MerchantChangeLog>();
|
||||
var criticalChanged = false;
|
||||
|
||||
TrackChange("name", merchant.BrandName, name, isCritical: true);
|
||||
TrackChange("licenseNumber", merchant.BusinessLicenseNumber, licenseNumber, isCritical: true);
|
||||
TrackChange("legalRepresentative", merchant.LegalPerson, legalRepresentative, isCritical: true);
|
||||
TrackChange("registeredAddress", merchant.Address, registeredAddress, isCritical: true);
|
||||
TrackChange("contactPhone", merchant.ContactPhone, contactPhone, isCritical: false);
|
||||
TrackChange("contactEmail", merchant.ContactEmail, contactEmail, isCritical: false);
|
||||
|
||||
// 4. 写入字段
|
||||
merchant.BrandName = name;
|
||||
merchant.BusinessLicenseNumber = licenseNumber;
|
||||
merchant.LegalPerson = legalRepresentative;
|
||||
merchant.Address = registeredAddress;
|
||||
merchant.ContactPhone = contactPhone;
|
||||
merchant.ContactEmail = contactEmail;
|
||||
merchant.RowVersion = request.RowVersion;
|
||||
|
||||
var requiresReview = merchant.Status == MerchantStatus.Approved && criticalChanged;
|
||||
if (requiresReview)
|
||||
{
|
||||
merchant.Status = MerchantStatus.Pending;
|
||||
merchant.IsFrozen = true;
|
||||
merchant.FrozenReason = "关键信息变更待审核";
|
||||
merchant.FrozenAt = now;
|
||||
}
|
||||
else if (merchant.Status == MerchantStatus.Rejected)
|
||||
{
|
||||
merchant.Status = MerchantStatus.Pending;
|
||||
merchant.IsFrozen = false;
|
||||
merchant.FrozenReason = null;
|
||||
merchant.FrozenAt = null;
|
||||
}
|
||||
|
||||
// 5. 持久化日志与数据
|
||||
await merchantRepository.UpdateMerchantAsync(merchant, cancellationToken);
|
||||
foreach (var log in changes)
|
||||
{
|
||||
await merchantRepository.AddChangeLogAsync(log, cancellationToken);
|
||||
}
|
||||
|
||||
if (requiresReview)
|
||||
{
|
||||
await merchantRepository.AddAuditLogAsync(new MerchantAuditLog
|
||||
{
|
||||
TenantId = merchant.TenantId,
|
||||
MerchantId = merchant.Id,
|
||||
Action = MerchantAuditAction.ReviewPendingReApproval,
|
||||
Title = "关键信息变更待审核",
|
||||
Description = "关键信息修改后已进入待审核状态",
|
||||
OperatorId = actorId,
|
||||
OperatorName = actorName
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await merchantRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception exception) when (IsConcurrencyException(exception))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "商户信息已被修改,请刷新后重试");
|
||||
}
|
||||
|
||||
logger.LogInformation("更新商户 {MerchantId} - {Name}", merchant.Id, merchant.BrandName);
|
||||
|
||||
// 6. 返回更新结果
|
||||
var stores = await storeRepository.GetByMerchantIdAsync(merchant.Id, merchant.TenantId, cancellationToken);
|
||||
var tenant = await tenantRepository.FindByIdAsync(merchant.TenantId, cancellationToken);
|
||||
var detail = MerchantMapping.ToDetailDto(merchant, tenant?.Name, MerchantMapping.ToStoreDtos(stores));
|
||||
|
||||
return new UpdateMerchantResultDto
|
||||
{
|
||||
Merchant = detail,
|
||||
RequiresReview = requiresReview
|
||||
};
|
||||
|
||||
void TrackChange(string fieldName, string? oldValue, string? newValue, bool isCritical)
|
||||
{
|
||||
var normalizedOld = NormalizeOptional(oldValue);
|
||||
var normalizedNew = NormalizeOptional(newValue);
|
||||
if (string.Equals(normalizedOld, normalizedNew, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCritical)
|
||||
{
|
||||
criticalChanged = true;
|
||||
}
|
||||
|
||||
changes.Add(new MerchantChangeLog
|
||||
{
|
||||
TenantId = merchant.TenantId,
|
||||
MerchantId = merchant.Id,
|
||||
FieldName = fieldName,
|
||||
OldValue = normalizedOld,
|
||||
NewValue = normalizedNew,
|
||||
ChangedBy = actorId,
|
||||
ChangedByName = actorName,
|
||||
ChangeType = "Update"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static MerchantDto MapToDto(Domain.Merchants.Entities.Merchant merchant) => new()
|
||||
private static string NormalizeRequired(string? value, string fieldName)
|
||||
{
|
||||
Id = merchant.Id,
|
||||
TenantId = merchant.TenantId,
|
||||
BrandName = merchant.BrandName,
|
||||
BrandAlias = merchant.BrandAlias,
|
||||
LogoUrl = merchant.LogoUrl,
|
||||
Category = merchant.Category,
|
||||
ContactPhone = merchant.ContactPhone,
|
||||
ContactEmail = merchant.ContactEmail,
|
||||
Status = merchant.Status,
|
||||
JoinedAt = merchant.JoinedAt,
|
||||
CreatedAt = merchant.CreatedAt
|
||||
};
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, $"{fieldName}不能为空");
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
private static string? NormalizeOptional(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private string ResolveActorName()
|
||||
=> currentUserAccessor.IsAuthenticated ? $"user:{currentUserAccessor.UserId}" : "system";
|
||||
|
||||
private static bool IsConcurrencyException(Exception exception)
|
||||
=> string.Equals(exception.GetType().Name, "DbUpdateConcurrencyException", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using TakeoutSaaS.Application.App.Merchants.Dto;
|
||||
using TakeoutSaaS.Domain.Merchants.Entities;
|
||||
using TakeoutSaaS.Domain.Stores.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Merchants;
|
||||
|
||||
@@ -28,6 +29,53 @@ internal static class MerchantMapping
|
||||
CreatedAt = merchant.CreatedAt
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 将商户实体映射为列表项 DTO。
|
||||
/// </summary>
|
||||
public static MerchantListItemDto ToListItemDto(Merchant merchant, string? tenantName, int storeCount) => new()
|
||||
{
|
||||
Id = merchant.Id,
|
||||
TenantId = merchant.TenantId,
|
||||
TenantName = tenantName,
|
||||
Name = merchant.BrandName,
|
||||
OperatingMode = merchant.OperatingMode,
|
||||
LicenseNumber = merchant.BusinessLicenseNumber,
|
||||
Status = merchant.Status,
|
||||
IsFrozen = merchant.IsFrozen,
|
||||
StoreCount = storeCount,
|
||||
CreatedAt = merchant.CreatedAt,
|
||||
UpdatedAt = merchant.UpdatedAt
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 将商户实体映射为详情 DTO。
|
||||
/// </summary>
|
||||
public static MerchantDetailDto ToDetailDto(Merchant merchant, string? tenantName, IReadOnlyList<StoreDto> stores) => new()
|
||||
{
|
||||
Id = merchant.Id,
|
||||
TenantId = merchant.TenantId,
|
||||
TenantName = tenantName,
|
||||
Name = merchant.BrandName,
|
||||
OperatingMode = merchant.OperatingMode,
|
||||
LicenseNumber = merchant.BusinessLicenseNumber,
|
||||
LegalRepresentative = merchant.LegalPerson,
|
||||
RegisteredAddress = merchant.Address,
|
||||
ContactPhone = merchant.ContactPhone,
|
||||
ContactEmail = merchant.ContactEmail,
|
||||
Status = merchant.Status,
|
||||
IsFrozen = merchant.IsFrozen,
|
||||
FrozenReason = merchant.FrozenReason,
|
||||
FrozenAt = merchant.FrozenAt,
|
||||
ApprovedBy = merchant.ApprovedBy,
|
||||
ApprovedAt = merchant.ApprovedAt,
|
||||
Stores = stores,
|
||||
RowVersion = merchant.RowVersion,
|
||||
CreatedAt = merchant.CreatedAt,
|
||||
CreatedBy = merchant.CreatedBy,
|
||||
UpdatedAt = merchant.UpdatedAt,
|
||||
UpdatedBy = merchant.UpdatedBy
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 将商户证照实体映射为 DTO。
|
||||
/// </summary>
|
||||
@@ -76,12 +124,42 @@ internal static class MerchantMapping
|
||||
Id = log.Id,
|
||||
MerchantId = log.MerchantId,
|
||||
Action = log.Action,
|
||||
OperatorId = log.OperatorId,
|
||||
Title = log.Title,
|
||||
Description = log.Description,
|
||||
OperatorName = log.OperatorName,
|
||||
IpAddress = log.IpAddress,
|
||||
CreatedAt = log.CreatedAt
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 将商户变更日志实体映射为 DTO。
|
||||
/// </summary>
|
||||
public static MerchantChangeLogDto ToDto(MerchantChangeLog log) => new()
|
||||
{
|
||||
Id = log.Id,
|
||||
FieldName = log.FieldName,
|
||||
OldValue = log.OldValue,
|
||||
NewValue = log.NewValue,
|
||||
ChangedBy = log.ChangedBy,
|
||||
ChangedByName = log.ChangedByName,
|
||||
ChangedAt = log.CreatedAt,
|
||||
ChangeReason = log.ChangeReason
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 将门店实体映射为 DTO。
|
||||
/// </summary>
|
||||
public static StoreDto ToStoreDto(Store store) => new()
|
||||
{
|
||||
Id = store.Id,
|
||||
Name = store.Name,
|
||||
LicenseNumber = store.BusinessLicenseNumber,
|
||||
ContactPhone = store.Phone,
|
||||
Address = store.Address,
|
||||
Status = store.Status
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 将商户分类实体映射为 DTO。
|
||||
/// </summary>
|
||||
@@ -119,4 +197,10 @@ internal static class MerchantMapping
|
||||
/// <returns>分类 DTO 列表。</returns>
|
||||
public static IReadOnlyList<MerchantCategoryDto> ToCategoryDtos(IEnumerable<MerchantCategory> categories)
|
||||
=> categories.Select(ToDto).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// 将门店集合映射为 DTO 集合。
|
||||
/// </summary>
|
||||
public static IReadOnlyList<StoreDto> ToStoreDtos(IEnumerable<Store> stores)
|
||||
=> stores.Select(ToStoreDto).ToList();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
using MediatR;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Merchants.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 导出商户 PDF 查询。
|
||||
/// </summary>
|
||||
public sealed record ExportMerchantPdfQuery(long MerchantId) : IRequest<byte[]>;
|
||||
@@ -0,0 +1,9 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Merchants.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Merchants.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 商户审核历史查询。
|
||||
/// </summary>
|
||||
public sealed record GetMerchantAuditHistoryQuery(long MerchantId) : IRequest<IReadOnlyList<MerchantAuditLogDto>>;
|
||||
@@ -0,0 +1,10 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Merchants.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Merchants.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 获取商户变更历史。
|
||||
/// </summary>
|
||||
public sealed record GetMerchantChangeHistoryQuery(long MerchantId, string? FieldName = null)
|
||||
: IRequest<IReadOnlyList<MerchantChangeLogDto>>;
|
||||
@@ -0,0 +1,53 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Merchants.Dto;
|
||||
using TakeoutSaaS.Domain.Common.Enums;
|
||||
using TakeoutSaaS.Domain.Merchants.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Merchants.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 商户列表查询。
|
||||
/// </summary>
|
||||
public sealed class GetMerchantListQuery : IRequest<PagedResult<MerchantListItemDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 关键词(商户名称/营业执照号)。
|
||||
/// </summary>
|
||||
public string? Keyword { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态过滤。
|
||||
/// </summary>
|
||||
public MerchantStatus? Status { 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;
|
||||
|
||||
/// <summary>
|
||||
/// 排序字段(createdAt/updatedAt/name/status)。
|
||||
/// </summary>
|
||||
public string? SortBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序方向(asc/desc)。
|
||||
/// </summary>
|
||||
public string? SortOrder { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Merchants.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Merchants.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 获取商户审核领取信息查询。
|
||||
/// </summary>
|
||||
public sealed record GetMerchantReviewClaimQuery(long MerchantId) : IRequest<ClaimInfoDto?>;
|
||||
@@ -0,0 +1,37 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -14,11 +14,13 @@ public sealed class UpdateMerchantCommandValidator : AbstractValidator<UpdateMer
|
||||
public UpdateMerchantCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.MerchantId).GreaterThan(0);
|
||||
RuleFor(x => x.BrandName).NotEmpty().MaximumLength(128);
|
||||
RuleFor(x => x.BrandAlias).MaximumLength(64);
|
||||
RuleFor(x => x.LogoUrl).MaximumLength(256);
|
||||
RuleFor(x => x.Category).MaximumLength(64);
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
|
||||
RuleFor(x => x.LicenseNumber).MaximumLength(64);
|
||||
RuleFor(x => x.LegalRepresentative).MaximumLength(64);
|
||||
RuleFor(x => x.RegisteredAddress).MaximumLength(256);
|
||||
RuleFor(x => x.ContactPhone).NotEmpty().MaximumLength(32);
|
||||
RuleFor(x => x.ContactEmail).EmailAddress().When(x => !string.IsNullOrWhiteSpace(x.ContactEmail));
|
||||
RuleFor(x => x.ContactEmail).EmailAddress().MaximumLength(128)
|
||||
.When(x => !string.IsNullOrWhiteSpace(x.ContactEmail));
|
||||
RuleFor(x => x.RowVersion).NotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using MediatR;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
using TakeoutSaaS.Domain.Common.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Commands;
|
||||
|
||||
@@ -29,4 +30,9 @@ public sealed record ReviewTenantCommand : IRequest<TenantDto>
|
||||
/// 审核通过后续费时长(月)。
|
||||
/// </summary>
|
||||
public int? RenewMonths { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 经营模式(审核通过时必填)。
|
||||
/// </summary>
|
||||
public OperatingMode? OperatingMode { get; init; }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using TakeoutSaaS.Domain.Common.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
|
||||
@@ -55,6 +56,11 @@ public sealed class TenantDto
|
||||
/// </summary>
|
||||
public TenantVerificationStatus VerificationStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 经营模式。
|
||||
/// </summary>
|
||||
public OperatingMode? OperatingMode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前套餐 ID。
|
||||
/// </summary>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Tenants.Commands;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
using TakeoutSaaS.Domain.Merchants.Entities;
|
||||
using TakeoutSaaS.Domain.Merchants.Enums;
|
||||
using TakeoutSaaS.Domain.Merchants.Repositories;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
@@ -14,6 +17,7 @@ namespace TakeoutSaaS.Application.App.Tenants.Handlers;
|
||||
/// </summary>
|
||||
public sealed class ReviewTenantCommandHandler(
|
||||
ITenantRepository tenantRepository,
|
||||
IMerchantRepository merchantRepository,
|
||||
ICurrentUserAccessor currentUserAccessor)
|
||||
: IRequestHandler<ReviewTenantCommand, TenantDto>
|
||||
{
|
||||
@@ -53,19 +57,25 @@ public sealed class ReviewTenantCommandHandler(
|
||||
// 4. 更新租户与订阅状态
|
||||
if (request.Approve)
|
||||
{
|
||||
if (!request.OperatingMode.HasValue)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "审核通过时必须选择经营模式");
|
||||
}
|
||||
|
||||
var renewMonths = request.RenewMonths ?? 0;
|
||||
if (renewMonths <= 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "续费时长必须为正整数(月)");
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
verification.Status = TenantVerificationStatus.Approved;
|
||||
tenant.Status = TenantStatus.Active;
|
||||
tenant.OperatingMode = request.OperatingMode;
|
||||
if (subscription != null)
|
||||
{
|
||||
subscription.Status = SubscriptionStatus.Active;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
if (subscription.EffectiveFrom == default || subscription.EffectiveFrom > now)
|
||||
{
|
||||
subscription.EffectiveFrom = now;
|
||||
@@ -92,6 +102,69 @@ public sealed class ReviewTenantCommandHandler(
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "订阅不存在,无法续费");
|
||||
}
|
||||
|
||||
var existingMerchant = await merchantRepository.FindByTenantIdAsync(tenant.Id, cancellationToken);
|
||||
if (existingMerchant == null)
|
||||
{
|
||||
var merchant = new Merchant
|
||||
{
|
||||
TenantId = tenant.Id,
|
||||
BrandName = tenant.Name,
|
||||
BrandAlias = tenant.ShortName,
|
||||
Category = tenant.Industry,
|
||||
ContactPhone = tenant.ContactPhone ?? string.Empty,
|
||||
ContactEmail = tenant.ContactEmail,
|
||||
BusinessLicenseNumber = verification.BusinessLicenseNumber,
|
||||
BusinessLicenseImageUrl = verification.BusinessLicenseUrl,
|
||||
LegalPerson = verification.LegalPersonName,
|
||||
Province = tenant.Province,
|
||||
City = tenant.City,
|
||||
Address = tenant.Address,
|
||||
Status = MerchantStatus.Approved,
|
||||
OperatingMode = request.OperatingMode,
|
||||
ApprovedAt = now,
|
||||
ApprovedBy = currentUserAccessor.UserId == 0 ? null : currentUserAccessor.UserId,
|
||||
JoinedAt = now,
|
||||
LastReviewedAt = now,
|
||||
LastReviewedBy = currentUserAccessor.UserId == 0 ? null : currentUserAccessor.UserId,
|
||||
IsFrozen = false
|
||||
};
|
||||
|
||||
await merchantRepository.AddMerchantAsync(merchant, cancellationToken);
|
||||
await merchantRepository.AddAuditLogAsync(new MerchantAuditLog
|
||||
{
|
||||
TenantId = tenant.Id,
|
||||
MerchantId = merchant.Id,
|
||||
Action = MerchantAuditAction.ReviewApproved,
|
||||
Title = "商户审核通过",
|
||||
Description = request.Reason,
|
||||
OperatorId = currentUserAccessor.UserId == 0 ? null : currentUserAccessor.UserId,
|
||||
OperatorName = actorName
|
||||
}, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
existingMerchant.Status = MerchantStatus.Approved;
|
||||
existingMerchant.OperatingMode = request.OperatingMode;
|
||||
existingMerchant.ApprovedAt = now;
|
||||
existingMerchant.ApprovedBy = currentUserAccessor.UserId == 0 ? null : currentUserAccessor.UserId;
|
||||
existingMerchant.LastReviewedAt = now;
|
||||
existingMerchant.LastReviewedBy = currentUserAccessor.UserId == 0 ? null : currentUserAccessor.UserId;
|
||||
existingMerchant.IsFrozen = false;
|
||||
existingMerchant.FrozenReason = null;
|
||||
existingMerchant.FrozenAt = null;
|
||||
await merchantRepository.UpdateMerchantAsync(existingMerchant, cancellationToken);
|
||||
await merchantRepository.AddAuditLogAsync(new MerchantAuditLog
|
||||
{
|
||||
TenantId = tenant.Id,
|
||||
MerchantId = existingMerchant.Id,
|
||||
Action = MerchantAuditAction.ReviewApproved,
|
||||
Title = "商户审核通过",
|
||||
Description = request.Reason,
|
||||
OperatorId = currentUserAccessor.UserId == 0 ? null : currentUserAccessor.UserId,
|
||||
OperatorName = actorName
|
||||
}, cancellationToken);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -141,6 +214,7 @@ public sealed class ReviewTenantCommandHandler(
|
||||
|
||||
// 8. 保存并返回 DTO
|
||||
await tenantRepository.SaveChangesAsync(cancellationToken);
|
||||
await merchantRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return TenantMapping.ToDto(tenant, subscription, verification);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ internal static class TenantMapping
|
||||
ContactEmail = tenant.ContactEmail,
|
||||
Status = tenant.Status,
|
||||
VerificationStatus = verification?.Status ?? Domain.Tenants.Enums.TenantVerificationStatus.Draft,
|
||||
OperatingMode = tenant.OperatingMode,
|
||||
CurrentPackageId = subscription?.TenantPackageId,
|
||||
EffectiveFrom = subscription?.EffectiveFrom ?? tenant.EffectiveFrom,
|
||||
EffectiveTo = subscription?.EffectiveTo ?? tenant.EffectiveTo,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.Tenants.Commands;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 租户审核命令验证器。
|
||||
/// </summary>
|
||||
public sealed class ReviewTenantValidator : AbstractValidator<ReviewTenantCommand>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
public ReviewTenantValidator()
|
||||
{
|
||||
RuleFor(x => x.TenantId).GreaterThan(0);
|
||||
RuleFor(x => x.Reason)
|
||||
.NotEmpty()
|
||||
.When(x => !x.Approve);
|
||||
RuleFor(x => x.OperatingMode)
|
||||
.NotNull()
|
||||
.When(x => x.Approve);
|
||||
RuleFor(x => x.RenewMonths)
|
||||
.NotNull()
|
||||
.GreaterThan(0)
|
||||
.When(x => x.Approve);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user