fix(sse): 优化SSE保活机制并添加自动重试逻辑

1. 后端调整心跳间隔为5秒,改用带时间戳的结构化ping心跳替代简单注释
2. 前端重构SSE连接逻辑,添加重试计数器,支持连接失败自动重试
3. 前端仅在首次连接时获取学员画像,避免重试时重复调用
4. 前端新增ping事件监听器处理服务端保活消息
This commit is contained in:
jqb 2026-07-29 14:37:17 +08:00
parent 543af54091
commit 61d559e553
2 changed files with 120 additions and 92 deletions

View File

@ -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<String, Object> 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);

View File

@ -35,6 +35,7 @@ export default function ScriptRecommendStream({ userInfo, customerId: propCustom
const [utterances, setUtterances] = useState<UtteranceItem[]>([])
const [error, setError] = useState('')
const sseRef = useRef<EventSource | null>(null)
const retryCountRef = useRef(0)
const customerId = propCustomerId || localStorage.getItem('current_customer_id') || ''
const staffId = userInfo?.userId || ''
@ -103,12 +104,16 @@ export default function ScriptRecommendStream({ userInfo, customerId: propCustom
setGenerating(true)
setLoading(false)
setError('')
retryCountRef.current = 0
const attempt = () => {
try {
// 单聊才获取画像标签
if (!isGroupChat && customerId) {
const profileRes = await fetch(`/api/v1/intent/profile/${encodeURIComponent(customerId)}?corpId=${corpId}`)
const profileData = await profileRes.json()
// 仅首次连接时获取画像标签,重试时不重复调用
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[] = []
@ -120,6 +125,8 @@ export default function ScriptRecommendStream({ userInfo, customerId: propCustom
if (p.decisionStage) tags.push(p.decisionStage)
setProfileTags(tags)
}
})
.catch(e => console.error('获取学员画像失败:', e))
}
// 构建 SSE URL:群聊传 roomid,单聊传 fromUser+toUser+staffName
@ -135,7 +142,7 @@ export default function ScriptRecommendStream({ userInfo, customerId: propCustom
}
const url = `/api/v1/generation/utterance-stream?${params.toString()}`
console.log('SSE connect:', url)
console.log('SSE connect (attempt', retryCountRef.current + 1, '):', url)
const es = new EventSource(url)
sseRef.current = es
@ -148,6 +155,10 @@ export default function ScriptRecommendStream({ userInfo, customerId: propCustom
} catch (e) { console.error('SSE start error:', e) }
})
es.addEventListener('ping', (_event: MessageEvent) => {
// 服务端心跳保活,无需处理
})
es.addEventListener('item-start', (event: MessageEvent) => {
try {
const data = JSON.parse(event.data)
@ -187,19 +198,30 @@ export default function ScriptRecommendStream({ userInfo, customerId: propCustom
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 || '生成失败')
} catch (e) {
setError('连接中断')
}
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) {
@ -207,6 +229,9 @@ export default function ScriptRecommendStream({ userInfo, customerId: propCustom
setError('网络错误,请重试')
setGenerating(false)
}
}
attempt()
}, [canStartGeneration, isGroupChat, customerId, staffId, chatId, corpId])
useEffect(() => {