Nie możesz wybrać więcej, niż 25 tematów Tematy muszą się zaczynać od litery lub cyfry, mogą zawierać myślniki ('-') i mogą mieć do 35 znaków.
 
 
 
 

372 wiersze
12 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. * SSR JSON 请求使用 Node 原生 http/https。
  79. *
  80. * 线上运行环境可能与 Nuxt 构建环境的 Node 小版本不同,Node 23 下曾出现
  81. * `Headers is not a constructor`,导致服务端渲染无法取到文章数据。这里不经过
  82. * fetch/ofetch/Headers,避免运行时兼容层影响 SEO 页面。
  83. */
  84. async function fetchJson(url, options = {}) {
  85. const target = new URL(url)
  86. const transport = target.protocol === 'https:'
  87. ? await import('node:https')
  88. : await import('node:http')
  89. const body = typeof options.body === 'string' ? options.body : ''
  90. const headers = { ...(options.headers || {}) }
  91. if (body && !Object.keys(headers).some(key => key.toLowerCase() === 'content-length')) {
  92. headers['Content-Length'] = Buffer.byteLength(body)
  93. }
  94. return await new Promise((resolve, reject) => {
  95. const req = transport.request(target, {
  96. method: options.method || 'GET',
  97. headers,
  98. }, (response) => {
  99. const chunks = []
  100. response.on('data', chunk => chunks.push(Buffer.from(chunk)))
  101. response.on('end', () => {
  102. const status = Number(response.statusCode || 0)
  103. const text = Buffer.concat(chunks).toString('utf8')
  104. let result = null
  105. if (text) {
  106. try {
  107. result = JSON.parse(text)
  108. } catch {
  109. const err = new Error(text || `请求失败 (${status})`)
  110. err.code = status
  111. reject(err)
  112. return
  113. }
  114. }
  115. if (status < 200 || status >= 300) {
  116. const err = new Error(result?.msg || result?.message || `请求失败 (${status})`)
  117. err.code = result?.code || status
  118. err.response = result
  119. reject(err)
  120. return
  121. }
  122. resolve(result || {})
  123. })
  124. })
  125. req.setTimeout(15000, () => {
  126. req.destroy(new Error(`请求超时: ${target.origin}`))
  127. })
  128. req.on('error', reject)
  129. if (body) req.write(body)
  130. req.end()
  131. })
  132. }
  133. /**
  134. * 发起请求
  135. */
  136. async function request(path, options = {}) {
  137. const { method = 'GET', params, body } = options
  138. /**
  139. * 浏览器端走 /api 代理(规避跨域,对齐 V2 H5 的 BASE_URL='/api');
  140. * 服务端(SSR)直接请求后端,避免内部再走一次 Nitro 代理时
  141. * form 请求体丢失导致后端报「client必填」。
  142. */
  143. const runtimeConfig = useRuntimeConfig()
  144. const apiBase = import.meta.server
  145. ? (runtimeConfig.apiServerBase || runtimeConfig.public.apiBase)
  146. : runtimeConfig.public.apiBase
  147. const url = import.meta.server ? apiBase + path : '/api' + path
  148. const timestamp = Math.floor(Date.now() / 1000)
  149. const { timeStr, nonce } = getNonce()
  150. // Header 签名(对齐 V2 header 注入)
  151. const reqHeaders = {
  152. 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
  153. timestr: timeStr,
  154. nonce,
  155. }
  156. // 每次请求时读取最新登录态(共享 state 优先,兼容 SSR 时的 cookie)
  157. const token = tokenState.value || tokenCookie.value || ''
  158. if (token) reqHeaders.token = token
  159. // Body 公共参数(对齐 V2 data 注入)
  160. const commonParams = {
  161. token: token || '',
  162. uid: openIdState.value || openIdCookie.value || '',
  163. mac: macCookie.value || SSR_MAC,
  164. base_timestamp: timestamp,
  165. client: getClientId(),
  166. source: sourceCookie.value || '',
  167. client_ios: getClientIos(),
  168. version: '1.0.0',
  169. version_code: 1,
  170. }
  171. const fetchOptions = {
  172. method,
  173. headers: reqHeaders,
  174. }
  175. if (import.meta.client) {
  176. fetchOptions.onResponseError = ({ response }) => {
  177. console.error('[useApi] HTTP Error', response.status, url)
  178. }
  179. }
  180. if (method === 'GET') {
  181. // GET: 参数拼接到 URL
  182. const allParams = { ...commonParams, ...(params || {}) }
  183. // 去除空字符串
  184. Object.keys(allParams).forEach((k) => {
  185. if (allParams[k] === '') delete allParams[k]
  186. })
  187. const queryStr = new URLSearchParams(allParams).toString()
  188. // GET 请求通过 server proxy 时,参数在 query 里
  189. const finalUrl = url + (queryStr ? '?' + queryStr : '')
  190. const result = import.meta.server
  191. ? await fetchJson(finalUrl, fetchOptions)
  192. : await $fetch(finalUrl, fetchOptions)
  193. return handleResult(result)
  194. } else {
  195. // POST: body 为 x-www-form-urlencoded
  196. const postBody = { ...commonParams, ...(body || {}) }
  197. // 去除空字符串
  198. Object.keys(postBody).forEach((k) => {
  199. if (postBody[k] === '') delete postBody[k]
  200. })
  201. fetchOptions.body = new URLSearchParams(postBody).toString()
  202. const result = import.meta.server
  203. ? await fetchJson(url, fetchOptions)
  204. : await $fetch(url, fetchOptions)
  205. return handleResult(result)
  206. }
  207. }
  208. /**
  209. * POST SSE 流式请求。
  210. * EventSource 不支持 POST,因此使用 fetch + ReadableStream 读取事件。
  211. */
  212. async function postStream(path, body = {}, handlers = {}) {
  213. if (import.meta.server) throw new Error('流式请求仅支持浏览器端')
  214. const { timeStr, nonce } = getNonce()
  215. const token = tokenState.value || tokenCookie.value || ''
  216. const requestHeaders = {
  217. 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
  218. Accept: 'text/event-stream',
  219. timestr: timeStr,
  220. nonce,
  221. }
  222. if (token) requestHeaders.token = token
  223. const postBody = {
  224. token: token || '',
  225. uid: openIdState.value || openIdCookie.value || '',
  226. mac: macCookie.value || SSR_MAC,
  227. base_timestamp: Math.floor(Date.now() / 1000),
  228. client: getClientId(),
  229. source: sourceCookie.value || '',
  230. client_ios: getClientIos(),
  231. version: '1.0.0',
  232. version_code: 1,
  233. ...body,
  234. stream: 1,
  235. }
  236. Object.keys(postBody).forEach((key) => {
  237. if (postBody[key] === '') delete postBody[key]
  238. })
  239. const response = await fetch('/api' + path, {
  240. method: 'POST',
  241. headers: requestHeaders,
  242. body: new URLSearchParams(postBody).toString(),
  243. signal: handlers.signal,
  244. })
  245. const contentType = response.headers.get('content-type') || ''
  246. // 鉴权、额度校验等错误可能在 SSE 响应头建立前以普通 JSON 返回。
  247. if (!contentType.includes('text/event-stream')) {
  248. const responseText = await response.text()
  249. let payload = null
  250. try { payload = JSON.parse(responseText) } catch {}
  251. if (payload && typeof payload.code !== 'undefined') {
  252. const result = handleResult(payload)
  253. return result.data || result
  254. }
  255. const error = new Error(responseText || `流式请求失败 (${response.status})`)
  256. error.code = response.status
  257. throw error
  258. }
  259. if (!response.ok || !response.body) {
  260. const error = new Error(`流式请求失败 (${response.status})`)
  261. error.code = response.status
  262. throw error
  263. }
  264. const reader = response.body.getReader()
  265. const decoder = new TextDecoder('utf-8')
  266. let buffer = ''
  267. let doneData = null
  268. let streamError = null
  269. const consumeBlock = (block) => {
  270. const packet = parseSseBlock(block)
  271. if (!packet) return
  272. const data = packet.data || {}
  273. if (packet.event === 'start') handlers.onStart?.(data)
  274. else if (packet.event === 'message') handlers.onMessage?.(data)
  275. else if (packet.event === 'done') {
  276. doneData = data
  277. handlers.onDone?.(data)
  278. } else if (packet.event === 'error') {
  279. streamError = new Error(data.msg || '生成失败,请稍后重试')
  280. streamError.code = data.code || 1000
  281. handlers.onError?.(data)
  282. }
  283. }
  284. while (true) {
  285. const { done, value } = await reader.read()
  286. buffer += decoder.decode(value || new Uint8Array(0), { stream: !done }).replace(/\r\n/g, '\n')
  287. const blocks = buffer.split('\n\n')
  288. buffer = blocks.pop() || ''
  289. blocks.forEach(consumeBlock)
  290. if (done) break
  291. if (streamError) {
  292. await reader.cancel()
  293. break
  294. }
  295. }
  296. if (buffer.trim()) consumeBlock(buffer)
  297. if (streamError) throw streamError
  298. if (!doneData) throw new Error('流式响应意外中断,请重试')
  299. return doneData
  300. }
  301. /**
  302. * 统一处理响应
  303. */
  304. function handleResult(result) {
  305. if (result.code === 1009) {
  306. // 登录失效 → 清理登录态并触发登录弹窗
  307. if (import.meta.client) {
  308. const { logout, showLogin } = useUser()
  309. logout()
  310. showLogin.value = true
  311. }
  312. const err = new Error(result.msg || '登录已失效,请重新登录')
  313. err.code = 1009
  314. throw err
  315. }
  316. if (result.code !== 0) {
  317. const err = new Error(result.msg || '请求失败')
  318. err.code = result.code || 1000
  319. throw err
  320. }
  321. return result
  322. }
  323. return {
  324. /** GET 请求 */
  325. get: (path, params) => request(path, { method: 'GET', params }),
  326. /** POST 请求 (x-www-form-urlencoded) */
  327. post: (path, body) => request(path, { method: 'POST', body }),
  328. /** POST SSE 流式请求 */
  329. postStream,
  330. /** 通用请求 */
  331. request,
  332. }
  333. }