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

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