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:
@@ -12,6 +12,12 @@ JWT_SECRET=p1-dev-secret-change-in-production
|
||||
JWT_ISSUER=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
|
||||
CLASSES_SERVICE_PORT=3001
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
interface ClassItem {
|
||||
id: string;
|
||||
@@ -20,24 +20,28 @@ interface ApiResponse<T> {
|
||||
export default function HomePage() {
|
||||
const [classes, setClasses] = useState<ClassItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [gradeId, setGradeId] = useState('550e8400-e29b-41d4-a716-446655440000');
|
||||
const [description, setDescription] = useState('');
|
||||
const [name, setName] = useState("");
|
||||
const [gradeId, setGradeId] = useState(
|
||||
"550e8400-e29b-41d4-a716-446655440000",
|
||||
);
|
||||
const [description, setDescription] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchClasses = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
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();
|
||||
if (json.success && json.data) {
|
||||
setClasses(json.data);
|
||||
} else {
|
||||
setError(json.error?.message || 'Failed to load');
|
||||
setError(json.error?.message || "Failed to load");
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Network error');
|
||||
setError(e instanceof Error ? e.message : "Network error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -51,49 +55,61 @@ export default function HomePage() {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
try {
|
||||
const res = await fetch('/api/v1/classes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer dev-token' },
|
||||
const res = await fetch("/api/v1/classes", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer dev-token",
|
||||
},
|
||||
body: JSON.stringify({ name, gradeId, description }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!json.success) {
|
||||
setError(json.error?.message || 'Create failed');
|
||||
setError(json.error?.message || "Create failed");
|
||||
return;
|
||||
}
|
||||
setName('');
|
||||
setDescription('');
|
||||
setName("");
|
||||
setDescription("");
|
||||
await fetchClasses();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Network error');
|
||||
setError(e instanceof Error ? e.message : "Network error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/classes/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': 'Bearer dev-token' },
|
||||
method: "DELETE",
|
||||
headers: { Authorization: "Bearer dev-token" },
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!json.success) {
|
||||
setError(json.error?.message || 'Delete failed');
|
||||
setError(json.error?.message || "Delete failed");
|
||||
return;
|
||||
}
|
||||
await fetchClasses();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Network error');
|
||||
setError(e instanceof Error ? e.message : "Network error");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen" style={{ background: 'var(--bg-paper)' }}>
|
||||
<header className="border-b" style={{ borderColor: 'var(--color-rule)' }}>
|
||||
<div className="min-h-screen" style={{ background: "var(--bg-paper)" }}>
|
||||
<header className="border-b" style={{ borderColor: "var(--color-rule)" }}>
|
||||
<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>
|
||||
<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
|
||||
</p>
|
||||
</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">
|
||||
{/* 左侧:创建表单 */}
|
||||
<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" />
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
<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>
|
||||
<input
|
||||
@@ -114,13 +138,19 @@ export default function HomePage() {
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
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)班"
|
||||
required
|
||||
/>
|
||||
</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
|
||||
</label>
|
||||
<input
|
||||
@@ -128,25 +158,34 @@ export default function HomePage() {
|
||||
value={gradeId}
|
||||
onChange={(e) => setGradeId(e.target.value)}
|
||||
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>
|
||||
<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>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
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>
|
||||
@@ -156,16 +195,19 @@ export default function HomePage() {
|
||||
{/* 中间:班级列表(纸面)*/}
|
||||
<section className="col-span-8">
|
||||
<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} 个
|
||||
</span>
|
||||
</h2>
|
||||
<button
|
||||
onClick={fetchClasses}
|
||||
className="text-xs uppercase tracking-wide hover:opacity-70"
|
||||
style={{ color: 'var(--color-accent)' }}
|
||||
style={{ color: "var(--color-accent)" }}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
@@ -173,37 +215,65 @@ export default function HomePage() {
|
||||
<div className="rule-thin mb-6" />
|
||||
|
||||
{error && (
|
||||
<div className="mark-left mb-4 py-2" style={{ borderColor: 'var(--color-accent)' }}>
|
||||
<p className="text-sm" style={{ color: 'var(--color-accent)' }}>{error}</p>
|
||||
<div
|
||||
className="mark-left mb-4 py-2"
|
||||
style={{ borderColor: "var(--color-accent)" }}
|
||||
>
|
||||
<p className="text-sm" style={{ color: "var(--color-accent)" }}>
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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 ? (
|
||||
<p className="text-sm italic" style={{ color: 'var(--color-ink-muted)' }}>
|
||||
<p
|
||||
className="text-sm italic"
|
||||
style={{ color: "var(--color-ink-muted)" }}
|
||||
>
|
||||
暂无班级,从左侧创建第一个
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-0">
|
||||
{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">
|
||||
<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}
|
||||
</h3>
|
||||
{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 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)}...
|
||||
</div>
|
||||
<div className="col-span-2 text-right">
|
||||
<button
|
||||
onClick={() => handleDelete(cls.id)}
|
||||
className="text-xs uppercase tracking-wide hover:opacity-70"
|
||||
style={{ color: 'var(--color-ink-muted)' }}
|
||||
style={{ color: "var(--color-ink-muted)" }}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
|
||||
4
go.work.sum
Normal file
4
go.work.sum
Normal 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=
|
||||
@@ -71,14 +71,20 @@ docker build -t edu/api-gateway .
|
||||
|
||||
通过环境变量配置(见 `internal/config/config.go`):
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
| --------------------- | --------------------- | -------------------- |
|
||||
| `PORT` | 8080 | 监听端口 |
|
||||
| `JWT_SECRET` | (必填) | HS256 签名密钥(P1) |
|
||||
| `JWT_PUBLIC_KEY` | (P2) | RS256 公钥 |
|
||||
| `CLASSES_SERVICE_URL` | http://localhost:3001 | classes 服务地址 |
|
||||
| `RATE_LIMIT_RPS` | 10 | 每秒令牌数 |
|
||||
| `RATE_LIMIT_BURST` | 20 | 突发容量 |
|
||||
| 变量 | 默认值 | 说明 |
|
||||
| ----------------------------- | --------------------- | ------------------------------------------------------ |
|
||||
| `API_GATEWAY_PORT` | 8080 | 监听端口 |
|
||||
| `JWT_SECRET` | (必填) | HS256 签名密钥(P1) |
|
||||
| `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 服务地址 |
|
||||
| `IAM_SERVICE_URL` | http://localhost:3002 | iam 服务地址 |
|
||||
| `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 身份。
|
||||
|
||||
## 关联文档
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ type Config struct {
|
||||
TeacherBffURL string
|
||||
OTLPEndpoint string
|
||||
LogLevel string
|
||||
DevMode bool
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
@@ -28,6 +29,7 @@ func Load() *Config {
|
||||
TeacherBffURL: getEnv("TEACHER_BFF_URL", "http://localhost:3003"),
|
||||
OTLPEndpoint: getEnv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318"),
|
||||
LogLevel: getEnv("LOG_LEVEL", "info"),
|
||||
DevMode: getEnvBool("DEV_MODE", false),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,3 +48,12 @@ func getEnvInt(key string, fallback int) int {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package middleware
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
@@ -44,6 +44,15 @@ func AuthMiddleware(cfg *config.Config) gin.HandlerFunc {
|
||||
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) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, jwt.ErrSignatureInvalid
|
||||
|
||||
@@ -23,6 +23,8 @@ func main() {
|
||||
cfg := config.Load()
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.New()
|
||||
// 关闭尾斜杠重定向:避免 Next.js rewrites 代理时 /api/v1/classes → 301 → /api/v1/classes/ 循环
|
||||
r.RedirectTrailingSlash = false
|
||||
|
||||
// 全局中间件(按顺序注册)
|
||||
// 1. panic 恢复(最外层,捕获后续所有中间件与 handler 的 panic)
|
||||
@@ -50,25 +52,33 @@ func main() {
|
||||
api.Use(middleware.AuthMiddleware(cfg))
|
||||
{
|
||||
// classes 服务路由
|
||||
// 注:同时注册无尾斜杠与通配符两条路由。RedirectTrailingSlash=false 时,
|
||||
// Gin 不会自动把 /classes 跳到 /classes/,所以两条都要显式注册。
|
||||
classesProxy, err := proxy.NewProxy(cfg.ClassesServiceURL)
|
||||
if err != nil {
|
||||
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 服务路由(身份与访问管理)
|
||||
iamProxy, err := proxy.NewProxy(cfg.IamServiceURL)
|
||||
if err != nil {
|
||||
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 路由(教师聚合层)
|
||||
bffProxy, err := proxy.NewProxy(cfg.TeacherBffURL)
|
||||
if err != nil {
|
||||
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{
|
||||
|
||||
Reference in New Issue
Block a user