From c6465480a91cd9ae98ecd5f1a655a19484bcc70f Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Mon, 9 Mar 2026 13:13:41 +0800 Subject: [PATCH] feat: initialize mini api skeleton --- .dockerignore | 37 +++++ .editorconfig | 13 ++ .gitignore | 11 ++ .gitmodules | 6 + Directory.Build.props | 15 ++ NuGet.Config | 8 + README.md | 33 ++++ TakeoutSaaS.BuildingBlocks | 1 + TakeoutSaaS.Docs | 1 + TakeoutSaaS.sln | 114 ++++++++++++++ src/Api/Directory.Build.props | 5 + .../Controllers/BootstrapController.cs | 30 ++++ .../Controllers/HealthController.cs | 33 ++++ src/Api/TakeoutSaaS.MiniApi/Dockerfile | 6 + src/Api/TakeoutSaaS.MiniApi/Program.cs | 148 ++++++++++++++++++ .../Properties/launchSettings.json | 14 ++ .../TakeoutSaaS.MiniApi.csproj | 28 ++++ .../appsettings.Development.json | 9 ++ .../appsettings.Production.json | 9 ++ src/Api/TakeoutSaaS.MiniApi/appsettings.json | 23 +++ .../Common/Behaviors/ValidationBehavior.cs | 44 ++++++ ...pApplicationServiceCollectionExtensions.cs | 26 +++ .../Mini/Bootstrap/GetMiniBootstrapQuery.cs | 8 + .../Bootstrap/GetMiniBootstrapQueryHandler.cs | 33 ++++ .../Mini/Bootstrap/MiniBootstrapResponse.cs | 37 +++++ .../TakeoutSaaS.Application.csproj | 16 ++ .../TakeoutSaaS.Domain.csproj | 12 ++ .../AppServiceCollectionExtensions.cs | 22 +++ .../TakeoutSaaS.Infrastructure.csproj | 16 ++ .../TenantServiceCollectionExtensions.cs | 40 +++++ .../ITenantCodeResolver.cs | 15 ++ .../NoOpTenantCodeResolver.cs | 15 ++ .../TakeoutSaaS.Module.Tenancy.csproj | 13 ++ .../TenantContextAccessor.cs | 33 ++++ .../TenantProvider.cs | 13 ++ .../TenantResolutionMiddleware.cs | 106 +++++++++++++ .../TenantResolutionOptions.cs | 38 +++++ stylecop.json | 7 + 38 files changed, 1038 insertions(+) create mode 100644 .dockerignore create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 Directory.Build.props create mode 100644 NuGet.Config create mode 100644 README.md create mode 160000 TakeoutSaaS.BuildingBlocks create mode 160000 TakeoutSaaS.Docs create mode 100644 TakeoutSaaS.sln create mode 100644 src/Api/Directory.Build.props create mode 100644 src/Api/TakeoutSaaS.MiniApi/Controllers/BootstrapController.cs create mode 100644 src/Api/TakeoutSaaS.MiniApi/Controllers/HealthController.cs create mode 100644 src/Api/TakeoutSaaS.MiniApi/Dockerfile create mode 100644 src/Api/TakeoutSaaS.MiniApi/Program.cs create mode 100644 src/Api/TakeoutSaaS.MiniApi/Properties/launchSettings.json create mode 100644 src/Api/TakeoutSaaS.MiniApi/TakeoutSaaS.MiniApi.csproj create mode 100644 src/Api/TakeoutSaaS.MiniApi/appsettings.Development.json create mode 100644 src/Api/TakeoutSaaS.MiniApi/appsettings.Production.json create mode 100644 src/Api/TakeoutSaaS.MiniApi/appsettings.json create mode 100644 src/Application/TakeoutSaaS.Application/App/Common/Behaviors/ValidationBehavior.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Extensions/AppApplicationServiceCollectionExtensions.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Mini/Bootstrap/GetMiniBootstrapQuery.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Mini/Bootstrap/GetMiniBootstrapQueryHandler.cs create mode 100644 src/Application/TakeoutSaaS.Application/App/Mini/Bootstrap/MiniBootstrapResponse.cs create mode 100644 src/Application/TakeoutSaaS.Application/TakeoutSaaS.Application.csproj create mode 100644 src/Domain/TakeoutSaaS.Domain/TakeoutSaaS.Domain.csproj create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs create mode 100644 src/Infrastructure/TakeoutSaaS.Infrastructure/TakeoutSaaS.Infrastructure.csproj create mode 100644 src/Modules/TakeoutSaaS.Module.Tenancy/Extensions/TenantServiceCollectionExtensions.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Tenancy/ITenantCodeResolver.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Tenancy/NoOpTenantCodeResolver.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Tenancy/TakeoutSaaS.Module.Tenancy.csproj create mode 100644 src/Modules/TakeoutSaaS.Module.Tenancy/TenantContextAccessor.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Tenancy/TenantProvider.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Tenancy/TenantResolutionMiddleware.cs create mode 100644 src/Modules/TakeoutSaaS.Module.Tenancy/TenantResolutionOptions.cs create mode 100644 stylecop.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f110f32 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,37 @@ +.git/ +.gitignore +.gitattributes +.gitmodules +.github/ +.svn/ +.hg/ +.vs/ +.idea/ +.vscode/ +*.suo +*.user +*.userosscache +*.sln.docstates +**/*.csproj.user +**/bin/ +**/obj/ +**/TestResults/ +**/coverage/ +**/artifacts/ +**/node_modules/ +**/bower_components/ +**/dist/ +**/build/ +**/.next/ +**/.nuxt/ +**/.output/ +*.log +*.tmp +*.temp +*.swp +*.bak +*.cache +*.pid +*.pdb +.DS_Store +Thumbs.db diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6c8b717 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +# EditorConfig +root = true + +[*.cs] +dotnet_diagnostic.SA1600.severity = error +dotnet_diagnostic.SA1601.severity = error +dotnet_diagnostic.SA1615.severity = error +dotnet_diagnostic.SA1629.severity = none +dotnet_diagnostic.SA1202.severity = none +dotnet_diagnostic.SA1200.severity = none +dotnet_diagnostic.SA1623.severity = none +dotnet_diagnostic.SA1111.severity = none +dotnet_diagnostic.SA1101.severity = none diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..497ab73 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.vs/ +bin/ +obj/ +**/bin/ +**/obj/ +.claude/ +*.log +/src/Api/TakeoutSaaS.MiniApi/TakeoutSaaS.MiniApi.csproj.user + +# 保留根目录 scripts 目录提交 +!scripts/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..c0e417a --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "TakeoutSaaS.BuildingBlocks"] + path = TakeoutSaaS.BuildingBlocks + url = git@github.com:msumshk/TakeoutSaaS.BuildingBlocks.git +[submodule "TakeoutSaaS.Docs"] + path = TakeoutSaaS.Docs + url = git@github.com:msumshk/TakeoutSaaS.Docs.git diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..68d6405 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,15 @@ + + + net10.0 + enable + enable + latest + false + + + + + + + + diff --git a/NuGet.Config b/NuGet.Config new file mode 100644 index 0000000..6049492 --- /dev/null +++ b/NuGet.Config @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e77984c --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# TakeoutSaaS.C-Side-Mini-Program-API + +本仓库承载 `TakeoutSaaS.MiniApi`,面向 C 端微信小程序,路由前缀为 `/api/mini/v1`。 + +## 子模块 + +- `TakeoutSaaS.BuildingBlocks/`:共享基础组件 +- `TakeoutSaaS.Docs/`:文档、部署资料与脚本 + +## 初始化 + +```bash +git submodule update --init --recursive +dotnet restore +dotnet build TakeoutSaaS.sln +``` + +## 运行 + +```bash +dotnet run --project src/Api/TakeoutSaaS.MiniApi/TakeoutSaaS.MiniApi.csproj +``` + +默认开发地址: + +- `http://localhost:2683` +- Swagger:`http://localhost:2683/api/docs` +- 健康检查:`http://localhost:2683/healthz` + +## 文档入口 + +- `TakeoutSaaS.Docs/README.md` +- `TakeoutSaaS.Docs/Document/README.md` diff --git a/TakeoutSaaS.BuildingBlocks b/TakeoutSaaS.BuildingBlocks new file mode 160000 index 0000000..5b07973 --- /dev/null +++ b/TakeoutSaaS.BuildingBlocks @@ -0,0 +1 @@ +Subproject commit 5b07973a39fc3bdb52f2e7c870aed7b6ea3ab388 diff --git a/TakeoutSaaS.Docs b/TakeoutSaaS.Docs new file mode 160000 index 0000000..6680599 --- /dev/null +++ b/TakeoutSaaS.Docs @@ -0,0 +1 @@ +Subproject commit 66805999120ba0e2df1e3c11100f523e2d3a7fef diff --git a/TakeoutSaaS.sln b/TakeoutSaaS.sln new file mode 100644 index 0000000..097640c --- /dev/null +++ b/TakeoutSaaS.sln @@ -0,0 +1,114 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Api", "Api", "{81034408-37C8-1011-444E-4C15C2FADA8E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.MiniApi", "src\Api\TakeoutSaaS.MiniApi\TakeoutSaaS.MiniApi.csproj", "{B2B0CCBE-B471-4282-9040-41FD4A30E368}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Application", "Application", "{22BAF98C-8415-17C4-B26A-D537657BC863}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.Application", "src\Application\TakeoutSaaS.Application\TakeoutSaaS.Application.csproj", "{9ED9512C-35CC-42D7-8EA3-317DF63F2C2D}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Domain", "Domain", "{8B290487-4C16-E85E-E807-F579CBE9FC4D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.Domain", "src\Domain\TakeoutSaaS.Domain\TakeoutSaaS.Domain.csproj", "{826F81CA-4E07-4C28-BB49-F32B2AF2F279}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Infrastructure", "Infrastructure", "{9048EB7F-3875-A59E-E36B-5BD4C6F2A282}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.Infrastructure", "src\Infrastructure\TakeoutSaaS.Infrastructure\TakeoutSaaS.Infrastructure.csproj", "{713544AA-AD1B-45AE-9A95-D27C102C7160}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Modules", "Modules", "{EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.Module.Tenancy", "src\Modules\TakeoutSaaS.Module.Tenancy\TakeoutSaaS.Module.Tenancy.csproj", "{FEC3EAFC-BED6-4A4F-B956-084F41D012BB}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {B2B0CCBE-B471-4282-9040-41FD4A30E368}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2B0CCBE-B471-4282-9040-41FD4A30E368}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2B0CCBE-B471-4282-9040-41FD4A30E368}.Debug|x64.ActiveCfg = Debug|Any CPU + {B2B0CCBE-B471-4282-9040-41FD4A30E368}.Debug|x64.Build.0 = Debug|Any CPU + {B2B0CCBE-B471-4282-9040-41FD4A30E368}.Debug|x86.ActiveCfg = Debug|Any CPU + {B2B0CCBE-B471-4282-9040-41FD4A30E368}.Debug|x86.Build.0 = Debug|Any CPU + {B2B0CCBE-B471-4282-9040-41FD4A30E368}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2B0CCBE-B471-4282-9040-41FD4A30E368}.Release|Any CPU.Build.0 = Release|Any CPU + {B2B0CCBE-B471-4282-9040-41FD4A30E368}.Release|x64.ActiveCfg = Release|Any CPU + {B2B0CCBE-B471-4282-9040-41FD4A30E368}.Release|x64.Build.0 = Release|Any CPU + {B2B0CCBE-B471-4282-9040-41FD4A30E368}.Release|x86.ActiveCfg = Release|Any CPU + {B2B0CCBE-B471-4282-9040-41FD4A30E368}.Release|x86.Build.0 = Release|Any CPU + {9ED9512C-35CC-42D7-8EA3-317DF63F2C2D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9ED9512C-35CC-42D7-8EA3-317DF63F2C2D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9ED9512C-35CC-42D7-8EA3-317DF63F2C2D}.Debug|x64.ActiveCfg = Debug|Any CPU + {9ED9512C-35CC-42D7-8EA3-317DF63F2C2D}.Debug|x64.Build.0 = Debug|Any CPU + {9ED9512C-35CC-42D7-8EA3-317DF63F2C2D}.Debug|x86.ActiveCfg = Debug|Any CPU + {9ED9512C-35CC-42D7-8EA3-317DF63F2C2D}.Debug|x86.Build.0 = Debug|Any CPU + {9ED9512C-35CC-42D7-8EA3-317DF63F2C2D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9ED9512C-35CC-42D7-8EA3-317DF63F2C2D}.Release|Any CPU.Build.0 = Release|Any CPU + {9ED9512C-35CC-42D7-8EA3-317DF63F2C2D}.Release|x64.ActiveCfg = Release|Any CPU + {9ED9512C-35CC-42D7-8EA3-317DF63F2C2D}.Release|x64.Build.0 = Release|Any CPU + {9ED9512C-35CC-42D7-8EA3-317DF63F2C2D}.Release|x86.ActiveCfg = Release|Any CPU + {9ED9512C-35CC-42D7-8EA3-317DF63F2C2D}.Release|x86.Build.0 = Release|Any CPU + {826F81CA-4E07-4C28-BB49-F32B2AF2F279}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {826F81CA-4E07-4C28-BB49-F32B2AF2F279}.Debug|Any CPU.Build.0 = Debug|Any CPU + {826F81CA-4E07-4C28-BB49-F32B2AF2F279}.Debug|x64.ActiveCfg = Debug|Any CPU + {826F81CA-4E07-4C28-BB49-F32B2AF2F279}.Debug|x64.Build.0 = Debug|Any CPU + {826F81CA-4E07-4C28-BB49-F32B2AF2F279}.Debug|x86.ActiveCfg = Debug|Any CPU + {826F81CA-4E07-4C28-BB49-F32B2AF2F279}.Debug|x86.Build.0 = Debug|Any CPU + {826F81CA-4E07-4C28-BB49-F32B2AF2F279}.Release|Any CPU.ActiveCfg = Release|Any CPU + {826F81CA-4E07-4C28-BB49-F32B2AF2F279}.Release|Any CPU.Build.0 = Release|Any CPU + {826F81CA-4E07-4C28-BB49-F32B2AF2F279}.Release|x64.ActiveCfg = Release|Any CPU + {826F81CA-4E07-4C28-BB49-F32B2AF2F279}.Release|x64.Build.0 = Release|Any CPU + {826F81CA-4E07-4C28-BB49-F32B2AF2F279}.Release|x86.ActiveCfg = Release|Any CPU + {826F81CA-4E07-4C28-BB49-F32B2AF2F279}.Release|x86.Build.0 = Release|Any CPU + {713544AA-AD1B-45AE-9A95-D27C102C7160}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {713544AA-AD1B-45AE-9A95-D27C102C7160}.Debug|Any CPU.Build.0 = Debug|Any CPU + {713544AA-AD1B-45AE-9A95-D27C102C7160}.Debug|x64.ActiveCfg = Debug|Any CPU + {713544AA-AD1B-45AE-9A95-D27C102C7160}.Debug|x64.Build.0 = Debug|Any CPU + {713544AA-AD1B-45AE-9A95-D27C102C7160}.Debug|x86.ActiveCfg = Debug|Any CPU + {713544AA-AD1B-45AE-9A95-D27C102C7160}.Debug|x86.Build.0 = Debug|Any CPU + {713544AA-AD1B-45AE-9A95-D27C102C7160}.Release|Any CPU.ActiveCfg = Release|Any CPU + {713544AA-AD1B-45AE-9A95-D27C102C7160}.Release|Any CPU.Build.0 = Release|Any CPU + {713544AA-AD1B-45AE-9A95-D27C102C7160}.Release|x64.ActiveCfg = Release|Any CPU + {713544AA-AD1B-45AE-9A95-D27C102C7160}.Release|x64.Build.0 = Release|Any CPU + {713544AA-AD1B-45AE-9A95-D27C102C7160}.Release|x86.ActiveCfg = Release|Any CPU + {713544AA-AD1B-45AE-9A95-D27C102C7160}.Release|x86.Build.0 = Release|Any CPU + {FEC3EAFC-BED6-4A4F-B956-084F41D012BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FEC3EAFC-BED6-4A4F-B956-084F41D012BB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FEC3EAFC-BED6-4A4F-B956-084F41D012BB}.Debug|x64.ActiveCfg = Debug|Any CPU + {FEC3EAFC-BED6-4A4F-B956-084F41D012BB}.Debug|x64.Build.0 = Debug|Any CPU + {FEC3EAFC-BED6-4A4F-B956-084F41D012BB}.Debug|x86.ActiveCfg = Debug|Any CPU + {FEC3EAFC-BED6-4A4F-B956-084F41D012BB}.Debug|x86.Build.0 = Debug|Any CPU + {FEC3EAFC-BED6-4A4F-B956-084F41D012BB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FEC3EAFC-BED6-4A4F-B956-084F41D012BB}.Release|Any CPU.Build.0 = Release|Any CPU + {FEC3EAFC-BED6-4A4F-B956-084F41D012BB}.Release|x64.ActiveCfg = Release|Any CPU + {FEC3EAFC-BED6-4A4F-B956-084F41D012BB}.Release|x64.Build.0 = Release|Any CPU + {FEC3EAFC-BED6-4A4F-B956-084F41D012BB}.Release|x86.ActiveCfg = Release|Any CPU + {FEC3EAFC-BED6-4A4F-B956-084F41D012BB}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {81034408-37C8-1011-444E-4C15C2FADA8E} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {B2B0CCBE-B471-4282-9040-41FD4A30E368} = {81034408-37C8-1011-444E-4C15C2FADA8E} + {22BAF98C-8415-17C4-B26A-D537657BC863} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {9ED9512C-35CC-42D7-8EA3-317DF63F2C2D} = {22BAF98C-8415-17C4-B26A-D537657BC863} + {8B290487-4C16-E85E-E807-F579CBE9FC4D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {826F81CA-4E07-4C28-BB49-F32B2AF2F279} = {8B290487-4C16-E85E-E807-F579CBE9FC4D} + {9048EB7F-3875-A59E-E36B-5BD4C6F2A282} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {713544AA-AD1B-45AE-9A95-D27C102C7160} = {9048EB7F-3875-A59E-E36B-5BD4C6F2A282} + {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {FEC3EAFC-BED6-4A4F-B956-084F41D012BB} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558} + EndGlobalSection +EndGlobal diff --git a/src/Api/Directory.Build.props b/src/Api/Directory.Build.props new file mode 100644 index 0000000..474a084 --- /dev/null +++ b/src/Api/Directory.Build.props @@ -0,0 +1,5 @@ + + + + + diff --git a/src/Api/TakeoutSaaS.MiniApi/Controllers/BootstrapController.cs b/src/Api/TakeoutSaaS.MiniApi/Controllers/BootstrapController.cs new file mode 100644 index 0000000..2df413b --- /dev/null +++ b/src/Api/TakeoutSaaS.MiniApi/Controllers/BootstrapController.cs @@ -0,0 +1,30 @@ +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using TakeoutSaaS.Application.App.Mini.Bootstrap; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Web.Api; + +namespace TakeoutSaaS.MiniApi.Controllers; + +/// +/// 小程序端启动引导接口。 +/// +[ApiVersion("1.0")] +[Route("api/mini/v{version:apiVersion}/bootstrap")] +public sealed class BootstrapController(ISender sender) : BaseApiController +{ + /// + /// 返回当前服务基础能力与运行环境信息。 + /// + /// 取消令牌。 + /// 引导信息。 + [HttpGet] + [AllowAnonymous] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> GetAsync(CancellationToken cancellationToken) + { + var response = await sender.Send(new GetMiniBootstrapQuery(), cancellationToken); + return ApiResponse.Ok(response); + } +} diff --git a/src/Api/TakeoutSaaS.MiniApi/Controllers/HealthController.cs b/src/Api/TakeoutSaaS.MiniApi/Controllers/HealthController.cs new file mode 100644 index 0000000..be7cfa3 --- /dev/null +++ b/src/Api/TakeoutSaaS.MiniApi/Controllers/HealthController.cs @@ -0,0 +1,33 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Web.Api; + +namespace TakeoutSaaS.MiniApi.Controllers; + +/// +/// 小程序端健康检查。 +/// +[ApiVersion("1.0")] +[Route("api/mini/v{version:apiVersion}/health")] +public sealed class HealthController : BaseApiController +{ + /// + /// 获取服务健康状态。 + /// + /// 健康状态。 + [HttpGet] + [AllowAnonymous] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public ApiResponse Get() + { + var payload = new + { + status = "OK", + service = "TakeoutSaaS.MiniApi", + time = DateTime.UtcNow + }; + + return ApiResponse.Ok(payload); + } +} diff --git a/src/Api/TakeoutSaaS.MiniApi/Dockerfile b/src/Api/TakeoutSaaS.MiniApi/Dockerfile new file mode 100644 index 0000000..baac5d6 --- /dev/null +++ b/src/Api/TakeoutSaaS.MiniApi/Dockerfile @@ -0,0 +1,6 @@ +FROM mcr.microsoft.com/dotnet/aspnet:10.0 +WORKDIR /app +COPY publish/ . +EXPOSE 7803 +ENV ASPNETCORE_URLS=http://+:7803 +ENTRYPOINT ["dotnet", "TakeoutSaaS.MiniApi.dll"] diff --git a/src/Api/TakeoutSaaS.MiniApi/Program.cs b/src/Api/TakeoutSaaS.MiniApi/Program.cs new file mode 100644 index 0000000..5c0d299 --- /dev/null +++ b/src/Api/TakeoutSaaS.MiniApi/Program.cs @@ -0,0 +1,148 @@ +using Microsoft.AspNetCore.Cors.Infrastructure; +using Microsoft.Extensions.Options; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; +using Serilog; +using TakeoutSaaS.Application.App.Extensions; +using TakeoutSaaS.Infrastructure.App.Extensions; +using TakeoutSaaS.Module.Tenancy.Extensions; +using TakeoutSaaS.Shared.Abstractions.Ids; +using TakeoutSaaS.Shared.Web.Extensions; +using TakeoutSaaS.Shared.Web.Swagger; +using TakeoutSaaS.Shared.Kernel.Ids; + +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(); + +builder.Host.UseSerilog((_, _, configuration) => +{ + configuration + .Enrich.FromLogContext() + .Enrich.WithProperty("Service", "TakeoutSaaS.MiniApi") + .WriteTo.Console(outputTemplate: logTemplate) + .WriteTo.File( + "logs/mini-api-.log", + rollingInterval: RollingInterval.Day, + retainedFileCountLimit: 7, + shared: true, + outputTemplate: logTemplate); +}); + +builder.Services.AddSharedWebCore(); +if (isDevelopment) +{ + builder.Services.AddSharedSwagger(options => + { + options.Title = "外卖SaaS - 小程序端"; + options.Description = "小程序 API 文档"; + options.EnableAuthorization = false; + }); +} + +builder.Services.AddAppInfrastructure(builder.Configuration); +builder.Services.AddAppApplication(); +builder.Services.AddTenantResolution(builder.Configuration); +builder.Services.AddHealthChecks(); +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(IdGeneratorOptions.SectionName)) + .ValidateDataAnnotations(); +builder.Services.AddSingleton(provider => +{ + var options = provider.GetRequiredService>().Value; + return new SnowflakeIdGenerator(options.WorkerId, options.DatacenterId); +}); + +var otelSection = builder.Configuration.GetSection("Otel"); +var otelEndpoint = otelSection.GetValue("Endpoint"); +var useConsoleExporter = otelSection.GetValue("UseConsoleExporter") ?? 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(); + + 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(); + } + }); + +var miniOrigins = ResolveCorsOrigins(builder.Configuration, "Cors:Mini"); +builder.Services.AddCors(options => +{ + options.AddPolicy("MiniApiCors", policy => + { + ConfigureCorsPolicy(policy, miniOrigins); + }); +}); + +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(); + +static string[] ResolveCorsOrigins(IConfiguration configuration, string sectionKey) +{ + var origins = configuration.GetSection(sectionKey).Get(); + return origins? + .Where(origin => !string.IsNullOrWhiteSpace(origin)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray() ?? []; +} + +static void ConfigureCorsPolicy(CorsPolicyBuilder policy, string[] origins) +{ + if (origins.Length == 0) + { + policy.AllowAnyOrigin(); + } + else + { + policy.WithOrigins(origins); + } + + policy + .AllowAnyHeader() + .AllowAnyMethod(); +} diff --git a/src/Api/TakeoutSaaS.MiniApi/Properties/launchSettings.json b/src/Api/TakeoutSaaS.MiniApi/Properties/launchSettings.json new file mode 100644 index 0000000..fbb9e28 --- /dev/null +++ b/src/Api/TakeoutSaaS.MiniApi/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "TakeoutSaaS.MiniApi": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "api/docs", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "http://localhost:2683" + } + } +} diff --git a/src/Api/TakeoutSaaS.MiniApi/TakeoutSaaS.MiniApi.csproj b/src/Api/TakeoutSaaS.MiniApi/TakeoutSaaS.MiniApi.csproj new file mode 100644 index 0000000..28125e1 --- /dev/null +++ b/src/Api/TakeoutSaaS.MiniApi/TakeoutSaaS.MiniApi.csproj @@ -0,0 +1,28 @@ + + + net10.0 + enable + enable + true + Linux + ../../.. + + + + + + + + + + + + + + + + + + + + diff --git a/src/Api/TakeoutSaaS.MiniApi/appsettings.Development.json b/src/Api/TakeoutSaaS.MiniApi/appsettings.Development.json new file mode 100644 index 0000000..5433dca --- /dev/null +++ b/src/Api/TakeoutSaaS.MiniApi/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Cors": { + "Mini": [] + }, + "Otel": { + "Endpoint": "", + "UseConsoleExporter": true + } +} diff --git a/src/Api/TakeoutSaaS.MiniApi/appsettings.Production.json b/src/Api/TakeoutSaaS.MiniApi/appsettings.Production.json new file mode 100644 index 0000000..94a5d9a --- /dev/null +++ b/src/Api/TakeoutSaaS.MiniApi/appsettings.Production.json @@ -0,0 +1,9 @@ +{ + "Cors": { + "Mini": [] + }, + "Otel": { + "Endpoint": "", + "UseConsoleExporter": false + } +} diff --git a/src/Api/TakeoutSaaS.MiniApi/appsettings.json b/src/Api/TakeoutSaaS.MiniApi/appsettings.json new file mode 100644 index 0000000..c94b7c4 --- /dev/null +++ b/src/Api/TakeoutSaaS.MiniApi/appsettings.json @@ -0,0 +1,23 @@ +{ + "AllowedHosts": "*", + "IdGenerator": { + "WorkerId": 0, + "DatacenterId": 0 + }, + "Tenancy": { + "TenantIdHeaderName": "X-Tenant-Id", + "TenantCodeHeaderName": "X-Tenant-Code", + "IgnoredPaths": [ + "/healthz", + "/api/mini/v1/health" + ], + "ThrowIfUnresolved": false + }, + "Cors": { + "Mini": [] + }, + "Otel": { + "Endpoint": "", + "UseConsoleExporter": false + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Common/Behaviors/ValidationBehavior.cs b/src/Application/TakeoutSaaS.Application/App/Common/Behaviors/ValidationBehavior.cs new file mode 100644 index 0000000..a846c70 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Common/Behaviors/ValidationBehavior.cs @@ -0,0 +1,44 @@ +using FluentValidation; +using MediatR; +using SharedValidationException = TakeoutSaaS.Shared.Abstractions.Exceptions.ValidationException; + +namespace TakeoutSaaS.Application.App.Common.Behaviors; + +/// +/// MediatR 校验行为。 +/// +/// 请求类型。 +/// 响应类型。 +public sealed class ValidationBehavior(IEnumerable> validators) + : IPipelineBehavior + where TRequest : notnull +{ + /// + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + if (!validators.Any()) + { + return await next(); + } + + var context = new ValidationContext(request); + var validationResults = await Task.WhenAll(validators.Select(validator => validator.ValidateAsync(context, cancellationToken))); + var failures = validationResults + .SelectMany(result => result.Errors) + .Where(error => error is not null) + .GroupBy(error => error.PropertyName) + .ToDictionary( + group => group.Key, + group => group.Select(error => error.ErrorMessage).Distinct().ToArray()); + + if (failures.Count > 0) + { + throw new SharedValidationException(failures); + } + + return await next(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Extensions/AppApplicationServiceCollectionExtensions.cs b/src/Application/TakeoutSaaS.Application/App/Extensions/AppApplicationServiceCollectionExtensions.cs new file mode 100644 index 0000000..9c92d11 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Extensions/AppApplicationServiceCollectionExtensions.cs @@ -0,0 +1,26 @@ +using FluentValidation; +using MediatR; +using Microsoft.Extensions.DependencyInjection; +using System.Reflection; +using TakeoutSaaS.Application.App.Common.Behaviors; + +namespace TakeoutSaaS.Application.App.Extensions; + +/// +/// 业务应用层服务注册。 +/// +public static class AppApplicationServiceCollectionExtensions +{ + /// + /// 注册应用层处理器与验证管道。 + /// + /// 服务集合。 + /// 服务集合。 + public static IServiceCollection AddAppApplication(this IServiceCollection services) + { + services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly())); + services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()); + services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); + return services; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Mini/Bootstrap/GetMiniBootstrapQuery.cs b/src/Application/TakeoutSaaS.Application/App/Mini/Bootstrap/GetMiniBootstrapQuery.cs new file mode 100644 index 0000000..3588e51 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Mini/Bootstrap/GetMiniBootstrapQuery.cs @@ -0,0 +1,8 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.Mini.Bootstrap; + +/// +/// 获取小程序端启动引导信息。 +/// +public sealed record GetMiniBootstrapQuery : IRequest; diff --git a/src/Application/TakeoutSaaS.Application/App/Mini/Bootstrap/GetMiniBootstrapQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Mini/Bootstrap/GetMiniBootstrapQueryHandler.cs new file mode 100644 index 0000000..729041d --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Mini/Bootstrap/GetMiniBootstrapQueryHandler.cs @@ -0,0 +1,33 @@ +using MediatR; +using Microsoft.Extensions.Hosting; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Mini.Bootstrap; + +/// +/// 小程序端启动引导查询处理器。 +/// +public sealed class GetMiniBootstrapQueryHandler( + IHostEnvironment environment, + ITenantContextAccessor tenantContextAccessor) + : IRequestHandler +{ + private static readonly string[] Scenes = ["Delivery", "Pickup", "DineIn"]; + private static readonly string[] Channels = ["WeChatMiniProgram"]; + + /// + public Task Handle(GetMiniBootstrapQuery request, CancellationToken cancellationToken) + { + var response = new MiniBootstrapResponse + { + Service = "TakeoutSaaS.MiniApi", + Environment = environment.EnvironmentName, + ServerTime = DateTime.UtcNow, + SupportedScenes = Scenes, + SupportedChannels = Channels, + TenantCode = tenantContextAccessor.Current?.TenantCode + }; + + return Task.FromResult(response); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Mini/Bootstrap/MiniBootstrapResponse.cs b/src/Application/TakeoutSaaS.Application/App/Mini/Bootstrap/MiniBootstrapResponse.cs new file mode 100644 index 0000000..1d97c94 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Mini/Bootstrap/MiniBootstrapResponse.cs @@ -0,0 +1,37 @@ +namespace TakeoutSaaS.Application.App.Mini.Bootstrap; + +/// +/// 小程序端启动引导响应。 +/// +public sealed record MiniBootstrapResponse +{ + /// + /// 服务标识。 + /// + public string Service { get; init; } = string.Empty; + + /// + /// 当前环境名称。 + /// + public string Environment { get; init; } = string.Empty; + + /// + /// 服务端时间。 + /// + public DateTime ServerTime { get; init; } + + /// + /// 支持的履约场景。 + /// + public IReadOnlyList SupportedScenes { get; init; } = []; + + /// + /// 支持的渠道。 + /// + public IReadOnlyList SupportedChannels { get; init; } = []; + + /// + /// 当前请求解析到的租户编码。 + /// + public string? TenantCode { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/TakeoutSaaS.Application.csproj b/src/Application/TakeoutSaaS.Application/TakeoutSaaS.Application.csproj new file mode 100644 index 0000000..3c980c5 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/TakeoutSaaS.Application.csproj @@ -0,0 +1,16 @@ + + + net10.0 + enable + enable + + + + + + + + + + + diff --git a/src/Domain/TakeoutSaaS.Domain/TakeoutSaaS.Domain.csproj b/src/Domain/TakeoutSaaS.Domain/TakeoutSaaS.Domain.csproj new file mode 100644 index 0000000..1b9e936 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/TakeoutSaaS.Domain.csproj @@ -0,0 +1,12 @@ + + + net10.0 + enable + enable + true + 1591 + + + + + diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs new file mode 100644 index 0000000..1b46430 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs @@ -0,0 +1,22 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace TakeoutSaaS.Infrastructure.App.Extensions; + +/// +/// 基础设施服务注册。 +/// +public static class AppServiceCollectionExtensions +{ + /// + /// 注册骨架所需的基础设施能力。 + /// + /// 服务集合。 + /// 配置源。 + /// 服务集合。 + public static IServiceCollection AddAppInfrastructure(this IServiceCollection services, IConfiguration configuration) + { + _ = configuration; + return services; + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/TakeoutSaaS.Infrastructure.csproj b/src/Infrastructure/TakeoutSaaS.Infrastructure/TakeoutSaaS.Infrastructure.csproj new file mode 100644 index 0000000..3167dba --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/TakeoutSaaS.Infrastructure.csproj @@ -0,0 +1,16 @@ + + + net10.0 + enable + enable + + + + + + + + + + + diff --git a/src/Modules/TakeoutSaaS.Module.Tenancy/Extensions/TenantServiceCollectionExtensions.cs b/src/Modules/TakeoutSaaS.Module.Tenancy/Extensions/TenantServiceCollectionExtensions.cs new file mode 100644 index 0000000..cbb1af4 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Tenancy/Extensions/TenantServiceCollectionExtensions.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Module.Tenancy.Extensions; + +/// +/// 多租户服务注册与应用扩展。 +/// +public static class TenantServiceCollectionExtensions +{ + /// + /// 注册轻量多租户服务。 + /// + /// 服务集合。 + /// 配置源。 + /// 服务集合。 + public static IServiceCollection AddTenantResolution(this IServiceCollection services, IConfiguration configuration) + { + services.TryAddSingleton(); + services.TryAddScoped(); + services.TryAddScoped(); + + services.AddOptions() + .Bind(configuration.GetSection("Tenancy")) + .ValidateDataAnnotations(); + + return services; + } + + /// + /// 启用租户解析中间件。 + /// + /// 应用实例。 + /// 应用实例。 + public static IApplicationBuilder UseTenantResolution(this IApplicationBuilder app) + => app.UseMiddleware(); +} diff --git a/src/Modules/TakeoutSaaS.Module.Tenancy/ITenantCodeResolver.cs b/src/Modules/TakeoutSaaS.Module.Tenancy/ITenantCodeResolver.cs new file mode 100644 index 0000000..c0e21d9 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Tenancy/ITenantCodeResolver.cs @@ -0,0 +1,15 @@ +namespace TakeoutSaaS.Module.Tenancy; + +/// +/// 租户编码解析器。 +/// +public interface ITenantCodeResolver +{ + /// + /// 根据租户编码解析租户 ID。 + /// + /// 租户编码。 + /// 取消令牌。 + /// 租户 ID;未找到时返回 null + Task ResolveAsync(string code, CancellationToken cancellationToken = default); +} diff --git a/src/Modules/TakeoutSaaS.Module.Tenancy/NoOpTenantCodeResolver.cs b/src/Modules/TakeoutSaaS.Module.Tenancy/NoOpTenantCodeResolver.cs new file mode 100644 index 0000000..d9fb291 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Tenancy/NoOpTenantCodeResolver.cs @@ -0,0 +1,15 @@ +namespace TakeoutSaaS.Module.Tenancy; + +/// +/// 默认空实现,不做数据库或远程租户解析。 +/// +public sealed class NoOpTenantCodeResolver : ITenantCodeResolver +{ + /// + public Task ResolveAsync(string code, CancellationToken cancellationToken = default) + { + _ = code; + _ = cancellationToken; + return Task.FromResult(null); + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Tenancy/TakeoutSaaS.Module.Tenancy.csproj b/src/Modules/TakeoutSaaS.Module.Tenancy/TakeoutSaaS.Module.Tenancy.csproj new file mode 100644 index 0000000..955acee --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Tenancy/TakeoutSaaS.Module.Tenancy.csproj @@ -0,0 +1,13 @@ + + + net10.0 + enable + enable + + + + + + + + diff --git a/src/Modules/TakeoutSaaS.Module.Tenancy/TenantContextAccessor.cs b/src/Modules/TakeoutSaaS.Module.Tenancy/TenantContextAccessor.cs new file mode 100644 index 0000000..8d77d9e --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Tenancy/TenantContextAccessor.cs @@ -0,0 +1,33 @@ +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Module.Tenancy; + +/// +/// 基于 的租户上下文访问器。 +/// +public sealed class TenantContextAccessor : ITenantContextAccessor +{ + private static readonly AsyncLocal Holder = new(); + + /// + public TenantContext? Current + { + get => Holder.Value?.Context; + set + { + if (Holder.Value != null) + { + Holder.Value.Context = value; + } + else if (value != null) + { + Holder.Value = new TenantContextHolder { Context = value }; + } + } + } + + private sealed class TenantContextHolder + { + public TenantContext? Context { get; set; } + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Tenancy/TenantProvider.cs b/src/Modules/TakeoutSaaS.Module.Tenancy/TenantProvider.cs new file mode 100644 index 0000000..9ca951b --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Tenancy/TenantProvider.cs @@ -0,0 +1,13 @@ +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Module.Tenancy; + +/// +/// 默认租户提供者。 +/// +public sealed class TenantProvider(ITenantContextAccessor tenantContextAccessor) : ITenantProvider +{ + /// + public long GetCurrentTenantId() + => tenantContextAccessor.Current?.TenantId ?? 0; +} diff --git a/src/Modules/TakeoutSaaS.Module.Tenancy/TenantResolutionMiddleware.cs b/src/Modules/TakeoutSaaS.Module.Tenancy/TenantResolutionMiddleware.cs new file mode 100644 index 0000000..c150a27 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Tenancy/TenantResolutionMiddleware.cs @@ -0,0 +1,106 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Module.Tenancy; + +/// +/// 轻量多租户解析中间件。 +/// +public sealed class TenantResolutionMiddleware( + RequestDelegate next, + ILogger logger, + ITenantContextAccessor tenantContextAccessor, + IOptionsMonitor optionsMonitor) +{ + /// + /// 执行租户解析并写入上下文。 + /// + /// HTTP 上下文。 + /// 租户编码解析器。 + /// 任务。 + public async Task InvokeAsync(HttpContext context, ITenantCodeResolver tenantCodeResolver) + { + var options = optionsMonitor.CurrentValue ?? new TenantResolutionOptions(); + if (ShouldSkip(context.Request.Path, options)) + { + await next(context); + return; + } + + var tenantContext = await ResolveTenantAsync(context, options, tenantCodeResolver); + tenantContextAccessor.Current = tenantContext; + context.Items[TenantConstants.HttpContextItemKey] = tenantContext; + + if (!tenantContext.IsResolved && options.ThrowIfUnresolved) + { + logger.LogDebug("未能解析租户:{Path}", context.Request.Path); + var response = ApiResponse.Error(ErrorCodes.BadRequest, "缺少租户标识"); + context.Response.StatusCode = StatusCodes.Status400BadRequest; + await context.Response.WriteAsJsonAsync(response, cancellationToken: context.RequestAborted); + tenantContextAccessor.Current = null; + context.Items.Remove(TenantConstants.HttpContextItemKey); + return; + } + + try + { + await next(context); + } + finally + { + tenantContextAccessor.Current = null; + context.Items.Remove(TenantConstants.HttpContextItemKey); + } + } + + private static bool ShouldSkip(PathString path, TenantResolutionOptions options) + { + if (!path.HasValue) + { + return false; + } + + return options.IgnoredPaths.Any(ignore => + { + if (string.IsNullOrWhiteSpace(ignore)) + { + return false; + } + + return path.StartsWithSegments(ignore, StringComparison.OrdinalIgnoreCase); + }); + } + + private static async Task ResolveTenantAsync( + HttpContext context, + TenantResolutionOptions options, + ITenantCodeResolver tenantCodeResolver) + { + var request = context.Request; + + if (!string.IsNullOrWhiteSpace(options.TenantIdHeaderName) && + request.Headers.TryGetValue(options.TenantIdHeaderName, out var tenantIdHeader) && + long.TryParse(tenantIdHeader.FirstOrDefault(), out var tenantId) && + tenantId > 0) + { + return new TenantContext(tenantId, null, $"header:{options.TenantIdHeaderName}"); + } + + if (!string.IsNullOrWhiteSpace(options.TenantCodeHeaderName) && + request.Headers.TryGetValue(options.TenantCodeHeaderName, out var tenantCodeHeader)) + { + var tenantCode = tenantCodeHeader.FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(tenantCode)) + { + var resolvedTenantId = await tenantCodeResolver.ResolveAsync(tenantCode, context.RequestAborted); + return new TenantContext(resolvedTenantId ?? 0, tenantCode, $"header:{options.TenantCodeHeaderName}"); + } + } + + return TenantContext.Empty; + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Tenancy/TenantResolutionOptions.cs b/src/Modules/TakeoutSaaS.Module.Tenancy/TenantResolutionOptions.cs new file mode 100644 index 0000000..bf471d8 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Tenancy/TenantResolutionOptions.cs @@ -0,0 +1,38 @@ +using System.Collections.ObjectModel; + +namespace TakeoutSaaS.Module.Tenancy; + +/// +/// 多租户解析配置。 +/// +public sealed class TenantResolutionOptions +{ + /// + /// Header 租户 ID 名称。 + /// + public string TenantIdHeaderName { get; set; } = "X-Tenant-Id"; + + /// + /// Header 租户编码名称。 + /// + public string TenantCodeHeaderName { get; set; } = "X-Tenant-Code"; + + /// + /// 跳过解析的路径列表。 + /// + public ISet IgnoredPaths { get; } = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "/healthz", + "/api/mini/v1/health" + }; + + /// + /// 未解析租户时是否立即返回 400。 + /// + public bool ThrowIfUnresolved { get; set; } + + /// + /// 对外只读的忽略路径视图。 + /// + public IReadOnlyCollection IgnoredPathItems => new ReadOnlyCollection(IgnoredPaths.ToList()); +} diff --git a/stylecop.json b/stylecop.json new file mode 100644 index 0000000..c5f59ab --- /dev/null +++ b/stylecop.json @@ -0,0 +1,7 @@ +{ + "settings": { + "documentationRules": { + "documentationCulture": "zh-CN" + } + } +}