|
- /**
- * 统一 API 请求封装,完全对齐 ai_uniapp_v2/utils/request.js
- *
- * 请求路径走 /api 前缀 → server/api/[...].js 服务端代理
- * /api/user/info → 代理到 https://api.jiefuku.com/user/info
- *
- * 自动注入(对齐 V2):
- * Header: { token, timestr, nonce }
- * Body: { token, uid, mac, base_timestamp, client, source, client_ios, version, version_code }
- *
- * 后端响应规范:
- * { code: 0, data: {...}, list: [...], msg: '...' } // 成功
- * { code: 1000, data: null, msg: '...' } // 业务异常
- * { code: 1009, data: null, msg: '...' } // 登录失效
- */
- import md5 from '~/utils/md5'
-
- function parseSseBlock(block) {
- let eventName = 'message'
- const dataLines = []
-
- String(block || '').split('\n').forEach((rawLine) => {
- const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine
- if (!line || line.startsWith(':')) return
- const separator = line.indexOf(':')
- const field = separator >= 0 ? line.slice(0, separator) : line
- let value = separator >= 0 ? line.slice(separator + 1) : ''
- if (value.startsWith(' ')) value = value.slice(1)
- if (field === 'event') eventName = value || 'message'
- if (field === 'data') dataLines.push(value)
- })
-
- if (!dataLines.length) return null
- const dataText = dataLines.join('\n')
- try {
- return { event: eventName, data: JSON.parse(dataText) }
- } catch {
- return { event: eventName, data: { content: dataText } }
- }
- }
-
- export function useApi() {
- const tokenCookie = useCookie('token', { path: '/' })
- const macCookie = useCookie('mac', { maxAge: 365 * 24 * 3600, path: '/' })
- const openIdCookie = useCookie('open_id', { path: '/' })
- const sourceCookie = useCookie('source', { path: '/' })
- const { getClientId, getClientIos } = useClientDevice()
-
- /**
- * 登录态从 useState 共享状态读取(与 useUser 同源)。
- * 不能只依赖这里的 useCookie ref:页面若在登录前就创建了 useApi,
- * 该 ref 感知不到后续登录写入,会导致请求不带 token → 1009。
- */
- const tokenState = useState('auth_token', () => tokenCookie.value || '')
- const openIdState = useState('auth_open_id', () => openIdCookie.value || '')
-
- // mac 缺失时自动生成
- if (import.meta.client && !macCookie.value) {
- macCookie.value = generateUUID()
- }
-
- /**
- * mac 兜底值。
- * SSR(含搜索引擎爬虫首访)时还没有 mac Cookie,而后端 base.js 要求 mac 必填,
- * 缺失会直接返回「mac必填」,导致服务端渲染取不到数据、SEO 抓不到内容。
- */
- const SSR_MAC = 'ssr-render'
-
- function generateUUID() {
- return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
- const r = (Math.random() * 16) | 0
- const v = c === 'x' ? r : (r & 0x3) | 0x8
- return v.toString(16)
- })
- }
-
- /**
- * 生成 nonce + timestr(对齐 V2 getNonce)
- */
- function getNonce() {
- const timeStr = Date.now().toString()
- const str = md5(timeStr)
- const nonce = str.substring(4, 14)
- return { timeStr, nonce }
- }
-
- /**
- * SSR JSON 请求使用 Node 原生 http/https。
- *
- * 线上运行环境可能与 Nuxt 构建环境的 Node 小版本不同,Node 23 下曾出现
- * `Headers is not a constructor`,导致服务端渲染无法取到文章数据。这里不经过
- * fetch/ofetch/Headers,避免运行时兼容层影响 SEO 页面。
- */
- async function fetchJson(url, options = {}) {
- const target = new URL(url)
- const transport = target.protocol === 'https:'
- ? await import('node:https')
- : await import('node:http')
- const body = typeof options.body === 'string' ? options.body : ''
- const headers = { ...(options.headers || {}) }
- if (body && !Object.keys(headers).some(key => key.toLowerCase() === 'content-length')) {
- headers['Content-Length'] = Buffer.byteLength(body)
- }
-
- return await new Promise((resolve, reject) => {
- const req = transport.request(target, {
- method: options.method || 'GET',
- headers,
- }, (response) => {
- const chunks = []
- response.on('data', chunk => chunks.push(Buffer.from(chunk)))
- response.on('end', () => {
- const status = Number(response.statusCode || 0)
- const text = Buffer.concat(chunks).toString('utf8')
- let result = null
-
- if (text) {
- try {
- result = JSON.parse(text)
- } catch {
- const err = new Error(text || `请求失败 (${status})`)
- err.code = status
- reject(err)
- return
- }
- }
-
- if (status < 200 || status >= 300) {
- const err = new Error(result?.msg || result?.message || `请求失败 (${status})`)
- err.code = result?.code || status
- err.response = result
- reject(err)
- return
- }
-
- resolve(result || {})
- })
- })
-
- req.setTimeout(15000, () => {
- req.destroy(new Error(`请求超时: ${target.origin}`))
- })
- req.on('error', reject)
- if (body) req.write(body)
- req.end()
- })
- }
-
- /**
- * 发起请求
- */
- async function request(path, options = {}) {
- const { method = 'GET', params, body } = options
-
- /**
- * 浏览器端走 /api 代理(规避跨域,对齐 V2 H5 的 BASE_URL='/api');
- * 服务端(SSR)直接请求后端,避免内部再走一次 Nitro 代理时
- * form 请求体丢失导致后端报「client必填」。
- */
- const runtimeConfig = useRuntimeConfig()
- const apiBase = import.meta.server
- ? (runtimeConfig.apiServerBase || runtimeConfig.public.apiBase)
- : runtimeConfig.public.apiBase
- const url = import.meta.server ? apiBase + path : '/api' + path
-
- const timestamp = Math.floor(Date.now() / 1000)
- const { timeStr, nonce } = getNonce()
-
- // Header 签名(对齐 V2 header 注入)
- const reqHeaders = {
- 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
- timestr: timeStr,
- nonce,
- }
- // 每次请求时读取最新登录态(共享 state 优先,兼容 SSR 时的 cookie)
- const token = tokenState.value || tokenCookie.value || ''
- if (token) reqHeaders.token = token
-
- // Body 公共参数(对齐 V2 data 注入)
- const commonParams = {
- token: token || '',
- uid: openIdState.value || openIdCookie.value || '',
- mac: macCookie.value || SSR_MAC,
- base_timestamp: timestamp,
- client: getClientId(),
- source: sourceCookie.value || '',
- client_ios: getClientIos(),
- version: '1.0.0',
- version_code: 1,
- }
-
- const fetchOptions = {
- method,
- headers: reqHeaders,
- }
-
- if (import.meta.client) {
- fetchOptions.onResponseError = ({ response }) => {
- console.error('[useApi] HTTP Error', response.status, url)
- }
- }
-
- if (method === 'GET') {
- // GET: 参数拼接到 URL
- const allParams = { ...commonParams, ...(params || {}) }
- // 去除空字符串
- Object.keys(allParams).forEach((k) => {
- if (allParams[k] === '') delete allParams[k]
- })
- const queryStr = new URLSearchParams(allParams).toString()
- // GET 请求通过 server proxy 时,参数在 query 里
- const finalUrl = url + (queryStr ? '?' + queryStr : '')
- const result = import.meta.server
- ? await fetchJson(finalUrl, fetchOptions)
- : await $fetch(finalUrl, fetchOptions)
- return handleResult(result)
- } else {
- // POST: body 为 x-www-form-urlencoded
- const postBody = { ...commonParams, ...(body || {}) }
- // 去除空字符串
- Object.keys(postBody).forEach((k) => {
- if (postBody[k] === '') delete postBody[k]
- })
- fetchOptions.body = new URLSearchParams(postBody).toString()
-
- const result = import.meta.server
- ? await fetchJson(url, fetchOptions)
- : await $fetch(url, fetchOptions)
- return handleResult(result)
- }
- }
-
- /**
- * POST SSE 流式请求。
- * EventSource 不支持 POST,因此使用 fetch + ReadableStream 读取事件。
- */
- async function postStream(path, body = {}, handlers = {}) {
- if (import.meta.server) throw new Error('流式请求仅支持浏览器端')
-
- const { timeStr, nonce } = getNonce()
- const token = tokenState.value || tokenCookie.value || ''
- const requestHeaders = {
- 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
- Accept: 'text/event-stream',
- timestr: timeStr,
- nonce,
- }
- if (token) requestHeaders.token = token
-
- const postBody = {
- token: token || '',
- uid: openIdState.value || openIdCookie.value || '',
- mac: macCookie.value || SSR_MAC,
- base_timestamp: Math.floor(Date.now() / 1000),
- client: getClientId(),
- source: sourceCookie.value || '',
- client_ios: getClientIos(),
- version: '1.0.0',
- version_code: 1,
- ...body,
- stream: 1,
- }
- Object.keys(postBody).forEach((key) => {
- if (postBody[key] === '') delete postBody[key]
- })
-
- const response = await fetch('/api' + path, {
- method: 'POST',
- headers: requestHeaders,
- body: new URLSearchParams(postBody).toString(),
- signal: handlers.signal,
- })
- const contentType = response.headers.get('content-type') || ''
-
- // 鉴权、额度校验等错误可能在 SSE 响应头建立前以普通 JSON 返回。
- if (!contentType.includes('text/event-stream')) {
- const responseText = await response.text()
- let payload = null
- try { payload = JSON.parse(responseText) } catch {}
- if (payload && typeof payload.code !== 'undefined') {
- const result = handleResult(payload)
- return result.data || result
- }
- const error = new Error(responseText || `流式请求失败 (${response.status})`)
- error.code = response.status
- throw error
- }
-
- if (!response.ok || !response.body) {
- const error = new Error(`流式请求失败 (${response.status})`)
- error.code = response.status
- throw error
- }
-
- const reader = response.body.getReader()
- const decoder = new TextDecoder('utf-8')
- let buffer = ''
- let doneData = null
- let streamError = null
-
- const consumeBlock = (block) => {
- const packet = parseSseBlock(block)
- if (!packet) return
- const data = packet.data || {}
- if (packet.event === 'start') handlers.onStart?.(data)
- else if (packet.event === 'message') handlers.onMessage?.(data)
- else if (packet.event === 'done') {
- doneData = data
- handlers.onDone?.(data)
- } else if (packet.event === 'error') {
- streamError = new Error(data.msg || '生成失败,请稍后重试')
- streamError.code = data.code || 1000
- handlers.onError?.(data)
- }
- }
-
- while (true) {
- const { done, value } = await reader.read()
- buffer += decoder.decode(value || new Uint8Array(0), { stream: !done }).replace(/\r\n/g, '\n')
- const blocks = buffer.split('\n\n')
- buffer = blocks.pop() || ''
- blocks.forEach(consumeBlock)
- if (done) break
- if (streamError) {
- await reader.cancel()
- break
- }
- }
- if (buffer.trim()) consumeBlock(buffer)
-
- if (streamError) throw streamError
- if (!doneData) throw new Error('流式响应意外中断,请重试')
- return doneData
- }
-
- /**
- * 统一处理响应
- */
- function handleResult(result) {
- if (result.code === 1009) {
- // 登录失效 → 清理登录态并触发登录弹窗
- if (import.meta.client) {
- const { logout, showLogin } = useUser()
- logout()
- showLogin.value = true
- }
- const err = new Error(result.msg || '登录已失效,请重新登录')
- err.code = 1009
- throw err
- }
-
- if (result.code !== 0) {
- const err = new Error(result.msg || '请求失败')
- err.code = result.code || 1000
- throw err
- }
-
- return result
- }
-
- return {
- /** GET 请求 */
- get: (path, params) => request(path, { method: 'GET', params }),
- /** POST 请求 (x-www-form-urlencoded) */
- post: (path, body) => request(path, { method: 'POST', body }),
- /** POST SSE 流式请求 */
- postStream,
- /** 通用请求 */
- request,
- }
- }
|