Files
gemini-banlancer/frontend/js/services/api.js
2025-11-20 12:24:05 +08:00

125 lines
4.7 KiB
JavaScript

// 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;
}
}