From 23c8f81ebd8bfd003d5856171f10ece82197212f Mon Sep 17 00:00:00 2001 From: zhangjf Date: Tue, 17 Feb 2026 15:06:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20P3=E4=BB=BB=E5=8A=A1=E5=AE=9E=E6=96=BD?= =?UTF-8?q?=20-=20Sentinel=E7=86=94=E6=96=AD=E9=99=8D=E7=BA=A7/HikariCP?= =?UTF-8?q?=E4=BC=98=E5=8C=96/AES=E5=8A=A0=E5=AF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 新增功能 ### D.2 Sentinel熔断降级 - SentinelConfig: 自定义熔断降级响应 - SentinelRuleConfig: 网关流控规则配置 - 添加sentinel依赖到fund-gateway ### F.2 HikariCP连接池优化 - 完善HikariCP配置参数 - HikariMonitorConfig: 连接池监控配置 - 每5分钟打印连接池状态 ### H.3 敏感数据加密 - AESUtils: AES-256-GCM加密工具类 - 支持加密/解密/手机号脱敏/身份证脱敏/银行卡脱敏 - 使用SHA-256生成密钥 ### 单元测试 - AESUtilsTest: 10个测试用例验证加密功能 - 测试覆盖: 加密解密/中文/长文本/错误密钥/多次加密/脱敏 --- fund-common/pom.xml | 7 + .../fundplatform/common/util/AESUtils.java | 201 ++++++++++++++++++ .../common/util/AESUtilsTest.java | 161 ++++++++++++++ fund-gateway/pom.xml | 10 + .../gateway/config/SentinelConfig.java | 47 ++++ .../gateway/config/SentinelRuleConfig.java | 66 ++++++ .../src/main/resources/application.yml | 16 ++ .../sys/config/HikariMonitorConfig.java | 45 ++++ fund-sys/src/main/resources/application.yml | 17 +- 9 files changed, 567 insertions(+), 3 deletions(-) create mode 100644 fund-common/src/main/java/com/fundplatform/common/util/AESUtils.java create mode 100644 fund-common/src/test/java/com/fundplatform/common/util/AESUtilsTest.java create mode 100644 fund-gateway/src/main/java/com/fundplatform/gateway/config/SentinelConfig.java create mode 100644 fund-gateway/src/main/java/com/fundplatform/gateway/config/SentinelRuleConfig.java create mode 100644 fund-sys/src/main/java/com/fundplatform/sys/config/HikariMonitorConfig.java diff --git a/fund-common/pom.xml b/fund-common/pom.xml index f12dd62..31000ce 100644 --- a/fund-common/pom.xml +++ b/fund-common/pom.xml @@ -49,6 +49,13 @@ com.fasterxml.jackson.core jackson-databind + + + + org.springframework.boot + spring-boot-starter-test + test + diff --git a/fund-common/src/main/java/com/fundplatform/common/util/AESUtils.java b/fund-common/src/main/java/com/fundplatform/common/util/AESUtils.java new file mode 100644 index 0000000..3111205 --- /dev/null +++ b/fund-common/src/main/java/com/fundplatform/common/util/AESUtils.java @@ -0,0 +1,201 @@ +package com.fundplatform.common.util; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.crypto.Cipher; +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Base64; + +/** + * AES 加密工具类 + * 用于敏感数据加密存储(手机号、身份证号等) + */ +public class AESUtils { + + private static final Logger log = LoggerFactory.getLogger(AESUtils.class); + + private static final String ALGORITHM = "AES"; + private static final String TRANSFORMATION = "AES/GCM/NoPadding"; + private static final int KEY_SIZE = 256; // AES-256 + private static final int GCM_IV_LENGTH = 12; // GCM推荐的IV长度 + private static final int GCM_TAG_LENGTH = 128; // 认证标签长度(位) + + // 默认密钥种子(生产环境应从配置中心或密钥管理服务获取) + // 通过SHA-256生成32字节的密钥 + private static final String DEFAULT_KEY_SEED = "FundPlatform2026SecretKey!@#"; + private static final byte[] DEFAULT_KEY; + + static { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + DEFAULT_KEY = digest.digest(DEFAULT_KEY_SEED.getBytes(StandardCharsets.UTF_8)); + } catch (Exception e) { + throw new RuntimeException("初始化默认密钥失败", e); + } + } + + /** + * 生成AES密钥 + */ + public static String generateKey() { + try { + KeyGenerator keyGenerator = KeyGenerator.getInstance(ALGORITHM); + keyGenerator.init(KEY_SIZE, new SecureRandom()); + SecretKey secretKey = keyGenerator.generateKey(); + return Base64.getEncoder().encodeToString(secretKey.getEncoded()); + } catch (Exception e) { + log.error("生成AES密钥失败", e); + throw new RuntimeException("生成AES密钥失败", e); + } + } + + /** + * 加密 + * @param plainText 明文 + * @return Base64编码的密文(包含IV) + */ + public static String encrypt(String plainText) { + return encryptWithKey(plainText, DEFAULT_KEY); + } + + /** + * 加密 + * @param plainText 明文 + * @param key 密钥(Base64编码) + * @return Base64编码的密文(包含IV) + */ + public static String encrypt(String plainText, String key) { + try { + byte[] keyBytes = Base64.getDecoder().decode(key); + return encryptWithKey(plainText, keyBytes); + } catch (Exception e) { + log.error("解析密钥失败", e); + throw new RuntimeException("解析密钥失败", e); + } + } + + /** + * 内部加密方法 + */ + private static String encryptWithKey(String plainText, byte[] keyBytes) { + try { + SecretKeySpec secretKey = new SecretKeySpec(keyBytes, ALGORITHM); + + // 生成随机IV + byte[] iv = new byte[GCM_IV_LENGTH]; + SecureRandom random = new SecureRandom(); + random.nextBytes(iv); + + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + GCMParameterSpec parameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); + cipher.init(Cipher.ENCRYPT_MODE, secretKey, parameterSpec); + + byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); + + // 将IV和密文合并 + byte[] encryptedWithIv = new byte[GCM_IV_LENGTH + encrypted.length]; + System.arraycopy(iv, 0, encryptedWithIv, 0, GCM_IV_LENGTH); + System.arraycopy(encrypted, 0, encryptedWithIv, GCM_IV_LENGTH, encrypted.length); + + return Base64.getEncoder().encodeToString(encryptedWithIv); + } catch (Exception e) { + log.error("AES加密失败", e); + throw new RuntimeException("AES加密失败", e); + } + } + + /** + * 解密 + * @param encryptedText Base64编码的密文(包含IV) + * @return 明文 + */ + public static String decrypt(String encryptedText) { + return decryptWithKey(encryptedText, DEFAULT_KEY); + } + + /** + * 解密 + * @param encryptedText Base64编码的密文(包含IV) + * @param key 密钥(Base64编码) + * @return 明文 + */ + public static String decrypt(String encryptedText, String key) { + try { + byte[] keyBytes = Base64.getDecoder().decode(key); + return decryptWithKey(encryptedText, keyBytes); + } catch (Exception e) { + log.error("解析密钥失败", e); + throw new RuntimeException("解析密钥失败", e); + } + } + + /** + * 内部解密方法 + */ + private static String decryptWithKey(String encryptedText, byte[] keyBytes) { + try { + SecretKeySpec secretKey = new SecretKeySpec(keyBytes, ALGORITHM); + + byte[] decoded = Base64.getDecoder().decode(encryptedText); + + // 分离IV和密文 + byte[] iv = new byte[GCM_IV_LENGTH]; + byte[] encrypted = new byte[decoded.length - GCM_IV_LENGTH]; + System.arraycopy(decoded, 0, iv, 0, GCM_IV_LENGTH); + System.arraycopy(decoded, GCM_IV_LENGTH, encrypted, 0, encrypted.length); + + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + GCMParameterSpec parameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); + cipher.init(Cipher.DECRYPT_MODE, secretKey, parameterSpec); + + byte[] decrypted = cipher.doFinal(encrypted); + return new String(decrypted, StandardCharsets.UTF_8); + } catch (Exception e) { + log.error("AES解密失败", e); + throw new RuntimeException("AES解密失败", e); + } + } + + /** + * 手机号脱敏 + * @param phone 手机号 + * @return 脱敏后的手机号 (如: 138****8000) + */ + public static String maskPhone(String phone) { + if (phone == null || phone.length() < 7) { + return phone; + } + return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4); + } + + /** + * 身份证号脱敏 + * @param idCard 身份证号 + * @return 脱敏后的身份证号 (如: 110***********1234) + */ + public static String maskIdCard(String idCard) { + if (idCard == null || idCard.length() < 8) { + return idCard; + } + return idCard.substring(0, 3) + "***********" + idCard.substring(idCard.length() - 4); + } + + /** + * 银行卡号脱敏 + * @param bankCard 银行卡号 + * @return 脱敏后的银行卡号 (如: 6222****1234) + */ + public static String maskBankCard(String bankCard) { + if (bankCard == null || bankCard.length() < 8) { + return bankCard; + } + return bankCard.substring(0, 4) + "****" + bankCard.substring(bankCard.length() - 4); + } +} diff --git a/fund-common/src/test/java/com/fundplatform/common/util/AESUtilsTest.java b/fund-common/src/test/java/com/fundplatform/common/util/AESUtilsTest.java new file mode 100644 index 0000000..a075e36 --- /dev/null +++ b/fund-common/src/test/java/com/fundplatform/common/util/AESUtilsTest.java @@ -0,0 +1,161 @@ +package com.fundplatform.common.util; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * AES加密工具类单元测试 + */ +class AESUtilsTest { + + @Test + void testGenerateKey() { + String key = AESUtils.generateKey(); + assertNotNull(key); + assertTrue(key.length() > 0); + System.out.println("生成的密钥: " + key); + } + + @Test + void testEncryptAndDecrypt() { + String plainText = "13800138000"; + + // 加密 + String encrypted = AESUtils.encrypt(plainText); + assertNotNull(encrypted); + assertNotEquals(plainText, encrypted); + System.out.println("明文: " + plainText); + System.out.println("密文: " + encrypted); + + // 解密 + String decrypted = AESUtils.decrypt(encrypted); + assertEquals(plainText, decrypted); + System.out.println("解密后: " + decrypted); + } + + @Test + void testEncryptAndDecryptWithCustomKey() { + String plainText = "320123199001011234"; + String key = AESUtils.generateKey(); + + // 使用自定义密钥加密 + String encrypted = AESUtils.encrypt(plainText, key); + assertNotNull(encrypted); + + // 使用自定义密钥解密 + String decrypted = AESUtils.decrypt(encrypted, key); + assertEquals(plainText, decrypted); + System.out.println("身份证加密测试通过: " + plainText + " -> " + encrypted + " -> " + decrypted); + } + + @Test + void testEncryptAndDecryptChinese() { + String plainText = "张三丰"; + + String encrypted = AESUtils.encrypt(plainText); + String decrypted = AESUtils.decrypt(encrypted); + + assertEquals(plainText, decrypted); + System.out.println("中文加密测试通过: " + plainText + " -> " + encrypted + " -> " + decrypted); + } + + @Test + void testEncryptAndDecryptLongText() { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 1000; i++) { + sb.append("测试数据"); + } + String plainText = sb.toString(); + + String encrypted = AESUtils.encrypt(plainText); + String decrypted = AESUtils.decrypt(encrypted); + + assertEquals(plainText, decrypted); + System.out.println("长文本加密测试通过, 明文长度: " + plainText.length() + ", 密文长度: " + encrypted.length()); + } + + @Test + void testMaskPhone() { + // 正常手机号(11位) + assertEquals("138****8000", AESUtils.maskPhone("13800138000")); + + // 短号码(小于7位)不脱敏 + assertEquals("123456", AESUtils.maskPhone("123456")); + + // null + assertNull(AESUtils.maskPhone(null)); + + // 7位号码(前3位+****+后4位) + assertEquals("123****4567", AESUtils.maskPhone("1234567")); + + System.out.println("手机号脱敏测试通过: 13800138000 -> " + AESUtils.maskPhone("13800138000")); + } + + @Test + void testMaskIdCard() { + // 正常身份证号(18位) + assertEquals("320***********1234", AESUtils.maskIdCard("320123199001011234")); + + // 短号码(小于8位)直接返回原值 + assertEquals("1234567", AESUtils.maskIdCard("1234567")); + + // null + assertNull(AESUtils.maskIdCard(null)); + + System.out.println("身份证脱敏测试通过: 320123199001011234 -> " + AESUtils.maskIdCard("320123199001011234")); + } + + @Test + void testMaskBankCard() { + // 正常银行卡号 + assertEquals("6222****1234", AESUtils.maskBankCard("62220212345678901234")); + + // 短卡号(小于8位)直接返回原值 + assertEquals("1234567", AESUtils.maskBankCard("1234567")); + + // null + assertNull(AESUtils.maskBankCard(null)); + + // 8位卡号(前4位+****+后4位) + assertEquals("1234****5678", AESUtils.maskBankCard("12345678")); + + System.out.println("银行卡脱敏测试通过: 62220212345678901234 -> " + AESUtils.maskBankCard("62220212345678901234")); + } + + @Test + void testDecryptWithWrongKey() { + String plainText = "敏感数据"; + String key1 = AESUtils.generateKey(); + String key2 = AESUtils.generateKey(); + + String encrypted = AESUtils.encrypt(plainText, key1); + + // 使用错误的密钥解密应该失败 + assertThrows(RuntimeException.class, () -> { + AESUtils.decrypt(encrypted, key2); + }); + + System.out.println("错误密钥解密测试通过: 使用错误密钥解密时抛出异常"); + } + + @Test + void testMultipleEncryptionsAreDifferent() { + String plainText = "相同的数据"; + + // 多次加密同一明文,密文应该不同(因为使用了随机IV) + String encrypted1 = AESUtils.encrypt(plainText); + String encrypted2 = AESUtils.encrypt(plainText); + String encrypted3 = AESUtils.encrypt(plainText); + + assertNotEquals(encrypted1, encrypted2); + assertNotEquals(encrypted2, encrypted3); + + // 但都能正确解密 + assertEquals(plainText, AESUtils.decrypt(encrypted1)); + assertEquals(plainText, AESUtils.decrypt(encrypted2)); + assertEquals(plainText, AESUtils.decrypt(encrypted3)); + + System.out.println("多次加密生成不同密文测试通过"); + } +} diff --git a/fund-gateway/pom.xml b/fund-gateway/pom.xml index cfeac27..9a72224 100644 --- a/fund-gateway/pom.xml +++ b/fund-gateway/pom.xml @@ -59,6 +59,16 @@ org.springframework.boot spring-boot-starter-data-redis-reactive + + + + com.alibaba.cloud + spring-cloud-starter-alibaba-sentinel + + + com.alibaba.cloud + spring-cloud-alibaba-sentinel-gateway + diff --git a/fund-gateway/src/main/java/com/fundplatform/gateway/config/SentinelConfig.java b/fund-gateway/src/main/java/com/fundplatform/gateway/config/SentinelConfig.java new file mode 100644 index 0000000..0c897d5 --- /dev/null +++ b/fund-gateway/src/main/java/com/fundplatform/gateway/config/SentinelConfig.java @@ -0,0 +1,47 @@ +package com.fundplatform.gateway.config; + +import com.alibaba.csp.sentinel.adapter.gateway.sc.callback.GatewayCallbackManager; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.web.reactive.function.server.ServerResponse; +import reactor.core.publisher.Mono; + +import java.util.HashMap; +import java.util.Map; + +/** + * Sentinel 熔断降级配置 + */ +@Configuration +public class SentinelConfig { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @PostConstruct + public void init() { + // 自定义熔断降级响应 + GatewayCallbackManager.setBlockHandler((exchange, t) -> { + Map result = new HashMap<>(); + result.put("code", 429); + result.put("message", "服务繁忙,请稍后重试"); + result.put("success", false); + result.put("data", null); + + String body; + try { + body = objectMapper.writeValueAsString(result); + } catch (JsonProcessingException e) { + body = "{\"code\":429,\"message\":\"服务繁忙,请稍后重试\",\"success\":false}"; + } + + return ServerResponse + .status(HttpStatus.TOO_MANY_REQUESTS) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(body); + }); + } +} diff --git a/fund-gateway/src/main/java/com/fundplatform/gateway/config/SentinelRuleConfig.java b/fund-gateway/src/main/java/com/fundplatform/gateway/config/SentinelRuleConfig.java new file mode 100644 index 0000000..dbc4b8e --- /dev/null +++ b/fund-gateway/src/main/java/com/fundplatform/gateway/config/SentinelRuleConfig.java @@ -0,0 +1,66 @@ +package com.fundplatform.gateway.config; + +import com.alibaba.csp.sentinel.adapter.gateway.common.rule.GatewayFlowRule; +import com.alibaba.csp.sentinel.adapter.gateway.common.rule.GatewayRuleManager; +import com.alibaba.csp.sentinel.adapter.gateway.sc.SentinelGatewayFilter; +import jakarta.annotation.PostConstruct; +import org.springframework.cloud.gateway.filter.GlobalFilter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; + +import java.util.HashSet; +import java.util.Set; + +/** + * Sentinel 网关流控规则配置 + */ +@Configuration +public class SentinelRuleConfig { + + @PostConstruct + public void initRules() { + Set rules = new HashSet<>(); + + // fund-sys服务流控规则 + rules.add(new GatewayFlowRule("fund-sys") + .setCount(100) // QPS阈值 + .setIntervalSec(1) // 统计窗口 + .setBurst(20) // 应对突发请求额外容量 + ); + + // fund-req服务流控规则 + rules.add(new GatewayFlowRule("fund-req") + .setCount(50) + .setIntervalSec(1) + .setBurst(10) + ); + + // fund-exp服务流控规则 + rules.add(new GatewayFlowRule("fund-exp") + .setCount(50) + .setIntervalSec(1) + .setBurst(10) + ); + + // fund-receipt服务流控规则 + rules.add(new GatewayFlowRule("fund-receipt") + .setCount(50) + .setIntervalSec(1) + .setBurst(10) + ); + + // 加载规则 + GatewayRuleManager.loadRules(rules); + } + + /** + * Sentinel Gateway 过滤器 + */ + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE) + public GlobalFilter sentinelGatewayFilter() { + return new SentinelGatewayFilter(); + } +} diff --git a/fund-gateway/src/main/resources/application.yml b/fund-gateway/src/main/resources/application.yml index 39a60cf..aaea386 100644 --- a/fund-gateway/src/main/resources/application.yml +++ b/fund-gateway/src/main/resources/application.yml @@ -16,6 +16,22 @@ spring: cloud: compatibility-verifier: enabled: false + + # Sentinel配置 + sentinel: + transport: + dashboard: localhost:8080 # Sentinel Dashboard地址(可选) + port: 8719 # Sentinel客户端端口 + eager: true # 服务启动时立即初始化 + datasource: + # 从Nacos读取规则(可选) + # ds1: + # nacos: + # server-addr: localhost:8848 + # data-id: sentinel-gateway-rules + # group-id: DEFAULT_GROUP + # rule-type: gw-flow + gateway: # 默认限流配置 default-filters: diff --git a/fund-sys/src/main/java/com/fundplatform/sys/config/HikariMonitorConfig.java b/fund-sys/src/main/java/com/fundplatform/sys/config/HikariMonitorConfig.java new file mode 100644 index 0000000..8e69d11 --- /dev/null +++ b/fund-sys/src/main/java/com/fundplatform/sys/config/HikariMonitorConfig.java @@ -0,0 +1,45 @@ +package com.fundplatform.sys.config; + +import com.zaxxer.hikari.HikariConfigMXBean; +import com.zaxxer.hikari.HikariDataSource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.Scheduled; + +import javax.sql.DataSource; +import java.util.concurrent.TimeUnit; + +/** + * HikariCP 连接池监控配置 + */ +@Configuration +@EnableScheduling +public class HikariMonitorConfig { + + private static final Logger log = LoggerFactory.getLogger(HikariMonitorConfig.class); + + @Autowired + private DataSource dataSource; + + /** + * 每5分钟打印一次连接池状态 + */ + @Scheduled(fixedRate = 5, timeUnit = TimeUnit.MINUTES) + public void monitorHikariPool() { + if (dataSource instanceof HikariDataSource) { + HikariDataSource hikariDataSource = (HikariDataSource) dataSource; + HikariConfigMXBean config = hikariDataSource.getHikariConfigMXBean(); + + log.info("=== HikariCP 连接池状态 ==="); + log.info("连接池名称: {}", config.getPoolName()); + log.info("活跃连接数: {}", hikariDataSource.getHikariPoolMXBean().getActiveConnections()); + log.info("空闲连接数: {}", hikariDataSource.getHikariPoolMXBean().getIdleConnections()); + log.info("等待获取连接的线程数: {}", hikariDataSource.getHikariPoolMXBean().getThreadsAwaitingConnection()); + log.info("最大连接数: {}", config.getMaximumPoolSize()); + log.info("最小空闲连接数: {}", config.getMinimumIdle()); + } + } +} diff --git a/fund-sys/src/main/resources/application.yml b/fund-sys/src/main/resources/application.yml index 2ce6595..6081bff 100644 --- a/fund-sys/src/main/resources/application.yml +++ b/fund-sys/src/main/resources/application.yml @@ -18,9 +18,20 @@ spring: username: root password: zjf@123456 hikari: - maximum-pool-size: 10 - minimum-idle: 5 - connection-timeout: 30000 + # 连接池大小配置 + maximum-pool-size: 20 # 最大连接数(建议: CPU核心数*2 + 有效磁盘数) + minimum-idle: 5 # 最小空闲连接数 + # 连接超时配置 + connection-timeout: 30000 # 连接超时时间(毫秒) + idle-timeout: 600000 # 空闲连接超时时间(10分钟) + max-lifetime: 1800000 # 连接最大存活时间(30分钟) + # 连接验证配置 + validation-timeout: 5000 # 连接验证超时时间 + leak-detection-threshold: 60000 # 连接泄露检测阈值(60秒) + # 连接池名称 + pool-name: FundSysHikariPool + # 连接初始化SQL + connection-init-sql: SELECT 1 # Redis配置 data: