From a536a554c2e2d90bef53ac3a06f9cd8a49e63da1 Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Wed, 3 Dec 2025 16:37:50 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E7=A7=9F=E6=88=B7?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=8F=8A=E5=A5=97=E9=A4=90=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Document/12_BusinessTodo.md | 9 +- .../Controllers/TenantsController.cs | 137 +++++++++++++++ .../ChangeTenantSubscriptionPlanCommand.cs | 15 ++ .../CreateTenantSubscriptionCommand.cs | 15 ++ .../Tenants/Commands/RegisterTenantCommand.cs | 21 +++ .../Tenants/Commands/ReviewTenantCommand.cs | 13 ++ .../SubmitTenantVerificationCommand.cs | 21 +++ .../App/Tenants/Dto/TenantAuditLogDto.cs | 58 +++++++ .../App/Tenants/Dto/TenantDetailDto.cs | 22 +++ .../App/Tenants/Dto/TenantDto.cs | 78 +++++++++ .../App/Tenants/Dto/TenantSubscriptionDto.cs | 54 ++++++ .../App/Tenants/Dto/TenantVerificationDto.cs | 73 ++++++++ ...ngeTenantSubscriptionPlanCommandHandler.cs | 74 ++++++++ .../CreateTenantSubscriptionCommandHandler.cs | 82 +++++++++ .../GetTenantAuditLogsQueryHandler.cs | 32 ++++ .../Handlers/GetTenantByIdQueryHandler.cs | 34 ++++ .../Handlers/RegisterTenantCommandHandler.cs | 88 ++++++++++ .../Handlers/ReviewTenantCommandHandler.cs | 87 ++++++++++ .../Handlers/SearchTenantsQueryHandler.cs | 39 +++++ .../SubmitTenantVerificationCommandHandler.cs | 63 +++++++ .../Queries/GetTenantAuditLogsQuery.cs | 13 ++ .../App/Tenants/Queries/GetTenantByIdQuery.cs | 9 + .../App/Tenants/Queries/SearchTenantsQuery.cs | 15 ++ .../App/Tenants/TenantMapping.cs | 76 +++++++++ .../Tenants/Entities/TenantAuditLog.cs | 50 ++++++ .../Entities/TenantSubscriptionHistory.cs | 60 +++++++ .../Entities/TenantVerificationProfile.cs | 95 +++++++++++ .../Tenants/Enums/SubscriptionChangeType.cs | 27 +++ .../Tenants/Enums/TenantAuditAction.cs | 42 +++++ .../Tenants/Enums/TenantVerificationStatus.cs | 27 +++ .../Tenants/Repositories/ITenantRepository.cs | 95 +++++++++++ .../AppServiceCollectionExtensions.cs | 2 + .../App/Persistence/TakeoutAppDbContext.cs | 47 +++++ .../App/Repositories/EfTenantRepository.cs | 161 ++++++++++++++++++ 34 files changed, 1732 insertions(+), 2 deletions(-) create mode 100644 src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Commands/ChangeTenantSubscriptionPlanCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantSubscriptionCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Commands/RegisterTenantCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Commands/ReviewTenantCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Commands/SubmitTenantVerificationCommand.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantAuditLogDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantDetailDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantSubscriptionDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantVerificationDto.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/ChangeTenantSubscriptionPlanCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantSubscriptionCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantAuditLogsQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantByIdQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/RegisterTenantCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/ReviewTenantCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantsQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SubmitTenantVerificationCommandHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantAuditLogsQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantByIdQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantsQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Tenants/TenantMapping.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantAuditLog.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantSubscriptionHistory.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantVerificationProfile.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Enums/SubscriptionChangeType.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantAuditAction.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantVerificationStatus.cs create mode 100644 src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantRepository.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantRepository.cs diff --git a/Document/12_BusinessTodo.md b/Document/12_BusinessTodo.md index bd3da81..ca1a132 100644 --- a/Document/12_BusinessTodo.md +++ b/Document/12_BusinessTodo.md @@ -4,11 +4,16 @@ --- ## Phase 1(当前阶段):租户/商家入驻、门店与菜品、扫码堂食、基础下单支付、预购自提、第三方配送骨架 -- [ ] 管理端租户 API:注册、实名认证、套餐订阅/续费/升降配、审核流,Swagger ≥6 个端点,含审核日志。 +- [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 存储与状态机端点。 - [ ] RBAC 模板:平台管理员、租户管理员、店长、店员四角色模板;API 可复制并允许租户自定义扩展。 + - 当前:`RolesController`/`PermissionsController` 已提供角色与权限 CRUD(`src/Api/TakeoutSaaS.AdminApi/Controllers/RolesController.cs:16-88`、`.../PermissionsController.cs:16-63`),但没有“模板复制”或按租户批量初始化的接口。 - [ ] 配额与套餐:TenantPackage CRUD、订阅/续费/配额校验(门店/账号/短信/配送单量),超额返回 409 并记录 TenantQuotaUsage。 + - 当前:领域层已有 `TenantPackage`/`TenantSubscription` 等实体(`src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantPackage.cs:5-48`),数据库模型也同步生成,但 Admin API/应用层未暴露任何 CRUD 或配额校验逻辑。 - [ ] 租户运营面板:欠费/到期告警、账单列表、公告通知接口,支持已读状态并在 Admin UI 展示。 + - 当前:`SystemParametersController` 仅负责普通参数 CRUD(`src/Api/TakeoutSaaS.AdminApi/Controllers/SystemParametersController.cs:15-104`),未包含租户账单、公告或通知接口。 - [ ] 门店管理:Store/StoreBusinessHour/StoreDeliveryZone/StoreHoliday CRUD 完整,含 GeoJSON 配送范围及能力开关。 - [ ] 桌码管理:批量生成桌码、绑定区域/容量、导出二维码 ZIP(POST /api/admin/stores/{id}/tables 可下载)。 - [ ] 员工排班:创建员工、绑定门店角色、维护 StoreEmployeeShift,可查询未来 7 日排班。 @@ -50,4 +55,4 @@ - [ ] 可靠性:幂等与重试策略、任务调度补偿、链路追踪、告警联动。 - [ ] 运营大盘:交易/营销/履约/用户维度的细分报表、GMV/成本/毛利分析。 - [ ] 文档与测试:完整测试矩阵、性能测试报告、上线手册、回滚方案。 -- [ ] 监控与运维:上线发布流程、灰度/回滚策略、系统稳定性指标、24x7 监控与告警。 \ No newline at end of file +- [ ] 监控与运维:上线发布流程、灰度/回滚策略、系统稳定性指标、24x7 监控与告警。 diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs new file mode 100644 index 0000000..5fe90bc --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs @@ -0,0 +1,137 @@ +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Application.App.Tenants.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}/tenants")] +public sealed class TenantsController(IMediator mediator) : BaseApiController +{ + /// + /// 注册租户并初始化套餐。 + /// + [HttpPost] + [PermissionAuthorize("tenant:create")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Register([FromBody] RegisterTenantCommand command, CancellationToken cancellationToken) + { + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 分页查询租户。 + /// + [HttpGet] + [PermissionAuthorize("tenant:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> Search([FromQuery] SearchTenantsQuery query, CancellationToken cancellationToken) + { + var result = await mediator.Send(query, cancellationToken); + return ApiResponse>.Ok(result); + } + + /// + /// 查看租户详情。 + /// + [HttpGet("{tenantId:long}")] + [PermissionAuthorize("tenant:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Detail(long tenantId, CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetTenantByIdQuery(tenantId), cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 提交或更新实名认证资料。 + /// + [HttpPost("{tenantId:long}/verification")] + [PermissionAuthorize("tenant:review")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> SubmitVerification( + long tenantId, + [FromBody] SubmitTenantVerificationCommand body, + CancellationToken cancellationToken) + { + var command = body with { TenantId = tenantId }; + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 审核租户。 + /// + [HttpPost("{tenantId:long}/review")] + [PermissionAuthorize("tenant:review")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Review(long tenantId, [FromBody] ReviewTenantCommand body, CancellationToken cancellationToken) + { + var command = body with { TenantId = tenantId }; + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 创建或续费租户订阅。 + /// + [HttpPost("{tenantId:long}/subscriptions")] + [PermissionAuthorize("tenant:subscription")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> CreateSubscription( + long tenantId, + [FromBody] CreateTenantSubscriptionCommand body, + CancellationToken cancellationToken) + { + var command = body with { TenantId = tenantId }; + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 套餐升降配。 + /// + [HttpPut("{tenantId:long}/subscriptions/{subscriptionId:long}/plan")] + [PermissionAuthorize("tenant:subscription")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> ChangePlan( + long tenantId, + long subscriptionId, + [FromBody] ChangeTenantSubscriptionPlanCommand body, + CancellationToken cancellationToken) + { + var command = body with { TenantId = tenantId, TenantSubscriptionId = subscriptionId }; + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 查询审核日志。 + /// + [HttpGet("{tenantId:long}/audits")] + [PermissionAuthorize("tenant:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> AuditLogs( + long tenantId, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + CancellationToken cancellationToken = default) + { + var query = new GetTenantAuditLogsQuery(tenantId, page, pageSize); + var result = await mediator.Send(query, cancellationToken); + return ApiResponse>.Ok(result); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/ChangeTenantSubscriptionPlanCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/ChangeTenantSubscriptionPlanCommand.cs new file mode 100644 index 0000000..d9c6287 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/ChangeTenantSubscriptionPlanCommand.cs @@ -0,0 +1,15 @@ +using MediatR; +using System.ComponentModel.DataAnnotations; +using TakeoutSaaS.Application.App.Tenants.Dto; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 套餐升降配命令。 +/// +public sealed record ChangeTenantSubscriptionPlanCommand( + [property: Required] long TenantId, + [property: Required] long TenantSubscriptionId, + [property: Required] long TargetPackageId, + bool Immediate, + string? Notes) : IRequest; diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantSubscriptionCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantSubscriptionCommand.cs new file mode 100644 index 0000000..468ace2 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantSubscriptionCommand.cs @@ -0,0 +1,15 @@ +using MediatR; +using System.ComponentModel.DataAnnotations; +using TakeoutSaaS.Application.App.Tenants.Dto; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 新建或续费订阅。 +/// +public sealed record CreateTenantSubscriptionCommand( + [property: Required] long TenantId, + [property: Required] long TenantPackageId, + int DurationMonths, + bool AutoRenew, + string? Notes) : IRequest; diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/RegisterTenantCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/RegisterTenantCommand.cs new file mode 100644 index 0000000..43cc053 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/RegisterTenantCommand.cs @@ -0,0 +1,21 @@ +using MediatR; +using System.ComponentModel.DataAnnotations; +using TakeoutSaaS.Application.App.Tenants.Dto; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 注册租户命令。 +/// +public sealed record RegisterTenantCommand( + [property: Required, StringLength(64)] string Code, + [property: Required, StringLength(128)] string Name, + string? ShortName, + string? Industry, + string? ContactName, + string? ContactPhone, + string? ContactEmail, + [property: Required] long TenantPackageId, + int DurationMonths = 12, + bool AutoRenew = true, + DateTime? EffectiveFrom = null) : IRequest; diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/ReviewTenantCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/ReviewTenantCommand.cs new file mode 100644 index 0000000..5784f9b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/ReviewTenantCommand.cs @@ -0,0 +1,13 @@ +using MediatR; +using System.ComponentModel.DataAnnotations; +using TakeoutSaaS.Application.App.Tenants.Dto; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 审核租户命令。 +/// +public sealed record ReviewTenantCommand( + [property: Required] long TenantId, + bool Approve, + string? Reason) : IRequest; diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/SubmitTenantVerificationCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/SubmitTenantVerificationCommand.cs new file mode 100644 index 0000000..8a94a46 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/SubmitTenantVerificationCommand.cs @@ -0,0 +1,21 @@ +using MediatR; +using System.ComponentModel.DataAnnotations; +using TakeoutSaaS.Application.App.Tenants.Dto; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 提交租户实名认证资料。 +/// +public sealed record SubmitTenantVerificationCommand( + [property: Required] long TenantId, + string? BusinessLicenseNumber, + string? BusinessLicenseUrl, + string? LegalPersonName, + string? LegalPersonIdNumber, + string? LegalPersonIdFrontUrl, + string? LegalPersonIdBackUrl, + string? BankAccountName, + string? BankAccountNumber, + string? BankName, + string? AdditionalDataJson) : IRequest; diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantAuditLogDto.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantAuditLogDto.cs new file mode 100644 index 0000000..ff3f9dc --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantAuditLogDto.cs @@ -0,0 +1,58 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Tenants.Dto; + +/// +/// 租户审核日志 DTO。 +/// +public sealed class TenantAuditLogDto +{ + /// + /// 日志 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 动作。 + /// + public TenantAuditAction Action { get; init; } + + /// + /// 标题。 + /// + public string Title { get; init; } = string.Empty; + + /// + /// 描述。 + /// + public string? Description { get; init; } + + /// + /// 操作人。 + /// + public string? OperatorName { get; init; } + + /// + /// 原状态。 + /// + public TenantStatus? PreviousStatus { get; init; } + + /// + /// 新状态。 + /// + public TenantStatus? CurrentStatus { get; init; } + + /// + /// 创建时间。 + /// + public DateTime CreatedAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantDetailDto.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantDetailDto.cs new file mode 100644 index 0000000..9035d93 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantDetailDto.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Application.App.Tenants.Dto; + +/// +/// 租户详情 DTO。 +/// +public sealed class TenantDetailDto +{ + /// + /// 基础信息。 + /// + public TenantDto Tenant { get; init; } = new(); + + /// + /// 实名信息。 + /// + public TenantVerificationDto? Verification { get; init; } + + /// + /// 当前订阅。 + /// + public TenantSubscriptionDto? Subscription { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantDto.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantDto.cs new file mode 100644 index 0000000..7f490ad --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantDto.cs @@ -0,0 +1,78 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Tenants.Dto; + +/// +/// 租户基础信息 DTO。 +/// +public sealed class TenantDto +{ + /// + /// 租户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户编码。 + /// + public string Code { get; init; } = string.Empty; + + /// + /// 名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 简称。 + /// + public string? ShortName { get; init; } + + /// + /// 联系人。 + /// + public string? ContactName { get; init; } + + /// + /// 联系电话。 + /// + public string? ContactPhone { get; init; } + + /// + /// 邮箱。 + /// + public string? ContactEmail { get; init; } + + /// + /// 当前状态。 + /// + public TenantStatus Status { get; init; } + + /// + /// 实名状态。 + /// + public TenantVerificationStatus VerificationStatus { get; init; } + + /// + /// 当前套餐 ID。 + /// + [JsonConverter(typeof(NullableSnowflakeIdJsonConverter))] + public long? CurrentPackageId { get; init; } + + /// + /// 当前订阅有效期开始。 + /// + public DateTime? EffectiveFrom { get; init; } + + /// + /// 当前订阅有效期结束。 + /// + public DateTime? EffectiveTo { get; init; } + + /// + /// 是否自动续费。 + /// + public bool AutoRenew { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantSubscriptionDto.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantSubscriptionDto.cs new file mode 100644 index 0000000..3699896 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantSubscriptionDto.cs @@ -0,0 +1,54 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Tenants.Dto; + +/// +/// 租户订阅 DTO。 +/// +public sealed class TenantSubscriptionDto +{ + /// + /// 订阅 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 套餐 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantPackageId { get; init; } + + /// + /// 状态。 + /// + public SubscriptionStatus Status { get; init; } + + /// + /// 生效时间。 + /// + public DateTime EffectiveFrom { get; init; } + + /// + /// 到期时间。 + /// + public DateTime EffectiveTo { get; init; } + + /// + /// 下次扣费时间。 + /// + public DateTime? NextBillingDate { get; init; } + + /// + /// 是否自动续费。 + /// + public bool AutoRenew { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantVerificationDto.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantVerificationDto.cs new file mode 100644 index 0000000..7f39c8f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantVerificationDto.cs @@ -0,0 +1,73 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Tenants.Dto; + +/// +/// 租户实名认证 DTO。 +/// +public sealed class TenantVerificationDto +{ + /// + /// 主键。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户标识。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 状态。 + /// + public TenantVerificationStatus Status { get; init; } + + /// + /// 营业执照号。 + /// + public string? BusinessLicenseNumber { get; init; } + + /// + /// 营业执照图片。 + /// + public string? BusinessLicenseUrl { get; init; } + + /// + /// 法人姓名。 + /// + public string? LegalPersonName { get; init; } + + /// + /// 法人身份证号。 + /// + public string? LegalPersonIdNumber { get; init; } + + /// + /// 银行账号。 + /// + public string? BankAccountNumber { get; init; } + + /// + /// 银行名称。 + /// + public string? BankName { get; init; } + + /// + /// 审核备注。 + /// + public string? ReviewRemarks { get; init; } + + /// + /// 最新审核人。 + /// + public string? ReviewedByName { get; init; } + + /// + /// 审核时间。 + /// + public DateTime? ReviewedAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/ChangeTenantSubscriptionPlanCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/ChangeTenantSubscriptionPlanCommandHandler.cs new file mode 100644 index 0000000..bc7f60f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/ChangeTenantSubscriptionPlanCommandHandler.cs @@ -0,0 +1,74 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Ids; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 套餐升降配处理器。 +/// +public sealed class ChangeTenantSubscriptionPlanCommandHandler( + ITenantRepository tenantRepository, + IIdGenerator idGenerator) + : IRequestHandler +{ + private readonly ITenantRepository _tenantRepository = tenantRepository; + private readonly IIdGenerator _idGenerator = idGenerator; + + /// + public async Task Handle(ChangeTenantSubscriptionPlanCommand request, CancellationToken cancellationToken) + { + _ = await _tenantRepository.FindByIdAsync(request.TenantId, cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在"); + + var subscription = await _tenantRepository.FindSubscriptionByIdAsync(request.TenantId, request.TenantSubscriptionId, cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "订阅不存在"); + + var previousPackage = subscription.TenantPackageId; + + if (request.Immediate) + { + subscription.TenantPackageId = request.TargetPackageId; + subscription.ScheduledPackageId = null; + } + else + { + subscription.ScheduledPackageId = request.TargetPackageId; + } + + await _tenantRepository.UpdateSubscriptionAsync(subscription, cancellationToken); + await _tenantRepository.AddSubscriptionHistoryAsync(new TenantSubscriptionHistory + { + Id = _idGenerator.NextId(), + TenantId = subscription.TenantId, + TenantSubscriptionId = subscription.Id, + FromPackageId = previousPackage, + ToPackageId = request.TargetPackageId, + ChangeType = SubscriptionChangeType.Upgrade, + EffectiveFrom = subscription.EffectiveFrom, + EffectiveTo = subscription.EffectiveTo, + Notes = request.Notes + }, cancellationToken); + + await _tenantRepository.AddAuditLogAsync(new TenantAuditLog + { + TenantId = subscription.TenantId, + Action = TenantAuditAction.SubscriptionPlanChanged, + Title = request.Immediate ? "套餐立即变更" : "套餐排期变更", + Description = request.Notes, + PreviousStatus = null, + CurrentStatus = null + }, cancellationToken); + + await _tenantRepository.SaveChangesAsync(cancellationToken); + + return subscription.ToSubscriptionDto() + ?? throw new BusinessException(ErrorCodes.InternalServerError, "订阅更新失败"); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantSubscriptionCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantSubscriptionCommandHandler.cs new file mode 100644 index 0000000..df52305 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantSubscriptionCommandHandler.cs @@ -0,0 +1,82 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Ids; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 新建/续费订阅处理器。 +/// +public sealed class CreateTenantSubscriptionCommandHandler( + ITenantRepository tenantRepository, + IIdGenerator idGenerator) + : IRequestHandler +{ + private readonly ITenantRepository _tenantRepository = tenantRepository; + private readonly IIdGenerator _idGenerator = idGenerator; + + /// + public async Task Handle(CreateTenantSubscriptionCommand request, CancellationToken cancellationToken) + { + if (request.DurationMonths <= 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "订阅时长必须大于 0"); + } + + var tenant = await _tenantRepository.FindByIdAsync(request.TenantId, cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在"); + + var current = await _tenantRepository.GetActiveSubscriptionAsync(request.TenantId, cancellationToken); + var from = current?.EffectiveTo ?? tenant.EffectiveTo ?? DateTime.UtcNow; + var effectiveFrom = from > DateTime.UtcNow ? from : DateTime.UtcNow; + var effectiveTo = effectiveFrom.AddMonths(request.DurationMonths); + + var subscription = new TenantSubscription + { + Id = _idGenerator.NextId(), + TenantId = tenant.Id, + TenantPackageId = request.TenantPackageId, + EffectiveFrom = effectiveFrom, + EffectiveTo = effectiveTo, + NextBillingDate = effectiveTo, + Status = SubscriptionStatus.Active, + AutoRenew = request.AutoRenew, + Notes = request.Notes + }; + + await _tenantRepository.AddSubscriptionAsync(subscription, cancellationToken); + await _tenantRepository.AddSubscriptionHistoryAsync(new TenantSubscriptionHistory + { + Id = _idGenerator.NextId(), + TenantId = tenant.Id, + TenantSubscriptionId = subscription.Id, + FromPackageId = current?.TenantPackageId ?? request.TenantPackageId, + ToPackageId = request.TenantPackageId, + ChangeType = current == null ? SubscriptionChangeType.New : SubscriptionChangeType.Renew, + EffectiveFrom = effectiveFrom, + EffectiveTo = effectiveTo, + Amount = null, + Currency = null, + Notes = request.Notes + }, cancellationToken); + + await _tenantRepository.AddAuditLogAsync(new TenantAuditLog + { + TenantId = tenant.Id, + Action = TenantAuditAction.SubscriptionUpdated, + Title = current == null ? "创建订阅" : "续费订阅", + Description = $"套餐 {request.TenantPackageId} 时长 {request.DurationMonths} 月" + }, cancellationToken); + + await _tenantRepository.SaveChangesAsync(cancellationToken); + + return subscription.ToSubscriptionDto() + ?? throw new BusinessException(ErrorCodes.InternalServerError, "订阅生成失败"); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantAuditLogsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantAuditLogsQueryHandler.cs new file mode 100644 index 0000000..bfa51ee --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantAuditLogsQueryHandler.cs @@ -0,0 +1,32 @@ +using System.Linq; +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Application.App.Tenants.Queries; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 审核日志查询。 +/// +public sealed class GetTenantAuditLogsQueryHandler(ITenantRepository tenantRepository) + : IRequestHandler> +{ + private readonly ITenantRepository _tenantRepository = tenantRepository; + + /// + public async Task> Handle(GetTenantAuditLogsQuery request, CancellationToken cancellationToken) + { + var logs = await _tenantRepository.GetAuditLogsAsync(request.TenantId, cancellationToken); + var total = logs.Count; + + var paged = logs + .Skip((request.Page - 1) * request.PageSize) + .Take(request.PageSize) + .Select(TenantMapping.ToDto) + .ToList(); + + return new PagedResult(paged, request.Page, request.PageSize, total); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantByIdQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantByIdQueryHandler.cs new file mode 100644 index 0000000..f3cd41b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantByIdQueryHandler.cs @@ -0,0 +1,34 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Application.App.Tenants.Queries; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 租户详情查询处理器。 +/// +public sealed class GetTenantByIdQueryHandler(ITenantRepository tenantRepository) + : IRequestHandler +{ + private readonly ITenantRepository _tenantRepository = tenantRepository; + + /// + public async Task Handle(GetTenantByIdQuery request, CancellationToken cancellationToken) + { + var tenant = await _tenantRepository.FindByIdAsync(request.TenantId, cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在"); + + var subscription = await _tenantRepository.GetActiveSubscriptionAsync(request.TenantId, cancellationToken); + var verification = await _tenantRepository.GetVerificationProfileAsync(request.TenantId, cancellationToken); + + return new TenantDetailDto + { + Tenant = TenantMapping.ToDto(tenant, subscription, verification), + Verification = verification.ToVerificationDto(), + Subscription = subscription.ToSubscriptionDto() + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/RegisterTenantCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/RegisterTenantCommandHandler.cs new file mode 100644 index 0000000..228bded --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/RegisterTenantCommandHandler.cs @@ -0,0 +1,88 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Ids; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 租户注册处理器。 +/// +public sealed class RegisterTenantCommandHandler( + ITenantRepository tenantRepository, + IIdGenerator idGenerator, + ILogger logger) + : IRequestHandler +{ + private readonly ITenantRepository _tenantRepository = tenantRepository; + private readonly IIdGenerator _idGenerator = idGenerator; + private readonly ILogger _logger = logger; + + /// + public async Task Handle(RegisterTenantCommand request, CancellationToken cancellationToken) + { + if (request.DurationMonths <= 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "订阅时长必须大于 0"); + } + + if (await _tenantRepository.ExistsByCodeAsync(request.Code, cancellationToken)) + { + throw new BusinessException(ErrorCodes.Conflict, $"租户编码 {request.Code} 已存在"); + } + + var now = DateTime.UtcNow; + var effectiveFrom = request.EffectiveFrom ?? now; + var effectiveTo = effectiveFrom.AddMonths(request.DurationMonths); + + var tenant = new Tenant + { + Id = _idGenerator.NextId(), + Code = request.Code.Trim(), + Name = request.Name, + ShortName = request.ShortName, + Industry = request.Industry, + ContactName = request.ContactName, + ContactPhone = request.ContactPhone, + ContactEmail = request.ContactEmail, + Status = TenantStatus.PendingReview, + EffectiveFrom = effectiveFrom, + EffectiveTo = effectiveTo + }; + + var subscription = new TenantSubscription + { + Id = _idGenerator.NextId(), + TenantId = tenant.Id, + TenantPackageId = request.TenantPackageId, + EffectiveFrom = effectiveFrom, + EffectiveTo = effectiveTo, + NextBillingDate = effectiveTo, + Status = SubscriptionStatus.Pending, + AutoRenew = request.AutoRenew, + Notes = "Init subscription" + }; + + await _tenantRepository.AddTenantAsync(tenant, cancellationToken); + await _tenantRepository.AddSubscriptionAsync(subscription, cancellationToken); + await _tenantRepository.AddAuditLogAsync(new TenantAuditLog + { + TenantId = tenant.Id, + Action = TenantAuditAction.RegistrationSubmitted, + Title = "租户注册", + Description = $"提交套餐 {request.TenantPackageId},时长 {request.DurationMonths} 月" + }, cancellationToken); + + await _tenantRepository.SaveChangesAsync(cancellationToken); + + _logger.LogInformation("已注册租户 {TenantCode}", tenant.Code); + + return TenantMapping.ToDto(tenant, subscription, null); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/ReviewTenantCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/ReviewTenantCommandHandler.cs new file mode 100644 index 0000000..97df442 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/ReviewTenantCommandHandler.cs @@ -0,0 +1,87 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Security; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 租户审核处理器。 +/// +public sealed class ReviewTenantCommandHandler( + ITenantRepository tenantRepository, + ICurrentUserAccessor currentUserAccessor) + : IRequestHandler +{ + private readonly ITenantRepository _tenantRepository = tenantRepository; + private readonly ICurrentUserAccessor _currentUserAccessor = currentUserAccessor; + + /// + public async Task Handle(ReviewTenantCommand request, CancellationToken cancellationToken) + { + var tenant = await _tenantRepository.FindByIdAsync(request.TenantId, cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在"); + + var verification = await _tenantRepository.GetVerificationProfileAsync(request.TenantId, cancellationToken) + ?? throw new BusinessException(ErrorCodes.BadRequest, "请先提交实名认证资料"); + + var subscription = await _tenantRepository.GetActiveSubscriptionAsync(request.TenantId, cancellationToken); + + var actorName = _currentUserAccessor.IsAuthenticated + ? $"user:{_currentUserAccessor.UserId}" + : "system"; + + verification.ReviewedAt = DateTime.UtcNow; + verification.ReviewedBy = _currentUserAccessor.UserId == 0 ? null : _currentUserAccessor.UserId; + verification.ReviewedByName = actorName; + verification.ReviewRemarks = request.Reason; + + var previousStatus = tenant.Status; + + if (request.Approve) + { + verification.Status = TenantVerificationStatus.Approved; + tenant.Status = TenantStatus.Active; + if (subscription != null) + { + subscription.Status = SubscriptionStatus.Active; + } + } + else + { + verification.Status = TenantVerificationStatus.Rejected; + tenant.Status = TenantStatus.PendingReview; + if (subscription != null) + { + subscription.Status = SubscriptionStatus.Suspended; + } + } + + await _tenantRepository.UpdateTenantAsync(tenant, cancellationToken); + await _tenantRepository.UpsertVerificationProfileAsync(verification, cancellationToken); + if (subscription != null) + { + await _tenantRepository.UpdateSubscriptionAsync(subscription, cancellationToken); + } + + await _tenantRepository.AddAuditLogAsync(new Domain.Tenants.Entities.TenantAuditLog + { + TenantId = tenant.Id, + Action = request.Approve ? TenantAuditAction.VerificationApproved : TenantAuditAction.VerificationRejected, + Title = request.Approve ? "审核通过" : "审核驳回", + Description = request.Reason, + OperatorId = _currentUserAccessor.UserId == 0 ? null : _currentUserAccessor.UserId, + OperatorName = actorName, + PreviousStatus = previousStatus, + CurrentStatus = tenant.Status + }, cancellationToken); + + await _tenantRepository.SaveChangesAsync(cancellationToken); + + return TenantMapping.ToDto(tenant, subscription, verification); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantsQueryHandler.cs new file mode 100644 index 0000000..e13b9fc --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantsQueryHandler.cs @@ -0,0 +1,39 @@ +using System.Linq; +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Application.App.Tenants.Queries; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 租户分页查询处理器。 +/// +public sealed class SearchTenantsQueryHandler(ITenantRepository tenantRepository) + : IRequestHandler> +{ + private readonly ITenantRepository _tenantRepository = tenantRepository; + + /// + public async Task> Handle(SearchTenantsQuery request, CancellationToken cancellationToken) + { + var tenants = await _tenantRepository.SearchAsync(request.Status, request.Keyword, cancellationToken); + var total = tenants.Count; + + var paged = tenants + .Skip((request.Page - 1) * request.PageSize) + .Take(request.PageSize) + .ToList(); + + var result = new List(paged.Count); + foreach (var tenant in paged) + { + var subscription = await _tenantRepository.GetActiveSubscriptionAsync(tenant.Id, cancellationToken); + var verification = await _tenantRepository.GetVerificationProfileAsync(tenant.Id, cancellationToken); + result.Add(TenantMapping.ToDto(tenant, subscription, verification)); + } + + return new PagedResult(result, request.Page, request.PageSize, total); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SubmitTenantVerificationCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SubmitTenantVerificationCommandHandler.cs new file mode 100644 index 0000000..dc6be8f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SubmitTenantVerificationCommandHandler.cs @@ -0,0 +1,63 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Ids; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 实名资料提交流程。 +/// +public sealed class SubmitTenantVerificationCommandHandler( + ITenantRepository tenantRepository, + IIdGenerator idGenerator) + : IRequestHandler +{ + private readonly ITenantRepository _tenantRepository = tenantRepository; + private readonly IIdGenerator _idGenerator = idGenerator; + + /// + public async Task Handle(SubmitTenantVerificationCommand request, CancellationToken cancellationToken) + { + var tenant = await _tenantRepository.FindByIdAsync(request.TenantId, cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在"); + + var profile = await _tenantRepository.GetVerificationProfileAsync(request.TenantId, cancellationToken) + ?? new TenantVerificationProfile { Id = _idGenerator.NextId(), TenantId = tenant.Id }; + + profile.BusinessLicenseNumber = request.BusinessLicenseNumber; + profile.BusinessLicenseUrl = request.BusinessLicenseUrl; + profile.LegalPersonName = request.LegalPersonName; + profile.LegalPersonIdNumber = request.LegalPersonIdNumber; + profile.LegalPersonIdFrontUrl = request.LegalPersonIdFrontUrl; + profile.LegalPersonIdBackUrl = request.LegalPersonIdBackUrl; + profile.BankAccountName = request.BankAccountName; + profile.BankAccountNumber = request.BankAccountNumber; + profile.BankName = request.BankName; + profile.AdditionalDataJson = request.AdditionalDataJson; + profile.Status = TenantVerificationStatus.Pending; + profile.SubmittedAt = DateTime.UtcNow; + profile.ReviewedAt = null; + profile.ReviewRemarks = null; + profile.ReviewedBy = null; + profile.ReviewedByName = null; + + await _tenantRepository.UpsertVerificationProfileAsync(profile, cancellationToken); + await _tenantRepository.AddAuditLogAsync(new TenantAuditLog + { + TenantId = tenant.Id, + Action = TenantAuditAction.VerificationSubmitted, + Title = "提交实名认证资料", + Description = request.BusinessLicenseNumber + }, cancellationToken); + await _tenantRepository.SaveChangesAsync(cancellationToken); + + return profile.ToVerificationDto() + ?? throw new BusinessException(ErrorCodes.InternalServerError, "实名资料保存失败"); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantAuditLogsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantAuditLogsQuery.cs new file mode 100644 index 0000000..10aa010 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantAuditLogsQuery.cs @@ -0,0 +1,13 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.Tenants.Queries; + +/// +/// 租户审核日志查询。 +/// +public sealed record GetTenantAuditLogsQuery( + long TenantId, + int Page = 1, + int PageSize = 20) : IRequest>; diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantByIdQuery.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantByIdQuery.cs new file mode 100644 index 0000000..43e226d --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantByIdQuery.cs @@ -0,0 +1,9 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; + +namespace TakeoutSaaS.Application.App.Tenants.Queries; + +/// +/// 单个租户查询。 +/// +public sealed record GetTenantByIdQuery(long TenantId) : IRequest; diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantsQuery.cs new file mode 100644 index 0000000..f3834d1 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantsQuery.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.Tenants.Queries; + +/// +/// 租户分页查询。 +/// +public sealed record SearchTenantsQuery( + TenantStatus? Status, + string? Keyword, + int Page = 1, + int PageSize = 20) : IRequest>; diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/TenantMapping.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/TenantMapping.cs new file mode 100644 index 0000000..bc6cfe8 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/TenantMapping.cs @@ -0,0 +1,76 @@ +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Entities; + +namespace TakeoutSaaS.Application.App.Tenants; + +/// +/// 租户 DTO 映射助手。 +/// +internal static class TenantMapping +{ + public static TenantDto ToDto(Tenant tenant, TenantSubscription? subscription, TenantVerificationProfile? verification) + => new() + { + Id = tenant.Id, + Code = tenant.Code, + Name = tenant.Name, + ShortName = tenant.ShortName, + ContactName = tenant.ContactName, + ContactPhone = tenant.ContactPhone, + ContactEmail = tenant.ContactEmail, + Status = tenant.Status, + VerificationStatus = verification?.Status ?? Domain.Tenants.Enums.TenantVerificationStatus.Draft, + CurrentPackageId = subscription?.TenantPackageId, + EffectiveFrom = subscription?.EffectiveFrom ?? tenant.EffectiveFrom, + EffectiveTo = subscription?.EffectiveTo ?? tenant.EffectiveTo, + AutoRenew = subscription?.AutoRenew ?? false + }; + + public static TenantVerificationDto? ToVerificationDto(this TenantVerificationProfile? profile) + => profile == null + ? null + : new TenantVerificationDto + { + Id = profile.Id, + TenantId = profile.TenantId, + Status = profile.Status, + BusinessLicenseNumber = profile.BusinessLicenseNumber, + BusinessLicenseUrl = profile.BusinessLicenseUrl, + LegalPersonName = profile.LegalPersonName, + LegalPersonIdNumber = profile.LegalPersonIdNumber, + BankAccountNumber = profile.BankAccountNumber, + BankName = profile.BankName, + ReviewRemarks = profile.ReviewRemarks, + ReviewedByName = profile.ReviewedByName, + ReviewedAt = profile.ReviewedAt + }; + + public static TenantSubscriptionDto? ToSubscriptionDto(this TenantSubscription? subscription) + => subscription == null + ? null + : new TenantSubscriptionDto + { + Id = subscription.Id, + TenantId = subscription.TenantId, + TenantPackageId = subscription.TenantPackageId, + Status = subscription.Status, + EffectiveFrom = subscription.EffectiveFrom, + EffectiveTo = subscription.EffectiveTo, + NextBillingDate = subscription.NextBillingDate, + AutoRenew = subscription.AutoRenew + }; + + public static TenantAuditLogDto ToDto(this TenantAuditLog log) + => new() + { + Id = log.Id, + TenantId = log.TenantId, + Action = log.Action, + Title = log.Title, + Description = log.Description, + OperatorName = log.OperatorName, + PreviousStatus = log.PreviousStatus, + CurrentStatus = log.CurrentStatus, + CreatedAt = log.CreatedAt + }; +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantAuditLog.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantAuditLog.cs new file mode 100644 index 0000000..79858ad --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantAuditLog.cs @@ -0,0 +1,50 @@ +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Tenants.Entities; + +/// +/// 租户运营审核日志。 +/// +public sealed class TenantAuditLog : AuditableEntityBase +{ + /// + /// 关联的租户标识。 + /// + public long TenantId { get; set; } + + /// + /// 操作类型。 + /// + public TenantAuditAction 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; } + + /// + /// 原状态。 + /// + public TenantStatus? PreviousStatus { get; set; } + + /// + /// 新状态。 + /// + public TenantStatus? CurrentStatus { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantSubscriptionHistory.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantSubscriptionHistory.cs new file mode 100644 index 0000000..9a47b77 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantSubscriptionHistory.cs @@ -0,0 +1,60 @@ +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Tenants.Entities; + +/// +/// 租户套餐订阅变更记录。 +/// +public sealed class TenantSubscriptionHistory : AuditableEntityBase +{ + /// + /// 租户标识。 + /// + public long TenantId { get; set; } + + /// + /// 对应的订阅 ID。 + /// + public long TenantSubscriptionId { get; set; } + + /// + /// 原套餐 ID。 + /// + public long FromPackageId { get; set; } + + /// + /// 新套餐 ID。 + /// + public long ToPackageId { get; set; } + + /// + /// 变更类型。 + /// + public SubscriptionChangeType ChangeType { get; set; } + + /// + /// 生效时间。 + /// + public DateTime EffectiveFrom { get; set; } + + /// + /// 到期时间。 + /// + public DateTime EffectiveTo { get; set; } + + /// + /// 相关费用。 + /// + public decimal? Amount { get; set; } + + /// + /// 币种。 + /// + public string? Currency { get; set; } + + /// + /// 备注。 + /// + public string? Notes { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantVerificationProfile.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantVerificationProfile.cs new file mode 100644 index 0000000..4a49fe9 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantVerificationProfile.cs @@ -0,0 +1,95 @@ +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Tenants.Entities; + +/// +/// 租户实名认证资料。 +/// +public sealed class TenantVerificationProfile : AuditableEntityBase +{ + /// + /// 对应的租户标识。 + /// + public long TenantId { get; set; } + + /// + /// 实名状态。 + /// + public TenantVerificationStatus Status { get; set; } = TenantVerificationStatus.Draft; + + /// + /// 营业执照编号。 + /// + public string? BusinessLicenseNumber { get; set; } + + /// + /// 营业执照文件地址。 + /// + public string? BusinessLicenseUrl { get; set; } + + /// + /// 法人姓名。 + /// + public string? LegalPersonName { get; set; } + + /// + /// 法人身份证号。 + /// + public string? LegalPersonIdNumber { get; set; } + + /// + /// 法人身份证正面。 + /// + public string? LegalPersonIdFrontUrl { get; set; } + + /// + /// 法人身份证反面。 + /// + public string? LegalPersonIdBackUrl { get; set; } + + /// + /// 开户名。 + /// + public string? BankAccountName { get; set; } + + /// + /// 银行账号。 + /// + public string? BankAccountNumber { get; set; } + + /// + /// 银行名称。 + /// + public string? BankName { get; set; } + + /// + /// 附加资料(JSON)。 + /// + public string? AdditionalDataJson { get; set; } + + /// + /// 提交时间。 + /// + public DateTime? SubmittedAt { get; set; } + + /// + /// 审核时间。 + /// + public DateTime? ReviewedAt { get; set; } + + /// + /// 审核人 ID。 + /// + public long? ReviewedBy { get; set; } + + /// + /// 审核人姓名。 + /// + public string? ReviewedByName { get; set; } + + /// + /// 审核备注。 + /// + public string? ReviewRemarks { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/SubscriptionChangeType.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/SubscriptionChangeType.cs new file mode 100644 index 0000000..0eb9af5 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/SubscriptionChangeType.cs @@ -0,0 +1,27 @@ +namespace TakeoutSaaS.Domain.Tenants.Enums; + +/// +/// 套餐订阅的操作类型。 +/// +public enum SubscriptionChangeType +{ + /// + /// 新订阅。 + /// + New = 0, + + /// + /// 续费。 + /// + Renew = 1, + + /// + /// 升级套餐。 + /// + Upgrade = 2, + + /// + /// 降级套餐。 + /// + Downgrade = 3 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantAuditAction.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantAuditAction.cs new file mode 100644 index 0000000..0b31a62 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantAuditAction.cs @@ -0,0 +1,42 @@ +namespace TakeoutSaaS.Domain.Tenants.Enums; + +/// +/// 租户运营审核动作。 +/// +public enum TenantAuditAction +{ + /// + /// 注册信息提交。 + /// + RegistrationSubmitted = 1, + + /// + /// 实名资料提交或更新。 + /// + VerificationSubmitted = 2, + + /// + /// 实名审核通过。 + /// + VerificationApproved = 3, + + /// + /// 实名审核驳回。 + /// + VerificationRejected = 4, + + /// + /// 订阅创建或续费。 + /// + SubscriptionUpdated = 5, + + /// + /// 套餐升降配。 + /// + SubscriptionPlanChanged = 6, + + /// + /// 租户状态变更(启用/停用/到期等)。 + /// + StatusChanged = 7 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantVerificationStatus.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantVerificationStatus.cs new file mode 100644 index 0000000..88dbea7 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantVerificationStatus.cs @@ -0,0 +1,27 @@ +namespace TakeoutSaaS.Domain.Tenants.Enums; + +/// +/// 租户实名认证状态。 +/// +public enum TenantVerificationStatus +{ + /// + /// 草稿,未提交审核。 + /// + Draft = 0, + + /// + /// 已提交审核,等待运营处理。 + /// + Pending = 1, + + /// + /// 审核通过。 + /// + Approved = 2, + + /// + /// 审核驳回。 + /// + Rejected = 3 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantRepository.cs new file mode 100644 index 0000000..ea63403 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantRepository.cs @@ -0,0 +1,95 @@ +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.Domain.Tenants.Repositories; + +/// +/// 租户聚合仓储。 +/// +public interface ITenantRepository +{ + /// + /// 依据 ID 获取租户。 + /// + Task FindByIdAsync(long tenantId, CancellationToken cancellationToken = default); + + /// + /// 按状态与关键词查询租户列表。 + /// + Task> SearchAsync( + TenantStatus? status, + string? keyword, + CancellationToken cancellationToken = default); + + /// + /// 新增租户。 + /// + Task AddTenantAsync(Tenant tenant, CancellationToken cancellationToken = default); + + /// + /// 更新租户。 + /// + Task UpdateTenantAsync(Tenant tenant, CancellationToken cancellationToken = default); + + /// + /// 判断编码是否存在。 + /// + Task ExistsByCodeAsync(string code, CancellationToken cancellationToken = default); + + /// + /// 获取实名资料。 + /// + Task GetVerificationProfileAsync(long tenantId, CancellationToken cancellationToken = default); + + /// + /// 新增或更新实名资料。 + /// + Task UpsertVerificationProfileAsync(TenantVerificationProfile profile, CancellationToken cancellationToken = default); + + /// + /// 获取当前订阅。 + /// + Task GetActiveSubscriptionAsync(long tenantId, CancellationToken cancellationToken = default); + + /// + /// 依据订阅 ID 查询。 + /// + Task FindSubscriptionByIdAsync(long tenantId, long subscriptionId, CancellationToken cancellationToken = default); + + /// + /// 新增订阅。 + /// + Task AddSubscriptionAsync(TenantSubscription subscription, CancellationToken cancellationToken = default); + + /// + /// 更新订阅。 + /// + Task UpdateSubscriptionAsync(TenantSubscription subscription, CancellationToken cancellationToken = default); + + /// + /// 记录订阅历史。 + /// + Task AddSubscriptionHistoryAsync(TenantSubscriptionHistory history, CancellationToken cancellationToken = default); + + /// + /// 获取订阅历史。 + /// + Task> GetSubscriptionHistoryAsync(long tenantId, CancellationToken cancellationToken = default); + + /// + /// 新增审核日志。 + /// + Task AddAuditLogAsync(TenantAuditLog log, CancellationToken cancellationToken = default); + + /// + /// 查询审核日志。 + /// + Task> GetAuditLogsAsync(long tenantId, CancellationToken cancellationToken = default); + + /// + /// 持久化。 + /// + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs index 02df728..c9ce810 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs @@ -6,6 +6,7 @@ using TakeoutSaaS.Domain.Orders.Repositories; using TakeoutSaaS.Domain.Payments.Repositories; using TakeoutSaaS.Domain.Products.Repositories; using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Domain.Tenants.Repositories; using TakeoutSaaS.Infrastructure.App.Options; using TakeoutSaaS.Infrastructure.App.Persistence; using TakeoutSaaS.Infrastructure.App.Repositories; @@ -36,6 +37,7 @@ public static class AppServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddOptions() .Bind(configuration.GetSection(AppSeedOptions.SectionName)) diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs index 14d4ff7..f107cea 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs @@ -39,9 +39,12 @@ public sealed class TakeoutAppDbContext( public DbSet Tenants => Set(); public DbSet TenantPackages => Set(); public DbSet TenantSubscriptions => Set(); + public DbSet TenantSubscriptionHistories => Set(); public DbSet TenantQuotaUsages => Set(); public DbSet TenantBillingStatements => Set(); public DbSet TenantNotifications => Set(); + public DbSet TenantVerificationProfiles => Set(); + public DbSet TenantAuditLogs => Set(); public DbSet Merchants => Set(); public DbSet MerchantDocuments => Set(); @@ -132,9 +135,12 @@ public sealed class TakeoutAppDbContext( ConfigureStore(modelBuilder.Entity()); ConfigureTenantPackage(modelBuilder.Entity()); ConfigureTenantSubscription(modelBuilder.Entity()); + ConfigureTenantSubscriptionHistory(modelBuilder.Entity()); ConfigureTenantQuotaUsage(modelBuilder.Entity()); ConfigureTenantBilling(modelBuilder.Entity()); ConfigureTenantNotification(modelBuilder.Entity()); + ConfigureTenantVerificationProfile(modelBuilder.Entity()); + ConfigureTenantAuditLog(modelBuilder.Entity()); ConfigureMerchantDocument(modelBuilder.Entity()); ConfigureMerchantContract(modelBuilder.Entity()); ConfigureMerchantStaff(modelBuilder.Entity()); @@ -216,6 +222,47 @@ public sealed class TakeoutAppDbContext( builder.HasIndex(x => x.Code).IsUnique(); } + private static void ConfigureTenantVerificationProfile(EntityTypeBuilder builder) + { + builder.ToTable("tenant_verification_profiles"); + builder.HasKey(x => x.Id); + builder.Property(x => x.TenantId).IsRequired(); + builder.Property(x => x.BusinessLicenseNumber).HasMaxLength(64); + builder.Property(x => x.BusinessLicenseUrl).HasMaxLength(512); + builder.Property(x => x.LegalPersonName).HasMaxLength(64); + builder.Property(x => x.LegalPersonIdNumber).HasMaxLength(32); + builder.Property(x => x.LegalPersonIdFrontUrl).HasMaxLength(512); + builder.Property(x => x.LegalPersonIdBackUrl).HasMaxLength(512); + builder.Property(x => x.BankAccountName).HasMaxLength(128); + builder.Property(x => x.BankAccountNumber).HasMaxLength(64); + builder.Property(x => x.BankName).HasMaxLength(128); + builder.Property(x => x.ReviewRemarks).HasMaxLength(512); + builder.Property(x => x.ReviewedByName).HasMaxLength(64); + builder.HasIndex(x => x.TenantId).IsUnique(); + } + + private static void ConfigureTenantAuditLog(EntityTypeBuilder builder) + { + builder.ToTable("tenant_audit_logs"); + builder.HasKey(x => x.Id); + builder.Property(x => x.TenantId).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 => x.TenantId); + } + + private static void ConfigureTenantSubscriptionHistory(EntityTypeBuilder builder) + { + builder.ToTable("tenant_subscription_histories"); + builder.HasKey(x => x.Id); + builder.Property(x => x.TenantId).IsRequired(); + builder.Property(x => x.TenantSubscriptionId).IsRequired(); + builder.Property(x => x.Notes).HasMaxLength(512); + builder.Property(x => x.Currency).HasMaxLength(8); + builder.HasIndex(x => new { x.TenantId, x.TenantSubscriptionId }); + } + private static void ConfigureMerchant(EntityTypeBuilder builder) { builder.ToTable("merchants"); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantRepository.cs new file mode 100644 index 0000000..9c4ab49 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantRepository.cs @@ -0,0 +1,161 @@ +using System.Linq; +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Infrastructure.App.Persistence; + +namespace TakeoutSaaS.Infrastructure.App.Repositories; + +/// +/// 租户聚合的 EF Core 仓储实现。 +/// +public sealed class EfTenantRepository(TakeoutAppDbContext context) : ITenantRepository +{ + /// + public Task FindByIdAsync(long tenantId, CancellationToken cancellationToken = default) + { + return context.Tenants + .AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == tenantId, cancellationToken); + } + + /// + public async Task> SearchAsync( + TenantStatus? status, + string? keyword, + CancellationToken cancellationToken = default) + { + var query = context.Tenants.AsNoTracking(); + + if (status.HasValue) + { + query = query.Where(x => x.Status == status.Value); + } + + if (!string.IsNullOrWhiteSpace(keyword)) + { + keyword = keyword.Trim(); + query = query.Where(x => + EF.Functions.ILike(x.Name, $"%{keyword}%") || + EF.Functions.ILike(x.Code, $"%{keyword}%") || + EF.Functions.ILike(x.ContactName ?? string.Empty, $"%{keyword}%")); + } + + return await query + .OrderByDescending(x => x.CreatedAt) + .ToListAsync(cancellationToken); + } + + /// + public Task AddTenantAsync(Tenant tenant, CancellationToken cancellationToken = default) + { + return context.Tenants.AddAsync(tenant, cancellationToken).AsTask(); + } + + /// + public Task UpdateTenantAsync(Tenant tenant, CancellationToken cancellationToken = default) + { + context.Tenants.Update(tenant); + return Task.CompletedTask; + } + + /// + public Task ExistsByCodeAsync(string code, CancellationToken cancellationToken = default) + { + var normalized = code.Trim(); + return context.Tenants.AnyAsync(x => x.Code == normalized, cancellationToken); + } + + /// + public Task GetVerificationProfileAsync(long tenantId, CancellationToken cancellationToken = default) + { + return context.TenantVerificationProfiles + .AsNoTracking() + .FirstOrDefaultAsync(x => x.TenantId == tenantId, cancellationToken); + } + + /// + public async Task UpsertVerificationProfileAsync(TenantVerificationProfile profile, CancellationToken cancellationToken = default) + { + var existing = await context.TenantVerificationProfiles + .FirstOrDefaultAsync(x => x.TenantId == profile.TenantId, cancellationToken); + + if (existing == null) + { + await context.TenantVerificationProfiles.AddAsync(profile, cancellationToken); + return; + } + + profile.Id = existing.Id; + context.Entry(existing).CurrentValues.SetValues(profile); + } + + /// + public Task GetActiveSubscriptionAsync(long tenantId, CancellationToken cancellationToken = default) + { + return context.TenantSubscriptions + .AsNoTracking() + .Where(x => x.TenantId == tenantId) + .OrderByDescending(x => x.EffectiveTo) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public Task FindSubscriptionByIdAsync(long tenantId, long subscriptionId, CancellationToken cancellationToken = default) + { + return context.TenantSubscriptions + .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == subscriptionId, cancellationToken); + } + + /// + public Task AddSubscriptionAsync(TenantSubscription subscription, CancellationToken cancellationToken = default) + { + return context.TenantSubscriptions.AddAsync(subscription, cancellationToken).AsTask(); + } + + /// + public Task UpdateSubscriptionAsync(TenantSubscription subscription, CancellationToken cancellationToken = default) + { + context.TenantSubscriptions.Update(subscription); + return Task.CompletedTask; + } + + /// + public Task AddSubscriptionHistoryAsync(TenantSubscriptionHistory history, CancellationToken cancellationToken = default) + { + return context.TenantSubscriptionHistories.AddAsync(history, cancellationToken).AsTask(); + } + + /// + public async Task> GetSubscriptionHistoryAsync(long tenantId, CancellationToken cancellationToken = default) + { + return await context.TenantSubscriptionHistories + .AsNoTracking() + .Where(x => x.TenantId == tenantId) + .OrderByDescending(x => x.EffectiveFrom) + .ToListAsync(cancellationToken); + } + + /// + public Task AddAuditLogAsync(TenantAuditLog log, CancellationToken cancellationToken = default) + { + return context.TenantAuditLogs.AddAsync(log, cancellationToken).AsTask(); + } + + /// + public async Task> GetAuditLogsAsync(long tenantId, CancellationToken cancellationToken = default) + { + return await context.TenantAuditLogs + .AsNoTracking() + .Where(x => x.TenantId == tenantId) + .OrderByDescending(x => x.CreatedAt) + .ToListAsync(cancellationToken); + } + + /// + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return context.SaveChangesAsync(cancellationToken); + } +}