commit c6465480a91cd9ae98ecd5f1a655a19484bcc70f Author: MSuMshk <2039814060@qq.com> Date: Mon Mar 9 13:13:41 2026 +0800 feat: initialize mini api skeleton 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" + } + } +}