diff --git a/web/src/api/auth.ts b/web/src/api/auth.ts index 9ca27b05..3f69cb3a 100644 --- a/web/src/api/auth.ts +++ b/web/src/api/auth.ts @@ -11,6 +11,7 @@ import { } from '../types/auth'; import { RoleDefinition } from '../types/rbac'; import { mapLoginResult, mapMeResult, mapUserAccount, mapUserAccountList } from './authUserMap'; +import { apiFetch } from './client'; const AUTH_STORAGE_KEY_SESSION = 'smdf_auth_session'; @@ -24,12 +25,11 @@ export interface AuthClientOptions { localFallback?: boolean; } -async function apiFetch(path: string, init?: RequestInit): Promise { - const win = (globalThis as { window?: Window }).window; - if (!win || typeof win.fetch !== 'function') { +async function authRequest(path: string, init?: RequestInit, withAuth = false): Promise { + if (typeof fetch !== 'function') { throw new Error('后端服务不可用,请检查网络连接'); } - return win.fetch(path, init); + return apiFetch(path, init, { auth: withAuth, retry: false }); } /** @@ -40,9 +40,9 @@ export class AuthClient { async fetchProfile(): Promise> { const token = this.getSessionToken(); - const resp = await apiFetch('/api/v1/me/profile', { + const resp = await authRequest('/api/v1/me/profile', { headers: token ? { Authorization: `Bearer ${token}` } : {}, - }); + }, true); if (!resp.ok) { throw new Error('无法加载个人资料'); } @@ -51,14 +51,14 @@ export class AuthClient { async savePreferences(prefs: Record): Promise> { const token = this.getSessionToken(); - const resp = await apiFetch('/api/v1/me/preferences', { + const resp = await authRequest('/api/v1/me/preferences', { method: 'PUT', headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), }, body: JSON.stringify(prefs), - }); + }, true); if (!resp.ok) { throw new Error('保存偏好失败'); } @@ -67,9 +67,9 @@ export class AuthClient { async fetchMe(): Promise { const token = this.getSessionToken(); - const resp = await apiFetch('/api/v1/auth/me', { + const resp = await authRequest('/api/v1/auth/me', { headers: token ? { Authorization: `Bearer ${token}` } : {}, - }); + }, true); if (!resp.ok) { throw new Error('会话已过期或未登录'); } @@ -96,7 +96,7 @@ export class AuthClient { if (!trimmedUser || !payload.password) { throw new Error('用户名和密码不能为空'); } - const resp = await apiFetch('/api/v1/auth/login', { + const resp = await authRequest('/api/v1/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), @@ -120,7 +120,7 @@ export class AuthClient { } async register(payload: RegisterPayload): Promise { - const resp = await apiFetch('/api/v1/auth/register', { + const resp = await authRequest('/api/v1/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), @@ -134,14 +134,14 @@ export class AuthClient { async changePassword(_userId: number, oldPass: string, newPass: string): Promise { const token = this.getSessionToken(); - const resp = await apiFetch('/api/v1/auth/change-password', { + const resp = await authRequest('/api/v1/auth/change-password', { method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), }, body: JSON.stringify({ old_password: oldPass, new_password: newPass }), - }); + }, true); if (!resp.ok) { const txt = await resp.text(); throw new Error(txt.trim() || '修改密码失败'); @@ -151,9 +151,9 @@ export class AuthClient { async getAdminUsers(): Promise { const token = this.getSessionToken(); - const resp = await apiFetch('/api/v1/admin/users', { + const resp = await authRequest('/api/v1/admin/users', { headers: token ? { Authorization: `Bearer ${token}` } : {}, - }); + }, true); if (!resp.ok) { throw new Error('获取用户列表失败'); } @@ -171,7 +171,7 @@ export class AuthClient { async createAdminUser(payload: CreateAdminUserPayload): Promise { const token = this.getSessionToken(); - const resp = await apiFetch('/api/v1/admin/users', { + const resp = await authRequest('/api/v1/admin/users', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -186,7 +186,7 @@ export class AuthClient { department: payload.department, employee_id: payload.employeeId, }), - }); + }, true); if (!resp.ok) { throw new Error('创建用户失败'); } @@ -195,34 +195,34 @@ export class AuthClient { async updateUserRole(userId: number, roleId: string, position: string): Promise { const token = this.getSessionToken(); - const resp = await apiFetch(`/api/v1/admin/users/${userId}/role`, { + const resp = await authRequest(`/api/v1/admin/users/${userId}/role`, { method: 'PUT', headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), }, body: JSON.stringify({ role_id: roleId, position }), - }); + }, true); if (!resp.ok) throw new Error('更新角色失败'); return mapUserAccount(await resp.json()); } async updateUserStatus(userId: number, status: AccountStatus, roleId?: string, position?: string): Promise { const token = this.getSessionToken(); - const resp = await apiFetch(`/api/v1/admin/users/${userId}/status`, { + const resp = await authRequest(`/api/v1/admin/users/${userId}/status`, { method: 'PUT', headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), }, body: JSON.stringify({ status, role_id: roleId, position }), - }); + }, true); if (!resp.ok) throw new Error('更新状态失败'); return mapUserAccount(await resp.json()); } async getSystemAuthConfig(): Promise { - const resp = await apiFetch('/api/v1/auth/public-config'); + const resp = await authRequest('/api/v1/auth/public-config'); if (!resp.ok) { return { allowRegistration: false, requireApproval: true }; } @@ -238,9 +238,9 @@ export class AuthClient { async listRoles(): Promise { const token = this.getSessionToken(); - const resp = await apiFetch('/api/v1/admin/roles', { + const resp = await authRequest('/api/v1/admin/roles', { headers: token ? { Authorization: `Bearer ${token}` } : {}, - }); + }, true); if (!resp.ok) { throw new Error('获取角色列表失败'); } @@ -255,14 +255,14 @@ export class AuthClient { permissions: string[]; }): Promise { const token = this.getSessionToken(); - const resp = await apiFetch('/api/v1/admin/roles', { + const resp = await authRequest('/api/v1/admin/roles', { method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), }, body: JSON.stringify(payload), - }); + }, true); if (!resp.ok) { throw new Error('创建角色失败'); } @@ -274,14 +274,14 @@ export class AuthClient { payload: { name: string; description?: string; permissions: string[] } ): Promise { const token = this.getSessionToken(); - const resp = await apiFetch(`/api/v1/admin/roles/${encodeURIComponent(roleId)}`, { + const resp = await authRequest(`/api/v1/admin/roles/${encodeURIComponent(roleId)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), }, body: JSON.stringify(payload), - }); + }, true); if (!resp.ok) { throw new Error('更新角色失败'); } @@ -290,10 +290,10 @@ export class AuthClient { async deleteRole(roleId: string): Promise { const token = this.getSessionToken(); - const resp = await apiFetch(`/api/v1/admin/roles/${encodeURIComponent(roleId)}`, { + const resp = await authRequest(`/api/v1/admin/roles/${encodeURIComponent(roleId)}`, { method: 'DELETE', headers: token ? { Authorization: `Bearer ${token}` } : {}, - }); + }, true); if (!resp.ok) { throw new Error('删除角色失败'); } @@ -303,9 +303,9 @@ export class AuthClient { Array<{ id: string; created_at: string; last_seen_at: string; user_agent: string; is_current: boolean }> > { const token = this.getSessionToken(); - const resp = await apiFetch('/api/v1/auth/sessions', { + const resp = await authRequest('/api/v1/auth/sessions', { headers: token ? { Authorization: `Bearer ${token}` } : {}, - }); + }, true); if (!resp.ok) { throw new Error('获取会话列表失败'); } @@ -315,10 +315,10 @@ export class AuthClient { async revokeSession(sessionId: string): Promise { const token = this.getSessionToken(); - const resp = await apiFetch(`/api/v1/auth/sessions/${encodeURIComponent(sessionId)}`, { + const resp = await authRequest(`/api/v1/auth/sessions/${encodeURIComponent(sessionId)}`, { method: 'DELETE', headers: token ? { Authorization: `Bearer ${token}` } : {}, - }); + }, true); if (!resp.ok) { throw new Error('撤销会话失败'); } @@ -326,14 +326,14 @@ export class AuthClient { async updateSystemAuthConfig(newConfig: SystemAuthConfig): Promise { const token = this.getSessionToken(); - const resp = await apiFetch('/api/v1/admin/config', { + const resp = await authRequest('/api/v1/admin/config', { method: 'PUT', headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), }, body: JSON.stringify(newConfig), - }); + }, true); if (!resp.ok) throw new Error('更新系统配置失败'); return resp.json(); } @@ -341,10 +341,10 @@ export class AuthClient { async logout(): Promise { const token = this.getSessionToken(); try { - await apiFetch('/api/v1/auth/logout', { + await authRequest('/api/v1/auth/logout', { method: 'POST', headers: token ? { Authorization: `Bearer ${token}` } : {}, - }); + }, true); } catch { // 网络失败仍清除本地态 } diff --git a/web/src/api/authHeaders.ts b/web/src/api/authHeaders.ts index 3e9128f3..8583781f 100644 --- a/web/src/api/authHeaders.ts +++ b/web/src/api/authHeaders.ts @@ -32,8 +32,12 @@ export function mergeAuthHeaders( return { ...getBearerAuthHeaders(contentType), ...extra }; } -/** Authenticated fetch wrapper; merges Bearer token into every request. */ -export async function authorizedFetch(input: RequestInfo, init?: RequestInit): Promise { +/** + * Authenticated fetch without retry. Prefer `apiFetch` from `./client` for new code + * (base URL + typed errors + limited retry). Kept for callers that only need headers. + */ +export async function authorizedFetch(input: RequestInfo | string, init?: RequestInit): Promise { + const path = typeof input === 'string' ? input : String(input); const extra = init?.headers instanceof Headers ? Object.fromEntries(init.headers.entries()) @@ -41,5 +45,5 @@ export async function authorizedFetch(input: RequestInfo, init?: RequestInit): P const contentType = typeof extra['Content-Type'] === 'string' ? extra['Content-Type'] : undefined; const headers = mergeAuthHeaders(extra, contentType); - return fetch(input, { ...init, headers }); + return fetch(path, { ...init, headers }); } diff --git a/web/src/api/chatDigest.ts b/web/src/api/chatDigest.ts index 7215d85f..547e217c 100644 --- a/web/src/api/chatDigest.ts +++ b/web/src/api/chatDigest.ts @@ -1,5 +1,6 @@ import { DailyDigest } from '../types/workbench'; import { mergeAuthHeaders, getBearerAuthHeaders } from './authHeaders'; +import { apiFetch } from './client'; export async function fetchDailyDigests( sessionId: string, @@ -11,7 +12,7 @@ export async function fetchDailyDigests( if (from) url.searchParams.set('from', from); if (to) url.searchParams.set('to', to); - const res = await fetch(url.toString(), { + const res = await apiFetch(url.toString(), { method: 'GET', headers: mergeAuthHeaders({ Accept: 'application/json' }), }); @@ -28,7 +29,7 @@ export async function generateDailyDigest( sessionId: string, dayKey: string ): Promise<{ queued: boolean; digest: DailyDigest }> { - const res = await fetch('/api/v1/chat/digests/generate', { + const res = await apiFetch('/api/v1/chat/digests/generate', { method: 'POST', headers: getBearerAuthHeaders('application/json'), body: JSON.stringify({ diff --git a/web/src/api/chatSearch.ts b/web/src/api/chatSearch.ts index c5faf138..2d548117 100644 --- a/web/src/api/chatSearch.ts +++ b/web/src/api/chatSearch.ts @@ -1,5 +1,6 @@ import { ChatSearchHit, ChatSearchMode } from '../types/workbench'; import { mergeAuthHeaders } from './authHeaders'; +import { apiFetch } from './client'; export interface SearchChatMessagesParams { sessionId: string; @@ -32,7 +33,7 @@ export async function searchChatMessages(params: SearchChatMessagesParams): Prom url.searchParams.set('limit', String(params.limit)); } - const res = await fetch(url.toString(), { + const res = await apiFetch(url.toString(), { method: 'GET', headers: mergeAuthHeaders({ Accept: 'application/json' }), }); diff --git a/web/src/api/chatSessions.ts b/web/src/api/chatSessions.ts index 70a19c40..f6488996 100644 --- a/web/src/api/chatSessions.ts +++ b/web/src/api/chatSessions.ts @@ -1,5 +1,6 @@ import { getBearerAuthHeaders } from './authHeaders'; import { projectSettingsErrorFromResponse } from './projectSettingsError'; +import { apiFetch, API_BASE } from './client'; export interface ChatSessionSummary { session_id: string; @@ -19,8 +20,6 @@ export interface UpsertChatSessionInput { project_id: number; } -const API_BASE = ''; - export async function fetchChatSessions( userId: number, projectId?: number, @@ -34,7 +33,7 @@ export async function fetchChatSessions( if (projectId != null && projectId > 0) { params.set('project_id', String(projectId)); } - const res = await fetch(`${API_BASE}/api/v1/chat/sessions?${params.toString()}`, { + const res = await apiFetch(`${API_BASE}/api/v1/chat/sessions?${params.toString()}`, { headers: getBearerAuthHeaders(), signal, }); @@ -46,7 +45,7 @@ export async function fetchChatSessions( } export async function upsertChatSession(input: UpsertChatSessionInput): Promise { - const res = await fetch(`${API_BASE}/api/v1/chat/sessions`, { + const res = await apiFetch(`${API_BASE}/api/v1/chat/sessions`, { method: 'POST', headers: getBearerAuthHeaders('application/json'), body: JSON.stringify(input), diff --git a/web/src/api/chatStream.ts b/web/src/api/chatStream.ts index 255eade0..7ccfd5c7 100644 --- a/web/src/api/chatStream.ts +++ b/web/src/api/chatStream.ts @@ -2,6 +2,7 @@ import type { ChatErrorInfo } from '../types/workbench'; import type { MessageExportCapability } from '../types/replyExport'; import { fallbackErrorClass } from './chatRetry'; import { mergeAuthHeaders } from './authHeaders'; +import { apiFetch } from './client'; export type StreamEvent = { id?: string; @@ -169,12 +170,16 @@ export async function streamChat( }, 'application/json', ); - const resp = await fetch('/api/v1/chat/stream', { - method: 'POST', - headers, - body: JSON.stringify(body), - signal, - }); + const resp = await apiFetch( + '/api/v1/chat/stream', + { + method: 'POST', + headers, + body: JSON.stringify(body), + signal, + }, + { retry: false }, + ); const rid = resp.headers.get('X-Request-Id') || resp.headers.get('x-request-id'); if (rid) handlers.onRequestId?.(rid); if (!resp.ok || !resp.body) { diff --git a/web/src/api/chatTurn.ts b/web/src/api/chatTurn.ts index be018b8a..9f53e9b2 100644 --- a/web/src/api/chatTurn.ts +++ b/web/src/api/chatTurn.ts @@ -1,4 +1,5 @@ import { getBearerAuthHeaders } from './authHeaders'; +import { apiFetch } from './client'; export type ChatTurnStatus = { sessionId: string; @@ -15,7 +16,7 @@ export async function fetchChatTurnStatus( signal?: AbortSignal, ): Promise { const url = `/api/v1/chat/turns/${encodeURIComponent(clientTurnId)}?session_id=${encodeURIComponent(sessionId)}`; - const res = await fetch(url, { signal, headers: getBearerAuthHeaders() }); + const res = await apiFetch(url, { signal, headers: getBearerAuthHeaders() }); if (res.status === 404) return null; if (!res.ok) { throw new Error(`fetchChatTurnStatus failed: HTTP ${res.status}`); diff --git a/web/src/api/chatWindow.ts b/web/src/api/chatWindow.ts index b73e48e5..d862fb34 100644 --- a/web/src/api/chatWindow.ts +++ b/web/src/api/chatWindow.ts @@ -11,6 +11,7 @@ import { import { MessageContentKind, MessageExportCapability } from '../types/replyExport'; import { getBearerAuthHeaders } from './authHeaders'; import { mapErrorInfo } from './chatStream'; +import { apiFetch } from './client'; /** 从 metadata.task_phase_snapshot 重建前端快照(兼容 JSON 对象)。 */ export function parseTaskPhaseSnapshot(raw: unknown): TaskPhaseSnapshot | undefined { @@ -288,7 +289,7 @@ export async function fetchChatWindow( url += `&before_day=${encodeURIComponent(beforeDay)}`; } - const res = await fetch(url, { signal, headers: getBearerAuthHeaders() }); + const res = await apiFetch(url, { signal, headers: getBearerAuthHeaders() }); if (!res.ok) { throw Object.assign(new Error(`fetchChatWindow failed: HTTP ${res.status}`), { httpStatus: res.status, @@ -323,7 +324,7 @@ export async function fetchDayMessages( if (options?.afterId) params.set('after_id', String(options.afterId)); if (options?.limit) params.set('limit', String(options.limit)); - const res = await fetch(`/api/v1/chat/messages/day?${params.toString()}`, { + const res = await apiFetch(`/api/v1/chat/messages/day?${params.toString()}`, { headers: getBearerAuthHeaders(), }); if (!res.ok) { @@ -343,7 +344,7 @@ export async function fetchDayMessages( } export async function fetchChatDates(sessionId: string): Promise { - const res = await fetch(`/api/v1/chat/dates?session_id=${encodeURIComponent(sessionId)}`, { + const res = await apiFetch(`/api/v1/chat/dates?session_id=${encodeURIComponent(sessionId)}`, { headers: getBearerAuthHeaders(), }); if (!res.ok) { @@ -362,7 +363,7 @@ export async function fetchChatDates(sessionId: string): Promise export async function importChatMessages( messages: Array> ): Promise<{ imported_count: number; total: number }> { - const res = await fetch('/api/v1/chat/messages/import', { + const res = await apiFetch('/api/v1/chat/messages/import', { method: 'POST', headers: getBearerAuthHeaders('application/json'), body: JSON.stringify(messages), @@ -374,7 +375,7 @@ export async function importChatMessages( } export async function fetchChatCategories(sessionId: string): Promise { - const res = await fetch(`/api/v1/chat/categories?session_id=${encodeURIComponent(sessionId)}`, { + const res = await apiFetch(`/api/v1/chat/categories?session_id=${encodeURIComponent(sessionId)}`, { headers: getBearerAuthHeaders(), }); if (!res.ok) { diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts new file mode 100644 index 00000000..bc9b5e91 --- /dev/null +++ b/web/src/api/client.test.ts @@ -0,0 +1,111 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { apiFetch, readApiError, ApiError, API_BASE } from './client'; + +test('API_BASE is empty relative base', () => { + assert.equal(API_BASE, ''); +}); + +test('apiFetch attaches Bearer from session storage', async () => { + const originalFetch = globalThis.fetch; + const originalWindow = globalThis.window; + const store: Record = { + smdf_auth_session: JSON.stringify({ token: 'tok-abc' }), + }; + (globalThis as { window?: unknown }).window = { + localStorage: { + getItem: (k: string) => store[k] ?? null, + setItem: (k: string, v: string) => { + store[k] = v; + }, + removeItem: (k: string) => { + delete store[k]; + }, + }, + }; + let seenAuth = ''; + globalThis.fetch = async (_input, init) => { + const headers = init?.headers as Record; + seenAuth = headers?.Authorization ?? ''; + return new Response('{}', { status: 200 }); + }; + try { + await apiFetch('/api/v1/chat/sessions'); + assert.equal(seenAuth, 'Bearer tok-abc'); + } finally { + globalThis.fetch = originalFetch; + (globalThis as { window?: unknown }).window = originalWindow; + } +}); + +test('apiFetch retries GET on 503 then succeeds', async () => { + const originalFetch = globalThis.fetch; + let calls = 0; + globalThis.fetch = async () => { + calls += 1; + if (calls === 1) { + return new Response('busy', { status: 503 }); + } + return new Response('{"ok":true}', { status: 200 }); + }; + try { + const resp = await apiFetch('/api/v1/ping'); + assert.equal(resp.status, 200); + assert.equal(calls, 2); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('apiFetch does not retry POST by default', async () => { + const originalFetch = globalThis.fetch; + let calls = 0; + globalThis.fetch = async () => { + calls += 1; + return new Response('busy', { status: 503 }); + }; + try { + const resp = await apiFetch('/api/v1/ping', { method: 'POST' }); + assert.equal(resp.status, 503); + assert.equal(calls, 1); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('readApiError preserves typed envelope for error cards', async () => { + const resp = new Response( + JSON.stringify({ code: 'AUTHZ', message: '无权访问', error: 'ERR_FORBIDDEN' }), + { status: 403, headers: { 'Content-Type': 'application/json' } }, + ); + const err = await readApiError(resp, 'fallback'); + assert.ok(err instanceof ApiError); + assert.equal(err.status, 403); + assert.equal(err.code, 'AUTHZ'); + assert.equal(err.message, '无权访问'); +}); + +test('apiFetch auth:false skips Authorization', async () => { + const originalFetch = globalThis.fetch; + const originalWindow = globalThis.window; + (globalThis as { window?: unknown }).window = { + localStorage: { + getItem: () => JSON.stringify({ token: 'secret' }), + setItem: () => {}, + removeItem: () => {}, + }, + }; + let seenAuth: string | undefined; + globalThis.fetch = async (_input, init) => { + const headers = init?.headers as Record; + seenAuth = headers?.Authorization; + return new Response('{}', { status: 200 }); + }; + try { + await apiFetch('/api/v1/auth/login', { method: 'POST' }, { auth: false }); + assert.equal(seenAuth, undefined); + } finally { + globalThis.fetch = originalFetch; + (globalThis as { window?: unknown }).window = originalWindow; + } +}); diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 00000000..a85fa536 --- /dev/null +++ b/web/src/api/client.ts @@ -0,0 +1,176 @@ +/** + * Shared frontend API client: base URL, Bearer auth, typed error envelope, limited retry. + * Domain modules should call apiFetch / readApiError instead of bare fetch. + */ + +import { mergeAuthHeaders } from './authHeaders'; +import { + ProjectSettingsApiError, + parseProjectSettingsErrorBody, + projectSettingsErrorFromResponse, +} from './projectSettingsError'; + +/** Relative base; Vite proxy / same-origin deploy leave this empty. */ +export const API_BASE = ''; + +/** Typed API failure; same shape as ProjectSettingsApiError for existing error cards. */ +export class ApiError extends ProjectSettingsApiError { + constructor(input: { + status: number; + code: string; + message: string; + issues?: { code?: string; field?: string; message?: string }[]; + }) { + super(input); + this.name = 'ApiError'; + } +} + +export type ApiFetchOptions = { + /** Attach Bearer from session (default true). Login/register set false. */ + auth?: boolean; + /** + * Limited retry for network errors and 502/503/504. + * Default: 2 for GET/HEAD, 0 otherwise. Pass false to disable (SSE / non-idempotent). + */ + retry?: boolean | number; +}; + +function resolveUrl(path: string): string { + if (/^https?:\/\//i.test(path)) return path; + if (!API_BASE) return path; + if (path.startsWith('/')) return `${API_BASE}${path}`; + return `${API_BASE}/${path}`; +} + +export function apiUrl(path: string): string { + return resolveUrl(path); +} + +function normalizeHeaders( + init?: RequestInit, + auth = true, +): Record { + const extra = + init?.headers instanceof Headers + ? Object.fromEntries(init.headers.entries()) + : ((init?.headers as Record | undefined) ?? {}); + if (!auth) { + return { ...extra }; + } + const contentType = + typeof extra['Content-Type'] === 'string' + ? extra['Content-Type'] + : typeof extra['content-type'] === 'string' + ? extra['content-type'] + : undefined; + return mergeAuthHeaders(extra, contentType); +} + +function methodOf(init?: RequestInit): string { + return (init?.method ?? 'GET').toUpperCase(); +} + +function maxAttempts(method: string, retry: ApiFetchOptions['retry']): number { + if (retry === false) return 1; + if (typeof retry === 'number') return Math.max(1, retry + 1); + if (retry === true) return 3; + // Default: only safe methods get limited retries (2 retries → 3 attempts). + if (method === 'GET' || method === 'HEAD') return 3; + return 1; +} + +function isRetryableStatus(status: number): boolean { + return status === 502 || status === 503 || status === 504; +} + +function retryDelayMs(attemptIndex: number): number { + return attemptIndex === 0 ? 200 : 500; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Authenticated fetch with optional limited retry. Returns the raw Response; + * callers still inspect status and throw domain errors (do not swallow). + */ +export async function apiFetch( + path: string, + init?: RequestInit, + options?: ApiFetchOptions, +): Promise { + const url = resolveUrl(path); + const auth = options?.auth !== false; + const method = methodOf(init); + const attempts = maxAttempts(method, options?.retry); + const headers = normalizeHeaders(init, auth); + const signal = init?.signal; + + let lastError: unknown; + for (let attempt = 0; attempt < attempts; attempt++) { + if (signal?.aborted) { + throw signal.reason instanceof Error + ? signal.reason + : new DOMException('The operation was aborted.', 'AbortError'); + } + try { + const resp = await fetch(url, { ...init, headers }); + if ( + attempt < attempts - 1 && + isRetryableStatus(resp.status) && + !signal?.aborted + ) { + await sleep(retryDelayMs(attempt)); + continue; + } + return resp; + } catch (err) { + lastError = err; + if (signal?.aborted) throw err; + if (attempt < attempts - 1) { + await sleep(retryDelayMs(attempt)); + continue; + } + throw err; + } + } + throw lastError instanceof Error ? lastError : new Error('请求失败'); +} + +/** Parse typed error envelope from a failed Response (body consumed). */ +export async function readApiError( + resp: Response, + fallback: string, +): Promise { + const text = await resp.text(); + const base = projectSettingsErrorFromResponse(resp.status, text, fallback); + return new ApiError({ + status: base.status, + code: base.code, + message: base.message, + issues: base.issues, + }); +} + +/** Build ApiError from already-read response text. */ +export function apiErrorFromBody( + status: number, + text: string, + fallback: string, +): ApiError { + const base = projectSettingsErrorFromResponse(status, text, fallback); + return new ApiError({ + status: base.status, + code: base.code, + message: base.message, + issues: base.issues, + }); +} + +export { + ProjectSettingsApiError, + parseProjectSettingsErrorBody, + projectSettingsErrorFromResponse, +}; diff --git a/web/src/api/docgov.ts b/web/src/api/docgov.ts index 0f216b8e..34eff665 100644 --- a/web/src/api/docgov.ts +++ b/web/src/api/docgov.ts @@ -1,8 +1,7 @@ /** 受控文档正文保存、编辑租约与生命周期流转 API */ import { getBearerAuthHeaders } from './authHeaders'; - -const API_BASE = ''; +import { apiFetch, API_BASE } from './client'; export interface SaveDocumentContentPayload { doc_id: string; @@ -78,7 +77,7 @@ export interface ApproverDecisionPayload { } async function postDocGov(path: string, body: unknown): Promise { - const resp = await fetch(`${API_BASE}${path}`, { + const resp = await apiFetch(`${API_BASE}${path}`, { method: 'POST', headers: getBearerAuthHeaders('application/json'), body: JSON.stringify(body), diff --git a/web/src/api/documentRevision.ts b/web/src/api/documentRevision.ts index 02528918..6e494085 100644 --- a/web/src/api/documentRevision.ts +++ b/web/src/api/documentRevision.ts @@ -6,6 +6,7 @@ import type { RevisionState, } from '../features/documentRevision/types'; import { mergeAuthHeaders } from './authHeaders'; +import { apiFetch } from './client'; async function api(path: string, init?: RequestInit): Promise { const extra = @@ -15,7 +16,7 @@ async function api(path: string, init?: RequestInit): Promise { const contentType = typeof extra['Content-Type'] === 'string' ? extra['Content-Type'] : 'application/json'; const headers = mergeAuthHeaders(extra, contentType); - const res = await fetch(path, { + const res = await apiFetch(path, { credentials: 'include', ...init, headers, diff --git a/web/src/api/freeformDoc.ts b/web/src/api/freeformDoc.ts index 10d0eed3..b7dd26f7 100644 --- a/web/src/api/freeformDoc.ts +++ b/web/src/api/freeformDoc.ts @@ -5,6 +5,7 @@ import { getBearerAuthHeaders } from './authHeaders'; import { projectSettingsErrorFromResponse } from './projectSettingsError'; +import { apiFetch } from './client'; export interface FreeformDocSummary { doc_id: string; @@ -54,7 +55,7 @@ export async function createFreeformDoc(payload: { space?: 'my_private' | 'project_shared'; clientKey?: string; }): Promise { - const res = await fetch('/api/v1/freeform-documents', { + const res = await apiFetch('/api/v1/freeform-documents', { method: 'POST', headers: getBearerAuthHeaders('application/json'), body: JSON.stringify({ @@ -83,7 +84,7 @@ export async function saveMessageToPrivate( title?: string, expectedSha256?: string, ): Promise { - const res = await fetch('/api/v1/freeform-documents/from-message', { + const res = await apiFetch('/api/v1/freeform-documents/from-message', { method: 'POST', headers: getBearerAuthHeaders('application/json'), body: JSON.stringify({ @@ -114,7 +115,7 @@ export async function listFreeformDocs( url += `&project_id=${projectId}`; } - const res = await fetch(url, { headers: getBearerAuthHeaders() }); + const res = await apiFetch(url, { headers: getBearerAuthHeaders() }); if (!res.ok) { throw new Error(`获取列表失败 (${res.status})`); } @@ -126,7 +127,7 @@ export async function listFreeformDocs( * 获取自由文档详情与 Markdown 正文 */ export async function fetchFreeformDoc(docId: string): Promise { - const res = await fetch(`/api/v1/freeform-documents/${encodeURIComponent(docId)}`, { + const res = await apiFetch(`/api/v1/freeform-documents/${encodeURIComponent(docId)}`, { headers: getBearerAuthHeaders(), }); if (!res.ok) { @@ -162,7 +163,7 @@ export async function updateFreeformDoc( headers['If-Match'] = `"${payload.expectedSha256}"`; } - const res = await fetch(`/api/v1/freeform-documents/${encodeURIComponent(docId)}`, { + const res = await apiFetch(`/api/v1/freeform-documents/${encodeURIComponent(docId)}`, { method: 'PUT', headers, body: JSON.stringify({ @@ -186,7 +187,7 @@ export async function updateFreeformDoc( * 一键共享至项目参考库 */ export async function shareFreeformDoc(docId: string): Promise { - const res = await fetch(`/api/v1/freeform-documents/${encodeURIComponent(docId)}/share`, { + const res = await apiFetch(`/api/v1/freeform-documents/${encodeURIComponent(docId)}/share`, { method: 'POST', headers: getBearerAuthHeaders(), }); @@ -200,7 +201,7 @@ export async function shareFreeformDoc(docId: string): Promise { * 撤回共享,恢复为个人私密 */ export async function unshareFreeformDoc(docId: string): Promise { - const res = await fetch(`/api/v1/freeform-documents/${encodeURIComponent(docId)}/unshare`, { + const res = await apiFetch(`/api/v1/freeform-documents/${encodeURIComponent(docId)}/unshare`, { method: 'POST', headers: getBearerAuthHeaders(), }); @@ -214,7 +215,7 @@ export async function unshareFreeformDoc(docId: string): Promise { * 软删除文档移至回收站 */ export async function deleteFreeformDoc(docId: string): Promise { - const res = await fetch(`/api/v1/freeform-documents/${encodeURIComponent(docId)}`, { + const res = await apiFetch(`/api/v1/freeform-documents/${encodeURIComponent(docId)}`, { method: 'DELETE', headers: getBearerAuthHeaders(), }); diff --git a/web/src/api/intentPlans.ts b/web/src/api/intentPlans.ts index cde171dc..2608c89e 100644 --- a/web/src/api/intentPlans.ts +++ b/web/src/api/intentPlans.ts @@ -1,4 +1,5 @@ import { getBearerAuthHeaders } from './authHeaders'; +import { apiFetch } from './client'; export type IntentPlanStatus = | 'proposed' @@ -44,7 +45,7 @@ export async function fetchActiveIntentPlan( sessionId: string, signal?: AbortSignal, ): Promise { - const res = await fetch(`/api/v1/intent-plans/active?${scopeQuery(projectId, sessionId)}`, { + const res = await apiFetch(`/api/v1/intent-plans/active?${scopeQuery(projectId, sessionId)}`, { signal, headers: getBearerAuthHeaders(), }); @@ -62,7 +63,7 @@ export async function fetchIntentPlan( sessionId: string, signal?: AbortSignal, ): Promise { - const res = await fetch( + const res = await apiFetch( `/api/v1/intent-plans/${encodeURIComponent(planId)}?${scopeQuery(projectId, sessionId)}`, { signal, headers: getBearerAuthHeaders() }, ); @@ -90,7 +91,7 @@ export async function submitIntentPlanDecision( input: IntentPlanDecisionInput, signal?: AbortSignal, ): Promise { - const res = await fetch( + const res = await apiFetch( `/api/v1/intent-plans/${encodeURIComponent(input.planId)}/decisions?${scopeQuery(input.projectId, input.sessionId)}`, { method: 'POST', diff --git a/web/src/api/projectAssets.ts b/web/src/api/projectAssets.ts index 47d020d2..3de18fc8 100644 --- a/web/src/api/projectAssets.ts +++ b/web/src/api/projectAssets.ts @@ -1,4 +1,5 @@ import { getBearerAuthHeaders } from './authHeaders'; +import { apiFetch } from './client'; export type ProjectAsset = { asset_id: number; @@ -22,7 +23,7 @@ export async function uploadProjectAsset( ): Promise { const form = new FormData(); form.append('file', file, filename); - const res = await fetch(`/api/v1/projects/${projectId}/assets`, { + const res = await apiFetch(`/api/v1/projects/${projectId}/assets`, { method: 'POST', headers: getBearerAuthHeaders(), body: form, @@ -34,7 +35,7 @@ export async function uploadProjectAsset( } export async function listProjectAssets(projectId: number): Promise { - const res = await fetch(`/api/v1/projects/${projectId}/assets`, { + const res = await apiFetch(`/api/v1/projects/${projectId}/assets`, { headers: getBearerAuthHeaders(), }); if (!res.ok) { @@ -48,7 +49,7 @@ export async function reparseProjectAsset( projectId: number, assetId: number, ): Promise<{ asset_id: number; parse_state: string }> { - const res = await fetch(`/api/v1/projects/${projectId}/assets/${assetId}/reparse`, { + const res = await apiFetch(`/api/v1/projects/${projectId}/assets/${assetId}/reparse`, { method: 'POST', headers: getBearerAuthHeaders(), }); @@ -66,7 +67,7 @@ export function projectAssetRawURL(projectId: number, assetId: number): string { /** 拉取资产二进制为 Blob(带 Bearer)。 */ export async function fetchProjectAssetBlob(projectId: number, assetId: number): Promise { - const res = await fetch(projectAssetRawURL(projectId, assetId), { + const res = await apiFetch(projectAssetRawURL(projectId, assetId), { headers: getBearerAuthHeaders(), }); if (!res.ok) { diff --git a/web/src/api/projectDocuments.ts b/web/src/api/projectDocuments.ts index 1b5e0514..ea859c77 100644 --- a/web/src/api/projectDocuments.ts +++ b/web/src/api/projectDocuments.ts @@ -1,6 +1,7 @@ /** 项目工作流受控文档 API */ import { getBearerAuthHeaders } from './authHeaders'; +import { apiFetch } from './client'; export type WorkflowDocumentSummary = { doc_id: string; @@ -54,7 +55,7 @@ export type ControlledDocumentContent = { export async function listProjectWorkflowDocuments( projectId: number, ): Promise { - const resp = await fetch( + const resp = await apiFetch( `/api/v1/projects/${projectId}/documents?category=workflow`, { headers: getBearerAuthHeaders() }, ); @@ -71,7 +72,7 @@ export async function listProjectWorkflowDocuments( export async function listProjectWorkflowDocumentsDetailed( projectId: number, ): Promise { - const resp = await fetch( + const resp = await apiFetch( `/api/v1/projects/${projectId}/documents?category=workflow`, { headers: getBearerAuthHeaders() }, ); @@ -106,7 +107,7 @@ export class ControlledContentRequestError extends Error { export async function fetchControlledDocumentContent( docId: string, ): Promise { - const resp = await fetch(`/api/v1/docgov/documents/${encodeURIComponent(docId)}/content`, { + const resp = await apiFetch(`/api/v1/docgov/documents/${encodeURIComponent(docId)}/content`, { headers: getBearerAuthHeaders(), }); if (!resp.ok) { diff --git a/web/src/api/projectMembers.ts b/web/src/api/projectMembers.ts index 08546091..e93eed52 100644 --- a/web/src/api/projectMembers.ts +++ b/web/src/api/projectMembers.ts @@ -1,5 +1,6 @@ import { getBearerAuthHeaders } from './authHeaders'; import { projectSettingsErrorFromResponse } from './projectSettingsError'; +import { apiFetch } from './client'; export type ApiProjectMemberRole = 'owner' | 'member' | 'viewer'; export type ApiProjectMemberStatus = 'active' | 'exited'; @@ -34,7 +35,7 @@ async function memberApiError(res: Response, fallback: string) { } export async function listProjectMembers(projectId: number): Promise { - const res = await fetch(`/api/v1/projects/${projectId}/members`, { + const res = await apiFetch(`/api/v1/projects/${projectId}/members`, { headers: getBearerAuthHeaders(), }); if (!res.ok) { @@ -48,7 +49,7 @@ export async function addProjectMember( projectId: number, payload: UpsertProjectMemberPayload ): Promise { - const res = await fetch(`/api/v1/projects/${projectId}/members`, { + const res = await apiFetch(`/api/v1/projects/${projectId}/members`, { method: 'POST', headers: getBearerAuthHeaders('application/json'), body: JSON.stringify(payload), @@ -62,7 +63,7 @@ export async function updateProjectMember( projectId: number, payload: UpsertProjectMemberPayload ): Promise { - const res = await fetch(`/api/v1/projects/${projectId}/members`, { + const res = await apiFetch(`/api/v1/projects/${projectId}/members`, { method: 'PUT', headers: getBearerAuthHeaders('application/json'), body: JSON.stringify(payload), @@ -73,7 +74,7 @@ export async function updateProjectMember( } export async function removeProjectMember(projectId: number, userId: number): Promise { - const res = await fetch( + const res = await apiFetch( `/api/v1/projects/${projectId}/members?user_id=${encodeURIComponent(String(userId))}`, { method: 'DELETE', diff --git a/web/src/api/projectSettings.ts b/web/src/api/projectSettings.ts index d1de09d7..38237292 100644 --- a/web/src/api/projectSettings.ts +++ b/web/src/api/projectSettings.ts @@ -11,8 +11,7 @@ import { } from '../types/projectSettings'; import { getBearerAuthHeaders } from './authHeaders'; import { projectSettingsErrorFromResponse } from './projectSettingsError'; - -const API_BASE = ''; +import { apiFetch, API_BASE } from './client'; export async function fetchDefaultWorkflowPlan( projectId: number, @@ -21,7 +20,7 @@ export async function fetchDefaultWorkflowPlan( ): Promise { const params = new URLSearchParams({ workflow_key: workflowKey }); if (version) params.set('version', String(version)); - const res = await fetch( + const res = await apiFetch( `${API_BASE}/api/v1/projects/${projectId}/workflow-plan/default?${params}`, { headers: getBearerAuthHeaders() } ); @@ -33,7 +32,7 @@ export async function fetchDefaultWorkflowPlan( } export async function fetchProjectSettings(projectId: number): Promise { - const res = await fetch(`${API_BASE}/api/v1/projects/${projectId}/settings`, { + const res = await apiFetch(`${API_BASE}/api/v1/projects/${projectId}/settings`, { headers: getBearerAuthHeaders(), }); if (!res.ok) { @@ -50,7 +49,7 @@ export async function saveWorkflowPlan( expectedSelectionVersion: number, liveEdit = false ): Promise { - const res = await fetch(`${API_BASE}/api/v1/projects/${projectId}/workflow-plan`, { + const res = await apiFetch(`${API_BASE}/api/v1/projects/${projectId}/workflow-plan`, { method: 'PUT', headers: getBearerAuthHeaders('application/json'), body: JSON.stringify({ @@ -77,7 +76,7 @@ export async function reassignDocTaskDuties( duty_policy?: string; } ): Promise { - const res = await fetch( + const res = await apiFetch( `${API_BASE}/api/v1/projects/${projectId}/doc-tasks/${encodeURIComponent(taskKey)}/duties`, { method: 'PATCH', @@ -100,7 +99,7 @@ export async function reassignDocTaskDuties( } export async function fetchActivationPreview(projectId: number): Promise { - const res = await fetch(`${API_BASE}/api/v1/projects/${projectId}/activation-preview`, { + const res = await apiFetch(`${API_BASE}/api/v1/projects/${projectId}/activation-preview`, { method: 'POST', headers: getBearerAuthHeaders('application/json'), body: '{}', @@ -120,7 +119,7 @@ export async function activateWorkflowInstance( expected_plan_revision: number; } ): Promise { - const res = await fetch(`${API_BASE}/api/v1/projects/${projectId}/workflow-instances/activate`, { + const res = await apiFetch(`${API_BASE}/api/v1/projects/${projectId}/workflow-instances/activate`, { method: 'POST', headers: getBearerAuthHeaders('application/json'), body: JSON.stringify(input), @@ -136,7 +135,7 @@ export async function fetchStageReadiness( instanceId: number, stageKey: string ): Promise { - const res = await fetch( + const res = await apiFetch( `${API_BASE}/api/v1/workflow-instances/${instanceId}/stages/${encodeURIComponent(stageKey)}/readiness`, { headers: getBearerAuthHeaders() } ); @@ -154,7 +153,7 @@ export async function fetchProjectNotifications( const params = new URLSearchParams(); if (sinceId && sinceId > 0) params.set('since_id', String(sinceId)); const qs = params.toString(); - const res = await fetch( + const res = await apiFetch( `${API_BASE}/api/v1/projects/${projectId}/notifications${qs ? `?${qs}` : ''}`, { headers: getBearerAuthHeaders() } ); @@ -168,7 +167,7 @@ export async function fetchProjectNotifications( } export async function fetchWorkflowCatalog(): Promise { - const res = await fetch(`${API_BASE}/api/v1/workflows/catalog`, { + const res = await apiFetch(`${API_BASE}/api/v1/workflows/catalog`, { headers: getBearerAuthHeaders(), }); if (!res.ok) { @@ -179,7 +178,7 @@ export async function fetchWorkflowCatalog(): Promise { } export async function fetchTerminationPreview(instanceId: number): Promise { - const res = await fetch(`${API_BASE}/api/v1/workflow-instances/${instanceId}/termination-preview`, { + const res = await apiFetch(`${API_BASE}/api/v1/workflow-instances/${instanceId}/termination-preview`, { headers: getBearerAuthHeaders(), }); if (!res.ok) { @@ -193,7 +192,7 @@ export async function terminateWorkflowInstance( instanceId: number, input: { reason: string; confirm: boolean; expected_config_revision: number } ): Promise<{ instance: { id: number; status: string }; reason: string }> { - const res = await fetch(`${API_BASE}/api/v1/workflow-instances/${instanceId}/terminate`, { + const res = await apiFetch(`${API_BASE}/api/v1/workflow-instances/${instanceId}/terminate`, { method: 'POST', headers: getBearerAuthHeaders('application/json'), body: JSON.stringify(input), @@ -211,7 +210,7 @@ export async function advanceStage( expectedConfigRevision: number, acknowledgeRecheck = false ): Promise { - const res = await fetch( + const res = await apiFetch( `${API_BASE}/api/v1/workflow-instances/${instanceId}/stages/${encodeURIComponent(stageKey)}/advance`, { method: 'POST', diff --git a/web/src/api/projectTrace.ts b/web/src/api/projectTrace.ts index da4a1273..2320e7dc 100644 --- a/web/src/api/projectTrace.ts +++ b/web/src/api/projectTrace.ts @@ -1,6 +1,5 @@ import { getBearerAuthHeaders } from './authHeaders'; - -const API_BASE = ''; +import { apiFetch, API_BASE } from './client'; export interface TraceItem { id: number; @@ -34,7 +33,7 @@ export interface ProjectTraceView { } export async function fetchProjectTrace(projectId: number): Promise { - const res = await fetch(`${API_BASE}/api/v1/projects/${projectId}/trace`, { + const res = await apiFetch(`${API_BASE}/api/v1/projects/${projectId}/trace`, { headers: getBearerAuthHeaders(), }); if (!res.ok) { @@ -55,7 +54,7 @@ export async function clearRecheckMark( reason: 'no_change' | 'revision_started', messageId?: number, ): Promise { - const res = await fetch(`${API_BASE}/api/v1/projects/${projectId}/trace/recheck/${markId}/clear`, { + const res = await apiFetch(`${API_BASE}/api/v1/projects/${projectId}/trace/recheck/${markId}/clear`, { method: 'POST', headers: getBearerAuthHeaders('application/json'), body: JSON.stringify({ reason, message_id: messageId || 0 }), diff --git a/web/src/api/projectWorkflow.ts b/web/src/api/projectWorkflow.ts index 721d9315..6fbe7a32 100644 --- a/web/src/api/projectWorkflow.ts +++ b/web/src/api/projectWorkflow.ts @@ -6,11 +6,10 @@ import { CreateProjectInput, } from '../types/projectWorkflow'; import { getBearerAuthHeaders } from './authHeaders'; - -const API_BASE = ''; +import { apiFetch, API_BASE } from './client'; export async function fetchProjects(): Promise { - const res = await fetch(`${API_BASE}/api/v1/projects`, { + const res = await apiFetch(`${API_BASE}/api/v1/projects`, { headers: getBearerAuthHeaders(), }); if (!res.ok) { @@ -23,7 +22,7 @@ export async function fetchProjects(): Promise { export async function fetchProjectTypesCatalog(): Promise< Array<{ key: string; name: string; description: string; default_workflow_key: string }> > { - const res = await fetch(`${API_BASE}/api/v1/project-types/catalog`, { + const res = await apiFetch(`${API_BASE}/api/v1/project-types/catalog`, { headers: getBearerAuthHeaders(), }); if (!res.ok) { @@ -34,7 +33,7 @@ export async function fetchProjectTypesCatalog(): Promise< } export async function createProject(data: CreateProjectInput): Promise { - const res = await fetch(`${API_BASE}/api/v1/projects`, { + const res = await apiFetch(`${API_BASE}/api/v1/projects`, { method: 'POST', headers: getBearerAuthHeaders('application/json'), body: JSON.stringify(data), @@ -47,7 +46,7 @@ export async function createProject(data: CreateProjectInput): Promise } export async function getProject(id: number): Promise { - const res = await fetch(`${API_BASE}/api/v1/projects/${id}`, { + const res = await apiFetch(`${API_BASE}/api/v1/projects/${id}`, { headers: getBearerAuthHeaders(), }); if (!res.ok) { @@ -57,7 +56,7 @@ export async function getProject(id: number): Promise { } export async function fetchProjectWorkflows(projectId: number): Promise { - const res = await fetch(`${API_BASE}/api/v1/projects/${projectId}/workflows`, { + const res = await apiFetch(`${API_BASE}/api/v1/projects/${projectId}/workflows`, { headers: getBearerAuthHeaders(), }); if (!res.ok) { @@ -67,7 +66,7 @@ export async function fetchProjectWorkflows(projectId: number): Promise { - const res = await fetch(`${API_BASE}/api/v1/workflows`, { + const res = await apiFetch(`${API_BASE}/api/v1/workflows`, { headers: getBearerAuthHeaders(), }); if (!res.ok) { @@ -83,7 +82,7 @@ export async function fetchProjectLineage( const url = docId ? `${API_BASE}/api/v1/projects/${projectId}/lineage?doc_id=${encodeURIComponent(docId)}` : `${API_BASE}/api/v1/projects/${projectId}/lineage`; - const res = await fetch(url, { + const res = await apiFetch(url, { headers: getBearerAuthHeaders(), }); if (!res.ok) { @@ -97,7 +96,7 @@ export async function runProjectWorkflow( workflowKey: string, payload: Record ): Promise { - const res = await fetch(`${API_BASE}/api/v1/projects/${projectId}/workflows/${workflowKey}/run`, { + const res = await apiFetch(`${API_BASE}/api/v1/projects/${projectId}/workflows/${workflowKey}/run`, { method: 'POST', headers: getBearerAuthHeaders('application/json'), body: JSON.stringify(payload), diff --git a/web/src/api/systemAdmin.ts b/web/src/api/systemAdmin.ts index 1e334aab..e82d54b9 100644 --- a/web/src/api/systemAdmin.ts +++ b/web/src/api/systemAdmin.ts @@ -1,4 +1,4 @@ -import { authorizedFetch } from './authHeaders'; +import { apiFetch } from './client'; import type { SystemConfigValue, SystemFeature, @@ -22,7 +22,7 @@ async function readError(resp: Response, fallback: string): Promise { } async function getJson(path: string, fallback: string): Promise { - const resp = await authorizedFetch(path); + const resp = await apiFetch(path); if (!resp.ok) throw await readError(resp, fallback); return resp.json() as Promise; } @@ -54,7 +54,7 @@ export async function updateSystemSetting( if (expectedRevision !== undefined) { body.expected_revision = expectedRevision; } - const resp = await authorizedFetch(`${BASE}/settings/${encodeURIComponent(key)}`, { + const resp = await apiFetch(`${BASE}/settings/${encodeURIComponent(key)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), diff --git a/web/src/api/taskRuns.ts b/web/src/api/taskRuns.ts index e01980f2..a470af5d 100644 --- a/web/src/api/taskRuns.ts +++ b/web/src/api/taskRuns.ts @@ -1,6 +1,7 @@ import { parseSSEChunk, type StreamEvent } from './chatStream'; import { mergeAuthHeaders, getBearerAuthHeaders } from './authHeaders'; import { projectSettingsErrorFromResponse } from './projectSettingsError'; +import { apiFetch } from './client'; export type CreateTaskRunBody = Record & { session_id: string; @@ -17,7 +18,7 @@ export type CreateTaskRunResponse = { }; export async function createTaskRun(body: CreateTaskRunBody): Promise { - const res = await fetch('/api/v1/tasks/runs', { + const res = await apiFetch('/api/v1/tasks/runs', { method: 'POST', headers: getBearerAuthHeaders('application/json'), body: JSON.stringify(body), @@ -60,11 +61,15 @@ export async function readTaskRunSSEOnce( ): Promise<{ terminal: boolean; lastSeq: number }> { const replay = opts?.replayOnly ? '&replay_only=1' : ''; const url = `/api/v1/tasks/runs/${encodeURIComponent(runId)}/events?after_seq=${afterSeq}${replay}`; - const resp = await fetch(url, { - method: 'GET', - headers: mergeAuthHeaders({ Accept: 'text/event-stream' }), - signal, - }); + const resp = await apiFetch( + url, + { + method: 'GET', + headers: mergeAuthHeaders({ Accept: 'text/event-stream' }), + signal, + }, + { retry: false }, + ); if (!resp.ok || !resp.body) { throw new TaskRunHttpError(resp.status); } @@ -141,7 +146,7 @@ export async function subscribeTaskRunEvents( } export async function resumeTaskRun(runId: string): Promise { - const res = await fetch(`/api/v1/tasks/runs/${encodeURIComponent(runId)}/resume`, { + const res = await apiFetch(`/api/v1/tasks/runs/${encodeURIComponent(runId)}/resume`, { method: 'POST', headers: getBearerAuthHeaders('application/json'), body: JSON.stringify({ confirm: true }), @@ -154,7 +159,7 @@ export async function resumeTaskRun(runId: string): Promise { - const res = await fetch(`/api/v1/tasks/runs/${encodeURIComponent(runId)}/cancel`, { + const res = await apiFetch(`/api/v1/tasks/runs/${encodeURIComponent(runId)}/cancel`, { method: 'POST', headers: getBearerAuthHeaders(), }); @@ -164,7 +169,7 @@ export async function cancelTaskRun(runId: string): Promise { } export async function getTaskRun(runId: string) { - const res = await fetch(`/api/v1/tasks/runs/${encodeURIComponent(runId)}`, { + const res = await apiFetch(`/api/v1/tasks/runs/${encodeURIComponent(runId)}`, { headers: getBearerAuthHeaders(), }); if (!res.ok) { diff --git a/web/src/api/taskStream.ts b/web/src/api/taskStream.ts index 53f7ac76..ebf0d676 100644 --- a/web/src/api/taskStream.ts +++ b/web/src/api/taskStream.ts @@ -2,6 +2,7 @@ import { parseSSEChunk, type StreamEvent } from './chatStream'; import { canonicalDocIdForTask } from '../lib/controlledDocHydrate'; import type { TaskRunContext } from '../types/workbench'; import { mergeAuthHeaders } from './authHeaders'; +import { apiFetch } from './client'; export type { TaskRunContext }; @@ -17,12 +18,16 @@ export async function streamTask( }, signal?: AbortSignal, ): Promise { - const resp = await fetch('/api/v1/tasks/run/stream', { - method: 'POST', - headers: mergeAuthHeaders({ Accept: 'text/event-stream' }, 'application/json'), - body: JSON.stringify(body), - signal, - }); + const resp = await apiFetch( + '/api/v1/tasks/run/stream', + { + method: 'POST', + headers: mergeAuthHeaders({ Accept: 'text/event-stream' }, 'application/json'), + body: JSON.stringify(body), + signal, + }, + { retry: false }, + ); const rid = resp.headers.get('X-Request-Id') || resp.headers.get('x-request-id'); if (rid) handlers.onRequestId?.(rid); if (!resp.ok || !resp.body) { diff --git a/web/src/api/workflowAdmin.ts b/web/src/api/workflowAdmin.ts index 7ff33a0e..6c9a8272 100644 --- a/web/src/api/workflowAdmin.ts +++ b/web/src/api/workflowAdmin.ts @@ -1,4 +1,4 @@ -import { authClient } from './auth'; +import { apiFetch } from './client'; export interface WorkflowDefinitionSummary { id: number; @@ -24,19 +24,8 @@ export interface ValidationIssue { field?: string; } -async function adminFetch(path: string, init?: RequestInit): Promise { - const token = authClient.getSessionToken(); - return fetch(path, { - ...init, - headers: { - ...(init?.headers ?? {}), - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - }); -} - export async function listAdminWorkflows(): Promise { - const resp = await adminFetch('/api/v1/admin/workflows'); + const resp = await apiFetch('/api/v1/admin/workflows'); if (!resp.ok) throw new Error('获取工作流列表失败'); const data = await resp.json(); return data.workflows ?? []; @@ -46,7 +35,7 @@ export async function getAdminWorkflow(id: number): Promise<{ definition: WorkflowDefinitionSummary; versions: WorkflowVersionSummary[]; }> { - const resp = await adminFetch(`/api/v1/admin/workflows/${id}`); + const resp = await apiFetch(`/api/v1/admin/workflows/${id}`); if (!resp.ok) throw new Error('获取工作流详情失败'); return resp.json(); } @@ -55,7 +44,7 @@ export async function validateWorkflowVersion(workflowId: number, version: numbe valid: boolean; issues: ValidationIssue[]; }> { - const resp = await adminFetch(`/api/v1/admin/workflows/${workflowId}/versions/${version}/validate`, { + const resp = await apiFetch(`/api/v1/admin/workflows/${workflowId}/versions/${version}/validate`, { method: 'POST', }); if (!resp.ok) throw new Error('校验失败'); @@ -63,7 +52,7 @@ export async function validateWorkflowVersion(workflowId: number, version: numbe } export async function publishWorkflowVersion(workflowId: number, version: number): Promise { - const resp = await adminFetch(`/api/v1/admin/workflows/${workflowId}/versions/${version}/publish`, { + const resp = await apiFetch(`/api/v1/admin/workflows/${workflowId}/versions/${version}/publish`, { method: 'POST', }); if (!resp.ok) { @@ -75,7 +64,7 @@ export async function publishWorkflowVersion(workflowId: number, version: number export async function listAdminTemplates(): Promise< Array<{ id: number; asset_key: string; name: string; status: string }> > { - const resp = await adminFetch('/api/v1/admin/templates'); + const resp = await apiFetch('/api/v1/admin/templates'); if (!resp.ok) throw new Error('获取模板列表失败'); const data = await resp.json(); return data.templates ?? [];