feat: 实现租户管理及套餐流程

This commit is contained in:
2025-12-03 16:37:50 +08:00
parent 151f64d41a
commit a536a554c2
34 changed files with 1732 additions and 2 deletions

View File

@@ -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 配送范围及能力开关。
- [ ] 桌码管理:批量生成桌码、绑定区域/容量、导出二维码 ZIPPOST /api/admin/stores/{id}/tables 可下载)。
- [ ] 员工排班:创建员工、绑定门店角色、维护 StoreEmployeeShift可查询未来 7 日排班。

View File

@@ -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;
/// <summary>
/// 租户管理。
/// </summary>
[ApiVersion("1.0")]
[Authorize]
[Route("api/admin/v{version:apiVersion}/tenants")]
public sealed class TenantsController(IMediator mediator) : BaseApiController
{
/// <summary>
/// 注册租户并初始化套餐。
/// </summary>
[HttpPost]
[PermissionAuthorize("tenant:create")]
[ProducesResponseType(typeof(ApiResponse<TenantDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<TenantDto>> Register([FromBody] RegisterTenantCommand command, CancellationToken cancellationToken)
{
var result = await mediator.Send(command, cancellationToken);
return ApiResponse<TenantDto>.Ok(result);
}
/// <summary>
/// 分页查询租户。
/// </summary>
[HttpGet]
[PermissionAuthorize("tenant:read")]
[ProducesResponseType(typeof(ApiResponse<PagedResult<TenantDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PagedResult<TenantDto>>> Search([FromQuery] SearchTenantsQuery query, CancellationToken cancellationToken)
{
var result = await mediator.Send(query, cancellationToken);
return ApiResponse<PagedResult<TenantDto>>.Ok(result);
}
/// <summary>
/// 查看租户详情。
/// </summary>
[HttpGet("{tenantId:long}")]
[PermissionAuthorize("tenant:read")]
[ProducesResponseType(typeof(ApiResponse<TenantDetailDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<TenantDetailDto>> Detail(long tenantId, CancellationToken cancellationToken)
{
var result = await mediator.Send(new GetTenantByIdQuery(tenantId), cancellationToken);
return ApiResponse<TenantDetailDto>.Ok(result);
}
/// <summary>
/// 提交或更新实名认证资料。
/// </summary>
[HttpPost("{tenantId:long}/verification")]
[PermissionAuthorize("tenant:review")]
[ProducesResponseType(typeof(ApiResponse<TenantVerificationDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<TenantVerificationDto>> SubmitVerification(
long tenantId,
[FromBody] SubmitTenantVerificationCommand body,
CancellationToken cancellationToken)
{
var command = body with { TenantId = tenantId };
var result = await mediator.Send(command, cancellationToken);
return ApiResponse<TenantVerificationDto>.Ok(result);
}
/// <summary>
/// 审核租户。
/// </summary>
[HttpPost("{tenantId:long}/review")]
[PermissionAuthorize("tenant:review")]
[ProducesResponseType(typeof(ApiResponse<TenantDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<TenantDto>> Review(long tenantId, [FromBody] ReviewTenantCommand body, CancellationToken cancellationToken)
{
var command = body with { TenantId = tenantId };
var result = await mediator.Send(command, cancellationToken);
return ApiResponse<TenantDto>.Ok(result);
}
/// <summary>
/// 创建或续费租户订阅。
/// </summary>
[HttpPost("{tenantId:long}/subscriptions")]
[PermissionAuthorize("tenant:subscription")]
[ProducesResponseType(typeof(ApiResponse<TenantSubscriptionDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<TenantSubscriptionDto>> CreateSubscription(
long tenantId,
[FromBody] CreateTenantSubscriptionCommand body,
CancellationToken cancellationToken)
{
var command = body with { TenantId = tenantId };
var result = await mediator.Send(command, cancellationToken);
return ApiResponse<TenantSubscriptionDto>.Ok(result);
}
/// <summary>
/// 套餐升降配。
/// </summary>
[HttpPut("{tenantId:long}/subscriptions/{subscriptionId:long}/plan")]
[PermissionAuthorize("tenant:subscription")]
[ProducesResponseType(typeof(ApiResponse<TenantSubscriptionDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<TenantSubscriptionDto>> 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<TenantSubscriptionDto>.Ok(result);
}
/// <summary>
/// 查询审核日志。
/// </summary>
[HttpGet("{tenantId:long}/audits")]
[PermissionAuthorize("tenant:read")]
[ProducesResponseType(typeof(ApiResponse<PagedResult<TenantAuditLogDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<PagedResult<TenantAuditLogDto>>> 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<PagedResult<TenantAuditLogDto>>.Ok(result);
}
}

View File

@@ -0,0 +1,15 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Application.App.Tenants.Dto;
namespace TakeoutSaaS.Application.App.Tenants.Commands;
/// <summary>
/// 套餐升降配命令。
/// </summary>
public sealed record ChangeTenantSubscriptionPlanCommand(
[property: Required] long TenantId,
[property: Required] long TenantSubscriptionId,
[property: Required] long TargetPackageId,
bool Immediate,
string? Notes) : IRequest<TenantSubscriptionDto>;

View File

@@ -0,0 +1,15 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Application.App.Tenants.Dto;
namespace TakeoutSaaS.Application.App.Tenants.Commands;
/// <summary>
/// 新建或续费订阅。
/// </summary>
public sealed record CreateTenantSubscriptionCommand(
[property: Required] long TenantId,
[property: Required] long TenantPackageId,
int DurationMonths,
bool AutoRenew,
string? Notes) : IRequest<TenantSubscriptionDto>;

View File

@@ -0,0 +1,21 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Application.App.Tenants.Dto;
namespace TakeoutSaaS.Application.App.Tenants.Commands;
/// <summary>
/// 注册租户命令。
/// </summary>
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<TenantDto>;

View File

@@ -0,0 +1,13 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Application.App.Tenants.Dto;
namespace TakeoutSaaS.Application.App.Tenants.Commands;
/// <summary>
/// 审核租户命令。
/// </summary>
public sealed record ReviewTenantCommand(
[property: Required] long TenantId,
bool Approve,
string? Reason) : IRequest<TenantDto>;

View File

@@ -0,0 +1,21 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Application.App.Tenants.Dto;
namespace TakeoutSaaS.Application.App.Tenants.Commands;
/// <summary>
/// 提交租户实名认证资料。
/// </summary>
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<TenantVerificationDto>;

View File

@@ -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;
/// <summary>
/// 租户审核日志 DTO。
/// </summary>
public sealed class TenantAuditLogDto
{
/// <summary>
/// 日志 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 租户 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 动作。
/// </summary>
public TenantAuditAction Action { get; init; }
/// <summary>
/// 标题。
/// </summary>
public string Title { get; init; } = string.Empty;
/// <summary>
/// 描述。
/// </summary>
public string? Description { get; init; }
/// <summary>
/// 操作人。
/// </summary>
public string? OperatorName { get; init; }
/// <summary>
/// 原状态。
/// </summary>
public TenantStatus? PreviousStatus { get; init; }
/// <summary>
/// 新状态。
/// </summary>
public TenantStatus? CurrentStatus { get; init; }
/// <summary>
/// 创建时间。
/// </summary>
public DateTime CreatedAt { get; init; }
}

View File

@@ -0,0 +1,22 @@
namespace TakeoutSaaS.Application.App.Tenants.Dto;
/// <summary>
/// 租户详情 DTO。
/// </summary>
public sealed class TenantDetailDto
{
/// <summary>
/// 基础信息。
/// </summary>
public TenantDto Tenant { get; init; } = new();
/// <summary>
/// 实名信息。
/// </summary>
public TenantVerificationDto? Verification { get; init; }
/// <summary>
/// 当前订阅。
/// </summary>
public TenantSubscriptionDto? Subscription { get; init; }
}

View File

@@ -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;
/// <summary>
/// 租户基础信息 DTO。
/// </summary>
public sealed class TenantDto
{
/// <summary>
/// 租户 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 租户编码。
/// </summary>
public string Code { get; init; } = string.Empty;
/// <summary>
/// 名称。
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 简称。
/// </summary>
public string? ShortName { get; init; }
/// <summary>
/// 联系人。
/// </summary>
public string? ContactName { get; init; }
/// <summary>
/// 联系电话。
/// </summary>
public string? ContactPhone { get; init; }
/// <summary>
/// 邮箱。
/// </summary>
public string? ContactEmail { get; init; }
/// <summary>
/// 当前状态。
/// </summary>
public TenantStatus Status { get; init; }
/// <summary>
/// 实名状态。
/// </summary>
public TenantVerificationStatus VerificationStatus { get; init; }
/// <summary>
/// 当前套餐 ID。
/// </summary>
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
public long? CurrentPackageId { get; init; }
/// <summary>
/// 当前订阅有效期开始。
/// </summary>
public DateTime? EffectiveFrom { get; init; }
/// <summary>
/// 当前订阅有效期结束。
/// </summary>
public DateTime? EffectiveTo { get; init; }
/// <summary>
/// 是否自动续费。
/// </summary>
public bool AutoRenew { get; init; }
}

View File

@@ -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;
/// <summary>
/// 租户订阅 DTO。
/// </summary>
public sealed class TenantSubscriptionDto
{
/// <summary>
/// 订阅 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 租户 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 套餐 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantPackageId { get; init; }
/// <summary>
/// 状态。
/// </summary>
public SubscriptionStatus Status { get; init; }
/// <summary>
/// 生效时间。
/// </summary>
public DateTime EffectiveFrom { get; init; }
/// <summary>
/// 到期时间。
/// </summary>
public DateTime EffectiveTo { get; init; }
/// <summary>
/// 下次扣费时间。
/// </summary>
public DateTime? NextBillingDate { get; init; }
/// <summary>
/// 是否自动续费。
/// </summary>
public bool AutoRenew { get; init; }
}

View File

@@ -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;
/// <summary>
/// 租户实名认证 DTO。
/// </summary>
public sealed class TenantVerificationDto
{
/// <summary>
/// 主键。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
/// <summary>
/// 租户标识。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 状态。
/// </summary>
public TenantVerificationStatus Status { get; init; }
/// <summary>
/// 营业执照号。
/// </summary>
public string? BusinessLicenseNumber { get; init; }
/// <summary>
/// 营业执照图片。
/// </summary>
public string? BusinessLicenseUrl { get; init; }
/// <summary>
/// 法人姓名。
/// </summary>
public string? LegalPersonName { get; init; }
/// <summary>
/// 法人身份证号。
/// </summary>
public string? LegalPersonIdNumber { get; init; }
/// <summary>
/// 银行账号。
/// </summary>
public string? BankAccountNumber { get; init; }
/// <summary>
/// 银行名称。
/// </summary>
public string? BankName { get; init; }
/// <summary>
/// 审核备注。
/// </summary>
public string? ReviewRemarks { get; init; }
/// <summary>
/// 最新审核人。
/// </summary>
public string? ReviewedByName { get; init; }
/// <summary>
/// 审核时间。
/// </summary>
public DateTime? ReviewedAt { get; init; }
}

View File

@@ -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;
/// <summary>
/// 套餐升降配处理器。
/// </summary>
public sealed class ChangeTenantSubscriptionPlanCommandHandler(
ITenantRepository tenantRepository,
IIdGenerator idGenerator)
: IRequestHandler<ChangeTenantSubscriptionPlanCommand, TenantSubscriptionDto>
{
private readonly ITenantRepository _tenantRepository = tenantRepository;
private readonly IIdGenerator _idGenerator = idGenerator;
/// <inheritdoc />
public async Task<TenantSubscriptionDto> 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, "订阅更新失败");
}
}

View File

@@ -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;
/// <summary>
/// 新建/续费订阅处理器。
/// </summary>
public sealed class CreateTenantSubscriptionCommandHandler(
ITenantRepository tenantRepository,
IIdGenerator idGenerator)
: IRequestHandler<CreateTenantSubscriptionCommand, TenantSubscriptionDto>
{
private readonly ITenantRepository _tenantRepository = tenantRepository;
private readonly IIdGenerator _idGenerator = idGenerator;
/// <inheritdoc />
public async Task<TenantSubscriptionDto> 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, "订阅生成失败");
}
}

View File

@@ -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;
/// <summary>
/// 审核日志查询。
/// </summary>
public sealed class GetTenantAuditLogsQueryHandler(ITenantRepository tenantRepository)
: IRequestHandler<GetTenantAuditLogsQuery, PagedResult<TenantAuditLogDto>>
{
private readonly ITenantRepository _tenantRepository = tenantRepository;
/// <inheritdoc />
public async Task<PagedResult<TenantAuditLogDto>> 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<TenantAuditLogDto>(paged, request.Page, request.PageSize, total);
}
}

View File

@@ -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;
/// <summary>
/// 租户详情查询处理器。
/// </summary>
public sealed class GetTenantByIdQueryHandler(ITenantRepository tenantRepository)
: IRequestHandler<GetTenantByIdQuery, TenantDetailDto>
{
private readonly ITenantRepository _tenantRepository = tenantRepository;
/// <inheritdoc />
public async Task<TenantDetailDto> 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()
};
}
}

View File

@@ -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;
/// <summary>
/// 租户注册处理器。
/// </summary>
public sealed class RegisterTenantCommandHandler(
ITenantRepository tenantRepository,
IIdGenerator idGenerator,
ILogger<RegisterTenantCommandHandler> logger)
: IRequestHandler<RegisterTenantCommand, TenantDto>
{
private readonly ITenantRepository _tenantRepository = tenantRepository;
private readonly IIdGenerator _idGenerator = idGenerator;
private readonly ILogger<RegisterTenantCommandHandler> _logger = logger;
/// <inheritdoc />
public async Task<TenantDto> 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);
}
}

View File

@@ -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;
/// <summary>
/// 租户审核处理器。
/// </summary>
public sealed class ReviewTenantCommandHandler(
ITenantRepository tenantRepository,
ICurrentUserAccessor currentUserAccessor)
: IRequestHandler<ReviewTenantCommand, TenantDto>
{
private readonly ITenantRepository _tenantRepository = tenantRepository;
private readonly ICurrentUserAccessor _currentUserAccessor = currentUserAccessor;
/// <inheritdoc />
public async Task<TenantDto> 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);
}
}

View File

@@ -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;
/// <summary>
/// 租户分页查询处理器。
/// </summary>
public sealed class SearchTenantsQueryHandler(ITenantRepository tenantRepository)
: IRequestHandler<SearchTenantsQuery, PagedResult<TenantDto>>
{
private readonly ITenantRepository _tenantRepository = tenantRepository;
/// <inheritdoc />
public async Task<PagedResult<TenantDto>> 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<TenantDto>(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<TenantDto>(result, request.Page, request.PageSize, total);
}
}

View File

@@ -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;
/// <summary>
/// 实名资料提交流程。
/// </summary>
public sealed class SubmitTenantVerificationCommandHandler(
ITenantRepository tenantRepository,
IIdGenerator idGenerator)
: IRequestHandler<SubmitTenantVerificationCommand, TenantVerificationDto>
{
private readonly ITenantRepository _tenantRepository = tenantRepository;
private readonly IIdGenerator _idGenerator = idGenerator;
/// <inheritdoc />
public async Task<TenantVerificationDto> 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, "实名资料保存失败");
}
}

View File

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

View File

@@ -0,0 +1,9 @@
using MediatR;
using TakeoutSaaS.Application.App.Tenants.Dto;
namespace TakeoutSaaS.Application.App.Tenants.Queries;
/// <summary>
/// 单个租户查询。
/// </summary>
public sealed record GetTenantByIdQuery(long TenantId) : IRequest<TenantDetailDto>;

View File

@@ -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;
/// <summary>
/// 租户分页查询。
/// </summary>
public sealed record SearchTenantsQuery(
TenantStatus? Status,
string? Keyword,
int Page = 1,
int PageSize = 20) : IRequest<PagedResult<TenantDto>>;

View File

@@ -0,0 +1,76 @@
using TakeoutSaaS.Application.App.Tenants.Dto;
using TakeoutSaaS.Domain.Tenants.Entities;
namespace TakeoutSaaS.Application.App.Tenants;
/// <summary>
/// 租户 DTO 映射助手。
/// </summary>
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
};
}

View File

@@ -0,0 +1,50 @@
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Tenants.Entities;
/// <summary>
/// 租户运营审核日志。
/// </summary>
public sealed class TenantAuditLog : AuditableEntityBase
{
/// <summary>
/// 关联的租户标识。
/// </summary>
public long TenantId { get; set; }
/// <summary>
/// 操作类型。
/// </summary>
public TenantAuditAction Action { get; set; }
/// <summary>
/// 日志标题。
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// 详细描述。
/// </summary>
public string? Description { get; set; }
/// <summary>
/// 操作人 ID。
/// </summary>
public long? OperatorId { get; set; }
/// <summary>
/// 操作人名称。
/// </summary>
public string? OperatorName { get; set; }
/// <summary>
/// 原状态。
/// </summary>
public TenantStatus? PreviousStatus { get; set; }
/// <summary>
/// 新状态。
/// </summary>
public TenantStatus? CurrentStatus { get; set; }
}

View File

@@ -0,0 +1,60 @@
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Tenants.Entities;
/// <summary>
/// 租户套餐订阅变更记录。
/// </summary>
public sealed class TenantSubscriptionHistory : AuditableEntityBase
{
/// <summary>
/// 租户标识。
/// </summary>
public long TenantId { get; set; }
/// <summary>
/// 对应的订阅 ID。
/// </summary>
public long TenantSubscriptionId { get; set; }
/// <summary>
/// 原套餐 ID。
/// </summary>
public long FromPackageId { get; set; }
/// <summary>
/// 新套餐 ID。
/// </summary>
public long ToPackageId { get; set; }
/// <summary>
/// 变更类型。
/// </summary>
public SubscriptionChangeType ChangeType { get; set; }
/// <summary>
/// 生效时间。
/// </summary>
public DateTime EffectiveFrom { get; set; }
/// <summary>
/// 到期时间。
/// </summary>
public DateTime EffectiveTo { get; set; }
/// <summary>
/// 相关费用。
/// </summary>
public decimal? Amount { get; set; }
/// <summary>
/// 币种。
/// </summary>
public string? Currency { get; set; }
/// <summary>
/// 备注。
/// </summary>
public string? Notes { get; set; }
}

View File

@@ -0,0 +1,95 @@
using TakeoutSaaS.Domain.Tenants.Enums;
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Tenants.Entities;
/// <summary>
/// 租户实名认证资料。
/// </summary>
public sealed class TenantVerificationProfile : AuditableEntityBase
{
/// <summary>
/// 对应的租户标识。
/// </summary>
public long TenantId { get; set; }
/// <summary>
/// 实名状态。
/// </summary>
public TenantVerificationStatus Status { get; set; } = TenantVerificationStatus.Draft;
/// <summary>
/// 营业执照编号。
/// </summary>
public string? BusinessLicenseNumber { get; set; }
/// <summary>
/// 营业执照文件地址。
/// </summary>
public string? BusinessLicenseUrl { get; set; }
/// <summary>
/// 法人姓名。
/// </summary>
public string? LegalPersonName { get; set; }
/// <summary>
/// 法人身份证号。
/// </summary>
public string? LegalPersonIdNumber { get; set; }
/// <summary>
/// 法人身份证正面。
/// </summary>
public string? LegalPersonIdFrontUrl { get; set; }
/// <summary>
/// 法人身份证反面。
/// </summary>
public string? LegalPersonIdBackUrl { get; set; }
/// <summary>
/// 开户名。
/// </summary>
public string? BankAccountName { get; set; }
/// <summary>
/// 银行账号。
/// </summary>
public string? BankAccountNumber { get; set; }
/// <summary>
/// 银行名称。
/// </summary>
public string? BankName { get; set; }
/// <summary>
/// 附加资料JSON
/// </summary>
public string? AdditionalDataJson { get; set; }
/// <summary>
/// 提交时间。
/// </summary>
public DateTime? SubmittedAt { get; set; }
/// <summary>
/// 审核时间。
/// </summary>
public DateTime? ReviewedAt { get; set; }
/// <summary>
/// 审核人 ID。
/// </summary>
public long? ReviewedBy { get; set; }
/// <summary>
/// 审核人姓名。
/// </summary>
public string? ReviewedByName { get; set; }
/// <summary>
/// 审核备注。
/// </summary>
public string? ReviewRemarks { get; set; }
}

View File

@@ -0,0 +1,27 @@
namespace TakeoutSaaS.Domain.Tenants.Enums;
/// <summary>
/// 套餐订阅的操作类型。
/// </summary>
public enum SubscriptionChangeType
{
/// <summary>
/// 新订阅。
/// </summary>
New = 0,
/// <summary>
/// 续费。
/// </summary>
Renew = 1,
/// <summary>
/// 升级套餐。
/// </summary>
Upgrade = 2,
/// <summary>
/// 降级套餐。
/// </summary>
Downgrade = 3
}

View File

@@ -0,0 +1,42 @@
namespace TakeoutSaaS.Domain.Tenants.Enums;
/// <summary>
/// 租户运营审核动作。
/// </summary>
public enum TenantAuditAction
{
/// <summary>
/// 注册信息提交。
/// </summary>
RegistrationSubmitted = 1,
/// <summary>
/// 实名资料提交或更新。
/// </summary>
VerificationSubmitted = 2,
/// <summary>
/// 实名审核通过。
/// </summary>
VerificationApproved = 3,
/// <summary>
/// 实名审核驳回。
/// </summary>
VerificationRejected = 4,
/// <summary>
/// 订阅创建或续费。
/// </summary>
SubscriptionUpdated = 5,
/// <summary>
/// 套餐升降配。
/// </summary>
SubscriptionPlanChanged = 6,
/// <summary>
/// 租户状态变更(启用/停用/到期等)。
/// </summary>
StatusChanged = 7
}

View File

@@ -0,0 +1,27 @@
namespace TakeoutSaaS.Domain.Tenants.Enums;
/// <summary>
/// 租户实名认证状态。
/// </summary>
public enum TenantVerificationStatus
{
/// <summary>
/// 草稿,未提交审核。
/// </summary>
Draft = 0,
/// <summary>
/// 已提交审核,等待运营处理。
/// </summary>
Pending = 1,
/// <summary>
/// 审核通过。
/// </summary>
Approved = 2,
/// <summary>
/// 审核驳回。
/// </summary>
Rejected = 3
}

View File

@@ -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;
/// <summary>
/// 租户聚合仓储。
/// </summary>
public interface ITenantRepository
{
/// <summary>
/// 依据 ID 获取租户。
/// </summary>
Task<Tenant?> FindByIdAsync(long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 按状态与关键词查询租户列表。
/// </summary>
Task<IReadOnlyList<Tenant>> SearchAsync(
TenantStatus? status,
string? keyword,
CancellationToken cancellationToken = default);
/// <summary>
/// 新增租户。
/// </summary>
Task AddTenantAsync(Tenant tenant, CancellationToken cancellationToken = default);
/// <summary>
/// 更新租户。
/// </summary>
Task UpdateTenantAsync(Tenant tenant, CancellationToken cancellationToken = default);
/// <summary>
/// 判断编码是否存在。
/// </summary>
Task<bool> ExistsByCodeAsync(string code, CancellationToken cancellationToken = default);
/// <summary>
/// 获取实名资料。
/// </summary>
Task<TenantVerificationProfile?> GetVerificationProfileAsync(long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 新增或更新实名资料。
/// </summary>
Task UpsertVerificationProfileAsync(TenantVerificationProfile profile, CancellationToken cancellationToken = default);
/// <summary>
/// 获取当前订阅。
/// </summary>
Task<TenantSubscription?> GetActiveSubscriptionAsync(long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 依据订阅 ID 查询。
/// </summary>
Task<TenantSubscription?> FindSubscriptionByIdAsync(long tenantId, long subscriptionId, CancellationToken cancellationToken = default);
/// <summary>
/// 新增订阅。
/// </summary>
Task AddSubscriptionAsync(TenantSubscription subscription, CancellationToken cancellationToken = default);
/// <summary>
/// 更新订阅。
/// </summary>
Task UpdateSubscriptionAsync(TenantSubscription subscription, CancellationToken cancellationToken = default);
/// <summary>
/// 记录订阅历史。
/// </summary>
Task AddSubscriptionHistoryAsync(TenantSubscriptionHistory history, CancellationToken cancellationToken = default);
/// <summary>
/// 获取订阅历史。
/// </summary>
Task<IReadOnlyList<TenantSubscriptionHistory>> GetSubscriptionHistoryAsync(long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 新增审核日志。
/// </summary>
Task AddAuditLogAsync(TenantAuditLog log, CancellationToken cancellationToken = default);
/// <summary>
/// 查询审核日志。
/// </summary>
Task<IReadOnlyList<TenantAuditLog>> GetAuditLogsAsync(long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 持久化。
/// </summary>
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@@ -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<IOrderRepository, EfOrderRepository>();
services.AddScoped<IPaymentRepository, EfPaymentRepository>();
services.AddScoped<IDeliveryRepository, EfDeliveryRepository>();
services.AddScoped<ITenantRepository, EfTenantRepository>();
services.AddOptions<AppSeedOptions>()
.Bind(configuration.GetSection(AppSeedOptions.SectionName))

View File

@@ -39,9 +39,12 @@ public sealed class TakeoutAppDbContext(
public DbSet<Tenant> Tenants => Set<Tenant>();
public DbSet<TenantPackage> TenantPackages => Set<TenantPackage>();
public DbSet<TenantSubscription> TenantSubscriptions => Set<TenantSubscription>();
public DbSet<TenantSubscriptionHistory> TenantSubscriptionHistories => Set<TenantSubscriptionHistory>();
public DbSet<TenantQuotaUsage> TenantQuotaUsages => Set<TenantQuotaUsage>();
public DbSet<TenantBillingStatement> TenantBillingStatements => Set<TenantBillingStatement>();
public DbSet<TenantNotification> TenantNotifications => Set<TenantNotification>();
public DbSet<TenantVerificationProfile> TenantVerificationProfiles => Set<TenantVerificationProfile>();
public DbSet<TenantAuditLog> TenantAuditLogs => Set<TenantAuditLog>();
public DbSet<Merchant> Merchants => Set<Merchant>();
public DbSet<MerchantDocument> MerchantDocuments => Set<MerchantDocument>();
@@ -132,9 +135,12 @@ public sealed class TakeoutAppDbContext(
ConfigureStore(modelBuilder.Entity<Store>());
ConfigureTenantPackage(modelBuilder.Entity<TenantPackage>());
ConfigureTenantSubscription(modelBuilder.Entity<TenantSubscription>());
ConfigureTenantSubscriptionHistory(modelBuilder.Entity<TenantSubscriptionHistory>());
ConfigureTenantQuotaUsage(modelBuilder.Entity<TenantQuotaUsage>());
ConfigureTenantBilling(modelBuilder.Entity<TenantBillingStatement>());
ConfigureTenantNotification(modelBuilder.Entity<TenantNotification>());
ConfigureTenantVerificationProfile(modelBuilder.Entity<TenantVerificationProfile>());
ConfigureTenantAuditLog(modelBuilder.Entity<TenantAuditLog>());
ConfigureMerchantDocument(modelBuilder.Entity<MerchantDocument>());
ConfigureMerchantContract(modelBuilder.Entity<MerchantContract>());
ConfigureMerchantStaff(modelBuilder.Entity<MerchantStaff>());
@@ -216,6 +222,47 @@ public sealed class TakeoutAppDbContext(
builder.HasIndex(x => x.Code).IsUnique();
}
private static void ConfigureTenantVerificationProfile(EntityTypeBuilder<TenantVerificationProfile> 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<TenantAuditLog> 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<TenantSubscriptionHistory> 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<Merchant> builder)
{
builder.ToTable("merchants");

View File

@@ -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;
/// <summary>
/// 租户聚合的 EF Core 仓储实现。
/// </summary>
public sealed class EfTenantRepository(TakeoutAppDbContext context) : ITenantRepository
{
/// <inheritdoc />
public Task<Tenant?> FindByIdAsync(long tenantId, CancellationToken cancellationToken = default)
{
return context.Tenants
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == tenantId, cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<Tenant>> 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);
}
/// <inheritdoc />
public Task AddTenantAsync(Tenant tenant, CancellationToken cancellationToken = default)
{
return context.Tenants.AddAsync(tenant, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateTenantAsync(Tenant tenant, CancellationToken cancellationToken = default)
{
context.Tenants.Update(tenant);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<bool> ExistsByCodeAsync(string code, CancellationToken cancellationToken = default)
{
var normalized = code.Trim();
return context.Tenants.AnyAsync(x => x.Code == normalized, cancellationToken);
}
/// <inheritdoc />
public Task<TenantVerificationProfile?> GetVerificationProfileAsync(long tenantId, CancellationToken cancellationToken = default)
{
return context.TenantVerificationProfiles
.AsNoTracking()
.FirstOrDefaultAsync(x => x.TenantId == tenantId, cancellationToken);
}
/// <inheritdoc />
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);
}
/// <inheritdoc />
public Task<TenantSubscription?> GetActiveSubscriptionAsync(long tenantId, CancellationToken cancellationToken = default)
{
return context.TenantSubscriptions
.AsNoTracking()
.Where(x => x.TenantId == tenantId)
.OrderByDescending(x => x.EffectiveTo)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public Task<TenantSubscription?> FindSubscriptionByIdAsync(long tenantId, long subscriptionId, CancellationToken cancellationToken = default)
{
return context.TenantSubscriptions
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == subscriptionId, cancellationToken);
}
/// <inheritdoc />
public Task AddSubscriptionAsync(TenantSubscription subscription, CancellationToken cancellationToken = default)
{
return context.TenantSubscriptions.AddAsync(subscription, cancellationToken).AsTask();
}
/// <inheritdoc />
public Task UpdateSubscriptionAsync(TenantSubscription subscription, CancellationToken cancellationToken = default)
{
context.TenantSubscriptions.Update(subscription);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task AddSubscriptionHistoryAsync(TenantSubscriptionHistory history, CancellationToken cancellationToken = default)
{
return context.TenantSubscriptionHistories.AddAsync(history, cancellationToken).AsTask();
}
/// <inheritdoc />
public async Task<IReadOnlyList<TenantSubscriptionHistory>> GetSubscriptionHistoryAsync(long tenantId, CancellationToken cancellationToken = default)
{
return await context.TenantSubscriptionHistories
.AsNoTracking()
.Where(x => x.TenantId == tenantId)
.OrderByDescending(x => x.EffectiveFrom)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public Task AddAuditLogAsync(TenantAuditLog log, CancellationToken cancellationToken = default)
{
return context.TenantAuditLogs.AddAsync(log, cancellationToken).AsTask();
}
/// <inheritdoc />
public async Task<IReadOnlyList<TenantAuditLog>> GetAuditLogsAsync(long tenantId, CancellationToken cancellationToken = default)
{
return await context.TenantAuditLogs
.AsNoTracking()
.Where(x => x.TenantId == tenantId)
.OrderByDescending(x => x.CreatedAt)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
return context.SaveChangesAsync(cancellationToken);
}
}