From 8b0cf671b57277a8f1a6358b994f8db0e802bea6 Mon Sep 17 00:00:00 2001 From: dap <15891557205@163.com> Date: Mon, 19 Jan 2026 19:05:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(http):=20=E5=AE=9E=E7=8E=B0=E5=9F=BA?= =?UTF-8?q?=E4=BA=8Ealova=E7=9A=84HTTP=E8=AF=B7=E6=B1=82=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加HTTP请求核心模块,包括异常处理、状态码检查、消息提示等功能 - 新增alova实例配置,支持请求加密、token添加等特性 - 实现401状态码自动处理及登出逻辑 - 添加多种消息提示方式(message/modal/notification) - 支持请求成功/失败的统一处理 - 添加WithMessage方法简化成功提示 - 更新相关依赖配置 --- apps/web-antd/package.json | 3 + apps/web-antd/src/utils/http/checkStatus.ts | 51 +++ apps/web-antd/src/utils/http/exception.ts | 21 ++ apps/web-antd/src/utils/http/helper.ts | 77 ++++ apps/web-antd/src/utils/http/index.ts | 375 ++++++++++++++++++++ apps/web-antd/src/utils/http/popup.ts | 49 +++ apps/web-antd/types/alova.d.ts | 59 +++ cspell.json | 3 +- pnpm-workspace.yaml | 4 +- 9 files changed, 639 insertions(+), 3 deletions(-) create mode 100644 apps/web-antd/src/utils/http/checkStatus.ts create mode 100644 apps/web-antd/src/utils/http/exception.ts create mode 100644 apps/web-antd/src/utils/http/helper.ts create mode 100644 apps/web-antd/src/utils/http/index.ts create mode 100644 apps/web-antd/src/utils/http/popup.ts create mode 100644 apps/web-antd/types/alova.d.ts diff --git a/apps/web-antd/package.json b/apps/web-antd/package.json index cedbed52..965e14d5 100644 --- a/apps/web-antd/package.json +++ b/apps/web-antd/package.json @@ -27,6 +27,7 @@ "#/*": "./src/*" }, "dependencies": { + "@alova/adapter-axios": "^2.0.17", "@ant-design/icons-vue": "^7.0.1", "@tinymce/tinymce-vue": "^6.0.1", "@vben/access": "workspace:*", @@ -44,7 +45,9 @@ "@vben/types": "workspace:*", "@vben/utils": "workspace:*", "@vueuse/core": "catalog:", + "alova": "^3.4.1", "antdv-next": "catalog:", + "axios": "catalog:", "cropperjs": "^1.6.2", "dayjs": "catalog:", "echarts": "^5.5.1", diff --git a/apps/web-antd/src/utils/http/checkStatus.ts b/apps/web-antd/src/utils/http/checkStatus.ts new file mode 100644 index 00000000..eea2604d --- /dev/null +++ b/apps/web-antd/src/utils/http/checkStatus.ts @@ -0,0 +1,51 @@ +import type { AlovaMeta } from '#/../types/alova'; + +import { $t } from '@vben/locales'; + +import { showAntdMessage } from './popup'; + +export function checkStatus( + status: number, + msg: string, + meta: AlovaMeta | undefined, +): void { + let errorMessage = msg; + + switch (status) { + case 400: { + errorMessage = $t('ui.fallback.http.badRequest'); + break; + } + case 401: { + errorMessage = $t('ui.fallback.http.unauthorized'); + break; + } + case 403: { + errorMessage = $t('ui.fallback.http.forbidden'); + break; + } + case 404: { + errorMessage = $t('ui.fallback.http.notFound'); + break; + } + case 408: { + errorMessage = $t('ui.fallback.http.requestTimeout'); + break; + } + default: { + errorMessage = $t('ui.fallback.http.internalServerError'); + } + } + + if ( + errorMessage && + meta && + !['none', undefined].includes(meta.showErrorMessage) + ) { + showAntdMessage({ + meta, + message: errorMessage, + type: 'error', + }); + } +} diff --git a/apps/web-antd/src/utils/http/exception.ts b/apps/web-antd/src/utils/http/exception.ts new file mode 100644 index 00000000..8aad9255 --- /dev/null +++ b/apps/web-antd/src/utils/http/exception.ts @@ -0,0 +1,21 @@ +/** + * 业务异常抛出 即业务状态码不为200时抛出 + */ +export class BusinessException extends Error { + code: number; + + constructor(code: number, message: string) { + super(message); + this.code = code; + } +} + +/** + * 定义一个401专用异常 用于可能会用到的区分场景? + */ +export class UnauthorizedException extends Error {} + +/** + * logout这种接口都返回401 抛出这个异常 + */ +export class ImpossibleReturn401Exception extends Error {} diff --git a/apps/web-antd/src/utils/http/helper.ts b/apps/web-antd/src/utils/http/helper.ts new file mode 100644 index 00000000..616a536a --- /dev/null +++ b/apps/web-antd/src/utils/http/helper.ts @@ -0,0 +1,77 @@ +import { $t } from '@vben/locales'; + +import { useAuthStore } from '#/store'; + +import { + ImpossibleReturn401Exception, + UnauthorizedException, +} from './exception'; + +/** + * @description: contentType + */ +export const ContentTypeEnum = { + // form-data upload + FORM_DATA: 'multipart/form-data;charset=UTF-8', + // form-data qs + FORM_URLENCODED: 'application/x-www-form-urlencoded;charset=UTF-8', + // json + JSON: 'application/json;charset=UTF-8', +} as const; + +/** + * 是否已经处在登出过程中了 一个标志位 + * 主要是防止一个页面会请求多个api 都401 会导致登出执行多次 + */ +let isLogoutProcessing = false; +/** + * 防止 调用logout接口 logout又返回401 然后又走到Logout逻辑死循环 + */ +let lockLogoutRequest = false; + +/** + * 登出逻辑 两个地方用到 提取出来 + * @throws UnauthorizedException 抛出特定的异常 + */ +export function handleUnauthorizedLogout() { + const timeoutMsg = $t('http.loginTimeout'); + /** + * lock 不再请求logout接口 + * 这里已经算异常情况了 + */ + if (lockLogoutRequest) { + throw new UnauthorizedException(timeoutMsg); + } + // 已经在登出过程中 不再执行 + if (isLogoutProcessing) { + throw new UnauthorizedException(timeoutMsg); + } + isLogoutProcessing = true; + const userStore = useAuthStore(); + userStore + .logout() + .catch((error) => { + /** + * logout接口返回了401 + * 做Lock处理 且 该标志位不会复位(因为这种场景出现 系统已经算故障了) + * 因为这已经不符合正常的逻辑了 + */ + if (error instanceof ImpossibleReturn401Exception) { + lockLogoutRequest = true; + if (import.meta.env.DEV) { + window.modal.error({ + title: '提示', + centered: true, + content: + '检测到你的logout接口返回了401, 去检查你的后端配置 这已经不符合正常逻辑(该提示不会在非dev环境弹出)', + }); + } + } + }) + .finally(() => { + window.message.error(timeoutMsg); + isLogoutProcessing = false; + }); + // 不再执行下面逻辑 + throw new UnauthorizedException(timeoutMsg); +} diff --git a/apps/web-antd/src/utils/http/index.ts b/apps/web-antd/src/utils/http/index.ts new file mode 100644 index 00000000..9f3e0c5e --- /dev/null +++ b/apps/web-antd/src/utils/http/index.ts @@ -0,0 +1,375 @@ +import type { AxiosError } from 'axios'; + +import type { HttpResponse } from '@vben/request'; +import type { + BaseAsymmetricEncryption, + BaseSymmetricEncryption, +} from '@vben/utils'; + +import type { AlovaMeta } from '#/../types/alova'; + +import { BUSINESS_SUCCESS_CODE, UNAUTHORIZED_CODE } from '@vben/constants'; +import { useAppConfig } from '@vben/hooks'; +import { preferences } from '@vben/preferences'; +import { stringify } from '@vben/request'; +import { useAccessStore } from '@vben/stores'; +import { + AesEncryption, + decodeBase64, + encodeBase64, + randomStr, + RsaEncryption, +} from '@vben/utils'; + +import { axiosRequestAdapter } from '@alova/adapter-axios'; +import { createAlova } from 'alova'; +import VueHook from 'alova/vue'; +import axios from 'axios'; +import { isEmpty, isNull, merge } from 'lodash-es'; + +import { $t } from '#/locales'; + +import { checkStatus } from './checkStatus'; +import { BusinessException } from './exception'; +import { ContentTypeEnum, handleUnauthorizedLogout } from './helper'; +import { showAntdMessage } from './popup'; + +const { apiURL, clientId, enableEncrypt, rsaPublicKey, rsaPrivateKey } = + useAppConfig(import.meta.env, import.meta.env.PROD); + +/** + * 使用非对称加密的实现 前端已经实现RSA/SM2 + * + * 你可以使用Sm2Encryption来替换 后端也需要同步替换公私钥对 + * + * 后端文件位置: ruoyi-common/ruoyi-common-encrypt/src/main/java/org/dromara/common/encrypt/filter/DecryptRequestBodyWrapper.java + * + * 注意前端sm-crypto库只能支持04开头的公钥! 否则加密会有问题 你可以使用前端的import { logSm2KeyPair } from '@vben/utils';方法来生成 + * 如果你生成的公钥开头不是04 那么不能正常加密 + * 或者使用这个网站来生成: https://tool.hiofd.com/sm2-key-gen/ + */ +const asymmetricEncryption: BaseAsymmetricEncryption = new RsaEncryption({ + publicKey: rsaPublicKey, + privateKey: rsaPrivateKey, +}); + +/** + * 对称加密的实现 AES/SM4 + */ +const symmetricEncryption: BaseSymmetricEncryption = new AesEncryption(); + +const defaultMeta: AlovaMeta = { + showSuccessMessage: 'none', + showErrorMessage: 'message', + withToken: true, + isReturnNativeResponse: false, + isTransformResponse: true, + encrypt: false, +}; + +const alovaInstance = createAlova({ + baseURL: apiURL, + /** + * 请求超时时间 单位毫秒 + * @default 10s + */ + timeout: 10_000, + statesHook: VueHook, + requestAdapter: axiosRequestAdapter(), + // 响应缓存让你可以更好地多次利用服务端数据,而不需要每次请求时都发送请求获取数据。GET 请求将默认设置 5 分钟的内存缓存时间,如果你不需要可以通过以下方式关闭当前请求的缓存。 + // https://alova.js.org/zh-CN/tutorial/getting-started/basic/method#%E5%93%8D%E5%BA%94%E7%BC%93%E5%AD%98 + cacheFor: null, + shareRequest: false, + beforeRequest: (request) => { + // 合并默认meta + const { meta = {}, config } = request; + const mergeMeta = merge({}, defaultMeta, meta); + request.meta = mergeMeta; + + // 全局开启token功能 && token存在 + const accessStore = useAccessStore(); + const token = accessStore.accessToken; + // 添加token + if (request.meta.withToken && token) { + config.headers.Authorization = token ? `Bearer ${token}` : null; + } + + /** + * locale跟后台不一致 需要转换 + */ + const language = preferences.app.locale.replace('-', '_'); + config.headers['Accept-Language'] = language; + config.headers['Content-Language'] = language; + /** + * 添加全局clientId + * 关于header的clientId被错误绑定到实体类 + * https://gitee.com/dapppp/ruoyi-plus-vben5/issues/IC0BDS + */ + config.headers.ClientID = clientId; + /** + * 格式化get/delete参数 + * 如果包含自定义的paramsSerializer则不走此逻辑 + */ + if ( + ['DELETE', 'GET'].includes(request.type?.toUpperCase() || '') && + config.params && + !config.paramsSerializer + ) { + /** + * 1. 格式化参数 微服务在传递区间时间选择(后端的params Map类型参数)需要格式化key 否则接收不到 + * 2. 数组参数需要格式化 后端才能正常接收 会变成arr=1&arr=2&arr=3的格式来接收 + */ + config.paramsSerializer = (params) => + stringify(params, { arrayFormat: 'repeat' }); + } + + const { encrypt } = request.meta; + // 全局开启请求加密功能 && 该请求开启 && 是post/put请求 + if ( + enableEncrypt && + encrypt && + ['POST', 'PUT'].includes(request.type?.toUpperCase() || '') + ) { + // sm4这里改为randomStr(16) + const key = randomStr(32); + const keyWithBase64 = encodeBase64(key); + config.headers['encrypt-key'] = + asymmetricEncryption.encrypt(keyWithBase64); + /** + * axios会默认给字符串前后加上引号 RSA可以正常解密(加不加都能解密) 但是SM2不行(大坑!!!) + * 这里通过transformRequest强制返回原始内容 + */ + config.transformRequest = (data) => data; + + const data = request.data ?? ''; + switch (typeof request.data) { + case 'string': { + request.data = symmetricEncryption.encrypt(data as string, key); + break; + } + default: { + request.data = symmetricEncryption.encrypt(JSON.stringify(data), key); + break; + } + } + // alova会默认使用www-form-urlencoded 这里需要手动设置为json + // 不要提什么为啥明明body是text而要用json 因为用text压根进不了controller 除非你的body用string来接收A + config.headers['Content-Type'] = ContentTypeEnum.JSON; + } + }, + responded: { + onSuccess: async (response, instance) => { + // 解密响应数据 + const encryptKey = (response.headers ?? {})['encrypt-key']; + if (encryptKey) { + /** RSA私钥解密 拿到解密秘钥的base64 */ + const base64Str = asymmetricEncryption.decrypt(encryptKey); + /** base64 解码 得到请求头的 AES 秘钥 */ + const secret = decodeBase64(base64Str); + /** 使用aesKey解密 responseData */ + const decryptData = symmetricEncryption.decrypt( + response.data as unknown as string, + secret, + ); + /** 赋值 需要转为对象 */ + response.data = JSON.parse(decryptData); + } + + const { isReturnNativeResponse, isTransformResponse } = + instance.meta as AlovaMeta; + // 是否返回原生响应 比如:需要获取响应时使用该属性 + if (isReturnNativeResponse) { + return response; + } + // 不进行任何处理,直接返回 + // 用于页面代码可能需要直接获取code,data,message这些信息时开启 + if (!isTransformResponse) { + /** + * @warning 注意 微服务版本在401(网关)会返回text/plain的头 所以这里代码会无效 + * 我建议你改后端而不是前端来做兼容 + */ + // json数据的判断 + if (response.headers['content-type']?.includes?.('application/json')) { + /** + * 需要判断是否登录超时/401 + * 执行登出操作 + */ + const resp = response.data as unknown as HttpResponse; + // 抛出异常 不再执行 + if ( + typeof resp === 'object' && + Reflect.has(resp, 'code') && + resp.code === UNAUTHORIZED_CODE + ) { + handleUnauthorizedLogout(); + } + + /** + * 需要判断下载二进制的情况 正常是返回二进制 报错会返回json + * 当type为blob且content-type为application/json时 则判断已经下载出错 + */ + if (response.config.responseType === 'blob') { + // 这时候的data为blob类型 + const blob = response.data as unknown as Blob; + // 拿到字符串转json对象 + response.data = JSON.parse(await blob.text()); + // 然后按正常逻辑执行下面的代码(判断业务状态码) + } else { + // 其他类型数据 直接返回 + return response.data; + } + } else { + // 非json数据 直接返回 不做校验 + return response.data; + } + } + + const axiosResponseData = response.data; + if (!axiosResponseData) { + throw new Error($t('http.apiRequestFailed')); + } + + // 后端并没有采用严格的{code, msg, data}模式 + const { code, data, msg, ...other } = axiosResponseData; + + // 业务状态码为200 则请求成功 + const hasSuccess = + Reflect.has(axiosResponseData, 'code') && + code === BUSINESS_SUCCESS_CODE; + if (hasSuccess) { + let successMsg = msg; + + if (isNull(successMsg) || isEmpty(successMsg)) { + successMsg = $t(`http.operationSuccess`); + } + + if (!['none', undefined].includes(instance.meta?.showSuccessMessage)) { + showAntdMessage({ + meta: instance.meta, + message: successMsg, + type: 'success', + }); + } + // 分页情况下为code msg rows total 并没有data字段 + // 如果有data 直接返回data 没有data将剩余参数(...other)封装为data返回 + // 需要考虑data为null的情况(比如查询为空) 所以这里直接判断undefined + if (data !== undefined) { + return data; + } + // 没有data 将其他参数包装为data + return other; + } + // 在此处根据自己项目的实际情况对不同的code执行不同的操作 + // 如果不希望中断当前请求,请return数据,否则直接抛出异常即可 + let timeoutMsg = ''; + switch (code) { + // 登录超时 + case UNAUTHORIZED_CODE: { + handleUnauthorizedLogout(); + break; + } + default: { + if (msg) { + timeoutMsg = msg; + } + } + } + + // errorMessageMode='modal'的时候会显示modal错误弹窗,而不是消息提示,用于一些比较重要的错误 + // errorMessageMode='none' 一般是调用时明确表示不希望自动弹出错误提示 + if (!['none', undefined].includes(instance.meta?.showErrorMessage)) { + showAntdMessage({ + meta: instance.meta, + message: timeoutMsg, + type: 'error', + }); + } + // 抛出业务异常 + throw new BusinessException(code, timeoutMsg); + }, + onError: (error, method) => { + if (axios.isCancel(error)) { + return Promise.reject(error); + } + const axiosError = error as AxiosError; + const { code, message, response } = axiosError; + let errMessage = ''; + + try { + if (code === 'ECONNABORTED' && message.includes('timeout')) { + errMessage = $t('ui.fallback.http.requestTimeout'); + } + if (axiosError?.toString()?.includes('Network Error')) { + errMessage = $t('ui.fallback.http.networkError'); + } + + if (errMessage) { + if (!['none', undefined].includes(method.meta?.showErrorMessage)) { + showAntdMessage({ + meta: method.meta, + message: errMessage, + type: 'error', + }); + } + return Promise.reject(error); + } + } catch (error) { + throw new Error(error as unknown as string); + } + + const msg: string = (response?.data as any)?.msg ?? ''; + // 根据httpStatus判断错误 弹窗提示 + checkStatus(error?.response?.status, msg, method.meta); + + return Promise.reject(error); + }, + }, +}); + +/** +提供xxWithMessage方法,用于请求成功后弹出提示 + */ + +alovaInstance.GetWithMessage = function (url, options) { + return this.Get(url, { + ...options, + meta: { + ...options?.meta, + showSuccessMessage: 'message', + }, + }); +}; + +alovaInstance.PostWithMessage = function (url, data, config) { + return this.Post(url, data, { + ...config, + meta: { + ...config?.meta, + showSuccessMessage: 'message', + }, + }); +}; + +alovaInstance.PutWithMessage = function (url, data, config) { + return this.Put(url, data, { + ...config, + meta: { + ...config?.meta, + showSuccessMessage: 'message', + }, + }); +}; + +alovaInstance.DeleteWithMessage = function (url, data, config) { + return this.Delete(url, data, { + ...config, + meta: { + ...config?.meta, + showSuccessMessage: 'message', + }, + }); +}; + +export type AlovaInstanceType = typeof alovaInstance; + +export { alovaInstance }; diff --git a/apps/web-antd/src/utils/http/popup.ts b/apps/web-antd/src/utils/http/popup.ts new file mode 100644 index 00000000..09a160de --- /dev/null +++ b/apps/web-antd/src/utils/http/popup.ts @@ -0,0 +1,49 @@ +import type { AlovaMeta } from '#/../types/alova'; + +import { $t } from '#/locales'; + +interface ShowMessageOptions { + meta?: AlovaMeta; + message: string; + type: 'error' | 'success'; +} + +export function showAntdMessage(options: ShowMessageOptions) { + const { meta = {}, message, type } = options; + + if (meta.showErrorMessage === 'message' && type === 'error') { + window.message[type](message); + } + if (meta.showSuccessMessage === 'message' && type === 'success') { + window.message[type](message); + } + + if (meta.showErrorMessage === 'modal' && type === 'error') { + window.modal.error({ + content: message, + title: $t('http.errorTip'), + centered: true, + okButtonProps: { danger: true }, + }); + } + if (meta.showSuccessMessage === 'modal' && type === 'success') { + window.modal.success({ + content: message, + title: $t('http.successTip'), + centered: true, + }); + } + + if (meta.showErrorMessage === 'notification' && type === 'error') { + window.notification.error({ + description: message, + title: $t('http.errorTip'), + }); + } + if (meta.showSuccessMessage === 'notification' && type === 'success') { + window.notification.success({ + description: message, + title: $t('http.successTip'), + }); + } +} diff --git a/apps/web-antd/types/alova.d.ts b/apps/web-antd/types/alova.d.ts new file mode 100644 index 00000000..3604ab8a --- /dev/null +++ b/apps/web-antd/types/alova.d.ts @@ -0,0 +1,59 @@ +/* eslint-disable unicorn/require-module-specifiers */ +import type { AlovaInstanceType } from '#/utils/http'; + +import 'alova'; + +/** + * 接口请求message提示方式 + */ +export type MessageType = 'message' | 'modal' | 'none' | 'notification'; + +type GetType = AlovaInstanceType['Get']; +type PostType = AlovaInstanceType['Post']; +type PutType = AlovaInstanceType['Put']; +type DeleteType = AlovaInstanceType['Delete']; + +export type AlovaMeta = { + /** + * 是否需要对请求体进行加密 + */ + encrypt?: boolean; + /** + * 是否返回原生axios响应 + */ + isReturnNativeResponse?: boolean; + /** + * 是否需要转换响应 即只获取{code, msg, data}中的data + */ + isTransformResponse?: boolean; + /** + * 接口请求失败时的提示方式 + */ + showErrorMessage?: MessageType; + /** + * 接口请求成功时的提示方式 + */ + showSuccessMessage?: MessageType; + /** + * 是否需要在请求头中添加 token + */ + withToken?: boolean; +}; + +declare module 'alova' { + export interface AlovaCustomTypes { + meta: AlovaMeta; + } + + /** + * 添加withMessage方法 用于success弹窗 + */ + interface Alova { + GetWithMessage: GetType; + PostWithMessage: PostType; + PutWithMessage: PutType; + DeleteWithMessage: DeleteType; + } +} + +export {}; diff --git a/cspell.json b/cspell.json index 0f520a03..ff17a5db 100644 --- a/cspell.json +++ b/cspell.json @@ -5,6 +5,7 @@ "allowCompoundWords": true, "words": [ "acmr", + "Alova", "antd", "antdv", "astro", @@ -44,8 +45,8 @@ "publint", "Qqchat", "qrcode", - "ruoyi", "reka", + "ruoyi", "shadcn", "sonner", "sortablejs", diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8920552a..5cd8b0f5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -69,7 +69,7 @@ catalog: antdv-next: latest archiver: ^7.0.1 autoprefixer: ^10.4.22 - axios: ^1.10.0 + axios: 1.13.2 axios-mock-adapter: ^2.1.0 cac: ^6.7.14 chalk: ^5.4.1 @@ -90,6 +90,7 @@ catalog: dotenv: ^16.6.1 echarts: ^6.0.0 element-plus: ^2.10.2 + es-toolkit: ^1.41.0 eslint: ^9.39.1 eslint-config-turbo: ^2.6.1 eslint-plugin-command: ^3.3.1 @@ -194,4 +195,3 @@ catalog: watermark-js-plus: ^1.6.2 zod: ^3.25.67 zod-defaults: 0.1.3 - es-toolkit: ^1.41.0