feat: 商户类目数据库化并增加权限种子

This commit is contained in:
2025-12-03 19:01:53 +08:00
parent a536a554c2
commit 0c329669a9
49 changed files with 1646 additions and 6 deletions

View File

@@ -0,0 +1,18 @@
using System;
using System.ComponentModel.DataAnnotations;
using MediatR;
using TakeoutSaaS.Application.App.Merchants.Dto;
using TakeoutSaaS.Domain.Merchants.Enums;
namespace TakeoutSaaS.Application.App.Merchants.Commands;
/// <summary>
/// 新增商户证照。
/// </summary>
public sealed record AddMerchantDocumentCommand(
[property: Required] long MerchantId,
[property: Required] MerchantDocumentType DocumentType,
[property: Required, MaxLength(512)] string FileUrl,
[property: MaxLength(64)] string? DocumentNumber,
DateTime? IssuedAt,
DateTime? ExpiresAt) : IRequest<MerchantDocumentDto>;

View File

@@ -0,0 +1,13 @@
using System.ComponentModel.DataAnnotations;
using MediatR;
using TakeoutSaaS.Application.App.Merchants.Dto;
namespace TakeoutSaaS.Application.App.Merchants.Commands;
/// <summary>
/// 新增商户类目。
/// </summary>
public sealed record CreateMerchantCategoryCommand(
[property: Required, MaxLength(64)] string Name,
int? DisplayOrder,
bool IsActive = true) : IRequest<MerchantCategoryDto>;

View File

@@ -0,0 +1,16 @@
using System;
using System.ComponentModel.DataAnnotations;
using MediatR;
using TakeoutSaaS.Application.App.Merchants.Dto;
namespace TakeoutSaaS.Application.App.Merchants.Commands;
/// <summary>
/// 新建商户合同。
/// </summary>
public sealed record CreateMerchantContractCommand(
[property: Required] long MerchantId,
[property: Required, MaxLength(64)] string ContractNumber,
DateTime StartDate,
DateTime EndDate,
[property: Required, MaxLength(512)] string FileUrl) : IRequest<MerchantContractDto>;

View File

@@ -0,0 +1,9 @@
using System.ComponentModel.DataAnnotations;
using MediatR;
namespace TakeoutSaaS.Application.App.Merchants.Commands;
/// <summary>
/// 删除商户类目。
/// </summary>
public sealed record DeleteMerchantCategoryCommand([property: Required] long CategoryId) : IRequest<bool>;

View File

@@ -0,0 +1,18 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using MediatR;
namespace TakeoutSaaS.Application.App.Merchants.Commands;
/// <summary>
/// 调整类目排序。
/// </summary>
public sealed record ReorderMerchantCategoriesCommand(
[property: Required, MinLength(1)] IReadOnlyList<MerchantCategoryOrderItem> Items) : IRequest<bool>;
/// <summary>
/// 类目排序条目。
/// </summary>
public sealed record MerchantCategoryOrderItem(
[property: Required] long CategoryId,
[property: Range(-1000, 100000)] int DisplayOrder);

View File

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

View File

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

View File

@@ -0,0 +1,17 @@
using System;
using System.ComponentModel.DataAnnotations;
using MediatR;
using TakeoutSaaS.Application.App.Merchants.Dto;
using TakeoutSaaS.Domain.Merchants.Enums;
namespace TakeoutSaaS.Application.App.Merchants.Commands;
/// <summary>
/// 更新合同状态。
/// </summary>
public sealed record UpdateMerchantContractStatusCommand(
[property: Required] long MerchantId,
[property: Required] long ContractId,
[property: Required] ContractStatus Status,
DateTime? SignedAt,
string? Reason) : IRequest<MerchantContractDto>;

View File

@@ -0,0 +1,27 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Merchants.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Merchants.Dto;
/// <summary>
/// 商户审核日志 DTO。
/// </summary>
public sealed class MerchantAuditLogDto
{
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long MerchantId { get; init; }
public MerchantAuditAction Action { get; init; }
public string Title { get; init; } = string.Empty;
public string? Description { get; init; }
public string? OperatorName { get; init; }
public DateTime CreatedAt { get; init; }
}

View File

@@ -0,0 +1,34 @@
using System;
namespace TakeoutSaaS.Application.App.Merchants.Dto;
/// <summary>
/// 商户类目 DTO。
/// </summary>
public sealed record MerchantCategoryDto
{
/// <summary>
/// 类目标识。
/// </summary>
public long Id { get; init; }
/// <summary>
/// 类目名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 显示顺序。
/// </summary>
public int DisplayOrder { get; init; }
/// <summary>
/// 是否启用。
/// </summary>
public bool IsActive { get; init; }
/// <summary>
/// 创建时间。
/// </summary>
public DateTime CreatedAt { get; init; }
}

View File

@@ -0,0 +1,33 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Merchants.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Merchants.Dto;
/// <summary>
/// 商户合同 DTO。
/// </summary>
public sealed class MerchantContractDto
{
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long MerchantId { get; init; }
public string ContractNumber { get; init; } = string.Empty;
public ContractStatus Status { get; init; }
public DateTime StartDate { get; init; }
public DateTime EndDate { get; init; }
public string FileUrl { get; init; } = string.Empty;
public DateTime? SignedAt { get; init; }
public DateTime? TerminatedAt { get; init; }
public string? TerminationReason { get; init; }
}

View File

@@ -0,0 +1,24 @@
using System.Collections.Generic;
namespace TakeoutSaaS.Application.App.Merchants.Dto;
/// <summary>
/// 商户详情 DTO。
/// </summary>
public sealed class MerchantDetailDto
{
/// <summary>
/// 基础信息。
/// </summary>
public MerchantDto Merchant { get; init; } = new();
/// <summary>
/// 证照列表。
/// </summary>
public IReadOnlyList<MerchantDocumentDto> Documents { get; init; } = [];
/// <summary>
/// 合同列表。
/// </summary>
public IReadOnlyList<MerchantContractDto> Contracts { get; init; } = [];
}

View File

@@ -0,0 +1,33 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Merchants.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.App.Merchants.Dto;
/// <summary>
/// 商户证照 DTO。
/// </summary>
public sealed class MerchantDocumentDto
{
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long MerchantId { get; init; }
public MerchantDocumentType DocumentType { get; init; }
public MerchantDocumentStatus Status { get; init; }
public string FileUrl { get; init; } = string.Empty;
public string? DocumentNumber { get; init; }
public DateTime? IssuedAt { get; init; }
public DateTime? ExpiresAt { get; init; }
public string? Remarks { get; init; }
public DateTime CreatedAt { get; init; }
}

View File

@@ -0,0 +1,76 @@
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.Ids;
using TakeoutSaaS.Shared.Abstractions.Security;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Merchants.Handlers;
/// <summary>
/// 处理证照上传。
/// </summary>
public sealed class AddMerchantDocumentCommandHandler(
IMerchantRepository merchantRepository,
ITenantProvider tenantProvider,
IIdGenerator idGenerator,
ICurrentUserAccessor currentUserAccessor)
: IRequestHandler<AddMerchantDocumentCommand, MerchantDocumentDto>
{
private readonly IMerchantRepository _merchantRepository = merchantRepository;
private readonly ITenantProvider _tenantProvider = tenantProvider;
private readonly IIdGenerator _idGenerator = idGenerator;
private readonly ICurrentUserAccessor _currentUserAccessor = currentUserAccessor;
public async Task<MerchantDocumentDto> Handle(AddMerchantDocumentCommand request, CancellationToken cancellationToken)
{
var tenantId = _tenantProvider.GetCurrentTenantId();
var merchant = await _merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "商户不存在");
var document = new MerchantDocument
{
Id = _idGenerator.NextId(),
MerchantId = merchant.Id,
DocumentType = request.DocumentType,
Status = MerchantDocumentStatus.Pending,
FileUrl = request.FileUrl.Trim(),
DocumentNumber = request.DocumentNumber?.Trim(),
IssuedAt = request.IssuedAt,
ExpiresAt = request.ExpiresAt
};
await _merchantRepository.AddDocumentAsync(document, cancellationToken);
await _merchantRepository.AddAuditLogAsync(new MerchantAuditLog
{
TenantId = tenantId,
MerchantId = merchant.Id,
Action = MerchantAuditAction.DocumentUploaded,
Title = "上传证照",
Description = $"类型:{request.DocumentType}",
OperatorId = ResolveOperatorId(),
OperatorName = ResolveOperatorName()
}, cancellationToken);
await _merchantRepository.SaveChangesAsync(cancellationToken);
return MerchantMapping.ToDto(document);
}
private long? ResolveOperatorId()
{
var id = _currentUserAccessor.UserId;
return id == 0 ? null : id;
}
private string ResolveOperatorName()
{
var id = _currentUserAccessor.UserId;
return id == 0 ? "system" : $"user:{id}";
}
}

View File

@@ -0,0 +1,49 @@
using System.Linq;
using MediatR;
using TakeoutSaaS.Application.App.Merchants.Commands;
using TakeoutSaaS.Application.App.Merchants.Dto;
using TakeoutSaaS.Domain.Merchants.Entities;
using TakeoutSaaS.Domain.Merchants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Merchants.Handlers;
/// <summary>
/// 创建类目处理器。
/// </summary>
public sealed class CreateMerchantCategoryCommandHandler(
IMerchantCategoryRepository categoryRepository,
ITenantProvider tenantProvider)
: IRequestHandler<CreateMerchantCategoryCommand, MerchantCategoryDto>
{
private readonly IMerchantCategoryRepository _categoryRepository = categoryRepository;
private readonly ITenantProvider _tenantProvider = tenantProvider;
public async Task<MerchantCategoryDto> Handle(CreateMerchantCategoryCommand request, CancellationToken cancellationToken)
{
var tenantId = _tenantProvider.GetCurrentTenantId();
var normalizedName = request.Name.Trim();
if (await _categoryRepository.ExistsAsync(normalizedName, tenantId, cancellationToken))
{
throw new BusinessException(ErrorCodes.Conflict, $"类目“{normalizedName}”已存在");
}
var categories = await _categoryRepository.ListAsync(tenantId, cancellationToken);
var targetOrder = request.DisplayOrder ?? (categories.Count == 0 ? 1 : categories.Max(x => x.DisplayOrder) + 1);
var entity = new MerchantCategory
{
Name = normalizedName,
DisplayOrder = targetOrder,
IsActive = request.IsActive
};
await _categoryRepository.AddAsync(entity, cancellationToken);
await _categoryRepository.SaveChangesAsync(cancellationToken);
return MerchantMapping.ToDto(entity);
}
}

View File

@@ -0,0 +1,78 @@
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.Ids;
using TakeoutSaaS.Shared.Abstractions.Security;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Merchants.Handlers;
/// <summary>
/// 创建商户合同。
/// </summary>
public sealed class CreateMerchantContractCommandHandler(
IMerchantRepository merchantRepository,
ITenantProvider tenantProvider,
IIdGenerator idGenerator,
ICurrentUserAccessor currentUserAccessor)
: IRequestHandler<CreateMerchantContractCommand, MerchantContractDto>
{
private readonly IMerchantRepository _merchantRepository = merchantRepository;
private readonly ITenantProvider _tenantProvider = tenantProvider;
private readonly IIdGenerator _idGenerator = idGenerator;
private readonly ICurrentUserAccessor _currentUserAccessor = currentUserAccessor;
public async Task<MerchantContractDto> Handle(CreateMerchantContractCommand request, CancellationToken cancellationToken)
{
if (request.EndDate <= request.StartDate)
{
throw new BusinessException(ErrorCodes.BadRequest, "合同结束时间必须晚于开始时间");
}
var tenantId = _tenantProvider.GetCurrentTenantId();
var merchant = await _merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "商户不存在");
var contract = new MerchantContract
{
Id = _idGenerator.NextId(),
MerchantId = merchant.Id,
ContractNumber = request.ContractNumber.Trim(),
StartDate = request.StartDate,
EndDate = request.EndDate,
FileUrl = request.FileUrl.Trim()
};
await _merchantRepository.AddContractAsync(contract, cancellationToken);
await _merchantRepository.AddAuditLogAsync(new MerchantAuditLog
{
TenantId = tenantId,
MerchantId = merchant.Id,
Action = MerchantAuditAction.ContractUpdated,
Title = "新增合同",
Description = $"合同号:{contract.ContractNumber}",
OperatorId = ResolveOperatorId(),
OperatorName = ResolveOperatorName()
}, cancellationToken);
await _merchantRepository.SaveChangesAsync(cancellationToken);
return MerchantMapping.ToDto(contract);
}
private long? ResolveOperatorId()
{
var id = _currentUserAccessor.UserId;
return id == 0 ? null : id;
}
private string ResolveOperatorName()
{
var id = _currentUserAccessor.UserId;
return id == 0 ? "system" : $"user:{id}";
}
}

View File

@@ -0,0 +1,33 @@
using MediatR;
using TakeoutSaaS.Application.App.Merchants.Commands;
using TakeoutSaaS.Domain.Merchants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Merchants.Handlers;
/// <summary>
/// 删除类目处理器。
/// </summary>
public sealed class DeleteMerchantCategoryCommandHandler(
IMerchantCategoryRepository categoryRepository,
ITenantProvider tenantProvider)
: IRequestHandler<DeleteMerchantCategoryCommand, bool>
{
private readonly IMerchantCategoryRepository _categoryRepository = categoryRepository;
private readonly ITenantProvider _tenantProvider = tenantProvider;
public async Task<bool> Handle(DeleteMerchantCategoryCommand request, CancellationToken cancellationToken)
{
var tenantId = _tenantProvider.GetCurrentTenantId();
var existing = await _categoryRepository.FindByIdAsync(request.CategoryId, tenantId, cancellationToken);
if (existing == null)
{
return false;
}
await _categoryRepository.RemoveAsync(existing, cancellationToken);
await _categoryRepository.SaveChangesAsync(cancellationToken);
return true;
}
}

View File

@@ -0,0 +1,35 @@
using System.Linq;
using MediatR;
using TakeoutSaaS.Application.App.Merchants.Dto;
using TakeoutSaaS.Application.App.Merchants.Queries;
using TakeoutSaaS.Domain.Merchants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Merchants.Handlers;
/// <summary>
/// 读取商户审核日志。
/// </summary>
public sealed class GetMerchantAuditLogsQueryHandler(
IMerchantRepository merchantRepository,
ITenantProvider tenantProvider)
: IRequestHandler<GetMerchantAuditLogsQuery, PagedResult<MerchantAuditLogDto>>
{
private readonly IMerchantRepository _merchantRepository = merchantRepository;
private readonly ITenantProvider _tenantProvider = tenantProvider;
public async Task<PagedResult<MerchantAuditLogDto>> Handle(GetMerchantAuditLogsQuery request, CancellationToken cancellationToken)
{
var tenantId = _tenantProvider.GetCurrentTenantId();
var logs = await _merchantRepository.GetAuditLogsAsync(request.MerchantId, tenantId, cancellationToken);
var total = logs.Count;
var paged = logs
.Skip((request.Page - 1) * request.PageSize)
.Take(request.PageSize)
.Select(MerchantMapping.ToDto)
.ToList();
return new PagedResult<MerchantAuditLogDto>(paged, request.Page, request.PageSize, total);
}
}

View File

@@ -0,0 +1,34 @@
using System.Collections.Generic;
using System.Linq;
using MediatR;
using TakeoutSaaS.Application.App.Merchants.Queries;
using TakeoutSaaS.Domain.Merchants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Merchants.Handlers;
/// <summary>
/// 读取可选类目。
/// </summary>
public sealed class GetMerchantCategoriesQueryHandler(
IMerchantCategoryRepository categoryRepository,
ITenantProvider tenantProvider)
: IRequestHandler<GetMerchantCategoriesQuery, IReadOnlyList<string>>
{
private readonly IMerchantCategoryRepository _categoryRepository = categoryRepository;
private readonly ITenantProvider _tenantProvider = tenantProvider;
public async Task<IReadOnlyList<string>> Handle(GetMerchantCategoriesQuery request, CancellationToken cancellationToken)
{
var tenantId = _tenantProvider.GetCurrentTenantId();
var categories = await _categoryRepository.ListAsync(tenantId, cancellationToken);
return categories
.Where(x => x.IsActive)
.Select(x => x.Name.Trim())
.Where(x => x.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList()
.AsReadOnly();
}
}

View File

@@ -0,0 +1,32 @@
using System.Collections.Generic;
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;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Merchants.Handlers;
/// <summary>
/// 查询合同列表。
/// </summary>
public sealed class GetMerchantContractsQueryHandler(
IMerchantRepository merchantRepository,
ITenantProvider tenantProvider)
: IRequestHandler<GetMerchantContractsQuery, IReadOnlyList<MerchantContractDto>>
{
private readonly IMerchantRepository _merchantRepository = merchantRepository;
private readonly ITenantProvider _tenantProvider = tenantProvider;
public async Task<IReadOnlyList<MerchantContractDto>> Handle(GetMerchantContractsQuery request, CancellationToken cancellationToken)
{
var tenantId = _tenantProvider.GetCurrentTenantId();
_ = await _merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "商户不存在");
var contracts = await _merchantRepository.GetContractsAsync(request.MerchantId, tenantId, cancellationToken);
return MerchantMapping.ToContractDtos(contracts);
}
}

View File

@@ -0,0 +1,38 @@
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;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Merchants.Handlers;
/// <summary>
/// 商户详情处理器。
/// </summary>
public sealed class GetMerchantDetailQueryHandler(
IMerchantRepository merchantRepository,
ITenantProvider tenantProvider)
: IRequestHandler<GetMerchantDetailQuery, MerchantDetailDto>
{
private readonly IMerchantRepository _merchantRepository = merchantRepository;
private readonly ITenantProvider _tenantProvider = tenantProvider;
public async Task<MerchantDetailDto> Handle(GetMerchantDetailQuery request, CancellationToken cancellationToken)
{
var tenantId = _tenantProvider.GetCurrentTenantId();
var merchant = await _merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "商户不存在");
var documents = await _merchantRepository.GetDocumentsAsync(request.MerchantId, tenantId, cancellationToken);
var contracts = await _merchantRepository.GetContractsAsync(request.MerchantId, tenantId, cancellationToken);
return new MerchantDetailDto
{
Merchant = MerchantMapping.ToDto(merchant),
Documents = MerchantMapping.ToDocumentDtos(documents),
Contracts = MerchantMapping.ToContractDtos(contracts)
};
}
}

View File

@@ -0,0 +1,32 @@
using System.Collections.Generic;
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;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Merchants.Handlers;
/// <summary>
/// 查询证照列表。
/// </summary>
public sealed class GetMerchantDocumentsQueryHandler(
IMerchantRepository merchantRepository,
ITenantProvider tenantProvider)
: IRequestHandler<GetMerchantDocumentsQuery, IReadOnlyList<MerchantDocumentDto>>
{
private readonly IMerchantRepository _merchantRepository = merchantRepository;
private readonly ITenantProvider _tenantProvider = tenantProvider;
public async Task<IReadOnlyList<MerchantDocumentDto>> Handle(GetMerchantDocumentsQuery request, CancellationToken cancellationToken)
{
var tenantId = _tenantProvider.GetCurrentTenantId();
_ = await _merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "商户不存在");
var documents = await _merchantRepository.GetDocumentsAsync(request.MerchantId, tenantId, cancellationToken);
return MerchantMapping.ToDocumentDtos(documents);
}
}

View File

@@ -0,0 +1,27 @@
using System.Collections.Generic;
using MediatR;
using TakeoutSaaS.Application.App.Merchants.Dto;
using TakeoutSaaS.Application.App.Merchants.Queries;
using TakeoutSaaS.Domain.Merchants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Merchants.Handlers;
/// <summary>
/// 列出类目。
/// </summary>
public sealed class ListMerchantCategoriesQueryHandler(
IMerchantCategoryRepository categoryRepository,
ITenantProvider tenantProvider)
: IRequestHandler<ListMerchantCategoriesQuery, IReadOnlyList<MerchantCategoryDto>>
{
private readonly IMerchantCategoryRepository _categoryRepository = categoryRepository;
private readonly ITenantProvider _tenantProvider = tenantProvider;
public async Task<IReadOnlyList<MerchantCategoryDto>> Handle(ListMerchantCategoriesQuery request, CancellationToken cancellationToken)
{
var tenantId = _tenantProvider.GetCurrentTenantId();
var categories = await _categoryRepository.ListAsync(tenantId, cancellationToken);
return MerchantMapping.ToCategoryDtos(categories);
}
}

View File

@@ -0,0 +1,42 @@
using System.Linq;
using MediatR;
using TakeoutSaaS.Application.App.Merchants.Commands;
using TakeoutSaaS.Domain.Merchants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Merchants.Handlers;
/// <summary>
/// 类目排序处理器。
/// </summary>
public sealed class ReorderMerchantCategoriesCommandHandler(
IMerchantCategoryRepository categoryRepository,
ITenantProvider tenantProvider)
: IRequestHandler<ReorderMerchantCategoriesCommand, bool>
{
private readonly IMerchantCategoryRepository _categoryRepository = categoryRepository;
private readonly ITenantProvider _tenantProvider = tenantProvider;
public async Task<bool> Handle(ReorderMerchantCategoriesCommand request, CancellationToken cancellationToken)
{
var tenantId = _tenantProvider.GetCurrentTenantId();
var categories = await _categoryRepository.ListAsync(tenantId, cancellationToken);
var map = categories.ToDictionary(x => x.Id);
foreach (var item in request.Items)
{
if (!map.TryGetValue(item.CategoryId, out var category))
{
throw new BusinessException(ErrorCodes.NotFound, $"类目 {item.CategoryId} 不存在");
}
category.DisplayOrder = item.DisplayOrder;
}
await _categoryRepository.UpdateRangeAsync(map.Values, cancellationToken);
await _categoryRepository.SaveChangesAsync(cancellationToken);
return true;
}
}

View File

@@ -0,0 +1,74 @@
using MediatR;
using TakeoutSaaS.Application.App.Merchants.Commands;
using TakeoutSaaS.Application.App.Merchants.Dto;
using TakeoutSaaS.Domain.Merchants.Entities;
using TakeoutSaaS.Domain.Merchants.Enums;
using TakeoutSaaS.Domain.Merchants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Security;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Merchants.Handlers;
/// <summary>
/// 商户审核处理器。
/// </summary>
public sealed class ReviewMerchantCommandHandler(
IMerchantRepository merchantRepository,
ITenantProvider tenantProvider,
ICurrentUserAccessor currentUserAccessor)
: IRequestHandler<ReviewMerchantCommand, MerchantDto>
{
private readonly IMerchantRepository _merchantRepository = merchantRepository;
private readonly ITenantProvider _tenantProvider = tenantProvider;
private readonly ICurrentUserAccessor _currentUserAccessor = currentUserAccessor;
public async Task<MerchantDto> Handle(ReviewMerchantCommand request, CancellationToken cancellationToken)
{
var tenantId = _tenantProvider.GetCurrentTenantId();
var merchant = await _merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "商户不存在");
if (request.Approve && merchant.Status == MerchantStatus.Approved)
{
return MerchantMapping.ToDto(merchant);
}
var previousStatus = merchant.Status;
merchant.Status = request.Approve ? MerchantStatus.Approved : MerchantStatus.Rejected;
merchant.ReviewRemarks = request.Remarks;
merchant.LastReviewedAt = DateTime.UtcNow;
if (request.Approve && merchant.JoinedAt == null)
{
merchant.JoinedAt = DateTime.UtcNow;
}
await _merchantRepository.UpdateMerchantAsync(merchant, cancellationToken);
await _merchantRepository.AddAuditLogAsync(new MerchantAuditLog
{
TenantId = tenantId,
MerchantId = merchant.Id,
Action = MerchantAuditAction.MerchantReviewed,
Title = request.Approve ? "商户审核通过" : "商户审核驳回",
Description = request.Remarks,
OperatorId = ResolveOperatorId(),
OperatorName = ResolveOperatorName()
}, cancellationToken);
await _merchantRepository.SaveChangesAsync(cancellationToken);
return MerchantMapping.ToDto(merchant);
}
private long? ResolveOperatorId()
{
var id = _currentUserAccessor.UserId;
return id == 0 ? null : id;
}
private string ResolveOperatorName()
{
var id = _currentUserAccessor.UserId;
return id == 0 ? "system" : $"user:{id}";
}
}

View File

@@ -0,0 +1,69 @@
using MediatR;
using TakeoutSaaS.Application.App.Merchants.Commands;
using TakeoutSaaS.Application.App.Merchants.Dto;
using TakeoutSaaS.Domain.Merchants.Enums;
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;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Merchants.Handlers;
/// <summary>
/// 审核证照处理器。
/// </summary>
public sealed class ReviewMerchantDocumentCommandHandler(
IMerchantRepository merchantRepository,
ITenantProvider tenantProvider,
ICurrentUserAccessor currentUserAccessor)
: IRequestHandler<ReviewMerchantDocumentCommand, MerchantDocumentDto>
{
private readonly IMerchantRepository _merchantRepository = merchantRepository;
private readonly ITenantProvider _tenantProvider = tenantProvider;
private readonly ICurrentUserAccessor _currentUserAccessor = currentUserAccessor;
public async Task<MerchantDocumentDto> Handle(ReviewMerchantDocumentCommand request, CancellationToken cancellationToken)
{
var tenantId = _tenantProvider.GetCurrentTenantId();
var document = await _merchantRepository.FindDocumentByIdAsync(request.MerchantId, tenantId, request.DocumentId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "证照不存在");
var targetStatus = request.Approve ? MerchantDocumentStatus.Approved : MerchantDocumentStatus.Rejected;
if (document.Status == targetStatus && document.Remarks == request.Remarks)
{
return MerchantMapping.ToDto(document);
}
document.Status = targetStatus;
document.Remarks = request.Remarks;
await _merchantRepository.UpdateDocumentAsync(document, cancellationToken);
await _merchantRepository.AddAuditLogAsync(new MerchantAuditLog
{
TenantId = tenantId,
MerchantId = document.MerchantId,
Action = MerchantAuditAction.DocumentReviewed,
Title = request.Approve ? "证照审核通过" : "证照审核驳回",
Description = request.Remarks,
OperatorId = ResolveOperatorId(),
OperatorName = ResolveOperatorName()
}, cancellationToken);
await _merchantRepository.SaveChangesAsync(cancellationToken);
return MerchantMapping.ToDto(document);
}
private long? ResolveOperatorId()
{
var id = _currentUserAccessor.UserId;
return id == 0 ? null : id;
}
private string ResolveOperatorName()
{
var id = _currentUserAccessor.UserId;
return id == 0 ? "system" : $"user:{id}";
}
}

View File

@@ -0,0 +1,76 @@
using MediatR;
using TakeoutSaaS.Application.App.Merchants.Commands;
using TakeoutSaaS.Application.App.Merchants.Dto;
using TakeoutSaaS.Domain.Merchants.Entities;
using TakeoutSaaS.Domain.Merchants.Enums;
using TakeoutSaaS.Domain.Merchants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Security;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Merchants.Handlers;
/// <summary>
/// 更新合同状态处理器。
/// </summary>
public sealed class UpdateMerchantContractStatusCommandHandler(
IMerchantRepository merchantRepository,
ITenantProvider tenantProvider,
ICurrentUserAccessor currentUserAccessor)
: IRequestHandler<UpdateMerchantContractStatusCommand, MerchantContractDto>
{
private readonly IMerchantRepository _merchantRepository = merchantRepository;
private readonly ITenantProvider _tenantProvider = tenantProvider;
private readonly ICurrentUserAccessor _currentUserAccessor = currentUserAccessor;
public async Task<MerchantContractDto> Handle(UpdateMerchantContractStatusCommand request, CancellationToken cancellationToken)
{
var tenantId = _tenantProvider.GetCurrentTenantId();
var contract = await _merchantRepository.FindContractByIdAsync(request.MerchantId, tenantId, request.ContractId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "合同不存在");
if (request.Status == ContractStatus.Active)
{
contract.Status = ContractStatus.Active;
contract.SignedAt = request.SignedAt ?? DateTime.UtcNow;
}
else if (request.Status == ContractStatus.Terminated)
{
contract.Status = ContractStatus.Terminated;
contract.TerminatedAt = DateTime.UtcNow;
contract.TerminationReason = request.Reason;
}
else
{
contract.Status = request.Status;
}
await _merchantRepository.UpdateContractAsync(contract, cancellationToken);
await _merchantRepository.AddAuditLogAsync(new MerchantAuditLog
{
TenantId = tenantId,
MerchantId = contract.MerchantId,
Action = MerchantAuditAction.ContractStatusChanged,
Title = $"合同状态变更为 {request.Status}",
Description = request.Reason,
OperatorId = ResolveOperatorId(),
OperatorName = ResolveOperatorName()
}, cancellationToken);
await _merchantRepository.SaveChangesAsync(cancellationToken);
return MerchantMapping.ToDto(contract);
}
private long? ResolveOperatorId()
{
var id = _currentUserAccessor.UserId;
return id == 0 ? null : id;
}
private string ResolveOperatorName()
{
var id = _currentUserAccessor.UserId;
return id == 0 ? "system" : $"user:{id}";
}
}

View File

@@ -0,0 +1,84 @@
using System.Collections.Generic;
using System.Linq;
using TakeoutSaaS.Application.App.Merchants.Dto;
using TakeoutSaaS.Domain.Merchants.Entities;
namespace TakeoutSaaS.Application.App.Merchants;
/// <summary>
/// 商户 DTO 映射工具。
/// </summary>
internal static class MerchantMapping
{
public static MerchantDto ToDto(Merchant merchant) => new()
{
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
};
public static MerchantDocumentDto ToDto(MerchantDocument document) => new()
{
Id = document.Id,
MerchantId = document.MerchantId,
DocumentType = document.DocumentType,
Status = document.Status,
FileUrl = document.FileUrl,
DocumentNumber = document.DocumentNumber,
IssuedAt = document.IssuedAt,
ExpiresAt = document.ExpiresAt,
Remarks = document.Remarks,
CreatedAt = document.CreatedAt
};
public static MerchantContractDto ToDto(MerchantContract contract) => new()
{
Id = contract.Id,
MerchantId = contract.MerchantId,
ContractNumber = contract.ContractNumber,
Status = contract.Status,
StartDate = contract.StartDate,
EndDate = contract.EndDate,
FileUrl = contract.FileUrl,
SignedAt = contract.SignedAt,
TerminatedAt = contract.TerminatedAt,
TerminationReason = contract.TerminationReason
};
public static MerchantAuditLogDto ToDto(MerchantAuditLog log) => new()
{
Id = log.Id,
MerchantId = log.MerchantId,
Action = log.Action,
Title = log.Title,
Description = log.Description,
OperatorName = log.OperatorName,
CreatedAt = log.CreatedAt
};
public static MerchantCategoryDto ToDto(MerchantCategory category) => new()
{
Id = category.Id,
Name = category.Name,
DisplayOrder = category.DisplayOrder,
IsActive = category.IsActive,
CreatedAt = category.CreatedAt
};
public static IReadOnlyList<MerchantDocumentDto> ToDocumentDtos(IEnumerable<MerchantDocument> documents)
=> documents.Select(ToDto).ToList();
public static IReadOnlyList<MerchantContractDto> ToContractDtos(IEnumerable<MerchantContract> contracts)
=> contracts.Select(ToDto).ToList();
public static IReadOnlyList<MerchantCategoryDto> ToCategoryDtos(IEnumerable<MerchantCategory> categories)
=> categories.Select(ToDto).ToList();
}

View File

@@ -0,0 +1,13 @@
using MediatR;
using TakeoutSaaS.Application.App.Merchants.Dto;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.App.Merchants.Queries;
/// <summary>
/// 商户审核日志查询。
/// </summary>
public sealed record GetMerchantAuditLogsQuery(
long MerchantId,
int Page = 1,
int PageSize = 20) : IRequest<PagedResult<MerchantAuditLogDto>>;

View File

@@ -0,0 +1,9 @@
using System.Collections.Generic;
using MediatR;
namespace TakeoutSaaS.Application.App.Merchants.Queries;
/// <summary>
/// 获取商户可选类目。
/// </summary>
public sealed record GetMerchantCategoriesQuery() : IRequest<IReadOnlyList<string>>;

View File

@@ -0,0 +1,10 @@
using System.Collections.Generic;
using MediatR;
using TakeoutSaaS.Application.App.Merchants.Dto;
namespace TakeoutSaaS.Application.App.Merchants.Queries;
/// <summary>
/// 查询商户合同。
/// </summary>
public sealed record GetMerchantContractsQuery(long MerchantId) : IRequest<IReadOnlyList<MerchantContractDto>>;

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 GetMerchantDetailQuery(long MerchantId) : IRequest<MerchantDetailDto>;

View File

@@ -0,0 +1,10 @@
using System.Collections.Generic;
using MediatR;
using TakeoutSaaS.Application.App.Merchants.Dto;
namespace TakeoutSaaS.Application.App.Merchants.Queries;
/// <summary>
/// 查询商户证照。
/// </summary>
public sealed record GetMerchantDocumentsQuery(long MerchantId) : IRequest<IReadOnlyList<MerchantDocumentDto>>;

View File

@@ -0,0 +1,10 @@
using System.Collections.Generic;
using MediatR;
using TakeoutSaaS.Application.App.Merchants.Dto;
namespace TakeoutSaaS.Application.App.Merchants.Queries;
/// <summary>
/// 管理端获取完整类目列表。
/// </summary>
public sealed record ListMerchantCategoriesQuery() : IRequest<IReadOnlyList<MerchantCategoryDto>>;