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

@@ -6,8 +6,8 @@
## Phase 1当前阶段租户/商家入驻、门店与菜品、扫码堂食、基础下单支付、预购自提、第三方配送骨架
- [x] 管理端租户 API注册、实名认证、套餐订阅/续费/升降配、审核流Swagger ≥6 个端点,含审核日志。
- 已交付:`src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs` 暴露注册、详情、实名提交、审核、订阅创建/升降配、审核日志 8 个端点;对应命令/查询位于 `src/Application/TakeoutSaaS.Application/App/Tenants`,仓储实现 `EfTenantRepository`,并写入 `TenantAuditLog` 记录。Swagger 自动收录上述接口,满足 Phase1 租户管理要求。
- [ ] 商家入驻 API证照上传、合同管理、类目选择驱动待审/审核/驳回/通过状态机,文件持久在 COS。
- 当前:`MerchantsController` 只暴露基础 CRUD`src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantsController.cs:21-88`),缺少证照/合同上传、COS 存储与状态机端点
- [x] 商家入驻 API证照上传、合同管理、类目选择驱动待审/审核/驳回/通过状态机,文件持久在 COS。
- 已交付:`src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantsController.cs` 新增证照上传/审核、合同创建与状态更新、商户审核、审核日志、类目列表等 8 个端点;应用层新增 `AddMerchantDocumentCommand``CreateMerchantContractCommand``ReviewMerchantCommand` 等 Handler`MerchantDocument/Contract/Audit` DTO 完整返回详情,文件 URL 仍通过 `/api/admin/v1/files/upload` 上 COS。仓储实现扩展 `EfMerchantRepository` 支持文档/合同/AuditLog 持久化,`TakeoutAppDbContext` 新增 `merchant_audit_logs` 表实现状态机追踪
- [ ] RBAC 模板平台管理员、租户管理员、店长、店员四角色模板API 可复制并允许租户自定义扩展。
- 当前:`RolesController`/`PermissionsController` 已提供角色与权限 CRUD`src/Api/TakeoutSaaS.AdminApi/Controllers/RolesController.cs:16-88``.../PermissionsController.cs:16-63`),但没有“模板复制”或按租户批量初始化的接口。
- [ ] 配额与套餐TenantPackage CRUD、订阅/续费/配额校验(门店/账号/短信/配送单量),超额返回 409 并记录 TenantQuotaUsage。

View File

@@ -0,0 +1,74 @@
using System.Collections.Generic;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using TakeoutSaaS.Application.App.Merchants.Commands;
using TakeoutSaaS.Application.App.Merchants.Dto;
using TakeoutSaaS.Application.App.Merchants.Queries;
using TakeoutSaaS.Module.Authorization.Attributes;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Web.Api;
namespace TakeoutSaaS.AdminApi.Controllers;
/// <summary>
/// 商户类目管理。
/// </summary>
[ApiVersion("1.0")]
[Authorize]
[Route("api/admin/v{version:apiVersion}/merchant-categories")]
public sealed class MerchantCategoriesController(IMediator mediator) : BaseApiController
{
/// <summary>
/// 列出所有类目。
/// </summary>
[HttpGet]
[PermissionAuthorize("merchant_category:read")]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<MerchantCategoryDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<IReadOnlyList<MerchantCategoryDto>>> List(CancellationToken cancellationToken)
{
var result = await mediator.Send(new ListMerchantCategoriesQuery(), cancellationToken);
return ApiResponse<IReadOnlyList<MerchantCategoryDto>>.Ok(result);
}
/// <summary>
/// 新增类目。
/// </summary>
[HttpPost]
[PermissionAuthorize("merchant_category:create")]
[ProducesResponseType(typeof(ApiResponse<MerchantCategoryDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<MerchantCategoryDto>> Create([FromBody] CreateMerchantCategoryCommand command, CancellationToken cancellationToken)
{
var result = await mediator.Send(command, cancellationToken);
return ApiResponse<MerchantCategoryDto>.Ok(result);
}
/// <summary>
/// 删除类目。
/// </summary>
[HttpDelete("{categoryId:long}")]
[PermissionAuthorize("merchant_category:delete")]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<object>> Delete(long categoryId, CancellationToken cancellationToken)
{
var success = await mediator.Send(new DeleteMerchantCategoryCommand(categoryId), cancellationToken);
return success
? ApiResponse<object>.Ok(null)
: ApiResponse<object>.Error(ErrorCodes.NotFound, "类目不存在");
}
/// <summary>
/// 批量调整类目排序。
/// </summary>
[HttpPost("reorder")]
[PermissionAuthorize("merchant_category:update")]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
public async Task<ApiResponse<object>> Reorder([FromBody] ReorderMerchantCategoriesCommand command, CancellationToken cancellationToken)
{
await mediator.Send(command, cancellationToken);
return ApiResponse<object>.Ok(null);
}
}

View File

@@ -99,7 +99,7 @@ public sealed class MerchantsController(IMediator mediator) : BaseApiController
}
/// <summary>
/// 获取商户详情
/// 获取商户概览
/// </summary>
[HttpGet("{merchantId:long}")]
[PermissionAuthorize("merchant:read")]
@@ -112,4 +112,147 @@ public sealed class MerchantsController(IMediator mediator) : BaseApiController
? ApiResponse<MerchantDto>.Error(ErrorCodes.NotFound, "商户不存在")
: ApiResponse<MerchantDto>.Ok(result);
}
/// <summary>
/// 获取商户详细资料(含证照、合同)。
/// </summary>
[HttpGet("{merchantId:long}/detail")]
[PermissionAuthorize("merchant:read")]
[ProducesResponseType(typeof(ApiResponse<MerchantDetailDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<MerchantDetailDto>> FullDetail(long merchantId, CancellationToken cancellationToken)
{
var result = await mediator.Send(new GetMerchantDetailQuery(merchantId), cancellationToken);
return ApiResponse<MerchantDetailDto>.Ok(result);
}
/// <summary>
/// 上传商户证照信息(先通过文件上传接口获取 COS 地址)。
/// </summary>
[HttpPost("{merchantId:long}/documents")]
[PermissionAuthorize("merchant:update")]
[ProducesResponseType(typeof(ApiResponse<MerchantDocumentDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<MerchantDocumentDto>> CreateDocument(
long merchantId,
[FromBody] AddMerchantDocumentCommand body,
CancellationToken cancellationToken)
{
var command = body with { MerchantId = merchantId };
var result = await mediator.Send(command, cancellationToken);
return ApiResponse<MerchantDocumentDto>.Ok(result);
}
/// <summary>
/// 商户证照列表。
/// </summary>
[HttpGet("{merchantId:long}/documents")]
[PermissionAuthorize("merchant:read")]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<MerchantDocumentDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<IReadOnlyList<MerchantDocumentDto>>> Documents(long merchantId, CancellationToken cancellationToken)
{
var result = await mediator.Send(new GetMerchantDocumentsQuery(merchantId), cancellationToken);
return ApiResponse<IReadOnlyList<MerchantDocumentDto>>.Ok(result);
}
/// <summary>
/// 审核指定证照。
/// </summary>
[HttpPost("{merchantId:long}/documents/{documentId:long}/review")]
[PermissionAuthorize("merchant:review")]
[ProducesResponseType(typeof(ApiResponse<MerchantDocumentDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<MerchantDocumentDto>> ReviewDocument(
long merchantId,
long documentId,
[FromBody] ReviewMerchantDocumentCommand body,
CancellationToken cancellationToken)
{
var command = body with { MerchantId = merchantId, DocumentId = documentId };
var result = await mediator.Send(command, cancellationToken);
return ApiResponse<MerchantDocumentDto>.Ok(result);
}
/// <summary>
/// 新增商户合同。
/// </summary>
[HttpPost("{merchantId:long}/contracts")]
[PermissionAuthorize("merchant:update")]
[ProducesResponseType(typeof(ApiResponse<MerchantContractDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<MerchantContractDto>> CreateContract(
long merchantId,
[FromBody] CreateMerchantContractCommand body,
CancellationToken cancellationToken)
{
var command = body with { MerchantId = merchantId };
var result = await mediator.Send(command, cancellationToken);
return ApiResponse<MerchantContractDto>.Ok(result);
}
/// <summary>
/// 合同列表。
/// </summary>
[HttpGet("{merchantId:long}/contracts")]
[PermissionAuthorize("merchant:read")]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<MerchantContractDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<IReadOnlyList<MerchantContractDto>>> Contracts(long merchantId, CancellationToken cancellationToken)
{
var result = await mediator.Send(new GetMerchantContractsQuery(merchantId), cancellationToken);
return ApiResponse<IReadOnlyList<MerchantContractDto>>.Ok(result);
}
/// <summary>
/// 更新合同状态(生效/终止等)。
/// </summary>
[HttpPut("{merchantId:long}/contracts/{contractId:long}/status")]
[PermissionAuthorize("merchant:update")]
[ProducesResponseType(typeof(ApiResponse<MerchantContractDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<MerchantContractDto>> UpdateContractStatus(
long merchantId,
long contractId,
[FromBody] UpdateMerchantContractStatusCommand body,
CancellationToken cancellationToken)
{
var command = body with { MerchantId = merchantId, ContractId = contractId };
var result = await mediator.Send(command, cancellationToken);
return ApiResponse<MerchantContractDto>.Ok(result);
}
/// <summary>
/// 审核商户(通过/驳回)。
/// </summary>
[HttpPost("{merchantId:long}/review")]
[PermissionAuthorize("merchant:review")]
[ProducesResponseType(typeof(ApiResponse<MerchantDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<MerchantDto>> Review(long merchantId, [FromBody] ReviewMerchantCommand body, CancellationToken cancellationToken)
{
var command = body with { MerchantId = merchantId };
var result = await mediator.Send(command, cancellationToken);
return ApiResponse<MerchantDto>.Ok(result);
}
/// <summary>
/// 审核日志。
/// </summary>
[HttpGet("{merchantId:long}/audits")]
[PermissionAuthorize("merchant:read")]
[ProducesResponseType(typeof(ApiResponse<PagedResult<MerchantAuditLogDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PagedResult<MerchantAuditLogDto>>> AuditLogs(
long merchantId,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
CancellationToken cancellationToken = default)
{
var result = await mediator.Send(new GetMerchantAuditLogsQuery(merchantId, page, pageSize), cancellationToken);
return ApiResponse<PagedResult<MerchantAuditLogDto>>.Ok(result);
}
/// <summary>
/// 可选商户类目列表。
/// </summary>
[HttpGet("categories")]
[PermissionAuthorize("merchant:read")]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<string>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<IReadOnlyList<string>>> Categories(CancellationToken cancellationToken)
{
var result = await mediator.Send(new GetMerchantCategoriesQuery(), cancellationToken);
return ApiResponse<IReadOnlyList<string>>.Ok(result);
}
}

View File

@@ -46,7 +46,19 @@
"Password": "Admin@123456",
"TenantId": 1000000000001,
"Roles": [ "PlatformAdmin" ],
"Permissions": [ "merchant:*", "store:*", "product:*", "order:*", "payment:*", "delivery:*" ]
"Permissions": [
"merchant:*",
"merchant_category:*",
"merchant_category:read",
"merchant_category:create",
"merchant_category:update",
"merchant_category:delete",
"store:*",
"product:*",
"order:*",
"payment:*",
"delivery:*"
]
}
]
}

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

View File

@@ -0,0 +1,40 @@
using TakeoutSaaS.Domain.Merchants.Enums;
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Merchants.Entities;
/// <summary>
/// 商户入驻审核日志。
/// </summary>
public sealed class MerchantAuditLog : MultiTenantEntityBase
{
/// <summary>
/// 商户标识。
/// </summary>
public long MerchantId { get; set; }
/// <summary>
/// 动作类型。
/// </summary>
public MerchantAuditAction Action { get; set; }
/// <summary>
/// 标题。
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// 详情描述。
/// </summary>
public string? Description { get; set; }
/// <summary>
/// 操作人 ID。
/// </summary>
public long? OperatorId { get; set; }
/// <summary>
/// 操作人名称。
/// </summary>
public string? OperatorName { get; set; }
}

View File

@@ -0,0 +1,24 @@
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Merchants.Entities;
/// <summary>
/// 商户可选类目。
/// </summary>
public sealed class MerchantCategory : MultiTenantEntityBase
{
/// <summary>
/// 类目名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 显示顺序,越小越靠前。
/// </summary>
public int DisplayOrder { get; set; }
/// <summary>
/// 是否可用。
/// </summary>
public bool IsActive { get; set; } = true;
}

View File

@@ -0,0 +1,37 @@
namespace TakeoutSaaS.Domain.Merchants.Enums;
/// <summary>
/// 商户审核日志动作。
/// </summary>
public enum MerchantAuditAction
{
/// <summary>
/// 提交入驻申请或资料。
/// </summary>
ApplicationSubmitted = 0,
/// <summary>
/// 上传/更新证照。
/// </summary>
DocumentUploaded = 1,
/// <summary>
/// 证照审核。
/// </summary>
DocumentReviewed = 2,
/// <summary>
/// 合同创建或更新。
/// </summary>
ContractUpdated = 3,
/// <summary>
/// 合同状态变更(生效/终止)。
/// </summary>
ContractStatusChanged = 4,
/// <summary>
/// 商户审核结果。
/// </summary>
MerchantReviewed = 5
}

View File

@@ -0,0 +1,47 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using TakeoutSaaS.Domain.Merchants.Entities;
namespace TakeoutSaaS.Domain.Merchants.Repositories;
/// <summary>
/// 商户类目仓储契约。
/// </summary>
public interface IMerchantCategoryRepository
{
/// <summary>
/// 列出当前租户的类目。
/// </summary>
Task<IReadOnlyList<MerchantCategory>> ListAsync(long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 是否存在同名类目。
/// </summary>
Task<bool> ExistsAsync(string name, long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 查找类目。
/// </summary>
Task<MerchantCategory?> FindByIdAsync(long id, long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 新增类目。
/// </summary>
Task AddAsync(MerchantCategory category, CancellationToken cancellationToken = default);
/// <summary>
/// 删除类目。
/// </summary>
Task RemoveAsync(MerchantCategory category, CancellationToken cancellationToken = default);
/// <summary>
/// 批量更新类目信息。
/// </summary>
Task UpdateRangeAsync(IEnumerable<MerchantCategory> categories, CancellationToken cancellationToken = default);
/// <summary>
/// 持久化更改。
/// </summary>
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@@ -30,11 +30,13 @@ public interface IMerchantRepository
/// 获取指定商户的合同列表。
/// </summary>
Task<IReadOnlyList<MerchantContract>> GetContractsAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default);
Task<MerchantContract?> FindContractByIdAsync(long merchantId, long tenantId, long contractId, CancellationToken cancellationToken = default);
/// <summary>
/// 获取指定商户的资质文件列表。
/// </summary>
Task<IReadOnlyList<MerchantDocument>> GetDocumentsAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default);
Task<MerchantDocument?> FindDocumentByIdAsync(long merchantId, long tenantId, long documentId, CancellationToken cancellationToken = default);
/// <summary>
/// 新增商户主体。
@@ -50,11 +52,13 @@ public interface IMerchantRepository
/// 新增商户合同。
/// </summary>
Task AddContractAsync(MerchantContract contract, CancellationToken cancellationToken = default);
Task UpdateContractAsync(MerchantContract contract, CancellationToken cancellationToken = default);
/// <summary>
/// 新增商户资质文件。
/// </summary>
Task AddDocumentAsync(MerchantDocument document, CancellationToken cancellationToken = default);
Task UpdateDocumentAsync(MerchantDocument document, CancellationToken cancellationToken = default);
/// <summary>
/// 持久化变更。
@@ -70,4 +74,14 @@ public interface IMerchantRepository
/// 删除商户。
/// </summary>
Task DeleteMerchantAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 记录审核日志。
/// </summary>
Task AddAuditLogAsync(MerchantAuditLog log, CancellationToken cancellationToken = default);
/// <summary>
/// 获取审核日志。
/// </summary>
Task<IReadOnlyList<MerchantAuditLog>> GetAuditLogsAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default);
}

View File

@@ -32,6 +32,7 @@ public static class AppServiceCollectionExtensions
services.AddPostgresDbContext<TakeoutAppDbContext>(DatabaseConstants.AppDataSource);
services.AddScoped<IMerchantRepository, EfMerchantRepository>();
services.AddScoped<IMerchantCategoryRepository, EfMerchantCategoryRepository>();
services.AddScoped<IStoreRepository, EfStoreRepository>();
services.AddScoped<IProductRepository, EfProductRepository>();
services.AddScoped<IOrderRepository, EfOrderRepository>();

View File

@@ -50,6 +50,8 @@ public sealed class TakeoutAppDbContext(
public DbSet<MerchantDocument> MerchantDocuments => Set<MerchantDocument>();
public DbSet<MerchantContract> MerchantContracts => Set<MerchantContract>();
public DbSet<MerchantStaff> MerchantStaff => Set<MerchantStaff>();
public DbSet<MerchantAuditLog> MerchantAuditLogs => Set<MerchantAuditLog>();
public DbSet<MerchantCategory> MerchantCategories => Set<MerchantCategory>();
public DbSet<Store> Stores => Set<Store>();
public DbSet<StoreBusinessHour> StoreBusinessHours => Set<StoreBusinessHour>();
@@ -144,6 +146,8 @@ public sealed class TakeoutAppDbContext(
ConfigureMerchantDocument(modelBuilder.Entity<MerchantDocument>());
ConfigureMerchantContract(modelBuilder.Entity<MerchantContract>());
ConfigureMerchantStaff(modelBuilder.Entity<MerchantStaff>());
ConfigureMerchantAuditLog(modelBuilder.Entity<MerchantAuditLog>());
ConfigureMerchantCategory(modelBuilder.Entity<MerchantCategory>());
ConfigureStoreBusinessHour(modelBuilder.Entity<StoreBusinessHour>());
ConfigureStoreHoliday(modelBuilder.Entity<StoreHoliday>());
ConfigureStoreDeliveryZone(modelBuilder.Entity<StoreDeliveryZone>());
@@ -499,6 +503,27 @@ public sealed class TakeoutAppDbContext(
builder.HasIndex(x => new { x.TenantId, x.MerchantId, x.Phone });
}
private static void ConfigureMerchantAuditLog(EntityTypeBuilder<MerchantAuditLog> builder)
{
builder.ToTable("merchant_audit_logs");
builder.HasKey(x => x.Id);
builder.Property(x => x.MerchantId).IsRequired();
builder.Property(x => x.Title).HasMaxLength(128).IsRequired();
builder.Property(x => x.Description).HasMaxLength(1024);
builder.Property(x => x.OperatorName).HasMaxLength(64);
builder.HasIndex(x => new { x.TenantId, x.MerchantId });
}
private static void ConfigureMerchantCategory(EntityTypeBuilder<MerchantCategory> builder)
{
builder.ToTable("merchant_categories");
builder.HasKey(x => x.Id);
builder.Property(x => x.Name).HasMaxLength(64).IsRequired();
builder.Property(x => x.DisplayOrder).HasDefaultValue(0);
builder.Property(x => x.IsActive).IsRequired();
builder.HasIndex(x => new { x.TenantId, x.Name }).IsUnique();
}
private static void ConfigureStoreBusinessHour(EntityTypeBuilder<StoreBusinessHour> builder)
{
builder.ToTable("store_business_hours");

View File

@@ -0,0 +1,68 @@
using System.Collections.Generic;
using System.Linq;
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Merchants.Entities;
using TakeoutSaaS.Domain.Merchants.Repositories;
using TakeoutSaaS.Infrastructure.App.Persistence;
namespace TakeoutSaaS.Infrastructure.App.Repositories;
/// <summary>
/// 商户类目的 EF Core 仓储实现。
/// </summary>
public sealed class EfMerchantCategoryRepository(TakeoutAppDbContext context)
: IMerchantCategoryRepository
{
/// <inheritdoc />
public async Task<IReadOnlyList<MerchantCategory>> ListAsync(long tenantId, CancellationToken cancellationToken = default)
{
var items = await context.MerchantCategories
.AsNoTracking()
.Where(x => x.TenantId == tenantId)
.OrderBy(x => x.DisplayOrder)
.ThenBy(x => x.CreatedAt)
.ToListAsync(cancellationToken);
return items;
}
/// <inheritdoc />
public Task<bool> ExistsAsync(string name, long tenantId, CancellationToken cancellationToken = default)
{
return context.MerchantCategories.AnyAsync(
x => x.TenantId == tenantId && x.Name == name, cancellationToken);
}
/// <inheritdoc />
public Task<MerchantCategory?> FindByIdAsync(long id, long tenantId, CancellationToken cancellationToken = default)
{
return context.MerchantCategories
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == id, cancellationToken);
}
/// <inheritdoc />
public Task AddAsync(MerchantCategory category, CancellationToken cancellationToken = default)
{
return context.MerchantCategories.AddAsync(category, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task RemoveAsync(MerchantCategory category, CancellationToken cancellationToken = default)
{
context.MerchantCategories.Remove(category);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task UpdateRangeAsync(IEnumerable<MerchantCategory> categories, CancellationToken cancellationToken = default)
{
context.MerchantCategories.UpdateRange(categories);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
return context.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -67,6 +67,14 @@ public sealed class EfMerchantRepository(TakeoutAppDbContext context) : IMerchan
return contracts;
}
/// <inheritdoc />
public Task<MerchantContract?> FindContractByIdAsync(long merchantId, long tenantId, long contractId, CancellationToken cancellationToken = default)
{
return context.MerchantContracts
.Where(x => x.TenantId == tenantId && x.MerchantId == merchantId && x.Id == contractId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<MerchantDocument>> GetDocumentsAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default)
{
@@ -79,6 +87,14 @@ public sealed class EfMerchantRepository(TakeoutAppDbContext context) : IMerchan
return documents;
}
/// <inheritdoc />
public Task<MerchantDocument?> FindDocumentByIdAsync(long merchantId, long tenantId, long documentId, CancellationToken cancellationToken = default)
{
return context.MerchantDocuments
.Where(x => x.TenantId == tenantId && x.MerchantId == merchantId && x.Id == documentId)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public Task AddMerchantAsync(Merchant merchant, CancellationToken cancellationToken = default)
{
@@ -97,12 +113,26 @@ public sealed class EfMerchantRepository(TakeoutAppDbContext context) : IMerchan
return context.MerchantContracts.AddAsync(contract, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateContractAsync(MerchantContract contract, CancellationToken cancellationToken = default)
{
context.MerchantContracts.Update(contract);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task AddDocumentAsync(MerchantDocument document, CancellationToken cancellationToken = default)
{
return context.MerchantDocuments.AddAsync(document, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateDocumentAsync(MerchantDocument document, CancellationToken cancellationToken = default)
{
context.MerchantDocuments.Update(document);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
@@ -130,4 +160,20 @@ public sealed class EfMerchantRepository(TakeoutAppDbContext context) : IMerchan
context.Merchants.Remove(existing);
}
/// <inheritdoc />
public Task AddAuditLogAsync(MerchantAuditLog log, CancellationToken cancellationToken = default)
{
return context.MerchantAuditLogs.AddAsync(log, cancellationToken).AsTask();
}
/// <inheritdoc />
public async Task<IReadOnlyList<MerchantAuditLog>> GetAuditLogsAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default)
{
return await context.MerchantAuditLogs
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.MerchantId == merchantId)
.OrderByDescending(x => x.CreatedAt)
.ToListAsync(cancellationToken);
}
}