fix(api-gateway): 修复尾斜杠重定向循环与 DEV_MODE 旁路

- main.go: 禁用 RedirectTrailingSlash,为 classes/iam/teacher 双注册无尾斜杠与通配符路由

- auth.go: DEV_MODE=true 时接受 Bearer dev-token 注入开发用户

- config.go: 新增 DevMode 配置项与 getEnvBool 工具

- page.tsx: 开发模式请求携带 Authorization: Bearer dev-token

- .env.example: 添加 DEV_MODE=false 默认值与生产警告
This commit is contained in:
SpecialX
2026-07-08 15:11:47 +08:00
parent a4ec5b72c5
commit e5902ca2b3
7 changed files with 171 additions and 55 deletions

View File

@@ -12,6 +12,12 @@ JWT_SECRET=p1-dev-secret-change-in-production
JWT_ISSUER=next-edu-cloud JWT_ISSUER=next-edu-cloud
JWT_AUDIENCE=next-edu-cloud JWT_AUDIENCE=next-edu-cloud
# 开发模式旁路(仅本地联调)
# DEV_MODE=true 时接受 "Authorization: Bearer dev-token" 旁路 JWT 校验,
# 注入固定身份 x-user-id=dev-user, x-user-roles=teacher,admin
# 生产环境必须设为 false 或不设此变量
DEV_MODE=false
# 服务端口 # 服务端口
API_GATEWAY_PORT=8080 API_GATEWAY_PORT=8080
CLASSES_SERVICE_PORT=3001 CLASSES_SERVICE_PORT=3001

View File

@@ -1,6 +1,6 @@
'use client'; "use client";
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from "react";
interface ClassItem { interface ClassItem {
id: string; id: string;
@@ -20,24 +20,28 @@ interface ApiResponse<T> {
export default function HomePage() { export default function HomePage() {
const [classes, setClasses] = useState<ClassItem[]>([]); const [classes, setClasses] = useState<ClassItem[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [name, setName] = useState(''); const [name, setName] = useState("");
const [gradeId, setGradeId] = useState('550e8400-e29b-41d4-a716-446655440000'); const [gradeId, setGradeId] = useState(
const [description, setDescription] = useState(''); "550e8400-e29b-41d4-a716-446655440000",
);
const [description, setDescription] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const fetchClasses = useCallback(async () => { const fetchClasses = useCallback(async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const res = await fetch('/api/v1/classes'); const res = await fetch("/api/v1/classes", {
headers: { Authorization: "Bearer dev-token" },
});
const json: ApiResponse<ClassItem[]> = await res.json(); const json: ApiResponse<ClassItem[]> = await res.json();
if (json.success && json.data) { if (json.success && json.data) {
setClasses(json.data); setClasses(json.data);
} else { } else {
setError(json.error?.message || 'Failed to load'); setError(json.error?.message || "Failed to load");
} }
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : 'Network error'); setError(e instanceof Error ? e.message : "Network error");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -51,49 +55,61 @@ export default function HomePage() {
e.preventDefault(); e.preventDefault();
if (!name.trim()) return; if (!name.trim()) return;
try { try {
const res = await fetch('/api/v1/classes', { const res = await fetch("/api/v1/classes", {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer dev-token' }, headers: {
"Content-Type": "application/json",
Authorization: "Bearer dev-token",
},
body: JSON.stringify({ name, gradeId, description }), body: JSON.stringify({ name, gradeId, description }),
}); });
const json = await res.json(); const json = await res.json();
if (!json.success) { if (!json.success) {
setError(json.error?.message || 'Create failed'); setError(json.error?.message || "Create failed");
return; return;
} }
setName(''); setName("");
setDescription(''); setDescription("");
await fetchClasses(); await fetchClasses();
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : 'Network error'); setError(e instanceof Error ? e.message : "Network error");
} }
}; };
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
try { try {
const res = await fetch(`/api/v1/classes/${id}`, { const res = await fetch(`/api/v1/classes/${id}`, {
method: 'DELETE', method: "DELETE",
headers: { 'Authorization': 'Bearer dev-token' }, headers: { Authorization: "Bearer dev-token" },
}); });
const json = await res.json(); const json = await res.json();
if (!json.success) { if (!json.success) {
setError(json.error?.message || 'Delete failed'); setError(json.error?.message || "Delete failed");
return; return;
} }
await fetchClasses(); await fetchClasses();
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : 'Network error'); setError(e instanceof Error ? e.message : "Network error");
} }
}; };
return ( return (
<div className="min-h-screen" style={{ background: 'var(--bg-paper)' }}> <div className="min-h-screen" style={{ background: "var(--bg-paper)" }}>
<header className="border-b" style={{ borderColor: 'var(--color-rule)' }}> <header className="border-b" style={{ borderColor: "var(--color-rule)" }}>
<div className="max-w-6xl mx-auto px-8 py-6"> <div className="max-w-6xl mx-auto px-8 py-6">
<h1 className="text-3xl" style={{ fontFamily: 'var(--font-serif)', color: 'var(--color-ink)' }}> <h1
className="text-3xl"
style={{
fontFamily: "var(--font-serif)",
color: "var(--color-ink)",
}}
>
</h1> </h1>
<p className="mt-1 text-sm" style={{ color: 'var(--color-ink-muted)' }}> <p
className="mt-1 text-sm"
style={{ color: "var(--color-ink-muted)" }}
>
P1 - classes CRUD P1 - classes CRUD
</p> </p>
</div> </div>
@@ -102,11 +118,19 @@ export default function HomePage() {
<main className="max-w-6xl mx-auto px-8 py-8 grid grid-cols-12 gap-8"> <main className="max-w-6xl mx-auto px-8 py-8 grid grid-cols-12 gap-8">
{/* 左侧:创建表单 */} {/* 左侧:创建表单 */}
<aside className="col-span-4"> <aside className="col-span-4">
<h2 className="text-xl mb-4" style={{ fontFamily: 'var(--font-serif)' }}></h2> <h2
className="text-xl mb-4"
style={{ fontFamily: "var(--font-serif)" }}
>
</h2>
<div className="rule-thin mb-4" /> <div className="rule-thin mb-4" />
<form onSubmit={handleCreate} className="space-y-4"> <form onSubmit={handleCreate} className="space-y-4">
<div> <div>
<label className="block text-xs uppercase tracking-wide mb-1" style={{ color: 'var(--color-ink-muted)' }}> <label
className="block text-xs uppercase tracking-wide mb-1"
style={{ color: "var(--color-ink-muted)" }}
>
</label> </label>
<input <input
@@ -114,13 +138,19 @@ export default function HomePage() {
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b focus:outline-none focus:border-b-2" className="w-full px-3 py-2 bg-transparent border-b focus:outline-none focus:border-b-2"
style={{ borderColor: 'var(--color-rule)', borderRadius: '6px 6px 0 0' }} style={{
borderColor: "var(--color-rule)",
borderRadius: "6px 6px 0 0",
}}
placeholder="如:高三(1)班" placeholder="如:高三(1)班"
required required
/> />
</div> </div>
<div> <div>
<label className="block text-xs uppercase tracking-wide mb-1" style={{ color: 'var(--color-ink-muted)' }}> <label
className="block text-xs uppercase tracking-wide mb-1"
style={{ color: "var(--color-ink-muted)" }}
>
ID ID
</label> </label>
<input <input
@@ -128,25 +158,34 @@ export default function HomePage() {
value={gradeId} value={gradeId}
onChange={(e) => setGradeId(e.target.value)} onChange={(e) => setGradeId(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b text-sm font-mono" className="w-full px-3 py-2 bg-transparent border-b text-sm font-mono"
style={{ borderColor: 'var(--color-rule)', borderRadius: '6px 6px 0 0' }} style={{
borderColor: "var(--color-rule)",
borderRadius: "6px 6px 0 0",
}}
/> />
</div> </div>
<div> <div>
<label className="block text-xs uppercase tracking-wide mb-1" style={{ color: 'var(--color-ink-muted)' }}> <label
className="block text-xs uppercase tracking-wide mb-1"
style={{ color: "var(--color-ink-muted)" }}
>
</label> </label>
<textarea <textarea
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
className="w-full px-3 py-2 bg-transparent border-b resize-none" className="w-full px-3 py-2 bg-transparent border-b resize-none"
style={{ borderColor: 'var(--color-rule)', borderRadius: '6px 6px 0 0' }} style={{
borderColor: "var(--color-rule)",
borderRadius: "6px 6px 0 0",
}}
rows={3} rows={3}
/> />
</div> </div>
<button <button
type="submit" type="submit"
className="px-4 py-2 text-white text-sm tracking-wide transition-opacity hover:opacity-90" className="px-4 py-2 text-white text-sm tracking-wide transition-opacity hover:opacity-90"
style={{ background: 'var(--color-accent)', borderRadius: '6px' }} style={{ background: "var(--color-accent)", borderRadius: "6px" }}
> >
</button> </button>
@@ -156,16 +195,19 @@ export default function HomePage() {
{/* 中间:班级列表(纸面)*/} {/* 中间:班级列表(纸面)*/}
<section className="col-span-8"> <section className="col-span-8">
<div className="flex items-baseline justify-between mb-4"> <div className="flex items-baseline justify-between mb-4">
<h2 className="text-xl" style={{ fontFamily: 'var(--font-serif)' }}> <h2 className="text-xl" style={{ fontFamily: "var(--font-serif)" }}>
<span className="ml-2 text-sm font-sans" style={{ color: 'var(--color-ink-muted)' }}> <span
className="ml-2 text-sm font-sans"
style={{ color: "var(--color-ink-muted)" }}
>
{classes.length} {classes.length}
</span> </span>
</h2> </h2>
<button <button
onClick={fetchClasses} onClick={fetchClasses}
className="text-xs uppercase tracking-wide hover:opacity-70" className="text-xs uppercase tracking-wide hover:opacity-70"
style={{ color: 'var(--color-accent)' }} style={{ color: "var(--color-accent)" }}
> >
</button> </button>
@@ -173,37 +215,65 @@ export default function HomePage() {
<div className="rule-thin mb-6" /> <div className="rule-thin mb-6" />
{error && ( {error && (
<div className="mark-left mb-4 py-2" style={{ borderColor: 'var(--color-accent)' }}> <div
<p className="text-sm" style={{ color: 'var(--color-accent)' }}>{error}</p> className="mark-left mb-4 py-2"
style={{ borderColor: "var(--color-accent)" }}
>
<p className="text-sm" style={{ color: "var(--color-accent)" }}>
{error}
</p>
</div> </div>
)} )}
{loading ? ( {loading ? (
<p className="text-sm" style={{ color: 'var(--color-ink-muted)' }}>...</p> <p className="text-sm" style={{ color: "var(--color-ink-muted)" }}>
...
</p>
) : classes.length === 0 ? ( ) : classes.length === 0 ? (
<p className="text-sm italic" style={{ color: 'var(--color-ink-muted)' }}> <p
className="text-sm italic"
style={{ color: "var(--color-ink-muted)" }}
>
</p> </p>
) : ( ) : (
<ul className="space-y-0"> <ul className="space-y-0">
{classes.map((cls) => ( {classes.map((cls) => (
<li key={cls.id} className="py-4 grid grid-cols-12 gap-4 items-baseline" style={{ borderBottom: '1px solid var(--color-rule)' }}> <li
key={cls.id}
className="py-4 grid grid-cols-12 gap-4 items-baseline"
style={{ borderBottom: "1px solid var(--color-rule)" }}
>
<div className="col-span-7"> <div className="col-span-7">
<h3 className="text-lg" style={{ fontFamily: 'var(--font-serif)', color: 'var(--color-ink)' }}> <h3
className="text-lg"
style={{
fontFamily: "var(--font-serif)",
color: "var(--color-ink)",
}}
>
{cls.name} {cls.name}
</h3> </h3>
{cls.description && ( {cls.description && (
<p className="mt-1 text-sm" style={{ color: 'var(--color-ink-muted)' }}>{cls.description}</p> <p
className="mt-1 text-sm"
style={{ color: "var(--color-ink-muted)" }}
>
{cls.description}
</p>
)} )}
</div> </div>
<div className="col-span-3 text-xs font-mono" style={{ color: 'var(--color-ink-muted)' }}> <div
className="col-span-3 text-xs font-mono"
style={{ color: "var(--color-ink-muted)" }}
>
{cls.id.slice(0, 8)}... {cls.id.slice(0, 8)}...
</div> </div>
<div className="col-span-2 text-right"> <div className="col-span-2 text-right">
<button <button
onClick={() => handleDelete(cls.id)} onClick={() => handleDelete(cls.id)}
className="text-xs uppercase tracking-wide hover:opacity-70" className="text-xs uppercase tracking-wide hover:opacity-70"
style={{ color: 'var(--color-ink-muted)' }} style={{ color: "var(--color-ink-muted)" }}
> >
</button> </button>

4
go.work.sum Normal file
View File

@@ -0,0 +1,4 @@
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=

View File

@@ -72,13 +72,19 @@ docker build -t edu/api-gateway .
通过环境变量配置(见 `internal/config/config.go` 通过环境变量配置(见 `internal/config/config.go`
| 变量 | 默认值 | 说明 | | 变量 | 默认值 | 说明 |
| --------------------- | --------------------- | -------------------- | | ----------------------------- | --------------------- | ------------------------------------------------------ |
| `PORT` | 8080 | 监听端口 | | `API_GATEWAY_PORT` | 8080 | 监听端口 |
| `JWT_SECRET` | (必填) | HS256 签名密钥P1 | | `JWT_SECRET` | (必填) | HS256 签名密钥P1 |
| `JWT_PUBLIC_KEY` | P2 | RS256 公钥 | | `JWT_ISSUER` | next-edu-cloud | JWT 签发者 |
| `JWT_AUDIENCE` | next-edu-cloud | JWT 受众 |
| `DEV_MODE` | false | 开发模式旁路true 时接受 `Bearer dev-token`(仅本地) |
| `CLASSES_SERVICE_URL` | http://localhost:3001 | classes 服务地址 | | `CLASSES_SERVICE_URL` | http://localhost:3001 | classes 服务地址 |
| `RATE_LIMIT_RPS` | 10 | 每秒令牌数 | | `IAM_SERVICE_URL` | http://localhost:3002 | iam 服务地址 |
| `RATE_LIMIT_BURST` | 20 | 突发容量 | | `TEACHER_BFF_URL` | http://localhost:3003 | teacher-bff 服务地址 |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | http://localhost:4318 | OpenTelemetry OTLP 端点 |
| `LOG_LEVEL` | info | 日志级别 |
> **生产环境警告**`DEV_MODE` 必须为 `false` 或不设。设为 `true` 会允许 `dev-token` 旁路鉴权并注入固定 admin 身份。
## 关联文档 ## 关联文档

View File

@@ -15,6 +15,7 @@ type Config struct {
TeacherBffURL string TeacherBffURL string
OTLPEndpoint string OTLPEndpoint string
LogLevel string LogLevel string
DevMode bool
} }
func Load() *Config { func Load() *Config {
@@ -28,6 +29,7 @@ func Load() *Config {
TeacherBffURL: getEnv("TEACHER_BFF_URL", "http://localhost:3003"), TeacherBffURL: getEnv("TEACHER_BFF_URL", "http://localhost:3003"),
OTLPEndpoint: getEnv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318"), OTLPEndpoint: getEnv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318"),
LogLevel: getEnv("LOG_LEVEL", "info"), LogLevel: getEnv("LOG_LEVEL", "info"),
DevMode: getEnvBool("DEV_MODE", false),
} }
} }
@@ -46,3 +48,12 @@ func getEnvInt(key string, fallback int) int {
} }
return fallback return fallback
} }
func getEnvBool(key string, fallback bool) bool {
if v := os.Getenv(key); v != "" {
if b, err := strconv.ParseBool(v); err == nil {
return b
}
}
return fallback
}

View File

@@ -1,4 +1,4 @@
package middleware package middleware
import ( import (
"net/http" "net/http"
@@ -44,6 +44,15 @@ func AuthMiddleware(cfg *config.Config) gin.HandlerFunc {
return return
} }
// 开发模式旁路DEV_MODE=true 时接受 "dev-token",注入开发用户
// 仅用于本地联调,生产环境必须关闭 DEV_MODE
if cfg.DevMode && tokenStr == "dev-token" {
c.Request.Header.Set("x-user-id", "dev-user")
c.Request.Header.Set("x-user-roles", "teacher,admin")
c.Next()
return
}
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) { token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid return nil, jwt.ErrSignatureInvalid

View File

@@ -23,6 +23,8 @@ func main() {
cfg := config.Load() cfg := config.Load()
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
r := gin.New() r := gin.New()
// 关闭尾斜杠重定向:避免 Next.js rewrites 代理时 /api/v1/classes → 301 → /api/v1/classes/ 循环
r.RedirectTrailingSlash = false
// 全局中间件(按顺序注册) // 全局中间件(按顺序注册)
// 1. panic 恢复(最外层,捕获后续所有中间件与 handler 的 panic // 1. panic 恢复(最外层,捕获后续所有中间件与 handler 的 panic
@@ -50,25 +52,33 @@ func main() {
api.Use(middleware.AuthMiddleware(cfg)) api.Use(middleware.AuthMiddleware(cfg))
{ {
// classes 服务路由 // classes 服务路由
// 注同时注册无尾斜杠与通配符两条路由。RedirectTrailingSlash=false 时,
// Gin 不会自动把 /classes 跳到 /classes/,所以两条都要显式注册。
classesProxy, err := proxy.NewProxy(cfg.ClassesServiceURL) classesProxy, err := proxy.NewProxy(cfg.ClassesServiceURL)
if err != nil { if err != nil {
log.Fatalf("failed to create classes proxy: %v", err) log.Fatalf("failed to create classes proxy: %v", err)
} }
api.Any("/classes/*path", proxy.ProxyHandler(classesProxy)) classesHandler := proxy.ProxyHandler(classesProxy)
api.Any("/classes", classesHandler)
api.Any("/classes/*path", classesHandler)
// IAM 服务路由(身份与访问管理) // IAM 服务路由(身份与访问管理)
iamProxy, err := proxy.NewProxy(cfg.IamServiceURL) iamProxy, err := proxy.NewProxy(cfg.IamServiceURL)
if err != nil { if err != nil {
log.Fatalf("failed to create iam proxy: %v", err) log.Fatalf("failed to create iam proxy: %v", err)
} }
api.Any("/iam/*path", proxy.ProxyHandler(iamProxy)) iamHandler := proxy.ProxyHandler(iamProxy)
api.Any("/iam", iamHandler)
api.Any("/iam/*path", iamHandler)
// Teacher BFF 路由(教师聚合层) // Teacher BFF 路由(教师聚合层)
bffProxy, err := proxy.NewProxy(cfg.TeacherBffURL) bffProxy, err := proxy.NewProxy(cfg.TeacherBffURL)
if err != nil { if err != nil {
log.Fatalf("failed to create teacher-bff proxy: %v", err) log.Fatalf("failed to create teacher-bff proxy: %v", err)
} }
api.Any("/teacher/*path", proxy.ProxyHandler(bffProxy)) bffHandler := proxy.ProxyHandler(bffProxy)
api.Any("/teacher", bffHandler)
api.Any("/teacher/*path", bffHandler)
} }
srv := &http.Server{ srv := &http.Server{