dynamicDomain.js 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  1. import { getDomainParts, setDomainParts } from '@/config/domainState'
  2. import {
  3. DOMAIN_CACHE_KEY,
  4. DOMAIN_CACHE_TTL,
  5. DOMAIN_CONFIG_URL,
  6. DOMAIN_INIT_TIMEOUT,
  7. DOMAIN_PROBE_PATH,
  8. DOMAIN_PROBE_TIMEOUT,
  9. DOMAIN_CACHE_PROBE_TIMEOUT,
  10. } from '@/config/domainConstants'
  11. let resolving = null
  12. let domainReady = false
  13. let domainReadyPromise = null
  14. let domainReadyResolve = null
  15. let initTimeoutId = null
  16. function createDomainReadyPromise() {
  17. domainReadyPromise = new Promise((resolve) => {
  18. domainReadyResolve = resolve
  19. })
  20. }
  21. function markDomainReady() {
  22. if (domainReady) return
  23. domainReady = true
  24. if (initTimeoutId) {
  25. clearTimeout(initTimeoutId)
  26. initTimeoutId = null
  27. }
  28. domainReadyResolve?.()
  29. }
  30. function notifyDomainChanged(cache) {
  31. try {
  32. uni.$emit('domain-changed', {
  33. ...getDomainParts(),
  34. activeSecureUrl: cache?.activeSecureUrl || '',
  35. })
  36. } catch (e) {
  37. // ignore
  38. }
  39. }
  40. /** 业务请求需等待域名就绪(仅 APP) */
  41. export function whenDomainReady() {
  42. // #ifndef APP-PLUS
  43. return Promise.resolve()
  44. // #endif
  45. if (domainReady) return Promise.resolve()
  46. if (!domainReadyPromise) createDomainReadyPromise()
  47. return domainReadyPromise
  48. }
  49. /** 当前域名状态(调试用) */
  50. export function getDomainStatus() {
  51. const cache = readCache()
  52. return {
  53. ready: domainReady,
  54. parts: getDomainParts(),
  55. cache,
  56. cacheValid: isCacheValid(cache),
  57. }
  58. }
  59. /** 从 secure 完整 URL 解析 ho / dt / ht */
  60. export function parseSecureUrl(url) {
  61. if (!url || typeof url !== 'string') {
  62. throw new Error('无效的域名地址')
  63. }
  64. const normalized = url.startsWith('http') ? url : `https://${url}`
  65. const matched = normalized.match(/^(https?):\/\/([^/]+)/i)
  66. if (!matched) {
  67. throw new Error('无法解析域名地址')
  68. }
  69. const ht = `${matched[1]}:`
  70. const hostname = matched[2]
  71. const parts = hostname.split('.')
  72. if (parts.length < 3) {
  73. throw new Error(`域名格式不正确: ${hostname}`)
  74. }
  75. const dt = parts[parts.length - 1]
  76. const ho = parts[parts.length - 2]
  77. return { ho, dt, ht, secureUrl: `${ht}//${hostname}` }
  78. }
  79. export function readCache() {
  80. try {
  81. const raw = null
  82. if (!raw) return null
  83. return typeof raw === 'string' ? JSON.parse(raw) : raw
  84. } catch (e) {
  85. console.warn('[dynamicDomain] 读取缓存失败', e)
  86. return null
  87. }
  88. }
  89. function writeCache(payload) {
  90. uni.setStorageSync(DOMAIN_CACHE_KEY, payload)
  91. }
  92. export function isCacheValid(cache) {
  93. if (!cache?.ho || !cache?.dt || !cache?.expiresAt) return false
  94. return Date.now() < cache.expiresAt
  95. }
  96. function applyCacheToState(cache) {
  97. if (!cache?.ho || !cache?.dt) return false
  98. setDomainParts({ ho: cache.ho, dt: cache.dt, ht: cache.ht || 'https:' })
  99. return true
  100. }
  101. function parseResponseData(data) {
  102. if (!data) return null
  103. if (typeof data === 'string') {
  104. try {
  105. return JSON.parse(data)
  106. } catch (e) {
  107. return null
  108. }
  109. }
  110. return data
  111. }
  112. function requestJson(url, timeout = 8000) {
  113. return new Promise((resolve, reject) => {
  114. uni.request({
  115. url,
  116. method: 'GET',
  117. timeout,
  118. success: (res) => {
  119. if (res.statusCode !== 200) {
  120. reject(new Error(`请求失败: ${res.statusCode}`))
  121. return
  122. }
  123. const body = parseResponseData(res.data)
  124. if (!body) {
  125. reject(new Error('配置解析失败'))
  126. return
  127. }
  128. resolve(body)
  129. },
  130. fail: (err) => reject(err || new Error('网络请求失败')),
  131. })
  132. })
  133. }
  134. /** 快速探测 secure 域名是否可用 */
  135. export function probeSecureUrl(secureUrl, timeout = DOMAIN_PROBE_TIMEOUT) {
  136. const url = `${secureUrl.replace(/\/$/, '')}${DOMAIN_PROBE_PATH}?_t=${Date.now()}`
  137. return new Promise((resolve) => {
  138. uni.request({
  139. url,
  140. method: 'GET',
  141. timeout,
  142. success: (res) => resolve(res.statusCode === 200),
  143. fail: () => resolve(false),
  144. })
  145. })
  146. }
  147. async function pickActiveSecureUrl(remoteConfig) {
  148. const candidates = []
  149. if (remoteConfig?.primary) {
  150. candidates.push({ url: remoteConfig.primary, priority: 0 })
  151. }
  152. ;(remoteConfig?.backup || []).forEach((url, index) => {
  153. if (url) candidates.push({ url, priority: index + 1 })
  154. })
  155. if (!candidates.length) {
  156. throw new Error('远程域名配置为空')
  157. }
  158. const checks = await Promise.all(
  159. candidates.map(async (item) => ({
  160. ...item,
  161. ok: await probeSecureUrl(item.url),
  162. }))
  163. )
  164. const winner = checks
  165. .filter((item) => item.ok)
  166. .sort((a, b) => a.priority - b.priority)[0]
  167. if (!winner) {
  168. throw new Error('所有域名均不可用')
  169. }
  170. return winner.url
  171. }
  172. function applySecureUrl(secureUrl, meta = {}) {
  173. const parsed = parseSecureUrl(secureUrl)
  174. setDomainParts(parsed)
  175. const cache = {
  176. ho: parsed.ho,
  177. dt: parsed.dt,
  178. ht: parsed.ht,
  179. activeSecureUrl: parsed.secureUrl,
  180. version: meta.version || 1,
  181. cachedAt: Date.now(),
  182. expiresAt: Date.now() + DOMAIN_CACHE_TTL,
  183. }
  184. writeCache(cache)
  185. notifyDomainChanged(cache)
  186. console.log('[dynamicDomain] 当前生效域名', cache)
  187. return cache
  188. }
  189. async function fetchRemoteConfig() {
  190. const data = await requestJson(`${DOMAIN_CONFIG_URL}?_t=${Date.now()}`)
  191. if (!data?.primary) {
  192. throw new Error('远程配置缺少 primary 字段')
  193. }
  194. return data
  195. }
  196. /**
  197. * 解析并应用动态域名
  198. * @param {{ force?: boolean, verifyCache?: boolean }} options
  199. */
  200. export async function resolveDynamicDomain(options = {}) {
  201. const { force = false, verifyCache = true } = options
  202. const cache = readCache()
  203. if (!force && isCacheValid(cache)) {
  204. applyCacheToState(cache)
  205. if (verifyCache && cache.activeSecureUrl) {
  206. const ok = await probeSecureUrl(cache.activeSecureUrl, DOMAIN_CACHE_PROBE_TIMEOUT)
  207. if (ok) {
  208. console.log('[dynamicDomain] 缓存域名可用,跳过刷新')
  209. return cache
  210. }
  211. console.warn('[dynamicDomain] 缓存域名不可用,重新探测')
  212. } else if (!verifyCache) {
  213. return cache
  214. }
  215. } else if (cache?.ho && cache?.dt) {
  216. // 缓存过期:先用旧域名,再后台刷新
  217. applyCacheToState(cache)
  218. }
  219. if (resolving) return resolving
  220. resolving = (async () => {
  221. try {
  222. const remoteConfig = await fetchRemoteConfig()
  223. const activeSecureUrl = await pickActiveSecureUrl(remoteConfig)
  224. return applySecureUrl(activeSecureUrl, { version: remoteConfig.version })
  225. } catch (error) {
  226. if (cache?.ho && cache?.dt) {
  227. applyCacheToState(cache)
  228. console.warn('[dynamicDomain] 刷新失败,继续使用缓存/默认域名', error)
  229. return cache
  230. }
  231. console.warn('[dynamicDomain] 刷新失败,使用内置默认域名', error)
  232. throw error
  233. } finally {
  234. resolving = null
  235. }
  236. })()
  237. return resolving
  238. }
  239. function scheduleInitTimeout() {
  240. if (initTimeoutId) return
  241. initTimeoutId = setTimeout(() => {
  242. console.warn(`[dynamicDomain] 初始化超过 ${DOMAIN_INIT_TIMEOUT}ms,放行业务请求`)
  243. markDomainReady()
  244. }, DOMAIN_INIT_TIMEOUT)
  245. }
  246. /**
  247. * 尽早初始化(main.js 调用),避免页面请求早于 onLaunch
  248. * 探测/切换完成后再放行业务请求,避免缓存域名失效时先发错误请求
  249. */
  250. export function initDynamicDomain() {
  251. // #ifndef APP-PLUS
  252. return Promise.resolve(null)
  253. // #endif
  254. if (!domainReadyPromise) createDomainReadyPromise()
  255. const cache = readCache()
  256. scheduleInitTimeout()
  257. const task = isCacheValid(cache)
  258. ? resolveDynamicDomain({ force: false, verifyCache: true })
  259. : resolveDynamicDomain({ force: true, verifyCache: false })
  260. return task
  261. .catch((e) => {
  262. console.warn('[dynamicDomain] 初始化失败,使用默认/过期缓存域名', e)
  263. if (cache?.ho && cache?.dt) applyCacheToState(cache)
  264. return cache
  265. })
  266. .finally(() => {
  267. markDomainReady()
  268. console.log('[dynamicDomain] 域名就绪', buildActiveSecureUrl())
  269. })
  270. }
  271. function buildActiveSecureUrl() {
  272. const { ht, ho, dt } = getDomainParts()
  273. return `${ht}//secure.${ho}.${dt}`
  274. }
  275. /** App 再次进入前台:仅缓存过期时后台刷新 */
  276. export function refreshDynamicDomainIfExpired() {
  277. // #ifndef APP-PLUS
  278. return Promise.resolve(null)
  279. // #endif
  280. const cache = readCache()
  281. if (isCacheValid(cache)) {
  282. return Promise.resolve(cache)
  283. }
  284. return resolveDynamicDomain({ force: true, verifyCache: false })
  285. }