Files
Edu/services/api-gateway/main.go

202 lines
8.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
"log/slog"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/edu-cloud/api-gateway/internal/config"
"github.com/edu-cloud/api-gateway/internal/health"
"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"
)
// maxBodyBytes 请求体大小上限10MB
const maxBodyBytes int64 = 10 * 1024 * 1024
// version 编译时通过 -ldflags "-X main.version=..." 注入,默认 dev
var version = "dev"
func main() {
cfg := config.Load()
// 初始化 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/ 循环
r.RedirectTrailingSlash = false
// 全局中间件(按顺序注册)
// 1. panic 恢复(最外层,捕获后续所有中间件与 handler 的 panic
r.Use(middleware.Recovery())
// 2. OpenTelemetry 自动埋点HTTP 请求/响应 span
r.Use(otelgin.Middleware("api-gateway"))
// 3. 请求 ID 注入
r.Use(middleware.RequestID())
// 4. 跨域
r.Use(middleware.CORS(cfg))
// 5. 安全响应头
r.Use(middleware.SecurityHeaders())
// 6. 请求体大小限制
r.Use(middleware.RequestBodyLimit(maxBodyBytes))
// 7. 限流(每 IP 100 rps突发 20
r.Use(middleware.RateLimit(100, 20))
// 健康检查路由(无需鉴权,在 Auth 之前)
r.GET("/healthz", health.Healthz)
r.GET("/readyz", health.Readyz(cfg))
// Prometheus 指标端点
r.GET("/metrics", gin.WrapH(promhttp.Handler()))
// API v1 组:熔断 + 鉴权 + 指标 + 反向代理
api := r.Group("/api/v1")
// 8. 熔断(仅作用于代理路由,下游 5xx 触发)
api.Use(middleware.CircuitBreaker("downstream"))
// 9. JWT 鉴权RS256通过 JWKS 公钥校验)
api.Use(middleware.AuthMiddleware(cfg, fetcher))
// 10. HTTP 请求指标(统计通过鉴权的请求)
api.Use(observability.Metrics())
{
// classes → core-eduC1 裁决classes 合并入 core-edu
registerProxy(api, "classes", cfg.CoreEduServiceURL)
// iam 服务路由(身份与访问管理)
registerProxy(api, "iam", cfg.IamServiceURL)
// teacher-bff 路由(教师聚合层 GraphQL
// BFF 在 /graphql 提供服务(@Controller("graphql")),需剥离 /api/v1/teacher 前缀
// 契约 student-portal_contract.md §2.3/api/v1/{bff}/* → {bff}:port/*
registerBffProxy(api, "teacher", cfg.TeacherBffURL)
// student-bff 路由(学生聚合层 GraphQLP3
registerBffProxy(api, "student", cfg.StudentBffURL)
// parent-bff 路由(家长聚合层 GraphQLP4
// parent-bff 在 /v1/graphql 提供服务ARB-022 §24.4 ISSUE-003 方案 A
registerBffProxy(api, "parent", cfg.ParentBffURL)
// core-edu 域路由(考试/作业/成绩)—— 下游 controller 在 /v1/{domain}/*,仅需剥离 /api
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)
// announcements 路由msg 公告 REST APImsg nextstep.md §2.5 要求)
// /api/v1/announcements/* → msg:3007/v1/announcements/*
registerProxy(api, "announcements", cfg.MsgServiceURL)
// ai 服务路由AI 聊天/生成/优化)
registerProxy(api, "ai", cfg.AiServiceURL)
// data-ana 域路由(学情诊断/错题本/仪表盘)
registerProxy(api, "analytics", cfg.DataAnaServiceURL)
registerProxy(api, "dashboard", cfg.DataAnaServiceURL)
}
// admin 路由组admin-portal 入口(契约 admin-portal_contract.md §2.3
// POST /api/admin/graphql → teacher-bff:3003/graphqladmin 命名空间)
// 中间件链:熔断 → JWT 鉴权 → admin 角色强制 → 指标 → 反向代理
admin := r.Group("/api/admin")
admin.Use(middleware.CircuitBreaker("teacher-bff-admin"))
admin.Use(middleware.AuthMiddleware(cfg, fetcher))
admin.Use(middleware.AdminRoleMiddleware())
admin.Use(observability.Metrics())
{
// /api/admin/graphql 是唯一入口(契约 §2.3 要求),
// 路径重写:/api/admin/graphql → /graphqlteacher-bff @Controller("graphql")
graphqlProxy, err := proxy.NewProxyRewrite(cfg.TeacherBffURL, func(p string) string {
return strings.TrimPrefix(p, "/api/admin")
})
if err != nil {
slog.Error("failed to create admin graphql proxy", "target", cfg.TeacherBffURL, "error", err)
panic(err)
}
admin.Any("/graphql", proxy.ProxyHandler(graphqlProxy))
}
srv := &http.Server{
Addr: ":" + cfg.Port,
Handler: r,
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
}
// 优雅关闭
go func() {
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 {
slog.Error("listen failed", "error", err)
os.Exit(1)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
slog.Info("shutting down API Gateway")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
slog.Error("server forced to shutdown", "error", err)
}
slog.Info("API Gateway exited")
}
// registerProxy 创建反向代理并注册无尾斜杠与通配符两条路由。
// RedirectTrailingSlash=false 时 Gin 不会自动跳转,故两条路由都要显式注册。
// 适用于下游 controller 路径含 /v1 前缀的服务iam/core-edu/content/msg/ai/data-ana
// /api/v1/{prefix}/* → 剥离 /api → /v1/{prefix}/* 转发下游。
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)
}
// registerBffProxy 创建带路径重写的反向代理,用于 BFF 路由teacher/student/parent
// BFF 在 /graphql 或 /v1/graphql 提供服务(@Controller与下游 controller 在 /v1/{domain}/* 的
// 非 BFF 服务不同,需剥离 /api/v1/{prefix} 前缀,仅转发剩余路径到下游。
// 例:/api/v1/teacher/graphql → /graphqlteacher-bff:3003/graphql
// 例:/api/v1/parent/v1/graphql → /v1/graphqlparent-bff:3010/v1/graphqlARB-022 §24.4 ISSUE-003 方案 A
// 契约依据student-portal_contract.md §2.3 /api/v1/{bff}/* → {bff}:port/*
func registerBffProxy(api *gin.RouterGroup, prefix, targetURL string) {
stripPrefix := "/api/v1/" + prefix
p, err := proxy.NewProxyRewrite(targetURL, func(p string) string {
return strings.TrimPrefix(p, stripPrefix)
})
if err != nil {
slog.Error("failed to create bff proxy", "prefix", prefix, "target", targetURL, "error", err)
panic(err)
}
handler := proxy.ProxyHandler(p)
api.Any("/"+prefix, handler)
api.Any("/"+prefix+"/*path", handler)
}