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 测试全部通过
203 lines
7.9 KiB
Go
203 lines
7.9 KiB
Go
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 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/ 循环
|
||
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-edu(C1 裁决:classes 合并入 core-edu)
|
||
registerProxy(api, "classes", cfg.CoreEduServiceURL)
|
||
// iam 服务路由(身份与访问管理)
|
||
registerProxy(api, "iam", cfg.IamServiceURL)
|
||
// v2.1 M9:teacher-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 API(msg 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 M9:GraphQL 联邦入口(替代旧 BFF 聚合层)
|
||
// /api/graphql → apollo-router:3000/graphql(统一前端 GraphQL 入口)
|
||
// /api/admin/graphql → apollo-router:3000/graphql(admin 命名空间)
|
||
// 中间件链:熔断 → 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 → /graphql(apollo-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 → /graphql(apollo-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。
|