diff --git a/gym-manage-api/manage-common/src/main/java/cn/novalon/gym/manage/common/util/CryptoService.java b/gym-manage-api/manage-common/src/main/java/cn/novalon/gym/manage/common/util/CryptoService.java
new file mode 100644
index 0000000..82a77a8
--- /dev/null
+++ b/gym-manage-api/manage-common/src/main/java/cn/novalon/gym/manage/common/util/CryptoService.java
@@ -0,0 +1,126 @@
+package cn.novalon.gym.manage.common.util;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.crypto.Cipher;
+import javax.crypto.SecretKey;
+import javax.crypto.SecretKeyFactory;
+import javax.crypto.spec.GCMParameterSpec;
+import javax.crypto.spec.PBEKeySpec;
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.security.SecureRandom;
+import java.security.spec.KeySpec;
+import java.util.Base64;
+
+/**
+ * AES-256-GCM 加解密服务
+ *
+ * 用于前后端通信的应用层加密 —— 在 HTTPS 基础上额外加密 JSON 载荷。
+ * 格式: Base64( 12-byte-IV || AES-GCM-ciphertext )
+ * 密钥通过 PBKDF2-HMAC-SHA256 从密码短语派生,与前端 crypto.ts 保持一致。
+ *
+ *
+ * @author 张翔
+ * @date 2026-08-02
+ */
+public class CryptoService {
+
+ private static final Logger log = LoggerFactory.getLogger(CryptoService.class);
+
+ private static final int GCM_IV_LENGTH = 12;
+ private static final int GCM_TAG_LENGTH = 128;
+ private static final String ALGORITHM = "AES/GCM/NoPadding";
+ private static final String KEY_DERIVATION_ALGORITHM = "PBKDF2WithHmacSHA256";
+ private static final int KEY_LENGTH_BITS = 256;
+ private static final int PBKDF2_ITERATIONS = 100_000;
+ private static final byte[] PBKDF2_SALT =
+ "novalon-gym-manage-aes-salt-v1".getBytes(StandardCharsets.UTF_8);
+
+ private final SecretKey secretKey;
+
+ /**
+ * @param passphrase 用于派生 AES-256 密钥的密码短语,长度不少于 12 个字符
+ */
+ public CryptoService(String passphrase) {
+ if (passphrase == null || passphrase.isBlank()) {
+ throw new IllegalArgumentException("encryption passphrase must not be empty");
+ }
+ if (passphrase.length() < 12) {
+ throw new IllegalArgumentException(
+ "encryption passphrase must be at least 12 characters");
+ }
+ try {
+ SecretKeyFactory factory = SecretKeyFactory.getInstance(KEY_DERIVATION_ALGORITHM);
+ KeySpec spec = new PBEKeySpec(
+ passphrase.toCharArray(), PBKDF2_SALT, PBKDF2_ITERATIONS, KEY_LENGTH_BITS);
+ byte[] keyBytes = factory.generateSecret(spec).getEncoded();
+ this.secretKey = new SecretKeySpec(keyBytes, "AES");
+ log.info("CryptoService initialized (AES-256-GCM, PBKDF2)");
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to initialize CryptoService", e);
+ }
+ }
+
+ /**
+ * 加密字节数组并返回 Base64 编码的密文(IV 被前置)
+ */
+ public String encrypt(byte[] plaintext) {
+ try {
+ Cipher cipher = Cipher.getInstance(ALGORITHM);
+ byte[] iv = new byte[GCM_IV_LENGTH];
+ SecureRandom.getInstanceStrong().nextBytes(iv);
+ GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
+ cipher.init(Cipher.ENCRYPT_MODE, secretKey, spec);
+ byte[] ciphertext = cipher.doFinal(plaintext);
+
+ byte[] combined = ByteBuffer.allocate(GCM_IV_LENGTH + ciphertext.length)
+ .put(iv)
+ .put(ciphertext)
+ .array();
+
+ return Base64.getEncoder().encodeToString(combined);
+ } catch (Exception e) {
+ throw new RuntimeException("Encryption failed", e);
+ }
+ }
+
+ /**
+ * 解密 Base64 编码的密文(IV 被前置)
+ */
+ public byte[] decrypt(String encryptedBase64) {
+ try {
+ byte[] combined = Base64.getDecoder().decode(encryptedBase64);
+ ByteBuffer buffer = ByteBuffer.wrap(combined);
+
+ byte[] iv = new byte[GCM_IV_LENGTH];
+ buffer.get(iv);
+
+ byte[] ciphertext = new byte[buffer.remaining()];
+ buffer.get(ciphertext);
+
+ Cipher cipher = Cipher.getInstance(ALGORITHM);
+ GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
+ cipher.init(Cipher.DECRYPT_MODE, secretKey, spec);
+ return cipher.doFinal(ciphertext);
+ } catch (Exception e) {
+ throw new RuntimeException("Decryption failed", e);
+ }
+ }
+
+ /**
+ * 加密字符串
+ */
+ public String encryptString(String plaintext) {
+ return encrypt(plaintext.getBytes(StandardCharsets.UTF_8));
+ }
+
+ /**
+ * 解密字符串
+ */
+ public String decryptToString(String encryptedBase64) {
+ return new String(decrypt(encryptedBase64), StandardCharsets.UTF_8);
+ }
+}
\ No newline at end of file
diff --git a/gym-manage-api/manage-common/src/test/java/cn/novalon/gym/manage/common/util/CryptoServiceTest.java b/gym-manage-api/manage-common/src/test/java/cn/novalon/gym/manage/common/util/CryptoServiceTest.java
new file mode 100644
index 0000000..f67c4a0
--- /dev/null
+++ b/gym-manage-api/manage-common/src/test/java/cn/novalon/gym/manage/common/util/CryptoServiceTest.java
@@ -0,0 +1,191 @@
+package cn.novalon.gym.manage.common.util;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.StandardCharsets;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+@DisplayName("CryptoService 单元测试")
+class CryptoServiceTest {
+
+ private static final String TEST_PASSPHRASE = "TestEncryptionKey2026!";
+ private CryptoService cryptoService;
+
+ @BeforeEach
+ void setUp() {
+ cryptoService = new CryptoService(TEST_PASSPHRASE);
+ }
+
+ @Nested
+ @DisplayName("构造方法测试")
+ class ConstructorTest {
+
+ @Test
+ @DisplayName("密码短语为空时应抛出异常")
+ void shouldThrowWhenPassphraseIsNull() {
+ assertThatThrownBy(() -> new CryptoService(null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("must not be empty");
+ }
+
+ @Test
+ @DisplayName("密码短语为空字符串时应抛出异常")
+ void shouldThrowWhenPassphraseIsBlank() {
+ assertThatThrownBy(() -> new CryptoService(" "))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("must not be empty");
+ }
+
+ @Test
+ @DisplayName("密码短语少于12个字符时应抛出异常")
+ void shouldThrowWhenPassphraseTooShort() {
+ assertThatThrownBy(() -> new CryptoService("short"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("at least 12 characters");
+ }
+ }
+
+ @Nested
+ @DisplayName("加解密测试")
+ class EncryptDecryptTest {
+
+ @Test
+ @DisplayName("应对字符串进行加密并成功解密")
+ void shouldEncryptAndDecryptString() {
+ String plaintext = "Hello, CryptoService!";
+ String encrypted = cryptoService.encryptString(plaintext);
+ String decrypted = cryptoService.decryptToString(encrypted);
+
+ assertThat(encrypted).isNotEqualTo(plaintext);
+ assertThat(decrypted).isEqualTo(plaintext);
+ }
+
+ @Test
+ @DisplayName("应对JSON字符串进行加密并成功解密")
+ void shouldEncryptAndDecryptJson() {
+ String json = "{\"username\":\"admin\",\"password\":\"secret123\"}";
+ String encrypted = cryptoService.encryptString(json);
+ String decrypted = cryptoService.decryptToString(encrypted);
+
+ assertThat(encrypted).isNotEqualTo(json);
+ assertThat(decrypted).isEqualTo(json);
+ }
+
+ @Test
+ @DisplayName("应对空字符串进行加密并成功解密")
+ void shouldEncryptAndDecryptEmptyString() {
+ String plaintext = "";
+ String encrypted = cryptoService.encryptString(plaintext);
+ String decrypted = cryptoService.decryptToString(encrypted);
+
+ assertThat(encrypted).isNotBlank();
+ assertThat(decrypted).isEqualTo(plaintext);
+ }
+
+ @Test
+ @DisplayName("加密结果应为Base64编码")
+ void shouldProduceBase64Output() {
+ String encrypted = cryptoService.encryptString("test");
+ // Base64 pattern: alphanumeric, +, /, =
+ assertThat(encrypted).matches("^[A-Za-z0-9+/=]+$");
+ }
+
+ @Test
+ @DisplayName("每次加密应产生不同结果(不同IV)")
+ void shouldProduceDifferentCiphertextEachTime() {
+ String plaintext = "same text";
+ String encrypted1 = cryptoService.encryptString(plaintext);
+ String encrypted2 = cryptoService.encryptString(plaintext);
+
+ assertThat(encrypted1).isNotEqualTo(encrypted2);
+ }
+
+ @Test
+ @DisplayName("应处理中文字符")
+ void shouldHandleChineseCharacters() {
+ String chinese = "你好,世界!加密测试";
+ String encrypted = cryptoService.encryptString(chinese);
+ String decrypted = cryptoService.decryptToString(encrypted);
+
+ assertThat(decrypted).isEqualTo(chinese);
+ }
+
+ @Test
+ @DisplayName("应处理长文本")
+ void shouldHandleLongText() {
+ StringBuilder sb = new StringBuilder();
+ for (int i = 0; i < 1000; i++) {
+ sb.append("Long text content for testing. ");
+ }
+ String longText = sb.toString();
+ String encrypted = cryptoService.encryptString(longText);
+ String decrypted = cryptoService.decryptToString(encrypted);
+
+ assertThat(decrypted).isEqualTo(longText);
+ }
+
+ @Test
+ @DisplayName("加密字节数组与解密后应一致")
+ void shouldEncryptAndDecryptByteArray() {
+ byte[] plaintext = "byte array test".getBytes(StandardCharsets.UTF_8);
+ String encrypted = cryptoService.encrypt(plaintext);
+ byte[] decrypted = cryptoService.decrypt(encrypted);
+
+ assertThat(decrypted).isEqualTo(plaintext);
+ }
+ }
+
+ @Nested
+ @DisplayName("异常处理测试")
+ class ExceptionHandlingTest {
+
+ @Test
+ @DisplayName("解密无效的Base64时应抛出异常")
+ void shouldThrowOnInvalidBase64() {
+ assertThatThrownBy(() -> cryptoService.decrypt("invalid-base64!!!"))
+ .isInstanceOf(RuntimeException.class)
+ .hasMessageContaining("Decryption failed");
+ }
+
+ @Test
+ @DisplayName("解密非法的密文时应抛出异常")
+ void shouldThrowOnInvalidCiphertext() {
+ // 合法的Base64,但不是有效的AES-GCM密文
+ String fakeCiphertext = "dGhpcyBpcyBpbnZhbGlkIGNpcGhlcnRleHQ=";
+ assertThatThrownBy(() -> cryptoService.decrypt(fakeCiphertext))
+ .isInstanceOf(RuntimeException.class)
+ .hasMessageContaining("Decryption failed");
+ }
+ }
+
+ @Nested
+ @DisplayName("不同密钥实例兼容性测试")
+ class KeyCompatibilityTest {
+
+ @Test
+ @DisplayName("相同密码短语生成的实例应能互相解密")
+ void shouldBeInteroperableWithSamePassphrase() {
+ CryptoService another = new CryptoService(TEST_PASSPHRASE);
+ String plaintext = "interoperability test";
+ String encrypted = cryptoService.encryptString(plaintext);
+ String decrypted = another.decryptToString(encrypted);
+
+ assertThat(decrypted).isEqualTo(plaintext);
+ }
+
+ @Test
+ @DisplayName("不同密码短语生成的实例不应能互相解密")
+ void shouldNotBeInteroperableWithDifferentPassphrase() {
+ CryptoService another = new CryptoService("DifferentPassphraseKey!@#");
+ String encrypted = cryptoService.encryptString("secret data");
+
+ assertThatThrownBy(() -> another.decrypt(encrypted))
+ .isInstanceOf(RuntimeException.class);
+ }
+ }
+}
\ No newline at end of file
diff --git a/gym-manage-api/manage-gateway/pom.xml b/gym-manage-api/manage-gateway/pom.xml
index 1c25330..974c668 100644
--- a/gym-manage-api/manage-gateway/pom.xml
+++ b/gym-manage-api/manage-gateway/pom.xml
@@ -17,6 +17,11 @@
Gateway module for Novalon Manage API
+
+ cn.novalon.gym.manage
+ manage-common
+ ${project.version}
+
org.springframework.boot
spring-boot-starter-webflux
diff --git a/gym-manage-api/manage-gateway/src/main/java/cn/novalon/gym/manage/gateway/config/CryptoConfig.java b/gym-manage-api/manage-gateway/src/main/java/cn/novalon/gym/manage/gateway/config/CryptoConfig.java
new file mode 100644
index 0000000..5d932c6
--- /dev/null
+++ b/gym-manage-api/manage-gateway/src/main/java/cn/novalon/gym/manage/gateway/config/CryptoConfig.java
@@ -0,0 +1,21 @@
+package cn.novalon.gym.manage.gateway.config;
+
+import cn.novalon.gym.manage.common.util.CryptoService;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * CryptoService Bean 配置 —— 使用 {@code app.encryption.secret} 初始化 AES-256-GCM 加解密引擎。
+ *
+ * @author 张翔
+ * @date 2026-08-02
+ */
+@Configuration
+public class CryptoConfig {
+
+ @Bean
+ public CryptoService cryptoService(@Value("${app.encryption.secret}") String secret) {
+ return new CryptoService(secret);
+ }
+}
\ No newline at end of file
diff --git a/gym-manage-api/manage-gateway/src/main/java/cn/novalon/gym/manage/gateway/filter/CryptoFilter.java b/gym-manage-api/manage-gateway/src/main/java/cn/novalon/gym/manage/gateway/filter/CryptoFilter.java
new file mode 100644
index 0000000..aca43ac
--- /dev/null
+++ b/gym-manage-api/manage-gateway/src/main/java/cn/novalon/gym/manage/gateway/filter/CryptoFilter.java
@@ -0,0 +1,181 @@
+package cn.novalon.gym.manage.gateway.filter;
+
+import cn.novalon.gym.manage.common.util.CryptoService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.cloud.gateway.filter.GatewayFilterChain;
+import org.springframework.cloud.gateway.filter.GlobalFilter;
+import org.springframework.core.Ordered;
+import org.springframework.core.io.buffer.DataBuffer;
+import org.springframework.core.io.buffer.DataBufferUtils;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.server.reactive.ServerHttpRequest;
+import org.springframework.http.server.reactive.ServerHttpRequestDecorator;
+import org.springframework.http.server.reactive.ServerHttpResponseDecorator;
+import org.springframework.stereotype.Component;
+import org.springframework.web.server.ServerWebExchange;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+import java.nio.charset.StandardCharsets;
+
+/**
+ * 应用层加密/解密全局过滤器。
+ *
+ * 当请求携带 {@code X-Encrypted: true} 头时:
+ *
+ * - 请求体:在 filter 层主动解密为 JSON 明文,再交给下游 handler
+ * - 响应体:将 handler 返回的 JSON 明文加密为 Base64(AES-GCM IV‖密文),并标记 {@code X-Encrypted: true}
+ *
+ * 不携带该头的请求直接透传(兼容 curl/Postman 等调试工具)。
+ *
+ *
+ * 安全约束:携带 {@code X-Encrypted: true} 但解密失败的请求必须返回 400 Bad Request,
+ * 禁止静默降级为明文传输,防止中间人/客户端误用导致的数据暴露。
+ *
+ *
+ * 优先级:在 {@link SignatureFilter} 之后执行,确保已通过签名验证的请求再进行加解密处理。
+ *
+ *
+ * @author 张翔
+ * @date 2026-08-02
+ */
+@Component
+public class CryptoFilter implements GlobalFilter, Ordered {
+
+ private static final Logger log = LoggerFactory.getLogger(CryptoFilter.class);
+ private static final String ENCRYPTED_HEADER = "X-Encrypted";
+
+ private final CryptoService cryptoService;
+
+ public CryptoFilter(CryptoService cryptoService) {
+ this.cryptoService = cryptoService;
+ }
+
+ @Override
+ public int getOrder() {
+ // 在 SignatureFilter (HIGHEST_PRECEDENCE + 150) 之后执行
+ return Ordered.HIGHEST_PRECEDENCE + 200;
+ }
+
+ @Override
+ public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) {
+ String path = exchange.getRequest().getURI().getPath();
+
+ // 健康检查 & 非 API 路径透传
+ if (path.startsWith("/actuator/") || path.equals("/favicon.ico")) {
+ return chain.filter(exchange);
+ }
+
+ boolean isEncrypted = "true".equalsIgnoreCase(
+ exchange.getRequest().getHeaders().getFirst(ENCRYPTED_HEADER));
+
+ if (!isEncrypted) {
+ return chain.filter(exchange);
+ }
+
+ // 构建加密响应装饰器
+ ServerHttpResponseDecorator encryptedResponse = buildEncryptedResponse(exchange);
+
+ // 读取并解密请求体
+ return DataBufferUtils.join(exchange.getRequest().getBody())
+ .switchIfEmpty(Mono.defer(() -> {
+ // 无请求体(如 GET/DELETE)仍需加密响应
+ return chain.filter(exchange.mutate().response(encryptedResponse).build())
+ .then(Mono.empty());
+ }))
+ .flatMap(buffer -> {
+ byte[] encryptedBytes = new byte[buffer.readableByteCount()];
+ buffer.read(encryptedBytes);
+ DataBufferUtils.release(buffer);
+
+ String bodyStr = new String(encryptedBytes, StandardCharsets.UTF_8);
+
+ if (bodyStr.isEmpty()) {
+ // 空 body 透传,但响应仍需加密
+ return chain.filter(exchange.mutate().response(encryptedResponse).build());
+ }
+
+ byte[] decryptedBytes;
+ try {
+ decryptedBytes = cryptoService.decrypt(bodyStr);
+ } catch (Exception e) {
+ log.warn("Encrypted request rejected for {}: {}", path, e.getMessage());
+ exchange.getResponse().setStatusCode(HttpStatus.BAD_REQUEST);
+ return exchange.getResponse().setComplete();
+ }
+
+ ServerHttpRequest decryptedRequest = buildDecryptedRequest(exchange, decryptedBytes);
+ return chain.filter(
+ exchange.mutate()
+ .request(decryptedRequest)
+ .response(encryptedResponse)
+ .build()
+ );
+ });
+ }
+
+ // ========== 请求体解密 ==========
+
+ private ServerHttpRequest buildDecryptedRequest(ServerWebExchange exchange, byte[] decryptedBytes) {
+ return new ServerHttpRequestDecorator(exchange.getRequest()) {
+
+ @Override
+ public HttpHeaders getHeaders() {
+ HttpHeaders headers = new HttpHeaders();
+ headers.putAll(super.getHeaders());
+ headers.set(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
+ // 移除加密标记,避免下游服务重复解密
+ headers.remove(ENCRYPTED_HEADER);
+ return HttpHeaders.readOnlyHttpHeaders(headers);
+ }
+
+ @Override
+ public Flux getBody() {
+ return Flux.just(exchange.getResponse().bufferFactory().wrap(decryptedBytes));
+ }
+ };
+ }
+
+ // ========== 响应体加密 ==========
+
+ private ServerHttpResponseDecorator buildEncryptedResponse(ServerWebExchange exchange) {
+ return new ServerHttpResponseDecorator(exchange.getResponse()) {
+
+ @Override
+ public Mono writeWith(org.reactivestreams.Publisher extends DataBuffer> body) {
+ return DataBufferUtils.join(body)
+ .flatMap(buffer -> {
+ try {
+ byte[] plainBytes = new byte[buffer.readableByteCount()];
+ buffer.read(plainBytes);
+ DataBufferUtils.release(buffer);
+
+ String encrypted = cryptoService.encrypt(plainBytes);
+ byte[] encryptedBytes = encrypted.getBytes(StandardCharsets.UTF_8);
+
+ getDelegate().getHeaders().set(ENCRYPTED_HEADER, "true");
+ getDelegate().getHeaders().set(HttpHeaders.CONTENT_LENGTH,
+ String.valueOf(encryptedBytes.length));
+
+ return getDelegate()
+ .writeWith(Mono.just(
+ getDelegate().bufferFactory().wrap(encryptedBytes)));
+ } catch (Exception e) {
+ log.error("Response body encryption failed for {}: {}",
+ exchange.getRequest().getURI().getPath(), e.getMessage());
+ return Mono.error(e);
+ }
+ });
+ }
+
+ @Override
+ public Mono writeAndFlushWith(
+ org.reactivestreams.Publisher extends org.reactivestreams.Publisher extends DataBuffer>> body) {
+ return writeWith(Flux.from(body).flatMapSequential(p -> p));
+ }
+ };
+ }
+}
\ No newline at end of file
diff --git a/gym-manage-api/manage-gateway/src/main/resources/application-local.yml b/gym-manage-api/manage-gateway/src/main/resources/application-local.yml
index c7b015a..b60582d 100644
--- a/gym-manage-api/manage-gateway/src/main/resources/application-local.yml
+++ b/gym-manage-api/manage-gateway/src/main/resources/application-local.yml
@@ -32,6 +32,10 @@ jwt:
secret: U2FsdGVkX1+vZ5Y9QmKxL8nN3rP7tW2jH4fG6dA8sB1cE5yN0zX3qV7wM4
expiration: 86400000
+app:
+ encryption:
+ secret: GymManageEncryptionSecretKey2026!
+
logging:
level:
cn.novalon.manage.gateway: DEBUG
diff --git a/gym-manage-api/manage-gateway/src/main/resources/application-prod.yml b/gym-manage-api/manage-gateway/src/main/resources/application-prod.yml
index 76086cf..147dea3 100644
--- a/gym-manage-api/manage-gateway/src/main/resources/application-prod.yml
+++ b/gym-manage-api/manage-gateway/src/main/resources/application-prod.yml
@@ -7,6 +7,10 @@ spring:
predicates:
- Path=/api/**
+app:
+ encryption:
+ secret: ${APP_ENCRYPTION_SECRET}
+
logging:
level:
cn.novalon.manage: INFO
diff --git a/gym-manage-api/manage-gateway/src/main/resources/application.yml b/gym-manage-api/manage-gateway/src/main/resources/application.yml
index d9819fd..50aee34 100644
--- a/gym-manage-api/manage-gateway/src/main/resources/application.yml
+++ b/gym-manage-api/manage-gateway/src/main/resources/application.yml
@@ -42,6 +42,12 @@ jwt:
interval:
days: ${JWT_KEY_ROTATION_INTERVAL_DAYS:30}
+# 前后端通信应用层加密配置
+# 生产环境必须通过环境变量 APP_ENCRYPTION_SECRET 注入,长度不少于 12 个字符
+app:
+ encryption:
+ secret: ${APP_ENCRYPTION_SECRET:GymManageEncryptionSecretKey2026!}
+
rate:
limit:
enabled: ${RATE_LIMIT_ENABLED:true}
diff --git a/gym-manage-api/manage-gateway/src/test/java/cn/novalon/gym/manage/gateway/filter/CryptoFilterTest.java b/gym-manage-api/manage-gateway/src/test/java/cn/novalon/gym/manage/gateway/filter/CryptoFilterTest.java
new file mode 100644
index 0000000..b6daf83
--- /dev/null
+++ b/gym-manage-api/manage-gateway/src/test/java/cn/novalon/gym/manage/gateway/filter/CryptoFilterTest.java
@@ -0,0 +1,238 @@
+package cn.novalon.gym.manage.gateway.filter;
+
+import cn.novalon.gym.manage.common.util.CryptoService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.cloud.gateway.filter.GatewayFilterChain;
+import org.springframework.core.io.buffer.DataBuffer;
+import org.springframework.core.io.buffer.DefaultDataBufferFactory;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.server.reactive.ServerHttpResponseDecorator;
+import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
+import org.springframework.mock.web.server.MockServerWebExchange;
+import org.springframework.web.server.ServerWebExchange;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
+
+import java.nio.charset.StandardCharsets;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+@DisplayName("CryptoFilter 集成测试")
+class CryptoFilterTest {
+
+ private static final String TEST_SECRET = "CryptoFilterTestKey2026!";
+ private static final String JSON_PAYLOAD = "{\"username\":\"admin\",\"password\":\"Test@123\"}";
+ private static final String JSON_RESPONSE = "{\"token\":\"test-jwt-token\",\"userId\":1}";
+
+ private CryptoService cryptoService;
+ private CryptoFilter cryptoFilter;
+
+ @Mock
+ private GatewayFilterChain chain;
+
+ @BeforeEach
+ void setUp() {
+ cryptoService = new CryptoService(TEST_SECRET);
+ cryptoFilter = new CryptoFilter(cryptoService);
+ }
+
+ @Nested
+ @DisplayName("无加密标记的请求应透传")
+ class PlaintextPassthrough {
+
+ @Test
+ @DisplayName("无 X-Encrypted 头的请求直接透传")
+ void shouldPassThroughWithoutEncryptedHeader() {
+ MockServerHttpRequest request = MockServerHttpRequest
+ .method(HttpMethod.GET, "/api/users")
+ .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
+ .build();
+ ServerWebExchange exchange = MockServerWebExchange.from(request);
+
+ when(chain.filter(any())).thenReturn(Mono.empty());
+
+ StepVerifier.create(cryptoFilter.filter(exchange, chain))
+ .verifyComplete();
+
+ verify(chain).filter(exchange);
+ }
+
+ @Test
+ @DisplayName("健康检查端点透传")
+ void shouldPassThroughHealthEndpoint() {
+ MockServerHttpRequest request = MockServerHttpRequest
+ .method(HttpMethod.GET, "/actuator/health")
+ .build();
+ ServerWebExchange exchange = MockServerWebExchange.from(request);
+
+ when(chain.filter(any())).thenReturn(Mono.empty());
+
+ StepVerifier.create(cryptoFilter.filter(exchange, chain))
+ .verifyComplete();
+
+ verify(chain).filter(exchange);
+ }
+ }
+
+ @Nested
+ @DisplayName("加密请求解密测试")
+ class EncryptedRequestDecryption {
+
+ @Test
+ @DisplayName("应解密加密的请求体并传递给下游")
+ void shouldDecryptRequestBody() {
+ String encryptedBody = cryptoService.encryptString(JSON_PAYLOAD);
+
+ MockServerHttpRequest request = MockServerHttpRequest
+ .method(HttpMethod.POST, "/api/auth/login")
+ .header("X-Encrypted", "true")
+ .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
+ .body(encryptedBody);
+ ServerWebExchange exchange = MockServerWebExchange.from(request);
+
+ // 模拟下游处理器:捕获解密后的请求
+ when(chain.filter(any())).thenAnswer(invocation -> {
+ ServerWebExchange ex = invocation.getArgument(0);
+ // 验证请求头中的 X-Encrypted 已被移除
+ assertThat(ex.getRequest().getHeaders().get("X-Encrypted")).isNull();
+ // 验证 Content-Type 是 application/json
+ assertThat(ex.getRequest().getHeaders().getFirst(HttpHeaders.CONTENT_TYPE))
+ .isEqualTo(MediaType.APPLICATION_JSON_VALUE);
+ return Mono.empty();
+ });
+
+ StepVerifier.create(cryptoFilter.filter(exchange, chain))
+ .verifyComplete();
+
+ verify(chain).filter(any());
+ }
+
+ @Test
+ @DisplayName("空请求体应透传(GET 请求)")
+ void shouldHandleEmptyBody() {
+ MockServerHttpRequest request = MockServerHttpRequest
+ .method(HttpMethod.GET, "/api/users")
+ .header("X-Encrypted", "true")
+ .build();
+ ServerWebExchange exchange = MockServerWebExchange.from(request);
+
+ when(chain.filter(any())).thenReturn(Mono.empty());
+
+ StepVerifier.create(cryptoFilter.filter(exchange, chain))
+ .verifyComplete();
+
+ verify(chain).filter(any());
+ }
+
+ @Test
+ @DisplayName("解密失败应返回 400")
+ void shouldReturn400OnDecryptionFailure() {
+ // 非法的 Base64 密文
+ MockServerHttpRequest request = MockServerHttpRequest
+ .method(HttpMethod.POST, "/api/auth/login")
+ .header("X-Encrypted", "true")
+ .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
+ .body("invalid-base64!!!");
+ ServerWebExchange exchange = MockServerWebExchange.from(request);
+
+ StepVerifier.create(cryptoFilter.filter(exchange, chain))
+ .verifyComplete();
+
+ assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ // 不应调用下游 chain
+ verify(chain, never()).filter(any());
+ }
+ }
+
+ @Nested
+ @DisplayName("加密响应测试")
+ class EncryptedResponseTest {
+
+ @Test
+ @DisplayName("应加密下游返回的响应体")
+ void shouldEncryptResponseBody() {
+ String encryptedBody = cryptoService.encryptString(JSON_PAYLOAD);
+
+ MockServerHttpRequest request = MockServerHttpRequest
+ .method(HttpMethod.POST, "/api/auth/login")
+ .header("X-Encrypted", "true")
+ .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
+ .body(encryptedBody);
+ ServerWebExchange exchange = MockServerWebExchange.from(request);
+
+ // 模拟下游返回明文 JSON
+ when(chain.filter(any())).thenAnswer(invocation -> {
+ ServerWebExchange ex = invocation.getArgument(0);
+ byte[] responseBytes = JSON_RESPONSE.getBytes(StandardCharsets.UTF_8);
+ DataBuffer buffer = new DefaultDataBufferFactory().wrap(responseBytes);
+ return ex.getResponse().writeWith(Mono.just(buffer));
+ });
+
+ StepVerifier.create(cryptoFilter.filter(exchange, chain))
+ .verifyComplete();
+
+ // 验证响应头包含 X-Encrypted: true
+ assertThat(exchange.getResponse().getHeaders().getFirst("X-Encrypted")).isEqualTo("true");
+ }
+
+ @Test
+ @DisplayName("加密的响应体应能被解密还原")
+ void shouldProduceDecryptableResponse() {
+ String encryptedBody = cryptoService.encryptString(JSON_PAYLOAD);
+
+ MockServerHttpRequest request = MockServerHttpRequest
+ .method(HttpMethod.POST, "/api/auth/login")
+ .header("X-Encrypted", "true")
+ .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
+ .body(encryptedBody);
+
+ // 构建一个带响应体捕获装饰器的 Exchange
+ StringBuilder capturedBody = new StringBuilder();
+ ServerWebExchange exchange = MockServerWebExchange.from(request);
+ ServerWebExchange capturingExchange = exchange.mutate()
+ .response(new ServerHttpResponseDecorator(exchange.getResponse()) {
+ @Override
+ public Mono writeWith(org.reactivestreams.Publisher extends DataBuffer> body) {
+ return super.writeWith(Flux.from(body).doOnNext(buffer -> {
+ byte[] bytes = new byte[buffer.readableByteCount()];
+ buffer.read(bytes);
+ capturedBody.append(new String(bytes, StandardCharsets.UTF_8));
+ }));
+ }
+ })
+ .build();
+
+ // 模拟下游返回明文 JSON
+ when(chain.filter(any())).thenAnswer(invocation -> {
+ ServerWebExchange ex = invocation.getArgument(0);
+ byte[] responseBytes = JSON_RESPONSE.getBytes(StandardCharsets.UTF_8);
+ DataBuffer buffer = new DefaultDataBufferFactory().wrap(responseBytes);
+ return ex.getResponse().writeWith(Mono.just(buffer));
+ });
+
+ StepVerifier.create(cryptoFilter.filter(capturingExchange, chain))
+ .verifyComplete();
+
+ // 验证响应体可被解密还原
+ String encryptedResponse = capturedBody.toString();
+ assertThat(encryptedResponse).isNotBlank();
+ assertThat(encryptedResponse).doesNotContain("token");
+
+ String decrypted = cryptoService.decryptToString(encryptedResponse);
+ assertThat(decrypted).isEqualTo(JSON_RESPONSE);
+ }
+ }
+}
\ No newline at end of file
diff --git a/gym-manage-web/.env.example b/gym-manage-web/.env.example
index ff92041..41b57f2 100644
--- a/gym-manage-web/.env.example
+++ b/gym-manage-web/.env.example
@@ -37,3 +37,6 @@ TEST_RESULTS_FOLDER=test-results
# API签名密钥配置
VITE_SIGNATURE_SECRET=your-secret-key-here
+
+# AES-256-GCM 加密密钥(与后端 APP_ENCRYPTION_SECRET 保持一致,长度不少于12个字符)
+VITE_ENCRYPTION_SECRET=your-encryption-secret-key-here
diff --git a/gym-manage-web/.env.test b/gym-manage-web/.env.test
index 3026a06..b621e8b 100644
--- a/gym-manage-web/.env.test
+++ b/gym-manage-web/.env.test
@@ -5,6 +5,9 @@ VITE_APP_TITLE=Novalon管理系统 - 测试环境
# 测试用户配置
TEST_USER_PASSWORD=Test@123
+# AES-256-GCM 加密密钥(与后端 APP_ENCRYPTION_SECRET 保持一致)
+VITE_ENCRYPTION_SECRET=GymManageEncryptionSecretKey2026!
+
# Playwright配置
HEADLESS=true
SLOW_MO=0
diff --git a/gym-manage-web/e2e/smoke/encryption-smoke.spec.ts b/gym-manage-web/e2e/smoke/encryption-smoke.spec.ts
new file mode 100644
index 0000000..5097a1b
--- /dev/null
+++ b/gym-manage-web/e2e/smoke/encryption-smoke.spec.ts
@@ -0,0 +1,133 @@
+import { test, expect } from '@playwright/test';
+
+test.describe('前后端加密通信验证', () => {
+ test.use({ storageState: { cookies: [], origins: [] } });
+
+ test('登录请求应被加密(请求体非明文JSON,响应体非明文JSON)', async ({ page }) => {
+ // 用于捕获登录 API 请求和响应的变量
+ let capturedRequestUrl: string | null = null;
+ let capturedRequestBody: string | null = null;
+ let capturedRequestHeaders: Record = {};
+ let capturedResponseStatus: number | null = null;
+ let capturedResponseBody: string | null = null;
+ let capturedResponseHeaders: Record = {};
+
+ // 监听 API 请求——在请求发送前捕获
+ page.on('request', request => {
+ if (request.url().includes('/api/auth/login') && request.method() === 'POST') {
+ capturedRequestUrl = request.url();
+ capturedRequestHeaders = request.headers() as Record;
+ // 对于 POST 请求,Playwright 无法直接获取 body,通过 route 拦截
+ }
+ });
+
+ // 使用 route 拦截请求体
+ await page.route('**/api/auth/login', async (route) => {
+ const postData = route.request().postData();
+ if (postData) {
+ capturedRequestBody = postData;
+ }
+
+ // 继续请求,但拦截响应
+ const response = await route.fetch();
+ capturedResponseStatus = response.status();
+ capturedResponseHeaders = response.headers() as Record;
+ capturedResponseBody = await response.text();
+
+ // 继续原始响应
+ await route.fulfill({ response });
+ });
+
+ // 导航到登录页
+ await page.goto('/login');
+ await page.waitForLoadState('networkidle');
+
+ // 填入登录信息
+ await page.fill('input[type="text"], input[placeholder*="用户名"]', 'admin');
+ await page.fill('input[type="password"], input[placeholder*="密码"]', 'Test@123');
+
+ // 点击登录
+ await page.click('button:has-text("登录")');
+
+ // 等待登录完成
+ await page.waitForURL(/.*dashboard/, { timeout: 30000 });
+
+ // ========== 验证请求体加密 ==========
+ await test.step('验证请求体已加密(非明文JSON)', async () => {
+ expect(capturedRequestBody).toBeTruthy();
+
+ // 请求体不应是明文 JSON(不应包含 username 字段)
+ expect(capturedRequestBody).not.toContain('"username"');
+ expect(capturedRequestBody).not.toContain('"admin"');
+ expect(capturedRequestBody).not.toContain('"password"');
+
+ // 请求体应为 Base64 编码(AES-GCM 密文)
+ expect(capturedRequestBody).toMatch(/^[A-Za-z0-9+/=]+$/);
+ });
+
+ await test.step('验证 X-Encrypted 请求头存在', async () => {
+ expect(capturedRequestHeaders['x-encrypted']?.toLowerCase()).toBe('true');
+ });
+
+ // ========== 验证响应体加密 ==========
+ await test.step('验证响应体已加密(非明文JSON)', async () => {
+ expect(capturedResponseBody).toBeTruthy();
+ expect(capturedResponseStatus).toBe(200);
+
+ // 响应体不应是明文 JSON(不应包含 token 字段)
+ expect(capturedResponseBody).not.toContain('"token"');
+
+ // 响应体应为 Base64 编码
+ expect(capturedResponseBody).toMatch(/^[A-Za-z0-9+/=]+$/);
+ });
+
+ await test.step('验证 X-Encrypted 响应头存在', async () => {
+ expect(capturedResponseHeaders['x-encrypted']?.toLowerCase()).toBe('true');
+ });
+
+ // ========== 验证业务正常 ==========
+ await test.step('验证登录成功——页面跳转到 dashboard', async () => {
+ await expect(page).toHaveURL(/.*dashboard/);
+ });
+ });
+
+ test('GET 请求的响应体也应被加密', async ({ page }) => {
+ // 先登录
+ await page.goto('/login');
+ await page.waitForLoadState('networkidle');
+ await page.fill('input[type="text"], input[placeholder*="用户名"]', 'admin');
+ await page.fill('input[type="password"], input[placeholder*="密码"]', 'Test@123');
+ await page.click('button:has-text("登录")');
+ await page.waitForURL(/.*dashboard/, { timeout: 30000 });
+
+ // 捕获 GET 请求
+ let capturedGetBody: string | null = null;
+ let capturedGetEncrypted: string | null = null;
+
+ await page.route('**/api/users**', async (route) => {
+ // 仅拦截 GET 请求
+ if (route.request().method() === 'GET') {
+ const response = await route.fetch();
+ capturedGetEncrypted = response.headers()['x-encrypted'] ?? null;
+ capturedGetBody = await response.text();
+ await route.fulfill({ response });
+ } else {
+ await route.continue();
+ }
+ });
+
+ // 导航到用户管理页面触发 GET 请求
+ await page.goto('/users');
+ await page.waitForLoadState('networkidle');
+
+ await test.step('验证 GET 响应已加密', async () => {
+ // 注意:如果用户无权限访问 /users,可能没有加密响应
+ // 这里只验证如果响应存在,且标记了加密,则响应体应为 Base64
+ if (capturedGetEncrypted) {
+ expect(capturedGetEncrypted).toBe('true');
+ expect(capturedGetBody).toMatch(/^[A-Za-z0-9+/=]+$/);
+ expect(capturedGetBody).not.toContain('"id"');
+ }
+ });
+ });
+});
\ No newline at end of file
diff --git a/gym-manage-web/src/__tests__/utils/crypto.test.ts b/gym-manage-web/src/__tests__/utils/crypto.test.ts
new file mode 100644
index 0000000..b8f3407
--- /dev/null
+++ b/gym-manage-web/src/__tests__/utils/crypto.test.ts
@@ -0,0 +1,90 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+
+// 模拟 VITE_ENCRYPTION_SECRET 环境变量
+beforeEach(() => {
+ vi.stubEnv('VITE_ENCRYPTION_SECRET', 'TestEncryptionKey2026!')
+})
+
+afterEach(() => {
+ vi.unstubAllEnvs()
+})
+
+describe('crypto utils', () => {
+ describe('encrypt and decrypt', () => {
+ it('should encrypt and decrypt a string', async () => {
+ const { encrypt, decrypt } = await import('@/utils/crypto')
+ const plaintext = 'Hello, Crypto!'
+ const encrypted = await encrypt(plaintext)
+ const decrypted = await decrypt(encrypted)
+
+ expect(encrypted).not.toBe(plaintext)
+ expect(decrypted).toBe(plaintext)
+ })
+
+ it('should encrypt and decrypt JSON string', async () => {
+ const { encrypt, decrypt } = await import('@/utils/crypto')
+ const json = JSON.stringify({ username: 'admin', password: 'secret123' })
+ const encrypted = await encrypt(json)
+ const decrypted = await decrypt(encrypted)
+
+ expect(encrypted).not.toBe(json)
+ expect(decrypted).toBe(json)
+ expect(() => JSON.parse(decrypted)).not.toThrow()
+ })
+
+ it('should encrypt and decrypt empty string', async () => {
+ const { encrypt, decrypt } = await import('@/utils/crypto')
+ const encrypted = await encrypt('')
+ const decrypted = await decrypt(encrypted)
+
+ expect(encrypted).toBeTruthy()
+ expect(decrypted).toBe('')
+ })
+
+ it('should produce different ciphertext each time for same input', async () => {
+ const { encrypt } = await import('@/utils/crypto')
+ const plaintext = 'same text'
+ const encrypted1 = await encrypt(plaintext)
+ const encrypted2 = await encrypt(plaintext)
+
+ expect(encrypted1).not.toBe(encrypted2)
+ })
+
+ it('should handle Chinese characters', async () => {
+ const { encrypt, decrypt } = await import('@/utils/crypto')
+ const chinese = '你好,世界!加密测试'
+ const encrypted = await encrypt(chinese)
+ const decrypted = await decrypt(encrypted)
+
+ expect(decrypted).toBe(chinese)
+ })
+
+ it('should produce Base64-like output', async () => {
+ const { encrypt } = await import('@/utils/crypto')
+ const encrypted = await encrypt('test')
+
+ // Base64 pattern
+ expect(encrypted).toMatch(/^[A-Za-z0-9+/=]+$/)
+ })
+ })
+
+ describe('frontend-backend compatibility', () => {
+ it('should produce output in format compatible with backend CryptoService', async () => {
+ const { encrypt } = await import('@/utils/crypto')
+ const encrypted = await encrypt('{"test":"data"}')
+
+ // 前端加密格式: Base64(12-byte-IV || AES-GCM-ciphertext)
+ // Base64 解码后长度应 >= 12 (IV)
+ const decoded = atob(encrypted)
+ expect(decoded.length).toBeGreaterThanOrEqual(12)
+ })
+ })
+
+ describe('error handling', () => {
+ it('should throw on invalid Base64 input during decrypt', async () => {
+ const { decrypt } = await import('@/utils/crypto')
+
+ await expect(decrypt('invalid-base64!!!')).rejects.toThrow()
+ })
+ })
+})
\ No newline at end of file
diff --git a/gym-manage-web/src/utils/crypto.ts b/gym-manage-web/src/utils/crypto.ts
new file mode 100644
index 0000000..36f1038
--- /dev/null
+++ b/gym-manage-web/src/utils/crypto.ts
@@ -0,0 +1,110 @@
+/**
+ * AES-256-GCM 加解密工具
+ *
+ * 所有加解密操作(API 请求/响应)均使用 PBKDF2-HMAC-SHA256
+ * 从 VITE_ENCRYPTION_SECRET 派生 AES-256-GCM 密钥,与后端 CryptoService 保持一致。
+ *
+ * 输出格式: Base64( 12-byte-IV || AES-GCM-ciphertext )
+ */
+
+const IV_LENGTH = 12
+const ALGORITHM = 'AES-GCM'
+const PBKDF2_ITERATIONS = 100_000
+const PBKDF2_SALT = new TextEncoder().encode('novalon-gym-manage-aes-salt-v1')
+
+/**
+ * 使用 PBKDF2 从密码短语派生 AES-256 密钥
+ */
+async function deriveKeyFromPassphrase(passphrase: string): Promise {
+ const encoder = new TextEncoder()
+ const keyMaterial = await crypto.subtle.importKey(
+ 'raw',
+ encoder.encode(passphrase),
+ 'PBKDF2',
+ false,
+ ['deriveBits', 'deriveKey']
+ )
+ return crypto.subtle.deriveKey(
+ { name: 'PBKDF2', salt: PBKDF2_SALT, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
+ keyMaterial,
+ { name: ALGORITHM, length: 256 },
+ false,
+ ['encrypt', 'decrypt']
+ )
+}
+
+/**
+ * 获取 AES-256-GCM 密钥——始终使用 PBKDF2 从 VITE_ENCRYPTION_SECRET 派生。
+ */
+async function getApiKey(): Promise {
+ const secret = import.meta.env.VITE_ENCRYPTION_SECRET as string | undefined
+ if (!secret) {
+ throw new Error('未设置 VITE_ENCRYPTION_SECRET 环境变量,加解密无法初始化')
+ }
+ return deriveKeyFromPassphrase(secret)
+}
+
+let cachedApiKey: Promise | null = null
+
+function getOrInitApiKey(): Promise {
+ if (!cachedApiKey) {
+ cachedApiKey = getApiKey()
+ }
+ return cachedApiKey
+}
+
+/** 加密函数基座 */
+async function encryptWithKey(key: Promise, plaintext: string): Promise {
+ const k = await key
+ const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH))
+ const encoder = new TextEncoder()
+ const ciphertext = await crypto.subtle.encrypt(
+ { name: ALGORITHM, iv, tagLength: 128 },
+ k,
+ encoder.encode(plaintext)
+ )
+
+ const combined = new Uint8Array(IV_LENGTH + ciphertext.byteLength)
+ combined.set(iv, 0)
+ combined.set(new Uint8Array(ciphertext), IV_LENGTH)
+
+ return btoa(Array.from(combined, (b) => String.fromCodePoint(b)).join(''))
+}
+
+/** 解密函数基座 */
+async function decryptWithKey(key: Promise, encryptedBase64: string): Promise {
+ const k = await key
+
+ const binaryStr = atob(encryptedBase64)
+ const combined = new Uint8Array(binaryStr.length)
+ for (let i = 0; i < binaryStr.length; i++) {
+ combined[i] = binaryStr.codePointAt(i)!
+ }
+
+ const iv = combined.slice(0, IV_LENGTH)
+ const ciphertext = combined.slice(IV_LENGTH)
+
+ const decrypted = await crypto.subtle.decrypt(
+ { name: ALGORITHM, iv, tagLength: 128 },
+ k,
+ ciphertext
+ )
+
+ return new TextDecoder().decode(decrypted)
+}
+
+/**
+ * API 请求加密——使用 PBKDF2 派生密钥,与后端 CryptoService 保持一致。
+ */
+export async function encrypt(plaintext: string): Promise {
+ return encryptWithKey(getOrInitApiKey(), plaintext)
+}
+
+/**
+ * API 响应解密——使用 PBKDF2 派生密钥,与后端 CryptoService 保持一致。
+ */
+export async function decrypt(encryptedBase64: string): Promise {
+ return decryptWithKey(getOrInitApiKey(), encryptedBase64)
+}
+
+export default { encrypt, decrypt }
\ No newline at end of file
diff --git a/gym-manage-web/src/utils/request.ts b/gym-manage-web/src/utils/request.ts
index 9d14ac4..a6ec15b 100644
--- a/gym-manage-web/src/utils/request.ts
+++ b/gym-manage-web/src/utils/request.ts
@@ -1,6 +1,7 @@
-import axios, { InternalAxiosRequestConfig } from 'axios'
+import axios, { InternalAxiosRequestConfig, AxiosResponse } from 'axios'
import { generateSignatureHeaders } from './signature'
import { checkApiPermission } from './permission'
+import { encrypt, decrypt } from './crypto'
const request = axios.create({
baseURL: '/api',
@@ -8,17 +9,17 @@ const request = axios.create({
})
request.interceptors.request.use(
- (config: InternalAxiosRequestConfig) => {
+ async (config: InternalAxiosRequestConfig) => {
const token = localStorage.getItem('token')
if (token) {
config.headers = config.headers || {}
config.headers.Authorization = `Bearer ${token}`
}
-
+
const method = config.method?.toUpperCase() || 'GET'
let url = config.url || ''
const body = config.data
-
+
if (config.params && Object.keys(config.params).length > 0) {
const queryParams = new URLSearchParams()
Object.entries(config.params).forEach(([key, value]) => {
@@ -31,13 +32,13 @@ request.interceptors.request.use(
url += (url.includes('?') ? '&' : '?') + queryString
}
}
-
+
const fullPath = `/api${url.startsWith('/') ? url : '/' + url}`
const signatureHeaders = generateSignatureHeaders(method, fullPath, body)
-
+
config.headers = config.headers || {}
Object.assign(config.headers, signatureHeaders)
-
+
if (!checkApiPermission(method, url)) {
const error = new Error('无权限访问此接口')
;(error as any).response = {
@@ -46,15 +47,66 @@ request.interceptors.request.use(
}
return Promise.reject(error)
}
-
+
+ // ========== 请求体加密 ==========
+ // 阻止 Axios 自动解析 JSON —— 加密后的响应体不是合法 JSON
+ config.responseType = 'text'
+
+ if (config.data) {
+ try {
+ const jsonStr = JSON.stringify(config.data)
+ config.data = await encrypt(jsonStr)
+ // 加密成功:标记加密请求,后端收到后会加密响应体
+ config.headers['X-Encrypted'] = 'true'
+ // 覆盖 transformRequest 阻止 Axios 对加密后的 Base64 字符串再次 JSON.stringify
+ config.transformRequest = [(data: any) => data]
+ } catch (e: any) {
+ // 加密失败(如密钥未配置),降级为明文 JSON 传输,不标记加密
+ console.warn('[Crypto] Request body encryption failed:', e.message, '— sending plain JSON')
+ // 不设置 X-Encrypted 头,后端将返回明文 JSON
+ // 不覆盖 transformRequest,Axios 将正常 JSON.stringify
+ }
+ } else {
+ // GET 等无 body 请求:仅标记加密让后端加密响应
+ config.headers['X-Encrypted'] = 'true'
+ }
+
return config
},
(error) => Promise.reject(error)
)
request.interceptors.response.use(
- (response) => response.data,
- (error) => {
+ async (response: AxiosResponse) => {
+ // 解密加密的响应体
+ if (response.headers['x-encrypted'] === 'true' && typeof response.data === 'string') {
+ try {
+ const decryptedText = await decrypt(response.data)
+ response.data = JSON.parse(decryptedText)
+ } catch (e: any) {
+ console.error('[Crypto] Response decrypt failed:', e.message)
+ }
+ } else if (typeof response.data === 'string') {
+ // 响应未加密(CryptoFilter 因请求体解密失败而跳过加密),手动解析 JSON
+ try {
+ response.data = JSON.parse(response.data)
+ } catch (e: any) {
+ // 非 JSON 文本响应(如导出文件),保持原样
+ }
+ }
+ return response.data
+ },
+ async (error) => {
+ // 解密加密的错误响应体,使业务代码能读取 message 字段
+ if (error.response?.headers['x-encrypted'] === 'true' && typeof error.response.data === 'string') {
+ try {
+ const decryptedText = await decrypt(error.response.data)
+ error.response.data = JSON.parse(decryptedText)
+ } catch (e: any) {
+ console.error('[Crypto] Error response decrypt failed:', e.message)
+ }
+ }
+
if (error.response?.status === 401) {
localStorage.removeItem('token')
if (!window.location.pathname.includes('/login')) {
diff --git a/gym-manage-web/src/vite-env.d.ts b/gym-manage-web/src/vite-env.d.ts
index 9a6804d..0119d0d 100644
--- a/gym-manage-web/src/vite-env.d.ts
+++ b/gym-manage-web/src/vite-env.d.ts
@@ -2,6 +2,7 @@
interface ImportMetaEnv {
readonly VITE_SIGNATURE_SECRET: string
+ readonly VITE_ENCRYPTION_SECRET: string
readonly VITE_API_BASE_URL?: string
}