Files
DadaoTask/web/src/lib/client.ts
T
Steven e3f1310cff
CI / backend (push) Canceled after 0s
fix(web): 内网 http 下写请求与复制邀请链接都会失败
crypto.randomUUID() 与 navigator.clipboard 只在安全上下文(https / localhost)里存在,
生产入口是纯 http,于是:

- 所有 POST/PATCH/DELETE 在拼 Idempotency-Key 时直接抛 TypeError,
  登录页只显示"登录失败",GET 一切正常,所以这个坑一直没暴露
- 「复制邀请链接」永远回"浏览器不允许自动复制"

改成幂等键自己拼一个 v4(getRandomValues 不受安全上下文限制,退回 Math.random),
复制走 navigator.clipboard → document.execCommand("copy") 两级降级。

顺带说明:这是生产上"能看不能写"的真正原因,不是权限或接口问题。
2026-10-31 04:13:13 +08:00

175 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 接口客户端。
*
* 三条约定:
* 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<T>(path: string, opts: RequestOptions = {}): Promise<T> {
const method: Method = opts.method ?? "GET";
const headers: Record<string, string> = { 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: <T>(path: string, signal?: AbortSignal) => raw<T>(path, { signal }),
post: <T>(path: string, body?: unknown) => raw<T>(path, { method: "POST", body }),
patch: <T>(path: string, body?: unknown) => raw<T>(path, { method: "PATCH", body }),
del: <T>(path: string) => raw<T>(path, { method: "DELETE" }),
};
/**
* 上传文件(multipart)。
*
* 不能用 api.post:它固定发 JSON。这里也不能自己设 Content-Type,
* 必须让浏览器带 boundary,否则 FastAPI 解析不出文件。
*/
export async function uploadFile<T>(path: string, file: File): Promise<T> {
const form = new FormData();
form.append("file", file, file.name);
const headers: Record<string, string> = { 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;
}
/**
* 附件直链:`<img>` 带不了 Authorization 头,所以令牌走 query。
* 只在渲染 Markdown 图片时用,不要拿它去做普通的下载链接(那样 token 会留在浏览器历史里)。
*/
/** 入库用的规范地址:不带任何查询串,抬头签名由渲染层按附件列表现配。 */
export function canonicalAttachmentPath(wsId: string, attachmentId: string): string {
return `${BASE}/workspaces/${wsId}/attachments/${attachmentId}/raw`;
}