Files
video-gen/video-gen-app/src/api/client.ts
T
root e21ba57597 修改Web Crypto API 在 HTTP 环境下不可用的问题,自动降级
- HTTPS/localhost :正常启用加密
- HTTP(非 localhost) :自动禁用加密,以明文方式通信
2026-06-26 14:56:03 +08:00

135 lines
3.9 KiB
TypeScript

/**
* Real API client for connecting to the FastAPI backend.
* Handles auth tokens, request/response encryption, snake_case→camelCase conversion.
*/
import { encrypt, decrypt, isCryptoAvailable } from './crypto';
const BASE_URL = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
const USE_ENCRYPTION = !!import.meta.env.VITE_ENCRYPTION_KEY && isCryptoAvailable();
interface RequestOptions {
method?: string;
body?: unknown;
auth?: boolean;
encryptBody?: boolean;
signal?: AbortSignal;
skipAuthRedirect?: boolean;
}
/** Convert snake_case string to camelCase */
function toCamel(s: string): string {
return s.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
}
/** Recursively convert all snake_case keys in an object/array to camelCase */
function keysToCamel(obj: unknown): unknown {
if (Array.isArray(obj)) return obj.map(keysToCamel);
if (obj !== null && typeof obj === 'object') {
return Object.fromEntries(
Object.entries(obj as Record<string, unknown>).map(([k, v]) => [toCamel(k), keysToCamel(v)])
);
}
return obj;
}
function getToken(): string | null {
return localStorage.getItem('auth_token');
}
export function setToken(token: string): void {
localStorage.setItem('auth_token', token);
}
export function clearToken(): void {
localStorage.removeItem('auth_token');
}
/** Try to decrypt response data, return null if it fails */
async function tryDecrypt(data: string): Promise<string | null> {
try {
return await decrypt(data);
} catch {
return null;
}
}
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { method = 'GET', body, auth = true, encryptBody = USE_ENCRYPTION, signal } = options;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (auth) {
const token = getToken();
if (token) headers['Authorization'] = `Bearer ${token}`;
}
let bodyStr: string | undefined;
if (body !== undefined) {
const json = JSON.stringify(body);
if (encryptBody) {
headers['X-Encrypted'] = 'true';
bodyStr = JSON.stringify({ data: await encrypt(json) });
} else {
bodyStr = json;
}
}
const res = await fetch(`${BASE_URL}/api${path}`, {
method,
headers,
body: bodyStr,
signal,
});
if (res.status === 204) return undefined as T;
const text = await res.text();
if (!text) return undefined as T;
// Parse the response body
let parsed: any;
try {
parsed = JSON.parse(text);
} catch {
throw new Error(`响应解析失败 (${res.status})`);
}
// If the response is encrypted, decrypt it
const hasEncryptedData = parsed && typeof parsed.data === 'string';
if (hasEncryptedData && encryptBody) {
const decrypted = await tryDecrypt(parsed.data);
if (decrypted !== null) {
parsed = JSON.parse(decrypted);
}
// If decryption fails, fall through — parsed still has {data: "..."}
// which means the response was NOT actually encrypted (just has a "data" field)
}
// Handle error responses
if (!res.ok) {
const msg = parsed?.detail || `请求失败 (${res.status})`;
if (res.status === 401 && !options.skipAuthRedirect) {
clearToken();
window.location.href = '/login';
}
throw new Error(msg);
}
return keysToCamel(parsed) as T;
}
// Convenience methods
export const api = {
get: <T>(path: string, options: boolean | { auth?: boolean; signal?: AbortSignal; skipAuthRedirect?: boolean } = true) => {
const opts = typeof options === 'boolean' ? { auth: options } : options;
return apiRequest<T>(path, opts);
},
post: <T>(path: string, body?: unknown, auth = true, skipAuthRedirect = false) =>
apiRequest<T>(path, { method: 'POST', body, auth, skipAuthRedirect }),
put: <T>(path: string, body?: unknown, auth = true) => apiRequest<T>(path, { method: 'PUT', body, auth }),
delete: <T>(path: string, auth = true) => apiRequest<T>(path, { method: 'DELETE', auth }),
};