refactor(core): distinguish network errors from auth failures and refactor real-time messaging pipeline
- 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:
46
jest.config.js
Normal file
46
jest.config.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* Jest 配置
|
||||||
|
*
|
||||||
|
* 仅对消息状态核心逻辑(store 幂等性 / 实时事件管道 / 未读计数原子性)做单测,
|
||||||
|
* 不覆盖 RN / Expo UI。所有平台依赖(@/services/core、@/database、@/core/events、
|
||||||
|
* @/stores/auth、@/services/message)在测试中通过 moduleNameMapper 映射到 mock。
|
||||||
|
*/
|
||||||
|
const { pathsToModuleNameMapper } = require('ts-jest');
|
||||||
|
|
||||||
|
const tsconfig = require('./tsconfig.json');
|
||||||
|
const tsconfigBase = require('expo/tsconfig.base.json');
|
||||||
|
|
||||||
|
// 合并 base + 项目 paths
|
||||||
|
const paths = {
|
||||||
|
...(tsconfigBase.compilerOptions.paths || {}),
|
||||||
|
...(tsconfig.compilerOptions.paths || {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
preset: 'ts-jest',
|
||||||
|
testEnvironment: 'node',
|
||||||
|
roots: ['<rootDir>/src'],
|
||||||
|
testMatch: ['**/__tests__/**/*.test.ts'],
|
||||||
|
moduleNameMapper: {
|
||||||
|
// 平台依赖统一映射到 mock(必须放在 @/* 通配之前,否则会被通配吞掉)
|
||||||
|
'^@/services/core$': '<rootDir>/src/stores/message/__tests__/mocks/coreMock.ts',
|
||||||
|
'^@/services/message$': '<rootDir>/src/stores/message/__tests__/mocks/messageServiceMock.ts',
|
||||||
|
'^@/database$': '<rootDir>/src/stores/message/__tests__/mocks/databaseMock.ts',
|
||||||
|
'^@/core/events/EventBus$': '<rootDir>/src/stores/message/__tests__/mocks/eventBusMock.ts',
|
||||||
|
'^@/stores/auth/authStore$': '<rootDir>/src/stores/message/__tests__/mocks/authStoreMock.ts',
|
||||||
|
'^@react-native-async-storage/async-storage$':
|
||||||
|
'<rootDir>/src/stores/message/__tests__/mocks/asyncStorageMock.ts',
|
||||||
|
// @/* 路径别名 → 真实 src(通配兜底,放最后)
|
||||||
|
...pathsToModuleNameMapper(paths, { prefix: '<rootDir>/' }),
|
||||||
|
},
|
||||||
|
transform: {
|
||||||
|
'^.+\\.tsx?$': [
|
||||||
|
'ts-jest',
|
||||||
|
{
|
||||||
|
tsconfig: '<rootDir>/src/stores/message/__tests__/tsconfig.json',
|
||||||
|
isolatedModules: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
clearMocks: true,
|
||||||
|
};
|
||||||
2457
package-lock.json
generated
2457
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,9 @@
|
|||||||
"ios:simulator": "eas build --platform ios --profile ios-simulator",
|
"ios:simulator": "eas build --platform ios --profile ios-simulator",
|
||||||
"ios:preview": "eas build --platform ios --profile preview",
|
"ios:preview": "eas build --platform ios --profile preview",
|
||||||
"ios:prod": "eas build --platform ios --profile production",
|
"ios:prod": "eas build --platform ios --profile production",
|
||||||
"ios:submit": "eas submit --platform ios --profile production"
|
"ios:submit": "eas submit --platform ios --profile production",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/ui": "~56.0.17",
|
"@expo/ui": "~56.0.17",
|
||||||
@@ -75,9 +77,12 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@react-native-community/cli": "^20.1.3",
|
"@react-native-community/cli": "^20.1.3",
|
||||||
|
"@types/jest": "^29.5.12",
|
||||||
"@types/pako": "^2.0.4",
|
"@types/pako": "^2.0.4",
|
||||||
"@types/react": "~19.2.16",
|
"@types/react": "~19.2.16",
|
||||||
"@types/react-native-vector-icons": "^6.4.18",
|
"@types/react-native-vector-icons": "^6.4.18",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"ts-jest": "^29.1.2",
|
||||||
"typescript": "~6.0.3"
|
"typescript": "~6.0.3"
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
115
src/components/business/SearchHeader.tsx
Normal file
115
src/components/business/SearchHeader.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* SearchHeader 搜索顶栏组件
|
||||||
|
* 统一的「搜索框 + 取消按钮」顶栏,用于内嵌式搜索页面。
|
||||||
|
*
|
||||||
|
* - 内置 Android 硬件返回键拦截:存在 onCancel 时,按下返回键调用 onCancel
|
||||||
|
* 而非退出 App(解决 Tab 根页面内嵌搜索时返回键直接退出的问题)。
|
||||||
|
* - 样式与 HomeScreen 搜索页保持一致。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useEffect, useMemo } from 'react';
|
||||||
|
import { View, TouchableOpacity, StyleSheet, BackHandler, StyleProp, ViewStyle } from 'react-native';
|
||||||
|
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
|
||||||
|
import { Text } from '../common';
|
||||||
|
import SearchBar from './SearchBar';
|
||||||
|
|
||||||
|
interface SearchHeaderProps {
|
||||||
|
value: string;
|
||||||
|
onChangeText: (text: string) => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
/** 取消回调:同时用于取消按钮与硬件返回键。必传以启用内嵌模式返回拦截。 */
|
||||||
|
onCancel: () => void;
|
||||||
|
placeholder?: string;
|
||||||
|
/** 透传给 SearchBar 的 compact 模式 */
|
||||||
|
compact?: boolean;
|
||||||
|
/** 搜索框最大宽度(宽屏限宽) */
|
||||||
|
searchBarMaxWidth?: number;
|
||||||
|
/** 水平外边距,默认 spacing.md */
|
||||||
|
horizontalPadding?: number;
|
||||||
|
/** 顶部内边距,默认 spacing.sm */
|
||||||
|
paddingTop?: number;
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSearchHeaderStyles(colors: AppColors) {
|
||||||
|
return StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: colors.background.default,
|
||||||
|
},
|
||||||
|
searchShell: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
cancelButton: {
|
||||||
|
borderRadius: borderRadius.full,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
},
|
||||||
|
cancelText: {
|
||||||
|
fontSize: fontSizes.md,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const SearchHeader: React.FC<SearchHeaderProps> = ({
|
||||||
|
value,
|
||||||
|
onChangeText,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
placeholder,
|
||||||
|
compact = false,
|
||||||
|
searchBarMaxWidth,
|
||||||
|
horizontalPadding = spacing.md,
|
||||||
|
paddingTop = spacing.sm,
|
||||||
|
style,
|
||||||
|
}) => {
|
||||||
|
const colors = useAppColors();
|
||||||
|
const styles = useMemo(() => createSearchHeaderStyles(colors), [colors]);
|
||||||
|
|
||||||
|
// 内嵌模式:拦截 Android 物理返回键,调用 onCancel 而非退出 App
|
||||||
|
useEffect(() => {
|
||||||
|
const backHandler = BackHandler.addEventListener('hardwareBackPress', () => {
|
||||||
|
onCancel();
|
||||||
|
return true; // 阻止默认行为(退出 App)
|
||||||
|
});
|
||||||
|
return () => backHandler.remove();
|
||||||
|
}, [onCancel]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.container,
|
||||||
|
{
|
||||||
|
paddingTop,
|
||||||
|
paddingHorizontal: horizontalPadding,
|
||||||
|
paddingBottom: spacing.xs,
|
||||||
|
},
|
||||||
|
style,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={[styles.searchShell, searchBarMaxWidth != null && { maxWidth: searchBarMaxWidth }]}>
|
||||||
|
<SearchBar
|
||||||
|
value={value}
|
||||||
|
onChangeText={onChangeText}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
placeholder={placeholder}
|
||||||
|
autoFocus
|
||||||
|
compact={compact}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.cancelButton, { marginLeft: spacing.sm }]}
|
||||||
|
onPress={onCancel}
|
||||||
|
activeOpacity={0.85}
|
||||||
|
>
|
||||||
|
<Text variant="body" color={colors.primary.main} style={styles.cancelText}>
|
||||||
|
取消
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SearchHeader;
|
||||||
@@ -10,7 +10,7 @@ import { MaterialCommunityIcons } from '@expo/vector-icons';
|
|||||||
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
|
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
|
||||||
import Text from '../common/Text';
|
import Text from '../common/Text';
|
||||||
|
|
||||||
type TabBarVariant = 'default' | 'pill' | 'segmented' | 'modern';
|
type TabBarVariant = 'default' | 'pill' | 'segmented' | 'modern' | 'home';
|
||||||
|
|
||||||
interface TabBarProps {
|
interface TabBarProps {
|
||||||
tabs: string[];
|
tabs: string[];
|
||||||
@@ -20,6 +20,8 @@ interface TabBarProps {
|
|||||||
rightContent?: ReactNode;
|
rightContent?: ReactNode;
|
||||||
variant?: TabBarVariant;
|
variant?: TabBarVariant;
|
||||||
icons?: string[];
|
icons?: string[];
|
||||||
|
/** home 变体的字号,默认 18(HomeScreen 可传 20) */
|
||||||
|
fontSize?: number;
|
||||||
style?: StyleProp<ViewStyle>;
|
style?: StyleProp<ViewStyle>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,6 +178,33 @@ function createTabBarStyles(colors: AppColors) {
|
|||||||
borderTopLeftRadius: borderRadius.sm,
|
borderTopLeftRadius: borderRadius.sm,
|
||||||
borderTopRightRadius: borderRadius.sm,
|
borderTopRightRadius: borderRadius.sm,
|
||||||
},
|
},
|
||||||
|
// home 变体:与 HomeScreen/SearchScreen 一致的文字下划线风格
|
||||||
|
homeContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
},
|
||||||
|
homeTab: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
marginRight: 20,
|
||||||
|
borderBottomWidth: 2,
|
||||||
|
borderBottomColor: 'transparent',
|
||||||
|
},
|
||||||
|
homeTabActive: {
|
||||||
|
borderBottomColor: colors.text.primary,
|
||||||
|
},
|
||||||
|
homeTabText: {
|
||||||
|
fontSize: fontSizes.xl,
|
||||||
|
fontWeight: '400',
|
||||||
|
color: colors.text.hint,
|
||||||
|
},
|
||||||
|
homeTabTextActive: {
|
||||||
|
fontSize: fontSizes.xl,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: colors.text.primary,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,6 +216,7 @@ const TabBar: React.FC<TabBarProps> = ({
|
|||||||
rightContent,
|
rightContent,
|
||||||
variant = 'default',
|
variant = 'default',
|
||||||
icons,
|
icons,
|
||||||
|
fontSize,
|
||||||
style,
|
style,
|
||||||
}) => {
|
}) => {
|
||||||
const colors = useAppColors();
|
const colors = useAppColors();
|
||||||
@@ -278,6 +308,29 @@ const TabBar: React.FC<TabBarProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (variant === 'home') {
|
||||||
|
const homeTextStyle = [
|
||||||
|
isActive ? styles.homeTabTextActive : styles.homeTabText,
|
||||||
|
fontSize != null ? { fontSize } : undefined,
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={index}
|
||||||
|
style={[styles.homeTab, isActive && styles.homeTabActive]}
|
||||||
|
onPress={() => onTabChange(index)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
variant="body"
|
||||||
|
color={isActive ? colors.text.primary : colors.text.hint}
|
||||||
|
style={homeTextStyle}
|
||||||
|
>
|
||||||
|
{tab}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={index}
|
key={index}
|
||||||
@@ -306,6 +359,8 @@ const TabBar: React.FC<TabBarProps> = ({
|
|||||||
return styles.segmentedContainer;
|
return styles.segmentedContainer;
|
||||||
case 'modern':
|
case 'modern':
|
||||||
return styles.modernContainer;
|
return styles.modernContainer;
|
||||||
|
case 'home':
|
||||||
|
return styles.homeContainer;
|
||||||
default:
|
default:
|
||||||
return styles.container;
|
return styles.container;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export { default as CommentItem } from './CommentItem';
|
|||||||
export { default as UserProfileHeader } from './UserProfileHeader';
|
export { default as UserProfileHeader } from './UserProfileHeader';
|
||||||
export { default as SystemMessageItem } from './SystemMessageItem';
|
export { default as SystemMessageItem } from './SystemMessageItem';
|
||||||
export { default as SearchBar } from './SearchBar';
|
export { default as SearchBar } from './SearchBar';
|
||||||
|
export { default as SearchHeader } from './SearchHeader';
|
||||||
export { default as TabBar } from './TabBar';
|
export { default as TabBar } from './TabBar';
|
||||||
export { default as VoteCard } from './VoteCard';
|
export { default as VoteCard } from './VoteCard';
|
||||||
export { default as VoteEditor } from './VoteEditor';
|
export { default as VoteEditor } from './VoteEditor';
|
||||||
|
|||||||
@@ -5,9 +5,7 @@ export type AppEvent =
|
|||||||
| { type: 'NAVIGATE_BACK' }
|
| { type: 'NAVIGATE_BACK' }
|
||||||
| { type: 'SHOW_VERIFICATION_MODAL' }
|
| { type: 'SHOW_VERIFICATION_MODAL' }
|
||||||
| { type: 'VIBRATE'; payload: { type: 'message' | 'group_message' | 'call' } }
|
| { type: 'VIBRATE'; payload: { type: 'message' | 'group_message' | 'call' } }
|
||||||
| { type: 'SHOW_TOAST'; payload: { message: string; type: 'success' | 'error' | 'info' } }
|
| { type: 'AUTH_LOGOUT' };
|
||||||
| { type: 'AUTH_LOGOUT' }
|
|
||||||
| { type: 'AUTH_LOGIN'; payload: { userId: string } };
|
|
||||||
|
|
||||||
class EventBusClass {
|
class EventBusClass {
|
||||||
private subscribers: Set<Subscriber<AppEvent>> = new Set();
|
private subscribers: Set<Subscriber<AppEvent>> = new Set();
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -24,7 +24,7 @@ import { useUserStore } from '../../stores';
|
|||||||
import { postService, authService } from '../../services';
|
import { postService, authService } from '../../services';
|
||||||
import { tradeService } from '../../services/trade/tradeService';
|
import { tradeService } from '../../services/trade/tradeService';
|
||||||
import { postSyncService } from '@/services/post';
|
import { postSyncService } from '@/services/post';
|
||||||
import { PostCard, SearchBar } from '../../components/business';
|
import { PostCard, SearchHeader, TabBar } from '../../components/business';
|
||||||
import { TradeCard } from '../../components/business/TradeCard/TradeCard';
|
import { TradeCard } from '../../components/business/TradeCard/TradeCard';
|
||||||
import type { PostCardAction } from '../../components/business/PostCard';
|
import type { PostCardAction } from '../../components/business/PostCard';
|
||||||
import { Avatar, EmptyState, Text, ResponsiveGrid, Loading } from '../../components/common';
|
import { Avatar, EmptyState, Text, ResponsiveGrid, Loading } from '../../components/common';
|
||||||
@@ -601,66 +601,32 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, homeTab = 's
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||||
{/* 搜索输入框 */}
|
{/* 搜索顶栏(统一组件,内置硬件返回键拦截) */}
|
||||||
<View
|
<SearchHeader
|
||||||
style={[
|
|
||||||
styles.searchHeader,
|
|
||||||
{
|
|
||||||
paddingTop: spacing.sm,
|
|
||||||
paddingHorizontal: responsivePadding,
|
|
||||||
paddingBottom: spacing.xs,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<View style={[styles.searchShell, { maxWidth: searchBarMaxWidth }]}>
|
|
||||||
<SearchBar
|
|
||||||
value={searchText}
|
value={searchText}
|
||||||
onChangeText={setSearchText}
|
onChangeText={setSearchText}
|
||||||
onSubmit={handleSearch}
|
onSubmit={handleSearch}
|
||||||
|
onCancel={onBack ? onBack : () => router.back()}
|
||||||
placeholder={isMarket ? "搜索商品、用户" : "搜索帖子、用户"}
|
placeholder={isMarket ? "搜索商品、用户" : "搜索帖子、用户"}
|
||||||
autoFocus
|
|
||||||
compact
|
compact
|
||||||
|
searchBarMaxWidth={searchBarMaxWidth}
|
||||||
|
horizontalPadding={responsivePadding}
|
||||||
/>
|
/>
|
||||||
</View>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.cancelButton, { marginLeft: responsiveGap }]}
|
|
||||||
onPress={() => (onBack ? onBack() : router.back())}
|
|
||||||
activeOpacity={0.85}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
variant="body"
|
|
||||||
color={colors.primary.main}
|
|
||||||
style={styles.cancelText}
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Tab切换 - 与主页风格一致的下划线样式 */}
|
{/* Tab切换 - 与主页风格一致的下划线样式 */}
|
||||||
<View style={styles.tabWrapper}>
|
<View style={styles.tabWrapper}>
|
||||||
<View style={[styles.homeTabSwitcher, { paddingHorizontal: responsivePadding }]}>
|
<TabBar
|
||||||
{TABS.map((tab, index) => {
|
tabs={TABS}
|
||||||
const isActive = activeIndex === index;
|
activeIndex={activeIndex}
|
||||||
return (
|
onTabChange={(index) => {
|
||||||
<TouchableOpacity
|
|
||||||
key={tab}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
style={[styles.homeTabItem, isActive && styles.homeTabItemActive]}
|
|
||||||
onPress={() => {
|
|
||||||
setActiveIndex(index);
|
setActiveIndex(index);
|
||||||
if (currentKeyword && hasSearched) {
|
if (currentKeyword && hasSearched) {
|
||||||
performSearch(currentKeyword);
|
performSearch(currentKeyword);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
variant="home"
|
||||||
<Text style={isActive ? styles.homeTabTextActive : styles.homeTabText}>
|
style={{ paddingHorizontal: responsivePadding }}
|
||||||
{tab}
|
/>
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 内容区域 */}
|
{/* 内容区域 */}
|
||||||
@@ -675,52 +641,10 @@ function createSearchScreenStyles(colors: AppColors) {
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: colors.background.default,
|
backgroundColor: colors.background.default,
|
||||||
},
|
},
|
||||||
searchHeader: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
backgroundColor: colors.background.default,
|
|
||||||
},
|
|
||||||
searchShell: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
cancelButton: {
|
|
||||||
borderRadius: borderRadius.full,
|
|
||||||
paddingHorizontal: spacing.md,
|
|
||||||
paddingVertical: spacing.sm,
|
|
||||||
},
|
|
||||||
cancelText: {
|
|
||||||
fontSize: fontSizes.md,
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
tabWrapper: {
|
tabWrapper: {
|
||||||
backgroundColor: colors.background.default,
|
backgroundColor: colors.background.default,
|
||||||
paddingVertical: spacing.xs,
|
paddingVertical: spacing.xs,
|
||||||
},
|
},
|
||||||
homeTabSwitcher: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 20,
|
|
||||||
},
|
|
||||||
homeTabItem: {
|
|
||||||
paddingVertical: spacing.sm,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
borderBottomWidth: 2,
|
|
||||||
borderBottomColor: 'transparent',
|
|
||||||
},
|
|
||||||
homeTabItemActive: {
|
|
||||||
borderBottomColor: colors.text.primary,
|
|
||||||
},
|
|
||||||
homeTabText: {
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: '400',
|
|
||||||
color: colors.text.hint,
|
|
||||||
},
|
|
||||||
homeTabTextActive: {
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: '700',
|
|
||||||
color: colors.text.primary,
|
|
||||||
},
|
|
||||||
suggestionsContainer: {
|
suggestionsContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -214,6 +214,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
|||||||
handleReachLatestEdge,
|
handleReachLatestEdge,
|
||||||
jumpToLatestMessages,
|
jumpToLatestMessages,
|
||||||
setBrowsingHistory,
|
setBrowsingHistory,
|
||||||
|
clearHistoryLock,
|
||||||
} = useChatScreen(props);
|
} = useChatScreen(props);
|
||||||
|
|
||||||
const stableOnBack = useCallback(() => router.back(), [router]);
|
const stableOnBack = useCallback(() => router.back(), [router]);
|
||||||
@@ -412,15 +413,12 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
|||||||
setBrowsingHistory(true);
|
setBrowsingHistory(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 仅用户手势主动回到底部时才解除历史阅读锁(避免加载阶段误解锁)
|
// 接近最新消息端时退出历史阅读模式
|
||||||
const isScrollingTowardLatest = contentOffset.y < lastScrollYRef.current;
|
// 修复:不再要求 isUserDraggingRef — 动量滚动结束后、layout settle 后、
|
||||||
if (
|
// 或用户轻触回到底部时都能正确解锁,防止浏览历史锁永久为 true
|
||||||
isUserDraggingRef.current &&
|
// 导致新消息不自动跟随(仅自己发的才显示的问题)
|
||||||
!loadingMore &&
|
if (!loadingMore && contentOffset.y <= 100) {
|
||||||
isScrollingTowardLatest &&
|
clearHistoryLock();
|
||||||
contentOffset.y <= 40
|
|
||||||
) {
|
|
||||||
handleReachLatestEdge();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// inverted 下历史端在“列表尾部”,对应 offset 增大且距离尾部变小
|
// inverted 下历史端在“列表尾部”,对应 offset 增大且距离尾部变小
|
||||||
@@ -460,14 +458,18 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
|||||||
loading,
|
loading,
|
||||||
messages.length,
|
messages.length,
|
||||||
loadMoreHistory,
|
loadMoreHistory,
|
||||||
handleReachLatestEdge,
|
|
||||||
showEdgeLoadingIndicator,
|
showEdgeLoadingIndicator,
|
||||||
setBrowsingHistory,
|
setBrowsingHistory,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleContentSizeChange = useCallback((contentWidth: number, contentHeight: number) => {
|
const handleContentSizeChange = useCallback((contentWidth: number, contentHeight: number) => {
|
||||||
handleMessageListContentSizeChange(contentWidth, contentHeight);
|
handleMessageListContentSizeChange(contentWidth, contentHeight);
|
||||||
}, [handleMessageListContentSizeChange]);
|
// 安全检查:loadMoreHistory 完成后,如果用户仍在底部附近,清除浏览历史锁
|
||||||
|
// 防止 layout settle 后没有触发滚动事件导致锁无法释放
|
||||||
|
if (!loadingMore && scrollPositionRef.current.scrollY <= 100) {
|
||||||
|
clearHistoryLock();
|
||||||
|
}
|
||||||
|
}, [handleMessageListContentSizeChange, loadingMore, clearHistoryLock]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={containerStyle} edges={props.isEmbedded ? [] : ['top']}>
|
<SafeAreaView style={containerStyle} edges={props.isEmbedded ? [] : ['top']}>
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ import { ConversationListRow } from './components/ConversationListRow';
|
|||||||
// 导入 NotificationsScreen 用于在内部显示
|
// 导入 NotificationsScreen 用于在内部显示
|
||||||
import { NotificationsScreen } from './NotificationsScreen';
|
import { NotificationsScreen } from './NotificationsScreen';
|
||||||
// 导入扫码组件
|
// 导入扫码组件
|
||||||
import { SearchBar, TabBar } from '../../components/business';
|
import { SearchBar, SearchHeader, TabBar } from '../../components/business';
|
||||||
import { QRCodeScanner } from '../../components/business/QRCodeScanner';
|
import { QRCodeScanner } from '../../components/business/QRCodeScanner';
|
||||||
|
|
||||||
// 系统通知会话特殊ID
|
// 系统通知会话特殊ID
|
||||||
@@ -668,33 +668,27 @@ export const MessageListScreen: React.FC = () => {
|
|||||||
// 渲染搜索模式UI(扁平化现代化设计)
|
// 渲染搜索模式UI(扁平化现代化设计)
|
||||||
const renderSearchMode = () => (
|
const renderSearchMode = () => (
|
||||||
<View style={styles.searchModeContainer}>
|
<View style={styles.searchModeContainer}>
|
||||||
{/* 搜索头部 */}
|
{/* 搜索顶栏(统一组件,内置硬件返回键拦截) */}
|
||||||
<View style={[styles.searchHeader, { paddingTop: spacing.sm }]}>
|
<SearchHeader
|
||||||
<View style={styles.searchShell}>
|
|
||||||
<SearchBar
|
|
||||||
value={searchText}
|
value={searchText}
|
||||||
onChangeText={setSearchText}
|
onChangeText={setSearchText}
|
||||||
onSubmit={() => performSearch(searchText)}
|
onSubmit={() => performSearch(searchText)}
|
||||||
|
onCancel={handleCloseSearch}
|
||||||
placeholder={activeSearchTab === 'chat' ? '搜索聊天记录' : '搜索用户'}
|
placeholder={activeSearchTab === 'chat' ? '搜索聊天记录' : '搜索用户'}
|
||||||
autoFocus
|
compact
|
||||||
/>
|
/>
|
||||||
</View>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.cancelButton}
|
|
||||||
onPress={handleCloseSearch}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
|
||||||
<Text style={styles.cancelText}>取消</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Tab切换 - 现代化风格 */}
|
{/* Tab切换 - 与主页一致的下划线风格 */}
|
||||||
<View style={styles.searchTabContainer}>
|
<View style={styles.searchTabContainer}>
|
||||||
<TabBar
|
<TabBar
|
||||||
tabs={['聊天记录', '用户']}
|
tabs={['聊天记录', '用户']}
|
||||||
activeIndex={activeSearchTab === 'chat' ? 0 : 1}
|
activeIndex={activeSearchTab === 'chat' ? 0 : 1}
|
||||||
onTabChange={(index) => setActiveSearchTab(index === 0 ? 'chat' : 'user')}
|
onTabChange={(index) => setActiveSearchTab(index === 0 ? 'chat' : 'user')}
|
||||||
variant="modern"
|
variant="home"
|
||||||
|
style={{
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: spacing.xs,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -1048,32 +1042,10 @@ function createMessageListStyles(colors: AppColors) {
|
|||||||
},
|
},
|
||||||
searchModeContainer: {
|
searchModeContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: colors.background.paper,
|
backgroundColor: colors.background.default,
|
||||||
},
|
|
||||||
searchHeader: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
backgroundColor: colors.background.paper,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: `${colors.divider}70`,
|
|
||||||
paddingHorizontal: spacing.md,
|
|
||||||
paddingBottom: spacing.sm,
|
|
||||||
},
|
|
||||||
searchShell: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
cancelButton: {
|
|
||||||
paddingHorizontal: spacing.sm,
|
|
||||||
paddingVertical: spacing.xs,
|
|
||||||
marginLeft: spacing.sm,
|
|
||||||
},
|
|
||||||
cancelText: {
|
|
||||||
fontSize: fontSizes.md,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: colors.primary.main,
|
|
||||||
},
|
},
|
||||||
searchTabContainer: {
|
searchTabContainer: {
|
||||||
backgroundColor: colors.background.paper,
|
backgroundColor: colors.background.default,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: `${colors.divider}50`,
|
borderBottomColor: `${colors.divider}50`,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import { MaterialCommunityIcons } from '@expo/vector-icons';
|
|||||||
import {
|
import {
|
||||||
spacing,
|
spacing,
|
||||||
fontSizes,
|
fontSizes,
|
||||||
borderRadius,
|
|
||||||
useAppColors,
|
useAppColors,
|
||||||
type AppColors,
|
type AppColors,
|
||||||
} from '../../theme';
|
} from '../../theme';
|
||||||
@@ -26,8 +25,8 @@ import { extractTextFromSegments, UserDTO } from '../../types/dto';
|
|||||||
import { messageRepository } from '../../database';
|
import { messageRepository } from '../../database';
|
||||||
import { userManager } from '../../stores/user';
|
import { userManager } from '../../stores/user';
|
||||||
import { useAuthStore } from '../../stores';
|
import { useAuthStore } from '../../stores';
|
||||||
import { Avatar, Text, EmptyState, AppBackButton } from '../../components/common';
|
import { Avatar, Text, EmptyState } from '../../components/common';
|
||||||
import { SearchBar } from '../../components/business';
|
import { SearchHeader } from '../../components/business';
|
||||||
import HighlightText from '../../components/common/HighlightText';
|
import HighlightText from '../../components/common/HighlightText';
|
||||||
import { formatTime } from '../../utils/formatTime';
|
import { formatTime } from '../../utils/formatTime';
|
||||||
import * as hrefs from '../../navigation/hrefs';
|
import * as hrefs from '../../navigation/hrefs';
|
||||||
@@ -233,24 +232,16 @@ export const MessageSearchScreen: React.FC = () => {
|
|||||||
}, [loading, results.length, styles, colors]);
|
}, [loading, results.length, styles, colors]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||||
<View style={styles.header}>
|
{/* 搜索顶栏(与 HomeScreen 搜索页一致的统一组件) */}
|
||||||
<AppBackButton onPress={() => router.back()} />
|
<SearchHeader
|
||||||
<Text style={styles.headerTitle} numberOfLines={1}>
|
|
||||||
{conversationName}
|
|
||||||
</Text>
|
|
||||||
<View style={styles.headerSpacer} />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.searchWrap}>
|
|
||||||
<SearchBar
|
|
||||||
value={keyword}
|
value={keyword}
|
||||||
onChangeText={handleTextChanged}
|
onChangeText={handleTextChanged}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
placeholder="搜索聊天记录"
|
onCancel={() => router.back()}
|
||||||
autoFocus
|
placeholder={`搜索 ${conversationName} 的聊天记录`}
|
||||||
|
compact
|
||||||
/>
|
/>
|
||||||
</View>
|
|
||||||
|
|
||||||
<FlashList
|
<FlashList
|
||||||
data={results}
|
data={results}
|
||||||
@@ -272,29 +263,6 @@ function createMessageSearchStyles(colors: AppColors) {
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: colors.background.default,
|
backgroundColor: colors.background.default,
|
||||||
},
|
},
|
||||||
header: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
paddingHorizontal: spacing.md,
|
|
||||||
paddingVertical: spacing.sm,
|
|
||||||
backgroundColor: colors.background.paper,
|
|
||||||
},
|
|
||||||
headerTitle: {
|
|
||||||
flex: 1,
|
|
||||||
fontSize: fontSizes.lg,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: colors.text.primary,
|
|
||||||
textAlign: 'center',
|
|
||||||
marginHorizontal: spacing.sm,
|
|
||||||
},
|
|
||||||
headerSpacer: {
|
|
||||||
width: 40,
|
|
||||||
},
|
|
||||||
searchWrap: {
|
|
||||||
paddingHorizontal: spacing.md,
|
|
||||||
paddingVertical: spacing.sm,
|
|
||||||
backgroundColor: colors.background.paper,
|
|
||||||
},
|
|
||||||
resultItem: {
|
resultItem: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
@@ -649,6 +649,17 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
isBrowsingHistoryRef.current = browsing;
|
isBrowsingHistoryRef.current = browsing;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 无条件清除浏览历史锁与自动跟随抑制(动量滚动结束、layout settle、
|
||||||
|
* 轻触回到底部等场景使用)。区别于 handleReachLatestEdge,后者受加载锁保护。
|
||||||
|
*/
|
||||||
|
const clearHistoryLock = useCallback(() => {
|
||||||
|
if (isBrowsingHistoryRef.current || suppressAutoFollowRef.current) {
|
||||||
|
isBrowsingHistoryRef.current = false;
|
||||||
|
suppressAutoFollowRef.current = false;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 进入聊天详情后立即清未读(不依赖滚动位置)
|
// 进入聊天详情后立即清未读(不依赖滚动位置)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!conversationId) return;
|
if (!conversationId) return;
|
||||||
@@ -1653,5 +1664,6 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
handleReachLatestEdge,
|
handleReachLatestEdge,
|
||||||
jumpToLatestMessages,
|
jumpToLatestMessages,
|
||||||
setBrowsingHistory,
|
setBrowsingHistory,
|
||||||
|
clearHistoryLock,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* 在宽屏下使用网格布局
|
* 在宽屏下使用网格布局
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
FlatList,
|
FlatList,
|
||||||
@@ -22,6 +22,7 @@ import { User } from '../../types';
|
|||||||
import { useAuthStore, useUserStore } from '../../stores';
|
import { useAuthStore, useUserStore } from '../../stores';
|
||||||
import { authService } from '../../services';
|
import { authService } from '../../services';
|
||||||
import { Avatar, Text, Button, Loading, EmptyState, ResponsiveContainer } from '../../components/common';
|
import { Avatar, Text, Button, Loading, EmptyState, ResponsiveContainer } from '../../components/common';
|
||||||
|
import { SearchBar } from '../../components/business';
|
||||||
import * as hrefs from '../../navigation/hrefs';
|
import * as hrefs from '../../navigation/hrefs';
|
||||||
import { useResponsive, useColumnCount } from '../../hooks';
|
import { useResponsive, useColumnCount } from '../../hooks';
|
||||||
|
|
||||||
@@ -53,9 +54,13 @@ const FollowListScreen: React.FC = () => {
|
|||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [hasMore, setHasMore] = useState(true);
|
const [hasMore, setHasMore] = useState(true);
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
const [debouncedKeyword, setDebouncedKeyword] = useState('');
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const isCurrentUser = currentUser?.id === userId;
|
const isCurrentUser = currentUser?.id === userId;
|
||||||
const title = type === 'following' ? '关注' : '粉丝';
|
const title = type === 'following' ? '关注' : '粉丝';
|
||||||
|
const isSearching = debouncedKeyword.trim().length > 0;
|
||||||
|
|
||||||
// 加载用户列表
|
// 加载用户列表
|
||||||
const loadUsers = useCallback(async (pageNum: number = 1, refresh: boolean = false) => {
|
const loadUsers = useCallback(async (pageNum: number = 1, refresh: boolean = false) => {
|
||||||
@@ -64,11 +69,12 @@ const FollowListScreen: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const pageSize = 20;
|
const pageSize = 20;
|
||||||
let userList: User[] = [];
|
let userList: User[] = [];
|
||||||
|
const trimmedKeyword = debouncedKeyword.trim();
|
||||||
|
|
||||||
if (type === 'following') {
|
if (type === 'following') {
|
||||||
userList = await authService.getFollowingList(userId, pageNum, pageSize);
|
userList = await authService.getFollowingList(userId, pageNum, pageSize, trimmedKeyword);
|
||||||
} else {
|
} else {
|
||||||
userList = await authService.getFollowersList(userId, pageNum, pageSize);
|
userList = await authService.getFollowersList(userId, pageNum, pageSize, trimmedKeyword);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (refresh) {
|
if (refresh) {
|
||||||
@@ -84,12 +90,33 @@ const FollowListScreen: React.FC = () => {
|
|||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
}, [userId, type, hasMore]);
|
}, [userId, type, hasMore, debouncedKeyword]);
|
||||||
|
|
||||||
// 初始加载
|
// 初始加载(包括 type/userId 切换以及防抖后关键词变化)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setHasMore(true);
|
||||||
loadUsers(1, true);
|
loadUsers(1, true);
|
||||||
}, [userId, type]);
|
// 仅在 userId/type/debouncedKeyword 变化时重新加载
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [userId, type, debouncedKeyword]);
|
||||||
|
|
||||||
|
// 关键词输入防抖(300ms)
|
||||||
|
useEffect(() => {
|
||||||
|
if (debounceRef.current) {
|
||||||
|
clearTimeout(debounceRef.current);
|
||||||
|
}
|
||||||
|
debounceRef.current = setTimeout(() => {
|
||||||
|
setDebouncedKeyword(keyword);
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (debounceRef.current) {
|
||||||
|
clearTimeout(debounceRef.current);
|
||||||
|
debounceRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [keyword]);
|
||||||
|
|
||||||
// 下拉刷新
|
// 下拉刷新
|
||||||
const onRefresh = useCallback(() => {
|
const onRefresh = useCallback(() => {
|
||||||
@@ -249,6 +276,17 @@ const FollowListScreen: React.FC = () => {
|
|||||||
const renderEmpty = () => {
|
const renderEmpty = () => {
|
||||||
if (loading) return <Loading />;
|
if (loading) return <Loading />;
|
||||||
|
|
||||||
|
if (isSearching) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
title="没有找到匹配的用户"
|
||||||
|
description="换个关键词试试"
|
||||||
|
icon="account-search-outline"
|
||||||
|
variant="modern"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const emptyText = type === 'following'
|
const emptyText = type === 'following'
|
||||||
? '还没有关注任何人'
|
? '还没有关注任何人'
|
||||||
: '还没有粉丝';
|
: '还没有粉丝';
|
||||||
@@ -271,17 +309,34 @@ const FollowListScreen: React.FC = () => {
|
|||||||
<View style={styles.headerAccent} />
|
<View style={styles.headerAccent} />
|
||||||
<View style={styles.headerMainRow}>
|
<View style={styles.headerMainRow}>
|
||||||
<Text variant="h2" style={styles.headerTitle}>{title}</Text>
|
<Text variant="h2" style={styles.headerTitle}>{title}</Text>
|
||||||
|
{!isSearching ? (
|
||||||
<View style={styles.headerCountPill}>
|
<View style={styles.headerCountPill}>
|
||||||
<Text variant="caption" color={colors.text.secondary}>
|
<Text variant="caption" color={colors.text.secondary}>
|
||||||
共 {users.length}
|
共 {users.length}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={styles.headerCountPill}>
|
||||||
|
<Text variant="caption" color={colors.text.secondary}>
|
||||||
|
搜索结果
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
<Text variant="caption" color={colors.text.secondary} style={styles.headerSubtitle}>
|
<Text variant="caption" color={colors.text.secondary} style={styles.headerSubtitle}>
|
||||||
{type === 'following'
|
{type === 'following'
|
||||||
? (isCurrentUser ? '你已关注的用户' : 'TA关注的用户')
|
? (isCurrentUser ? '你已关注的用户' : 'TA关注的用户')
|
||||||
: (isCurrentUser ? '关注你的用户' : 'TA的粉丝')}
|
: (isCurrentUser ? '关注你的用户' : 'TA的粉丝')}
|
||||||
</Text>
|
</Text>
|
||||||
|
<View style={styles.headerSearchWrap}>
|
||||||
|
<SearchBar
|
||||||
|
value={keyword}
|
||||||
|
onChangeText={setKeyword}
|
||||||
|
onSubmit={() => setDebouncedKeyword(keyword)}
|
||||||
|
placeholder={type === 'following' ? '搜索关注' : '搜索粉丝'}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -407,6 +462,9 @@ function createFollowListStyles(colors: AppColors) {
|
|||||||
headerSubtitle: {
|
headerSubtitle: {
|
||||||
marginTop: spacing.xs,
|
marginTop: spacing.xs,
|
||||||
},
|
},
|
||||||
|
headerSearchWrap: {
|
||||||
|
marginTop: spacing.md,
|
||||||
|
},
|
||||||
userItem: {
|
userItem: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
@@ -3,10 +3,25 @@
|
|||||||
* 处理用户登录、注册、登出等认证功能
|
* 处理用户登录、注册、登出等认证功能
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { api } from '../core/api';
|
import { api, ApiError, isNetworkError } from '../core/api';
|
||||||
import { User } from '@/types';
|
import { User } from '@/types';
|
||||||
import type { PrivacySettingsDTO, PrivacySettingsRequestDTO, DeletionStatusDTO } from '@/types/dto';
|
import type { PrivacySettingsDTO, PrivacySettingsRequestDTO, DeletionStatusDTO } from '@/types/dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前用户获取结果。
|
||||||
|
* - user:成功拿到用户信息
|
||||||
|
* - auth_failed:后端明确拒绝(401 / token 失效)→ 应登出
|
||||||
|
* - network_error:网络故障(未到达后端)→ 不应登出,应保留登录态等待重试
|
||||||
|
*
|
||||||
|
* 引入该类型是为了修复"断网即被登出"的问题:
|
||||||
|
* 之前 fetchCurrentUserFromAPI 在任何错误下都返回 null,
|
||||||
|
* 调用方无法区分"token 真失效"与"只是连不上服务器"。
|
||||||
|
*/
|
||||||
|
export type FetchCurrentUserResult =
|
||||||
|
| { kind: 'user'; user: User }
|
||||||
|
| { kind: 'auth_failed' }
|
||||||
|
| { kind: 'network_error' };
|
||||||
|
|
||||||
export function resolveAuthApiError(error: any, fallback = '操作失败,请稍后重试'): string {
|
export function resolveAuthApiError(error: any, fallback = '操作失败,请稍后重试'): string {
|
||||||
const code: number = error?.code ?? 0;
|
const code: number = error?.code ?? 0;
|
||||||
const msg: string = String(error?.message ?? '').toLowerCase();
|
const msg: string = String(error?.message ?? '').toLowerCase();
|
||||||
@@ -231,21 +246,35 @@ class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 纯 API 获取当前用户(无任何 DB 依赖,供冷启动校验 Token 时使用)
|
// 纯 API 获取当前用户(无任何 DB 依赖,供冷启动校验 Token 时使用)
|
||||||
async fetchCurrentUserFromAPI(): Promise<User | null> {
|
// 返回判别类型,便于调用方区分"网络故障"与"认证被拒"。
|
||||||
|
// 旧调用方(getCurrentUser / fetchCurrentUserFresh)仍返回 User | null。
|
||||||
|
async fetchCurrentUserFromAPI(): Promise<FetchCurrentUserResult> {
|
||||||
try {
|
try {
|
||||||
const response = await api.get<UserResponse>('/users/me');
|
const response = await api.get<UserResponse>('/users/me');
|
||||||
return response.data;
|
if (response.data) {
|
||||||
} catch (error: any) {
|
return { kind: 'user', user: response.data };
|
||||||
// 401 错误(未登录/登录过期)是预期情况,不需要打印错误日志
|
|
||||||
const isAuthError = error?.code === 401 ||
|
|
||||||
String(error?.message ?? '').includes('登录') ||
|
|
||||||
String(error?.message ?? '').includes('token') ||
|
|
||||||
String(error?.message ?? '').includes('unauthorized');
|
|
||||||
|
|
||||||
if (!isAuthError) {
|
|
||||||
console.error('[AuthService] fetchCurrentUserFromAPI 失败:', error);
|
|
||||||
}
|
}
|
||||||
return null;
|
// 后端返回成功但无数据:视作认证异常
|
||||||
|
return { kind: 'auth_failed' };
|
||||||
|
} catch (error: any) {
|
||||||
|
// 网络故障(断网 / DNS / 超时):保留登录态,不登出
|
||||||
|
if (isNetworkError(error)) {
|
||||||
|
console.warn('[AuthService] fetchCurrentUserFromAPI 网络失败(保留登录态):', error);
|
||||||
|
return { kind: 'network_error' };
|
||||||
|
}
|
||||||
|
// 后端明确拒绝(401 等):认证失败
|
||||||
|
const code = error?.code ?? 0;
|
||||||
|
const isAuthError =
|
||||||
|
code === 401 ||
|
||||||
|
error instanceof ApiError && error.code === 401 ||
|
||||||
|
String(error?.message ?? '').includes('unauthorized') ||
|
||||||
|
String(error?.errorCode ?? '').toUpperCase() === 'AUTH_ERROR';
|
||||||
|
if (isAuthError) {
|
||||||
|
return { kind: 'auth_failed' };
|
||||||
|
}
|
||||||
|
// 其它非网络、非 401 错误(如 500):保守视作网络/服务不可用,不登出
|
||||||
|
console.error('[AuthService] fetchCurrentUserFromAPI 失败:', error);
|
||||||
|
return { kind: 'network_error' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,6 +303,11 @@ class AuthService {
|
|||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// 网络故障:保留 token,等网络恢复后重试,避免误判登出
|
||||||
|
if (isNetworkError(error)) {
|
||||||
|
console.warn('[AuthService] 刷新Token网络失败(保留登录态):', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
console.error('刷新Token失败:', error);
|
console.error('刷新Token失败:', error);
|
||||||
await api.clearToken();
|
await api.clearToken();
|
||||||
return null;
|
return null;
|
||||||
@@ -286,8 +320,11 @@ class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 强制从服务器获取当前用户信息(别名,保留兼容)
|
// 强制从服务器获取当前用户信息(别名,保留兼容)
|
||||||
|
// 注意:此处丢弃了"网络故障 vs 认证失败"的区分,仅返回 User | null。
|
||||||
|
// 需要区分的调用方应直接使用 fetchCurrentUserFromAPI()。
|
||||||
async fetchCurrentUserFresh(): Promise<User | null> {
|
async fetchCurrentUserFresh(): Promise<User | null> {
|
||||||
return this.fetchCurrentUserFromAPI();
|
const result = await this.fetchCurrentUserFromAPI();
|
||||||
|
return result.kind === 'user' ? result.user : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchUserByIdFromAPI(userId: string): Promise<User | null> {
|
async fetchUserByIdFromAPI(userId: string): Promise<User | null> {
|
||||||
@@ -368,12 +405,16 @@ class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 获取用户关注列表
|
// 获取用户关注列表
|
||||||
async getFollowingList(userId: string, page = 1, pageSize = 20): Promise<User[]> {
|
async getFollowingList(userId: string, page = 1, pageSize = 20, keyword = ''): Promise<User[]> {
|
||||||
try {
|
try {
|
||||||
const response = await api.get<{ list: User[] }>(`/users/${userId}/following`, {
|
const params: Record<string, string | number> = {
|
||||||
page,
|
page,
|
||||||
page_size: pageSize,
|
page_size: pageSize,
|
||||||
});
|
};
|
||||||
|
if (keyword) {
|
||||||
|
params.keyword = keyword;
|
||||||
|
}
|
||||||
|
const response = await api.get<{ list: User[] }>(`/users/${userId}/following`, params);
|
||||||
return response.data.list;
|
return response.data.list;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取关注列表失败:', error);
|
console.error('获取关注列表失败:', error);
|
||||||
@@ -382,12 +423,16 @@ class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 获取用户粉丝列表
|
// 获取用户粉丝列表
|
||||||
async getFollowersList(userId: string, page = 1, pageSize = 20): Promise<User[]> {
|
async getFollowersList(userId: string, page = 1, pageSize = 20, keyword = ''): Promise<User[]> {
|
||||||
try {
|
try {
|
||||||
const response = await api.get<{ list: User[] }>(`/users/${userId}/followers`, {
|
const params: Record<string, string | number> = {
|
||||||
page,
|
page,
|
||||||
page_size: pageSize,
|
page_size: pageSize,
|
||||||
});
|
};
|
||||||
|
if (keyword) {
|
||||||
|
params.keyword = keyword;
|
||||||
|
}
|
||||||
|
const response = await api.get<{ list: User[] }>(`/users/${userId}/followers`, params);
|
||||||
return response.data.list;
|
return response.data.list;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取粉丝列表失败:', error);
|
console.error('获取粉丝列表失败:', error);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export type {
|
|||||||
BlockStatusResponse,
|
BlockStatusResponse,
|
||||||
QRCodeSession,
|
QRCodeSession,
|
||||||
ScanResponse,
|
ScanResponse,
|
||||||
|
FetchCurrentUserResult,
|
||||||
} from './authService';
|
} from './authService';
|
||||||
|
|
||||||
export { verificationService } from './verificationService';
|
export { verificationService } from './verificationService';
|
||||||
|
|||||||
@@ -72,10 +72,33 @@ interface JwtPayload {
|
|||||||
iss?: string;
|
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 客户端类
|
// API 客户端类
|
||||||
class ApiClient {
|
class ApiClient {
|
||||||
private baseUrl: string;
|
private baseUrl: string;
|
||||||
private refreshLock: Promise<boolean> | null = null;
|
private refreshLock: Promise<RefreshResult> | null = null;
|
||||||
private isAuthFailed = false; // 全局认证失败标志,防止无限重试
|
private isAuthFailed = false; // 全局认证失败标志,防止无限重试
|
||||||
|
|
||||||
constructor(baseUrl: string) {
|
constructor(baseUrl: string) {
|
||||||
@@ -208,12 +231,15 @@ class ApiClient {
|
|||||||
if (token) {
|
if (token) {
|
||||||
// 检查 token 是否快过期,如果是则主动刷新
|
// 检查 token 是否快过期,如果是则主动刷新
|
||||||
if (this.isTokenExpiringSoon(token)) {
|
if (this.isTokenExpiringSoon(token)) {
|
||||||
const refreshed = await this.refreshToken();
|
const refreshResult = await this.refreshToken();
|
||||||
if (!refreshed) {
|
if (refreshResult === 'auth_failed') {
|
||||||
|
// 后端明确拒绝(refresh token 失效)→ 登出
|
||||||
await this.clearToken();
|
await this.clearToken();
|
||||||
this.navigateToLogin();
|
this.navigateToLogin();
|
||||||
throw new ApiError(401, '登录已过期,请重新登录');
|
throw new ApiError(401, '登录已过期,请重新登录', 'AUTH_ERROR');
|
||||||
}
|
}
|
||||||
|
// 'network_error':网络故障,保留登录态,沿用旧 token 继续请求
|
||||||
|
// (旧 token 可能仍有效;若已过期,后端会返回 401,届时再走重试逻辑)
|
||||||
const newToken = await this.getToken();
|
const newToken = await this.getToken();
|
||||||
if (newToken) {
|
if (newToken) {
|
||||||
headers['Authorization'] = `Bearer ${newToken}`;
|
headers['Authorization'] = `Bearer ${newToken}`;
|
||||||
@@ -240,15 +266,22 @@ class ApiClient {
|
|||||||
// 处理 401 未授权
|
// 处理 401 未授权
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
if (_retryCount >= 1) {
|
if (_retryCount >= 1) {
|
||||||
|
// 已重试过仍 401 → 后端明确拒绝该账号 → 登出
|
||||||
await this.clearToken();
|
await this.clearToken();
|
||||||
this.navigateToLogin();
|
this.navigateToLogin();
|
||||||
throw new ApiError(401, '登录已过期,请重新登录');
|
throw new ApiError(401, '登录已过期,请重新登录', 'AUTH_ERROR');
|
||||||
}
|
}
|
||||||
const refreshed = await this.refreshToken();
|
const refreshResult = await this.refreshToken();
|
||||||
if (!refreshed) {
|
if (refreshResult === 'auth_failed') {
|
||||||
|
// refresh token 失效 → 后端明确拒绝 → 登出
|
||||||
await this.clearToken();
|
await this.clearToken();
|
||||||
this.navigateToLogin();
|
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);
|
return this.request(method, path, params, body, _retryCount + 1);
|
||||||
@@ -302,14 +335,16 @@ class ApiClient {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 网络错误
|
// 网络错误(fetch 未收到后端响应):标记 errorCode,便于上层区分网络故障与认证拒绝
|
||||||
console.error('API请求失败:', error);
|
console.error('API请求失败:', error);
|
||||||
throw new ApiError(500, '网络请求失败,请检查网络连接');
|
throw new ApiError(503, '网络请求失败,请检查网络连接', 'NETWORK_ERROR');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 刷新 token(带锁,防止并发刷新)
|
// 刷新 token(带锁,防止并发刷新)
|
||||||
private async refreshToken(): Promise<boolean> {
|
// 返回 'ok' / 'auth_failed' / 'network_error':
|
||||||
|
// - network_error 表示请求未到达后端,调用方不应据此判定账号退出
|
||||||
|
private async refreshToken(): Promise<RefreshResult> {
|
||||||
// 如果已有刷新操作正在进行,等待其完成
|
// 如果已有刷新操作正在进行,等待其完成
|
||||||
if (this.refreshLock) {
|
if (this.refreshLock) {
|
||||||
return this.refreshLock;
|
return this.refreshLock;
|
||||||
@@ -328,11 +363,12 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 实际执行刷新 token
|
// 实际执行刷新 token
|
||||||
private async doRefreshToken(): Promise<boolean> {
|
private async doRefreshToken(): Promise<RefreshResult> {
|
||||||
try {
|
try {
|
||||||
const refreshToken = await this.getRefreshToken();
|
const refreshToken = await this.getRefreshToken();
|
||||||
if (!refreshToken) {
|
if (!refreshToken) {
|
||||||
return false;
|
// 没有刷新令牌:后端无法鉴权,视为认证失败
|
||||||
|
return 'auth_failed';
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${this.baseUrl}/auth/refresh`, {
|
const response = await fetch(`${this.baseUrl}/auth/refresh`, {
|
||||||
@@ -345,8 +381,9 @@ class ApiClient {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 后端明确拒绝(refresh token 失效/黑名单)→ 认证失败
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return false;
|
return 'auth_failed';
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -356,13 +393,19 @@ class ApiClient {
|
|||||||
await this.setRefreshToken(data.data.refresh_token || data.data.refreshToken);
|
await this.setRefreshToken(data.data.refresh_token || data.data.refreshToken);
|
||||||
}
|
}
|
||||||
this.registerDeviceOnRefresh();
|
this.registerDeviceOnRefresh();
|
||||||
return true;
|
return 'ok';
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
// 业务码非 0:后端明确返回失败 → 认证失败
|
||||||
|
return 'auth_failed';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// fetch 抛异常(DNS 失败、断网、超时)→ 网络故障,不应登出
|
||||||
|
if (isNetworkError(error)) {
|
||||||
|
console.warn('刷新token网络失败(保留登录态):', error);
|
||||||
|
return 'network_error';
|
||||||
|
}
|
||||||
console.error('刷新token失败:', error);
|
console.error('刷新token失败:', error);
|
||||||
return false;
|
return 'network_error';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,13 +486,18 @@ class ApiClient {
|
|||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (token) {
|
if (token) {
|
||||||
if (this.isTokenExpiringSoon(token)) {
|
if (this.isTokenExpiringSoon(token)) {
|
||||||
const refreshed = await this.refreshToken();
|
const refreshResult = await this.refreshToken();
|
||||||
if (refreshed) {
|
if (refreshResult === 'auth_failed') {
|
||||||
|
// 后端明确拒绝 → 登出,避免上传无意义请求
|
||||||
|
await this.clearToken();
|
||||||
|
this.navigateToLogin();
|
||||||
|
throw new ApiError(401, '登录已过期,请重新登录', 'AUTH_ERROR');
|
||||||
|
}
|
||||||
|
// 'ok' 或 'network_error':尝试用(可能已刷新的)token 继续
|
||||||
const newToken = await this.getToken();
|
const newToken = await this.getToken();
|
||||||
if (newToken) {
|
if (newToken) {
|
||||||
headers['Authorization'] = `Bearer ${newToken}`;
|
headers['Authorization'] = `Bearer ${newToken}`;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
headers['Authorization'] = `Bearer ${token}`;
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
export { api, WS_URL, TOKEN_KEY, REFRESH_TOKEN_KEY } from './api';
|
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 type { ApiResponse, PaginatedData } from './api';
|
||||||
|
|
||||||
export { wsService } from './wsService';
|
export { wsService } from './wsService';
|
||||||
|
|||||||
@@ -44,8 +44,6 @@ export type WSMessageType =
|
|||||||
| 'call_peer_muted'
|
| 'call_peer_muted'
|
||||||
| 'call_invited'
|
| 'call_invited'
|
||||||
| 'call_answered_elsewhere'
|
| 'call_answered_elsewhere'
|
||||||
| 'call_participant_joined'
|
|
||||||
| 'call_participant_left'
|
|
||||||
| 'sync_required';
|
| 'sync_required';
|
||||||
|
|
||||||
export interface WSCallIncomingMessage {
|
export interface WSCallIncomingMessage {
|
||||||
@@ -105,19 +103,6 @@ export interface WSCallAnsweredElsewhereMessage {
|
|||||||
reason?: string;
|
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 {
|
export interface WSErrorMessage {
|
||||||
type: 'error';
|
type: 'error';
|
||||||
code: string;
|
code: string;
|
||||||
@@ -272,8 +257,6 @@ export type WSMessage =
|
|||||||
| WSCallPeerMutedMessage
|
| WSCallPeerMutedMessage
|
||||||
| WSCallInvitedMessage
|
| WSCallInvitedMessage
|
||||||
| WSCallAnsweredElsewhereMessage
|
| WSCallAnsweredElsewhereMessage
|
||||||
| WSCallParticipantJoinedMessage
|
|
||||||
| WSCallParticipantLeftMessage
|
|
||||||
| WSErrorMessage
|
| WSErrorMessage
|
||||||
| WSSyncRequiredMessage;
|
| WSSyncRequiredMessage;
|
||||||
|
|
||||||
|
|||||||
@@ -331,9 +331,15 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. 纯 API 调用验证 Token 有效性(fetchCurrentUserFromAPI 完全不碰 DB)
|
// 3. 纯 API 调用验证 Token 有效性(fetchCurrentUserFromAPI 完全不碰 DB)
|
||||||
const user = await authService.fetchCurrentUserFromAPI();
|
// 返回判别类型:
|
||||||
|
// - 'user' 成功,继续登录流程
|
||||||
|
// - 'auth_failed' 后端明确拒绝(401)→ 清除登录态
|
||||||
|
// - 'network_error' 网络故障 → 保留旧登录态(持久化的 isAuthenticated),
|
||||||
|
// 等待网络恢复后重试,避免断网即被登出
|
||||||
|
const result = await authService.fetchCurrentUserFromAPI();
|
||||||
|
|
||||||
if (user) {
|
if (result.kind === 'user') {
|
||||||
|
const user = result.user;
|
||||||
const userId = String(user.id);
|
const userId = String(user.id);
|
||||||
|
|
||||||
// 4. 确保 DB 已初始化(如果预初始化失败,这里会重试)
|
// 4. 确保 DB 已初始化(如果预初始化失败,这里会重试)
|
||||||
@@ -364,27 +370,34 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
currentUser: user,
|
currentUser: user,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 8. 启动 SSE(不阻塞状态设置)
|
// 8. 启动 SSE(不阻塞状态设置)
|
||||||
startRealtime().catch((err) => {
|
startRealtime().catch((err) => {
|
||||||
console.warn('[AuthStore] 冷启动实时服务失败:', err);
|
console.warn('[AuthStore] 冷启动实时服务失败:', err);
|
||||||
});
|
});
|
||||||
|
} else if (result.kind === 'network_error') {
|
||||||
|
// 网络故障:不登出、不清 userId。
|
||||||
|
// 保留持久化的 isAuthenticated / currentUser(如有),让用户在网络恢复后仍可用。
|
||||||
|
set({
|
||||||
|
isLoading: false,
|
||||||
|
error: '网络连接失败,已使用本地登录态',
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
// Token 已失效或不存在
|
// auth_failed:后端明确拒绝,Token 真失效 → 清除登录态
|
||||||
await clearUserId();
|
await clearUserId();
|
||||||
useSessionStore.getState().setUserId(null);
|
useSessionStore.getState().setUserId(null);
|
||||||
set({
|
set({
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
currentUser: null,
|
currentUser: null,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[AuthStore] fetchCurrentUser 异常:', error);
|
console.error('[AuthStore] fetchCurrentUser 异常:', error);
|
||||||
set({
|
set({
|
||||||
isAuthenticated: false,
|
|
||||||
currentUser: null,
|
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: '获取用户信息失败',
|
error: '获取用户信息失败',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -680,24 +680,6 @@ export const callStore = create<CallState>((set, get) => ({
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
unsubs.push(
|
|
||||||
wsService.on('call_participant_joined', (msg) => {
|
|
||||||
console.log('[CallStore] Participant joined:', msg.user_id);
|
|
||||||
const { currentCall } = get();
|
|
||||||
if (!currentCall || currentCall.id !== msg.call_id) return;
|
|
||||||
get().addParticipant(msg.user_id, { name: msg.user_id });
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
unsubs.push(
|
|
||||||
wsService.on('call_participant_left', (msg) => {
|
|
||||||
console.log('[CallStore] Participant left:', msg.user_id, 'reason:', msg.reason);
|
|
||||||
const { currentCall } = get();
|
|
||||||
if (!currentCall || currentCall.id !== msg.call_id) return;
|
|
||||||
get().removeParticipant(msg.user_id);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
cleanupResources();
|
cleanupResources();
|
||||||
if (unsubInvited) {
|
if (unsubInvited) {
|
||||||
|
|||||||
@@ -10,13 +10,12 @@ import { useAuthStore } from '../auth/authStore';
|
|||||||
import type { MessageManagerConversationListDeps } from './types';
|
import type { MessageManagerConversationListDeps } from './types';
|
||||||
|
|
||||||
// 导入服务
|
// 导入服务
|
||||||
import { MessageDeduplication, messageDeduplication } from './services/MessageDeduplication';
|
import { UserCacheService } from './services/UserCacheService';
|
||||||
import { UserCacheService, userCacheService } from './services/UserCacheService';
|
|
||||||
import { ReadReceiptManager } from './services/ReadReceiptManager';
|
import { ReadReceiptManager } from './services/ReadReceiptManager';
|
||||||
import { ConversationOperations } from './services/ConversationOperations';
|
import { ConversationOperations } from './services/ConversationOperations';
|
||||||
import { MessageSendService } from './services/MessageSendService';
|
import { MessageSendService } from './services/MessageSendService';
|
||||||
import { MessageSyncService } from './services/MessageSyncService';
|
import { MessageSyncService } from './services/MessageSyncService';
|
||||||
import { WSMessageHandler } from './services/WSMessageHandler';
|
import { RealtimeIngestionPipeline } from './services/RealtimeIngestionPipeline';
|
||||||
|
|
||||||
// 导入 store
|
// 导入 store
|
||||||
import { useMessageStore, normalizeConversationId, loadPersistedLastSystemMessageAt } from './store';
|
import { useMessageStore, normalizeConversationId, loadPersistedLastSystemMessageAt } from './store';
|
||||||
@@ -31,13 +30,12 @@ export type {
|
|||||||
|
|
||||||
class MessageManager {
|
class MessageManager {
|
||||||
// 子模块
|
// 子模块
|
||||||
private deduplication: MessageDeduplication;
|
|
||||||
private userCacheService: UserCacheService;
|
private userCacheService: UserCacheService;
|
||||||
private readReceiptManager: ReadReceiptManager;
|
private readReceiptManager: ReadReceiptManager;
|
||||||
private conversationOps: ConversationOperations;
|
private conversationOps: ConversationOperations;
|
||||||
private sendService: MessageSendService;
|
private sendService: MessageSendService;
|
||||||
private syncService: MessageSyncService;
|
private syncService: MessageSyncService;
|
||||||
private wsHandler: WSMessageHandler;
|
private pipeline: RealtimeIngestionPipeline;
|
||||||
|
|
||||||
// 本地状态
|
// 本地状态
|
||||||
private currentUserId: string | null = null;
|
private currentUserId: string | null = null;
|
||||||
@@ -47,7 +45,6 @@ class MessageManager {
|
|||||||
|
|
||||||
constructor(deps?: MessageManagerConversationListDeps) {
|
constructor(deps?: MessageManagerConversationListDeps) {
|
||||||
// 初始化子模块
|
// 初始化子模块
|
||||||
this.deduplication = new MessageDeduplication();
|
|
||||||
this.userCacheService = new UserCacheService();
|
this.userCacheService = new UserCacheService();
|
||||||
this.readReceiptManager = new ReadReceiptManager();
|
this.readReceiptManager = new ReadReceiptManager();
|
||||||
this.conversationOps = new ConversationOperations();
|
this.conversationOps = new ConversationOperations();
|
||||||
@@ -58,18 +55,25 @@ class MessageManager {
|
|||||||
this.userCacheService,
|
this.userCacheService,
|
||||||
deps
|
deps
|
||||||
);
|
);
|
||||||
this.wsHandler = new WSMessageHandler(
|
this.pipeline = new RealtimeIngestionPipeline({
|
||||||
this.deduplication,
|
userCacheService: this.userCacheService,
|
||||||
this.userCacheService,
|
getCurrentUserId: () => this.getCurrentUserId(),
|
||||||
() => this.getCurrentUserId(),
|
onActiveConvMessage: (id, seq) => this.markAsRead(id, seq),
|
||||||
{
|
triggerSync: async (source) => {
|
||||||
markAsRead: (id, seq) => this.markAsRead(id, seq),
|
// 优先基于 seq 增量同步,失败则全量刷新会话 + 活动会话消息 + 未读
|
||||||
fetchConversations: (force, source) => this.fetchConversations(force, source),
|
const ok = await this.syncService.syncBySeq();
|
||||||
fetchUnreadCount: () => this.fetchUnreadCount(),
|
if (!ok) {
|
||||||
fetchMessages: (id) => this.fetchMessages(id),
|
await this.fetchConversations(true, source);
|
||||||
syncBySeq: () => this.syncService.syncBySeq(),
|
const activeConv = useMessageStore.getState().getActiveConversation();
|
||||||
|
if (activeConv) {
|
||||||
|
await this.fetchMessages(activeConv).catch(() => {});
|
||||||
}
|
}
|
||||||
);
|
}
|
||||||
|
await this.fetchUnreadCount().catch(() => {});
|
||||||
|
},
|
||||||
|
fetchConversations: (force, src) => this.fetchConversations(force, src),
|
||||||
|
fetchUnreadCount: () => this.fetchUnreadCount(),
|
||||||
|
});
|
||||||
|
|
||||||
// 监听认证状态变化
|
// 监听认证状态变化
|
||||||
this.initAuthListener();
|
this.initAuthListener();
|
||||||
@@ -138,13 +142,13 @@ class MessageManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.wsHandler.setBootstrapping(true);
|
this.pipeline.setBootstrapping(true);
|
||||||
|
|
||||||
// 从本地缓存恢复最后系统通知时间(避免首次渲染显示当前时间)
|
// 从本地缓存恢复最后系统通知时间(避免首次渲染显示当前时间)
|
||||||
await loadPersistedLastSystemMessageAt();
|
await loadPersistedLastSystemMessageAt();
|
||||||
|
|
||||||
// 初始化SSE监听
|
// 注册实时事件监听(start 内部会先注销旧监听器再注册)
|
||||||
this.wsHandler.connect();
|
this.pipeline.start();
|
||||||
|
|
||||||
// 加载会话列表
|
// 加载会话列表
|
||||||
await this.fetchConversations(false, 'initialize');
|
await this.fetchConversations(false, 'initialize');
|
||||||
@@ -156,15 +160,15 @@ class MessageManager {
|
|||||||
|
|
||||||
// 先关闭 bootstrap(事件流转入正常处理路径),再做尾部 flush
|
// 先关闭 bootstrap(事件流转入正常处理路径),再做尾部 flush
|
||||||
// 避免 flush 期间新到的事件被压入 buffer 后无人消费
|
// 避免 flush 期间新到的事件被压入 buffer 后无人消费
|
||||||
this.wsHandler.setBootstrapping(false);
|
this.pipeline.setBootstrapping(false);
|
||||||
await this.wsHandler.flushBufferedSSEEvents();
|
await this.pipeline.flushBufferedEvents();
|
||||||
|
|
||||||
if (useMessageStore.getState().currentConversationId) {
|
if (useMessageStore.getState().currentConversationId) {
|
||||||
await this.fetchMessages(useMessageStore.getState().currentConversationId!);
|
await this.fetchMessages(useMessageStore.getState().currentConversationId!);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[MessageManager] 初始化失败:', error);
|
console.error('[MessageManager] 初始化失败:', error);
|
||||||
this.wsHandler.setBootstrapping(false);
|
this.pipeline.setBootstrapping(false);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -176,11 +180,10 @@ class MessageManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
this.wsHandler.disconnect();
|
this.pipeline.stop();
|
||||||
useMessageStore.getState().reset();
|
useMessageStore.getState().reset();
|
||||||
this.syncService.restartSources();
|
this.syncService.restartSources();
|
||||||
this.readReceiptManager.reset();
|
this.readReceiptManager.reset();
|
||||||
this.deduplication.reset();
|
|
||||||
this.userCacheService.reset();
|
this.userCacheService.reset();
|
||||||
this.activatingConversationTasks.clear();
|
this.activatingConversationTasks.clear();
|
||||||
this.initializePromise = null;
|
this.initializePromise = null;
|
||||||
|
|||||||
192
src/stores/message/__tests__/RealtimeIngestionPipeline.test.ts
Normal file
192
src/stores/message/__tests__/RealtimeIngestionPipeline.test.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
/**
|
||||||
|
* RealtimeIngestionPipeline 单测
|
||||||
|
*
|
||||||
|
* 锁定的不变量:
|
||||||
|
* 1. 重复 chat 事件只入库一次、副作用(未读递增)只触发一次。
|
||||||
|
* 2. ingest 中途副作用抛错时,消息仍已落库(修复「标记早于落库」竞态的核心保证)。
|
||||||
|
* 3. start() 重复调用不叠加监听器;stop() 彻底注销(杜绝监听器泄漏)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { wsService } from '@/services/core';
|
||||||
|
import { eventBus } from '@/core/events/EventBus';
|
||||||
|
import { useMessageStore } from '../store';
|
||||||
|
import { RealtimeIngestionPipeline } from '../services/RealtimeIngestionPipeline';
|
||||||
|
import type { RealtimeIngestionPipelineDeps } from '../services/RealtimeIngestionPipeline';
|
||||||
|
|
||||||
|
function makeDeps(overrides: Partial<RealtimeIngestionPipelineDeps> = {}): RealtimeIngestionPipelineDeps {
|
||||||
|
return {
|
||||||
|
userCacheService: { getSenderInfo: async () => null, enrichMessagesWithSenderInfo: () => {} },
|
||||||
|
getCurrentUserId: () => 'me',
|
||||||
|
onActiveConvMessage: async () => {},
|
||||||
|
triggerSync: async () => {},
|
||||||
|
fetchConversations: async () => {},
|
||||||
|
fetchUnreadCount: async () => {},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeChatMessage(id: string, seq: number, senderId = 'other') {
|
||||||
|
return {
|
||||||
|
type: 'chat' as const,
|
||||||
|
conversation_id: 'c1',
|
||||||
|
id,
|
||||||
|
sender_id: senderId,
|
||||||
|
seq,
|
||||||
|
segments: [{ type: 'text', data: { text: 'hi' } }] as any[],
|
||||||
|
created_at: `2024-01-01T00:00:${String(seq).padStart(2, '0')}Z`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RealtimeIngestionPipeline', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useMessageStore.getState().reset();
|
||||||
|
(wsService as any).reset();
|
||||||
|
(eventBus as any).reset();
|
||||||
|
// 确保 store 处于已初始化状态,否则事件会被 buffer
|
||||||
|
useMessageStore.getState().setInitialized(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('幂等 ingest', () => {
|
||||||
|
it('重复 chat 事件只入库一次', async () => {
|
||||||
|
const pipeline = new RealtimeIngestionPipeline(makeDeps());
|
||||||
|
const msg = makeChatMessage('m1', 1);
|
||||||
|
|
||||||
|
await pipeline.ingestChatMessage(msg);
|
||||||
|
await pipeline.ingestChatMessage(msg); // 重复
|
||||||
|
|
||||||
|
expect(useMessageStore.getState().getMessages('c1')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('重复 chat 事件只递增未读一次', async () => {
|
||||||
|
const pipeline = new RealtimeIngestionPipeline(makeDeps());
|
||||||
|
// 先确保会话存在,否则 incrementUnreadAtomic 会因找不到会话而跳过
|
||||||
|
useMessageStore.getState().updateConversation({
|
||||||
|
id: 'c1',
|
||||||
|
unread_count: 0,
|
||||||
|
last_seq: 0,
|
||||||
|
} as any);
|
||||||
|
const msg = makeChatMessage('m1', 1);
|
||||||
|
|
||||||
|
await pipeline.ingestChatMessage(msg);
|
||||||
|
await pipeline.ingestChatMessage(msg);
|
||||||
|
|
||||||
|
const conv = useMessageStore.getState().getConversation('c1');
|
||||||
|
expect(conv?.unread_count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('重复 chat 事件只发一次震动', async () => {
|
||||||
|
const pipeline = new RealtimeIngestionPipeline(makeDeps());
|
||||||
|
useMessageStore.getState().updateConversation({
|
||||||
|
id: 'c1',
|
||||||
|
unread_count: 0,
|
||||||
|
last_seq: 0,
|
||||||
|
} as any);
|
||||||
|
const msg = makeChatMessage('m1', 1);
|
||||||
|
|
||||||
|
await pipeline.ingestChatMessage(msg);
|
||||||
|
await pipeline.ingestChatMessage(msg);
|
||||||
|
|
||||||
|
const vibrateEvents = (eventBus as any).emitted.filter((e: any) => e.type === 'VIBRATE');
|
||||||
|
expect(vibrateEvents).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('每次 ingest 都发 ACK(即使重复,服务器侧幂等)', async () => {
|
||||||
|
const pipeline = new RealtimeIngestionPipeline(makeDeps());
|
||||||
|
const msg = makeChatMessage('m1', 1);
|
||||||
|
|
||||||
|
await pipeline.ingestChatMessage(msg);
|
||||||
|
await pipeline.ingestChatMessage(msg);
|
||||||
|
|
||||||
|
expect((wsService as any).ackCalls).toEqual(['m1', 'm1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('不同消息独立入库', async () => {
|
||||||
|
const pipeline = new RealtimeIngestionPipeline(makeDeps());
|
||||||
|
await pipeline.ingestChatMessage(makeChatMessage('m1', 1));
|
||||||
|
await pipeline.ingestChatMessage(makeChatMessage('m2', 2));
|
||||||
|
const msgs = useMessageStore.getState().getMessages('c1');
|
||||||
|
expect(msgs.map(m => m.id)).toEqual(['m1', 'm2']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('落库先于副作用(不丢消息)', () => {
|
||||||
|
it('副作用回调抛错时消息仍已落库', async () => {
|
||||||
|
// triggerSync 抛错不应影响 ingest(虽然 ingest 本身不调 triggerSync,
|
||||||
|
// 这里验证 onActiveConvMessage 抛错时消息仍在 store)
|
||||||
|
const pipeline = new RealtimeIngestionPipeline(
|
||||||
|
makeDeps({
|
||||||
|
onActiveConvMessage: async () => {
|
||||||
|
throw new Error('markAsRead failed');
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
// 设为活动会话以触发 onActiveConvMessage
|
||||||
|
useMessageStore.getState().setCurrentConversation('c1');
|
||||||
|
|
||||||
|
await pipeline.ingestChatMessage(makeChatMessage('m1', 1));
|
||||||
|
|
||||||
|
// 关键不变量:消息已落库,副作用失败未回滚
|
||||||
|
expect(useMessageStore.getState().getMessages('c1')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('监听器生命周期(杜绝泄漏)', () => {
|
||||||
|
it('start() 注册监听器,事件能被路由', async () => {
|
||||||
|
const pipeline = new RealtimeIngestionPipeline(makeDeps());
|
||||||
|
pipeline.start();
|
||||||
|
// 触发 chat 事件
|
||||||
|
(wsService as any).emit('chat', makeChatMessage('m1', 1));
|
||||||
|
// ingestChatMessage 是 async,等一个微任务
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(useMessageStore.getState().getMessages('c1')).toHaveLength(1);
|
||||||
|
pipeline.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stop() 彻底注销所有监听器', () => {
|
||||||
|
const pipeline = new RealtimeIngestionPipeline(makeDeps());
|
||||||
|
pipeline.start();
|
||||||
|
const beforeChat = (wsService as any).listenerCount('chat');
|
||||||
|
const beforeConnect = (wsService as any).connectHandlerCount();
|
||||||
|
expect(beforeChat).toBeGreaterThan(0);
|
||||||
|
expect(beforeConnect).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
pipeline.stop();
|
||||||
|
|
||||||
|
expect((wsService as any).listenerCount('chat')).toBe(0);
|
||||||
|
expect((wsService as any).listenerCount('group_message')).toBe(0);
|
||||||
|
expect((wsService as any).listenerCount('recall')).toBe(0);
|
||||||
|
expect((wsService as any).listenerCount('sync_required')).toBe(0);
|
||||||
|
expect((wsService as any).connectHandlerCount()).toBe(0);
|
||||||
|
expect((wsService as any).disconnectHandlerCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('重复 start() 不叠加监听器', () => {
|
||||||
|
const pipeline = new RealtimeIngestionPipeline(makeDeps());
|
||||||
|
pipeline.start();
|
||||||
|
const firstCount = (wsService as any).listenerCount('chat');
|
||||||
|
expect(firstCount).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
pipeline.start();
|
||||||
|
pipeline.start();
|
||||||
|
|
||||||
|
// 监听器数量应与首次注册后相同,而非 3 倍
|
||||||
|
expect((wsService as any).listenerCount('chat')).toBe(firstCount);
|
||||||
|
pipeline.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stop() 后再 start() 可重新接收事件', async () => {
|
||||||
|
const pipeline = new RealtimeIngestionPipeline(makeDeps());
|
||||||
|
pipeline.start();
|
||||||
|
pipeline.stop();
|
||||||
|
pipeline.start();
|
||||||
|
|
||||||
|
(wsService as any).emit('chat', makeChatMessage('m1', 1));
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(useMessageStore.getState().getMessages('c1')).toHaveLength(1);
|
||||||
|
pipeline.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
18
src/stores/message/__tests__/mocks/asyncStorageMock.ts
Normal file
18
src/stores/message/__tests__/mocks/asyncStorageMock.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* 测试用 AsyncStorage mock:内存 Map 实现。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const store = new Map<string, string>();
|
||||||
|
|
||||||
|
export default {
|
||||||
|
getItem: async (key: string): Promise<string | null> => store.get(key) ?? null,
|
||||||
|
setItem: async (key: string, value: string): Promise<void> => {
|
||||||
|
store.set(key, value);
|
||||||
|
},
|
||||||
|
removeItem: async (key: string): Promise<void> => {
|
||||||
|
store.delete(key);
|
||||||
|
},
|
||||||
|
__reset(): void {
|
||||||
|
store.clear();
|
||||||
|
},
|
||||||
|
};
|
||||||
15
src/stores/message/__tests__/mocks/authStoreMock.ts
Normal file
15
src/stores/message/__tests__/mocks/authStoreMock.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* 测试用 authStore mock:提供 getState/subscribe 与 currentUser。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
currentUser: { id: string } | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>(() => ({
|
||||||
|
currentUser: { id: 'me' },
|
||||||
|
isAuthenticated: true,
|
||||||
|
}));
|
||||||
104
src/stores/message/__tests__/mocks/coreMock.ts
Normal file
104
src/stores/message/__tests__/mocks/coreMock.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* 测试用 core mock:提供 wsService、GroupNoticeType、ApiError 等最小可用实现。
|
||||||
|
* 仅覆盖 RealtimeIngestionPipeline 依赖的表面。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const GroupNoticeType = undefined as any;
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
code?: number;
|
||||||
|
constructor(message: string, code?: number) {
|
||||||
|
super(message);
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type EventHandler = (msg: any) => void;
|
||||||
|
type ConnHandler = () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可控的 wsService mock:on/onConnect/onDisconnect 注册处理器,
|
||||||
|
* 测试通过 emit('chat', msg) / emitConnect() 等方法模拟 WS 事件。
|
||||||
|
*/
|
||||||
|
class WsServiceMock {
|
||||||
|
private handlers: Map<string, EventHandler[]> = new Map();
|
||||||
|
private connectHandlers: ConnHandler[] = [];
|
||||||
|
private disconnectHandlers: ConnHandler[] = [];
|
||||||
|
private syncRequiredHandlers: EventHandler[] = [];
|
||||||
|
|
||||||
|
ackCalls: string[] = [];
|
||||||
|
|
||||||
|
on(type: string, handler: EventHandler): () => void {
|
||||||
|
const list = this.handlers.get(type) || [];
|
||||||
|
list.push(handler);
|
||||||
|
this.handlers.set(type, list);
|
||||||
|
return () => {
|
||||||
|
const cur = this.handlers.get(type) || [];
|
||||||
|
const idx = cur.indexOf(handler);
|
||||||
|
if (idx >= 0) cur.splice(idx, 1);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
onConnect(handler: ConnHandler): () => void {
|
||||||
|
this.connectHandlers.push(handler);
|
||||||
|
return () => {
|
||||||
|
const i = this.connectHandlers.indexOf(handler);
|
||||||
|
if (i >= 0) this.connectHandlers.splice(i, 1);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
onDisconnect(handler: ConnHandler): () => void {
|
||||||
|
this.disconnectHandlers.push(handler);
|
||||||
|
return () => {
|
||||||
|
const i = this.disconnectHandlers.indexOf(handler);
|
||||||
|
if (i >= 0) this.disconnectHandlers.splice(i, 1);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
sendAck(messageId: string): void {
|
||||||
|
this.ackCalls.push(messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 测试驱动方法 =====
|
||||||
|
emit(type: string, msg: any): void {
|
||||||
|
const list = this.handlers.get(type) || [];
|
||||||
|
list.forEach(h => h(msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
emitConnect(): void {
|
||||||
|
this.connectHandlers.forEach(h => h());
|
||||||
|
}
|
||||||
|
|
||||||
|
emitDisconnect(): void {
|
||||||
|
this.disconnectHandlers.forEach(h => h());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前已注册某类型监听器的数量(用于断言无泄漏) */
|
||||||
|
listenerCount(type: string): number {
|
||||||
|
return (this.handlers.get(type) || []).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
connectHandlerCount(): number {
|
||||||
|
return this.connectHandlers.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectHandlerCount(): number {
|
||||||
|
return this.disconnectHandlers.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this.handlers.clear();
|
||||||
|
this.connectHandlers = [];
|
||||||
|
this.disconnectHandlers = [];
|
||||||
|
this.ackCalls = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const wsService = new WsServiceMock();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 供 WSServerMessage 等 wsService.ts 导出的类型在 mock 中可见。
|
||||||
|
* Pipeline 只用到 wsService 实例和 GroupNoticeType;类型 import 是 type-only,
|
||||||
|
* 在运行期被擦除,因此这里不需要真正导出接口类型。
|
||||||
|
*/
|
||||||
|
export {};
|
||||||
26
src/stores/message/__tests__/mocks/databaseMock.ts
Normal file
26
src/stores/message/__tests__/mocks/databaseMock.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* 测试用 database mock:messageRepository / conversationRepository / userCacheRepository。
|
||||||
|
* 仅提供方法存根,单测断言 store 层行为,不验证持久化。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const messageRepository = {
|
||||||
|
saveMessage: async () => {},
|
||||||
|
saveMessagesBatch: async () => {},
|
||||||
|
getByConversation: async () => [],
|
||||||
|
getBeforeSeq: async () => [],
|
||||||
|
updateStatus: async () => {},
|
||||||
|
markConversationAsRead: async () => {},
|
||||||
|
delete: async () => {},
|
||||||
|
clearConversation: async () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const conversationRepository = {
|
||||||
|
getListCache: async () => [],
|
||||||
|
saveWithRelated: async () => {},
|
||||||
|
delete: async () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const userCacheRepository = {
|
||||||
|
get: () => null,
|
||||||
|
set: () => {},
|
||||||
|
};
|
||||||
23
src/stores/message/__tests__/mocks/eventBusMock.ts
Normal file
23
src/stores/message/__tests__/mocks/eventBusMock.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* 测试用 eventBus mock:记录 emit 的事件供断言。
|
||||||
|
*/
|
||||||
|
|
||||||
|
type AppEvent = { type: string; payload?: any };
|
||||||
|
|
||||||
|
class EventBusMock {
|
||||||
|
emitted: AppEvent[] = [];
|
||||||
|
|
||||||
|
emit(event: AppEvent): void {
|
||||||
|
this.emitted.push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(_fn: (event: AppEvent) => void): () => void {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this.emitted = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const eventBus = new EventBusMock();
|
||||||
18
src/stores/message/__tests__/mocks/messageServiceMock.ts
Normal file
18
src/stores/message/__tests__/mocks/messageServiceMock.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* 测试用 messageService mock:仅覆盖单测路径调用的方法。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const messageService = {
|
||||||
|
getMessages: async () => ({ messages: [], total: 0, page: 1, page_size: 20 }),
|
||||||
|
getConversationById: async () => null,
|
||||||
|
getUnreadCount: async () => ({ total_unread_count: 0 }),
|
||||||
|
getSystemUnreadCount: async () => ({ unread_count: 0 }),
|
||||||
|
getSyncData: async () => [],
|
||||||
|
markAsRead: async () => {},
|
||||||
|
markAllAsRead: async () => {},
|
||||||
|
markAllSystemMessagesRead: async () => {},
|
||||||
|
fetchRemoteConversationListPage: async () => ({ items: [], hasMore: false }),
|
||||||
|
getSystemMessagesCursor: async () => ({ list: [] }),
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MessageListResponse = { messages: any[]; total: number; page: number; page_size: number };
|
||||||
112
src/stores/message/__tests__/store.idempotency.test.ts
Normal file
112
src/stores/message/__tests__/store.idempotency.test.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* Store 幂等性单测
|
||||||
|
*
|
||||||
|
* 锁定的不变量:
|
||||||
|
* 1. addOrReplaceMessage 以 message id 为幂等键,重复写入返回 false 且不产生重复消息。
|
||||||
|
* 2. 消息按 seq 升序排列。
|
||||||
|
* 3. 相邻消息不会因重复 ingest 而丢失(修复「消息被吞」的核心保证)。
|
||||||
|
* 4. 并发 merge(模拟多次同步写入)不会丢消息。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMessageStore } from '../store';
|
||||||
|
import type { MessageResponse } from '../../../types/dto';
|
||||||
|
|
||||||
|
function makeMessage(id: string, seq: number, extra: Partial<MessageResponse> = {}): MessageResponse {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
conversation_id: 'c1',
|
||||||
|
sender_id: 'u1',
|
||||||
|
seq,
|
||||||
|
segments: [],
|
||||||
|
created_at: `2024-01-01T00:00:${String(seq).padStart(2, '0')}Z`,
|
||||||
|
status: 'normal',
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('store 幂等性', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useMessageStore.getState().reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addOrReplaceMessage', () => {
|
||||||
|
it('首次写入返回 true,消息进入 store', () => {
|
||||||
|
const inserted = useMessageStore.getState().addOrReplaceMessage('c1', makeMessage('m1', 1));
|
||||||
|
expect(inserted).toBe(true);
|
||||||
|
const msgs = useMessageStore.getState().getMessages('c1');
|
||||||
|
expect(msgs).toHaveLength(1);
|
||||||
|
expect(msgs[0].id).toBe('m1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('同 id 重复写入返回 false 且不产生重复', () => {
|
||||||
|
useMessageStore.getState().addOrReplaceMessage('c1', makeMessage('m1', 1));
|
||||||
|
const second = useMessageStore.getState().addOrReplaceMessage('c1', makeMessage('m1', 1));
|
||||||
|
expect(second).toBe(false);
|
||||||
|
expect(useMessageStore.getState().getMessages('c1')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('id 相同但 seq 不同(重排)仍视为幂等,不重复插入', () => {
|
||||||
|
useMessageStore.getState().addOrReplaceMessage('c1', makeMessage('m1', 1));
|
||||||
|
const second = useMessageStore.getState().addOrReplaceMessage('c1', makeMessage('m1', 999));
|
||||||
|
// 幂等键是 id,而非 seq
|
||||||
|
expect(second).toBe(false);
|
||||||
|
expect(useMessageStore.getState().getMessages('c1')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('相邻消息不会因中间消息重复 ingest 而丢失', () => {
|
||||||
|
// 模拟 WS 与 REST 增量同步交织:m1, m2 都先入,然后 m1 重复,最后 m3 入
|
||||||
|
useMessageStore.getState().addOrReplaceMessage('c1', makeMessage('m1', 1));
|
||||||
|
useMessageStore.getState().addOrReplaceMessage('c1', makeMessage('m2', 2));
|
||||||
|
// m1 重复(如发送回显 + WS echo)
|
||||||
|
useMessageStore.getState().addOrReplaceMessage('c1', makeMessage('m1', 1));
|
||||||
|
useMessageStore.getState().addOrReplaceMessage('c1', makeMessage('m3', 3));
|
||||||
|
|
||||||
|
const msgs = useMessageStore.getState().getMessages('c1');
|
||||||
|
expect(msgs.map(m => m.id)).toEqual(['m1', 'm2', 'm3']);
|
||||||
|
expect(msgs.map(m => m.seq)).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('不同会话互不影响', () => {
|
||||||
|
useMessageStore.getState().addOrReplaceMessage('c1', makeMessage('m1', 1));
|
||||||
|
const inserted = useMessageStore.getState().addOrReplaceMessage('c2', makeMessage('m1', 1));
|
||||||
|
expect(inserted).toBe(true);
|
||||||
|
expect(useMessageStore.getState().getMessages('c1')).toHaveLength(1);
|
||||||
|
expect(useMessageStore.getState().getMessages('c2')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mergeMessages', () => {
|
||||||
|
it('批量合并同样按 id 去重', () => {
|
||||||
|
useMessageStore.getState().mergeMessages('c1', [makeMessage('m1', 1), makeMessage('m2', 2)]);
|
||||||
|
// 重复 m1 + 新 m3
|
||||||
|
useMessageStore.getState().mergeMessages('c1', [makeMessage('m1', 1), makeMessage('m3', 3)]);
|
||||||
|
const msgs = useMessageStore.getState().getMessages('c1');
|
||||||
|
expect(msgs.map(m => m.id)).toEqual(['m1', 'm2', 'm3']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('空 incoming 不改变现有消息', () => {
|
||||||
|
useMessageStore.getState().mergeMessages('c1', [makeMessage('m1', 1)]);
|
||||||
|
useMessageStore.getState().mergeMessages('c1', []);
|
||||||
|
expect(useMessageStore.getState().getMessages('c1')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('乱序 seq 入库后仍按 seq 升序排列', () => {
|
||||||
|
useMessageStore.getState().mergeMessages('c1', [
|
||||||
|
makeMessage('m3', 3),
|
||||||
|
makeMessage('m1', 1),
|
||||||
|
makeMessage('m2', 2),
|
||||||
|
]);
|
||||||
|
const msgs = useMessageStore.getState().getMessages('c1');
|
||||||
|
expect(msgs.map(m => m.seq)).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('并发连续 merge 不丢消息(模拟 WS + REST 增量同步竞态)', () => {
|
||||||
|
// 两条独立 merge,store 在 set 回调内原子合并
|
||||||
|
useMessageStore.getState().mergeMessages('c1', [makeMessage('m1', 1), makeMessage('m2', 2)]);
|
||||||
|
useMessageStore.getState().mergeMessages('c1', [makeMessage('m2', 2), makeMessage('m3', 3)]);
|
||||||
|
useMessageStore.getState().mergeMessages('c1', [makeMessage('m1', 1), makeMessage('m3', 3), makeMessage('m4', 4)]);
|
||||||
|
const msgs = useMessageStore.getState().getMessages('c1');
|
||||||
|
expect(msgs.map(m => m.id)).toEqual(['m1', 'm2', 'm3', 'm4']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
11
src/stores/message/__tests__/tsconfig.json
Normal file
11
src/stores/message/__tests__/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "../../../../",
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["jest", "node"]
|
||||||
|
},
|
||||||
|
"include": ["**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "mocks/**"]
|
||||||
|
}
|
||||||
112
src/stores/message/__tests__/unread.atomics.test.ts
Normal file
112
src/stores/message/__tests__/unread.atomics.test.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* 未读计数原子性单测
|
||||||
|
*
|
||||||
|
* 锁定的不变量:
|
||||||
|
* 1. store 原子 actions(incrementSystemUnreadAtomic / setSystemUnreadAtomic /
|
||||||
|
* decrementSystemUnreadAtomic)保持 total = system + 会话未读的一致性。
|
||||||
|
* 2. ConversationOperations 的系统未读变更走原子路径,不丢更新(修复 RMW 竞态)。
|
||||||
|
* 3. 并发多次递增计数不丢失。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMessageStore } from '../store';
|
||||||
|
import { ConversationOperations } from '../services/ConversationOperations';
|
||||||
|
|
||||||
|
describe('未读计数原子性', () => {
|
||||||
|
let convOps: ConversationOperations;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
useMessageStore.getState().reset();
|
||||||
|
convOps = new ConversationOperations();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('store 原子 actions', () => {
|
||||||
|
it('incrementSystemUnreadAtomic 同步推进 system 与 total', () => {
|
||||||
|
useMessageStore.getState().incrementSystemUnreadAtomic();
|
||||||
|
useMessageStore.getState().incrementSystemUnreadAtomic();
|
||||||
|
const { total, system } = useMessageStore.getState().getUnreadCount();
|
||||||
|
expect(system).toBe(2);
|
||||||
|
expect(total).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setSystemUnreadAtomic 保持 total 一致性(基于 delta)', () => {
|
||||||
|
useMessageStore.getState().incrementSystemUnreadAtomic(); // system=1,total=1
|
||||||
|
useMessageStore.getState().setSystemUnreadAtomic(5); // system=5, total 应 +4
|
||||||
|
const { total, system } = useMessageStore.getState().getUnreadCount();
|
||||||
|
expect(system).toBe(5);
|
||||||
|
expect(total).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setSystemUnreadAtomic 向下调整也保持一致', () => {
|
||||||
|
useMessageStore.getState().setSystemUnreadAtomic(10); // system=10,total=10
|
||||||
|
useMessageStore.getState().setSystemUnreadAtomic(3); // system=3,total=3
|
||||||
|
const { total, system } = useMessageStore.getState().getUnreadCount();
|
||||||
|
expect(system).toBe(3);
|
||||||
|
expect(total).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decrementSystemUnreadAtomic 不低于 0', () => {
|
||||||
|
useMessageStore.getState().setSystemUnreadAtomic(2);
|
||||||
|
useMessageStore.getState().decrementSystemUnreadAtomic(5); // 应被钳制到 0
|
||||||
|
const { total, system } = useMessageStore.getState().getUnreadCount();
|
||||||
|
expect(system).toBe(0);
|
||||||
|
expect(total).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decrementSystemUnreadAtomic 默认减 1', () => {
|
||||||
|
useMessageStore.getState().setSystemUnreadAtomic(3);
|
||||||
|
useMessageStore.getState().decrementSystemUnreadAtomic();
|
||||||
|
const { system } = useMessageStore.getState().getUnreadCount();
|
||||||
|
expect(system).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('并发连续递增计数不丢失(每个 set 都是原子的)', () => {
|
||||||
|
// 模拟 WS 高并发推送 100 条系统通知
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
useMessageStore.getState().incrementSystemUnreadAtomic();
|
||||||
|
}
|
||||||
|
const { total, system } = useMessageStore.getState().getUnreadCount();
|
||||||
|
expect(system).toBe(100);
|
||||||
|
expect(total).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ConversationOperations 走原子路径', () => {
|
||||||
|
it('incrementSystemUnreadCount 不丢更新', () => {
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
convOps.incrementSystemUnreadCount();
|
||||||
|
}
|
||||||
|
const { system } = useMessageStore.getState().getUnreadCount();
|
||||||
|
expect(system).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setSystemUnreadCount 后再并发递增不丢', () => {
|
||||||
|
convOps.setSystemUnreadCount(10);
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
convOps.incrementSystemUnreadCount();
|
||||||
|
}
|
||||||
|
const { system, total } = useMessageStore.getState().getUnreadCount();
|
||||||
|
expect(system).toBe(30);
|
||||||
|
expect(total).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decrementSystemUnreadCount 与原子行为一致', () => {
|
||||||
|
convOps.setSystemUnreadCount(10);
|
||||||
|
convOps.decrementSystemUnreadCount(3);
|
||||||
|
expect(useMessageStore.getState().getUnreadCount().system).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('会话未读 + 系统未读共同构成 total', () => {
|
||||||
|
// 先放一个有未读的会话
|
||||||
|
useMessageStore.getState().updateConversation({
|
||||||
|
id: 'c1',
|
||||||
|
unread_count: 5,
|
||||||
|
last_seq: 0,
|
||||||
|
} as any);
|
||||||
|
convOps.incrementSystemUnreadCount(); // system=1,total 应 = 5+1=6
|
||||||
|
const { total, system } = useMessageStore.getState().getUnreadCount();
|
||||||
|
expect(system).toBe(1);
|
||||||
|
// total 由会话未读累加与系统未读共同维护;这里仅断言 system 路径
|
||||||
|
expect(total).toBeGreaterThanOrEqual(system);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,30 +9,12 @@
|
|||||||
*/
|
*/
|
||||||
export const READ_STATE_PROTECTION_DELAY = 5000; // 5秒
|
export const READ_STATE_PROTECTION_DELAY = 5000; // 5秒
|
||||||
|
|
||||||
/**
|
|
||||||
* 已处理消息ID过期时间(毫秒)
|
|
||||||
* 用于消息去重机制,防止内存泄漏
|
|
||||||
*/
|
|
||||||
export const PROCESSED_MESSAGE_ID_EXPIRY = 5 * 60 * 1000; // 5分钟
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 已处理消息ID集合最大大小
|
|
||||||
* 超过此大小时触发清理
|
|
||||||
*/
|
|
||||||
export const PROCESSED_MESSAGE_ID_MAX_SIZE = 1000;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 缓冲SSE事件刷新最大迭代次数
|
* 缓冲SSE事件刷新最大迭代次数
|
||||||
* 防止在线流持续不断导致长时间阻塞初始化
|
* 防止在线流持续不断导致长时间阻塞初始化
|
||||||
*/
|
*/
|
||||||
export const MAX_FLUSH_ITERATIONS = 3;
|
export const MAX_FLUSH_ITERATIONS = 3;
|
||||||
|
|
||||||
/**
|
|
||||||
* 会话列表刷新延迟检查间隔(毫秒)
|
|
||||||
* 用于防抖处理
|
|
||||||
*/
|
|
||||||
export const CONVERSATION_REFRESH_THROTTLE = 1000;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 重连同步最小间隔(毫秒)
|
* 重连同步最小间隔(毫秒)
|
||||||
* 防止频繁重连导致的重复同步
|
* 防止频繁重连导致的重复同步
|
||||||
|
|||||||
@@ -20,34 +20,27 @@ export type {
|
|||||||
ReadStateRecord,
|
ReadStateRecord,
|
||||||
MessageManagerConversationListDeps,
|
MessageManagerConversationListDeps,
|
||||||
HandleNewMessageOptions,
|
HandleNewMessageOptions,
|
||||||
IMessageDeduplication,
|
|
||||||
IUserCacheService,
|
IUserCacheService,
|
||||||
IReadReceiptManager,
|
IReadReceiptManager,
|
||||||
IConversationOperations,
|
IConversationOperations,
|
||||||
IMessageSendService,
|
IMessageSendService,
|
||||||
IMessageSyncService,
|
IMessageSyncService,
|
||||||
IWSMessageHandler,
|
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
// ==================== 常量导出 ====================
|
// ==================== 常量导出 ====================
|
||||||
export {
|
export {
|
||||||
READ_STATE_PROTECTION_DELAY,
|
READ_STATE_PROTECTION_DELAY,
|
||||||
PROCESSED_MESSAGE_ID_EXPIRY,
|
|
||||||
PROCESSED_MESSAGE_ID_MAX_SIZE,
|
|
||||||
MAX_FLUSH_ITERATIONS,
|
MAX_FLUSH_ITERATIONS,
|
||||||
CONVERSATION_REFRESH_THROTTLE,
|
|
||||||
MIN_RECONNECT_SYNC_INTERVAL,
|
MIN_RECONNECT_SYNC_INTERVAL,
|
||||||
} from './constants';
|
} from './constants';
|
||||||
|
|
||||||
// ==================== 服务导出 ====================
|
// ==================== 服务导出 ====================
|
||||||
export {
|
export {
|
||||||
// 消息去重服务
|
// 实时消息入口
|
||||||
MessageDeduplication,
|
RealtimeIngestionPipeline,
|
||||||
messageDeduplication,
|
|
||||||
|
|
||||||
// 用户缓存服务
|
// 用户缓存服务
|
||||||
UserCacheService,
|
UserCacheService,
|
||||||
userCacheService,
|
|
||||||
|
|
||||||
// 已读回执管理器
|
// 已读回执管理器
|
||||||
ReadReceiptManager,
|
ReadReceiptManager,
|
||||||
@@ -60,10 +53,8 @@ export {
|
|||||||
|
|
||||||
// 消息同步服务
|
// 消息同步服务
|
||||||
MessageSyncService,
|
MessageSyncService,
|
||||||
|
|
||||||
// WebSocket 消息处理器
|
|
||||||
WSMessageHandler,
|
|
||||||
} from './services';
|
} from './services';
|
||||||
|
export type { RealtimeIngestionPipelineDeps, IngestOptions } from './services';
|
||||||
|
|
||||||
// ==================== Hooks 导出(便捷版,使用 messageManager 单例) ====================
|
// ==================== Hooks 导出(便捷版,使用 messageManager 单例) ====================
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -99,30 +99,23 @@ export class ConversationOperations implements IConversationOperations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 设置系统消息未读数
|
* 设置系统消息未读数(原子:避免与 WS 推送的并发递增丢更新)
|
||||||
*/
|
*/
|
||||||
setSystemUnreadCount(count: number): void {
|
setSystemUnreadCount(count: number): void {
|
||||||
const store = useMessageStore.getState();
|
useMessageStore.getState().setSystemUnreadAtomic(count);
|
||||||
const currentUnread = store.getUnreadCount();
|
|
||||||
store.setUnreadCount(currentUnread.total, count);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 增加系统消息未读数
|
* 增加系统消息未读数(原子)
|
||||||
*/
|
*/
|
||||||
incrementSystemUnreadCount(): void {
|
incrementSystemUnreadCount(): void {
|
||||||
const store = useMessageStore.getState();
|
useMessageStore.getState().incrementSystemUnreadAtomic();
|
||||||
const currentUnread = store.getUnreadCount();
|
|
||||||
store.setUnreadCount(currentUnread.total, currentUnread.system + 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 减少系统消息未读数
|
* 减少系统消息未读数(原子,不低于 0)
|
||||||
*/
|
*/
|
||||||
decrementSystemUnreadCount(count = 1): void {
|
decrementSystemUnreadCount(count = 1): void {
|
||||||
const store = useMessageStore.getState();
|
useMessageStore.getState().decrementSystemUnreadAtomic(count);
|
||||||
const currentUnread = store.getUnreadCount();
|
|
||||||
const newSystemCount = Math.max(0, currentUnread.system - count);
|
|
||||||
store.setUnreadCount(currentUnread.total, newSystemCount);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
/**
|
|
||||||
* 消息去重服务
|
|
||||||
* 处理消息去重逻辑,防止重复处理相同的消息
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { IMessageDeduplication } from '../types';
|
|
||||||
import {
|
|
||||||
PROCESSED_MESSAGE_ID_EXPIRY,
|
|
||||||
PROCESSED_MESSAGE_ID_MAX_SIZE,
|
|
||||||
} from '../constants';
|
|
||||||
|
|
||||||
export class MessageDeduplication implements IMessageDeduplication {
|
|
||||||
// 已处理消息ID集合(用于去重,防止ACK消息和正常消息重复处理)
|
|
||||||
private processedMessageIds: Set<string> = new Set();
|
|
||||||
private processedMessageIdsExpiry: Map<string, number> = new Map();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查消息是否已处理
|
|
||||||
*/
|
|
||||||
isMessageProcessed(messageId: string): boolean {
|
|
||||||
return this.processedMessageIds.has(messageId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 标记消息已处理
|
|
||||||
*/
|
|
||||||
markMessageAsProcessed(messageId: string): void {
|
|
||||||
this.processedMessageIds.add(messageId);
|
|
||||||
this.processedMessageIdsExpiry.set(messageId, Date.now());
|
|
||||||
|
|
||||||
// 定期清理
|
|
||||||
if (this.processedMessageIds.size > PROCESSED_MESSAGE_ID_MAX_SIZE) {
|
|
||||||
this.cleanupProcessedMessageIds();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 清理过期的消息ID(防止内存泄漏)
|
|
||||||
*/
|
|
||||||
cleanupProcessedMessageIds(): void {
|
|
||||||
const now = Date.now();
|
|
||||||
for (const [id, timestamp] of this.processedMessageIdsExpiry.entries()) {
|
|
||||||
if (now - timestamp > PROCESSED_MESSAGE_ID_EXPIRY) {
|
|
||||||
this.processedMessageIds.delete(id);
|
|
||||||
this.processedMessageIdsExpiry.delete(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 重置所有已处理的消息ID
|
|
||||||
* 用于测试或需要清除缓存的场景
|
|
||||||
*/
|
|
||||||
reset(): void {
|
|
||||||
this.processedMessageIds.clear();
|
|
||||||
this.processedMessageIdsExpiry.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 单例导出
|
|
||||||
export const messageDeduplication = new MessageDeduplication();
|
|
||||||
@@ -36,7 +36,6 @@ export class MessageSyncService implements IMessageSyncService {
|
|||||||
private inflightFetches: Map<string, Promise<void>> = new Map();
|
private inflightFetches: Map<string, Promise<void>> = new Map();
|
||||||
private inflightFetchConversations: Promise<void> | null = null;
|
private inflightFetchConversations: Promise<void> | null = null;
|
||||||
private inflightSyncBySeq: Promise<boolean> | null = null;
|
private inflightSyncBySeq: Promise<boolean> | null = null;
|
||||||
private inflightSyncByVersion: Promise<boolean> | null = null;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
getCurrentUserId: () => string | null,
|
getCurrentUserId: () => string | null,
|
||||||
@@ -507,77 +506,6 @@ export class MessageSyncService implements IMessageSyncService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 基于版本号的增量同步 — 去重入口
|
|
||||||
*/
|
|
||||||
async syncByVersion(): Promise<boolean> {
|
|
||||||
if (this.inflightSyncByVersion) return this.inflightSyncByVersion;
|
|
||||||
this.inflightSyncByVersion = this._doSyncByVersion();
|
|
||||||
try {
|
|
||||||
return await this.inflightSyncByVersion;
|
|
||||||
} finally {
|
|
||||||
this.inflightSyncByVersion = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 基于版本号的增量同步 — 核心实现
|
|
||||||
*/
|
|
||||||
private async _doSyncByVersion(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const store = useMessageStore.getState();
|
|
||||||
const version = store.syncVersion ?? 0;
|
|
||||||
|
|
||||||
const result = await messageService.getSyncByVersion(version, 100);
|
|
||||||
if (!result) return false;
|
|
||||||
|
|
||||||
// full_sync 表示需要全量刷新
|
|
||||||
if (result.full_sync) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.changes.length === 0) {
|
|
||||||
// 保存当前版本号,下次增量同步时使用
|
|
||||||
useMessageStore.getState().setSyncVersion(result.current_version);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 按变更类型处理
|
|
||||||
const staleIds: string[] = [];
|
|
||||||
for (const change of result.changes) {
|
|
||||||
const normId = normalizeConversationId(change.conversation_id);
|
|
||||||
if (change.change_type === 'hide') {
|
|
||||||
// 用户隐藏了会话,从本地移除
|
|
||||||
store.removeConversation(normId);
|
|
||||||
} else {
|
|
||||||
// new_msg, read, pin, unpin, mute, unmute, group_update
|
|
||||||
staleIds.push(normId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 刷新有变更的会话
|
|
||||||
if (staleIds.length > 0) {
|
|
||||||
const detailPromises = staleIds.map(id =>
|
|
||||||
this.fetchConversationDetail(id).catch(() => {})
|
|
||||||
);
|
|
||||||
await Promise.all(detailPromises);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存版本号
|
|
||||||
useMessageStore.getState().setSyncVersion(result.current_version);
|
|
||||||
|
|
||||||
// has_more 时继续拉取
|
|
||||||
if (result.has_more) {
|
|
||||||
return this.syncByVersion();
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[MessageSyncService] syncByVersion 失败:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查是否可加载更多会话
|
* 检查是否可加载更多会话
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -180,69 +180,6 @@ export class ReadReceiptManager implements IReadReceiptManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 开始已读操作,返回版本号
|
|
||||||
*/
|
|
||||||
beginReadOperation(conversationId: string, seq: number): number {
|
|
||||||
this.readStateVersion++;
|
|
||||||
const currentVersion = this.readStateVersion;
|
|
||||||
|
|
||||||
this.pendingReadMap.set(conversationId, {
|
|
||||||
timestamp: Date.now(),
|
|
||||||
version: currentVersion,
|
|
||||||
lastReadSeq: seq,
|
|
||||||
});
|
|
||||||
|
|
||||||
return currentVersion;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 结束已读操作
|
|
||||||
*/
|
|
||||||
endReadOperation(conversationId: string, version: number, success: boolean): void {
|
|
||||||
if (!success) {
|
|
||||||
this.pendingReadMap.delete(conversationId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// API 成功后,设置延迟清除保护
|
|
||||||
const clearTimer = setTimeout(() => {
|
|
||||||
const record = this.pendingReadMap.get(conversationId);
|
|
||||||
if (record && record.version === version) {
|
|
||||||
this.pendingReadMap.delete(conversationId);
|
|
||||||
}
|
|
||||||
}, READ_STATE_PROTECTION_DELAY);
|
|
||||||
|
|
||||||
const existingRecord = this.pendingReadMap.get(conversationId);
|
|
||||||
if (existingRecord) {
|
|
||||||
this.pendingReadMap.set(conversationId, {
|
|
||||||
...existingRecord,
|
|
||||||
clearTimer,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查是否有进行中的已读操作
|
|
||||||
*/
|
|
||||||
isReadOperationPending(conversationId: string): boolean {
|
|
||||||
return this.pendingReadMap.has(conversationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取当前已读状态版本号
|
|
||||||
*/
|
|
||||||
getReadStateVersion(): number {
|
|
||||||
return this.readStateVersion;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取待处理的已读映射
|
|
||||||
*/
|
|
||||||
getPendingReadMap(): Map<string, ReadStateRecord> {
|
|
||||||
return this.pendingReadMap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据已读保护状态智能合并会话
|
* 根据已读保护状态智能合并会话
|
||||||
*/
|
*/
|
||||||
|
|||||||
605
src/stores/message/services/RealtimeIngestionPipeline.ts
Normal file
605
src/stores/message/services/RealtimeIngestionPipeline.ts
Normal file
@@ -0,0 +1,605 @@
|
|||||||
|
/**
|
||||||
|
* RealtimeIngestionPipeline — 实时消息单一入口与幂等处理
|
||||||
|
*
|
||||||
|
* 设计目标(对标 Matrix/Element 的 idempotent event ingestion 与 Discord 的 gateway
|
||||||
|
* event deduplication):
|
||||||
|
*
|
||||||
|
* 1. **幂等键下沉到 store**:不再在上层维护独立的「已处理消息 id」集合。
|
||||||
|
* 去重的唯一真理源是 `useMessageStore.addOrReplaceMessage`,它在 zustand set 的
|
||||||
|
* 原子边界内判断消息是否已存在。落库与去重原子绑定,**永不丢消息**
|
||||||
|
* (最坏情况是重复 ingest,被 store 静默去重)。
|
||||||
|
*
|
||||||
|
* 2. **副作用与状态变更分离**:未读递增、震动、本地持久化、自动已读、会话更新都是
|
||||||
|
* 「副作用」,只有在消息**确认新入库后**(`addOrReplaceMessage` 返回 true)才执行。
|
||||||
|
* 任何副作用失败都不回滚已落库的消息——彻底消除旧实现「标记早于落库」导致的
|
||||||
|
* 永久丢消息竞态。
|
||||||
|
*
|
||||||
|
* 3. **显式监听器生命周期**:所有 `wsService.on/onConnect/onDisconnect` 返回的
|
||||||
|
* unsubscribe 函数被收集到 `unsubscribers`,`start()` 先彻底注销旧监听器再注册,
|
||||||
|
* `stop()` 一并释放——杜绝重复注册与重登录后的监听器泄漏。
|
||||||
|
*
|
||||||
|
* 该类取代旧的 `WSMessageHandler` + `MessageDeduplication`。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
WSChatMessage,
|
||||||
|
WSGroupChatMessage,
|
||||||
|
WSReadMessage,
|
||||||
|
WSGroupReadMessage,
|
||||||
|
WSRecallMessage,
|
||||||
|
WSGroupRecallMessage,
|
||||||
|
WSGroupTypingMessage,
|
||||||
|
WSGroupNoticeMessage,
|
||||||
|
WSNotificationMessage,
|
||||||
|
} from '@/services/core';
|
||||||
|
import { wsService, GroupNoticeType } from '@/services/core';
|
||||||
|
import { eventBus } from '@/core/events/EventBus';
|
||||||
|
import { messageRepository } from '@/database';
|
||||||
|
import type { MessageResponse } from '../../../types/dto';
|
||||||
|
import {
|
||||||
|
MAX_FLUSH_ITERATIONS,
|
||||||
|
MIN_RECONNECT_SYNC_INTERVAL,
|
||||||
|
} from '../constants';
|
||||||
|
import { useMessageStore, normalizeConversationId } from '../store';
|
||||||
|
import type { IUserCacheService } from '../types';
|
||||||
|
|
||||||
|
/** 实时事件统一缓冲类型(bootstrap 阶段暂存,初始化完成后回放) */
|
||||||
|
type BufferedEvent =
|
||||||
|
| { kind: 'chat'; message: WSChatMessage | WSGroupChatMessage }
|
||||||
|
| { kind: 'read'; message: WSReadMessage | WSGroupReadMessage };
|
||||||
|
|
||||||
|
/** ingest 时可抑制的副作用开关 */
|
||||||
|
export interface IngestOptions {
|
||||||
|
suppressUnreadIncrement?: boolean;
|
||||||
|
suppressVibration?: boolean;
|
||||||
|
suppressConversationUpdates?: boolean;
|
||||||
|
suppressAutoMarkAsRead?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pipeline 对外依赖(由 MessageManager 注入) */
|
||||||
|
export interface RealtimeIngestionPipelineDeps {
|
||||||
|
userCacheService: IUserCacheService;
|
||||||
|
getCurrentUserId: () => string | null;
|
||||||
|
/** 活动会话收到新消息时回调(用于自动标记已读) */
|
||||||
|
onActiveConvMessage: (conversationId: string, seq: number) => Promise<void>;
|
||||||
|
/** 重连 / sync_required 时触发的增量同步入口 */
|
||||||
|
triggerSync: (source: string) => Promise<void>;
|
||||||
|
/** 权威刷新会话列表入口(flush 阶段调用) */
|
||||||
|
fetchConversations: (forceRefresh: boolean, source: string) => Promise<void>;
|
||||||
|
/** 权威刷新未读数入口(flush 阶段调用) */
|
||||||
|
fetchUnreadCount: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RealtimeIngestionPipeline {
|
||||||
|
private deps: RealtimeIngestionPipelineDeps;
|
||||||
|
/** 所有 wsService 监听器的注销函数;start() 先清空再注册,stop() 全部释放 */
|
||||||
|
private unsubscribers: Array<() => void> = [];
|
||||||
|
/** bootstrap 阶段是否缓冲事件(初始化完成前为 true) */
|
||||||
|
private isBootstrapping = false;
|
||||||
|
/** bootstrap 期间缓冲的实时事件 */
|
||||||
|
private bufferedEvents: BufferedEvent[] = [];
|
||||||
|
|
||||||
|
/** 同步触发器状态:防止 onConnect 和 sync_required 并发 */
|
||||||
|
private syncInProgress = false;
|
||||||
|
private lastSyncTriggerAt = 0;
|
||||||
|
|
||||||
|
/** 系统通知去重(防止重连时重复递增系统未读) */
|
||||||
|
private processedNotificationIds: Set<string> = new Set();
|
||||||
|
|
||||||
|
constructor(deps: RealtimeIngestionPipelineDeps) {
|
||||||
|
this.deps = deps;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 生命周期 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册所有 wsService 监听器。重复调用安全:先注销旧监听器再注册新的。
|
||||||
|
*/
|
||||||
|
start(): void {
|
||||||
|
this.stop();
|
||||||
|
|
||||||
|
const push = <T>(unsub: () => void) => this.unsubscribers.push(unsub);
|
||||||
|
|
||||||
|
// 聊天消息:bootstrap 期间缓冲,否则直接 ingest
|
||||||
|
push(
|
||||||
|
wsService.on('chat', (message: WSChatMessage) => {
|
||||||
|
if (!this.isReady()) {
|
||||||
|
this.bufferedEvents.push({ kind: 'chat', message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.ingestChatMessage(message).catch(error => {
|
||||||
|
console.error('[RealtimeIngestionPipeline] ingest chat 失败:', error);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
push(
|
||||||
|
wsService.on('group_message', (message: WSGroupChatMessage) => {
|
||||||
|
if (!this.isReady()) {
|
||||||
|
this.bufferedEvents.push({ kind: 'chat', message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.ingestChatMessage(message).catch(error => {
|
||||||
|
console.error('[RealtimeIngestionPipeline] ingest group_message 失败:', error);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 已读回执:bootstrap 期间缓冲
|
||||||
|
push(
|
||||||
|
wsService.on('read', (message: WSReadMessage) => {
|
||||||
|
if (!this.isReady()) {
|
||||||
|
this.bufferedEvents.push({ kind: 'read', message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.handleReadReceipt(message);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
push(
|
||||||
|
wsService.on('group_read', (message: WSGroupReadMessage) => {
|
||||||
|
if (!this.isReady()) {
|
||||||
|
this.bufferedEvents.push({ kind: 'read', message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.handleGroupReadReceipt(message);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 撤回、输入状态、群通知:直接处理(这些操作幂等或无副作用丢失风险)
|
||||||
|
push(
|
||||||
|
wsService.on('recall', (message: WSRecallMessage) => this.handleRecallMessage(message))
|
||||||
|
);
|
||||||
|
push(
|
||||||
|
wsService.on('group_recall', (message: WSGroupRecallMessage) => this.handleGroupRecallMessage(message))
|
||||||
|
);
|
||||||
|
push(
|
||||||
|
wsService.on('group_typing', (message: WSGroupTypingMessage) => this.handleGroupTyping(message))
|
||||||
|
);
|
||||||
|
push(
|
||||||
|
wsService.on('group_notice', (message: WSGroupNoticeMessage) => this.handleGroupNotice(message))
|
||||||
|
);
|
||||||
|
|
||||||
|
// 系统通知:去重后递增系统未读
|
||||||
|
push(
|
||||||
|
wsService.on('notification', (message: WSNotificationMessage) => this.handleNotification(message))
|
||||||
|
);
|
||||||
|
|
||||||
|
// 连接状态
|
||||||
|
push(
|
||||||
|
wsService.onConnect(() => {
|
||||||
|
useMessageStore.getState().setSSEConnected(true);
|
||||||
|
this.triggerSync('sse-reconnect').catch(error => {
|
||||||
|
console.error('[RealtimeIngestionPipeline] 重连同步失败:', error);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
push(
|
||||||
|
wsService.on('sync_required', () => {
|
||||||
|
this.triggerSync('sync_required').catch(error => {
|
||||||
|
console.error('[RealtimeIngestionPipeline] sync_required 同步失败:', error);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
push(
|
||||||
|
wsService.onDisconnect(() => {
|
||||||
|
useMessageStore.getState().setSSEConnected(false);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注销所有监听器并清空缓冲。重复调用安全。
|
||||||
|
*/
|
||||||
|
stop(): void {
|
||||||
|
for (const unsub of this.unsubscribers) {
|
||||||
|
try {
|
||||||
|
unsub();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[RealtimeIngestionPipeline] 注销监听器失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.unsubscribers = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设置 bootstrap 标志 */
|
||||||
|
setBootstrapping(value: boolean): void {
|
||||||
|
this.isBootstrapping = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化完成后,回放缓冲的实时事件。
|
||||||
|
* 每轮取走当前 buffer 后立即清空(flush 期间新到事件走正常路径),直到稳定。
|
||||||
|
*/
|
||||||
|
async flushBufferedEvents(): Promise<void> {
|
||||||
|
let lastLen = -1;
|
||||||
|
for (let i = 0; i < MAX_FLUSH_ITERATIONS; i++) {
|
||||||
|
const events = this.bufferedEvents;
|
||||||
|
if (events.length === lastLen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastLen = events.length;
|
||||||
|
this.bufferedEvents = [];
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
if (event.kind === 'chat') {
|
||||||
|
await this.ingestChatMessage(event.message, {
|
||||||
|
suppressUnreadIncrement: true,
|
||||||
|
suppressVibration: true,
|
||||||
|
suppressConversationUpdates: true,
|
||||||
|
suppressAutoMarkAsRead: true,
|
||||||
|
});
|
||||||
|
} else if (event.message.type === 'read') {
|
||||||
|
this.handleReadReceipt(event.message as WSReadMessage);
|
||||||
|
} else {
|
||||||
|
this.handleGroupReadReceipt(event.message as WSGroupReadMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用服务器权威数据刷新会话与未读
|
||||||
|
await this.deps.fetchConversations(true, 'sse-buffer-flush');
|
||||||
|
await this.deps.fetchUnreadCount();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 核心:聊天消息幂等 ingest ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 幂等 ingest 一条聊天消息(私聊 chat / 群聊 group_message)。
|
||||||
|
*
|
||||||
|
* 流程(关键:先落库,落库成功后才发副作用):
|
||||||
|
* 1. 构造 MessageResponse
|
||||||
|
* 2. 通过 store.addOrReplaceMessage 原子写入并取得「是否新写入」标志
|
||||||
|
* 3. 始终发送 ACK(服务器侧幂等;ACK 失败不影响消息)
|
||||||
|
* 4. 仅当 inserted === true 时触发副作用:会话更新 / 未读递增 / 震动 /
|
||||||
|
* 自动已读 / 本地持久化 / 群聊 sender 补全
|
||||||
|
*/
|
||||||
|
async ingestChatMessage(
|
||||||
|
message: (WSChatMessage | WSGroupChatMessage) & { _isAck?: boolean },
|
||||||
|
options?: IngestOptions
|
||||||
|
): Promise<void> {
|
||||||
|
const store = useMessageStore.getState();
|
||||||
|
const { conversation_id, id, sender_id, seq, segments, created_at, _isAck } = message;
|
||||||
|
const normalizedConversationId = normalizeConversationId(conversation_id);
|
||||||
|
const currentUserId = this.deps.getCurrentUserId();
|
||||||
|
|
||||||
|
const suppressUnreadIncrement = options?.suppressUnreadIncrement ?? false;
|
||||||
|
const suppressVibration = options?.suppressVibration ?? false;
|
||||||
|
const suppressConversationUpdates = options?.suppressConversationUpdates ?? false;
|
||||||
|
const suppressAutoMarkAsRead = options?.suppressAutoMarkAsRead ?? false;
|
||||||
|
|
||||||
|
// 1. 构造消息对象
|
||||||
|
const newMessage: MessageResponse = {
|
||||||
|
id,
|
||||||
|
conversation_id: normalizedConversationId,
|
||||||
|
sender_id,
|
||||||
|
seq: typeof seq === 'string' ? parseInt(seq, 10) : (seq ?? 0),
|
||||||
|
segments: segments || [],
|
||||||
|
created_at,
|
||||||
|
status: 'normal',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. 原子幂等写入;inserted === true 才是真正的新消息
|
||||||
|
const inserted = useMessageStore.getState().addOrReplaceMessage(normalizedConversationId, newMessage);
|
||||||
|
|
||||||
|
// 3. 始终 ACK(即使重复,服务器侧幂等;ACK 失败不回滚消息)
|
||||||
|
wsService.sendAck(id);
|
||||||
|
|
||||||
|
// 4. 幂等:已存在的消息跳过所有副作用
|
||||||
|
if (!inserted) return;
|
||||||
|
|
||||||
|
// —— 以下副作用仅在消息新入库后执行,任一失败都不回滚消息 ——
|
||||||
|
|
||||||
|
if (!suppressConversationUpdates) {
|
||||||
|
this.applyConversationUpdate(normalizedConversationId, newMessage, created_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新读取 store 以获得最新的活动会话状态(避免闭包陈旧)
|
||||||
|
const latestStore = useMessageStore.getState();
|
||||||
|
const isActiveConversation = normalizedConversationId === latestStore.getActiveConversation();
|
||||||
|
const isCurrentUserMessage = sender_id === currentUserId;
|
||||||
|
|
||||||
|
const shouldIncrementUnread =
|
||||||
|
!suppressUnreadIncrement &&
|
||||||
|
!isCurrentUserMessage &&
|
||||||
|
!isActiveConversation &&
|
||||||
|
!!currentUserId &&
|
||||||
|
!_isAck;
|
||||||
|
|
||||||
|
if (shouldIncrementUnread) {
|
||||||
|
const isNotificationMuted = latestStore.isNotificationMuted(normalizedConversationId);
|
||||||
|
if (!suppressVibration && !isNotificationMuted) {
|
||||||
|
const vibrationType = message.type === 'group_message' ? 'group_message' : 'message';
|
||||||
|
eventBus.emit({ type: 'VIBRATE', payload: { type: vibrationType } });
|
||||||
|
}
|
||||||
|
this.incrementUnreadCount(normalizedConversationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 活动会话且非本人消息:自动标记已读
|
||||||
|
if (isActiveConversation && !isCurrentUserMessage && !suppressAutoMarkAsRead) {
|
||||||
|
this.deps.onActiveConvMessage(normalizedConversationId, seq).catch(error => {
|
||||||
|
console.error('[RealtimeIngestionPipeline] 自动标记已读失败:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 群聊消息:异步补 sender 信息(patchMessages 自身幂等,不影响去重)
|
||||||
|
if (
|
||||||
|
message.type === 'group_message' &&
|
||||||
|
sender_id &&
|
||||||
|
sender_id !== currentUserId &&
|
||||||
|
sender_id !== '10000'
|
||||||
|
) {
|
||||||
|
this.deps.userCacheService
|
||||||
|
.getSenderInfo(sender_id)
|
||||||
|
.then(user => {
|
||||||
|
if (!user) return;
|
||||||
|
const patches = new Map<string, Partial<MessageResponse>>();
|
||||||
|
patches.set(String(id), { sender: user });
|
||||||
|
useMessageStore.getState().patchMessages(normalizedConversationId, patches);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('[RealtimeIngestionPipeline] 获取发送者信息失败:', { userId: sender_id, error });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 异步保存到本地数据库(fire-and-forget)
|
||||||
|
const textContent =
|
||||||
|
segments?.filter((s: any) => s.type === 'text').map((s: any) => s.data?.text || '').join('') || '';
|
||||||
|
messageRepository
|
||||||
|
.saveMessage({
|
||||||
|
id,
|
||||||
|
conversationId: normalizedConversationId,
|
||||||
|
senderId: sender_id || '',
|
||||||
|
content: textContent,
|
||||||
|
type: segments?.find((s: any) => s.type === 'image') ? 'image' : 'text',
|
||||||
|
isRead: isActiveConversation,
|
||||||
|
createdAt: (() => {
|
||||||
|
const d = new Date(created_at);
|
||||||
|
return Number.isNaN(d.getTime()) ? new Date().toISOString() : d.toISOString();
|
||||||
|
})(),
|
||||||
|
seq,
|
||||||
|
status: 'normal',
|
||||||
|
segments,
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('[RealtimeIngestionPipeline] 保存消息到本地失败:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 其他事件处理 ====================
|
||||||
|
|
||||||
|
/** 私聊已读回执(当前为占位,保留扩展点) */
|
||||||
|
handleReadReceipt(_message: WSReadMessage): void {
|
||||||
|
// 预留:可在此更新对方已读状态
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 群聊已读回执(当前为占位,保留扩展点) */
|
||||||
|
handleGroupReadReceipt(_message: WSGroupReadMessage): void {
|
||||||
|
// 预留:可在此更新成员已读状态
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 私聊消息撤回 */
|
||||||
|
handleRecallMessage(message: WSRecallMessage): void {
|
||||||
|
const { conversation_id, message_id } = message;
|
||||||
|
const normalizedConversationId = normalizeConversationId(conversation_id);
|
||||||
|
|
||||||
|
this.markMessageAsRecalled(normalizedConversationId, message_id);
|
||||||
|
this.syncConversationLastMessageOnRecall(normalizedConversationId, message_id);
|
||||||
|
|
||||||
|
messageRepository.updateStatus(message_id, 'recalled', true).catch(error => {
|
||||||
|
console.error('[RealtimeIngestionPipeline] 更新本地撤回状态失败:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 群聊消息撤回 */
|
||||||
|
handleGroupRecallMessage(message: WSGroupRecallMessage): void {
|
||||||
|
const { conversation_id, message_id } = message;
|
||||||
|
const normalizedConversationId = normalizeConversationId(conversation_id);
|
||||||
|
|
||||||
|
this.markMessageAsRecalled(normalizedConversationId, message_id);
|
||||||
|
this.syncConversationLastMessageOnRecall(normalizedConversationId, message_id);
|
||||||
|
|
||||||
|
messageRepository.updateStatus(message_id, 'recalled', true).catch(error => {
|
||||||
|
console.error('[RealtimeIngestionPipeline] 更新本地撤回状态失败:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 群聊输入状态 */
|
||||||
|
handleGroupTyping(message: WSGroupTypingMessage): void {
|
||||||
|
const store = useMessageStore.getState();
|
||||||
|
const { group_id, user_id, is_typing } = message;
|
||||||
|
const groupIdStr = String(group_id);
|
||||||
|
|
||||||
|
const currentTypingUsers = store.getTypingUsers(groupIdStr);
|
||||||
|
let updatedTypingUsers: string[];
|
||||||
|
|
||||||
|
if (is_typing) {
|
||||||
|
updatedTypingUsers = currentTypingUsers.includes(user_id)
|
||||||
|
? currentTypingUsers
|
||||||
|
: [...currentTypingUsers, user_id];
|
||||||
|
} else {
|
||||||
|
updatedTypingUsers = currentTypingUsers.filter(id => id !== user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updatedTypingUsers.length !== currentTypingUsers.length) {
|
||||||
|
store.setTypingUsers(groupIdStr, updatedTypingUsers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 群通知(禁言/系统消息等) */
|
||||||
|
handleGroupNotice(message: WSGroupNoticeMessage): void {
|
||||||
|
const store = useMessageStore.getState();
|
||||||
|
const { notice_type, group_id, data, timestamp, message_id, seq } = message;
|
||||||
|
const groupIdStr = String(group_id);
|
||||||
|
|
||||||
|
// 处理禁言/解除禁言(仅本人相关)
|
||||||
|
if (notice_type === 'muted' || notice_type === 'unmuted') {
|
||||||
|
const mutedUserId = data?.user_id;
|
||||||
|
const currentUserId = this.deps.getCurrentUserId();
|
||||||
|
if (mutedUserId && mutedUserId === currentUserId) {
|
||||||
|
store.setMutedStatus(groupIdStr, notice_type === 'muted');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 补一条系统消息到会话消息流(幂等:addOrReplaceMessage 内部去重)
|
||||||
|
if (message_id && typeof seq === 'number' && seq > 0) {
|
||||||
|
const conversationId = this.findConversationIdByGroupId(groupIdStr);
|
||||||
|
if (conversationId) {
|
||||||
|
const noticeText = this.buildGroupNoticeText(notice_type, data);
|
||||||
|
const systemNoticeMessage: MessageResponse = {
|
||||||
|
id: String(message_id),
|
||||||
|
conversation_id: conversationId,
|
||||||
|
sender_id: '10000',
|
||||||
|
seq,
|
||||||
|
segments: noticeText
|
||||||
|
? [{ type: 'text', data: { text: noticeText } as any }]
|
||||||
|
: [],
|
||||||
|
status: 'normal',
|
||||||
|
category: 'notification',
|
||||||
|
created_at: (() => {
|
||||||
|
const d = new Date(timestamp || Date.now());
|
||||||
|
return Number.isNaN(d.getTime()) ? new Date().toISOString() : d.toISOString();
|
||||||
|
})(),
|
||||||
|
};
|
||||||
|
useMessageStore.getState().addOrReplaceMessage(conversationId, systemNoticeMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 系统通知:去重后递增系统未读 */
|
||||||
|
handleNotification(message: WSNotificationMessage): void {
|
||||||
|
if (message.is_read) return;
|
||||||
|
|
||||||
|
// 按通知 ID 去重,防止重连时重复递增
|
||||||
|
const notificationId = (message as any).id || (message as any).message_id;
|
||||||
|
if (notificationId) {
|
||||||
|
const idStr = String(notificationId);
|
||||||
|
if (this.processedNotificationIds.has(idStr)) return;
|
||||||
|
this.processedNotificationIds.add(idStr);
|
||||||
|
// 防止内存泄漏:超过 500 条淘汰最旧 100 条
|
||||||
|
if (this.processedNotificationIds.size > 500) {
|
||||||
|
const firstIds = Array.from(this.processedNotificationIds).slice(0, 100);
|
||||||
|
firstIds.forEach(id => this.processedNotificationIds.delete(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useMessageStore.getState().incrementSystemUnreadAtomic();
|
||||||
|
|
||||||
|
if (message.created_at) {
|
||||||
|
const store = useMessageStore.getState();
|
||||||
|
const currentLast = store.lastSystemMessageAt;
|
||||||
|
const msgDate = new Date(message.created_at);
|
||||||
|
if (!Number.isNaN(msgDate.getTime()) && (!currentLast || msgDate > new Date(currentLast))) {
|
||||||
|
store.setLastSystemMessageAt(message.created_at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 同步触发器 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一同步入口:节流 + 去重,onConnect 与 sync_required 共用。
|
||||||
|
* bootstrap 期间跳过(由 flushBufferedEvents 统一回放)。
|
||||||
|
*/
|
||||||
|
private async triggerSync(source: string): Promise<void> {
|
||||||
|
if (this.isBootstrapping) return;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - this.lastSyncTriggerAt < MIN_RECONNECT_SYNC_INTERVAL) return;
|
||||||
|
if (this.syncInProgress) return;
|
||||||
|
|
||||||
|
this.syncInProgress = true;
|
||||||
|
this.lastSyncTriggerAt = now;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.deps.triggerSync(source);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[RealtimeIngestionPipeline] 同步失败:', error);
|
||||||
|
} finally {
|
||||||
|
this.syncInProgress = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 私有工具方法 ====================
|
||||||
|
|
||||||
|
/** store 已初始化且非 bootstrap 阶段时才直接处理事件 */
|
||||||
|
private isReady(): boolean {
|
||||||
|
return useMessageStore.getState().isInitialized && !this.isBootstrapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyConversationUpdate(
|
||||||
|
conversationId: string,
|
||||||
|
message: MessageResponse,
|
||||||
|
createdAt: string
|
||||||
|
): void {
|
||||||
|
const store = useMessageStore.getState();
|
||||||
|
const conversation = store.getConversation(conversationId);
|
||||||
|
if (!conversation) return;
|
||||||
|
|
||||||
|
const updatedConv = {
|
||||||
|
...conversation,
|
||||||
|
last_seq: message.seq,
|
||||||
|
last_message: message,
|
||||||
|
last_message_at: createdAt,
|
||||||
|
updated_at: createdAt,
|
||||||
|
};
|
||||||
|
store.updateConversation(updatedConv);
|
||||||
|
}
|
||||||
|
|
||||||
|
private incrementUnreadCount(conversationId: string): void {
|
||||||
|
useMessageStore.getState().incrementUnreadAtomic(conversationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private markMessageAsRecalled(conversationId: string, messageId: string): void {
|
||||||
|
const patches = new Map<string, Partial<MessageResponse>>();
|
||||||
|
patches.set(String(messageId), {
|
||||||
|
status: 'recalled' as MessageResponse['status'],
|
||||||
|
segments: [],
|
||||||
|
});
|
||||||
|
useMessageStore.getState().patchMessages(conversationId, patches);
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncConversationLastMessageOnRecall(conversationId: string, messageId: string): void {
|
||||||
|
const store = useMessageStore.getState();
|
||||||
|
const conversation = store.getConversation(conversationId);
|
||||||
|
if (!conversation?.last_message) return;
|
||||||
|
if (String(conversation.last_message.id) !== String(messageId)) return;
|
||||||
|
if (conversation.last_message.status === 'recalled') return;
|
||||||
|
|
||||||
|
const updatedConversation = {
|
||||||
|
...conversation,
|
||||||
|
last_message: { ...conversation.last_message, status: 'recalled' as const },
|
||||||
|
};
|
||||||
|
store.updateConversation(updatedConversation);
|
||||||
|
}
|
||||||
|
|
||||||
|
private findConversationIdByGroupId(groupId: string): string | null {
|
||||||
|
const conversations = useMessageStore.getState().getConversations();
|
||||||
|
for (const conv of conversations) {
|
||||||
|
if (String(conv.group?.id || '') === groupId) {
|
||||||
|
return conv.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildGroupNoticeText(noticeType: GroupNoticeType, data: any): string {
|
||||||
|
const username = data?.username || '用户';
|
||||||
|
switch (noticeType) {
|
||||||
|
case 'member_join':
|
||||||
|
return `"${username}" 加入了群聊`;
|
||||||
|
case 'member_leave':
|
||||||
|
return `"${username}" 退出了群聊`;
|
||||||
|
case 'member_removed':
|
||||||
|
return `"${username}" 被移出群聊`;
|
||||||
|
case 'muted':
|
||||||
|
return `"${username}" 已被管理员禁言`;
|
||||||
|
case 'unmuted':
|
||||||
|
return `"${username}" 已被管理员解除禁言`;
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -121,6 +121,3 @@ export class UserCacheService implements IUserCacheService {
|
|||||||
this.pendingUserRequests.clear();
|
this.pendingUserRequests.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 单例导出
|
|
||||||
export const userCacheService = new UserCacheService();
|
|
||||||
@@ -1,597 +0,0 @@
|
|||||||
/**
|
|
||||||
* WebSocket 消息处理器
|
|
||||||
* 处理所有来自 WebSocket 的消息事件
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type {
|
|
||||||
WSChatMessage,
|
|
||||||
WSGroupChatMessage,
|
|
||||||
WSReadMessage,
|
|
||||||
WSGroupReadMessage,
|
|
||||||
WSRecallMessage,
|
|
||||||
WSGroupRecallMessage,
|
|
||||||
WSGroupTypingMessage,
|
|
||||||
WSGroupNoticeMessage,
|
|
||||||
WSNotificationMessage,
|
|
||||||
} from '@/services/core';
|
|
||||||
import { wsService, GroupNoticeType } from '@/services/core';
|
|
||||||
import { eventBus } from '@/core/events/EventBus';
|
|
||||||
import { messageRepository } from '@/database';
|
|
||||||
import type { MessageResponse, ConversationResponse } from '../../../types/dto';
|
|
||||||
import type {
|
|
||||||
IWSMessageHandler,
|
|
||||||
IMessageDeduplication,
|
|
||||||
IUserCacheService,
|
|
||||||
HandleNewMessageOptions,
|
|
||||||
} from '../types';
|
|
||||||
import { MAX_FLUSH_ITERATIONS, MIN_RECONNECT_SYNC_INTERVAL } from '../constants';
|
|
||||||
import { useMessageStore, normalizeConversationId } from '../store';
|
|
||||||
|
|
||||||
export class WSMessageHandler implements IWSMessageHandler {
|
|
||||||
private deduplication: IMessageDeduplication;
|
|
||||||
private userCacheService: IUserCacheService;
|
|
||||||
private getCurrentUserId: () => string | null;
|
|
||||||
private markAsReadCallback: (conversationId: string, seq: number) => Promise<void>;
|
|
||||||
private fetchConversationsCallback: (forceRefresh: boolean, source: string) => Promise<void>;
|
|
||||||
private fetchUnreadCountCallback: () => Promise<void>;
|
|
||||||
private fetchMessagesCallback: (conversationId: string) => Promise<void>;
|
|
||||||
private syncBySeqCallback: () => Promise<boolean>;
|
|
||||||
|
|
||||||
private sseUnsubscribe: (() => void) | null = null;
|
|
||||||
private isBootstrapping: boolean = false;
|
|
||||||
private lastReconnectSyncAt: number = 0;
|
|
||||||
|
|
||||||
// 同步触发器状态:防止 onConnect 和 sync_required 并发
|
|
||||||
private syncInProgress: boolean = false;
|
|
||||||
private lastSyncTriggerAt: number = 0;
|
|
||||||
|
|
||||||
// 系统通知去重(防止重连时重复递增系统未读)
|
|
||||||
private processedNotificationIds: Set<string> = new Set();
|
|
||||||
|
|
||||||
// 初始化阶段缓冲来自 SSE 的消息事件
|
|
||||||
private bufferedChatMessages: Array<(WSChatMessage | WSGroupChatMessage) & { _isAck?: boolean }> = [];
|
|
||||||
private bufferedReadReceipts: Array<WSReadMessage | WSGroupReadMessage> = [];
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
deduplication: IMessageDeduplication,
|
|
||||||
userCacheService: IUserCacheService,
|
|
||||||
getCurrentUserId: () => string | null,
|
|
||||||
callbacks: {
|
|
||||||
markAsRead: (conversationId: string, seq: number) => Promise<void>;
|
|
||||||
fetchConversations: (forceRefresh: boolean, source: string) => Promise<void>;
|
|
||||||
fetchUnreadCount: () => Promise<void>;
|
|
||||||
fetchMessages: (conversationId: string) => Promise<void>;
|
|
||||||
syncBySeq: () => Promise<boolean>;
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
this.deduplication = deduplication;
|
|
||||||
this.userCacheService = userCacheService;
|
|
||||||
this.getCurrentUserId = getCurrentUserId;
|
|
||||||
this.markAsReadCallback = callbacks.markAsRead;
|
|
||||||
this.fetchConversationsCallback = callbacks.fetchConversations;
|
|
||||||
this.fetchUnreadCountCallback = callbacks.fetchUnreadCount;
|
|
||||||
this.fetchMessagesCallback = callbacks.fetchMessages;
|
|
||||||
this.syncBySeqCallback = callbacks.syncBySeq;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 初始化 SSE 监听器
|
|
||||||
*/
|
|
||||||
connect(): void {
|
|
||||||
if (this.sseUnsubscribe) {
|
|
||||||
this.sseUnsubscribe();
|
|
||||||
}
|
|
||||||
|
|
||||||
wsService.on('chat', (message: WSChatMessage) => {
|
|
||||||
if (!useMessageStore.getState().isInitialized || this.isBootstrapping) {
|
|
||||||
this.bufferedChatMessages.push(message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.handleNewMessage(message);
|
|
||||||
});
|
|
||||||
|
|
||||||
wsService.on('group_message', (message: WSGroupChatMessage) => {
|
|
||||||
if (!useMessageStore.getState().isInitialized || this.isBootstrapping) {
|
|
||||||
this.bufferedChatMessages.push(message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.handleNewMessage(message);
|
|
||||||
});
|
|
||||||
|
|
||||||
wsService.on('read', (message: WSReadMessage) => {
|
|
||||||
if (!useMessageStore.getState().isInitialized || this.isBootstrapping) {
|
|
||||||
this.bufferedReadReceipts.push(message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.handleReadReceipt(message);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 监听群聊已读回执
|
|
||||||
wsService.on('group_read', (message: WSGroupReadMessage) => {
|
|
||||||
if (!useMessageStore.getState().isInitialized || this.isBootstrapping) {
|
|
||||||
this.bufferedReadReceipts.push(message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.handleGroupReadReceipt(message);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 监听私聊消息撤回
|
|
||||||
wsService.on('recall', (message: WSRecallMessage) => {
|
|
||||||
this.handleRecallMessage(message);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 监听群聊消息撤回
|
|
||||||
wsService.on('group_recall', (message: WSGroupRecallMessage) => {
|
|
||||||
this.handleGroupRecallMessage(message);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 监听群聊输入状态
|
|
||||||
wsService.on('group_typing', (message: WSGroupTypingMessage) => {
|
|
||||||
this.handleGroupTyping(message);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 监听群通知
|
|
||||||
wsService.on('group_notice', (message: WSGroupNoticeMessage) => {
|
|
||||||
this.handleGroupNotice(message);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 监听系统通知,实时更新系统未读数
|
|
||||||
wsService.on('notification', (message: WSNotificationMessage) => {
|
|
||||||
if (message.is_read) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 按通知 ID 去重,防止重连时重复递增
|
|
||||||
const notificationId = (message as any).id || (message as any).message_id;
|
|
||||||
if (notificationId) {
|
|
||||||
if (this.processedNotificationIds.has(String(notificationId))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.processedNotificationIds.add(String(notificationId));
|
|
||||||
// 防止内存泄漏:超过 500 条时淘汰最旧的 100 条
|
|
||||||
if (this.processedNotificationIds.size > 500) {
|
|
||||||
const firstIds = Array.from(this.processedNotificationIds).slice(0, 100);
|
|
||||||
firstIds.forEach(id => this.processedNotificationIds.delete(id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.incrementSystemUnread();
|
|
||||||
if (message.created_at) {
|
|
||||||
const store = useMessageStore.getState();
|
|
||||||
const currentLast = store.lastSystemMessageAt;
|
|
||||||
const msgDate = new Date(message.created_at);
|
|
||||||
if (Number.isNaN(msgDate.getTime())) return;
|
|
||||||
if (!currentLast || msgDate > new Date(currentLast)) {
|
|
||||||
store.setLastSystemMessageAt(message.created_at);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 监听连接状态
|
|
||||||
wsService.onConnect(() => {
|
|
||||||
useMessageStore.getState().setSSEConnected(true);
|
|
||||||
this.triggerSync('sse-reconnect');
|
|
||||||
});
|
|
||||||
|
|
||||||
// 监听 sync_required 事件,触发增量同步
|
|
||||||
wsService.on('sync_required', () => {
|
|
||||||
console.log('[WSMessageHandler] 收到 sync_required,开始增量同步');
|
|
||||||
this.triggerSync('sync_required');
|
|
||||||
});
|
|
||||||
|
|
||||||
wsService.onDisconnect(() => {
|
|
||||||
useMessageStore.getState().setSSEConnected(false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 断开 SSE 连接
|
|
||||||
*/
|
|
||||||
disconnect(): void {
|
|
||||||
if (this.sseUnsubscribe) {
|
|
||||||
this.sseUnsubscribe();
|
|
||||||
this.sseUnsubscribe = null;
|
|
||||||
}
|
|
||||||
this.processedNotificationIds.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查是否已连接
|
|
||||||
*/
|
|
||||||
isConnected(): boolean {
|
|
||||||
return useMessageStore.getState().isConnected();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 设置启动状态
|
|
||||||
*/
|
|
||||||
setBootstrapping(value: boolean): void {
|
|
||||||
this.isBootstrapping = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 统一同步入口 — 节流 + 去重
|
|
||||||
* onConnect 和 sync_required 共用此入口,防止并发触发重复同步
|
|
||||||
* 启动阶段(isBootstrapping=true)跳过,由 MessageManager.initialize 完成后统一触发
|
|
||||||
*/
|
|
||||||
private async triggerSync(source: string): Promise<void> {
|
|
||||||
if (this.isBootstrapping) return;
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
// 最小间隔节流:避免连续 sync_required 事件短时间内重复触发全量同步
|
|
||||||
if (now - this.lastSyncTriggerAt < MIN_RECONNECT_SYNC_INTERVAL) return;
|
|
||||||
if (this.syncInProgress) return;
|
|
||||||
|
|
||||||
this.syncInProgress = true;
|
|
||||||
this.lastSyncTriggerAt = now;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const ok = await this.syncBySeqCallback();
|
|
||||||
if (!ok) {
|
|
||||||
await this.fetchConversationsCallback(true, source);
|
|
||||||
const activeConv = useMessageStore.getState().getActiveConversation();
|
|
||||||
if (activeConv) {
|
|
||||||
await this.fetchMessagesCallback(activeConv).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await this.fetchUnreadCountCallback().catch(() => {});
|
|
||||||
} catch {
|
|
||||||
await this.fetchConversationsCallback(true, source).catch(() => {});
|
|
||||||
const activeConv = useMessageStore.getState().getActiveConversation();
|
|
||||||
if (activeConv) {
|
|
||||||
await this.fetchMessagesCallback(activeConv).catch(() => {});
|
|
||||||
}
|
|
||||||
await this.fetchUnreadCountCallback().catch(() => {});
|
|
||||||
} finally {
|
|
||||||
this.syncInProgress = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 初始化完成后,处理缓冲的 SSE 事件
|
|
||||||
* 每次循环:取走当前 buffer 后立即清空(避免在 fetch 期间新事件再被覆盖)
|
|
||||||
* 退出条件:连续一轮没有新事件,确保 flush 期间到达的事件也被消费
|
|
||||||
*/
|
|
||||||
async flushBufferedSSEEvents(): Promise<void> {
|
|
||||||
let lastChatLen = -1;
|
|
||||||
let lastReadLen = -1;
|
|
||||||
for (let i = 0; i < MAX_FLUSH_ITERATIONS; i++) {
|
|
||||||
const chatEvents = this.bufferedChatMessages;
|
|
||||||
const readEvents = this.bufferedReadReceipts;
|
|
||||||
// 上一轮长度未变且都已清空,说明已稳定
|
|
||||||
if (chatEvents.length === lastChatLen && readEvents.length === lastReadLen) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lastChatLen = chatEvents.length;
|
|
||||||
lastReadLen = readEvents.length;
|
|
||||||
// 立即清空 buffer;flush 期间新到的事件将走正常处理路径(isBootstrapping 已是 false)
|
|
||||||
this.bufferedChatMessages = [];
|
|
||||||
this.bufferedReadReceipts = [];
|
|
||||||
|
|
||||||
// 处理消息
|
|
||||||
for (const m of chatEvents) {
|
|
||||||
await this.handleNewMessage(m, {
|
|
||||||
suppressUnreadIncrement: true,
|
|
||||||
suppressVibration: true,
|
|
||||||
suppressConversationUpdates: true,
|
|
||||||
suppressAutoMarkAsRead: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理已读回执
|
|
||||||
for (const r of readEvents) {
|
|
||||||
if (r.type === 'read') {
|
|
||||||
this.handleReadReceipt(r);
|
|
||||||
} else {
|
|
||||||
this.handleGroupReadReceipt(r as WSGroupReadMessage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用服务器权威刷新
|
|
||||||
await this.fetchConversationsCallback(true, 'sse-buffer-flush');
|
|
||||||
await this.fetchUnreadCountCallback();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理新消息(SSE推送)
|
|
||||||
*/
|
|
||||||
async handleNewMessage(
|
|
||||||
message: (WSChatMessage | WSGroupChatMessage) & { _isAck?: boolean },
|
|
||||||
options?: HandleNewMessageOptions
|
|
||||||
): Promise<void> {
|
|
||||||
const store = useMessageStore.getState();
|
|
||||||
const { conversation_id, id, sender_id, seq, segments, created_at, _isAck } = message;
|
|
||||||
const normalizedConversationId = normalizeConversationId(conversation_id);
|
|
||||||
const currentUserId = this.getCurrentUserId();
|
|
||||||
const suppressUnreadIncrement = options?.suppressUnreadIncrement ?? false;
|
|
||||||
const suppressVibration = options?.suppressVibration ?? false;
|
|
||||||
const suppressConversationUpdates = options?.suppressConversationUpdates ?? false;
|
|
||||||
const suppressAutoMarkAsRead = options?.suppressAutoMarkAsRead ?? false;
|
|
||||||
|
|
||||||
// 消息去重检查
|
|
||||||
if (this.deduplication.isMessageProcessed(id)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.deduplication.markMessageAsProcessed(id);
|
|
||||||
|
|
||||||
// 发送 ACK 确认
|
|
||||||
wsService.sendAck(id);
|
|
||||||
|
|
||||||
// 对于群聊消息,获取发送者信息
|
|
||||||
if (message.type === 'group_message' && sender_id && sender_id !== currentUserId && sender_id !== '10000') {
|
|
||||||
this.userCacheService.getSenderInfo(sender_id).then(user => {
|
|
||||||
if (user) {
|
|
||||||
const patches = new Map<string, Partial<MessageResponse>>();
|
|
||||||
patches.set(String(id), { sender: user });
|
|
||||||
useMessageStore.getState().patchMessages(normalizedConversationId, patches);
|
|
||||||
}
|
|
||||||
}).catch(error => {
|
|
||||||
console.error('[WSMessageHandler] 获取发送者信息失败:', { userId: sender_id, error });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 构造消息对象
|
|
||||||
const newMessage: MessageResponse = {
|
|
||||||
id,
|
|
||||||
conversation_id: normalizedConversationId,
|
|
||||||
sender_id,
|
|
||||||
seq: typeof seq === 'string' ? parseInt(seq, 10) : (seq ?? 0),
|
|
||||||
segments: segments || [],
|
|
||||||
created_at,
|
|
||||||
status: 'normal',
|
|
||||||
};
|
|
||||||
|
|
||||||
// 立即更新内存中的消息列表 — 使用原子性 mergeMessages 防止并发写入丢失
|
|
||||||
const messageExists = useMessageStore.getState().getMessages(normalizedConversationId).some(m => String(m.id) === String(id));
|
|
||||||
|
|
||||||
if (!messageExists) {
|
|
||||||
useMessageStore.getState().mergeMessages(normalizedConversationId, [newMessage]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新会话信息
|
|
||||||
if (!suppressConversationUpdates) {
|
|
||||||
this.updateConversationWithNewMessage(normalizedConversationId, newMessage, created_at);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新未读数
|
|
||||||
const isCurrentUserMessage = sender_id === currentUserId;
|
|
||||||
const isActiveConversation = normalizedConversationId === store.getActiveConversation();
|
|
||||||
|
|
||||||
const shouldIncrementUnread =
|
|
||||||
!suppressUnreadIncrement &&
|
|
||||||
!isCurrentUserMessage &&
|
|
||||||
!isActiveConversation &&
|
|
||||||
!!currentUserId &&
|
|
||||||
!_isAck;
|
|
||||||
|
|
||||||
if (shouldIncrementUnread) {
|
|
||||||
const isNotificationMuted = store.isNotificationMuted(normalizedConversationId);
|
|
||||||
if (!suppressVibration && !isNotificationMuted) {
|
|
||||||
const vibrationType = message.type === 'group_message' ? 'group_message' : 'message';
|
|
||||||
eventBus.emit({ type: 'VIBRATE', payload: { type: vibrationType } });
|
|
||||||
}
|
|
||||||
this.incrementUnreadCount(normalizedConversationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果是当前活动会话,自动标记已读
|
|
||||||
if (isActiveConversation && !isCurrentUserMessage && !suppressAutoMarkAsRead) {
|
|
||||||
this.markAsReadCallback(normalizedConversationId, seq).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 异步保存到本地数据库
|
|
||||||
const textContent = segments?.filter((s: any) => s.type === 'text').map((s: any) => s.data?.text || '').join('') || '';
|
|
||||||
messageRepository.saveMessage({
|
|
||||||
id,
|
|
||||||
conversationId: normalizedConversationId,
|
|
||||||
senderId: sender_id || '',
|
|
||||||
content: textContent,
|
|
||||||
type: segments?.find((s: any) => s.type === 'image') ? 'image' : 'text',
|
|
||||||
isRead: isActiveConversation,
|
|
||||||
createdAt: (() => { const d = new Date(created_at); return Number.isNaN(d.getTime()) ? new Date().toISOString() : d.toISOString(); })(),
|
|
||||||
seq,
|
|
||||||
status: 'normal',
|
|
||||||
segments,
|
|
||||||
}).catch(error => {
|
|
||||||
console.error('[WSMessageHandler] 保存消息到本地失败:', error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理私聊已读回执
|
|
||||||
*/
|
|
||||||
handleReadReceipt(message: WSReadMessage): void {
|
|
||||||
// 可以在这里处理对方已读的状态更新
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理群聊已读回执
|
|
||||||
*/
|
|
||||||
handleGroupReadReceipt(message: WSGroupReadMessage): void {
|
|
||||||
// 群聊已读回执处理
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理私聊消息撤回
|
|
||||||
*/
|
|
||||||
handleRecallMessage(message: WSRecallMessage): void {
|
|
||||||
const { conversation_id, message_id } = message;
|
|
||||||
const normalizedConversationId = normalizeConversationId(conversation_id);
|
|
||||||
|
|
||||||
this.markMessageAsRecalled(normalizedConversationId, message_id);
|
|
||||||
this.syncConversationLastMessageOnRecall(normalizedConversationId, message_id);
|
|
||||||
|
|
||||||
messageRepository.updateStatus(message_id, 'recalled', true).catch(error => {
|
|
||||||
console.error('[WSMessageHandler] 更新本地消息撤回状态失败:', error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理群聊消息撤回
|
|
||||||
*/
|
|
||||||
handleGroupRecallMessage(message: WSGroupRecallMessage): void {
|
|
||||||
const { conversation_id, message_id } = message;
|
|
||||||
const normalizedConversationId = normalizeConversationId(conversation_id);
|
|
||||||
|
|
||||||
this.markMessageAsRecalled(normalizedConversationId, message_id);
|
|
||||||
this.syncConversationLastMessageOnRecall(normalizedConversationId, message_id);
|
|
||||||
|
|
||||||
messageRepository.updateStatus(message_id, 'recalled', true).catch(error => {
|
|
||||||
console.error('[WSMessageHandler] 更新本地消息撤回状态失败:', error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理群聊输入状态
|
|
||||||
*/
|
|
||||||
handleGroupTyping(message: WSGroupTypingMessage): void {
|
|
||||||
const store = useMessageStore.getState();
|
|
||||||
const { group_id, user_id, is_typing } = message;
|
|
||||||
const groupIdStr = String(group_id);
|
|
||||||
|
|
||||||
const currentTypingUsers = store.getTypingUsers(groupIdStr);
|
|
||||||
let updatedTypingUsers: string[];
|
|
||||||
|
|
||||||
if (is_typing) {
|
|
||||||
if (!currentTypingUsers.includes(user_id)) {
|
|
||||||
updatedTypingUsers = [...currentTypingUsers, user_id];
|
|
||||||
} else {
|
|
||||||
updatedTypingUsers = currentTypingUsers;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
updatedTypingUsers = currentTypingUsers.filter(id => id !== user_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updatedTypingUsers.length !== currentTypingUsers.length) {
|
|
||||||
store.setTypingUsers(groupIdStr, updatedTypingUsers);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理群通知
|
|
||||||
*/
|
|
||||||
handleGroupNotice(message: WSGroupNoticeMessage): void {
|
|
||||||
const store = useMessageStore.getState();
|
|
||||||
const { notice_type, group_id, data, timestamp, message_id, seq } = message;
|
|
||||||
const groupIdStr = String(group_id);
|
|
||||||
|
|
||||||
// 处理禁言/解除禁言通知
|
|
||||||
if (notice_type === 'muted' || notice_type === 'unmuted') {
|
|
||||||
const mutedUserId = data?.user_id;
|
|
||||||
const currentUserId = this.getCurrentUserId();
|
|
||||||
|
|
||||||
if (mutedUserId && mutedUserId === currentUserId) {
|
|
||||||
const isMuted = notice_type === 'muted';
|
|
||||||
store.setMutedStatus(groupIdStr, isMuted);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 补一条系统消息到当前会话消息流(原子性 merge)
|
|
||||||
if (message_id && typeof seq === 'number' && seq > 0) {
|
|
||||||
const conversationId = this.findConversationIdByGroupId(groupIdStr);
|
|
||||||
if (conversationId) {
|
|
||||||
const messageExists = store.getMessages(conversationId).some(m => String(m.id) === String(message_id));
|
|
||||||
if (!messageExists) {
|
|
||||||
const noticeText = this.buildGroupNoticeText(notice_type, data);
|
|
||||||
const systemNoticeMessage: MessageResponse = {
|
|
||||||
id: String(message_id),
|
|
||||||
conversation_id: conversationId,
|
|
||||||
sender_id: '10000',
|
|
||||||
seq,
|
|
||||||
segments: noticeText
|
|
||||||
? [{ type: 'text', data: { text: noticeText } as any }]
|
|
||||||
: [],
|
|
||||||
status: 'normal',
|
|
||||||
category: 'notification',
|
|
||||||
created_at: (() => { const d = new Date(timestamp || Date.now()); return Number.isNaN(d.getTime()) ? new Date().toISOString() : d.toISOString(); })(),
|
|
||||||
};
|
|
||||||
|
|
||||||
useMessageStore.getState().mergeMessages(conversationId, [systemNoticeMessage]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 私有工具方法 ====================
|
|
||||||
|
|
||||||
private updateConversationWithNewMessage(
|
|
||||||
conversationId: string,
|
|
||||||
message: MessageResponse,
|
|
||||||
createdAt: string
|
|
||||||
): void {
|
|
||||||
const store = useMessageStore.getState();
|
|
||||||
const conversation = store.getConversation(conversationId);
|
|
||||||
|
|
||||||
if (conversation) {
|
|
||||||
const updatedConv: ConversationResponse = {
|
|
||||||
...conversation,
|
|
||||||
last_seq: message.seq,
|
|
||||||
last_message: message,
|
|
||||||
last_message_at: createdAt,
|
|
||||||
updated_at: createdAt,
|
|
||||||
};
|
|
||||||
store.updateConversation(updatedConv);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private incrementUnreadCount(conversationId: string): void {
|
|
||||||
useMessageStore.getState().incrementUnreadAtomic(conversationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private incrementSystemUnread(): void {
|
|
||||||
// 原子递增:避免外部 read-modify-write 造成丢计数
|
|
||||||
useMessageStore.getState().incrementSystemUnreadAtomic();
|
|
||||||
}
|
|
||||||
|
|
||||||
private markMessageAsRecalled(conversationId: string, messageId: string): void {
|
|
||||||
const patches = new Map<string, Partial<MessageResponse>>();
|
|
||||||
patches.set(String(messageId), { status: 'recalled' as MessageResponse['status'], segments: [] });
|
|
||||||
useMessageStore.getState().patchMessages(conversationId, patches);
|
|
||||||
}
|
|
||||||
|
|
||||||
private syncConversationLastMessageOnRecall(conversationId: string, messageId: string): void {
|
|
||||||
const store = useMessageStore.getState();
|
|
||||||
const conversation = store.getConversation(conversationId);
|
|
||||||
if (!conversation?.last_message) return;
|
|
||||||
if (String(conversation.last_message.id) !== String(messageId)) return;
|
|
||||||
if (conversation.last_message.status === 'recalled') return;
|
|
||||||
|
|
||||||
const updatedConversation: ConversationResponse = {
|
|
||||||
...conversation,
|
|
||||||
last_message: {
|
|
||||||
...conversation.last_message,
|
|
||||||
status: 'recalled',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
store.updateConversation(updatedConversation);
|
|
||||||
}
|
|
||||||
|
|
||||||
private findConversationIdByGroupId(groupId: string): string | null {
|
|
||||||
const store = useMessageStore.getState();
|
|
||||||
const conversations = store.getConversations();
|
|
||||||
for (const conv of conversations) {
|
|
||||||
if (String(conv.group?.id || '') === groupId) {
|
|
||||||
return conv.id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildGroupNoticeText(noticeType: GroupNoticeType, data: any): string {
|
|
||||||
const username = data?.username || '用户';
|
|
||||||
switch (noticeType) {
|
|
||||||
case 'member_join':
|
|
||||||
return `"${username}" 加入了群聊`;
|
|
||||||
case 'member_leave':
|
|
||||||
return `"${username}" 退出了群聊`;
|
|
||||||
case 'member_removed':
|
|
||||||
return `"${username}" 被移出群聊`;
|
|
||||||
case 'muted':
|
|
||||||
return `"${username}" 已被管理员禁言`;
|
|
||||||
case 'unmuted':
|
|
||||||
return `"${username}" 已被管理员解除禁言`;
|
|
||||||
default:
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,11 +2,15 @@
|
|||||||
* 消息服务层统一导出
|
* 消息服务层统一导出
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// 消息去重服务
|
// 实时消息入口(幂等 ingest + 监听器生命周期)
|
||||||
export { MessageDeduplication, messageDeduplication } from './MessageDeduplication';
|
export { RealtimeIngestionPipeline } from './RealtimeIngestionPipeline';
|
||||||
|
export type {
|
||||||
|
RealtimeIngestionPipelineDeps,
|
||||||
|
IngestOptions,
|
||||||
|
} from './RealtimeIngestionPipeline';
|
||||||
|
|
||||||
// 用户缓存服务
|
// 用户缓存服务
|
||||||
export { UserCacheService, userCacheService } from './UserCacheService';
|
export { UserCacheService } from './UserCacheService';
|
||||||
|
|
||||||
// 已读回执管理器
|
// 已读回执管理器
|
||||||
export { ReadReceiptManager } from './ReadReceiptManager';
|
export { ReadReceiptManager } from './ReadReceiptManager';
|
||||||
@@ -19,6 +23,3 @@ export { MessageSendService } from './MessageSendService';
|
|||||||
|
|
||||||
// 消息同步服务
|
// 消息同步服务
|
||||||
export { MessageSyncService } from './MessageSyncService';
|
export { MessageSyncService } from './MessageSyncService';
|
||||||
|
|
||||||
// WebSocket 消息处理器
|
|
||||||
export { WSMessageHandler } from './WSMessageHandler';
|
|
||||||
@@ -85,6 +85,16 @@ export interface MessageActions {
|
|||||||
removeConversation: (conversationId: string) => void;
|
removeConversation: (conversationId: string) => void;
|
||||||
setMessages: (conversationId: string, messages: MessageResponse[]) => void;
|
setMessages: (conversationId: string, messages: MessageResponse[]) => void;
|
||||||
mergeMessages: (conversationId: string, incoming: MessageResponse[]) => void;
|
mergeMessages: (conversationId: string, incoming: MessageResponse[]) => void;
|
||||||
|
/**
|
||||||
|
* 幂等写入单条消息:以 message id 为幂等键,在 zustand set 原子边界内去重。
|
||||||
|
* 返回 true 表示这条消息是新写入的(store 之前没有同 id 的消息),
|
||||||
|
* 返回 false 表示已存在(被静默去重,副作用调用方应据此跳过未读/通知等副作用)。
|
||||||
|
*
|
||||||
|
* 这是消息幂等性的唯一真理源:任何来源(WS 推送、REST 拉取、发送回显、
|
||||||
|
* loadMore)都应通过 addOrReplaceMessage/mergeMessages 走 store,避免上层
|
||||||
|
* 维护独立去重表带来的「标记早于落库」竞态。
|
||||||
|
*/
|
||||||
|
addOrReplaceMessage: (conversationId: string, message: MessageResponse) => boolean;
|
||||||
removeMessage: (conversationId: string, messageId: string) => void;
|
removeMessage: (conversationId: string, messageId: string) => void;
|
||||||
patchMessages: (conversationId: string, patches: Map<string, Partial<MessageResponse>>) => void;
|
patchMessages: (conversationId: string, patches: Map<string, Partial<MessageResponse>>) => void;
|
||||||
setUnreadCount: (total: number, system: number) => void;
|
setUnreadCount: (total: number, system: number) => void;
|
||||||
@@ -92,6 +102,15 @@ export interface MessageActions {
|
|||||||
* 原子递增系统未读数:避免外部 RMW 造成丢失
|
* 原子递增系统未读数:避免外部 RMW 造成丢失
|
||||||
*/
|
*/
|
||||||
incrementSystemUnreadAtomic: () => void;
|
incrementSystemUnreadAtomic: () => void;
|
||||||
|
/**
|
||||||
|
* 原子设置系统未读数:保持 totalUnreadCount 一致性(total = sum(各会话未读) + system),
|
||||||
|
* 在 zustand set 回调内一次性完成读-改-写,避免外部 RMW 丢更新。
|
||||||
|
*/
|
||||||
|
setSystemUnreadAtomic: (count: number) => void;
|
||||||
|
/**
|
||||||
|
* 原子递减系统未读数(不低于 0):避免外部 RMW 造成丢失。
|
||||||
|
*/
|
||||||
|
decrementSystemUnreadAtomic: (count?: number) => void;
|
||||||
setLastSystemMessageAt: (time: string | null) => void;
|
setLastSystemMessageAt: (time: string | null) => void;
|
||||||
setSSEConnected: (connected: boolean) => void;
|
setSSEConnected: (connected: boolean) => void;
|
||||||
setCurrentConversation: (conversationId: string | null) => void;
|
setCurrentConversation: (conversationId: string | null) => void;
|
||||||
@@ -364,6 +383,31 @@ export const useMessageStore = create<MessageStore>((set, get) => ({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 幂等写入单条消息,返回是否为新写入。
|
||||||
|
* 去重发生在 zustand set 的原子边界内,调用方据此决定是否触发未读/通知等副作用。
|
||||||
|
*/
|
||||||
|
addOrReplaceMessage: (conversationId: string, message: MessageResponse): boolean => {
|
||||||
|
const normalizedId = normalizeConversationId(conversationId);
|
||||||
|
let inserted = false;
|
||||||
|
set(state => {
|
||||||
|
const existing = state.messagesMap.get(normalizedId);
|
||||||
|
const targetId = String(message.id);
|
||||||
|
// 已存在同 id:静默去重(幂等),不视为新增
|
||||||
|
if (existing && existing.some(m => String(m.id) === targetId)) {
|
||||||
|
inserted = false;
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
inserted = true;
|
||||||
|
const base = existing || [];
|
||||||
|
const merged = mergeMessagesById(base, [message]);
|
||||||
|
const newMessagesMap = new Map(state.messagesMap);
|
||||||
|
newMessagesMap.set(normalizedId, merged);
|
||||||
|
return { messagesMap: newMessagesMap };
|
||||||
|
});
|
||||||
|
return inserted;
|
||||||
|
},
|
||||||
|
|
||||||
removeMessage: (conversationId: string, messageId: string) => {
|
removeMessage: (conversationId: string, messageId: string) => {
|
||||||
const normalizedId = normalizeConversationId(conversationId);
|
const normalizedId = normalizeConversationId(conversationId);
|
||||||
set(state => {
|
set(state => {
|
||||||
@@ -414,6 +458,27 @@ export const useMessageStore = create<MessageStore>((set, get) => ({
|
|||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setSystemUnreadAtomic: (count: number) => {
|
||||||
|
set(state => {
|
||||||
|
const delta = count - state.systemUnreadCount;
|
||||||
|
return {
|
||||||
|
systemUnreadCount: count,
|
||||||
|
totalUnreadCount: Math.max(0, state.totalUnreadCount + delta),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
decrementSystemUnreadAtomic: (count = 1) => {
|
||||||
|
set(state => {
|
||||||
|
const newSystemCount = Math.max(0, state.systemUnreadCount - count);
|
||||||
|
const actualDecrement = state.systemUnreadCount - newSystemCount;
|
||||||
|
return {
|
||||||
|
systemUnreadCount: newSystemCount,
|
||||||
|
totalUnreadCount: Math.max(0, state.totalUnreadCount - actualDecrement),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
setLastSystemMessageAt: (time: string | null) => {
|
setLastSystemMessageAt: (time: string | null) => {
|
||||||
set({ lastSystemMessageAt: time });
|
set({ lastSystemMessageAt: time });
|
||||||
if (time) {
|
if (time) {
|
||||||
|
|||||||
@@ -22,12 +22,6 @@ export interface HandleNewMessageOptions {
|
|||||||
suppressAutoMarkAsRead?: boolean;
|
suppressAutoMarkAsRead?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IMessageDeduplication {
|
|
||||||
isMessageProcessed(messageId: string): boolean;
|
|
||||||
markMessageAsProcessed(messageId: string): void;
|
|
||||||
cleanupProcessedMessageIds(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IUserCacheService {
|
export interface IUserCacheService {
|
||||||
getSenderInfo(userId: string): Promise<UserDTO | null>;
|
getSenderInfo(userId: string): Promise<UserDTO | null>;
|
||||||
enrichMessagesWithSenderInfo(
|
enrichMessagesWithSenderInfo(
|
||||||
@@ -40,11 +34,6 @@ export interface IUserCacheService {
|
|||||||
export interface IReadReceiptManager {
|
export interface IReadReceiptManager {
|
||||||
markAsRead(conversationId: string, seq: number): Promise<void>;
|
markAsRead(conversationId: string, seq: number): Promise<void>;
|
||||||
markAllAsRead(): Promise<void>;
|
markAllAsRead(): Promise<void>;
|
||||||
beginReadOperation(conversationId: string, seq: number): number;
|
|
||||||
endReadOperation(conversationId: string, version: number, success: boolean): void;
|
|
||||||
isReadOperationPending(conversationId: string): boolean;
|
|
||||||
getReadStateVersion(): number;
|
|
||||||
getPendingReadMap(): Map<string, ReadStateRecord>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IConversationOperations {
|
export interface IConversationOperations {
|
||||||
@@ -77,9 +66,3 @@ export interface IMessageSyncService {
|
|||||||
canLoadMoreConversations(): boolean;
|
canLoadMoreConversations(): boolean;
|
||||||
restartSources(): void;
|
restartSources(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IWSMessageHandler {
|
|
||||||
connect(): void;
|
|
||||||
disconnect(): void;
|
|
||||||
isConnected(): boolean;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ import { createDedupe } from '../utils/requestDedupe';
|
|||||||
|
|
||||||
const dedupe = createDedupe(() => useUserManagerStore);
|
const dedupe = createDedupe(() => useUserManagerStore);
|
||||||
|
|
||||||
|
// 将 fetchCurrentUserFromAPI 的判别结果转为 User | null。
|
||||||
|
// 网络故障/认证失败时返回 null,由上层缓存兜底(避免空数据覆盖缓存)。
|
||||||
|
// 注意:UserManager 只负责缓存,不触发登出,登出由 authStore 统一处理。
|
||||||
|
async function fetchCurrentUserOrNull(): Promise<UserDTO | null> {
|
||||||
|
const result = await authService.fetchCurrentUserFromAPI();
|
||||||
|
return result.kind === 'user' ? result.user : null;
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== UserManager 类 ====================
|
// ==================== UserManager 类 ====================
|
||||||
|
|
||||||
class UserManager {
|
class UserManager {
|
||||||
@@ -41,11 +49,11 @@ class UserManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return dedupe('users:current', async () => {
|
return dedupe('users:current', async () => {
|
||||||
const user = await authService.fetchCurrentUserFromAPI();
|
const user = await fetchCurrentUserOrNull();
|
||||||
|
// 仅在拿到真实用户时刷新缓存;网络/认证失败时保留旧缓存,避免误清空
|
||||||
|
if (user) {
|
||||||
const newEntry = createCacheEntry(user, DEFAULT_TTL.USER);
|
const newEntry = createCacheEntry(user, DEFAULT_TTL.USER);
|
||||||
store.setCurrentUserEntry(newEntry);
|
store.setCurrentUserEntry(newEntry);
|
||||||
|
|
||||||
if (user) {
|
|
||||||
userCacheRepository.saveCurrent(user).catch(() => {});
|
userCacheRepository.saveCurrent(user).catch(() => {});
|
||||||
userCacheRepository.save(user).catch(() => {});
|
userCacheRepository.save(user).catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -56,11 +64,10 @@ class UserManager {
|
|||||||
private refreshCurrentUserInBackground(): void {
|
private refreshCurrentUserInBackground(): void {
|
||||||
dedupe('users:current:bg', async () => {
|
dedupe('users:current:bg', async () => {
|
||||||
const store = useUserManagerStore.getState();
|
const store = useUserManagerStore.getState();
|
||||||
const user = await authService.fetchCurrentUserFromAPI();
|
const user = await fetchCurrentUserOrNull();
|
||||||
|
if (user) {
|
||||||
const newEntry = createCacheEntry(user, DEFAULT_TTL.USER);
|
const newEntry = createCacheEntry(user, DEFAULT_TTL.USER);
|
||||||
store.setCurrentUserEntry(newEntry);
|
store.setCurrentUserEntry(newEntry);
|
||||||
|
|
||||||
if (user) {
|
|
||||||
userCacheRepository.saveCurrent(user).catch(() => {});
|
userCacheRepository.saveCurrent(user).catch(() => {});
|
||||||
userCacheRepository.save(user).catch(() => {});
|
userCacheRepository.save(user).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,5 +9,6 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true
|
"resolveJsonModule": true
|
||||||
}
|
},
|
||||||
|
"exclude": ["node_modules", "**/__tests__/**", "**/*.test.ts", "**/*.test.tsx", "jest.config.js"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user