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

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