Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.
 
 
 
 

301 рядки
9.8 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. /**
  78. * 发起请求
  79. */
  80. async function request(path, options = {}) {
  81. const { method = 'GET', params, body } = options
  82. /**
  83. * 浏览器端走 /api 代理(规避跨域,对齐 V2 H5 的 BASE_URL='/api');
  84. * 服务端(SSR)直接请求后端,避免内部再走一次 Nitro 代理时
  85. * form 请求体丢失导致后端报「client必填」。
  86. */
  87. const apiBase = useRuntimeConfig().public.apiBase
  88. const url = import.meta.server ? apiBase + path : '/api' + path
  89. const timestamp = Math.floor(Date.now() / 1000)
  90. const { timeStr, nonce } = getNonce()
  91. // Header 签名(对齐 V2 header 注入)
  92. const reqHeaders = {
  93. 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
  94. timestr: timeStr,
  95. nonce,
  96. }
  97. // 每次请求时读取最新登录态(共享 state 优先,兼容 SSR 时的 cookie)
  98. const token = tokenState.value || tokenCookie.value || ''
  99. if (token) reqHeaders.token = token
  100. // Body 公共参数(对齐 V2 data 注入)
  101. const commonParams = {
  102. token: token || '',
  103. uid: openIdState.value || openIdCookie.value || '',
  104. mac: macCookie.value || SSR_MAC,
  105. base_timestamp: timestamp,
  106. client: getClientId(),
  107. source: sourceCookie.value || '',
  108. client_ios: getClientIos(),
  109. version: '1.0.0',
  110. version_code: 1,
  111. }
  112. const fetchOptions = {
  113. method,
  114. headers: reqHeaders,
  115. onResponseError({ response }) {
  116. console.error('[useApi] HTTP Error', response.status, url)
  117. },
  118. }
  119. if (method === 'GET') {
  120. // GET: 参数拼接到 URL
  121. const allParams = { ...commonParams, ...(params || {}) }
  122. // 去除空字符串
  123. Object.keys(allParams).forEach((k) => {
  124. if (allParams[k] === '') delete allParams[k]
  125. })
  126. const queryStr = new URLSearchParams(allParams).toString()
  127. fetchOptions.query = allParams
  128. // GET 请求通过 server proxy 时,参数在 query 里
  129. const finalUrl = url + (queryStr ? '?' + queryStr : '')
  130. const result = await $fetch(finalUrl, fetchOptions)
  131. return handleResult(result)
  132. } else {
  133. // POST: body 为 x-www-form-urlencoded
  134. const postBody = { ...commonParams, ...(body || {}) }
  135. // 去除空字符串
  136. Object.keys(postBody).forEach((k) => {
  137. if (postBody[k] === '') delete postBody[k]
  138. })
  139. fetchOptions.body = new URLSearchParams(postBody).toString()
  140. const result = await $fetch(url, fetchOptions)
  141. return handleResult(result)
  142. }
  143. }
  144. /**
  145. * POST SSE 流式请求。
  146. * EventSource 不支持 POST,因此使用 fetch + ReadableStream 读取事件。
  147. */
  148. async function postStream(path, body = {}, handlers = {}) {
  149. if (import.meta.server) throw new Error('流式请求仅支持浏览器端')
  150. const { timeStr, nonce } = getNonce()
  151. const token = tokenState.value || tokenCookie.value || ''
  152. const requestHeaders = {
  153. 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
  154. Accept: 'text/event-stream',
  155. timestr: timeStr,
  156. nonce,
  157. }
  158. if (token) requestHeaders.token = token
  159. const postBody = {
  160. token: token || '',
  161. uid: openIdState.value || openIdCookie.value || '',
  162. mac: macCookie.value || SSR_MAC,
  163. base_timestamp: Math.floor(Date.now() / 1000),
  164. client: getClientId(),
  165. source: sourceCookie.value || '',
  166. client_ios: getClientIos(),
  167. version: '1.0.0',
  168. version_code: 1,
  169. ...body,
  170. stream: 1,
  171. }
  172. Object.keys(postBody).forEach((key) => {
  173. if (postBody[key] === '') delete postBody[key]
  174. })
  175. const response = await fetch('/api' + path, {
  176. method: 'POST',
  177. headers: requestHeaders,
  178. body: new URLSearchParams(postBody).toString(),
  179. signal: handlers.signal,
  180. })
  181. const contentType = response.headers.get('content-type') || ''
  182. // 鉴权、额度校验等错误可能在 SSE 响应头建立前以普通 JSON 返回。
  183. if (!contentType.includes('text/event-stream')) {
  184. const responseText = await response.text()
  185. let payload = null
  186. try { payload = JSON.parse(responseText) } catch {}
  187. if (payload && typeof payload.code !== 'undefined') {
  188. const result = handleResult(payload)
  189. return result.data || result
  190. }
  191. const error = new Error(responseText || `流式请求失败 (${response.status})`)
  192. error.code = response.status
  193. throw error
  194. }
  195. if (!response.ok || !response.body) {
  196. const error = new Error(`流式请求失败 (${response.status})`)
  197. error.code = response.status
  198. throw error
  199. }
  200. const reader = response.body.getReader()
  201. const decoder = new TextDecoder('utf-8')
  202. let buffer = ''
  203. let doneData = null
  204. let streamError = null
  205. const consumeBlock = (block) => {
  206. const packet = parseSseBlock(block)
  207. if (!packet) return
  208. const data = packet.data || {}
  209. if (packet.event === 'start') handlers.onStart?.(data)
  210. else if (packet.event === 'message') handlers.onMessage?.(data)
  211. else if (packet.event === 'done') {
  212. doneData = data
  213. handlers.onDone?.(data)
  214. } else if (packet.event === 'error') {
  215. streamError = new Error(data.msg || '生成失败,请稍后重试')
  216. streamError.code = data.code || 1000
  217. handlers.onError?.(data)
  218. }
  219. }
  220. while (true) {
  221. const { done, value } = await reader.read()
  222. buffer += decoder.decode(value || new Uint8Array(0), { stream: !done }).replace(/\r\n/g, '\n')
  223. const blocks = buffer.split('\n\n')
  224. buffer = blocks.pop() || ''
  225. blocks.forEach(consumeBlock)
  226. if (done) break
  227. if (streamError) {
  228. await reader.cancel()
  229. break
  230. }
  231. }
  232. if (buffer.trim()) consumeBlock(buffer)
  233. if (streamError) throw streamError
  234. if (!doneData) throw new Error('流式响应意外中断,请重试')
  235. return doneData
  236. }
  237. /**
  238. * 统一处理响应
  239. */
  240. function handleResult(result) {
  241. if (result.code === 1009) {
  242. // 登录失效 → 清理登录态并触发登录弹窗
  243. if (import.meta.client) {
  244. const { logout, showLogin } = useUser()
  245. logout()
  246. showLogin.value = true
  247. }
  248. const err = new Error(result.msg || '登录已失效,请重新登录')
  249. err.code = 1009
  250. throw err
  251. }
  252. if (result.code !== 0) {
  253. const err = new Error(result.msg || '请求失败')
  254. err.code = result.code || 1000
  255. throw err
  256. }
  257. return result
  258. }
  259. return {
  260. /** GET 请求 */
  261. get: (path, params) => request(path, { method: 'GET', params }),
  262. /** POST 请求 (x-www-form-urlencoded) */
  263. post: (path, body) => request(path, { method: 'POST', body }),
  264. /** POST SSE 流式请求 */
  265. postStream,
  266. /** 通用请求 */
  267. request,
  268. }
  269. }