From 61d559e55308db1ba27a3da552004c86705891c9 Mon Sep 17 00:00:00 2001 From: jqb Date: Wed, 29 Jul 2026 14:37:17 +0800 Subject: [PATCH] =?UTF-8?q?fix(sse):=20=E4=BC=98=E5=8C=96SSE=E4=BF=9D?= =?UTF-8?q?=E6=B4=BB=E6=9C=BA=E5=88=B6=E5=B9=B6=E6=B7=BB=E5=8A=A0=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E9=87=8D=E8=AF=95=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 后端调整心跳间隔为5秒,改用带时间戳的结构化ping心跳替代简单注释 2. 前端重构SSE连接逻辑,添加重试计数器,支持连接失败自动重试 3. 前端仅在首次连接时获取学员画像,避免重试时重复调用 4. 前端新增ping事件监听器处理服务端保活消息 --- .../controller/GenerationController.java | 9 +- .../src/components/ScriptRecommendStream.tsx | 203 ++++++++++-------- 2 files changed, 120 insertions(+), 92 deletions(-) diff --git a/backend/generation-service/src/main/java/com/artedu/generation/controller/GenerationController.java b/backend/generation-service/src/main/java/com/artedu/generation/controller/GenerationController.java index 1ad45d9..ac75513 100644 --- a/backend/generation-service/src/main/java/com/artedu/generation/controller/GenerationController.java +++ b/backend/generation-service/src/main/java/com/artedu/generation/controller/GenerationController.java @@ -350,14 +350,17 @@ public class GenerationController { emitter.onCompletion(() -> generationFuture.cancel(true)); emitter.onTimeout(() -> generationFuture.cancel(true)); - // 等待生成结果,每 10s 发送一次心跳,防止 nginx/移动网络因 30s 空闲超时而断开 + // 等待生成结果,每 5s 发送一次心跳,防止 nginx/移动网络因空闲超时而断开 while (!generationFuture.isDone()) { try { - generationFuture.get(10, TimeUnit.SECONDS); + generationFuture.get(5, TimeUnit.SECONDS); break; } catch (TimeoutException e) { try { - emitter.send(SseEmitter.event().comment("keep-alive")); + Map ping = new HashMap<>(); + ping.put("type", "ping"); + ping.put("time", System.currentTimeMillis()); + emitter.send(SseEmitter.event().name("ping").data(ping)); } catch (IOException ioe) { log.warn("SSE 心跳发送失败,连接已断开"); generationFuture.cancel(true); diff --git a/frontend/sidebar/src/components/ScriptRecommendStream.tsx b/frontend/sidebar/src/components/ScriptRecommendStream.tsx index 02706fc..d0460ec 100644 --- a/frontend/sidebar/src/components/ScriptRecommendStream.tsx +++ b/frontend/sidebar/src/components/ScriptRecommendStream.tsx @@ -35,6 +35,7 @@ export default function ScriptRecommendStream({ userInfo, customerId: propCustom const [utterances, setUtterances] = useState([]) const [error, setError] = useState('') const sseRef = useRef(null) + const retryCountRef = useRef(0) const customerId = propCustomerId || localStorage.getItem('current_customer_id') || '' const staffId = userInfo?.userId || '' @@ -103,110 +104,134 @@ export default function ScriptRecommendStream({ userInfo, customerId: propCustom setGenerating(true) setLoading(false) + setError('') + retryCountRef.current = 0 - try { - // 单聊才获取画像标签 - if (!isGroupChat && customerId) { - const profileRes = await fetch(`/api/v1/intent/profile/${encodeURIComponent(customerId)}?corpId=${corpId}`) - const profileData = await profileRes.json() - if (profileData.code === 0 && profileData.data) { - const p = profileData.data - const tags: string[] = [] - if (p.currentOccupation) tags.push(p.currentOccupation) - if (p.studentType) tags.push(p.studentType) - if (p.skillLevel) tags.push(p.skillLevel) - if (p.intentLevel) tags.push(p.intentLevel) - if (p.concernFocus) tags.push(p.concernFocus) - if (p.decisionStage) tags.push(p.decisionStage) - setProfileTags(tags) + const attempt = () => { + try { + // 仅首次连接时获取画像标签,重试时不重复调用 + if (retryCountRef.current === 0 && !isGroupChat && customerId) { + fetch(`/api/v1/intent/profile/${encodeURIComponent(customerId)}?corpId=${corpId}`) + .then(res => res.json()) + .then(profileData => { + if (profileData.code === 0 && profileData.data) { + const p = profileData.data + const tags: string[] = [] + if (p.currentOccupation) tags.push(p.currentOccupation) + if (p.studentType) tags.push(p.studentType) + if (p.skillLevel) tags.push(p.skillLevel) + if (p.intentLevel) tags.push(p.intentLevel) + if (p.concernFocus) tags.push(p.concernFocus) + if (p.decisionStage) tags.push(p.decisionStage) + setProfileTags(tags) + } + }) + .catch(e => console.error('获取学员画像失败:', e)) } - } - // 构建 SSE URL:群聊传 roomid,单聊传 fromUser+toUser+staffName - const params = new URLSearchParams() - if (isGroupChat && chatId) { - params.append('roomid', chatId) - } else if (customerId && staffId) { - params.append('fromUser', customerId) - params.append('toUser', staffId) - } - if (userInfo?.userName) { - params.append('staffName', userInfo.userName) - } + // 构建 SSE URL:群聊传 roomid,单聊传 fromUser+toUser+staffName + const params = new URLSearchParams() + if (isGroupChat && chatId) { + params.append('roomid', chatId) + } else if (customerId && staffId) { + params.append('fromUser', customerId) + params.append('toUser', staffId) + } + if (userInfo?.userName) { + params.append('staffName', userInfo.userName) + } - const url = `/api/v1/generation/utterance-stream?${params.toString()}` - console.log('SSE connect:', url) - const es = new EventSource(url) - sseRef.current = es + const url = `/api/v1/generation/utterance-stream?${params.toString()}` + console.log('SSE connect (attempt', retryCountRef.current + 1, '):', url) + const es = new EventSource(url) + sseRef.current = es - setUtterances(STYLES.map(s => ({ styleId: s.id, text: '', isComplete: false, isTyping: false }))) + setUtterances(STYLES.map(s => ({ styleId: s.id, text: '', isComplete: false, isTyping: false }))) - es.addEventListener('start', (event: MessageEvent) => { - try { - const data = JSON.parse(event.data) - if (data.type === 'start') { /* 可处理 styles 信息 */ } - } catch (e) { console.error('SSE start error:', e) } - }) + es.addEventListener('start', (event: MessageEvent) => { + try { + const data = JSON.parse(event.data) + if (data.type === 'start') { /* 可处理 styles 信息 */ } + } catch (e) { console.error('SSE start error:', e) } + }) - es.addEventListener('item-start', (event: MessageEvent) => { - try { - const data = JSON.parse(event.data) - if (data.type === 'item-start') { - setUtterances(prev => prev.map(u => - u.styleId === data.styleId ? { ...u, isTyping: true } : u - )) - } - } catch (e) { console.error('SSE item-start error:', e) } - }) + es.addEventListener('ping', (_event: MessageEvent) => { + // 服务端心跳保活,无需处理 + }) - es.addEventListener('chunk', (event: MessageEvent) => { - try { - const data = JSON.parse(event.data) - if (data.type === 'chunk') { - setUtterances(prev => prev.map(u => - u.styleId === data.styleId ? { ...u, text: u.text + data.text } : u - )) - } - } catch (e) { console.error('SSE chunk error:', e) } - }) + es.addEventListener('item-start', (event: MessageEvent) => { + try { + const data = JSON.parse(event.data) + if (data.type === 'item-start') { + setUtterances(prev => prev.map(u => + u.styleId === data.styleId ? { ...u, isTyping: true } : u + )) + } + } catch (e) { console.error('SSE item-start error:', e) } + }) - es.addEventListener('complete', (event: MessageEvent) => { - try { - const data = JSON.parse(event.data) - if (data.type === 'complete') { - setUtterances(prev => prev.map(u => - u.styleId === data.styleId ? { ...u, text: data.fullText, isComplete: true, isTyping: false } : u - )) - } - } catch (e) { console.error('SSE complete error:', e) } - }) + es.addEventListener('chunk', (event: MessageEvent) => { + try { + const data = JSON.parse(event.data) + if (data.type === 'chunk') { + setUtterances(prev => prev.map(u => + u.styleId === data.styleId ? { ...u, text: u.text + data.text } : u + )) + } + } catch (e) { console.error('SSE chunk error:', e) } + }) - es.addEventListener('done', (event: MessageEvent) => { - try { - const data = JSON.parse(event.data) - if (data.type === 'done') { + es.addEventListener('complete', (event: MessageEvent) => { + try { + const data = JSON.parse(event.data) + if (data.type === 'complete') { + setUtterances(prev => prev.map(u => + u.styleId === data.styleId ? { ...u, text: data.fullText, isComplete: true, isTyping: false } : u + )) + } + } catch (e) { console.error('SSE complete error:', e) } + }) + + es.addEventListener('done', (event: MessageEvent) => { + try { + const data = JSON.parse(event.data) + if (data.type === 'done') { + setGenerating(false) + es.close() + retryCountRef.current = 0 + } + } catch (e) { console.error('SSE done error:', e) } + }) + + es.addEventListener('error', (event: MessageEvent) => { + es.close() + try { + const data = JSON.parse(event.data) + setError(data.message || '生成失败') setGenerating(false) - es.close() + retryCountRef.current = 0 + } catch (e) { + // 连接类错误自动重试一次,避免网络抖动导致用户看到"连接中断" + if (retryCountRef.current < 2) { + retryCountRef.current += 1 + console.warn(`SSE 连接中断,第 ${retryCountRef.current} 次重试...`) + setTimeout(attempt, 1000) + } else { + setError('连接中断') + setGenerating(false) + retryCountRef.current = 0 + } } - } catch (e) { console.error('SSE done error:', e) } - }) + }) - es.addEventListener('error', (event: MessageEvent) => { - try { - const data = JSON.parse(event.data) - setError(data.message || '生成失败') - } catch (e) { - setError('连接中断') - } + } catch (e) { + console.error('生成失败:', e) + setError('网络错误,请重试') setGenerating(false) - es.close() - }) - - } catch (e) { - console.error('生成失败:', e) - setError('网络错误,请重试') - setGenerating(false) + } } + + attempt() }, [canStartGeneration, isGroupChat, customerId, staffId, chatId, corpId]) useEffect(() => {