// Filename: frontend/js/services/api.js class APIClientError extends Error { constructor(message, status, code, rawMessageFromServer) { super(message); this.name = 'APIClientError'; this.status = status; this.code = code; this.rawMessageFromServer = rawMessageFromServer; } } // Global Promise cache for raw responses const apiPromiseCache = new Map(); /** * [CORRECTED & CACHE-AWARE] A low-level fetch wrapper. * It handles caching, authentication, and centralized error handling. * On success (2xx), it returns the raw, unread Response object. * On failure (non-2xx), it consumes the body to throw a detailed APIClientError. */ export async function apiFetch(url, options = {}) { // For non-GET requests or noCache requests, we bypass the promise cache. const isGetRequest = !options.method || options.method.toUpperCase() === 'GET'; const cacheKey = isGetRequest && !options.noCache ? url : null; if (cacheKey && apiPromiseCache.has(cacheKey)) { return apiPromiseCache.get(cacheKey); } const token = localStorage.getItem('bearerToken'); const headers = { 'Content-Type': 'application/json', ...options.headers, }; if (token) { headers['Authorization'] = `Bearer ${token}`; } const requestPromise = (async () => { try { const response = await fetch(url, { ...options, headers }); if (response.status === 401) { // On auth error, always clear caches for this URL. if (cacheKey) apiPromiseCache.delete(cacheKey); // ... (rest of the 401 logic is correct) localStorage.removeItem('bearerToken'); if (window.location.pathname !== '/login') { window.location.href = '/login?error=会话已过期,请重新登录。'; } throw new APIClientError('Unauthorized', 401, 'UNAUTHORIZED', 'Session expired or token is invalid.'); } if (!response.ok) { let errorData = null; let rawMessage = ''; try { // This is the ONLY place the body is consumed in the failure path. rawMessage = await response.text(); if(rawMessage) { // Avoid parsing empty string errorData = JSON.parse(rawMessage); } } catch (e) { errorData = { error: { code: 'UNKNOWN_FORMAT', message: rawMessage || response.statusText } }; } const code = errorData?.error?.code || 'UNKNOWN_ERROR'; const messageFromServer = errorData?.error?.message || rawMessage || 'No message provided by server.'; const error = new APIClientError( `API request failed: ${response.status}`, response.status, code, messageFromServer ); // Throwing the error will cause this promise to reject. throw error; } // On success, the promise resolves with the PRISTINE response object. return response; } catch (error) { // If an error occurred (either thrown by us or a network error), // ensure the promise cache is cleared for this key before re-throwing. if (cacheKey) apiPromiseCache.delete(cacheKey); throw error; // Re-throw to propagate the failure. } })(); // If we are caching, store the promise. if (cacheKey) { apiPromiseCache.set(cacheKey, requestPromise); } return requestPromise; } /** * [CORRECTED & CACHE-AWARE] High-level wrapper that expects a JSON response. * It leverages apiFetch and is ONLY responsible for calling .json() on a successful response. */ export async function apiFetchJson(url, options = {}) { try { // 1. Get the raw response from apiFetch. It's either fresh or from the promise cache. // If it fails, apiFetch will throw, and this function will propagate the error. const response = await apiFetch(url, options); // 2. We have a successful response. We need to clone it before reading the body. // This is CRITICAL because the original response in the promise cache MUST remain unread. const clonedResponse = response.clone(); // 3. Now we can safely consume the body of the CLONE. const jsonData = await clonedResponse.json(); return jsonData; } catch (error) { // Just propagate the detailed error from apiFetch. throw error; } }