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.
 
 
 
 

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