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:
@@ -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 tracer(endpoint 为空时自动跳过)
|
||||
tracerShutdown := observability.InitTracer("api-gateway", cfg.OTLPEndpoint)
|
||||
|
||||
// 初始化 OpenTelemetry tracer(W6 裁决:资源属性补全)
|
||||
hostName, _ := os.Hostname()
|
||||
tracerShutdown := observability.InitTracer("api-gateway", cfg.OTLPEndpoint, cfg.Env, version, hostName)
|
||||
defer tracerShutdown()
|
||||
|
||||
// 初始化 JWKS Fetcher(RS256 公钥缓存,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-edu(C1 裁决:classes 合并入 core-edu)
|
||||
registerProxy(api, "classes", cfg.CoreEduServiceURL)
|
||||
// iam 服务路由(身份与访问管理)
|
||||
registerProxy(api, "iam", cfg.IamServiceURL)
|
||||
// teacher-bff 路由(教师聚合层 GraphQL)
|
||||
registerProxy(api, "teacher", cfg.TeacherBffURL)
|
||||
// student-bff 路由(学生聚合层 GraphQL,P3)
|
||||
registerProxy(api, "student", cfg.StudentBffURL)
|
||||
// parent-bff 路由(家长聚合层 GraphQL,P4)
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user