diff --git a/Document/05_部署运维.md b/Document/05_部署运维.md
index 2d90a48..f92e303 100644
--- a/Document/05_部署运维.md
+++ b/Document/05_部署运维.md
@@ -1009,3 +1009,50 @@ docker-compose up -d --force-recreate --no-deps api
docker pull takeout-saas-api:previous-version
docker-compose up -d
```
+
+## 13. 网关 TakeoutSaaS.ApiGateway 部署
+
+1. **部署拓扑**
+ - Nginx 负责域名 `kjkj.qiyuesns.cn`(含后续 HTTPS 证书),并将所有流量反代到本机 `http://127.0.0.1:5000`。
+ - .NET 网关容器(TakeoutSaaS.ApiGateway)负责 YARP 路由、限流、日志与 OpenTelemetry 埋点,向下游 49.7.179.246 的 Admin/User/Mini API 转发。
+
+2. **构建与运行**
+ ```bash
+ # 构建镜像
+ docker build -f src/Gateway/TakeoutSaaS.ApiGateway/Dockerfile -t takeoutsaas/apigateway:latest .
+
+ # 启动容器(生产环境建议挂载独立配置)
+ docker run -d --name takeout-gateway -p 5000:5000 ^
+ -e ASPNETCORE_ENVIRONMENT=Production ^
+ -v /opt/takeoutsaas/gateway/appsettings.Production.json:/app/appsettings.Production.json ^
+ takeoutsaas/apigateway:latest
+ ```
+ - `appsettings.json` 默认将 `/api/admin|mini|user/**` 指向主应用服务器(7801/7701/7901 端口)。
+ - `appsettings.Development.json` 可在本地覆盖为 `localhost:5001/5002/5003`,无需改代码。
+
+3. **Nginx 参考配置**
+ ```nginx
+ server {
+ listen 80;
+ server_name kjkj.qiyuesns.cn;
+
+ location / {
+ proxy_pass http://127.0.0.1:5000;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+ }
+ ```
+ - 后续启用 HTTPS 时,将 `listen 443 ssl` 与证书配置加入同一 `server`,其余保持不变。
+
+4. **关键配置项说明**
+ - `Gateway:RateLimiting`:按客户端 IP 固定窗口限流(默认 300 次/60 秒),可通过配置文件调整或关闭。
+ - `ReverseProxy`:集中声明路由规则(`/api/{service}/**`),后端地址变更时只需改配置即可。
+ - `OpenTelemetry`:默认开启 OTLP 导出,Collector 地址通过 `OpenTelemetry:OtlpEndpoint` 指定。
+ - `Serilog`:统一输出到控制台,日志采集器可以直接收集 Docker stdout。
+
+5. **健康检查**
+ - `GET /healthz`:基础健康,用于探活或监控告警。
+ - `GET /`:返回服务元信息,可作为简易诊断接口。
diff --git a/src/Gateway/TakeoutSaaS.ApiGateway/Configuration/GatewayOpenTelemetryOptions.cs b/src/Gateway/TakeoutSaaS.ApiGateway/Configuration/GatewayOpenTelemetryOptions.cs
new file mode 100644
index 0000000..97064fb
--- /dev/null
+++ b/src/Gateway/TakeoutSaaS.ApiGateway/Configuration/GatewayOpenTelemetryOptions.cs
@@ -0,0 +1,22 @@
+namespace TakeoutSaaS.ApiGateway.Configuration;
+
+///
+/// 网关 OpenTelemetry 导出配置。
+///
+public class GatewayOpenTelemetryOptions
+{
+ ///
+ /// 是否启用 OpenTelemetry。
+ ///
+ public bool Enabled { get; set; } = true;
+
+ ///
+ /// 服务名称,用于 Resource 标识。
+ ///
+ public string ServiceName { get; set; } = "TakeoutSaaS.ApiGateway";
+
+ ///
+ /// OTLP 导出端点(http/https)。
+ ///
+ public string? OtlpEndpoint { get; set; }
+}
diff --git a/src/Gateway/TakeoutSaaS.ApiGateway/Configuration/GatewayRateLimitOptions.cs b/src/Gateway/TakeoutSaaS.ApiGateway/Configuration/GatewayRateLimitOptions.cs
new file mode 100644
index 0000000..cf633a4
--- /dev/null
+++ b/src/Gateway/TakeoutSaaS.ApiGateway/Configuration/GatewayRateLimitOptions.cs
@@ -0,0 +1,27 @@
+namespace TakeoutSaaS.ApiGateway.Configuration;
+
+///
+/// 网关限流参数配置。
+///
+public class GatewayRateLimitOptions
+{
+ ///
+ /// 是否开启固定窗口限流。
+ ///
+ public bool Enabled { get; set; } = true;
+
+ ///
+ /// 固定窗口内允许的最大请求数。
+ ///
+ public int PermitLimit { get; set; } = 300;
+
+ ///
+ /// 固定窗口长度(秒)。
+ ///
+ public int WindowSeconds { get; set; } = 60;
+
+ ///
+ /// 排队等待的最大请求数。
+ ///
+ public int QueueLimit { get; set; } = 100;
+}
diff --git a/src/Gateway/TakeoutSaaS.ApiGateway/Dockerfile b/src/Gateway/TakeoutSaaS.ApiGateway/Dockerfile
new file mode 100644
index 0000000..beac7d4
--- /dev/null
+++ b/src/Gateway/TakeoutSaaS.ApiGateway/Dockerfile
@@ -0,0 +1,25 @@
+FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
+WORKDIR /src
+
+# 1. 先只复制 csproj 文件 (利用 Docker 缓存)
+COPY ["src/Gateway/TakeoutSaaS.ApiGateway/TakeoutSaaS.ApiGateway.csproj", "src/Gateway/TakeoutSaaS.ApiGateway/"]
+
+# 2. 还原依赖 (如果 csproj 没变,这一步会直接使用缓存,瞬间完成)
+RUN dotnet restore "src/Gateway/TakeoutSaaS.ApiGateway/TakeoutSaaS.ApiGateway.csproj"
+
+# 3. 再复制剩余的所有源代码
+COPY . .
+
+# 4. 发布
+RUN dotnet publish "src/Gateway/TakeoutSaaS.ApiGateway/TakeoutSaaS.ApiGateway.csproj" -c Release -o /app/publish
+
+# --- 运行时环境 ---
+FROM mcr.microsoft.com/dotnet/aspnet:10.0
+WORKDIR /app
+COPY --from=build /app/publish .
+
+# 显式声明端口
+EXPOSE 5000
+ENV ASPNETCORE_URLS=http://+:5000
+
+ENTRYPOINT ["dotnet", "TakeoutSaaS.ApiGateway.dll"]
\ No newline at end of file
diff --git a/src/Gateway/TakeoutSaaS.ApiGateway/Program.cs b/src/Gateway/TakeoutSaaS.ApiGateway/Program.cs
index f7467d5..2e76835 100644
--- a/src/Gateway/TakeoutSaaS.ApiGateway/Program.cs
+++ b/src/Gateway/TakeoutSaaS.ApiGateway/Program.cs
@@ -1,38 +1,129 @@
+using System.Diagnostics;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Hosting;
-using Microsoft.Extensions.Logging;
-using System.Diagnostics;
+using OpenTelemetry.Logs;
+using OpenTelemetry.Metrics;
+using OpenTelemetry.Resources;
+using OpenTelemetry.Trace;
+using Serilog;
+using TakeoutSaaS.ApiGateway.Configuration;
using System.Threading.RateLimiting;
+const string CorsPolicyName = "GatewayCors";
+
var builder = WebApplication.CreateBuilder(args);
+builder.Host.UseSerilog((context, services, loggerConfiguration) =>
+{
+ loggerConfiguration
+ .ReadFrom.Configuration(context.Configuration)
+ .ReadFrom.Services(services)
+ .Enrich.FromLogContext();
+});
+
builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));
-builder.Services.AddRateLimiter(options =>
+builder.Services.Configure(options =>
{
- options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
- options.GlobalLimiter = PartitionedRateLimiter.Create(httpContext =>
+ options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
+ options.KnownIPNetworks.Clear();
+ options.KnownProxies.Clear();
+});
+
+builder.Services.AddCors(options =>
+{
+ options.AddPolicy(CorsPolicyName, policy =>
{
- const string partitionKey = "proxy-default";
- return RateLimitPartition.GetFixedWindowLimiter(partitionKey, _ => new FixedWindowRateLimiterOptions
- {
- PermitLimit = 100,
- Window = TimeSpan.FromSeconds(1),
- QueueLimit = 50,
- QueueProcessingOrder = QueueProcessingOrder.OldestFirst
- });
+ policy.AllowAnyOrigin()
+ .AllowAnyHeader()
+ .AllowAnyMethod();
});
});
+builder.Services.Configure(builder.Configuration.GetSection("Gateway:RateLimiting"));
+var rateLimitOptions = builder.Configuration.GetSection("Gateway:RateLimiting").Get() ?? new();
+
+if (rateLimitOptions.Enabled)
+{
+ builder.Services.AddRateLimiter(options =>
+ {
+ options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
+ options.GlobalLimiter = PartitionedRateLimiter.Create(context =>
+ {
+ var remoteIp = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
+ return RateLimitPartition.GetFixedWindowLimiter(remoteIp, _ => new FixedWindowRateLimiterOptions
+ {
+ PermitLimit = Math.Max(1, rateLimitOptions.PermitLimit),
+ Window = TimeSpan.FromSeconds(Math.Max(1, rateLimitOptions.WindowSeconds)),
+ QueueLimit = Math.Max(0, rateLimitOptions.QueueLimit),
+ QueueProcessingOrder = QueueProcessingOrder.OldestFirst
+ });
+ });
+ });
+}
+
+var otelOptions = builder.Configuration.GetSection("OpenTelemetry").Get() ?? new();
+if (otelOptions.Enabled)
+{
+ builder.Services.AddOpenTelemetry()
+ // 1. 配置统一的 Resource,便于追踪定位。
+ .ConfigureResource(resource => resource.AddService(otelOptions.ServiceName ?? "TakeoutSaaS.ApiGateway"))
+ .WithMetrics(metrics =>
+ {
+ metrics.AddAspNetCoreInstrumentation()
+ .AddHttpClientInstrumentation()
+ .AddRuntimeInstrumentation();
+
+ if (!string.IsNullOrWhiteSpace(otelOptions.OtlpEndpoint))
+ {
+ metrics.AddOtlpExporter(options =>
+ {
+ options.Endpoint = new Uri(otelOptions.OtlpEndpoint);
+ });
+ }
+ })
+ .WithTracing(tracing =>
+ {
+ tracing.AddAspNetCoreInstrumentation(options =>
+ {
+ options.RecordException = true;
+ })
+ .AddHttpClientInstrumentation();
+
+ if (!string.IsNullOrWhiteSpace(otelOptions.OtlpEndpoint))
+ {
+ tracing.AddOtlpExporter(options =>
+ {
+ options.Endpoint = new Uri(otelOptions.OtlpEndpoint);
+ });
+ }
+ });
+
+ builder.Logging.AddOpenTelemetry(logging =>
+ {
+ logging.IncludeScopes = true;
+ logging.ParseStateValues = true;
+ if (!string.IsNullOrWhiteSpace(otelOptions.OtlpEndpoint))
+ {
+ logging.AddOtlpExporter(options =>
+ {
+ options.Endpoint = new Uri(otelOptions.OtlpEndpoint);
+ });
+ }
+ });
+}
+
var app = builder.Build();
+app.UseForwardedHeaders();
+
app.UseExceptionHandler(errorApp =>
{
+ // 1. 捕获所有未处理异常并返回统一结构。
errorApp.Run(async context =>
{
var feature = context.Features.Get();
@@ -54,36 +145,40 @@ app.UseExceptionHandler(errorApp =>
});
});
-app.Use(async (context, next) =>
+app.UseSerilogRequestLogging(options =>
{
- var logger = context.RequestServices.GetRequiredService().CreateLogger("Gateway");
- var start = DateTime.UtcNow;
- await next(context);
- var elapsed = DateTime.UtcNow - start;
- logger.LogInformation("Gateway {Method} {Path} => {Status} ({Elapsed} ms)",
- context.Request.Method,
- context.Request.Path,
- context.Response.StatusCode,
- (int)elapsed.TotalMilliseconds);
+ options.MessageTemplate = "网关请求 {RequestMethod} {RequestPath} => {StatusCode} 用时 {Elapsed:0.000} 秒";
+ options.GetLevel = (httpContext, elapsed, ex) => ex is not null ? Serilog.Events.LogEventLevel.Error : Serilog.Events.LogEventLevel.Information;
+ options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
+ {
+ diagnosticContext.Set("TraceId", Activity.Current?.Id ?? httpContext.TraceIdentifier);
+ diagnosticContext.Set("ClientIp", httpContext.Connection.RemoteIpAddress?.ToString());
+ };
});
-app.UseRateLimiter();
+app.UseCors(CorsPolicyName);
+
+if (rateLimitOptions.Enabled)
+{
+ app.UseRateLimiter();
+}
app.Use(async (context, next) =>
{
- // 确保存在请求 ID,便于上下游链路追踪。
+ // 1. 确保请求拥有可追踪的 ID。
if (!context.Request.Headers.ContainsKey("X-Request-Id"))
{
- context.Request.Headers["X-Request-Id"] = Guid.NewGuid().ToString("N");
+ context.Request.Headers["X-Request-Id"] = Activity.Current?.Id ?? Guid.NewGuid().ToString("N");
}
- // 透传租户与认证头。
+ // 2. 透传租户等标识头,方便下游继续使用。
var tenantId = context.Request.Headers["X-Tenant-Id"];
- var tenantCode = context.Request.Headers["X-Tenant-Code"];
if (!string.IsNullOrWhiteSpace(tenantId))
{
context.Request.Headers["X-Tenant-Id"] = tenantId;
}
+
+ var tenantCode = context.Request.Headers["X-Tenant-Code"];
if (!string.IsNullOrWhiteSpace(tenantCode))
{
context.Request.Headers["X-Tenant-Code"] = tenantCode;
@@ -92,13 +187,7 @@ app.Use(async (context, next) =>
await next(context);
});
-app.MapReverseProxy(proxyPipeline =>
-{
- proxyPipeline.Use(async (context, next) =>
- {
- await next().ConfigureAwait(false);
- });
-});
+app.MapReverseProxy();
app.MapGet("/", () => Results.Json(new
{
@@ -107,4 +196,11 @@ app.MapGet("/", () => Results.Json(new
Timestamp = DateTimeOffset.UtcNow
}));
+app.MapGet("/healthz", () => Results.Json(new
+{
+ Service = "TakeoutSaaS.ApiGateway",
+ Status = "Healthy",
+ Timestamp = DateTimeOffset.UtcNow
+}));
+
app.Run();
diff --git a/src/Gateway/TakeoutSaaS.ApiGateway/TakeoutSaaS.ApiGateway.csproj b/src/Gateway/TakeoutSaaS.ApiGateway/TakeoutSaaS.ApiGateway.csproj
index 463d6c9..8d989af 100644
Binary files a/src/Gateway/TakeoutSaaS.ApiGateway/TakeoutSaaS.ApiGateway.csproj and b/src/Gateway/TakeoutSaaS.ApiGateway/TakeoutSaaS.ApiGateway.csproj differ
diff --git a/src/Gateway/TakeoutSaaS.ApiGateway/appsettings.Development.json b/src/Gateway/TakeoutSaaS.ApiGateway/appsettings.Development.json
index 9c7c501..6c70a10 100644
--- a/src/Gateway/TakeoutSaaS.ApiGateway/appsettings.Development.json
+++ b/src/Gateway/TakeoutSaaS.ApiGateway/appsettings.Development.json
@@ -1,4 +1,7 @@
{
+ "OpenTelemetry": {
+ "Enabled": false
+ },
"ReverseProxy": {
"Routes": [
{
diff --git a/src/Gateway/TakeoutSaaS.ApiGateway/appsettings.json b/src/Gateway/TakeoutSaaS.ApiGateway/appsettings.json
new file mode 100644
index 0000000..a2f3c3c
--- /dev/null
+++ b/src/Gateway/TakeoutSaaS.ApiGateway/appsettings.json
@@ -0,0 +1,75 @@
+{
+ "Serilog": {
+ "Using": [ "Serilog.Sinks.Console" ],
+ "MinimumLevel": {
+ "Default": "Information",
+ "Override": {
+ "Microsoft": "Warning",
+ "System": "Warning",
+ "Yarp": "Information"
+ }
+ },
+ "WriteTo": [
+ {
+ "Name": "Console",
+ "Args": {
+ "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}"
+ }
+ }
+ ],
+ "Enrich": [ "FromLogContext" ],
+ "Properties": {
+ "Application": "TakeoutSaaS.ApiGateway"
+ }
+ },
+ "Gateway": {
+ "RateLimiting": {
+ "Enabled": true,
+ "PermitLimit": 300,
+ "WindowSeconds": 60,
+ "QueueLimit": 100
+ }
+ },
+ "OpenTelemetry": {
+ "Enabled": true,
+ "ServiceName": "TakeoutSaaS.ApiGateway",
+ "OtlpEndpoint": "http://localhost:4317"
+ },
+ "ReverseProxy": {
+ "Routes": [
+ {
+ "RouteId": "admin-route",
+ "ClusterId": "admin",
+ "Match": { "Path": "/api/admin/{**catch-all}" }
+ },
+ {
+ "RouteId": "mini-route",
+ "ClusterId": "mini",
+ "Match": { "Path": "/api/mini/{**catch-all}" }
+ },
+ {
+ "RouteId": "user-route",
+ "ClusterId": "user",
+ "Match": { "Path": "/api/user/{**catch-all}" }
+ }
+ ],
+ "Clusters": {
+ "admin": {
+ "Destinations": {
+ "primary": { "Address": "http://49.7.179.246:7801/" }
+ }
+ },
+ "mini": {
+ "Destinations": {
+ "primary": { "Address": "http://49.7.179.246:7701/" }
+ }
+ },
+ "user": {
+ "Destinations": {
+ "primary": { "Address": "http://49.7.179.246:7901/" }
+ }
+ }
+ }
+ },
+ "AllowedHosts": "*"
+}