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);
+ }
+}