/** * 统一 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 } } /** * 发起请求 */ async function request(path, options = {}) { const { method = 'GET', params, body } = options /** * 浏览器端走 /api 代理(规避跨域,对齐 V2 H5 的 BASE_URL='/api'); * 服务端(SSR)直接请求后端,避免内部再走一次 Nitro 代理时 * form 请求体丢失导致后端报「client必填」。 */ const apiBase = useRuntimeConfig().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, 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() fetchOptions.query = allParams // GET 请求通过 server proxy 时,参数在 query 里 const finalUrl = url + (queryStr ? '?' + queryStr : '') const result = 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 = 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, } }