Replace expo-notifications with JPush for push notifications across the app. This includes adding JPush native module dependencies, configuring the JPush plugin in app.json, implementing jpushService wrapper, and updating notification services to use JPush APIs. Also adds device registration on token refresh for both mobile and web platforms. BREAKING CHANGE: expo-notifications removed in favor of JPush for push notifications
507 lines
14 KiB
TypeScript
507 lines
14 KiB
TypeScript
/**
|
||
* API 客户端
|
||
* 使用 fetch API 进行 HTTP 请求
|
||
* 支持请求/响应拦截器
|
||
* 支持主动刷新 token(快过期时自动刷新)
|
||
*/
|
||
|
||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||
import Constants from 'expo-constants';
|
||
import { Platform } from 'react-native';
|
||
|
||
import { eventBus } from '@/core/events/EventBus';
|
||
|
||
// 生产地址 https://withyou.littlelan.cn
|
||
const getBaseUrl = () => {
|
||
const configuredBaseUrl = Constants.expoConfig?.extra?.apiBaseUrl;
|
||
if (typeof configuredBaseUrl === 'string' && configuredBaseUrl.trim().length > 0) {
|
||
return configuredBaseUrl;
|
||
}
|
||
return 'https://withyou.littlelan.cn/api/v1';
|
||
};
|
||
|
||
const BASE_URL = getBaseUrl();
|
||
const WS_URL = `${BASE_URL.replace(/^https?:\/\//, 'wss://').replace(/\/api\/v1$/, '/api/v1')}/realtime/ws`;
|
||
|
||
// 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;
|
||
errorCode?: string;
|
||
|
||
constructor(code: number, message: string, errorCode?: string) {
|
||
super(message);
|
||
this.code = code;
|
||
this.message = message;
|
||
this.errorCode = errorCode;
|
||
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 verificationModalShown = false;
|
||
private refreshLock: Promise<boolean> | null = null;
|
||
private isAuthFailed = false; // 全局认证失败标志,防止无限重试
|
||
|
||
constructor(baseUrl: string) {
|
||
this.baseUrl = baseUrl;
|
||
}
|
||
|
||
private handleVerificationRequired() {
|
||
if (this.verificationModalShown) return;
|
||
this.verificationModalShown = true;
|
||
eventBus.emit({ type: 'SHOW_VERIFICATION_MODAL' });
|
||
setTimeout(() => {
|
||
this.verificationModalShown = false;
|
||
}, 3000);
|
||
}
|
||
|
||
private navigateToLogin() {
|
||
this.isAuthFailed = true; // 设置全局失败标志
|
||
eventBus.emit({ type: 'NAVIGATE', payload: { path: '/(auth)/login' } });
|
||
eventBus.emit({ type: 'AUTH_LOGOUT' });
|
||
}
|
||
|
||
// 重置认证状态(登录成功后调用)
|
||
resetAuthState() {
|
||
this.isAuthFailed = false;
|
||
this.refreshLock = null;
|
||
}
|
||
|
||
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 static PUBLIC_PATHS = ['/auth/login', '/auth/register', '/auth/password/', '/auth/check-username', '/auth/qrcode', '/auth/refresh'];
|
||
|
||
// 统一请求方法
|
||
private async request<T>(
|
||
method: string,
|
||
path: string,
|
||
params?: Record<string, any>,
|
||
body?: any,
|
||
_retryCount = 0,
|
||
): Promise<ApiResponse<T>> {
|
||
// 认证已失败,仅放行公开路径(登录、注册等)
|
||
if (this.isAuthFailed && !ApiClient.PUBLIC_PATHS.some(p => path.startsWith(p))) {
|
||
throw new ApiError(401, '登录已过期,请重新登录');
|
||
}
|
||
|
||
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) {
|
||
await this.clearToken();
|
||
this.navigateToLogin();
|
||
throw new ApiError(401, '登录已过期,请重新登录');
|
||
}
|
||
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) {
|
||
if (_retryCount >= 1) {
|
||
await this.clearToken();
|
||
this.navigateToLogin();
|
||
throw new ApiError(401, '登录已过期,请重新登录');
|
||
}
|
||
const refreshed = await this.refreshToken();
|
||
if (!refreshed) {
|
||
await this.clearToken();
|
||
this.navigateToLogin();
|
||
throw new ApiError(401, '登录已过期,请重新登录');
|
||
}
|
||
|
||
return this.request(method, path, params, body, _retryCount + 1);
|
||
}
|
||
|
||
// 解析响应(兼容非 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) {
|
||
if (parsedBody.error_code === 'VERIFICATION_REQUIRED') {
|
||
this.handleVerificationRequired();
|
||
}
|
||
throw new ApiError(data.code, data.message || '请求失败', parsedBody.error_code);
|
||
}
|
||
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);
|
||
if (data.data.refresh_token || data.data.refreshToken) {
|
||
await this.setRefreshToken(data.data.refresh_token || data.data.refreshToken);
|
||
}
|
||
this.registerDeviceOnRefresh();
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
} catch (error) {
|
||
console.error('刷新token失败:', error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 冷启动/刷新token后自动注册设备,方便存量设备接入
|
||
private registerDeviceOnRefresh(): void {
|
||
(async () => {
|
||
try {
|
||
const { pushService } = await import('../notification/pushService');
|
||
const { jpushService } = await import('../notification/jpushService');
|
||
const AsyncStorage = (await import('@react-native-async-storage/async-storage')).default;
|
||
|
||
const DEVICE_ID_KEY = 'withyou_device_id';
|
||
let deviceId = await AsyncStorage.getItem(DEVICE_ID_KEY);
|
||
|
||
if (Platform.OS === 'web') {
|
||
if (!deviceId) {
|
||
const timestamp = Date.now().toString(36);
|
||
const random = Math.random().toString(36).substring(2, 8);
|
||
deviceId = `web_${timestamp}_${random}`;
|
||
await AsyncStorage.setItem(DEVICE_ID_KEY, deviceId);
|
||
}
|
||
await pushService.registerDevice({
|
||
device_id: deviceId,
|
||
device_type: 'web',
|
||
device_name: 'Web Browser',
|
||
});
|
||
} else {
|
||
// Mobile: use JPush registration ID if available
|
||
const regID = jpushService.getRegistrationIDValue();
|
||
if (!deviceId) {
|
||
const timestamp = Date.now().toString(36);
|
||
const random = Math.random().toString(36).substring(2, 8);
|
||
deviceId = `${Platform.OS}_${timestamp}_${random}`;
|
||
await AsyncStorage.setItem(DEVICE_ID_KEY, deviceId);
|
||
}
|
||
await pushService.registerDevice({
|
||
device_id: deviceId,
|
||
device_type: Platform.OS === 'ios' ? 'ios' : 'android',
|
||
...(regID ? { push_token: regID } : {}),
|
||
device_name: `${Platform.OS === 'ios' ? 'iOS' : 'Android'} Device`,
|
||
});
|
||
}
|
||
console.log('[API] device registered on token refresh');
|
||
} catch (error) {
|
||
console.warn('[API] device register on refresh failed:', error);
|
||
}
|
||
})();
|
||
}
|
||
|
||
// 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 { WS_URL, TOKEN_KEY, REFRESH_TOKEN_KEY };
|