import type { ApiPayload } from '@/types/api'; import { clearAuth, getAuth, redirectToLoginForAuthLoss } from './auth'; import { isNativeAppRuntime } from './speech-capture'; const configuredApiBase = import.meta.env.VITE_API_BASE; const currentApiBase = () => configuredApiBase || (isNativeAppRuntime() ? 'https://peilian.njzhmj.top/dev-api' : '/dev-api'); type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'; export interface RequestOptions { url: string; method?: HttpMethod; data?: unknown; header?: Record; timeout?: number; auth?: boolean; clearAuthOnUnauthorized?: boolean; } export class ApiError extends Error { readonly statusCode: number; readonly bizCode?: number; constructor(message: string, statusCode: number, bizCode?: number) { super(message); this.name = 'ApiError'; this.statusCode = statusCode; this.bizCode = bizCode; } } export const isAuthApiError = (error: unknown): boolean => error instanceof ApiError && (error.statusCode === 401 || error.statusCode === 403 || error.bizCode === 401 || error.bizCode === 403); const normalizeUrl = (url: string) => `${currentApiBase()}${url.startsWith('/') ? url : `/${url}`}`; export const apiUrl = normalizeUrl; export const apiMediaUrl = (url: string) => { const normalized = normalizeUrl(url); if (/^https?:\/\//i.test(normalized) || typeof window === 'undefined') return normalized; const origin = window.location?.origin; return /^https?:\/\//i.test(origin || '') ? new URL(normalized, `${origin}/`).toString() : normalized; }; export const authHeaders = (json = true, includeAuth = true) => { const auth = getAuth(); return { ...(json ? { 'Content-Type': 'application/json' } : {}), ...(includeAuth && auth.token ? { Authorization: `Bearer ${auth.token}` } : {}), ...(includeAuth && auth.clientId ? { clientid: auth.clientId } : {}) }; }; export const readPayload = (statusCode: number, data: unknown, clearAuthOnUnauthorized = true): T => { const payload = data as ApiPayload; const code = typeof payload?.code === 'number' ? payload.code : undefined; if (statusCode < 200 || statusCode >= 300 || (code !== undefined && code !== 200)) { // Only a real login failure (401) drops the local session; business 403 // (permission denied) must surface as a message, never a silent logout. if (clearAuthOnUnauthorized && (statusCode === 401 || code === 401)) { clearAuth(); redirectToLoginForAuthLoss(); } throw new ApiError(payload?.msg || `HTTP ${statusCode}`, statusCode, code); } return payload?.data as T; }; export const parsePayloadText = (statusCode: number, text: string, clearAuthOnUnauthorized = true) => { try { return JSON.parse(text || '{}'); } catch (error) { if (clearAuthOnUnauthorized && statusCode === 401) { clearAuth(); redirectToLoginForAuthLoss(); } throw error instanceof SyntaxError ? new ApiError(statusCode >= 400 ? `HTTP ${statusCode}` : '响应解析失败', statusCode) : error; } }; export const readTextPayload = (statusCode: number, text: string): T => readPayload(statusCode, parsePayloadText(statusCode, text)); const shouldUseXhr = () => !isNativeAppRuntime() && typeof XMLHttpRequest !== 'undefined'; const requestWithXhr = async (options: RequestOptions): Promise => { const method = options.method || 'GET'; const hasBody = method !== 'GET' && options.data !== undefined; const response = await new Promise<{ status: number; data: unknown }>((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open(method, normalizeUrl(options.url)); xhr.timeout = options.timeout || 30000; Object.entries({ ...authHeaders(hasBody, options.auth !== false), ...options.header }).forEach(([key, value]) => xhr.setRequestHeader(key, value)); xhr.onload = () => { try { resolve({ status: xhr.status, data: parsePayloadText(xhr.status, xhr.responseText || 'null', options.clearAuthOnUnauthorized !== false) }); } catch (error) { reject(error instanceof ApiError ? error : new Error(xhr.status >= 400 ? `HTTP ${xhr.status}` : '响应解析失败')); } }; xhr.onerror = () => reject(new Error('网络请求失败')); xhr.ontimeout = () => reject(new Error('网络请求超时')); xhr.send(hasBody ? JSON.stringify(options.data) : undefined); }); return readPayload(response.status, response.data, options.clearAuthOnUnauthorized !== false); }; export const apiRequest = async (options: RequestOptions): Promise => { if (shouldUseXhr()) { return requestWithXhr(options); } const timeout = options.timeout || 30000; const response = await new Promise((resolve, reject) => { let finished = false; let timer: ReturnType | undefined; const finish = (callback: () => void) => { if (finished) return; finished = true; if (timer) clearTimeout(timer); callback(); }; const task = uni.request({ url: normalizeUrl(options.url), method: options.method || 'GET', data: options.data as string | ArrayBuffer | Record | undefined, header: { ...authHeaders(options.method !== 'GET', options.auth !== false), ...options.header }, timeout, success: (result) => finish(() => resolve(result)), fail: (error) => finish(() => reject(error)) }); if (!finished) { timer = setTimeout(() => finish(() => { try { task.abort(); } finally { reject(new Error('网络请求超时')); } }), timeout); } }); return readPayload(response.statusCode, response.data, options.clearAuthOnUnauthorized !== false); }; export const authenticatedFileUrl = async (url: string): Promise => { const fullUrl = normalizeUrl(url); if (shouldUseXhr() && typeof URL !== 'undefined') { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('GET', fullUrl); xhr.responseType = 'blob'; Object.entries(authHeaders(false)).forEach(([key, value]) => xhr.setRequestHeader(key, value)); xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { resolve(URL.createObjectURL(xhr.response)); return; } if (xhr.status === 401) { clearAuth(); redirectToLoginForAuthLoss(); } reject(new Error(`HTTP ${xhr.status}`)); }; xhr.onerror = () => reject(new Error('网络请求失败')); xhr.send(); }); } return new Promise((resolve, reject) => { uni.downloadFile({ url: fullUrl, header: authHeaders(false), success: (response) => { if (response.statusCode >= 200 && response.statusCode < 300) { resolve(response.tempFilePath); return; } if (response.statusCode === 401) { clearAuth(); redirectToLoginForAuthLoss(); } reject(new Error(`HTTP ${response.statusCode}`)); }, fail: () => reject(new Error('网络请求失败')) }); }); }; export const releaseAuthenticatedFileUrl = (url: string) => { if (url.startsWith('blob:') && typeof URL !== 'undefined') { URL.revokeObjectURL(url); } };