feat: add public tenant packages listing and sort order
This commit is contained in:
@@ -68,4 +68,9 @@ public sealed record CreateTenantPackageCommand : IRequest<TenantPackageDto>
|
||||
/// 是否可售。
|
||||
/// </summary>
|
||||
public bool IsActive { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 展示排序,数值越小越靠前。
|
||||
/// </summary>
|
||||
public int SortOrder { get; init; } = 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
using MediatR;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 自助注册租户命令。
|
||||
/// </summary>
|
||||
public sealed record SelfRegisterTenantCommand : IRequest<SelfRegisterResultDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始管理员账号。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(64)]
|
||||
public string AdminAccount { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 初始管理员展示名称。
|
||||
/// </summary>
|
||||
[StringLength(64)]
|
||||
public string? AdminDisplayName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 初始管理员邮箱。
|
||||
/// </summary>
|
||||
[EmailAddress]
|
||||
[StringLength(128)]
|
||||
public string? AdminEmail { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 初始管理员手机号。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(32)]
|
||||
public string AdminPhone { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 初始管理员登录密码(前端自定义)。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(128, MinimumLength = 8)]
|
||||
public string AdminPassword { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -73,4 +73,9 @@ public sealed record UpdateTenantPackageCommand : IRequest<TenantPackageDto?>
|
||||
/// 是否可售。
|
||||
/// </summary>
|
||||
public bool IsActive { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 展示排序,数值越小越靠前。
|
||||
/// </summary>
|
||||
public int SortOrder { get; init; } = 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
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 SelfRegisterResultDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户编码。
|
||||
/// </summary>
|
||||
public string Code { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 初始状态。
|
||||
/// </summary>
|
||||
public TenantStatus Status { get; init; } = TenantStatus.PendingReview;
|
||||
|
||||
/// <summary>
|
||||
/// 当前实名状态。
|
||||
/// </summary>
|
||||
public TenantVerificationStatus VerificationStatus { get; init; } = TenantVerificationStatus.Draft;
|
||||
|
||||
/// <summary>
|
||||
/// 订阅开始时间。
|
||||
/// </summary>
|
||||
public DateTime? EffectiveFrom { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 订阅到期时间。
|
||||
/// </summary>
|
||||
public DateTime? EffectiveTo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 初始管理员账号。
|
||||
/// </summary>
|
||||
public string AdminAccount { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -74,4 +74,9 @@ public sealed class TenantPackageDto
|
||||
/// 是否可售。
|
||||
/// </summary>
|
||||
public bool IsActive { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 展示排序,数值越小越靠前。
|
||||
/// </summary>
|
||||
public int SortOrder { get; init; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
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 TenantProgressDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户编码。
|
||||
/// </summary>
|
||||
public string Code { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 当前租户状态。
|
||||
/// </summary>
|
||||
public TenantStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 实名审核状态。
|
||||
/// </summary>
|
||||
public TenantVerificationStatus VerificationStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前订阅开始时间。
|
||||
/// </summary>
|
||||
public DateTime? EffectiveFrom { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前订阅到期时间。
|
||||
/// </summary>
|
||||
public DateTime? EffectiveTo { get; init; }
|
||||
}
|
||||
@@ -37,7 +37,8 @@ public sealed class CreateTenantPackageCommandHandler(ITenantPackageRepository p
|
||||
MaxSmsCredits = request.MaxSmsCredits,
|
||||
MaxDeliveryOrders = request.MaxDeliveryOrders,
|
||||
FeaturePoliciesJson = request.FeaturePoliciesJson,
|
||||
IsActive = request.IsActive
|
||||
IsActive = request.IsActive,
|
||||
SortOrder = request.SortOrder
|
||||
};
|
||||
|
||||
// 3. 持久化并返回
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
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 GetPublicTenantPackagesQueryHandler(ITenantPackageRepository packageRepository)
|
||||
: IRequestHandler<GetPublicTenantPackagesQuery, PagedResult<TenantPackageDto>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<PagedResult<TenantPackageDto>> Handle(GetPublicTenantPackagesQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 仅查询启用套餐
|
||||
var packages = await packageRepository.SearchAsync(null, true, cancellationToken);
|
||||
// 2. 规范化分页参数
|
||||
var pageIndex = request.Page <= 0 ? 1 : request.Page;
|
||||
var size = request.PageSize <= 0 ? 20 : request.PageSize;
|
||||
// 3. 执行排序、分页与映射
|
||||
var ordered = packages
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ThenByDescending(x => x.CreatedAt)
|
||||
.ToList();
|
||||
var items = ordered
|
||||
.Skip((pageIndex - 1) * size)
|
||||
.Take(size)
|
||||
.Select(x => x.ToDto())
|
||||
.ToList();
|
||||
// 4. 返回分页结果
|
||||
return new PagedResult<TenantPackageDto>(items, pageIndex, size, ordered.Count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
using TakeoutSaaS.Application.App.Tenants.Queries;
|
||||
using TakeoutSaaS.Domain.Tenants.Enums;
|
||||
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 GetTenantProgressQueryHandler(ITenantRepository tenantRepository)
|
||||
: IRequestHandler<GetTenantProgressQuery, TenantProgressDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<TenantProgressDto> Handle(GetTenantProgressQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询租户
|
||||
var tenant = await tenantRepository.FindByIdAsync(request.TenantId, cancellationToken)
|
||||
?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在");
|
||||
|
||||
// 2. 查询订阅与实名
|
||||
var subscription = await tenantRepository.GetActiveSubscriptionAsync(request.TenantId, cancellationToken);
|
||||
var verification = await tenantRepository.GetVerificationProfileAsync(request.TenantId, cancellationToken);
|
||||
|
||||
// 3. 组装进度信息
|
||||
return new TenantProgressDto
|
||||
{
|
||||
TenantId = tenant.Id,
|
||||
Code = tenant.Code,
|
||||
Status = tenant.Status,
|
||||
VerificationStatus = verification?.Status ?? TenantVerificationStatus.Draft,
|
||||
EffectiveFrom = subscription?.EffectiveFrom ?? tenant.EffectiveFrom,
|
||||
EffectiveTo = subscription?.EffectiveTo ?? tenant.EffectiveTo
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -19,10 +19,12 @@ public sealed class SearchTenantPackagesQueryHandler(ITenantPackageRepository pa
|
||||
var packages = await packageRepository.SearchAsync(request.Keyword, request.IsActive, cancellationToken);
|
||||
|
||||
// 2. 排序与分页
|
||||
var ordered = packages.OrderByDescending(x => x.CreatedAt).ToList();
|
||||
var ordered = packages
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ThenByDescending(x => x.CreatedAt)
|
||||
.ToList();
|
||||
var pageIndex = request.Page <= 0 ? 1 : request.Page;
|
||||
var size = request.PageSize <= 0 ? 20 : request.PageSize;
|
||||
|
||||
var pagedItems = ordered
|
||||
.Skip((pageIndex - 1) * size)
|
||||
.Take(size)
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using TakeoutSaaS.Application.App.Tenants.Commands;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
using TakeoutSaaS.Application.Identity.Commands;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
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;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 自助注册租户处理器。
|
||||
/// </summary>
|
||||
public sealed class SelfRegisterTenantCommandHandler(
|
||||
ITenantRepository tenantRepository,
|
||||
IIdentityUserRepository identityUserRepository,
|
||||
IRoleRepository roleRepository,
|
||||
IPasswordHasher<IdentityUser> passwordHasher,
|
||||
IIdGenerator idGenerator,
|
||||
IMediator mediator,
|
||||
ITenantContextAccessor tenantContextAccessor)
|
||||
: IRequestHandler<SelfRegisterTenantCommand, SelfRegisterResultDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<SelfRegisterResultDto> Handle(SelfRegisterTenantCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 唯一性校验
|
||||
var normalizedAccount = request.AdminAccount.Trim();
|
||||
if (await identityUserRepository.ExistsByAccountAsync(normalizedAccount, cancellationToken))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, $"账号 {normalizedAccount} 已存在");
|
||||
}
|
||||
|
||||
// 1.2 校验手机号唯一性
|
||||
var normalizedPhone = request.AdminPhone.Trim();
|
||||
if (await tenantRepository.ExistsByContactPhoneAsync(normalizedPhone, cancellationToken))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, $"手机号 {normalizedPhone} 已注册");
|
||||
}
|
||||
|
||||
// 2. 生成租户标识与编码
|
||||
var tenantId = idGenerator.NextId();
|
||||
var tenantCode = $"t{tenantId}";
|
||||
|
||||
// 3. 构建租户(无订阅,待审核)
|
||||
var tenant = new Tenant
|
||||
{
|
||||
Id = tenantId,
|
||||
Code = tenantCode,
|
||||
Name = normalizedAccount,
|
||||
ShortName = string.IsNullOrWhiteSpace(request.AdminDisplayName) ? normalizedAccount : request.AdminDisplayName!.Trim(),
|
||||
ContactName = string.IsNullOrWhiteSpace(request.AdminDisplayName) ? normalizedAccount : request.AdminDisplayName!.Trim(),
|
||||
ContactPhone = normalizedPhone,
|
||||
ContactEmail = request.AdminEmail,
|
||||
Status = TenantStatus.PendingReview,
|
||||
EffectiveFrom = null,
|
||||
EffectiveTo = null
|
||||
};
|
||||
|
||||
// 4. 写入审计日志
|
||||
var auditLog = new TenantAuditLog
|
||||
{
|
||||
TenantId = tenant.Id,
|
||||
Action = TenantAuditAction.RegistrationSubmitted,
|
||||
Title = "自助注册",
|
||||
Description = "自助注册提交,等待补充资料与审核"
|
||||
};
|
||||
|
||||
// 5. 持久化租户与审计
|
||||
await tenantRepository.AddTenantAsync(tenant, cancellationToken);
|
||||
await tenantRepository.AddAuditLogAsync(auditLog, cancellationToken);
|
||||
await tenantRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 6. 临时切换租户上下文,保证身份与权限写入正确
|
||||
var previousContext = tenantContextAccessor.Current;
|
||||
tenantContextAccessor.Current = new TenantContext(tenant.Id, tenant.Code, "self-register");
|
||||
try
|
||||
{
|
||||
// 7. 使用用户自设密码创建管理员
|
||||
var adminUser = new IdentityUser
|
||||
{
|
||||
TenantId = tenant.Id,
|
||||
Account = normalizedAccount,
|
||||
DisplayName = string.IsNullOrWhiteSpace(request.AdminDisplayName) ? normalizedAccount : request.AdminDisplayName!.Trim(),
|
||||
PasswordHash = string.Empty
|
||||
};
|
||||
adminUser.PasswordHash = passwordHasher.HashPassword(adminUser, request.AdminPassword);
|
||||
await identityUserRepository.AddAsync(adminUser, cancellationToken);
|
||||
await identityUserRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 8. 初始化租户管理员角色模板
|
||||
await mediator.Send(new InitializeRoleTemplatesCommand
|
||||
{
|
||||
TemplateCodes = new[] { "tenant-admin" }
|
||||
}, cancellationToken);
|
||||
|
||||
// 9. 绑定租户管理员角色
|
||||
var tenantAdminRole = await roleRepository.FindByCodeAsync("tenant-admin", tenant.Id, cancellationToken);
|
||||
if (tenantAdminRole != null)
|
||||
{
|
||||
await mediator.Send(new AssignUserRolesCommand
|
||||
{
|
||||
UserId = adminUser.Id,
|
||||
RoleIds = new[] { tenantAdminRole.Id }
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
// 10. 返回注册结果
|
||||
return new SelfRegisterResultDto
|
||||
{
|
||||
TenantId = tenant.Id,
|
||||
Code = tenant.Code,
|
||||
Status = tenant.Status,
|
||||
VerificationStatus = TenantVerificationStatus.Draft,
|
||||
EffectiveFrom = tenant.EffectiveFrom,
|
||||
EffectiveTo = tenant.EffectiveTo,
|
||||
AdminAccount = adminUser.Account
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
// 11. 恢复上下文
|
||||
tenantContextAccessor.Current = previousContext;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ public sealed class UpdateTenantPackageCommandHandler(ITenantPackageRepository p
|
||||
package.MaxDeliveryOrders = request.MaxDeliveryOrders;
|
||||
package.FeaturePoliciesJson = request.FeaturePoliciesJson;
|
||||
package.IsActive = request.IsActive;
|
||||
package.SortOrder = request.SortOrder;
|
||||
|
||||
// 4. 持久化并返回
|
||||
await packageRepository.UpdateAsync(package, cancellationToken);
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 公共场景分页查询启用套餐。
|
||||
/// </summary>
|
||||
public sealed record GetPublicTenantPackagesQuery : IRequest<PagedResult<TenantPackageDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
public int Page { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; } = 20;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using MediatR;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using TakeoutSaaS.Application.App.Tenants.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Tenants.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 租户入住进度查询。
|
||||
/// </summary>
|
||||
public sealed record GetTenantProgressQuery : IRequest<TenantProgressDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID(雪花算法)。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public long TenantId { get; init; }
|
||||
}
|
||||
@@ -89,7 +89,8 @@ internal static class TenantMapping
|
||||
MaxSmsCredits = package.MaxSmsCredits,
|
||||
MaxDeliveryOrders = package.MaxDeliveryOrders,
|
||||
FeaturePoliciesJson = package.FeaturePoliciesJson,
|
||||
IsActive = package.IsActive
|
||||
IsActive = package.IsActive,
|
||||
SortOrder = package.SortOrder
|
||||
};
|
||||
|
||||
public static TenantBillingDto ToDto(this TenantBillingStatement bill)
|
||||
|
||||
Reference in New Issue
Block a user