/** * 接口客户端:类型由 openapi.yaml 生成(pnpm gen:api),不手写。 * * 守住架构 §5.2 的约束: * - 接口地址由调用方传入(网页版同源,桌面版用用户选择的服务器); * - 网页版用 cookie,写操作自动带 X-CSRF-Token;桌面版用 Bearer 令牌; * - 事件流用 fetch 读(API2),EventSource 带不了 Bearer 头。 */ import createClient, { type Middleware } from "openapi-fetch"; import type { components, paths } from "./schema"; export type { components, paths }; export type Schemas = components["schemas"]; export type Problem = Schemas["Problem"]; export type BdEvent = Schemas["Event"]; export interface ClientOptions { /** 例如 "/api/v1" 或 "https://saas.example.com/api/v1" */ baseUrl: string; /** 桌面版:返回 Bearer 令牌;网页版不传,走 cookie */ getToken?: () => string | null; } const SAFE = new Set(["GET", "HEAD", "OPTIONS"]); function readCookie(name: string): string | null { if (typeof document === "undefined") return null; const m = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`)); return m?.[1] ? decodeURIComponent(m[1]) : null; } /** 服务端返回的 problem+json,带稳定的 code(api-design §5)。前端按 code 判断,不解析文案。 */ export class ApiError extends Error { constructor(public readonly status: number, public readonly problem: Problem) { super(problem.title); } get code(): string { return this.problem.code; } } export function createApi(opts: ClientOptions) { const auth: Middleware = { onRequest({ request }) { const token = opts.getToken?.(); if (token) { request.headers.set("Authorization", `Bearer ${token}`); } else if (!SAFE.has(request.method)) { const csrf = readCookie("bd_csrf"); if (csrf) request.headers.set("X-CSRF-Token", csrf); } return request; }, }; const client = createClient({ baseUrl: opts.baseUrl, credentials: "include" }); client.use(auth); return client; } export type Api = ReturnType; /** 取出数据,失败时抛 ApiError。 */ export async function unwrap(p: Promise<{ data?: T; error?: unknown; response: Response }>): Promise { const { data, error, response } = await p; if (error !== undefined || !response.ok) { const problem = (error ?? { title: "请求失败", status: response.status, code: "internal_error" }) as Problem; throw new ApiError(response.status, problem); } return data as T; } /** 按分片上传一个文件(api-design §4.3),返回完成后的上传信息。 */ export async function uploadFile( api: Api, opts: ClientOptions, file: Blob & { name: string }, purpose: "project_document" | "qualification_file", onProgress?: (ratio: number) => void, ): Promise { const up = await unwrap( api.POST("/uploads", { body: { filename: file.name, size_bytes: file.size, purpose } }), ); for (let n = 1; n <= up.part_count; n++) { if (up.received_parts.includes(n)) continue; const part = file.slice((n - 1) * up.part_size, n * up.part_size); const res = await fetch(`${opts.baseUrl}/uploads/${up.id}/parts/${n}`, { method: "PUT", body: part, credentials: "include", headers: authHeaders(opts, "PUT"), }); if (!res.ok) throw new ApiError(res.status, (await res.json()) as Problem); onProgress?.(n / up.part_count); } return unwrap(api.POST("/uploads/{upload_id}/complete", { params: { path: { upload_id: up.id } } })); } function authHeaders(opts: ClientOptions, method: string): Record { const token = opts.getToken?.(); if (token) return { Authorization: `Bearer ${token}` }; const csrf = SAFE.has(method) ? null : readCookie("bd_csrf"); return csrf ? { "X-CSRF-Token": csrf } : {}; } /** * 订阅事件流(SSE,用 fetch 读)。断线后带 Last-Event-ID 自动重连; * 事件只是「有变化」的提示,收到后让相关查询重新请求即可(api-design §4.2)。 * 返回取消函数。 */ export function subscribeEvents(opts: ClientOptions, onEvent: (e: BdEvent) => void): () => void { const ctrl = new AbortController(); let lastId = ""; const loop = async () => { while (!ctrl.signal.aborted) { try { const headers: Record = { Accept: "text/event-stream", ...authHeaders(opts, "GET") }; if (lastId) headers["Last-Event-ID"] = lastId; const res = await fetch(`${opts.baseUrl}/events`, { headers, credentials: "include", signal: ctrl.signal }); if (!res.ok || !res.body) throw new Error(`events ${res.status}`); const reader = res.body.pipeThrough(new TextDecoderStream()).getReader(); let buf = ""; for (;;) { const { value, done } = await reader.read(); if (done) break; buf += value; let i: number; while ((i = buf.indexOf("\n\n")) >= 0) { const block = buf.slice(0, i); buf = buf.slice(i + 2); let data = ""; for (const line of block.split("\n")) { if (line.startsWith("id: ")) lastId = line.slice(4); else if (line.startsWith("data: ")) data += line.slice(6); } if (data) onEvent(JSON.parse(data) as BdEvent); } } } catch { if (ctrl.signal.aborted) return; } await new Promise((r) => setTimeout(r, 3000)); // 断线:3 秒后重连;页面上的轮询会兜底 } }; void loop(); return () => ctrl.abort(); }