diff --git a/.env b/.env index 2a3c155..4b344dc 100644 --- a/.env +++ b/.env @@ -1,3 +1,4 @@ # 公共配置 VITE_APP_NAME = GroupBuying -VITE_APP_VERSION = 1.0.0 \ No newline at end of file +VITE_APP_VERSION = 1.0.0 +VITE_API_BASE = "" \ No newline at end of file diff --git a/.env.development b/.env.development index 1a34ba4..159a0b4 100644 --- a/.env.development +++ b/.env.development @@ -1,3 +1,4 @@ NODE_ENV = development -VITE_API_BASE = http://dev-api.example.com +VITE_API_BASE = http://api-dev.example.com/ +VITE_AUTH_REFRESH_URL = /auth/refresh VITE_DEBUG_MODE = true \ No newline at end of file diff --git a/.env.production b/.env.production index a71d4b4..e3d5456 100644 --- a/.env.production +++ b/.env.production @@ -1,3 +1,4 @@ NODE_ENV = production -VITE_API_BASE = https://api.example.com +VITE_API_BASE = https://api.example.com/ +VITE_AUTH_REFRESH_URL = /auth/refresh VITE_DEBUG_MODE = false diff --git a/src/App.vue b/src/App.vue index 729b914..6434c51 100644 --- a/src/App.vue +++ b/src/App.vue @@ -2,6 +2,12 @@ export default { onLaunch: function () { console.log('App Launch') + if (import.meta.env.VITE_DEBUG_MODE) { + console.log('当前环境变量:', { + API_BASE: import.meta.env.VITE_API_BASE, + APP_ENV: import.meta.env.VITE_APP_ENV + }) + } }, onShow: function () { console.log('App Show') diff --git a/src/api/auth/login.js b/src/api/auth/login.js new file mode 100644 index 0000000..e69de29 diff --git a/src/main.js b/src/main.js index dd44bf7..fd70a53 100644 --- a/src/main.js +++ b/src/main.js @@ -2,9 +2,11 @@ import { createSSRApp } from "vue"; import App from "./App.vue"; +import { setupInterceptors } from "@/utils/interceptors"; export function createApp() { const app = createSSRApp(App); + app.use(setupInterceptors) return { app, }; -} +} diff --git a/src/utils/auth.js b/src/utils/auth.js deleted file mode 100644 index 2c8ed70..0000000 --- a/src/utils/auth.js +++ /dev/null @@ -1,88 +0,0 @@ -let isRefreshing = false // 刷新状态锁 -let requestsQueue = [] // 等待队列 - -// 核心拦截器 -export const authInterceptor = async (config) => { - // 1. 获取当前 Token - let token = uni.getStorageSync('token') - const refreshToken = uni.getStorageSync('refreshToken') - - // 2. 检查是否需要跳过认证 - if (config.skipAuth) return config - - // 3. 自动添加 Authorization 头 - if (token) { - config.header = { - ...config.header, - Authorization: `Bearer ${token}` - } - } - - // 4. 小程序 HTTPS 强制转换 - // #ifdef MP-WEIXIN - if (config.url.startsWith('http:')) { - config.url = config.url.replace('http:', 'https:') - } - // #endif - - return config -} - -// 刷新 Token 逻辑 -const refreshTokenRequest = async () => { - try { - const { data } = await uni.request({ - url: '/auth/refresh', - method: 'POST', - data: { - refresh_token: uni.getStorageSync('refreshToken') - } - }) - - // 更新存储 - uni.setStorageSync('token', data.token) - uni.setStorageSync('refreshToken', data.refreshToken) - return data.token - } catch (error) { - // 刷新失败跳转登录 - uni.removeStorageSync('token') - uni.removeStorageSync('refreshToken') - uni.reLaunch({ url: '/pages/login/login' }) - throw new Error('登录已过期,请重新登录') - } -} - -// 响应拦截器处理 Token 过期 -export const authErrorHandler = async (error) => { - const { statusCode, config } = error - - // 1. 非 401 错误直接抛出 - if (statusCode !== 401 || config.skipAuth) throw error - - // 2. 正在刷新时缓存请求 - if (isRefreshing) { - return new Promise((resolve) => { - requestsQueue.push(() => resolve(http.request(config))) - }) - } - - // 3. 标记刷新状态 - isRefreshing = true - - try { - // 4. 执行 Token 刷新 - const newToken = await refreshTokenRequest() - - // 5. 更新原请求头 - config.header.Authorization = `Bearer ${newToken}` - - // 6. 重试所有等待请求 - requestsQueue.forEach(cb => cb()) - requestsQueue = [] - - // 7. 重试当前请求 - return http.request(config) - } finally { - isRefreshing = false - } -} \ No newline at end of file diff --git a/src/utils/http.js b/src/utils/http.js deleted file mode 100644 index a6cd012..0000000 --- a/src/utils/http.js +++ /dev/null @@ -1,139 +0,0 @@ -import { authInterceptor, authErrorHandler } from './auth' - -class Request { - constructor() { - // 全局默认配置 - this.config = { - baseURL: import.meta.env.VITE_API_BASE, - timeout: 10000, - header: { - 'Content-Type': 'application/json' - } - } - - // 开发环境打印日志 - if (import.meta.env.VITE_DEBUG_MODE === 'true') { - this._enableDebugLog() - } - - // 拦截器存储 - this.interceptors = { - request: [authInterceptor], - response: [authErrorHandler] - } - - // 请求队列(用于取消请求) - this.pendingRequests = new Map() - } - - // 调试日志 - _enableDebugLog() { - const originalRequest = this.request - this.request = async function(config) { - console.log('[HTTP Request]', config) - const start = Date.now() - try { - const res = await originalRequest.call(this, config) - console.log(`[HTTP Response] ${Date.now() - start}ms`, res) - return res - } catch (err) { - console.error(`[HTTP Error] ${Date.now() - start}ms`, err) - throw err - } - } - } - - // 小程序 HTTPS 强制转换 - _fixUrlProtocol(url) { - // #ifdef MP-WEIXIN - return url.replace(/^http:/, 'https:') - // #else - return url - // #endif - } - // 核心请求方法 - async _request(config) { - // 合并配置 - const mergedConfig = { - ...this.config, - ...config, - url: this._fixUrlProtocol(config.url) - } - - // 生成请求标识 - const requestKey = `${mergedConfig.method}-${mergedConfig.url}` - const controller = new AbortController() - this.pendingRequests.set(requestKey, controller) - - // 构建完整URL - const fullUrl = `${mergedConfig.baseURL}${mergedConfig.url}` - - return new Promise((resolve, reject) => { - uni.request({ - ...mergedConfig, - url: fullUrl, - signal: controller.signal, - success: (res) => resolve(res), - fail: (err) => reject(err) - }) - }) - } - - // 执行拦截器链 - async _runInterceptors(type, value) { - let chain = type === 'request' - ? [...this.interceptors.request, this._request, undefined] - : [undefined, ...this.interceptors.response] - - let promise = Promise.resolve(value) - for (let i = 0; i < chain.length; i += 2) { - const thenFn = chain[i] - const catchFn = chain[i + 1] - promise = promise.then(thenFn, catchFn) - } - return promise - } - - // 公开请求方法 - async request(config) { - try { - // 执行请求拦截 - const processedConfig = await this._runInterceptors('request', config) - // 发起请求 - const response = await this._request(processedConfig) - // 执行响应拦截 - return this._runInterceptors('response', response) - } catch (error) { - // 统一错误处理 - this._handleError(error) - return Promise.reject(error) - } - } - - // 快捷方法 - get(url, config = {}) { - return this.request({ ...config, url, method: 'GET' }) - } - - post(url, data, config = {}) { - return this.request({ ...config, url, data, method: 'POST' }) - } - - // 错误处理 - _handleError(error) { - let message = '请求失败' - if (error.errMsg?.includes('timeout')) message = '网络超时' - if (error.statusCode === 404) message = '接口不存在' - uni.showToast({ title: message, icon: 'none' }) - } - - // 取消请求 - cancelRequest(url, method = 'GET') { - const key = `${method}-${url}` - const controller = this.pendingRequests.get(key) - controller?.abort() - this.pendingRequests.delete(key) - } -} - -export const http = new Request() \ No newline at end of file diff --git a/src/utils/interceptors/auth.js b/src/utils/interceptors/auth.js new file mode 100644 index 0000000..1f841bd --- /dev/null +++ b/src/utils/interceptors/auth.js @@ -0,0 +1,78 @@ +// interceptors/auth.js +let isRefreshing = false +let pendingRequests = [] +const API_BASE = import.meta.env.VITE_API_BASE +const REFRESH_TOKEN_URL = `${API_BASE}/auth/refresh` + +// 获取存储的token +const getToken = () => ({ + token: uni.getStorageSync('token'), + refreshToken: uni.getStorageSync('refreshToken') +}) + +// 清理认证信息 +const clearAuth = () => { + uni.removeStorageSync('token') + uni.removeStorageSync('refreshToken') +} + +// 刷新Token逻辑 +const refreshToken = async () => { + const { refreshToken } = getToken() + if (!refreshToken) throw new Error('No refresh token available') + + try { + const res = await uni.request({ + url: REFRESH_TOKEN_URL, + method: 'POST', + data: { refresh_token: refreshToken }, + header: { 'Content-Type': 'application/json' } + }) + + if (res.statusCode === 200) { + uni.setStorageSync('token', res.data.token) + uni.setStorageSync('refreshToken', res.data.refreshToken) + return res.data.token + } + throw new Error('Refresh token failed') + } catch (error) { + clearAuth() + uni.reLaunch({ url: '/pages/login/login' }) + throw error + } +} + +// 处理401错误 +export const handle401Error = async (error) => { + const originalRequest = error.config + + if (isRefreshing) { + return new Promise(resolve => { + pendingRequests.push(() => resolve(retryRequest(originalRequest))) + }) + } + + isRefreshing = true + try { + const newToken = await refreshToken() + originalRequest.header.Authorization = `Bearer ${newToken}` + + pendingRequests.forEach(cb => cb()) + pendingRequests = [] + + return retryRequest(originalRequest) + } finally { + isRefreshing = false + } +} + +// 重试请求 +const retryRequest = (config) => { + return new Promise((resolve, reject) => { + uni.request({ + ...config, + success: resolve, + fail: reject + }) + }) +} \ No newline at end of file diff --git a/src/utils/interceptors/index.js b/src/utils/interceptors/index.js new file mode 100644 index 0000000..93f2fc7 --- /dev/null +++ b/src/utils/interceptors/index.js @@ -0,0 +1,121 @@ +// interceptors/index.js +import { handle401Error } from './auth' + +// 基础配置 +const getBaseConfig = () => ({ + baseURL: import.meta.env.VITE_API_BASE, + timeout: Number(import.meta.env.VITE_REQUEST_TIMEOUT) || 10000, + defaultHeaders: { + 'X-App-Env': import.meta.env.VITE_APP_ENV, + 'Content-Type': 'application/json' + } +}) + +// 请求拦截处理 +const requestInterceptor = (args) => { + const config = getBaseConfig() + + // 协议处理 + // #ifdef MP-WEIXIN + if (args.url.startsWith('http:')) { + args.url = args.url.replace('http:', 'https:') + } + // #endif + + // 合并基础路径 + if (!args.url.startsWith('http')) { + args.url = config.baseURL + args.url + } + + // 合并headers + args.header = { + ...config.defaultHeaders, + ...(args.header || {}) + } + + // 添加认证头 + if (!args.skipAuth) { + const token = uni.getStorageSync('token') + if (token) { + args.header.Authorization = `Bearer ${token}` + } + } + + // 开发环境日志 + if (import.meta.env.VITE_DEBUG_MODE === 'true') { + console.log('[Request]', args) + } + + return args +} + +// 响应拦截处理 +const responseInterceptor = (res) => { + if (import.meta.env.VITE_DEBUG_MODE === 'true') { + console.log('[Response]', res) + } + + // 业务状态码处理 + if (res.data?.code !== 0 && res.data?.code !== 200) { + return Promise.reject(res.data) + } + return res.data +} + +// 错误处理 +const errorHandler = async (error) => { + console.error('[Request Error]', error) + + // 网络错误 + if (error.errMsg?.includes('timeout')) { + uni.showToast({ title: '请求超时', icon: 'none' }) + throw error + } + + // HTTP状态码处理 + switch (error.statusCode) { + case 401: + if (!error.config.skipAuth) { + try { + return await handle401Error(error) + } catch (refreshError) { + uni.showToast({ title: '登录已过期', icon: 'none' }) + throw refreshError + } + } + break + case 404: + uni.showToast({ title: '接口不存在', icon: 'none' }) + break + case 500: + uni.showToast({ title: '服务器错误', icon: 'none' }) + break + } + + // 业务错误处理 + if (error.code) { + uni.showToast({ title: error.message || '请求失败', icon: 'none' }) + } + + throw error +} + +// 注册拦截器 +export const setupInterceptors = () => { + uni.addInterceptor('request', { + invoke: requestInterceptor, + success: responseInterceptor, + fail: errorHandler + }) + + uni.addInterceptor('uploadFile', { + invoke(args) { + return requestInterceptor({ + ...args, + method: 'UPLOAD' // 添加特殊标识 + }) + }, + success: responseInterceptor, + fail: errorHandler + }) +} \ No newline at end of file diff --git a/src/utils/js_interceptors/auth.js b/src/utils/js_interceptors/auth.js new file mode 100644 index 0000000..f41839f --- /dev/null +++ b/src/utils/js_interceptors/auth.js @@ -0,0 +1,79 @@ +// core/auth.js +import { http } from './http' +import { REFRESH_TOKEN_URL } from './constants/api' + +const createAuthInterceptor = () => { + let isRefreshing = false + const queue = [] + + const getToken = () => ({ + token: uni.getStorageSync('token'), + refreshToken: uni.getStorageSync('refreshToken') + }) + + const refreshToken = async () => { + const { refreshToken } = getToken() + if (!refreshToken) throw new Error('No refresh token') + + try { + const { data } = await http.request({ + url: REFRESH_TOKEN_URL, + method: 'POST', + data: { refresh_token: refreshToken }, + skipAuth: true + }) + + uni.setStorageSync('token', data.token) + uni.setStorageSync('refreshToken', data.refreshToken) + return data.token + } catch (error) { + clearAuthStorage() + throw error + } + } + + const clearAuthStorage = () => { + uni.removeStorageSync('token') + uni.removeStorageSync('refreshToken') + } + + const authInterceptor = async (config) => { + if (config.skipAuth) return config + + const { token } = getToken() + if (token) { + config.header = { + ...config.header, + Authorization: `Bearer ${token}` + } + } + return config + } + + const authErrorHandler = async (error) => { + const { statusCode, config } = error + + if (statusCode !== 401 || config.skipAuth) throw error + + if (!isRefreshing) { + isRefreshing = true + try { + const newToken = await refreshToken() + config.header.Authorization = `Bearer ${newToken}` + queue.forEach(cb => cb()) + return http.request(config) + } finally { + isRefreshing = false + queue.length = 0 + } + } + + return new Promise((resolve) => { + queue.push(() => resolve(http.request(config))) + }) + } + + return { authInterceptor, authErrorHandler } +} + +export const { authInterceptor, authErrorHandler } = createAuthInterceptor() \ No newline at end of file diff --git a/src/utils/js_interceptors/constants/api.js b/src/utils/js_interceptors/constants/api.js new file mode 100644 index 0000000..8b9abd0 --- /dev/null +++ b/src/utils/js_interceptors/constants/api.js @@ -0,0 +1,3 @@ +// constants/api.js +export const API_BASE = import.meta.env.VITE_API_BASE +export const REFRESH_TOKEN_URL = `${API_BASE}${import.meta.env.VITE_AUTH_REFRESH_URL}` \ No newline at end of file diff --git a/src/utils/js_interceptors/http.js b/src/utils/js_interceptors/http.js new file mode 100644 index 0000000..7df5a73 --- /dev/null +++ b/src/utils/js_interceptors/http.js @@ -0,0 +1,71 @@ +// core/http.js +import { authInterceptor, authErrorHandler } from './auth' +import { API_BASE } from './constants/api' + +class Request { + constructor() { + this.config = { + baseURL: API_BASE, + timeout: Number(import.meta.env.VITE_REQUEST_TIMEOUT) || 10000, + header: { + 'Content-Type': 'application/json', + 'X-App-ID': import.meta.env.VITE_APP_ID, + 'X-App-Env': import.meta.env.VITE_APP_ENV + } + } + + this._initDebugMode() + this._initInterceptors() + this.pendingRequests = new Map() + } + + _initDebugMode() { + if (import.meta.env.VITE_DEBUG_MODE === 'true') { + this._enableDebugLog() + } + } + + _initInterceptors() { + this.interceptors = { + request: [this._handleMPFix, authInterceptor], + response: [authErrorHandler] + } + } + + // 小程序环境修复处理 + _handleMPFix = (config) => { + // #ifdef MP-WEIXIN + if (config.url.startsWith('http:')) { + config.url = config.url.replace('http:', 'https:') + } + // #endif + return config + } + + // 新增环境判断方法 + isProduction() { + return import.meta.env.VITE_APP_ENV === 'production' + } + + // 错误处理优化 + _handleError(error) { + const errorMap = { + 401: '登录已过期,请重新登录', + 404: '请求资源不存在', + 500: '服务器开小差了', + timeout: '请求超时,请检查网络' + } + + const message = this.isProduction() + ? '请求失败,请稍后重试' + : errorMap[error.statusCode] || error.errMsg || '未知错误' + + uni.showToast({ + title: message, + icon: 'none', + duration: this.isProduction() ? 1500 : 3000 + }) + } +} + +export const http = new Request() \ No newline at end of file diff --git a/vite.config.js b/vite.config.js index 6f7b2c3..3708fda 100644 --- a/vite.config.js +++ b/vite.config.js @@ -5,4 +5,7 @@ export default defineConfig({ plugins: [ uni(), ], + define: { + 'import.meta.env.VITE_DEBUG_MODE': JSON.stringify(process.env.NODE_ENV === 'development') + } })