diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 138c139..2c21c1d 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -1,9 +1,9 @@ name: TakeoutSaaS CI/CD on: - push: + pull_request: branches: - - master + - main workflow_dispatch: env: @@ -13,6 +13,7 @@ env: DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} DEPLOY_USER: ${{ secrets.DEPLOY_USER }} DEPLOY_PASSWORD: ${{ secrets.DEPLOY_PASSWORD }} + REGISTRY_NAMESPACE: kjkj-saas jobs: detect: @@ -102,15 +103,15 @@ jobs: case "$SERVICE" in admin-api) DOCKERFILE="src/Api/TakeoutSaaS.AdminApi/Dockerfile" - IMAGE="$REGISTRY/admin-api:$IMAGE_TAG" + IMAGE="$REGISTRY/$REGISTRY_NAMESPACE/admin-api:$IMAGE_TAG" ;; mini-api) DOCKERFILE="src/Api/TakeoutSaaS.MiniApi/Dockerfile" - IMAGE="$REGISTRY/mini-api:$IMAGE_TAG" + IMAGE="$REGISTRY/$REGISTRY_NAMESPACE/mini-api:$IMAGE_TAG" ;; user-api) DOCKERFILE="src/Api/TakeoutSaaS.UserApi/Dockerfile" - IMAGE="$REGISTRY/user-api:$IMAGE_TAG" + IMAGE="$REGISTRY/$REGISTRY_NAMESPACE/user-api:$IMAGE_TAG" ;; *) echo "未知服务:$SERVICE" @@ -148,15 +149,15 @@ jobs: case "$SERVICE" in admin-api) - IMAGE="$REGISTRY/admin-api:$IMAGE_TAG" + IMAGE="$REGISTRY/$REGISTRY_NAMESPACE/admin-api:$IMAGE_TAG" PORT=7801 ;; mini-api) - IMAGE="$REGISTRY/mini-api:$IMAGE_TAG" + IMAGE="$REGISTRY/$REGISTRY_NAMESPACE/mini-api:$IMAGE_TAG" PORT=7701 ;; user-api) - IMAGE="$REGISTRY/user-api:$IMAGE_TAG" + IMAGE="$REGISTRY/$REGISTRY_NAMESPACE/user-api:$IMAGE_TAG" PORT=7901 ;; *) 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/Api/TakeoutSaaS.MiniApi/Program.cs b/src/Api/TakeoutSaaS.MiniApi/Program.cs index 34d6a45..3c5adac 100644 --- a/src/Api/TakeoutSaaS.MiniApi/Program.cs +++ b/src/Api/TakeoutSaaS.MiniApi/Program.cs @@ -13,12 +13,15 @@ using TakeoutSaaS.Module.Messaging.Extensions; using TakeoutSaaS.Module.Sms.Extensions; using TakeoutSaaS.Module.Storage.Extensions; using TakeoutSaaS.Module.Tenancy.Extensions; +using TakeoutSaaS.Shared.Abstractions.Ids; +using TakeoutSaaS.Shared.Kernel.Ids; using TakeoutSaaS.Shared.Web.Extensions; using TakeoutSaaS.Shared.Web.Swagger; 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}"; +builder.Services.AddSingleton(_ => new SnowflakeIdGenerator()); builder.Host.UseSerilog((_, _, configuration) => { configuration diff --git a/src/Api/TakeoutSaaS.MiniApi/TakeoutSaaS.MiniApi.csproj b/src/Api/TakeoutSaaS.MiniApi/TakeoutSaaS.MiniApi.csproj index 0b5e0ba..3ee3e92 100644 Binary files a/src/Api/TakeoutSaaS.MiniApi/TakeoutSaaS.MiniApi.csproj and b/src/Api/TakeoutSaaS.MiniApi/TakeoutSaaS.MiniApi.csproj differ diff --git a/src/Api/TakeoutSaaS.UserApi/Program.cs b/src/Api/TakeoutSaaS.UserApi/Program.cs index 98ed208..5c93992 100644 --- a/src/Api/TakeoutSaaS.UserApi/Program.cs +++ b/src/Api/TakeoutSaaS.UserApi/Program.cs @@ -7,12 +7,15 @@ using OpenTelemetry.Resources; using OpenTelemetry.Trace; using Serilog; using TakeoutSaaS.Module.Tenancy.Extensions; +using TakeoutSaaS.Shared.Abstractions.Ids; +using TakeoutSaaS.Shared.Kernel.Ids; using TakeoutSaaS.Shared.Web.Extensions; using TakeoutSaaS.Shared.Web.Swagger; 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}"; +builder.Services.AddSingleton(_ => new SnowflakeIdGenerator()); builder.Host.UseSerilog((_, _, configuration) => { configuration diff --git a/src/Api/TakeoutSaaS.UserApi/TakeoutSaaS.UserApi.csproj b/src/Api/TakeoutSaaS.UserApi/TakeoutSaaS.UserApi.csproj index b5f3476..2a31850 100644 Binary files a/src/Api/TakeoutSaaS.UserApi/TakeoutSaaS.UserApi.csproj and b/src/Api/TakeoutSaaS.UserApi/TakeoutSaaS.UserApi.csproj differ 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": "*" +}