docs(app): explain common API session workspace and geometry helpers

This commit is contained in:
2026-11-12 01:36:26 +08:00
parent c488bc86b6
commit aacafa7a9b
8 changed files with 103 additions and 0 deletions
+20
View File
@@ -1,3 +1,9 @@
/**
* 应用通用JSON/FormData请求与登录跳转辅助。请求默认经过同源Foundry代理,错误封装为ApiError,
* 界面通过errorText映射中文说明;业务schema和账户代次等更强校验由各领域适配器处理。
* 本函数本身不设置超时或自动重试,取消依赖传入signal,代理传输时限在对应路由/响应封装中管理。
*/
/** 账户资料响应的静态类型,不能用它代替登录会话有效性或权限校验。 */
export type Profile = {
id: string;
username: string;
@@ -5,20 +11,25 @@ export type Profile = {
email: string | null;
avatar_url?: string | null;
};
/** 保留HTTP状态、服务details和原Retry-After头,message使用稳定错误码,供上层决定显示/重试。 */
export class ApiError extends Error {
readonly status: number;
readonly details?: unknown;
readonly retryAfter?: string | null;
/** 以Error兼容方式封装服务错误,不把details自动拼进面向用户文案。 */
constructor(code: string, status: number, details?: unknown, retryAfter?: string | null) {
super(code); this.name = "ApiError"; this.status = status; this.details = details; this.retryAfter = retryAfter;
}
}
/** 泛型T只是调用方的响应形状声明,不执行运行时schema检查。
* 204返回null;其它响应必须可解析JSON,非2xx统一抛ApiError,成功body直接交业务层验证。 */
export async function api<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
const res = await fetch("/api/foundry/" + path, {
...options,
// FormData让浏览器生成boundary,其它请求默认JSON;调用方headers可覆盖默认Content-Type。
headers: { ...(options.body instanceof FormData ? {} : { "Content-Type": "application/json" }), ...options.headers },
});
let body: unknown;
@@ -26,21 +37,27 @@ export async function api<T>(
else {
try { body = await res.json(); }
catch (error) {
// 消费body失败时优先传播明确取消原因;语法坏JSON与传输中断使用不同错误码。
if (options.signal?.aborted) throw options.signal.reason;
const malformed = error instanceof SyntaxError;
throw new ApiError(malformed ? "invalid_api_response" : "response_interrupted", res.ok ? 502 : res.status);
}
}
const payload = body as { error?: string | { code?: string }; details?: unknown } | null;
// 服务error兼容字符串或{code},无可用码则request_failed,并保留Retry-After原值供上层解释。
if (!res.ok) {
const code = typeof payload?.error === "object" ? payload.error?.code : payload?.error;
throw new ApiError(typeof code === "string" ? code : "request_failed", res.status, payload?.details, res.headers.get("retry-after"));
}
return body as T;
}
/** 公开项目标识,来自构建时NEXT_PUBLIC配置;不是密码或用户账户身份。 */
export const PROJECT = process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap";
/** 创建PKCE verifier和SHA-256 challenge:verifier留在当前标签页sessionStorage,
* 只把challenge带到同源OAuth入口,后续交换流程负责取回verifier。此处不直接取得令牌。 */
export async function startOAuth(provider: "google" | "slack") {
const bytes = crypto.getRandomValues(new Uint8Array(32));
// 32随机字节编码为无填充base64url,避免普通base64字符影响URL参数。
const verifier = btoa(String.fromCharCode(...bytes))
.replace(/\+/g, "-")
.replace(/\//g, "_")
@@ -53,11 +70,14 @@ export async function startOAuth(provider: "google" | "slack") {
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
// 页面跳转前保存原verifier;使用单一固定键,同标签页后一次发起会替换上一次值。
sessionStorage.setItem("foundry_verifier", verifier);
window.location.assign("/api/oauth/" + provider + "?challenge=" + challenge);
}
/** 应用错误码→用户文案适配,不改变任务状态或执行恢复;未知错误给通用提示,不直接泄漏内部异常文本。 */
export const errorText = (error: unknown) => {
const code = error instanceof Error ? error.message : "";
// 文案只解释既有领域状态;“保留/暂停”等实际行为须由触发错误的模块保证,不能由映射表证明。
const messages: Record<string, string> = {
invalid_model_stream: "画布返回的数据无法解析,请重新加载。",
incomplete_model_stream: "画布数据未加载完整,请重新加载。",