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_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
|
||||||
|
|||||||
@@ -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
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`):
|
通过环境变量配置(见 `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 签发者 |
|
||||||
| `CLASSES_SERVICE_URL` | http://localhost:3001 | classes 服务地址 |
|
| `JWT_AUDIENCE` | next-edu-cloud | JWT 受众 |
|
||||||
| `RATE_LIMIT_RPS` | 10 | 每秒令牌数 |
|
| `DEV_MODE` | false | 开发模式旁路:true 时接受 `Bearer dev-token`(仅本地) |
|
||||||
| `RATE_LIMIT_BURST` | 20 | 突发容量 |
|
| `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
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
Reference in New Issue
Block a user