From 0c329669a92d3a4650771a959f48ae8476bba73b Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Wed, 3 Dec 2025 19:01:53 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=95=86=E6=88=B7=E7=B1=BB=E7=9B=AE?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=BA=93=E5=8C=96=E5=B9=B6=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E6=9D=83=E9=99=90=E7=A7=8D=E5=AD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Document/12_BusinessTodo.md | 4 +- .../MerchantCategoriesController.cs | 74 +++++++++ .../Controllers/MerchantsController.cs | 145 +++++++++++++++++- .../appsettings.Development.json | 2 +- .../appsettings.Production.json | 2 +- .../appsettings.Seed.Development.json | 14 +- .../Commands/AddMerchantDocumentCommand.cs | 18 +++ .../Commands/CreateMerchantCategoryCommand.cs | 13 ++ .../Commands/CreateMerchantContractCommand.cs | 16 ++ .../Commands/DeleteMerchantCategoryCommand.cs | 9 ++ .../ReorderMerchantCategoriesCommand.cs | 18 +++ .../Commands/ReviewMerchantCommand.cs | 13 ++ .../Commands/ReviewMerchantDocumentCommand.cs | 14 ++ .../UpdateMerchantContractStatusCommand.cs | 17 ++ .../App/Merchants/Dto/MerchantAuditLogDto.cs | 27 ++++ .../App/Merchants/Dto/MerchantCategoryDto.cs | 34 ++++ .../App/Merchants/Dto/MerchantContractDto.cs | 33 ++++ .../App/Merchants/Dto/MerchantDetailDto.cs | 24 +++ .../App/Merchants/Dto/MerchantDocumentDto.cs | 33 ++++ .../AddMerchantDocumentCommandHandler.cs | 76 +++++++++ .../CreateMerchantCategoryCommandHandler.cs | 49 ++++++ .../CreateMerchantContractCommandHandler.cs | 78 ++++++++++ .../DeleteMerchantCategoryCommandHandler.cs | 33 ++++ .../GetMerchantAuditLogsQueryHandler.cs | 35 +++++ .../GetMerchantCategoriesQueryHandler.cs | 34 ++++ .../GetMerchantContractsQueryHandler.cs | 32 ++++ .../Handlers/GetMerchantDetailQueryHandler.cs | 38 +++++ .../GetMerchantDocumentsQueryHandler.cs | 32 ++++ .../ListMerchantCategoriesQueryHandler.cs | 27 ++++ ...ReorderMerchantCategoriesCommandHandler.cs | 42 +++++ .../Handlers/ReviewMerchantCommandHandler.cs | 74 +++++++++ .../ReviewMerchantDocumentCommandHandler.cs | 69 +++++++++ ...ateMerchantContractStatusCommandHandler.cs | 76 +++++++++ .../App/Merchants/MerchantMapping.cs | 84 ++++++++++ .../Queries/GetMerchantAuditLogsQuery.cs | 13 ++ .../Queries/GetMerchantCategoriesQuery.cs | 9 ++ .../Queries/GetMerchantContractsQuery.cs | 10 ++ .../Queries/GetMerchantDetailQuery.cs | 9 ++ .../Queries/GetMerchantDocumentsQuery.cs | 10 ++ .../Queries/ListMerchantCategoriesQuery.cs | 10 ++ .../Merchants/Entities/MerchantAuditLog.cs | 40 +++++ .../Merchants/Entities/MerchantCategory.cs | 24 +++ .../Merchants/Enums/MerchantAuditAction.cs | 37 +++++ .../IMerchantCategoryRepository.cs | 47 ++++++ .../Repositories/IMerchantRepository.cs | 14 ++ .../AppServiceCollectionExtensions.cs | 1 + .../App/Persistence/TakeoutAppDbContext.cs | 25 +++ .../EfMerchantCategoryRepository.cs | 68 ++++++++ .../App/Repositories/EfMerchantRepository.cs | 46 ++++++ 49 files changed, 1646 insertions(+), 6 deletions(-) create mode 100644 src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantCategoriesController.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Commands/AddMerchantDocumentCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Commands/CreateMerchantCategoryCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Commands/CreateMerchantContractCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Commands/DeleteMerchantCategoryCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Commands/ReorderMerchantCategoriesCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Commands/ReviewMerchantCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Commands/ReviewMerchantDocumentCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Commands/UpdateMerchantContractStatusCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantAuditLogDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantCategoryDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantContractDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantDetailDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantDocumentDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/AddMerchantDocumentCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/CreateMerchantCategoryCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/CreateMerchantContractCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/DeleteMerchantCategoryCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantAuditLogsQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantCategoriesQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantContractsQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantDetailQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantDocumentsQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ListMerchantCategoriesQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReorderMerchantCategoriesCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReviewMerchantCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReviewMerchantDocumentCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/UpdateMerchantContractStatusCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/MerchantMapping.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantAuditLogsQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantCategoriesQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantContractsQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantDetailQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantDocumentsQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Merchants/Queries/ListMerchantCategoriesQuery.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Merchants/Entities/MerchantAuditLog.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Merchants/Entities/MerchantCategory.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Merchants/Enums/MerchantAuditAction.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Merchants/Repositories/IMerchantCategoryRepository.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMerchantCategoryRepository.cs diff --git a/Document/12_BusinessTodo.md b/Document/12_BusinessTodo.md index ca1a132..5eaadc5 100644 --- a/Document/12_BusinessTodo.md +++ b/Document/12_BusinessTodo.md @@ -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。 diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantCategoriesController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantCategoriesController.cs new file mode 100644 index 0000000..72684f0 --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantCategoriesController.cs @@ -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; + +/// +/// 商户类目管理。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/admin/v{version:apiVersion}/merchant-categories")] +public sealed class MerchantCategoriesController(IMediator mediator) : BaseApiController +{ + /// + /// 列出所有类目。 + /// + [HttpGet] + [PermissionAuthorize("merchant_category:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> List(CancellationToken cancellationToken) + { + var result = await mediator.Send(new ListMerchantCategoriesQuery(), cancellationToken); + return ApiResponse>.Ok(result); + } + + /// + /// 新增类目。 + /// + [HttpPost] + [PermissionAuthorize("merchant_category:create")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Create([FromBody] CreateMerchantCategoryCommand command, CancellationToken cancellationToken) + { + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 删除类目。 + /// + [HttpDelete("{categoryId:long}")] + [PermissionAuthorize("merchant_category:delete")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Delete(long categoryId, CancellationToken cancellationToken) + { + var success = await mediator.Send(new DeleteMerchantCategoryCommand(categoryId), cancellationToken); + return success + ? ApiResponse.Ok(null) + : ApiResponse.Error(ErrorCodes.NotFound, "类目不存在"); + } + + /// + /// 批量调整类目排序。 + /// + [HttpPost("reorder")] + [PermissionAuthorize("merchant_category:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Reorder([FromBody] ReorderMerchantCategoriesCommand command, CancellationToken cancellationToken) + { + await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(null); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantsController.cs index dd99bb6..d63bf56 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantsController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantsController.cs @@ -99,7 +99,7 @@ public sealed class MerchantsController(IMediator mediator) : BaseApiController } /// - /// 获取商户详情。 + /// 获取商户概览。 /// [HttpGet("{merchantId:long}")] [PermissionAuthorize("merchant:read")] @@ -112,4 +112,147 @@ public sealed class MerchantsController(IMediator mediator) : BaseApiController ? ApiResponse.Error(ErrorCodes.NotFound, "商户不存在") : ApiResponse.Ok(result); } + + /// + /// 获取商户详细资料(含证照、合同)。 + /// + [HttpGet("{merchantId:long}/detail")] + [PermissionAuthorize("merchant:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> FullDetail(long merchantId, CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetMerchantDetailQuery(merchantId), cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 上传商户证照信息(先通过文件上传接口获取 COS 地址)。 + /// + [HttpPost("{merchantId:long}/documents")] + [PermissionAuthorize("merchant:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> CreateDocument( + long merchantId, + [FromBody] AddMerchantDocumentCommand body, + CancellationToken cancellationToken) + { + var command = body with { MerchantId = merchantId }; + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 商户证照列表。 + /// + [HttpGet("{merchantId:long}/documents")] + [PermissionAuthorize("merchant:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> Documents(long merchantId, CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetMerchantDocumentsQuery(merchantId), cancellationToken); + return ApiResponse>.Ok(result); + } + + /// + /// 审核指定证照。 + /// + [HttpPost("{merchantId:long}/documents/{documentId:long}/review")] + [PermissionAuthorize("merchant:review")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> 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.Ok(result); + } + + /// + /// 新增商户合同。 + /// + [HttpPost("{merchantId:long}/contracts")] + [PermissionAuthorize("merchant:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> CreateContract( + long merchantId, + [FromBody] CreateMerchantContractCommand body, + CancellationToken cancellationToken) + { + var command = body with { MerchantId = merchantId }; + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 合同列表。 + /// + [HttpGet("{merchantId:long}/contracts")] + [PermissionAuthorize("merchant:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> Contracts(long merchantId, CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetMerchantContractsQuery(merchantId), cancellationToken); + return ApiResponse>.Ok(result); + } + + /// + /// 更新合同状态(生效/终止等)。 + /// + [HttpPut("{merchantId:long}/contracts/{contractId:long}/status")] + [PermissionAuthorize("merchant:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> 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.Ok(result); + } + + /// + /// 审核商户(通过/驳回)。 + /// + [HttpPost("{merchantId:long}/review")] + [PermissionAuthorize("merchant:review")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Review(long merchantId, [FromBody] ReviewMerchantCommand body, CancellationToken cancellationToken) + { + var command = body with { MerchantId = merchantId }; + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 审核日志。 + /// + [HttpGet("{merchantId:long}/audits")] + [PermissionAuthorize("merchant:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> 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>.Ok(result); + } + + /// + /// 可选商户类目列表。 + /// + [HttpGet("categories")] + [PermissionAuthorize("merchant:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> Categories(CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetMerchantCategoriesQuery(), cancellationToken); + return ApiResponse>.Ok(result); + } } diff --git a/src/Api/TakeoutSaaS.AdminApi/appsettings.Development.json b/src/Api/TakeoutSaaS.AdminApi/appsettings.Development.json index 1bc5044..3955d16 100644 --- a/src/Api/TakeoutSaaS.AdminApi/appsettings.Development.json +++ b/src/Api/TakeoutSaaS.AdminApi/appsettings.Development.json @@ -173,4 +173,4 @@ "Sampling": "ParentBasedAlwaysOn", "UseConsoleExporter": true } -} \ No newline at end of file +} diff --git a/src/Api/TakeoutSaaS.AdminApi/appsettings.Production.json b/src/Api/TakeoutSaaS.AdminApi/appsettings.Production.json index 1bc5044..3955d16 100644 --- a/src/Api/TakeoutSaaS.AdminApi/appsettings.Production.json +++ b/src/Api/TakeoutSaaS.AdminApi/appsettings.Production.json @@ -173,4 +173,4 @@ "Sampling": "ParentBasedAlwaysOn", "UseConsoleExporter": true } -} \ No newline at end of file +} diff --git a/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json b/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json index 3079e81..6f897a7 100644 --- a/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json +++ b/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json @@ -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:*" + ] } ] } diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/AddMerchantDocumentCommand.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/AddMerchantDocumentCommand.cs new file mode 100644 index 0000000..cf0d4a3 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/AddMerchantDocumentCommand.cs @@ -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; + +/// +/// 新增商户证照。 +/// +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; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/CreateMerchantCategoryCommand.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/CreateMerchantCategoryCommand.cs new file mode 100644 index 0000000..115d4ba --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/CreateMerchantCategoryCommand.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Dto; + +namespace TakeoutSaaS.Application.App.Merchants.Commands; + +/// +/// 新增商户类目。 +/// +public sealed record CreateMerchantCategoryCommand( + [property: Required, MaxLength(64)] string Name, + int? DisplayOrder, + bool IsActive = true) : IRequest; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/CreateMerchantContractCommand.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/CreateMerchantContractCommand.cs new file mode 100644 index 0000000..176cf98 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/CreateMerchantContractCommand.cs @@ -0,0 +1,16 @@ +using System; +using System.ComponentModel.DataAnnotations; +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Dto; + +namespace TakeoutSaaS.Application.App.Merchants.Commands; + +/// +/// 新建商户合同。 +/// +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; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/DeleteMerchantCategoryCommand.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/DeleteMerchantCategoryCommand.cs new file mode 100644 index 0000000..ffa0326 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/DeleteMerchantCategoryCommand.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; +using MediatR; + +namespace TakeoutSaaS.Application.App.Merchants.Commands; + +/// +/// 删除商户类目。 +/// +public sealed record DeleteMerchantCategoryCommand([property: Required] long CategoryId) : IRequest; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/ReorderMerchantCategoriesCommand.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/ReorderMerchantCategoriesCommand.cs new file mode 100644 index 0000000..79bd657 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/ReorderMerchantCategoriesCommand.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using MediatR; + +namespace TakeoutSaaS.Application.App.Merchants.Commands; + +/// +/// 调整类目排序。 +/// +public sealed record ReorderMerchantCategoriesCommand( + [property: Required, MinLength(1)] IReadOnlyList Items) : IRequest; + +/// +/// 类目排序条目。 +/// +public sealed record MerchantCategoryOrderItem( + [property: Required] long CategoryId, + [property: Range(-1000, 100000)] int DisplayOrder); diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/ReviewMerchantCommand.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/ReviewMerchantCommand.cs new file mode 100644 index 0000000..792cddc --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/ReviewMerchantCommand.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Dto; + +namespace TakeoutSaaS.Application.App.Merchants.Commands; + +/// +/// 审核商户入驻。 +/// +public sealed record ReviewMerchantCommand( + [property: Required] long MerchantId, + bool Approve, + string? Remarks) : IRequest; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/ReviewMerchantDocumentCommand.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/ReviewMerchantDocumentCommand.cs new file mode 100644 index 0000000..23d6e2a --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/ReviewMerchantDocumentCommand.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Dto; + +namespace TakeoutSaaS.Application.App.Merchants.Commands; + +/// +/// 审核商户证照。 +/// +public sealed record ReviewMerchantDocumentCommand( + [property: Required] long MerchantId, + [property: Required] long DocumentId, + bool Approve, + string? Remarks) : IRequest; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/UpdateMerchantContractStatusCommand.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/UpdateMerchantContractStatusCommand.cs new file mode 100644 index 0000000..3514b58 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/UpdateMerchantContractStatusCommand.cs @@ -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; + +/// +/// 更新合同状态。 +/// +public sealed record UpdateMerchantContractStatusCommand( + [property: Required] long MerchantId, + [property: Required] long ContractId, + [property: Required] ContractStatus Status, + DateTime? SignedAt, + string? Reason) : IRequest; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantAuditLogDto.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantAuditLogDto.cs new file mode 100644 index 0000000..2c42c35 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantAuditLogDto.cs @@ -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; + +/// +/// 商户审核日志 DTO。 +/// +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; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantCategoryDto.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantCategoryDto.cs new file mode 100644 index 0000000..0d8636b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantCategoryDto.cs @@ -0,0 +1,34 @@ +using System; + +namespace TakeoutSaaS.Application.App.Merchants.Dto; + +/// +/// 商户类目 DTO。 +/// +public sealed record MerchantCategoryDto +{ + /// + /// 类目标识。 + /// + public long Id { get; init; } + + /// + /// 类目名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 显示顺序。 + /// + public int DisplayOrder { get; init; } + + /// + /// 是否启用。 + /// + public bool IsActive { get; init; } + + /// + /// 创建时间。 + /// + public DateTime CreatedAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantContractDto.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantContractDto.cs new file mode 100644 index 0000000..3c12bb3 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantContractDto.cs @@ -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; + +/// +/// 商户合同 DTO。 +/// +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; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantDetailDto.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantDetailDto.cs new file mode 100644 index 0000000..4c8dedb --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantDetailDto.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; + +namespace TakeoutSaaS.Application.App.Merchants.Dto; + +/// +/// 商户详情 DTO。 +/// +public sealed class MerchantDetailDto +{ + /// + /// 基础信息。 + /// + public MerchantDto Merchant { get; init; } = new(); + + /// + /// 证照列表。 + /// + public IReadOnlyList Documents { get; init; } = []; + + /// + /// 合同列表。 + /// + public IReadOnlyList Contracts { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantDocumentDto.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantDocumentDto.cs new file mode 100644 index 0000000..8723031 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantDocumentDto.cs @@ -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; + +/// +/// 商户证照 DTO。 +/// +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; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/AddMerchantDocumentCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/AddMerchantDocumentCommandHandler.cs new file mode 100644 index 0000000..46e2923 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/AddMerchantDocumentCommandHandler.cs @@ -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; + +/// +/// 处理证照上传。 +/// +public sealed class AddMerchantDocumentCommandHandler( + IMerchantRepository merchantRepository, + ITenantProvider tenantProvider, + IIdGenerator idGenerator, + ICurrentUserAccessor currentUserAccessor) + : IRequestHandler +{ + private readonly IMerchantRepository _merchantRepository = merchantRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + private readonly IIdGenerator _idGenerator = idGenerator; + private readonly ICurrentUserAccessor _currentUserAccessor = currentUserAccessor; + + public async Task 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}"; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/CreateMerchantCategoryCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/CreateMerchantCategoryCommandHandler.cs new file mode 100644 index 0000000..84dd79e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/CreateMerchantCategoryCommandHandler.cs @@ -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; + +/// +/// 创建类目处理器。 +/// +public sealed class CreateMerchantCategoryCommandHandler( + IMerchantCategoryRepository categoryRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + private readonly IMerchantCategoryRepository _categoryRepository = categoryRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + + public async Task 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); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/CreateMerchantContractCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/CreateMerchantContractCommandHandler.cs new file mode 100644 index 0000000..0c4aecb --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/CreateMerchantContractCommandHandler.cs @@ -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; + +/// +/// 创建商户合同。 +/// +public sealed class CreateMerchantContractCommandHandler( + IMerchantRepository merchantRepository, + ITenantProvider tenantProvider, + IIdGenerator idGenerator, + ICurrentUserAccessor currentUserAccessor) + : IRequestHandler +{ + private readonly IMerchantRepository _merchantRepository = merchantRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + private readonly IIdGenerator _idGenerator = idGenerator; + private readonly ICurrentUserAccessor _currentUserAccessor = currentUserAccessor; + + public async Task 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}"; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/DeleteMerchantCategoryCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/DeleteMerchantCategoryCommandHandler.cs new file mode 100644 index 0000000..f0084b5 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/DeleteMerchantCategoryCommandHandler.cs @@ -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; + +/// +/// 删除类目处理器。 +/// +public sealed class DeleteMerchantCategoryCommandHandler( + IMerchantCategoryRepository categoryRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + private readonly IMerchantCategoryRepository _categoryRepository = categoryRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + + public async Task 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; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantAuditLogsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantAuditLogsQueryHandler.cs new file mode 100644 index 0000000..f43146c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantAuditLogsQueryHandler.cs @@ -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; + +/// +/// 读取商户审核日志。 +/// +public sealed class GetMerchantAuditLogsQueryHandler( + IMerchantRepository merchantRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + private readonly IMerchantRepository _merchantRepository = merchantRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + + public async Task> 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(paged, request.Page, request.PageSize, total); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantCategoriesQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantCategoriesQueryHandler.cs new file mode 100644 index 0000000..e0f9e89 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantCategoriesQueryHandler.cs @@ -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; + +/// +/// 读取可选类目。 +/// +public sealed class GetMerchantCategoriesQueryHandler( + IMerchantCategoryRepository categoryRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + private readonly IMerchantCategoryRepository _categoryRepository = categoryRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + + public async Task> 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(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantContractsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantContractsQueryHandler.cs new file mode 100644 index 0000000..50b20ab --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantContractsQueryHandler.cs @@ -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; + +/// +/// 查询合同列表。 +/// +public sealed class GetMerchantContractsQueryHandler( + IMerchantRepository merchantRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + private readonly IMerchantRepository _merchantRepository = merchantRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + + public async Task> 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); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantDetailQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantDetailQueryHandler.cs new file mode 100644 index 0000000..f21f1d0 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantDetailQueryHandler.cs @@ -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; + +/// +/// 商户详情处理器。 +/// +public sealed class GetMerchantDetailQueryHandler( + IMerchantRepository merchantRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + private readonly IMerchantRepository _merchantRepository = merchantRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + + public async Task 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) + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantDocumentsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantDocumentsQueryHandler.cs new file mode 100644 index 0000000..f8ceeb4 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantDocumentsQueryHandler.cs @@ -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; + +/// +/// 查询证照列表。 +/// +public sealed class GetMerchantDocumentsQueryHandler( + IMerchantRepository merchantRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + private readonly IMerchantRepository _merchantRepository = merchantRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + + public async Task> 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); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ListMerchantCategoriesQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ListMerchantCategoriesQueryHandler.cs new file mode 100644 index 0000000..c3de6cd --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ListMerchantCategoriesQueryHandler.cs @@ -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; + +/// +/// 列出类目。 +/// +public sealed class ListMerchantCategoriesQueryHandler( + IMerchantCategoryRepository categoryRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + private readonly IMerchantCategoryRepository _categoryRepository = categoryRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + + public async Task> Handle(ListMerchantCategoriesQuery request, CancellationToken cancellationToken) + { + var tenantId = _tenantProvider.GetCurrentTenantId(); + var categories = await _categoryRepository.ListAsync(tenantId, cancellationToken); + return MerchantMapping.ToCategoryDtos(categories); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReorderMerchantCategoriesCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReorderMerchantCategoriesCommandHandler.cs new file mode 100644 index 0000000..a9be94d --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReorderMerchantCategoriesCommandHandler.cs @@ -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; + +/// +/// 类目排序处理器。 +/// +public sealed class ReorderMerchantCategoriesCommandHandler( + IMerchantCategoryRepository categoryRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + private readonly IMerchantCategoryRepository _categoryRepository = categoryRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + + public async Task 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; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReviewMerchantCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReviewMerchantCommandHandler.cs new file mode 100644 index 0000000..02ea882 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReviewMerchantCommandHandler.cs @@ -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; + +/// +/// 商户审核处理器。 +/// +public sealed class ReviewMerchantCommandHandler( + IMerchantRepository merchantRepository, + ITenantProvider tenantProvider, + ICurrentUserAccessor currentUserAccessor) + : IRequestHandler +{ + private readonly IMerchantRepository _merchantRepository = merchantRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + private readonly ICurrentUserAccessor _currentUserAccessor = currentUserAccessor; + + public async Task 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}"; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReviewMerchantDocumentCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReviewMerchantDocumentCommandHandler.cs new file mode 100644 index 0000000..20c1a3a --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReviewMerchantDocumentCommandHandler.cs @@ -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; + +/// +/// 审核证照处理器。 +/// +public sealed class ReviewMerchantDocumentCommandHandler( + IMerchantRepository merchantRepository, + ITenantProvider tenantProvider, + ICurrentUserAccessor currentUserAccessor) + : IRequestHandler +{ + private readonly IMerchantRepository _merchantRepository = merchantRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + private readonly ICurrentUserAccessor _currentUserAccessor = currentUserAccessor; + + public async Task 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}"; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/UpdateMerchantContractStatusCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/UpdateMerchantContractStatusCommandHandler.cs new file mode 100644 index 0000000..5e368f8 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/UpdateMerchantContractStatusCommandHandler.cs @@ -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; + +/// +/// 更新合同状态处理器。 +/// +public sealed class UpdateMerchantContractStatusCommandHandler( + IMerchantRepository merchantRepository, + ITenantProvider tenantProvider, + ICurrentUserAccessor currentUserAccessor) + : IRequestHandler +{ + private readonly IMerchantRepository _merchantRepository = merchantRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + private readonly ICurrentUserAccessor _currentUserAccessor = currentUserAccessor; + + public async Task 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}"; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/MerchantMapping.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/MerchantMapping.cs new file mode 100644 index 0000000..8c61873 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/MerchantMapping.cs @@ -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; + +/// +/// 商户 DTO 映射工具。 +/// +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 ToDocumentDtos(IEnumerable documents) + => documents.Select(ToDto).ToList(); + + public static IReadOnlyList ToContractDtos(IEnumerable contracts) + => contracts.Select(ToDto).ToList(); + + public static IReadOnlyList ToCategoryDtos(IEnumerable categories) + => categories.Select(ToDto).ToList(); +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantAuditLogsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantAuditLogsQuery.cs new file mode 100644 index 0000000..e82a58c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantAuditLogsQuery.cs @@ -0,0 +1,13 @@ +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Dto; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.Merchants.Queries; + +/// +/// 商户审核日志查询。 +/// +public sealed record GetMerchantAuditLogsQuery( + long MerchantId, + int Page = 1, + int PageSize = 20) : IRequest>; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantCategoriesQuery.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantCategoriesQuery.cs new file mode 100644 index 0000000..c55fd86 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantCategoriesQuery.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; +using MediatR; + +namespace TakeoutSaaS.Application.App.Merchants.Queries; + +/// +/// 获取商户可选类目。 +/// +public sealed record GetMerchantCategoriesQuery() : IRequest>; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantContractsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantContractsQuery.cs new file mode 100644 index 0000000..8940465 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantContractsQuery.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Dto; + +namespace TakeoutSaaS.Application.App.Merchants.Queries; + +/// +/// 查询商户合同。 +/// +public sealed record GetMerchantContractsQuery(long MerchantId) : IRequest>; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantDetailQuery.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantDetailQuery.cs new file mode 100644 index 0000000..f3b3eaa --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantDetailQuery.cs @@ -0,0 +1,9 @@ +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Dto; + +namespace TakeoutSaaS.Application.App.Merchants.Queries; + +/// +/// 商户详情查询。 +/// +public sealed record GetMerchantDetailQuery(long MerchantId) : IRequest; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantDocumentsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantDocumentsQuery.cs new file mode 100644 index 0000000..997f02a --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantDocumentsQuery.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Dto; + +namespace TakeoutSaaS.Application.App.Merchants.Queries; + +/// +/// 查询商户证照。 +/// +public sealed record GetMerchantDocumentsQuery(long MerchantId) : IRequest>; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/ListMerchantCategoriesQuery.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/ListMerchantCategoriesQuery.cs new file mode 100644 index 0000000..4e4ef4a --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/ListMerchantCategoriesQuery.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Dto; + +namespace TakeoutSaaS.Application.App.Merchants.Queries; + +/// +/// 管理端获取完整类目列表。 +/// +public sealed record ListMerchantCategoriesQuery() : IRequest>; diff --git a/src/Domain/TakeoutSaaS.Domain/Merchants/Entities/MerchantAuditLog.cs b/src/Domain/TakeoutSaaS.Domain/Merchants/Entities/MerchantAuditLog.cs new file mode 100644 index 0000000..e41d3e6 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Merchants/Entities/MerchantAuditLog.cs @@ -0,0 +1,40 @@ +using TakeoutSaaS.Domain.Merchants.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Merchants.Entities; + +/// +/// 商户入驻审核日志。 +/// +public sealed class MerchantAuditLog : MultiTenantEntityBase +{ + /// + /// 商户标识。 + /// + public long MerchantId { get; set; } + + /// + /// 动作类型。 + /// + public MerchantAuditAction Action { get; set; } + + /// + /// 标题。 + /// + public string Title { get; set; } = string.Empty; + + /// + /// 详情描述。 + /// + public string? Description { get; set; } + + /// + /// 操作人 ID。 + /// + public long? OperatorId { get; set; } + + /// + /// 操作人名称。 + /// + public string? OperatorName { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Merchants/Entities/MerchantCategory.cs b/src/Domain/TakeoutSaaS.Domain/Merchants/Entities/MerchantCategory.cs new file mode 100644 index 0000000..d55dc26 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Merchants/Entities/MerchantCategory.cs @@ -0,0 +1,24 @@ +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Merchants.Entities; + +/// +/// 商户可选类目。 +/// +public sealed class MerchantCategory : MultiTenantEntityBase +{ + /// + /// 类目名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 显示顺序,越小越靠前。 + /// + public int DisplayOrder { get; set; } + + /// + /// 是否可用。 + /// + public bool IsActive { get; set; } = true; +} diff --git a/src/Domain/TakeoutSaaS.Domain/Merchants/Enums/MerchantAuditAction.cs b/src/Domain/TakeoutSaaS.Domain/Merchants/Enums/MerchantAuditAction.cs new file mode 100644 index 0000000..330f6c4 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Merchants/Enums/MerchantAuditAction.cs @@ -0,0 +1,37 @@ +namespace TakeoutSaaS.Domain.Merchants.Enums; + +/// +/// 商户审核日志动作。 +/// +public enum MerchantAuditAction +{ + /// + /// 提交入驻申请或资料。 + /// + ApplicationSubmitted = 0, + + /// + /// 上传/更新证照。 + /// + DocumentUploaded = 1, + + /// + /// 证照审核。 + /// + DocumentReviewed = 2, + + /// + /// 合同创建或更新。 + /// + ContractUpdated = 3, + + /// + /// 合同状态变更(生效/终止)。 + /// + ContractStatusChanged = 4, + + /// + /// 商户审核结果。 + /// + MerchantReviewed = 5 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Merchants/Repositories/IMerchantCategoryRepository.cs b/src/Domain/TakeoutSaaS.Domain/Merchants/Repositories/IMerchantCategoryRepository.cs new file mode 100644 index 0000000..f8669d6 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Merchants/Repositories/IMerchantCategoryRepository.cs @@ -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; + +/// +/// 商户类目仓储契约。 +/// +public interface IMerchantCategoryRepository +{ + /// + /// 列出当前租户的类目。 + /// + Task> ListAsync(long tenantId, CancellationToken cancellationToken = default); + + /// + /// 是否存在同名类目。 + /// + Task ExistsAsync(string name, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 查找类目。 + /// + Task FindByIdAsync(long id, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 新增类目。 + /// + Task AddAsync(MerchantCategory category, CancellationToken cancellationToken = default); + + /// + /// 删除类目。 + /// + Task RemoveAsync(MerchantCategory category, CancellationToken cancellationToken = default); + + /// + /// 批量更新类目信息。 + /// + Task UpdateRangeAsync(IEnumerable categories, CancellationToken cancellationToken = default); + + /// + /// 持久化更改。 + /// + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Domain/TakeoutSaaS.Domain/Merchants/Repositories/IMerchantRepository.cs b/src/Domain/TakeoutSaaS.Domain/Merchants/Repositories/IMerchantRepository.cs index 9e8710a..c467a2e 100644 --- a/src/Domain/TakeoutSaaS.Domain/Merchants/Repositories/IMerchantRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Merchants/Repositories/IMerchantRepository.cs @@ -30,11 +30,13 @@ public interface IMerchantRepository /// 获取指定商户的合同列表。 /// Task> GetContractsAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default); + Task FindContractByIdAsync(long merchantId, long tenantId, long contractId, CancellationToken cancellationToken = default); /// /// 获取指定商户的资质文件列表。 /// Task> GetDocumentsAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default); + Task FindDocumentByIdAsync(long merchantId, long tenantId, long documentId, CancellationToken cancellationToken = default); /// /// 新增商户主体。 @@ -50,11 +52,13 @@ public interface IMerchantRepository /// 新增商户合同。 /// Task AddContractAsync(MerchantContract contract, CancellationToken cancellationToken = default); + Task UpdateContractAsync(MerchantContract contract, CancellationToken cancellationToken = default); /// /// 新增商户资质文件。 /// Task AddDocumentAsync(MerchantDocument document, CancellationToken cancellationToken = default); + Task UpdateDocumentAsync(MerchantDocument document, CancellationToken cancellationToken = default); /// /// 持久化变更。 @@ -70,4 +74,14 @@ public interface IMerchantRepository /// 删除商户。 /// Task DeleteMerchantAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 记录审核日志。 + /// + Task AddAuditLogAsync(MerchantAuditLog log, CancellationToken cancellationToken = default); + + /// + /// 获取审核日志。 + /// + Task> GetAuditLogsAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default); } diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs index c9ce810..c9a416d 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs @@ -32,6 +32,7 @@ public static class AppServiceCollectionExtensions services.AddPostgresDbContext(DatabaseConstants.AppDataSource); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs index f107cea..78dca47 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs @@ -50,6 +50,8 @@ public sealed class TakeoutAppDbContext( public DbSet MerchantDocuments => Set(); public DbSet MerchantContracts => Set(); public DbSet MerchantStaff => Set(); + public DbSet MerchantAuditLogs => Set(); + public DbSet MerchantCategories => Set(); public DbSet Stores => Set(); public DbSet StoreBusinessHours => Set(); @@ -144,6 +146,8 @@ public sealed class TakeoutAppDbContext( ConfigureMerchantDocument(modelBuilder.Entity()); ConfigureMerchantContract(modelBuilder.Entity()); ConfigureMerchantStaff(modelBuilder.Entity()); + ConfigureMerchantAuditLog(modelBuilder.Entity()); + ConfigureMerchantCategory(modelBuilder.Entity()); ConfigureStoreBusinessHour(modelBuilder.Entity()); ConfigureStoreHoliday(modelBuilder.Entity()); ConfigureStoreDeliveryZone(modelBuilder.Entity()); @@ -499,6 +503,27 @@ public sealed class TakeoutAppDbContext( builder.HasIndex(x => new { x.TenantId, x.MerchantId, x.Phone }); } + private static void ConfigureMerchantAuditLog(EntityTypeBuilder 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 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 builder) { builder.ToTable("store_business_hours"); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMerchantCategoryRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMerchantCategoryRepository.cs new file mode 100644 index 0000000..8b83a3e --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMerchantCategoryRepository.cs @@ -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; + +/// +/// 商户类目的 EF Core 仓储实现。 +/// +public sealed class EfMerchantCategoryRepository(TakeoutAppDbContext context) + : IMerchantCategoryRepository +{ + /// + public async Task> 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; + } + + /// + public Task ExistsAsync(string name, long tenantId, CancellationToken cancellationToken = default) + { + return context.MerchantCategories.AnyAsync( + x => x.TenantId == tenantId && x.Name == name, cancellationToken); + } + + /// + public Task FindByIdAsync(long id, long tenantId, CancellationToken cancellationToken = default) + { + return context.MerchantCategories + .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == id, cancellationToken); + } + + /// + public Task AddAsync(MerchantCategory category, CancellationToken cancellationToken = default) + { + return context.MerchantCategories.AddAsync(category, cancellationToken).AsTask(); + } + + /// + public Task RemoveAsync(MerchantCategory category, CancellationToken cancellationToken = default) + { + context.MerchantCategories.Remove(category); + return Task.CompletedTask; + } + + /// + public Task UpdateRangeAsync(IEnumerable categories, CancellationToken cancellationToken = default) + { + context.MerchantCategories.UpdateRange(categories); + return Task.CompletedTask; + } + + /// + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return context.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMerchantRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMerchantRepository.cs index 6158f92..35e00a8 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMerchantRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMerchantRepository.cs @@ -67,6 +67,14 @@ public sealed class EfMerchantRepository(TakeoutAppDbContext context) : IMerchan return contracts; } + /// + public Task 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); + } + /// public async Task> GetDocumentsAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default) { @@ -79,6 +87,14 @@ public sealed class EfMerchantRepository(TakeoutAppDbContext context) : IMerchan return documents; } + /// + public Task 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); + } + /// 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(); } + /// + public Task UpdateContractAsync(MerchantContract contract, CancellationToken cancellationToken = default) + { + context.MerchantContracts.Update(contract); + return Task.CompletedTask; + } + /// public Task AddDocumentAsync(MerchantDocument document, CancellationToken cancellationToken = default) { return context.MerchantDocuments.AddAsync(document, cancellationToken).AsTask(); } + /// + public Task UpdateDocumentAsync(MerchantDocument document, CancellationToken cancellationToken = default) + { + context.MerchantDocuments.Update(document); + return Task.CompletedTask; + } + /// public Task SaveChangesAsync(CancellationToken cancellationToken = default) { @@ -130,4 +160,20 @@ public sealed class EfMerchantRepository(TakeoutAppDbContext context) : IMerchan context.Merchants.Remove(existing); } + + /// + public Task AddAuditLogAsync(MerchantAuditLog log, CancellationToken cancellationToken = default) + { + return context.MerchantAuditLogs.AddAsync(log, cancellationToken).AsTask(); + } + + /// + public async Task> 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); + } }