diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/AuthController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/AuthController.cs index 5b75885..b12135c 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/AuthController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/AuthController.cs @@ -34,6 +34,22 @@ public sealed class AuthController(IAdminAuthService authService) : BaseApiContr return ApiResponse.Ok(response); } + /// + /// 免租户号登录(仅账号+密码)。 + /// + /// 登录请求。 + /// 取消标记。 + /// 包含访问令牌与刷新令牌的响应。 + /// 用于前端简化登录,无需额外传递租户号。 + [HttpPost("login/simple")] + [AllowAnonymous] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> LoginSimple([FromBody] AdminLoginRequest request, CancellationToken cancellationToken) + { + var response = await authService.LoginAsync(request, cancellationToken); + return ApiResponse.Ok(response); + } + /// /// 刷新 Token /// diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/PublicTenantPackagesController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/PublicTenantPackagesController.cs new file mode 100644 index 0000000..716608d --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/PublicTenantPackagesController.cs @@ -0,0 +1,39 @@ +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using System.ComponentModel.DataAnnotations; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Application.App.Tenants.Queries; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Web.Api; + +namespace TakeoutSaaS.AdminApi.Controllers; + +/// +/// 公共租户套餐查询接口。 +/// +[ApiVersion("1.0")] +[AllowAnonymous] +[EnableRateLimiting("public-self-service")] +[Route("api/public/v{version:apiVersion}/tenant-packages")] +public sealed class PublicTenantPackagesController(IMediator mediator) : BaseApiController +{ + /// + /// 分页获取已启用的租户套餐。 + /// + /// 分页参数。 + /// 取消标记。 + /// 启用套餐的分页列表。 + [HttpGet] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery, Required] GetPublicTenantPackagesQuery query, + CancellationToken cancellationToken) + { + // 1. 执行查询 + var result = await mediator.Send(query, cancellationToken); + // 2. 返回结果 + return ApiResponse>.Ok(result); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/PublicTenantsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/PublicTenantsController.cs new file mode 100644 index 0000000..05e7764 --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/PublicTenantsController.cs @@ -0,0 +1,76 @@ +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using System.ComponentModel.DataAnnotations; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Application.App.Tenants.Queries; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Web.Api; + +namespace TakeoutSaaS.AdminApi.Controllers; + +/// +/// 公域租户自助入住接口。 +/// +[ApiVersion("1.0")] +[AllowAnonymous] +[EnableRateLimiting("public-self-service")] +[Route("api/public/v{version:apiVersion}/tenants")] +public sealed class PublicTenantsController(IMediator mediator) : BaseApiController +{ + /// + /// 自助注册租户并生成初始管理员。 + /// + /// 自助注册命令。 + /// 取消标记。 + /// 注册结果(含临时密码)。 + [HttpPost("self-register")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> SelfRegister( + [FromBody, Required] SelfRegisterTenantCommand command, + CancellationToken cancellationToken) + { + // 1. 执行自助注册 + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 自助提交或更新实名资料。 + /// + /// 租户 ID。 + /// 实名资料。 + /// 取消标记。 + /// 实名资料结果。 + [HttpPost("{tenantId:long}/verification")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> SubmitVerification( + long tenantId, + [FromBody, Required] SubmitTenantVerificationCommand command, + CancellationToken cancellationToken) + { + // 1. 绑定租户 ID + var merged = command with { TenantId = tenantId }; + // 2. 提交实名 + var result = await mediator.Send(merged, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 查询租户入住进度。 + /// + /// 租户 ID。 + /// 取消标记。 + /// 入住进度。 + [HttpGet("{tenantId:long}/status")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Progress(long tenantId, CancellationToken cancellationToken) + { + // 1. 查询进度 + var query = new GetTenantProgressQuery { TenantId = tenantId }; + var result = await mediator.Send(query, cancellationToken); + return ApiResponse.Ok(result); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Program.cs b/src/Api/TakeoutSaaS.AdminApi/Program.cs index f8fe6b9..211c70d 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Program.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Program.cs @@ -1,8 +1,11 @@ +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Cors.Infrastructure; +using Microsoft.AspNetCore.RateLimiting; using OpenTelemetry.Metrics; using OpenTelemetry.Resources; using OpenTelemetry.Trace; using Serilog; +using System.Threading.RateLimiting; using TakeoutSaaS.Application.App.Extensions; using TakeoutSaaS.Application.Identity.Extensions; using TakeoutSaaS.Application.Messaging.Extensions; @@ -75,6 +78,17 @@ builder.Services.AddMessagingModule(builder.Configuration); builder.Services.AddMessagingApplication(); builder.Services.AddSchedulerModule(builder.Configuration); builder.Services.AddHealthChecks(); +builder.Services.AddRateLimiter(options => +{ + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + options.AddFixedWindowLimiter("public-self-service", limiterOptions => + { + limiterOptions.PermitLimit = 10; + limiterOptions.Window = TimeSpan.FromMinutes(1); + limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst; + limiterOptions.QueueLimit = 2; + }); +}); // 6. 配置 OpenTelemetry 采集 var otelSection = builder.Configuration.GetSection("Otel"); @@ -140,6 +154,7 @@ builder.Services.AddCors(options => var app = builder.Build(); app.UseCors("AdminApiCors"); app.UseTenantResolution(); +app.UseRateLimiter(); app.UseSharedWebCore(); app.UseAuthentication(); app.UseAuthorization(); diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantPackageCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantPackageCommand.cs index c809fca..aa51ca2 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantPackageCommand.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantPackageCommand.cs @@ -68,4 +68,9 @@ public sealed record CreateTenantPackageCommand : IRequest /// 是否可售。 /// public bool IsActive { get; init; } = true; + + /// + /// 展示排序,数值越小越靠前。 + /// + public int SortOrder { get; init; } = 0; } diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/SelfRegisterTenantCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/SelfRegisterTenantCommand.cs new file mode 100644 index 0000000..259a004 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/SelfRegisterTenantCommand.cs @@ -0,0 +1,45 @@ +using MediatR; +using System.ComponentModel.DataAnnotations; +using TakeoutSaaS.Application.App.Tenants.Dto; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 自助注册租户命令。 +/// +public sealed record SelfRegisterTenantCommand : IRequest +{ + /// + /// 初始管理员账号。 + /// + [Required] + [StringLength(64)] + public string AdminAccount { get; init; } = string.Empty; + + /// + /// 初始管理员展示名称。 + /// + [StringLength(64)] + public string? AdminDisplayName { get; init; } + + /// + /// 初始管理员邮箱。 + /// + [EmailAddress] + [StringLength(128)] + public string? AdminEmail { get; init; } + + /// + /// 初始管理员手机号。 + /// + [Required] + [StringLength(32)] + public string AdminPhone { get; init; } = string.Empty; + + /// + /// 初始管理员登录密码(前端自定义)。 + /// + [Required] + [StringLength(128, MinimumLength = 8)] + public string AdminPassword { get; init; } = string.Empty; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/UpdateTenantPackageCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/UpdateTenantPackageCommand.cs index a4529d3..96ca433 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/UpdateTenantPackageCommand.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/UpdateTenantPackageCommand.cs @@ -73,4 +73,9 @@ public sealed record UpdateTenantPackageCommand : IRequest /// 是否可售。 /// public bool IsActive { get; init; } = true; + + /// + /// 展示排序,数值越小越靠前。 + /// + public int SortOrder { get; init; } = 0; } diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/SelfRegisterResultDto.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/SelfRegisterResultDto.cs new file mode 100644 index 0000000..024e485 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/SelfRegisterResultDto.cs @@ -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; + +/// +/// 自助注册结果 DTO。 +/// +public sealed class SelfRegisterResultDto +{ + /// + /// 租户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 租户编码。 + /// + public string Code { get; init; } = string.Empty; + + /// + /// 初始状态。 + /// + public TenantStatus Status { get; init; } = TenantStatus.PendingReview; + + /// + /// 当前实名状态。 + /// + public TenantVerificationStatus VerificationStatus { get; init; } = TenantVerificationStatus.Draft; + + /// + /// 订阅开始时间。 + /// + public DateTime? EffectiveFrom { get; init; } + + /// + /// 订阅到期时间。 + /// + public DateTime? EffectiveTo { get; init; } + + /// + /// 初始管理员账号。 + /// + public string AdminAccount { get; init; } = string.Empty; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantPackageDto.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantPackageDto.cs index 52f9e90..4f7b57a 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantPackageDto.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantPackageDto.cs @@ -74,4 +74,9 @@ public sealed class TenantPackageDto /// 是否可售。 /// public bool IsActive { get; init; } + + /// + /// 展示排序,数值越小越靠前。 + /// + public int SortOrder { get; init; } } diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantProgressDto.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantProgressDto.cs new file mode 100644 index 0000000..25e5807 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantProgressDto.cs @@ -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; + +/// +/// 租户入住进度 DTO。 +/// +public sealed class TenantProgressDto +{ + /// + /// 租户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 租户编码。 + /// + public string Code { get; init; } = string.Empty; + + /// + /// 当前租户状态。 + /// + public TenantStatus Status { get; init; } + + /// + /// 实名审核状态。 + /// + public TenantVerificationStatus VerificationStatus { get; init; } + + /// + /// 当前订阅开始时间。 + /// + public DateTime? EffectiveFrom { get; init; } + + /// + /// 当前订阅到期时间。 + /// + public DateTime? EffectiveTo { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantPackageCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantPackageCommandHandler.cs index c14a993..bf2dd1f 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantPackageCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantPackageCommandHandler.cs @@ -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. 持久化并返回 diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetPublicTenantPackagesQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetPublicTenantPackagesQueryHandler.cs new file mode 100644 index 0000000..de7e402 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetPublicTenantPackagesQueryHandler.cs @@ -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; + +/// +/// 公共场景分页查询启用套餐处理器。 +/// +public sealed class GetPublicTenantPackagesQueryHandler(ITenantPackageRepository packageRepository) + : IRequestHandler> +{ + /// + public async Task> 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(items, pageIndex, size, ordered.Count); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantProgressQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantProgressQueryHandler.cs new file mode 100644 index 0000000..25eaca6 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantProgressQueryHandler.cs @@ -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; + +/// +/// 租户入住进度查询处理器。 +/// +public sealed class GetTenantProgressQueryHandler(ITenantRepository tenantRepository) + : IRequestHandler +{ + /// + public async Task 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 + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantPackagesQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantPackagesQueryHandler.cs index 21c3ba7..4081c91 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantPackagesQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantPackagesQueryHandler.cs @@ -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) diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SelfRegisterTenantCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SelfRegisterTenantCommandHandler.cs new file mode 100644 index 0000000..14c3503 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SelfRegisterTenantCommandHandler.cs @@ -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; + +/// +/// 自助注册租户处理器。 +/// +public sealed class SelfRegisterTenantCommandHandler( + ITenantRepository tenantRepository, + IIdentityUserRepository identityUserRepository, + IRoleRepository roleRepository, + IPasswordHasher passwordHasher, + IIdGenerator idGenerator, + IMediator mediator, + ITenantContextAccessor tenantContextAccessor) + : IRequestHandler +{ + /// + public async Task 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; + } + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/UpdateTenantPackageCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/UpdateTenantPackageCommandHandler.cs index 7821bbf..c96ade6 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/UpdateTenantPackageCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/UpdateTenantPackageCommandHandler.cs @@ -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); diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetPublicTenantPackagesQuery.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetPublicTenantPackagesQuery.cs new file mode 100644 index 0000000..3c56e95 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetPublicTenantPackagesQuery.cs @@ -0,0 +1,21 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.Tenants.Queries; + +/// +/// 公共场景分页查询启用套餐。 +/// +public sealed record GetPublicTenantPackagesQuery : IRequest> +{ + /// + /// 页码。 + /// + public int Page { get; init; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; init; } = 20; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantProgressQuery.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantProgressQuery.cs new file mode 100644 index 0000000..214e600 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantProgressQuery.cs @@ -0,0 +1,17 @@ +using MediatR; +using System.ComponentModel.DataAnnotations; +using TakeoutSaaS.Application.App.Tenants.Dto; + +namespace TakeoutSaaS.Application.App.Tenants.Queries; + +/// +/// 租户入住进度查询。 +/// +public sealed record GetTenantProgressQuery : IRequest +{ + /// + /// 租户 ID(雪花算法)。 + /// + [Required] + public long TenantId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/TenantMapping.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/TenantMapping.cs index d361892..24f7b44 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/TenantMapping.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/TenantMapping.cs @@ -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) diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IIdentityUserRepository.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IIdentityUserRepository.cs index cc3a497..3a393c2 100644 --- a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IIdentityUserRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IIdentityUserRepository.cs @@ -15,6 +15,14 @@ public interface IIdentityUserRepository /// 后台用户或 null。 Task FindByAccountAsync(string account, CancellationToken cancellationToken = default); + /// + /// 判断账号是否存在。 + /// + /// 账号。 + /// 取消标记。 + /// 存在返回 true。 + Task ExistsByAccountAsync(string account, CancellationToken cancellationToken = default); + /// /// 根据 ID 获取后台用户。 /// @@ -40,4 +48,19 @@ public interface IIdentityUserRepository /// 取消标记。 /// 后台用户列表。 Task> GetByIdsAsync(long tenantId, IEnumerable userIds, CancellationToken cancellationToken = default); + + /// + /// 新增后台用户。 + /// + /// 后台用户实体。 + /// 取消标记。 + /// 异步操作任务。 + Task AddAsync(IdentityUser user, CancellationToken cancellationToken = default); + + /// + /// 持久化仓储变更。 + /// + /// 取消标记。 + /// 异步操作任务。 + Task SaveChangesAsync(CancellationToken cancellationToken = default); } diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantPackage.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantPackage.cs index 807f159..bf090ce 100644 --- a/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantPackage.cs +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantPackage.cs @@ -67,4 +67,9 @@ public sealed class TenantPackage : AuditableEntityBase /// 是否仍可售卖。 /// public bool IsActive { get; set; } = true; + + /// + /// 展示排序,数值越小越靠前。 + /// + public int SortOrder { get; set; } = 0; } diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantRepository.cs index c396852..41b9136 100644 --- a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantRepository.cs @@ -52,6 +52,14 @@ public interface ITenantRepository /// 存在返回 true,否则 false。 Task ExistsByCodeAsync(string code, CancellationToken cancellationToken = default); + /// + /// 判断联系人手机号是否存在。 + /// + /// 联系人手机号。 + /// 取消标记。 + /// 存在返回 true,否则 false。 + Task ExistsByContactPhoneAsync(string phone, CancellationToken cancellationToken = default); + /// /// 获取实名资料。 /// diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs index 0d38a33..19ecffe 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs @@ -432,6 +432,8 @@ public sealed class TakeoutAppDbContext( builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); builder.Property(x => x.Description).HasMaxLength(512); builder.Property(x => x.FeaturePoliciesJson).HasColumnType("text"); + builder.Property(x => x.SortOrder).HasDefaultValue(0).HasComment("展示排序,数值越小越靠前。"); + builder.HasIndex(x => new { x.IsActive, x.SortOrder }); } private static void ConfigureTenantSubscription(EntityTypeBuilder builder) diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantPackageRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantPackageRepository.cs index d23b429..a1018b8 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantPackageRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantPackageRepository.cs @@ -19,21 +19,26 @@ public sealed class EfTenantPackageRepository(TakeoutAppDbContext context) : ITe /// public async Task> SearchAsync(string? keyword, bool? isActive, CancellationToken cancellationToken = default) { + // 1. 构建基础查询 var query = context.TenantPackages.AsNoTracking(); + // 2. 关键字过滤 if (!string.IsNullOrWhiteSpace(keyword)) { var normalized = keyword.Trim(); query = query.Where(x => EF.Functions.ILike(x.Name, $"%{normalized}%") || EF.Functions.ILike(x.Description ?? string.Empty, $"%{normalized}%")); } + // 3. 状态过滤 if (isActive.HasValue) { query = query.Where(x => x.IsActive == isActive.Value); } + // 4. 排序返回 return await query - .OrderByDescending(x => x.CreatedAt) + .OrderBy(x => x.SortOrder) + .ThenByDescending(x => x.CreatedAt) .ToListAsync(cancellationToken); } diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantRepository.cs index 850046d..a1bd911 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantRepository.cs @@ -25,13 +25,16 @@ public sealed class EfTenantRepository(TakeoutAppDbContext context) : ITenantRep string? keyword, CancellationToken cancellationToken = default) { + // 1. 构建基础查询 var query = context.Tenants.AsNoTracking(); + // 2. 按状态过滤 if (status.HasValue) { query = query.Where(x => x.Status == status.Value); } + // 3. 按关键字过滤 if (!string.IsNullOrWhiteSpace(keyword)) { keyword = keyword.Trim(); @@ -41,6 +44,7 @@ public sealed class EfTenantRepository(TakeoutAppDbContext context) : ITenantRep EF.Functions.ILike(x.ContactName ?? string.Empty, $"%{keyword}%")); } + // 4. 排序返回 return await query .OrderByDescending(x => x.CreatedAt) .ToListAsync(cancellationToken); @@ -66,6 +70,13 @@ public sealed class EfTenantRepository(TakeoutAppDbContext context) : ITenantRep return context.Tenants.AnyAsync(x => x.Code == normalized, cancellationToken); } + /// + public Task ExistsByContactPhoneAsync(string phone, CancellationToken cancellationToken = default) + { + var normalized = phone.Trim(); + return context.Tenants.AnyAsync(x => x.ContactPhone == normalized, cancellationToken); + } + /// public Task GetVerificationProfileAsync(long tenantId, CancellationToken cancellationToken = default) { @@ -77,15 +88,18 @@ public sealed class EfTenantRepository(TakeoutAppDbContext context) : ITenantRep /// public async Task UpsertVerificationProfileAsync(TenantVerificationProfile profile, CancellationToken cancellationToken = default) { + // 1. 查询现有实名资料 var existing = await context.TenantVerificationProfiles .FirstOrDefaultAsync(x => x.TenantId == profile.TenantId, cancellationToken); if (existing == null) { + // 2. 不存在则新增 await context.TenantVerificationProfiles.AddAsync(profile, cancellationToken); return; } + // 3. 存在则更新当前值 profile.Id = existing.Id; context.Entry(existing).CurrentValues.SetValues(profile); } diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfIdentityUserRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfIdentityUserRepository.cs index 487428f..ea21444 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfIdentityUserRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfIdentityUserRepository.cs @@ -9,30 +9,95 @@ namespace TakeoutSaaS.Infrastructure.Identity.Persistence; /// public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIdentityUserRepository { + /// + /// 根据账号获取后台用户。 + /// + /// 账号。 + /// 取消标记。 + /// 后台用户或 null。 public Task FindByAccountAsync(string account, CancellationToken cancellationToken = default) => dbContext.IdentityUsers.AsNoTracking().FirstOrDefaultAsync(x => x.Account == account, cancellationToken); + /// + /// 判断账号是否存在。 + /// + /// 账号。 + /// 取消标记。 + /// 存在返回 true。 + public Task ExistsByAccountAsync(string account, CancellationToken cancellationToken = default) + { + // 1. 标准化账号 + var normalized = account.Trim(); + // 2. 查询是否存在 + return dbContext.IdentityUsers.AnyAsync(x => x.Account == normalized, cancellationToken); + } + + /// + /// 根据 ID 获取后台用户。 + /// + /// 用户 ID。 + /// 取消标记。 + /// 后台用户或 null。 public Task FindByIdAsync(long userId, CancellationToken cancellationToken = default) => dbContext.IdentityUsers.AsNoTracking().FirstOrDefaultAsync(x => x.Id == userId, cancellationToken); + /// + /// 按租户与关键字搜索后台用户(只读)。 + /// + /// 租户 ID。 + /// 关键字(账号/名称)。 + /// 取消标记。 + /// 后台用户列表。 public async Task> SearchAsync(long tenantId, string? keyword, CancellationToken cancellationToken = default) { + // 1. 构建基础查询 var query = dbContext.IdentityUsers .AsNoTracking() .Where(x => x.TenantId == tenantId); + // 2. 关键字过滤 if (!string.IsNullOrWhiteSpace(keyword)) { var normalized = keyword.Trim(); query = query.Where(x => x.Account.Contains(normalized) || x.DisplayName.Contains(normalized)); } + // 3. 返回列表 return await query.ToListAsync(cancellationToken); } + /// + /// 根据 ID 集合批量获取后台用户(只读)。 + /// + /// 租户 ID。 + /// 用户 ID 集合。 + /// 取消标记。 + /// 后台用户列表。 public Task> GetByIdsAsync(long tenantId, IEnumerable userIds, CancellationToken cancellationToken = default) => dbContext.IdentityUsers.AsNoTracking() .Where(x => x.TenantId == tenantId && userIds.Contains(x.Id)) .ToListAsync(cancellationToken) .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); + + /// + /// 新增后台用户。 + /// + /// 后台用户实体。 + /// 取消标记。 + /// 异步任务。 + public Task AddAsync(IdentityUser user, CancellationToken cancellationToken = default) + { + // 1. 添加实体 + dbContext.IdentityUsers.Add(user); + // 2. 返回完成任务 + return Task.CompletedTask; + } + + /// + /// 持久化仓储变更。 + /// + /// 取消标记。 + /// 保存任务。 + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + => dbContext.SaveChangesAsync(cancellationToken); } diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20251211150000_AddTenantPackageSortOrder.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20251211150000_AddTenantPackageSortOrder.cs new file mode 100644 index 0000000..8233f91 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20251211150000_AddTenantPackageSortOrder.cs @@ -0,0 +1,50 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TakeoutSaaS.Infrastructure.Migrations +{ + /// + /// 为租户套餐新增排序字段与索引的迁移。 + /// + /// + public partial class AddTenantPackageSortOrder : Migration + { + /// + /// 升级:新增排序列并创建索引。 + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // 1. 新增排序列,默认 0 + migrationBuilder.AddColumn( + name: "SortOrder", + table: "tenant_packages", + type: "integer", + nullable: false, + defaultValue: 0, + comment: "展示排序,数值越小越靠前。"); + + // 2. 创建可售+排序索引用于前台查询 + migrationBuilder.CreateIndex( + name: "IX_tenant_packages_IsActive_SortOrder", + table: "tenant_packages", + columns: new[] { "IsActive", "SortOrder" }); + } + + /// + /// 回滚:删除索引并移除排序列。 + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // 1. 移除索引 + migrationBuilder.DropIndex( + name: "IX_tenant_packages_IsActive_SortOrder", + table: "tenant_packages"); + + // 2. 回滚排序列 + migrationBuilder.DropColumn( + name: "SortOrder", + table: "tenant_packages"); + } + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/TakeoutAppDbContextModelSnapshot.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/TakeoutAppDbContextModelSnapshot.cs index 86764de..7632766 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/TakeoutAppDbContextModelSnapshot.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/TakeoutAppDbContextModelSnapshot.cs @@ -6247,6 +6247,12 @@ namespace TakeoutSaaS.Infrastructure.Migrations .HasColumnType("boolean") .HasComment("是否仍可售卖。"); + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasComment("展示排序,数值越小越靠前。"); + b.Property("MaxAccountCount") .HasColumnType("integer") .HasComment("允许创建的最大账号数。"); @@ -6295,6 +6301,8 @@ namespace TakeoutSaaS.Infrastructure.Migrations b.HasKey("Id"); + b.HasIndex("IsActive", "SortOrder"); + b.ToTable("tenant_packages", null, t => { t.HasComment("平台提供的租户套餐定义。");