feat: P3任务实施 - Sentinel熔断降级/HikariCP优化/AES加密

## 新增功能

### D.2 Sentinel熔断降级
- SentinelConfig: 自定义熔断降级响应
- SentinelRuleConfig: 网关流控规则配置
- 添加sentinel依赖到fund-gateway

### F.2 HikariCP连接池优化
- 完善HikariCP配置参数
- HikariMonitorConfig: 连接池监控配置
- 每5分钟打印连接池状态

### H.3 敏感数据加密
- AESUtils: AES-256-GCM加密工具类
- 支持加密/解密/手机号脱敏/身份证脱敏/银行卡脱敏
- 使用SHA-256生成密钥

### 单元测试
- AESUtilsTest: 10个测试用例验证加密功能
- 测试覆盖: 加密解密/中文/长文本/错误密钥/多次加密/脱敏
This commit is contained in:
zhangjf 2026-02-17 15:06:22 +08:00
parent 5d4cdd5c33
commit 23c8f81ebd
9 changed files with 567 additions and 3 deletions

View File

@ -49,6 +49,13 @@
<groupId>com.fasterxml.jackson.core</groupId> <groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId> <artifactId>jackson-databind</artifactId>
</dependency> </dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
</project> </project>

View File

@ -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);
}
}

View File

@ -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("多次加密生成不同密文测试通过");
}
}

View File

@ -59,6 +59,16 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis-reactive</artifactId> <artifactId>spring-boot-starter-data-redis-reactive</artifactId>
</dependency> </dependency>
<!-- Sentinel 熔断降级 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-sentinel-gateway</artifactId>
</dependency>
</dependencies> </dependencies>
<build> <build>

View File

@ -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<String, Object> 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);
});
}
}

View File

@ -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<GatewayFlowRule> 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();
}
}

View File

@ -16,6 +16,22 @@ spring:
cloud: cloud:
compatibility-verifier: compatibility-verifier:
enabled: false 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: gateway:
# 默认限流配置 # 默认限流配置
default-filters: default-filters:

View File

@ -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());
}
}
}

View File

@ -18,9 +18,20 @@ spring:
username: root username: root
password: zjf@123456 password: zjf@123456
hikari: hikari:
maximum-pool-size: 10 # 连接池大小配置
minimum-idle: 5 maximum-pool-size: 20 # 最大连接数(建议: CPU核心数*2 + 有效磁盘数)
connection-timeout: 30000 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配置 # Redis配置
data: data: