requestNative.ts 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. import type { FetchContext } from 'ofetch'
  2. import type { ApiResponse } from '~/types/api'
  3. import CryptoJS from 'crypto-js'
  4. import { v4 as uuidv4 } from 'uuid'
  5. import { APP_LOCALE_KEY, DEVICE_UDID_KEY } from '~/constants'
  6. import { showToast } from 'vant'
  7. import { getDeviceLanguage } from '~/utils/helpers'
  8. const MD5 = CryptoJS.MD5
  9. // Device and app information (fallback values)
  10. const DEVICE_INFO = {
  11. udid: '', // Will be initialized
  12. app: 'web',
  13. device: '',
  14. platform: 3,
  15. channel: 'official',
  16. api: 1,
  17. version: '1.0.0',
  18. network: 'wifi',
  19. }
  20. /**
  21. * Initialize device info (fallback for when bridge headers are missing)
  22. */
  23. const initDeviceInfo = () => {
  24. if (import.meta.client) {
  25. let udid = localStorage.getItem(DEVICE_UDID_KEY)
  26. if (!udid) {
  27. udid = uuidv4()
  28. localStorage.setItem(DEVICE_UDID_KEY, udid)
  29. }
  30. DEVICE_INFO.udid = udid
  31. DEVICE_INFO.device = (navigator.userAgent || 'unknown').slice(0, 256)
  32. interface NetworkInformation {
  33. effectiveType?: string
  34. }
  35. const nav = navigator as Navigator & {
  36. connection?: NetworkInformation
  37. mozConnection?: NetworkInformation
  38. webkitConnection?: NetworkInformation
  39. }
  40. const connection = nav.connection || nav.mozConnection || nav.webkitConnection
  41. if (connection) {
  42. DEVICE_INFO.network = connection.effectiveType || 'wifi'
  43. }
  44. }
  45. }
  46. /**
  47. * Generate request ID
  48. */
  49. const generateRequestId = (): string => {
  50. return uuidv4().replace(/-/g, '')
  51. }
  52. type SignDeviceInfo = {
  53. udid: string
  54. app: string
  55. device: string
  56. platform: string | number
  57. channel: string
  58. api: string | number
  59. version: string
  60. network: string
  61. }
  62. /**
  63. * Generate sign (same algorithm as web request.ts)
  64. */
  65. const generateSign = (id: string, time: number, token: string, secret: string, body: unknown, deviceInfo: SignDeviceInfo): string => {
  66. const { udid, app, device, platform, channel, api, version, network } = deviceInfo
  67. const bodyStr = body ? JSON.stringify(body) : ''
  68. const signStr = `${id}${udid}${app}${device}${platform}${channel}${api}${version}${network}${time}${token}${secret}${bodyStr}`
  69. return MD5(signStr).toString()
  70. }
  71. /**
  72. * Create request instance for native pages.
  73. * Related headers are read from window.GAMI_BRIDGE.requestHeader, while `id` and `sign` are generated locally.
  74. */
  75. export const createRequestNative = () => {
  76. const runtimeConfig = useRuntimeConfig()
  77. const { token, logout } = useAuth()
  78. const nuxtApp = useNuxtApp()
  79. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  80. const t = ((nuxtApp as any)?.$i18n?.t?.bind((nuxtApp as any)?.$i18n) as ((key: string) => string) | undefined) || ((key: string) => key)
  81. const API_CONFIG = {
  82. baseURL: runtimeConfig.public.apiBase || '/api',
  83. timeout: 30000,
  84. secret: 'abc|abc|edg|9527|1234',
  85. identity: runtimeConfig.public.apiIdentity || 'web',
  86. }
  87. if (import.meta.client && !DEVICE_INFO.udid) {
  88. initDeviceInfo()
  89. }
  90. const getBridgeHeaders = (): Record<string, string> => {
  91. if (!import.meta.client) return {}
  92. const raw = window.GAMI_BRIDGE?.requestHeader
  93. if (!raw || typeof raw !== 'object') {
  94. console.error('Must in native context')
  95. showError(t('common.errors.nativeOnly'))
  96. return {}
  97. }
  98. const out: Record<string, string> = {}
  99. for (const [k, v] of Object.entries(raw)) {
  100. if (v === undefined || v === null) continue
  101. out[k] = String(v)
  102. }
  103. return out
  104. }
  105. const onRequest = (ctx: FetchContext): void => {
  106. const { options } = ctx
  107. if (!options.baseURL) {
  108. options.baseURL = API_CONFIG.baseURL
  109. }
  110. const headers: Record<string, string> = {}
  111. if (options.headers) {
  112. const existingHeaders = options.headers as unknown as Record<string, string>
  113. Object.assign(headers, existingHeaders)
  114. }
  115. const bridgeHeaders = getBridgeHeaders()
  116. Object.assign(headers, bridgeHeaders)
  117. const requestId = generateRequestId()
  118. const time = Date.now()
  119. const tokenVal = bridgeHeaders.token || token.value || ''
  120. if (import.meta.client) {
  121. const language = localStorage.getItem(APP_LOCALE_KEY)
  122. headers['Accept-Language'] = language || getDeviceLanguage()
  123. }
  124. const signDeviceInfo: SignDeviceInfo = {
  125. udid: bridgeHeaders.udid || DEVICE_INFO.udid,
  126. app: bridgeHeaders.app || API_CONFIG.identity,
  127. device: bridgeHeaders.device || DEVICE_INFO.device,
  128. platform: bridgeHeaders.platform ?? DEVICE_INFO.platform,
  129. channel: bridgeHeaders.channel || DEVICE_INFO.channel,
  130. api: bridgeHeaders.api ?? DEVICE_INFO.api,
  131. version: bridgeHeaders.version || DEVICE_INFO.version,
  132. network: bridgeHeaders.network || DEVICE_INFO.network,
  133. }
  134. // Ensure mandatory API headers exist (from bridge or fallback), but override id/time/sign
  135. headers['id'] = requestId
  136. headers['udid'] = String(signDeviceInfo.udid)
  137. headers['app'] = String(signDeviceInfo.app)
  138. headers['device'] = String(signDeviceInfo.device)
  139. headers['platform'] = String(signDeviceInfo.platform)
  140. headers['channel'] = String(signDeviceInfo.channel)
  141. headers['api'] = String(signDeviceInfo.api)
  142. headers['version'] = String(signDeviceInfo.version)
  143. headers['network'] = String(signDeviceInfo.network)
  144. headers['time'] = String(time)
  145. if (tokenVal) headers['token'] = tokenVal
  146. else delete headers.token
  147. const body = options.body
  148. headers['sign'] = generateSign(requestId, time, tokenVal, API_CONFIG.secret, body, signDeviceInfo)
  149. if (!headers['Content-Type']) {
  150. headers['Content-Type'] = 'application/json'
  151. }
  152. // @ts-expect-error - Headers type mismatch between HeadersInit and Headers
  153. options.headers = headers
  154. }
  155. const onResponse = (ctx: FetchContext): void => {
  156. if (!ctx.response) return
  157. const data = ctx.response._data as ApiResponse
  158. if (data && typeof data === 'object' && 'code' in data) {
  159. if (data.code === 0) {
  160. ctx.response._data = data.data
  161. return
  162. }
  163. handleApiError(data.code, data.msg)
  164. throw new Error(data.msg || t('common.errors.requestFailed'))
  165. }
  166. }
  167. const onResponseError = (ctx: FetchContext): Promise<never> => {
  168. const { error } = ctx
  169. const response = ctx.response
  170. if (response) {
  171. const status = response.status
  172. const data = response._data as { msg?: string }
  173. switch (status) {
  174. case 401:
  175. handleUnauthorized()
  176. break
  177. case 403:
  178. showError(t('common.errors.noPermission'))
  179. break
  180. case 404:
  181. showError(t('common.errors.notFound'))
  182. break
  183. case 500:
  184. showError(t('common.errors.serverError'))
  185. break
  186. default:
  187. showError(data?.msg || error?.message || t('common.errors.requestFailed'))
  188. }
  189. }
  190. else {
  191. const message = error?.message || ''
  192. if (message.includes('timeout')) showError(t('common.errors.timeout'))
  193. else if (message.includes('Network Error')) showError(t('common.errors.network'))
  194. else showError(message || t('common.errors.unknownError'))
  195. }
  196. return Promise.reject(error)
  197. }
  198. function handleApiError(code: number, message: string) {
  199. switch (code) {
  200. case 100004:
  201. handleUnauthorized()
  202. break
  203. default:
  204. if (message) showError(message)
  205. }
  206. }
  207. function handleUnauthorized() {
  208. logout()
  209. if (import.meta.client) {
  210. const route = useRoute()
  211. const redirect = route.fullPath
  212. showError(t('common.errors.loginExpired'))
  213. navigateTo({
  214. path: '/login',
  215. query: redirect && redirect !== '/login' ? { redirect } : undefined,
  216. })
  217. }
  218. }
  219. function showError(message: string) {
  220. if (import.meta.client) {
  221. showToast({ message: `${message}` })
  222. }
  223. }
  224. const request = $fetch.create({
  225. baseURL: API_CONFIG.baseURL,
  226. timeout: API_CONFIG.timeout,
  227. onRequest,
  228. onResponse,
  229. onResponseError,
  230. })
  231. return request
  232. }