Files
frontend/src/services/api.ts
lafay 126e204592
Some checks failed
Frontend CI / build-and-push-web (push) Successful in 2m48s
Frontend CI / ota-android (push) Successful in 11m3s
Frontend CI / build-android-apk (push) Has been cancelled
refactor(Post, PostMapper, PostRepository, CreatePostScreen, HomeScreen): update communityId to channelId for improved clarity
- Renamed communityId to channelId across Post entity, PostMapper, and PostRepository for consistency and clarity.
- Updated CreatePostScreen to include channel selection functionality, enhancing user experience when creating posts.
- Adjusted HomeScreen to support filtering posts by channel, improving content organization.
- Refactored related interfaces and services to align with the new channelId terminology, ensuring a cohesive codebase.
2026-03-24 22:27:33 +08:00

445 lines
12 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 请求
* 支持请求/响应拦截器
* 支持主动刷新 token快过期时自动刷新
*/
import AsyncStorage from '@react-native-async-storage/async-storage';
import { CommonActions } from '@react-navigation/native';
import Constants from 'expo-constants';
import { Platform } from 'react-native';
// 生产地址 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';
// Token 提前刷新阈值(秒):提前 5 分钟刷新
const TOKEN_REFRESH_THRESHOLD_SECONDS = 5 * 60;
// 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';
}
}
// JWT Payload 结构
interface JwtPayload {
user_id: string;
username: string;
exp?: number;
iat?: number;
iss?: string;
}
// API 客户端类
class ApiClient {
private baseUrl: string;
private navigation: any = null;
// 刷新锁:防止并发刷新
private refreshLock: Promise<boolean> | null = null;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
// 设置导航对象用于处理401跳转
setNavigation(navigation: any) {
this.navigation = navigation;
}
// 解析 JWT token 获取 payload
private parseJwt(token: string): JwtPayload | null {
try {
const parts = token.split('.');
if (parts.length !== 3) {
return null;
}
// React Native/Expo 环境使用 atob
const payload = JSON.parse(atob(parts[1]));
return payload as JwtPayload;
} catch {
return null;
}
}
// 检查 token 是否快过期(返回 true 表示需要刷新)
private isTokenExpiringSoon(token: string): boolean {
const payload = this.parseJwt(token);
if (!payload || !payload.exp) {
return false;
}
const now = Math.floor(Date.now() / 1000);
const remaining = payload.exp - now;
// 剩余时间小于阈值时需要刷新
return remaining < TOKEN_REFRESH_THRESHOLD_SECONDS && remaining > 0;
}
// 获取 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主动刷新快过期的 token
const token = await this.getToken();
if (token) {
// 检查 token 是否快过期,如果是则主动刷新
if (this.isTokenExpiringSoon(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, '登录已过期,请重新登录');
}
// 刷新成功,使用新 token
const newToken = await this.getToken();
if (newToken) {
headers['Authorization'] = `Bearer ${newToken}`;
}
} else {
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> {
// 如果已有刷新操作正在进行,等待其完成
if (this.refreshLock) {
return this.refreshLock;
}
// 创建刷新锁
this.refreshLock = this.doRefreshToken();
try {
const result = await this.refreshLock;
return result;
} finally {
// 释放刷新锁
this.refreshLock = null;
}
}
// 实际执行刷新 token
private async doRefreshToken(): 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',
},
body: JSON.stringify({
refresh_token: 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 字段名)
// Web 端需要 append Blob/File原生端可以直接传 { uri, name, type }。
if (Platform.OS === 'web') {
const fileResponse = await fetch(file.uri);
const blob = await fileResponse.blob();
const uploadFile = new File([blob], file.name, { type: file.type || blob.type || 'application/octet-stream' });
formData.append('image', uploadFile);
} else {
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]);
});
}
// 获取 token 并检查是否需要刷新
const token = await this.getToken();
const headers: HeadersInit = {};
if (token) {
// 检查 token 是否快过期
if (this.isTokenExpiringSoon(token)) {
const refreshed = await this.refreshToken();
if (refreshed) {
const newToken = await this.getToken();
if (newToken) {
headers['Authorization'] = `Bearer ${newToken}`;
}
}
} else {
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 };