优化实时聊天:稳定 WS、按 seq 增量同步,并修复访客评价推送
- 客服/访客 WebSocket 不再因切换会话反复重连,断线自动恢复 - 新增 after_seq 增量拉取,重连与消息空洞时 catch-up - 结束会话同时推送给访客,预览页可弹出评价并支持兜底状态同步
This commit is contained in:
+322
-54
@@ -12,6 +12,8 @@ interface Message {
|
||||
content: string
|
||||
time: string
|
||||
type?: 'text' | 'image'
|
||||
/** 服务端单调序号,用于增量同步 */
|
||||
seq?: number
|
||||
}
|
||||
|
||||
const defaultQuickQuestions = ['查询订单状态', '退换货政策', '配送时效说明']
|
||||
@@ -73,6 +75,22 @@ const VisitorChat = ({
|
||||
const textInputRef = useRef<HTMLInputElement>(null)
|
||||
const socketRef = useRef<WebSocket | null>(null)
|
||||
const lastTypingAt = useRef(0)
|
||||
const sessionIdRef = useRef<number | null>(sessionId)
|
||||
const visitorTokenRef = useRef(visitorToken)
|
||||
const agentNameRef = useRef(agentName)
|
||||
const loadMessagesRef = useRef<(sid?: number, token?: string, opts?: { afterSeq?: number; full?: boolean }) => Promise<void>>(async () => {})
|
||||
const msgsKeyRef = useRef(msgsKey)
|
||||
/** 本地已同步到的最大 seq(从缓存消息初始化) */
|
||||
const lastSeqRef = useRef((() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(msgsKey)
|
||||
if (!saved) return 0
|
||||
const cached = JSON.parse(saved) as Message[]
|
||||
return cached.reduce((acc, m) => Math.max(acc, m.seq || 0), 0)
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
})())
|
||||
|
||||
const insertEmoji = (emoji: string) => {
|
||||
const { next, cursor } = insertAtCursor(textInputRef.current, input, emoji)
|
||||
@@ -86,32 +104,163 @@ const VisitorChat = ({
|
||||
})
|
||||
}
|
||||
|
||||
const loadMessages = useCallback(async (sid?: number, token?: string) => {
|
||||
const s = sid || sessionId
|
||||
const visitorCredential = token || visitorToken || localStorage.getItem(tokenKey) || ''
|
||||
const mapServerMessage = (m: {
|
||||
id: number
|
||||
sender_type?: string
|
||||
content?: string
|
||||
type?: string
|
||||
sent_at?: string
|
||||
seq?: number
|
||||
}): Message => ({
|
||||
id: Number(m.id),
|
||||
sender: m.sender_type === 'agent' ? 'agent' : 'visitor',
|
||||
content: String(m.content ?? ''),
|
||||
type: m.type === 'image' ? 'image' : 'text',
|
||||
seq: typeof m.seq === 'number' ? m.seq : Number(m.seq || 0) || undefined,
|
||||
time: m.sent_at
|
||||
? new Date(m.sent_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||
: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
|
||||
})
|
||||
|
||||
const rememberSeq = (seq?: number) => {
|
||||
if (seq && seq > lastSeqRef.current) lastSeqRef.current = seq
|
||||
}
|
||||
|
||||
const mergeVisitorMessages = (existing: Message[], incoming: Message[]): Message[] => {
|
||||
const map = new Map<number, Message>()
|
||||
for (const item of existing) {
|
||||
if (item.id > 0) map.set(item.id, item)
|
||||
}
|
||||
// 保留未确认的乐观气泡
|
||||
const temps = existing.filter(item => item.id < 0)
|
||||
for (const item of incoming) map.set(item.id, item)
|
||||
const merged = Array.from(map.values())
|
||||
// 去掉已被服务端确认的乐观气泡
|
||||
const remainingTemps = temps.filter(temp =>
|
||||
!incoming.some(srv =>
|
||||
srv.sender === 'visitor'
|
||||
&& srv.content === temp.content
|
||||
&& (srv.type || 'text') === (temp.type || 'text'),
|
||||
),
|
||||
)
|
||||
return [...merged, ...remainingTemps].sort((a, b) => {
|
||||
const as = a.seq || 0
|
||||
const bs = b.seq || 0
|
||||
if (as && bs && as !== bs) return as - bs
|
||||
if (a.id > 0 && b.id > 0) return a.id - b.id
|
||||
return a.id - b.id
|
||||
})
|
||||
}
|
||||
|
||||
const applySessionStatus = useCallback((status?: string, satisfactionScore?: number | null) => {
|
||||
if (!status) return
|
||||
if (status === 'ended' || status === 'archived') {
|
||||
setSessionEnded(true)
|
||||
const alreadyRated = satisfactionScore != null && Number(satisfactionScore) > 0
|
||||
if (alreadyRated) {
|
||||
setRated(true)
|
||||
setShowRating(false)
|
||||
} else {
|
||||
setRated(false)
|
||||
setShowRating(true)
|
||||
}
|
||||
} else if (status === 'active') {
|
||||
setAgentsOnline(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadMessages = useCallback(async (
|
||||
sid?: number,
|
||||
token?: string,
|
||||
opts?: { afterSeq?: number; full?: boolean },
|
||||
) => {
|
||||
const s = sid || sessionIdRef.current || sessionId
|
||||
const visitorCredential = token || visitorTokenRef.current || visitorToken || localStorage.getItem(tokenKey) || ''
|
||||
if (!s || !visitorCredential) return
|
||||
const full = opts?.full === true || (opts?.afterSeq == null && lastSeqRef.current <= 0)
|
||||
const afterSeq = full ? 0 : (opts?.afterSeq ?? lastSeqRef.current)
|
||||
try {
|
||||
const res = await fetch(`/api/widget/messages?session_id=${s}`, {
|
||||
const qs = new URLSearchParams({ session_id: String(s) })
|
||||
if (afterSeq > 0) qs.set('after_seq', String(afterSeq))
|
||||
const res = await fetch(`/api/widget/messages?${qs}`, {
|
||||
headers: { 'X-Visitor-Token': visitorCredential },
|
||||
})
|
||||
const json = await res.json()
|
||||
if (json.code === 0 && json.data && json.data.length > 0) {
|
||||
const msgs: Message[] = json.data.map((m: any) => ({
|
||||
id: m.id,
|
||||
sender: m.sender_type === 'agent' ? 'agent' : 'visitor',
|
||||
content: m.content,
|
||||
type: m.type === 'image' ? 'image' : 'text',
|
||||
time: new Date(m.sent_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
|
||||
}))
|
||||
setMessages(msgs)
|
||||
localStorage.setItem(msgsKey, JSON.stringify(msgs))
|
||||
if (json.code !== 0 || !json.data) return
|
||||
|
||||
// 兼容旧版 data 为数组的响应
|
||||
const payload = json.data
|
||||
const list: any[] = Array.isArray(payload)
|
||||
? payload
|
||||
: (Array.isArray(payload.messages) ? payload.messages : [])
|
||||
const maxSeq = Array.isArray(payload)
|
||||
? list.reduce((acc, m) => Math.max(acc, Number(m.seq || 0)), 0)
|
||||
: Number(payload.max_seq || 0)
|
||||
const hasMore = !Array.isArray(payload) && Boolean(payload.has_more)
|
||||
|
||||
if (!Array.isArray(payload)) {
|
||||
applySessionStatus(payload.session_status, payload.satisfaction_score)
|
||||
}
|
||||
|
||||
if (list.length === 0) {
|
||||
if (maxSeq > 0) rememberSeq(maxSeq)
|
||||
return
|
||||
}
|
||||
|
||||
const mapped = list.map((m: any) => mapServerMessage(m))
|
||||
setMessages(prev => {
|
||||
const next = afterSeq > 0 ? mergeVisitorMessages(prev, mapped) : mapped
|
||||
localStorage.setItem(msgsKeyRef.current, JSON.stringify(next.filter(m => m.id > 0)))
|
||||
return next
|
||||
})
|
||||
const localMax = mapped.reduce((acc, m) => Math.max(acc, m.seq || 0), 0)
|
||||
rememberSeq(Math.max(maxSeq, localMax))
|
||||
|
||||
// 一次未拉完则继续增量
|
||||
if (hasMore && localMax > afterSeq) {
|
||||
await loadMessages(s, visitorCredential, { afterSeq: localMax })
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}, [sessionId, visitorToken, tokenKey, msgsKey])
|
||||
}, [sessionId, visitorToken, tokenKey, applySessionStatus])
|
||||
|
||||
const appendPushedMessage = useCallback((raw: {
|
||||
id?: number
|
||||
sender_type?: string
|
||||
content?: string
|
||||
type?: string
|
||||
sent_at?: string
|
||||
seq?: number
|
||||
}) => {
|
||||
if (!raw?.id) return false
|
||||
const msg = mapServerMessage(raw as {
|
||||
id: number
|
||||
sender_type?: string
|
||||
content?: string
|
||||
type?: string
|
||||
sent_at?: string
|
||||
seq?: number
|
||||
})
|
||||
const known = lastSeqRef.current
|
||||
// seq 空洞 → 走增量拉取补齐
|
||||
if (known > 0 && msg.seq && msg.seq > known + 1) {
|
||||
void loadMessagesRef.current(undefined, undefined, { afterSeq: known })
|
||||
return true
|
||||
}
|
||||
setMessages(prev => {
|
||||
if (prev.some(item => item.id === msg.id)) return prev
|
||||
const next = mergeVisitorMessages(prev, [msg])
|
||||
localStorage.setItem(msgsKeyRef.current, JSON.stringify(next.filter(m => m.id > 0)))
|
||||
return next
|
||||
})
|
||||
if (msg.seq) rememberSeq(msg.seq)
|
||||
return true
|
||||
}, [])
|
||||
|
||||
const initSession = useCallback(async () => {
|
||||
if (sessionId && visitorToken) {
|
||||
await loadMessages(sessionId, visitorToken)
|
||||
// 本地有 seq 游标时增量补齐,否则全量
|
||||
const after = lastSeqRef.current
|
||||
await loadMessages(sessionId, visitorToken, after > 0 ? { afterSeq: after } : { full: true })
|
||||
return
|
||||
}
|
||||
if (initRef.current) return
|
||||
@@ -133,10 +282,14 @@ const VisitorChat = ({
|
||||
if (data.display_name) setDisplayName(data.display_name)
|
||||
if (data.agent_name) setAgentName(data.agent_name)
|
||||
else if (data.agent_nickname) setAgentName(data.agent_nickname)
|
||||
if (data.session_status === 'ended') setSessionEnded(true)
|
||||
if (data.session_status === 'ended' || data.session_status === 'archived') {
|
||||
setSessionEnded(true)
|
||||
setShowRating(true)
|
||||
}
|
||||
localStorage.setItem(storageKey, String(sid))
|
||||
localStorage.setItem(tokenKey, token)
|
||||
await loadMessages(sid, token)
|
||||
lastSeqRef.current = 0
|
||||
await loadMessages(sid, token, { full: true })
|
||||
} else {
|
||||
setSendError(json.message || '初始化会话失败')
|
||||
}
|
||||
@@ -146,59 +299,158 @@ const VisitorChat = ({
|
||||
}
|
||||
}, [sessionId, visitorToken, loadMessages, channelKey, storageKey, tokenKey])
|
||||
|
||||
useEffect(() => {
|
||||
sessionIdRef.current = sessionId
|
||||
visitorTokenRef.current = visitorToken
|
||||
agentNameRef.current = agentName
|
||||
loadMessagesRef.current = loadMessages
|
||||
msgsKeyRef.current = msgsKey
|
||||
}, [sessionId, visitorToken, agentName, loadMessages, msgsKey])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) initSession()
|
||||
}, [open, initSession])
|
||||
|
||||
// 兜底轮询:按 after_seq 增量对齐
|
||||
useEffect(() => {
|
||||
if (sessionId && visitorToken && open) {
|
||||
pollRef.current = window.setInterval(() => loadMessages(), 3000)
|
||||
pollRef.current = window.setInterval(() => {
|
||||
const after = lastSeqRef.current
|
||||
void loadMessages(undefined, undefined, after > 0 ? { afterSeq: after } : { full: true })
|
||||
}, 8000)
|
||||
return () => { if (pollRef.current) clearInterval(pollRef.current) }
|
||||
}
|
||||
}, [sessionId, visitorToken, open, loadMessages])
|
||||
|
||||
// 访客 WS:session / token 就绪后建连;断线指数退避重连;消息直接追加
|
||||
useEffect(() => {
|
||||
if (!sessionId || !visitorToken || !open) return
|
||||
const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const socket = new WebSocket(
|
||||
`${scheme}//${window.location.host}/api/widget/ws?session_id=${sessionId}`,
|
||||
['kefu-visitor-v1', visitorToken],
|
||||
)
|
||||
socketRef.current = socket
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data)
|
||||
if (payload.session_id === sessionId && payload.type === 'typing') {
|
||||
if (payload.data?.from && payload.data.from !== 'agent') return
|
||||
setAgentTyping(true)
|
||||
if (typingTimerRef.current) clearTimeout(typingTimerRef.current)
|
||||
typingTimerRef.current = window.setTimeout(() => setAgentTyping(false), 1800)
|
||||
return
|
||||
}
|
||||
if (payload.session_id === sessionId && (payload.type === 'message' || payload.type === 'session_updated')) {
|
||||
if (payload.type === 'session_updated') {
|
||||
if (payload.data?.status === 'ended') {
|
||||
setSessionEnded(true)
|
||||
setShowRating(true)
|
||||
}
|
||||
if (payload.data?.status === 'active') {
|
||||
setAgentsOnline(true)
|
||||
setAgentName(payload.data?.agent_name || agentName)
|
||||
}
|
||||
}
|
||||
setAgentTyping(false)
|
||||
loadMessages(sessionId, visitorToken)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
let disposed = false
|
||||
let reconnectTimer: number | null = null
|
||||
let attempt = 0
|
||||
|
||||
const clearReconnect = () => {
|
||||
if (reconnectTimer != null) {
|
||||
window.clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const connect = () => {
|
||||
if (disposed) return
|
||||
const sid = sessionIdRef.current
|
||||
const token = visitorTokenRef.current
|
||||
if (!sid || !token) return
|
||||
|
||||
const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const socket = new WebSocket(
|
||||
`${scheme}//${window.location.host}/api/widget/ws?session_id=${sid}`,
|
||||
['kefu-visitor-v1', token],
|
||||
)
|
||||
socketRef.current = socket
|
||||
|
||||
socket.onopen = () => {
|
||||
attempt = 0
|
||||
// 重连后按 seq 增量 catch-up
|
||||
const after = lastSeqRef.current
|
||||
void loadMessagesRef.current(sid, token, after > 0 ? { afterSeq: after } : { full: true })
|
||||
}
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data) as {
|
||||
type?: string
|
||||
session_id?: number | string
|
||||
data?: {
|
||||
id?: number
|
||||
sender_type?: string
|
||||
content?: string
|
||||
type?: string
|
||||
sent_at?: string
|
||||
from?: string
|
||||
status?: string
|
||||
agent_name?: string
|
||||
}
|
||||
}
|
||||
const eventSid = Number(payload.session_id)
|
||||
const currentSid = Number(sessionIdRef.current)
|
||||
if (!Number.isFinite(eventSid) || eventSid !== currentSid) return
|
||||
|
||||
if (payload.type === 'typing') {
|
||||
if (payload.data?.from && payload.data.from !== 'agent') return
|
||||
setAgentTyping(true)
|
||||
if (typingTimerRef.current) clearTimeout(typingTimerRef.current)
|
||||
typingTimerRef.current = window.setTimeout(() => setAgentTyping(false), 1800)
|
||||
return
|
||||
}
|
||||
|
||||
if (payload.type === 'session_updated') {
|
||||
const status = payload.data?.status
|
||||
if (status === 'ended' || status === 'archived') {
|
||||
setSessionEnded(true)
|
||||
setShowRating(true)
|
||||
setAgentTyping(false)
|
||||
return
|
||||
}
|
||||
if (status === 'active') {
|
||||
setAgentsOnline(true)
|
||||
setAgentName(payload.data?.agent_name || agentNameRef.current)
|
||||
}
|
||||
setAgentTyping(false)
|
||||
const after = lastSeqRef.current
|
||||
void loadMessagesRef.current(
|
||||
currentSid,
|
||||
visitorTokenRef.current,
|
||||
after > 0 ? { afterSeq: after } : { full: true },
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (payload.type === 'message') {
|
||||
setAgentTyping(false)
|
||||
const ok = appendPushedMessage(payload.data || {})
|
||||
if (!ok) {
|
||||
const after = lastSeqRef.current
|
||||
void loadMessagesRef.current(
|
||||
currentSid,
|
||||
visitorTokenRef.current,
|
||||
after > 0 ? { afterSeq: after } : { full: true },
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
socket.onclose = () => {
|
||||
if (socketRef.current === socket) socketRef.current = null
|
||||
if (disposed) return
|
||||
const delay = Math.min(1000 * (2 ** attempt), 15000)
|
||||
attempt += 1
|
||||
clearReconnect()
|
||||
reconnectTimer = window.setTimeout(connect, delay)
|
||||
}
|
||||
|
||||
socket.onerror = () => {
|
||||
try { socket.close() } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
connect()
|
||||
|
||||
return () => {
|
||||
socket.close()
|
||||
disposed = true
|
||||
clearReconnect()
|
||||
const sock = socketRef.current
|
||||
socketRef.current = null
|
||||
if (sock) {
|
||||
sock.onclose = null
|
||||
sock.close()
|
||||
}
|
||||
if (typingTimerRef.current) clearTimeout(typingTimerRef.current)
|
||||
}
|
||||
}, [sessionId, visitorToken, open, loadMessages, agentName])
|
||||
}, [sessionId, visitorToken, open, appendPushedMessage])
|
||||
|
||||
useEffect(() => {
|
||||
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
@@ -382,9 +634,12 @@ const VisitorChat = ({
|
||||
if (json.code === 0) {
|
||||
setRated(true)
|
||||
setShowRating(false)
|
||||
setSendError('')
|
||||
} else {
|
||||
setSendError(json.message || '评价提交失败')
|
||||
}
|
||||
} catch {
|
||||
// keep modal
|
||||
setSendError('评价提交失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -520,6 +775,19 @@ const VisitorChat = ({
|
||||
<footer className="shrink-0 px-4 py-3 bg-white border-t border-neutral-200">
|
||||
{sendError && <div className="mb-2 text-xs text-red-500">{sendError}</div>}
|
||||
|
||||
{sessionEnded && !rated && (
|
||||
<div className="mx-4 mb-2 rounded-lg bg-amber-50 border border-amber-100 px-3 py-2 flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-amber-800">会话已结束,欢迎为本次服务打分</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRating(true)}
|
||||
className="shrink-0 text-xs font-medium text-amber-900 bg-amber-100 hover:bg-amber-200 border-0 rounded-md px-2 py-1 cursor-pointer"
|
||||
>
|
||||
去评价
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!agentsOnline && !sessionEnded && (
|
||||
<div className="mb-3 p-3 rounded-lg border border-amber-100 bg-amber-50 space-y-2">
|
||||
{leaveSent ? (
|
||||
|
||||
Reference in New Issue
Block a user