feat(api-gateway): admin 路由组 + BFF 路径重写 + announcements 路由 + Dockerfile 修复 + nextstep v2 文档

This commit is contained in:
SpecialX
2026-07-14 16:03:38 +08:00
parent 9fd7c018c2
commit a70977ad4b
8 changed files with 1121 additions and 14 deletions

View File

@@ -9,7 +9,10 @@ import (
"github.com/gin-gonic/gin"
)
// NewProxy 创建反向代理
// NewProxy 创建反向代理
// 路径改写:去除 /api 前缀,保留 /v1 下游 controller 前缀。
// 适用于下游 controller 路径含 /v1 前缀的服务iam/core-edu/content/msg/ai/data-ana
// /api/v1/{prefix}/* → 剥离 /api → /v1/{prefix}/* 转发下游。
func NewProxy(targetURL string) (*httputil.ReverseProxy, error) {
target, err := url.Parse(targetURL)
if err != nil {
@@ -19,14 +22,32 @@ func NewProxy(targetURL string) (*httputil.ReverseProxy, error) {
originalDirector := proxy.Director
proxy.Director = func(req *http.Request) {
originalDirector(req)
// 去除 /api 前缀,保留 /v1 下游 controller 前缀
// 下游 NestJS controller 路径为 /v1/iam/*, /v1/exams/* 等
req.URL.Path = strings.TrimPrefix(req.URL.Path, "/api")
req.Host = target.Host
}
return proxy, nil
}
// NewProxyRewrite 创建带自定义路径重写的反向代理。
// 用于 BFF 路由teacher/student/parent和 admin 路由:
// - BFF/api/v1/{bff}/graphql → 剥离 /api/v1/{bff} → /graphqlteacher-bff/student-bff
// - BFF/api/v1/{bff}/v1/graphql → 剥离 /api/v1/{bff} → /v1/graphqlparent-bffARB-022 §24.4 ISSUE-003 方案 A
// - admin/api/admin/graphql → 剥离 /api/admin → /graphqlteacher-bff admin 命名空间)
func NewProxyRewrite(targetURL string, pathRewriter func(string) string) (*httputil.ReverseProxy, error) {
target, err := url.Parse(targetURL)
if err != nil {
return nil, err
}
proxy := httputil.NewSingleHostReverseProxy(target)
originalDirector := proxy.Director
proxy.Director = func(req *http.Request) {
originalDirector(req)
req.URL.Path = pathRewriter(req.URL.Path)
req.Host = target.Host
}
return proxy, nil
}
// ProxyHandler 返回 Gin 处理函数
func ProxyHandler(proxy *httputil.ReverseProxy) gin.HandlerFunc {
return func(c *gin.Context) {

View File

@@ -0,0 +1,173 @@
package proxy
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
// startGateway 启动一个真实 httptest.Server 作为 api-gateway返回其 URL。
// 使用真实 server 是因为 httputil.ReverseProxy.ServeHTTP 会调用
// ResponseWriter.CloseNotify(),而 httptest.ResponseRecorder 未实现该接口。
func startGateway(t *testing.T, handler gin.HandlerFunc) *httptest.Server {
t.Helper()
gin.SetMode(gin.TestMode)
r := gin.New()
// 只注册 graphql 具体路由,避免与通配符 *path 冲突
r.POST("/api/admin/graphql", handler)
r.GET("/api/admin/graphql", handler)
return httptest.NewServer(r)
}
// TestNewProxyRewrite_StripsPrefix 验证 NewProxyRewrite 调用自定义 rewriter 后下游收到的路径正确。
// 模拟 admin-portal 场景:/api/admin/graphql → /graphql
func TestNewProxyRewrite_StripsPrefix(t *testing.T) {
// 下游服务器:记录收到的路径
var receivedPath string
downstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedPath = r.URL.Path
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}))
defer downstream.Close()
rewriter := func(p string) string {
return strings.TrimPrefix(p, "/api/admin")
}
p, err := NewProxyRewrite(downstream.URL, rewriter)
if err != nil {
t.Fatalf("NewProxyRewrite 失败: %v", err)
}
gateway := startGateway(t, ProxyHandler(p))
defer gateway.Close()
// 通过真实 HTTP 客户端发起请求
req, _ := http.NewRequest(http.MethodPost, gateway.URL+"/api/admin/graphql", strings.NewReader(`{"query":"{}"}`))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("请求 gateway 失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("期望 200实际 %d", resp.StatusCode)
}
if receivedPath != "/graphql" {
t.Fatalf("下游收到路径应为 /graphql实际 %s", receivedPath)
}
}
// TestNewProxyRewrite_PreservesQuery 验证 NewProxyRewrite 保留查询参数。
func TestNewProxyRewrite_PreservesQuery(t *testing.T) {
var receivedQuery string
downstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedQuery = r.URL.RawQuery
w.WriteHeader(http.StatusOK)
}))
defer downstream.Close()
rewriter := func(p string) string {
return strings.TrimPrefix(p, "/api/admin")
}
p, err := NewProxyRewrite(downstream.URL, rewriter)
if err != nil {
t.Fatalf("NewProxyRewrite 失败: %v", err)
}
gateway := startGateway(t, ProxyHandler(p))
defer gateway.Close()
req, _ := http.NewRequest(http.MethodGet, gateway.URL+"/api/admin/graphql?operation=adminUsers", nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("请求 gateway 失败: %v", err)
}
defer resp.Body.Close()
if receivedQuery != "operation=adminUsers" {
t.Fatalf("下游应收到查询参数 operation=adminUsers实际 %s", receivedQuery)
}
}
// TestNewProxyRewrite_InvalidURL 验证 NewProxyRewrite 对无效 URL 报错。
func TestNewProxyRewrite_InvalidURL(t *testing.T) {
rewriter := func(p string) string { return p }
_, err := NewProxyRewrite("://invalid", rewriter)
if err == nil {
t.Fatal("期望无效 URL 报错,实际返回 nil")
}
}
// TestNewProxyRewrite_ForwardsBody 验证 NewProxyRewrite 转发请求体。
func TestNewProxyRewrite_ForwardsBody(t *testing.T) {
var receivedBody string
downstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
receivedBody = string(body)
w.WriteHeader(http.StatusOK)
}))
defer downstream.Close()
rewriter := func(p string) string {
return strings.TrimPrefix(p, "/api/admin")
}
p, err := NewProxyRewrite(downstream.URL, rewriter)
if err != nil {
t.Fatalf("NewProxyRewrite 失败: %v", err)
}
gateway := startGateway(t, ProxyHandler(p))
defer gateway.Close()
body := `{"query":"query { adminUsers { id } }"}`
req, _ := http.NewRequest(http.MethodPost, gateway.URL+"/api/admin/graphql", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("请求 gateway 失败: %v", err)
}
defer resp.Body.Close()
if receivedBody != body {
t.Fatalf("下游应收到完整请求体,实际 %s", receivedBody)
}
}
// TestNewProxyRewrite_ForwardsHeaders 验证 NewProxyRewrite 转发请求头。
func TestNewProxyRewrite_ForwardsHeaders(t *testing.T) {
var receivedAuth string
downstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedAuth = r.Header.Get("x-user-roles")
w.WriteHeader(http.StatusOK)
}))
defer downstream.Close()
rewriter := func(p string) string {
return strings.TrimPrefix(p, "/api/admin")
}
p, err := NewProxyRewrite(downstream.URL, rewriter)
if err != nil {
t.Fatalf("NewProxyRewrite 失败: %v", err)
}
gateway := startGateway(t, ProxyHandler(p))
defer gateway.Close()
req, _ := http.NewRequest(http.MethodPost, gateway.URL+"/api/admin/graphql", nil)
req.Header.Set("x-user-roles", "admin")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("请求 gateway 失败: %v", err)
}
defer resp.Body.Close()
if receivedAuth != "admin" {
t.Fatalf("下游应收到 x-user-roles=admin实际 %s", receivedAuth)
}
}