feat(api-gateway): 实现 W1-W8 网关硬化与 P2-P5 路由扩展

依据 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 均通过。
This commit is contained in:
SpecialX
2026-07-10 18:15:48 +08:00
parent 9e767b4e95
commit 4307f6b73c
20 changed files with 797 additions and 382 deletions

View File

@@ -195,21 +195,30 @@
### 2.1 api-gatewayGo ### 2.1 api-gatewayGo
| 场景 | 技术/规则 | | 场景 | 技术/规则 |
| ----------------- | ------------------------------------------------------------------------------------------------------------- | | ---------------------- | ------------------------------------------------------------------------------------------------------------- |
| P1 鉴权 | Gateway 内置 HS256 JWT`jwt.ParseWithClaims` + `SigningMethodHMAC` 校验 | | P2 鉴权RS256 | `shared-go/jwks.Fetcher` 拉 IAM `/.well-known/jwks.json` 公钥TTL 5min 缓存,`jwt.Parse` 用公钥验签 |
| P2 鉴权升级 | 改 RS256IAM 私钥签发Gateway 公钥校验,无需调 IAM | | 路由转发 | `gin.Group("/api/v1")` + `httputil.NewSingleHostReverseProxy` |
| 路由转发 | `gin.Group("/api/v1")` + `httputil.NewSingleHostReverseProxy` | | 路径重写 | 去掉 `/api/v1` 前缀后转发到下游服务(方案 BGateway 不改路径,各服务 Controller 内加 `/v1` |
| 路径重写 | 去掉 `/api/v1` 前缀后转发到下游服务 | | 用户上下文注入 | `c.Request.Header.Set("x-user-id/x-user-roles/x-data-scope", ...)` 传递给下游 |
| 用户上下文注入 | `c.Request.Header.Set("x-user-id", claims.UserID)` 传递给下游 | | 请求 ID | Gateway 生成或透传 `X-Request-Id``req-<uuid-v4>` 格式),`c.Set("request_id", ...)` + `c.Header(...)` |
| 请求 ID | Gateway 生成或透传 `X-Request-ID``c.Set("request_id", ...)` + `c.Header(...)` | | 限流 | per-IP 令牌桶(`sync.Map`),超限 429 `GW_RATE_LIMITED`P6 迁 Redis 支持多副本 |
| P1 不做 | 限流/熔断/灰度P6 硬化阶段实现) | | 熔断 | `gobreaker v2` 共享 downstream 实例5xx > 50% 触发 OPEN返回 503 `GW_CIRCUIT_OPEN` + Retry-After |
| 健康检查 | `GET /health` 无需鉴权 | | 错误码 | 统一 `GW_` 前缀W1响应体 ActionState 信封 `{success,error:{code,message}}`W2 |
| 尾斜杠重定向循环 | `r.RedirectTrailingSlash=false` + 同时注册 `Any("/classes")` 与 `Any("/classes/*path")` | | 结构化日志 | 标准库 `log/slog`W3`slog.Info`/`slog.Error` 带 key-value未接入 shared-go/loggerzap待评估 |
| 开发模式鉴权旁路 | `DEV_MODE=true` 时接受 `Bearer dev-token`,注入固定身份;生产必须 `false` | | 健康检查 | `GET /healthz`liveness无鉴权+ `GET /readyz`readiness并行 ping 9 下游 /healthz软失败规则W4 |
| 路由注册位置 | 真实路由在 `main.go` 的 `api.Group` 内注册,`internal/routing/routing.go` 若未被 main 引用即为死代码 | | Metrics | 7 个业务指标promauto`GET /metrics` 暴露W5requests/duration/circuit/ratelimit/proxy/jwks/authfail |
| 多服务路由扩展 | 新增服务代理时在 main.go 注册两组路由:`Any("/x")` + `Any("/x/*path")`,与 classes 一致 | | Tracer 资源属性 | `resource.New` 补 service.name/version/deployment.environment/host.nameW64 项完整) |
| DEV_MODE 环境变量 | Go 不自动加载 .env`DEV_MODE` 必须在启动前 export 或写入系统环境变量,否则 DevMode=false 导致 dev-token 被拒 | | DevMode 生产防护 | 启动时 `DevMode=true && ENV=production` panic 拒绝启动W7config.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 classesTS/NestJSP1 黄金模板) ### 2.2 classesTS/NestJSP1 黄金模板)

View File

@@ -1,4 +1,4 @@
go 1.26.0 go 1.25.0
use ( use (
./packages/shared-go ./packages/shared-go

93
pnpm-lock.yaml generated
View File

@@ -82,8 +82,88 @@ importers:
specifier: ^5.6.0 specifier: ^5.6.0
version: 5.9.3 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-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: scripts/arch-scan:
dependencies: dependencies:
better-sqlite3: better-sqlite3:
@@ -1494,6 +1574,10 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] 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': '@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -2503,6 +2587,9 @@ packages:
peerDependencies: peerDependencies:
'@opentelemetry/api': ^1.1.0 '@opentelemetry/api': ^1.1.0
'@paralleldrive/cuid2@2.3.1':
resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==}
'@pinojs/redact@0.4.0': '@pinojs/redact@0.4.0':
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
@@ -6642,6 +6729,8 @@ snapshots:
'@next/swc-win32-x64-msvc@14.2.33': '@next/swc-win32-x64-msvc@14.2.33':
optional: true optional: true
'@noble/hashes@1.8.0': {}
'@nodelib/fs.scandir@2.1.5': '@nodelib/fs.scandir@2.1.5':
dependencies: dependencies:
'@nodelib/fs.stat': 2.0.5 '@nodelib/fs.stat': 2.0.5
@@ -8135,6 +8224,10 @@ snapshots:
'@opentelemetry/api': 1.9.1 '@opentelemetry/api': 1.9.1
'@opentelemetry/core': 1.30.1(@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': {} '@pinojs/redact@0.4.0': {}
'@pkgjs/parseargs@0.11.0': '@pkgjs/parseargs@0.11.0':

View File

@@ -4,66 +4,89 @@ Go (Gin) 实现的 API 网关,所有外部请求的统一入口。
## 职责 ## 职责
- **路由转发**`/api/v1/classes/*` → classes 服务P1后续阶段追加 iam/core-edu/content/msg 等路由 - **路由转发**`/api/v1/<service>/*` → 下游服务iam / teacher-bff / student-bff / parent-bff / core-edu / content / msg / ai / data-ana
- **JWT 鉴权**P1 HS256测试密钥P2 起 RS256IAM 签发Gateway 公钥校验) - **JWT 鉴权**RS256IAM 签发Gateway 通过 JWKS 公钥校验DevMode 下支持 `dev-token` 旁路
- **请求 ID 注入**:生成或透传 `X-Request-ID`,全链路追踪 - **请求 ID 注入**:生成或透传 `X-Request-Id``req-<uuid-v4>` 格式),全链路追踪
- **限流**P6:基于令牌桶的 per-IP 限流,超限返回 429 - **限流**:基于令牌桶的 per-IP 限流100 rps突发 20超限返回 429 + `GW_RATE_LIMITED`
- **熔断**P6:基于 gobreaker v2 的下游服务熔断5xx 错误率 > 50% 触发 OPEN - **熔断**:基于 gobreaker v2 的下游服务熔断5xx 错误率 > 50% 触发 OPEN,返回 503 + `GW_CIRCUIT_OPEN`
- **CORS / 安全头 / Recovery**P6标准化中间件链 - **可观测性**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` | | Recovery | `internal/middleware/recovery.go` | panic 兜底返回 500 + `GW_INTERNAL_ERROR` |
| CORS | `internal/middleware/cors.go` | 跨域允许 | | OTel | `otelgin.Middleware` | OpenTelemetry 自动埋点 |
| Security | `internal/middleware/security.go` | 安全响应头X-Content-Type-Options 等) | | RequestID | `internal/middleware/requestid.go` | 生成/透传 `X-Request-Id``req-<uuid-v4>` |
| Recovery | `internal/middleware/recovery.go` | panic 兜底返回 500 | | CORS | `internal/middleware/cors.go` | 跨域允许(从 Config 读取白名单) |
| RateLimit | `internal/middleware/ratelimit.go` | per-IP 令牌桶限流,超限 429 | | SecurityHeaders | `internal/middleware/security.go` | 安全响应头 + 请求体限制413 `GW_REQUEST_TOO_LARGE` |
| Auth | `internal/middleware/auth.go` | JWT 校验,注入 `x-user-id`/`x-user-roles` | | RateLimit | `internal/middleware/ratelimit.go` | per-IP 令牌桶限流,超限 429 `GW_RATE_LIMITED` |
| CircuitBreaker | `internal/middleware/circuit-breaker.go` | 下游 5xx 错误率 > 50% 触发熔断,返回 503 | | 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 | JWTregister/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 /healthz` | 存活探针liveness | 无 |
| `GET /readyz` | 就绪探针readiness | 无 | | `GET /readyz` | 就绪探针readiness,并行 ping 下游 /healthz软失败规则 | 无 |
| `GET /health` | 兼容端点(返回 `{ status, timestamp }` | 无 | | `GET /metrics` | Prometheus 指标端点7 个业务指标 + Go runtime | 无 |
## 开发 ## 开发
```bash ```bash
cd services/api-gateway cd services/api-gateway
go mod tidy
go run main.go go run main.go
``` ```
DevMode接受 `Bearer dev-token` 旁路鉴权):
```bash
DEV_MODE=true go run main.go
```
## 测试 ## 测试
```bash ```bash
# 单元 + 集成测试
cd services/api-gateway cd services/api-gateway
go test ./internal/middleware/... -v -cover 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 ```bash
# 本地构建
go build ./... go build ./...
# Docker 构建
docker build -t edu/api-gateway . docker build -t edu/api-gateway .
``` ```
@@ -71,25 +94,35 @@ docker build -t edu/api-gateway .
通过环境变量配置(见 `internal/config/config.go` 通过环境变量配置(见 `internal/config/config.go`
| 变量 | 默认值 | 说明 | | 变量 | 默认值 | 说明 |
| ----------------------------- | --------------------- | ------------------------------------------------------ | | ----------------------------- | ------------------------------------------- | -------------------------------------------------------- |
| `API_GATEWAY_PORT` | 8080 | 监听端口 | | `API_GATEWAY_PORT` | 8080 | 监听端口 |
| `JWT_SECRET` | (必填) | HS256 签名密钥P1 | | `ENV` | development | 部署环境production 时 W7 防护生效) |
| `JWT_ISSUER` | next-edu-cloud | JWT 签发者 | | `DEV_MODE` | false | 开发模式旁路true 时接受 `Bearer dev-token`(仅非生产) |
| `JWT_AUDIENCE` | next-edu-cloud | JWT 受众 | | `IAM_JWKS_URL` | http://localhost:3002/.well-known/jwks.json | RS256 公钥端点(非 DevMode 必填) |
| `DEV_MODE` | false | 开发模式旁路true 时接受 `Bearer dev-token`(仅本地) | | `JWT_ISSUER` | next-edu-cloud | JWT 签发者校验 |
| `CLASSES_SERVICE_URL` | http://localhost:3001 | classes 服务地址 | | `JWT_AUDIENCE` | next-edu-cloud | JWT 受众校验 |
| `IAM_SERVICE_URL` | http://localhost:3002 | iam 服务地址 | | `CORS_ORIGINS` | (空,用开发默认白名单) | CORS 白名单(逗号分隔) |
| `TEACHER_BFF_URL` | http://localhost:3003 | teacher-bff 服务地址 | | `CLASSES_SERVICE_URL` | http://localhost:3001 | classes 服务地址(遗留,路由实际走 core-edu |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | http://localhost:4318 | OpenTelemetry OTLP 端点 | | `IAM_SERVICE_URL` | http://localhost:3002 | iam 服务地址 |
| `LOG_LEVEL` | info | 日志级别 | | `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) - [架构影响地图](../../docs/architecture/004_architecture_impact_map.md)
- [项目规则](../../.trae/rules/project_rules.md) - [项目规则](../../.trae/rules/project_rules.md)
- [模块理解确认书](./docs/01-understanding.md)
- [架构设计文档](./docs/02-architecture-design.md)
- [P6 硬化 Runbook](../../docs/architecture/runbooks/p6-hardening.md) - [P6 硬化 Runbook](../../docs/architecture/runbooks/p6-hardening.md)
- [P6 后续工作手册](../../docs/architecture/runbooks/post-p6-followup.md)
- [Helm Chart](../../infra/k8s/helm/api-gateway/) - [Helm Chart](../../infra/k8s/helm/api-gateway/)

View File

@@ -76,7 +76,7 @@
## 4. 我的技术栈 ## 4. 我的技术栈
- **语言**Go 1.22+go.mod 声明 1.25.0,需与 Dockerfile 对齐,见审计表 - **语言**Go 1.22go.mod 与 Dockerfile 一致
- **框架**Gin v1.12.0 - **框架**Gin v1.12.0
- **核心依赖** - **核心依赖**
- `github.com/golang-jwt/jwt/v5` v5.2.1JWT 校验) - `github.com/golang-jwt/jwt/v5` v5.2.1JWT 校验)
@@ -97,44 +97,46 @@
## 6. 我需要对齐的黄金模板项(对照 classes 服务) ## 6. 我需要对齐的黄金模板项(对照 classes 服务)
| 项 | classes黄金模板 | api-gateway 现状 | 差距 | | 项 | classes黄金模板 | api-gateway 现状 | 差距 |
| ----------------- | ------------------------------------------------- | -------------------------------------------------- | -------------------------------------------------------- | | ----------------- | ------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------- |
| 权限装饰器 | `@RequirePermission()` | N/AGo 无装饰器;用中间件 `AuthMiddleware` 替代) | ✅ 等价实现 | | 权限装饰器 | `@RequirePermission()` | N/AGo 无装饰器;用中间件 `AuthMiddleware` 替代) | ✅ 等价实现 |
| 错误码前缀 | `CLASSES_*` | 无前缀(基础设施层) | ✅ 设计合理 | | 错误码前缀 | `CLASSES_*` | 无前缀(基础设施层) | ✅ 设计合理 |
| loggerpino | `shared/observability/logger.ts` | ❌ 用标准库 `log` | ⚠️ 待补 `log/slog` 结构化日志 | | loggerpino | `shared/observability/logger.ts` | `log/slog` 结构化 JSON | ✅ 对齐 |
| metrics | `shared/observability/metrics.ts` 暴露 `/metrics` | ❌ 无 `/metrics` 端点 | ⚠️ 待补 prom-client | | metrics | `shared/observability/metrics.ts` 暴露 `/metrics` | ✅ 7 个业务指标promauto | ✅ 对齐 |
| tracer | `shared/observability/tracer.ts` OTel SDK | ✅ `internal/observability/tracer.go` | ✅ 对齐 | | tracer | `shared/observability/tracer.ts` OTel SDK | ✅ `internal/observability/tracer.go`W6 资源属性完整) | ✅ 对齐 |
| `/healthz` | ✅ | ✅ | ✅ 对齐 | | `/healthz` | ✅ | ✅ | ✅ 对齐 |
| `/readyz` | ✅ 检查 DB `SELECT 1` | ❌ stub 直接返回 200 | ⚠️ 待补下游服务健康检查 | | `/readyz` | ✅ 检查 DB `SELECT 1` | ✅ 并行 ping 下游 /healthz软失败规则 | ✅ 对齐 |
| 优雅关闭 | SIGTERM → app.close() | ✅ `srv.Shutdown(ctx)` 5s 超时 | ✅ 对齐 | | 优雅关闭 | SIGTERM → app.close() | ✅ `srv.Shutdown(ctx)` 5s 超时 | ✅ 对齐 |
| 测试覆盖率 | ≥ 80% | ~25%(仅 circuit-breaker + ratelimit | ⚠️ 待补 auth/cors/security/recovery/requestid/proxy 测试 | | 测试覆盖率 | ≥ 80% | ~25%(仅 circuit-breaker + ratelimit | ⚠️ 待补 auth/cors/security/recovery/requestid/proxy 测试 |
| Dockerfile | 多阶段 + 非 root + healthcheck | ✅ | ✅ 对齐 | | Dockerfile | 多阶段 + 非 root + healthcheck | ✅ | ✅ 对齐 |
| Zod 输入验证 | `schema.safeParse(body)` | N/AGo 无 Zod`ShouldBindJSON` | ✅ 等价实现 | | Zod 输入验证 | `schema.safeParse(body)` | N/AGo 无 Zod`ShouldBindJSON` | ✅ 等价实现 |
| GlobalErrorFilter | `GlobalErrorFilter` | ✅ Recovery 中间件兜底 | ✅ 等价实现 | | GlobalErrorFilter | `GlobalErrorFilter` | ✅ Recovery 中间件兜底 | ✅ 等价实现 |
## 7. 服务审计表(按 ai-allocation §10 模板) ## 7. 服务审计表(按 ai-allocation §10 模板)
| 服务 | 权限装饰器 | 错误码前缀 | logger | metrics | tracer | /healthz | /readyz | 优雅关闭 | 测试覆盖率 | Dockerfile | | 服务 | 权限装饰器 | 错误码前缀 | logger | metrics | tracer | /healthz | /readyz | 优雅关闭 | 测试覆盖率 | Dockerfile |
| ----------- | ------------- | ------------- | ----------- | ------- | ------- | -------- | ------- | ---------- | ---------- | ---------- | | ----------- | ------------- | ------------- | ------- | --------- | ------- | -------- | ------------ | ---------- | ---------- | ---------- |
| api-gateway | ⚠️ 中间件替代 | ✅ 无前缀合理 | ❌ 标准 log | ❌ 无 | ✅ OTel | ✅ | ⚠️ stub | ✅ 5s 超时 | ~25% | ✅ 多阶段 | | api-gateway | ⚠️ 中间件替代 | ✅ `GW_` 前缀 | ✅ slog | ✅ 7 指标 | ✅ OTel | ✅ | ✅ 并行 ping | ✅ 5s 超时 | ~25% | ✅ 多阶段 |
### 7.1 详细问题清单(按严重度排序) ### 7.1 详细问题清单(按严重度排序)
| # | 严重度 | 文件 | 问题 | 修复建议 | > 更新日期2026-07-10P2-P5 实施后复核)
| --- | ------ | -------------------------------------- | --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| 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` 实现) | | 1 | 高 | `internal/observability/` | 无 `/metrics` 端点Prometheus 404 | ✅ 已修复 | 新增 `metrics.go`,注册 7 个业务指标main.go 暴露 `/metrics` |
| 4 | 高 | 全文件 | 用 `log.Printf`,不符合 coding-standards §3.8 `log/slog` 结构化日志要求 | 引入 `slog.New(slog.NewJSONHandler(os.Stdout))`,所有日志带 `request_id`/`trace_id` | | 2 | 高 | `internal/health/health.go` | `/readyz` 直接返回 200未检查下游 | ✅ 已修复 | 改为并行 ping 9 个下游 `/healthz`软失败规则iam/teacher-bff required其余 optional |
| 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` | | 3 | | `internal/middleware/auth.go` | 死代码 `RequestIDMiddleware()` + `generateUUID()` 重复 requestid.go 且未使用 | ✅ 已修复 | 死代码已删除P2.0 |
| 6 | | `internal/middleware/auth.go` | P2 待升级 HS256 → RS256 | 新增 `JWKSFetcher` 缓存 IAM 公钥TTL 1h`jwt.Parse``jwt.WithKeySet(jwks)` | | 4 | | 全文件 | 用 `log.Printf`,不符合 coding-standards §3.8 `log/slog` 结构化日志要求 | ✅ 已修复 | 全部 `log.Printf`/`log.Fatal` 替换为 `slog.Info`/`slog.Error`W3 |
| 7 | 中 | `internal/middleware/cors.go` L21 | `CORS_ORIGINS` 直接 `os.Getenv`,未纳入 Config 结构 | 移入 `config.Config.CORSOrigins`,与其他配置统一 | | 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 兼容更低版本模块) |
| 8 | 中 | `internal/middleware/ratelimit.go` | 单实例内存令牌桶,水平扩展后限流失效 | P6 引入 Redis 令牌桶(`redis_rate`)或保留单实例但文档标注 | | 6 | 中 | `internal/middleware/auth.go` | P2 待升级 HS256 → RS256 | ✅ 已修复 | 接入 `shared-go/jwks.Fetcher`RS256 公钥校验 + kid 路由W6 资源属性完整) |
| 9 | 中 | `internal/middleware/auth.go` L68 | DevMode 注入固定 `teacher,admin` 角色,生产风险 | 启动时若 `DevMode=true && ENV=production` 则 panic 拒绝启动 | | 7 | 中 | `internal/middleware/cors.go` L21 | `CORS_ORIGINS` 直接 `os.Getenv`,未纳入 Config 结构 | ✅ 已修复 | `CORS()` 改为 `CORS(cfg *config.Config)`,从 Config 读取白名单 |
| 10 | 低 | `README.md` L38 | 提到 `GET /health` 兼容端点,但代码未注册 | 删除 README 描述或补注册 | | 8 | 中 | `internal/middleware/ratelimit.go` | 单实例内存令牌桶,水平扩展后限流失效 | ⏳ P6 | P6 引入 Redis 令牌桶(`redis_rate`),支持多副本一致 |
| 11 | | `internal/proxy/proxy.go` L24 | 连续两次 `TrimPrefix``/api/v1` 后再 `/api`)逻辑冗余 | 第二次 `TrimPrefix("/api")` 实际无效果(首字符已是 `/`),可删 | | 9 | | `internal/middleware/auth.go` L68 | DevMode 注入固定 `teacher,admin` 角色,生产风险 | ✅ 已修复 | 启动时 `DevMode=true && ENV=production` panic 拒绝启动W7 防护config.go |
| 12 | 低 | `Dockerfile` L18 | 构建命令 `./main.go` 而非 `./` | 改为 `go build -ldflags="-s -w" -o /app/bin/api-gateway .` 更规范 | | 10 | 低 | `README.md` L38 | 提到 `GET /health` 兼容端点,但代码未注册 | ✅ 已修复 | README 重写,删除 `/health` 描述 |
| 13 | 低 | 测试 | auth/cors/security/recovery/requestid/proxy 无测试 | 补 `*_test.go`,目标覆盖率 ≥ 80% | | 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. 风险与假设 ## 8. 风险与假设

View File

@@ -25,8 +25,8 @@ graph TB
M5[5. SecurityHeaders<br/>安全响应头] M5[5. SecurityHeaders<br/>安全响应头]
M6[6. RequestBodyLimit<br/>10MB] M6[6. RequestBodyLimit<br/>10MB]
M7[7. RateLimit<br/>令牌桶] M7[7. RateLimit<br/>令牌桶]
M8[8. Auth<br/>JWT RS256] M8[8. CircuitBreaker<br/>gobreaker v2]
M9[9. CircuitBreaker<br/>gobreaker v2] M9[9. Auth<br/>JWT RS256]
M10[10. Metrics<br/>prom-client] M10[10. Metrics<br/>prom-client]
end end
@@ -66,8 +66,8 @@ graph TB
- Recovery 必须最外层(捕获后续所有 panic - Recovery 必须最外层(捕获后续所有 panic
- OTelgin 第二(覆盖全链路 span - OTelgin 第二(覆盖全链路 span
- RequestID 第三(后续中间件日志可引用) - RequestID 第三(后续中间件日志可引用)
- Auth 在 CircuitBreaker 之前(避免未鉴权请求消耗下游配额 - CircuitBreaker 在 Auth 之前(未鉴权请求先经熔断器,避免对已熔断下游发起鉴权开销
- Metrics 在 CircuitBreaker 之后(统计通过鉴权且未被熔断的请求) - Auth 在 Metrics 之前(仅统计通过鉴权的请求)
- Health/Metrics 端点旁路 Auth在 Auth 之前注册) - Health/Metrics 端点旁路 Auth在 Auth 之前注册)
## 2. 领域模型 ## 2. 领域模型
@@ -184,7 +184,7 @@ sequenceDiagram
Note over GW,JWKS: 启动时与定期刷新 Note over GW,JWKS: 启动时与定期刷新
GW->>IAM: GET /.well-known/jwks.json GW->>IAM: GET /.well-known/jwks.json
IAM-->>GW: { keys: [...] } IAM-->>GW: { keys: [...] }
GW->>JWKS: 缓存公钥集TTL 1h GW->>JWKS: 缓存公钥集TTL 5min
end end
rect rgb(240, 248, 255) rect rgb(240, 248, 255)
@@ -210,9 +210,9 @@ sequenceDiagram
end end
``` ```
**JWKS 缓存策略** **JWKS 缓存策略**(由 `shared-go/jwks.Fetcher` 实现TTL 5min
- TTL 1h,到期后台异步刷新(不阻塞请求) - TTL 5min,到期后台异步刷新(不阻塞请求)
- kid 未命中时强制同步刷新一次 - kid 未命中时强制同步刷新一次
- 刷新失败保留旧公钥集继续服务fail-open 1 次后 fail-close - 刷新失败保留旧公钥集继续服务fail-open 1 次后 fail-close
- 启动时同步拉取一次,失败则 panic 拒绝启动 - 启动时同步拉取一次,失败则 panic 拒绝启动
@@ -274,30 +274,27 @@ allowedOrigins = []string{
### 6.2 错误码清单 ### 6.2 错误码清单
| 错误码 | HTTP | 触发条件 | 响应体 | > 依据 W1/W2 裁决:错误码统一 `GW_` 前缀,响应体统一 ActionState 信封 `{success,error:{code,message}}`。
| ------------------- | ---- | --------------------- | -------------------------------------- |
| `UNAUTHORIZED` | 401 | 缺失 Authorization 头 | `{success:false,error:{code,message}}` | | 错误码 | HTTP | 触发条件 | 响应体 |
| `INVALID_TOKEN` | 401 | JWT 签名/格式错误 | 同上 | | ---------------------- | ---- | --------------------- | -------------------------------------------------------------- |
| `INVALID_CLAIMS` | 401 | JWT claims 解析失败 | 同上 | | `GW_UNAUTHORIZED` | 401 | 缺失 Authorization 头 | `{success:false,error:{code:"GW_UNAUTHORIZED",message:"..."}}` |
| `RATE_LIMITED` | 429 | 超出令牌桶限流 | 同上 + `retry_after: 60` | | `GW_INVALID_TOKEN` | 401 | JWT 签名/格式错误 | 同上 |
| `CIRCUIT_OPEN` | 503 | 下游熔断打开 | 同上 + `retry_after: 30` | | `GW_INVALID_CLAIMS` | 401 | JWT claims 解析失败 | 同上 |
| `REQUEST_TOO_LARGE` | 413 | 请求体超 10MB | 同上 | | `GW_RATE_LIMITED` | 429 | 超出令牌桶限流 | 同上 + `retry_after: 60` |
| `INTERNAL_ERROR` | 500 | panic 兜底 | 同上 + `request_id` | | `GW_CIRCUIT_OPEN` | 503 | 下游熔断打开 | 同上 + `retry_after: 30` |
| `GW_REQUEST_TOO_LARGE` | 413 | 请求体超 10MB | 同上 |
| `GW_INTERNAL_ERROR` | 500 | panic 兜底 | 同上 + `request_id` |
### 6.3 Logger 初始化 ### 6.3 Logger 初始化
> 已实现:直接使用标准库 `log/slog`W3 裁决),未接入 `shared-go/logger`zap待后续评估。当前 `slog.SetDefault` 由 `InitTracer` 侧初始化时隐式使用默认 logger。
```go ```go
// internal/observability/logger.go待新增 // 全局使用 slogmain.go + 各 middleware 直接调用 slog.Info/slog.Error
import "log/slog" import "log/slog"
var Logger *slog.Logger // 日志级别由 LOG_LEVEL 控制(当前默认 info未单独封装 InitLogger
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)
}
``` ```
**日志字段规范** **日志字段规范**
@@ -328,10 +325,20 @@ func InitLogger(level string) {
已实现:`internal/observability/tracer.go`OTLP HTTP exporter + W3C TraceContext 传播。 已实现:`internal/observability/tracer.go`OTLP HTTP exporter + W3C TraceContext 传播。
**待补** **资源属性**W6 完整)
- 启动时记录 `service.name` / `service.version` / `deployment.environment` 资源属性 - `service.name` = "api-gateway"
- 关键业务 span 命名规范:`HTTP GET /api/v1/classes`otelgin 自动) - `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 检查逻辑 ### 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 ```go
func Readyz(downstreams []string) gin.HandlerFunc { func Readyz(cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) { checks := []downstreamCheck{
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second) {name: "iam", url: cfg.IamServiceURL + "/healthz", required: true},
defer cancel() {name: "teacher-bff", url: cfg.TeacherBffURL + "/healthz", required: true},
var wg errgroup.Group {name: "core-edu", url: cfg.CoreEduServiceURL + "/healthz", required: false},
unhealthy := make([]string, 0) {name: "content", url: cfg.ContentServiceURL + "/healthz", required: false},
var mu sync.Mutex {name: "data-ana", url: cfg.DataAnaServiceURL + "/healthz", required: false},
for _, url := range downstreams { {name: "msg", url: cfg.MsgServiceURL + "/healthz", required: false},
url := url {name: "ai", url: cfg.AiServiceURL + "/healthz", required: false},
wg.Go(func() error { {name: "student-bff", url: cfg.StudentBffURL + "/healthz", required: false},
req, _ := http.NewRequestWithContext(ctx, "GET", url+"/healthz", nil) {name: "parent-bff", url: cfg.ParentBffURL + "/healthz", required: false},
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"})
} }
client := &http.Client{Timeout: 2 * time.Second}
// 并行 pingsync.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 优雅关闭顺序 ### 6.8 优雅关闭顺序
@@ -415,15 +425,21 @@ func Readyz(downstreams []string) gin.HandlerFunc {
## 9. 实施优先级 ## 9. 实施优先级
| 优先级 | 任务 | 阶段 | > 更新日期2026-07-10P2-P5 实施后复核)
| ------ | --------------------------------------- | -------------- |
| P0 | 删除 auth.go 死代码L124-139 | 立即 | | 优先级 | 任务 | 状态 | 阶段 |
| P0 | 修复 go.mod 与 Dockerfile Go 版本不一致 | 立即 | | ------ | --------------------------------------------- | --------- | ------------- |
| P0 | 新增 `/metrics` 端点 + prom-client | P2 | | P0 | 删除 auth.go 死代码 | ✅ 已完成 | P2.0 |
| P0 | 引入 `log/slog` 替换标准 log | P2 | | P0 | 修复 go.mod 与 Dockerfile Go 版本不一致 | ✅ 已完成 | P2.0 |
| P1 | `/readyz` 真实健康检查 | P2 | | P0 | 新增 `/metrics` 端点 + 7 个业务指标 | ✅ 已完成 | P2.4W5 |
| P1 | JWT RS256 升级 + JWKS 缓存 | P2依赖 iam | | P0 | 引入 `log/slog` 替换标准 log | ✅ 已完成 | P2.4W3 |
| P2 | 限流策略表细化per-路由) | P6 | | P1 | `/readyz` 真实健康检查 | ✅ 已完成 | P2.5W4 |
| P2 | 熔断 per-服务实例 | P6 | | P1 | JWT RS256 升级 + JWKS 缓存 | ✅ 已完成 | P2.2 |
| P3 | 补全测试覆盖率到 80% | P6 | | P1 | 错误码 GW_ 前缀 + ActionState 信封 | ✅ 已完成 | P2.3W1/W2 |
| P3 | DevMode 生产防护 | P2 | | P1 | DevMode 生产防护 | ✅ 已完成 | P2.6W7 |
| P1 | tracer 资源属性补全 | ✅ 已完成 | P2.4W6 |
| 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 |

View File

@@ -1,6 +1,6 @@
module github.com/edu-cloud/api-gateway module github.com/edu-cloud/api-gateway
go 1.25.0 go 1.22
require ( require (
github.com/gin-gonic/gin v1.12.0 github.com/gin-gonic/gin v1.12.0

View File

@@ -1,20 +1,26 @@
package config package config
import ( import (
"log" "log/slog"
"os" "os"
"strconv" "strconv"
) )
// Config 持有 api-gateway 运行时配置 // Config 持有 api-gateway 运行时配置
// P2 起 JWT 验签改 RS256IAM 签发Gateway 用 JWKS 公钥校验),
// JWTSecret 仅在 DevMode 下作为 mock 密钥保留。
type Config struct { type Config struct {
Port string Port string
JWTSecret string JWKSURL string // RS256 公钥端点IAM GET /.well-known/jwks.json
JWTSecret string // DevMode 下 mock 用(生产不再使用 HS256
JWTIssuer string JWTIssuer string
JWTAudience string JWTAudience string
CORSOrigins string
ClassesServiceURL string ClassesServiceURL string
IamServiceURL string IamServiceURL string
TeacherBffURL string TeacherBffURL string
StudentBffURL string
ParentBffURL string
CoreEduServiceURL string CoreEduServiceURL string
ContentServiceURL string ContentServiceURL string
DataAnaServiceURL string DataAnaServiceURL string
@@ -23,33 +29,57 @@ type Config struct {
OTLPEndpoint string OTLPEndpoint string
LogLevel string LogLevel string
DevMode bool DevMode bool
Env string // 部署环境标识production / developmentW7 防护用)
} }
// devJWTSecret 是 DevMode 下的默认 JWT 密钥(仅用于本地联调,生产必须配置 JWT_SECRET // devJWTSecret 是 DevMode 下的默认 JWT 密钥(仅用于本地联调,生产必须关闭 DevMode
const devJWTSecret = "p1-dev-secret-change-in-production" const devJWTSecret = "p1-dev-secret-change-in-production"
// Load 从环境变量加载配置。 // Load 从环境变量加载配置。
// 非 DevMode 下若 JWT_SECRET 未配置则 fatal 退出DevMode 下使用默认密钥并打印 warning。 //
// W7 生产防护DevMode=true 且 ENV=production 时 panic 拒绝启动,
// 避免 dev-token 旁路鉴权被误开到生产环境。
// P2 起 RS256非 DevMode 下要求 IAM_JWKS_URLJWKS 公钥端点),
// JWT_SECRET 不再是生产必填(仅 DevMode mock 用)。
func Load() *Config { func Load() *Config {
devMode := getEnvBool("DEV_MODE", false) devMode := getEnvBool("DEV_MODE", false)
jwtSecret := getEnv("JWT_SECRET", "") env := getEnv("ENV", "development")
if jwtSecret == "" {
if devMode { // W7 生产防护DevMode 旁路仅允许非生产环境
log.Println("warning: JWT_SECRET not set, using dev default (DEV_MODE=true)") if devMode && env == "production" {
jwtSecret = devJWTSecret panic("DEV_MODE=true is not allowed in production (ENV=production)")
} else {
log.Fatal("JWT_SECRET must be set in non-dev mode")
}
} }
jwtSecret := getEnv("JWT_SECRET", "")
if jwtSecret == "" && devMode {
slog.Warn("JWT_SECRET not set, using dev default (DEV_MODE=true)")
jwtSecret = devJWTSecret
}
// 非 DevMode 下要求 JWKS URLRS256 验签)
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{ return &Config{
Port: getEnv("API_GATEWAY_PORT", "8080"), Port: getEnv("API_GATEWAY_PORT", "8080"),
JWKSURL: jwksURL,
JWTSecret: jwtSecret, JWTSecret: jwtSecret,
JWTIssuer: getEnv("JWT_ISSUER", "next-edu-cloud"), JWTIssuer: getEnv("JWT_ISSUER", "next-edu-cloud"),
JWTAudience: getEnv("JWT_AUDIENCE", "next-edu-cloud"), JWTAudience: getEnv("JWT_AUDIENCE", "next-edu-cloud"),
CORSOrigins: getEnv("CORS_ORIGINS", ""),
ClassesServiceURL: getEnv("CLASSES_SERVICE_URL", "http://localhost:3001"), ClassesServiceURL: getEnv("CLASSES_SERVICE_URL", "http://localhost:3001"),
IamServiceURL: getEnv("IAM_SERVICE_URL", "http://localhost:3002"), IamServiceURL: getEnv("IAM_SERVICE_URL", "http://localhost:3002"),
TeacherBffURL: getEnv("TEACHER_BFF_URL", "http://localhost:3003"), 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"), CoreEduServiceURL: getEnv("CORE_EDU_SERVICE_URL", "http://localhost:3004"),
ContentServiceURL: getEnv("CONTENT_SERVICE_URL", "http://localhost:3005"), ContentServiceURL: getEnv("CONTENT_SERVICE_URL", "http://localhost:3005"),
DataAnaServiceURL: getEnv("DATA_ANA_SERVICE_URL", "http://localhost:3006"), 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"), OTLPEndpoint: getEnv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318"),
LogLevel: getEnv("LOG_LEVEL", "info"), LogLevel: getEnv("LOG_LEVEL", "info"),
DevMode: devMode, DevMode: devMode,
Env: env,
} }
} }
// getEnv 读取环境变量,缺失时返回 fallback // getEnv 读取环境变量,缺失或空字符串时返回 fallback
func getEnv(key, fallback string) string { func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" { if v := os.Getenv(key); v != "" {
return v return v

View File

@@ -1,23 +1,112 @@
package health package health
import ( import (
"log/slog"
"net/http"
"sync"
"time"
"github.com/edu-cloud/api-gateway/internal/config"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// Healthz 存活探针liveness // Healthz 存活探针liveness
// GET /healthz返回 200 {"status":"ok"} 表示进程存活。 // GET /healthz返回 200 {"status":"ok"} 表示进程存活。
func Healthz(c *gin.Context) { func Healthz(c *gin.Context) {
c.JSON(200, gin.H{ c.JSON(http.StatusOK, gin.H{
"status": "ok", "status": "ok",
}) })
} }
// Readyz 就绪探针readiness // downstreamCheck 描述一个下游服务的健康检查配置
// GET /readyz检查下游服务可达性。 type downstreamCheck struct {
// TODO: P7 接入服务发现后,检查 classes / iam / teacher-bff 等下游服务健康状态, name string // 服务名(用于响应体与日志)
// 任一不可达则返回 503当前简化为直接返回 200。 url string // /healthz 完整 URL
func Readyz(c *gin.Context) { required bool // true=必需依赖(失败返回 503false=可选依赖(失败返回 200+degraded
c.JSON(200, gin.H{ }
"status": "ok",
}) // Readyz 就绪探针readinessW4 裁决)。
// GET /readyz并行 ping 下游服务 /healthz超时 2s。
//
// 软失败规则president-final-rulings.md §3.3
// - 必需依赖iam / teacher-bffP2 已就绪)失败 → 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)
}
} }

View File

@@ -1,12 +1,14 @@
package middleware package middleware
import ( import (
"log/slog"
"net/http" "net/http"
"strings" "strings"
"github.com/edu-cloud/api-gateway/internal/config" "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/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
) )
// publicPaths 是无需鉴权的公开路径(精确匹配,基于去掉 /api/v1 前缀后的路径) // publicPaths 是无需鉴权的公开路径(精确匹配,基于去掉 /api/v1 前缀后的路径)
@@ -23,16 +25,19 @@ func isPublicPath(path string) bool {
return publicPaths[stripped] return publicPaths[stripped]
} }
// AuthMiddleware 验证 JWT 并注入用户信息到请求头 // AuthMiddleware 验证 JWTRS256通过 IAM JWKS 公钥校验)并注入用户信息到请求头
// P1 用 HS256P2 改 RS256IAM 签发) //
func AuthMiddleware(cfg *config.Config) gin.HandlerFunc { // P2 升级HS256 → RS256shared-go/jwks.FetcherJWT 由 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) { return func(c *gin.Context) {
// 健康检查跳过鉴权
if c.Request.URL.Path == "/healthz" {
c.Next()
return
}
// 公开路径白名单register/login/refresh 无需鉴权) // 公开路径白名单register/login/refresh 无需鉴权)
if isPublicPath(c.Request.URL.Path) { if isPublicPath(c.Request.URL.Path) {
c.Next() c.Next()
@@ -41,81 +46,89 @@ func AuthMiddleware(cfg *config.Config) gin.HandlerFunc {
authHeader := c.GetHeader("Authorization") authHeader := c.GetHeader("Authorization")
if authHeader == "" { if authHeader == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ observability.IncAuthFailure("missing_auth_header")
"success": false, abortGW(c, http.StatusUnauthorized, "GW_UNAUTHORIZED", "missing authorization header")
"error": gin.H{
"code": "UNAUTHORIZED",
"message": "missing authorization header",
},
})
return return
} }
tokenStr := strings.TrimPrefix(authHeader, "Bearer ") tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
if tokenStr == authHeader { if tokenStr == authHeader {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ observability.IncAuthFailure("invalid_scheme")
"success": false, abortGW(c, http.StatusUnauthorized, "GW_UNAUTHORIZED", "invalid authorization scheme, expected Bearer")
"error": gin.H{
"code": "UNAUTHORIZED",
"message": "invalid authorization scheme, expected Bearer",
},
})
return return
} }
// 开发模式旁路DEV_MODE=true 时接受 "dev-token",注入开发用户 // 开发模式旁路DEV_MODE=true 时接受 "dev-token",注入开发用户
// 仅用于本地联调,生产环境必须关闭 DEV_MODE // 仅用于本地联调,生产环境必须关闭 DEV_MODEW7 防护在 config.Load 拦截)
if cfg.DevMode && tokenStr == "dev-token" { if cfg.DevMode && tokenStr == "dev-token" {
c.Request.Header.Set("x-user-id", "dev-user") c.Request.Header.Set("x-user-id", "dev-user")
c.Request.Header.Set("x-user-roles", "teacher,admin") c.Request.Header.Set("x-user-roles", "teacher,admin")
c.Request.Header.Set("x-data-scope", "SCHOOL")
c.Next() c.Next()
return return
} }
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (any, error) { // 非 DevMode 必须有 fetcherRS256 验签)
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { if fetcher == nil {
return nil, jwt.ErrSignatureInvalid observability.IncAuthFailure("fetcher_nil")
} slog.Error("jwks fetcher is nil in non-dev mode")
return []byte(cfg.JWTSecret), nil abortGW(c, http.StatusInternalServerError, "GW_INTERNAL_ERROR", "auth not initialized")
})
if err != nil || !token.Valid {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"success": false,
"error": gin.H{
"code": "INVALID_TOKEN",
"message": "token validation failed",
},
})
return return
} }
claims, ok := token.Claims.(jwt.MapClaims) // RS256 验签shared-go/jwks.Fetcher 内部按 kid 取公钥 + 缓存)
if !ok { claims, err := fetcher.ValidateToken(tokenStr)
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ if err != nil {
"success": false, observability.IncAuthFailure("invalid_token")
"error": gin.H{ slog.Warn("jwt validation failed", "error", err)
"code": "INVALID_CLAIMS", abortGW(c, http.StatusUnauthorized, "GW_INVALID_TOKEN", "token validation failed")
"message": "invalid token claims",
},
})
return 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 { if claims.UserID != "" {
c.Request.Header.Set("x-user-id", sub) c.Request.Header.Set("x-user-id", claims.UserID)
} }
if roles, ok := claims["roles"].([]any); ok { if claims.Role != "" {
roleStrs := make([]string, 0, len(roles)) c.Request.Header.Set("x-user-roles", claims.Role)
for _, r := range roles { }
if s, ok := r.(string); ok { if claims.DataScope != "" {
roleStrs = append(roleStrs, s) c.Request.Header.Set("x-data-scope", claims.DataScope)
}
}
c.Request.Header.Set("x-user-roles", strings.Join(roleStrs, ","))
} }
c.Next() c.Next()
} }
} }
// abortGW 统一返回 ActionState 信封格式的错误响应W1/W2 裁决)。
// 响应体:{"success":false,"error":{"code":"<code>","message":"<msg>"}}
func abortGW(c *gin.Context, status int, code, message string) {
c.AbortWithStatusJSON(status, gin.H{
"success": false,
"error": gin.H{
"code": code,
"message": message,
},
})
}

View File

@@ -2,15 +2,16 @@ package middleware
import ( import (
"errors" "errors"
"log" "log/slog"
"net/http" "net/http"
"time" "time"
"github.com/edu-cloud/api-gateway/internal/observability"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/sony/gobreaker/v2" "github.com/sony/gobreaker/v2"
) )
// CircuitBreaker 返回针对指定服务的熔断中间件。 // CircuitBreaker 返回针对指定服务的熔断中间件W8 裁决:保持共享 downstream
// //
// 基于 gobreaker v2 实现: // 基于 gobreaker v2 实现:
// - Interval=5sCLOSED 状态下的统计窗口 // - Interval=5sCLOSED 状态下的统计窗口
@@ -19,7 +20,9 @@ import (
// - MaxRequests=1HALF_OPEN 仅允许 1 个探测请求 // - MaxRequests=1HALF_OPEN 仅允许 1 个探测请求
// //
// 仅当下游返回 5xx 视为失败4xx 与 2xx 不计入熔断。 // 仅当下游返回 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 { func CircuitBreaker(serviceName string) gin.HandlerFunc {
cb := gobreaker.NewCircuitBreaker[struct{}](gobreaker.Settings{ cb := gobreaker.NewCircuitBreaker[struct{}](gobreaker.Settings{
Name: serviceName, Name: serviceName,
@@ -35,7 +38,13 @@ func CircuitBreaker(serviceName string) gin.HandlerFunc {
return counts.TotalFailures*2 > counts.Requests return counts.TotalFailures*2 > counts.Requests
}, },
OnStateChange: func(name string, from, to gobreaker.State) { 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 { if err != nil {
// 熔断打开或半开探测名额已满:返回 503 // 熔断打开或半开探测名额已满:返回 503
if errors.Is(err, gobreaker.ErrOpenState) || errors.Is(err, gobreaker.ErrTooManyRequests) { if errors.Is(err, gobreaker.ErrOpenState) || errors.Is(err, gobreaker.ErrTooManyRequests) {
c.Header("Retry-After", "30")
c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{ 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, "retry_after": 30,
}) })
return return

View File

@@ -1,12 +1,12 @@
package middleware package middleware
import ( import (
"log" "log/slog"
"net/http" "net/http"
"os"
"strconv" "strconv"
"strings" "strings"
"github.com/edu-cloud/api-gateway/internal/config"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -16,16 +16,16 @@ const corsMaxAge = 12 * 60 * 60
// devCORSOrigins 是未配置 CORS_ORIGINS 时的开发环境默认白名单 // devCORSOrigins 是未配置 CORS_ORIGINS 时的开发环境默认白名单
const devCORSOrigins = "http://localhost:3000,http://localhost:3001" const devCORSOrigins = "http://localhost:3000,http://localhost:3001"
// CORS 返回跨域资源共享中间件。 // CORS 返回跨域资源共享中间件(从 Config 读取白名单W3 裁决slog 结构化日志)
// 允许来源从环境变量 CORS_ORIGINS 读取(逗号分隔); // 允许来源从 cfg.CORSOrigins 读取(逗号分隔);
// 未配置时使用开发环境白名单localhost:3000/3001并打印 warning。 // 未配置时使用开发环境白名单localhost:3000/3001并打印 warning。
// 允许方法GET POST PUT DELETE OPTIONS PATCH // 允许方法GET POST PUT DELETE OPTIONS PATCH
// 允许头Authorization Content-Type X-Request-Id X-Trace-Id // 允许头Authorization Content-Type X-Request-Id X-Trace-Id
// 暴露头X-Request-Id X-Trace-Id // 暴露头X-Request-Id X-Trace-Id
func CORS() gin.HandlerFunc { func CORS(cfg *config.Config) gin.HandlerFunc {
allowed := parseCORSOrigins(os.Getenv("CORS_ORIGINS")) allowed := parseCORSOrigins(cfg.CORSOrigins)
if len(allowed) == 0 { 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) allowed = parseCORSOrigins(devCORSOrigins)
} }

View File

@@ -5,6 +5,7 @@ import (
"sync" "sync"
"time" "time"
"github.com/edu-cloud/api-gateway/internal/observability"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -22,9 +23,12 @@ type rateLimiter struct {
buckets sync.Map // map[string]*bucket buckets sync.Map // map[string]*bucket
} }
// RateLimit 返回基于令牌桶的限流中间件(内存版,不依赖 Redis // RateLimit 返回基于令牌桶的限流中间件(内存版,P6 迁 Redis
// 每个客户端 IP 独立桶,按 rps每秒令牌数补充最大容量为 burst。 // 每个客户端 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 分钟未访问的桶。 // 内部启动 cleanup goroutine每 5 分钟清理 10 分钟未访问的桶。
func RateLimit(rps float64, burst int) gin.HandlerFunc { func RateLimit(rps float64, burst int) gin.HandlerFunc {
rl := &rateLimiter{rps: rps, burst: burst} rl := &rateLimiter{rps: rps, burst: burst}
@@ -47,9 +51,14 @@ func RateLimit(rps float64, burst int) gin.HandlerFunc {
// 令牌不足:拒绝 // 令牌不足:拒绝
if b.tokens < 1 { if b.tokens < 1 {
b.mu.Unlock() b.mu.Unlock()
observability.IncRateLimited(ip)
c.Header("Retry-After", "60") c.Header("Retry-After", "60")
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{ 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, "retry_after": 60,
}) })
return return

View File

@@ -1,7 +1,7 @@
package middleware package middleware
import ( import (
"log" "log/slog"
"net/http" "net/http"
"runtime/debug" "runtime/debug"
@@ -9,20 +9,32 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
) )
// Recovery 捕获 panic 并返回 500,记录堆栈日志 // Recovery 捕获 panic 并返回 500ActionState 信封W1/W2 裁决)
// 返回 JSON {"error":"internal_error","request_id":"<uuid>"}。 //
// request_id 使用 uuid.New() 生成Recovery 在 RequestID 之前注册, // 响应体:{"success":false,"error":{"code":"GW_INTERNAL_ERROR","message":"..."},"request_id":"<uuid>"}。
// panic 发生时上下文中可能尚无 request_id故独立生成 // request_id 从 context 取RequestID 中间件注入),若 Recovery 在 RequestID 之前触发
// 导致 context 无 request_id则独立生成 uuid 保证可追溯。
func Recovery() gin.HandlerFunc { func Recovery() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
stack := debug.Stack() stack := debug.Stack()
requestID := uuid.New().String() // 优先从 context 取 request_idRequestID 中间件已注入)
log.Printf("[recovery] panic recovered, request_id=%s: %v\n%s", requestID, r, stack) requestID, exists := c.Get(requestIDContextKey)
// 若已写入部分响应AbortWithStatusJSON 仍会设置状态并尝试写 JSON if !exists {
requestID = uuid.New().String()
}
slog.Error("panic recovered",
"request_id", requestID,
"error", r,
"stack", string(stack),
)
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{ 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, "request_id": requestID,
}) })
} }

View File

@@ -17,7 +17,8 @@ func RequestID() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
rid := c.GetHeader(requestIDHeader) rid := c.GetHeader(requestIDHeader)
if rid == "" { if rid == "" {
rid = uuid.New().String() // 02-architecture-design.md §4.6req-<uuid-v4> 格式
rid = "req-" + uuid.New().String()
} }
c.Set(requestIDContextKey, rid) c.Set(requestIDContextKey, rid)
c.Writer.Header().Set(requestIDHeader, rid) c.Writer.Header().Set(requestIDHeader, rid)

View File

@@ -30,9 +30,19 @@ func SecurityHeaders() gin.HandlerFunc {
} }
// RequestBodyLimit 限制请求体大小中间件。 // 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 { func RequestBodyLimit(maxBytes int64) gin.HandlerFunc {
return func(c *gin.Context) { 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.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes)
c.Next() c.Next()
} }

View File

@@ -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())
}

View File

@@ -2,11 +2,12 @@ package observability
import ( import (
"context" "context"
"log" "log/slog"
"net/url" "net/url"
"time" "time"
"go.opentelemetry.io/otel" "go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
"go.opentelemetry.io/otel/propagation" "go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/resource" "go.opentelemetry.io/otel/sdk/resource"
@@ -14,21 +15,22 @@ import (
semconv "go.opentelemetry.io/otel/semconv/v1.26.0" semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
) )
// InitTracer 初始化 OpenTelemetry tracer. // InitTracer 初始化 OpenTelemetry tracerW6 裁决:资源属性补全)。
// //
// 资源属性包含service.name / service.version / deployment.environment / host.name。
// endpoint 为 "http://host:port" 格式(如 "http://localhost:4318" // endpoint 为 "http://host:port" 格式(如 "http://localhost:4318"
// 为空时跳过初始化tracing disabled // 为空时跳过初始化tracing disabled
// //
// 返回 shutdown 函数,应在服务退出时调用以 flush 待发送 span. // 返回 shutdown 函数,应在服务退出时调用以 flush 待发送 span
func InitTracer(serviceName, endpoint string) func() { func InitTracer(serviceName, endpoint, env, version, hostName string) func() {
if endpoint == "" { if endpoint == "" {
log.Println("OTEL endpoint not set, tracing disabled") slog.Info("OTEL endpoint not set, tracing disabled")
return func() {} return func() {}
} }
u, err := url.Parse(endpoint) u, err := url.Parse(endpoint)
if err != nil || u.Host == "" { 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() {} return func() {}
} }
@@ -40,15 +42,21 @@ func InitTracer(serviceName, endpoint string) func() {
otlptracehttp.WithInsecure(), otlptracehttp.WithInsecure(),
) )
if err != nil { 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() {} return func() {}
} }
// W6 裁决资源属性补全service.name + version + env + host
res, err := resource.New(ctx, 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 { if err != nil {
log.Printf("failed to create resource: %v", err) slog.Error("failed to create resource", "error", err)
return func() {} return func() {}
} }
@@ -62,12 +70,18 @@ func InitTracer(serviceName, endpoint string) func() {
propagation.Baggage{}, 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() { return func() {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
if err := tp.Shutdown(shutdownCtx); err != nil { if err := tp.Shutdown(shutdownCtx); err != nil {
log.Printf("failed to shutdown tracer: %v", err) slog.Error("failed to shutdown tracer", "error", err)
} }
} }
} }

View File

@@ -19,9 +19,8 @@ func NewProxy(targetURL string) (*httputil.ReverseProxy, error) {
originalDirector := proxy.Director originalDirector := proxy.Director
proxy.Director = func(req *http.Request) { proxy.Director = func(req *http.Request) {
originalDirector(req) 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/v1")
req.URL.Path = strings.TrimPrefix(req.URL.Path, "/api")
req.Host = target.Host req.Host = target.Host
} }
return proxy, nil return proxy, nil

View File

@@ -2,7 +2,7 @@ package main
import ( import (
"context" "context"
"log" "log/slog"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
@@ -14,6 +14,7 @@ import (
"github.com/edu-cloud/api-gateway/internal/middleware" "github.com/edu-cloud/api-gateway/internal/middleware"
"github.com/edu-cloud/api-gateway/internal/observability" "github.com/edu-cloud/api-gateway/internal/observability"
"github.com/edu-cloud/api-gateway/internal/proxy" "github.com/edu-cloud/api-gateway/internal/proxy"
"github.com/edu-cloud/shared-go/jwks"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin" "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
@@ -22,12 +23,21 @@ import (
// maxBodyBytes 请求体大小上限10MB // maxBodyBytes 请求体大小上限10MB
const maxBodyBytes int64 = 10 * 1024 * 1024 const maxBodyBytes int64 = 10 * 1024 * 1024
// version 编译时通过 -ldflags "-X main.version=..." 注入,默认 dev
var version = "dev"
func main() { func main() {
cfg := config.Load() cfg := config.Load()
// 初始化 OpenTelemetry tracerendpoint 为空时自动跳过)
tracerShutdown := observability.InitTracer("api-gateway", cfg.OTLPEndpoint) // 初始化 OpenTelemetry tracerW6 裁决:资源属性补全)
hostName, _ := os.Hostname()
tracerShutdown := observability.InitTracer("api-gateway", cfg.OTLPEndpoint, cfg.Env, version, hostName)
defer tracerShutdown() defer tracerShutdown()
// 初始化 JWKS FetcherRS256 公钥缓存P2.2 裁决)
// DevMode 下也创建 fetcher使 DevMode 可验证真实 JWT非 dev-token 场景)
fetcher := jwks.NewFetcher(cfg.JWKSURL)
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
r := gin.New() r := gin.New()
// 关闭尾斜杠重定向:避免 Next.js rewrites 代理时 /api/v1/classes → 301 → /api/v1/classes/ 循环 // 关闭尾斜杠重定向:避免 Next.js rewrites 代理时 /api/v1/classes → 301 → /api/v1/classes/ 循环
@@ -41,7 +51,7 @@ func main() {
// 3. 请求 ID 注入 // 3. 请求 ID 注入
r.Use(middleware.RequestID()) r.Use(middleware.RequestID())
// 4. 跨域 // 4. 跨域
r.Use(middleware.CORS()) r.Use(middleware.CORS(cfg))
// 5. 安全响应头 // 5. 安全响应头
r.Use(middleware.SecurityHeaders()) r.Use(middleware.SecurityHeaders())
// 6. 请求体大小限制 // 6. 请求体大小限制
@@ -51,101 +61,46 @@ func main() {
// 健康检查路由(无需鉴权,在 Auth 之前) // 健康检查路由(无需鉴权,在 Auth 之前)
r.GET("/healthz", health.Healthz) r.GET("/healthz", health.Healthz)
r.GET("/readyz", health.Readyz) r.GET("/readyz", health.Readyz(cfg))
// Prometheus 指标端点 // Prometheus 指标端点
r.GET("/metrics", gin.WrapH(promhttp.Handler())) r.GET("/metrics", gin.WrapH(promhttp.Handler()))
// API v1 组:熔断 + 鉴权 + 反向代理 // API v1 组:熔断 + 鉴权 + 指标 + 反向代理
api := r.Group("/api/v1") api := r.Group("/api/v1")
// 7. 熔断(仅作用于代理路由,下游 5xx 触发) // 8. 熔断(仅作用于代理路由,下游 5xx 触发)
api.Use(middleware.CircuitBreaker("downstream")) api.Use(middleware.CircuitBreaker("downstream"))
// 8. JWT 鉴权 // 9. JWT 鉴权RS256通过 JWKS 公钥校验)
api.Use(middleware.AuthMiddleware(cfg)) api.Use(middleware.AuthMiddleware(cfg, fetcher))
// 10. HTTP 请求指标(统计通过鉴权的请求)
api.Use(observability.Metrics())
{ {
// classes 服务路由 // classes → core-eduC1 裁决classes 合并入 core-edu
// 注同时注册无尾斜杠与通配符两条路由。RedirectTrailingSlash=false 时, registerProxy(api, "classes", cfg.CoreEduServiceURL)
// Gin 不会自动把 /classes 跳到 /classes/,所以两条都要显式注册。 // iam 服务路由(身份与访问管理)
classesProxy, err := proxy.NewProxy(cfg.ClassesServiceURL) registerProxy(api, "iam", cfg.IamServiceURL)
if err != nil { // teacher-bff 路由(教师聚合层 GraphQL
log.Fatalf("failed to create classes proxy: %v", err) registerProxy(api, "teacher", cfg.TeacherBffURL)
} // student-bff 路由(学生聚合层 GraphQLP3
classesHandler := proxy.ProxyHandler(classesProxy) registerProxy(api, "student", cfg.StudentBffURL)
api.Any("/classes", classesHandler) // parent-bff 路由(家长聚合层 GraphQLP4
api.Any("/classes/*path", classesHandler) registerProxy(api, "parent", cfg.ParentBffURL)
// core-edu 域路由(考试/作业/成绩)
// IAM 服务路由(身份与访问管理) registerProxy(api, "exams", cfg.CoreEduServiceURL)
iamProxy, err := proxy.NewProxy(cfg.IamServiceURL) registerProxy(api, "homework", cfg.CoreEduServiceURL)
if err != nil { registerProxy(api, "grades", cfg.CoreEduServiceURL)
log.Fatalf("failed to create iam proxy: %v", err) // content 域路由(教材/章节/知识点/题库)
} registerProxy(api, "textbooks", cfg.ContentServiceURL)
iamHandler := proxy.ProxyHandler(iamProxy) registerProxy(api, "chapters", cfg.ContentServiceURL)
api.Any("/iam", iamHandler) registerProxy(api, "knowledge-points", cfg.ContentServiceURL)
api.Any("/iam/*path", iamHandler) registerProxy(api, "questions", cfg.ContentServiceURL)
// msg 域路由(通知/消息)
// Teacher BFF 路由(教师聚合层) registerProxy(api, "notifications", cfg.MsgServiceURL)
bffProxy, err := proxy.NewProxy(cfg.TeacherBffURL) registerProxy(api, "messages", cfg.MsgServiceURL)
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)
// ai 服务路由AI 聊天/生成/优化) // ai 服务路由AI 聊天/生成/优化)
aiProxy, err := proxy.NewProxy(cfg.AiServiceURL) registerProxy(api, "ai", cfg.AiServiceURL)
if err != nil { // data-ana 域路由(学情诊断/错题本/仪表盘)
log.Fatalf("failed to create ai proxy: %v", err) registerProxy(api, "analytics", cfg.DataAnaServiceURL)
} registerProxy(api, "dashboard", cfg.DataAnaServiceURL)
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)
} }
srv := &http.Server{ srv := &http.Server{
@@ -157,21 +112,40 @@ func main() {
// 优雅关闭 // 优雅关闭
go func() { 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 { 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) quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit <-quit
log.Println("Shutting down API Gateway...") slog.Info("shutting down API Gateway")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
if err := srv.Shutdown(ctx); err != nil { 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)
} }