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
| 场景 | 技术/规则 |
| ----------------- | ------------------------------------------------------------------------------------------------------------- |
| P1 鉴权 | Gateway 内置 HS256 JWT`jwt.ParseWithClaims` + `SigningMethodHMAC` 校验 |
| P2 鉴权升级 | 改 RS256IAM 私钥签发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` 前缀后转发到下游服务(方案 BGateway 不改路径,各服务 Controller 内加 `/v1` |
| 用户上下文注入 | `c.Request.Header.Set("x-user-id/x-user-roles/x-data-scope", ...)` 传递给下游 |
| 请求 ID | Gateway 生成或透传 `X-Request-Id``req-<uuid-v4>` 格式),`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/loggerzap待评估 |
| 健康检查 | `GET /healthz`liveness无鉴权+ `GET /readyz`readiness并行 ping 9 下游 /healthz软失败规则W4 |
| Metrics | 7 个业务指标promauto`GET /metrics` 暴露W5requests/duration/circuit/ratelimit/proxy/jwks/authfail |
| Tracer 资源属性 | `resource.New` 补 service.name/version/deployment.environment/host.nameW64 项完整) |
| 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 黄金模板)

View File

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

93
pnpm-lock.yaml generated
View File

@@ -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':

View File

@@ -4,66 +4,89 @@ Go (Gin) 实现的 API 网关,所有外部请求的统一入口。
## 职责
- **路由转发**`/api/v1/classes/*` → classes 服务P1后续阶段追加 iam/core-edu/content/msg 等路由
- **JWT 鉴权**P1 HS256测试密钥P2 起 RS256IAM 签发Gateway 公钥校验)
- **请求 ID 注入**:生成或透传 `X-Request-ID`,全链路追踪
- **限流**P6:基于令牌桶的 per-IP 限流,超限返回 429
- **熔断**P6:基于 gobreaker v2 的下游服务熔断5xx 错误率 > 50% 触发 OPEN
- **CORS / 安全头 / Recovery**P6标准化中间件链
- **路由转发**`/api/v1/<service>/*` → 下游服务iam / teacher-bff / student-bff / parent-bff / core-edu / content / msg / ai / data-ana
- **JWT 鉴权**RS256IAM 签发Gateway 通过 JWKS 公钥校验DevMode 下支持 `dev-token` 旁路
- **请求 ID 注入**:生成或透传 `X-Request-Id``req-<uuid-v4>` 格式),全链路追踪
- **限流**:基于令牌桶的 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-<uuid-v4>` |
| 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 | 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 /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/)

View File

@@ -76,7 +76,7 @@
## 4. 我的技术栈
- **语言**Go 1.22+go.mod 声明 1.25.0,需与 Dockerfile 对齐,见审计表
- **语言**Go 1.22go.mod 与 Dockerfile 一致
- **框架**Gin v1.12.0
- **核心依赖**
- `github.com/golang-jwt/jwt/v5` v5.2.1JWT 校验)
@@ -97,44 +97,46 @@
## 6. 我需要对齐的黄金模板项(对照 classes 服务)
| 项 | classes黄金模板 | api-gateway 现状 | 差距 |
| ----------------- | ------------------------------------------------- | -------------------------------------------------- | -------------------------------------------------------- |
| 权限装饰器 | `@RequirePermission()` | N/AGo 无装饰器;用中间件 `AuthMiddleware` 替代) | ✅ 等价实现 |
| 错误码前缀 | `CLASSES_*` | 无前缀(基础设施层) | ✅ 设计合理 |
| loggerpino | `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/AGo 无 Zod`ShouldBindJSON` | ✅ 等价实现 |
| GlobalErrorFilter | `GlobalErrorFilter` | ✅ Recovery 中间件兜底 | ✅ 等价实现 |
| 项 | classes黄金模板 | api-gateway 现状 | 差距 |
| ----------------- | ------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------- |
| 权限装饰器 | `@RequirePermission()` | N/AGo 无装饰器;用中间件 `AuthMiddleware` 替代) | ✅ 等价实现 |
| 错误码前缀 | `CLASSES_*` | 无前缀(基础设施层) | ✅ 设计合理 |
| loggerpino | `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/AGo 无 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-10P2-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. 风险与假设

View File

@@ -25,8 +25,8 @@ graph TB
M5[5. SecurityHeaders<br/>安全响应头]
M6[6. RequestBodyLimit<br/>10MB]
M7[7. RateLimit<br/>令牌桶]
M8[8. Auth<br/>JWT RS256]
M9[9. CircuitBreaker<br/>gobreaker v2]
M8[8. CircuitBreaker<br/>gobreaker v2]
M9[9. Auth<br/>JWT RS256]
M10[10. Metrics<br/>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待新增
// 全局使用 slogmain.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}
// 并行 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 优雅关闭顺序
@@ -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-10P2-P5 实施后复核)
| 优先级 | 任务 | 状态 | 阶段 |
| ------ | --------------------------------------------- | --------- | ------------- |
| P0 | 删除 auth.go 死代码 | ✅ 已完成 | P2.0 |
| P0 | 修复 go.mod 与 Dockerfile Go 版本不一致 | ✅ 已完成 | P2.0 |
| P0 | 新增 `/metrics` 端点 + 7 个业务指标 | ✅ 已完成 | P2.4W5 |
| P0 | 引入 `log/slog` 替换标准 log | ✅ 已完成 | P2.4W3 |
| P1 | `/readyz` 真实健康检查 | ✅ 已完成 | P2.5W4 |
| P1 | JWT RS256 升级 + JWKS 缓存 | ✅ 已完成 | P2.2 |
| P1 | 错误码 GW_ 前缀 + ActionState 信封 | ✅ 已完成 | P2.3W1/W2 |
| 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
go 1.25.0
go 1.22
require (
github.com/gin-gonic/gin v1.12.0

View File

@@ -1,20 +1,26 @@
package config
import (
"log"
"log/slog"
"os"
"strconv"
)
// Config 持有 api-gateway 运行时配置
// Config 持有 api-gateway 运行时配置
// P2 起 JWT 验签改 RS256IAM 签发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 / developmentW7 防护用)
}
// 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_URLJWKS 公钥端点),
// 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 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{
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

View File

@@ -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=必需依赖(失败返回 503false=可选依赖(失败返回 200+degraded
}
// 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
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 用 HS256P2 改 RS256IAM 签发)
func AuthMiddleware(cfg *config.Config) gin.HandlerFunc {
// AuthMiddleware 验证 JWTRS256通过 IAM JWKS 公钥校验)并注入用户信息到请求头
//
// 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) {
// 健康检查跳过鉴权
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_MODEW7 防护在 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 必须有 fetcherRS256 验签)
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":"<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 (
"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=5sCLOSED 状态下的统计窗口
@@ -19,7 +20,9 @@ import (
// - MaxRequests=1HALF_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

View File

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

View File

@@ -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

View File

@@ -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":"<uuid>"}。
// request_id 使用 uuid.New() 生成Recovery 在 RequestID 之前注册,
// panic 发生时上下文中可能尚无 request_id故独立生成
// Recovery 捕获 panic 并返回 500ActionState 信封W1/W2 裁决)
//
// 响应体:{"success":false,"error":{"code":"GW_INTERNAL_ERROR","message":"..."},"request_id":"<uuid>"}。
// 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_idRequestID 中间件已注入)
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,
})
}

View File

@@ -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.6req-<uuid-v4> 格式
rid = "req-" + uuid.New().String()
}
c.Set(requestIDContextKey, rid)
c.Writer.Header().Set(requestIDHeader, rid)

View File

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

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 (
"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 tracerW6 裁决:资源属性补全)。
//
// 资源属性包含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)
}
}
}

View File

@@ -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

View File

@@ -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 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()
// 初始化 JWKS FetcherRS256 公钥缓存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-eduC1 裁决classes 合并入 core-edu
registerProxy(api, "classes", cfg.CoreEduServiceURL)
// iam 服务路由(身份与访问管理)
registerProxy(api, "iam", cfg.IamServiceURL)
// teacher-bff 路由(教师聚合层 GraphQL
registerProxy(api, "teacher", cfg.TeacherBffURL)
// student-bff 路由(学生聚合层 GraphQLP3
registerProxy(api, "student", cfg.StudentBffURL)
// parent-bff 路由(家长聚合层 GraphQLP4
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)
}