Files
frontend/src/services/api.ts
lan be84c01abd Migrate frontend realtime messaging to SSE.
Switch service integrations and screen/store consumers from websocket events to SSE, and ignore generated dist-web artifacts.

Made-with: Cursor
2026-03-10 12:58:23 +08:00

338 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* API 客户端
* 使用 fetch API 进行 HTTP 请求
* 支持请求/响应拦截器
*/
import AsyncStorage from '@react-native-async-storage/async-storage';
import { CommonActions } from '@react-navigation/native';
import Constants from 'expo-constants';
// 生产地址 https://bbs.littlelan.cn
const getBaseUrl = () => {
const configuredBaseUrl = Constants.expoConfig?.extra?.apiBaseUrl;
if (typeof configuredBaseUrl === 'string' && configuredBaseUrl.trim().length > 0) {
return configuredBaseUrl;
}
return 'https://bbs.littlelan.cn/api/v1';
};
const BASE_URL = getBaseUrl();
const SSE_URL = `${BASE_URL.replace(/\/+$/, '')}/realtime/sse`;
// Token 存储键
const TOKEN_KEY = 'auth_token';
const REFRESH_TOKEN_KEY = 'refresh_token';
// API 响应格式
export interface ApiResponse<T> {
code: number;
message: string;
data: T;
}
// 分页响应格式
export interface PaginatedData<T> {
list: T[];
total: number;
page: number;
page_size: number;
total_pages: number;
}
// 错误类型
export class ApiError extends Error {
code: number;
message: string;
constructor(code: number, message: string) {
super(message);
this.code = code;
this.message = message;
this.name = 'ApiError';
}
}
// API 客户端类
class ApiClient {
private baseUrl: string;
private navigation: any = null;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
// 设置导航对象用于处理401跳转
setNavigation(navigation: any) {
this.navigation = navigation;
}
// 获取 token
async getToken(): Promise<string | null> {
try {
return await AsyncStorage.getItem(TOKEN_KEY);
} catch (error) {
console.error('获取token失败:', error);
return null;
}
}
// 保存 token
async setToken(token: string): Promise<void> {
try {
await AsyncStorage.setItem(TOKEN_KEY, token);
} catch (error) {
console.error('保存token失败:', error);
}
}
// 获取刷新 token
async getRefreshToken(): Promise<string | null> {
try {
return await AsyncStorage.getItem(REFRESH_TOKEN_KEY);
} catch (error) {
console.error('获取刷新token失败:', error);
return null;
}
}
// 保存刷新 token
async setRefreshToken(token: string): Promise<void> {
try {
await AsyncStorage.setItem(REFRESH_TOKEN_KEY, token);
} catch (error) {
console.error('保存刷新token失败:', error);
}
}
// 清除 token
async clearToken(): Promise<void> {
try {
await AsyncStorage.multiRemove([TOKEN_KEY, REFRESH_TOKEN_KEY]);
} catch (error) {
console.error('清除token失败:', error);
}
}
// 统一请求方法
private async request<T>(
method: string,
path: string,
params?: Record<string, any>,
body?: any
): Promise<ApiResponse<T>> {
const url = new URL(`${this.baseUrl}${path}`);
// 添加查询参数
if (params) {
Object.keys(params).forEach(key => {
if (params[key] !== undefined && params[key] !== null) {
url.searchParams.append(key, String(params[key]));
}
});
}
// 构建请求头
const headers: HeadersInit = {
'Content-Type': 'application/json',
};
// 添加认证 token
const token = await this.getToken();
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
// 构建请求配置
const config: RequestInit = {
method,
headers,
};
// 添加请求体
if (body && method !== 'GET') {
config.body = JSON.stringify(body);
}
try {
const response = await fetch(url.toString(), config);
// 处理 401 未授权
if (response.status === 401) {
// 尝试刷新 token
const refreshed = await this.refreshToken();
if (!refreshed) {
// 刷新失败,清除 token 并跳转登录
await this.clearToken();
if (this.navigation) {
this.navigation.dispatch(
CommonActions.reset({
index: 0,
routes: [{ name: 'Login' }],
})
);
}
throw new ApiError(401, '登录已过期,请重新登录');
}
// 刷新成功后重试请求
return this.request(method, path, params, body);
}
// 解析响应(兼容非 JSON 返回,避免 SyntaxError 被误判为网络错误)
const contentType = response.headers.get('content-type') || '';
let parsedBody: any = null;
let rawText = '';
if (contentType.includes('application/json')) {
parsedBody = await response.json();
} else {
rawText = await response.text();
if (rawText) {
try {
parsedBody = JSON.parse(rawText);
} catch {
parsedBody = null;
}
}
}
// 优先处理标准 API 结构
if (parsedBody && typeof parsedBody === 'object' && 'code' in parsedBody) {
const data = parsedBody as ApiResponse<T>;
if (data.code !== 0) {
throw new ApiError(data.code, data.message || '请求失败');
}
return data;
}
// 非标准结构:先按 HTTP 状态处理失败
if (!response.ok) {
const fallbackMessage = rawText || response.statusText || `请求失败(${response.status})`;
throw new ApiError(response.status, fallbackMessage);
}
// 非标准结构但 HTTP 成功:兜底为成功响应(兼容部分纯文本成功接口)
return {
code: 0,
message: 'success',
data: (parsedBody as T) ?? (undefined as T),
};
} catch (error) {
// 如果是 ApiError直接抛出
if (error instanceof ApiError) {
throw error;
}
// 网络错误
console.error('API请求失败:', error);
throw new ApiError(500, '网络请求失败,请检查网络连接');
}
}
// 刷新 token
private async refreshToken(): Promise<boolean> {
try {
const refreshToken = await this.getRefreshToken();
if (!refreshToken) {
return false;
}
const response = await fetch(`${this.baseUrl}/auth/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${refreshToken}`,
},
});
if (!response.ok) {
return false;
}
const data = await response.json();
if (data.code === 0 && data.data?.token) {
await this.setToken(data.data.token);
// 后端返回refresh_token (snake_case),兼容两种命名
if (data.data.refresh_token || data.data.refreshToken) {
await this.setRefreshToken(data.data.refresh_token || data.data.refreshToken);
}
return true;
}
return false;
} catch (error) {
console.error('刷新token失败:', error);
return false;
}
}
// GET 请求
async get<T>(path: string, params?: Record<string, any>): Promise<ApiResponse<T>> {
return this.request<T>('GET', path, params);
}
// POST 请求
async post<T>(path: string, body?: any): Promise<ApiResponse<T>> {
return this.request<T>('POST', path, undefined, body);
}
// PUT 请求
async put<T>(path: string, body?: any): Promise<ApiResponse<T>> {
return this.request<T>('PUT', path, undefined, body);
}
// DELETE 请求
async delete<T>(path: string, body?: any): Promise<ApiResponse<T>> {
return this.request<T>('DELETE', path, undefined, body);
}
// 上传文件
async upload<T>(
path: string,
file: { uri: string; name: string; type: string },
additionalData?: Record<string, string>
): Promise<ApiResponse<T>> {
const formData = new FormData();
// 添加文件使用image字段名
formData.append('image', {
uri: file.uri,
name: file.name,
type: file.type,
} as any);
// 添加额外数据
if (additionalData) {
Object.keys(additionalData).forEach(key => {
formData.append(key, additionalData[key]);
});
}
const token = await this.getToken();
const headers: HeadersInit = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${this.baseUrl}${path}`, {
method: 'POST',
headers,
body: formData,
});
const data: ApiResponse<T> = await response.json();
if (data.code !== 0) {
throw new ApiError(data.code, data.message);
}
return data;
}
}
// 导出 API 客户端实例
export const api = new ApiClient(BASE_URL);
export { SSE_URL, TOKEN_KEY, REFRESH_TOKEN_KEY };