|
|
@@ -1,14 +1,46 @@
|
|
|
/**
|
|
|
* 消息 WebSocket:登录后连接,收到新消息时更新 chatStore(消息列表、本地未读、会话预览),不整表刷新
|
|
|
+ * 断线后自动重连(指数退避);主动 disconnect 时不重连
|
|
|
*/
|
|
|
import { getToken, normalizeMessageContentType } from '../utils/api'
|
|
|
import { chatStore } from '../store/chat'
|
|
|
|
|
|
const WS_BASE = 'wss://api.hnyunzhu.com/api/v1/ws/messages'
|
|
|
const HEARTBEAT_INTERVAL = 30000
|
|
|
+const INITIAL_RECONNECT_DELAY = 1000
|
|
|
+const MAX_RECONNECT_DELAY = 30000
|
|
|
+
|
|
|
let socketTask = null
|
|
|
let fetchContactsRef = null
|
|
|
let heartbeatTimer = null
|
|
|
+/** 仅 uni.onSocket* 注册一次,避免每次 connect 叠加监听 */
|
|
|
+let socketListenersAttached = false
|
|
|
+let intentionalClose = false
|
|
|
+let reconnectTimer = null
|
|
|
+let reconnectAttempt = 0
|
|
|
+
|
|
|
+function clearReconnectTimer() {
|
|
|
+ if (reconnectTimer) {
|
|
|
+ clearTimeout(reconnectTimer)
|
|
|
+ reconnectTimer = null
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function scheduleReconnect() {
|
|
|
+ if (intentionalClose) return
|
|
|
+ if (!getToken()) return
|
|
|
+ clearReconnectTimer()
|
|
|
+ const delay = Math.min(
|
|
|
+ INITIAL_RECONNECT_DELAY * Math.pow(2, reconnectAttempt),
|
|
|
+ MAX_RECONNECT_DELAY
|
|
|
+ )
|
|
|
+ reconnectAttempt += 1
|
|
|
+ console.log(`[WS] reconnect scheduled in ${delay}ms (attempt ${reconnectAttempt})`)
|
|
|
+ reconnectTimer = setTimeout(() => {
|
|
|
+ reconnectTimer = null
|
|
|
+ tryConnect()
|
|
|
+ }, delay)
|
|
|
+}
|
|
|
|
|
|
function normalizeWsMessage(raw) {
|
|
|
const m = raw.message || raw
|
|
|
@@ -50,123 +82,157 @@ function getContactIdFromMessage(msg, currentUserId) {
|
|
|
return receiverId
|
|
|
}
|
|
|
|
|
|
-export function useWebSocket(fetchContacts) {
|
|
|
- fetchContactsRef = fetchContacts
|
|
|
+function attachSocketListenersOnce() {
|
|
|
+ if (socketListenersAttached) return
|
|
|
+ socketListenersAttached = true
|
|
|
|
|
|
- function connect() {
|
|
|
- const token = getToken()
|
|
|
- if (!token || socketTask) return
|
|
|
- const url = `${WS_BASE}?token=${encodeURIComponent(token)}`
|
|
|
- socketTask = uni.connectSocket({
|
|
|
- url,
|
|
|
- success: () => {}
|
|
|
- })
|
|
|
- uni.onSocketOpen(() => {
|
|
|
- console.log('[WS] messages connected')
|
|
|
- heartbeatTimer = setInterval(() => {
|
|
|
- try {
|
|
|
- if (socketTask) uni.sendSocketMessage({ data: 'ping' })
|
|
|
- } catch (e) {}
|
|
|
- }, HEARTBEAT_INTERVAL)
|
|
|
- })
|
|
|
- uni.onSocketMessage((res) => {
|
|
|
+ uni.onSocketOpen(() => {
|
|
|
+ reconnectAttempt = 0
|
|
|
+ clearReconnectTimer()
|
|
|
+ console.log('[WS] messages connected')
|
|
|
+ heartbeatTimer = setInterval(() => {
|
|
|
try {
|
|
|
- // 心跳:服务端回复 pong
|
|
|
- if (res.data === 'pong') return
|
|
|
- const data = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
|
|
|
- // 文档格式:{ type: 'NEW_MESSAGE', data: { id, sender_id, receiver_id, ... } }
|
|
|
- const msg = data.type === 'NEW_MESSAGE' ? data.data : (data.message ?? data)
|
|
|
- if (!msg) return
|
|
|
- const normalized = normalizeWsMessage({ message: msg })
|
|
|
- // 需要当前用户 id 判断会话方:若后端推送里带 current_user_id 用那个,否则用 receiver_id 判断
|
|
|
- const currentUserId = data.current_user_id ?? normalized.receiverId
|
|
|
- const contactId = getContactIdFromMessage(msg, currentUserId)
|
|
|
- if (contactId != null) {
|
|
|
- const cid = String(contactId)
|
|
|
- const list = chatStore.messages[cid] || []
|
|
|
- const hasId = normalized.id && list.some((m) => String(m.id) === String(normalized.id))
|
|
|
- if (hasId) return
|
|
|
- chatStore.appendMessage(cid, normalized)
|
|
|
- // 前台 & 后台消息通知:若当前不在该会话,则给出提示
|
|
|
- const isActive = String(chatStore.activeContactId || '') === String(contactId)
|
|
|
- // 别人发给我且不在当前会话:本地未读 +1,并更新底部消息 Tab 角标
|
|
|
- if (!normalized.isMe && !isActive) {
|
|
|
- chatStore.incrementUnread(cid)
|
|
|
- chatStore.updateTabBarUnreadBadge()
|
|
|
- }
|
|
|
- if (!isActive) {
|
|
|
- const contact = (chatStore.contacts || []).find(
|
|
|
- (c) => String(c.user_id || c.id) === String(contactId)
|
|
|
- )
|
|
|
- const title = (contact && contact.title) || '新消息'
|
|
|
- let body = ''
|
|
|
- if (normalized.contentType === 'TEXT') {
|
|
|
- body = normalized.content ? String(normalized.content).slice(0, 50) : ''
|
|
|
- } else if (normalized.contentType === 'IMAGE') {
|
|
|
- body = '[图片]'
|
|
|
- } else if (normalized.contentType === 'VIDEO') {
|
|
|
- body = '[视频]'
|
|
|
- } else if (normalized.contentType === 'USER_NOTIFICATION') {
|
|
|
- body = normalized.title
|
|
|
- ? String(normalized.title).slice(0, 50)
|
|
|
- : normalized.content
|
|
|
- ? String(normalized.content).slice(0, 50)
|
|
|
- : '[通知]'
|
|
|
- } else {
|
|
|
- body = normalized.title || '[文件]'
|
|
|
- }
|
|
|
- // #ifdef APP-PLUS
|
|
|
- try {
|
|
|
- plus.push.createMessage(body || '您有一条新消息', { contactId }, { title })
|
|
|
- } catch (e) {
|
|
|
- // 兜底为 Toast
|
|
|
- uni.showToast({ title: `${title}: ${body || '新消息'}`, icon: 'none' })
|
|
|
- }
|
|
|
- // #endif
|
|
|
- // #ifndef APP-PLUS
|
|
|
- uni.showToast({ title: `${title}: ${body || '新消息'}`, icon: 'none' })
|
|
|
- // #endif
|
|
|
- }
|
|
|
- // 只更新该会话在列表中的最后一条预览与时间,不整表刷新
|
|
|
- let preview = ''
|
|
|
+ if (socketTask) uni.sendSocketMessage({ data: 'ping' })
|
|
|
+ } catch (e) {}
|
|
|
+ }, HEARTBEAT_INTERVAL)
|
|
|
+ })
|
|
|
+
|
|
|
+ uni.onSocketMessage((res) => {
|
|
|
+ try {
|
|
|
+ // 心跳:服务端回复 pong
|
|
|
+ if (res.data === 'pong') return
|
|
|
+ const data = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
|
|
|
+ // 文档格式:{ type: 'NEW_MESSAGE', data: { id, sender_id, receiver_id, ... } }
|
|
|
+ const msg = data.type === 'NEW_MESSAGE' ? data.data : (data.message ?? data)
|
|
|
+ if (!msg) return
|
|
|
+ const normalized = normalizeWsMessage({ message: msg })
|
|
|
+ // 需要当前用户 id 判断会话方:若后端推送里带 current_user_id 用那个,否则用 receiver_id 判断
|
|
|
+ const currentUserId = data.current_user_id ?? normalized.receiverId
|
|
|
+ const contactId = getContactIdFromMessage(msg, currentUserId)
|
|
|
+ if (contactId != null) {
|
|
|
+ const cid = String(contactId)
|
|
|
+ const list = chatStore.messages[cid] || []
|
|
|
+ const hasId = normalized.id && list.some((m) => String(m.id) === String(normalized.id))
|
|
|
+ if (hasId) return
|
|
|
+ chatStore.appendMessage(cid, normalized)
|
|
|
+ // 前台 & 后台消息通知:若当前不在该会话,则给出提示
|
|
|
+ const isActive = String(chatStore.activeContactId || '') === String(contactId)
|
|
|
+ // 别人发给我且不在当前会话:本地未读 +1,并更新底部消息 Tab 角标
|
|
|
+ if (!normalized.isMe && !isActive) {
|
|
|
+ chatStore.incrementUnread(cid)
|
|
|
+ chatStore.updateTabBarUnreadBadge()
|
|
|
+ }
|
|
|
+ if (!isActive) {
|
|
|
+ const contact = (chatStore.contacts || []).find(
|
|
|
+ (c) => String(c.user_id || c.id) === String(contactId)
|
|
|
+ )
|
|
|
+ const title = (contact && contact.title) || '新消息'
|
|
|
+ let body = ''
|
|
|
if (normalized.contentType === 'TEXT') {
|
|
|
- preview = normalized.content ? String(normalized.content).slice(0, 50) : ''
|
|
|
- } else if (normalized.contentType === 'IMAGE') preview = '[图片]'
|
|
|
- else if (normalized.contentType === 'VIDEO') preview = '[视频]'
|
|
|
- else if (normalized.contentType === 'USER_NOTIFICATION') {
|
|
|
- preview = normalized.title
|
|
|
+ body = normalized.content ? String(normalized.content).slice(0, 50) : ''
|
|
|
+ } else if (normalized.contentType === 'IMAGE') {
|
|
|
+ body = '[图片]'
|
|
|
+ } else if (normalized.contentType === 'VIDEO') {
|
|
|
+ body = '[视频]'
|
|
|
+ } else if (normalized.contentType === 'USER_NOTIFICATION') {
|
|
|
+ body = normalized.title
|
|
|
? String(normalized.title).slice(0, 50)
|
|
|
: normalized.content
|
|
|
? String(normalized.content).slice(0, 50)
|
|
|
: '[通知]'
|
|
|
- } else preview = normalized.title || '[文件]'
|
|
|
- chatStore.updateContactPreview(cid, { lastMessage: preview, time: normalized.createdAt })
|
|
|
+ } else {
|
|
|
+ body = normalized.title || '[文件]'
|
|
|
+ }
|
|
|
+ // #ifdef APP-PLUS
|
|
|
+ try {
|
|
|
+ plus.push.createMessage(body || '您有一条新消息', { contactId }, { title })
|
|
|
+ } catch (e) {
|
|
|
+ // 兜底为 Toast
|
|
|
+ uni.showToast({ title: `${title}: ${body || '新消息'}`, icon: 'none' })
|
|
|
+ }
|
|
|
+ // #endif
|
|
|
+ // #ifndef APP-PLUS
|
|
|
+ uni.showToast({ title: `${title}: ${body || '新消息'}`, icon: 'none' })
|
|
|
+ // #endif
|
|
|
}
|
|
|
- } catch (e) {
|
|
|
- console.warn('[WS] parse message error', e)
|
|
|
- }
|
|
|
- })
|
|
|
- uni.onSocketError((err) => {
|
|
|
- console.warn('[WS] error', err)
|
|
|
- })
|
|
|
- uni.onSocketClose(() => {
|
|
|
- if (heartbeatTimer) {
|
|
|
- clearInterval(heartbeatTimer)
|
|
|
- heartbeatTimer = null
|
|
|
+ // 只更新该会话在列表中的最后一条预览与时间,不整表刷新
|
|
|
+ let preview = ''
|
|
|
+ if (normalized.contentType === 'TEXT') {
|
|
|
+ preview = normalized.content ? String(normalized.content).slice(0, 50) : ''
|
|
|
+ } else if (normalized.contentType === 'IMAGE') preview = '[图片]'
|
|
|
+ else if (normalized.contentType === 'VIDEO') preview = '[视频]'
|
|
|
+ else if (normalized.contentType === 'USER_NOTIFICATION') {
|
|
|
+ preview = normalized.title
|
|
|
+ ? String(normalized.title).slice(0, 50)
|
|
|
+ : normalized.content
|
|
|
+ ? String(normalized.content).slice(0, 50)
|
|
|
+ : '[通知]'
|
|
|
+ } else preview = normalized.title || '[文件]'
|
|
|
+ chatStore.updateContactPreview(cid, { lastMessage: preview, time: normalized.createdAt })
|
|
|
}
|
|
|
- socketTask = null
|
|
|
- })
|
|
|
- }
|
|
|
+ } catch (e) {
|
|
|
+ console.warn('[WS] parse message error', e)
|
|
|
+ }
|
|
|
+ })
|
|
|
|
|
|
- function disconnect() {
|
|
|
+ uni.onSocketError((err) => {
|
|
|
+ console.warn('[WS] error', err)
|
|
|
+ })
|
|
|
+
|
|
|
+ uni.onSocketClose(() => {
|
|
|
if (heartbeatTimer) {
|
|
|
clearInterval(heartbeatTimer)
|
|
|
heartbeatTimer = null
|
|
|
}
|
|
|
- if (socketTask) {
|
|
|
- uni.closeSocket()
|
|
|
- socketTask = null
|
|
|
+ socketTask = null
|
|
|
+ if (!intentionalClose && getToken()) {
|
|
|
+ scheduleReconnect()
|
|
|
}
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function tryConnect() {
|
|
|
+ const token = getToken()
|
|
|
+ if (!token || socketTask) return
|
|
|
+ intentionalClose = false
|
|
|
+ attachSocketListenersOnce()
|
|
|
+ const url = `${WS_BASE}?token=${encodeURIComponent(token)}`
|
|
|
+ socketTask = uni.connectSocket({
|
|
|
+ url,
|
|
|
+ success: () => {}
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function performDisconnect() {
|
|
|
+ intentionalClose = true
|
|
|
+ clearReconnectTimer()
|
|
|
+ reconnectAttempt = 0
|
|
|
+ if (heartbeatTimer) {
|
|
|
+ clearInterval(heartbeatTimer)
|
|
|
+ heartbeatTimer = null
|
|
|
+ }
|
|
|
+ if (socketTask) {
|
|
|
+ uni.closeSocket()
|
|
|
+ socketTask = null
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 登出 / 清空 token 时关闭连接并禁止自动重连(供 setToken 等统一调用)
|
|
|
+ */
|
|
|
+export function disconnectWebSocket() {
|
|
|
+ performDisconnect()
|
|
|
+}
|
|
|
+
|
|
|
+export function useWebSocket(fetchContacts) {
|
|
|
+ fetchContactsRef = fetchContacts
|
|
|
+
|
|
|
+ function connect() {
|
|
|
+ tryConnect()
|
|
|
+ }
|
|
|
+
|
|
|
+ function disconnect() {
|
|
|
+ performDisconnect()
|
|
|
}
|
|
|
|
|
|
return { connect, disconnect }
|