feat: 商户冻结/解冻功能及字典缓存重构
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
using MediatR;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Merchants.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 冻结商户命令。
|
||||
/// </summary>
|
||||
public sealed record FreezeMerchantCommand : IRequest<bool>
|
||||
{
|
||||
/// <summary>
|
||||
/// 商户 ID。
|
||||
/// </summary>
|
||||
public long MerchantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 冻结原因。
|
||||
/// </summary>
|
||||
public string? Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 并发控制版本(PostgreSQL xmin)。
|
||||
/// </summary>
|
||||
public uint RowVersion { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using MediatR;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Merchants.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 解冻商户命令。
|
||||
/// </summary>
|
||||
public sealed record UnfreezeMerchantCommand : IRequest<bool>
|
||||
{
|
||||
/// <summary>
|
||||
/// 商户 ID。
|
||||
/// </summary>
|
||||
public long MerchantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 并发控制版本(PostgreSQL xmin)。
|
||||
/// </summary>
|
||||
public uint RowVersion { get; init; }
|
||||
}
|
||||
@@ -18,21 +18,36 @@ public sealed record UpdateMerchantCommand : IRequest<UpdateMerchantResultDto?>
|
||||
/// </summary>
|
||||
public string? Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 品牌简称。
|
||||
/// </summary>
|
||||
public string? BrandAlias { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Logo URL。
|
||||
/// </summary>
|
||||
public string? LogoUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 品类。
|
||||
/// </summary>
|
||||
public string? Category { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 营业执照号。
|
||||
/// </summary>
|
||||
public string? LicenseNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 税号。
|
||||
/// </summary>
|
||||
public string? TaxNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 法人或负责人。
|
||||
/// </summary>
|
||||
public string? LegalRepresentative { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 注册地址。
|
||||
/// </summary>
|
||||
public string? RegisteredAddress { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 联系电话。
|
||||
/// </summary>
|
||||
@@ -44,7 +59,42 @@ public sealed record UpdateMerchantCommand : IRequest<UpdateMerchantResultDto?>
|
||||
public string? ContactEmail { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 并发控制版本。
|
||||
/// 客服电话。
|
||||
/// </summary>
|
||||
public byte[] RowVersion { get; init; } = Array.Empty<byte>();
|
||||
public string? ServicePhone { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 客服邮箱。
|
||||
/// </summary>
|
||||
public string? SupportEmail { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 省份。
|
||||
/// </summary>
|
||||
public string? Province { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 城市。
|
||||
/// </summary>
|
||||
public string? City { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 区县。
|
||||
/// </summary>
|
||||
public string? District { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 详细地址。
|
||||
/// </summary>
|
||||
public string? Address { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 并发控制版本(PostgreSQL xmin)。
|
||||
/// </summary>
|
||||
public uint RowVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否跳过敏感字段审核(Admin 端设置为 true)。
|
||||
/// </summary>
|
||||
public bool SkipReview { get; init; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Domain.Common.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Merchants.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 更新商户经营模式命令。
|
||||
/// </summary>
|
||||
public sealed record UpdateMerchantOperatingModeCommand : IRequest<bool>
|
||||
{
|
||||
/// <summary>
|
||||
/// 商户 ID。
|
||||
/// </summary>
|
||||
public long MerchantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 经营模式。
|
||||
/// </summary>
|
||||
public OperatingMode OperatingMode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 并发控制版本(PostgreSQL xmin)。
|
||||
/// </summary>
|
||||
public uint RowVersion { get; init; }
|
||||
}
|
||||
@@ -28,10 +28,25 @@ public sealed class MerchantDetailDto
|
||||
public string? TenantName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 商户名称。
|
||||
/// 商户名称(品牌名)。
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 品牌简称。
|
||||
/// </summary>
|
||||
public string? BrandAlias { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Logo 地址。
|
||||
/// </summary>
|
||||
public string? LogoUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 品类。
|
||||
/// </summary>
|
||||
public string? Category { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 经营模式。
|
||||
/// </summary>
|
||||
@@ -48,9 +63,29 @@ public sealed class MerchantDetailDto
|
||||
public string? LegalRepresentative { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 注册地址。
|
||||
/// 税号。
|
||||
/// </summary>
|
||||
public string? RegisteredAddress { get; init; }
|
||||
public string? TaxNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 省份。
|
||||
/// </summary>
|
||||
public string? Province { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 城市。
|
||||
/// </summary>
|
||||
public string? City { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 区县。
|
||||
/// </summary>
|
||||
public string? District { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 详细地址。
|
||||
/// </summary>
|
||||
public string? Address { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 联系电话。
|
||||
@@ -62,6 +97,16 @@ public sealed class MerchantDetailDto
|
||||
/// </summary>
|
||||
public string? ContactEmail { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 客服电话。
|
||||
/// </summary>
|
||||
public string? ServicePhone { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 客服邮箱。
|
||||
/// </summary>
|
||||
public string? SupportEmail { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 审核状态。
|
||||
/// </summary>
|
||||
@@ -99,9 +144,9 @@ public sealed class MerchantDetailDto
|
||||
public IReadOnlyList<MerchantStoreDto> Stores { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 并发控制版本。
|
||||
/// 并发控制版本(PostgreSQL xmin)。
|
||||
/// </summary>
|
||||
public byte[] RowVersion { get; init; } = Array.Empty<byte>();
|
||||
public uint RowVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间。
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Security.Cryptography;
|
||||
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.Security;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Merchants.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 创建商户命令处理器。
|
||||
/// </summary>
|
||||
public sealed class CreateMerchantCommandHandler(IMerchantRepository merchantRepository, ILogger<CreateMerchantCommandHandler> logger)
|
||||
public sealed class CreateMerchantCommandHandler(
|
||||
IMerchantRepository merchantRepository,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
ILogger<CreateMerchantCommandHandler> logger)
|
||||
: IRequestHandler<CreateMerchantCommand, MerchantDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<MerchantDto> Handle(CreateMerchantCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 构建商户实体
|
||||
// 1. 构建商户实体(RowVersion 由 PostgreSQL xmin 自动管理)
|
||||
var merchant = new Merchant
|
||||
{
|
||||
TenantId = request.TenantId,
|
||||
@@ -28,16 +32,39 @@ public sealed class CreateMerchantCommandHandler(IMerchantRepository merchantRep
|
||||
ContactPhone = request.ContactPhone.Trim(),
|
||||
ContactEmail = request.ContactEmail?.Trim(),
|
||||
Status = request.Status,
|
||||
RowVersion = RandomNumberGenerator.GetBytes(16),
|
||||
JoinedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
// 2. 持久化
|
||||
// 2. 如果状态为已通过,设置审核通过时间和审核人
|
||||
if (request.Status == MerchantStatus.Approved)
|
||||
{
|
||||
merchant.ApprovedAt = DateTime.UtcNow;
|
||||
merchant.ApprovedBy = currentUserAccessor.UserId;
|
||||
}
|
||||
|
||||
// 3. 持久化商户
|
||||
await merchantRepository.AddMerchantAsync(merchant, cancellationToken);
|
||||
await merchantRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 3. 记录日志
|
||||
logger.LogInformation("创建商户 {MerchantId} - {BrandName}", merchant.Id, merchant.BrandName);
|
||||
// 4. 如果状态为已通过,添加默认审核通过记录
|
||||
if (request.Status == MerchantStatus.Approved)
|
||||
{
|
||||
var auditLog = new MerchantAuditLog
|
||||
{
|
||||
TenantId = merchant.TenantId,
|
||||
MerchantId = merchant.Id,
|
||||
Action = MerchantAuditAction.ReviewApproved,
|
||||
Title = "商户创建时直接通过审核",
|
||||
Description = "平台管理员创建商户时选择无需审核,系统自动通过",
|
||||
OperatorId = currentUserAccessor.UserId == 0 ? null : currentUserAccessor.UserId,
|
||||
OperatorName = currentUserAccessor.UserId == 0 ? "system" : $"user:{currentUserAccessor.UserId}"
|
||||
};
|
||||
await merchantRepository.AddAuditLogAsync(auditLog, cancellationToken);
|
||||
await merchantRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
// 5. 记录日志
|
||||
logger.LogInformation("创建商户 {MerchantId} - {BrandName},状态:{Status}", merchant.Id, merchant.BrandName, merchant.Status);
|
||||
return MapToDto(merchant);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
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 FreezeMerchantCommandHandler(
|
||||
IMerchantRepository merchantRepository,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
ILogger<FreezeMerchantCommandHandler> logger)
|
||||
: IRequestHandler<FreezeMerchantCommand, bool>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> Handle(FreezeMerchantCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 验证 RowVersion
|
||||
if (request.RowVersion == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "RowVersion 不能为空");
|
||||
}
|
||||
|
||||
// 2. 读取商户信息
|
||||
var merchant = await merchantRepository.FindByIdAsync(request.MerchantId, cancellationToken);
|
||||
if (merchant == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. 检查是否已冻结
|
||||
if (merchant.IsFrozen)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "商户已处于冻结状态");
|
||||
}
|
||||
|
||||
// 4. 执行冻结
|
||||
var now = DateTime.UtcNow;
|
||||
var actorId = currentUserAccessor.UserId == 0 ? (long?)null : currentUserAccessor.UserId;
|
||||
var actorName = currentUserAccessor.IsAuthenticated ? $"user:{currentUserAccessor.UserId}" : "system";
|
||||
|
||||
merchant.IsFrozen = true;
|
||||
merchant.FrozenReason = request.Reason?.Trim();
|
||||
merchant.FrozenAt = now;
|
||||
merchant.RowVersion = request.RowVersion;
|
||||
|
||||
// 5. 记录审核日志
|
||||
await merchantRepository.AddAuditLogAsync(new MerchantAuditLog
|
||||
{
|
||||
TenantId = merchant.TenantId,
|
||||
MerchantId = merchant.Id,
|
||||
Action = MerchantAuditAction.Frozen,
|
||||
Title = "商户冻结",
|
||||
Description = string.IsNullOrWhiteSpace(request.Reason) ? "管理员冻结商户" : $"冻结原因:{request.Reason.Trim()}",
|
||||
OperatorId = actorId,
|
||||
OperatorName = actorName
|
||||
}, cancellationToken);
|
||||
|
||||
// 6. 持久化
|
||||
await merchantRepository.UpdateMerchantAsync(merchant, cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
await merchantRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception exception) when (IsConcurrencyException(exception))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "商户信息已被修改,请刷新后重试");
|
||||
}
|
||||
|
||||
logger.LogInformation("冻结商户 {MerchantId},原因:{Reason}", merchant.Id, request.Reason);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsConcurrencyException(Exception exception)
|
||||
=> string.Equals(exception.GetType().Name, "DbUpdateConcurrencyException", StringComparison.Ordinal);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
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 UnfreezeMerchantCommandHandler(
|
||||
IMerchantRepository merchantRepository,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
ILogger<UnfreezeMerchantCommandHandler> logger)
|
||||
: IRequestHandler<UnfreezeMerchantCommand, bool>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> Handle(UnfreezeMerchantCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 验证 RowVersion
|
||||
if (request.RowVersion == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "RowVersion 不能为空");
|
||||
}
|
||||
|
||||
// 2. 读取商户信息
|
||||
var merchant = await merchantRepository.FindByIdAsync(request.MerchantId, cancellationToken);
|
||||
if (merchant == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. 检查是否已解冻
|
||||
if (!merchant.IsFrozen)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "商户未处于冻结状态");
|
||||
}
|
||||
|
||||
// 4. 执行解冻
|
||||
var actorId = currentUserAccessor.UserId == 0 ? (long?)null : currentUserAccessor.UserId;
|
||||
var actorName = currentUserAccessor.IsAuthenticated ? $"user:{currentUserAccessor.UserId}" : "system";
|
||||
|
||||
merchant.IsFrozen = false;
|
||||
merchant.FrozenReason = null;
|
||||
merchant.FrozenAt = null;
|
||||
merchant.RowVersion = request.RowVersion;
|
||||
|
||||
// 5. 记录审核日志
|
||||
await merchantRepository.AddAuditLogAsync(new MerchantAuditLog
|
||||
{
|
||||
TenantId = merchant.TenantId,
|
||||
MerchantId = merchant.Id,
|
||||
Action = MerchantAuditAction.Unfrozen,
|
||||
Title = "商户解冻",
|
||||
Description = "管理员解冻商户",
|
||||
OperatorId = actorId,
|
||||
OperatorName = actorName
|
||||
}, cancellationToken);
|
||||
|
||||
// 6. 持久化
|
||||
await merchantRepository.UpdateMerchantAsync(merchant, cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
await merchantRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception exception) when (IsConcurrencyException(exception))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "商户信息已被修改,请刷新后重试");
|
||||
}
|
||||
|
||||
logger.LogInformation("解冻商户 {MerchantId}", merchant.Id);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsConcurrencyException(Exception exception)
|
||||
=> string.Equals(exception.GetType().Name, "DbUpdateConcurrencyException", StringComparison.Ordinal);
|
||||
}
|
||||
@@ -27,7 +27,7 @@ public sealed class UpdateMerchantCommandHandler(
|
||||
/// <inheritdoc />
|
||||
public async Task<UpdateMerchantResultDto?> Handle(UpdateMerchantCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.RowVersion == null || request.RowVersion.Length == 0)
|
||||
if (request.RowVersion == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "RowVersion 不能为空");
|
||||
}
|
||||
@@ -43,10 +43,19 @@ public sealed class UpdateMerchantCommandHandler(
|
||||
// 2. (空行后) 规范化输入
|
||||
var name = NormalizeRequired(request.Name, "商户名称");
|
||||
var contactPhone = NormalizeRequired(request.ContactPhone, "联系电话");
|
||||
var brandAlias = NormalizeOptional(request.BrandAlias);
|
||||
var logoUrl = NormalizeOptional(request.LogoUrl);
|
||||
var category = NormalizeOptional(request.Category);
|
||||
var licenseNumber = NormalizeOptional(request.LicenseNumber);
|
||||
var taxNumber = NormalizeOptional(request.TaxNumber);
|
||||
var legalRepresentative = NormalizeOptional(request.LegalRepresentative);
|
||||
var registeredAddress = NormalizeOptional(request.RegisteredAddress);
|
||||
var contactEmail = NormalizeOptional(request.ContactEmail);
|
||||
var servicePhone = NormalizeOptional(request.ServicePhone);
|
||||
var supportEmail = NormalizeOptional(request.SupportEmail);
|
||||
var province = NormalizeOptional(request.Province);
|
||||
var city = NormalizeOptional(request.City);
|
||||
var district = NormalizeOptional(request.District);
|
||||
var address = NormalizeOptional(request.Address);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var actorId = currentUserAccessor.UserId == 0 ? (long?)null : currentUserAccessor.UserId;
|
||||
@@ -54,23 +63,44 @@ public sealed class UpdateMerchantCommandHandler(
|
||||
var changes = new List<MerchantChangeLog>();
|
||||
var criticalChanged = false;
|
||||
|
||||
// 判断是否为管理员操作(Admin 端修改不触发审核)
|
||||
var isAdminOperation = request.SkipReview;
|
||||
|
||||
TrackChange("name", merchant.BrandName, name, isCritical: true);
|
||||
TrackChange("brandAlias", merchant.BrandAlias, brandAlias, isCritical: false);
|
||||
TrackChange("logoUrl", merchant.LogoUrl, logoUrl, isCritical: false);
|
||||
TrackChange("category", merchant.Category, category, isCritical: false);
|
||||
TrackChange("licenseNumber", merchant.BusinessLicenseNumber, licenseNumber, isCritical: true);
|
||||
TrackChange("taxNumber", merchant.TaxNumber, taxNumber, isCritical: false);
|
||||
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);
|
||||
TrackChange("servicePhone", merchant.ServicePhone, servicePhone, isCritical: false);
|
||||
TrackChange("supportEmail", merchant.SupportEmail, supportEmail, isCritical: false);
|
||||
TrackChange("province", merchant.Province, province, isCritical: false);
|
||||
TrackChange("city", merchant.City, city, isCritical: false);
|
||||
TrackChange("district", merchant.District, district, isCritical: false);
|
||||
TrackChange("address", merchant.Address, address, isCritical: true);
|
||||
|
||||
// 3. (空行后) 写入字段
|
||||
merchant.BrandName = name;
|
||||
merchant.BrandAlias = brandAlias;
|
||||
merchant.LogoUrl = logoUrl;
|
||||
merchant.Category = category;
|
||||
merchant.BusinessLicenseNumber = licenseNumber;
|
||||
merchant.TaxNumber = taxNumber;
|
||||
merchant.LegalPerson = legalRepresentative;
|
||||
merchant.Address = registeredAddress;
|
||||
merchant.ContactPhone = contactPhone;
|
||||
merchant.ContactEmail = contactEmail;
|
||||
merchant.ServicePhone = servicePhone;
|
||||
merchant.SupportEmail = supportEmail;
|
||||
merchant.Province = province;
|
||||
merchant.City = city;
|
||||
merchant.District = district;
|
||||
merchant.Address = address;
|
||||
merchant.RowVersion = request.RowVersion;
|
||||
|
||||
var requiresReview = merchant.Status == MerchantStatus.Approved && criticalChanged;
|
||||
var requiresReview = !isAdminOperation && merchant.Status == MerchantStatus.Approved && criticalChanged;
|
||||
if (requiresReview)
|
||||
{
|
||||
merchant.Status = MerchantStatus.Pending;
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TakeoutSaaS.Application.App.Merchants.Commands;
|
||||
using TakeoutSaaS.Domain.Merchants.Entities;
|
||||
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 UpdateMerchantOperatingModeCommandHandler(
|
||||
IMerchantRepository merchantRepository,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
ILogger<UpdateMerchantOperatingModeCommandHandler> logger)
|
||||
: IRequestHandler<UpdateMerchantOperatingModeCommand, bool>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> Handle(UpdateMerchantOperatingModeCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 验证 RowVersion
|
||||
if (request.RowVersion == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "RowVersion 不能为空");
|
||||
}
|
||||
|
||||
// 2. 读取商户信息
|
||||
var merchant = await merchantRepository.FindByIdAsync(request.MerchantId, cancellationToken);
|
||||
if (merchant == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. 记录变更日志
|
||||
var actorId = currentUserAccessor.UserId == 0 ? (long?)null : currentUserAccessor.UserId;
|
||||
var actorName = currentUserAccessor.IsAuthenticated ? $"user:{currentUserAccessor.UserId}" : "system";
|
||||
var oldValue = merchant.OperatingMode?.ToString();
|
||||
var newValue = request.OperatingMode.ToString();
|
||||
|
||||
if (!string.Equals(oldValue, newValue, StringComparison.Ordinal))
|
||||
{
|
||||
await merchantRepository.AddChangeLogAsync(new MerchantChangeLog
|
||||
{
|
||||
TenantId = merchant.TenantId,
|
||||
MerchantId = merchant.Id,
|
||||
FieldName = "operatingMode",
|
||||
OldValue = oldValue,
|
||||
NewValue = newValue,
|
||||
ChangedBy = actorId,
|
||||
ChangedByName = actorName,
|
||||
ChangeType = "Update"
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
// 4. 更新经营模式
|
||||
merchant.OperatingMode = request.OperatingMode;
|
||||
merchant.RowVersion = request.RowVersion;
|
||||
|
||||
// 5. 持久化
|
||||
await merchantRepository.UpdateMerchantAsync(merchant, cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
await merchantRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception exception) when (IsConcurrencyException(exception))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "商户信息已被修改,请刷新后重试");
|
||||
}
|
||||
|
||||
logger.LogInformation("更新商户 {MerchantId} 经营模式为 {OperatingMode}", merchant.Id, request.OperatingMode);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsConcurrencyException(Exception exception)
|
||||
=> string.Equals(exception.GetType().Name, "DbUpdateConcurrencyException", StringComparison.Ordinal);
|
||||
}
|
||||
@@ -56,12 +56,21 @@ internal static class MerchantMapping
|
||||
TenantId = merchant.TenantId,
|
||||
TenantName = tenantName,
|
||||
Name = merchant.BrandName,
|
||||
BrandAlias = merchant.BrandAlias,
|
||||
LogoUrl = merchant.LogoUrl,
|
||||
Category = merchant.Category,
|
||||
OperatingMode = merchant.OperatingMode,
|
||||
LicenseNumber = merchant.BusinessLicenseNumber,
|
||||
LegalRepresentative = merchant.LegalPerson,
|
||||
RegisteredAddress = merchant.Address,
|
||||
TaxNumber = merchant.TaxNumber,
|
||||
Province = merchant.Province,
|
||||
City = merchant.City,
|
||||
District = merchant.District,
|
||||
Address = merchant.Address,
|
||||
ContactPhone = merchant.ContactPhone,
|
||||
ContactEmail = merchant.ContactEmail,
|
||||
ServicePhone = merchant.ServicePhone,
|
||||
SupportEmail = merchant.SupportEmail,
|
||||
Status = merchant.Status,
|
||||
IsFrozen = merchant.IsFrozen,
|
||||
FrozenReason = merchant.FrozenReason,
|
||||
|
||||
@@ -15,12 +15,22 @@ public sealed class UpdateMerchantCommandValidator : AbstractValidator<UpdateMer
|
||||
{
|
||||
RuleFor(x => x.MerchantId).GreaterThan(0);
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
|
||||
RuleFor(x => x.BrandAlias).MaximumLength(64);
|
||||
RuleFor(x => x.LogoUrl).MaximumLength(512);
|
||||
RuleFor(x => x.Category).MaximumLength(64);
|
||||
RuleFor(x => x.LicenseNumber).MaximumLength(64);
|
||||
RuleFor(x => x.TaxNumber).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().MaximumLength(128)
|
||||
.When(x => !string.IsNullOrWhiteSpace(x.ContactEmail));
|
||||
RuleFor(x => x.ServicePhone).MaximumLength(32);
|
||||
RuleFor(x => x.SupportEmail).EmailAddress().MaximumLength(128)
|
||||
.When(x => !string.IsNullOrWhiteSpace(x.SupportEmail));
|
||||
RuleFor(x => x.Province).MaximumLength(64);
|
||||
RuleFor(x => x.City).MaximumLength(64);
|
||||
RuleFor(x => x.District).MaximumLength(64);
|
||||
RuleFor(x => x.Address).MaximumLength(256);
|
||||
RuleFor(x => x.RowVersion).NotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user