feat:商户管理

This commit is contained in:
2025-12-29 16:40:27 +08:00
parent 57f4c2d394
commit dd91c1010a
62 changed files with 10536 additions and 165 deletions

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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;
}

View File

@@ -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>();
}

View File

@@ -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; }
}

View File

@@ -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>

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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
};
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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()
};
}
}

View File

@@ -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
};
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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();
}

View File

@@ -0,0 +1,8 @@
using MediatR;
namespace TakeoutSaaS.Application.App.Merchants.Queries;
/// <summary>
/// 导出商户 PDF 查询。
/// </summary>
public sealed record ExportMerchantPdfQuery(long MerchantId) : IRequest<byte[]>;

View File

@@ -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>>;

View File

@@ -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>>;

View File

@@ -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; }
}

View File

@@ -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?>;

View File

@@ -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;
}

View File

@@ -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));
}
}

View File

@@ -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();
}
}

View File

@@ -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; }
}

View File

@@ -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>

View File

@@ -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);
}

View File

@@ -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,

View File

@@ -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);
}
}