diff --git a/infra/docker-compose.deploy.yml b/infra/docker-compose.deploy.yml index c149fbb..2758fe8 100644 --- a/infra/docker-compose.deploy.yml +++ b/infra/docker-compose.deploy.yml @@ -39,15 +39,14 @@ services: DEV_MODE: "false" CLASSES_SERVICE_URL: http://classes:3001 IAM_SERVICE_URL: http://iam:3002 - TEACHER_BFF_URL: http://teacher-bff:3003 - STUDENT_BFF_URL: http://student-bff:3009 - PARENT_BFF_URL: http://parent-bff:3010 + # v2.1 M9:apollo-router 替代 teacher-bff / student-bff / parent-bff + APOLLO_ROUTER_URL: http://apollo-router:3000 CORE_EDU_SERVICE_URL: http://core-edu:3004 CONTENT_SERVICE_URL: http://content:3005 DATA_ANA_SERVICE_URL: http://data-ana:3006 MSG_SERVICE_URL: http://msg:3007 AI_SERVICE_URL: http://ai:3008 - CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000,http://localhost:4000,http://localhost:4001,http://localhost:4002,http://localhost:4003} + CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000,http://localhost:4000,http://localhost:4001,http://localhost:4002,http://localhost:4003,http://localhost:4010} OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4318} LOG_LEVEL: ${LOG_LEVEL:-info} ports: @@ -55,6 +54,8 @@ services: depends_on: classes: condition: service_healthy + apollo-router: + condition: service_healthy healthcheck: test: ["CMD", "wget", "--quiet", "--spider", "http://localhost:8080/healthz"] interval: 30s @@ -114,39 +115,75 @@ services: - edu-net - edu-shared - teacher-bff: + # v2.1 M3:config-service 从 iam 拆出(插件配置 + 布局 + 用户偏好) + config-service: build: context: ./repo - dockerfile: services/teacher-bff/Dockerfile - container_name: edu-teacher-bff + dockerfile: services/config-service/Dockerfile + container_name: edu-config-service restart: unless-stopped environment: - PORT: 3003 - TEACHER_BFF_DEV_MODE: "false" + PORT: 3011 + GRPC_PORT: 50059 + DATABASE_URL: ${DATABASE_URL} REDIS_URL: ${REDIS_URL} - IAM_GRPC_TARGET: iam:50052 - IAM_SERVICE_URL: http://iam:3002 - CLASSES_SERVICE_URL: http://classes:3001 - CORE_EDU_GRPC_TARGET: core-edu:50053 - CORE_EDU_SERVICE_URL: http://core-edu:3004 - CONTENT_GRPC_TARGET: content:50054 - DATA_ANA_GRPC_TARGET: data-ana:50055 - MSG_GRPC_TARGET: msg:50056 - MSG_SERVICE_URL: http://msg:3007 - AI_GRPC_TARGET: ai:50058 + # GraphQL 子图信任凭证(ADR-036) + ROUTER_AUTH_SECRET: ${ROUTER_AUTH_SECRET:-edu-router-secret-change-in-production} OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4318} LOG_LEVEL: ${LOG_LEVEL:-info} NODE_ENV: production depends_on: iam: condition: service_healthy - classes: - condition: service_healthy healthcheck: - test: ["CMD", "wget", "--quiet", "--spider", "http://localhost:3003/healthz"] + test: ["CMD", "wget", "--quiet", "--spider", "http://localhost:3011/healthz"] interval: 30s timeout: 5s - start_period: 20s + start_period: 30s + retries: 5 + networks: + - edu-net + - edu-shared + + # v2.1 M9:teacher-bff / student-bff / parent-bff 已下线,由 apollo-router 替代 + # 源码保留在 services/{teacher,student,parent}-bff/ 以便回滚 + apollo-router: + build: + context: ./repo + dockerfile: infra/apollo-router/Dockerfile + container_name: edu-apollo-router + restart: unless-stopped + environment: + # 路由配置文件位于 /dist/router.yaml(Dockerfile COPY) + APOLLO_ROUTER_CONFIG_PATH: /dist/router.yaml + # 信任凭证(子图 RouterAuthGuard 校验,ADR-036) + ROUTER_AUTH_SECRET: ${ROUTER_AUTH_SECRET:-edu-router-secret-change-in-production} + # 子图 URL 覆盖(通过 router.yaml override_subgraph_url 配置) + IAM_SUBGRAPH_URL: http://iam:3002/graphql + CONFIG_SERVICE_SUBGRAPH_URL: http://config-service:3011/graphql + CORE_EDU_SUBGRAPH_URL: http://core-edu:3004/graphql + CONTENT_SUBGRAPH_URL: http://content:3005/graphql + MSG_SUBGRAPH_URL: http://msg:3007/graphql + DATA_ANA_SUBGRAPH_URL: http://data-ana:3006/graphql + AI_SUBGRAPH_URL: http://ai:3008/graphql + ports: + - "${APOLLO_ROUTER_PORT:-3000}:3000" + depends_on: + iam: + condition: service_healthy + core-edu: + condition: service_healthy + content: + condition: service_healthy + msg: + condition: service_healthy + config-service: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "--quiet", "--spider", "http://localhost:8088/healthz"] + interval: 30s + timeout: 5s + start_period: 10s retries: 3 networks: - edu-net @@ -318,64 +355,9 @@ services: - edu-net - edu-shared - teacher-portal: - build: - context: ./repo - dockerfile: apps/teacher-portal/Dockerfile - container_name: edu-teacher-portal - restart: unless-stopped - environment: - NODE_ENV: production - PORT: 3000 - API_GATEWAY_URL: http://api-gateway:8080 - ports: - - "${TEACHER_PORTAL_PORT:-3000}:3000" - depends_on: - api-gateway: - condition: service_healthy - healthcheck: - test: ["CMD", "wget", "--quiet", "--spider", "http://localhost:3000/"] - interval: 30s - timeout: 5s - start_period: 20s - retries: 3 - networks: - - edu-net - - edu-shared - - admin-portal: - build: - context: ./repo - dockerfile: apps/admin-portal/Dockerfile - container_name: edu-admin-portal - restart: unless-stopped - environment: - NODE_ENV: production - PORT: 4003 - API_GATEWAY_URL: http://api-gateway:8080 - # 生产环境强制关闭 mock,使用真实后端 - NEXT_PUBLIC_API_MOCKING: "disabled" - NEXT_PUBLIC_MF_ENABLED: "false" - NEXT_PUBLIC_SHELL_URL: http://admin-portal:4003 - NEXT_PUBLIC_WS_URL: ws://push-gateway:8081/ws - NEXT_PUBLIC_OTEL_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4318} - NEXT_PUBLIC_WEB_VITALS_ENDPOINT: /api/admin/web-vitals - ports: - - "${ADMIN_PORTAL_PORT:-4003}:4003" - depends_on: - api-gateway: - condition: service_healthy - teacher-bff: - condition: service_healthy - healthcheck: - test: ["CMD", "wget", "--quiet", "--spider", "http://localhost:4003/api/ready"] - interval: 30s - timeout: 5s - start_period: 20s - retries: 3 - networks: - - edu-net - - edu-shared + # v2.1 M10:teacher-portal / admin-portal 已下线,由 portal-shell 替代 + # 源码保留在 apps/{teacher,student,parent,admin}-portal/ 以便回滚 + # portal-shell 部署在 infra/docker-compose.yml(开发环境)中,端口 4010 networks: # 应用服务内部网络 diff --git a/services/api-gateway/internal/config/config.go b/services/api-gateway/internal/config/config.go index c19ecc2..473877b 100644 --- a/services/api-gateway/internal/config/config.go +++ b/services/api-gateway/internal/config/config.go @@ -9,6 +9,9 @@ import ( // Config 持有 api-gateway 运行时配置。 // P2 起 JWT 验签改 RS256(IAM 签发,Gateway 用 JWKS 公钥校验), // JWTSecret 仅在 DevMode 下作为 mock 密钥保留。 +// +// v2.1 M9:teacher-bff / student-bff / parent-bff 已下线, +// 由 apollo-router(GraphQL 联邦)替代 BFF 聚合层。 type Config struct { Port string JWKSURL string // RS256 公钥端点(IAM GET /.well-known/jwks.json) @@ -18,9 +21,7 @@ type Config struct { CORSOrigins string ClassesServiceURL string IamServiceURL string - TeacherBffURL string - StudentBffURL string - ParentBffURL string + ApolloRouterURL string // v2.1 M9:apollo-router GraphQL 联邦入口 CoreEduServiceURL string ContentServiceURL string DataAnaServiceURL string @@ -77,9 +78,7 @@ func Load() *Config { CORSOrigins: getEnv("CORS_ORIGINS", ""), ClassesServiceURL: getEnv("CLASSES_SERVICE_URL", "http://localhost:3001"), IamServiceURL: getEnv("IAM_SERVICE_URL", "http://localhost:3002"), - TeacherBffURL: getEnv("TEACHER_BFF_URL", "http://localhost:3003"), - StudentBffURL: getEnv("STUDENT_BFF_URL", "http://localhost:3009"), - ParentBffURL: getEnv("PARENT_BFF_URL", "http://localhost:3010"), + ApolloRouterURL: getEnv("APOLLO_ROUTER_URL", "http://localhost:3000"), CoreEduServiceURL: getEnv("CORE_EDU_SERVICE_URL", "http://localhost:3004"), ContentServiceURL: getEnv("CONTENT_SERVICE_URL", "http://localhost:3005"), DataAnaServiceURL: getEnv("DATA_ANA_SERVICE_URL", "http://localhost:3006"), diff --git a/services/api-gateway/internal/health/health.go b/services/api-gateway/internal/health/health.go index 67f9d66..deba91a 100644 --- a/services/api-gateway/internal/health/health.go +++ b/services/api-gateway/internal/health/health.go @@ -29,20 +29,20 @@ type downstreamCheck struct { // GET /readyz:并行 ping 下游服务 /healthz,超时 2s。 // // 软失败规则(president-final-rulings.md §3.3): -// - 必需依赖(iam / teacher-bff,P2 已就绪)失败 → 503 -// - 可选依赖(P3-P5 未就绪服务)失败 → 200 + degraded 列表 +// - 必需依赖(iam / apollo-router)失败 → 503 +// - 可选依赖(core-edu / content / msg / ai / data-ana)失败 → 200 + degraded 列表 // - 全部可达 → 200 {"status":"ok"} +// +// v2.1 M9:teacher-bff / student-bff / parent-bff 已下线,由 apollo-router 替代。 func Readyz(cfg *config.Config) gin.HandlerFunc { checks := []downstreamCheck{ {name: "iam", url: cfg.IamServiceURL + "/healthz", required: true}, - {name: "teacher-bff", url: cfg.TeacherBffURL + "/healthz", required: true}, + {name: "apollo-router", url: cfg.ApolloRouterURL + "/healthz", required: true}, {name: "core-edu", url: cfg.CoreEduServiceURL + "/healthz", required: false}, {name: "content", url: cfg.ContentServiceURL + "/healthz", required: false}, {name: "msg", url: cfg.MsgServiceURL + "/healthz", required: false}, {name: "ai", url: cfg.AiServiceURL + "/healthz", required: false}, {name: "data-ana", url: cfg.DataAnaServiceURL + "/healthz", required: false}, - {name: "student-bff", url: cfg.StudentBffURL + "/healthz", required: false}, - {name: "parent-bff", url: cfg.ParentBffURL + "/healthz", required: false}, } client := &http.Client{Timeout: 2 * time.Second} diff --git a/services/api-gateway/main.go b/services/api-gateway/main.go index 56e4e25..7b05876 100644 --- a/services/api-gateway/main.go +++ b/services/api-gateway/main.go @@ -79,15 +79,9 @@ func main() { 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 路由(学生聚合层 GraphQL,P3) - registerBffProxy(api, "student", cfg.StudentBffURL) - // parent-bff 路由(家长聚合层 GraphQL,P4) - // parent-bff 在 /v1/graphql 提供服务(ARB-022 §24.4 ISSUE-003 方案 A) - registerBffProxy(api, "parent", cfg.ParentBffURL) + // 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) @@ -110,25 +104,43 @@ func main() { registerProxy(api, "dashboard", cfg.DataAnaServiceURL) } - // admin 路由组:admin-portal 入口(契约 admin-portal_contract.md §2.3) - // POST /api/admin/graphql → teacher-bff:3003/graphql(admin 命名空间) - // 中间件链:熔断 → JWT 鉴权 → admin 角色强制 → 指标 → 反向代理 + // 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("teacher-bff-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 是唯一入口(契约 §2.3 要求), - // 路径重写:/api/admin/graphql → /graphql(teacher-bff @Controller("graphql")) - graphqlProxy, err := proxy.NewProxyRewrite(cfg.TeacherBffURL, func(p string) string { + // 路径重写:/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.TeacherBffURL, "error", err) + slog.Error("failed to create admin graphql proxy", "target", cfg.ApolloRouterURL, "error", err) panic(err) } - admin.Any("/graphql", proxy.ProxyHandler(graphqlProxy)) + admin.Any("/graphql", proxy.ProxyHandler(adminGraphqlProxy)) } srv := &http.Server{ @@ -180,22 +192,5 @@ func registerProxy(api *gin.RouterGroup, prefix, targetURL string) { 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 → /graphql(teacher-bff:3003/graphql)。 -// 例:/api/v1/parent/v1/graphql → /v1/graphql(parent-bff:3010/v1/graphql,ARB-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) -} +// registerBffProxy 已在 v2.1 M9 移除:teacher-bff / student-bff / parent-bff 由 apollo-router 替代。 +// 前端 GraphQL 查询统一通过 /api/graphql → apollo-router:3000/graphql。