/** * 接口客户端。 * * 三条约定: * 1. 后端永远返回 {code,message,data,request_id},code !== 0 一律抛出。 * 2. 写请求自动带 Idempotency-Key,网络抖动重试不会重复建数据。 * 3. 401 统一清理本地令牌并回到登录页,不在业务里各写一遍。 */ const BASE = "/api/v1"; export class ApiError extends Error { constructor( readonly code: number, message: string, readonly requestId?: string, ) { super(message); this.name = "ApiError"; } } export interface Tokens { access_token: string; refresh_token: string; expires_in: number; } const ACCESS_KEY = "dadao.access"; const REFRESH_KEY = "dadao.refresh"; export const session = { get access(): string | null { return localStorage.getItem(ACCESS_KEY); }, get refresh(): string | null { return localStorage.getItem(REFRESH_KEY); }, save(tokens: Tokens) { localStorage.setItem(ACCESS_KEY, tokens.access_token); localStorage.setItem(REFRESH_KEY, tokens.refresh_token); }, clear() { localStorage.removeItem(ACCESS_KEY); localStorage.removeItem(REFRESH_KEY); }, }; export const authExpired = new EventTarget(); /** * 幂等键。 * * 别直接用 `crypto.randomUUID()`:它只在**安全上下文**(https 或 localhost)里存在, * 而内网生产入口是纯 http,那里 `crypto.randomUUID` 是 undefined,调它会把 * 所有写请求变成 "登录失败" 这种莫名其妙的报错——GET 没事,所以特别难发现。 * `getRandomValues` 不受这个限制,退回它自己拼一个 v4。 */ function newIdempotencyKey(): string { const c = globalThis.crypto; if (typeof c?.randomUUID === "function") return c.randomUUID(); const bytes = new Uint8Array(16); if (typeof c?.getRandomValues === "function") { c.getRandomValues(bytes); } else { for (let i = 0; i < bytes.length; i += 1) bytes[i] = Math.floor(Math.random() * 256); } bytes[6] = (bytes[6] & 0x0f) | 0x40; bytes[8] = (bytes[8] & 0x3f) | 0x80; const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; } type Method = "GET" | "POST" | "PATCH" | "PUT" | "DELETE"; interface RequestOptions { method?: Method; body?: unknown; signal?: AbortSignal; /** 幂等键;不传则对写请求自动生成 */ idempotencyKey?: string; } async function raw(path: string, opts: RequestOptions = {}): Promise { const method: Method = opts.method ?? "GET"; const headers: Record = { Accept: "application/json" }; if (opts.body !== undefined) headers["Content-Type"] = "application/json"; const token = session.access; if (token) headers.Authorization = `Bearer ${token}`; const isWrite = method !== "GET"; if (isWrite) { headers["Idempotency-Key"] = opts.idempotencyKey ?? newIdempotencyKey(); } const resp = await fetch(`${BASE}${path}`, { method, headers, signal: opts.signal, body: opts.body === undefined ? undefined : JSON.stringify(opts.body), }); let body: { code: number; message: string; data: T; request_id: string } | null = null; try { body = await resp.json(); } catch { body = null; } if (!body) { throw new ApiError(resp.status, `服务返回了非 JSON 响应(HTTP ${resp.status})`); } if (!resp.ok || body.code !== 0) { if (resp.status === 401) { session.clear(); authExpired.dispatchEvent(new Event("expired")); } throw new ApiError(body.code, body.message, body.request_id); } return body.data; } export const api = { get: (path: string, signal?: AbortSignal) => raw(path, { signal }), post: (path: string, body?: unknown) => raw(path, { method: "POST", body }), patch: (path: string, body?: unknown) => raw(path, { method: "PATCH", body }), del: (path: string) => raw(path, { method: "DELETE" }), }; /** * 上传文件(multipart)。 * * 不能用 api.post:它固定发 JSON。这里也不能自己设 Content-Type, * 必须让浏览器带 boundary,否则 FastAPI 解析不出文件。 */ export async function uploadFile(path: string, file: File): Promise { const form = new FormData(); form.append("file", file, file.name); const headers: Record = { Accept: "application/json" }; const token = session.access; if (token) headers.Authorization = `Bearer ${token}`; headers["Idempotency-Key"] = newIdempotencyKey(); const resp = await fetch(`${BASE}${path}`, { method: "POST", headers, body: form }); let body: { code: number; message: string; data: T; request_id: string } | null = null; try { body = await resp.json(); } catch { body = null; } if (!body) { throw new ApiError(resp.status, `上传失败:服务返回了非 JSON 响应(HTTP ${resp.status})`); } if (!resp.ok || body.code !== 0) { if (resp.status === 401) { session.clear(); authExpired.dispatchEvent(new Event("expired")); } throw new ApiError(body.code, body.message, body.request_id); } return body.data; } /** * 附件直链:`` 带不了 Authorization 头,所以令牌走 query。 * 只在渲染 Markdown 图片时用,不要拿它去做普通的下载链接(那样 token 会留在浏览器历史里)。 */ /** 入库用的规范地址:不带任何查询串,抬头签名由渲染层按附件列表现配。 */ export function canonicalAttachmentPath(wsId: string, attachmentId: string): string { return `${BASE}/workspaces/${wsId}/attachments/${attachmentId}/raw`; }