You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

333 regels
11 KiB

  1. /**
  2. * 统一 API 请求封装,完全对齐 ai_uniapp_v2/utils/request.js
  3. *
  4. * 请求路径走 /api 前缀 → server/api/[...].js 服务端代理
  5. * /api/user/info → 代理到 https://api.jiefuku.com/user/info
  6. *
  7. * 自动注入(对齐 V2):
  8. * Header: { token, timestr, nonce }
  9. * Body: { token, uid, mac, base_timestamp, client, source, client_ios, version, version_code }
  10. *
  11. * 后端响应规范:
  12. * { code: 0, data: {...}, list: [...], msg: '...' } // 成功
  13. * { code: 1000, data: null, msg: '...' } // 业务异常
  14. * { code: 1009, data: null, msg: '...' } // 登录失效
  15. */
  16. import md5 from '~/utils/md5'
  17. function parseSseBlock(block) {
  18. let eventName = 'message'
  19. const dataLines = []
  20. String(block || '').split('\n').forEach((rawLine) => {
  21. const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine
  22. if (!line || line.startsWith(':')) return
  23. const separator = line.indexOf(':')
  24. const field = separator >= 0 ? line.slice(0, separator) : line
  25. let value = separator >= 0 ? line.slice(separator + 1) : ''
  26. if (value.startsWith(' ')) value = value.slice(1)
  27. if (field === 'event') eventName = value || 'message'
  28. if (field === 'data') dataLines.push(value)
  29. })
  30. if (!dataLines.length) return null
  31. const dataText = dataLines.join('\n')
  32. try {
  33. return { event: eventName, data: JSON.parse(dataText) }
  34. } catch {
  35. return { event: eventName, data: { content: dataText } }
  36. }
  37. }
  38. export function useApi() {
  39. const tokenCookie = useCookie('token', { path: '/' })
  40. const macCookie = useCookie('mac', { maxAge: 365 * 24 * 3600, path: '/' })
  41. const openIdCookie = useCookie('open_id', { path: '/' })
  42. const sourceCookie = useCookie('source', { path: '/' })
  43. const { getClientId, getClientIos } = useClientDevice()
  44. /**
  45. * 登录态从 useState 共享状态读取(与 useUser 同源)。
  46. * 不能只依赖这里的 useCookie ref:页面若在登录前就创建了 useApi,
  47. * 该 ref 感知不到后续登录写入,会导致请求不带 token → 1009。
  48. */
  49. const tokenState = useState('auth_token', () => tokenCookie.value || '')
  50. const openIdState = useState('auth_open_id', () => openIdCookie.value || '')
  51. // mac 缺失时自动生成
  52. if (import.meta.client && !macCookie.value) {
  53. macCookie.value = generateUUID()
  54. }
  55. /**
  56. * mac 兜底值。
  57. * SSR(含搜索引擎爬虫首访)时还没有 mac Cookie,而后端 base.js 要求 mac 必填,
  58. * 缺失会直接返回「mac必填」,导致服务端渲染取不到数据、SEO 抓不到内容。
  59. */
  60. const SSR_MAC = 'ssr-render'
  61. function generateUUID() {
  62. return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
  63. const r = (Math.random() * 16) | 0
  64. const v = c === 'x' ? r : (r & 0x3) | 0x8
  65. return v.toString(16)
  66. })
  67. }
  68. /**
  69. * 生成 nonce + timestr(对齐 V2 getNonce)
  70. */
  71. function getNonce() {
  72. const timeStr = Date.now().toString()
  73. const str = md5(timeStr)
  74. const nonce = str.substring(4, 14)
  75. return { timeStr, nonce }
  76. }
  77. async function fetchJson(url, options = {}) {
  78. const response = await fetch(url, options)
  79. const text = await response.text()
  80. let result = null
  81. if (text) {
  82. try {
  83. result = JSON.parse(text)
  84. } catch {
  85. const err = new Error(text || `请求失败 (${response.status})`)
  86. err.code = response.status
  87. throw err
  88. }
  89. }
  90. if (!response.ok) {
  91. const err = new Error(result?.msg || result?.message || `请求失败 (${response.status})`)
  92. err.code = result?.code || response.status
  93. err.response = result
  94. throw err
  95. }
  96. return result || {}
  97. }
  98. /**
  99. * 发起请求
  100. */
  101. async function request(path, options = {}) {
  102. const { method = 'GET', params, body } = options
  103. /**
  104. * 浏览器端走 /api 代理(规避跨域,对齐 V2 H5 的 BASE_URL='/api');
  105. * 服务端(SSR)直接请求后端,避免内部再走一次 Nitro 代理时
  106. * form 请求体丢失导致后端报「client必填」。
  107. */
  108. const runtimeConfig = useRuntimeConfig()
  109. const apiBase = import.meta.server
  110. ? (runtimeConfig.apiServerBase || runtimeConfig.public.apiBase)
  111. : runtimeConfig.public.apiBase
  112. const url = import.meta.server ? apiBase + path : '/api' + path
  113. const timestamp = Math.floor(Date.now() / 1000)
  114. const { timeStr, nonce } = getNonce()
  115. // Header 签名(对齐 V2 header 注入)
  116. const reqHeaders = {
  117. 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
  118. timestr: timeStr,
  119. nonce,
  120. }
  121. // 每次请求时读取最新登录态(共享 state 优先,兼容 SSR 时的 cookie)
  122. const token = tokenState.value || tokenCookie.value || ''
  123. if (token) reqHeaders.token = token
  124. // Body 公共参数(对齐 V2 data 注入)
  125. const commonParams = {
  126. token: token || '',
  127. uid: openIdState.value || openIdCookie.value || '',
  128. mac: macCookie.value || SSR_MAC,
  129. base_timestamp: timestamp,
  130. client: getClientId(),
  131. source: sourceCookie.value || '',
  132. client_ios: getClientIos(),
  133. version: '1.0.0',
  134. version_code: 1,
  135. }
  136. const fetchOptions = {
  137. method,
  138. headers: reqHeaders,
  139. onResponseError({ response }) {
  140. console.error('[useApi] HTTP Error', response.status, url)
  141. },
  142. }
  143. if (method === 'GET') {
  144. // GET: 参数拼接到 URL
  145. const allParams = { ...commonParams, ...(params || {}) }
  146. // 去除空字符串
  147. Object.keys(allParams).forEach((k) => {
  148. if (allParams[k] === '') delete allParams[k]
  149. })
  150. const queryStr = new URLSearchParams(allParams).toString()
  151. fetchOptions.query = allParams
  152. // GET 请求通过 server proxy 时,参数在 query 里
  153. const finalUrl = url + (queryStr ? '?' + queryStr : '')
  154. const result = import.meta.server
  155. ? await fetchJson(finalUrl, fetchOptions)
  156. : await $fetch(finalUrl, fetchOptions)
  157. return handleResult(result)
  158. } else {
  159. // POST: body 为 x-www-form-urlencoded
  160. const postBody = { ...commonParams, ...(body || {}) }
  161. // 去除空字符串
  162. Object.keys(postBody).forEach((k) => {
  163. if (postBody[k] === '') delete postBody[k]
  164. })
  165. fetchOptions.body = new URLSearchParams(postBody).toString()
  166. const result = import.meta.server
  167. ? await fetchJson(url, fetchOptions)
  168. : await $fetch(url, fetchOptions)
  169. return handleResult(result)
  170. }
  171. }
  172. /**
  173. * POST SSE 流式请求。
  174. * EventSource 不支持 POST,因此使用 fetch + ReadableStream 读取事件。
  175. */
  176. async function postStream(path, body = {}, handlers = {}) {
  177. if (import.meta.server) throw new Error('流式请求仅支持浏览器端')
  178. const { timeStr, nonce } = getNonce()
  179. const token = tokenState.value || tokenCookie.value || ''
  180. const requestHeaders = {
  181. 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
  182. Accept: 'text/event-stream',
  183. timestr: timeStr,
  184. nonce,
  185. }
  186. if (token) requestHeaders.token = token
  187. const postBody = {
  188. token: token || '',
  189. uid: openIdState.value || openIdCookie.value || '',
  190. mac: macCookie.value || SSR_MAC,
  191. base_timestamp: Math.floor(Date.now() / 1000),
  192. client: getClientId(),
  193. source: sourceCookie.value || '',
  194. client_ios: getClientIos(),
  195. version: '1.0.0',
  196. version_code: 1,
  197. ...body,
  198. stream: 1,
  199. }
  200. Object.keys(postBody).forEach((key) => {
  201. if (postBody[key] === '') delete postBody[key]
  202. })
  203. const response = await fetch('/api' + path, {
  204. method: 'POST',
  205. headers: requestHeaders,
  206. body: new URLSearchParams(postBody).toString(),
  207. signal: handlers.signal,
  208. })
  209. const contentType = response.headers.get('content-type') || ''
  210. // 鉴权、额度校验等错误可能在 SSE 响应头建立前以普通 JSON 返回。
  211. if (!contentType.includes('text/event-stream')) {
  212. const responseText = await response.text()
  213. let payload = null
  214. try { payload = JSON.parse(responseText) } catch {}
  215. if (payload && typeof payload.code !== 'undefined') {
  216. const result = handleResult(payload)
  217. return result.data || result
  218. }
  219. const error = new Error(responseText || `流式请求失败 (${response.status})`)
  220. error.code = response.status
  221. throw error
  222. }
  223. if (!response.ok || !response.body) {
  224. const error = new Error(`流式请求失败 (${response.status})`)
  225. error.code = response.status
  226. throw error
  227. }
  228. const reader = response.body.getReader()
  229. const decoder = new TextDecoder('utf-8')
  230. let buffer = ''
  231. let doneData = null
  232. let streamError = null
  233. const consumeBlock = (block) => {
  234. const packet = parseSseBlock(block)
  235. if (!packet) return
  236. const data = packet.data || {}
  237. if (packet.event === 'start') handlers.onStart?.(data)
  238. else if (packet.event === 'message') handlers.onMessage?.(data)
  239. else if (packet.event === 'done') {
  240. doneData = data
  241. handlers.onDone?.(data)
  242. } else if (packet.event === 'error') {
  243. streamError = new Error(data.msg || '生成失败,请稍后重试')
  244. streamError.code = data.code || 1000
  245. handlers.onError?.(data)
  246. }
  247. }
  248. while (true) {
  249. const { done, value } = await reader.read()
  250. buffer += decoder.decode(value || new Uint8Array(0), { stream: !done }).replace(/\r\n/g, '\n')
  251. const blocks = buffer.split('\n\n')
  252. buffer = blocks.pop() || ''
  253. blocks.forEach(consumeBlock)
  254. if (done) break
  255. if (streamError) {
  256. await reader.cancel()
  257. break
  258. }
  259. }
  260. if (buffer.trim()) consumeBlock(buffer)
  261. if (streamError) throw streamError
  262. if (!doneData) throw new Error('流式响应意外中断,请重试')
  263. return doneData
  264. }
  265. /**
  266. * 统一处理响应
  267. */
  268. function handleResult(result) {
  269. if (result.code === 1009) {
  270. // 登录失效 → 清理登录态并触发登录弹窗
  271. if (import.meta.client) {
  272. const { logout, showLogin } = useUser()
  273. logout()
  274. showLogin.value = true
  275. }
  276. const err = new Error(result.msg || '登录已失效,请重新登录')
  277. err.code = 1009
  278. throw err
  279. }
  280. if (result.code !== 0) {
  281. const err = new Error(result.msg || '请求失败')
  282. err.code = result.code || 1000
  283. throw err
  284. }
  285. return result
  286. }
  287. return {
  288. /** GET 请求 */
  289. get: (path, params) => request(path, { method: 'GET', params }),
  290. /** POST 请求 (x-www-form-urlencoded) */
  291. post: (path, body) => request(path, { method: 'POST', body }),
  292. /** POST SSE 流式请求 */
  293. postStream,
  294. /** 通用请求 */
  295. request,
  296. }
  297. }