feat(admin): 管理端接登录 + Bearer 鉴权 + 修雪花ID/探活(适配硬化后的网关)

/admin 加 RequireAdmin 后管理端原来无鉴权会 401。本次打通:
- api.ts:JWT token 存 localStorage + 所有 /admin 调用带 Bearer + 401 清令牌广播登出;
  login/me;gatewayOnline 改用公开 /healthz(原 /billing 已转受保护会误判离线)。
- 修类型:Model.id number → string(模型 id 早已迁雪花字符串)。
- Login 登录门 + App 鉴权门(启动校验 me,无则登录页)+ AppShell 显示用户/登出。

实测(硬化网关):无 token /admin/models → 401;登录拿 token → 200 返回模型(string id)。
dev 未配 ADMIN_USER_IDS 时任意登录账号放行;生产须在白名单。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-19 11:09:02 +08:00
parent 8f619c2a62
commit 030dcda9b4
4 changed files with 185 additions and 28 deletions
+83 -22
View File
@@ -1,12 +1,80 @@
// 运维控制台 → Gateway 控制面 API。
// 运维控制台 → Gateway 控制面 API(带 JWT 鉴权;/admin 受 RequireAdmin 保护)
export const GATEWAY: string =
(import.meta.env.VITE_GATEWAY as string | undefined) ?? "http://localhost:8080";
const ADMIN = `${GATEWAY}/api/v1/admin`;
// ---- 鉴权(JWT,存 localStorage----
const TOKEN_KEY = "sdx_admin_token";
let token = typeof localStorage !== "undefined" ? localStorage.getItem(TOKEN_KEY) ?? "" : "";
export function setToken(t: string): void {
token = t;
try {
localStorage.setItem(TOKEN_KEY, t);
} catch {
/* ignore */
}
}
export function clearToken(): void {
token = "";
try {
localStorage.removeItem(TOKEN_KEY);
} catch {
/* ignore */
}
}
export function getToken(): string {
return token;
}
function authHeaders(json = false): Record<string, string> {
const h: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {};
if (json) h["Content-Type"] = "application/json";
return h;
}
// guard 在 401(未登录) 时清令牌并广播登出(403=已登录但非管理员,照常抛错)。
function guard(res: Response): Response {
if (res.status === 401) {
clearToken();
if (typeof window !== "undefined") window.dispatchEvent(new Event("sdx:logout"));
}
return res;
}
export interface AuthUser {
id: string;
email: string;
name?: string;
}
export async function login(email: string, password: string): Promise<AuthUser> {
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;
}
export async function me(): Promise<AuthUser | null> {
if (!token) return null;
const res = await fetch(`${GATEWAY}/api/v1/auth/me`, { headers: authHeaders() });
if (!res.ok) {
clearToken();
return null;
}
return ((await res.json()) as { user?: AuthUser }).user ?? null;
}
// ---- 模型配置(id 为雪花字符串)----
export type Kind = "chat" | "embedding";
export interface Model {
id: number;
id: string;
kind: Kind;
provider: string;
base_url: string;
@@ -16,7 +84,7 @@ export interface Model {
}
export interface ModelInput {
id?: number;
id?: string;
kind: Kind;
provider: string;
base_url: string;
@@ -25,44 +93,37 @@ export interface ModelInput {
}
export async function listModels(kind: Kind): Promise<Model[]> {
const res = await fetch(`${ADMIN}/models?kind=${kind}`);
const res = guard(await fetch(`${ADMIN}/models?kind=${kind}`, { headers: authHeaders() }));
if (!res.ok) throw new Error(`list failed: ${res.status}`);
return ((await res.json()) as { models: Model[] }).models;
}
export async function saveModel(m: ModelInput): Promise<number> {
const res = await fetch(`${ADMIN}/models`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(m),
});
const data = (await res.json()) as { id?: number; error?: string };
export async function saveModel(m: ModelInput): Promise<string> {
const res = guard(await fetch(`${ADMIN}/models`, { method: "POST", headers: authHeaders(true), body: JSON.stringify(m) }));
const data = (await res.json()) as { id?: string; error?: string };
if (!res.ok) throw new Error(data.error ?? `save failed: ${res.status}`);
return data.id ?? 0;
return data.id ?? "";
}
export async function setActive(id: number): Promise<void> {
const res = await fetch(`${ADMIN}/models/${id}/active`, { method: "POST" });
export async function setActive(id: string): Promise<void> {
const res = guard(await fetch(`${ADMIN}/models/${id}/active`, { method: "POST", headers: authHeaders() }));
if (!res.ok) throw new Error(`activate failed: ${res.status}`);
}
export async function deleteModel(id: number): Promise<void> {
const res = await fetch(`${ADMIN}/models/${id}`, { method: "DELETE" });
export async function deleteModel(id: string): Promise<void> {
const res = guard(await fetch(`${ADMIN}/models/${id}`, { method: "DELETE", headers: authHeaders() }));
if (!res.ok) throw new Error(`delete failed: ${res.status}`);
}
export async function testModel(m: ModelInput): Promise<{ ok: boolean; message: string }> {
const res = await fetch(`${ADMIN}/models/test`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(m),
});
const res = guard(await fetch(`${ADMIN}/models/test`, { method: "POST", headers: authHeaders(true), body: JSON.stringify(m) }));
return (await res.json()) as { ok: boolean; message: string };
}
// gatewayOnline 用公开的 /healthz 探活(不受鉴权影响)。
export async function gatewayOnline(): Promise<boolean> {
try {
const res = await fetch(`${GATEWAY}/api/v1/billing`);
const res = await fetch(`${GATEWAY}/healthz`);
return res.ok;
} catch {
return false;