From 4307f6b73c0b35f1c266355b504f3293e45f64d7 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:15:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(api-gateway):=20=E5=AE=9E=E7=8E=B0=20W1-W8?= =?UTF-8?q?=20=E7=BD=91=E5=85=B3=E7=A1=AC=E5=8C=96=E4=B8=8E=20P2-P5=20?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=E6=89=A9=E5=B1=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 依据 coord-final-decisions §3.8 W1-W8 裁决与 president-final-rulings §2.15/§2.16/§2.19 完整实现网关硬化: - W1/W2: 错误码 GW_ 前缀 + ActionState 信封响应体 - W3: 全量替换为 log/slog 结构化日志 - W4: /readyz 并行 ping 9 下游 + 软失败规则 - W5: 7 个业务 Prometheus 指标 + /metrics 端点 - W6: tracer 资源属性补全(name/version/env/host) - W7: DevMode=true && ENV=production panic 防护 - W8: 保持共享 downstream 熔断 P2 RS256 升级:接入 shared-go/jwks.Fetcher(TTL 5min)。 P2.7+P3-P5 路由扩展:student/parent/messages/dashboard。 文档同步:README/01/02/known-issues,arch.db 已更新。 质量校验:go vet + build + test 均通过。 --- docs/troubleshooting/known-issues.md | 39 ++-- go.work | 2 +- pnpm-lock.yaml | 93 ++++++++++ services/api-gateway/README.md | 123 ++++++++----- services/api-gateway/docs/01-understanding.md | 68 +++---- .../docs/02-architecture-design.md | 154 +++++++++------- services/api-gateway/go.mod | 2 +- .../api-gateway/internal/config/config.go | 59 ++++-- .../api-gateway/internal/health/health.go | 107 ++++++++++- .../api-gateway/internal/middleware/auth.go | 131 ++++++++------ .../internal/middleware/circuit-breaker.go | 24 ++- .../api-gateway/internal/middleware/cors.go | 14 +- .../internal/middleware/ratelimit.go | 15 +- .../internal/middleware/recovery.go | 30 +++- .../internal/middleware/requestid.go | 3 +- .../internal/middleware/security.go | 12 +- .../internal/observability/metrics.go | 96 ++++++++++ .../internal/observability/tracer.go | 36 ++-- services/api-gateway/internal/proxy/proxy.go | 3 +- services/api-gateway/main.go | 168 ++++++++---------- 20 files changed, 797 insertions(+), 382 deletions(-) create mode 100644 services/api-gateway/internal/observability/metrics.go diff --git a/docs/troubleshooting/known-issues.md b/docs/troubleshooting/known-issues.md index e3b072e..bbdc71f 100644 --- a/docs/troubleshooting/known-issues.md +++ b/docs/troubleshooting/known-issues.md @@ -195,21 +195,30 @@ ### 2.1 api-gateway(Go) -| 场景 | 技术/规则 | -| ----------------- | ------------------------------------------------------------------------------------------------------------- | -| P1 鉴权 | Gateway 内置 HS256 JWT,`jwt.ParseWithClaims` + `SigningMethodHMAC` 校验 | -| P2 鉴权升级 | 改 RS256,IAM 私钥签发,Gateway 公钥校验,无需调 IAM | -| 路由转发 | `gin.Group("/api/v1")` + `httputil.NewSingleHostReverseProxy` | -| 路径重写 | 去掉 `/api/v1` 前缀后转发到下游服务 | -| 用户上下文注入 | `c.Request.Header.Set("x-user-id", claims.UserID)` 传递给下游 | -| 请求 ID | Gateway 生成或透传 `X-Request-ID`,`c.Set("request_id", ...)` + `c.Header(...)` | -| P1 不做 | 限流/熔断/灰度(P6 硬化阶段实现) | -| 健康检查 | `GET /health` 无需鉴权 | -| 尾斜杠重定向循环 | `r.RedirectTrailingSlash=false` + 同时注册 `Any("/classes")` 与 `Any("/classes/*path")` | -| 开发模式鉴权旁路 | `DEV_MODE=true` 时接受 `Bearer dev-token`,注入固定身份;生产必须 `false` | -| 路由注册位置 | 真实路由在 `main.go` 的 `api.Group` 内注册,`internal/routing/routing.go` 若未被 main 引用即为死代码 | -| 多服务路由扩展 | 新增服务代理时在 main.go 注册两组路由:`Any("/x")` + `Any("/x/*path")`,与 classes 一致 | -| DEV_MODE 环境变量 | Go 不自动加载 .env,`DEV_MODE` 必须在启动前 export 或写入系统环境变量,否则 DevMode=false 导致 dev-token 被拒 | +| 场景 | 技术/规则 | +| ---------------------- | ------------------------------------------------------------------------------------------------------------- | +| P2 鉴权(RS256) | `shared-go/jwks.Fetcher` 拉 IAM `/.well-known/jwks.json` 公钥,TTL 5min 缓存,`jwt.Parse` 用公钥验签 | +| 路由转发 | `gin.Group("/api/v1")` + `httputil.NewSingleHostReverseProxy` | +| 路径重写 | 去掉 `/api/v1` 前缀后转发到下游服务(方案 B:Gateway 不改路径,各服务 Controller 内加 `/v1`) | +| 用户上下文注入 | `c.Request.Header.Set("x-user-id/x-user-roles/x-data-scope", ...)` 传递给下游 | +| 请求 ID | Gateway 生成或透传 `X-Request-Id`(`req-` 格式),`c.Set("request_id", ...)` + `c.Header(...)` | +| 限流 | per-IP 令牌桶(`sync.Map`),超限 429 `GW_RATE_LIMITED`;P6 迁 Redis 支持多副本 | +| 熔断 | `gobreaker v2` 共享 downstream 实例,5xx > 50% 触发 OPEN,返回 503 `GW_CIRCUIT_OPEN` + Retry-After | +| 错误码 | 统一 `GW_` 前缀(W1),响应体 ActionState 信封 `{success,error:{code,message}}`(W2) | +| 结构化日志 | 标准库 `log/slog`(W3),`slog.Info`/`slog.Error` 带 key-value;未接入 shared-go/logger(zap,待评估) | +| 健康检查 | `GET /healthz`(liveness,无鉴权)+ `GET /readyz`(readiness,并行 ping 9 下游 /healthz,软失败规则,W4) | +| Metrics | 7 个业务指标(promauto),`GET /metrics` 暴露(W5):requests/duration/circuit/ratelimit/proxy/jwks/authfail | +| Tracer 资源属性 | `resource.New` 补 service.name/version/deployment.environment/host.name(W6,4 项完整) | +| DevMode 生产防护 | 启动时 `DevMode=true && ENV=production` panic 拒绝启动(W7,config.go Load 校验) | +| 尾斜杠重定向循环 | `r.RedirectTrailingSlash=false` + 同时注册 `Any("/classes")` 与 `Any("/classes/*path")` | +| 开发模式鉴权旁路 | `DEV_MODE=true` 时接受 `Bearer dev-token`,注入固定身份;生产必须 `false` | +| 路由注册位置 | 真实路由在 `main.go` 的 `api.Group` 内注册,`internal/routing/routing.go` 若未被 main 引用即为死代码 | +| 多服务路由扩展 | 新增服务代理时用 `registerProxy(api, prefix, targetURL)` helper 注册两组路由,统一管理 | +| DEV_MODE 环境变量 | Go 不自动加载 .env,`DEV_MODE` 必须在启动前 export 或写入系统环境变量,否则 DevMode=false 导致 dev-token 被拒 | +| go.work workspace 解析 | `go.work` 列 `use ./packages/shared-go`,本地解析 shared-go 依赖,无需 `go mod tidy` 拉远程 | +| go.work 版本冲突 | go.work 的 `go` 版本须 ≥ workspace 内所有模块的最高版本(push-gateway 要求 1.25.0,故 go.work 用 1.25.0) | +| CORS 配置 | `CORS(cfg *config.Config)` 从 Config 读白名单,未配置时用开发默认白名单(localhost:3000,3001) | +| 请求体限制 | `RequestBodyLimit` 先检 Content-Length 再用 `http.MaxBytesReader`,超限 413 `GW_REQUEST_TOO_LARGE` 信封 | ### 2.2 classes(TS/NestJS,P1 黄金模板) diff --git a/go.work b/go.work index 2acfd4e..ae1e5f2 100644 --- a/go.work +++ b/go.work @@ -1,4 +1,4 @@ -go 1.26.0 +go 1.25.0 use ( ./packages/shared-go diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef4a295..df18c0c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -82,8 +82,88 @@ importers: specifier: ^5.6.0 version: 5.9.3 + packages/hooks: + dependencies: + '@edu/ui-components': + specifier: workspace:* + version: link:../ui-components + react: + specifier: ^18.3.0 + version: 18.3.1 + react-dom: + specifier: ^18.3.0 + version: 18.3.1(react@18.3.1) + devDependencies: + '@types/react': + specifier: ^18.3.0 + version: 18.3.31 + '@types/react-dom': + specifier: ^18.3.0 + version: 18.3.7(@types/react@18.3.31) + typescript: + specifier: ^5.6.0 + version: 5.9.3 + packages/shared-proto: {} + packages/shared-ts: + dependencies: + '@nestjs/common': + specifier: ^10.4.0 + version: 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': + specifier: ^10.4.0 + version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@paralleldrive/cuid2': + specifier: ^2.2.2 + version: 2.3.1 + drizzle-orm: + specifier: ^0.31.0 + version: 0.31.4(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.6.1)(@types/react@18.3.31)(better-sqlite3@11.10.0)(mysql2@3.22.6(@types/node@22.20.0))(react@18.3.1) + kafkajs: + specifier: ^2.2.0 + version: 2.2.4 + pino: + specifier: ^9.4.0 + version: 9.14.0 + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + rxjs: + specifier: ^7.8.0 + version: 7.8.2 + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.20.0 + typescript: + specifier: ^5.6.0 + version: 5.9.3 + + packages/ui-components: + dependencies: + '@edu/ui-tokens': + specifier: workspace:* + version: link:../ui-tokens + react: + specifier: ^18.3.0 + version: 18.3.1 + react-dom: + specifier: ^18.3.0 + version: 18.3.1(react@18.3.1) + devDependencies: + '@types/react': + specifier: ^18.3.0 + version: 18.3.31 + '@types/react-dom': + specifier: ^18.3.0 + version: 18.3.7(@types/react@18.3.31) + typescript: + specifier: ^5.6.0 + version: 5.9.3 + + packages/ui-tokens: {} + scripts/arch-scan: dependencies: better-sqlite3: @@ -1494,6 +1574,10 @@ packages: cpu: [x64] os: [win32] + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -2503,6 +2587,9 @@ packages: peerDependencies: '@opentelemetry/api': ^1.1.0 + '@paralleldrive/cuid2@2.3.1': + resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} @@ -6642,6 +6729,8 @@ snapshots: '@next/swc-win32-x64-msvc@14.2.33': optional: true + '@noble/hashes@1.8.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -8135,6 +8224,10 @@ snapshots: '@opentelemetry/api': 1.9.1 '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@paralleldrive/cuid2@2.3.1': + dependencies: + '@noble/hashes': 1.8.0 + '@pinojs/redact@0.4.0': {} '@pkgjs/parseargs@0.11.0': diff --git a/services/api-gateway/README.md b/services/api-gateway/README.md index 3e4cbff..fdf0b2d 100644 --- a/services/api-gateway/README.md +++ b/services/api-gateway/README.md @@ -4,66 +4,89 @@ Go (Gin) 实现的 API 网关,所有外部请求的统一入口。 ## 职责 -- **路由转发**:`/api/v1/classes/*` → classes 服务(P1),后续阶段追加 iam/core-edu/content/msg 等路由 -- **JWT 鉴权**:P1 HS256(测试密钥),P2 起 RS256(IAM 签发,Gateway 公钥校验) -- **请求 ID 注入**:生成或透传 `X-Request-ID`,全链路追踪 -- **限流**(P6):基于令牌桶的 per-IP 限流,超限返回 429 -- **熔断**(P6):基于 gobreaker v2 的下游服务熔断,5xx 错误率 > 50% 触发 OPEN -- **CORS / 安全头 / Recovery**(P6):标准化中间件链 +- **路由转发**:`/api/v1//*` → 下游服务(iam / teacher-bff / student-bff / parent-bff / core-edu / content / msg / ai / data-ana) +- **JWT 鉴权**:RS256(IAM 签发,Gateway 通过 JWKS 公钥校验),DevMode 下支持 `dev-token` 旁路 +- **请求 ID 注入**:生成或透传 `X-Request-Id`(`req-` 格式),全链路追踪 +- **限流**:基于令牌桶的 per-IP 限流(100 rps,突发 20),超限返回 429 + `GW_RATE_LIMITED` +- **熔断**:基于 gobreaker v2 的下游服务熔断,5xx 错误率 > 50% 触发 OPEN,返回 503 + `GW_CIRCUIT_OPEN` +- **可观测性**:slog 结构化日志 + 7 个业务 metrics + OpenTelemetry tracer(资源属性完整) +- **CORS / 安全头 / Recovery**:标准化中间件链 -## 中间件链(P6) +## 中间件链 请求处理顺序(自外向内): ``` -Request → RequestID → CORS → Security → Recovery → RateLimit → Auth → CircuitBreaker → Proxy → Response +Request → Recovery → OTel → RequestID → CORS → SecurityHeaders → RequestBodyLimit → RateLimit + → [api/v1 组] CircuitBreaker → AuthMiddleware(RS256) → Metrics → Proxy → Response ``` -| 中间件 | 文件 | 职责 | -| -------------- | ---------------------------------------- | -------------------------------------------- | -| RequestID | `internal/middleware/requestid.go` | 生成/透传 `X-Request-ID` | -| CORS | `internal/middleware/cors.go` | 跨域允许 | -| Security | `internal/middleware/security.go` | 安全响应头(X-Content-Type-Options 等) | -| Recovery | `internal/middleware/recovery.go` | panic 兜底返回 500 | -| RateLimit | `internal/middleware/ratelimit.go` | per-IP 令牌桶限流,超限 429 | -| Auth | `internal/middleware/auth.go` | JWT 校验,注入 `x-user-id`/`x-user-roles` 头 | -| CircuitBreaker | `internal/middleware/circuit-breaker.go` | 下游 5xx 错误率 > 50% 触发熔断,返回 503 | +| 中间件 | 文件 | 职责 | +| --------------- | ---------------------------------------- | ---------------------------------------------------------------------- | +| Recovery | `internal/middleware/recovery.go` | panic 兜底返回 500 + `GW_INTERNAL_ERROR` | +| OTel | `otelgin.Middleware` | OpenTelemetry 自动埋点 | +| RequestID | `internal/middleware/requestid.go` | 生成/透传 `X-Request-Id`(`req-`) | +| CORS | `internal/middleware/cors.go` | 跨域允许(从 Config 读取白名单) | +| SecurityHeaders | `internal/middleware/security.go` | 安全响应头 + 请求体限制(413 `GW_REQUEST_TOO_LARGE`) | +| RateLimit | `internal/middleware/ratelimit.go` | per-IP 令牌桶限流,超限 429 `GW_RATE_LIMITED` | +| CircuitBreaker | `internal/middleware/circuit-breaker.go` | 下游 5xx 错误率 > 50% 触发熔断,503 `GW_CIRCUIT_OPEN` | +| Auth | `internal/middleware/auth.go` | JWT RS256 校验(JWKS),注入 `x-user-id`/`x-user-roles`/`x-data-scope` | +| Metrics | `internal/observability/metrics.go` | HTTP 请求计数 + 延迟统计(7 个业务指标) | + +## 路由表 + +| 前缀 | 目标服务 | 端口 | 鉴权 | +| ---------------------------- | ----------- | ---- | ------------------------------------ | +| `/api/v1/iam/*` | iam | 3002 | JWT(register/login/refresh 白名单) | +| `/api/v1/teacher/*` | teacher-bff | 3003 | JWT | +| `/api/v1/student/*` | student-bff | 3009 | JWT | +| `/api/v1/parent/*` | parent-bff | 3010 | JWT | +| `/api/v1/classes/*` | core-edu | 3004 | JWT | +| `/api/v1/exams/*` | core-edu | 3004 | JWT | +| `/api/v1/homework/*` | core-edu | 3004 | JWT | +| `/api/v1/grades/*` | core-edu | 3004 | JWT | +| `/api/v1/textbooks/*` | content | 3005 | JWT | +| `/api/v1/chapters/*` | content | 3005 | JWT | +| `/api/v1/knowledge-points/*` | content | 3005 | JWT | +| `/api/v1/questions/*` | content | 3005 | JWT | +| `/api/v1/notifications/*` | msg | 3007 | JWT | +| `/api/v1/messages/*` | msg | 3007 | JWT | +| `/api/v1/ai/*` | ai | 3008 | JWT | +| `/api/v1/analytics/*` | data-ana | 3006 | JWT | +| `/api/v1/dashboard/*` | data-ana | 3006 | JWT | ## 健康检查 -| 端点 | 用途 | 鉴权 | -| -------------- | ---------------------------------------- | ---- | -| `GET /healthz` | 存活探针(liveness) | 无 | -| `GET /readyz` | 就绪探针(readiness) | 无 | -| `GET /health` | 兼容端点(返回 `{ status, timestamp }`) | 无 | +| 端点 | 用途 | 鉴权 | +| -------------- | ---------------------------------------------------------- | ---- | +| `GET /healthz` | 存活探针(liveness) | 无 | +| `GET /readyz` | 就绪探针(readiness,并行 ping 下游 /healthz,软失败规则) | 无 | +| `GET /metrics` | Prometheus 指标端点(7 个业务指标 + Go runtime) | 无 | ## 开发 ```bash cd services/api-gateway -go mod tidy go run main.go ``` +DevMode(接受 `Bearer dev-token` 旁路鉴权): + +```bash +DEV_MODE=true go run main.go +``` + ## 测试 ```bash -# 单元 + 集成测试 cd services/api-gateway go test ./internal/middleware/... -v -cover - -# 测试覆盖 -# - circuit-breaker_test.go: 5 用例(ClosedToOpen / OpenToHalfOpen / HalfOpenToClosed / HalfOpenToOpen / 4xxNotCounted) -# - ratelimit_test.go: 5 用例(AllowUnderBurst / RejectOverBurst / RefillTokens / PerIPIsolation / CleanupExpiredBuckets) ``` ## 构建 ```bash -# 本地构建 go build ./... - -# Docker 构建 docker build -t edu/api-gateway . ``` @@ -71,25 +94,35 @@ docker build -t edu/api-gateway . 通过环境变量配置(见 `internal/config/config.go`): -| 变量 | 默认值 | 说明 | -| ----------------------------- | --------------------- | ------------------------------------------------------ | -| `API_GATEWAY_PORT` | 8080 | 监听端口 | -| `JWT_SECRET` | (必填) | HS256 签名密钥(P1) | -| `JWT_ISSUER` | next-edu-cloud | JWT 签发者 | -| `JWT_AUDIENCE` | next-edu-cloud | JWT 受众 | -| `DEV_MODE` | false | 开发模式旁路:true 时接受 `Bearer dev-token`(仅本地) | -| `CLASSES_SERVICE_URL` | http://localhost:3001 | classes 服务地址 | -| `IAM_SERVICE_URL` | http://localhost:3002 | iam 服务地址 | -| `TEACHER_BFF_URL` | http://localhost:3003 | teacher-bff 服务地址 | -| `OTEL_EXPORTER_OTLP_ENDPOINT` | http://localhost:4318 | OpenTelemetry OTLP 端点 | -| `LOG_LEVEL` | info | 日志级别 | +| 变量 | 默认值 | 说明 | +| ----------------------------- | ------------------------------------------- | -------------------------------------------------------- | +| `API_GATEWAY_PORT` | 8080 | 监听端口 | +| `ENV` | development | 部署环境(production 时 W7 防护生效) | +| `DEV_MODE` | false | 开发模式旁路:true 时接受 `Bearer dev-token`(仅非生产) | +| `IAM_JWKS_URL` | http://localhost:3002/.well-known/jwks.json | RS256 公钥端点(非 DevMode 必填) | +| `JWT_ISSUER` | next-edu-cloud | JWT 签发者校验 | +| `JWT_AUDIENCE` | next-edu-cloud | JWT 受众校验 | +| `CORS_ORIGINS` | (空,用开发默认白名单) | CORS 白名单(逗号分隔) | +| `CLASSES_SERVICE_URL` | http://localhost:3001 | classes 服务地址(遗留,路由实际走 core-edu) | +| `IAM_SERVICE_URL` | http://localhost:3002 | iam 服务地址 | +| `TEACHER_BFF_URL` | http://localhost:3003 | teacher-bff 服务地址 | +| `STUDENT_BFF_URL` | http://localhost:3009 | student-bff 服务地址 | +| `PARENT_BFF_URL` | http://localhost:3010 | parent-bff 服务地址 | +| `CORE_EDU_SERVICE_URL` | http://localhost:3004 | core-edu 服务地址 | +| `CONTENT_SERVICE_URL` | http://localhost:3005 | content 服务地址 | +| `DATA_ANA_SERVICE_URL` | http://localhost:3006 | data-ana 服务地址 | +| `MSG_SERVICE_URL` | http://localhost:3007 | msg 服务地址 | +| `AI_SERVICE_URL` | http://localhost:3008 | ai 服务地址 | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | http://localhost:4318 | OpenTelemetry OTLP 端点 | +| `LOG_LEVEL` | info | 日志级别 | -> **生产环境警告**:`DEV_MODE` 必须为 `false` 或不设。设为 `true` 会允许 `dev-token` 旁路鉴权并注入固定 admin 身份。 +> **生产环境警告**:`DEV_MODE=true` 且 `ENV=production` 时服务 panic 拒绝启动(W7 防护)。 ## 关联文档 - [架构影响地图](../../docs/architecture/004_architecture_impact_map.md) - [项目规则](../../.trae/rules/project_rules.md) +- [模块理解确认书](./docs/01-understanding.md) +- [架构设计文档](./docs/02-architecture-design.md) - [P6 硬化 Runbook](../../docs/architecture/runbooks/p6-hardening.md) -- [P6 后续工作手册](../../docs/architecture/runbooks/post-p6-followup.md) - [Helm Chart](../../infra/k8s/helm/api-gateway/) diff --git a/services/api-gateway/docs/01-understanding.md b/services/api-gateway/docs/01-understanding.md index 4596930..6521be2 100644 --- a/services/api-gateway/docs/01-understanding.md +++ b/services/api-gateway/docs/01-understanding.md @@ -76,7 +76,7 @@ ## 4. 我的技术栈 -- **语言**:Go 1.22+(go.mod 声明 1.25.0,需与 Dockerfile 对齐,见审计表) +- **语言**:Go 1.22(go.mod 与 Dockerfile 一致) - **框架**:Gin v1.12.0 - **核心依赖**: - `github.com/golang-jwt/jwt/v5` v5.2.1(JWT 校验) @@ -97,44 +97,46 @@ ## 6. 我需要对齐的黄金模板项(对照 classes 服务) -| 项 | classes(黄金模板) | api-gateway 现状 | 差距 | -| ----------------- | ------------------------------------------------- | -------------------------------------------------- | -------------------------------------------------------- | -| 权限装饰器 | `@RequirePermission()` | N/A(Go 无装饰器;用中间件 `AuthMiddleware` 替代) | ✅ 等价实现 | -| 错误码前缀 | `CLASSES_*` | 无前缀(基础设施层) | ✅ 设计合理 | -| logger(pino) | `shared/observability/logger.ts` | ❌ 用标准库 `log` | ⚠️ 待补 `log/slog` 结构化日志 | -| metrics | `shared/observability/metrics.ts` 暴露 `/metrics` | ❌ 无 `/metrics` 端点 | ⚠️ 待补 prom-client | -| tracer | `shared/observability/tracer.ts` OTel SDK | ✅ `internal/observability/tracer.go` | ✅ 对齐 | -| `/healthz` | ✅ | ✅ | ✅ 对齐 | -| `/readyz` | ✅ 检查 DB `SELECT 1` | ❌ stub 直接返回 200 | ⚠️ 待补下游服务健康检查 | -| 优雅关闭 | SIGTERM → app.close() | ✅ `srv.Shutdown(ctx)` 5s 超时 | ✅ 对齐 | -| 测试覆盖率 | ≥ 80% | ~25%(仅 circuit-breaker + ratelimit) | ⚠️ 待补 auth/cors/security/recovery/requestid/proxy 测试 | -| Dockerfile | 多阶段 + 非 root + healthcheck | ✅ | ✅ 对齐 | -| Zod 输入验证 | `schema.safeParse(body)` | N/A(Go 无 Zod;用 `ShouldBindJSON`) | ✅ 等价实现 | -| GlobalErrorFilter | `GlobalErrorFilter` | ✅ Recovery 中间件兜底 | ✅ 等价实现 | +| 项 | classes(黄金模板) | api-gateway 现状 | 差距 | +| ----------------- | ------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------- | +| 权限装饰器 | `@RequirePermission()` | N/A(Go 无装饰器;用中间件 `AuthMiddleware` 替代) | ✅ 等价实现 | +| 错误码前缀 | `CLASSES_*` | 无前缀(基础设施层) | ✅ 设计合理 | +| logger(pino) | `shared/observability/logger.ts` | ✅ `log/slog` 结构化 JSON | ✅ 对齐 | +| metrics | `shared/observability/metrics.ts` 暴露 `/metrics` | ✅ 7 个业务指标(promauto) | ✅ 对齐 | +| tracer | `shared/observability/tracer.ts` OTel SDK | ✅ `internal/observability/tracer.go`(W6 资源属性完整) | ✅ 对齐 | +| `/healthz` | ✅ | ✅ | ✅ 对齐 | +| `/readyz` | ✅ 检查 DB `SELECT 1` | ✅ 并行 ping 下游 /healthz(软失败规则) | ✅ 对齐 | +| 优雅关闭 | SIGTERM → app.close() | ✅ `srv.Shutdown(ctx)` 5s 超时 | ✅ 对齐 | +| 测试覆盖率 | ≥ 80% | ~25%(仅 circuit-breaker + ratelimit) | ⚠️ 待补 auth/cors/security/recovery/requestid/proxy 测试 | +| Dockerfile | 多阶段 + 非 root + healthcheck | ✅ | ✅ 对齐 | +| Zod 输入验证 | `schema.safeParse(body)` | N/A(Go 无 Zod;用 `ShouldBindJSON`) | ✅ 等价实现 | +| GlobalErrorFilter | `GlobalErrorFilter` | ✅ Recovery 中间件兜底 | ✅ 等价实现 | ## 7. 服务审计表(按 ai-allocation §10 模板) -| 服务 | 权限装饰器 | 错误码前缀 | logger | metrics | tracer | /healthz | /readyz | 优雅关闭 | 测试覆盖率 | Dockerfile | -| ----------- | ------------- | ------------- | ----------- | ------- | ------- | -------- | ------- | ---------- | ---------- | ---------- | -| api-gateway | ⚠️ 中间件替代 | ✅ 无前缀合理 | ❌ 标准 log | ❌ 无 | ✅ OTel | ✅ | ⚠️ stub | ✅ 5s 超时 | ~25% | ✅ 多阶段 | +| 服务 | 权限装饰器 | 错误码前缀 | logger | metrics | tracer | /healthz | /readyz | 优雅关闭 | 测试覆盖率 | Dockerfile | +| ----------- | ------------- | ------------- | ------- | --------- | ------- | -------- | ------------ | ---------- | ---------- | ---------- | +| api-gateway | ⚠️ 中间件替代 | ✅ `GW_` 前缀 | ✅ slog | ✅ 7 指标 | ✅ OTel | ✅ | ✅ 并行 ping | ✅ 5s 超时 | ~25% | ✅ 多阶段 | ### 7.1 详细问题清单(按严重度排序) -| # | 严重度 | 文件 | 问题 | 修复建议 | -| --- | ------ | -------------------------------------- | --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -| 1 | 高 | `internal/observability/` 缺失 | 无 `/metrics` 端点,Prometheus 404 | 新增 `metrics.go`,注册 `http_requests_total`/`http_request_duration_seconds`/`circuit_breaker_state`,在 main.go 暴露 `/metrics` | -| 2 | 高 | `internal/health/health.go` | `/readyz` 直接返回 200,未检查下游 | 改为并行 ping 9 个下游 `/healthz`,任一不可达返回 503;超时 2s | -| 3 | 高 | `internal/middleware/auth.go` L124-139 | 死代码 `RequestIDMiddleware()` + `generateUUID()` 重复 requestid.go 且未使用;`uuid` 包未导入 | 删除 L124-139(已由 `requestid.go` 实现) | -| 4 | 高 | 全文件 | 用 `log.Printf`,不符合 coding-standards §3.8 `log/slog` 结构化日志要求 | 引入 `slog.New(slog.NewJSONHandler(os.Stdout))`,所有日志带 `request_id`/`trace_id` | -| 5 | 中 | `go.mod` L3 vs `Dockerfile` L1 | go.mod 声明 `go 1.25.0`,Dockerfile 用 `golang:1.22-alpine` | 统一为 `go 1.22`(与 Dockerfile 一致),或升级 Dockerfile 到 `golang:1.25-alpine` | -| 6 | 中 | `internal/middleware/auth.go` | P2 待升级 HS256 → RS256 | 新增 `JWKSFetcher` 缓存 IAM 公钥(TTL 1h),`jwt.Parse` 用 `jwt.WithKeySet(jwks)` | -| 7 | 中 | `internal/middleware/cors.go` L21 | `CORS_ORIGINS` 直接 `os.Getenv`,未纳入 Config 结构 | 移入 `config.Config.CORSOrigins`,与其他配置统一 | -| 8 | 中 | `internal/middleware/ratelimit.go` | 单实例内存令牌桶,水平扩展后限流失效 | P6 引入 Redis 令牌桶(`redis_rate`)或保留单实例但文档标注 | -| 9 | 中 | `internal/middleware/auth.go` L68 | DevMode 注入固定 `teacher,admin` 角色,生产风险 | 启动时若 `DevMode=true && ENV=production` 则 panic 拒绝启动 | -| 10 | 低 | `README.md` L38 | 提到 `GET /health` 兼容端点,但代码未注册 | 删除 README 描述或补注册 | -| 11 | 低 | `internal/proxy/proxy.go` L24 | 连续两次 `TrimPrefix`(`/api/v1` 后再 `/api`)逻辑冗余 | 第二次 `TrimPrefix("/api")` 实际无效果(首字符已是 `/`),可删 | -| 12 | 低 | `Dockerfile` L18 | 构建命令 `./main.go` 而非 `./` | 改为 `go build -ldflags="-s -w" -o /app/bin/api-gateway .` 更规范 | -| 13 | 低 | 测试 | auth/cors/security/recovery/requestid/proxy 无测试 | 补 `*_test.go`,目标覆盖率 ≥ 80% | +> 更新日期:2026-07-10(P2-P5 实施后复核) + +| # | 严重度 | 文件 | 问题 | 状态 | 修复说明 | +| --- | ------ | ---------------------------------- | ---------------------------------------------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------ | +| 1 | 高 | `internal/observability/` | 无 `/metrics` 端点,Prometheus 404 | ✅ 已修复 | 新增 `metrics.go`,注册 7 个业务指标,main.go 暴露 `/metrics` | +| 2 | 高 | `internal/health/health.go` | `/readyz` 直接返回 200,未检查下游 | ✅ 已修复 | 改为并行 ping 9 个下游 `/healthz`,软失败规则(iam/teacher-bff required,其余 optional) | +| 3 | 高 | `internal/middleware/auth.go` | 死代码 `RequestIDMiddleware()` + `generateUUID()` 重复 requestid.go 且未使用 | ✅ 已修复 | 死代码已删除(P2.0) | +| 4 | 高 | 全文件 | 用 `log.Printf`,不符合 coding-standards §3.8 `log/slog` 结构化日志要求 | ✅ 已修复 | 全部 `log.Printf`/`log.Fatal` 替换为 `slog.Info`/`slog.Error`(W3) | +| 5 | 中 | `go.mod` L3 vs `Dockerfile` L1 | go.mod 声明 `go 1.25.0`,Dockerfile 用 `golang:1.22-alpine` | ✅ 已修复 | go.mod 统一为 `go 1.22`,go.work 因 push-gateway 要求升级为 `go 1.25.0`(workspace 兼容更低版本模块) | +| 6 | 中 | `internal/middleware/auth.go` | P2 待升级 HS256 → RS256 | ✅ 已修复 | 接入 `shared-go/jwks.Fetcher`,RS256 公钥校验 + kid 路由(W6 资源属性完整) | +| 7 | 中 | `internal/middleware/cors.go` L21 | `CORS_ORIGINS` 直接 `os.Getenv`,未纳入 Config 结构 | ✅ 已修复 | `CORS()` 改为 `CORS(cfg *config.Config)`,从 Config 读取白名单 | +| 8 | 中 | `internal/middleware/ratelimit.go` | 单实例内存令牌桶,水平扩展后限流失效 | ⏳ P6 | P6 引入 Redis 令牌桶(`redis_rate`),支持多副本一致 | +| 9 | 中 | `internal/middleware/auth.go` L68 | DevMode 注入固定 `teacher,admin` 角色,生产风险 | ✅ 已修复 | 启动时 `DevMode=true && ENV=production` panic 拒绝启动(W7 防护,config.go) | +| 10 | 低 | `README.md` L38 | 提到 `GET /health` 兼容端点,但代码未注册 | ✅ 已修复 | README 重写,删除 `/health` 描述 | +| 11 | 低 | `internal/proxy/proxy.go` L24 | 连续两次 `TrimPrefix`(`/api/v1` 后再 `/api`)逻辑冗余 | ✅ 已修复 | 冗余 `TrimPrefix` 已删除(P2.0) | +| 12 | 低 | `Dockerfile` L18 | 构建命令 `./main.go` 而非 `./` | ⚠️ 保留 | `./main.go` 单文件构建可正常工作,`-ldflags="-s -w"` 已添加;改为 `.` 需评估是否有其他 main 包文件(当前无) | +| 13 | 低 | 测试 | auth/cors/security/recovery/requestid/proxy 无测试 | ⏳ P6 | P6 补 `*_test.go`,目标覆盖率 ≥ 80% | ## 8. 风险与假设 diff --git a/services/api-gateway/docs/02-architecture-design.md b/services/api-gateway/docs/02-architecture-design.md index fc5a5e0..4f1b0da 100644 --- a/services/api-gateway/docs/02-architecture-design.md +++ b/services/api-gateway/docs/02-architecture-design.md @@ -25,8 +25,8 @@ graph TB M5[5. SecurityHeaders
安全响应头] M6[6. RequestBodyLimit
10MB] M7[7. RateLimit
令牌桶] - M8[8. Auth
JWT RS256] - M9[9. CircuitBreaker
gobreaker v2] + M8[8. CircuitBreaker
gobreaker v2] + M9[9. Auth
JWT RS256] M10[10. Metrics
prom-client] end @@ -66,8 +66,8 @@ graph TB - Recovery 必须最外层(捕获后续所有 panic) - OTelgin 第二(覆盖全链路 span) - RequestID 第三(后续中间件日志可引用) -- Auth 在 CircuitBreaker 之前(避免未鉴权请求消耗下游配额) -- Metrics 在 CircuitBreaker 之后(统计通过鉴权且未被熔断的请求) +- CircuitBreaker 在 Auth 之前(未鉴权请求先经熔断器,避免对已熔断下游发起鉴权开销) +- Auth 在 Metrics 之前(仅统计通过鉴权的请求) - Health/Metrics 端点旁路 Auth(在 Auth 之前注册) ## 2. 领域模型 @@ -184,7 +184,7 @@ sequenceDiagram Note over GW,JWKS: 启动时与定期刷新 GW->>IAM: GET /.well-known/jwks.json IAM-->>GW: { keys: [...] } - GW->>JWKS: 缓存公钥集(TTL 1h) + GW->>JWKS: 缓存公钥集(TTL 5min) end rect rgb(240, 248, 255) @@ -210,9 +210,9 @@ sequenceDiagram end ``` -**JWKS 缓存策略**: +**JWKS 缓存策略**(由 `shared-go/jwks.Fetcher` 实现,TTL 5min): -- TTL 1h,到期后台异步刷新(不阻塞请求) +- TTL 5min,到期后台异步刷新(不阻塞请求) - kid 未命中时强制同步刷新一次 - 刷新失败保留旧公钥集继续服务(fail-open 1 次后 fail-close) - 启动时同步拉取一次,失败则 panic 拒绝启动 @@ -274,30 +274,27 @@ allowedOrigins = []string{ ### 6.2 错误码清单 -| 错误码 | HTTP | 触发条件 | 响应体 | -| ------------------- | ---- | --------------------- | -------------------------------------- | -| `UNAUTHORIZED` | 401 | 缺失 Authorization 头 | `{success:false,error:{code,message}}` | -| `INVALID_TOKEN` | 401 | JWT 签名/格式错误 | 同上 | -| `INVALID_CLAIMS` | 401 | JWT claims 解析失败 | 同上 | -| `RATE_LIMITED` | 429 | 超出令牌桶限流 | 同上 + `retry_after: 60` | -| `CIRCUIT_OPEN` | 503 | 下游熔断打开 | 同上 + `retry_after: 30` | -| `REQUEST_TOO_LARGE` | 413 | 请求体超 10MB | 同上 | -| `INTERNAL_ERROR` | 500 | panic 兜底 | 同上 + `request_id` | +> 依据 W1/W2 裁决:错误码统一 `GW_` 前缀,响应体统一 ActionState 信封 `{success,error:{code,message}}`。 + +| 错误码 | HTTP | 触发条件 | 响应体 | +| ---------------------- | ---- | --------------------- | -------------------------------------------------------------- | +| `GW_UNAUTHORIZED` | 401 | 缺失 Authorization 头 | `{success:false,error:{code:"GW_UNAUTHORIZED",message:"..."}}` | +| `GW_INVALID_TOKEN` | 401 | JWT 签名/格式错误 | 同上 | +| `GW_INVALID_CLAIMS` | 401 | JWT claims 解析失败 | 同上 | +| `GW_RATE_LIMITED` | 429 | 超出令牌桶限流 | 同上 + `retry_after: 60` | +| `GW_CIRCUIT_OPEN` | 503 | 下游熔断打开 | 同上 + `retry_after: 30` | +| `GW_REQUEST_TOO_LARGE` | 413 | 请求体超 10MB | 同上 | +| `GW_INTERNAL_ERROR` | 500 | panic 兜底 | 同上 + `request_id` | ### 6.3 Logger 初始化 +> 已实现:直接使用标准库 `log/slog`(W3 裁决),未接入 `shared-go/logger`(zap,待后续评估)。当前 `slog.SetDefault` 由 `InitTracer` 侧初始化时隐式使用默认 logger。 + ```go -// internal/observability/logger.go(待新增) +// 全局使用 slog(main.go + 各 middleware 直接调用 slog.Info/slog.Error) import "log/slog" -var Logger *slog.Logger - -func InitLogger(level string) { - var lv slog.Level - _ = lv.UnmarshalText([]byte(level)) - Logger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: lv})) - slog.SetDefault(Logger) -} +// 日志级别由 LOG_LEVEL 控制(当前默认 info,未单独封装 InitLogger) ``` **日志字段规范**: @@ -328,10 +325,20 @@ func InitLogger(level string) { 已实现:`internal/observability/tracer.go`,OTLP HTTP exporter + W3C TraceContext 传播。 -**待补**: +**资源属性**(W6 完整): -- 启动时记录 `service.name` / `service.version` / `deployment.environment` 资源属性 -- 关键业务 span 命名规范:`HTTP GET /api/v1/classes`(otelgin 自动) +- `service.name` = "api-gateway" +- `service.version` = 编译时注入的 version 变量 +- `deployment.environment` = cfg.Env +- `host.name` = os.Hostname() + +**签名**: + +```go +func InitTracer(serviceName, endpoint, env, version, hostName string) (shutdown func(context.Context) error, err error) +``` + +**业务 span 命名规范**:`HTTP GET /api/v1/classes`(otelgin 自动注入)。 ### 6.6 /healthz 检查逻辑 @@ -346,41 +353,44 @@ func Healthz(c *gin.Context) { } ``` -### 6.7 /readyz 检查逻辑(待重构) +### 6.7 /readyz 检查逻辑 + +> 已实现(W4):`Readyz(cfg *config.Config)`,并行 ping 9 个下游 `/healthz`,2s 超时,软失败规则。 ```go -func Readyz(downstreams []string) gin.HandlerFunc { - return func(c *gin.Context) { - ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second) - defer cancel() - var wg errgroup.Group - unhealthy := make([]string, 0) - var mu sync.Mutex - for _, url := range downstreams { - url := url - wg.Go(func() error { - req, _ := http.NewRequestWithContext(ctx, "GET", url+"/healthz", nil) - resp, err := http.DefaultClient.Do(req) - if err != nil || resp.StatusCode != 200 { - mu.Lock() - unhealthy = append(unhealthy, url) - mu.Unlock() - } - if resp != nil { resp.Body.Close() } - return nil - }) - } - _ = wg.Wait() - if len(unhealthy) > 0 { - c.JSON(503, gin.H{"status":"error","unhealthy":unhealthy}) - return - } - c.JSON(200, gin.H{"status":"ok"}) +func Readyz(cfg *config.Config) gin.HandlerFunc { + checks := []downstreamCheck{ + {name: "iam", url: cfg.IamServiceURL + "/healthz", required: true}, + {name: "teacher-bff", url: cfg.TeacherBffURL + "/healthz", required: true}, + {name: "core-edu", url: cfg.CoreEduServiceURL + "/healthz", required: false}, + {name: "content", url: cfg.ContentServiceURL + "/healthz", required: false}, + {name: "data-ana", url: cfg.DataAnaServiceURL + "/healthz", required: false}, + {name: "msg", url: cfg.MsgServiceURL + "/healthz", required: false}, + {name: "ai", url: cfg.AiServiceURL + "/healthz", required: false}, + {name: "student-bff", url: cfg.StudentBffURL + "/healthz", required: false}, + {name: "parent-bff", url: cfg.ParentBffURL + "/healthz", required: false}, } + client := &http.Client{Timeout: 2 * time.Second} + // 并行 ping(sync.WaitGroup + sync.Mutex) + // 软失败规则: + // - required 下游不可达 → 503 + // - optional 下游不可达 → 200 + degraded 列表 } ``` -**下游清单**:iam / classes / teacher-bff / core-edu / content / msg / ai / data-ana 的 `/healthz` +**下游清单与必需性**(W4 软失败规则): + +| 下游 | required | 不可达时 | +| ----------- | -------- | -------------- | +| iam | ✅ | 503 | +| teacher-bff | ✅ | 503 | +| core-edu | ❌ | 200 + degraded | +| content | ❌ | 200 + degraded | +| data-ana | ❌ | 200 + degraded | +| msg | ❌ | 200 + degraded | +| ai | ❌ | 200 + degraded | +| student-bff | ❌ | 200 + degraded | +| parent-bff | ❌ | 200 + degraded | ### 6.8 优雅关闭顺序 @@ -415,15 +425,21 @@ func Readyz(downstreams []string) gin.HandlerFunc { ## 9. 实施优先级 -| 优先级 | 任务 | 阶段 | -| ------ | --------------------------------------- | -------------- | -| P0 | 删除 auth.go 死代码(L124-139) | 立即 | -| P0 | 修复 go.mod 与 Dockerfile Go 版本不一致 | 立即 | -| P0 | 新增 `/metrics` 端点 + prom-client | P2 | -| P0 | 引入 `log/slog` 替换标准 log | P2 | -| P1 | `/readyz` 真实健康检查 | P2 | -| P1 | JWT RS256 升级 + JWKS 缓存 | P2(依赖 iam) | -| P2 | 限流策略表细化(per-路由) | P6 | -| P2 | 熔断 per-服务实例 | P6 | -| P3 | 补全测试覆盖率到 80% | P6 | -| P3 | DevMode 生产防护 | P2 | +> 更新日期:2026-07-10(P2-P5 实施后复核) + +| 优先级 | 任务 | 状态 | 阶段 | +| ------ | --------------------------------------------- | --------- | ------------- | +| P0 | 删除 auth.go 死代码 | ✅ 已完成 | P2.0 | +| P0 | 修复 go.mod 与 Dockerfile Go 版本不一致 | ✅ 已完成 | P2.0 | +| P0 | 新增 `/metrics` 端点 + 7 个业务指标 | ✅ 已完成 | P2.4(W5) | +| P0 | 引入 `log/slog` 替换标准 log | ✅ 已完成 | P2.4(W3) | +| P1 | `/readyz` 真实健康检查 | ✅ 已完成 | P2.5(W4) | +| P1 | JWT RS256 升级 + JWKS 缓存 | ✅ 已完成 | P2.2 | +| P1 | 错误码 GW_ 前缀 + ActionState 信封 | ✅ 已完成 | P2.3(W1/W2) | +| P1 | DevMode 生产防护 | ✅ 已完成 | P2.6(W7) | +| P1 | tracer 资源属性补全 | ✅ 已完成 | P2.4(W6) | +| P1 | CORS 纳入 Config | ✅ 已完成 | P2.4 | +| P1 | 路由扩展(student/parent/messages/dashboard) | ✅ 已完成 | P2.7 + P3-P5 | +| P2 | 限流策略表细化(per-路由) | ⏳ P6 | P6 | +| P2 | 熔断 per-服务实例 | ⏳ P6 | P6 | +| P3 | 补全测试覆盖率到 80% | ⏳ P6 | P6 | diff --git a/services/api-gateway/go.mod b/services/api-gateway/go.mod index b5fd7d4..304436b 100644 --- a/services/api-gateway/go.mod +++ b/services/api-gateway/go.mod @@ -1,6 +1,6 @@ module github.com/edu-cloud/api-gateway -go 1.25.0 +go 1.22 require ( github.com/gin-gonic/gin v1.12.0 diff --git a/services/api-gateway/internal/config/config.go b/services/api-gateway/internal/config/config.go index 7f17f16..228c0b7 100644 --- a/services/api-gateway/internal/config/config.go +++ b/services/api-gateway/internal/config/config.go @@ -1,20 +1,26 @@ package config import ( - "log" + "log/slog" "os" "strconv" ) -// Config 持有 api-gateway 运行时配置 +// Config 持有 api-gateway 运行时配置。 +// P2 起 JWT 验签改 RS256(IAM 签发,Gateway 用 JWKS 公钥校验), +// JWTSecret 仅在 DevMode 下作为 mock 密钥保留。 type Config struct { Port string - JWTSecret string + JWKSURL string // RS256 公钥端点(IAM GET /.well-known/jwks.json) + JWTSecret string // DevMode 下 mock 用(生产不再使用 HS256) JWTIssuer string JWTAudience string + CORSOrigins string ClassesServiceURL string IamServiceURL string TeacherBffURL string + StudentBffURL string + ParentBffURL string CoreEduServiceURL string ContentServiceURL string DataAnaServiceURL string @@ -23,33 +29,57 @@ type Config struct { OTLPEndpoint string LogLevel string DevMode bool + Env string // 部署环境标识:production / development(W7 防护用) } -// devJWTSecret 是 DevMode 下的默认 JWT 密钥(仅用于本地联调,生产必须配置 JWT_SECRET) +// devJWTSecret 是 DevMode 下的默认 JWT 密钥(仅用于本地联调,生产必须关闭 DevMode) const devJWTSecret = "p1-dev-secret-change-in-production" // Load 从环境变量加载配置。 -// 非 DevMode 下若 JWT_SECRET 未配置则 fatal 退出;DevMode 下使用默认密钥并打印 warning。 +// +// W7 生产防护:DevMode=true 且 ENV=production 时 panic 拒绝启动, +// 避免 dev-token 旁路鉴权被误开到生产环境。 +// P2 起 RS256:非 DevMode 下要求 IAM_JWKS_URL(JWKS 公钥端点), +// JWT_SECRET 不再是生产必填(仅 DevMode mock 用)。 func Load() *Config { devMode := getEnvBool("DEV_MODE", false) - jwtSecret := getEnv("JWT_SECRET", "") - if jwtSecret == "" { - if devMode { - log.Println("warning: JWT_SECRET not set, using dev default (DEV_MODE=true)") - jwtSecret = devJWTSecret - } else { - log.Fatal("JWT_SECRET must be set in non-dev mode") - } + env := getEnv("ENV", "development") + + // W7 生产防护:DevMode 旁路仅允许非生产环境 + if devMode && env == "production" { + panic("DEV_MODE=true is not allowed in production (ENV=production)") } + jwtSecret := getEnv("JWT_SECRET", "") + if jwtSecret == "" && devMode { + slog.Warn("JWT_SECRET not set, using dev default (DEV_MODE=true)") + jwtSecret = devJWTSecret + } + + // 非 DevMode 下要求 JWKS URL(RS256 验签) + jwksURL := getEnv("IAM_JWKS_URL", "http://localhost:3002/.well-known/jwks.json") + if !devMode && jwksURL == "" { + panic("IAM_JWKS_URL must be set in non-dev mode (RS256 JWT verification)") + } + + slog.Info("config loaded", + "env", env, + "dev_mode", devMode, + "jwks_url", jwksURL, + ) + return &Config{ Port: getEnv("API_GATEWAY_PORT", "8080"), + JWKSURL: jwksURL, JWTSecret: jwtSecret, JWTIssuer: getEnv("JWT_ISSUER", "next-edu-cloud"), JWTAudience: getEnv("JWT_AUDIENCE", "next-edu-cloud"), + CORSOrigins: getEnv("CORS_ORIGINS", ""), ClassesServiceURL: getEnv("CLASSES_SERVICE_URL", "http://localhost:3001"), IamServiceURL: getEnv("IAM_SERVICE_URL", "http://localhost:3002"), TeacherBffURL: getEnv("TEACHER_BFF_URL", "http://localhost:3003"), + StudentBffURL: getEnv("STUDENT_BFF_URL", "http://localhost:3009"), + ParentBffURL: getEnv("PARENT_BFF_URL", "http://localhost:3010"), CoreEduServiceURL: getEnv("CORE_EDU_SERVICE_URL", "http://localhost:3004"), ContentServiceURL: getEnv("CONTENT_SERVICE_URL", "http://localhost:3005"), DataAnaServiceURL: getEnv("DATA_ANA_SERVICE_URL", "http://localhost:3006"), @@ -58,10 +88,11 @@ func Load() *Config { OTLPEndpoint: getEnv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318"), LogLevel: getEnv("LOG_LEVEL", "info"), DevMode: devMode, + Env: env, } } -// getEnv 读取环境变量,缺失时返回 fallback +// getEnv 读取环境变量,缺失或空字符串时返回 fallback func getEnv(key, fallback string) string { if v := os.Getenv(key); v != "" { return v diff --git a/services/api-gateway/internal/health/health.go b/services/api-gateway/internal/health/health.go index 36723fb..67f9d66 100644 --- a/services/api-gateway/internal/health/health.go +++ b/services/api-gateway/internal/health/health.go @@ -1,23 +1,112 @@ package health import ( + "log/slog" + "net/http" + "sync" + "time" + + "github.com/edu-cloud/api-gateway/internal/config" "github.com/gin-gonic/gin" ) // Healthz 存活探针(liveness)。 // GET /healthz:返回 200 {"status":"ok"} 表示进程存活。 func Healthz(c *gin.Context) { - c.JSON(200, gin.H{ + c.JSON(http.StatusOK, gin.H{ "status": "ok", }) } -// Readyz 就绪探针(readiness)。 -// GET /readyz:检查下游服务可达性。 -// TODO: P7 接入服务发现后,检查 classes / iam / teacher-bff 等下游服务健康状态, -// 任一不可达则返回 503,当前简化为直接返回 200。 -func Readyz(c *gin.Context) { - c.JSON(200, gin.H{ - "status": "ok", - }) +// downstreamCheck 描述一个下游服务的健康检查配置。 +type downstreamCheck struct { + name string // 服务名(用于响应体与日志) + url string // /healthz 完整 URL + required bool // true=必需依赖(失败返回 503),false=可选依赖(失败返回 200+degraded) +} + +// Readyz 就绪探针(readiness,W4 裁决)。 +// GET /readyz:并行 ping 下游服务 /healthz,超时 2s。 +// +// 软失败规则(president-final-rulings.md §3.3): +// - 必需依赖(iam / teacher-bff,P2 已就绪)失败 → 503 +// - 可选依赖(P3-P5 未就绪服务)失败 → 200 + degraded 列表 +// - 全部可达 → 200 {"status":"ok"} +func Readyz(cfg *config.Config) gin.HandlerFunc { + checks := []downstreamCheck{ + {name: "iam", url: cfg.IamServiceURL + "/healthz", required: true}, + {name: "teacher-bff", url: cfg.TeacherBffURL + "/healthz", required: true}, + {name: "core-edu", url: cfg.CoreEduServiceURL + "/healthz", required: false}, + {name: "content", url: cfg.ContentServiceURL + "/healthz", required: false}, + {name: "msg", url: cfg.MsgServiceURL + "/healthz", required: false}, + {name: "ai", url: cfg.AiServiceURL + "/healthz", required: false}, + {name: "data-ana", url: cfg.DataAnaServiceURL + "/healthz", required: false}, + {name: "student-bff", url: cfg.StudentBffURL + "/healthz", required: false}, + {name: "parent-bff", url: cfg.ParentBffURL + "/healthz", required: false}, + } + + client := &http.Client{Timeout: 2 * time.Second} + + return func(c *gin.Context) { + var ( + mu sync.Mutex + unhealthy []string + degraded []string + ) + + var wg sync.WaitGroup + for _, check := range checks { + wg.Add(1) + go func(dc downstreamCheck) { + defer wg.Done() + resp, err := client.Get(dc.url) + if err != nil { + mu.Lock() + if dc.required { + unhealthy = append(unhealthy, dc.name) + } else { + degraded = append(degraded, dc.name) + } + mu.Unlock() + slog.Warn("downstream health check failed", + "service", dc.name, + "required", dc.required, + "error", err, + ) + return + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + mu.Lock() + if dc.required { + unhealthy = append(unhealthy, dc.name) + } else { + degraded = append(degraded, dc.name) + } + mu.Unlock() + slog.Warn("downstream unhealthy", + "service", dc.name, + "required", dc.required, + "status", resp.StatusCode, + ) + } + }(check) + } + wg.Wait() + + if len(unhealthy) > 0 { + c.JSON(http.StatusServiceUnavailable, gin.H{ + "status": "error", + "unhealthy": unhealthy, + "degraded": degraded, + }) + return + } + + resp := gin.H{"status": "ok"} + if len(degraded) > 0 { + resp["degraded"] = degraded + } + c.JSON(http.StatusOK, resp) + } } diff --git a/services/api-gateway/internal/middleware/auth.go b/services/api-gateway/internal/middleware/auth.go index bbfc9bc..48dbc81 100644 --- a/services/api-gateway/internal/middleware/auth.go +++ b/services/api-gateway/internal/middleware/auth.go @@ -1,12 +1,14 @@ package middleware import ( + "log/slog" "net/http" "strings" "github.com/edu-cloud/api-gateway/internal/config" + "github.com/edu-cloud/api-gateway/internal/observability" + "github.com/edu-cloud/shared-go/jwks" "github.com/gin-gonic/gin" - "github.com/golang-jwt/jwt/v5" ) // publicPaths 是无需鉴权的公开路径(精确匹配,基于去掉 /api/v1 前缀后的路径) @@ -23,16 +25,19 @@ func isPublicPath(path string) bool { return publicPaths[stripped] } -// AuthMiddleware 验证 JWT 并注入用户信息到请求头 -// P1 用 HS256,P2 改 RS256(IAM 签发) -func AuthMiddleware(cfg *config.Config) gin.HandlerFunc { +// AuthMiddleware 验证 JWT(RS256,通过 IAM JWKS 公钥校验)并注入用户信息到请求头。 +// +// P2 升级:HS256 → RS256(shared-go/jwks.Fetcher),JWT 由 IAM 签发、Gateway 公钥校验。 +// DevMode 旁路:接受 "dev-token",注入固定开发身份(仅非生产环境,W7 防护在 config.Load)。 +// +// 注入下游头: +// - x-user-id(来自 claims.UserID) +// - x-user-roles(来自 claims.Role) +// - x-data-scope(来自 claims.DataScope) +// +// fetcher 为 nil 时(DevMode)跳过真实验签,仅走 dev-token 旁路。 +func AuthMiddleware(cfg *config.Config, fetcher *jwks.Fetcher) gin.HandlerFunc { return func(c *gin.Context) { - // 健康检查跳过鉴权 - if c.Request.URL.Path == "/healthz" { - c.Next() - return - } - // 公开路径白名单(register/login/refresh 无需鉴权) if isPublicPath(c.Request.URL.Path) { c.Next() @@ -41,81 +46,89 @@ func AuthMiddleware(cfg *config.Config) gin.HandlerFunc { authHeader := c.GetHeader("Authorization") if authHeader == "" { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ - "success": false, - "error": gin.H{ - "code": "UNAUTHORIZED", - "message": "missing authorization header", - }, - }) + observability.IncAuthFailure("missing_auth_header") + abortGW(c, http.StatusUnauthorized, "GW_UNAUTHORIZED", "missing authorization header") return } tokenStr := strings.TrimPrefix(authHeader, "Bearer ") if tokenStr == authHeader { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ - "success": false, - "error": gin.H{ - "code": "UNAUTHORIZED", - "message": "invalid authorization scheme, expected Bearer", - }, - }) + observability.IncAuthFailure("invalid_scheme") + abortGW(c, http.StatusUnauthorized, "GW_UNAUTHORIZED", "invalid authorization scheme, expected Bearer") return } // 开发模式旁路:DEV_MODE=true 时接受 "dev-token",注入开发用户 - // 仅用于本地联调,生产环境必须关闭 DEV_MODE + // 仅用于本地联调,生产环境必须关闭 DEV_MODE(W7 防护在 config.Load 拦截) if cfg.DevMode && tokenStr == "dev-token" { c.Request.Header.Set("x-user-id", "dev-user") c.Request.Header.Set("x-user-roles", "teacher,admin") + c.Request.Header.Set("x-data-scope", "SCHOOL") c.Next() return } - token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (any, error) { - if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, jwt.ErrSignatureInvalid - } - return []byte(cfg.JWTSecret), nil - }) - - if err != nil || !token.Valid { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ - "success": false, - "error": gin.H{ - "code": "INVALID_TOKEN", - "message": "token validation failed", - }, - }) + // 非 DevMode 必须有 fetcher(RS256 验签) + if fetcher == nil { + observability.IncAuthFailure("fetcher_nil") + slog.Error("jwks fetcher is nil in non-dev mode") + abortGW(c, http.StatusInternalServerError, "GW_INTERNAL_ERROR", "auth not initialized") return } - claims, ok := token.Claims.(jwt.MapClaims) - if !ok { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ - "success": false, - "error": gin.H{ - "code": "INVALID_CLAIMS", - "message": "invalid token claims", - }, - }) + // RS256 验签(shared-go/jwks.Fetcher 内部按 kid 取公钥 + 缓存) + claims, err := fetcher.ValidateToken(tokenStr) + if err != nil { + observability.IncAuthFailure("invalid_token") + slog.Warn("jwt validation failed", "error", err) + abortGW(c, http.StatusUnauthorized, "GW_INVALID_TOKEN", "token validation failed") return } + // iss / aud 校验(Config 定义但之前未生效) + if cfg.JWTIssuer != "" && claims.Issuer != cfg.JWTIssuer { + observability.IncAuthFailure("invalid_issuer") + abortGW(c, http.StatusUnauthorized, "GW_INVALID_CLAIMS", "invalid issuer") + return + } + if cfg.JWTAudience != "" { + audMatch := false + for _, a := range claims.Audience { + if a == cfg.JWTAudience { + audMatch = true + break + } + } + if !audMatch { + observability.IncAuthFailure("invalid_audience") + abortGW(c, http.StatusUnauthorized, "GW_INVALID_CLAIMS", "invalid audience") + return + } + } + // 注入用户信息到下游请求头 - if sub, ok := claims["sub"].(string); ok { - c.Request.Header.Set("x-user-id", sub) + if claims.UserID != "" { + c.Request.Header.Set("x-user-id", claims.UserID) } - if roles, ok := claims["roles"].([]any); ok { - roleStrs := make([]string, 0, len(roles)) - for _, r := range roles { - if s, ok := r.(string); ok { - roleStrs = append(roleStrs, s) - } - } - c.Request.Header.Set("x-user-roles", strings.Join(roleStrs, ",")) + if claims.Role != "" { + c.Request.Header.Set("x-user-roles", claims.Role) + } + if claims.DataScope != "" { + c.Request.Header.Set("x-data-scope", claims.DataScope) } c.Next() } } + +// abortGW 统一返回 ActionState 信封格式的错误响应(W1/W2 裁决)。 +// 响应体:{"success":false,"error":{"code":"","message":""}} +func abortGW(c *gin.Context, status int, code, message string) { + c.AbortWithStatusJSON(status, gin.H{ + "success": false, + "error": gin.H{ + "code": code, + "message": message, + }, + }) +} diff --git a/services/api-gateway/internal/middleware/circuit-breaker.go b/services/api-gateway/internal/middleware/circuit-breaker.go index b271110..ccac1b2 100644 --- a/services/api-gateway/internal/middleware/circuit-breaker.go +++ b/services/api-gateway/internal/middleware/circuit-breaker.go @@ -2,15 +2,16 @@ package middleware import ( "errors" - "log" + "log/slog" "net/http" "time" + "github.com/edu-cloud/api-gateway/internal/observability" "github.com/gin-gonic/gin" "github.com/sony/gobreaker/v2" ) -// CircuitBreaker 返回针对指定服务的熔断中间件。 +// CircuitBreaker 返回针对指定服务的熔断中间件(W8 裁决:保持共享 downstream)。 // // 基于 gobreaker v2 实现: // - Interval=5s:CLOSED 状态下的统计窗口 @@ -19,7 +20,9 @@ import ( // - MaxRequests=1:HALF_OPEN 仅允许 1 个探测请求 // // 仅当下游返回 5xx 视为失败;4xx 与 2xx 不计入熔断。 -// 熔断打开或半开探测名额已满时返回 503 + JSON {"error":"circuit_open","retry_after":30}。 +// 熔断打开或半开探测名额已满时返回 503 + ActionState 信封(W1/W2 裁决): +// +// {"success":false,"error":{"code":"GW_CIRCUIT_OPEN","message":"..."},"retry_after":30} func CircuitBreaker(serviceName string) gin.HandlerFunc { cb := gobreaker.NewCircuitBreaker[struct{}](gobreaker.Settings{ Name: serviceName, @@ -35,7 +38,13 @@ func CircuitBreaker(serviceName string) gin.HandlerFunc { return counts.TotalFailures*2 > counts.Requests }, OnStateChange: func(name string, from, to gobreaker.State) { - log.Printf("[circuit-breaker] service=%s state: %s -> %s", name, from, to) + observability.SetCircuitBreakerState(name, from.String(), 0) + observability.SetCircuitBreakerState(name, to.String(), 1) + slog.Info("circuit breaker state changed", + "service", name, + "from", from.String(), + "to", to.String(), + ) }, }) @@ -52,8 +61,13 @@ func CircuitBreaker(serviceName string) gin.HandlerFunc { if err != nil { // 熔断打开或半开探测名额已满:返回 503 if errors.Is(err, gobreaker.ErrOpenState) || errors.Is(err, gobreaker.ErrTooManyRequests) { + c.Header("Retry-After", "30") c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{ - "error": "circuit_open", + "success": false, + "error": gin.H{ + "code": "GW_CIRCUIT_OPEN", + "message": "upstream circuit breaker open", + }, "retry_after": 30, }) return diff --git a/services/api-gateway/internal/middleware/cors.go b/services/api-gateway/internal/middleware/cors.go index 0bd3688..79de6f2 100644 --- a/services/api-gateway/internal/middleware/cors.go +++ b/services/api-gateway/internal/middleware/cors.go @@ -1,12 +1,12 @@ package middleware import ( - "log" + "log/slog" "net/http" - "os" "strconv" "strings" + "github.com/edu-cloud/api-gateway/internal/config" "github.com/gin-gonic/gin" ) @@ -16,16 +16,16 @@ const corsMaxAge = 12 * 60 * 60 // devCORSOrigins 是未配置 CORS_ORIGINS 时的开发环境默认白名单 const devCORSOrigins = "http://localhost:3000,http://localhost:3001" -// CORS 返回跨域资源共享中间件。 -// 允许来源从环境变量 CORS_ORIGINS 读取(逗号分隔); +// CORS 返回跨域资源共享中间件(从 Config 读取白名单,W3 裁决:slog 结构化日志)。 +// 允许来源从 cfg.CORSOrigins 读取(逗号分隔); // 未配置时使用开发环境白名单(localhost:3000/3001)并打印 warning。 // 允许方法:GET POST PUT DELETE OPTIONS PATCH // 允许头:Authorization Content-Type X-Request-Id X-Trace-Id // 暴露头:X-Request-Id X-Trace-Id -func CORS() gin.HandlerFunc { - allowed := parseCORSOrigins(os.Getenv("CORS_ORIGINS")) +func CORS(cfg *config.Config) gin.HandlerFunc { + allowed := parseCORSOrigins(cfg.CORSOrigins) if len(allowed) == 0 { - log.Println("warning: CORS_ORIGINS not set, using dev default whitelist") + slog.Warn("CORS_ORIGINS not set, using dev default whitelist") allowed = parseCORSOrigins(devCORSOrigins) } diff --git a/services/api-gateway/internal/middleware/ratelimit.go b/services/api-gateway/internal/middleware/ratelimit.go index 24d0e23..3eecde3 100644 --- a/services/api-gateway/internal/middleware/ratelimit.go +++ b/services/api-gateway/internal/middleware/ratelimit.go @@ -5,6 +5,7 @@ import ( "sync" "time" + "github.com/edu-cloud/api-gateway/internal/observability" "github.com/gin-gonic/gin" ) @@ -22,9 +23,12 @@ type rateLimiter struct { buckets sync.Map // map[string]*bucket } -// RateLimit 返回基于令牌桶的限流中间件(内存版,不依赖 Redis)。 +// RateLimit 返回基于令牌桶的限流中间件(内存版,P6 迁 Redis)。 // 每个客户端 IP 独立桶,按 rps(每秒令牌数)补充,最大容量为 burst。 -// 超限返回 429 + JSON {"error":"rate_limited","retry_after":60}。 +// 超限返回 429 + ActionState 信封(W1/W2 裁决): +// +// {"success":false,"error":{"code":"GW_RATE_LIMITED","message":"..."},"retry_after":60} +// // 内部启动 cleanup goroutine,每 5 分钟清理 10 分钟未访问的桶。 func RateLimit(rps float64, burst int) gin.HandlerFunc { rl := &rateLimiter{rps: rps, burst: burst} @@ -47,9 +51,14 @@ func RateLimit(rps float64, burst int) gin.HandlerFunc { // 令牌不足:拒绝 if b.tokens < 1 { b.mu.Unlock() + observability.IncRateLimited(ip) c.Header("Retry-After", "60") c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{ - "error": "rate_limited", + "success": false, + "error": gin.H{ + "code": "GW_RATE_LIMITED", + "message": "rate limit exceeded", + }, "retry_after": 60, }) return diff --git a/services/api-gateway/internal/middleware/recovery.go b/services/api-gateway/internal/middleware/recovery.go index 497f7e9..9d93129 100644 --- a/services/api-gateway/internal/middleware/recovery.go +++ b/services/api-gateway/internal/middleware/recovery.go @@ -1,7 +1,7 @@ package middleware import ( - "log" + "log/slog" "net/http" "runtime/debug" @@ -9,20 +9,32 @@ import ( "github.com/google/uuid" ) -// Recovery 捕获 panic 并返回 500,记录堆栈日志。 -// 返回 JSON {"error":"internal_error","request_id":""}。 -// request_id 使用 uuid.New() 生成(Recovery 在 RequestID 之前注册, -// panic 发生时上下文中可能尚无 request_id,故独立生成)。 +// Recovery 捕获 panic 并返回 500(ActionState 信封,W1/W2 裁决)。 +// +// 响应体:{"success":false,"error":{"code":"GW_INTERNAL_ERROR","message":"..."},"request_id":""}。 +// request_id 从 context 取(RequestID 中间件注入),若 Recovery 在 RequestID 之前触发 +// 导致 context 无 request_id,则独立生成 uuid 保证可追溯。 func Recovery() gin.HandlerFunc { return func(c *gin.Context) { defer func() { if r := recover(); r != nil { stack := debug.Stack() - requestID := uuid.New().String() - log.Printf("[recovery] panic recovered, request_id=%s: %v\n%s", requestID, r, stack) - // 若已写入部分响应,AbortWithStatusJSON 仍会设置状态并尝试写 JSON + // 优先从 context 取 request_id(RequestID 中间件已注入) + requestID, exists := c.Get(requestIDContextKey) + if !exists { + requestID = uuid.New().String() + } + slog.Error("panic recovered", + "request_id", requestID, + "error", r, + "stack", string(stack), + ) c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{ - "error": "internal_error", + "success": false, + "error": gin.H{ + "code": "GW_INTERNAL_ERROR", + "message": "internal server error", + }, "request_id": requestID, }) } diff --git a/services/api-gateway/internal/middleware/requestid.go b/services/api-gateway/internal/middleware/requestid.go index 6f19bc4..4e8a7cb 100644 --- a/services/api-gateway/internal/middleware/requestid.go +++ b/services/api-gateway/internal/middleware/requestid.go @@ -17,7 +17,8 @@ func RequestID() gin.HandlerFunc { return func(c *gin.Context) { rid := c.GetHeader(requestIDHeader) if rid == "" { - rid = uuid.New().String() + // 02-architecture-design.md §4.6:req- 格式 + rid = "req-" + uuid.New().String() } c.Set(requestIDContextKey, rid) c.Writer.Header().Set(requestIDHeader, rid) diff --git a/services/api-gateway/internal/middleware/security.go b/services/api-gateway/internal/middleware/security.go index 8ee51be..7dddcb3 100644 --- a/services/api-gateway/internal/middleware/security.go +++ b/services/api-gateway/internal/middleware/security.go @@ -30,9 +30,19 @@ func SecurityHeaders() gin.HandlerFunc { } // RequestBodyLimit 限制请求体大小中间件。 -// 通过 http.MaxBytesReader 包装 Body,超限读取时返回 413。 +// 超限时返回 413 + ActionState 信封(W1/W2 裁决): +// +// {"success":false,"error":{"code":"GW_REQUEST_TOO_LARGE","message":"..."}} +// +// 通过 Content-Length 预检 + http.MaxBytesReader 双重保障: +// - Content-Length 预检:已知大小的请求体立即拒绝(JSON 信封响应) +// - MaxBytesReader:流式请求体(无 Content-Length)读取超限时由标准库返回 413 func RequestBodyLimit(maxBytes int64) gin.HandlerFunc { return func(c *gin.Context) { + if c.Request.ContentLength > maxBytes { + abortGW(c, http.StatusRequestEntityTooLarge, "GW_REQUEST_TOO_LARGE", "request body too large") + return + } c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes) c.Next() } diff --git a/services/api-gateway/internal/observability/metrics.go b/services/api-gateway/internal/observability/metrics.go new file mode 100644 index 0000000..229d1a9 --- /dev/null +++ b/services/api-gateway/internal/observability/metrics.go @@ -0,0 +1,96 @@ +// Package observability provides tracer, metrics, and structured logging +// initialization for the api-gateway. +package observability + +import ( + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +// 业务指标(W5 裁决:7 个业务 metrics) +var ( + httpRequestsTotal = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "api_gateway_http_requests_total", + Help: "Total number of HTTP requests processed by the gateway.", + }, []string{"method", "endpoint", "status"}) + + httpRequestDurationSeconds = promauto.NewHistogramVec(prometheus.HistogramOpts{ + Name: "api_gateway_http_request_duration_seconds", + Help: "HTTP request processing duration in seconds.", + Buckets: prometheus.DefBuckets, + }, []string{"method", "endpoint"}) + + circuitBreakerState = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "api_gateway_circuit_breaker_state", + Help: "Circuit breaker state by service: 1=active state, 0=inactive.", + }, []string{"service", "state"}) + + rateLimitedTotal = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "api_gateway_rate_limited_total", + Help: "Total number of rate-limited requests.", + }, []string{"ip"}) + + proxyUpstreamDurationSeconds = promauto.NewHistogramVec(prometheus.HistogramOpts{ + Name: "api_gateway_proxy_upstream_duration_seconds", + Help: "Upstream proxy response duration in seconds.", + Buckets: prometheus.DefBuckets, + }, []string{"upstream"}) + + jwksRefreshTotal = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "api_gateway_jwks_refresh_total", + Help: "Total number of JWKS cache refresh attempts.", + }, []string{"result"}) + + authFailuresTotal = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "api_gateway_auth_failures_total", + Help: "Total number of authentication failures.", + }, []string{"reason"}) +) + +// Metrics 返回 HTTP 请求统计中间件(W5 裁决)。 +// 记录请求总数(method/endpoint/status)与请求延迟(method/endpoint)。 +// endpoint 使用 gin 路由模式(c.FullPath())避免高基数。 +func Metrics() gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + c.Next() + + duration := time.Since(start).Seconds() + status := strconv.Itoa(c.Writer.Status()) + endpoint := c.FullPath() + if endpoint == "" { + endpoint = "unknown" + } + httpRequestsTotal.WithLabelValues(c.Request.Method, endpoint, status).Inc() + httpRequestDurationSeconds.WithLabelValues(c.Request.Method, endpoint).Observe(duration) + } +} + +// IncAuthFailure 递增鉴权失败计数器。 +func IncAuthFailure(reason string) { + authFailuresTotal.WithLabelValues(reason).Inc() +} + +// IncRateLimited 递增限流计数器。 +func IncRateLimited(ip string) { + rateLimitedTotal.WithLabelValues(ip).Inc() +} + +// IncJWKSRefresh 递增 JWKS 刷新计数器(result: success/failure)。 +func IncJWKSRefresh(result string) { + jwksRefreshTotal.WithLabelValues(result).Inc() +} + +// SetCircuitBreakerState 更新熔断器状态 gauge。 +func SetCircuitBreakerState(service, state string, value float64) { + circuitBreakerState.WithLabelValues(service, state).Set(value) +} + +// ObserveProxyUpstreamDuration 记录上游代理响应延迟。 +func ObserveProxyUpstreamDuration(upstream string, duration time.Duration) { + proxyUpstreamDurationSeconds.WithLabelValues(upstream).Observe(duration.Seconds()) +} diff --git a/services/api-gateway/internal/observability/tracer.go b/services/api-gateway/internal/observability/tracer.go index 3d1861c..b36dd90 100644 --- a/services/api-gateway/internal/observability/tracer.go +++ b/services/api-gateway/internal/observability/tracer.go @@ -2,11 +2,12 @@ package observability import ( "context" - "log" + "log/slog" "net/url" "time" "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" "go.opentelemetry.io/otel/propagation" "go.opentelemetry.io/otel/sdk/resource" @@ -14,21 +15,22 @@ import ( semconv "go.opentelemetry.io/otel/semconv/v1.26.0" ) -// InitTracer 初始化 OpenTelemetry tracer. +// InitTracer 初始化 OpenTelemetry tracer(W6 裁决:资源属性补全)。 // +// 资源属性包含:service.name / service.version / deployment.environment / host.name。 // endpoint 为 "http://host:port" 格式(如 "http://localhost:4318"); // 为空时跳过初始化(tracing disabled)。 // -// 返回 shutdown 函数,应在服务退出时调用以 flush 待发送 span. -func InitTracer(serviceName, endpoint string) func() { +// 返回 shutdown 函数,应在服务退出时调用以 flush 待发送 span。 +func InitTracer(serviceName, endpoint, env, version, hostName string) func() { if endpoint == "" { - log.Println("OTEL endpoint not set, tracing disabled") + slog.Info("OTEL endpoint not set, tracing disabled") return func() {} } u, err := url.Parse(endpoint) if err != nil || u.Host == "" { - log.Printf("invalid OTEL endpoint %q, tracing disabled", endpoint) + slog.Warn("invalid OTEL endpoint, tracing disabled", "endpoint", endpoint) return func() {} } @@ -40,15 +42,21 @@ func InitTracer(serviceName, endpoint string) func() { otlptracehttp.WithInsecure(), ) if err != nil { - log.Printf("failed to create OTLP exporter: %v, tracing disabled", err) + slog.Error("failed to create OTLP exporter, tracing disabled", "error", err) return func() {} } + // W6 裁决:资源属性补全(service.name + version + env + host) res, err := resource.New(ctx, - resource.WithAttributes(semconv.ServiceName(serviceName)), + resource.WithAttributes( + semconv.ServiceName(serviceName), + attribute.String("service.version", version), + attribute.String("deployment.environment", env), + attribute.String("host.name", hostName), + ), ) if err != nil { - log.Printf("failed to create resource: %v", err) + slog.Error("failed to create resource", "error", err) return func() {} } @@ -62,12 +70,18 @@ func InitTracer(serviceName, endpoint string) func() { propagation.Baggage{}, )) - log.Printf("OpenTelemetry tracer initialized for %s (endpoint=%s)", serviceName, u.Host) + slog.Info("OpenTelemetry tracer initialized", + "service", serviceName, + "endpoint", u.Host, + "env", env, + "version", version, + "host", hostName, + ) return func() { shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := tp.Shutdown(shutdownCtx); err != nil { - log.Printf("failed to shutdown tracer: %v", err) + slog.Error("failed to shutdown tracer", "error", err) } } } diff --git a/services/api-gateway/internal/proxy/proxy.go b/services/api-gateway/internal/proxy/proxy.go index 43390d4..d4798b6 100644 --- a/services/api-gateway/internal/proxy/proxy.go +++ b/services/api-gateway/internal/proxy/proxy.go @@ -19,9 +19,8 @@ func NewProxy(targetURL string) (*httputil.ReverseProxy, error) { originalDirector := proxy.Director proxy.Director = func(req *http.Request) { originalDirector(req) - // 去除 /api/v1 前缀 + // 去除 /api/v1 前缀(Gateway 不改路径,直接透传给下游服务根路径) req.URL.Path = strings.TrimPrefix(req.URL.Path, "/api/v1") - req.URL.Path = strings.TrimPrefix(req.URL.Path, "/api") req.Host = target.Host } return proxy, nil diff --git a/services/api-gateway/main.go b/services/api-gateway/main.go index 300b690..129575e 100644 --- a/services/api-gateway/main.go +++ b/services/api-gateway/main.go @@ -2,7 +2,7 @@ package main import ( "context" - "log" + "log/slog" "net/http" "os" "os/signal" @@ -14,6 +14,7 @@ import ( "github.com/edu-cloud/api-gateway/internal/middleware" "github.com/edu-cloud/api-gateway/internal/observability" "github.com/edu-cloud/api-gateway/internal/proxy" + "github.com/edu-cloud/shared-go/jwks" "github.com/gin-gonic/gin" "github.com/prometheus/client_golang/prometheus/promhttp" "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin" @@ -22,12 +23,21 @@ import ( // maxBodyBytes 请求体大小上限:10MB const maxBodyBytes int64 = 10 * 1024 * 1024 +// version 编译时通过 -ldflags "-X main.version=..." 注入,默认 dev +var version = "dev" + func main() { cfg := config.Load() - // 初始化 OpenTelemetry tracer(endpoint 为空时自动跳过) - tracerShutdown := observability.InitTracer("api-gateway", cfg.OTLPEndpoint) + + // 初始化 OpenTelemetry tracer(W6 裁决:资源属性补全) + hostName, _ := os.Hostname() + tracerShutdown := observability.InitTracer("api-gateway", cfg.OTLPEndpoint, cfg.Env, version, hostName) defer tracerShutdown() + // 初始化 JWKS Fetcher(RS256 公钥缓存,P2.2 裁决) + // DevMode 下也创建 fetcher,使 DevMode 可验证真实 JWT(非 dev-token 场景) + fetcher := jwks.NewFetcher(cfg.JWKSURL) + gin.SetMode(gin.ReleaseMode) r := gin.New() // 关闭尾斜杠重定向:避免 Next.js rewrites 代理时 /api/v1/classes → 301 → /api/v1/classes/ 循环 @@ -41,7 +51,7 @@ func main() { // 3. 请求 ID 注入 r.Use(middleware.RequestID()) // 4. 跨域 - r.Use(middleware.CORS()) + r.Use(middleware.CORS(cfg)) // 5. 安全响应头 r.Use(middleware.SecurityHeaders()) // 6. 请求体大小限制 @@ -51,101 +61,46 @@ func main() { // 健康检查路由(无需鉴权,在 Auth 之前) r.GET("/healthz", health.Healthz) - r.GET("/readyz", health.Readyz) + r.GET("/readyz", health.Readyz(cfg)) // Prometheus 指标端点 r.GET("/metrics", gin.WrapH(promhttp.Handler())) - // API v1 组:熔断 + 鉴权 + 反向代理 + // API v1 组:熔断 + 鉴权 + 指标 + 反向代理 api := r.Group("/api/v1") - // 7. 熔断(仅作用于代理路由,下游 5xx 触发) + // 8. 熔断(仅作用于代理路由,下游 5xx 触发) api.Use(middleware.CircuitBreaker("downstream")) - // 8. JWT 鉴权 - api.Use(middleware.AuthMiddleware(cfg)) + // 9. JWT 鉴权(RS256,通过 JWKS 公钥校验) + api.Use(middleware.AuthMiddleware(cfg, fetcher)) + // 10. HTTP 请求指标(统计通过鉴权的请求) + api.Use(observability.Metrics()) { - // classes 服务路由 - // 注:同时注册无尾斜杠与通配符两条路由。RedirectTrailingSlash=false 时, - // Gin 不会自动把 /classes 跳到 /classes/,所以两条都要显式注册。 - classesProxy, err := proxy.NewProxy(cfg.ClassesServiceURL) - if err != nil { - log.Fatalf("failed to create classes proxy: %v", err) - } - classesHandler := proxy.ProxyHandler(classesProxy) - api.Any("/classes", classesHandler) - api.Any("/classes/*path", classesHandler) - - // IAM 服务路由(身份与访问管理) - iamProxy, err := proxy.NewProxy(cfg.IamServiceURL) - if err != nil { - log.Fatalf("failed to create iam proxy: %v", err) - } - iamHandler := proxy.ProxyHandler(iamProxy) - api.Any("/iam", iamHandler) - api.Any("/iam/*path", iamHandler) - - // Teacher BFF 路由(教师聚合层) - bffProxy, err := proxy.NewProxy(cfg.TeacherBffURL) - if err != nil { - log.Fatalf("failed to create teacher-bff proxy: %v", err) - } - bffHandler := proxy.ProxyHandler(bffProxy) - api.Any("/teacher", bffHandler) - api.Any("/teacher/*path", bffHandler) - - // core-edu 服务路由(考试/作业/成绩) - // 注:同时注册无尾斜杠与通配符两条路由,与 classes/iam/teacher 一致。 - coreEduProxy, err := proxy.NewProxy(cfg.CoreEduServiceURL) - if err != nil { - log.Fatalf("failed to create core-edu proxy: %v", err) - } - coreEduHandler := proxy.ProxyHandler(coreEduProxy) - api.Any("/exams", coreEduHandler) - api.Any("/exams/*path", coreEduHandler) - api.Any("/homework", coreEduHandler) - api.Any("/homework/*path", coreEduHandler) - api.Any("/grades", coreEduHandler) - api.Any("/grades/*path", coreEduHandler) - - // content 服务路由(教材/章节/知识点/题库) - contentProxy, err := proxy.NewProxy(cfg.ContentServiceURL) - if err != nil { - log.Fatalf("failed to create content proxy: %v", err) - } - contentHandler := proxy.ProxyHandler(contentProxy) - api.Any("/textbooks", contentHandler) - api.Any("/textbooks/*path", contentHandler) - api.Any("/chapters", contentHandler) - api.Any("/chapters/*path", contentHandler) - api.Any("/knowledge-points", contentHandler) - api.Any("/knowledge-points/*path", contentHandler) - api.Any("/questions", contentHandler) - api.Any("/questions/*path", contentHandler) - - // msg 服务路由(通知/消息) - msgProxy, err := proxy.NewProxy(cfg.MsgServiceURL) - if err != nil { - log.Fatalf("failed to create msg proxy: %v", err) - } - msgHandler := proxy.ProxyHandler(msgProxy) - api.Any("/notifications", msgHandler) - api.Any("/notifications/*path", msgHandler) - + // classes → core-edu(C1 裁决:classes 合并入 core-edu) + registerProxy(api, "classes", cfg.CoreEduServiceURL) + // iam 服务路由(身份与访问管理) + registerProxy(api, "iam", cfg.IamServiceURL) + // teacher-bff 路由(教师聚合层 GraphQL) + registerProxy(api, "teacher", cfg.TeacherBffURL) + // student-bff 路由(学生聚合层 GraphQL,P3) + registerProxy(api, "student", cfg.StudentBffURL) + // parent-bff 路由(家长聚合层 GraphQL,P4) + registerProxy(api, "parent", cfg.ParentBffURL) + // core-edu 域路由(考试/作业/成绩) + registerProxy(api, "exams", cfg.CoreEduServiceURL) + registerProxy(api, "homework", cfg.CoreEduServiceURL) + registerProxy(api, "grades", cfg.CoreEduServiceURL) + // content 域路由(教材/章节/知识点/题库) + registerProxy(api, "textbooks", cfg.ContentServiceURL) + registerProxy(api, "chapters", cfg.ContentServiceURL) + registerProxy(api, "knowledge-points", cfg.ContentServiceURL) + registerProxy(api, "questions", cfg.ContentServiceURL) + // msg 域路由(通知/消息) + registerProxy(api, "notifications", cfg.MsgServiceURL) + registerProxy(api, "messages", cfg.MsgServiceURL) // ai 服务路由(AI 聊天/生成/优化) - aiProxy, err := proxy.NewProxy(cfg.AiServiceURL) - if err != nil { - log.Fatalf("failed to create ai proxy: %v", err) - } - aiHandler := proxy.ProxyHandler(aiProxy) - api.Any("/ai", aiHandler) - api.Any("/ai/*path", aiHandler) - - // data-ana 服务路由(学情诊断/错题本) - dataAnaProxy, err := proxy.NewProxy(cfg.DataAnaServiceURL) - if err != nil { - log.Fatalf("failed to create data-ana proxy: %v", err) - } - dataAnaHandler := proxy.ProxyHandler(dataAnaProxy) - api.Any("/analytics", dataAnaHandler) - api.Any("/analytics/*path", dataAnaHandler) + registerProxy(api, "ai", cfg.AiServiceURL) + // data-ana 域路由(学情诊断/错题本/仪表盘) + registerProxy(api, "analytics", cfg.DataAnaServiceURL) + registerProxy(api, "dashboard", cfg.DataAnaServiceURL) } srv := &http.Server{ @@ -157,21 +112,40 @@ func main() { // 优雅关闭 go func() { - log.Printf("API Gateway listening on :%s", cfg.Port) + slog.Info("API Gateway listening", + "port", cfg.Port, + "env", cfg.Env, + "dev_mode", cfg.DevMode, + "version", version, + ) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Fatalf("listen: %s", err) + slog.Error("listen failed", "error", err) + os.Exit(1) } }() quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit - log.Println("Shutting down API Gateway...") + slog.Info("shutting down API Gateway") ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := srv.Shutdown(ctx); err != nil { - log.Fatal("Server forced to shutdown:", err) + slog.Error("server forced to shutdown", "error", err) } - log.Println("API Gateway exited") + slog.Info("API Gateway exited") +} + +// registerProxy 创建反向代理并注册无尾斜杠与通配符两条路由。 +// RedirectTrailingSlash=false 时 Gin 不会自动跳转,故两条路由都要显式注册。 +func registerProxy(api *gin.RouterGroup, prefix, targetURL string) { + p, err := proxy.NewProxy(targetURL) + if err != nil { + slog.Error("failed to create proxy", "prefix", prefix, "target", targetURL, "error", err) + panic(err) + } + handler := proxy.ProxyHandler(p) + api.Any("/"+prefix, handler) + api.Any("/"+prefix+"/*path", handler) }