feat: 新增租户管理端 TenantApi 并移除旧 API
This commit is contained in:
@@ -31,10 +31,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.Module.Authoriz
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.Module.Tenancy", "src\Modules\TakeoutSaaS.Module.Tenancy\TakeoutSaaS.Module.Tenancy.csproj", "{5B1DAF2B-C36C-4CB1-9452-81D5D6F79D38}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.MiniApi", "src\Api\TakeoutSaaS.MiniApi\TakeoutSaaS.MiniApi.csproj", "{12ECF33A-D5E3-4F8B-A9D9-60F7F55B869D}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.UserApi", "src\Api\TakeoutSaaS.UserApi\TakeoutSaaS.UserApi.csproj", "{1C0BCC51-AF18-44F3-A1E6-A693F74276B5}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.Shared.Kernel", "TakeoutSaaS.BuildingBlocks\src\Core\TakeoutSaaS.Shared.Kernel\TakeoutSaaS.Shared.Kernel.csproj", "{BBC99B58-ECA8-42C3-9070-9AA058D778D3}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.Module.Storage", "src\Modules\TakeoutSaaS.Module.Storage\TakeoutSaaS.Module.Storage.csproj", "{05058F44-6FB7-43AF-8648-8BF538E283EF}"
|
||||
@@ -55,6 +51,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.Application.Tes
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.Integration.Tests", "tests\TakeoutSaaS.Integration.Tests\TakeoutSaaS.Integration.Tests.csproj", "{8179CA95-33F8-45F2-BA29-9B1CC7D1E7CB}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.TenantApi", "src\Api\TakeoutSaaS.TenantApi\TakeoutSaaS.TenantApi.csproj", "{F53E274A-838A-477A-8D29-6EEB0DBD62CD}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -149,30 +147,6 @@ Global
|
||||
{5B1DAF2B-C36C-4CB1-9452-81D5D6F79D38}.Release|x64.Build.0 = Release|Any CPU
|
||||
{5B1DAF2B-C36C-4CB1-9452-81D5D6F79D38}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{5B1DAF2B-C36C-4CB1-9452-81D5D6F79D38}.Release|x86.Build.0 = Release|Any CPU
|
||||
{12ECF33A-D5E3-4F8B-A9D9-60F7F55B869D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{12ECF33A-D5E3-4F8B-A9D9-60F7F55B869D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{12ECF33A-D5E3-4F8B-A9D9-60F7F55B869D}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{12ECF33A-D5E3-4F8B-A9D9-60F7F55B869D}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{12ECF33A-D5E3-4F8B-A9D9-60F7F55B869D}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{12ECF33A-D5E3-4F8B-A9D9-60F7F55B869D}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{12ECF33A-D5E3-4F8B-A9D9-60F7F55B869D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{12ECF33A-D5E3-4F8B-A9D9-60F7F55B869D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{12ECF33A-D5E3-4F8B-A9D9-60F7F55B869D}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{12ECF33A-D5E3-4F8B-A9D9-60F7F55B869D}.Release|x64.Build.0 = Release|Any CPU
|
||||
{12ECF33A-D5E3-4F8B-A9D9-60F7F55B869D}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{12ECF33A-D5E3-4F8B-A9D9-60F7F55B869D}.Release|x86.Build.0 = Release|Any CPU
|
||||
{1C0BCC51-AF18-44F3-A1E6-A693F74276B5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{1C0BCC51-AF18-44F3-A1E6-A693F74276B5}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{1C0BCC51-AF18-44F3-A1E6-A693F74276B5}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{1C0BCC51-AF18-44F3-A1E6-A693F74276B5}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{1C0BCC51-AF18-44F3-A1E6-A693F74276B5}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{1C0BCC51-AF18-44F3-A1E6-A693F74276B5}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{1C0BCC51-AF18-44F3-A1E6-A693F74276B5}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{1C0BCC51-AF18-44F3-A1E6-A693F74276B5}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{1C0BCC51-AF18-44F3-A1E6-A693F74276B5}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{1C0BCC51-AF18-44F3-A1E6-A693F74276B5}.Release|x64.Build.0 = Release|Any CPU
|
||||
{1C0BCC51-AF18-44F3-A1E6-A693F74276B5}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{1C0BCC51-AF18-44F3-A1E6-A693F74276B5}.Release|x86.Build.0 = Release|Any CPU
|
||||
{BBC99B58-ECA8-42C3-9070-9AA058D778D3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{BBC99B58-ECA8-42C3-9070-9AA058D778D3}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{BBC99B58-ECA8-42C3-9070-9AA058D778D3}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
@@ -281,6 +255,18 @@ Global
|
||||
{8179CA95-33F8-45F2-BA29-9B1CC7D1E7CB}.Release|x64.Build.0 = Release|Any CPU
|
||||
{8179CA95-33F8-45F2-BA29-9B1CC7D1E7CB}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{8179CA95-33F8-45F2-BA29-9B1CC7D1E7CB}.Release|x86.Build.0 = Release|Any CPU
|
||||
{F53E274A-838A-477A-8D29-6EEB0DBD62CD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{F53E274A-838A-477A-8D29-6EEB0DBD62CD}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{F53E274A-838A-477A-8D29-6EEB0DBD62CD}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{F53E274A-838A-477A-8D29-6EEB0DBD62CD}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{F53E274A-838A-477A-8D29-6EEB0DBD62CD}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{F53E274A-838A-477A-8D29-6EEB0DBD62CD}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{F53E274A-838A-477A-8D29-6EEB0DBD62CD}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{F53E274A-838A-477A-8D29-6EEB0DBD62CD}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{F53E274A-838A-477A-8D29-6EEB0DBD62CD}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{F53E274A-838A-477A-8D29-6EEB0DBD62CD}.Release|x64.Build.0 = Release|Any CPU
|
||||
{F53E274A-838A-477A-8D29-6EEB0DBD62CD}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{F53E274A-838A-477A-8D29-6EEB0DBD62CD}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -299,8 +285,6 @@ Global
|
||||
{EC447DCF-ABFA-6E24-52A5-D7FD48A5C558} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{6CB8487D-5C74-487C-9D84-E57838BDA015} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
|
||||
{5B1DAF2B-C36C-4CB1-9452-81D5D6F79D38} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
|
||||
{12ECF33A-D5E3-4F8B-A9D9-60F7F55B869D} = {81034408-37C8-1011-444E-4C15C2FADA8E}
|
||||
{1C0BCC51-AF18-44F3-A1E6-A693F74276B5} = {81034408-37C8-1011-444E-4C15C2FADA8E}
|
||||
{BBC99B58-ECA8-42C3-9070-9AA058D778D3} = {8D626EA8-CB54-BC41-363A-217881BEBA6E}
|
||||
{05058F44-6FB7-43AF-8648-8BF538E283EF} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
|
||||
{5C12177E-6C25-4F78-BFD4-AA073CFC0650} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
|
||||
@@ -310,5 +294,6 @@ Global
|
||||
{38011EC3-7EC3-40E4-B9B2-E631966B350B} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
|
||||
{2601637E-777A-4FA2-81BA-1AFE32E961FF} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||
{8179CA95-33F8-45F2-BA29-9B1CC7D1E7CB} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||
{F53E274A-838A-477A-8D29-6EEB0DBD62CD} = {81034408-37C8-1011-444E-4C15C2FADA8E}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace TakeoutSaaS.MiniApi.Contracts.Requests;
|
||||
|
||||
/// <summary>
|
||||
/// 文件上传表单请求。
|
||||
/// </summary>
|
||||
public sealed record FileUploadFormRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 上传文件。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public required IFormFile File { get; init; }
|
||||
/// <summary>
|
||||
/// 上传类型。
|
||||
/// </summary>
|
||||
public string? Type { get; init; }
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
|
||||
namespace TakeoutSaaS.MiniApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 小程序登录认证
|
||||
/// </summary>
|
||||
/// <remarks>提供小程序端的微信登录与 Token 刷新能力。</remarks>
|
||||
/// <param name="authService">小程序认证服务</param>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/mini/v{version:apiVersion}/auth")]
|
||||
public sealed class AuthController(IMiniAuthService authService) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 微信登录
|
||||
/// </summary>
|
||||
/// <param name="request">微信登录请求。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>包含访问令牌与刷新令牌的响应。</returns>
|
||||
[HttpPost("wechat/login")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(ApiResponse<TokenResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<TokenResponse>> LoginWithWeChat([FromBody] WeChatLoginRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 调用认证服务完成微信登录
|
||||
var response = await authService.LoginWithWeChatAsync(request, cancellationToken);
|
||||
|
||||
// 2. 返回访问与刷新令牌
|
||||
return ApiResponse<TokenResponse>.Ok(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 刷新 Token
|
||||
/// </summary>
|
||||
/// <param name="request">刷新令牌请求。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>新的访问令牌与刷新令牌。</returns>
|
||||
[HttpPost("refresh")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(ApiResponse<TokenResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<TokenResponse>> RefreshToken([FromBody] RefreshTokenRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 调用认证服务刷新 Token
|
||||
var response = await authService.RefreshTokenAsync(request, cancellationToken);
|
||||
|
||||
// 2. 返回新的令牌
|
||||
return ApiResponse<TokenResponse>.Ok(response);
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Application.Storage.Abstractions;
|
||||
using TakeoutSaaS.Application.Storage.Contracts;
|
||||
using TakeoutSaaS.Application.Storage.Extensions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
using TakeoutSaaS.MiniApi.Contracts.Requests;
|
||||
|
||||
namespace TakeoutSaaS.MiniApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 小程序文件上传。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/mini/v{version:apiVersion}/files")]
|
||||
public sealed class FilesController(IFileStorageService fileStorageService) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 上传图片或文件。
|
||||
/// </summary>
|
||||
/// <param name="request">表单请求,包含文件与类型。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>上传结果,包含访问链接等信息。</returns>
|
||||
[HttpPost("upload")]
|
||||
[Consumes("multipart/form-data")]
|
||||
[RequestFormLimits(MultipartBodyLengthLimit = 30 * 1024 * 1024)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FileUploadResponse>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FileUploadResponse>), StatusCodes.Status400BadRequest)]
|
||||
public async Task<ApiResponse<FileUploadResponse>> Upload([FromForm] FileUploadFormRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 校验文件有效性
|
||||
if (request.File is null || request.File.Length == 0)
|
||||
{
|
||||
return ApiResponse<FileUploadResponse>.Error(ErrorCodes.BadRequest, "文件不能为空");
|
||||
}
|
||||
// 2. 解析上传类型
|
||||
if (!UploadFileTypeParser.TryParse(request.Type, out var uploadType))
|
||||
{
|
||||
return ApiResponse<FileUploadResponse>.Error(ErrorCodes.BadRequest, "上传类型不合法");
|
||||
}
|
||||
// 3. 提取请求来源
|
||||
var origin = Request.Headers["Origin"].FirstOrDefault() ?? Request.Headers["Referer"].FirstOrDefault();
|
||||
await using var stream = request.File.OpenReadStream();
|
||||
// 4. 调用存储服务执行上传
|
||||
var result = await fileStorageService.UploadAsync(
|
||||
new UploadFileRequest(uploadType, stream, request.File.FileName, request.File.ContentType ?? string.Empty, request.File.Length, origin),
|
||||
cancellationToken);
|
||||
// 5. 返回上传结果
|
||||
return ApiResponse<FileUploadResponse>.Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
using System;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Application.App.Products.Dto;
|
||||
using TakeoutSaaS.Application.App.Products.Queries;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
|
||||
namespace TakeoutSaaS.MiniApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 小程序端菜单查询。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/mini/v{version:apiVersion}/stores/{storeId:long}/menu")]
|
||||
public sealed class MenusController(IMediator mediator) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取门店菜单(含分类与商品详情)。
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoreMenuDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<StoreMenuDto>> GetMenu(long storeId, [FromQuery] DateTime? updatedAfter, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 组装查询
|
||||
var query = new GetStoreMenuQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
UpdatedAfter = updatedAfter
|
||||
};
|
||||
// 2. 拉取菜单
|
||||
var result = await mediator.Send(query, cancellationToken);
|
||||
return ApiResponse<StoreMenuDto>.Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
using System;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Application.App.Stores.Dto;
|
||||
using TakeoutSaaS.Application.App.Stores.Queries;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
|
||||
namespace TakeoutSaaS.MiniApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 小程序端自提档期查询。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/mini/v{version:apiVersion}/stores/{storeId:long}/pickup-slots")]
|
||||
public sealed class PickupSlotsController(IMediator mediator) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取指定日期可用档期。
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<StorePickupSlotDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyList<StorePickupSlotDto>>> GetSlots(long storeId, [FromQuery] DateTime date, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetAvailablePickupSlotsQuery { StoreId = storeId, Date = date }, cancellationToken);
|
||||
return ApiResponse<IReadOnlyList<StorePickupSlotDto>>.Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Application.App.Stores.Dto;
|
||||
using TakeoutSaaS.Application.App.Stores.Queries;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
|
||||
namespace TakeoutSaaS.MiniApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 桌码上下文。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/mini/v{version:apiVersion}/tables")]
|
||||
public sealed class TablesController(IMediator mediator) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 解析桌码并返回上下文。
|
||||
/// </summary>
|
||||
[HttpGet("{code}/context")]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoreTableContextDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<StoreTableContextDto>> GetContext(string code, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetStoreTableContextQuery { TableCode = code }, cancellationToken);
|
||||
return result is null
|
||||
? ApiResponse<StoreTableContextDto>.Error(ErrorCodes.NotFound, "桌码不存在")
|
||||
: ApiResponse<StoreTableContextDto>.Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN dotnet restore src/Api/TakeoutSaaS.MiniApi/TakeoutSaaS.MiniApi.csproj
|
||||
RUN dotnet publish src/Api/TakeoutSaaS.MiniApi/TakeoutSaaS.MiniApi.csproj -c Release -o /app/publish
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
EXPOSE 7701
|
||||
ENV ASPNETCORE_URLS=http://+:7701
|
||||
ENTRYPOINT ["dotnet", "TakeoutSaaS.MiniApi.dll"]
|
||||
@@ -1,176 +0,0 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Cors.Infrastructure;
|
||||
using Microsoft.Extensions.Caching.StackExchangeRedis;
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
using Serilog;
|
||||
using TakeoutSaaS.Application.Messaging.Extensions;
|
||||
using TakeoutSaaS.Application.Sms.Extensions;
|
||||
using TakeoutSaaS.Application.Storage.Extensions;
|
||||
using TakeoutSaaS.Module.Messaging.Extensions;
|
||||
using TakeoutSaaS.Module.Sms.Extensions;
|
||||
using TakeoutSaaS.Module.Storage.Extensions;
|
||||
using TakeoutSaaS.Module.Tenancy.Extensions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Ids;
|
||||
using TakeoutSaaS.Shared.Kernel.Ids;
|
||||
using TakeoutSaaS.Shared.Web.Extensions;
|
||||
using TakeoutSaaS.Shared.Web.Swagger;
|
||||
using System.Reflection;
|
||||
|
||||
// 1. 创建构建器与日志模板
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
const string logTemplate = "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {Level:u3}] [TraceId:{TraceId}] [SpanId:{SpanId}] [Service:{Service}] {SourceContext} {Message:lj}{NewLine}{Exception}";
|
||||
var isDevelopment = builder.Environment.IsDevelopment();
|
||||
|
||||
// 2. 注册雪花 ID 生成器与 Serilog
|
||||
builder.Services.AddSingleton<IIdGenerator>(_ => new SnowflakeIdGenerator());
|
||||
builder.Host.UseSerilog((_, _, configuration) =>
|
||||
{
|
||||
configuration
|
||||
.Enrich.FromLogContext()
|
||||
.Enrich.WithProperty("Service", "MiniApi")
|
||||
.WriteTo.Console(outputTemplate: logTemplate)
|
||||
.WriteTo.File(
|
||||
"logs/mini-api-.log",
|
||||
rollingInterval: RollingInterval.Day,
|
||||
retainedFileCountLimit: 7,
|
||||
shared: true,
|
||||
outputTemplate: logTemplate);
|
||||
});
|
||||
|
||||
// 3. 注册通用 Web 能力,开发环境启用 Swagger
|
||||
builder.Services.AddSharedWebCore();
|
||||
if (isDevelopment)
|
||||
{
|
||||
builder.Services.AddSharedSwagger(options =>
|
||||
{
|
||||
options.Title = "外卖SaaS - 小程序端";
|
||||
options.Description = "小程序 API 文档";
|
||||
options.EnableAuthorization = true;
|
||||
});
|
||||
}
|
||||
builder.Services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
|
||||
|
||||
// 4. 注册 Redis 分布式缓存,供验证码等功能使用
|
||||
var redisConnection = builder.Configuration.GetValue<string>("Redis");
|
||||
builder.Services.AddStackExchangeRedisCache(options =>
|
||||
{
|
||||
options.Configuration = redisConnection;
|
||||
});
|
||||
|
||||
// 4. 注册多租户与业务模块
|
||||
builder.Services.AddTenantResolution(builder.Configuration);
|
||||
builder.Services.AddStorageModule(builder.Configuration);
|
||||
builder.Services.AddStorageApplication();
|
||||
builder.Services.AddSmsModule(builder.Configuration);
|
||||
builder.Services.AddSmsApplication(builder.Configuration);
|
||||
builder.Services.AddMessagingModule(builder.Configuration);
|
||||
builder.Services.AddMessagingApplication();
|
||||
builder.Services.AddHealthChecks();
|
||||
|
||||
// 5. 配置 OpenTelemetry 采集
|
||||
var otelSection = builder.Configuration.GetSection("Otel");
|
||||
var otelEndpoint = otelSection.GetValue<string>("Endpoint");
|
||||
var useConsoleExporter = otelSection.GetValue<bool?>("UseConsoleExporter") ?? builder.Environment.IsDevelopment();
|
||||
builder.Services.AddOpenTelemetry()
|
||||
.ConfigureResource(resource => resource.AddService(
|
||||
serviceName: "TakeoutSaaS.MiniApi",
|
||||
serviceVersion: "1.0.0",
|
||||
serviceInstanceId: Environment.MachineName))
|
||||
.WithTracing(tracing =>
|
||||
{
|
||||
tracing
|
||||
.SetSampler(new ParentBasedSampler(new AlwaysOnSampler()))
|
||||
.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddEntityFrameworkCoreInstrumentation();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(otelEndpoint))
|
||||
{
|
||||
tracing.AddOtlpExporter(exporter =>
|
||||
{
|
||||
exporter.Endpoint = new Uri(otelEndpoint);
|
||||
});
|
||||
}
|
||||
|
||||
if (useConsoleExporter)
|
||||
{
|
||||
tracing.AddConsoleExporter();
|
||||
}
|
||||
})
|
||||
.WithMetrics(metrics =>
|
||||
{
|
||||
metrics
|
||||
.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddRuntimeInstrumentation()
|
||||
.AddPrometheusExporter();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(otelEndpoint))
|
||||
{
|
||||
metrics.AddOtlpExporter(exporter =>
|
||||
{
|
||||
exporter.Endpoint = new Uri(otelEndpoint);
|
||||
});
|
||||
}
|
||||
|
||||
if (useConsoleExporter)
|
||||
{
|
||||
metrics.AddConsoleExporter();
|
||||
}
|
||||
});
|
||||
|
||||
// 6. 配置 CORS
|
||||
var miniOrigins = ResolveCorsOrigins(builder.Configuration, "Cors:Mini");
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddPolicy("MiniApiCors", policy =>
|
||||
{
|
||||
ConfigureCorsPolicy(policy, miniOrigins);
|
||||
});
|
||||
});
|
||||
|
||||
// 7. 构建应用并配置中间件管道
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseCors("MiniApiCors");
|
||||
app.UseTenantResolution();
|
||||
app.UseSharedWebCore();
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSharedSwagger();
|
||||
}
|
||||
|
||||
app.MapHealthChecks("/healthz");
|
||||
app.MapPrometheusScrapingEndpoint();
|
||||
app.MapControllers();
|
||||
app.Run();
|
||||
|
||||
// 8. 解析配置中的 CORS 域名
|
||||
static string[] ResolveCorsOrigins(IConfiguration configuration, string sectionKey)
|
||||
{
|
||||
var origins = configuration.GetSection(sectionKey).Get<string[]>();
|
||||
return origins?
|
||||
.Where(origin => !string.IsNullOrWhiteSpace(origin))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray() ?? [];
|
||||
}
|
||||
|
||||
// 9. 构建 CORS 策略
|
||||
static void ConfigureCorsPolicy(CorsPolicyBuilder policy, string[] origins)
|
||||
{
|
||||
if (origins.Length == 0)
|
||||
{
|
||||
policy.AllowAnyOrigin();
|
||||
}
|
||||
else
|
||||
{
|
||||
policy.WithOrigins(origins)
|
||||
.AllowCredentials();
|
||||
}
|
||||
|
||||
policy
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod();
|
||||
}
|
||||
Binary file not shown.
@@ -1,175 +0,0 @@
|
||||
{
|
||||
"Database": {
|
||||
"DataSources": {
|
||||
"AppDatabase": {
|
||||
"Write": "Host=120.53.222.17;Port=5432;Database=takeout_app_db;Username=app_user;Password=AppUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50",
|
||||
"Reads": [
|
||||
"Host=120.53.222.17;Port=5432;Database=takeout_app_db;Username=app_user;Password=AppUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50"
|
||||
],
|
||||
"CommandTimeoutSeconds": 30,
|
||||
"MaxRetryCount": 3,
|
||||
"MaxRetryDelaySeconds": 5
|
||||
},
|
||||
"IdentityDatabase": {
|
||||
"Write": "Host=120.53.222.17;Port=5432;Database=takeout_identity_db;Username=identity_user;Password=IdentityUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50",
|
||||
"Reads": [
|
||||
"Host=120.53.222.17;Port=5432;Database=takeout_identity_db;Username=identity_user;Password=IdentityUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50"
|
||||
],
|
||||
"CommandTimeoutSeconds": 30,
|
||||
"MaxRetryCount": 3,
|
||||
"MaxRetryDelaySeconds": 5
|
||||
},
|
||||
"DictionaryDatabase": {
|
||||
"Write": "Host=120.53.222.17;Port=5432;Database=takeout_dictionary_db;Username=dictionary_user;Password=DictionaryUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50",
|
||||
"Reads": [
|
||||
"Host=120.53.222.17;Port=5432;Database=takeout_dictionary_db;Username=dictionary_user;Password=DictionaryUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50"
|
||||
],
|
||||
"CommandTimeoutSeconds": 30,
|
||||
"MaxRetryCount": 3,
|
||||
"MaxRetryDelaySeconds": 5
|
||||
},
|
||||
"LogsDatabase": {
|
||||
"Write": "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=logs_user;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50",
|
||||
"Reads": [
|
||||
"Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=logs_user;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50"
|
||||
],
|
||||
"CommandTimeoutSeconds": 30,
|
||||
"MaxRetryCount": 3,
|
||||
"MaxRetryDelaySeconds": 5
|
||||
}
|
||||
}
|
||||
},
|
||||
"Redis": "49.232.6.45:6379,password=MsuMshk112233,abortConnect=false",
|
||||
"Identity": {
|
||||
"Jwt": {
|
||||
"Issuer": "takeout-saas",
|
||||
"Audience": "takeout-saas-clients",
|
||||
"Secret": "psZEx_O##]Mq(W.1$?8Aia*LM03sXGGx",
|
||||
"AccessTokenExpirationMinutes": 120,
|
||||
"RefreshTokenExpirationMinutes": 10080
|
||||
},
|
||||
"LoginRateLimit": {
|
||||
"WindowSeconds": 60,
|
||||
"MaxAttempts": 5
|
||||
},
|
||||
"RefreshTokenStore": {
|
||||
"Prefix": "identity:refresh:"
|
||||
}
|
||||
},
|
||||
"Dictionary": {
|
||||
"Cache": {
|
||||
"SlidingExpiration": "00:30:00"
|
||||
}
|
||||
},
|
||||
"Tenancy": {
|
||||
"TenantIdHeaderName": "X-Tenant-Id",
|
||||
"TenantCodeHeaderName": "X-Tenant-Code",
|
||||
"IgnoredPaths": [
|
||||
"/health"
|
||||
],
|
||||
"RootDomain": ""
|
||||
},
|
||||
"Storage": {
|
||||
"Provider": "TencentCos",
|
||||
"CdnBaseUrl": "https://saas2025-1388556178.cos.ap-beijing.myqcloud.com",
|
||||
"TencentCos": {
|
||||
"SecretId": "AKID52mHageV8ZnnY5NRL3Xq270fAcw2vb5R",
|
||||
"SecretKey": "B8sPitsiEXcS4ScaMvGMErFOL3ZqsgFa",
|
||||
"Region": "ap-beijing",
|
||||
"Bucket": "saas2025-1388556178",
|
||||
"Endpoint": "https://saas2025-1388556178.cos.ap-beijing.myqcloud.com",
|
||||
"CdnBaseUrl": "https://saas2025-1388556178.cos.ap-beijing.myqcloud.com",
|
||||
"UseHttps": true,
|
||||
"ForcePathStyle": false
|
||||
},
|
||||
"QiniuKodo": {
|
||||
"AccessKey": "QINIU_ACCESS_KEY",
|
||||
"SecretKey": "QINIU_SECRET_KEY",
|
||||
"Bucket": "takeout-files",
|
||||
"DownloadDomain": "",
|
||||
"Endpoint": "",
|
||||
"UseHttps": true,
|
||||
"SignedUrlExpirationMinutes": 30
|
||||
},
|
||||
"AliyunOss": {
|
||||
"AccessKeyId": "OSS_ACCESS_KEY_ID",
|
||||
"AccessKeySecret": "OSS_ACCESS_KEY_SECRET",
|
||||
"Endpoint": "https://oss-cn-hangzhou.aliyuncs.com",
|
||||
"Bucket": "takeout-files",
|
||||
"CdnBaseUrl": "",
|
||||
"UseHttps": true
|
||||
},
|
||||
"Security": {
|
||||
"MaxFileSizeBytes": 10485760,
|
||||
"AllowedImageExtensions": [
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".webp",
|
||||
".gif"
|
||||
],
|
||||
"AllowedFileExtensions": [
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".webp",
|
||||
".gif",
|
||||
".pdf"
|
||||
],
|
||||
"DefaultUrlExpirationMinutes": 30,
|
||||
"EnableRefererValidation": true,
|
||||
"AllowedReferers": [
|
||||
"https://admin.example.com",
|
||||
"https://miniapp.example.com"
|
||||
],
|
||||
"AntiLeechTokenSecret": "ReplaceWithARandomToken"
|
||||
}
|
||||
},
|
||||
"Sms": {
|
||||
"Provider": "Tencent",
|
||||
"DefaultSignName": "外卖SaaS",
|
||||
"UseMock": true,
|
||||
"Tencent": {
|
||||
"SecretId": "TENCENT_SMS_SECRET_ID",
|
||||
"SecretKey": "TENCENT_SMS_SECRET_KEY",
|
||||
"SdkAppId": "1400000000",
|
||||
"SignName": "外卖SaaS",
|
||||
"Region": "ap-beijing",
|
||||
"Endpoint": "https://sms.tencentcloudapi.com"
|
||||
},
|
||||
"Aliyun": {
|
||||
"AccessKeyId": "ALIYUN_SMS_AK",
|
||||
"AccessKeySecret": "ALIYUN_SMS_SK",
|
||||
"Endpoint": "dysmsapi.aliyuncs.com",
|
||||
"SignName": "外卖SaaS",
|
||||
"Region": "cn-hangzhou"
|
||||
},
|
||||
"SceneTemplates": {
|
||||
"login": "LOGIN_TEMPLATE_ID",
|
||||
"register": "REGISTER_TEMPLATE_ID",
|
||||
"reset": "RESET_TEMPLATE_ID"
|
||||
},
|
||||
"VerificationCode": {
|
||||
"CodeLength": 6,
|
||||
"ExpireMinutes": 5,
|
||||
"CooldownSeconds": 60,
|
||||
"CachePrefix": "sms:code"
|
||||
}
|
||||
},
|
||||
"RabbitMQ": {
|
||||
"Host": "49.232.6.45",
|
||||
"Port": 5672,
|
||||
"Username": "Admin",
|
||||
"Password": "MsuMshk112233",
|
||||
"VirtualHost": "/",
|
||||
"Exchange": "takeout.events",
|
||||
"ExchangeType": "topic",
|
||||
"PrefetchCount": 20
|
||||
},
|
||||
"Otel": {
|
||||
"Endpoint": "",
|
||||
"Sampling": "ParentBasedAlwaysOn",
|
||||
"UseConsoleExporter": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
{
|
||||
"Database": {
|
||||
"DataSources": {
|
||||
"AppDatabase": {
|
||||
"Write": "Host=120.53.222.17;Port=5432;Database=takeout_app_db;Username=app_user;Password=AppUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50",
|
||||
"Reads": [
|
||||
"Host=120.53.222.17;Port=5432;Database=takeout_app_db;Username=app_user;Password=AppUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50"
|
||||
],
|
||||
"CommandTimeoutSeconds": 30,
|
||||
"MaxRetryCount": 3,
|
||||
"MaxRetryDelaySeconds": 5
|
||||
},
|
||||
"IdentityDatabase": {
|
||||
"Write": "Host=120.53.222.17;Port=5432;Database=takeout_identity_db;Username=identity_user;Password=IdentityUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50",
|
||||
"Reads": [
|
||||
"Host=120.53.222.17;Port=5432;Database=takeout_identity_db;Username=identity_user;Password=IdentityUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50"
|
||||
],
|
||||
"CommandTimeoutSeconds": 30,
|
||||
"MaxRetryCount": 3,
|
||||
"MaxRetryDelaySeconds": 5
|
||||
},
|
||||
"DictionaryDatabase": {
|
||||
"Write": "Host=120.53.222.17;Port=5432;Database=takeout_dictionary_db;Username=dictionary_user;Password=DictionaryUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50",
|
||||
"Reads": [
|
||||
"Host=120.53.222.17;Port=5432;Database=takeout_dictionary_db;Username=dictionary_user;Password=DictionaryUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50"
|
||||
],
|
||||
"CommandTimeoutSeconds": 30,
|
||||
"MaxRetryCount": 3,
|
||||
"MaxRetryDelaySeconds": 5
|
||||
},
|
||||
"LogsDatabase": {
|
||||
"Write": "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=logs_user;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50",
|
||||
"Reads": [
|
||||
"Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=logs_user;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50"
|
||||
],
|
||||
"CommandTimeoutSeconds": 30,
|
||||
"MaxRetryCount": 3,
|
||||
"MaxRetryDelaySeconds": 5
|
||||
}
|
||||
}
|
||||
},
|
||||
"Redis": "49.232.6.45:6379,password=MsuMshk112233,abortConnect=false",
|
||||
"Identity": {
|
||||
"Jwt": {
|
||||
"Issuer": "takeout-saas",
|
||||
"Audience": "takeout-saas-clients",
|
||||
"Secret": "psZEx_O##]Mq(W.1$?8Aia*LM03sXGGx",
|
||||
"AccessTokenExpirationMinutes": 120,
|
||||
"RefreshTokenExpirationMinutes": 10080
|
||||
},
|
||||
"LoginRateLimit": {
|
||||
"WindowSeconds": 60,
|
||||
"MaxAttempts": 5
|
||||
},
|
||||
"RefreshTokenStore": {
|
||||
"Prefix": "identity:refresh:"
|
||||
}
|
||||
},
|
||||
"Dictionary": {
|
||||
"Cache": {
|
||||
"SlidingExpiration": "00:30:00"
|
||||
}
|
||||
},
|
||||
"Tenancy": {
|
||||
"TenantIdHeaderName": "X-Tenant-Id",
|
||||
"TenantCodeHeaderName": "X-Tenant-Code",
|
||||
"IgnoredPaths": [
|
||||
"/health"
|
||||
],
|
||||
"RootDomain": ""
|
||||
},
|
||||
"Storage": {
|
||||
"Provider": "TencentCos",
|
||||
"CdnBaseUrl": "https://saas2025-1388556178.cos.ap-beijing.myqcloud.com",
|
||||
"TencentCos": {
|
||||
"SecretId": "AKID52mHageV8ZnnY5NRL3Xq270fAcw2vb5R",
|
||||
"SecretKey": "B8sPitsiEXcS4ScaMvGMErFOL3ZqsgFa",
|
||||
"Region": "ap-beijing",
|
||||
"Bucket": "saas2025-1388556178",
|
||||
"Endpoint": "https://saas2025-1388556178.cos.ap-beijing.myqcloud.com",
|
||||
"CdnBaseUrl": "https://saas2025-1388556178.cos.ap-beijing.myqcloud.com",
|
||||
"UseHttps": true,
|
||||
"ForcePathStyle": false
|
||||
},
|
||||
"QiniuKodo": {
|
||||
"AccessKey": "QINIU_ACCESS_KEY",
|
||||
"SecretKey": "QINIU_SECRET_KEY",
|
||||
"Bucket": "takeout-files",
|
||||
"DownloadDomain": "",
|
||||
"Endpoint": "",
|
||||
"UseHttps": true,
|
||||
"SignedUrlExpirationMinutes": 30
|
||||
},
|
||||
"AliyunOss": {
|
||||
"AccessKeyId": "OSS_ACCESS_KEY_ID",
|
||||
"AccessKeySecret": "OSS_ACCESS_KEY_SECRET",
|
||||
"Endpoint": "https://oss-cn-hangzhou.aliyuncs.com",
|
||||
"Bucket": "takeout-files",
|
||||
"CdnBaseUrl": "",
|
||||
"UseHttps": true
|
||||
},
|
||||
"Security": {
|
||||
"MaxFileSizeBytes": 10485760,
|
||||
"AllowedImageExtensions": [
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".webp",
|
||||
".gif"
|
||||
],
|
||||
"AllowedFileExtensions": [
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".webp",
|
||||
".gif",
|
||||
".pdf"
|
||||
],
|
||||
"DefaultUrlExpirationMinutes": 30,
|
||||
"EnableRefererValidation": true,
|
||||
"AllowedReferers": [
|
||||
"https://admin.example.com",
|
||||
"https://miniapp.example.com"
|
||||
],
|
||||
"AntiLeechTokenSecret": "ReplaceWithARandomToken"
|
||||
}
|
||||
},
|
||||
"Sms": {
|
||||
"Provider": "Tencent",
|
||||
"DefaultSignName": "外卖SaaS",
|
||||
"UseMock": true,
|
||||
"Tencent": {
|
||||
"SecretId": "TENCENT_SMS_SECRET_ID",
|
||||
"SecretKey": "TENCENT_SMS_SECRET_KEY",
|
||||
"SdkAppId": "1400000000",
|
||||
"SignName": "外卖SaaS",
|
||||
"Region": "ap-beijing",
|
||||
"Endpoint": "https://sms.tencentcloudapi.com"
|
||||
},
|
||||
"Aliyun": {
|
||||
"AccessKeyId": "ALIYUN_SMS_AK",
|
||||
"AccessKeySecret": "ALIYUN_SMS_SK",
|
||||
"Endpoint": "dysmsapi.aliyuncs.com",
|
||||
"SignName": "外卖SaaS",
|
||||
"Region": "cn-hangzhou"
|
||||
},
|
||||
"SceneTemplates": {
|
||||
"login": "LOGIN_TEMPLATE_ID",
|
||||
"register": "REGISTER_TEMPLATE_ID",
|
||||
"reset": "RESET_TEMPLATE_ID"
|
||||
},
|
||||
"VerificationCode": {
|
||||
"CodeLength": 6,
|
||||
"ExpireMinutes": 5,
|
||||
"CooldownSeconds": 60,
|
||||
"CachePrefix": "sms:code"
|
||||
}
|
||||
},
|
||||
"RabbitMQ": {
|
||||
"Host": "49.232.6.45",
|
||||
"Port": 5672,
|
||||
"Username": "Admin",
|
||||
"Password": "MsuMshk112233",
|
||||
"VirtualHost": "/",
|
||||
"Exchange": "takeout.events",
|
||||
"ExchangeType": "topic",
|
||||
"PrefetchCount": 20
|
||||
},
|
||||
"Otel": {
|
||||
"Endpoint": "",
|
||||
"Sampling": "ParentBasedAlwaysOn",
|
||||
"UseConsoleExporter": true
|
||||
}
|
||||
}
|
||||
|
||||
72
src/Api/TakeoutSaaS.TenantApi/Controllers/AuthController.cs
Normal file
72
src/Api/TakeoutSaaS.TenantApi/Controllers/AuthController.cs
Normal file
@@ -0,0 +1,72 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 租户管理端登录认证。
|
||||
/// </summary>
|
||||
/// <remarks>仅允许租户管理员登录获取 Token。</remarks>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/tenant/v{version:apiVersion}/auth")]
|
||||
public sealed class AuthController(IAdminAuthService authService) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 账号密码登录。
|
||||
/// </summary>
|
||||
/// <param name="request">登录请求。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>访问令牌与刷新令牌。</returns>
|
||||
[HttpPost("login")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(ApiResponse<TokenResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<TokenResponse>> Login([FromBody] AdminLoginRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 调用认证服务登录
|
||||
var response = await authService.LoginAsync(request, cancellationToken);
|
||||
|
||||
// 2. 返回令牌
|
||||
return ApiResponse<TokenResponse>.Ok(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 简化登录:支持使用“账号@手机号”自动解析租户后登录。
|
||||
/// </summary>
|
||||
/// <param name="request">登录请求。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>访问令牌与刷新令牌。</returns>
|
||||
[HttpPost("login/simple")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(ApiResponse<TokenResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<TokenResponse>> LoginSimple([FromBody] AdminLoginRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 调用认证服务完成简化登录
|
||||
var response = await authService.LoginSimpleAsync(request, cancellationToken);
|
||||
|
||||
// 2. 返回令牌
|
||||
return ApiResponse<TokenResponse>.Ok(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 刷新 Token。
|
||||
/// </summary>
|
||||
/// <param name="request">刷新令牌请求。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>新的访问令牌与刷新令牌。</returns>
|
||||
[HttpPost("refresh")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(ApiResponse<TokenResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<TokenResponse>> RefreshToken([FromBody] RefreshTokenRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 刷新 Token
|
||||
var response = await authService.RefreshTokenAsync(request, cancellationToken);
|
||||
|
||||
// 2. 返回新的令牌
|
||||
return ApiResponse<TokenResponse>.Ok(response);
|
||||
}
|
||||
}
|
||||
@@ -3,26 +3,26 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
|
||||
namespace TakeoutSaaS.MiniApi.Controllers;
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 小程序端 - 健康检查。
|
||||
/// 租户管理端 - 健康检查。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[AllowAnonymous]
|
||||
[Route("api/mini/v{version:apiVersion}/[controller]")]
|
||||
public class HealthController : BaseApiController
|
||||
[Route("api/tenant/v{version:apiVersion}/[controller]")]
|
||||
public sealed class HealthController : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取服务健康状态。
|
||||
/// </summary>
|
||||
/// <returns>健康状态</returns>
|
||||
/// <returns>健康状态。</returns>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public ApiResponse<object> Get()
|
||||
{
|
||||
// 1. 构造健康状态
|
||||
var payload = new { status = "OK", service = "MiniApi", time = DateTime.UtcNow };
|
||||
var payload = new { status = "OK", service = "TenantApi", time = DateTime.UtcNow };
|
||||
|
||||
// 2. 返回健康响应
|
||||
return ApiResponse<object>.Ok(payload);
|
||||
@@ -7,20 +7,18 @@ using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
using TakeoutSaaS.Shared.Web.Security;
|
||||
|
||||
namespace TakeoutSaaS.MiniApi.Controllers;
|
||||
namespace TakeoutSaaS.TenantApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 当前用户信息
|
||||
/// 当前租户管理员信息。
|
||||
/// </summary>
|
||||
/// <remarks>提供小程序端当前用户档案查询。</remarks>
|
||||
/// <param name="authService">小程序认证服务</param>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/mini/v{version:apiVersion}/me")]
|
||||
public sealed class MeController(IMiniAuthService authService) : BaseApiController
|
||||
[Authorize(Roles = "tenant-admin")]
|
||||
[Route("api/tenant/v{version:apiVersion}/me")]
|
||||
public sealed class MeController(IAdminAuthService authService) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取用户档案
|
||||
/// 获取当前用户档案。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>当前用户档案信息。</returns>
|
||||
12
src/Api/TakeoutSaaS.TenantApi/Dockerfile
Normal file
12
src/Api/TakeoutSaaS.TenantApi/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN dotnet restore src/Api/TakeoutSaaS.TenantApi/TakeoutSaaS.TenantApi.csproj
|
||||
RUN dotnet publish src/Api/TakeoutSaaS.TenantApi/TakeoutSaaS.TenantApi.csproj -c Release -o /app/publish
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
EXPOSE 7903
|
||||
ENV ASPNETCORE_URLS=http://+:7903
|
||||
ENTRYPOINT ["dotnet", "TakeoutSaaS.TenantApi.dll"]
|
||||
@@ -1,13 +1,21 @@
|
||||
using Asp.Versioning;
|
||||
using Asp.Versioning.ApiExplorer;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
using TakeoutSaaS.Shared.Web.Filters;
|
||||
using TakeoutSaaS.Shared.Web.Security;
|
||||
using Microsoft.AspNetCore.Cors.Infrastructure;
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
using Serilog;
|
||||
using TakeoutSaaS.Application.App.Extensions;
|
||||
using TakeoutSaaS.Application.Identity.Extensions;
|
||||
using TakeoutSaaS.Infrastructure.App.Extensions;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Extensions;
|
||||
using TakeoutSaaS.Module.Dictionary.Extensions;
|
||||
using TakeoutSaaS.Module.Authorization.Extensions;
|
||||
using TakeoutSaaS.Module.Tenancy.Extensions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Ids;
|
||||
using TakeoutSaaS.Shared.Kernel.Ids;
|
||||
using TakeoutSaaS.Shared.Web.Extensions;
|
||||
using TakeoutSaaS.Shared.Web.Swagger;
|
||||
|
||||
@@ -16,48 +24,83 @@ var builder = WebApplication.CreateBuilder(args);
|
||||
const string logTemplate = "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {Level:u3}] [TraceId:{TraceId}] [SpanId:{SpanId}] [Service:{Service}] {SourceContext} {Message:lj}{NewLine}{Exception}";
|
||||
var isDevelopment = builder.Environment.IsDevelopment();
|
||||
|
||||
// 2. 注册雪花 ID 生成器与 Serilog
|
||||
builder.Services.AddSingleton<IIdGenerator>(_ => new SnowflakeIdGenerator());
|
||||
// 2. 配置 Serilog
|
||||
builder.Host.UseSerilog((_, _, configuration) =>
|
||||
{
|
||||
configuration
|
||||
.Enrich.FromLogContext()
|
||||
.Enrich.WithProperty("Service", "UserApi")
|
||||
.Enrich.WithProperty("Service", "TenantApi")
|
||||
.WriteTo.Console(outputTemplate: logTemplate)
|
||||
.WriteTo.File(
|
||||
"logs/user-api-.log",
|
||||
"logs/tenant-api-.log",
|
||||
rollingInterval: RollingInterval.Day,
|
||||
retainedFileCountLimit: 7,
|
||||
shared: true,
|
||||
outputTemplate: logTemplate);
|
||||
});
|
||||
|
||||
// 3. 注册通用 Web 能力,开发环境启用 Swagger
|
||||
builder.Services.AddSharedWebCore();
|
||||
// 3. 注册 Web Core(控制器、API 版本化、模型验证、雪花 ID 序列化等)
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddScoped<ICurrentUserAccessor, HttpContextCurrentUserAccessor>();
|
||||
builder.Services
|
||||
.AddControllers(options =>
|
||||
{
|
||||
options.Filters.Add<ValidateModelAttribute>();
|
||||
options.Filters.Add<ApiResponseResultFilter>();
|
||||
})
|
||||
.AddJsonOptions(options =>
|
||||
{
|
||||
options.JsonSerializerOptions.Converters.Add(new SnowflakeIdJsonConverter());
|
||||
options.JsonSerializerOptions.Converters.Add(new NullableSnowflakeIdJsonConverter());
|
||||
});
|
||||
builder.Services.Configure<ApiBehaviorOptions>(options =>
|
||||
{
|
||||
options.SuppressModelStateInvalidFilter = true;
|
||||
});
|
||||
var apiVersioningBuilder = builder.Services.AddApiVersioning(options =>
|
||||
{
|
||||
options.AssumeDefaultVersionWhenUnspecified = true;
|
||||
options.DefaultApiVersion = new ApiVersion(1, 0);
|
||||
options.ReportApiVersions = true;
|
||||
});
|
||||
apiVersioningBuilder.AddApiExplorer(setup =>
|
||||
{
|
||||
setup.GroupNameFormat = "'v'VVV";
|
||||
setup.SubstituteApiVersionInUrl = true;
|
||||
});
|
||||
|
||||
// 4. (空行后) 开发环境启用 Swagger(含 JWT 鉴权按钮)
|
||||
if (isDevelopment)
|
||||
{
|
||||
builder.Services.AddSharedSwagger(options =>
|
||||
{
|
||||
options.Title = "外卖SaaS - 用户端";
|
||||
options.Description = "C 端用户 API 文档";
|
||||
options.Title = "外卖SaaS - 租户管理端";
|
||||
options.Description = "租户管理员 API 文档";
|
||||
options.EnableAuthorization = true;
|
||||
});
|
||||
}
|
||||
|
||||
// 4. 注册多租户与健康检查
|
||||
// 5. 注册多租户解析、鉴权授权与权限策略
|
||||
builder.Services.AddTenantResolution(builder.Configuration);
|
||||
builder.Services.AddJwtAuthentication(builder.Configuration);
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddDictionaryModule(builder.Configuration);
|
||||
builder.Services.AddPermissionAuthorization();
|
||||
builder.Services.AddHealthChecks();
|
||||
|
||||
// 5. 配置 OpenTelemetry 采集
|
||||
// 6. 注册应用层与基础设施(仅租户侧所需)
|
||||
builder.Services.AddAppApplication();
|
||||
builder.Services.AddIdentityApplication(enableMiniSupport: false);
|
||||
builder.Services.AddAppInfrastructure(builder.Configuration);
|
||||
builder.Services.AddIdentityInfrastructure(builder.Configuration, enableMiniFeatures: false, enableAdminSeed: false);
|
||||
|
||||
// 7. 配置 OpenTelemetry 采集
|
||||
var otelSection = builder.Configuration.GetSection("Otel");
|
||||
var otelEndpoint = otelSection.GetValue<string>("Endpoint");
|
||||
var useConsoleExporter = otelSection.GetValue<bool?>("UseConsoleExporter") ?? builder.Environment.IsDevelopment();
|
||||
builder.Services.AddOpenTelemetry()
|
||||
.ConfigureResource(resource => resource.AddService(
|
||||
serviceName: "TakeoutSaaS.UserApi",
|
||||
serviceName: "TakeoutSaaS.TenantApi",
|
||||
serviceVersion: "1.0.0",
|
||||
serviceInstanceId: Environment.MachineName))
|
||||
.WithTracing(tracing =>
|
||||
@@ -67,7 +110,7 @@ builder.Services.AddOpenTelemetry()
|
||||
.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddEntityFrameworkCoreInstrumentation();
|
||||
|
||||
// 1. (空行后) 配置 OTLP 导出
|
||||
if (!string.IsNullOrWhiteSpace(otelEndpoint))
|
||||
{
|
||||
tracing.AddOtlpExporter(exporter =>
|
||||
@@ -75,7 +118,7 @@ builder.Services.AddOpenTelemetry()
|
||||
exporter.Endpoint = new Uri(otelEndpoint);
|
||||
});
|
||||
}
|
||||
|
||||
// 2. (空行后) 配置 Console 导出
|
||||
if (useConsoleExporter)
|
||||
{
|
||||
tracing.AddConsoleExporter();
|
||||
@@ -88,7 +131,7 @@ builder.Services.AddOpenTelemetry()
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddRuntimeInstrumentation()
|
||||
.AddPrometheusExporter();
|
||||
|
||||
// 1. (空行后) 配置 OTLP 导出
|
||||
if (!string.IsNullOrWhiteSpace(otelEndpoint))
|
||||
{
|
||||
metrics.AddOtlpExporter(exporter =>
|
||||
@@ -96,42 +139,50 @@ builder.Services.AddOpenTelemetry()
|
||||
exporter.Endpoint = new Uri(otelEndpoint);
|
||||
});
|
||||
}
|
||||
|
||||
// 2. (空行后) 配置 Console 导出
|
||||
if (useConsoleExporter)
|
||||
{
|
||||
metrics.AddConsoleExporter();
|
||||
}
|
||||
});
|
||||
|
||||
// 6. 配置 CORS
|
||||
var userOrigins = ResolveCorsOrigins(builder.Configuration, "Cors:User");
|
||||
// 8. 配置 CORS
|
||||
var tenantOrigins = ResolveCorsOrigins(builder.Configuration, "Cors:Tenant");
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddPolicy("UserApiCors", policy =>
|
||||
options.AddPolicy("TenantApiCors", policy =>
|
||||
{
|
||||
ConfigureCorsPolicy(policy, userOrigins);
|
||||
ConfigureCorsPolicy(policy, tenantOrigins);
|
||||
});
|
||||
});
|
||||
|
||||
// 7. 构建应用并配置中间件管道
|
||||
// 9. 构建应用并配置中间件管道
|
||||
var app = builder.Build();
|
||||
app.UseCors("TenantApiCors");
|
||||
|
||||
app.UseCors("UserApiCors");
|
||||
app.UseTenantResolution();
|
||||
app.UseSharedWebCore();
|
||||
// 1. (空行后) 先完成身份认证,确保租户解析优先使用 Token Claim
|
||||
app.UseAuthentication();
|
||||
|
||||
// 2. (空行后) 解析并注入租户上下文(已认证请求不允许 Header 覆盖)
|
||||
app.UseTenantResolution();
|
||||
|
||||
// 3. (空行后) 通用 Web Core 中间件(异常、ProblemDetails、日志等)
|
||||
app.UseSharedWebCore();
|
||||
|
||||
// 4. (空行后) 执行授权
|
||||
app.UseAuthorization();
|
||||
|
||||
// 5. (空行后) 开发环境启用 Swagger
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSharedSwagger();
|
||||
}
|
||||
|
||||
app.MapHealthChecks("/healthz");
|
||||
app.MapPrometheusScrapingEndpoint();
|
||||
app.MapControllers();
|
||||
app.Run();
|
||||
|
||||
// 8. 解析配置中的 CORS 域名
|
||||
// 10. 解析配置中的 CORS 域名
|
||||
static string[] ResolveCorsOrigins(IConfiguration configuration, string sectionKey)
|
||||
{
|
||||
var origins = configuration.GetSection(sectionKey).Get<string[]>();
|
||||
@@ -141,7 +192,7 @@ static string[] ResolveCorsOrigins(IConfiguration configuration, string sectionK
|
||||
.ToArray() ?? [];
|
||||
}
|
||||
|
||||
// 9. 构建 CORS 策略
|
||||
// 10. 构建 CORS 策略
|
||||
static void ConfigureCorsPolicy(CorsPolicyBuilder policy, string[] origins)
|
||||
{
|
||||
if (origins.Length == 0)
|
||||
@@ -153,7 +204,7 @@ static void ConfigureCorsPolicy(CorsPolicyBuilder policy, string[] origins)
|
||||
policy.WithOrigins(origins)
|
||||
.AllowCredentials();
|
||||
}
|
||||
|
||||
// 1. (空行后) 放行通用 Header 与 Method
|
||||
policy
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod();
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"profiles": {
|
||||
"TakeoutSaaS.MiniApi": {
|
||||
"TakeoutSaaS.TenantApi": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "http://localhost:2681"
|
||||
"applicationUrl": "http://localhost:2683"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
30
src/Api/TakeoutSaaS.TenantApi/TakeoutSaaS.TenantApi.csproj
Normal file
30
src/Api/TakeoutSaaS.TenantApi/TakeoutSaaS.TenantApi.csproj
Normal file
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.0" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.14.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.14.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.14.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.EntityFrameworkCore" Version="1.14.0-beta.2" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.14.0" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.14.0" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.14.0" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.14.0-beta.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\TakeoutSaaS.BuildingBlocks\src\Core\TakeoutSaaS.Shared.Web\TakeoutSaaS.Shared.Web.csproj" />
|
||||
<ProjectReference Include="..\..\..\TakeoutSaaS.BuildingBlocks\src\Core\TakeoutSaaS.Shared.Kernel\TakeoutSaaS.Shared.Kernel.csproj" />
|
||||
<ProjectReference Include="..\..\Application\TakeoutSaaS.Application\TakeoutSaaS.Application.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\TakeoutSaaS.Infrastructure\TakeoutSaaS.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\..\Modules\TakeoutSaaS.Module.Authorization\TakeoutSaaS.Module.Authorization.csproj" />
|
||||
<ProjectReference Include="..\..\Modules\TakeoutSaaS.Module.Tenancy\TakeoutSaaS.Module.Tenancy.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -19,15 +19,6 @@
|
||||
"MaxRetryCount": 3,
|
||||
"MaxRetryDelaySeconds": 5
|
||||
},
|
||||
"DictionaryDatabase": {
|
||||
"Write": "Host=120.53.222.17;Port=5432;Database=takeout_dictionary_db;Username=dictionary_user;Password=DictionaryUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50",
|
||||
"Reads": [
|
||||
"Host=120.53.222.17;Port=5432;Database=takeout_dictionary_db;Username=dictionary_user;Password=DictionaryUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50"
|
||||
],
|
||||
"CommandTimeoutSeconds": 30,
|
||||
"MaxRetryCount": 3,
|
||||
"MaxRetryDelaySeconds": 5
|
||||
},
|
||||
"LogsDatabase": {
|
||||
"Write": "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=logs_user;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50",
|
||||
"Reads": [
|
||||
@@ -39,7 +30,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Redis": "49.232.6.45:6379,password=MsuMshk112233,abortConnect=false",
|
||||
"ConnectionStrings": {
|
||||
"Redis": "49.232.6.45:6379,password=MsuMshk112233,abortConnect=false"
|
||||
},
|
||||
"Identity": {
|
||||
"Jwt": {
|
||||
"Issuer": "takeout-saas",
|
||||
@@ -56,23 +49,21 @@
|
||||
"Prefix": "identity:refresh:"
|
||||
}
|
||||
},
|
||||
"Dictionary": {
|
||||
"Cache": {
|
||||
"SlidingExpiration": "00:30:00"
|
||||
}
|
||||
},
|
||||
"Tenancy": {
|
||||
"TenantIdHeaderName": "X-Tenant-Id",
|
||||
"TenantCodeHeaderName": "X-Tenant-Code",
|
||||
"IgnoredPaths": [
|
||||
"/health"
|
||||
"/health",
|
||||
"/healthz"
|
||||
],
|
||||
"RootDomain": ""
|
||||
},
|
||||
"Cors": {
|
||||
"Tenant": []
|
||||
},
|
||||
"Otel": {
|
||||
"Endpoint": "",
|
||||
"Sampling": "ParentBasedAlwaysOn",
|
||||
"UseConsoleExporter": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,15 +19,6 @@
|
||||
"MaxRetryCount": 3,
|
||||
"MaxRetryDelaySeconds": 5
|
||||
},
|
||||
"DictionaryDatabase": {
|
||||
"Write": "Host=120.53.222.17;Port=5432;Database=takeout_dictionary_db;Username=dictionary_user;Password=DictionaryUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50",
|
||||
"Reads": [
|
||||
"Host=120.53.222.17;Port=5432;Database=takeout_dictionary_db;Username=dictionary_user;Password=DictionaryUser112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50"
|
||||
],
|
||||
"CommandTimeoutSeconds": 30,
|
||||
"MaxRetryCount": 3,
|
||||
"MaxRetryDelaySeconds": 5
|
||||
},
|
||||
"LogsDatabase": {
|
||||
"Write": "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=logs_user;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50",
|
||||
"Reads": [
|
||||
@@ -39,7 +30,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Redis": "49.232.6.45:6379,password=MsuMshk112233,abortConnect=false",
|
||||
"ConnectionStrings": {
|
||||
"Redis": "49.232.6.45:6379,password=MsuMshk112233,abortConnect=false"
|
||||
},
|
||||
"Identity": {
|
||||
"Jwt": {
|
||||
"Issuer": "takeout-saas",
|
||||
@@ -56,23 +49,21 @@
|
||||
"Prefix": "identity:refresh:"
|
||||
}
|
||||
},
|
||||
"Dictionary": {
|
||||
"Cache": {
|
||||
"SlidingExpiration": "00:30:00"
|
||||
}
|
||||
},
|
||||
"Tenancy": {
|
||||
"TenantIdHeaderName": "X-Tenant-Id",
|
||||
"TenantCodeHeaderName": "X-Tenant-Code",
|
||||
"IgnoredPaths": [
|
||||
"/health"
|
||||
"/health",
|
||||
"/healthz"
|
||||
],
|
||||
"RootDomain": ""
|
||||
},
|
||||
"Cors": {
|
||||
"Tenant": []
|
||||
},
|
||||
"Otel": {
|
||||
"Endpoint": "",
|
||||
"Sampling": "ParentBasedAlwaysOn",
|
||||
"UseConsoleExporter": true
|
||||
"UseConsoleExporter": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
using TakeoutSaaS.Application.Dictionary.Contracts;
|
||||
using TakeoutSaaS.Application.Dictionary.Models;
|
||||
using TakeoutSaaS.Application.Dictionary.Services;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
|
||||
namespace TakeoutSaaS.UserApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 字典查询接口。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/user/v{version:apiVersion}/dictionary")]
|
||||
public sealed class DictionaryController(DictionaryQueryService queryService) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取指定字典分组的合并结果。
|
||||
/// </summary>
|
||||
[HttpGet("{code}")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<DictionaryItemDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyList<DictionaryItemDto>>> GetByCode(string code, CancellationToken cancellationToken)
|
||||
{
|
||||
Response.Headers[HeaderNames.CacheControl] = "max-age=1800";
|
||||
var result = await queryService.GetMergedDictionaryAsync(code, cancellationToken);
|
||||
return ApiResponse<IReadOnlyList<DictionaryItemDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量获取字典分组。
|
||||
/// </summary>
|
||||
[HttpPost("batch")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyDictionary<string, IReadOnlyList<DictionaryItemDto>>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyDictionary<string, IReadOnlyList<DictionaryItemDto>>>> BatchGet(
|
||||
[FromBody] DictionaryBatchQueryRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.Codes.Count > 20)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "最多支持 20 个字典编码");
|
||||
}
|
||||
|
||||
var result = await queryService.BatchGetDictionariesAsync(request.Codes, cancellationToken);
|
||||
return ApiResponse<IReadOnlyDictionary<string, IReadOnlyList<DictionaryItemDto>>>.Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Web.Api;
|
||||
|
||||
namespace TakeoutSaaS.UserApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 用户端 - 健康检查。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[AllowAnonymous]
|
||||
[Route("api/user/v{version:apiVersion}/[controller]")]
|
||||
public class HealthController : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取服务健康状态。
|
||||
/// </summary>
|
||||
/// <returns>健康状态</returns>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public ApiResponse<object> Get()
|
||||
{
|
||||
// 1. 构造健康状态
|
||||
var payload = new { status = "OK", service = "UserApi", time = DateTime.UtcNow };
|
||||
|
||||
// 2. 返回健康响应
|
||||
return ApiResponse<object>.Ok(payload);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN dotnet restore src/Api/TakeoutSaaS.UserApi/TakeoutSaaS.UserApi.csproj
|
||||
RUN dotnet publish src/Api/TakeoutSaaS.UserApi/TakeoutSaaS.UserApi.csproj -c Release -o /app/publish
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
EXPOSE 7901
|
||||
ENV ASPNETCORE_URLS=http://+:7901
|
||||
ENTRYPOINT ["dotnet", "TakeoutSaaS.UserApi.dll"]
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"profiles": {
|
||||
"TakeoutSaaS.UserApi": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "http://localhost:2682"
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -1,14 +1,11 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Merchants.Queries;
|
||||
using TakeoutSaaS.Application.Identity;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Domain.Merchants.Repositories;
|
||||
using TakeoutSaaS.Domain.Merchants.Services;
|
||||
using TakeoutSaaS.Domain.Stores.Repositories;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Merchants.Handlers;
|
||||
@@ -21,31 +18,19 @@ public sealed class ExportMerchantPdfQueryHandler(
|
||||
IStoreRepository storeRepository,
|
||||
ITenantRepository tenantRepository,
|
||||
IMerchantExportService exportService,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
IAdminAuthService adminAuthService)
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<ExportMerchantPdfQuery, byte[]>
|
||||
{
|
||||
public async Task<byte[]> Handle(ExportMerchantPdfQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
|
||||
|
||||
var merchant = isSuperAdmin
|
||||
? await merchantRepository.FindByIdAsync(request.MerchantId, cancellationToken)
|
||||
: await merchantRepository.FindByIdAsync(request.MerchantId, currentTenantId, cancellationToken);
|
||||
var merchant = await merchantRepository.FindByIdAsync(request.MerchantId, currentTenantId, cancellationToken);
|
||||
|
||||
if (merchant == null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "商户不存在");
|
||||
}
|
||||
|
||||
if (!isSuperAdmin && merchant.TenantId != currentTenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "禁止导出其他租户商户");
|
||||
}
|
||||
|
||||
var stores = await storeRepository.GetByMerchantIdAsync(merchant.Id, merchant.TenantId, cancellationToken);
|
||||
var auditLogs = await merchantRepository.GetAuditLogsAsync(merchant.Id, merchant.TenantId, cancellationToken);
|
||||
var tenant = await tenantRepository.FindByIdAsync(merchant.TenantId, cancellationToken);
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Merchants.Dto;
|
||||
using TakeoutSaaS.Application.App.Merchants.Queries;
|
||||
using TakeoutSaaS.Application.Identity;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Domain.Merchants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Merchants.Handlers;
|
||||
@@ -16,9 +13,7 @@ namespace TakeoutSaaS.Application.App.Merchants.Handlers;
|
||||
/// </summary>
|
||||
public sealed class GetMerchantAuditHistoryQueryHandler(
|
||||
IMerchantRepository merchantRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
IAdminAuthService adminAuthService)
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetMerchantAuditHistoryQuery, IReadOnlyList<MerchantAuditLogDto>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
@@ -27,23 +22,13 @@ public sealed class GetMerchantAuditHistoryQueryHandler(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
|
||||
|
||||
var merchant = isSuperAdmin
|
||||
? await merchantRepository.FindByIdAsync(request.MerchantId, cancellationToken)
|
||||
: await merchantRepository.FindByIdAsync(request.MerchantId, currentTenantId, cancellationToken);
|
||||
var merchant = await merchantRepository.FindByIdAsync(request.MerchantId, currentTenantId, cancellationToken);
|
||||
|
||||
if (merchant == null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "商户不存在");
|
||||
}
|
||||
|
||||
if (!isSuperAdmin && merchant.TenantId != currentTenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "禁止访问其他租户的商户审核历史");
|
||||
}
|
||||
|
||||
var logs = await merchantRepository.GetAuditLogsAsync(merchant.Id, merchant.TenantId, cancellationToken);
|
||||
return logs.Select(MerchantMapping.ToDto).ToList();
|
||||
}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Merchants.Dto;
|
||||
using TakeoutSaaS.Application.App.Merchants.Queries;
|
||||
using TakeoutSaaS.Application.Identity;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Domain.Merchants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Merchants.Handlers;
|
||||
@@ -16,9 +13,7 @@ namespace TakeoutSaaS.Application.App.Merchants.Handlers;
|
||||
/// </summary>
|
||||
public sealed class GetMerchantChangeHistoryQueryHandler(
|
||||
IMerchantRepository merchantRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
IAdminAuthService adminAuthService)
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetMerchantChangeHistoryQuery, IReadOnlyList<MerchantChangeLogDto>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
@@ -27,23 +22,13 @@ public sealed class GetMerchantChangeHistoryQueryHandler(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
|
||||
|
||||
var merchant = isSuperAdmin
|
||||
? await merchantRepository.FindByIdAsync(request.MerchantId, cancellationToken)
|
||||
: await merchantRepository.FindByIdAsync(request.MerchantId, currentTenantId, cancellationToken);
|
||||
var merchant = await merchantRepository.FindByIdAsync(request.MerchantId, currentTenantId, cancellationToken);
|
||||
|
||||
if (merchant == null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "商户不存在");
|
||||
}
|
||||
|
||||
if (!isSuperAdmin && merchant.TenantId != currentTenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "禁止访问其他租户的商户变更历史");
|
||||
}
|
||||
|
||||
var logs = await merchantRepository.GetChangeLogsAsync(merchant.Id, merchant.TenantId, request.FieldName, cancellationToken);
|
||||
return logs.Select(MerchantMapping.ToDto).ToList();
|
||||
}
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Merchants.Dto;
|
||||
using TakeoutSaaS.Application.App.Merchants.Queries;
|
||||
using TakeoutSaaS.Application.Identity;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Domain.Merchants.Repositories;
|
||||
using TakeoutSaaS.Domain.Stores.Repositories;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Merchants.Handlers;
|
||||
@@ -20,9 +17,7 @@ public sealed class GetMerchantDetailQueryHandler(
|
||||
IMerchantRepository merchantRepository,
|
||||
IStoreRepository storeRepository,
|
||||
ITenantRepository tenantRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
IAdminAuthService adminAuthService)
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetMerchantDetailQuery, MerchantDetailDto>
|
||||
{
|
||||
/// <summary>
|
||||
@@ -33,25 +28,15 @@ public sealed class GetMerchantDetailQueryHandler(
|
||||
/// <returns>商户详情 DTO。</returns>
|
||||
public async Task<MerchantDetailDto> Handle(GetMerchantDetailQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取权限与商户
|
||||
// 1. 获取当前租户并查询商户
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
|
||||
|
||||
var merchant = isSuperAdmin
|
||||
? await merchantRepository.FindByIdAsync(request.MerchantId, cancellationToken)
|
||||
: await merchantRepository.FindByIdAsync(request.MerchantId, currentTenantId, cancellationToken);
|
||||
var merchant = await merchantRepository.FindByIdAsync(request.MerchantId, currentTenantId, cancellationToken);
|
||||
|
||||
if (merchant == null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "商户不存在");
|
||||
}
|
||||
|
||||
if (!isSuperAdmin && merchant.TenantId != currentTenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "禁止访问其他租户的商户");
|
||||
}
|
||||
|
||||
// 2. 查询门店与租户信息
|
||||
var stores = await storeRepository.GetByMerchantIdAsync(merchant.Id, merchant.TenantId, cancellationToken);
|
||||
var storeDtos = MerchantMapping.ToStoreDtos(stores);
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Merchants.Dto;
|
||||
using TakeoutSaaS.Application.App.Merchants.Queries;
|
||||
using TakeoutSaaS.Application.Identity;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Domain.Merchants.Repositories;
|
||||
using TakeoutSaaS.Domain.Stores.Repositories;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Merchants.Handlers;
|
||||
@@ -21,9 +18,7 @@ public sealed class GetMerchantListQueryHandler(
|
||||
IMerchantRepository merchantRepository,
|
||||
IStoreRepository storeRepository,
|
||||
ITenantRepository tenantRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
IAdminAuthService adminAuthService)
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetMerchantListQuery, PagedResult<MerchantListItemDto>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
@@ -31,17 +26,14 @@ public sealed class GetMerchantListQueryHandler(
|
||||
GetMerchantListQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 校验跨租户访问权限
|
||||
// 1. 获取当前租户并校验跨租户访问
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
|
||||
|
||||
if (!isSuperAdmin && request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
|
||||
if (request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户查询商户");
|
||||
}
|
||||
|
||||
var effectiveTenantId = isSuperAdmin ? request.TenantId : currentTenantId;
|
||||
var effectiveTenantId = currentTenantId;
|
||||
|
||||
// 2. 查询商户列表
|
||||
var merchants = await merchantRepository.SearchAsync(
|
||||
|
||||
@@ -2,8 +2,6 @@ using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TakeoutSaaS.Application.App.Merchants.Commands;
|
||||
using TakeoutSaaS.Application.App.Merchants.Dto;
|
||||
using TakeoutSaaS.Application.Identity;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Domain.Merchants.Entities;
|
||||
using TakeoutSaaS.Domain.Merchants.Enums;
|
||||
using TakeoutSaaS.Domain.Merchants.Repositories;
|
||||
@@ -25,7 +23,6 @@ public sealed class UpdateMerchantCommandHandler(
|
||||
ITenantRepository tenantRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
IAdminAuthService adminAuthService,
|
||||
ILogger<UpdateMerchantCommandHandler> logger)
|
||||
: IRequestHandler<UpdateMerchantCommand, UpdateMerchantResultDto?>
|
||||
{
|
||||
@@ -39,24 +36,15 @@ public sealed class UpdateMerchantCommandHandler(
|
||||
|
||||
// 1. 获取操作者权限
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
|
||||
|
||||
// 2. 读取商户信息
|
||||
var merchant = isSuperAdmin
|
||||
? await merchantRepository.FindByIdAsync(request.MerchantId, cancellationToken)
|
||||
: await merchantRepository.FindByIdAsync(request.MerchantId, currentTenantId, cancellationToken);
|
||||
var merchant = await merchantRepository.FindByIdAsync(request.MerchantId, currentTenantId, cancellationToken);
|
||||
|
||||
if (merchant == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isSuperAdmin && merchant.TenantId != currentTenantId)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3. 规范化输入
|
||||
var name = NormalizeRequired(request.Name, "商户名称");
|
||||
var contactPhone = NormalizeRequired(request.ContactPhone, "联系电话");
|
||||
|
||||
@@ -1,45 +1,12 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Stores;
|
||||
|
||||
internal static class StoreTenantAccess
|
||||
{
|
||||
private const string PermissionClaimType = "permission";
|
||||
private const string ViewAllStoresPermission = "store:read:all";
|
||||
private static readonly string[] PlatformRoleCodes =
|
||||
{
|
||||
"super-admin",
|
||||
"SUPER_ADMIN",
|
||||
"PlatformAdmin",
|
||||
"platform-admin"
|
||||
};
|
||||
|
||||
public static bool ShouldIgnoreTenantFilter(IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
var httpContext = httpContextAccessor.HttpContext;
|
||||
if (httpContext == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var user = httpContext.User;
|
||||
if (user?.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (PlatformRoleCodes.Any(user.IsInRole))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var permissions = user.FindAll(PermissionClaimType)
|
||||
.Select(c => c.Value?.Trim())
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
return permissions.Contains(ViewAllStoresPermission);
|
||||
// 1. 租户管理端不允许跨租户访问门店数据
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Subscriptions;
|
||||
|
||||
internal static class SubscriptionTenantAccess
|
||||
{
|
||||
private const string PermissionClaimType = "permission";
|
||||
private const string PlatformAdminRole = "PlatformAdmin";
|
||||
|
||||
public static bool ShouldIgnoreTenantFilter(IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
var httpContext = httpContextAccessor.HttpContext;
|
||||
@@ -16,24 +12,7 @@ internal static class SubscriptionTenantAccess
|
||||
// Background jobs / out-of-request execution should process across tenants.
|
||||
return true;
|
||||
}
|
||||
|
||||
var user = httpContext.User;
|
||||
if (user?.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (user.IsInRole(PlatformAdminRole))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var permissions = user.FindAll(PermissionClaimType)
|
||||
.Select(c => c.Value?.Trim())
|
||||
.Where(v => !string.IsNullOrWhiteSpace(v))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Platform-level tenant permissions imply cross-tenant visibility.
|
||||
return permissions.Contains("tenant:read");
|
||||
// (空行后) 请求上下文下强制不允许跨租户
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Services;
|
||||
|
||||
internal static class DictionaryAccessHelper
|
||||
{
|
||||
internal static bool IsPlatformAdmin(IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
var user = httpContextAccessor.HttpContext?.User;
|
||||
if (user?.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return user.IsInRole("PlatformAdmin") ||
|
||||
user.IsInRole("platform-admin") ||
|
||||
user.IsInRole("super-admin") ||
|
||||
user.IsInRole("SUPER_ADMIN");
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using System.Security.Cryptography;
|
||||
using TakeoutSaaS.Application.Dictionary.Abstractions;
|
||||
using TakeoutSaaS.Application.Dictionary.Contracts;
|
||||
@@ -20,7 +19,6 @@ public sealed class DictionaryAppService(
|
||||
IDictionaryRepository repository,
|
||||
IDictionaryCache cache,
|
||||
ITenantProvider tenantProvider,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
ILogger<DictionaryAppService> logger) : IDictionaryAppService
|
||||
{
|
||||
/// <summary>
|
||||
@@ -356,17 +354,20 @@ public sealed class DictionaryAppService(
|
||||
private void EnsureScopePermission(DictionaryScope scope)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
if (scope == DictionaryScope.System && tenantId != 0 && !DictionaryAccessHelper.IsPlatformAdmin(httpContextAccessor))
|
||||
|
||||
// 1. (空行后) 租户端不允许操作系统字典
|
||||
if (scope == DictionaryScope.System && tenantId != 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "仅平台管理员可操作系统字典");
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "租户端不允许操作系统字典");
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsurePlatformTenant(long tenantId)
|
||||
{
|
||||
if (tenantId != 0 && !DictionaryAccessHelper.IsPlatformAdmin(httpContextAccessor))
|
||||
// 1. (空行后) 系统字典只能在平台租户(TenantId=0)上下文中操作
|
||||
if (tenantId != 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "仅平台管理员可操作系统字典");
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "租户端不允许操作系统字典");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using TakeoutSaaS.Application.Dictionary.Abstractions;
|
||||
using TakeoutSaaS.Application.Dictionary.Contracts;
|
||||
using TakeoutSaaS.Application.Dictionary.Models;
|
||||
@@ -22,7 +21,6 @@ public sealed class DictionaryCommandService(
|
||||
IDictionaryItemRepository itemRepository,
|
||||
IDictionaryHybridCache cache,
|
||||
ITenantProvider tenantProvider,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
ILogger<DictionaryCommandService> logger)
|
||||
{
|
||||
/// <summary>
|
||||
@@ -231,14 +229,16 @@ public sealed class DictionaryCommandService(
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
if (scope == DictionaryScope.System)
|
||||
{
|
||||
if (tenantId != 0 && !DictionaryAccessHelper.IsPlatformAdmin(httpContextAccessor))
|
||||
// 1. (空行后) 租户端禁止写入系统字典
|
||||
if (tenantId != 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "仅平台管理员可创建系统字典");
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "租户端不允许创建系统字典");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 2. (空行后) 业务字典必须在租户上下文中创建
|
||||
if (tenantId == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "业务字典必须在租户上下文中创建");
|
||||
@@ -250,11 +250,14 @@ public sealed class DictionaryCommandService(
|
||||
private void EnsureGroupAccess(DictionaryGroup group)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
if (group.Scope == DictionaryScope.System && tenantId != 0 && !DictionaryAccessHelper.IsPlatformAdmin(httpContextAccessor))
|
||||
|
||||
// 1. (空行后) 租户端不允许操作系统字典
|
||||
if (group.Scope == DictionaryScope.System && tenantId != 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "仅平台管理员可操作系统字典");
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "租户端不允许操作系统字典");
|
||||
}
|
||||
|
||||
// 2. (空行后) 业务字典必须属于当前租户
|
||||
if (group.Scope == DictionaryScope.Business && tenantId != group.TenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "无权操作其他租户字典");
|
||||
|
||||
@@ -14,7 +14,6 @@ using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Services;
|
||||
|
||||
@@ -30,7 +29,6 @@ public sealed class DictionaryImportExportService(
|
||||
IDictionaryHybridCache cache,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUser,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
ILogger<DictionaryImportExportService> logger)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
@@ -426,11 +424,14 @@ public sealed class DictionaryImportExportService(
|
||||
private void EnsureGroupAccess(DictionaryGroup group)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
if (group.Scope == DictionaryScope.System && tenantId != 0 && !DictionaryAccessHelper.IsPlatformAdmin(httpContextAccessor))
|
||||
|
||||
// 1. (空行后) 租户端不允许操作系统字典
|
||||
if (group.Scope == DictionaryScope.System && tenantId != 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "仅平台管理员可操作系统字典");
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "租户端不允许操作系统字典");
|
||||
}
|
||||
|
||||
// 2. (空行后) 业务字典必须属于当前租户
|
||||
if (group.Scope == DictionaryScope.Business && tenantId != group.TenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "无权操作其他租户字典");
|
||||
|
||||
@@ -31,24 +31,18 @@ public sealed class BatchIdentityUserOperationCommandHandler(
|
||||
/// <inheritdoc />
|
||||
public async Task<BatchIdentityUserOperationResult> Handle(BatchIdentityUserOperationCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取操作者档案并判断权限
|
||||
// 1. 获取当前租户与操作者档案
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
|
||||
|
||||
// 2. 校验跨租户访问权限
|
||||
if (!isSuperAdmin && request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
|
||||
// 2. 校验跨租户访问
|
||||
if (request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户批量操作用户");
|
||||
}
|
||||
|
||||
if (isSuperAdmin && !request.TenantId.HasValue)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "批量操作必须指定租户");
|
||||
}
|
||||
|
||||
// 3. 解析用户 ID 列表
|
||||
var tenantId = request.TenantId ?? currentTenantId;
|
||||
var tenantId = currentTenantId;
|
||||
var userIds = ParseIds(request.UserIds, "用户");
|
||||
if (userIds.Length == 0)
|
||||
{
|
||||
@@ -63,7 +57,7 @@ public sealed class BatchIdentityUserOperationCommandHandler(
|
||||
|
||||
// 4. 查询目标用户集合
|
||||
var includeDeleted = request.Operation == IdentityUserBatchOperation.Restore;
|
||||
var users = await identityUserRepository.GetForUpdateByIdsAsync(tenantId, userIds, includeDeleted, isSuperAdmin, cancellationToken);
|
||||
var users = await identityUserRepository.GetForUpdateByIdsAsync(tenantId, userIds, includeDeleted, false, cancellationToken);
|
||||
var usersById = users.ToDictionary(user => user.Id, user => user, EqualityComparer<long>.Default);
|
||||
|
||||
// 5. 预计算租户管理员约束
|
||||
@@ -85,7 +79,7 @@ public sealed class BatchIdentityUserOperationCommandHandler(
|
||||
IncludeDeleted = false,
|
||||
Page = 1,
|
||||
PageSize = 1
|
||||
}, isSuperAdmin, cancellationToken)).Total;
|
||||
}, false, cancellationToken)).Total;
|
||||
var remainingActiveAdmins = activeAdminCount;
|
||||
|
||||
// 6. 执行批量操作
|
||||
|
||||
@@ -28,27 +28,24 @@ public sealed class ChangeIdentityUserStatusCommandHandler(
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> Handle(ChangeIdentityUserStatusCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取操作者档案并判断权限
|
||||
// 1. 获取当前租户与操作者档案
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
|
||||
|
||||
// 2. 校验跨租户访问权限
|
||||
if (!isSuperAdmin && request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
|
||||
// 2. 校验跨租户访问
|
||||
if (request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户修改用户状态");
|
||||
}
|
||||
|
||||
// 3. 查询用户实体
|
||||
var user = isSuperAdmin
|
||||
? await identityUserRepository.GetForUpdateIgnoringTenantAsync(request.UserId, cancellationToken)
|
||||
: await identityUserRepository.GetForUpdateAsync(request.UserId, cancellationToken);
|
||||
var user = await identityUserRepository.GetForUpdateAsync(request.UserId, cancellationToken);
|
||||
if (user == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isSuperAdmin && user.TenantId != currentTenantId)
|
||||
if (user.TenantId != currentTenantId)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -56,7 +53,7 @@ public sealed class ChangeIdentityUserStatusCommandHandler(
|
||||
// 4. 校验租户管理员保留规则
|
||||
if (request.Status == IdentityUserStatus.Disabled && user.Status == IdentityUserStatus.Active)
|
||||
{
|
||||
await EnsureNotLastActiveTenantAdminAsync(user.TenantId, user.Id, isSuperAdmin, cancellationToken);
|
||||
await EnsureNotLastActiveTenantAdminAsync(user.TenantId, user.Id, cancellationToken);
|
||||
}
|
||||
|
||||
// 5. 更新状态
|
||||
@@ -114,7 +111,7 @@ public sealed class ChangeIdentityUserStatusCommandHandler(
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task EnsureNotLastActiveTenantAdminAsync(long tenantId, long userId, bool ignoreTenantFilter, CancellationToken cancellationToken)
|
||||
private async Task EnsureNotLastActiveTenantAdminAsync(long tenantId, long userId, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取租户管理员角色
|
||||
var tenantAdminRole = await roleRepository.FindByCodeAsync("tenant-admin", tenantId, cancellationToken);
|
||||
@@ -140,7 +137,7 @@ public sealed class ChangeIdentityUserStatusCommandHandler(
|
||||
Page = 1,
|
||||
PageSize = 1
|
||||
};
|
||||
var result = await identityUserRepository.SearchPagedAsync(filter, ignoreTenantFilter, cancellationToken);
|
||||
var result = await identityUserRepository.SearchPagedAsync(filter, false, cancellationToken);
|
||||
if (result.Total <= 1)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "至少保留一个管理员");
|
||||
|
||||
@@ -36,19 +36,18 @@ public sealed class CreateIdentityUserCommandHandler(
|
||||
/// <inheritdoc />
|
||||
public async Task<UserDetailDto> Handle(CreateIdentityUserCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取操作者档案并判断权限
|
||||
// 1. 获取当前租户与操作者档案
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
|
||||
|
||||
// 2. 校验跨租户访问权限
|
||||
if (!isSuperAdmin && request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
|
||||
// 2. 校验跨租户访问
|
||||
if (request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户创建用户");
|
||||
}
|
||||
|
||||
// 3. 规范化输入并准备校验
|
||||
var tenantId = isSuperAdmin ? request.TenantId ?? currentTenantId : currentTenantId;
|
||||
var tenantId = currentTenantId;
|
||||
var account = request.Account.Trim();
|
||||
var displayName = request.DisplayName.Trim();
|
||||
var phone = string.IsNullOrWhiteSpace(request.Phone) ? null : request.Phone.Trim();
|
||||
|
||||
@@ -28,27 +28,24 @@ public sealed class DeleteIdentityUserCommandHandler(
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> Handle(DeleteIdentityUserCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取操作者档案并判断权限
|
||||
// 1. 获取当前租户与操作者档案
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
|
||||
|
||||
// 2. 校验跨租户访问权限
|
||||
if (!isSuperAdmin && request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
|
||||
// 2. 校验跨租户访问
|
||||
if (request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户删除用户");
|
||||
}
|
||||
|
||||
// 3. 查询用户实体
|
||||
var user = isSuperAdmin
|
||||
? await identityUserRepository.GetForUpdateIgnoringTenantAsync(request.UserId, cancellationToken)
|
||||
: await identityUserRepository.GetForUpdateAsync(request.UserId, cancellationToken);
|
||||
var user = await identityUserRepository.GetForUpdateAsync(request.UserId, cancellationToken);
|
||||
if (user == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isSuperAdmin && user.TenantId != currentTenantId)
|
||||
if (user.TenantId != currentTenantId)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -56,7 +53,7 @@ public sealed class DeleteIdentityUserCommandHandler(
|
||||
// 4. 校验租户管理员保留规则
|
||||
if (user.Status == IdentityUserStatus.Active)
|
||||
{
|
||||
await EnsureNotLastActiveTenantAdminAsync(user.TenantId, user.Id, isSuperAdmin, cancellationToken);
|
||||
await EnsureNotLastActiveTenantAdminAsync(user.TenantId, user.Id, cancellationToken);
|
||||
}
|
||||
|
||||
// 5. 构建操作日志消息
|
||||
@@ -88,7 +85,7 @@ public sealed class DeleteIdentityUserCommandHandler(
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task EnsureNotLastActiveTenantAdminAsync(long tenantId, long userId, bool ignoreTenantFilter, CancellationToken cancellationToken)
|
||||
private async Task EnsureNotLastActiveTenantAdminAsync(long tenantId, long userId, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取租户管理员角色
|
||||
var tenantAdminRole = await roleRepository.FindByCodeAsync("tenant-admin", tenantId, cancellationToken);
|
||||
@@ -114,7 +111,7 @@ public sealed class DeleteIdentityUserCommandHandler(
|
||||
Page = 1,
|
||||
PageSize = 1
|
||||
};
|
||||
var result = await identityUserRepository.SearchPagedAsync(filter, ignoreTenantFilter, cancellationToken);
|
||||
var result = await identityUserRepository.SearchPagedAsync(filter, false, cancellationToken);
|
||||
if (result.Total <= 1)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "至少保留一个管理员");
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
using TakeoutSaaS.Application.Identity.Queries;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
using TakeoutSaaS.Domain.Identity.Enums;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||
@@ -19,30 +17,24 @@ public sealed class GetIdentityUserDetailQueryHandler(
|
||||
IRoleRepository roleRepository,
|
||||
IRolePermissionRepository rolePermissionRepository,
|
||||
IPermissionRepository permissionRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
IAdminAuthService adminAuthService)
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetIdentityUserDetailQuery, UserDetailDto?>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<UserDetailDto?> Handle(GetIdentityUserDetailQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取操作者档案并判断权限
|
||||
// 1. 获取当前租户
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
|
||||
|
||||
// 2. 查询用户实体
|
||||
IdentityUser? user;
|
||||
if (request.IncludeDeleted)
|
||||
{
|
||||
user = await identityUserRepository.GetForUpdateIncludingDeletedAsync(currentTenantId, request.UserId, isSuperAdmin, cancellationToken);
|
||||
user = await identityUserRepository.GetForUpdateIncludingDeletedAsync(currentTenantId, request.UserId, false, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
user = isSuperAdmin
|
||||
? await identityUserRepository.GetForUpdateIgnoringTenantAsync(request.UserId, cancellationToken)
|
||||
: await identityUserRepository.GetForUpdateAsync(request.UserId, cancellationToken);
|
||||
user = await identityUserRepository.GetForUpdateAsync(request.UserId, cancellationToken);
|
||||
}
|
||||
|
||||
if (user == null)
|
||||
@@ -50,7 +42,7 @@ public sealed class GetIdentityUserDetailQueryHandler(
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isSuperAdmin && user.TenantId != currentTenantId)
|
||||
if (user.TenantId != currentTenantId)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -28,27 +28,24 @@ public sealed class ResetIdentityUserPasswordCommandHandler(
|
||||
/// <inheritdoc />
|
||||
public async Task<ResetIdentityUserPasswordResult> Handle(ResetIdentityUserPasswordCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取操作者档案并判断权限
|
||||
// 1. 获取当前租户与操作者档案
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
|
||||
|
||||
// 2. 校验跨租户访问权限
|
||||
if (!isSuperAdmin && request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
|
||||
// 2. 校验跨租户访问
|
||||
if (request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户重置密码");
|
||||
}
|
||||
|
||||
// 3. 查询用户实体
|
||||
var user = isSuperAdmin
|
||||
? await identityUserRepository.GetForUpdateIgnoringTenantAsync(request.UserId, cancellationToken)
|
||||
: await identityUserRepository.GetForUpdateAsync(request.UserId, cancellationToken);
|
||||
var user = await identityUserRepository.GetForUpdateAsync(request.UserId, cancellationToken);
|
||||
if (user == null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "用户不存在");
|
||||
}
|
||||
|
||||
if (!isSuperAdmin && user.TenantId != currentTenantId)
|
||||
if (user.TenantId != currentTenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户重置密码");
|
||||
}
|
||||
|
||||
@@ -25,25 +25,24 @@ public sealed class RestoreIdentityUserCommandHandler(
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> Handle(RestoreIdentityUserCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取操作者档案并判断权限
|
||||
// 1. 获取当前租户与操作者档案
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
|
||||
|
||||
// 2. 校验跨租户访问权限
|
||||
if (!isSuperAdmin && request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
|
||||
// 2. 校验跨租户访问
|
||||
if (request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户恢复用户");
|
||||
}
|
||||
|
||||
// 3. 查询用户实体(包含已删除)
|
||||
var user = await identityUserRepository.GetForUpdateIncludingDeletedAsync(currentTenantId, request.UserId, isSuperAdmin, cancellationToken);
|
||||
var user = await identityUserRepository.GetForUpdateIncludingDeletedAsync(currentTenantId, request.UserId, false, cancellationToken);
|
||||
if (user == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isSuperAdmin && user.TenantId != currentTenantId)
|
||||
if (user.TenantId != currentTenantId)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
using TakeoutSaaS.Application.Identity.Queries;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
@@ -8,7 +7,6 @@ using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||
@@ -20,21 +18,17 @@ public sealed class SearchIdentityUsersQueryHandler(
|
||||
IIdentityUserRepository identityUserRepository,
|
||||
IUserRoleRepository userRoleRepository,
|
||||
IRoleRepository roleRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
IAdminAuthService adminAuthService)
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<SearchIdentityUsersQuery, PagedResult<UserListItemDto>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<PagedResult<UserListItemDto>> Handle(SearchIdentityUsersQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取操作者档案并判断权限
|
||||
// 1. 获取当前租户
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
|
||||
|
||||
// 2. 校验跨租户访问权限
|
||||
if (!isSuperAdmin && request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
|
||||
// 2. 校验跨租户访问
|
||||
if (request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户查询用户");
|
||||
}
|
||||
@@ -42,7 +36,7 @@ public sealed class SearchIdentityUsersQueryHandler(
|
||||
// 3. 组装查询过滤条件
|
||||
var filter = new IdentityUserSearchFilter
|
||||
{
|
||||
TenantId = isSuperAdmin ? request.TenantId : currentTenantId,
|
||||
TenantId = currentTenantId,
|
||||
Keyword = request.Keyword,
|
||||
Status = request.Status,
|
||||
RoleId = request.RoleId,
|
||||
@@ -58,7 +52,7 @@ public sealed class SearchIdentityUsersQueryHandler(
|
||||
};
|
||||
|
||||
// 4. 执行分页查询
|
||||
var (items, total) = await identityUserRepository.SearchPagedAsync(filter, isSuperAdmin, cancellationToken);
|
||||
var (items, total) = await identityUserRepository.SearchPagedAsync(filter, false, cancellationToken);
|
||||
if (items.Count == 0)
|
||||
{
|
||||
return new PagedResult<UserListItemDto>(Array.Empty<UserListItemDto>(), request.Page, request.PageSize, total);
|
||||
|
||||
@@ -31,27 +31,24 @@ public sealed class UpdateIdentityUserCommandHandler(
|
||||
/// <inheritdoc />
|
||||
public async Task<UserDetailDto?> Handle(UpdateIdentityUserCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取操作者档案并判断权限
|
||||
// 1. 获取当前租户与操作者档案
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
|
||||
|
||||
// 2. 校验跨租户访问权限
|
||||
if (!isSuperAdmin && request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
|
||||
// 2. 校验跨租户访问
|
||||
if (request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户更新用户");
|
||||
}
|
||||
|
||||
// 3. 获取用户实体
|
||||
var user = isSuperAdmin
|
||||
? await identityUserRepository.GetForUpdateIgnoringTenantAsync(request.UserId, cancellationToken)
|
||||
: await identityUserRepository.GetForUpdateAsync(request.UserId, cancellationToken);
|
||||
var user = await identityUserRepository.GetForUpdateAsync(request.UserId, cancellationToken);
|
||||
if (user == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isSuperAdmin && user.TenantId != currentTenantId)
|
||||
if (user.TenantId != currentTenantId)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
using System.Collections.Frozen;
|
||||
using System.Linq;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity;
|
||||
|
||||
internal static class IdentityUserAccess
|
||||
{
|
||||
private static readonly FrozenSet<string> SuperAdminRoleCodes = new[]
|
||||
{
|
||||
"super-admin",
|
||||
"SUPER_ADMIN",
|
||||
"PlatformAdmin",
|
||||
"platform-admin"
|
||||
}.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
internal static bool IsSuperAdmin(CurrentUserProfile profile)
|
||||
=> profile.Roles.Any(role => SuperAdminRoleCodes.Contains(role));
|
||||
}
|
||||
@@ -29,14 +29,7 @@ public sealed class AdminAuthService(
|
||||
ITenantContextAccessor tenantContextAccessor,
|
||||
ITenantRepository tenantRepository) : IAdminAuthService
|
||||
{
|
||||
private readonly ITenantProvider _tenantProvider = tenantProvider;
|
||||
private readonly ITenantContextAccessor _tenantContextAccessor = tenantContextAccessor;
|
||||
private readonly ITenantRepository _tenantRepository = tenantRepository;
|
||||
private readonly IUserRoleRepository _userRoleRepository = userRoleRepository;
|
||||
private readonly IRoleRepository _roleRepository = roleRepository;
|
||||
private readonly IPermissionRepository _permissionRepository = permissionRepository;
|
||||
private readonly IRolePermissionRepository _rolePermissionRepository = rolePermissionRepository;
|
||||
private readonly IMenuRepository _menuRepository = menuRepository;
|
||||
private const string TenantAdminRoleCode = "tenant-admin";
|
||||
|
||||
/// <summary>
|
||||
/// 管理后台登录:验证账号密码并生成令牌。
|
||||
@@ -47,8 +40,15 @@ public sealed class AdminAuthService(
|
||||
/// <exception cref="BusinessException">账号或密码错误时抛出</exception>
|
||||
public async Task<TokenResponse> LoginAsync(AdminLoginRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 0. 强制要求租户上下文(严格多租户隔离)
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
if (currentTenantId <= 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "缺少租户标识,请使用 账号@手机号 登录或在 Header 指定租户");
|
||||
}
|
||||
|
||||
// 1. 根据账号查找用户
|
||||
var user = await userRepository.FindByAccountAsync(request.Account, cancellationToken)
|
||||
var user = await userRepository.FindByAccountAsync(currentTenantId, request.Account, cancellationToken)
|
||||
?? throw new BusinessException(ErrorCodes.Unauthorized, "账号或密码错误");
|
||||
|
||||
// 2. 校验账号状态
|
||||
@@ -114,27 +114,27 @@ public sealed class AdminAuthService(
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "账号格式错误,应为 账号@手机号");
|
||||
}
|
||||
|
||||
var tenantId = await _tenantRepository.FindTenantIdByContactPhoneAsync(phonePart, cancellationToken);
|
||||
var tenantId = await tenantRepository.FindTenantIdByContactPhoneAsync(phonePart, cancellationToken);
|
||||
if (!tenantId.HasValue || tenantId.Value == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Unauthorized, "账号或密码错误");
|
||||
}
|
||||
|
||||
var originalTenant = _tenantContextAccessor.Current;
|
||||
_tenantContextAccessor.Current = new TenantContext(tenantId.Value, null, "login:simple:contact_phone");
|
||||
var originalTenant = tenantContextAccessor.Current;
|
||||
tenantContextAccessor.Current = new TenantContext(tenantId.Value, null, "login:simple:contact_phone");
|
||||
try
|
||||
{
|
||||
return await LoginAsync(new AdminLoginRequest { Account = accountPart, Password = request.Password }, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_tenantContextAccessor.Current = originalTenant;
|
||||
tenantContextAccessor.Current = originalTenant;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 未携带手机号时,要求外部已解析租户(Header/Host 等)
|
||||
if (_tenantProvider.GetCurrentTenantId() == 0)
|
||||
if (tenantProvider.GetCurrentTenantId() == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "缺少租户标识,请使用 账号@手机号 登录");
|
||||
}
|
||||
@@ -163,6 +163,9 @@ public sealed class AdminAuthService(
|
||||
var user = await userRepository.FindByIdAsync(descriptor.UserId, cancellationToken)
|
||||
?? throw new BusinessException(ErrorCodes.Unauthorized, "用户不存在");
|
||||
|
||||
// 2.1 校验租户上下文与用户租户一致
|
||||
EnsureTenantMatched(user.TenantId);
|
||||
|
||||
// 3. 撤销旧刷新令牌(防止重复使用)
|
||||
await refreshTokenStore.RevokeAsync(descriptor.Token, cancellationToken);
|
||||
|
||||
@@ -197,8 +200,8 @@ public sealed class AdminAuthService(
|
||||
// 1. 读取档案以获取权限
|
||||
var profile = await GetProfileAsync(userId, cancellationToken);
|
||||
// 2. 读取菜单定义
|
||||
var tenantId = _tenantProvider.GetCurrentTenantId();
|
||||
var definitions = await _menuRepository.GetByTenantAsync(tenantId, cancellationToken);
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var definitions = await menuRepository.GetByTenantAsync(tenantId, cancellationToken);
|
||||
|
||||
// 3. 生成菜单树
|
||||
var menu = BuildMenuTree(definitions, profile.Permissions);
|
||||
@@ -210,7 +213,7 @@ public sealed class AdminAuthService(
|
||||
/// </summary>
|
||||
public async Task<UserPermissionDto?> GetUserPermissionsAsync(long userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tenantId = _tenantProvider.GetCurrentTenantId();
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var user = await userRepository.FindByIdAsync(userId, cancellationToken);
|
||||
if (user == null || user.TenantId != tenantId)
|
||||
{
|
||||
@@ -244,7 +247,7 @@ public sealed class AdminAuthService(
|
||||
bool sortDescending,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tenantId = _tenantProvider.GetCurrentTenantId();
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var users = await userRepository.SearchAsync(tenantId, keyword, cancellationToken);
|
||||
|
||||
var sorted = sortBy?.ToLowerInvariant() switch
|
||||
@@ -285,8 +288,10 @@ public sealed class AdminAuthService(
|
||||
{
|
||||
var tenantId = user.TenantId;
|
||||
var roles = await ResolveUserRolesAsync(tenantId, user.Id, cancellationToken);
|
||||
// 1. 强制仅允许租户管理员登录(平台不允许超级管理员)
|
||||
EnsureTenantAdmin(tenantId, roles);
|
||||
// 2. 加载权限并返回档案
|
||||
var permissions = await ResolveUserPermissionsAsync(tenantId, user.Id, cancellationToken);
|
||||
|
||||
return new CurrentUserProfile
|
||||
{
|
||||
UserId = user.Id,
|
||||
@@ -300,6 +305,38 @@ public sealed class AdminAuthService(
|
||||
};
|
||||
}
|
||||
|
||||
private void EnsureTenantMatched(long userTenantId)
|
||||
{
|
||||
// 1. 读取当前租户
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
if (currentTenantId <= 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "缺少租户标识,请在 Header 指定租户");
|
||||
}
|
||||
|
||||
// 2. 校验租户一致
|
||||
if (currentTenantId != userTenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Unauthorized, "RefreshToken 无效或已过期");
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureTenantAdmin(long tenantId, IReadOnlyCollection<string> roles)
|
||||
{
|
||||
// 1. 租户 ID 必须有效
|
||||
if (tenantId <= 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "仅允许租户管理员登录");
|
||||
}
|
||||
|
||||
// 2. 必须具备租户管理员角色
|
||||
var isTenantAdmin = roles.Any(role => string.Equals(role, TenantAdminRoleCode, StringComparison.OrdinalIgnoreCase));
|
||||
if (!isTenantAdmin)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "仅允许租户管理员登录");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ResetLockedUserAsync(long userId, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取可更新实体
|
||||
@@ -495,34 +532,34 @@ public sealed class AdminAuthService(
|
||||
|
||||
private async Task<string[]> ResolveUserRolesAsync(long tenantId, long userId, CancellationToken cancellationToken)
|
||||
{
|
||||
var relations = await _userRoleRepository.GetByUserIdAsync(tenantId, userId, cancellationToken);
|
||||
var relations = await userRoleRepository.GetByUserIdAsync(tenantId, userId, cancellationToken);
|
||||
var roleIds = relations.Select(x => x.RoleId).Distinct().ToArray();
|
||||
if (roleIds.Length == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var roles = await _roleRepository.GetByIdsAsync(tenantId, roleIds, cancellationToken);
|
||||
var roles = await roleRepository.GetByIdsAsync(tenantId, roleIds, cancellationToken);
|
||||
return roles.Select(x => x.Code).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
}
|
||||
|
||||
private async Task<string[]> ResolveUserPermissionsAsync(long tenantId, long userId, CancellationToken cancellationToken)
|
||||
{
|
||||
var relations = await _userRoleRepository.GetByUserIdAsync(tenantId, userId, cancellationToken);
|
||||
var relations = await userRoleRepository.GetByUserIdAsync(tenantId, userId, cancellationToken);
|
||||
var roleIds = relations.Select(x => x.RoleId).Distinct().ToArray();
|
||||
if (roleIds.Length == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var rolePermissions = await _rolePermissionRepository.GetByRoleIdsAsync(tenantId, roleIds, cancellationToken);
|
||||
var rolePermissions = await rolePermissionRepository.GetByRoleIdsAsync(tenantId, roleIds, cancellationToken);
|
||||
var permissionIds = rolePermissions.Select(x => x.PermissionId).Distinct().ToArray();
|
||||
if (permissionIds.Length == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var permissions = await _permissionRepository.GetByIdsAsync(tenantId, permissionIds, cancellationToken);
|
||||
var permissions = await permissionRepository.GetByIdsAsync(tenantId, permissionIds, cancellationToken);
|
||||
return permissions.Select(x => x.Code).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
}
|
||||
|
||||
@@ -532,22 +569,22 @@ public sealed class AdminAuthService(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var userIds = users.Select(x => x.Id).ToArray();
|
||||
var userRoleRelations = await _userRoleRepository.GetByUserIdsAsync(tenantId, userIds, cancellationToken);
|
||||
var userRoleRelations = await userRoleRepository.GetByUserIdsAsync(tenantId, userIds, cancellationToken);
|
||||
var roleIds = userRoleRelations.Select(x => x.RoleId).Distinct().ToArray();
|
||||
|
||||
var roles = roleIds.Length == 0
|
||||
? Array.Empty<Role>()
|
||||
: await _roleRepository.GetByIdsAsync(tenantId, roleIds, cancellationToken);
|
||||
: await roleRepository.GetByIdsAsync(tenantId, roleIds, cancellationToken);
|
||||
var roleCodeMap = roles.ToDictionary(r => r.Id, r => r.Code, comparer: EqualityComparer<long>.Default);
|
||||
|
||||
var rolePermissions = roleIds.Length == 0
|
||||
? Array.Empty<RolePermission>()
|
||||
: await _rolePermissionRepository.GetByRoleIdsAsync(tenantId, roleIds, cancellationToken);
|
||||
: await rolePermissionRepository.GetByRoleIdsAsync(tenantId, roleIds, cancellationToken);
|
||||
|
||||
var permissionIds = rolePermissions.Select(x => x.PermissionId).Distinct().ToArray();
|
||||
var permissions = permissionIds.Length == 0
|
||||
? Array.Empty<Permission>()
|
||||
: await _permissionRepository.GetByIdsAsync(tenantId, permissionIds, cancellationToken);
|
||||
: await permissionRepository.GetByIdsAsync(tenantId, permissionIds, cancellationToken);
|
||||
var permissionCodeMap = permissions.ToDictionary(p => p.Id, p => p.Code, comparer: EqualityComparer<long>.Default);
|
||||
|
||||
var rolePermissionsLookup = rolePermissions
|
||||
|
||||
@@ -11,11 +11,21 @@ public interface IIdentityUserRepository
|
||||
/// <summary>
|
||||
/// 根据账号获取后台用户。
|
||||
/// </summary>
|
||||
/// <remarks>为保证多租户隔离,优先使用带租户参数的重载方法。</remarks>
|
||||
/// <param name="account">账号。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>后台用户或 null。</returns>
|
||||
Task<IdentityUser?> FindByAccountAsync(string account, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 根据租户与账号获取后台用户。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="account">账号。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>后台用户或 null。</returns>
|
||||
Task<IdentityUser?> FindByAccountAsync(long tenantId, string account, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 判断账号是否存在。
|
||||
/// </summary>
|
||||
|
||||
@@ -26,7 +26,6 @@ using TakeoutSaaS.Shared.Abstractions.Ids;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
using TakeoutSaaS.Infrastructure.App.Persistence.Configurations;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.App.Persistence;
|
||||
|
||||
@@ -37,9 +36,8 @@ public sealed class TakeoutAppDbContext(
|
||||
DbContextOptions<TakeoutAppDbContext> options,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor? currentUserAccessor = null,
|
||||
IIdGenerator? idGenerator = null,
|
||||
IHttpContextAccessor? httpContextAccessor = null)
|
||||
: TenantAwareDbContext(options, tenantProvider, currentUserAccessor, idGenerator, httpContextAccessor)
|
||||
IIdGenerator? idGenerator = null)
|
||||
: TenantAwareDbContext(options, tenantProvider, currentUserAccessor, idGenerator)
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户聚合根。
|
||||
|
||||
@@ -111,16 +111,16 @@ internal abstract class DesignTimeDbContextFactoryBase<TContext> : IDesignTimeDb
|
||||
return explicitDir;
|
||||
}
|
||||
|
||||
// 1. (空行后) 尝试从当前目录定位解决方案根目录
|
||||
var currentDir = Directory.GetCurrentDirectory();
|
||||
var solutionRoot = LocateSolutionRoot(currentDir);
|
||||
|
||||
// 2. (空行后) 依次尝试常见 appsettings 目录(仅保留租户管理端 TenantApi)
|
||||
var candidateDirs = new[]
|
||||
{
|
||||
currentDir,
|
||||
solutionRoot,
|
||||
solutionRoot is null ? null : Path.Combine(solutionRoot, "src", "Api", "TakeoutSaaS.AdminApi"),
|
||||
solutionRoot is null ? null : Path.Combine(solutionRoot, "src", "Api", "TakeoutSaaS.UserApi"),
|
||||
solutionRoot is null ? null : Path.Combine(solutionRoot, "src", "Api", "TakeoutSaaS.MiniApi")
|
||||
solutionRoot is null ? null : Path.Combine(solutionRoot, "src", "Api", "TakeoutSaaS.TenantApi")
|
||||
}.Where(dir => !string.IsNullOrWhiteSpace(dir));
|
||||
|
||||
foreach (var dir in candidateDirs)
|
||||
|
||||
@@ -5,7 +5,6 @@ using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
using TakeoutSaaS.Shared.Abstractions.Ids;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Common.Persistence;
|
||||
|
||||
@@ -16,18 +15,9 @@ public abstract class TenantAwareDbContext(
|
||||
DbContextOptions options,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor? currentUserAccessor = null,
|
||||
IIdGenerator? idGenerator = null,
|
||||
IHttpContextAccessor? httpContextAccessor = null) : AppDbContext(options, currentUserAccessor, idGenerator)
|
||||
IIdGenerator? idGenerator = null) : AppDbContext(options, currentUserAccessor, idGenerator)
|
||||
{
|
||||
private readonly ITenantProvider _tenantProvider = tenantProvider ?? throw new ArgumentNullException(nameof(tenantProvider));
|
||||
private readonly IHttpContextAccessor? _httpContextAccessor = httpContextAccessor;
|
||||
private static readonly string[] PlatformRoleCodes =
|
||||
{
|
||||
"super-admin",
|
||||
"SUPER_ADMIN",
|
||||
"PlatformAdmin",
|
||||
"platform-admin"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 当前请求租户 ID。
|
||||
@@ -86,22 +76,8 @@ public abstract class TenantAwareDbContext(
|
||||
{
|
||||
if (entry.State == EntityState.Added && entry.Entity.TenantId == 0 && tenantId != 0)
|
||||
{
|
||||
if (!IsPlatformAdmin())
|
||||
{
|
||||
entry.Entity.TenantId = tenantId;
|
||||
}
|
||||
entry.Entity.TenantId = tenantId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsPlatformAdmin()
|
||||
{
|
||||
var user = _httpContextAccessor?.HttpContext?.User;
|
||||
if (user?.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return PlatformRoleCodes.Any(user.IsInRole);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ using TakeoutSaaS.Infrastructure.Common.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Ids;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Persistence;
|
||||
|
||||
@@ -18,9 +17,8 @@ public sealed class DictionaryDbContext(
|
||||
DbContextOptions<DictionaryDbContext> options,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor? currentUserAccessor = null,
|
||||
IIdGenerator? idGenerator = null,
|
||||
IHttpContextAccessor? httpContextAccessor = null)
|
||||
: TenantAwareDbContext(options, tenantProvider, currentUserAccessor, idGenerator, httpContextAccessor)
|
||||
IIdGenerator? idGenerator = null)
|
||||
: TenantAwareDbContext(options, tenantProvider, currentUserAccessor, idGenerator)
|
||||
{
|
||||
/// <summary>
|
||||
/// 字典分组集合。
|
||||
|
||||
@@ -18,6 +18,24 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
|
||||
public Task<IdentityUser?> FindByAccountAsync(string account, CancellationToken cancellationToken = default)
|
||||
=> dbContext.IdentityUsers.AsNoTracking().FirstOrDefaultAsync(x => x.Account == account, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 根据租户与账号获取后台用户。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="account">账号。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>后台用户或 null。</returns>
|
||||
public Task<IdentityUser?> FindByAccountAsync(long tenantId, string account, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 标准化账号
|
||||
var normalized = account.Trim();
|
||||
|
||||
// 2. 查询用户(强制租户隔离)
|
||||
return dbContext.IdentityUsers
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Account == normalized, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断账号是否存在。
|
||||
/// </summary>
|
||||
|
||||
@@ -6,7 +6,6 @@ using TakeoutSaaS.Infrastructure.Common.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Ids;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Persistence;
|
||||
|
||||
@@ -17,9 +16,8 @@ public sealed class IdentityDbContext(
|
||||
DbContextOptions<IdentityDbContext> options,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor? currentUserAccessor = null,
|
||||
IIdGenerator? idGenerator = null,
|
||||
IHttpContextAccessor? httpContextAccessor = null)
|
||||
: TenantAwareDbContext(options, tenantProvider, currentUserAccessor, idGenerator, httpContextAccessor)
|
||||
IIdGenerator? idGenerator = null)
|
||||
: TenantAwareDbContext(options, tenantProvider, currentUserAccessor, idGenerator)
|
||||
{
|
||||
/// <summary>
|
||||
/// 管理后台用户集合。
|
||||
|
||||
@@ -7,7 +7,6 @@ using TakeoutSaaS.Infrastructure.Common.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Ids;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Logs.Persistence;
|
||||
|
||||
@@ -18,9 +17,8 @@ public sealed class TakeoutLogsDbContext(
|
||||
DbContextOptions<TakeoutLogsDbContext> options,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor? currentUserAccessor = null,
|
||||
IIdGenerator? idGenerator = null,
|
||||
IHttpContextAccessor? httpContextAccessor = null)
|
||||
: TenantAwareDbContext(options, tenantProvider, currentUserAccessor, idGenerator, httpContextAccessor)
|
||||
IIdGenerator? idGenerator = null)
|
||||
: TenantAwareDbContext(options, tenantProvider, currentUserAccessor, idGenerator)
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户审计日志集合。
|
||||
|
||||
@@ -90,7 +90,14 @@ public sealed class TenantResolutionMiddleware(
|
||||
{
|
||||
var request = context.Request;
|
||||
|
||||
// 1. Header 中的租户 ID
|
||||
// 1. Token Claim(已认证请求必须以 Claim 为准,避免 Header 覆盖导致跨租户访问)
|
||||
var claim = context.User?.FindFirst("tenant_id");
|
||||
if (claim != null && long.TryParse(claim.Value, out var claimTenant))
|
||||
{
|
||||
return new TenantContext(claimTenant, null, "claim:tenant_id");
|
||||
}
|
||||
|
||||
// 2. Header 中的租户 ID
|
||||
if (!string.IsNullOrWhiteSpace(options.TenantIdHeaderName) &&
|
||||
request.Headers.TryGetValue(options.TenantIdHeaderName, out var tenantHeader) &&
|
||||
long.TryParse(tenantHeader.FirstOrDefault(), out var headerTenantId))
|
||||
@@ -98,7 +105,7 @@ public sealed class TenantResolutionMiddleware(
|
||||
return new TenantContext(headerTenantId, null, $"header:{options.TenantIdHeaderName}");
|
||||
}
|
||||
|
||||
// 2. Header 中的租户编码
|
||||
// 3. Header 中的租户编码
|
||||
if (!string.IsNullOrWhiteSpace(options.TenantCodeHeaderName) &&
|
||||
request.Headers.TryGetValue(options.TenantCodeHeaderName, out var codeHeader))
|
||||
{
|
||||
@@ -109,7 +116,7 @@ public sealed class TenantResolutionMiddleware(
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Host 映射/子域名解析
|
||||
// 4. Host 映射/子域名解析
|
||||
var host = request.Host.Host;
|
||||
if (!string.IsNullOrWhiteSpace(host))
|
||||
{
|
||||
@@ -125,13 +132,6 @@ public sealed class TenantResolutionMiddleware(
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Token Claim
|
||||
var claim = context.User?.FindFirst("tenant_id");
|
||||
if (claim != null && long.TryParse(claim.Value, out var claimTenant))
|
||||
{
|
||||
return new TenantContext(claimTenant, null, "claim:tenant_id");
|
||||
}
|
||||
|
||||
return TenantContext.Empty;
|
||||
}
|
||||
|
||||
|
||||
@@ -444,7 +444,6 @@ public sealed class DictionaryApiTests
|
||||
new DictionaryItemRepository(context),
|
||||
cache,
|
||||
tenantProvider,
|
||||
new HttpContextAccessor { HttpContext = new DefaultHttpContext() },
|
||||
NullLogger<DictionaryCommandService>.Instance);
|
||||
}
|
||||
|
||||
@@ -496,7 +495,6 @@ public sealed class DictionaryApiTests
|
||||
cache,
|
||||
tenantProvider,
|
||||
currentUser,
|
||||
new HttpContextAccessor { HttpContext = new DefaultHttpContext() },
|
||||
NullLogger<DictionaryImportExportService>.Instance);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user