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

@@ -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_FORBIDDENHTTP 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
}

View 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)
}
}
}