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:
@@ -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(待新增)
|
||||
// 全局使用 slog(main.go + 各 middleware 直接调用 slog.Info/slog.Error)
|
||||
import "log/slog"
|
||||
|
||||
var Logger *slog.Logger
|
||||
|
||||
func InitLogger(level string) {
|
||||
var lv slog.Level
|
||||
_ = lv.UnmarshalText([]byte(level))
|
||||
Logger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: lv}))
|
||||
slog.SetDefault(Logger)
|
||||
}
|
||||
// 日志级别由 LOG_LEVEL 控制(当前默认 info,未单独封装 InitLogger)
|
||||
```
|
||||
|
||||
**日志字段规范**:
|
||||
@@ -328,10 +325,20 @@ func InitLogger(level string) {
|
||||
|
||||
已实现:`internal/observability/tracer.go`,OTLP HTTP exporter + W3C TraceContext 传播。
|
||||
|
||||
**待补**:
|
||||
**资源属性**(W6 完整):
|
||||
|
||||
- 启动时记录 `service.name` / `service.version` / `deployment.environment` 资源属性
|
||||
- 关键业务 span 命名规范:`HTTP GET /api/v1/classes`(otelgin 自动)
|
||||
- `service.name` = "api-gateway"
|
||||
- `service.version` = 编译时注入的 version 变量
|
||||
- `deployment.environment` = cfg.Env
|
||||
- `host.name` = os.Hostname()
|
||||
|
||||
**签名**:
|
||||
|
||||
```go
|
||||
func InitTracer(serviceName, endpoint, env, version, hostName string) (shutdown func(context.Context) error, err error)
|
||||
```
|
||||
|
||||
**业务 span 命名规范**:`HTTP GET /api/v1/classes`(otelgin 自动注入)。
|
||||
|
||||
### 6.6 /healthz 检查逻辑
|
||||
|
||||
@@ -346,41 +353,44 @@ func Healthz(c *gin.Context) {
|
||||
}
|
||||
```
|
||||
|
||||
### 6.7 /readyz 检查逻辑(待重构)
|
||||
### 6.7 /readyz 检查逻辑
|
||||
|
||||
> 已实现(W4):`Readyz(cfg *config.Config)`,并行 ping 9 个下游 `/healthz`,2s 超时,软失败规则。
|
||||
|
||||
```go
|
||||
func Readyz(downstreams []string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
var wg errgroup.Group
|
||||
unhealthy := make([]string, 0)
|
||||
var mu sync.Mutex
|
||||
for _, url := range downstreams {
|
||||
url := url
|
||||
wg.Go(func() error {
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", url+"/healthz", nil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil || resp.StatusCode != 200 {
|
||||
mu.Lock()
|
||||
unhealthy = append(unhealthy, url)
|
||||
mu.Unlock()
|
||||
}
|
||||
if resp != nil { resp.Body.Close() }
|
||||
return nil
|
||||
})
|
||||
}
|
||||
_ = wg.Wait()
|
||||
if len(unhealthy) > 0 {
|
||||
c.JSON(503, gin.H{"status":"error","unhealthy":unhealthy})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"status":"ok"})
|
||||
func Readyz(cfg *config.Config) gin.HandlerFunc {
|
||||
checks := []downstreamCheck{
|
||||
{name: "iam", url: cfg.IamServiceURL + "/healthz", required: true},
|
||||
{name: "teacher-bff", url: cfg.TeacherBffURL + "/healthz", required: true},
|
||||
{name: "core-edu", url: cfg.CoreEduServiceURL + "/healthz", required: false},
|
||||
{name: "content", url: cfg.ContentServiceURL + "/healthz", required: false},
|
||||
{name: "data-ana", url: cfg.DataAnaServiceURL + "/healthz", required: false},
|
||||
{name: "msg", url: cfg.MsgServiceURL + "/healthz", required: false},
|
||||
{name: "ai", url: cfg.AiServiceURL + "/healthz", required: false},
|
||||
{name: "student-bff", url: cfg.StudentBffURL + "/healthz", required: false},
|
||||
{name: "parent-bff", url: cfg.ParentBffURL + "/healthz", required: false},
|
||||
}
|
||||
client := &http.Client{Timeout: 2 * time.Second}
|
||||
// 并行 ping(sync.WaitGroup + sync.Mutex)
|
||||
// 软失败规则:
|
||||
// - required 下游不可达 → 503
|
||||
// - optional 下游不可达 → 200 + degraded 列表
|
||||
}
|
||||
```
|
||||
|
||||
**下游清单**:iam / classes / teacher-bff / core-edu / content / msg / ai / data-ana 的 `/healthz`
|
||||
**下游清单与必需性**(W4 软失败规则):
|
||||
|
||||
| 下游 | required | 不可达时 |
|
||||
| ----------- | -------- | -------------- |
|
||||
| iam | ✅ | 503 |
|
||||
| teacher-bff | ✅ | 503 |
|
||||
| core-edu | ❌ | 200 + degraded |
|
||||
| content | ❌ | 200 + degraded |
|
||||
| data-ana | ❌ | 200 + degraded |
|
||||
| msg | ❌ | 200 + degraded |
|
||||
| ai | ❌ | 200 + degraded |
|
||||
| student-bff | ❌ | 200 + degraded |
|
||||
| parent-bff | ❌ | 200 + degraded |
|
||||
|
||||
### 6.8 优雅关闭顺序
|
||||
|
||||
@@ -415,15 +425,21 @@ func Readyz(downstreams []string) gin.HandlerFunc {
|
||||
|
||||
## 9. 实施优先级
|
||||
|
||||
| 优先级 | 任务 | 阶段 |
|
||||
| ------ | --------------------------------------- | -------------- |
|
||||
| P0 | 删除 auth.go 死代码(L124-139) | 立即 |
|
||||
| P0 | 修复 go.mod 与 Dockerfile Go 版本不一致 | 立即 |
|
||||
| P0 | 新增 `/metrics` 端点 + prom-client | P2 |
|
||||
| P0 | 引入 `log/slog` 替换标准 log | P2 |
|
||||
| P1 | `/readyz` 真实健康检查 | P2 |
|
||||
| P1 | JWT RS256 升级 + JWKS 缓存 | P2(依赖 iam) |
|
||||
| P2 | 限流策略表细化(per-路由) | P6 |
|
||||
| P2 | 熔断 per-服务实例 | P6 |
|
||||
| P3 | 补全测试覆盖率到 80% | P6 |
|
||||
| P3 | DevMode 生产防护 | P2 |
|
||||
> 更新日期:2026-07-10(P2-P5 实施后复核)
|
||||
|
||||
| 优先级 | 任务 | 状态 | 阶段 |
|
||||
| ------ | --------------------------------------------- | --------- | ------------- |
|
||||
| P0 | 删除 auth.go 死代码 | ✅ 已完成 | P2.0 |
|
||||
| P0 | 修复 go.mod 与 Dockerfile Go 版本不一致 | ✅ 已完成 | P2.0 |
|
||||
| P0 | 新增 `/metrics` 端点 + 7 个业务指标 | ✅ 已完成 | P2.4(W5) |
|
||||
| P0 | 引入 `log/slog` 替换标准 log | ✅ 已完成 | P2.4(W3) |
|
||||
| P1 | `/readyz` 真实健康检查 | ✅ 已完成 | P2.5(W4) |
|
||||
| P1 | JWT RS256 升级 + JWKS 缓存 | ✅ 已完成 | P2.2 |
|
||||
| P1 | 错误码 GW_ 前缀 + ActionState 信封 | ✅ 已完成 | P2.3(W1/W2) |
|
||||
| P1 | DevMode 生产防护 | ✅ 已完成 | P2.6(W7) |
|
||||
| P1 | tracer 资源属性补全 | ✅ 已完成 | P2.4(W6) |
|
||||
| P1 | CORS 纳入 Config | ✅ 已完成 | P2.4 |
|
||||
| P1 | 路由扩展(student/parent/messages/dashboard) | ✅ 已完成 | P2.7 + P3-P5 |
|
||||
| P2 | 限流策略表细化(per-路由) | ⏳ P6 | P6 |
|
||||
| P2 | 熔断 per-服务实例 | ⏳ P6 | P6 |
|
||||
| P3 | 补全测试覆盖率到 80% | ⏳ P6 | P6 |
|
||||
|
||||
Reference in New Issue
Block a user