Files
Edu/services/api-gateway/main.go
SpecialX f586a0b19e feat(portal-shell): v2.0 P1-P4 token migration + unit tests + prod endpoint + e2e
P1: 31 widget 旧纸感令牌批量迁移到 shadcn 标准(1104 次替换)
- bg-paper→bg-background / bg-surface→bg-card / text-ink→text-foreground
- 保留 button.tsx 中 bg-accent(shadcn 标准 hover 语义令牌)

P2: v2.0 新增组件单元测试补齐(5 文件 81 用例)
- permission-bitmap: 24 用例(含 GRADE_READ 重复去重)
- route-permissions: 26 用例(4 张表优先级 + AND/OR 语义)
- notify: 12 用例(sonner toast 双重性质 vi.hoisted mock)
- use-error-report: 9 用例(jsdom Blob vi.stubGlobal mock)
- plugin-boundary: 10 用例(错误边界 + 骨架变体)

P3: 错误上报端点生产替换(后端 /api/v1/log)
- api-gateway: internal/log/handler.go(slog 结构化日志,64KB 限制,204 返回)
- main.go: 注册 POST /api/v1/log 路由
- useErrorReport: 环境感知端点(prod→/api/v1/log,dev→/api/log)

P4: E2E 测试(3 文件 30 用例)
- streaming: 4 用例(React 19 use() + Suspense,act 包裹 render)
- error-boundaries: 6 用例(三级错误边界层级 L1/L2/L3)
- security-boundaries: 20 用例(L1 角色门禁 + L2 权限点 + L3 数据范围)
- vitest setup: IS_REACT_ACT_ENVIRONMENT + jest-dom matchers

验证:typecheck 0 错误 / lint 0 错误 / build 6 路由 / 206 测试全部通过
2026-07-17 16:49:00 +08:00

203 lines
7.9 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"
loghandler "github.com/edu-cloud/api-gateway/internal/log"
"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)
// v2.1 M9teacher-bff / student-bff / parent-bff 已下线,
// 前端 GraphQL 查询统一通过 apollo-router 聚邦层。
// /api/v1/teacher/* /api/v1/student/* /api/v1/parent/* 路由已移除。
// 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)
// 前端错误上报端点portal-shell v2.0 P3
// POST /api/v1/log → 结构化日志记录slog
// 不代理到下游服务,在 Gateway 层直接处理(轻量级、低延迟)
api.POST("/log", loghandler.HandleReport)
}
// v2.1 M9GraphQL 联邦入口(替代旧 BFF 聚合层)
// /api/graphql → apollo-router:3000/graphql统一前端 GraphQL 入口)
// /api/admin/graphql → apollo-router:3000/graphqladmin 命名空间)
// 中间件链:熔断 → JWT 鉴权 → 指标 → 反向代理admin 路由额外强制 admin 角色)
graphqlGroup := r.Group("/api/graphql")
graphqlGroup.Use(middleware.CircuitBreaker("apollo-router"))
graphqlGroup.Use(middleware.AuthMiddleware(cfg, fetcher))
graphqlGroup.Use(observability.Metrics())
{
// 路径重写:/api/graphql → /graphqlapollo-router supergraph.path
graphqlProxy, err := proxy.NewProxyRewrite(cfg.ApolloRouterURL, func(p string) string {
return strings.TrimPrefix(p, "/api/graphql")
})
if err != nil {
slog.Error("failed to create apollo-router graphql proxy", "target", cfg.ApolloRouterURL, "error", err)
panic(err)
}
graphqlGroup.Any("", proxy.ProxyHandler(graphqlProxy))
graphqlGroup.Any("/", proxy.ProxyHandler(graphqlProxy))
}
// admin GraphQL 入口admin-portal 仍走独立路径以强制 admin 角色)
admin := r.Group("/api/admin")
admin.Use(middleware.CircuitBreaker("apollo-router-admin"))
admin.Use(middleware.AuthMiddleware(cfg, fetcher))
admin.Use(middleware.AdminRoleMiddleware())
admin.Use(observability.Metrics())
{
// 路径重写:/api/admin/graphql → /graphqlapollo-router
adminGraphqlProxy, err := proxy.NewProxyRewrite(cfg.ApolloRouterURL, func(p string) string {
return strings.TrimPrefix(p, "/api/admin")
})
if err != nil {
slog.Error("failed to create admin graphql proxy", "target", cfg.ApolloRouterURL, "error", err)
panic(err)
}
admin.Any("/graphql", proxy.ProxyHandler(adminGraphqlProxy))
}
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 已在 v2.1 M9 移除teacher-bff / student-bff / parent-bff 由 apollo-router 替代。
// 前端 GraphQL 查询统一通过 /api/graphql → apollo-router:3000/graphql。