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

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