refactor(core): distinguish network errors from auth failures and refactor real-time messaging pipeline
Some checks failed
Frontend CI / ota (android) (push) Successful in 3m11s
Frontend CI / ota (ios) (push) Successful in 3m31s
Frontend CI / build-and-push-web (push) Successful in 4m38s
Frontend CI / build-android-apk (push) Has been cancelled

- Add isNetworkError() helper and FetchCurrentUserResult discriminated union to prevent logout on network failures
- Refactor authService to return { kind: 'user' } | { kind: 'auth_failed' } | { kind: 'network_error' }
- Update authStore to preserve login state on network errors, only logout on auth failures
- Replace WSMessageHandler with RealtimeIngestionPipeline for improved real-time message handling
- Remove MessageDeduplication service; add store-level idempotent addOrReplaceMessage for message dedup
- Add atomic systemUnreadCount operations to prevent race conditions
- Add SearchHeader component for consistent search UI across screens
- Add 'home' TabBar variant with underline style matching HomeScreen
- Add Jest test infrastructure and exclude tests from tsconfig
- Fix ChatScreen history lock being stuck when scrolling to latest messages
- Remove unused call_participant_joined/left WS event types
This commit is contained in:
lafay
2026-06-21 17:17:15 +08:00
parent 94a202506b
commit 705b365536
47 changed files with 4357 additions and 1574 deletions

View File

@@ -72,10 +72,33 @@ interface JwtPayload {
iss?: string;
}
// 刷新 token 的结果:
// - 'ok' 刷新成功
// - 'auth_failed' 后端明确拒绝refresh token 失效)→ 可安全登出
// - 'network_error' 网络故障(未收到后端响应)→ 不应登出
type RefreshResult = 'ok' | 'auth_failed' | 'network_error';
// 判断是否为网络错误fetch 未收到后端响应)。
// 与"后端明确拒绝401 等)"区分开,避免网络故障被误判为账号退出。
export function isNetworkError(error: unknown): boolean {
if (error instanceof ApiError) {
return error.errorCode === 'NETWORK_ERROR';
}
if (error instanceof Error) {
const msg = error.message.toLowerCase();
return (
msg.includes('network request failed') ||
msg.includes('failed to fetch') ||
msg.includes('networkerror')
);
}
return false;
}
// API 客户端类
class ApiClient {
private baseUrl: string;
private refreshLock: Promise<boolean> | null = null;
private refreshLock: Promise<RefreshResult> | null = null;
private isAuthFailed = false; // 全局认证失败标志,防止无限重试
constructor(baseUrl: string) {
@@ -208,12 +231,15 @@ class ApiClient {
if (token) {
// 检查 token 是否快过期,如果是则主动刷新
if (this.isTokenExpiringSoon(token)) {
const refreshed = await this.refreshToken();
if (!refreshed) {
const refreshResult = await this.refreshToken();
if (refreshResult === 'auth_failed') {
// 后端明确拒绝refresh token 失效)→ 登出
await this.clearToken();
this.navigateToLogin();
throw new ApiError(401, '登录已过期,请重新登录');
throw new ApiError(401, '登录已过期,请重新登录', 'AUTH_ERROR');
}
// 'network_error':网络故障,保留登录态,沿用旧 token 继续请求
// (旧 token 可能仍有效;若已过期,后端会返回 401届时再走重试逻辑
const newToken = await this.getToken();
if (newToken) {
headers['Authorization'] = `Bearer ${newToken}`;
@@ -240,17 +266,24 @@ class ApiClient {
// 处理 401 未授权
if (response.status === 401) {
if (_retryCount >= 1) {
// 已重试过仍 401 → 后端明确拒绝该账号 → 登出
await this.clearToken();
this.navigateToLogin();
throw new ApiError(401, '登录已过期,请重新登录');
throw new ApiError(401, '登录已过期,请重新登录', 'AUTH_ERROR');
}
const refreshed = await this.refreshToken();
if (!refreshed) {
const refreshResult = await this.refreshToken();
if (refreshResult === 'auth_failed') {
// refresh token 失效 → 后端明确拒绝 → 登出
await this.clearToken();
this.navigateToLogin();
throw new ApiError(401, '登录已过期,请重新登录');
throw new ApiError(401, '登录已过期,请重新登录', 'AUTH_ERROR');
}
if (refreshResult === 'network_error') {
// 刷新请求网络失败:此时无法判断 401 是真失效还是旧 token 临时过期,
// 保留登录态,向上抛出可重试的网络错误,避免误判登出
throw new ApiError(503, '网络连接失败,请检查网络后重试', 'NETWORK_ERROR');
}
return this.request(method, path, params, body, _retryCount + 1);
}
@@ -302,14 +335,16 @@ class ApiClient {
throw error;
}
// 网络错误
// 网络错误fetch 未收到后端响应):标记 errorCode便于上层区分网络故障与认证拒绝
console.error('API请求失败:', error);
throw new ApiError(500, '网络请求失败,请检查网络连接');
throw new ApiError(503, '网络请求失败,请检查网络连接', 'NETWORK_ERROR');
}
}
// 刷新 token带锁防止并发刷新
private async refreshToken(): Promise<boolean> {
// 返回 'ok' / 'auth_failed' / 'network_error'
// - network_error 表示请求未到达后端,调用方不应据此判定账号退出
private async refreshToken(): Promise<RefreshResult> {
// 如果已有刷新操作正在进行,等待其完成
if (this.refreshLock) {
return this.refreshLock;
@@ -328,11 +363,12 @@ class ApiClient {
}
// 实际执行刷新 token
private async doRefreshToken(): Promise<boolean> {
private async doRefreshToken(): Promise<RefreshResult> {
try {
const refreshToken = await this.getRefreshToken();
if (!refreshToken) {
return false;
// 没有刷新令牌:后端无法鉴权,视为认证失败
return 'auth_failed';
}
const response = await fetch(`${this.baseUrl}/auth/refresh`, {
@@ -345,8 +381,9 @@ class ApiClient {
}),
});
// 后端明确拒绝refresh token 失效/黑名单)→ 认证失败
if (!response.ok) {
return false;
return 'auth_failed';
}
const data = await response.json();
@@ -356,13 +393,19 @@ class ApiClient {
await this.setRefreshToken(data.data.refresh_token || data.data.refreshToken);
}
this.registerDeviceOnRefresh();
return true;
return 'ok';
}
return false;
// 业务码非 0后端明确返回失败 → 认证失败
return 'auth_failed';
} catch (error) {
// fetch 抛异常DNS 失败、断网、超时)→ 网络故障,不应登出
if (isNetworkError(error)) {
console.warn('刷新token网络失败保留登录态:', error);
return 'network_error';
}
console.error('刷新token失败:', error);
return false;
return 'network_error';
}
}
@@ -443,12 +486,17 @@ class ApiClient {
const headers: Record<string, string> = {};
if (token) {
if (this.isTokenExpiringSoon(token)) {
const refreshed = await this.refreshToken();
if (refreshed) {
const newToken = await this.getToken();
if (newToken) {
headers['Authorization'] = `Bearer ${newToken}`;
}
const refreshResult = await this.refreshToken();
if (refreshResult === 'auth_failed') {
// 后端明确拒绝 → 登出,避免上传无意义请求
await this.clearToken();
this.navigateToLogin();
throw new ApiError(401, '登录已过期,请重新登录', 'AUTH_ERROR');
}
// 'ok' 或 'network_error'尝试用可能已刷新的token 继续
const newToken = await this.getToken();
if (newToken) {
headers['Authorization'] = `Bearer ${newToken}`;
}
} else {
headers['Authorization'] = `Bearer ${token}`;

View File

@@ -1,184 +0,0 @@
/**
* WSClient - WebSocket连接管理
* 只负责WebSocket连接管理提供事件驱动架构
*/
import {
wsService,
WSChatMessage,
WSGroupChatMessage,
WSReadMessage,
WSGroupReadMessage,
WSRecallMessage,
WSGroupRecallMessage,
WSGroupTypingMessage,
WSGroupNoticeMessage,
WSMessageType,
} from '@/services/core';
// 事件处理器类型
type EventHandler<T> = (data: T) => void;
// WS事件类型
export interface WSEvents {
'chat': WSChatMessage;
'group_message': WSGroupChatMessage;
'read': WSReadMessage;
'group_read': WSGroupReadMessage;
'recall': WSRecallMessage;
'group_recall': WSGroupRecallMessage;
'group_typing': WSGroupTypingMessage;
'group_notice': WSGroupNoticeMessage;
'connected': void;
'disconnected': void;
'error': Error;
}
export type WSEventType = keyof WSEvents;
class WSClient {
private handlers: Map<WSEventType, Set<EventHandler<any>>> = new Map();
private unsubscribeFns: Array<() => void> = [];
private isInitialized = false;
/**
* 初始化WebSocket监听
*/
initialize(): void {
if (this.isInitialized) return;
// 监听私聊消息
const unsubChat = wsService.on('chat', (message) => {
this.emit('chat', message);
});
// 监听群聊消息
const unsubGroupMessage = wsService.on('group_message', (message) => {
this.emit('group_message', message);
});
// 监听私聊已读回执
const unsubRead = wsService.on('read', (message) => {
this.emit('read', message);
});
// 监听群聊已读回执
const unsubGroupRead = wsService.on('group_read', (message) => {
this.emit('group_read', message);
});
// 监听私聊消息撤回
const unsubRecall = wsService.on('recall', (message) => {
this.emit('recall', message);
});
// 监听群聊消息撤回
const unsubGroupRecall = wsService.on('group_recall', (message) => {
this.emit('group_recall', message);
});
// 监听群聊输入状态
const unsubGroupTyping = wsService.on('group_typing', (message) => {
this.emit('group_typing', message);
});
// 监听群通知
const unsubGroupNotice = wsService.on('group_notice', (message) => {
this.emit('group_notice', message);
});
// 监听连接状态
const unsubConnect = wsService.onConnect(() => {
this.emit('connected', undefined);
});
const unsubDisconnect = wsService.onDisconnect(() => {
this.emit('disconnected', undefined);
});
this.unsubscribeFns = [
unsubChat,
unsubGroupMessage,
unsubRead,
unsubGroupRead,
unsubRecall,
unsubGroupRecall,
unsubGroupTyping,
unsubGroupNotice,
unsubConnect,
unsubDisconnect,
];
this.isInitialized = true;
}
/**
* 销毁WebSocket监听
*/
destroy(): void {
this.unsubscribeFns.forEach((fn) => fn());
this.unsubscribeFns = [];
this.handlers.clear();
this.isInitialized = false;
}
/**
* 订阅事件
*/
on<T extends WSEventType>(
event: T,
handler: EventHandler<WSEvents[T]>
): () => void {
if (!this.handlers.has(event)) {
this.handlers.set(event, new Set());
}
this.handlers.get(event)!.add(handler);
return () => {
this.handlers.get(event)?.delete(handler);
};
}
/**
* 触发事件
*/
private emit<T extends WSEventType>(
event: T,
data: WSEvents[T]
): void {
const handlers = this.handlers.get(event);
if (handlers) {
handlers.forEach((handler) => {
try {
handler(data);
} catch (error) {
console.error(`[WSClient] 事件处理器执行失败: ${event}`, error);
}
});
}
}
/**
* 检查连接状态
*/
isConnected(): boolean {
return wsService.isConnected();
}
/**
* 启动WebSocket连接
*/
async connect(): Promise<boolean> {
return wsService.start();
}
/**
* 断开WebSocket连接
*/
disconnect(): void {
wsService.stop();
}
}
export const wsClient = new WSClient();
export default wsClient;

View File

@@ -1,5 +1,5 @@
export { api, WS_URL, TOKEN_KEY, REFRESH_TOKEN_KEY } from './api';
export { ApiError } from './api';
export { ApiError, isNetworkError } from './api';
export type { ApiResponse, PaginatedData } from './api';
export { wsService } from './wsService';

View File

@@ -44,8 +44,6 @@ export type WSMessageType =
| 'call_peer_muted'
| 'call_invited'
| 'call_answered_elsewhere'
| 'call_participant_joined'
| 'call_participant_left'
| 'sync_required';
export interface WSCallIncomingMessage {
@@ -105,19 +103,6 @@ export interface WSCallAnsweredElsewhereMessage {
reason?: string;
}
export interface WSCallParticipantJoinedMessage {
type: 'call_participant_joined';
call_id: string;
user_id: string;
}
export interface WSCallParticipantLeftMessage {
type: 'call_participant_left';
call_id: string;
user_id: string;
reason?: string;
}
export interface WSErrorMessage {
type: 'error';
code: string;
@@ -272,8 +257,6 @@ export type WSMessage =
| WSCallPeerMutedMessage
| WSCallInvitedMessage
| WSCallAnsweredElsewhereMessage
| WSCallParticipantJoinedMessage
| WSCallParticipantLeftMessage
| WSErrorMessage
| WSSyncRequiredMessage;