useWebSocket.js 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  1. /**
  2. * 消息 WebSocket:登录后连接,收到新消息时更新 chatStore(消息列表、会话预览),不整表刷新
  3. * 未读角标由 GET /messages/unread-count 刷新,不在此本地累加
  4. * 断线后自动重连(指数退避);主动 disconnect 时不重连
  5. */
  6. import { getToken, markHistoryReadAll, normalizeMessageContentType } from '../utils/api'
  7. import { chatStore } from '../store/chat'
  8. import { fetchContactsList } from './useContacts'
  9. import { fetchUnreadCountAndUpdateTabBar } from './useUnreadBadge'
  10. const WS_BASE = 'wss://api.hnyunzhu.com/api/v1/ws/messages'
  11. const HEARTBEAT_INTERVAL = 30000
  12. const INITIAL_RECONNECT_DELAY = 1000
  13. const MAX_RECONNECT_DELAY = 30000
  14. let socketTask = null
  15. let heartbeatTimer = null
  16. /** 仅 uni.onSocket* 注册一次,避免每次 connect 叠加监听 */
  17. let socketListenersAttached = false
  18. let intentionalClose = false
  19. let reconnectTimer = null
  20. let reconnectAttempt = 0
  21. function clearReconnectTimer() {
  22. if (reconnectTimer) {
  23. clearTimeout(reconnectTimer)
  24. reconnectTimer = null
  25. }
  26. }
  27. /** WS 心跳成功或收到推送后:拉总未读 + 会话列表 */
  28. function syncInboxFromServer() {
  29. if (!getToken()) return
  30. Promise.all([fetchUnreadCountAndUpdateTabBar(), fetchContactsList()]).catch(() => {})
  31. }
  32. function scheduleReconnect() {
  33. if (intentionalClose) return
  34. if (!getToken()) return
  35. clearReconnectTimer()
  36. const delay = Math.min(
  37. INITIAL_RECONNECT_DELAY * Math.pow(2, reconnectAttempt),
  38. MAX_RECONNECT_DELAY
  39. )
  40. reconnectAttempt += 1
  41. console.log(`[WS] reconnect scheduled in ${delay}ms (attempt ${reconnectAttempt})`)
  42. reconnectTimer = setTimeout(() => {
  43. reconnectTimer = null
  44. tryConnect()
  45. }, delay)
  46. }
  47. function normalizeWsMessage(raw) {
  48. const m = raw.message || raw
  49. const type = m.type ?? 'MESSAGE'
  50. // 通知类消息:应用发给用户,当前用户始终为接收方
  51. const isMe = type === 'NOTIFICATION' ? false : (m.is_me ?? m.isMe)
  52. const rawText = m.content ?? m.text ?? ''
  53. const urlField = m.url ?? m.file_url ?? m.content_url
  54. const content =
  55. urlField && /^https?:\/\//i.test(String(urlField)) ? String(urlField) : String(rawText)
  56. return {
  57. id: String(m.id),
  58. type,
  59. senderId: m.sender_id ?? m.senderId,
  60. receiverId: m.receiver_id ?? m.receiverId,
  61. content,
  62. contentType: normalizeMessageContentType(m.content_type ?? m.contentType ?? 'TEXT'),
  63. title: m.title,
  64. createdAt: m.created_at ?? m.createdAt,
  65. isMe,
  66. actionUrl: m.action_url ?? m.actionUrl,
  67. actionText: m.action_text ?? m.actionText
  68. }
  69. }
  70. /**
  71. * 根据推送消息计算会话 ID:私信用 sender_id/receiver_id,通知用负的 app_id
  72. */
  73. function getContactIdFromMessage(msg, currentUserId) {
  74. const type = msg.type ?? 'MESSAGE'
  75. const appId = msg.app_id ?? msg.appId
  76. if (type === 'NOTIFICATION' && appId != null && appId !== '') {
  77. return -Math.abs(Number(appId))
  78. }
  79. const senderId = msg.sender_id ?? msg.senderId
  80. const receiverId = msg.receiver_id ?? msg.receiverId
  81. // 当前用户是接收方则会话对方是 sender_id,否则是 receiver_id
  82. if (String(receiverId) === String(currentUserId)) return senderId
  83. return receiverId
  84. }
  85. function attachSocketListenersOnce() {
  86. if (socketListenersAttached) return
  87. socketListenersAttached = true
  88. uni.onSocketOpen(() => {
  89. reconnectAttempt = 0
  90. clearReconnectTimer()
  91. console.log('[WS] messages connected')
  92. syncInboxFromServer()
  93. heartbeatTimer = setInterval(() => {
  94. try {
  95. if (socketTask) uni.sendSocketMessage({ data: 'ping' })
  96. } catch (e) {}
  97. }, HEARTBEAT_INTERVAL)
  98. })
  99. uni.onSocketMessage((res) => {
  100. try {
  101. // 心跳:服务端回复 pong
  102. if (res.data === 'pong') {
  103. syncInboxFromServer()
  104. return
  105. }
  106. const data = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
  107. console.log('[WS] recv', data)
  108. syncInboxFromServer()
  109. // 文档格式:{ type: 'NEW_MESSAGE', data: { id, sender_id, receiver_id, ... } }
  110. const msg = data.type === 'NEW_MESSAGE' ? data.data : (data.message ?? data)
  111. if (!msg) {
  112. console.log('[WS] recv (ignored: no message payload)')
  113. return
  114. }
  115. const normalized = normalizeWsMessage({ message: msg })
  116. // 需要当前用户 id 判断会话方:若后端推送里带 current_user_id 用那个,否则用 receiver_id 判断
  117. const currentUserId = data.current_user_id ?? normalized.receiverId
  118. const contactId = getContactIdFromMessage(msg, currentUserId)
  119. if (contactId == null) {
  120. console.log('[WS] recv (ignored: no contactId)', { msg, currentUserId })
  121. return
  122. }
  123. {
  124. const cid = String(contactId)
  125. const list = chatStore.messages[cid] || []
  126. const hasId = normalized.id && list.some((m) => String(m.id) === String(normalized.id))
  127. if (hasId) {
  128. console.log('[WS] recv (ignored: duplicate id)', String(normalized.id))
  129. return
  130. }
  131. chatStore.appendMessage(cid, normalized)
  132. // 前台 & 后台消息通知:若当前不在该会话,则给出提示
  133. const isActive = String(chatStore.activeContactId || '') === String(contactId)
  134. if (!isActive) {
  135. const contact = (chatStore.contacts || []).find(
  136. (c) => String(c.user_id || c.id) === String(contactId)
  137. )
  138. const title = (contact && contact.title) || '新消息'
  139. let body = ''
  140. if (normalized.contentType === 'TEXT') {
  141. body = normalized.content ? String(normalized.content).slice(0, 50) : ''
  142. } else if (normalized.contentType === 'IMAGE') {
  143. body = '[图片]'
  144. } else if (normalized.contentType === 'VIDEO') {
  145. body = '[视频]'
  146. } else if (normalized.contentType === 'USER_NOTIFICATION') {
  147. body = normalized.title
  148. ? String(normalized.title).slice(0, 50)
  149. : normalized.content
  150. ? String(normalized.content).slice(0, 50)
  151. : '[通知]'
  152. } else {
  153. body = normalized.title || '[文件]'
  154. }
  155. // #ifdef APP-PLUS
  156. try {
  157. plus.push.createMessage(body || '您有一条新消息', { contactId }, { title })
  158. } catch (e) {
  159. // 兜底为 Toast
  160. uni.showToast({ title: `${title}: ${body || '新消息'}`, icon: 'none' })
  161. }
  162. // #endif
  163. // #ifndef APP-PLUS
  164. uni.showToast({ title: `${title}: ${body || '新消息'}`, icon: 'none' })
  165. // #endif
  166. } else {
  167. // 正在该会话对话框内收到新消息:标记本会话全部已读,并刷新未读与列表
  168. const t = getToken()
  169. if (t) {
  170. Promise.resolve(markHistoryReadAll(t, cid))
  171. .then(() =>
  172. Promise.all([fetchUnreadCountAndUpdateTabBar(), fetchContactsList()])
  173. )
  174. .catch(() => {})
  175. }
  176. }
  177. // 只更新该会话在列表中的最后一条预览与时间,不整表刷新
  178. let preview = ''
  179. if (normalized.contentType === 'TEXT') {
  180. preview = normalized.content ? String(normalized.content).slice(0, 50) : ''
  181. } else if (normalized.contentType === 'IMAGE') preview = '[图片]'
  182. else if (normalized.contentType === 'VIDEO') preview = '[视频]'
  183. else if (normalized.contentType === 'USER_NOTIFICATION') {
  184. preview = normalized.title
  185. ? String(normalized.title).slice(0, 50)
  186. : normalized.content
  187. ? String(normalized.content).slice(0, 50)
  188. : '[通知]'
  189. } else preview = normalized.title || '[文件]'
  190. chatStore.updateContactPreview(cid, { lastMessage: preview, time: normalized.createdAt })
  191. }
  192. } catch (e) {
  193. console.warn('[WS] parse message error', e, res && res.data)
  194. }
  195. })
  196. uni.onSocketError((err) => {
  197. console.warn('[WS] error', err)
  198. })
  199. uni.onSocketClose(() => {
  200. if (heartbeatTimer) {
  201. clearInterval(heartbeatTimer)
  202. heartbeatTimer = null
  203. }
  204. socketTask = null
  205. if (!intentionalClose && getToken()) {
  206. scheduleReconnect()
  207. }
  208. })
  209. }
  210. function tryConnect() {
  211. const token = getToken()
  212. if (!token || socketTask) return
  213. intentionalClose = false
  214. attachSocketListenersOnce()
  215. const url = `${WS_BASE}?token=${encodeURIComponent(token)}`
  216. socketTask = uni.connectSocket({
  217. url,
  218. success: () => {}
  219. })
  220. }
  221. function performDisconnect() {
  222. intentionalClose = true
  223. clearReconnectTimer()
  224. reconnectAttempt = 0
  225. if (heartbeatTimer) {
  226. clearInterval(heartbeatTimer)
  227. heartbeatTimer = null
  228. }
  229. if (socketTask) {
  230. uni.closeSocket()
  231. socketTask = null
  232. }
  233. }
  234. /**
  235. * 登出 / 清空 token 时关闭连接并禁止自动重连(供 setToken 等统一调用)
  236. */
  237. export function disconnectWebSocket() {
  238. performDisconnect()
  239. }
  240. /** 启动已登录 / 登录成功后显式建连(与 useWebSocket().connect 相同) */
  241. export function connectWebSocket() {
  242. tryConnect()
  243. }
  244. export function useWebSocket() {
  245. function connect() {
  246. tryConnect()
  247. }
  248. function disconnect() {
  249. performDisconnect()
  250. }
  251. return { connect, disconnect }
  252. }