feat(api-gateway): admin 路由组 + BFF 路径重写 + announcements 路由 + Dockerfile 修复 + nextstep v2 文档
This commit is contained in:
49
services/api-gateway/internal/middleware/admin_role.go
Normal file
49
services/api-gateway/internal/middleware/admin_role.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/edu-cloud/api-gateway/internal/observability"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AdminRoleMiddleware 强制校验请求者具备 admin 角色。
|
||||
//
|
||||
// 用途:保护 /api/admin/* 路由组(admin-portal 入口),拒绝非 admin 角色访问。
|
||||
// 前置条件:必须在 AuthMiddleware 之后注册,依赖 AuthMiddleware 注入的 x-user-roles 头。
|
||||
//
|
||||
// 响应规范(W1/W2 裁决):拒绝时返回 ActionState 信封,
|
||||
// 错误码 GW_FORBIDDEN,HTTP 403。
|
||||
//
|
||||
// 注意:DevMode 旁路由 AuthMiddleware 注入 x-user-roles=teacher,admin,
|
||||
// 因此 dev-token 自动通过 admin 校验,无需在此重复 DevMode 判断。
|
||||
func AdminRoleMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
rolesHeader := c.GetHeader("x-user-roles")
|
||||
if rolesHeader == "" {
|
||||
observability.IncAuthFailure("admin_missing_roles")
|
||||
abortGW(c, http.StatusForbidden, "GW_FORBIDDEN", "missing roles header")
|
||||
return
|
||||
}
|
||||
|
||||
if !hasAdminRole(rolesHeader) {
|
||||
observability.IncAuthFailure("admin_role_required")
|
||||
abortGW(c, http.StatusForbidden, "GW_FORBIDDEN", "admin role required")
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// hasAdminRole 判断逗号分隔的角色列表中是否包含 admin(大小写敏感)。
|
||||
// 角色列表格式示例:"teacher,admin" / "admin" / "student,parent"。
|
||||
func hasAdminRole(rolesHeader string) bool {
|
||||
for _, r := range strings.Split(rolesHeader, ",") {
|
||||
if strings.TrimSpace(r) == "admin" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
187
services/api-gateway/internal/middleware/admin_role_test.go
Normal file
187
services/api-gateway/internal/middleware/admin_role_test.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// setupAdminRoleRouter 构造一个仅含 AdminRoleMiddleware 的 gin 路由用于测试。
|
||||
// nextCalled 标记后续 handler 是否被调用。
|
||||
func setupAdminRoleRouter(t *testing.T) (*gin.Engine, *bool) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
called := false
|
||||
r.Use(AdminRoleMiddleware())
|
||||
r.Any("/test", func(c *gin.Context) {
|
||||
called = true
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
return r, &called
|
||||
}
|
||||
|
||||
// parseActionState 解析 ActionState 错误信封,返回 success/code/message。
|
||||
func parseActionState(t *testing.T, w *httptest.ResponseRecorder) (bool, string, string) {
|
||||
t.Helper()
|
||||
var body struct {
|
||||
Success bool `json:"success"`
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("解析响应体失败: %v, body=%s", err, w.Body.String())
|
||||
}
|
||||
return body.Success, body.Error.Code, body.Error.Message
|
||||
}
|
||||
|
||||
func TestAdminRoleMiddleware_PassesWhenAdminOnly(t *testing.T) {
|
||||
r, called := setupAdminRoleRouter(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.Header.Set("x-user-roles", "admin")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("纯 admin 角色应通过,期望 200,实际 %d", w.Code)
|
||||
}
|
||||
if !*called {
|
||||
t.Fatal("下游 handler 应被调用")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminRoleMiddleware_PassesWhenAdminInList(t *testing.T) {
|
||||
r, called := setupAdminRoleRouter(t)
|
||||
// 多角色列表中包含 admin
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.Header.Set("x-user-roles", "teacher,admin")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("多角色包含 admin 应通过,期望 200,实际 %d", w.Code)
|
||||
}
|
||||
if !*called {
|
||||
t.Fatal("下游 handler 应被调用")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminRoleMiddleware_RejectsWhenMissingRolesHeader(t *testing.T) {
|
||||
r, called := setupAdminRoleRouter(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
// 不设置 x-user-roles 头
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("缺失 roles 头期望 403,实际 %d", w.Code)
|
||||
}
|
||||
if *called {
|
||||
t.Fatal("下游 handler 不应被调用")
|
||||
}
|
||||
success, code, _ := parseActionState(t, w)
|
||||
if success {
|
||||
t.Fatal("响应 success 应为 false")
|
||||
}
|
||||
if code != "GW_FORBIDDEN" {
|
||||
t.Fatalf("错误码应为 GW_FORBIDDEN,实际 %s", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminRoleMiddleware_RejectsWhenNoAdminRole(t *testing.T) {
|
||||
r, called := setupAdminRoleRouter(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.Header.Set("x-user-roles", "teacher")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("非 admin 角色期望 403,实际 %d", w.Code)
|
||||
}
|
||||
if *called {
|
||||
t.Fatal("下游 handler 不应被调用")
|
||||
}
|
||||
_, code, msg := parseActionState(t, w)
|
||||
if code != "GW_FORBIDDEN" {
|
||||
t.Fatalf("错误码应为 GW_FORBIDDEN,实际 %s", code)
|
||||
}
|
||||
if msg != "admin role required" {
|
||||
t.Fatalf("错误消息应为 'admin role required',实际 %s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminRoleMiddleware_RejectsStudentRole(t *testing.T) {
|
||||
r, called := setupAdminRoleRouter(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.Header.Set("x-user-roles", "student,parent")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("student/parent 角色期望 403,实际 %d", w.Code)
|
||||
}
|
||||
if *called {
|
||||
t.Fatal("下游 handler 不应被调用")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminRoleMiddleware_RejectsEmptyRolesHeader(t *testing.T) {
|
||||
r, called := setupAdminRoleRouter(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.Header.Set("x-user-roles", "")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
// 空字符串会被视为缺失 roles 头
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("空 roles 头期望 403,实际 %d", w.Code)
|
||||
}
|
||||
if *called {
|
||||
t.Fatal("下游 handler 不应被调用")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminRoleMiddleware_CaseSensitive(t *testing.T) {
|
||||
r, called := setupAdminRoleRouter(t)
|
||||
// "Admin"(大写)不应通过(大小写敏感)
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.Header.Set("x-user-roles", "Admin")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("'Admin'(大写)不应通过,期望 403,实际 %d", w.Code)
|
||||
}
|
||||
if *called {
|
||||
t.Fatal("下游 handler 不应被调用")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasAdminRole_Variants(t *testing.T) {
|
||||
cases := []struct {
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
{"admin", true},
|
||||
{"teacher,admin", true},
|
||||
{"admin,teacher", true},
|
||||
{" teacher , admin ", true}, // 含空格
|
||||
{"teacher", false},
|
||||
{"student,parent", false},
|
||||
{"", false},
|
||||
{"Admin", false}, // 大小写敏感
|
||||
{"administrator", false},
|
||||
{"admin-role", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := hasAdminRole(c.input)
|
||||
if got != c.want {
|
||||
t.Errorf("hasAdminRole(%q) = %v, want %v", c.input, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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} → /graphql(teacher-bff/student-bff)
|
||||
// - BFF:/api/v1/{bff}/v1/graphql → 剥离 /api/v1/{bff} → /v1/graphql(parent-bff,ARB-022 §24.4 ISSUE-003 方案 A)
|
||||
// - admin:/api/admin/graphql → 剥离 /api/admin → /graphql(teacher-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) {
|
||||
|
||||
173
services/api-gateway/internal/proxy/proxy_rewrite_test.go
Normal file
173
services/api-gateway/internal/proxy/proxy_rewrite_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user