添加socketservice
This commit is contained in:
546
WEBSOCKET_INTEGRATION_GUIDE.md
Normal file
546
WEBSOCKET_INTEGRATION_GUIDE.md
Normal file
@@ -0,0 +1,546 @@
|
|||||||
|
# WebSocket 事件实时推送 - 前端集成指南
|
||||||
|
|
||||||
|
## 📦 已创建的文件
|
||||||
|
|
||||||
|
1. **`src/services/socketService.js`** - WebSocket 服务(已扩展)
|
||||||
|
2. **`src/hooks/useEventNotifications.js`** - React Hook
|
||||||
|
3. **`test_websocket.html`** - 测试页面
|
||||||
|
4. **`test_create_event.py`** - 测试脚本
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 快速开始
|
||||||
|
|
||||||
|
### 方案 1:使用 React Hook(推荐)
|
||||||
|
|
||||||
|
在任何 React 组件中使用:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { useEventNotifications } from 'hooks/useEventNotifications';
|
||||||
|
import { useToast } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
function EventsPage() {
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
// 订阅事件推送
|
||||||
|
const { newEvent, isConnected } = useEventNotifications({
|
||||||
|
eventType: 'all', // 'all' | 'policy' | 'market' | 'tech' | ...
|
||||||
|
importance: 'all', // 'all' | 'S' | 'A' | 'B' | 'C'
|
||||||
|
enabled: true, // 是否启用订阅
|
||||||
|
onNewEvent: (event) => {
|
||||||
|
// 收到新事件时的处理
|
||||||
|
console.log('🔔 收到新事件:', event);
|
||||||
|
|
||||||
|
// 显示 Toast 通知
|
||||||
|
toast({
|
||||||
|
title: '新事件提醒',
|
||||||
|
description: event.title,
|
||||||
|
status: 'info',
|
||||||
|
duration: 5000,
|
||||||
|
isClosable: true,
|
||||||
|
position: 'top-right',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Text>连接状态: {isConnected ? '已连接 ✅' : '未连接 ❌'}</Text>
|
||||||
|
{/* 你的事件列表 */}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 方案 2:在事件列表页面集成(完整示例)
|
||||||
|
|
||||||
|
**在 `src/views/Community/components/EventList.js` 中集成:**
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Box, Text, Badge, useToast } from '@chakra-ui/react';
|
||||||
|
import { useEventNotifications } from 'hooks/useEventNotifications';
|
||||||
|
|
||||||
|
function EventList() {
|
||||||
|
const [events, setEvents] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
// 1️⃣ 初始加载事件列表(REST API)
|
||||||
|
useEffect(() => {
|
||||||
|
fetchEvents();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchEvents = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/events?per_page=20');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
setEvents(data.data.events);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载事件失败:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2️⃣ 订阅 WebSocket 实时推送
|
||||||
|
const { newEvent, isConnected } = useEventNotifications({
|
||||||
|
eventType: 'all',
|
||||||
|
importance: 'all',
|
||||||
|
enabled: true, // 可以根据用户设置控制是否启用
|
||||||
|
onNewEvent: (event) => {
|
||||||
|
console.log('🔔 收到新事件:', event);
|
||||||
|
|
||||||
|
// 显示通知
|
||||||
|
toast({
|
||||||
|
title: '📰 新事件发布',
|
||||||
|
description: `${event.title}`,
|
||||||
|
status: 'info',
|
||||||
|
duration: 6000,
|
||||||
|
isClosable: true,
|
||||||
|
position: 'top-right',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 将新事件添加到列表顶部
|
||||||
|
setEvents((prevEvents) => {
|
||||||
|
// 检查是否已存在(防止重复)
|
||||||
|
const exists = prevEvents.some(e => e.id === event.id);
|
||||||
|
if (exists) {
|
||||||
|
return prevEvents;
|
||||||
|
}
|
||||||
|
// 添加到顶部,最多保留 100 个
|
||||||
|
return [event, ...prevEvents].slice(0, 100);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
{/* 连接状态指示器 */}
|
||||||
|
<Box mb={4} display="flex" alignItems="center" gap={2}>
|
||||||
|
<Badge colorScheme={isConnected ? 'green' : 'red'}>
|
||||||
|
{isConnected ? '实时推送已开启' : '实时推送未连接'}
|
||||||
|
</Badge>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 事件列表 */}
|
||||||
|
{loading ? (
|
||||||
|
<Text>加载中...</Text>
|
||||||
|
) : (
|
||||||
|
<Box>
|
||||||
|
{events.map((event) => (
|
||||||
|
<EventCard key={event.id} event={event} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EventList;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 方案 3:只订阅重要事件(S 和 A 级)
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { useImportantEventNotifications } from 'hooks/useEventNotifications';
|
||||||
|
|
||||||
|
function Dashboard() {
|
||||||
|
const { importantEvents, isConnected } = useImportantEventNotifications((event) => {
|
||||||
|
// 只会收到 S 和 A 级别的重要事件
|
||||||
|
console.log('⚠️ 重要事件:', event);
|
||||||
|
|
||||||
|
// 播放提示音
|
||||||
|
new Audio('/notification.mp3').play();
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Heading>重要事件通知</Heading>
|
||||||
|
{importantEvents.map(event => (
|
||||||
|
<Alert key={event.id} status="warning">
|
||||||
|
<AlertIcon />
|
||||||
|
{event.title}
|
||||||
|
</Alert>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 方案 4:直接使用 Service(不用 Hook)
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import socketService from 'services/socketService';
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
useEffect(() => {
|
||||||
|
// 连接
|
||||||
|
socketService.connect();
|
||||||
|
|
||||||
|
// 订阅
|
||||||
|
const unsubscribe = socketService.subscribeToAllEvents((event) => {
|
||||||
|
console.log('新事件:', event);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 清理
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
socketService.disconnect();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <div>...</div>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 UI 集成示例
|
||||||
|
|
||||||
|
### 1. Toast 通知(Chakra UI)
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { useToast } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
// 在 onNewEvent 回调中
|
||||||
|
onNewEvent: (event) => {
|
||||||
|
toast({
|
||||||
|
title: '新事件',
|
||||||
|
description: event.title,
|
||||||
|
status: 'info',
|
||||||
|
duration: 5000,
|
||||||
|
isClosable: true,
|
||||||
|
position: 'top-right',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. 顶部通知栏
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { Alert, AlertIcon, CloseButton } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
function EventNotificationBanner() {
|
||||||
|
const [showNotification, setShowNotification] = useState(false);
|
||||||
|
const [latestEvent, setLatestEvent] = useState(null);
|
||||||
|
|
||||||
|
useEventNotifications({
|
||||||
|
eventType: 'all',
|
||||||
|
onNewEvent: (event) => {
|
||||||
|
setLatestEvent(event);
|
||||||
|
setShowNotification(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!showNotification || !latestEvent) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert status="info" variant="solid">
|
||||||
|
<AlertIcon />
|
||||||
|
新事件:{latestEvent.title}
|
||||||
|
<CloseButton
|
||||||
|
position="absolute"
|
||||||
|
right="8px"
|
||||||
|
top="8px"
|
||||||
|
onClick={() => setShowNotification(false)}
|
||||||
|
/>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. 角标提示(红点)
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { Badge } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
function EventsMenuItem() {
|
||||||
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
|
|
||||||
|
useEventNotifications({
|
||||||
|
eventType: 'all',
|
||||||
|
onNewEvent: () => {
|
||||||
|
setUnreadCount(prev => prev + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MenuItem position="relative">
|
||||||
|
事件中心
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<Badge
|
||||||
|
colorScheme="red"
|
||||||
|
position="absolute"
|
||||||
|
top="-5px"
|
||||||
|
right="-5px"
|
||||||
|
borderRadius="full"
|
||||||
|
>
|
||||||
|
{unreadCount > 99 ? '99+' : unreadCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</MenuItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. 浮动通知卡片
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { Box, Slide, useDisclosure } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
function FloatingEventNotification() {
|
||||||
|
const { isOpen, onClose, onOpen } = useDisclosure();
|
||||||
|
const [event, setEvent] = useState(null);
|
||||||
|
|
||||||
|
useEventNotifications({
|
||||||
|
eventType: 'all',
|
||||||
|
onNewEvent: (newEvent) => {
|
||||||
|
setEvent(newEvent);
|
||||||
|
onOpen();
|
||||||
|
|
||||||
|
// 5秒后自动关闭
|
||||||
|
setTimeout(onClose, 5000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Slide direction="bottom" in={isOpen} style={{ zIndex: 10 }}>
|
||||||
|
<Box
|
||||||
|
p="40px"
|
||||||
|
color="white"
|
||||||
|
bg="blue.500"
|
||||||
|
rounded="md"
|
||||||
|
shadow="md"
|
||||||
|
m={4}
|
||||||
|
>
|
||||||
|
<Text fontWeight="bold">{event?.title}</Text>
|
||||||
|
<Text fontSize="sm">{event?.description}</Text>
|
||||||
|
<Button size="sm" mt={2} onClick={onClose}>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Slide>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 API 参考
|
||||||
|
|
||||||
|
### `useEventNotifications(options)`
|
||||||
|
|
||||||
|
**参数:**
|
||||||
|
| 参数 | 类型 | 默认值 | 说明 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| `eventType` | string | `'all'` | 事件类型:`'all'` / `'policy'` / `'market'` / `'tech'` 等 |
|
||||||
|
| `importance` | string | `'all'` | 重要性:`'all'` / `'S'` / `'A'` / `'B'` / `'C'` |
|
||||||
|
| `enabled` | boolean | `true` | 是否启用订阅 |
|
||||||
|
| `onNewEvent` | function | - | 收到新事件时的回调函数 |
|
||||||
|
|
||||||
|
**返回值:**
|
||||||
|
| 属性 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `newEvent` | object | 最新收到的事件对象 |
|
||||||
|
| `isConnected` | boolean | WebSocket 连接状态 |
|
||||||
|
| `error` | object | 错误信息 |
|
||||||
|
| `clearNewEvent` | function | 清除新事件状态 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `socketService` API
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 连接
|
||||||
|
socketService.connect(options)
|
||||||
|
|
||||||
|
// 断开
|
||||||
|
socketService.disconnect()
|
||||||
|
|
||||||
|
// 订阅所有事件
|
||||||
|
socketService.subscribeToAllEvents(callback)
|
||||||
|
|
||||||
|
// 订阅特定类型
|
||||||
|
socketService.subscribeToEventType('tech', callback)
|
||||||
|
|
||||||
|
// 订阅特定重要性
|
||||||
|
socketService.subscribeToImportantEvents('S', callback)
|
||||||
|
|
||||||
|
// 取消订阅
|
||||||
|
socketService.unsubscribeFromEvents({ eventType: 'all' })
|
||||||
|
|
||||||
|
// 检查连接状态
|
||||||
|
socketService.isConnected()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 事件数据结构
|
||||||
|
|
||||||
|
收到的 `event` 对象包含:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
id: 123,
|
||||||
|
title: "事件标题",
|
||||||
|
description: "事件描述",
|
||||||
|
event_type: "tech", // 类型
|
||||||
|
importance: "S", // 重要性
|
||||||
|
status: "active",
|
||||||
|
created_at: "2025-01-21T14:30:00",
|
||||||
|
hot_score: 85.5,
|
||||||
|
view_count: 1234,
|
||||||
|
related_avg_chg: 5.2, // 平均涨幅
|
||||||
|
related_max_chg: 15.8, // 最大涨幅
|
||||||
|
keywords: ["AI", "芯片"], // 关键词
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ 高级配置
|
||||||
|
|
||||||
|
### 1. 条件订阅(用户设置)
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
function EventsPage() {
|
||||||
|
const [enableNotifications, setEnableNotifications] = useState(
|
||||||
|
localStorage.getItem('enableEventNotifications') === 'true'
|
||||||
|
);
|
||||||
|
|
||||||
|
useEventNotifications({
|
||||||
|
eventType: 'all',
|
||||||
|
enabled: enableNotifications, // 根据用户设置控制
|
||||||
|
onNewEvent: handleNewEvent
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Switch
|
||||||
|
isChecked={enableNotifications}
|
||||||
|
onChange={(e) => {
|
||||||
|
const enabled = e.target.checked;
|
||||||
|
setEnableNotifications(enabled);
|
||||||
|
localStorage.setItem('enableEventNotifications', enabled);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
启用事件实时通知
|
||||||
|
</Switch>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. 多个订阅(不同类型)
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
function MultiSubscriptionExample() {
|
||||||
|
// 订阅科技类事件
|
||||||
|
useEventNotifications({
|
||||||
|
eventType: 'tech',
|
||||||
|
onNewEvent: (event) => console.log('科技事件:', event)
|
||||||
|
});
|
||||||
|
|
||||||
|
// 订阅政策类事件
|
||||||
|
useEventNotifications({
|
||||||
|
eventType: 'policy',
|
||||||
|
onNewEvent: (event) => console.log('政策事件:', event)
|
||||||
|
});
|
||||||
|
|
||||||
|
return <div>...</div>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. 防抖处理(避免通知过多)
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { debounce } from 'lodash';
|
||||||
|
|
||||||
|
const debouncedNotify = debounce((event) => {
|
||||||
|
toast({
|
||||||
|
title: '新事件',
|
||||||
|
description: event.title,
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
useEventNotifications({
|
||||||
|
eventType: 'all',
|
||||||
|
onNewEvent: debouncedNotify
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 测试步骤
|
||||||
|
|
||||||
|
1. **启动 Flask 服务**
|
||||||
|
```bash
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **启动 React 应用**
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **创建测试事件**
|
||||||
|
```bash
|
||||||
|
python test_create_event.py
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **观察结果**
|
||||||
|
- 最多等待 30 秒
|
||||||
|
- 前端页面应该显示通知
|
||||||
|
- 控制台输出日志
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 常见问题
|
||||||
|
|
||||||
|
### Q: 没有收到推送?
|
||||||
|
**A:** 检查:
|
||||||
|
1. Flask 服务是否启动
|
||||||
|
2. 浏览器控制台是否有连接错误
|
||||||
|
3. 后端日志是否显示 `[轮询] 发现 X 个新事件`
|
||||||
|
|
||||||
|
### Q: 连接一直失败?
|
||||||
|
**A:** 检查:
|
||||||
|
1. API_BASE_URL 配置是否正确
|
||||||
|
2. CORS 配置是否包含前端域名
|
||||||
|
3. 防火墙/代理设置
|
||||||
|
|
||||||
|
### Q: 收到重复通知?
|
||||||
|
**A:** 检查是否多次调用了 Hook,确保只在需要的地方订阅一次。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 更多资源
|
||||||
|
|
||||||
|
- Socket.IO 文档: https://socket.io/docs/v4/
|
||||||
|
- Chakra UI Toast: https://chakra-ui.com/docs/components/toast
|
||||||
|
- React Hooks: https://react.dev/reference/react
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**完成!🎉** 现在你的前端可以实时接收事件推送了!
|
||||||
86
app.py
86
app.py
@@ -7579,34 +7579,73 @@ def broadcast_new_event(event):
|
|||||||
|
|
||||||
# ==================== WebSocket 轮询机制(检测新事件) ====================
|
# ==================== WebSocket 轮询机制(检测新事件) ====================
|
||||||
|
|
||||||
# 内存变量:记录上次检查的最大事件 ID
|
# 内存变量:记录上次检查的时间戳和已推送的事件 ID 集合
|
||||||
last_checked_event_id = 0
|
last_checked_time = None
|
||||||
|
pushed_event_ids = set() # 已推送的事件 ID 集合,防止重复推送
|
||||||
|
MAX_PUSHED_IDS_SIZE = 1000 # 已推送 ID 集合的最大容量
|
||||||
|
|
||||||
def poll_new_events():
|
def poll_new_events():
|
||||||
"""
|
"""
|
||||||
定期轮询数据库,检查是否有新事件
|
定期轮询数据库,检查是否有新事件
|
||||||
每 30 秒执行一次
|
每 30 秒执行一次
|
||||||
|
|
||||||
|
设计思路:
|
||||||
|
1. 使用时间戳查询(created_at),而不是 ID
|
||||||
|
2. 维护已推送事件 ID 集合,避免重复推送
|
||||||
|
3. 使用重叠时间窗口(向前多查60秒),捕获延迟写入的事件
|
||||||
|
4. 定期清理已推送集合,防止内存泄漏
|
||||||
"""
|
"""
|
||||||
global last_checked_event_id
|
global last_checked_time, pushed_event_ids
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
# 查询比上次检查 ID 更大的所有新事件
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
current_time = datetime.now()
|
||||||
|
|
||||||
|
# 如果是第一次运行,只查询最近 30 秒的事件
|
||||||
|
if last_checked_time is None:
|
||||||
|
query_start_time = current_time - timedelta(seconds=30)
|
||||||
|
else:
|
||||||
|
# 向前多查 60 秒(重叠窗口),防止漏掉延迟写入的事件
|
||||||
|
query_start_time = last_checked_time - timedelta(seconds=60)
|
||||||
|
|
||||||
|
# 查询时间范围内的新事件
|
||||||
new_events = Event.query.filter(
|
new_events = Event.query.filter(
|
||||||
Event.id > last_checked_event_id,
|
Event.created_at >= query_start_time,
|
||||||
|
Event.created_at <= current_time,
|
||||||
Event.status == 'active'
|
Event.status == 'active'
|
||||||
).order_by(Event.id.asc()).all()
|
).order_by(Event.created_at.asc()).all()
|
||||||
|
|
||||||
if new_events:
|
# 过滤掉已经推送过的事件
|
||||||
print(f'[轮询] 发现 {len(new_events)} 个新事件')
|
unpushed_events = [
|
||||||
|
event for event in new_events
|
||||||
|
if event.id not in pushed_event_ids
|
||||||
|
]
|
||||||
|
|
||||||
for event in new_events:
|
if unpushed_events:
|
||||||
# 推送每个新事件
|
print(f'[轮询] 发现 {len(unpushed_events)} 个新事件(查询到 {len(new_events)} 个,已过滤 {len(new_events) - len(unpushed_events)} 个重复)')
|
||||||
|
|
||||||
|
for event in unpushed_events:
|
||||||
|
# 推送新事件
|
||||||
broadcast_new_event(event)
|
broadcast_new_event(event)
|
||||||
# 更新最后检查的 ID
|
# 记录已推送
|
||||||
last_checked_event_id = event.id
|
pushed_event_ids.add(event.id)
|
||||||
|
print(f'[轮询] 推送事件 ID={event.id}, 标题={event.title}')
|
||||||
|
|
||||||
print(f'[轮询] 已推送新事件,最新 ID: {last_checked_event_id}')
|
# 更新检查时间
|
||||||
|
last_checked_time = current_time
|
||||||
|
|
||||||
|
# 清理已推送集合(防止无限增长)
|
||||||
|
if len(pushed_event_ids) > MAX_PUSHED_IDS_SIZE:
|
||||||
|
# 只保留最新的一半
|
||||||
|
sorted_ids = sorted(pushed_event_ids)
|
||||||
|
pushed_event_ids = set(sorted_ids[-MAX_PUSHED_IDS_SIZE//2:])
|
||||||
|
print(f'[轮询] 已清理推送记录,当前保留 {len(pushed_event_ids)} 个')
|
||||||
|
|
||||||
|
else:
|
||||||
|
# 没有新事件,也要更新检查时间
|
||||||
|
last_checked_time = current_time
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f'[轮询] 检查新事件时出错: {e}')
|
print(f'[轮询] 检查新事件时出错: {e}')
|
||||||
@@ -7617,17 +7656,22 @@ def initialize_event_polling():
|
|||||||
初始化事件轮询机制
|
初始化事件轮询机制
|
||||||
在应用启动时调用
|
在应用启动时调用
|
||||||
"""
|
"""
|
||||||
global last_checked_event_id
|
global last_checked_time, pushed_event_ids
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
# 获取当前数据库中最新的事件 ID,作为起点
|
# 设置初始检查时间为当前时间
|
||||||
latest_event = Event.query.order_by(Event.id.desc()).first()
|
# 这样启动后只会推送新创建的事件,不会推送历史事件
|
||||||
if latest_event:
|
last_checked_time = datetime.now()
|
||||||
last_checked_event_id = latest_event.id
|
pushed_event_ids.clear()
|
||||||
print(f'[轮询] 初始化完成,起始事件 ID: {last_checked_event_id}')
|
|
||||||
else:
|
# 统计数据库中的事件总数
|
||||||
print('[轮询] 数据库中暂无事件')
|
total_events = Event.query.filter_by(status='active').count()
|
||||||
|
print(f'[轮询] 初始化完成,数据库中共有 {total_events} 个活跃事件')
|
||||||
|
print(f'[轮询] 起始时间: {last_checked_time.strftime("%Y-%m-%d %H:%M:%S")}')
|
||||||
|
print(f'[轮询] 只会推送此时间之后创建的新事件')
|
||||||
|
|
||||||
# 创建后台调度器
|
# 创建后台调度器
|
||||||
scheduler = BackgroundScheduler()
|
scheduler = BackgroundScheduler()
|
||||||
|
|||||||
161
src/hooks/useEventNotifications.js
Normal file
161
src/hooks/useEventNotifications.js
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
// src/hooks/useEventNotifications.js
|
||||||
|
/**
|
||||||
|
* React Hook:用于在组件中订阅事件推送通知
|
||||||
|
*
|
||||||
|
* 使用示例:
|
||||||
|
* ```jsx
|
||||||
|
* import { useEventNotifications } from 'hooks/useEventNotifications';
|
||||||
|
*
|
||||||
|
* function MyComponent() {
|
||||||
|
* const { newEvent, isConnected } = useEventNotifications({
|
||||||
|
* eventType: 'all',
|
||||||
|
* importance: 'all',
|
||||||
|
* onNewEvent: (event) => {
|
||||||
|
* console.log('收到新事件:', event);
|
||||||
|
* // 显示通知...
|
||||||
|
* }
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* return <div>...</div>;
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState, useRef } from 'react';
|
||||||
|
import { socketService } from '../services/socketService';
|
||||||
|
|
||||||
|
export const useEventNotifications = (options = {}) => {
|
||||||
|
const {
|
||||||
|
eventType = 'all',
|
||||||
|
importance = 'all',
|
||||||
|
enabled = true,
|
||||||
|
onNewEvent,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
const [newEvent, setNewEvent] = useState(null);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const unsubscribeRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 如果禁用,则不订阅
|
||||||
|
if (!enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 连接状态监听
|
||||||
|
const handleConnect = () => {
|
||||||
|
setIsConnected(true);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDisconnect = () => {
|
||||||
|
setIsConnected(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConnectError = (err) => {
|
||||||
|
setError(err);
|
||||||
|
setIsConnected(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 连接 WebSocket
|
||||||
|
socketService.connect();
|
||||||
|
|
||||||
|
// 监听连接事件
|
||||||
|
socketService.on('connect', handleConnect);
|
||||||
|
socketService.on('disconnect', handleDisconnect);
|
||||||
|
socketService.on('connect_error', handleConnectError);
|
||||||
|
|
||||||
|
// 新事件处理函数
|
||||||
|
const handleNewEvent = (eventData) => {
|
||||||
|
setNewEvent(eventData);
|
||||||
|
|
||||||
|
// 调用外部回调
|
||||||
|
if (onNewEvent) {
|
||||||
|
onNewEvent(eventData);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 订阅事件推送
|
||||||
|
socketService.subscribeToEvents({
|
||||||
|
eventType,
|
||||||
|
importance,
|
||||||
|
onNewEvent: handleNewEvent,
|
||||||
|
onSubscribed: (data) => {
|
||||||
|
console.log('订阅成功:', data);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 保存取消订阅函数
|
||||||
|
unsubscribeRef.current = () => {
|
||||||
|
socketService.unsubscribeFromEvents({ eventType });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 组件卸载时清理
|
||||||
|
return () => {
|
||||||
|
console.log('清理 WebSocket 订阅');
|
||||||
|
|
||||||
|
// 取消订阅
|
||||||
|
if (unsubscribeRef.current) {
|
||||||
|
unsubscribeRef.current();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除监听器
|
||||||
|
socketService.off('connect', handleConnect);
|
||||||
|
socketService.off('disconnect', handleDisconnect);
|
||||||
|
socketService.off('connect_error', handleConnectError);
|
||||||
|
|
||||||
|
// 断开连接
|
||||||
|
socketService.disconnect();
|
||||||
|
};
|
||||||
|
}, [eventType, importance, enabled, onNewEvent]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
newEvent, // 最新收到的事件
|
||||||
|
isConnected, // WebSocket 连接状态
|
||||||
|
error, // 错误信息
|
||||||
|
clearNewEvent: () => setNewEvent(null), // 清除新事件状态
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 简化版 Hook:只订阅所有事件
|
||||||
|
*/
|
||||||
|
export const useAllEventNotifications = (onNewEvent) => {
|
||||||
|
return useEventNotifications({
|
||||||
|
eventType: 'all',
|
||||||
|
importance: 'all',
|
||||||
|
onNewEvent,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook:订阅重要事件(S 和 A 级)
|
||||||
|
*/
|
||||||
|
export const useImportantEventNotifications = (onNewEvent) => {
|
||||||
|
const [importantEvents, setImportantEvents] = useState([]);
|
||||||
|
|
||||||
|
const handleEvent = (event) => {
|
||||||
|
// 只处理 S 和 A 级事件
|
||||||
|
if (event.importance === 'S' || event.importance === 'A') {
|
||||||
|
setImportantEvents(prev => [event, ...prev].slice(0, 10)); // 最多保留 10 个
|
||||||
|
if (onNewEvent) {
|
||||||
|
onNewEvent(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = useEventNotifications({
|
||||||
|
eventType: 'all',
|
||||||
|
importance: 'all',
|
||||||
|
onNewEvent: handleEvent,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
importantEvents,
|
||||||
|
clearImportantEvents: () => setImportantEvents([]),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useEventNotifications;
|
||||||
@@ -186,6 +186,169 @@ class SocketService {
|
|||||||
getSocketId() {
|
getSocketId() {
|
||||||
return this.socket?.id || null;
|
return this.socket?.id || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 事件推送专用方法 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订阅事件推送
|
||||||
|
* @param {object} options - 订阅选项
|
||||||
|
* @param {string} options.eventType - 事件类型 ('all' | 'policy' | 'market' | 'tech' | ...)
|
||||||
|
* @param {string} options.importance - 重要性 ('all' | 'S' | 'A' | 'B' | 'C')
|
||||||
|
* @param {Function} options.onNewEvent - 收到新事件时的回调函数
|
||||||
|
* @param {Function} options.onSubscribed - 订阅成功的回调函数(可选)
|
||||||
|
*/
|
||||||
|
subscribeToEvents(options = {}) {
|
||||||
|
const {
|
||||||
|
eventType = 'all',
|
||||||
|
importance = 'all',
|
||||||
|
onNewEvent,
|
||||||
|
onSubscribed,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
if (!this.socket || !this.connected) {
|
||||||
|
logger.warn('socketService', 'Cannot subscribe: socket not connected');
|
||||||
|
// 自动连接
|
||||||
|
this.connect();
|
||||||
|
// 等待连接成功后再订阅
|
||||||
|
this.socket.once('connect', () => {
|
||||||
|
this._doSubscribe(eventType, importance, onNewEvent, onSubscribed);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._doSubscribe(eventType, importance, onNewEvent, onSubscribed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行订阅操作(内部方法)
|
||||||
|
*/
|
||||||
|
_doSubscribe(eventType, importance, onNewEvent, onSubscribed) {
|
||||||
|
// 发送订阅请求
|
||||||
|
this.emit('subscribe_events', {
|
||||||
|
event_type: eventType,
|
||||||
|
importance: importance,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听订阅确认
|
||||||
|
this.socket.once('subscription_confirmed', (data) => {
|
||||||
|
logger.info('socketService', 'Subscription confirmed', data);
|
||||||
|
if (onSubscribed) {
|
||||||
|
onSubscribed(data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听订阅错误
|
||||||
|
this.socket.once('subscription_error', (error) => {
|
||||||
|
logger.error('socketService', 'Subscription error', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听新事件推送
|
||||||
|
if (onNewEvent) {
|
||||||
|
// 先移除之前的监听器(避免重复)
|
||||||
|
this.socket.off('new_event');
|
||||||
|
// 添加新的监听器
|
||||||
|
this.socket.on('new_event', (eventData) => {
|
||||||
|
logger.info('socketService', 'New event received', eventData);
|
||||||
|
onNewEvent(eventData);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消订阅事件推送
|
||||||
|
* @param {object} options - 取消订阅选项
|
||||||
|
* @param {string} options.eventType - 事件类型
|
||||||
|
* @param {Function} options.onUnsubscribed - 取消订阅成功的回调函数(可选)
|
||||||
|
*/
|
||||||
|
unsubscribeFromEvents(options = {}) {
|
||||||
|
const {
|
||||||
|
eventType = 'all',
|
||||||
|
onUnsubscribed,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
if (!this.socket || !this.connected) {
|
||||||
|
logger.warn('socketService', 'Cannot unsubscribe: socket not connected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送取消订阅请求
|
||||||
|
this.emit('unsubscribe_events', {
|
||||||
|
event_type: eventType,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听取消订阅确认
|
||||||
|
this.socket.once('unsubscription_confirmed', (data) => {
|
||||||
|
logger.info('socketService', 'Unsubscription confirmed', data);
|
||||||
|
|
||||||
|
// 移除新事件监听器
|
||||||
|
this.socket.off('new_event');
|
||||||
|
|
||||||
|
if (onUnsubscribed) {
|
||||||
|
onUnsubscribed(data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听取消订阅错误
|
||||||
|
this.socket.once('unsubscription_error', (error) => {
|
||||||
|
logger.error('socketService', 'Unsubscription error', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 快捷方法:订阅所有类型的事件
|
||||||
|
* @param {Function} onNewEvent - 收到新事件时的回调函数
|
||||||
|
* @returns {Function} 取消订阅的函数
|
||||||
|
*/
|
||||||
|
subscribeToAllEvents(onNewEvent) {
|
||||||
|
this.subscribeToEvents({
|
||||||
|
eventType: 'all',
|
||||||
|
importance: 'all',
|
||||||
|
onNewEvent,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 返回取消订阅的清理函数
|
||||||
|
return () => {
|
||||||
|
this.unsubscribeFromEvents({ eventType: 'all' });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 快捷方法:订阅特定重要性的事件
|
||||||
|
* @param {string} importance - 重要性级别 ('S' | 'A' | 'B' | 'C')
|
||||||
|
* @param {Function} onNewEvent - 收到新事件时的回调函数
|
||||||
|
* @returns {Function} 取消订阅的函数
|
||||||
|
*/
|
||||||
|
subscribeToImportantEvents(importance, onNewEvent) {
|
||||||
|
this.subscribeToEvents({
|
||||||
|
eventType: 'all',
|
||||||
|
importance,
|
||||||
|
onNewEvent,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 返回取消订阅的清理函数
|
||||||
|
return () => {
|
||||||
|
this.unsubscribeFromEvents({ eventType: 'all' });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 快捷方法:订阅特定类型的事件
|
||||||
|
* @param {string} eventType - 事件类型
|
||||||
|
* @param {Function} onNewEvent - 收到新事件时的回调函数
|
||||||
|
* @returns {Function} 取消订阅的函数
|
||||||
|
*/
|
||||||
|
subscribeToEventType(eventType, onNewEvent) {
|
||||||
|
this.subscribeToEvents({
|
||||||
|
eventType,
|
||||||
|
importance: 'all',
|
||||||
|
onNewEvent,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 返回取消订阅的清理函数
|
||||||
|
return () => {
|
||||||
|
this.unsubscribeFromEvents({ eventType });
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 导出单例
|
// 导出单例
|
||||||
|
|||||||
Reference in New Issue
Block a user