price-realtime.js 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. import { BASE_URL, TOKEN_KEY } from '@/config/api'
  2. import { request, parseResponseJson } from '@/utils/request'
  3. import { emit } from '@/utils/bus'
  4. const STORAGE_PRICE_VERSION_KEY = 'sq_active_price_version'
  5. const STORAGE_PRICE_LIST_ID_KEY = 'sq_active_price_list_id'
  6. const WS_LOG_PREFIX = '[price-ws]'
  7. function wsLog(...args) {
  8. try {
  9. console.log(WS_LOG_PREFIX, ...args)
  10. } catch (e) {
  11. // ignore
  12. }
  13. }
  14. function getStoredVersion() {
  15. const v = uni.getStorageSync(STORAGE_PRICE_VERSION_KEY)
  16. const n = Number(v)
  17. return Number.isFinite(n) ? n : 0
  18. }
  19. function setStoredVersion(version, priceListId) {
  20. uni.setStorageSync(STORAGE_PRICE_VERSION_KEY, Number(version) || 0)
  21. if (priceListId) uni.setStorageSync(STORAGE_PRICE_LIST_ID_KEY, String(priceListId))
  22. }
  23. function getStoredPriceListId() {
  24. return String(uni.getStorageSync(STORAGE_PRICE_LIST_ID_KEY) || '').trim()
  25. }
  26. function buildWsUrl(token) {
  27. const base = String(BASE_URL || '').trim()
  28. const origin = base.replace(/\/api\/?$/, '').replace(/\/$/, '')
  29. const scheme = origin.startsWith('https://') ? 'wss://' : 'ws://'
  30. const host = origin.replace(/^https?:\/\//, '')
  31. return `${scheme}${host}/ws/mini?token=${encodeURIComponent(token)}`
  32. }
  33. let socketTask = null
  34. let reconnectTimer = null
  35. let reconnectDelayMs = 1000
  36. let isConnecting = false
  37. let h5ListenersBound = false
  38. let shouldReconnect = true
  39. let socketOpened = false
  40. let pollTimer = null
  41. const FALLBACK_POLL_INTERVAL_MS = 5000
  42. const WS_ENABLED = true
  43. function safeCloseSocket(task, reason = 'client-close') {
  44. // 仅在连接已建立后主动关闭,避免小程序端在未连接状态 close 抛错
  45. if (!socketOpened) {
  46. wsLog('skip close: socket not opened yet', { reason })
  47. return false
  48. }
  49. // 微信小程序仅允许 1000 或 3000-4999 的关闭码
  50. const closeOptions = { code: 1000, reason }
  51. if (task && typeof task.close === 'function') {
  52. wsLog('closing socket', { reason, code: 1000 })
  53. // #ifdef MP-WEIXIN
  54. task.close({ code: 1000 })
  55. // #endif
  56. // #ifndef MP-WEIXIN
  57. task.close(closeOptions)
  58. // #endif
  59. return true
  60. }
  61. // 无有效 task 时不调用 uni.closeSocket,避免出现“WebSocket is not connected / invalid code”噪音
  62. return false
  63. }
  64. function bindSocketListeners() {
  65. const onOpen = () => {
  66. wsLog('socket open')
  67. socketOpened = true
  68. clearFallbackPoll()
  69. reconnectDelayMs = 1000
  70. void checkActiveVersionAndEmitIfChanged()
  71. }
  72. const onMessage = (evt) => {
  73. wsLog('socket message received')
  74. handleWsMessage(evt?.data)
  75. }
  76. const onClose = () => {
  77. wsLog('socket close', { shouldReconnect })
  78. socketOpened = false
  79. socketTask = null
  80. ensureFallbackPollRunning()
  81. if (shouldReconnect) scheduleReconnect()
  82. }
  83. const onError = () => {
  84. wsLog('socket error', { shouldReconnect })
  85. try {
  86. // 错误回调中不主动 close,交给 onClose,避免重复关闭触发小程序异常
  87. } catch (e) {
  88. // ignore
  89. }
  90. socketOpened = false
  91. socketTask = null
  92. ensureFallbackPollRunning()
  93. if (shouldReconnect) scheduleReconnect()
  94. }
  95. // App/小程序端:SocketTask 事件方法(每次重连都会产生新的 SocketTask,需要重新绑定监听)
  96. if (socketTask && typeof socketTask.onOpen === 'function') {
  97. socketTask.onOpen(onOpen)
  98. socketTask.onMessage(onMessage)
  99. socketTask.onClose(onClose)
  100. socketTask.onError(onError)
  101. return
  102. }
  103. // H5 端:uni 全局 socket 事件(全局监听只需注册一次)
  104. if (h5ListenersBound) return
  105. if (typeof uni.onSocketOpen === 'function') uni.onSocketOpen(onOpen)
  106. if (typeof uni.onSocketMessage === 'function') uni.onSocketMessage(onMessage)
  107. if (typeof uni.onSocketClose === 'function') uni.onSocketClose(onClose)
  108. if (typeof uni.onSocketError === 'function') uni.onSocketError(onError)
  109. h5ListenersBound = true
  110. }
  111. function clearReconnect() {
  112. if (reconnectTimer) clearTimeout(reconnectTimer)
  113. reconnectTimer = null
  114. }
  115. function clearFallbackPoll() {
  116. if (pollTimer) clearInterval(pollTimer)
  117. pollTimer = null
  118. }
  119. function ensureFallbackPollRunning() {
  120. if (pollTimer) return
  121. wsLog('start fallback polling', { intervalMs: FALLBACK_POLL_INTERVAL_MS })
  122. pollTimer = setInterval(() => {
  123. void checkActiveVersionAndEmitIfChanged()
  124. }, FALLBACK_POLL_INTERVAL_MS)
  125. }
  126. function scheduleReconnect() {
  127. if (reconnectTimer) return
  128. const delay = reconnectDelayMs
  129. wsLog('schedule reconnect', { delay })
  130. reconnectDelayMs = Math.min(reconnectDelayMs * 2, 30000)
  131. reconnectTimer = setTimeout(() => {
  132. reconnectTimer = null
  133. void ensurePriceRealtimeConnected()
  134. }, delay)
  135. }
  136. async function checkActiveVersionAndEmitIfChanged() {
  137. try {
  138. const res = await request({
  139. url: '/mini/client/price-list/active-version',
  140. method: 'GET'
  141. })
  142. const body = parseResponseJson(res)
  143. const data = body && body.code === 200 ? body.data : null
  144. const newVersion = Number(data?.priceVersion || 0) || 0
  145. const oldVersion = getStoredVersion()
  146. wsLog('poll active-version', { oldVersion, newVersion, priceListId: data?.priceListId || '' })
  147. if (newVersion > 0 && newVersion !== oldVersion) {
  148. setStoredVersion(newVersion, data?.priceListId)
  149. emit('price_list_updated', data)
  150. }
  151. } catch (e) {
  152. // ignore
  153. }
  154. }
  155. function handleWsMessage(raw) {
  156. let msg = null
  157. try {
  158. msg = typeof raw === 'string' ? JSON.parse(raw) : raw
  159. } catch (e) {
  160. msg = null
  161. }
  162. if (!msg || msg.event !== 'price_list_updated') return
  163. const data = msg.data || {}
  164. const newVersion = Number(data.priceVersion || 0) || 0
  165. const oldVersion = getStoredVersion()
  166. const oldPriceListId = getStoredPriceListId()
  167. const newPriceListId = String(data.priceListId || '').trim()
  168. wsLog('price_list_updated event', {
  169. oldVersion,
  170. newVersion,
  171. oldPriceListId,
  172. newPriceListId
  173. })
  174. // WebSocket 推送属于“已发生更新”的强信号:
  175. // 即便版本号缺失/相同,也触发页面刷新,避免界面停留旧价。
  176. const nextVersion = newVersion > 0 ? Math.max(newVersion, oldVersion) : oldVersion
  177. setStoredVersion(nextVersion, newPriceListId || oldPriceListId)
  178. // 若版本与清单都完全没变化,仍允许刷新一次,确保客户端与服务端最终一致。
  179. emit('price_list_updated', data)
  180. try {
  181. uni.$emit && uni.$emit('price_list_updated', data)
  182. } catch (e) {
  183. // ignore
  184. }
  185. }
  186. export async function ensurePriceRealtimeConnected() {
  187. const token = String(uni.getStorageSync(TOKEN_KEY) || '').trim()
  188. if (!token) {
  189. wsLog('skip connect: empty token')
  190. ensureFallbackPollRunning()
  191. return
  192. }
  193. if (isConnecting) {
  194. wsLog('skip connect: already connecting')
  195. return
  196. }
  197. if (socketTask) {
  198. wsLog('skip connect: socketTask exists')
  199. return
  200. }
  201. isConnecting = true
  202. shouldReconnect = true
  203. socketOpened = false
  204. try {
  205. clearReconnect()
  206. ensureFallbackPollRunning()
  207. const url = buildWsUrl(token)
  208. wsLog('connect socket', { url })
  209. if (WS_ENABLED) {
  210. socketTask = uni.connectSocket({ url })
  211. bindSocketListeners()
  212. }
  213. } finally {
  214. isConnecting = false
  215. }
  216. }
  217. export function stopPriceRealtime() {
  218. wsLog('stop realtime')
  219. clearReconnect()
  220. clearFallbackPoll()
  221. reconnectDelayMs = 1000
  222. shouldReconnect = false
  223. if (socketTask) {
  224. try {
  225. safeCloseSocket(socketTask, 'manual-stop')
  226. } catch (e) {
  227. // ignore
  228. }
  229. }
  230. socketOpened = false
  231. socketTask = null
  232. }
  233. export async function checkActivePriceVersionOnce() {
  234. await checkActiveVersionAndEmitIfChanged()
  235. }