Merge branch 'dev' of github.com:msumshk/TakeoutSaaS into dev
This commit is contained in:
@@ -34,6 +34,22 @@ public sealed class AuthController(IAdminAuthService authService) : BaseApiContr
|
|||||||
return ApiResponse<TokenResponse>.Ok(response);
|
return ApiResponse<TokenResponse>.Ok(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 免租户号登录(仅账号+密码)。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">登录请求。</param>
|
||||||
|
/// <param name="cancellationToken">取消标记。</param>
|
||||||
|
/// <returns>包含访问令牌与刷新令牌的响应。</returns>
|
||||||
|
/// <remarks>用于前端简化登录,无需额外传递租户号。</remarks>
|
||||||
|
[HttpPost("login/simple")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<TokenResponse>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<TokenResponse>> LoginSimple([FromBody] AdminLoginRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var response = await authService.LoginAsync(request, cancellationToken);
|
||||||
|
return ApiResponse<TokenResponse>.Ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 刷新 Token
|
/// 刷新 Token
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 公共租户套餐查询接口。
|
||||||
|
/// </summary>
|
||||||
|
[ApiVersion("1.0")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[EnableRateLimiting("public-self-service")]
|
||||||
|
[Route("api/public/v{version:apiVersion}/tenant-packages")]
|
||||||
|
public sealed class PublicTenantPackagesController(IMediator mediator) : BaseApiController
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 分页获取已启用的租户套餐。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="query">分页参数。</param>
|
||||||
|
/// <param name="cancellationToken">取消标记。</param>
|
||||||
|
/// <returns>启用套餐的分页列表。</returns>
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<PagedResult<TenantPackageDto>>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<PagedResult<TenantPackageDto>>> List(
|
||||||
|
[FromQuery, Required] GetPublicTenantPackagesQuery query,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// 1. 执行查询
|
||||||
|
var result = await mediator.Send(query, cancellationToken);
|
||||||
|
// 2. 返回结果
|
||||||
|
return ApiResponse<PagedResult<TenantPackageDto>>.Ok(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 公域租户自助入住接口。
|
||||||
|
/// </summary>
|
||||||
|
[ApiVersion("1.0")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[EnableRateLimiting("public-self-service")]
|
||||||
|
[Route("api/public/v{version:apiVersion}/tenants")]
|
||||||
|
public sealed class PublicTenantsController(IMediator mediator) : BaseApiController
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 自助注册租户并生成初始管理员。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="command">自助注册命令。</param>
|
||||||
|
/// <param name="cancellationToken">取消标记。</param>
|
||||||
|
/// <returns>注册结果(含临时密码)。</returns>
|
||||||
|
[HttpPost("self-register")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<SelfRegisterResultDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<SelfRegisterResultDto>> SelfRegister(
|
||||||
|
[FromBody, Required] SelfRegisterTenantCommand command,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// 1. 执行自助注册
|
||||||
|
var result = await mediator.Send(command, cancellationToken);
|
||||||
|
return ApiResponse<SelfRegisterResultDto>.Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 自助提交或更新实名资料。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tenantId">租户 ID。</param>
|
||||||
|
/// <param name="command">实名资料。</param>
|
||||||
|
/// <param name="cancellationToken">取消标记。</param>
|
||||||
|
/// <returns>实名资料结果。</returns>
|
||||||
|
[HttpPost("{tenantId:long}/verification")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<TenantVerificationDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<TenantVerificationDto>> 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<TenantVerificationDto>.Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 查询租户入住进度。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tenantId">租户 ID。</param>
|
||||||
|
/// <param name="cancellationToken">取消标记。</param>
|
||||||
|
/// <returns>入住进度。</returns>
|
||||||
|
[HttpGet("{tenantId:long}/status")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<TenantProgressDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ApiResponse<TenantProgressDto>> Progress(long tenantId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// 1. 查询进度
|
||||||
|
var query = new GetTenantProgressQuery { TenantId = tenantId };
|
||||||
|
var result = await mediator.Send(query, cancellationToken);
|
||||||
|
return ApiResponse<TenantProgressDto>.Ok(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Cors.Infrastructure;
|
using Microsoft.AspNetCore.Cors.Infrastructure;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using OpenTelemetry.Metrics;
|
using OpenTelemetry.Metrics;
|
||||||
using OpenTelemetry.Resources;
|
using OpenTelemetry.Resources;
|
||||||
using OpenTelemetry.Trace;
|
using OpenTelemetry.Trace;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
|
using System.Threading.RateLimiting;
|
||||||
using TakeoutSaaS.Application.App.Extensions;
|
using TakeoutSaaS.Application.App.Extensions;
|
||||||
using TakeoutSaaS.Application.Identity.Extensions;
|
using TakeoutSaaS.Application.Identity.Extensions;
|
||||||
using TakeoutSaaS.Application.Messaging.Extensions;
|
using TakeoutSaaS.Application.Messaging.Extensions;
|
||||||
@@ -75,6 +78,17 @@ builder.Services.AddMessagingModule(builder.Configuration);
|
|||||||
builder.Services.AddMessagingApplication();
|
builder.Services.AddMessagingApplication();
|
||||||
builder.Services.AddSchedulerModule(builder.Configuration);
|
builder.Services.AddSchedulerModule(builder.Configuration);
|
||||||
builder.Services.AddHealthChecks();
|
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 采集
|
// 6. 配置 OpenTelemetry 采集
|
||||||
var otelSection = builder.Configuration.GetSection("Otel");
|
var otelSection = builder.Configuration.GetSection("Otel");
|
||||||
@@ -140,6 +154,7 @@ builder.Services.AddCors(options =>
|
|||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
app.UseCors("AdminApiCors");
|
app.UseCors("AdminApiCors");
|
||||||
app.UseTenantResolution();
|
app.UseTenantResolution();
|
||||||
|
app.UseRateLimiter();
|
||||||
app.UseSharedWebCore();
|
app.UseSharedWebCore();
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
|||||||
@@ -68,4 +68,9 @@ public sealed record CreateTenantPackageCommand : IRequest<TenantPackageDto>
|
|||||||
/// 是否可售。
|
/// 是否可售。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool IsActive { get; init; } = true;
|
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>
|
/// </summary>
|
||||||
public bool IsActive { get; init; } = true;
|
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>
|
/// </summary>
|
||||||
public bool IsActive { get; init; }
|
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,
|
MaxSmsCredits = request.MaxSmsCredits,
|
||||||
MaxDeliveryOrders = request.MaxDeliveryOrders,
|
MaxDeliveryOrders = request.MaxDeliveryOrders,
|
||||||
FeaturePoliciesJson = request.FeaturePoliciesJson,
|
FeaturePoliciesJson = request.FeaturePoliciesJson,
|
||||||
IsActive = request.IsActive
|
IsActive = request.IsActive,
|
||||||
|
SortOrder = request.SortOrder
|
||||||
};
|
};
|
||||||
|
|
||||||
// 3. 持久化并返回
|
// 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);
|
var packages = await packageRepository.SearchAsync(request.Keyword, request.IsActive, cancellationToken);
|
||||||
|
|
||||||
// 2. 排序与分页
|
// 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 pageIndex = request.Page <= 0 ? 1 : request.Page;
|
||||||
var size = request.PageSize <= 0 ? 20 : request.PageSize;
|
var size = request.PageSize <= 0 ? 20 : request.PageSize;
|
||||||
|
|
||||||
var pagedItems = ordered
|
var pagedItems = ordered
|
||||||
.Skip((pageIndex - 1) * size)
|
.Skip((pageIndex - 1) * size)
|
||||||
.Take(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.MaxDeliveryOrders = request.MaxDeliveryOrders;
|
||||||
package.FeaturePoliciesJson = request.FeaturePoliciesJson;
|
package.FeaturePoliciesJson = request.FeaturePoliciesJson;
|
||||||
package.IsActive = request.IsActive;
|
package.IsActive = request.IsActive;
|
||||||
|
package.SortOrder = request.SortOrder;
|
||||||
|
|
||||||
// 4. 持久化并返回
|
// 4. 持久化并返回
|
||||||
await packageRepository.UpdateAsync(package, cancellationToken);
|
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,
|
MaxSmsCredits = package.MaxSmsCredits,
|
||||||
MaxDeliveryOrders = package.MaxDeliveryOrders,
|
MaxDeliveryOrders = package.MaxDeliveryOrders,
|
||||||
FeaturePoliciesJson = package.FeaturePoliciesJson,
|
FeaturePoliciesJson = package.FeaturePoliciesJson,
|
||||||
IsActive = package.IsActive
|
IsActive = package.IsActive,
|
||||||
|
SortOrder = package.SortOrder
|
||||||
};
|
};
|
||||||
|
|
||||||
public static TenantBillingDto ToDto(this TenantBillingStatement bill)
|
public static TenantBillingDto ToDto(this TenantBillingStatement bill)
|
||||||
|
|||||||
@@ -15,6 +15,14 @@ public interface IIdentityUserRepository
|
|||||||
/// <returns>后台用户或 null。</returns>
|
/// <returns>后台用户或 null。</returns>
|
||||||
Task<IdentityUser?> FindByAccountAsync(string account, CancellationToken cancellationToken = default);
|
Task<IdentityUser?> FindByAccountAsync(string account, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 判断账号是否存在。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="account">账号。</param>
|
||||||
|
/// <param name="cancellationToken">取消标记。</param>
|
||||||
|
/// <returns>存在返回 true。</returns>
|
||||||
|
Task<bool> ExistsByAccountAsync(string account, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 根据 ID 获取后台用户。
|
/// 根据 ID 获取后台用户。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -40,4 +48,19 @@ public interface IIdentityUserRepository
|
|||||||
/// <param name="cancellationToken">取消标记。</param>
|
/// <param name="cancellationToken">取消标记。</param>
|
||||||
/// <returns>后台用户列表。</returns>
|
/// <returns>后台用户列表。</returns>
|
||||||
Task<IReadOnlyList<IdentityUser>> GetByIdsAsync(long tenantId, IEnumerable<long> userIds, CancellationToken cancellationToken = default);
|
Task<IReadOnlyList<IdentityUser>> GetByIdsAsync(long tenantId, IEnumerable<long> userIds, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 新增后台用户。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="user">后台用户实体。</param>
|
||||||
|
/// <param name="cancellationToken">取消标记。</param>
|
||||||
|
/// <returns>异步操作任务。</returns>
|
||||||
|
Task AddAsync(IdentityUser user, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 持久化仓储变更。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">取消标记。</param>
|
||||||
|
/// <returns>异步操作任务。</returns>
|
||||||
|
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,4 +67,9 @@ public sealed class TenantPackage : AuditableEntityBase
|
|||||||
/// 是否仍可售卖。
|
/// 是否仍可售卖。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool IsActive { get; set; } = true;
|
public bool IsActive { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 展示排序,数值越小越靠前。
|
||||||
|
/// </summary>
|
||||||
|
public int SortOrder { get; set; } = 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,14 @@ public interface ITenantRepository
|
|||||||
/// <returns>存在返回 true,否则 false。</returns>
|
/// <returns>存在返回 true,否则 false。</returns>
|
||||||
Task<bool> ExistsByCodeAsync(string code, CancellationToken cancellationToken = default);
|
Task<bool> ExistsByCodeAsync(string code, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 判断联系人手机号是否存在。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="phone">联系人手机号。</param>
|
||||||
|
/// <param name="cancellationToken">取消标记。</param>
|
||||||
|
/// <returns>存在返回 true,否则 false。</returns>
|
||||||
|
Task<bool> ExistsByContactPhoneAsync(string phone, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取实名资料。
|
/// 获取实名资料。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -432,6 +432,8 @@ public sealed class TakeoutAppDbContext(
|
|||||||
builder.Property(x => x.Name).HasMaxLength(128).IsRequired();
|
builder.Property(x => x.Name).HasMaxLength(128).IsRequired();
|
||||||
builder.Property(x => x.Description).HasMaxLength(512);
|
builder.Property(x => x.Description).HasMaxLength(512);
|
||||||
builder.Property(x => x.FeaturePoliciesJson).HasColumnType("text");
|
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<TenantSubscription> builder)
|
private static void ConfigureTenantSubscription(EntityTypeBuilder<TenantSubscription> builder)
|
||||||
|
|||||||
@@ -19,21 +19,26 @@ public sealed class EfTenantPackageRepository(TakeoutAppDbContext context) : ITe
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<IReadOnlyList<TenantPackage>> SearchAsync(string? keyword, bool? isActive, CancellationToken cancellationToken = default)
|
public async Task<IReadOnlyList<TenantPackage>> SearchAsync(string? keyword, bool? isActive, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
// 1. 构建基础查询
|
||||||
var query = context.TenantPackages.AsNoTracking();
|
var query = context.TenantPackages.AsNoTracking();
|
||||||
|
|
||||||
|
// 2. 关键字过滤
|
||||||
if (!string.IsNullOrWhiteSpace(keyword))
|
if (!string.IsNullOrWhiteSpace(keyword))
|
||||||
{
|
{
|
||||||
var normalized = keyword.Trim();
|
var normalized = keyword.Trim();
|
||||||
query = query.Where(x => EF.Functions.ILike(x.Name, $"%{normalized}%") || EF.Functions.ILike(x.Description ?? string.Empty, $"%{normalized}%"));
|
query = query.Where(x => EF.Functions.ILike(x.Name, $"%{normalized}%") || EF.Functions.ILike(x.Description ?? string.Empty, $"%{normalized}%"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. 状态过滤
|
||||||
if (isActive.HasValue)
|
if (isActive.HasValue)
|
||||||
{
|
{
|
||||||
query = query.Where(x => x.IsActive == isActive.Value);
|
query = query.Where(x => x.IsActive == isActive.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4. 排序返回
|
||||||
return await query
|
return await query
|
||||||
.OrderByDescending(x => x.CreatedAt)
|
.OrderBy(x => x.SortOrder)
|
||||||
|
.ThenByDescending(x => x.CreatedAt)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,13 +25,16 @@ public sealed class EfTenantRepository(TakeoutAppDbContext context) : ITenantRep
|
|||||||
string? keyword,
|
string? keyword,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
// 1. 构建基础查询
|
||||||
var query = context.Tenants.AsNoTracking();
|
var query = context.Tenants.AsNoTracking();
|
||||||
|
|
||||||
|
// 2. 按状态过滤
|
||||||
if (status.HasValue)
|
if (status.HasValue)
|
||||||
{
|
{
|
||||||
query = query.Where(x => x.Status == status.Value);
|
query = query.Where(x => x.Status == status.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. 按关键字过滤
|
||||||
if (!string.IsNullOrWhiteSpace(keyword))
|
if (!string.IsNullOrWhiteSpace(keyword))
|
||||||
{
|
{
|
||||||
keyword = keyword.Trim();
|
keyword = keyword.Trim();
|
||||||
@@ -41,6 +44,7 @@ public sealed class EfTenantRepository(TakeoutAppDbContext context) : ITenantRep
|
|||||||
EF.Functions.ILike(x.ContactName ?? string.Empty, $"%{keyword}%"));
|
EF.Functions.ILike(x.ContactName ?? string.Empty, $"%{keyword}%"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4. 排序返回
|
||||||
return await query
|
return await query
|
||||||
.OrderByDescending(x => x.CreatedAt)
|
.OrderByDescending(x => x.CreatedAt)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
@@ -66,6 +70,13 @@ public sealed class EfTenantRepository(TakeoutAppDbContext context) : ITenantRep
|
|||||||
return context.Tenants.AnyAsync(x => x.Code == normalized, cancellationToken);
|
return context.Tenants.AnyAsync(x => x.Code == normalized, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<bool> ExistsByContactPhoneAsync(string phone, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var normalized = phone.Trim();
|
||||||
|
return context.Tenants.AnyAsync(x => x.ContactPhone == normalized, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<TenantVerificationProfile?> GetVerificationProfileAsync(long tenantId, CancellationToken cancellationToken = default)
|
public Task<TenantVerificationProfile?> GetVerificationProfileAsync(long tenantId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -77,15 +88,18 @@ public sealed class EfTenantRepository(TakeoutAppDbContext context) : ITenantRep
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task UpsertVerificationProfileAsync(TenantVerificationProfile profile, CancellationToken cancellationToken = default)
|
public async Task UpsertVerificationProfileAsync(TenantVerificationProfile profile, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
// 1. 查询现有实名资料
|
||||||
var existing = await context.TenantVerificationProfiles
|
var existing = await context.TenantVerificationProfiles
|
||||||
.FirstOrDefaultAsync(x => x.TenantId == profile.TenantId, cancellationToken);
|
.FirstOrDefaultAsync(x => x.TenantId == profile.TenantId, cancellationToken);
|
||||||
|
|
||||||
if (existing == null)
|
if (existing == null)
|
||||||
{
|
{
|
||||||
|
// 2. 不存在则新增
|
||||||
await context.TenantVerificationProfiles.AddAsync(profile, cancellationToken);
|
await context.TenantVerificationProfiles.AddAsync(profile, cancellationToken);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. 存在则更新当前值
|
||||||
profile.Id = existing.Id;
|
profile.Id = existing.Id;
|
||||||
context.Entry(existing).CurrentValues.SetValues(profile);
|
context.Entry(existing).CurrentValues.SetValues(profile);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,30 +9,95 @@ namespace TakeoutSaaS.Infrastructure.Identity.Persistence;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIdentityUserRepository
|
public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIdentityUserRepository
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 根据账号获取后台用户。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="account">账号。</param>
|
||||||
|
/// <param name="cancellationToken">取消标记。</param>
|
||||||
|
/// <returns>后台用户或 null。</returns>
|
||||||
public Task<IdentityUser?> FindByAccountAsync(string account, CancellationToken cancellationToken = default)
|
public Task<IdentityUser?> FindByAccountAsync(string account, CancellationToken cancellationToken = default)
|
||||||
=> dbContext.IdentityUsers.AsNoTracking().FirstOrDefaultAsync(x => x.Account == account, cancellationToken);
|
=> dbContext.IdentityUsers.AsNoTracking().FirstOrDefaultAsync(x => x.Account == account, cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 判断账号是否存在。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="account">账号。</param>
|
||||||
|
/// <param name="cancellationToken">取消标记。</param>
|
||||||
|
/// <returns>存在返回 true。</returns>
|
||||||
|
public Task<bool> ExistsByAccountAsync(string account, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// 1. 标准化账号
|
||||||
|
var normalized = account.Trim();
|
||||||
|
// 2. 查询是否存在
|
||||||
|
return dbContext.IdentityUsers.AnyAsync(x => x.Account == normalized, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 根据 ID 获取后台用户。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userId">用户 ID。</param>
|
||||||
|
/// <param name="cancellationToken">取消标记。</param>
|
||||||
|
/// <returns>后台用户或 null。</returns>
|
||||||
public Task<IdentityUser?> FindByIdAsync(long userId, CancellationToken cancellationToken = default)
|
public Task<IdentityUser?> FindByIdAsync(long userId, CancellationToken cancellationToken = default)
|
||||||
=> dbContext.IdentityUsers.AsNoTracking().FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
|
=> dbContext.IdentityUsers.AsNoTracking().FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 按租户与关键字搜索后台用户(只读)。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tenantId">租户 ID。</param>
|
||||||
|
/// <param name="keyword">关键字(账号/名称)。</param>
|
||||||
|
/// <param name="cancellationToken">取消标记。</param>
|
||||||
|
/// <returns>后台用户列表。</returns>
|
||||||
public async Task<IReadOnlyList<IdentityUser>> SearchAsync(long tenantId, string? keyword, CancellationToken cancellationToken = default)
|
public async Task<IReadOnlyList<IdentityUser>> SearchAsync(long tenantId, string? keyword, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
// 1. 构建基础查询
|
||||||
var query = dbContext.IdentityUsers
|
var query = dbContext.IdentityUsers
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(x => x.TenantId == tenantId);
|
.Where(x => x.TenantId == tenantId);
|
||||||
|
|
||||||
|
// 2. 关键字过滤
|
||||||
if (!string.IsNullOrWhiteSpace(keyword))
|
if (!string.IsNullOrWhiteSpace(keyword))
|
||||||
{
|
{
|
||||||
var normalized = keyword.Trim();
|
var normalized = keyword.Trim();
|
||||||
query = query.Where(x => x.Account.Contains(normalized) || x.DisplayName.Contains(normalized));
|
query = query.Where(x => x.Account.Contains(normalized) || x.DisplayName.Contains(normalized));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. 返回列表
|
||||||
return await query.ToListAsync(cancellationToken);
|
return await query.ToListAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 根据 ID 集合批量获取后台用户(只读)。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tenantId">租户 ID。</param>
|
||||||
|
/// <param name="userIds">用户 ID 集合。</param>
|
||||||
|
/// <param name="cancellationToken">取消标记。</param>
|
||||||
|
/// <returns>后台用户列表。</returns>
|
||||||
public Task<IReadOnlyList<IdentityUser>> GetByIdsAsync(long tenantId, IEnumerable<long> userIds, CancellationToken cancellationToken = default)
|
public Task<IReadOnlyList<IdentityUser>> GetByIdsAsync(long tenantId, IEnumerable<long> userIds, CancellationToken cancellationToken = default)
|
||||||
=> dbContext.IdentityUsers.AsNoTracking()
|
=> dbContext.IdentityUsers.AsNoTracking()
|
||||||
.Where(x => x.TenantId == tenantId && userIds.Contains(x.Id))
|
.Where(x => x.TenantId == tenantId && userIds.Contains(x.Id))
|
||||||
.ToListAsync(cancellationToken)
|
.ToListAsync(cancellationToken)
|
||||||
.ContinueWith(t => (IReadOnlyList<IdentityUser>)t.Result, cancellationToken);
|
.ContinueWith(t => (IReadOnlyList<IdentityUser>)t.Result, cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 新增后台用户。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="user">后台用户实体。</param>
|
||||||
|
/// <param name="cancellationToken">取消标记。</param>
|
||||||
|
/// <returns>异步任务。</returns>
|
||||||
|
public Task AddAsync(IdentityUser user, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// 1. 添加实体
|
||||||
|
dbContext.IdentityUsers.Add(user);
|
||||||
|
// 2. 返回完成任务
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 持久化仓储变更。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">取消标记。</param>
|
||||||
|
/// <returns>保存任务。</returns>
|
||||||
|
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||||
|
=> dbContext.SaveChangesAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 为租户套餐新增排序字段与索引的迁移。
|
||||||
|
/// </summary>
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddTenantPackageSortOrder : Migration
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 升级:新增排序列并创建索引。
|
||||||
|
/// </summary>
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// 1. 新增排序列,默认 0
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
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" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 回滚:删除索引并移除排序列。
|
||||||
|
/// </summary>
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6247,6 +6247,12 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
|||||||
.HasColumnType("boolean")
|
.HasColumnType("boolean")
|
||||||
.HasComment("是否仍可售卖。");
|
.HasComment("是否仍可售卖。");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasDefaultValue(0)
|
||||||
|
.HasComment("展示排序,数值越小越靠前。");
|
||||||
|
|
||||||
b.Property<int?>("MaxAccountCount")
|
b.Property<int?>("MaxAccountCount")
|
||||||
.HasColumnType("integer")
|
.HasColumnType("integer")
|
||||||
.HasComment("允许创建的最大账号数。");
|
.HasComment("允许创建的最大账号数。");
|
||||||
@@ -6295,6 +6301,8 @@ namespace TakeoutSaaS.Infrastructure.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("IsActive", "SortOrder");
|
||||||
|
|
||||||
b.ToTable("tenant_packages", null, t =>
|
b.ToTable("tenant_packages", null, t =>
|
||||||
{
|
{
|
||||||
t.HasComment("平台提供的租户套餐定义。");
|
t.HasComment("平台提供的租户套餐定义。");
|
||||||
|
|||||||
Reference in New Issue
Block a user