Browse Source

refactor: 重构接口加解密体系并统一多实例策略

- 架构重构:采用策略模式与类实例模式,分离加解密判定逻辑与算法实现。
- 策略优化:
  - 加密逻辑:改为显式判定 `application/json` 类型,解决 Content-Type 为空导致的判定失效问题。
  - 解密逻辑:移除白名单校验,支持自动识别 Base64 密文,提高兼容性。
  - 拦截器优化:加密时不再强制修改请求头,保持原始 Content-Type。
- 多实例同步:同步更新项目内所有 API 实例(主服务、AI、通用、登录、商铺服务)。
- 规范统一:
  - 统一环境变量命名为 `VITE_CRYPTO_KEY` 和 `VITE_CRYPTO_IV`。
  - 更新全局类型定义 `ViteEnv`。
  - 更新 `ENCRYPTION_CONFIG.md` 配置文档并清理冗余说明。
chenjiarong 2 months ago
parent
commit
26c76ee5f4
13 changed files with 425 additions and 217 deletions
  1. 3 3
      .env
  2. 3 3
      .env.development
  3. 3 3
      .env.production
  4. 3 3
      .env.test
  5. 26 19
      src/api/ENCRYPTION_CONFIG.md
  6. 10 46
      src/api/index.ts
  7. 35 8
      src/api/indexAi.ts
  8. 36 8
      src/api/indexApi.ts
  9. 36 8
      src/api/indexLogin.ts
  10. 36 8
      src/api/indexStore.ts
  11. 3 0
      src/typings/global.d.ts
  12. 38 108
      src/utils/crypto.ts
  13. 193 0
      src/utils/cryptoStrategy.ts

+ 3 - 3
.env

@@ -18,10 +18,10 @@ VITE_CODEINSPECTOR = false
 
 # 接口加密配置
 # 加密功能总开关
-VITE_API_ENCRYPTION_ENABLED = false
+VITE_API_ENCRYPTION_ENABLED = true
 
 # AES 密钥(16字节)
-VITE_API_ENCRYPTION_KEY = alien67890982316
+VITE_CRYPTO_KEY = alien67890982316
 
 # AES 向量(16字节)
-VITE_API_ENCRYPTION_IV = 1234560405060708
+VITE_CRYPTO_IV = 1234560405060708

+ 3 - 3
.env.development

@@ -28,10 +28,10 @@ VITE_API_URL_PLATFORM = /api/alienStorePlatform
 
 # 接口加密配置
 # 加密功能总开关
-VITE_API_ENCRYPTION_ENABLED = false
+VITE_API_ENCRYPTION_ENABLED = true
 
 # AES 密钥(16字节)
-VITE_API_ENCRYPTION_KEY = alien67890982316
+VITE_CRYPTO_KEY = alien67890982316
 
 # AES 向量(16字节)
-VITE_API_ENCRYPTION_IV = 1234560405060708
+VITE_CRYPTO_IV = 1234560405060708

+ 3 - 3
.env.production

@@ -39,10 +39,10 @@ VITE_PROXY_AI = [["/ai-api","http://124.93.18.180:9000"]]
 
 # 接口加密配置
 # 加密功能总开关
-VITE_API_ENCRYPTION_ENABLED = false
+VITE_API_ENCRYPTION_ENABLED = true
 
 # AES 密钥(16字节)
-VITE_API_ENCRYPTION_KEY = alien67890982316
+VITE_CRYPTO_KEY = alien67890982316
 
 # AES 向量(16字节)
-VITE_API_ENCRYPTION_IV = 1234560405060708
+VITE_CRYPTO_IV = 1234560405060708

+ 3 - 3
.env.test

@@ -32,10 +32,10 @@ VITE_PROXY = [["/api","http://localhost:8888"]]
 
 # 接口加密配置
 # 加密功能总开关
-VITE_API_ENCRYPTION_ENABLED = false
+VITE_API_ENCRYPTION_ENABLED = true
 
 # AES 密钥(16字节)
-VITE_API_ENCRYPTION_KEY = alien67890982316
+VITE_CRYPTO_KEY = alien67890982316
 
 # AES 向量(16字节)
-VITE_API_ENCRYPTION_IV = 1234560405060708
+VITE_CRYPTO_IV = 1234560405060708

+ 26 - 19
src/api/ENCRYPTION_CONFIG.md

@@ -11,48 +11,55 @@
 - **密钥**: 16 字节 UTF-8 字符串
 - **向量**: 16 字节 UTF-8 字符串
 - **输出格式**: Base64 编码
-- **Content-Type**: application/json
+- **判定标准**: `Content-Type` 必须包含 `application/json`
 
 ## 环境配置
 
-在 `.env`、`.env.development`、`.env.test`、`.env.production` 中配置:
+在 `.env`、`.env.development` 等文件中配置:
 
 ```bash
-# 接口加密功能总开关(默认 false)
-VITE_API_ENCRYPTION_ENABLED = false
+# 接口加密功能总开关
+VITE_API_ENCRYPTION_ENABLED = true
 
 # AES 密钥(16 字节)
-VITE_API_ENCRYPTION_KEY = alien67890982316
+VITE_CRYPTO_KEY = xxxxxxxxxxxxxxxx
 
 # AES 向量(16 字节)
-VITE_API_ENCRYPTION_IV = 1234560405060708
+VITE_CRYPTO_IV = xxxxxxxxxxxxxxxx
 ```
 
 ## 使用方式
 
-### 1. 全局控制
+### 1. 多实例支持
 
-设置 `VITE_API_ENCRYPTION_ENABLED = true`,所有接口自动加密。
+重构后的加解密逻辑已同步应用到所有 API 实例:
+
+- `http` (@/api/index) - 主服务
+- `httpAi` (@/api/indexAi) - AI 服务
+- `httpApi` (@/api/indexApi) - 通用服务
+- `httpLogin` (@/api/indexLogin) - 登录服务
+- `httpStore` (@/api/indexStore) - 商铺服务
+
+所有实例均支持相同的 `encrypt` 配置和自动解密逻辑。
 
 ### 2. 接口级别控制
 
-在接口调用时通过第三个参数 `encrypt` 控制:
+在接口调用时通过配置对象中的 `encrypt` 属性控制:
 
 ```typescript
-// 强制加密(全局开关关闭时也生效)
-await api.post("/api/sensitive-endpoint", params, { encrypt: true });
+// 强制加密
+await api.post("/api/path", params, { encrypt: true });
 
-// 强制跳过加密(全局开关开启时也生效)
-await api.post("/api/public-endpoint", params, { encrypt: false });
+// 强制跳过加密
+await api.post("/api/path", params, { encrypt: false });
 ```
 
 ## 配置优先级
 
-接口级别配置 > 全局配置
+**接口级别配置 (`encrypt: boolean`) > 全局环境变量 (`VITE_API_ENCRYPTION_ENABLED`)**
 
-## 安全建议
+## 关键逻辑说明
 
-1. 不同环境使用不同的密钥
-2. 生产环境密钥使用强随机生成
-3. 重要接口(登录、支付等)启用加密
-4. 加密会增加约 1-5ms 延迟
+1. **加密白名单**:在 `src/utils/cryptoStrategy.ts` 中配置,白名单内的接口永远不会被加密。
+2. **自动解密**:系统会自动识别响应数据是否为 Base64 格式。如果是,则自动解密,业务层无感知。
+3. **ContentType 要求**:请求和响应的 `Content-Type` 必须包含 `application/json` 才会触发加解密逻辑。

+ 10 - 46
src/api/index.ts

@@ -9,7 +9,8 @@ import { AxiosCanceler } from "./helper/axiosCancel";
 import { useUserStore } from "@/stores/modules/user";
 import { localGet } from "@/utils";
 import router from "@/routers";
-import { crypto } from "@/utils/crypto";
+import { cryptoUtil } from "@/utils/crypto";
+import { cryptoStrategy } from "@/utils/cryptoStrategy";
 
 export interface CustomAxiosRequestConfig extends InternalAxiosRequestConfig {
   loading?: boolean;
@@ -28,36 +29,6 @@ const config = {
 
 const axiosCanceler = new AxiosCanceler();
 
-/**
- * 判断是否应该加密请求
- * @param config 请求配置
- * @returns 是否加密
- */
-function shouldEncrypt(config: CustomAxiosRequestConfig): boolean {
-  // 接口级别配置优先于全局配置
-  if (config.encrypt !== undefined) {
-    return config.encrypt;
-  }
-  // 检查全局加密开关
-  const globalEnabled = import.meta.env.VITE_API_ENCRYPTION_ENABLED === "true";
-  return globalEnabled && crypto.isConfigured();
-}
-
-/**
- * 判断是否应该解密响应
- * @param config 请求配置
- * @param responseData 响应数据
- * @returns 是否解密
- */
-function shouldDecrypt(config: CustomAxiosRequestConfig, responseData: any): boolean {
-  // 只有在请求加密了,并且响应数据是字符串时才解密
-  if (!shouldEncrypt(config)) {
-    return false;
-  }
-  // 检查响应数据是否为字符串(Base64 编码的密文)
-  return typeof responseData === "string" && crypto.isConfigured();
-}
-
 class RequestHttp {
   service: AxiosInstance;
   public constructor(config: AxiosRequestConfig) {
@@ -82,18 +53,11 @@ class RequestHttp {
           config.headers.set("Authorization", userStore.token);
         }
         // 加密处理
-        if (shouldEncrypt(config) && config.data) {
+        if (cryptoStrategy.shouldEncrypt(config) && config.data) {
           try {
-            config.data = crypto.encrypt(config.data);
-            // 设置 Content-Type 为 application/json
-            if (config.headers && typeof config.headers.set === "function") {
-              config.headers.set("Content-Type", "application/json");
-            }
+            config.data = cryptoUtil.encrypt(config.data);
           } catch (error) {
-            console.error("加密失败:", error);
-            // 加密失败时可以选择降级为明文传输或抛出错误
-            // 这里选择降级为明文传输,以避免影响业务
-            return Promise.reject(new Error("请求参数加密失败"));
+            return Promise.reject(error);
           }
         }
         return config;
@@ -109,17 +73,16 @@ class RequestHttp {
      */
     this.service.interceptors.response.use(
       (response: AxiosResponse & { config: CustomAxiosRequestConfig }) => {
-        const { data, config } = response;
+        const { data, config, headers } = response;
 
         const userStore = useUserStore();
         axiosCanceler.removePending(config);
         config.loading && tryHideFullScreenLoading();
+
         // 解密处理
-        if (shouldDecrypt(config, data)) {
+        if (cryptoStrategy.shouldDecrypt(config, headers, data)) {
           try {
-            const decryptedData = crypto.decrypt(data);
-            // 将解密后的数据赋值给 data
-            // @ts-expect-error - 解密后的数据类型可能与原始类型不同
+            const decryptedData = cryptoUtil.decrypt(data);
             response.data = decryptedData;
           } catch (error) {
             console.error("解密失败:", error);
@@ -127,6 +90,7 @@ class RequestHttp {
             return Promise.reject(new Error("响应数据解密失败"));
           }
         }
+
         const processedData = response.data;
         // 登录失效
         if (processedData.code == ResultEnum.OVERDUE) {

+ 35 - 8
src/api/indexAi.ts

@@ -9,10 +9,13 @@ import { AxiosCanceler } from "./helper/axiosCancel";
 import { useUserStore } from "@/stores/modules/user";
 import router from "@/routers";
 import { localGet, localSet } from "@/utils";
+import { cryptoUtil } from "@/utils/crypto";
+import { cryptoStrategy } from "@/utils/cryptoStrategy";
 
 export interface CustomAxiosRequestConfig extends InternalAxiosRequestConfig {
   loading?: boolean;
   cancel?: boolean;
+  encrypt?: boolean; // 是否加密请求参数
 }
 
 const config = {
@@ -148,6 +151,15 @@ class RequestHttp {
           }
         }
 
+        // 加密处理
+        if (cryptoStrategy.shouldEncrypt(config) && config.data) {
+          try {
+            config.data = cryptoUtil.encrypt(config.data);
+          } catch (error) {
+            return Promise.reject(error);
+          }
+        }
+
         return config;
       },
       (error: AxiosError) => {
@@ -161,26 +173,41 @@ class RequestHttp {
      */
     this.service.interceptors.response.use(
       (response: AxiosResponse & { config: CustomAxiosRequestConfig }) => {
-        const { data, config } = response;
+        const { data, config, headers } = response;
         const userStore = useUserStore();
         axiosCanceler.removePending(config);
         config.loading && tryHideFullScreenLoading();
+
+        // 解密处理
+        if (cryptoStrategy.shouldDecrypt(config, headers, data)) {
+          try {
+            const decryptedData = cryptoUtil.decrypt(data);
+            response.data = decryptedData;
+          } catch (error) {
+            console.error("解密失败:", error);
+            ElMessage.error("响应数据解密失败");
+            return Promise.reject(new Error("响应数据解密失败"));
+          }
+        }
+
+        const processedData = response.data;
+
         // 登录失效 - AI服务token过期,清除token,下次请求时会重新登录
-        if (data.code == ResultEnum.OVERDUE || data.code === 401) {
+        if (processedData.code == ResultEnum.OVERDUE || processedData.code === 401) {
           localSet(AI_TOKEN_KEY, null);
           // 如果是登录接口本身失败,不显示错误(避免循环)
           if (!config.url?.includes("/auth/login")) {
-            ElMessage.error(data.msg || "AI服务认证失败,请重试");
+            ElMessage.error(processedData.msg || "AI服务认证失败,请重试");
           }
-          return Promise.reject(data);
+          return Promise.reject(processedData);
         }
         // 全局错误信息拦截(防止下载文件的时候返回数据流,没有 code 直接报错)
-        if (data.code && data.code !== ResultEnum.SUCCESS) {
-          ElMessage.error(data.msg);
-          return Promise.reject(data);
+        if (processedData.code && processedData.code !== ResultEnum.SUCCESS) {
+          ElMessage.error(processedData.msg);
+          return Promise.reject(processedData);
         }
         // 成功请求(在页面上除非特殊情况,否则不用处理失败逻辑)
-        return data;
+        return processedData;
       },
       async (error: AxiosError) => {
         const { response } = error;

+ 36 - 8
src/api/indexApi.ts

@@ -8,10 +8,13 @@ import { checkStatus } from "./helper/checkStatus";
 import { AxiosCanceler } from "./helper/axiosCancel";
 import { useUserStore } from "@/stores/modules/user";
 import router from "@/routers";
+import { cryptoUtil } from "@/utils/crypto";
+import { cryptoStrategy } from "@/utils/cryptoStrategy";
 
 export interface CustomAxiosRequestConfig extends InternalAxiosRequestConfig {
   loading?: boolean;
   cancel?: boolean;
+  encrypt?: boolean; // 是否加密请求参数
 }
 
 const config = {
@@ -48,6 +51,16 @@ class RequestHttp {
         if (config.headers && typeof config.headers.set === "function") {
           config.headers.set("Authorization", userStore.token);
         }
+
+        // 加密处理
+        if (cryptoStrategy.shouldEncrypt(config) && config.data) {
+          try {
+            config.data = cryptoUtil.encrypt(config.data);
+          } catch (error) {
+            return Promise.reject(error);
+          }
+        }
+
         return config;
       },
       (error: AxiosError) => {
@@ -61,24 +74,39 @@ class RequestHttp {
      */
     this.service.interceptors.response.use(
       (response: AxiosResponse & { config: CustomAxiosRequestConfig }) => {
-        const { data, config } = response;
+        const { data, config, headers } = response;
         const userStore = useUserStore();
         axiosCanceler.removePending(config);
         config.loading && tryHideFullScreenLoading();
+
+        // 解密处理
+        if (cryptoStrategy.shouldDecrypt(config, headers, data)) {
+          try {
+            const decryptedData = cryptoUtil.decrypt(data);
+            response.data = decryptedData;
+          } catch (error) {
+            console.error("解密失败:", error);
+            ElMessage.error("响应数据解密失败");
+            return Promise.reject(new Error("响应数据解密失败"));
+          }
+        }
+
+        const processedData = response.data;
+
         // 登录失效
-        if (data.code == ResultEnum.OVERDUE) {
+        if (processedData.code == ResultEnum.OVERDUE) {
           userStore.setToken("");
           router.replace(LOGIN_URL);
-          ElMessage.error(data.msg);
-          return Promise.reject(data);
+          ElMessage.error(processedData.msg);
+          return Promise.reject(processedData);
         }
         // 全局错误信息拦截(防止下载文件的时候返回数据流,没有 code 直接报错)
-        if (data.code && data.code !== ResultEnum.SUCCESS) {
-          ElMessage.error(data.msg);
-          return Promise.reject(data);
+        if (processedData.code && processedData.code !== ResultEnum.SUCCESS) {
+          ElMessage.error(processedData.msg);
+          return Promise.reject(processedData);
         }
         // 成功请求(在页面上除非特殊情况,否则不用处理失败逻辑)
-        return data;
+        return processedData;
       },
       async (error: AxiosError) => {
         const { response } = error;

+ 36 - 8
src/api/indexLogin.ts

@@ -8,10 +8,13 @@ import { checkStatus } from "./helper/checkStatus";
 import { AxiosCanceler } from "./helper/axiosCancel";
 import { useUserStore } from "@/stores/modules/user";
 import router from "@/routers";
+import { cryptoUtil } from "@/utils/crypto";
+import { cryptoStrategy } from "@/utils/cryptoStrategy";
 
 export interface CustomAxiosRequestConfig extends InternalAxiosRequestConfig {
   loading?: boolean;
   cancel?: boolean;
+  encrypt?: boolean; // 是否加密请求参数
 }
 
 const config = {
@@ -48,6 +51,16 @@ class RequestHttp {
         if (config.headers && typeof config.headers.set === "function") {
           config.headers.set("Authorization", userStore.token);
         }
+
+        // 加密处理
+        if (cryptoStrategy.shouldEncrypt(config) && config.data) {
+          try {
+            config.data = cryptoUtil.encrypt(config.data);
+          } catch (error) {
+            return Promise.reject(error);
+          }
+        }
+
         return config;
       },
       (error: AxiosError) => {
@@ -61,25 +74,40 @@ class RequestHttp {
      */
     this.service.interceptors.response.use(
       (response: AxiosResponse & { config: CustomAxiosRequestConfig }) => {
-        const { data, config } = response;
+        const { data, config, headers } = response;
 
         const userStore = useUserStore();
         axiosCanceler.removePending(config);
         config.loading && tryHideFullScreenLoading();
+
+        // 解密处理
+        if (cryptoStrategy.shouldDecrypt(config, headers, data)) {
+          try {
+            const decryptedData = cryptoUtil.decrypt(data);
+            response.data = decryptedData;
+          } catch (error) {
+            console.error("解密失败:", error);
+            ElMessage.error("响应数据解密失败");
+            return Promise.reject(new Error("响应数据解密失败"));
+          }
+        }
+
+        const processedData = response.data;
+
         // 登录失效
-        if (data.code == ResultEnum.OVERDUE) {
+        if (processedData.code == ResultEnum.OVERDUE) {
           userStore.setToken("");
           router.replace(LOGIN_URL);
-          ElMessage.error(data.msg);
-          return Promise.reject(data);
+          ElMessage.error(processedData.msg);
+          return Promise.reject(processedData);
         }
         // 全局错误信息拦截(防止下载文件的时候返回数据流,没有 code 直接报错)
-        if (data.code && data.code !== ResultEnum.SUCCESS) {
-          ElMessage.error(data.msg);
-          return Promise.reject(data);
+        if (processedData.code && processedData.code !== ResultEnum.SUCCESS) {
+          ElMessage.error(processedData.msg);
+          return Promise.reject(processedData);
         }
         // 成功请求(在页面上除非特殊情况,否则不用处理失败逻辑)
-        return data;
+        return processedData;
       },
       async (error: AxiosError) => {
         const response: AxiosResponse | undefined = error.response as AxiosResponse | undefined;

+ 36 - 8
src/api/indexStore.ts

@@ -9,10 +9,13 @@ import { AxiosCanceler } from "./helper/axiosCancel";
 import { useUserStore } from "@/stores/modules/user";
 import { localGet } from "@/utils";
 import router from "@/routers";
+import { cryptoUtil } from "@/utils/crypto";
+import { cryptoStrategy } from "@/utils/cryptoStrategy";
 
 export interface CustomAxiosRequestConfig extends InternalAxiosRequestConfig {
   loading?: boolean;
   cancel?: boolean;
+  encrypt?: boolean; // 是否加密请求参数
 }
 
 const config = {
@@ -49,6 +52,16 @@ class RequestHttp {
         if (config.headers && typeof config.headers.set === "function") {
           config.headers.set("Authorization", userStore.token);
         }
+
+        // 加密处理
+        if (cryptoStrategy.shouldEncrypt(config) && config.data) {
+          try {
+            config.data = cryptoUtil.encrypt(config.data);
+          } catch (error) {
+            return Promise.reject(error);
+          }
+        }
+
         return config;
       },
       (error: AxiosError) => {
@@ -62,25 +75,40 @@ class RequestHttp {
      */
     this.service.interceptors.response.use(
       (response: AxiosResponse & { config: CustomAxiosRequestConfig }) => {
-        const { data, config } = response;
+        const { data, config, headers } = response;
 
         const userStore = useUserStore();
         axiosCanceler.removePending(config);
         config.loading && tryHideFullScreenLoading();
+
+        // 解密处理
+        if (cryptoStrategy.shouldDecrypt(config, headers, data)) {
+          try {
+            const decryptedData = cryptoUtil.decrypt(data);
+            response.data = decryptedData;
+          } catch (error) {
+            console.error("解密失败:", error);
+            ElMessage.error("响应数据解密失败");
+            return Promise.reject(new Error("响应数据解密失败"));
+          }
+        }
+
+        const processedData = response.data;
+
         // 登录失效
-        if (data.code == ResultEnum.OVERDUE) {
+        if (processedData.code == ResultEnum.OVERDUE) {
           userStore.setToken("");
           router.replace(LOGIN_URL);
-          ElMessage.error(data.msg);
-          return Promise.reject(data);
+          ElMessage.error(processedData.msg);
+          return Promise.reject(processedData);
         }
         // 全局错误信息拦截(防止下载文件的时候返回数据流,没有 code 直接报错)
-        if (data.code && data.code !== ResultEnum.SUCCESS) {
-          ElMessage.error(data.msg);
-          return Promise.reject(data);
+        if (processedData.code && processedData.code !== ResultEnum.SUCCESS) {
+          ElMessage.error(processedData.msg);
+          return Promise.reject(processedData);
         }
         // 成功请求(在页面上除非特殊情况,否则不用处理失败逻辑)
-        return data;
+        return processedData;
       },
       async (error: AxiosError) => {
         const { response } = error;

+ 3 - 0
src/typings/global.d.ts

@@ -58,6 +58,9 @@ declare interface ViteEnv {
   VITE_PROXY: [string, string][];
   VITE_PROXY_AI: [string, string][];
   VITE_CODEINSPECTOR: boolean;
+  VITE_API_ENCRYPTION_ENABLED: string;
+  VITE_CRYPTO_KEY: string;
+  VITE_CRYPTO_IV: string;
 }
 
 interface ImportMetaEnv extends ViteEnv {

+ 38 - 108
src/utils/crypto.ts

@@ -1,150 +1,80 @@
 import CryptoJS from "crypto-js";
 
 /**
- * 加密配置接口
+ * AES-128-CBC 加解密工具类
+ * 参考中台项目实现,采用类实例模式
  */
-export interface CryptoConfig {
-  key: string;
-  iv: string;
-}
+class CryptoUtil {
+  private key: CryptoJS.lib.WordArray;
+  private iv: CryptoJS.lib.WordArray;
 
-/**
- * AES 加密工具类
- * 使用 AES-128-CBC 算法进行加解密
- */
-export class CryptoUtils {
-  private static readonly KEY_LENGTH = 16;
-  private static readonly IV_LENGTH = 16;
+  constructor(key: string, iv: string) {
+    this.key = CryptoJS.enc.Utf8.parse(key);
+    this.iv = CryptoJS.enc.Utf8.parse(iv);
+  }
 
   /**
-   * 验证密钥和向量的长度
-   * @param key AES 密钥(16字节)
-   * @param iv AES 向量(16字节)
-   * @returns 是否有效
+   * 验证密钥和向量是否有效
    */
-  static validateConfig(key: string, iv: string): boolean {
-    return key.length === this.KEY_LENGTH && iv.length === this.IV_LENGTH;
+  isConfigured(): boolean {
+    return Boolean(this.key.sigBytes && this.iv.sigBytes);
   }
 
   /**
-   * 加密数据
-   * @param data 待加密的数据(对象或字符串)
-   * @param key AES 密钥(16字节)
-   * @param iv AES 向量(16字节)
-   * @returns Base64 编码的加密字符串
+   * 加密:对象/字符串 -> Base64
    */
-  static encrypt(data: any, key: string, iv: string): string {
-    if (!this.validateConfig(key, iv)) {
-      throw new Error(`Invalid crypto config: key must be ${this.KEY_LENGTH} bytes, iv must be ${this.IV_LENGTH} bytes`);
-    }
-
+  encrypt(data: any): string {
+    if (data === null || data === undefined) return data;
     try {
-      // 将对象转换为 JSON 字符串
       const plainText = typeof data === "object" ? JSON.stringify(data) : String(data);
-
-      // 将密钥和向量转换为 WordArray
-      const keyWordArray = CryptoJS.enc.Utf8.parse(key);
-      const ivWordArray = CryptoJS.enc.Utf8.parse(iv);
-
-      // 使用 AES-128-CBC 加密
-      const encrypted = CryptoJS.AES.encrypt(plainText, keyWordArray, {
-        iv: ivWordArray,
+      const encrypted = CryptoJS.AES.encrypt(plainText, this.key, {
+        iv: this.iv,
         mode: CryptoJS.mode.CBC,
         padding: CryptoJS.pad.Pkcs7
       });
-
-      // 返回 Base64 编码的密文
       return encrypted.toString();
     } catch (error) {
-      throw new Error(`Encryption failed: ${error instanceof Error ? error.message : String(error)}`);
+      console.error("Encryption failed:", error);
+      throw new Error("请求参数加密失败");
     }
   }
 
   /**
-   * 解密数据
-   * @param ciphertext Base64 编码的密文
-   * @param key AES 密钥(16字节)
-   * @param iv AES 向量(16字节)
-   * @returns 解密后的数据(对象或字符串)
+   * 解解密:Base64 -> 对象/字符串
    */
-  static decrypt(ciphertext: string, key: string, iv: string): any {
-    if (!this.validateConfig(key, iv)) {
-      throw new Error(`Invalid crypto config: key must be ${this.KEY_LENGTH} bytes, iv must be ${this.IV_LENGTH} bytes`);
-    }
-
+  decrypt(cipherText: string): any {
+    if (!cipherText || typeof cipherText !== "string") return cipherText;
     try {
-      // 将密钥和向量转换为 WordArray
-      const keyWordArray = CryptoJS.enc.Utf8.parse(key);
-      const ivWordArray = CryptoJS.enc.Utf8.parse(iv);
-
-      // 使用 AES-128-CBC 解密
-      const decrypted = CryptoJS.AES.decrypt(ciphertext, keyWordArray, {
-        iv: ivWordArray,
+      const decrypted = CryptoJS.AES.decrypt(cipherText, this.key, {
+        iv: this.iv,
         mode: CryptoJS.mode.CBC,
         padding: CryptoJS.pad.Pkcs7
       });
-
-      // 将解密结果转换为 UTF-8 字符串
       const result = decrypted.toString(CryptoJS.enc.Utf8);
-
-      // 如果解密结果为空,返回空字符串
       if (!result) {
-        return "";
+        throw new Error("Decryption failed: empty result");
       }
-
-      // 尝试解析为 JSON 对象
+      // 尝试解析为 JSON
       try {
         return JSON.parse(result);
-      } catch (e) {
-        // 如果不是 JSON,则直接返回字符串
+      } catch {
         return result;
       }
     } catch (error) {
-      throw new Error(`Decryption failed: ${error instanceof Error ? error.message : String(error)}`);
+      console.error("Decryption failed:", error);
+      throw new Error(`响应数据解密失败: ${error instanceof Error ? error.message : String(error)}`);
     }
   }
 }
 
-/**
- * 默认加密工具实例
- */
-export const crypto = {
-  /**
-   * 加密数据(使用默认配置)
-   */
-  encrypt(data: any, config?: Partial<CryptoConfig>): string {
-    const key = config?.key || import.meta.env.VITE_API_ENCRYPTION_KEY || "";
-    const iv = config?.iv || import.meta.env.VITE_API_ENCRYPTION_IV || "";
-
-    if (!key || !iv) {
-      console.warn("Encryption keys not configured, returning plaintext");
-      return typeof data === "object" ? JSON.stringify(data) : String(data);
-    }
+// 获取环境变量
+const KEY = import.meta.env.VITE_CRYPTO_KEY || "";
+const IV = import.meta.env.VITE_CRYPTO_IV || "";
 
-    return CryptoUtils.encrypt(data, key, iv);
-  },
+// 单例导出
+export const cryptoUtil = new CryptoUtil(KEY, IV);
 
-  /**
-   * 解密数据(使用默认配置)
-   */
-  decrypt(ciphertext: string, config?: Partial<CryptoConfig>): any {
-    const key = config?.key || import.meta.env.VITE_API_ENCRYPTION_KEY || "";
-    const iv = config?.iv || import.meta.env.VITE_API_ENCRYPTION_IV || "";
-
-    if (!key || !iv) {
-      console.warn("Decryption keys not configured, returning ciphertext");
-      return ciphertext;
-    }
-
-    return CryptoUtils.decrypt(ciphertext, key, iv);
-  },
-
-  /**
-   * 检查是否启用了加密配置
-   */
-  isConfigured(): boolean {
-    const key = import.meta.env.VITE_API_ENCRYPTION_KEY || "";
-    const iv = import.meta.env.VITE_API_ENCRYPTION_IV || "";
-    return CryptoUtils.validateConfig(key, iv);
-  }
-};
+/**
+ * @deprecated 请使用 cryptoUtil
+ */
+export const crypto = cryptoUtil;

+ 193 - 0
src/utils/cryptoStrategy.ts

@@ -0,0 +1,193 @@
+/**
+ * 接口加解密策略管理
+ *
+ * 加密条件:
+ * 1. 加密开关开启(接口级 > 全局)
+ * 2. 请求方法是 POST 或 PUT
+ * 3. 请求路径不在白名单内
+ * 4. Content-Type 包含 application/json
+ * 5. 请求数据存在且不是特殊类型(FormData/Blob等)
+ *
+ * 解密条件:
+ * 1. 加密开关开启(接口级 > 全局)
+ * 2. 响应 Content-Type 包含 application/json
+ * 3. 响应数据符合 Base64 密文特征
+ */
+
+// ==================== 配置 ====================
+
+/** 加密白名单:这些路径的请求不加密 */
+export const ENCRYPT_WHITE_LIST = [
+  "/doc.html",
+  "/swagger-ui/**",
+  "/v2/api-docs",
+  "/swagger-resources/**",
+  "/webjars/**",
+  "/actuator/**",
+  "/favicon.ico",
+  "/error",
+  "/test/**",
+  "/xxl-job-admin/**",
+  "/payment/notify",
+  "/lawyer/img/upload",
+  "/file/upload/**",
+  "/file/**",
+  "/video/moderation/submit",
+  "/video/moderation/processTask",
+  "/template/download/**",
+  "/store/img/upload",
+  "/ai/upload",
+  "/store/platform/login",
+  "/license/upload",
+  "/base/data/export",
+  "/sys/login",
+  "/sys/logout"
+];
+
+/** 支持加密的请求方法 */
+const ENCRYPT_METHODS = ["POST", "PUT"];
+
+// ==================== 工具函数 ====================
+
+/**
+ * 判断是否启用加密(接口级配置优先于全局配置)
+ */
+function isEncryptEnabled(config: any): boolean {
+  if (config?.encrypt !== undefined) {
+    return !!config.encrypt;
+  }
+  return import.meta.env.VITE_API_ENCRYPTION_ENABLED === "true";
+}
+
+/**
+ * 获取请求头中的 Content-Type
+ */
+function getContentType(headers: any): string {
+  if (!headers) return "";
+  // 兼容 AxiosHeaders 和普通对象
+  if (typeof headers.get === "function") {
+    return headers.get("Content-Type") || headers.get("content-type") || "";
+  }
+  return headers["Content-Type"] || headers["content-type"] || "";
+}
+
+/**
+ * 规范化路径(去除域名、查询参数等)
+ */
+function normalizePath(url: string): string {
+  if (!url) return "";
+  let path = url;
+  // 去除协议和域名
+  if (path.includes("://")) {
+    path = "/" + path.split("://")[1].split("/").slice(1).join("/");
+  }
+  // 去除查询参数
+  path = path.split("?")[0];
+  // 确保以 / 开头
+  if (!path.startsWith("/")) {
+    path = "/" + path;
+  }
+  return path;
+}
+
+/**
+ * 检查路径是否匹配白名单
+ */
+function isPathInWhiteList(url: string): boolean {
+  const path = normalizePath(url);
+  return ENCRYPT_WHITE_LIST.some(pattern => {
+    const normalizedPattern = pattern.startsWith("/") ? pattern : "/" + pattern;
+    // 通配符匹配:/path/** 匹配 /path 下所有子路径
+    if (normalizedPattern.endsWith("/**")) {
+      return path.startsWith(normalizedPattern.slice(0, -3));
+    }
+    return path === normalizedPattern;
+  });
+}
+
+/**
+ * 检查数据是否是特殊类型(不应加密的类型)
+ */
+function isSpecialDataType(data: any): boolean {
+  if (!data) return false;
+  return (
+    (typeof FormData !== "undefined" && data instanceof FormData) ||
+    (typeof URLSearchParams !== "undefined" && data instanceof URLSearchParams) ||
+    (typeof Blob !== "undefined" && data instanceof Blob) ||
+    (typeof File !== "undefined" && data instanceof File) ||
+    (typeof ArrayBuffer !== "undefined" && data instanceof ArrayBuffer)
+  );
+}
+
+/**
+ * 检查字符串是否符合 Base64 密文特征
+ */
+function isBase64CipherText(data: any): boolean {
+  if (typeof data !== "string" || data.length < 16) return false;
+  // Base64: 长度是4的倍数,只包含合法字符
+  return data.length % 4 === 0 && /^[A-Za-z0-9+/=]+$/.test(data);
+}
+
+// ==================== 核心策略 ====================
+
+export const cryptoStrategy = {
+  /**
+   * 判断请求是否应该加密
+   */
+  shouldEncrypt(config: any): boolean {
+    // 1. 检查加密开关
+    if (!isEncryptEnabled(config)) {
+      return false;
+    }
+
+    // 2. 检查请求方法
+    const method = (config.method || "").toUpperCase();
+    if (!ENCRYPT_METHODS.includes(method)) {
+      return false;
+    }
+
+    // 3. 检查白名单
+    if (isPathInWhiteList(config.url || "")) {
+      return false;
+    }
+
+    // 4. 检查 Content-Type 必须包含 application/json
+    const contentType = getContentType(config.headers);
+    if (!contentType.toLowerCase().includes("application/json")) {
+      return false;
+    }
+
+    // 5. 检查数据类型
+    if (isSpecialDataType(config.data)) {
+      return false;
+    }
+
+    return true;
+  },
+
+  /**
+   * 判断响应是否应该解密
+   * @param config 请求配置(用于判断加密开关)
+   * @param responseHeaders 响应头(用于判断 Content-Type)
+   * @param data 响应数据
+   */
+  shouldDecrypt(config: any, responseHeaders: any, data: any): boolean {
+    // 1. 检查加密开关
+    if (!isEncryptEnabled(config)) {
+      return false;
+    }
+
+    // 2. 检查响应 Content-Type 必须包含 application/json
+    const contentType = getContentType(responseHeaders);
+    if (!contentType.toLowerCase().includes("application/json")) {
+      return false;
+    }
+
+    // 3. 检查数据是否是 Base64 密文
+    if (!isBase64CipherText(data)) {
+      return false;
+    }
+
+    return true;
+  }
+};