CI / backend (push) Canceled after 0s
crypto.randomUUID() 与 navigator.clipboard 只在安全上下文(https / localhost)里存在,
生产入口是纯 http,于是:
- 所有 POST/PATCH/DELETE 在拼 Idempotency-Key 时直接抛 TypeError,
登录页只显示"登录失败",GET 一切正常,所以这个坑一直没暴露
- 「复制邀请链接」永远回"浏览器不允许自动复制"
改成幂等键自己拼一个 v4(getRandomValues 不受安全上下文限制,退回 Math.random),
复制走 navigator.clipboard → document.execCommand("copy") 两级降级。
顺带说明:这是生产上"能看不能写"的真正原因,不是权限或接口问题。
175 lines
5.5 KiB
TypeScript
175 lines
5.5 KiB
TypeScript
/**
|
||
* 接口客户端。
|
||
*
|
||
* 三条约定:
|
||
* 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`;
|
||
}
|