feat: 实现租户管理及套餐流程
This commit is contained in:
137
src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs
Normal file
137
src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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, "订阅更新失败");
|
||||
}
|
||||
}
|
||||
@@ -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, "订阅生成失败");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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, "实名资料保存失败");
|
||||
}
|
||||
}
|
||||
@@ -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>>;
|
||||
@@ -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>;
|
||||
@@ -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>>;
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user