Просмотр исходного кода

feat: 对接服务端配置,登录接口

BaiLuoYan 4 дней назад
Родитель
Сommit
49c9c6682b

+ 2 - 2
config/config.ts

@@ -99,9 +99,9 @@ export default defineConfig({
       REQUEST_ENCRYPTION_KEY: envConfig.REQUEST_ENCRYPTION_KEY,
       ENABLE_STORAGE_ENCRYPTION: envConfig.ENABLE_STORAGE_ENCRYPTION,
       STORAGE_ENCRYPTION_KEY: envConfig.STORAGE_ENCRYPTION_KEY,
+      REACT_APP_MANAGEMENT_KEY: envConfig.REACT_APP_MANAGEMENT_KEY,
+      REQUEST_DATA_COMPRESSION: envConfig.REQUEST_DATA_COMPRESSION,
       REACT_APP_API_URL: envConfig.REACT_APP_API_URL,
-      REACT_APP_STAT_API_URL: envConfig.REACT_APP_STAT_API_URL,
-      REACT_APP_RES_SERVER: envConfig.REACT_APP_RES_SERVER,
     },
   },
   links: [

+ 4 - 2
config/env/perms-system/default.ts

@@ -5,7 +5,9 @@ export default {
   REACT_APP_VERSION: '1.0.0',
   STORAGE_NAME_SPACE: 'perms-system-web-',
   ENABLE_REQUEST_ENCRYPTION: false,
-  REQUEST_ENCRYPTION_KEY: 'dc8da78edbfb641ea32dc2ff627aa1cc', // md5(sha256(${REACT_APP_ID}-${REACT_APP_VERSION}))
+  REQUEST_ENCRYPTION_KEY: 'ef7d8c60c013cf85bf1a079d2d50af46', // md5(sha256(${REACT_APP_ID}-${REACT_APP_VERSION}))
   ENABLE_STORAGE_ENCRYPTION: true,
-  STORAGE_ENCRYPTION_KEY: 'ec4daca9bfd00ff7', // md5(sha1(${REACT_APP_ID}-${REACT_APP_VERSION})).slice(0, 16)
+  STORAGE_ENCRYPTION_KEY: '31396fcaaf54dcc8', // md5(sha1(${REACT_APP_ID}-${REACT_APP_VERSION})).slice(0, 16)
+  // 管理后台登录密钥,必须与服务端 etc/perm-api.yaml 中 Auth.ManagementKey 一致,各环境单独配置
+  REACT_APP_MANAGEMENT_KEY: '',
 } as NodeJS.ProcessEnv;

+ 1 - 2
config/env/perms-system/dev.ts

@@ -1,5 +1,4 @@
 export default {
+  REACT_APP_MANAGEMENT_KEY: 'c653e85ba6528542746eb46298db48db',
   REACT_APP_API_URL: 'https://pmp.clickto.dev/api/v1/pmp',
-  REACT_APP_STAT_API_URL: 'https://stat-pmp.clickto.dev/api/v1/pmp',
-  REACT_APP_RES_SERVER: 'https://pmp.clickto.dev',
 } as NodeJS.ProcessEnv;

+ 2 - 8
config/env/perms-system/local.ts

@@ -1,11 +1,5 @@
 export default {
+  REACT_APP_MANAGEMENT_KEY: '86c4ca86b3f20b9902ecbd2aec658831',
   REACT_APP_API_URL: '/dev',
-  // REACT_APP_REAL_API_URL: 'https://pmp.goio.dev/api/v1/pmp',
-  REACT_APP_REAL_API_URL: 'http://127.0.0.1:40003/api/v1/pmp',
-
-  REACT_APP_STAT_API_URL: '/d-stat',
-  REACT_APP_STAT_REAL_API_URL: 'http://127.0.0.1:30203/api/v1/pmp',
-
-  REACT_APP_RES_SERVER: 'https://pmp.goio.dev',
-  REACT_APP_REAL_RES_SERVER: 'https://pmp.goio.dev',
+  REACT_APP_REAL_API_URL: 'http://127.0.0.1:10001/api',
 } as NodeJS.DevProcessEnv;

+ 1 - 2
config/env/perms-system/prod.ts

@@ -1,5 +1,4 @@
 export default {
+  REACT_APP_MANAGEMENT_KEY: 'bec2b6b1df692610fb914ef2935bda88',
   REACT_APP_API_URL: 'https://pmp.zperms-system.com/api/v1/pmp',
-  REACT_APP_STAT_API_URL: 'https://stat-pmp.zperms-system.com/api/v1/pmp',
-  REACT_APP_RES_SERVER: 'https://pmp.zperms-system.com',
 } as NodeJS.ProcessEnv;

+ 1 - 2
config/env/perms-system/test.ts

@@ -1,5 +1,4 @@
 export default {
+  REACT_APP_MANAGEMENT_KEY: '1f63927367bd65aaca2bce0247e40b52',
   REACT_APP_API_URL: 'https://pmp.zperms-system.com/api/v1/pmp',
-  REACT_APP_STAT_API_URL: 'https://stat-pmp.zperms-system.com/api/v1/pmp',
-  REACT_APP_RES_SERVER: 'https://pmp.zperms-system.com',
 } as NodeJS.ProcessEnv;

+ 0 - 18
config/proxy.ts

@@ -22,23 +22,5 @@ export default function (envConfig: NodeJS.DevProcessEnv) {
       pathRewrite: { '^/dev': '' },
       logLevel: 'debug',
     },
-    '/d-res': {
-      // 要代理的地址
-      target: envConfig.REACT_APP_REAL_RES_SERVER,
-      // 配置了这个可以从 http 代理到 https
-      // 依赖 origin 的功能可能需要这个,比如 cookie
-      changeOrigin: true,
-      pathRewrite: { '^/d-res': '' },
-      logLevel: 'debug',
-    },
-    '/d-stat': {
-      // 要代理的地址
-      target: envConfig.REACT_APP_STAT_REAL_API_URL,
-      // 配置了这个可以从 http 代理到 https
-      // 依赖 origin 的功能可能需要这个,比如 cookie
-      changeOrigin: true,
-      pathRewrite: { '^/d-stat': '' },
-      logLevel: 'debug',
-    },
   };
 }

+ 3 - 0
package.json

@@ -50,12 +50,15 @@
     "antd": "^5.28.1",
     "antd-style": "^3.7.1",
     "axios": "0.27.2",
+    "brotli-wasm": "3.0.1",
     "classnames": "^2.5.1",
+    "copy-to-clipboard": "4.0.2",
     "crypto-js": "^4.2.0",
     "dayjs": "^1.11.20",
     "dayjs-plugin-utc": "^0.1.2",
     "echarts": "^6.0.0",
     "echarts-for-react": "^3.0.6",
+    "fflate": "0.8.2",
     "file-saver": "^2.0.5",
     "js-cookie": "^3.0.5",
     "jsoneditor": "^10.4.3",

+ 20 - 0
pnpm-lock.yaml

@@ -62,9 +62,15 @@ importers:
       axios:
         specifier: 0.27.2
         version: 0.27.2
+      brotli-wasm:
+        specifier: 3.0.1
+        version: 3.0.1
       classnames:
         specifier: ^2.5.1
         version: 2.5.1
+      copy-to-clipboard:
+        specifier: 4.0.2
+        version: 4.0.2
       crypto-js:
         specifier: ^4.2.0
         version: 4.2.0
@@ -80,6 +86,9 @@ importers:
       echarts-for-react:
         specifier: ^3.0.6
         version: 3.0.6([email protected])([email protected])
+      fflate:
+        specifier: 0.8.2
+        version: 0.8.2
       file-saver:
         specifier: ^2.0.5
         version: 2.0.5
@@ -4194,6 +4203,10 @@ packages:
   [email protected]:
     resolution: {integrity: sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==}
 
+  [email protected]:
+    resolution: {integrity: sha512-U3K72/JAi3jITpdhZBqzSUq+DUY697tLxOuFXB+FpAE/Ug+5C3VZrv4uA674EUZHxNAuQ9wETXNqQkxZD6oL4A==}
+    engines: {node: '>=v18.0.0'}
+
   [email protected]:
     resolution: {integrity: sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==}
 
@@ -4569,6 +4582,9 @@ packages:
   [email protected]:
     resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==}
 
+  [email protected]:
+    resolution: {integrity: sha512-gklSft7IuhriZKHKpuoA1fpJSLPNgvUMWMo5BlnzAJm0zNKnznoSv23IjtNqclx8eKi6ZcdvFFzYEER/+U1LoQ==}
+
   [email protected]:
     resolution: {integrity: sha512-p9hObIIEENxSV8xIu+V68JjSeARg6UVMG5mR+JEUguG3sI6MsiS1njz2jHmyJDvA+8jX/sytkBHup6kxhM9law==}
 
@@ -17139,6 +17155,8 @@ snapshots:
 
   [email protected]: {}
 
+  [email protected]: {}
+
   [email protected]:
     dependencies:
       buffer-xor: 1.0.3
@@ -17559,6 +17577,8 @@ snapshots:
     dependencies:
       toggle-selection: 1.0.6
 
+  [email protected]: {}
+
   [email protected]:
     dependencies:
       browserslist: 4.28.0

+ 37 - 0
src/config/index.ts

@@ -0,0 +1,37 @@
+import { CompressMethod, Config } from './types';
+
+const env = process.env.REACT_APP_ENV || 'prod';
+const isDev = env === 'dev';
+const isTest = env === 'test';
+
+const configTitle = process.env.REACT_APP_NAME!;
+let title = configTitle;
+if (isDev) {
+  title = `${configTitle} - 开发环境`;
+} else if (isTest) {
+  title = `${configTitle} - 测试环境`;
+}
+
+const config: Config = {
+  app: {
+    code: process.env.REACT_APP_ID!,
+    title,
+    version: process.env.REACT_APP_VERSION!,
+    routerMode: 'hash',
+    storageNameSpace: process.env.STORAGE_NAME_SPACE!,
+  },
+  security: {
+    enableRequestEncryption: process.env.ENABLE_REQUEST_ENCRYPTION === true,
+    requestEncryptionKey: process.env.REQUEST_ENCRYPTION_KEY ?? '',
+    enableStorageEncryption: process.env.ENABLE_STORAGE_ENCRYPTION === true,
+    storageEncryptionKey: process.env.STORAGE_ENCRYPTION_KEY ?? '',
+    compressMethod:
+      (process.env.REQUEST_DATA_COMPRESSION as CompressMethod) ?? CompressMethod.NO_ZIP,
+    managementKey: process.env.REACT_APP_MANAGEMENT_KEY ?? '',
+  },
+  api: {
+    baseURL: process.env.REACT_APP_API_URL!,
+  },
+};
+
+export default config;

+ 38 - 0
src/config/types.ts

@@ -0,0 +1,38 @@
+export enum CompressMethod {
+  NO_ZIP = 'nozip',
+  BR = 'br',
+  GZIP = 'gzip',
+}
+
+export interface Config {
+  app: {
+    /**应用代码 */
+    code: string;
+    /**应用标题 */
+    title: string;
+    /**应用版本 */
+    version: string;
+    /**路由模式 */
+    routerMode: 'hash' | 'history';
+    /**存储命名空间 */
+    storageNameSpace: string;
+  };
+  security: {
+    /**是否启用请求加密 */
+    enableRequestEncryption: boolean;
+    /**请求加密密钥 */
+    requestEncryptionKey: string;
+    /**存储加密总开关,如果设置为 false,则会忽略 StorageOptions 中的加密配置,不对存储进行加密 */
+    enableStorageEncryption: boolean;
+    /**存储加密密钥 */
+    storageEncryptionKey: string;
+    /**压缩方法 */
+    compressMethod: CompressMethod;
+    /**管理后台登录密钥 */
+    managementKey: string;
+  };
+  api: {
+    /**API基础URL */
+    baseURL: string;
+  };
+}

+ 46 - 23
src/pages/Sys/Login/index.tsx

@@ -7,19 +7,11 @@ import { LoadingOutlined, LockOutlined, UserOutlined } from '@ant-design/icons';
 import { LoginForm, ProFormText } from '@ant-design/pro-components';
 import '@cap.js/widget';
 import mcLogoSvg from '@svgs/multi-color/mc-logo.svg';
-import {
-  FormattedMessage,
-  Helmet,
-  SelectLang,
-  history,
-  useIntl,
-  useModel,
-  useRequest,
-} from '@umijs/max';
+import { FormattedMessage, Helmet, SelectLang, history, useIntl, useModel } from '@umijs/max';
 import { Alert, Spin } from 'antd';
 import { createStyles } from 'antd-style';
 import dayjs from 'dayjs';
-import React, { useEffect, useRef, useState } from 'react';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
 import { flushSync } from 'react-dom';
 import Settings from '../../../../config/defaultSettings';
 
@@ -28,6 +20,12 @@ type CapWidgetElement = HTMLElement & {
   reset: () => void;
 };
 
+interface LoginFormValues {
+  username: string;
+  password: string;
+  captchaCode?: string;
+}
+
 const defSettings = {
   ...Settings,
   title: process.env.REACT_APP_NAME,
@@ -74,11 +72,20 @@ const LoginMessage: React.FC<{
 };
 
 const Login: React.FC = () => {
-  const {
-    data: captchaInfo,
-    loading: captchaLoading,
-    run: refreshCaptcha,
-  } = useRequest<API.CaptchaResult>(loginApi.fetchCaptcha);
+  const [captchaInfo, setCaptchaInfo] = useState<API.CaptchaInfo | null>(null);
+  const [captchaLoading, setCaptchaLoading] = useState(false);
+
+  const refreshCaptcha = useCallback(() => {
+    setCaptchaLoading(true);
+    loginApi
+      .fetchCaptcha()
+      .then((res) => setCaptchaInfo(res.data ?? null))
+      .finally(() => setCaptchaLoading(false));
+  }, []);
+
+  useEffect(() => {
+    refreshCaptcha();
+  }, [refreshCaptcha]);
 
   const onClickedCaptcha = () => {
     if (captchaLoading) return;
@@ -97,7 +104,7 @@ const Login: React.FC = () => {
 
   useEffect(() => {
     loginApi.fetchCapEndpoint().then((res) => {
-      if (res.data?.code === 0 && res.data?.data) {
+      if (res.data?.data) {
         setCapEndpoint(res.data.data);
       } else {
         setCapEndpoint('');
@@ -205,7 +212,10 @@ const Login: React.FC = () => {
     message.error(intl.formatMessage({ id: 'pages.login.failure' }));
   };
 
-  const handleSubmit = async (values: API.LoginParams) => {
+  const managementKey = process.env.REACT_APP_MANAGEMENT_KEY ?? '';
+
+  /** cap.js 验证登录 */
+  const handleSubmit = async (values: LoginFormValues) => {
     if (!capToken) {
       setCapError(
         intl.formatMessage(
@@ -217,7 +227,10 @@ const Login: React.FC = () => {
     }
 
     loginApi
-      .fetchLoginByCap({ ...values, capToken }, { skipErrorHandler: true })
+      .fetchAdminLoginByCap(
+        { username: values.username, password: values.password, managementKey, capToken },
+        { skipErrorHandler: true },
+      )
       .then(handleLoginSuccess, (err) => {
         handleLoginError(err);
         setCapToken('');
@@ -225,10 +238,20 @@ const Login: React.FC = () => {
       });
   };
 
-  const handleSubmit2 = (values: API.LoginParams) => {
+  /** 图片验证码登录 */
+  const handleSubmit2 = (values: LoginFormValues) => {
     const { id } = captchaInfo!;
     loginApi
-      .fetchLogin({ ...values, captchaId: id }, { skipErrorHandler: true })
+      .fetchAdminLogin(
+        {
+          username: values.username,
+          password: values.password,
+          managementKey,
+          captchaId: id,
+          captchaCode: values.captchaCode!,
+        },
+        { skipErrorHandler: true },
+      )
       .then(handleLoginSuccess, (err) => {
         handleLoginError(err);
         refreshCaptcha();
@@ -245,7 +268,7 @@ const Login: React.FC = () => {
       </Helmet>
       <Lang />
       <div style={{ flex: '1', padding: '32px 0' }}>
-        <LoginForm
+        <LoginForm<LoginFormValues>
           contentStyle={{ minWidth: 280, maxWidth: '75vw' }}
           logo={<Icon icon={mcLogoSvg} width="44px" height="44px" />}
           title={process.env.REACT_APP_NAME}
@@ -255,9 +278,9 @@ const Login: React.FC = () => {
           )}
           onFinish={async (values) => {
             if (capEndpoint !== '') {
-              await handleSubmit(values as API.LoginParams);
+              await handleSubmit(values);
             } else {
-              handleSubmit2(values as API.LoginParams);
+              handleSubmit2(values);
             }
           }}
         >

+ 20 - 13
src/requestConfig/authHeaderInterceptor.ts

@@ -1,8 +1,8 @@
 import { fetchRefreshToken } from '@/services/login';
 import { formatToken, getToken, setToken } from '@/utils/authUtils';
-import { md5, sha1 } from '@/utils/crypto';
-import { RequestConfig } from '@umijs/max';
-import { currentUnixTimestamp } from '../utils/timeUtils';
+import { stringMd5, stringSha1 } from '@/utils/crypto';
+import { currentUnixTimestamp } from '@/utils/timeUtils';
+import { RequestConfig, RequestInterceptorAxios } from '@umijs/max';
 
 class TokenRefresh {
   private static isRefreshing: boolean = false;
@@ -24,7 +24,7 @@ class TokenRefresh {
         TokenRefresh.isRefreshing = true;
         fetchRefreshToken({ headers: { Authorization: formatToken(refreshToken) } })
           .then(
-            (res) => {
+            (res: API.RefreshTokenResult) => {
               const info = res.data as API.UserInfo;
               const accessToken = info.accessToken!;
               setToken({
@@ -37,7 +37,7 @@ class TokenRefresh {
               TokenRefresh.requests.forEach((cb) => cb(accessToken));
               TokenRefresh.requests = [];
             },
-            (err) => {
+            (err: Error) => {
               reject(err);
             },
           )
@@ -50,19 +50,26 @@ class TokenRefresh {
   }
 }
 
-export const authHeaderInterceptor = (config: RequestConfig) => {
-  const ts = currentUnixTimestamp();
+export const authHeaderInterceptor: RequestInterceptorAxios = (config: RequestConfig) => {
   config.headers = config.headers ?? {};
-  config.headers['X-Request-Sign'] = sha1(ts + md5(navigator.userAgent ?? ''));
-  config.headers['X-Request-Timestamp'] = ts;
-  if (config.requireToken === false) return config;
+  const existingTs = config.headers['X-Request-Timestamp'];
+  let ts: string | number;
+  if (existingTs !== null && existingTs !== '') {
+    ts = String(existingTs);
+  } else {
+    ts = currentUnixTimestamp();
+    config.headers['X-Request-Timestamp'] = ts;
+  }
+  config.headers['X-Request-Sign'] = stringSha1(String(ts) + stringMd5(navigator.userAgent ?? ''));
+  if (config.requireToken === false) return config; // 默认所有接口都需要 token,如果接口不需要 token,需要显示设置 requireToken 为 false
+  // if (config.requireToken !== true) return config; // 默认所有接口都不需要 token,如果接口需要 token,需要显示设置 requireToken 为 true
 
   const data = getToken();
-  const expired = (data.expires ?? 0) - currentUnixTimestamp() <= 0;
-  if (data.accessToken && !expired) {
+  const expires = (data?.expires ?? 0) - currentUnixTimestamp() > 0;
+  if (data?.accessToken && expires) {
     config.headers['Authorization'] = formatToken(data.accessToken);
     return config;
   } else {
-    return TokenRefresh.beforeRequestRefreshTokenFirst(config, data.refreshToken!);
+    return TokenRefresh.beforeRequestRefreshTokenFirst(config, data?.refreshToken || 'none'); // 如果 refreshToken 为空,接口会返回 401 错误,跳转到登录页
   }
 };

+ 103 - 25
src/requestConfig/encryptionInterceptors.ts

@@ -1,28 +1,79 @@
-import { aesDecrypt, aesEncrypt } from '@/utils/crypto';
+import isNil from 'ramda/es/isNil';
+
+import globalConfig from '@/config';
+import { CompressMethod } from '@/config/types';
+// import { bytesBase64decode } from '@/utils/crypto';
+import { bytesToString, stringToBytes } from '@/utils/bytesUtils';
+import { bytesBase64encode } from '@/utils/crypto';
+import { decryptResponsePayload, encryptRequestPayload } from '@/utils/requestCrypto';
+import { currentUnixTimestamp } from '@/utils/timeUtils';
 import { RequestConfig, RequestInterceptorAxios, ResponseInterceptor } from '@umijs/max';
 
 /**
  * 请求数据加密拦截器
- * 对请求数据进行加密
+ * 将请求体加密为二进制(可选压缩 + 时间戳 + AES-CBC),以二进制发送
  */
-export const requestEncryptionInterceptor: RequestInterceptorAxios = (config) => {
-  const { enabled: optEnableEncryption, key: optEncryptionKey } =
-    (config as RequestConfig)?.encryption ?? {};
+export const requestEncryptionInterceptor: RequestInterceptorAxios = async (config) => {
+  const {
+    enabled: optEnableEncryption,
+    key: optEncryptionKey,
+    compressMethod: optCompressMethod,
+  } = (config as RequestConfig)?.encryption ?? {};
 
-  const enableEncryption = optEnableEncryption ?? process.env.ENABLE_REQUEST_ENCRYPTION;
-  const encryptionKey = optEncryptionKey ?? process.env.REQUEST_ENCRYPTION_KEY!;
+  const enableEncryption = optEnableEncryption ?? globalConfig.security.enableRequestEncryption;
+  const encryptionKey = optEncryptionKey ?? globalConfig.security.requestEncryptionKey;
+  const compressMethod = optCompressMethod ?? globalConfig.security.compressMethod;
 
   if (!enableEncryption) {
     return config;
   }
 
-  if (['post', 'put'].includes(config.method?.toLowerCase() || '') && config.data) {
-    config.headers = {
-      ...(config.headers ?? {}),
-      'X-Request-Encrypted': 'true',
-      'Content-Type': 'text/plain',
-    };
-    config.data = aesEncrypt(JSON.stringify(config.data), encryptionKey);
+  // 指定 axios 按二进制处理响应数据
+  config.responseType = 'arraybuffer';
+
+  // 指定接口数据压缩方式
+  config.headers = config.headers ?? {};
+  config.headers['X-NL-Content-Encoding'] = compressMethod;
+
+  // 设置 X-Request-Timestamp
+  const existingTs = config.headers['X-Request-Timestamp'];
+  let timestamp: number;
+  if (!isNil(existingTs) && existingTs !== '') {
+    timestamp = Number(existingTs);
+    if (Number.isNaN(timestamp)) timestamp = currentUnixTimestamp();
+  } else {
+    timestamp = currentUnixTimestamp();
+    config.headers['X-Request-Timestamp'] = timestamp;
+  }
+
+  config.data = config.data ?? {}; // 如果请求体为空,则设置为空对象(后端需要非空请求体)
+  console.log(
+    `⬆️ 🔐 [requestEncryptionInterceptor] api (${config.url}) request data:`,
+    config.data,
+  );
+  if (['post', 'put'].includes(config.method?.toLowerCase() || '')) {
+    // 设置 Content-Type 为 application/octet-stream
+    config.headers['Content-Type'] = 'application/octet-stream';
+
+    // 加密请求体
+    // const keyBytes = bytesBase64decode(encryptionKey);
+    const keyBytes = stringToBytes(encryptionKey);
+    const dataBytes = new TextEncoder().encode(JSON.stringify(config.data));
+    const encrypted = await encryptRequestPayload(
+      dataBytes,
+      timestamp,
+      keyBytes,
+      compressMethod as CompressMethod,
+    );
+
+    console.log(
+      `⬆️ 🔐 [requestEncryptionInterceptor] api (${config.url}) request data encrypted,`,
+      `timestamp: ${timestamp},`,
+      `data: ${bytesBase64encode(encrypted)}`,
+    );
+
+    // 设置请求体
+    config.data = encrypted.buffer;
   }
 
   return config;
@@ -30,27 +81,54 @@ export const requestEncryptionInterceptor: RequestInterceptorAxios = (config) =>
 
 /**
  * 响应数据解密拦截器
- * 对响应数据进行解密
+ * 将服务端返回的二进制密文解密(AES-CBC → 去时间戳 → 解压)并解析为 JSON
  */
-export const responseDecryptionInterceptor: ResponseInterceptor = (response) => {
-  const { enabled: optEnableEncryption, key: optEncryptionKey } =
-    (response.config as RequestConfig)?.encryption ?? {};
+export const responseDecryptionInterceptor: ResponseInterceptor = async (response) => {
+  const {
+    enabled: optEnableEncryption,
+    key: optEncryptionKey,
+    compressMethod: optCompressMethod,
+  } = (response.config as RequestConfig)?.encryption ?? {};
 
-  const enableEncryption = optEnableEncryption ?? process.env.ENABLE_REQUEST_ENCRYPTION;
-  const encryptionKey = optEncryptionKey ?? process.env.REQUEST_ENCRYPTION_KEY!;
+  const enableEncryption = optEnableEncryption ?? globalConfig.security.enableRequestEncryption;
+  const encryptionKey = optEncryptionKey ?? globalConfig.security.requestEncryptionKey;
+  const compressMethod = optCompressMethod ?? globalConfig.security.compressMethod;
 
   if (!enableEncryption) {
     return response;
   }
 
-  const isEncrypted = response.headers?.['X-Response-Encrypted'] === 'true';
+  // 后端永远加密响应体,但不设置 X-Response-Encrypted,因此不用判断 header 中的 X-Response-Encrypted 字段
+  // const isEncrypted = response.headers?.['X-Response-Encrypted'] === 'true';
+  const isEncrypted = true;
   if (isEncrypted && response.data) {
-    const decryptedData = aesDecrypt(response.data as string, encryptionKey);
-    if (decryptedData) {
+    const raw = response.data as ArrayBuffer | unknown;
+    const encryptedBytes = new Uint8Array(raw instanceof ArrayBuffer ? raw : (raw as ArrayBuffer));
+    // const keyBytes = bytesBase64decode(encryptionKey);
+    const keyBytes = stringToBytes(encryptionKey);
+    const decrypted = await decryptResponsePayload(
+      encryptedBytes,
+      keyBytes,
+      compressMethod as CompressMethod,
+    );
+    console.log(
+      `⬇️ 🔓 [responseDecryptionInterceptor] api (${response.config.url}) response data decrypted,`,
+      `timestamp: ${decrypted?.timestamp},`,
+      `data: ${bytesToString(decrypted?.data)}`,
+    );
+    if (decrypted?.data?.length) {
       try {
-        response.data = JSON.parse(decryptedData);
+        const dataStr = bytesToString(decrypted.data);
+        response.data = JSON.parse(dataStr);
+        console.log(
+          `⬇️ [responseDecryptionInterceptor] api (${response.config.url}) response data parsed:`,
+          response.data,
+        );
       } catch (error) {
-        console.error('Failed to parse decrypted response data:', error);
+        console.error(
+          `⬇️ [responseDecryptionInterceptor] api (${response.config.url}) response data parse failed:`,
+          error,
+        );
       }
     }
   }

+ 3 - 1
src/requestConfig/index.ts

@@ -15,8 +15,10 @@ export const requestConfig: RequestConfig = {
     Accept: 'application/json, text/plain, */*',
     'Content-Type': 'application/json',
     'X-Requested-With': 'XMLHttpRequest',
+    'X-NL-Product-Code': process.env.REACT_APP_ID!, // 每次请求必须包含产品代码
+    'X-NL-Request-Type': 'web', // 请求类型,用于区分请求来源
   },
-  paramsSerializer: stringify,
+  paramsSerializer: (params) => stringify(params),
   ...errorConfig,
   requestInterceptors: [authHeaderInterceptor, requestEncryptionInterceptor],
   responseInterceptors: [responseDecryptionInterceptor],

+ 20 - 12
src/services/login/index.ts

@@ -1,7 +1,24 @@
 import { request } from '@umijs/max';
 
-export async function fetchLogin(body: API.LoginParams, options?: { [key: string]: any }) {
-  return request<API.LoginResult>('/user/login', {
+/** 管理后台图片验证码登录(POST /auth/adminLogin) */
+export async function fetchAdminLogin(
+  body: API.AdminLoginParams,
+  options?: { [key: string]: any },
+) {
+  return request<API.LoginResult>('/auth/adminLogin', {
+    method: 'POST',
+    data: body,
+    ...(options || {}),
+    requireToken: false,
+  });
+}
+
+/** 管理后台 cap.js 登录(POST /auth/adminLogin/cap) */
+export async function fetchAdminLoginByCap(
+  body: API.AdminLoginByCapParams,
+  options?: { [key: string]: any },
+) {
+  return request<API.LoginResult>('/auth/adminLogin/cap', {
     method: 'POST',
     data: body,
     ...(options || {}),
@@ -17,7 +34,7 @@ export async function fetchLogout(options?: { [key: string]: any }) {
 }
 
 export async function fetchRefreshToken(options?: { [key: string]: any }) {
-  return request<API.RefreshTokenResult>('/user/refreshToken', {
+  return request<API.RefreshTokenResult>('/auth/refreshToken', {
     method: 'POST',
     ...(options || {}),
     requireToken: false,
@@ -54,15 +71,6 @@ export async function fetchUserUpdatePassword(
   });
 }
 
-export async function fetchLoginByCap(body: API.LoginParams, options?: { [key: string]: any }) {
-  return request<API.LoginResult>('/user/login/cap', {
-    method: 'POST',
-    data: body,
-    ...(options || {}),
-    requireToken: false,
-  });
-}
-
 export async function fetchCapEndpoint(options?: { [key: string]: any }) {
   return request<API.CapEndpointResult>('/capjs/endpoint', {
     method: 'POST',

+ 57 - 41
src/services/login/typings.d.ts

@@ -1,77 +1,93 @@
 declare namespace API {
-  type UserInfo = {
-    /** 用户ID */
-    id?: number;
-    /** 用户名 */
+  /** 服务端 LoginResp 中的用户信息(对应 Go UserInfo 结构体) */
+  interface ServerUserInfo {
+    userId: number;
+    username: string;
+    nickname: string;
+    avatar: string;
+    email: string;
+    phone: string;
+    isSuperAdmin: number;
+    mustChangePassword: number;
+    memberType: string;
+    perms: string[];
+  }
+
+  /** 服务端登录/刷新 token 接口返回的数据(对应 Go LoginResp 结构体) */
+  interface LoginResp {
+    accessToken: string;
+    refreshToken: string;
+    expires: number;
+    userInfo: ServerUserInfo;
+  }
+
+  /**
+   * 本地存储的用户信息(localStorage user-info + cookie authorized-token 内容合并)。
+   * 读取 token 和展示用户信息时均使用此类型。
+   */
+  interface UserInfo {
+    userId?: number;
     username?: string;
-    /** 昵称 */
     nickname?: string;
-    /** 头像 */
     avatar?: string;
-    /** 邮箱 */
     email?: string;
-    /** 手机号 */
     phone?: string;
-    /** 当前登录用户的角色 */
-    roles?: Array<string>;
-    /** 当前登录用户的按钮级别权限 */
-    perms?: Array<string>;
-    /** 当前登录用户的权限等级 值越大 权限越低 */
-    permsLevel?: number;
-    /** token */
+    isSuperAdmin?: number;
+    mustChangePassword?: number;
+    memberType?: string;
+    perms?: string[];
+    /** token 字段,与用户信息一起存储以方便读取 */
     accessToken?: string;
-    /** `accessToken`的过期时间(unix时间戳) */
     expires?: number;
-    /** 用于调用刷新accessToken的接口时所需的token */
     refreshToken?: string;
-  };
+  }
 
-  type LoginParams = {
+  /** 管理后台登录参数 */
+  interface AdminLoginParams {
     username: string;
     password: string;
+    managementKey: string;
     captchaId?: string;
     captchaCode?: string;
-    capToken?: string;
-  };
+  }
+
+  /** 管理后台 cap.js 登录参数 */
+  interface AdminLoginByCapParams {
+    username: string;
+    password: string;
+    managementKey: string;
+    capToken: string;
+  }
 
-  type LoginResult = Result<UserInfo>;
+  type LoginResult = Result<LoginResp>;
 
-  type RefreshTokenInfo = {
-    /** token */
-    accessToken: string;
-    /** 用于调用刷新accessToken的接口时所需的token */
-    refreshToken: string;
-    /** `accessToken`的过期时间(unix时间戳) */
-    expires: number;
-  };
-  type RefreshTokenResult = Result<RefreshTokenInfo>;
+  type RefreshTokenResult = Result<LoginResp>;
 
-  type CaptchaInfo = {
+  interface CaptchaInfo {
     base64image: string;
     id: string;
-  };
+  }
   type CaptchaResult = Result<CaptchaInfo>;
 
-  type CapEndpointData = {
-    code: number;
-    msg: string;
+  /** cap.js 端点响应(go-zero CapEndpointResp.Data) */
+  interface CapEndpointData {
     data: string;
-  };
+  }
   type CapEndpointResult = Result<CapEndpointData>;
 
-  type UserUpdateInfoReq = {
+  interface UserUpdateInfoReq {
     username: string;
     nickname: string;
     avatar: string;
     email: string;
     phone: string;
-  };
+  }
   type UserUpdateInfoResult = Result<Empty>;
 
-  type UserUpdatePasswordReq = {
+  interface UserUpdatePasswordReq {
     username: string;
     oldPassword: string;
     newPassword: string;
-  };
+  }
   type UserUpdatePasswordResult = Result<Empty>;
 }

+ 4 - 8
src/typings.d.ts

@@ -60,21 +60,17 @@ declare namespace NodeJS {
     ENABLE_STORAGE_ENCRYPTION?: boolean;
     /** 存储加密密钥 */
     STORAGE_ENCRYPTION_KEY?: string;
+    /** 管理后台登录密钥(对应服务端 Auth.ManagementKey,由前端透明传递,不在表单中展示) */
+    REACT_APP_MANAGEMENT_KEY?: string;
+    /** 请求数据压缩方式(nozip / br / gzip),默认 nozip */
+    REQUEST_DATA_COMPRESSION?: string;
     /** 接口服务器地址,开发环境下通常与 proxy 配置配合,将请求代理到 REACT_APP_REAL_API_URL 指定的服务器。生产环境下若 接口服务器 与 web服务器 的地址不同,需配置为完整地址 */
     REACT_APP_API_URL?: string;
-    /** 统计接口服务器地址,开发环境下通常与 proxy 配置配合,将请求代理到 REACT_APP_STAT_REAL_API_URL 指定的服务器。生产环境下若 统计接口服务器 与 web服务器 的地址不同,需配置为完整地址 */
-    REACT_APP_STAT_API_URL?: string;
-    /** 资源服务器地址,开发环境下通常与 proxy 配置配合,将请求代理到 REACT_APP_REAL_RES_SERVER 指定的服务器。生产环境下若 资源服务器 与 web服务器 的地址不同,需配置为完整地址 */
-    REACT_APP_RES_SERVER?: string;
   }
 
   interface DevProcessEnv extends ProcessEnv {
     /** 接口服务器的实际地址,不要在项目代码中引用,该配置仅在开发环境中才能被 本地代理服务器 成功引用 */
     REACT_APP_REAL_API_URL?: string;
-    /** 资源服务器的实际地址,不要在项目代码中引用,该配置仅在开发环境中才能被 本地代理服务器 成功引用 */
-    REACT_APP_REAL_RES_SERVER?: string;
-    /** 统计服务器的实际地址,不要在项目代码中引用,该配置仅在开发环境中才能被 本地代理服务器 成功引用 */
-    REACT_APP_STAT_REAL_API_URL?: string;
   }
 }
 

+ 2 - 0
src/umi-request.d.ts

@@ -1,3 +1,4 @@
+import { CompressMethod } from '@/config/types';
 import '@umijs/max';
 declare module '@umijs/max' {
   interface RequestConfig {
@@ -5,6 +6,7 @@ declare module '@umijs/max' {
     encryption?: {
       enabled: boolean;
       key: string;
+      compressMethod?: CompressMethod;
     };
   }
 }

+ 37 - 79
src/utils/authUtils.ts

@@ -1,114 +1,72 @@
-import { createLocalTools } from '@/utils/localUtils';
 import Cookies from 'js-cookie';
-import { currentJsTimestamp } from './timeUtils';
+import omit from 'ramda/es/omit';
 
-const ls = createLocalTools();
+import { secureLocalStorage as ls } from '@/utils/localUtils';
+import { decryptData, encryptData, encryptKey } from '@/utils/storage';
+
+import { currentJsTimestamp, currentUnixTimestamp } from './timeUtils';
 
 export const userKey = 'user-info';
 export const tokenKey = 'authorized-token';
 
 /** 获取`token` */
 export function getToken(): API.UserInfo {
-  return Cookies.get(tokenKey) ? JSON.parse(Cookies.get(tokenKey)!) : (ls.getLocal(userKey) ?? {});
+  const cookieData = Cookies.get(encryptKey(tokenKey));
+  const cookieDecrypted = cookieData ? decryptData(cookieData) : '';
+  return cookieDecrypted ? JSON.parse(cookieDecrypted) : (ls.getLocal(userKey) as API.UserInfo);
 }
 
 /**
- * @description
- *
- * 设置`token`以及一些必要信息并采用无感刷新`token`方案
- *
- * 无感刷新:后端需返回`accessToken`(用于 API 访问)、`refreshToken`(用于刷新`accessToken`)、`expires`(`accessToken`的过期时间)
- *
- * 访问普通接口时使用 `accessToken` 设置请求头
- *
- * 刷新`accessToken`时使用 `refreshToken` 设置请求头
- *
- * `refreshToken`的过期时间应大于`accessToken`的过期时间
- *
- * 将`accessToken`、`expires`、`refreshToken`这三条信息放在 key 值为 `authorized-token` 的 cookie 里(过期自动销毁)
- *
- * 将`username`、`nickname`、`avatar`、`email`、`phone`、`roles`、`perms`、`permsLevel`、`refreshToken`、`expires`这十条信息放在 key 值为`user-info`的 localStorage 里
+ * @description 设置 token 以及一些必要信息并采用无感刷新 token 方案
+ * 无感刷新:后端返回 accessToken(访问接口使用的 token)、refreshToken(用于调用刷新 accessToken 的接口时所需的 token,refreshToken 的过期时间(比如30天)应大于 accessToken 的过期时间(比如2小时))、expires(accessToken 的过期时间)
+ * 将 accessToken、expires、refreshToken 这三条信息放在 key 值为 authorized-token 的 cookie 里(过期自动销毁)
+ * 将除了 accessToken 之外的其他信息放在 key 值为 user-info 的 localStorage 里
  */
 export function setToken(data: API.UserInfo) {
-  const {
-    id,
-    username,
-    nickname = '',
-    avatar = '',
-    email = '',
-    phone = '',
-    roles = [],
-    perms = [],
-    permsLevel = 999999,
-    accessToken = '',
-    expires = 0,
-    refreshToken = '',
-  } = data;
+  const { accessToken = '', expires = 0, refreshToken = '' } = data;
+  const cookieData = JSON.stringify({ accessToken, expires, refreshToken });
+  const cookieEncrypted = encryptData(cookieData);
 
-  const cookieString = JSON.stringify({ accessToken, expires, refreshToken });
   if (expires > 0) {
-    Cookies.set(tokenKey, cookieString, {
+    Cookies.set(encryptKey(tokenKey), cookieEncrypted, {
       expires: (expires * 1000 - currentJsTimestamp()) / 86400000,
     });
   } else {
-    Cookies.set(tokenKey, cookieString);
-  }
-
-  function saveUserInfo(info: API.UserInfo) {
-    ls.setLocal(userKey, info);
+    Cookies.set(encryptKey(tokenKey), cookieEncrypted);
   }
 
-  if (username) {
-    // username 不为空,说明是登录,此时需保存完整的用户信息
-    saveUserInfo({
-      id,
-      username,
-      nickname,
-      avatar,
-      email,
-      phone,
-      roles,
-      perms,
-      permsLevel,
+  function setUserKey(data: API.UserInfo) {
+    ls.setLocal(userKey, {
       refreshToken,
       expires,
+      ...omit(['accessToken', 'expires', 'refreshToken'], data),
     });
+  }
+
+  if (data.username) {
+    // 登录时,后端接口会返回用户信息,直接设置到 localStorage 中
+    setUserKey(data);
   } else {
-    // username 为空,说明是刷新 token,此时更新 refreshToken 和 expires
+    // 刷新 token 时,后端接口不会返回用户信息,需要从 localStorage 中获取用户信息
     const d = ls.getLocal<API.UserInfo>(userKey);
-    const {
-      id,
-      username,
-      nickname = '',
-      avatar = '',
-      email = '',
-      phone = '',
-      roles = [],
-      perms = [],
-      permsLevel = 999999,
-    } = d ?? {};
-    saveUserInfo({
-      id,
-      username,
-      nickname,
-      avatar,
-      email,
-      phone,
-      roles,
-      perms,
-      permsLevel,
-      refreshToken,
-      expires,
-    });
+    setUserKey({ ...d });
   }
 }
 
-/** 删除`token`以及 key 值为`user-info`的 session 信息 */
+/** 删除`token`以及key值为`user-info`的session信息 */
 export function removeToken() {
-  Cookies.remove(tokenKey);
+  Cookies.remove(encryptKey(tokenKey));
   ls.removeLocal(userKey);
 }
 
+/** 实时判断当前是否已认证(accessToken 存在且未过期) */
+export function isAuthenticated(): boolean {
+  const token = getToken();
+  if (!token?.accessToken) return false;
+  if (!token.expires) return false;
+  return token.expires - currentUnixTimestamp() > 0;
+}
+
 /** 格式化token(jwt格式) */
 export const formatToken = (token: string): string => {
   return 'Bearer ' + token;

+ 91 - 0
src/utils/bytesUtils.ts

@@ -0,0 +1,91 @@
+export enum Endian {
+  BE = 'be',
+  LE = 'le',
+}
+
+const UTF8_ENCODER = new TextEncoder();
+const UTF8_DECODER = new TextDecoder();
+
+function swapWord32Bytes(bytes: Uint8Array): Uint8Array {
+  const out = new Uint8Array(bytes.length);
+  let i = 0;
+  for (; i + 4 <= bytes.length; i += 4) {
+    out[i] = bytes[i + 3]!;
+    out[i + 1] = bytes[i + 2]!;
+    out[i + 2] = bytes[i + 1]!;
+    out[i + 3] = bytes[i]!;
+  }
+  for (; i < bytes.length; i++) out[i] = bytes[i]!;
+  return out;
+}
+
+/** 将小端数据转换为大端数据 */
+export function littleEndianToBigEndian(bytes: Uint8Array): Uint8Array {
+  return swapWord32Bytes(bytes);
+}
+
+/** 将大端数据转换为小端数据 */
+export function bigEndianToLittleEndian(bytes: Uint8Array): Uint8Array {
+  return swapWord32Bytes(bytes);
+}
+
+/** 在 buffer 的 offset 处写入 4 字节无符号整数 */
+export function numberToBytesAt(
+  value: number,
+  buffer: Uint8Array,
+  offset: number,
+  endian: Endian = Endian.BE,
+): void {
+  if (endian === Endian.BE) {
+    buffer[offset] = (value >>> 24) & 0xff;
+    buffer[offset + 1] = (value >>> 16) & 0xff;
+    buffer[offset + 2] = (value >>> 8) & 0xff;
+    buffer[offset + 3] = value & 0xff;
+  } else {
+    buffer[offset] = value & 0xff;
+    buffer[offset + 1] = (value >>> 8) & 0xff;
+    buffer[offset + 2] = (value >>> 16) & 0xff;
+    buffer[offset + 3] = (value >>> 24) & 0xff;
+  }
+}
+
+/** 将数字转为 4 字节二进制;endian 为 'be' 大端 或 'le' 小端 */
+export function numberToBytes(value: number, endian: Endian = Endian.BE): Uint8Array {
+  const buf = new Uint8Array(4);
+  numberToBytesAt(value, buf, 0, endian);
+  return buf;
+}
+
+/** 从 buffer 的 offset 处读取 4 字节无符号整数 */
+export function bytesToNumber(
+  buffer: Uint8Array,
+  offset: number,
+  endian: Endian = Endian.BE,
+): number {
+  if (endian === 'be') {
+    return (
+      ((buffer[offset]! << 24) |
+        (buffer[offset + 1]! << 16) |
+        (buffer[offset + 2]! << 8) |
+        buffer[offset + 3]!) >>>
+      0
+    );
+  }
+  return (
+    (buffer[offset]! |
+      (buffer[offset + 1]! << 8) |
+      (buffer[offset + 2]! << 16) |
+      (buffer[offset + 3]! << 24)) >>>
+    0
+  );
+}
+
+/** 将 UTF-8 字符串转为二进制 */
+export function stringToBytes(str: string): Uint8Array {
+  return UTF8_ENCODER.encode(str);
+}
+
+/** 将 UTF-8 二进制转为字符串 */
+export function bytesToString(bytes: Uint8Array): string {
+  return UTF8_DECODER.decode(bytes);
+}

+ 25 - 0
src/utils/clipboard.ts

@@ -0,0 +1,25 @@
+import copy from 'copy-to-clipboard';
+
+export const copyText = async (text: string): Promise<boolean> => {
+  const copyByNavigator = async () => {
+    try {
+      if (navigator.clipboard?.writeText) {
+        await navigator.clipboard.writeText(text);
+        return true;
+      }
+      return false;
+    } catch {
+      return false;
+    }
+  };
+
+  try {
+    const navigatorSuccess = await copyByNavigator();
+    if (navigatorSuccess) {
+      return true;
+    }
+    return copy(text);
+  } catch {
+    return false;
+  }
+};

+ 49 - 0
src/utils/compress.ts

@@ -0,0 +1,49 @@
+import brotliWasm from 'brotli-wasm';
+import { gunzipSync, gzipSync } from 'fflate';
+
+export enum CompressFormat {
+  GZIP = 'gzip',
+  BROTLI = 'brotli',
+}
+
+let brotliModule: Awaited<typeof brotliWasm> | null = null;
+
+async function getBrotli() {
+  if (brotliModule) return brotliModule;
+  brotliModule = await brotliWasm;
+  return brotliModule;
+}
+
+/**
+ * 使用 fflate(brotli-wasm) 压缩二进制数据
+ * @param bytes 原始二进制数据
+ * @param format 压缩格式
+ * @returns 压缩后的二进制数据
+ */
+export async function compressBytes(
+  bytes: Uint8Array,
+  format: CompressFormat,
+): Promise<Uint8Array> {
+  if (format === CompressFormat.GZIP) {
+    return gzipSync(bytes);
+  }
+  const brotli = await getBrotli();
+  return brotli.compress(bytes);
+}
+
+/**
+ * 使用 fflate(brotli-wasm) 解压二进制数据
+ * @param bytes 压缩后的二进制数据
+ * @param format 压缩格式(需与压缩时一致)
+ * @returns 解压后的二进制数据
+ */
+export async function decompressBytes(
+  bytes: Uint8Array,
+  format: CompressFormat,
+): Promise<Uint8Array> {
+  if (format === CompressFormat.GZIP) {
+    return gunzipSync(bytes);
+  }
+  const brotli = await getBrotli();
+  return brotli.decompress(bytes);
+}

+ 25 - 0
src/utils/crypto/base64url.ts

@@ -0,0 +1,25 @@
+function padString(input: string): string {
+  const segmentLength = 4;
+  const diff = input.length % segmentLength;
+  if (!diff) return input;
+  const padLength = segmentLength - diff;
+  return input + '='.repeat(padLength);
+}
+
+/**
+ * 将 Base64URL 字符串转换为 Base64 字符串
+ * @param base64url Base64URL 字符串
+ * @returns Base64 字符串
+ */
+export function toBase64(base64url: string): string {
+  return padString(base64url).replace(/-/g, '+').replace(/_/g, '/');
+}
+
+/**
+ * 将 Base64 字符串转换为 Base64URL 字符串
+ * @param base64 Base64 字符串
+ * @returns Base64URL 字符串
+ */
+export function fromBase64(base64: string): string {
+  return base64.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
+}

+ 423 - 59
src/utils/crypto/index.ts

@@ -1,99 +1,231 @@
 import AES from 'crypto-js/aes';
+import 'crypto-js/cipher-core';
+import CryptoJS from 'crypto-js/core';
 import encBase64 from 'crypto-js/enc-base64';
 import encUtf8 from 'crypto-js/enc-utf8';
+import 'crypto-js/lib-typedarrays';
 import MD5 from 'crypto-js/md5';
+import modeCtr from 'crypto-js/mode-ctr';
+import modeEcb from 'crypto-js/mode-ecb';
 import Rabbit from 'crypto-js/rabbit';
 import SHA1 from 'crypto-js/sha1';
 import SHA256 from 'crypto-js/sha256';
 
-export function md5(str: string, lowerCase?: boolean): string {
+import { bigEndianToLittleEndian, littleEndianToBigEndian } from '@/utils/bytesUtils';
+
+import { fromBase64, toBase64 } from './base64url';
+
+// ---------------------------------------------------------------------------
+// 常量
+// ---------------------------------------------------------------------------
+
+const WordArray = CryptoJS.lib.WordArray as typeof CryptoJS.lib.WordArray & {
+  create(words?: number[] | Uint8Array, sigBytes?: number): CryptoJS.lib.WordArray;
+};
+const CipherParams = CryptoJS.lib.CipherParams as {
+  create(cfg: {
+    ciphertext: CryptoJS.lib.WordArray;
+    iv?: CryptoJS.lib.WordArray;
+  }): CryptoJS.lib.CipherParams;
+};
+
+const KEY_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
+const KEY_CHARS_LEN = KEY_CHARS.length;
+const MAX_ALLOWED = 256 - (256 % KEY_CHARS_LEN);
+
+const AES_KEY_BYTES = 32;
+const AES_IV_BYTES = 16;
+const RABBIT_KEY_BYTES = 16;
+const RABBIT_IV_BYTES = 8;
+
+// ---------------------------------------------------------------------------
+// 内部工具函数
+// ---------------------------------------------------------------------------
+
+function wordsToBytes(words: number[], sigBytes: number, littleEndian?: boolean): Uint8Array {
+  const out = new Uint8Array(sigBytes);
+  for (let i = 0; i < sigBytes; i++) {
+    out[i] = (words[i >>> 2]! >>> (24 - (i % 4) * 8)) & 0xff;
+  }
+  return littleEndian ? bigEndianToLittleEndian(out) : out;
+}
+
+function bytesToWordArray(bytes: Uint8Array, littleEndian?: boolean): CryptoJS.lib.WordArray {
+  const input = littleEndian ? littleEndianToBigEndian(bytes) : bytes;
+  return WordArray.create(input);
+}
+
+function wordArrayToBytes(wa: CryptoJS.lib.WordArray, littleEndian?: boolean): Uint8Array {
+  return wordsToBytes(wa.words, wa.sigBytes, littleEndian);
+}
+
+function padBytes(buf: Uint8Array, len: number): Uint8Array {
+  if (buf.length >= len) return buf.subarray(0, len);
+  const out = new Uint8Array(len);
+  out.set(buf);
+  return out;
+}
+
+/**
+ * 计算字符串的 MD5
+ */
+export function stringMd5(str: string, lowerCase?: boolean): string {
   return lowerCase ? String(MD5(str)).toLowerCase() : String(MD5(str)).toUpperCase();
 }
 
-export function sha1(str: string, lowerCase?: boolean): string {
+/**
+ * 计算二进制数据的 MD5,返回 16 字节
+ */
+export function bytesMd5(bytes: Uint8Array): Uint8Array {
+  return wordArrayToBytes(MD5(bytesToWordArray(bytes)) as CryptoJS.lib.WordArray);
+}
+
+/**
+ * 计算字符串的 SHA-1
+ */
+export function stringSha1(str: string, lowerCase?: boolean): string {
   return lowerCase ? String(SHA1(str)).toLowerCase() : String(SHA1(str)).toUpperCase();
 }
 
-export function sha256(str: string, lowerCase?: boolean): string {
+/**
+ * 计算二进制数据的 SHA-1,返回 20 字节
+ */
+export function bytesSha1(bytes: Uint8Array): Uint8Array {
+  return wordArrayToBytes(SHA1(bytesToWordArray(bytes)) as CryptoJS.lib.WordArray);
+}
+
+/**
+ * 计算字符串的 SHA-256
+ */
+export function stringSha256(str: string, lowerCase?: boolean): string {
   return lowerCase ? String(SHA256(str)).toLowerCase() : String(SHA256(str)).toUpperCase();
 }
 
-export function base64encode(raw: string): string {
+/**
+ * 计算二进制数据的 SHA-256,返回 32 字节
+ */
+export function bytesSha256(bytes: Uint8Array): Uint8Array {
+  return wordArrayToBytes(SHA256(bytesToWordArray(bytes)) as CryptoJS.lib.WordArray);
+}
+
+/**
+ * 将字符串编码为 Base64 字符串
+ */
+export function stringBase64encode(raw: string, urlSafe = false): string {
+  try {
+    const out = encBase64.stringify(encUtf8.parse(raw));
+    if (urlSafe) return fromBase64(out);
+    return out;
+  } catch {
+    return '';
+  }
+}
+
+/**
+ * 对二进制数据进行 Base64 编码
+ */
+export function bytesBase64encode(bytes: Uint8Array, urlSafe = false): string {
   try {
-    return encBase64.stringify(encUtf8.parse(raw));
+    const out = encBase64.stringify(bytesToWordArray(bytes));
+    if (urlSafe) return fromBase64(out);
+    return out;
   } catch {
     return '';
   }
 }
 
-export function base64decode(str: string): string {
+/**
+ * 将 Base64 字符串解码为字符串
+ */
+export function stringBase64decode(str: string, urlSafe = false): string {
   try {
-    return encBase64.parse(str).toString(encUtf8);
+    const input = urlSafe ? toBase64(str) : str;
+    return encBase64.parse(input).toString(encUtf8);
   } catch {
     return '';
   }
 }
 
 /**
- * 生成随机密钥
+ * 将 Base64 字符串解码为二进制数据
+ */
+export function bytesBase64decode(str: string, urlSafe = false): Uint8Array {
+  try {
+    const input = urlSafe ? toBase64(str) : str;
+    return wordArrayToBytes(encBase64.parse(input));
+  } catch {
+    return new Uint8Array(0);
+  }
+}
+
+/**
+ * 生成随机密钥 (字符串)
  * @param length 密钥长度
  * @returns 随机密钥
  */
-export const generateKey = (length: number = 32): string => {
-  const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
-  let result = '';
-  for (let i = 0; i < length; i++) {
-    result += chars.charAt(Math.floor(Math.random() * chars.length));
+export const generateKeyString = (length: number = 32): string => {
+  if (length <= 0) return '';
+
+  const result: string[] = [];
+  const bufferSize = Math.ceil((length * 256) / MAX_ALLOWED) + 16;
+  const buffer = new Uint8Array(bufferSize);
+  let pos = buffer.length;
+  while (result.length < length) {
+    if (pos >= buffer.length) {
+      crypto.getRandomValues(buffer);
+      pos = 0;
+    }
+    const byte = buffer[pos++];
+    if (byte < MAX_ALLOWED) {
+      result.push(KEY_CHARS[byte % KEY_CHARS_LEN]);
+    }
   }
-  return result;
+  return result.join('');
 };
 
 /**
- * 生成随机 IV
- * @param length 初始化向量长度
- * @returns 16 字节的随机 IV
+ * 生成随机密钥 (二进制)
+ * @param length 密钥长度
+ * @returns 随机密钥
  */
-const generateIV = (length: number = 16): string => {
-  return generateKey(length);
+export const generateKeyBytes = (length: number): Uint8Array => {
+  const out = new Uint8Array(length);
+  crypto.getRandomValues(out);
+  return out;
 };
 
 /**
- * 加密数据
- * @param data 要加密的字符串数据
- * @param key 加密密钥
- * @param iv 可选的初始化向量,如果不提供则随机生成
- * @returns 加密后的字符串(如果未提供 IV,则包含 IV)
+ * AES-CBC 加密(字符串)
+ *
+ * 注意:
+ *   - 如果如果 iv 为空,则返回的加密结果的头部会包含 iv,否则不包含 iv。
+ *   - 如果 key 的长度不足 32 位,则会在末尾补齐 字符'0'。
+ *   - 如果 iv 的长度不足 16 位,则会在末尾补齐 字符'0'。
  */
-export const aesEncrypt = (data: string, key: string, iv?: string): string => {
+export const aesCbcEncryptString = (data: string, key: string, iv?: string): string => {
   if (!data || !key) return '';
-  // aes算法要求密钥长度为 16、24 或 32 字节,因此我们这里保证密钥长度为 32 个字符
   const paddedKey = key.padEnd(32, '0').slice(0, 32);
-  // aes算法要求 IV 长度为 16 字节,因此我们这里保证 IV 长度为 16 个字符
-  const ivValue = iv ? iv.padEnd(16, '0').slice(0, 16) : generateIV(16);
+  const ivValue = iv ? iv.padEnd(16, '0').slice(0, 16) : generateKeyString(16);
   const keyHex = encUtf8.parse(paddedKey);
   const ivHex = encUtf8.parse(ivValue);
   const encrypted = AES.encrypt(data, keyHex, { iv: ivHex });
-  return iv ? encrypted.toString() : ivValue + encrypted.toString();
+  const ciphertextBase64 = encBase64.stringify(encrypted.ciphertext);
+  return iv ? ciphertextBase64 : ivValue + ciphertextBase64;
 };
 
 /**
- * 解密数据
- * @param encryptedData 加密的字符串(如果加密时未提供 IV,则包含 IV)
- * @param key 解密密钥
- * @param iv 可选的初始化向量,如果不提供则从加密数据中提取
- * @returns 解密后的字符串
+ * AES-CBC 解密(字符串),对应 aesCbcEncryptString
  */
-export const aesDecrypt = (encryptedData: string, key: string, iv?: string): string => {
+export const aesCbcDecryptString = (encryptedData: string, key: string, iv?: string): string => {
   if (!encryptedData || !key) return '';
   try {
-    // aes算法要求密钥长度为 16、24 或 32 字节,因此我们这里保证密钥长度为 32 个字符
     const paddedKey = key.padEnd(32, '0').slice(0, 32);
-    // aes算法要求 IV 长度为 16 字节,因此我们这里保证 IV 长度为 16 个字符
     const ivValue = iv ? iv.padEnd(16, '0').slice(0, 16) : encryptedData.slice(0, 16);
-    const data = iv ? encryptedData : encryptedData.slice(16);
+    const ciphertextBase64 = iv ? encryptedData : encryptedData.slice(16);
     const keyHex = encUtf8.parse(paddedKey);
     const ivHex = encUtf8.parse(ivValue);
-    const decrypted = AES.decrypt(data, keyHex, { iv: ivHex });
+    const cp = CipherParams.create({ ciphertext: encBase64.parse(ciphertextBase64) });
+    const decrypted = AES.decrypt(cp, keyHex, { iv: ivHex });
     return decrypted.toString(encUtf8);
   } catch (error) {
     console.error('AES decrypt error:', error);
@@ -102,45 +234,277 @@ export const aesDecrypt = (encryptedData: string, key: string, iv?: string): str
 };
 
 /**
- * Rabbit 加密
- * @param data 要加密的数据
- * @param key 加密密钥
- * @param iv 可选的初始化向量,如果不提供则随机生成
- * @returns 加密后的数据(如果未提供 IV,则包含 IV)
+ * AES-CBC 加密(二进制)
+ *
+ * 注意:
+ *   - 如果如果 iv 为空,则返回的加密结果的头部会包含 iv,否则不包含 iv。
+ *   - 如果 key 的长度不足 32 位,则会在末尾补齐 数字0。
+ *   - 如果 iv 的长度不足 16 位,则会在末尾补齐 数字0。
+ */
+export function aesCbcEncryptBytes(data: Uint8Array, key: Uint8Array, iv?: Uint8Array): Uint8Array {
+  if (!data.length || !key.length) return new Uint8Array(0);
+  const keyWa = bytesToWordArray(padBytes(key, AES_KEY_BYTES));
+  const ivWa = iv
+    ? bytesToWordArray(padBytes(iv, AES_IV_BYTES))
+    : bytesToWordArray(generateKeyBytes(AES_IV_BYTES));
+  const encrypted = AES.encrypt(bytesToWordArray(data), keyWa, { iv: ivWa });
+  const ct = wordArrayToBytes(encrypted.ciphertext);
+  if (iv) return ct;
+  const out = new Uint8Array(AES_IV_BYTES + ct.length);
+  out.set(wordArrayToBytes(ivWa), 0);
+  out.set(ct, AES_IV_BYTES);
+  return out;
+}
+
+/**
+ * AES-CBC 解密(二进制)对应 aesCbcEncryptBytes
+ */
+export function aesCbcDecryptBytes(
+  encryptedData: Uint8Array,
+  key: Uint8Array,
+  iv?: Uint8Array,
+): Uint8Array {
+  if (!encryptedData.length || !key.length) return new Uint8Array(0);
+  try {
+    const ivBytes = iv ? padBytes(iv, AES_IV_BYTES) : encryptedData.subarray(0, AES_IV_BYTES);
+    const ctBytes = iv ? encryptedData : encryptedData.subarray(AES_IV_BYTES);
+    const keyWa = bytesToWordArray(padBytes(key, AES_KEY_BYTES));
+    const ivWa = bytesToWordArray(ivBytes);
+    const cp = CipherParams.create({ ciphertext: bytesToWordArray(ctBytes), iv: ivWa });
+    const decrypted = AES.decrypt(cp, keyWa, { iv: ivWa });
+    return wordArrayToBytes(decrypted);
+  } catch (error) {
+    console.error('AES-CBC decrypt bytes error:', error);
+    return new Uint8Array(0);
+  }
+}
+
+/**
+ * AES-CTR 加密(字符串)
+ *
+ * 注意:
+ *   - 如果如果 iv 为空,则返回的加密结果的头部会包含 iv,否则不包含 iv。
+ *   - 如果 key 的长度不足 32 位,则会在末尾补齐 字符'0'。
+ *   - 如果 iv 的长度不足 16 位,则会在末尾补齐 字符'0'。
  */
-export const rabbitEncrypt = (data: string, key: string, iv?: string): string => {
+export const aesCtrEncryptString = (data: string, key: string, iv?: string): string => {
+  if (!data || !key) return '';
+  const paddedKey = key.padEnd(32, '0').slice(0, 32);
+  const ivValue = iv ? iv.padEnd(16, '0').slice(0, 16) : generateKeyString(16);
+  const keyHex = encUtf8.parse(paddedKey);
+  const ivHex = encUtf8.parse(ivValue);
+  const encrypted = AES.encrypt(data, keyHex, { iv: ivHex, mode: modeCtr });
+  const ciphertextBase64 = encBase64.stringify(encrypted.ciphertext);
+  return iv ? ciphertextBase64 : ivValue + ciphertextBase64;
+};
+
+/**
+ * AES-CTR 解密(字符串)对应 aesCtrEncryptString
+ */
+export const aesCtrDecryptString = (encryptedData: string, key: string, iv?: string): string => {
+  if (!encryptedData || !key) return '';
+  try {
+    const paddedKey = key.padEnd(32, '0').slice(0, 32);
+    const ivValue = iv ? iv.padEnd(16, '0').slice(0, 16) : encryptedData.slice(0, 16);
+    const ciphertextBase64 = iv ? encryptedData : encryptedData.slice(16);
+    const keyHex = encUtf8.parse(paddedKey);
+    const ivHex = encUtf8.parse(ivValue);
+    const cp = CipherParams.create({ ciphertext: encBase64.parse(ciphertextBase64) });
+    const decrypted = AES.decrypt(cp, keyHex, { iv: ivHex, mode: modeCtr });
+    return decrypted.toString(encUtf8);
+  } catch (error) {
+    console.error('AES-CTR decrypt error:', error);
+    return '';
+  }
+};
+
+/**
+ * AES-CTR 加密(二进制)
+ *
+ * 注意:
+ *   - 如果如果 iv 为空,则返回的加密结果的头部会包含 iv,否则不包含 iv。
+ *   - 如果 key 的长度不足 32 位,则会在末尾补齐 数字0。
+ *   - 如果 iv 的长度不足 16 位,则会在末尾补齐 数字0。
+ */
+export function aesCtrEncryptBytes(data: Uint8Array, key: Uint8Array, iv?: Uint8Array): Uint8Array {
+  if (!data.length || !key.length) return new Uint8Array(0);
+  const keyWa = bytesToWordArray(padBytes(key, AES_KEY_BYTES));
+  const ivWa = iv
+    ? bytesToWordArray(padBytes(iv, AES_IV_BYTES))
+    : bytesToWordArray(generateKeyBytes(AES_IV_BYTES));
+  const encrypted = AES.encrypt(bytesToWordArray(data), keyWa, { iv: ivWa, mode: modeCtr });
+  const ct = wordArrayToBytes(encrypted.ciphertext);
+  if (iv) return ct;
+  const out = new Uint8Array(AES_IV_BYTES + ct.length);
+  out.set(wordArrayToBytes(ivWa), 0);
+  out.set(ct, AES_IV_BYTES);
+  return out;
+}
+
+/**
+ * AES-CTR 解密(二进制)
+ */
+export function aesCtrDecryptBytes(
+  encryptedData: Uint8Array,
+  key: Uint8Array,
+  iv?: Uint8Array,
+): Uint8Array {
+  if (!encryptedData.length || !key.length) return new Uint8Array(0);
+  try {
+    const ivBytes = iv ? padBytes(iv, AES_IV_BYTES) : encryptedData.subarray(0, AES_IV_BYTES);
+    const ctBytes = iv ? encryptedData : encryptedData.subarray(AES_IV_BYTES);
+    const keyWa = bytesToWordArray(padBytes(key, AES_KEY_BYTES));
+    const ivWa = bytesToWordArray(ivBytes);
+    const cp = CipherParams.create({ ciphertext: bytesToWordArray(ctBytes), iv: ivWa });
+    const decrypted = AES.decrypt(cp, keyWa, { iv: ivWa, mode: modeCtr });
+    return wordArrayToBytes(decrypted);
+  } catch (error) {
+    console.error('AES-CTR decrypt bytes error:', error);
+    return new Uint8Array(0);
+  }
+}
+
+/**
+ * AES-ECB 加密(字符串,无 IV,不推荐用于敏感数据)
+ *
+ * 注意:
+ *   - 如果 key 的长度不足32位,则会在末尾补齐 字符'0'。
+ */
+export const aesEcbEncryptString = (data: string, key: string): string => {
+  if (!data || !key) return '';
+  const paddedKey = key.padEnd(32, '0').slice(0, 32);
+  const keyHex = encUtf8.parse(paddedKey);
+  const encrypted = AES.encrypt(data, keyHex, { mode: modeEcb });
+  return encBase64.stringify(encrypted.ciphertext);
+};
+
+/**
+ * AES-ECB 解密(字符串)
+ */
+export const aesEcbDecryptString = (encryptedData: string, key: string): string => {
+  if (!encryptedData || !key) return '';
+  try {
+    const paddedKey = key.padEnd(32, '0').slice(0, 32);
+    const keyHex = encUtf8.parse(paddedKey);
+    const cp = CipherParams.create({ ciphertext: encBase64.parse(encryptedData) });
+    const decrypted = AES.decrypt(cp, keyHex, { mode: modeEcb });
+    return decrypted.toString(encUtf8);
+  } catch (error) {
+    console.error('AES-ECB decrypt error:', error);
+    return '';
+  }
+};
+
+/**
+ * AES-ECB 加密(二进制,无 IV)
+ *
+ * 注意:
+ *   - 如果 key 的长度不足 32 位,则会在末尾补齐 数字0。
+ */
+export function aesEcbEncryptBytes(data: Uint8Array, key: Uint8Array): Uint8Array {
+  if (!data.length || !key.length) return new Uint8Array(0);
+  const keyWa = bytesToWordArray(padBytes(key, AES_KEY_BYTES));
+  const encrypted = AES.encrypt(bytesToWordArray(data), keyWa, { mode: modeEcb });
+  return wordArrayToBytes(encrypted.ciphertext);
+}
+
+/**
+ * AES-ECB 解密(二进制)
+ */
+export function aesEcbDecryptBytes(encryptedData: Uint8Array, key: Uint8Array): Uint8Array {
+  if (!encryptedData.length || !key.length) return new Uint8Array(0);
+  try {
+    const keyWa = bytesToWordArray(padBytes(key, AES_KEY_BYTES));
+    const cp = CipherParams.create({ ciphertext: bytesToWordArray(encryptedData) });
+    const decrypted = AES.decrypt(cp, keyWa, { mode: modeEcb });
+    return wordArrayToBytes(decrypted);
+  } catch (error) {
+    console.error('AES-ECB decrypt bytes error:', error);
+    return new Uint8Array(0);
+  }
+}
+
+/**
+ * Rabbit 加密(字符串)
+ *
+ * 注意:
+ *   - 如果如果 iv 为空,则返回的加密结果的头部会包含 iv,否则不包含 iv。
+ *   - 如果 key 的长度不足 16 位,则会在末尾补齐 字符'0'。
+ *   - 如果 iv 的长度不足 8 位,则会在末尾补齐 字符'0'。
+ */
+export const rabbitEncryptString = (data: string, key: string, iv?: string): string => {
   if (!data || !key) return '';
-  // Rabbit 算法要求密钥长度为 16 字节,因此我们这里保证密钥长度为 16 个字符
   const paddedKey = key.padEnd(16, '0').slice(0, 16);
-  // Rabbit 算法要求 IV 长度为 8 字节,因此我们这里保证 IV 长度为 8 个字符
-  const ivValue = iv ? iv.padEnd(8, '0').slice(0, 8) : generateIV(8);
+  const ivValue = iv ? iv.padEnd(8, '0').slice(0, 8) : generateKeyString(8);
   const keyHex = encUtf8.parse(paddedKey);
   const ivHex = encUtf8.parse(ivValue);
   const encrypted = Rabbit.encrypt(data, keyHex, { iv: ivHex });
-  return iv ? encrypted.toString() : ivValue + encrypted.toString();
+  const ciphertextBase64 = encBase64.stringify(encrypted.ciphertext);
+  return iv ? ciphertextBase64 : ivValue + ciphertextBase64;
 };
 
 /**
- * Rabbit 解密
- * @param encryptedData 加密的数据(如果加密时未提供 IV,则包含 IV)
- * @param key 解密密钥
- * @param iv 可选的初始化向量,如果不提供则从加密数据中提取
- * @returns 解密后的数据
+ * Rabbit 解密(字符串)对应 rabbitEncryptString
  */
-export const rabbitDecrypt = (encryptedData: string, key: string, iv?: string): string => {
+export const rabbitDecryptString = (encryptedData: string, key: string, iv?: string): string => {
   if (!encryptedData || !key) return '';
   try {
-    // Rabbit 算法要求密钥长度为 16 字节,因此我们这里保证密钥长度为 16 个字符
     const paddedKey = key.padEnd(16, '0').slice(0, 16);
-    // Rabbit 算法要求 IV 长度为 8 字节,因此我们这里保证 IV 长度为 8 个字符
     const ivValue = iv ? iv.padEnd(8, '0').slice(0, 8) : encryptedData.slice(0, 8);
-    const data = iv ? encryptedData : encryptedData.slice(8);
+    const ciphertextBase64 = iv ? encryptedData : encryptedData.slice(8);
     const keyHex = encUtf8.parse(paddedKey);
     const ivHex = encUtf8.parse(ivValue);
-    const decrypted = Rabbit.decrypt(data, keyHex, { iv: ivHex });
+    const cp = CipherParams.create({ ciphertext: encBase64.parse(ciphertextBase64) });
+    const decrypted = Rabbit.decrypt(cp, keyHex, { iv: ivHex });
     return decrypted.toString(encUtf8);
   } catch (error) {
     console.error('Rabbit decrypt error:', error);
     return '';
   }
 };
+
+/**
+ * Rabbit 加密(二进制)
+ *
+ * 注意:
+ *   - 如果如果 iv 为空,则返回的加密结果的头部会包含 iv,否则不包含 iv。
+ *   - 如果 key 的长度不足 16 位,则会在末尾补齐 数字0。
+ *   - 如果 iv 的长度不足 8 位,则会在末尾补齐 数字0。
+ */
+export function rabbitEncryptBytes(data: Uint8Array, key: Uint8Array, iv?: Uint8Array): Uint8Array {
+  if (!data.length || !key.length) return new Uint8Array(0);
+  const keyWa = bytesToWordArray(padBytes(key, RABBIT_KEY_BYTES));
+  const ivWa = iv
+    ? bytesToWordArray(padBytes(iv, RABBIT_IV_BYTES))
+    : bytesToWordArray(generateKeyBytes(RABBIT_IV_BYTES));
+  const encrypted = Rabbit.encrypt(bytesToWordArray(data), keyWa, { iv: ivWa });
+  const ct = wordArrayToBytes(encrypted.ciphertext);
+  if (iv) return ct;
+  const out = new Uint8Array(RABBIT_IV_BYTES + ct.length);
+  out.set(wordArrayToBytes(ivWa), 0);
+  out.set(ct, RABBIT_IV_BYTES);
+  return out;
+}
+
+/**
+ * Rabbit 解密(二进制)
+ */
+export function rabbitDecryptBytes(
+  encryptedData: Uint8Array,
+  key: Uint8Array,
+  iv?: Uint8Array,
+): Uint8Array {
+  if (!encryptedData.length || !key.length) return new Uint8Array(0);
+  try {
+    const ivBytes = iv ? padBytes(iv, RABBIT_IV_BYTES) : encryptedData.subarray(0, RABBIT_IV_BYTES);
+    const ctBytes = iv ? encryptedData : encryptedData.subarray(RABBIT_IV_BYTES);
+    const keyWa = bytesToWordArray(padBytes(key, RABBIT_KEY_BYTES));
+    const ivWa = bytesToWordArray(ivBytes);
+    const cp = CipherParams.create({ ciphertext: bytesToWordArray(ctBytes), iv: ivWa });
+    const decrypted = Rabbit.decrypt(cp, keyWa, { iv: ivWa });
+    return wordArrayToBytes(decrypted);
+  } catch (error) {
+    console.error('Rabbit decrypt bytes error:', error);
+    return new Uint8Array(0);
+  }
+}

+ 9 - 6
src/utils/localUtils.ts

@@ -1,3 +1,4 @@
+import globalConfig from '@/config';
 import { createLocalStorage, decryptKey, StorageOptions } from '@/utils/storage';
 
 /**
@@ -16,7 +17,7 @@ function createLocalTools(storageOptions: StorageOptions = {}) {
    * @param opts 当传递了 opts 时,opts 中存在的配置项会覆盖当前实例的全局配置中的配置项。
    */
   function setLocal<T = any>(key: string, value: T, opts?: StorageOptions) {
-    ls.set<T>(`${process.env.STORAGE_NAME_SPACE}${key}`, value, opts);
+    ls.set<T>(`${globalConfig.app.storageNameSpace}${key}`, value, opts);
   }
 
   /**
@@ -26,7 +27,7 @@ function createLocalTools(storageOptions: StorageOptions = {}) {
    * @returns
    */
   function getLocal<T>(key: string, opts?: StorageOptions) {
-    return ls.get<T>(`${process.env.STORAGE_NAME_SPACE}${key}`, opts);
+    return ls.get<T>(`${globalConfig.app.storageNameSpace}${key}`, opts);
   }
 
   /**
@@ -35,7 +36,7 @@ function createLocalTools(storageOptions: StorageOptions = {}) {
    * @param opts 当传递了 opts 时,opts 中存在的配置项会覆盖当前实例的全局配置中的配置项。
    */
   function removeLocal(key: string, opts?: StorageOptions) {
-    ls.remove(`${process.env.STORAGE_NAME_SPACE}${key}`, opts);
+    ls.remove(`${globalConfig.app.storageNameSpace}${key}`, opts);
   }
 
   /**
@@ -49,14 +50,14 @@ function createLocalTools(storageOptions: StorageOptions = {}) {
       if (!key) continue;
 
       // 先检查原始 key 是否属于当前命名空间
-      if (key.startsWith(process.env.STORAGE_NAME_SPACE!)) {
+      if (key.startsWith(globalConfig.app.storageNameSpace)) {
         removedKeys.push(key);
         continue;
       }
 
       // 如果不是,尝试解密后检查是否属于当前命名空间
       const decryptedKey = decryptKey(key);
-      if (decryptedKey && decryptedKey.startsWith(process.env.STORAGE_NAME_SPACE!)) {
+      if (decryptedKey && decryptedKey.startsWith(globalConfig.app.storageNameSpace)) {
         removedKeys.push(key);
       }
     }
@@ -70,4 +71,6 @@ function createLocalTools(storageOptions: StorageOptions = {}) {
   return { setLocal, getLocal, removeLocal, clearLocal };
 }
 
-export { createLocalTools };
+const secureLocalStorage = createLocalTools({ encryptKey: true, encryptData: true });
+
+export { createLocalTools, secureLocalStorage };

+ 172 - 18
src/utils/numberUtils.ts

@@ -1,9 +1,28 @@
-import { isNumber } from 'lodash-es';
+/**
+ * 判断值是否为有效数字(排除 NaN)
+ */
+export function isNumber(value: unknown): value is number {
+  return typeof value === 'number' && !Number.isNaN(value);
+}
+
+/**
+ * 为整数部分添加千位分隔符
+ */
+function addThousandSeparator(integerStr: string): string {
+  return integerStr.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
+}
+
 /**
  * 格式化数字,保留 count 位小数,小数不足 count 位时低位补 0,多余的小数则四舍五入
+ * 典型用途:通用小数展示;行为与原生 Number.prototype.toFixed 一致,额外做了类型保护
  * @param num
  * @param count
  * @returns 如果 num 不是数字则原样返回,否则返回处理后的字符串
+ * @example
+ *   toFixed(0.1, 2)     // '0.10'
+ *   toFixed(1.236, 2)   // '1.24'
+ *   toFixed(1.234, 2)   // '1.23'
+ *   toFixed(1234, 2)    // '1234.00'
  */
 export const toFixed = (num: number, count: number) => {
   if (!isNumber(num)) return num;
@@ -11,10 +30,16 @@ export const toFixed = (num: number, count: number) => {
 };
 
 /**
- * 格式化数字,最多保留 limit 位小数,多余的小数四舍五入
+ * 格式化数字,最多保留 limit 位小数,多余的小数四舍五入,尾随的 0 会被去除
+ * 典型用途:展示型数字,避免无意义的尾随零造成视觉冗余(如 "1.50" → "1.5")
  * @param num
  * @param limit
  * @returns 如果 num 不是数字则原样返回,否则返回处理后的字符串
+ * @example
+ *   toFixedLimit(0.1, 2)     // '0.1'
+ *   toFixedLimit(1.236, 2)   // '1.24'
+ *   toFixedLimit(1.5, 2)     // '1.5'
+ *   toFixedLimit(1234, 2)    // '1234'
  */
 export const toFixedLimit = (num: number, limit: number) => {
   if (!isNumber(num)) return num;
@@ -25,10 +50,15 @@ export const toFixedLimit = (num: number, limit: number) => {
 };
 
 /**
- * 格式化数字,保留 count 位小数,小数不足 count 位时低位补 0,多余的小数则直接丢弃,不进行四舍五入
+ * 格式化数字,保留 count 位小数,小数不足 count 位时低位补 0,多余的小数直接丢弃不做四舍五入
+ * 典型用途:精度受限且不希望"凭空进位"的场景(如计费下取整、利率向下截断)
  * @param num
  * @param count
  * @returns 如果 num 不是数字则原样返回,否则返回处理后的字符串
+ * @example
+ *   toTruncate(1.299, 2)   // '1.29'(不会进位到 1.30)
+ *   toTruncate(0.1, 3)     // '0.100'
+ *   toTruncate(1234, 2)    // '1234.00'
  */
 export const toTruncate = (num: number, count: number) => {
   if (!isNumber(num)) return num;
@@ -38,10 +68,15 @@ export const toTruncate = (num: number, count: number) => {
 };
 
 /**
- * 格式化数字,最多保留 limit 位小数,多余的小数直接丢弃,不进行四舍五入
+ * 格式化数字,最多保留 limit 位小数,多余的小数直接丢弃不做四舍五入,尾随的 0 会被去除
+ * 典型用途:下取整展示,同时不希望输出尾随 0
  * @param num
- * @param count
+ * @param limit
  * @returns 如果 num 不是数字则原样返回,否则返回处理后的字符串
+ * @example
+ *   toTruncateLimit(1.299, 2)   // '1.29'
+ *   toTruncateLimit(1.1, 3)     // '1.1'
+ *   toTruncateLimit(1234, 2)    // '1234'
  */
 export const toTruncateLimit = (num: number, limit: number) => {
   if (!isNumber(num)) return num;
@@ -50,25 +85,78 @@ export const toTruncateLimit = (num: number, limit: number) => {
   return truncated.toString();
 };
 
+/**
+ * 格式化数字,保留 count 位小数,小数不足 count 位时低位补 0,多余的小数向上取整
+ * 典型用途:费用/额度展示(无需千分位),避免极小非零值被舍入为 0
+ * @param num
+ * @param count
+ * @returns 如果 num 不是数字则原样返回,否则返回处理后的字符串
+ * @example
+ *   toCeil(0.000000000001, 2) // '0.01'
+ *   toCeil(0.000000000001, 3) // '0.001'
+ *   toCeil(1.2301, 2)         // '1.24'
+ *   toCeil(0, 2)              // '0.00'
+ */
+export const toCeil = (num: number, count: number) => {
+  if (!isNumber(num)) return num;
+  const factor = Math.pow(10, count);
+  const ceiled = Math.ceil(num * factor) / factor;
+  return ceiled.toFixed(count);
+};
+
+/**
+ * 格式化数字,最多保留 limit 位小数,多余的小数向上取整,尾随的 0 会被去除
+ * 与 {@link toCeil} 的区别:不做低位补 0
+ * @param num
+ * @param limit
+ * @returns 如果 num 不是数字则原样返回,否则返回处理后的字符串
+ * @example
+ *   toCeilLimit(0.000000000001, 2) // '0.01'
+ *   toCeilLimit(0.000000000001, 3) // '0.001'
+ *   toCeilLimit(0.1, 2)            // '0.1'
+ *   toCeilLimit(1.2301, 2)         // '1.24'
+ *   toCeilLimit(1234, 2)           // '1234'
+ *   toCeilLimit(0, 2)              // '0'
+ */
+export const toCeilLimit = (num: number, limit: number) => {
+  if (!isNumber(num)) return num;
+  const factor = Math.pow(10, limit);
+  const ceiled = Math.ceil(num * factor) / factor;
+  return ceiled
+    .toFixed(limit)
+    .replace(/(\.\d*?)0+$/, '$1')
+    .replace(/\.$/, '');
+};
+
 /**
  * 格式化数字,添加千位分隔符,保留 count 位小数,小数不足 count 位时低位补 0,多余的小数则四舍五入
+ * 典型用途:货币/金额展示,例如 "$1,234.56"
  * @param num
  * @param count
  * @returns 如果 num 不是数字则原样返回,否则返回处理后的字符串
+ * @example
+ *   toCommaFixed(1234.5, 2)         // '1,234.50'
+ *   toCommaFixed(1000000.128, 2)    // '1,000,000.13'
+ *   toCommaFixed(0, 2)              // '0.00'
  */
 export const toCommaFixed = (num: number, count: number) => {
   if (!isNumber(num)) return num;
   const fixedNum = num.toFixed(count);
   const [i, d] = fixedNum.split('.');
-  const integerPart = i.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
-  return `${integerPart}.${d}`;
+  const integerPart = addThousandSeparator(i);
+  return d !== undefined ? `${integerPart}.${d}` : integerPart;
 };
 
 /**
- * 格式化数字,添加千位分隔符,最多保留 limit 位小数,多余的小数四舍五入
+ * 格式化数字,添加千位分隔符,最多保留 limit 位小数,多余的小数四舍五入,尾随的 0 会被去除
+ * 典型用途:大数值展示,省略无意义的尾随零(如 "1,000.00" → "1,000")
  * @param num
  * @param limit
  * @returns 如果 num 不是数字则原样返回,否则返回处理后的字符串
+ * @example
+ *   toCommaFixedLimit(1234.5, 2)        // '1,234.5'
+ *   toCommaFixedLimit(1000, 2)          // '1,000'
+ *   toCommaFixedLimit(1000000.128, 2)   // '1,000,000.13'
  */
 export const toCommaFixedLimit = (num: number, limit: number) => {
   if (!isNumber(num)) return num;
@@ -77,49 +165,115 @@ export const toCommaFixedLimit = (num: number, limit: number) => {
     .replace(/(\.\d*?)0+$/, '$1')
     .replace(/\.$/, '');
   const [i, d] = fixedNum.split('.');
-  const integerPart = i.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
+  const integerPart = addThousandSeparator(i);
   const decimalPart = d ? `.${d}` : '';
   return integerPart + decimalPart;
 };
 
 /**
- * 格式化数字,添加千位分隔符,保留 count 位小数,小数不足 count 位时低位补 0,多余的小数则直接丢弃,不进行四舍五入
+ * 格式化数字,添加千位分隔符,保留 count 位小数,小数不足 count 位时低位补 0,多余的小数直接丢弃不做四舍五入
+ * 典型用途:大额计费展示,固定精度且不希望进位(如实际消耗 $1,234.569,展示为 "$1,234.56")
  * @param num
  * @param count
  * @returns 如果 num 不是数字则原样返回,否则返回处理后的字符串
+ * @example
+ *   toCommaTruncate(1234.5678, 2)    // '1,234.56'
+ *   toCommaTruncate(1234, 2)         // '1,234.00'
+ *   toCommaTruncate(0, 2)            // '0.00'
  */
 export const toCommaTruncate = (num: number, count: number) => {
   if (!isNumber(num)) return num;
   const factor = Math.pow(10, count);
   const truncated = Math.trunc(num * factor) / factor;
   const [i, d] = truncated.toFixed(count).split('.');
-  const integerPart = i.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
-  return `${integerPart}.${d}`;
+  const integerPart = addThousandSeparator(i);
+  return d !== undefined ? `${integerPart}.${d}` : integerPart;
 };
 
 /**
- * 格式化数字,添加千位分隔符,最多保留 limit 位小数,多余的小数直接丢弃,不进行四舍五入
+ * 格式化数字,添加千位分隔符,最多保留 limit 位小数,多余的小数直接丢弃不做四舍五入,尾随的 0 会被去除
+ * 典型用途:大数值下取整展示,省略尾随零
  * @param num
  * @param limit
  * @returns 如果 num 不是数字则原样返回,否则返回处理后的字符串
+ * @example
+ *   toCommaTruncateLimit(1234.5678, 2)   // '1,234.56'
+ *   toCommaTruncateLimit(1234, 2)        // '1,234'
+ *   toCommaTruncateLimit(1234.1, 3)      // '1,234.1'
  */
 export const toCommaTruncateLimit = (num: number, limit: number) => {
   if (!isNumber(num)) return num;
   const factor = Math.pow(10, limit);
   const truncated = Math.trunc(num * factor) / factor;
   const [i, d] = truncated.toString().split('.');
-  const integerPart = i.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
+  const integerPart = addThousandSeparator(i);
+  const decimalPart = d ? `.${d}` : '';
+  return integerPart + decimalPart;
+};
+
+/**
+ * 格式化数字,添加千位分隔符,保留 count 位小数,小数不足 count 位时低位补 0,多余的小数向上取整
+ * 典型用途:费用/额度展示,避免把极小的非零值舍入为 0,造成"已消耗但显示为 0"的误解
+ * @param num
+ * @param count
+ * @returns 如果 num 不是数字则原样返回,否则返回处理后的字符串
+ * @example
+ *   toCommaCeil(0.000000000001, 2) // '0.01'
+ *   toCommaCeil(0.000000000001, 3) // '0.001'
+ *   toCommaCeil(1234.5001, 2)      // '1,234.51'
+ *   toCommaCeil(0, 2)              // '0.00'
+ */
+export const toCommaCeil = (num: number, count: number) => {
+  if (!isNumber(num)) return num;
+  const factor = Math.pow(10, count);
+  const ceiled = Math.ceil(num * factor) / factor;
+  const [i, d] = ceiled.toFixed(count).split('.');
+  const integerPart = addThousandSeparator(i);
+  return d !== undefined ? `${integerPart}.${d}` : integerPart;
+};
+
+/**
+ * 格式化数字,添加千位分隔符,最多保留 limit 位小数,多余的小数向上取整,尾随的 0 会被去除
+ * 与 {@link toCommaCeil} 的区别:不做低位补 0
+ * @param num
+ * @param limit
+ * @returns 如果 num 不是数字则原样返回,否则返回处理后的字符串
+ * @example
+ *   toCommaCeilLimit(0.000000000001, 2) // '0.01'
+ *   toCommaCeilLimit(0.000000000001, 3) // '0.001'
+ *   toCommaCeilLimit(0.1, 2)            // '0.1'
+ *   toCommaCeilLimit(1234.5001, 2)      // '1,234.51'
+ *   toCommaCeilLimit(1234, 2)           // '1,234'
+ *   toCommaCeilLimit(0, 2)              // '0'
+ */
+export const toCommaCeilLimit = (num: number, limit: number) => {
+  if (!isNumber(num)) return num;
+  const factor = Math.pow(10, limit);
+  const ceiled = Math.ceil(num * factor) / factor;
+  const fixedNum = ceiled
+    .toFixed(limit)
+    .replace(/(\.\d*?)0+$/, '$1')
+    .replace(/\.$/, '');
+  const [i, d] = fixedNum.split('.');
+  const integerPart = addThousandSeparator(i);
   const decimalPart = d ? `.${d}` : '';
   return integerPart + decimalPart;
 };
 
 /**
- * 格式化百分比
+ * 将 0-1 之间的小数格式化为百分比数值(乘 100),保留指定位数小数
+ * 典型用途:比率/占比展示,输入 0.1234 得到 "12.34" 便于配合 "%" 符号使用
  * @param rate 百分比数值,范围为 0-1
  * @param decimalPlaces 保留的小数位数
- * @returns
+ * @returns 处理后的字符串,输入无效(null / undefined / NaN)时返回 '0'
+ * @example
+ *   formatPercentage(0.1234, 2)     // '12.34'
+ *   formatPercentage(1, 2)          // '100.00'
+ *   formatPercentage(0, 2)          // '0.00'
+ *   formatPercentage(null, 2)       // '0'
+ *   formatPercentage(undefined, 2)  // '0'
  */
-export const formatPercentage = (rate: number | undefined, decimalPlaces: number) => {
-  if (!rate) return 0;
+export const formatPercentage = (rate: number | undefined | null, decimalPlaces: number) => {
+  if (!isNumber(rate)) return '0';
   return (rate * 100).toFixed(decimalPlaces);
 };

+ 134 - 0
src/utils/requestCrypto.ts

@@ -0,0 +1,134 @@
+import globalConfig from '@/config';
+import { CompressMethod } from '@/config/types';
+import {
+  Endian,
+  bytesToNumber,
+  bytesToString,
+  numberToBytesAt,
+  stringToBytes,
+} from '@/utils/bytesUtils';
+import {
+  aesCbcDecryptBytes,
+  aesCbcEncryptBytes,
+  bytesBase64decode,
+  bytesBase64encode,
+} from '@/utils/crypto';
+
+import { CompressFormat, compressBytes, decompressBytes } from './compress';
+import { currentUnixTimestamp } from './timeUtils';
+
+export type JsonPrimitive = string | number | boolean | null;
+export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue | undefined };
+
+const TIMESTAMP_BYTES = 8;
+
+function buildTimestampBytes(timestamp: number): Uint8Array {
+  const arr = new Uint8Array(TIMESTAMP_BYTES);
+  numberToBytesAt(0, arr, 0, Endian.BE);
+  numberToBytesAt(timestamp, arr, 4, Endian.BE);
+  return arr;
+}
+
+function parseTimestampBytes(bytes: Uint8Array): number {
+  const high = bytesToNumber(bytes, 0, Endian.BE);
+  const low = bytesToNumber(bytes, 4, Endian.BE);
+  return high * 0x100000000 + low;
+}
+
+function compressFormatFromMethod(method: CompressMethod): CompressFormat {
+  return method === CompressMethod.BR ? CompressFormat.BROTLI : CompressFormat.GZIP;
+}
+
+/**
+ * 请求体加密(与后端 GoDataEncrypt 对应)
+ * 仅接收字符串:8字节时间戳(BE)+数据 → 压缩() → AES-CBC 加密 → IV + 密文
+ * @param plaintext 明文字符串
+ * @param key 密钥(由 crypto 内部补齐/截断为 32 字节)
+ * @param compressMethod 压缩算法,空表示不压缩
+ * @param timestamp 时间戳(秒级数字),内部转为 8 字节大端后拼在明文前
+ * @returns 二进制密文(IV 16 字节 + 密文)
+ */
+export async function encryptRequestPayload(
+  dataBytes: Uint8Array,
+  timestamp: number,
+  key: Uint8Array,
+  compressMethod: CompressMethod = CompressMethod.NO_ZIP,
+): Promise<Uint8Array> {
+  const compressed =
+    compressMethod && compressMethod !== CompressMethod.NO_ZIP
+      ? await compressBytes(dataBytes, compressFormatFromMethod(compressMethod))
+      : dataBytes;
+
+  const timestampBytes = buildTimestampBytes(timestamp);
+  const timeAndData = new Uint8Array(TIMESTAMP_BYTES + compressed.length);
+  timeAndData.set(timestampBytes, 0);
+  timeAndData.set(compressed, TIMESTAMP_BYTES);
+
+  return aesCbcEncryptBytes(timeAndData, key);
+}
+
+/**
+ * 响应体解密(与后端 GoDataDecrypt 对应)
+ * 仅接收二进制:IV + 密文 → AES-CBC 解密 → 去掉 8 字节时间戳 → 可选解压
+ * @param encryptedBytes 二进制密文(IV 16 字节 + 密文)
+ * @param key 密钥(由 crypto 内部补齐/截断为 32 字节)
+ * @param compressMethod 解压算法,空表示未压缩
+ * @returns 去掉时间戳并解压后的数据
+ */
+export async function decryptResponsePayload(
+  encryptedBytes: Uint8Array,
+  key: Uint8Array,
+  compressMethod: CompressMethod = CompressMethod.NO_ZIP,
+): Promise<{ timestamp: number; data: Uint8Array }> {
+  const decrypted = aesCbcDecryptBytes(encryptedBytes, key);
+  if (decrypted.length < TIMESTAMP_BYTES) return { timestamp: 0, data: new Uint8Array(0) };
+
+  const timestampBytes = decrypted.subarray(0, TIMESTAMP_BYTES);
+  const timestamp = parseTimestampBytes(timestampBytes);
+
+  const dataBytes = decrypted.subarray(TIMESTAMP_BYTES);
+
+  const rawBytes =
+    compressMethod && compressMethod !== CompressMethod.NO_ZIP
+      ? await decompressBytes(dataBytes, compressFormatFromMethod(compressMethod))
+      : dataBytes;
+
+  return { timestamp, data: rawBytes };
+}
+
+/**
+ * 加密 URL 参数
+ * @param params 参数对象
+ * @returns 加密后的 URL 参数
+ */
+export async function encryptUrlParams(params: JsonValue): Promise<string> {
+  const jsonString = typeof params === 'string' ? params : JSON.stringify(params);
+  const key = stringToBytes(globalConfig.security.requestEncryptionKey);
+  const dataBytes = stringToBytes(jsonString);
+  const encrypted = await encryptRequestPayload(
+    dataBytes,
+    currentUnixTimestamp(),
+    key,
+    globalConfig.security.compressMethod,
+  );
+  return bytesBase64encode(encrypted, true);
+}
+
+/**
+ * 解密 URL 参数
+ * @param params 加密后的 URL 参数
+ * @returns 解密后的参数对象
+ */
+export async function decryptUrlParams<T = any>(params: string): Promise<T | null> {
+  // const key = bytesBase64decode(globalConfig.security.requestEncryptionKey);
+  const key = stringToBytes(globalConfig.security.requestEncryptionKey);
+  const dataBytes = bytesBase64decode(params, true);
+  const compressMethod = globalConfig.security.compressMethod;
+  try {
+    const { data } = await decryptResponsePayload(dataBytes, key, compressMethod);
+    const result = bytesToString(data);
+    return JSON.parse(result) as T;
+  } catch {
+    return null;
+  }
+}

+ 9 - 6
src/utils/sessionUtils.ts

@@ -1,3 +1,4 @@
+import globalConfig from '@/config';
 import { createSessionStorage, decryptKey, StorageOptions } from '@/utils/storage';
 
 /**
@@ -16,7 +17,7 @@ function createSessionTools(storageOptions: StorageOptions = {}) {
    * @param opts 当传递了 opts 时,opts 中存在的配置项会覆盖当前实例的全局配置中的配置项。
    */
   function setSession<T = any>(key: string, value: T, opts?: StorageOptions) {
-    ss.set<T>(`${process.env.STORAGE_NAME_SPACE}${key}`, value, opts);
+    ss.set<T>(`${globalConfig.app.storageNameSpace}${key}`, value, opts);
   }
 
   /**
@@ -26,7 +27,7 @@ function createSessionTools(storageOptions: StorageOptions = {}) {
    * @returns
    */
   function getSession<T>(key: string, opts?: StorageOptions) {
-    return ss.get<T>(`${process.env.STORAGE_NAME_SPACE}${key}`, opts);
+    return ss.get<T>(`${globalConfig.app.storageNameSpace}${key}`, opts);
   }
 
   /**
@@ -35,7 +36,7 @@ function createSessionTools(storageOptions: StorageOptions = {}) {
    * @param opts 当传递了 opts 时,opts 中存在的配置项会覆盖当前实例的全局配置中的配置项。
    */
   function removeSession(key: string, opts?: StorageOptions) {
-    ss.remove(`${process.env.STORAGE_NAME_SPACE}${key}`, opts);
+    ss.remove(`${globalConfig.app.storageNameSpace}${key}`, opts);
   }
 
   /**
@@ -49,14 +50,14 @@ function createSessionTools(storageOptions: StorageOptions = {}) {
       if (!key) continue;
 
       // 先检查原始 key 是否属于当前命名空间
-      if (key.startsWith(process.env.STORAGE_NAME_SPACE!)) {
+      if (key.startsWith(globalConfig.app.storageNameSpace)) {
         removedKeys.push(key);
         continue;
       }
 
       // 如果不是,尝试解密后检查是否属于当前命名空间
       const decryptedKey = decryptKey(key);
-      if (decryptedKey && decryptedKey.startsWith(process.env.STORAGE_NAME_SPACE!)) {
+      if (decryptedKey && decryptedKey.startsWith(globalConfig.app.storageNameSpace)) {
         removedKeys.push(key);
       }
     }
@@ -70,4 +71,6 @@ function createSessionTools(storageOptions: StorageOptions = {}) {
   return { setSession, getSession, removeSession, clearSession };
 }
 
-export { createSessionTools };
+const secureSessionStorage = createSessionTools({ encryptKey: true, encryptData: true });
+
+export { createSessionTools, secureSessionStorage };

+ 10 - 11
src/utils/storage/index.ts

@@ -1,24 +1,23 @@
-import { aesDecrypt, aesEncrypt, md5, rabbitDecrypt, rabbitEncrypt } from '@/utils/crypto';
+import globalConfig from '@/config';
+import { rabbitDecryptString, rabbitEncryptString, stringMd5 } from '@/utils/crypto';
 
-const storageKeyIV = md5(`${process.env.REACT_APP_ID}_${process.env.REACT_APP_VERSION}`).slice(
-  0,
-  8,
-);
+// 用一样的 key 和 iv 来加密 storage 的 key, 保证每次加密后的 storage 的 key 的结果一样,否则每次获取数据都需要把 storage 中的所有 key 解密一遍才知道哪个是我们需要的 key
+const storageKeyIV = stringMd5(`${globalConfig.app.title}_${globalConfig.app.version}`).slice(0, 8);
 
 export function encryptKey(key: string) {
-  return rabbitEncrypt(key, process.env.STORAGE_ENCRYPTION_KEY!, storageKeyIV);
+  return rabbitEncryptString(key, globalConfig.security.storageEncryptionKey, storageKeyIV);
 }
 
 export function decryptKey(key: string) {
-  return rabbitDecrypt(key, process.env.STORAGE_ENCRYPTION_KEY!, storageKeyIV);
+  return rabbitDecryptString(key, globalConfig.security.storageEncryptionKey, storageKeyIV);
 }
 
 export function encryptData(data: string) {
-  return aesEncrypt(data, process.env.STORAGE_ENCRYPTION_KEY!);
+  return rabbitEncryptString(data, globalConfig.security.storageEncryptionKey);
 }
 
 export function decryptData(data: string) {
-  return aesDecrypt(data, process.env.STORAGE_ENCRYPTION_KEY!);
+  return rabbitDecryptString(data, globalConfig.security.storageEncryptionKey);
 }
 
 export interface StorageOptions {
@@ -45,14 +44,14 @@ interface StorageData<T = any> {
 }
 
 function shouldEncryptKey(functionOptions?: StorageOptions, instanceOptions?: StorageOptions) {
-  if (process.env.ENABLE_STORAGE_ENCRYPTION) {
+  if (globalConfig.security.enableStorageEncryption) {
     return functionOptions?.encryptKey ?? instanceOptions?.encryptKey ?? false;
   }
   return false;
 }
 
 function shouldEncryptData(funcOpts?: StorageOptions, instanceOpts?: StorageOptions) {
-  if (process.env.ENABLE_STORAGE_ENCRYPTION) {
+  if (globalConfig.security.enableStorageEncryption) {
     return funcOpts?.encryptData ?? instanceOpts?.encryptData ?? false;
   }
   return false;

+ 57 - 48
src/utils/stringUtils.ts

@@ -6,8 +6,11 @@ import split from 'ramda/es/split';
 import trim from 'ramda/es/trim';
 
 /**
- * 移除字符串首尾的指定字符
- * @returns
+ * 移除字符串首尾的指定字符(仅各移除一个,若首/尾等于 char)。
+ * 支持柯里化:仅传 char 时返回 (str) => string。
+ * @param char 要移除的单个字符
+ * @param str 原字符串;省略时返回柯里化函数
+ * @returns 处理后的字符串,或柯里化后的函数
  */
 function trimChar(char: string, str: string): string;
 function trimChar(char: string): (str: string) => string;
@@ -38,60 +41,72 @@ function trimChar(char: string, str?: string) {
 export { trimChar };
 
 /**
- * 将字符串的首字母转换为小写字母
- * @param str
- * @returns
+ * 将字符串的首字母转为小写,其余不变;空字符串原样返回。
+ * @param str 原字符串
+ * @returns 首字母小写后的字符串
  */
 export function toLowerCaseFirstLetter(str: string) {
-  if (!str) return str; // 如果字符串为空,直接返回
+  if (!str) return str;
   return str.charAt(0).toLowerCase() + str.slice(1);
 }
 
 /**
- * 将字符串的首字母转换为大写字母
- * @param str
- * @returns
+ * 将字符串的首字母转为大写,其余不变;空字符串原样返回。
+ * @param str 原字符串
+ * @returns 首字母大写后的字符串
  */
 export function toUpperCaseFirstLetter(str: string) {
-  if (!str) return str; // 如果字符串为空,直接返回
+  if (!str) return str;
   return str.charAt(0).toUpperCase() + str.slice(1);
 }
 
 /**
- * 判断是否为邮箱
- * @param str
- * @returns
+ * 账号脱敏:前二后四保留,中间用 '**' 代替;长度 ≤8 时仅保留后四位并前缀 '**'。空串返回 ''。
+ * @param str 原始账号字符串
+ * @returns 脱敏后的字符串,如 'us**com.'、'**5678'
+ */
+export function maskAccount(str: string): string {
+  if (!str) return '';
+  const len = str.length;
+  if (len <= 8) return '**' + str.slice(-4);
+  return str.slice(0, 2) + '**' + str.slice(-4);
+}
+
+/**
+ * 判断字符串是否为常见邮箱格式(本地部分 + @ + 域名 + 至少两位 TLD)。
+ * @param str 待检测字符串
+ * @returns 符合邮箱格式返回 true,否则 false
  */
 export function isEmail(str: string): boolean {
-  const regex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; // 标准邮箱校验
+  const regex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
   return regex.test(str);
 }
 
 /**
- * 判断是否为 Gmail 邮箱
- * @param str
- * @returns
+ * 判断字符串是否为 Gmail 邮箱[email protected])。
+ * @param str 待检测字符串
+ * @returns 为 Gmail 格式返回 true,否则 false
  */
 export function isGmail(str: string): boolean {
-  const regex = /^[a-zA-Z0-9._%+-]+@gmail([.])com$/; // 限制为gmail
+  const regex = /^[a-zA-Z0-9._%+-]+@gmail([.])com$/;
   return regex.test(str);
 }
 
 /**
- * 判断是否为 IP 地址
- * @param str
- * @returns
+ * 判断字符串是否为合法 IPv4 地址(四段、每段 0–255)。
+ * @param str 待检测字符串
+ * @returns 合法 IPv4 返回 true,否则 false
  */
 export function isIp(str: string): boolean {
-  // 使用正则表达式严格验证 IP 地址格式和范围
   const octet = '(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])';
   const regex = new RegExp(`^${octet}\\.${octet}\\.${octet}\\.${octet}$`);
   return regex.test(str);
 }
 
 /**
- * 生成具备加密强度的唯一ID,该算法能保证在同一时刻生成的ID不会重复,并具有加密强度(不可预测),但效率稍低(需调用底层的操作系统加密API,实测100000次调用,需450毫秒左右)
- * @returns
+ * 生成具备加密强度的唯一标识(基于 crypto.getRandomValues + 时间戳,base36)。
+ * 同一时刻多次调用不重复、不可预测,但较慢(依赖底层加密 API)。
+ * @returns 唯一字符串,适合用作防篡改或安全场景的 sign
  */
 export function getCryptoUniqueSign() {
   const v = Array.from(crypto.getRandomValues(new Uint32Array(3)))
@@ -101,8 +116,8 @@ export function getCryptoUniqueSign() {
 }
 
 /**
- * 生成唯一ID,该算法能保证在同一时刻生成的ID不会重复,不具有加密强度(可预测),但效率较高(实测100000次调用,需100毫秒左右)
- * @returns
+ * 生成唯一标识(基于 Math.random + 时间戳,base36)。同一时刻多次调用不重复,可预测、效率较高,不适用于安全场景。
+ * @returns 唯一字符串,适合用作非安全场景的 ID 或 sign
  */
 export function getUniqueSign() {
   const v = [Math.floor(Math.random() * 2 ** 32), Math.floor(Math.random() * 2 ** 32)]
@@ -112,9 +127,9 @@ export function getUniqueSign() {
 }
 
 /**
- * 将 IP 地址转换为数字,与 MySQL 的 INET_ATON 函数一致
- * @param ip
- * @returns
+ * 将 IPv4 字符串转为 32 位无符号整数,语义与 MySQL INET_ATON 一致。非法或空串返回 0。
+ * @param ip 点分十进制 IPv4 字符串
+ * @returns 无符号整数,或 0(格式错误/空)
  */
 export function ipToNum(ip: string): number {
   if (!ip) return 0;
@@ -147,9 +162,9 @@ export function ipToNum(ip: string): number {
 }
 
 /**
- * 将数字转换为 IP 地址,与 MySQL 的 INET_NTOA 函数一致
- * @param num
- * @returns
+ * 将 32 位无符号整数转为 IPv4 点分十进制字符串,语义与 MySQL INET_NTOA 一致。超出 [0, 0xffffffff] 返回 ''。
+ * @param num 无符号整数
+ * @returns 点分十进制 IP 字符串,或 ''(越界)
  */
 export function numToIp(num: number): string {
   if (num < 0 || num > 0xffffffff) {
@@ -165,7 +180,9 @@ export function numToIp(num: number): string {
 }
 
 /**
- * 字符串转数组,支持逗号分隔、换行分隔、空格分隔
+ * 将字符串按逗号、空白(含换行)拆分为非空字符串数组;先 trim 再按 /[,\s]+/ 拆分,并对每项 trim、过滤空串。
+ * @param str 原字符串(由 compose 管道传入,调用方式 toStrArray(str))
+ * @returns 非空字符串数组
  */
 export const toStrArray = compose(
   filter((v) => v !== ''),
@@ -176,8 +193,8 @@ export const toStrArray = compose(
 );
 
 /**
- * 检测文本的语言类型
- * @param text 要检测的文本
+ * 根据字符组成检测文本主要语言:中文(含 CJK 等)与英文(拉丁字母)数量比较,空或仅空白返回 'unknown'。
+ * @param text 待检测文本
  * @returns 'zh-CN' | 'en' | 'unknown'
  */
 export function detectLanguage(text: string): 'zh-CN' | 'en' | 'unknown' {
@@ -187,52 +204,44 @@ export function detectLanguage(text: string): 'zh-CN' | 'en' | 'unknown' {
 
   const trimmedText = text.trim();
 
-  // 检测中文字符(包括中文汉字、中文标点符号)
   const chineseRegex =
     /[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff\u3040-\u309f\u30a0-\u30ff\uff00-\uffef]/;
-
-  // 检测英文字符(基本拉丁字母)
   const englishRegex = /[a-zA-Z]/;
 
   const hasChineseChars = chineseRegex.test(trimmedText);
   const hasEnglishChars = englishRegex.test(trimmedText);
 
-  // 统计中文字符和英文字符的数量
   const chineseMatches = trimmedText.match(/[\u4e00-\u9fff]/g) || [];
   const englishMatches = trimmedText.match(/[a-zA-Z]/g) || [];
 
   const chineseCount = chineseMatches.length;
   const englishCount = englishMatches.length;
 
-  // 如果只有中文字符,或者中文字符明显多于英文字符
   if (hasChineseChars && (!hasEnglishChars || chineseCount > englishCount)) {
     return 'zh-CN';
   }
 
-  // 如果只有英文字符,或者英文字符明显多于中文字符
   if (hasEnglishChars && (!hasChineseChars || englishCount > chineseCount)) {
     return 'en';
   }
 
-  // 如果都没有或者无法判断
   return 'unknown';
 }
 
 /**
- * 根据标题自动检测并返回对应的源语言选项
+ * 根据标题文本检测语言并返回源语言代码;与 TranslateSourceLang 的 value 对应,未知时默认 'zh-CN'。
  * @param title 标题文本
- * @returns TranslateSourceLang 中对应的 value
+ * @returns 'zh-CN' | 'en'(检测为英文时返回 'en',其余返回 'zh-CN')
  */
 export function getSourceLanguageFromTitle(title: string): string {
   const detectedLang = detectLanguage(title);
 
-  // 根据检测结果返回对应的源语言值
   switch (detectedLang) {
     case 'zh-CN':
-      return 'zh-CN'; // 对应 TranslateSourceLang.ZhCN.value
+      return 'zh-CN';
     case 'en':
-      return 'en'; // 对应 TranslateSourceLang.En.value
+      return 'en';
     default:
-      return 'zh-CN'; // 默认返回中文
+      return 'zh-CN';
   }
 }

+ 71 - 112
src/utils/timeUtils.ts

@@ -4,144 +4,116 @@ import dayjs from 'dayjs';
 // Date.getTimezoneOffset() = utcTime - localTime;
 
 /**
- * 获取当前的 unix 时间戳(10位 秒级)
- * @returns
+ * 获取当前时间的 Unix 时间戳(10 位,秒级)。
+ * @returns 当前时间的 Unix 秒级时间戳
  */
 export function currentUnixTimestamp() {
   return dayjs().unix();
 }
 
 /**
- * 获取当前的 js 时间戳(13位 毫秒级)
- * @returns
+ * 获取当前时间的 JavaScript 时间戳(13 位,毫秒级)。
+ * @returns 当前时间的毫秒级时间戳
  */
 export function currentJsTimestamp() {
   return dayjs().valueOf();
 }
 
 /**
- * 获取当前时间
- * @param fmt 返回的时间格式,默认为 YYYY-MM-DD HH:mm:ss
- * @returns
+ * 获取当前本地时间的格式化字符串。
+ * @param fmt dayjs 格式模板,默认 'YYYY-MM-DD HH:mm:ss'
+ * @returns 当前本地时间字符串
  */
 export function currentDateTime(fmt = 'YYYY-MM-DD HH:mm:ss') {
   return dayjs().format(fmt);
 }
 
 /**
- * 获取当前 UTC 时间
- * @param fmt 返回的时间格式,默认为 YYYY-MM-DD HH:mm:ss
- * @returns
+ * 获取当前 UTC 时间的格式化字符串。
+ * @param fmt dayjs 格式模板,默认 'YYYY-MM-DD HH:mm:ss'
+ * @returns 当前 UTC 时间字符串
  */
 export function currentUtcDateTime(fmt = 'YYYY-MM-DD HH:mm:ss') {
   return dayjs().utc().format(fmt);
 }
 
 /**
- * 基于当前时间,计算 days 天前的时间
- * @param days
- * @param fmt 返回的时间格式,默认为 YYYY-MM-DD HH:mm:ss
- * @returns
+ * 基于当前本地时间,计算若干天前的本地时间字符串。
+ * @param days 往前推的天数(正数表示过去)
+ * @param fmt dayjs 格式模板,默认 'YYYY-MM-DD HH:mm:ss'
+ * @returns 对应日期的本地时间字符串
  */
 export function getDateTimeString(days: number, fmt = 'YYYY-MM-DD HH:mm:ss') {
   return dayjs().subtract(days, 'day').format(fmt);
 }
 
 /**
- * 基于当前时间,计算 days 天前的 UTC 时间
- * @param days
- * @param fmt 返回的时间格式,默认为 YYYY-MM-DD HH:mm:ss
- * @returns
+ * 基于当前时间,计算若干天前的 UTC 时间字符串。
+ * @param days 往前推的天数(正数表示过去)
+ * @param fmt dayjs 格式模板,默认 'YYYY-MM-DD HH:mm:ss'
+ * @returns 对应日期的 UTC 时间字符串
  */
 export function getUtcDateTimeString(days: number, fmt = 'YYYY-MM-DD HH:mm:ss') {
   return dayjs().subtract(days, 'day').utc().format(fmt);
-
-  // 不使用 dayjs 的 utc 插件时,可以用如下代码:
-  // const date = new Date();
-  // const utcDt = date.getTime() + date.getTimezoneOffset() * 60 * 1000 - days * 24 * 3600 * 1000; //公式:utcTime = localTime + date.getTimezoneOffset()
-  // return dayjs(utcDt).format(fmt);
-
-  // 也可以使用如下代码,更容易理解
-  // const date = new Date();
-  // const crtUtcDate = new Date(
-  //     date.getUTCFullYear(),
-  //     date.getUTCMonth(),
-  //     date.getUTCDate(),
-  //     date.getUTCHours(),
-  //     date.getUTCMinutes(),
-  //     date.getUTCSeconds()
-  // ).getTime();
-  // const distUtcDt = crtUtcDate - days * 24 * 3600 * 1000;
-  // return dayjs(distUtcDt).format(fmt);
 }
 
 /**
- * 格式化 unix 时间戳
- * @param unixTimestamp
- * @param fmt 返回的时间格式,默认为 YYYY-MM-DD HH:mm:ss
- * @returns
+ * 将 Unix 时间戳(秒级)格式化为本地时间字符串。
+ * @param unixTimestamp Unix 秒级时间戳
+ * @param fmt dayjs 格式模板,默认 'YYYY-MM-DD HH:mm:ss'
+ * @returns 本地时间字符串
  */
 export function unixTimeFormat(unixTimestamp: number, fmt = 'YYYY-MM-DD HH:mm:ss') {
-  return dayjs.unix(unixTimestamp).format(fmt); // 格式化时间戳
+  return dayjs.unix(unixTimestamp).format(fmt);
 }
 
 /**
- * 格式化 unix 时间戳为 UTC 时间
- * @param unixTimestamp
- * @param fmt 返回的时间格式,默认为 YYYY-MM-DD HH:mm:ss
- * @returns
+ * 将 Unix 时间戳(秒级)格式化为 UTC 时间字符串。
+ * @param unixTimestamp Unix 秒级时间戳
+ * @param fmt dayjs 格式模板,默认 'YYYY-MM-DD HH:mm:ss'
+ * @returns UTC 时间字符串
  */
 export function unixTimeFormatToUtc(unixTimestamp: number, fmt = 'YYYY-MM-DD HH:mm:ss') {
   return dayjs.unix(unixTimestamp).utc().format(fmt);
-  // const oDt = new Date(unixTime * 1000);
-  // const dt = new Date(
-  //     oDt.getUTCFullYear(),
-  //     oDt.getUTCMonth(),
-  //     oDt.getUTCDate(),
-  //     oDt.getUTCHours(),
-  //     oDt.getUTCMinutes(),
-  //     oDt.getUTCSeconds()
-  // ).getTime();
-  // return dayjs(dt).format(fmt);
 }
 
 /**
- * 格式化 JavaScript 时间戳
- * @param jsTimestamp
- * @param fmt 返回的时间格式,默认为 YYYY-MM-DD HH:mm:ss
- * @returns
+ * 将 JavaScript 时间戳(毫秒级)格式化为本地时间字符串。
+ * @param jsTimestamp 毫秒级时间戳
+ * @param fmt dayjs 格式模板,默认 'YYYY-MM-DD HH:mm:ss'
+ * @returns 本地时间字符串
  */
 export function jsTimeFormat(jsTimestamp: number, fmt = 'YYYY-MM-DD HH:mm:ss') {
-  return dayjs(jsTimestamp).format(fmt); // 格式化时间戳
+  return dayjs(jsTimestamp).format(fmt);
 }
 
 /**
- * 格式化 JavaScript 时间戳为 UTC 时间
- * @param jsTimestamp
- * @param fmt 返回的时间格式,默认为 YYYY-MM-DD HH:mm:ss
- * @returns
+ * 将 JavaScript 时间戳(毫秒级)格式化为 UTC 时间字符串。
+ * @param jsTimestamp 毫秒级时间戳
+ * @param fmt dayjs 格式模板,默认 'YYYY-MM-DD HH:mm:ss'
+ * @returns UTC 时间字符串
  */
 export function jsTimeFormatToUtc(jsTimestamp: number, fmt = 'YYYY-MM-DD HH:mm:ss') {
-  return dayjs(jsTimestamp).utc().format(fmt); // 格式化时间戳
+  return dayjs(jsTimestamp).utc().format(fmt);
 }
 
 /**
- * 将一个 inFmt 格式的时间字符串格式化为 fmt 格式的时间字符串
- * @param dateTime
- * @param inFmt
- * @param fmt
- * @returns
+ * 按输入格式解析时间字符串,再格式化为目标格式的本地时间字符串。
+ * @param dateTime 时间字符串
+ * @param inFmt 输入字符串的 dayjs 格式模板
+ * @param fmt 输出字符串的 dayjs 格式模板,默认 'YYYY-MM-DD HH:mm:ss'
+ * @returns 格式化后的本地时间字符串
  */
 export function stringTimeFormat(dateTime: string, inFmt: string, fmt = 'YYYY-MM-DD HH:mm:ss') {
   return dayjs(dateTime, inFmt).format(fmt);
 }
 
 /**
- * 将一个 inFmt 格式的时间字符串格式化为 fmt 格式的utc的时间字符串
- * @param dateTime
- * @param inFmt
- * @param fmt
- * @returns
+ * 按输入格式解析时间字符串,再格式化为目标格式的 UTC 时间字符串。
+ * @param dateTime 时间字符串
+ * @param inFmt 输入字符串的 dayjs 格式模板
+ * @param fmt 输出字符串的 dayjs 格式模板,默认 'YYYY-MM-DD HH:mm:ss'
+ * @returns 格式化后的 UTC 时间字符串
  */
 export function stringTimeFormatToUtc(
   dateTime: string,
@@ -152,10 +124,10 @@ export function stringTimeFormatToUtc(
 }
 
 /**
- * 严格根据格式模板,格式化秒数,仅支持天、时、分、秒,低于2位时,高位补0
- * @param seconds
- * @param fmt 返回的时间格式,默认为 DD HH:mm:ss
- * @returns
+ * 将总秒数按格式模板格式化为「天 时:分:秒」字符串;仅支持 D/d、H/h、m、s 占位符,数值不足两位时高位补 0。
+ * @param seconds 总秒数
+ * @param fmt 占位符模板,默认 'DD HH:mm:ss'(D/d 天,H/h 时,m 分,s 秒)
+ * @returns 格式化后的时长字符串
  */
 export function secondsFormat(seconds: number, fmt = 'DD HH:mm:ss') {
   let result = fmt;
@@ -181,10 +153,10 @@ export function secondsFormat(seconds: number, fmt = 'DD HH:mm:ss') {
 }
 
 /**
- * 严格根据格式模板,格式化秒数,仅支持天、时、分、秒,不显示为0的级别,例如 seconds 为 61 时,返回 1分钟1秒
- * @param seconds
- * @param fmt 返回的时间格式,默认为 DD天HH小时mm分钟ss秒
- * @returns
+ * 将总秒数按格式模板格式化为时长字符串,值为 0 的档位会省略(秒档位始终保留);seconds ≤ 0 时返回空字符串。
+ * @param seconds 总秒数
+ * @param fmt 占位符模板,默认 'DD天HH小时mm分钟ss秒'(DD/HH/mm/ss 对应天/时/分/秒及后缀)
+ * @returns 精简后的时长字符串,如 61 秒对应「1分钟1秒」
  */
 export function secondsFormatFit(seconds: number, fmt = 'DD天HH小时mm分钟ss秒') {
   if (seconds <= 0) {
@@ -220,10 +192,10 @@ export function secondsFormatFit(seconds: number, fmt = 'DD天HH小时mm分钟ss
 }
 
 /**
- * 输入一个时间字符串,返回其对应的 js 时间戳(13位 毫秒级)
- * @param dateTimeString 时间字符串,若不指定 fmt,可以支持的格式类似 '2013-11-11T15:30:00Z'、'2013-11-11T15:30:00+08:00'、'Thu Nov 14 2024 14:57:29 GMT+0800 (中国标准时间)'
- * @param fmt 时间字符串的格式,默认 YYYY-MM-DD HH:mm:ss
- * @returns
+ * 将时间字符串解析为 JavaScript 时间戳(13 位,毫秒级)。若为 ISO 或 GMT 格式则自动识别,否则按 fmt 解析。
+ * @param dateTimeString 时间字符串, '2013-11-11T15:30:00Z'、'2013-11-11T15:30:00+08:00'、'Thu Nov 14 2024 14:57:29 GMT+0800 (中国标准时间)' 或 'YYYY-MM-DD HH:mm:ss'
+ * @param fmt 非 ISO/GMT 时的 dayjs 格式模板,默认 'YYYY-MM-DD HH:mm:ss'
+ * @returns 毫秒级时间戳
  */
 export function getTimeFromDateTimeString(
   dateTimeString: string,
@@ -243,10 +215,10 @@ export function getTimeFromDateTimeString(
 }
 
 /**
- *  输入一个表示 UTC 时间的字符串,返回其对应的 js 时间戳(13位 毫秒级)
- * @param utcDateTimeString UTC 时间字符串,若不指定 fmt,可以支持的格式类似 '2013-11-11T15:30:00Z'、'2013-11-11T15:30:00+08:00'、'Thu Nov 14 2024 14:57:29 GMT+0800 (中国标准时间)'
- * @param fmt 时间字符串的格式,默认 YYYY-MM-DD HH:mm:ss
- * @returns
+ * 将表示 UTC 的时间字符串按 UTC 解析为 JavaScript 时间戳(13 位,毫秒级)。若为 ISO 或 GMT 格式则自动识别,否则按 fmt 解析。
+ * @param utcDateTimeString UTC 时间字符串,格式同 getTimeFromDateTimeString 的 dateTimeString
+ * @param fmt 非 ISO/GMT 时的 dayjs 格式模板,默认 'YYYY-MM-DD HH:mm:ss'
+ * @returns 毫秒级时间戳
  */
 export function getTimeFromUtcDateTimeString(
   utcDateTimeString: string,
@@ -263,26 +235,13 @@ export function getTimeFromUtcDateTimeString(
   } else {
     return dayjs.utc(utcDateTimeString, fmt).valueOf();
   }
-
-  // if (
-  //     /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(utcDateTimeString) ||
-  //     /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/.test(utcDateTimeString) ||
-  //     /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{2} \d{4} \d{2}:\d{2}:\d{2} GMT[+-]\d{4}( \(.+\))?$/.test(
-  //         utcDateTimeString
-  //     )
-  // ) {
-  //     return new Date(utcDateTimeString).getTime();
-  // } else {
-  //     const dt = dayjs(utcDateTimeString, fmt).toDate();
-  //     return dt.getTime() - dt.getTimezoneOffset() * 60 * 1000;
-  // }
 }
 
 /**
- * 输入一个时间字符串,返回其对应的 unix 时间戳(10位 秒级)
- * @param dateTimeString 时间字符串,若不指定 fmt,可以支持的格式类似 '2013-11-11T15:30:00Z'、'2013-11-11T15:30:00+08:00'、'Thu Nov 14 2024 14:57:29 GMT+0800 (中国标准时间)'
- * @param fmt 时间字符串的格式,默认 YYYY-MM-DD HH:mm:ss
- * @returns
+ * 将时间字符串解析为 Unix 时间戳(10 位,秒级)。解析规则同 getTimeFromDateTimeString,结果为毫秒戳向下取整除以 1000。
+ * @param dateTimeString 时间字符串,格式同 getTimeFromDateTimeString
+ * @param fmt 非 ISO/GMT 时的 dayjs 格式模板,默认 'YYYY-MM-DD HH:mm:ss'
+ * @returns Unix 秒级时间戳
  */
 export function getUnixFromDateTimeString(
   dateTimeString: string,
@@ -292,10 +251,10 @@ export function getUnixFromDateTimeString(
 }
 
 /**
- *  输入一个表示 UTC 时间的字符串,返回其对应的 unix 时间戳(10位 秒级)
- * @param utcDateTimeString UTC 时间字符串,若不指定 fmt,可以支持的格式类似 '2013-11-11T15:30:00Z'、'2013-11-11T15:30:00+08:00'、'Thu Nov 14 2024 14:57:29 GMT+0800 (中国标准时间)'
- * @param fmt 时间字符串的格式,默认 YYYY-MM-DD HH:mm:ss
- * @returns
+ * 将表示 UTC 的时间字符串解析为 Unix 时间戳(10 位,秒级)。解析规则同 getTimeFromUtcDateTimeString,结果为毫秒戳向下取整除以 1000。
+ * @param utcDateTimeString UTC 时间字符串,格式同 getTimeFromUtcDateTimeString
+ * @param fmt 非 ISO/GMT 时的 dayjs 格式模板,默认 'YYYY-MM-DD HH:mm:ss'
+ * @returns Unix 秒级时间戳
  */
 export function getUnixFromUtcDateTimeString(
   utcDateTimeString: string,