支持浏览器侧素材快照撰写、拒绝报告读时修复、Mermaid PNG 导出与 Word 导出加固。 Co-authored-by: Cursor <cursoragent@cursor.com>
143 lines
4.6 KiB
TypeScript
143 lines
4.6 KiB
TypeScript
/**
|
||
* 报告协作 API 基础客户端:鉴权、幂等键、乐观锁与统一错误映射。
|
||
*
|
||
* - 写请求自动携带 `X-Idempotency-Key` 与 `X-Expected-Revision`。
|
||
* - 409 → ConflictError(刷新快照后由用户重新确认);422 → ValidationError;
|
||
* 429 → RateLimitError(预算/并发限制,不自动重试);5xx → ServerError
|
||
* (保留输入,允许同一幂等键重试)。
|
||
*/
|
||
|
||
import { apiFetch } from "@/core/api/fetch-client";
|
||
import { getBackendBaseURL } from "@/core/config";
|
||
|
||
import type { ApiErrorDetail } from "./types";
|
||
|
||
export const REPORT_COLLABORATION_BASE = () =>
|
||
`${getBackendBaseURL()}/api/report-collaboration`;
|
||
|
||
export class ApiError extends Error {
|
||
readonly status: number;
|
||
readonly code?: string;
|
||
readonly detail?: unknown;
|
||
|
||
constructor(status: number, message: string, code?: string, detail?: unknown) {
|
||
super(message);
|
||
this.name = "ApiError";
|
||
this.status = status;
|
||
this.code = code;
|
||
this.detail = detail;
|
||
}
|
||
}
|
||
|
||
/** 409:期望 revision 已过期(或 active run 冲突),不能静默覆盖。 */
|
||
export class ConflictError extends ApiError {}
|
||
/** 422:请求参数/语义错误,需在对应操作附近展示。 */
|
||
export class ValidationError extends ApiError {}
|
||
/** 429:预算、并发或速率限制,不自动重试。 */
|
||
export class RateLimitError extends ApiError {}
|
||
/** 5xx:服务端错误,可带同一幂等键重试。 */
|
||
export class ServerError extends ApiError {}
|
||
/** 网络层错误(fetch reject)。 */
|
||
export class NetworkError extends ApiError {
|
||
constructor(message: string) {
|
||
super(0, message, "network");
|
||
}
|
||
}
|
||
|
||
export function newIdempotencyKey(): string {
|
||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||
return `rc-${crypto.randomUUID()}`;
|
||
}
|
||
return `rc-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||
}
|
||
|
||
async function parseErrorDetail(res: Response): Promise<ApiErrorDetail | null> {
|
||
try {
|
||
const data = (await res.json()) as ApiErrorDetail | { detail?: unknown };
|
||
const source = "message" in data ? data : null;
|
||
if (source) return source;
|
||
if (typeof data.detail === "string") {
|
||
return { message: data.detail };
|
||
}
|
||
return null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function errorForStatus(res: Response, message: string, code?: string, detail?: unknown) {
|
||
if (res.status === 409) return new ConflictError(409, message, code, detail);
|
||
if (res.status === 422) return new ValidationError(422, message, code, detail);
|
||
if (res.status === 429) return new RateLimitError(429, message, code, detail);
|
||
if (res.status >= 500) return new ServerError(res.status, message, code, detail);
|
||
return new ApiError(res.status, message, code, detail);
|
||
}
|
||
|
||
export interface RcRequestInit extends Omit<RequestInit, "body"> {
|
||
body?: unknown;
|
||
idempotencyKey?: string;
|
||
expectedRevision?: number | null;
|
||
/** 404 时返回 null 而不是抛错。 */
|
||
nullOn404?: boolean;
|
||
signal?: AbortSignal;
|
||
}
|
||
|
||
export async function rcRequest<T>(path: string, init: RcRequestInit = {}): Promise<T | null> {
|
||
const headers = new Headers();
|
||
headers.set("Accept", "application/json");
|
||
if (init.body !== undefined) {
|
||
headers.set("Content-Type", "application/json");
|
||
}
|
||
if (init.idempotencyKey) {
|
||
headers.set("X-Idempotency-Key", init.idempotencyKey);
|
||
}
|
||
if (init.expectedRevision !== undefined && init.expectedRevision !== null) {
|
||
headers.set("X-Expected-Revision", String(init.expectedRevision));
|
||
}
|
||
|
||
let res: Response;
|
||
try {
|
||
res = await apiFetch(`${REPORT_COLLABORATION_BASE()}${path}`, {
|
||
method: init.method ?? (init.body !== undefined ? "POST" : "GET"),
|
||
headers,
|
||
body: init.body !== undefined ? JSON.stringify(init.body) : undefined,
|
||
credentials: "include",
|
||
signal: init.signal,
|
||
});
|
||
} catch (err) {
|
||
throw new NetworkError(err instanceof Error ? err.message : String(err));
|
||
}
|
||
|
||
if (res.status === 404 && init.nullOn404) {
|
||
return null;
|
||
}
|
||
|
||
if (!res.ok) {
|
||
const detail = await parseErrorDetail(res);
|
||
throw errorForStatus(
|
||
res,
|
||
detail?.message ?? `request failed (${res.status})`,
|
||
detail?.code,
|
||
detail?.detail,
|
||
);
|
||
}
|
||
|
||
if (res.status === 204) {
|
||
return null;
|
||
}
|
||
const text = await res.text();
|
||
if (!text) {
|
||
return null;
|
||
}
|
||
return JSON.parse(text) as T;
|
||
}
|
||
|
||
/** GET 便捷封装(非空断言版)。 */
|
||
export async function rcGet<T>(path: string, signal?: AbortSignal): Promise<T> {
|
||
const data = await rcRequest<T>(path, { method: "GET", signal });
|
||
if (data === null) {
|
||
throw new ApiError(404, `resource not found: ${path}`);
|
||
}
|
||
return data;
|
||
}
|