From 9657a07bb54549e4b5637de30be5cbe1e8635c44 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Wed, 17 Jun 2026 16:32:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E9=89=B4=E6=9D=83=E7=89=872=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E5=89=8D=E7=AB=AF=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E9=97=AD=E7=8E=AF=20+=20=E4=BF=9D=E6=8A=A4=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=20+=20=E5=8E=BB=E6=8E=89=20header=20=E5=85=9C=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把 JWT 鉴权从后端核心闭环到端到端: 后端: - middleware.RequireAuth:上下文无已验证 uid 则 401;挂在 owner 作用域业务路由组。 - 路由拆 公开/受保护:公开=auth/health + 按 task_id 寻址的 SSE 与报告导出 (EventSource/下载无法带 Bearer);受保护=tasks/memory/kb*/agents/reports/billing。 - userID(c) 去掉 X-User-ID 兜底,仅信任 JWT 注入的 uid。 - 修 CORS:Allow-Headers 增 Authorization(否则浏览器拦截带 Bearer 的请求)。 前端: - lib/api:token 存 localStorage + Bearer 头(不再发 X-User-ID)+ authRegister/Login/Me + 401 清令牌并广播 sdx:logout;submitTask/report/memory/列表加载走 Bearer 与 401 守卫。 - views/Login:登录/注册全屏门。 - App:启动校验令牌 → 无则渲染 Login,有则进主应用;identity.userId=已验证 user.id; 监听 sdx:logout 回登录页。 - TopBar:去掉可编辑身份输入,改显登录用户 + 登出。 实跑验证(docker+gateway+preview): - RequireAuth:无 token /kb/list、/agents → 401;/health → 200;带 token → 200。 - 前端:无 token 显登录门;注入有效 token 重载 → 进主应用、顶栏显 Dexter、KB 加载本人库、 隔离徽标显雪花 uid。控制台无错、生产构建通过。 - 过程中发现并修复 CORS 缺 Authorization 头的真实 bug。 Co-Authored-By: Claude Opus 4.8 --- PROGRESS.md | 4 +- sundynix-desktop/frontend/src/App.tsx | 47 +++++- sundynix-desktop/frontend/src/lib/api.ts | 142 ++++++++++++++---- .../frontend/src/shell/TopBar.tsx | 34 ++--- sundynix-desktop/frontend/src/views/Login.tsx | 72 +++++++++ .../internal/handler/task_handler.go | 7 +- sundynix-gateway/internal/middleware/auth.go | 15 ++ sundynix-gateway/internal/router/router.go | 58 +++---- 8 files changed, 292 insertions(+), 87 deletions(-) create mode 100644 sundynix-desktop/frontend/src/views/Login.tsx diff --git a/PROGRESS.md b/PROGRESS.md index a2e6e46..d75abb2 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -81,14 +81,14 @@ - [x] DB 规约全库统一:雪花字符串 id + created/updated + 软删(gateway 各表 + mcp-go Profile) - [x] 文件主表,文档间关联用雪花 ID(弃用按名关联) - [x] 后端首批单测(19 纯逻辑用例:引擎/DSL/docx/报告)+ mcp-go 集成测试(Profile 迁移) -- [ ] 🟡 真实鉴权(JWT):后端核心已完成 ✅(注册/登录签发 JWT + 校验中间件 + owner 取已验证 uid + 单测/实跑);**前端登录页 + 去掉 header 兜底 + 保护路由**待做(片 2) +- [x] **真实鉴权(JWT)闭环**:后端注册/登录/校验 + RequireAuth 保护路由 + owner=已验证 uid(去掉 header 兜底);前端登录/注册门 + 存 token + Bearer + 401 自动登出 + 顶栏用户/登出。实跑验证(含 CORS Authorization 修复) - [ ] 集成/前端测试(`runGraph` / `handleReport` 需 mock pool/tools/sink;前端无测试) --- ## 未实现的大块(路线图) -- [ ] 🟡 **真实登录 / 鉴权**(JWT 后端核心 ✅;前端登录 + 强制鉴权 = 片 2 待做) +- [x] **真实登录 / 鉴权(JWT)** —— 后端 + 前端闭环已完成 ✅ - [ ] **代码解释器 + 安全沙箱**(mcp-py 核心能力,目前全桩) - [ ] **Harness 余下**:输出护栏(dispatcher token 发射层)(熔断降级 ✅、输入护栏 ✅、LLM 自动化评测 ✅ 已完成) - [ ] **长期记忆抽取** + external_api 工具 diff --git a/sundynix-desktop/frontend/src/App.tsx b/sundynix-desktop/frontend/src/App.tsx index c5eaf4e..4009511 100644 --- a/sundynix-desktop/frontend/src/App.tsx +++ b/sundynix-desktop/frontend/src/App.tsx @@ -12,11 +12,22 @@ import { RunsView } from "./views/RunsView"; import { Home } from "./views/Home"; import { Placeholder } from "./views/Placeholder"; import { CommandPalette, type Command } from "./components/CommandPalette"; -import { submitTask, streamTokens, streamExec, type Identity } from "./lib/api"; +import { Login } from "./views/Login"; +import { submitTask, streamTokens, streamExec, authMe, logout, type Identity, type AuthUser } from "./lib/api"; import type { TaskDsl } from "./lib/dsl"; import { emptyRun, type RunState } from "./lib/run"; import { ToastProvider } from "./ui"; +// 会话标识:本地持久化生成一次(多轮历史用,与鉴权身份分离)。 +function getSessionId(): string { + let s = localStorage.getItem("sdx_sess"); + if (!s) { + s = "sess-" + Math.random().toString(36).slice(2, 10); + localStorage.setItem("sdx_sess", s); + } + return s; +} + const PLACEHOLDERS: Partial> = { home: { title: "工作台", desc: "概览:知识库 / 文档 / 近期运行 / 待办报告 / 配额计费 + 快捷入口。" }, kb: { title: "知识库 (RAG)", desc: "入库流水线监控 · 检索调试台(带来源徽标) · 文档/块浏览 · 知识图谱 · 检索评测。依赖 embedding + 入库 worker + 真实混合检索。" }, @@ -28,7 +39,9 @@ const PLACEHOLDERS: Partial> = export default function App() { const [view, setView] = useState("home"); - const [identity, setIdentity] = useState({ userId: "wt", sessionId: "sess-ui" }); + const [user, setUser] = useState(null); + const [authLoading, setAuthLoading] = useState(true); + const identity = useMemo(() => ({ userId: user?.id ?? "", sessionId: getSessionId() }), [user]); const [run, setRun] = useState(emptyRun); const [cmdOpen, setCmdOpen] = useState(false); const closeRef = useRef<(() => void) | null>(null); @@ -47,6 +60,22 @@ export default function App() { return () => window.removeEventListener("keydown", onKey); }, []); + // 启动校验现有令牌;并监听 401 登出事件(令牌失效 → 回登录页)。 + useEffect(() => { + authMe() + .then(setUser) + .catch(() => setUser(null)) + .finally(() => setAuthLoading(false)); + const onLogout = () => setUser(null); + window.addEventListener("sdx:logout", onLogout); + return () => window.removeEventListener("sdx:logout", onLogout); + }, []); + + const onLogout = useCallback(() => { + logout(); + setUser(null); + }, []); + const commands = useMemo(() => { const go = (key: ViewKey) => () => setView(key); return [ @@ -109,6 +138,18 @@ export default function App() { [identity], ); + // 鉴权门:校验中显示占位;未登录显示登录页;登录后进入主应用。 + if (authLoading) { + return
加载中…
; + } + if (!user) { + return ( + + + + ); + } + return (
@@ -117,7 +158,7 @@ export default function App() { className="pointer-events-none absolute inset-x-0 top-0 h-64 opacity-60" style={{ background: "radial-gradient(60% 100% at 50% 0%, rgba(124,92,246,0.10), transparent 70%)" }} /> - setCmdOpen(true)} /> + setCmdOpen(true)} />
diff --git a/sundynix-desktop/frontend/src/lib/api.ts b/sundynix-desktop/frontend/src/lib/api.ts index d739c03..8dfd8c6 100644 --- a/sundynix-desktop/frontend/src/lib/api.ts +++ b/sundynix-desktop/frontend/src/lib/api.ts @@ -10,17 +10,100 @@ export interface Identity { sessionId: string; } +export interface AuthUser { + id: string; + email: string; + name?: string; +} + +// ---- JWT 令牌存储(localStorage)---- +const TOKEN_KEY = "sdx_token"; +let authToken: string = typeof localStorage !== "undefined" ? localStorage.getItem(TOKEN_KEY) ?? "" : ""; + +export function setToken(t: string): void { + authToken = t; + try { + localStorage.setItem(TOKEN_KEY, t); + } catch { + /* 隐私模式忽略 */ + } +} +export function clearToken(): void { + authToken = ""; + try { + localStorage.removeItem(TOKEN_KEY); + } catch { + /* ignore */ + } +} +export function getToken(): string { + return authToken; +} + +// bearer 把 JWT 放进请求头(无令牌则不带)。 +function bearer(): Record { + return authToken ? { Authorization: `Bearer ${authToken}` } : {}; +} + +// guard401 在收到 401 时清理令牌并广播登出事件(App 监听后回到登录页)。 +function guard401(res: Response): Response { + if (res.status === 401) { + clearToken(); + if (typeof window !== "undefined") window.dispatchEvent(new Event("sdx:logout")); + } + return res; +} + +// ---- 鉴权 API ---- +export async function authRegister(email: string, password: string, name: string): Promise { + const res = await fetch(`${GATEWAY}/api/v1/auth/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password, name }), + }); + const data = (await res.json()) as { token?: string; user?: AuthUser; error?: string }; + if (!res.ok || !data.token || !data.user) throw new Error(data.error ?? `注册失败: ${res.status}`); + setToken(data.token); + return data.user; +} + +export async function authLogin(email: string, password: string): Promise { + const res = await fetch(`${GATEWAY}/api/v1/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + const data = (await res.json()) as { token?: string; user?: AuthUser; error?: string }; + if (!res.ok || !data.token || !data.user) throw new Error(data.error ?? `登录失败: ${res.status}`); + setToken(data.token); + return data.user; +} + +// authMe 用当前令牌取登录用户;无效/过期返回 null(用于应用启动校验)。 +export async function authMe(): Promise { + if (!authToken) return null; + const res = await fetch(`${GATEWAY}/api/v1/auth/me`, { headers: bearer() }); + if (!res.ok) { + clearToken(); + return null; + } + const data = (await res.json()) as { user?: AuthUser }; + return data.user ?? null; +} + +export function logout(): void { + clearToken(); +} + // submitTask: POST /api/v1/tasks,返回 task_id。 export async function submitTask(dsl: TaskDsl, id: Identity): Promise { - const res = await fetch(`${GATEWAY}/api/v1/tasks`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-User-ID": id.userId, - "X-Session-ID": id.sessionId, - }, - body: JSON.stringify(dsl), - }); + const res = guard401( + await fetch(`${GATEWAY}/api/v1/tasks`, { + method: "POST", + headers: { "Content-Type": "application/json", ...idHeaders(id) }, + body: JSON.stringify(dsl), + }), + ); if (!res.ok) throw new Error(`submit failed: ${res.status} ${await res.text()}`); const data = (await res.json()) as { task_id: string }; return data.task_id; @@ -91,9 +174,10 @@ export interface IngestEvent { error?: string; } -// idHeaders 把身份带进请求头 —— 网关据此把知识库锁进 owner 作用域(隔离)。 +// idHeaders 把身份带进请求头:JWT(Bearer) 作鉴权与 owner 作用域;X-Session-ID 作多轮会话标识。 +// 不再发 X-User-ID —— owner 由网关从已验证 JWT 取(伪造头无效)。 function idHeaders(id: Identity): Record { - return { "X-User-ID": id.userId, "X-Session-ID": id.sessionId }; + return { ...bearer(), "X-Session-ID": id.sessionId }; } export interface KbInfo { @@ -103,7 +187,7 @@ export interface KbInfo { // listKb: GET /api/v1/kb/list —— 当前用户的知识库列表(owner 隔离)。 export async function listKb(id: Identity): Promise { - const res = await fetch(`${GATEWAY}/api/v1/kb/list`, { headers: idHeaders(id) }); + const res = guard401(await fetch(`${GATEWAY}/api/v1/kb/list`, { headers: idHeaders(id) })); const data = (await res.json()) as { kbs?: KbInfo[]; error?: string }; if (!res.ok) throw new Error(data.error ?? `list failed: ${res.status}`); return data.kbs ?? []; @@ -141,7 +225,7 @@ export interface AgentInfo { } export async function listAgents(id: Identity): Promise { - const res = await fetch(`${GATEWAY}/api/v1/agents`, { headers: idHeaders(id) }); + const res = guard401(await fetch(`${GATEWAY}/api/v1/agents`, { headers: idHeaders(id) })); const data = (await res.json()) as { agents?: AgentInfo[]; error?: string }; if (!res.ok) throw new Error(data.error ?? `list agents failed: ${res.status}`); return data.agents ?? []; @@ -288,15 +372,13 @@ export async function searchKb(id: Identity, kb: string, q: string, topK = 5): P // generateReport: POST /api/v1/reports —— 触发报告生成,返回 task_id。 // 用 streamTokens(task_id) 看实时进度,完成后用 reportDownloadUrl(task_id) 下载 Word。 export async function generateReport(id: Identity, topic: string, kb?: string): Promise { - const res = await fetch(`${GATEWAY}/api/v1/reports`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-User-ID": id.userId, - "X-Session-ID": id.sessionId, - }, - body: JSON.stringify({ topic, kb: kb ?? "" }), - }); + const res = guard401( + await fetch(`${GATEWAY}/api/v1/reports`, { + method: "POST", + headers: { "Content-Type": "application/json", ...idHeaders(id) }, + body: JSON.stringify({ topic, kb: kb ?? "" }), + }), + ); const data = (await res.json()) as { task_id?: string; error?: string }; if (!res.ok || !data.task_id) throw new Error(data.error ?? `report failed: ${res.status}`); return data.task_id; @@ -318,15 +400,13 @@ export async function setMemory( key: string, value: string, ): Promise { - const res = await fetch(`${GATEWAY}/api/v1/memory`, { - method: "PUT", - headers: { - "Content-Type": "application/json", - "X-User-ID": id.userId, - "X-Session-ID": id.sessionId, - }, - body: JSON.stringify({ key, value }), - }); + const res = guard401( + await fetch(`${GATEWAY}/api/v1/memory`, { + method: "PUT", + headers: { "Content-Type": "application/json", ...idHeaders(id) }, + body: JSON.stringify({ key, value }), + }), + ); const data = (await res.json()) as { message?: string; error?: string }; if (!res.ok) throw new Error(data.error ?? `memory failed: ${res.status}`); return data.message ?? "ok"; diff --git a/sundynix-desktop/frontend/src/shell/TopBar.tsx b/sundynix-desktop/frontend/src/shell/TopBar.tsx index 5d1cc18..f2d68da 100644 --- a/sundynix-desktop/frontend/src/shell/TopBar.tsx +++ b/sundynix-desktop/frontend/src/shell/TopBar.tsx @@ -1,6 +1,6 @@ import type { CSSProperties } from "react"; -import { User, ChevronDown, Search as SearchIcon } from "lucide-react"; -import type { Identity } from "../lib/api"; +import { User, ChevronDown, Search as SearchIcon, LogOut } from "lucide-react"; +import type { AuthUser } from "../lib/api"; import { useHealth } from "../lib/health"; import { isMacDesktop } from "../lib/desktop"; import { cn } from "../ui"; @@ -19,8 +19,8 @@ function Light({ on, label }: { on: boolean; label: string }) { ); } -// 顶栏:品牌 · 垂直切换 · 健康灯 · 身份/会话(深色 + 毛玻璃)。 -export function TopBar({ identity, setIdentity, onCommand }: { identity: Identity; setIdentity: (id: Identity) => void; onCommand?: () => void }) { +// 顶栏:品牌 · 垂直切换 · 健康灯 · 登录用户 + 登出(深色 + 毛玻璃)。 +export function TopBar({ user, onLogout, onCommand }: { user: AuthUser; onLogout: () => void; onCommand?: () => void }) { const h = useHealth(); return (
-
- - setIdentity({ ...identity, userId: e.target.value })} - title="用户" - /> -
- setIdentity({ ...identity, sessionId: e.target.value })} - title="会话" - /> + + + {user.name || user.email} + +
); diff --git a/sundynix-desktop/frontend/src/views/Login.tsx b/sundynix-desktop/frontend/src/views/Login.tsx new file mode 100644 index 0000000..c360382 --- /dev/null +++ b/sundynix-desktop/frontend/src/views/Login.tsx @@ -0,0 +1,72 @@ +import { useState } from "react"; +import { LogIn, UserPlus, Loader2 } from "lucide-react"; +import { authLogin, authRegister, type AuthUser } from "../lib/api"; +import { Button, Input, Field } from "../ui"; + +// Login:未登录时的全屏鉴权门。登录/注册成功后回调 onAuthed 把用户交给 App。 +export function Login({ onAuthed }: { onAuthed: (u: AuthUser) => void }) { + const [mode, setMode] = useState<"login" | "register">("login"); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [name, setName] = useState(""); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(""); + + const submit = async () => { + if (busy) return; + setErr(""); + setBusy(true); + try { + const u = mode === "login" ? await authLogin(email.trim(), password) : await authRegister(email.trim(), password, name.trim()); + onAuthed(u); + } catch (e) { + setErr((e as Error).message); + } finally { + setBusy(false); + } + }; + + const isRegister = mode === "register"; + return ( +
+
+
+
+
S
+ sundynix-agentix +
+

{isRegister ? "创建账户以开始" : "登录以继续"}

+ +
+ {isRegister && ( + + setName(e.target.value)} placeholder="如 Dexter" autoFocus={isRegister} /> + + )} + + setEmail(e.target.value)} placeholder="you@example.com" autoFocus={!isRegister} onKeyDown={(e) => e.key === "Enter" && submit()} /> + + + setPassword(e.target.value)} placeholder={isRegister ? "至少 6 位" : "••••••••"} onKeyDown={(e) => e.key === "Enter" && submit()} /> + +
+ + {err &&
{err}
} + + + +
+ {isRegister ? "已有账户?" : "还没有账户?"} + +
+
+
+ ); +} diff --git a/sundynix-gateway/internal/handler/task_handler.go b/sundynix-gateway/internal/handler/task_handler.go index 19d25e8..61941b4 100644 --- a/sundynix-gateway/internal/handler/task_handler.go +++ b/sundynix-gateway/internal/handler/task_handler.go @@ -184,17 +184,14 @@ func (h *Handler) SetMemory(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok", "message": res.Content}) } -// userID 取当前用户标识:优先 JWT 鉴权中间件注入的已验证 uid; -// 兜底 X-User-ID 头(开发期 / 前端尚未接登录),都没有则匿名。 +// userID 取当前用户标识 —— 仅信任 JWT 鉴权中间件注入的已验证 uid(不再认 header)。 +// 受保护路由有 RequireAuth 兜底,此处理论上不会返回 anonymous。 func userID(c *gin.Context) string { if v, ok := c.Get("uid"); ok { if s, _ := v.(string); s != "" { return s } } - if u := c.GetHeader("X-User-ID"); u != "" { - return u - } return "anonymous" } diff --git a/sundynix-gateway/internal/middleware/auth.go b/sundynix-gateway/internal/middleware/auth.go index 02bdc6d..077f78c 100644 --- a/sundynix-gateway/internal/middleware/auth.go +++ b/sundynix-gateway/internal/middleware/auth.go @@ -1,6 +1,7 @@ package middleware import ( + "net/http" "strings" "github.com/gin-gonic/gin" @@ -25,3 +26,17 @@ func Auth() gin.HandlerFunc { c.Next() } } + +// RequireAuth 在 Auth 之后使用:上下文无已验证 userID 则 401 拒绝。 +// 用于 owner 作用域的业务路由;SSE/导出等按 task_id 寻址的端点不挂(EventSource 无法带头)。 +func RequireAuth() gin.HandlerFunc { + return func(c *gin.Context) { + if v, ok := c.Get(CtxUserID); ok { + if s, _ := v.(string); s != "" { + c.Next() + return + } + } + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "需要登录"}) + } +} diff --git a/sundynix-gateway/internal/router/router.go b/sundynix-gateway/internal/router/router.go index 926e284..578793b 100644 --- a/sundynix-gateway/internal/router/router.go +++ b/sundynix-gateway/internal/router/router.go @@ -22,36 +22,40 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob. h := handler.New(db, cache, bus, blobStore) api := r.Group("/api/v1") { + // —— 公开:鉴权端点 / 健康 / 按 task_id 寻址的 SSE 与导出(EventSource/下载无法带 Bearer)—— api.POST("/auth/register", h.Register) // 注册 + 签发 JWT api.POST("/auth/login", h.Login) // 登录 + 签发 JWT - api.GET("/auth/me", h.Me) // 当前登录用户 + api.GET("/auth/me", h.Me) // 当前登录用户(无效令牌 → 401) + api.GET("/health", h.Health) // 依赖健康聚合(顶栏五盏灯) + api.GET("/tasks/:id/stream", h.StreamTask) // SSE 回流 Token Stream(task_id 寻址) + api.GET("/tasks/:id/exec", h.StreamExec) // SSE 回流执行轨迹(task_id 寻址) + api.GET("/kb/ingest/:id/stream", h.KbIngestStream) // 入库进度 SSE(job_id 寻址) + api.GET("/reports/:id/export", h.ExportReport) // 按需导出(report_id 寻址) + api.GET("/reports/:id/download", h.ExportReport) // 兼容旧入口(默认 docx) - api.POST("/tasks", h.SubmitTask) // 1. 解析 DSL 并 Publish 到 NATS - api.GET("/tasks/:id/stream", h.StreamTask) // 4. SSE/WS 回流 Token Stream - api.GET("/tasks/:id/exec", h.StreamExec) // 4b. SSE 回流执行轨迹事件(运行·观测) - api.PUT("/memory", h.SetMemory) // 偏好记忆登记(→ mcp-go memory_upsert) - api.GET("/kb/list", h.KbList) // 当前用户的知识库列表(owner 隔离) - api.POST("/kb/create", h.KbCreate) // 新建知识库(项目/案件/文件夹/通用) - api.POST("/kb/ingest", h.KbIngest) // 知识库入库(文本,→ mcp-go kb_ingest) - api.POST("/kb/ingest_file", h.KbIngestFile) // 文件入库(docx/xlsx/pdf… 异步) - api.GET("/kb/ingest/:id/stream", h.KbIngestStream) // 入库进度 SSE(实时监控) - api.POST("/kb/search", h.KbSearch) // 知识库检索台(→ mcp-go kb_search) - api.GET("/kb/vault", h.KbVault) // 文库:文档列表(仅元数据+预览) - api.GET("/kb/doc", h.KbDoc) // 取单篇文档全文(按需加载) - api.GET("/kb/links", h.KbLinks) // 某库全部 [[双链]](反链/笔记关系图) - api.POST("/kb/note", h.KbSaveNote) // 新建/编辑笔记(落库 + 按 doc 重入库) - api.GET("/kb/graph", h.KbGraph) // 知识图谱三元组(→ mcp-go kb_graph,Neo4j) + // —— 受保护:owner 作用域业务,必须携带有效 JWT —— + p := api.Group("", middleware.RequireAuth()) + { + p.POST("/tasks", h.SubmitTask) // 解析 DSL 并 Publish 到 NATS(带已验证 uid) + p.PUT("/memory", h.SetMemory) // 偏好记忆登记(→ mcp-go memory_upsert) + p.GET("/kb/list", h.KbList) // 当前用户的知识库列表(owner 隔离) + p.POST("/kb/create", h.KbCreate) // 新建知识库 + p.POST("/kb/ingest", h.KbIngest) // 文本入库 + p.POST("/kb/ingest_file", h.KbIngestFile) // 文件入库 + p.POST("/kb/search", h.KbSearch) // 检索台 + p.GET("/kb/vault", h.KbVault) // 文库列表 + p.GET("/kb/doc", h.KbDoc) // 取单篇文档 + p.GET("/kb/links", h.KbLinks) // 某库双链 + p.POST("/kb/note", h.KbSaveNote) // 新建/编辑笔记 + p.GET("/kb/graph", h.KbGraph) // 知识图谱三元组 + p.GET("/agents", h.AgentList) // 我的编排列表(owner 隔离) + p.POST("/agents", h.AgentSave) // 保存/更新编排 + p.DELETE("/agents", h.AgentDelete) // 删除编排 + p.POST("/reports", h.GenerateReport) // 报告生成 + p.GET("/billing", h.Billing) + } - api.GET("/agents", h.AgentList) // 我的 Agent 编排列表(owner 隔离) - api.POST("/agents", h.AgentSave) // 保存/更新编排 - api.DELETE("/agents", h.AgentDelete) // 删除编排 - api.POST("/reports", h.GenerateReport) // 报告生成(intent=report 任务 → Dispatcher 专用编排) - api.GET("/reports/:id/export", h.ExportReport) // 按需导出(format=docx|md;默认 docx) - api.GET("/reports/:id/download", h.ExportReport) // 兼容旧入口(默认 docx) - api.GET("/health", h.Health) // 依赖健康聚合(顶栏五盏灯) - api.GET("/billing", h.Billing) - - // 运维控制面:LLM 模型配置(独立运维控制台调用)。 + // 运维控制面:LLM 模型配置(独立运维控制台调用;鉴权待后续接管理员角色)。 admin := api.Group("/admin") { admin.GET("/models", h.ListModels) @@ -69,7 +73,7 @@ func cors() gin.HandlerFunc { return func(c *gin.Context) { c.Header("Access-Control-Allow-Origin", "*") c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") - c.Header("Access-Control-Allow-Headers", "Content-Type, X-User-ID, X-Session-ID") + c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Session-ID, X-User-ID") if c.Request.Method == "OPTIONS" { c.AbortWithStatus(204) return