From 477f41ca07a3ca838042c87e1d838a9fce351579 Mon Sep 17 00:00:00 2001 From: DaxPay Dev Date: Thu, 16 Jul 2026 08:46:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(cache):=20=E6=95=8F=E6=84=9F=E7=BC=93?= =?UTF-8?q?=E5=AD=98=20L2=20=E6=95=B4=E5=8C=85=20AES-GCM=20=E5=8A=A0?= =?UTF-8?q?=E5=AF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 SecureAesGcmEncryptor 下沉至 common-config 并注册唯一 Bean,与 DB 字段加密共用。secure: 前缀的 cacheName 在 Redis 中整包加密 value;加密未启用时仅 L1 不写 Redis。 --- .../capability-cache/README.md | 60 ++++++++++++ .../capability-cache/pom.xml | 5 + .../cache/CacheAutoConfiguration.java | 4 +- .../configuration/CachingConfiguration.java | 69 +++++++++++-- .../cache/core/DaxpayRedisCacheManager.java | 61 +++++++----- .../cache/core/MultiLevelCache.java | 44 ++++++++- .../cache/core/MultiLevelCacheManager.java | 17 +++- .../secure/EncryptingRedisSerializer.java | 77 +++++++++++++++ .../cache/secure/SecureCacheNameMatcher.java | 46 +++++++++ .../secure/EncryptingRedisSerializerTest.java | 98 +++++++++++++++++++ .../secure/SecureCacheNameMatcherTest.java | 47 +++++++++ .../common-config/pom.xml | 10 ++ .../config/ConfigAutoConfiguration.java | 3 + .../encrypt/EncryptorConfiguration.java | 35 +++++++ .../encrypt/SecureAesGcmEncryptor.java | 31 +++--- .../properties/PlatformCommonProperties.java | 7 ++ .../encrypt/SecureAesGcmEncryptorTest.java | 2 +- .../MybatisPlusCommonAutoConfiguration.java | 4 +- .../encrypt/DataEncryptTypeHandler.java | 2 + .../DataEncryptTypeHandlerConfiguration.java | 15 +-- 20 files changed, 569 insertions(+), 68 deletions(-) create mode 100644 daxpay-platform/daxpay-platform-capability/capability-cache/src/main/java/cn/daxpay/open/platform/capability/cache/secure/EncryptingRedisSerializer.java create mode 100644 daxpay-platform/daxpay-platform-capability/capability-cache/src/main/java/cn/daxpay/open/platform/capability/cache/secure/SecureCacheNameMatcher.java create mode 100644 daxpay-platform/daxpay-platform-capability/capability-cache/src/test/java/cn/daxpay/open/platform/capability/cache/secure/EncryptingRedisSerializerTest.java create mode 100644 daxpay-platform/daxpay-platform-capability/capability-cache/src/test/java/cn/daxpay/open/platform/capability/cache/secure/SecureCacheNameMatcherTest.java create mode 100644 daxpay-platform/daxpay-platform-common/common-config/src/main/java/cn/daxpay/open/platform/common/config/encrypt/EncryptorConfiguration.java rename daxpay-platform/daxpay-platform-common/{common-mybatis-plus/src/main/java/cn/daxpay/open/platform/common/mybatisplus/handler => common-config/src/main/java/cn/daxpay/open/platform/common/config}/encrypt/SecureAesGcmEncryptor.java (95%) rename daxpay-platform/daxpay-platform-common/{common-mybatis-plus/src/test/java/cn/daxpay/open/platform/common/mybatisplus/handler => common-config/src/test/java/cn/daxpay/open/platform/common/config}/encrypt/SecureAesGcmEncryptorTest.java (98%) diff --git a/daxpay-platform/daxpay-platform-capability/capability-cache/README.md b/daxpay-platform/daxpay-platform-capability/capability-cache/README.md index 90bc89144..4b561b778 100644 --- a/daxpay-platform/daxpay-platform-capability/capability-cache/README.md +++ b/daxpay-platform/daxpay-platform-capability/capability-cache/README.md @@ -39,6 +39,10 @@ daxpay: maximum-size: 10000 # L1 本地缓存最大容量 l2: default-ttl: 1800 # L2 Redis 缓存默认过期时间(秒) + secure-prefix: "secure:" # 敏感缓存名前缀,匹配的 L2 value 整包 AES-GCM 加密 + # secure-names: [] # 额外精确敏感 cacheName 列表(可选) + # 敏感 L2 加密复用 DB 字段加密密钥(全进程同一 SecureAesGcmEncryptor) + # platform.config.encrypt.enable / keys ``` ## 架构设计 @@ -178,6 +182,8 @@ public void clearUserCache() { @Cacheable(value = "merchant:config", key = "#mchNo") ``` +**含密钥/证书等敏感数据的缓存名必须以 `secure:` 开头**(见下文「敏感缓存」)。 + ### 7. 如何处理缓存穿透? 当前实现不缓存 null 值(`disableCachingNullValues()`),如需防止缓存穿透: @@ -208,3 +214,57 @@ public void printCacheStats(String cacheName) { log.info("命中次数: {}, 未命中次数: {}", stats.hitCount(), stats.missCount()); } ``` + +## 敏感缓存(secure:) + +通道密钥、商户对接密钥等敏感对象若使用 Spring Cache,**必须**使用 `secure:` 前缀的 cacheName,基础设施会自动对 L2 Redis value 做**整包 AES-GCM 加密**。 + +### 与 DB 字段加密的区别 + +| 层 | 粒度 | 说明 | +|----|------|------| +| DB(DataEncryptTypeHandler) | **字段级** | 仅 `privateKey` / `apiKey` 等列密文,其它列明文 | +| Redis L2(secure:) | **整包** | 整个缓存对象 JSON 一次加密,Redis 中不可见任何字段明文 | +| L1 本地 | 明文对象 | 进程内,与普通缓存一致 | + +密钥管理复用 `daxpay.platform.config.encrypt`(与 DB 同一 `SecureAesGcmEncryptor` Bean)。 + +### 业务写法(与普通缓存相同) + +```java +// 读:默认 CacheManager,仅 cacheName 用 secure: 前缀 +@Cacheable(value = "secure:channel-key:adapay-direct", + key = "#channelMchNo + ':' + #sandbox") +public AdapayDirectKeyConfig getForPay(String channelMchNo, boolean sandbox) { + return manager.find(...).orElseThrow(...); +} + +// 写:必须同名同 key 失效 +@CacheEvict(value = "secure:channel-key:adapay-direct", + key = "#param.channelMchNo + ':' + #param.sandbox") +public void saveConfig(AdapayDirectKeyConfigParam param) { + // ... +} +``` + +命名建议: + +``` +secure:channel-key:wechat-isv +secure:channel-key:adapay-direct +secure:merchant-credential +secure:platform-encrypt-config +``` + +### 数据加密未启用时 + +若 `daxpay.platform.config.encrypt.enable=false`: + +- `secure:*` **禁止写 Redis**(L1-only 降级),避免明文密钥落盘 +- 启动日志会 warn 提示 + +### 禁止事项 + +- 禁止对含密钥的实体使用非 `secure:` 的 cacheName(会明文进 Redis) +- 禁止缓存完整 `*SdkCredential` 到普通 cacheName +- 不需要也不应指定第二个 `cacheManager`:统一用默认二级缓存即可 diff --git a/daxpay-platform/daxpay-platform-capability/capability-cache/pom.xml b/daxpay-platform/daxpay-platform-capability/capability-cache/pom.xml index 8d932f77a..4ca9fcb48 100644 --- a/daxpay-platform/daxpay-platform-capability/capability-cache/pom.xml +++ b/daxpay-platform/daxpay-platform-capability/capability-cache/pom.xml @@ -45,5 +45,10 @@ common-artemis ${project.version} + + org.junit.jupiter + junit-jupiter + test + diff --git a/daxpay-platform/daxpay-platform-capability/capability-cache/src/main/java/cn/daxpay/open/platform/capability/cache/CacheAutoConfiguration.java b/daxpay-platform/daxpay-platform-capability/capability-cache/src/main/java/cn/daxpay/open/platform/capability/cache/CacheAutoConfiguration.java index b088c2b33..f1a46ef55 100644 --- a/daxpay-platform/daxpay-platform-capability/capability-cache/src/main/java/cn/daxpay/open/platform/capability/cache/CacheAutoConfiguration.java +++ b/daxpay-platform/daxpay-platform-capability/capability-cache/src/main/java/cn/daxpay/open/platform/capability/cache/CacheAutoConfiguration.java @@ -1,14 +1,16 @@ package cn.daxpay.open.platform.capability.cache; +import cn.daxpay.open.platform.common.config.ConfigAutoConfiguration; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.context.annotation.ComponentScan; /// # 缓存配置 /// +/// 依赖 [ConfigAutoConfiguration] 先注册 SecureAesGcmEncryptor,供敏感缓存 L2 加密使用。 @ComponentScan @ConfigurationPropertiesScan -@AutoConfiguration +@AutoConfiguration(after = ConfigAutoConfiguration.class) public class CacheAutoConfiguration { } diff --git a/daxpay-platform/daxpay-platform-capability/capability-cache/src/main/java/cn/daxpay/open/platform/capability/cache/configuration/CachingConfiguration.java b/daxpay-platform/daxpay-platform-capability/capability-cache/src/main/java/cn/daxpay/open/platform/capability/cache/configuration/CachingConfiguration.java index 9c787f11c..16779523d 100644 --- a/daxpay-platform/daxpay-platform-capability/capability-cache/src/main/java/cn/daxpay/open/platform/capability/cache/configuration/CachingConfiguration.java +++ b/daxpay-platform/daxpay-platform-capability/capability-cache/src/main/java/cn/daxpay/open/platform/capability/cache/configuration/CachingConfiguration.java @@ -4,9 +4,14 @@ import cn.daxpay.open.platform.capability.cache.core.MultiLevelCacheManager; import cn.daxpay.open.platform.capability.cache.core.DaxpayRedisCacheManager; import cn.daxpay.open.platform.capability.cache.core.LocalCacheRegistry; import cn.daxpay.open.platform.capability.cache.notify.publisher.CacheInvalidationPublisher; +import cn.daxpay.open.platform.capability.cache.secure.EncryptingRedisSerializer; +import cn.daxpay.open.platform.capability.cache.secure.SecureCacheNameMatcher; +import cn.daxpay.open.platform.common.config.encrypt.SecureAesGcmEncryptor; import cn.daxpay.open.platform.common.config.properties.PlatformCommonProperties; import cn.daxpay.open.platform.common.redis.serializer.JacksonRedisSerializer; import cn.daxpay.open.platform.common.artemis.service.ArtemisTemplateService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -35,6 +40,8 @@ import java.util.stream.Collectors; /// - L1 本地缓存始终启用,作为性能加速层 /// - L2 Redis 作为共享缓存主层 /// - 缓存失效通知通过 Artemis 广播,始终启用 +/// - 名称匹配 secure: 的缓存:L2 整包 AES-GCM 加密;加密未启用时仅 L1 +@Slf4j @Configuration @EnableCaching @EnableConfigurationProperties(PlatformCommonProperties.class) @@ -65,16 +72,44 @@ public class CachingConfiguration implements CachingConfigurer { }; } + /// 敏感缓存名匹配器 + @Bean + @ConditionalOnMissingBean + public SecureCacheNameMatcher secureCacheNameMatcher() { + var cache = platformCommonProperties.getCache(); + return new SecureCacheNameMatcher(cache.getSecurePrefix(), cache.getSecureNames()); + } + /// Redis 缓存管理器(L2) /// /// 作为二级缓存的 L2 层,负责跨节点数据共享 + /// + /// 普通 cacheName 使用明文 JSON;敏感 cacheName 在 encryptor 可用时使用整包加密序列化。 @Bean @ConditionalOnMissingBean(DaxpayRedisCacheManager.class) - public DaxpayRedisCacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory) { + public DaxpayRedisCacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory, + SecureCacheNameMatcher secureCacheNameMatcher, + ObjectProvider encryptorProvider) { var l2Config = platformCommonProperties.getCache().getL2(); + Duration ttl = Duration.ofSeconds(l2Config.getDefaultTtl()); + RedisCacheConfiguration plainConfig = this.plainValueConfig(ttl); + + SecureAesGcmEncryptor encryptor = encryptorProvider.getIfAvailable(); + RedisCacheConfiguration secureConfig = null; + if (encryptor != null) { + EncryptingRedisSerializer encryptingSerializer = new EncryptingRedisSerializer(encryptor); + secureConfig = this.secureValueConfig(ttl, encryptingSerializer); + log.info("敏感缓存 L2 整包加密已启用,前缀: {}", secureCacheNameMatcher.getSecurePrefix()); + } else { + log.warn("未启用数据加密:名称匹配 {} 的敏感缓存将仅使用 L1,不写 Redis", + secureCacheNameMatcher.getSecurePrefix()); + } + return new DaxpayRedisCacheManager( RedisCacheWriter.nonLockingRedisCacheWriter(redisConnectionFactory), - this.getRedisCacheConfigurationWithTtl(Duration.ofSeconds(l2Config.getDefaultTtl()))); + plainConfig, + secureConfig, + secureCacheNameMatcher); } /// 本地缓存注册表 @@ -106,16 +141,21 @@ public class CachingConfiguration implements CachingConfigurer { @Primary public CacheManager cacheManager(DaxpayRedisCacheManager redisCacheManager, LocalCacheRegistry localCacheRegistry, - CacheInvalidationPublisher publisher) { + CacheInvalidationPublisher publisher, + SecureCacheNameMatcher secureCacheNameMatcher, + ObjectProvider encryptorProvider) { + boolean secureL2Enabled = encryptorProvider.getIfAvailable() != null; return new MultiLevelCacheManager( redisCacheManager, localCacheRegistry, - publisher + publisher, + secureCacheNameMatcher, + secureL2Enabled ); } - /// 缓存管理器策略过期时间配置 - private RedisCacheConfiguration getRedisCacheConfigurationWithTtl(Duration duration) { + /// 缓存管理器策略过期时间配置(普通缓存:明文 JSON value) + private RedisCacheConfiguration plainValueConfig(Duration duration) { // redis缓存配置 return RedisCacheConfiguration.defaultCacheConfig() // 设置key为String @@ -130,5 +170,20 @@ public class CachingConfiguration implements CachingConfigurer { .entryTtl(duration); } -} + /// 缓存管理器策略过期时间配置(敏感缓存:整包 AES-GCM 加密 value) + private RedisCacheConfiguration secureValueConfig(Duration duration, EncryptingRedisSerializer encryptingSerializer) { + // redis缓存配置 + return RedisCacheConfiguration.defaultCacheConfig() + // 设置key为String + .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) + // 设置value 序列化方式为整包加密(JSON + AES-GCM) + .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(encryptingSerializer)) + // 不缓存null + .disableCachingNullValues() + // 覆盖默认的构造key,否则会多出一个冒号 + .computePrefixWith(name -> name + ":") + // 过期时间 + .entryTtl(duration); + } +} diff --git a/daxpay-platform/daxpay-platform-capability/capability-cache/src/main/java/cn/daxpay/open/platform/capability/cache/core/DaxpayRedisCacheManager.java b/daxpay-platform/daxpay-platform-capability/capability-cache/src/main/java/cn/daxpay/open/platform/capability/cache/core/DaxpayRedisCacheManager.java index d76ee7de8..94d72546d 100644 --- a/daxpay-platform/daxpay-platform-capability/capability-cache/src/main/java/cn/daxpay/open/platform/capability/cache/core/DaxpayRedisCacheManager.java +++ b/daxpay-platform/daxpay-platform-capability/capability-cache/src/main/java/cn/daxpay/open/platform/capability/cache/core/DaxpayRedisCacheManager.java @@ -1,52 +1,59 @@ package cn.daxpay.open.platform.capability.cache.core; +import cn.daxpay.open.platform.capability.cache.secure.SecureCacheNameMatcher; import org.springframework.data.redis.cache.RedisCache; import org.springframework.data.redis.cache.RedisCacheConfiguration; import org.springframework.data.redis.cache.RedisCacheManager; import org.springframework.data.redis.cache.RedisCacheWriter; -import java.util.Map; - /// # 自定义 Redis 缓存管理器 /// /// 作为二级缓存的 L2 层,负责跨节点数据共享 +/// +/// 按 cacheName 选择序列化策略: +/// - 敏感名(SecureCacheNameMatcher 命中)且已配置 secureConfig:整包 AES-GCM 加密 +/// - 其它:明文 JSON public class DaxpayRedisCacheManager extends RedisCacheManager { private final RedisCacheWriter cacheWriter; + private final RedisCacheConfiguration defaultConfig; + private final RedisCacheConfiguration secureConfig; + private final SecureCacheNameMatcher secureMatcher; - public DaxpayRedisCacheManager(RedisCacheWriter cacheWriter, RedisCacheConfiguration defaultCacheConfiguration) { + /// @param cacheWriter Redis 写入器 + /// @param defaultCacheConfiguration 普通缓存配置(明文 JSON) + /// @param secureCacheConfiguration 敏感缓存配置(整包加密);encrypt 未启用时可为 null + /// @param secureMatcher 敏感缓存名匹配器 + public DaxpayRedisCacheManager(RedisCacheWriter cacheWriter, + RedisCacheConfiguration defaultCacheConfiguration, + RedisCacheConfiguration secureCacheConfiguration, + SecureCacheNameMatcher secureMatcher) { super(cacheWriter, defaultCacheConfiguration); this.cacheWriter = cacheWriter; - } - - public DaxpayRedisCacheManager(RedisCacheWriter cacheWriter, RedisCacheConfiguration defaultCacheConfiguration, String... initialCacheNames) { - super(cacheWriter, defaultCacheConfiguration, initialCacheNames); - this.cacheWriter = cacheWriter; - } - - public DaxpayRedisCacheManager(RedisCacheWriter cacheWriter, RedisCacheConfiguration defaultCacheConfiguration, - boolean allowInFlightCacheCreation, String... initialCacheNames) { - super(cacheWriter, defaultCacheConfiguration, allowInFlightCacheCreation, initialCacheNames); - this.cacheWriter = cacheWriter; - } - - public DaxpayRedisCacheManager(RedisCacheWriter cacheWriter, RedisCacheConfiguration defaultCacheConfiguration, - Map initialCacheConfigurations) { - super(cacheWriter, defaultCacheConfiguration, initialCacheConfigurations); - this.cacheWriter = cacheWriter; - } - - public DaxpayRedisCacheManager(RedisCacheWriter cacheWriter, RedisCacheConfiguration defaultCacheConfiguration, - Map initialCacheConfigurations, boolean allowInFlightCacheCreation) { - super(cacheWriter, defaultCacheConfiguration, allowInFlightCacheCreation, initialCacheConfigurations); - this.cacheWriter = cacheWriter; + this.defaultConfig = defaultCacheConfiguration; + this.secureConfig = secureCacheConfiguration; + this.secureMatcher = secureMatcher; } /// 创建 Redis 缓存 @Override @SuppressWarnings({ "ConstantConditions", "NullableProblems" }) protected RedisCache createRedisCache(String name, RedisCacheConfiguration cacheConfig) { - return new DaxpayRedisCache(name, this.cacheWriter, cacheConfig); + RedisCacheConfiguration config = this.resolveConfig(name, cacheConfig); + return new DaxpayRedisCache(name, this.cacheWriter, config); } + /// 按 cacheName 解析最终 RedisCacheConfiguration + private RedisCacheConfiguration resolveConfig(String name, RedisCacheConfiguration cacheConfig) { + // 敏感缓存且加密 L2 可用:强制使用 secure 序列化,避免 initialCacheConfigurations 漏配 + if (this.secureMatcher != null + && this.secureMatcher.matches(name) + && this.secureConfig != null) { + return this.secureConfig; + } + if (cacheConfig != null) { + return cacheConfig; + } + return this.defaultConfig; + } } diff --git a/daxpay-platform/daxpay-platform-capability/capability-cache/src/main/java/cn/daxpay/open/platform/capability/cache/core/MultiLevelCache.java b/daxpay-platform/daxpay-platform-capability/capability-cache/src/main/java/cn/daxpay/open/platform/capability/cache/core/MultiLevelCache.java index e5c21150d..f57d212d5 100644 --- a/daxpay-platform/daxpay-platform-capability/capability-cache/src/main/java/cn/daxpay/open/platform/capability/cache/core/MultiLevelCache.java +++ b/daxpay-platform/daxpay-platform-capability/capability-cache/src/main/java/cn/daxpay/open/platform/capability/cache/core/MultiLevelCache.java @@ -14,8 +14,9 @@ import java.util.concurrent.Callable; /// 设计要点: /// - L2 Redis 是共享缓存主层,负责跨节点数据共享 /// - L1 Caffeine 是性能加速层,仅在本节点生效 -/// - 缓存失效通过 RocketMQ 广播通知其他节点删除本地 L1 +/// - 缓存失效通过 Artemis 广播通知其他节点删除本地 L1 /// - 本地缓存 key 必须统一使用字符串形式,保证跨节点广播删除一致性 +/// - 敏感缓存(secure:)在数据加密未启用时仅使用 L1,禁止明文写 Redis @Slf4j public class MultiLevelCache implements Cache { @@ -27,14 +28,27 @@ public class MultiLevelCache implements Cache { private final CacheInvalidationPublisher publisher; + /// 是否为敏感缓存名 + private final boolean secureCache; + + /// 敏感缓存是否允许写/读 L2(依赖数据加密已启用) + private final boolean secureL2Enabled; + public MultiLevelCache(String name, LocalCacheRegistry localCacheRegistry, Cache redisCache, - CacheInvalidationPublisher publisher) { + CacheInvalidationPublisher publisher, + boolean secureCache, + boolean secureL2Enabled) { this.name = name; this.localCache = localCacheRegistry.getOrCreate(name); this.redisCache = redisCache; this.publisher = publisher; + this.secureCache = secureCache; + this.secureL2Enabled = secureL2Enabled; + if (secureCache && !secureL2Enabled) { + log.warn("敏感缓存 [{}] 因未启用数据加密,仅使用 L1 本地缓存,不写 Redis", name); + } } @Override @@ -47,6 +61,11 @@ public class MultiLevelCache implements Cache { return this.localCache; } + /// 是否跳过 L2(敏感且未开加密) + private boolean isL1Only() { + return this.secureCache && !this.secureL2Enabled; + } + /// 读取缓存,优先从 L1 本地缓存读取,未命中则从 L2 Redis 读取并回填 L1 /// /// 读取流程: @@ -54,6 +73,8 @@ public class MultiLevelCache implements Cache { /// - L1 未命中则查 L2 Redis /// - L2 命中则回填 L1 /// - 全未命中返回 null + /// + /// 敏感缓存且未启用加密时跳过 L2,仅查 L1 @Override public ValueWrapper get(Object key) { String localKey = this.toLocalKey(key); @@ -62,6 +83,12 @@ public class MultiLevelCache implements Cache { return () -> localValue; } + // 敏感缓存未开加密:禁止读 Redis,避免历史明文或无法解密的脏数据 + if (this.isL1Only()) { + log.debug("敏感缓存 L1-only 未命中: cacheName={}, key={}", this.name, key); + return null; + } + ValueWrapper redisValue = this.redisCache.get(key); if (redisValue != null) { this.localCache.put(localKey, Objects.requireNonNull(redisValue.get())); @@ -103,6 +130,8 @@ public class MultiLevelCache implements Cache { /// 写入缓存,同时写入 L2 Redis 和 L1 本地缓存 /// /// 注意:写入操作不广播通知其他节点,其他节点在读取时会从 L2 加载最新值 + /// + /// 敏感缓存且未启用加密时仅写 L1,禁止明文写 Redis @Override public void put(Object key, Object value) { if (value == null) { @@ -110,6 +139,11 @@ public class MultiLevelCache implements Cache { return; } String localKey = this.toLocalKey(key); + if (this.isL1Only()) { + this.localCache.put(localKey, value); + log.debug("写入 L1-only 敏感缓存: cacheName={}, key={}", this.name, key); + return; + } this.redisCache.put(key, value); this.localCache.put(localKey, value); log.debug("写入二级缓存: cacheName={}, key={}", this.name, key); @@ -120,11 +154,12 @@ public class MultiLevelCache implements Cache { /// 删除流程: /// - 删除 L2 Redis /// - 删除本机 L1 - /// - 发布 RocketMQ 广播消息 + /// - 发布 Artemis 广播消息 /// - 其他节点收到消息后删除各自 L1 @Override public void evict(Object key) { String localKey = this.toLocalKey(key); + // 仍尝试删 Redis,清理可能存在的历史数据 this.redisCache.evict(key); this.localCache.invalidate(localKey); log.debug("删除二级缓存: cacheName={}, key={}", this.name, key); @@ -144,7 +179,7 @@ public class MultiLevelCache implements Cache { /// /// 为什么必须统一使用字符串 key: /// - 本地缓存原始 key 可能是任意对象类型(Long、String、自定义对象等) - /// - RocketMQ 广播消息中的 key 只能是字符串 + /// - Artemis 广播消息中的 key 只能是字符串 /// - 如果本地缓存使用原始对象作为 key,广播消息使用字符串,会导致跨节点删除失败 /// - 例如:本机 key=Long(1),广播 key="1",远端无法匹配 /// @@ -159,4 +194,3 @@ public class MultiLevelCache implements Cache { return String.valueOf(key); } } - diff --git a/daxpay-platform/daxpay-platform-capability/capability-cache/src/main/java/cn/daxpay/open/platform/capability/cache/core/MultiLevelCacheManager.java b/daxpay-platform/daxpay-platform-capability/capability-cache/src/main/java/cn/daxpay/open/platform/capability/cache/core/MultiLevelCacheManager.java index 871659640..aa2da3a80 100644 --- a/daxpay-platform/daxpay-platform-capability/capability-cache/src/main/java/cn/daxpay/open/platform/capability/cache/core/MultiLevelCacheManager.java +++ b/daxpay-platform/daxpay-platform-capability/capability-cache/src/main/java/cn/daxpay/open/platform/capability/cache/core/MultiLevelCacheManager.java @@ -1,6 +1,7 @@ package cn.daxpay.open.platform.capability.cache.core; import cn.daxpay.open.platform.capability.cache.notify.publisher.CacheInvalidationPublisher; +import cn.daxpay.open.platform.capability.cache.secure.SecureCacheNameMatcher; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.cache.Cache; @@ -18,7 +19,8 @@ import java.util.concurrent.ConcurrentHashMap; /// 设计要点: /// - 作为 Spring 默认 CacheManager,通过 @Primary 标识 /// - L1 本地缓存始终启用,不需要开关控制 -/// - 缓存失效通知通过 RocketMQ 广播,始终启用 +/// - 缓存失效通知通过 Artemis 广播,始终启用 +/// - 敏感 cacheName 结合 secureL2Enabled 决定是否写 Redis @Slf4j @RequiredArgsConstructor public class MultiLevelCacheManager implements CacheManager { @@ -29,6 +31,11 @@ public class MultiLevelCacheManager implements CacheManager { private final CacheInvalidationPublisher publisher; + private final SecureCacheNameMatcher secureMatcher; + + /// 敏感缓存是否允许 L2(数据加密已启用时为 true) + private final boolean secureL2Enabled; + private final Map cacheMap = new ConcurrentHashMap<>(); @Override @@ -50,13 +57,15 @@ public class MultiLevelCacheManager implements CacheManager { throw new IllegalStateException("Redis cache not found: " + name); } - log.debug("创建二级缓存: name={}", name); + boolean secureCache = this.secureMatcher != null && this.secureMatcher.matches(name); + log.debug("创建二级缓存: name={}, secure={}, secureL2Enabled={}", name, secureCache, this.secureL2Enabled); return new MultiLevelCache( name, this.localCacheRegistry, redisCache, - this.publisher + this.publisher, + secureCache, + this.secureL2Enabled ); } } - diff --git a/daxpay-platform/daxpay-platform-capability/capability-cache/src/main/java/cn/daxpay/open/platform/capability/cache/secure/EncryptingRedisSerializer.java b/daxpay-platform/daxpay-platform-capability/capability-cache/src/main/java/cn/daxpay/open/platform/capability/cache/secure/EncryptingRedisSerializer.java new file mode 100644 index 000000000..05b34ebc3 --- /dev/null +++ b/daxpay-platform/daxpay-platform-capability/capability-cache/src/main/java/cn/daxpay/open/platform/capability/cache/secure/EncryptingRedisSerializer.java @@ -0,0 +1,77 @@ +package cn.daxpay.open.platform.capability.cache.secure; + +import cn.daxpay.open.platform.common.config.encrypt.SecureAesGcmEncryptor; +import cn.daxpay.open.platform.common.json.util.JacksonUtil; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.serializer.RedisSerializer; +import org.springframework.data.redis.serializer.SerializationException; + +import java.nio.charset.StandardCharsets; + +/// # 敏感缓存 L2 整包加密序列化器 +/// +/// 将缓存对象序列化为 JSON 后,对**整段 JSON 字符串**做 AES-256-GCM 加密再写入 Redis。 +/// 非字段级加密:密钥字段与非敏感字段一起进入同一密文包。 +/// +/// 流程: +/// - serialize: Object → JSON → encrypt → `v{n}:...` bytes +/// - deserialize: bytes → decrypt → JSON → Object +/// +/// JSON 编解码与 JacksonRedisSerializer 一致,使用平台标准 ObjectMapper。 +@Slf4j +public class EncryptingRedisSerializer implements RedisSerializer { + + private final SecureAesGcmEncryptor encryptor; + + public EncryptingRedisSerializer(SecureAesGcmEncryptor encryptor) { + if (encryptor == null) { + throw new IllegalArgumentException("SecureAesGcmEncryptor 不能为空"); + } + this.encryptor = encryptor; + } + + @Override + public byte[] serialize(Object value) throws SerializationException { + if (value == null) { + return new byte[0]; + } + try { + ObjectMapper objectMapper = JacksonUtil.getObjectMapper(); + String json = objectMapper.writeValueAsString(value); + String cipher = encryptor.encrypt(json); + return cipher.getBytes(StandardCharsets.UTF_8); + } catch (JacksonException e) { + log.error("敏感缓存 JSON 序列化失败: {}", e.getMessage(), e); + throw new SerializationException("Could not serialize to JSON for encrypt: " + e.getMessage(), e); + } catch (RuntimeException e) { + log.error("敏感缓存加密序列化失败: {}", e.getMessage(), e); + throw new SerializationException("Could not encrypt-serialize cache value: " + e.getMessage(), e); + } + } + + @Override + public Object deserialize(byte[] bytes) throws SerializationException { + if (bytes == null || bytes.length == 0) { + return null; + } + try { + String cipher = new String(bytes, StandardCharsets.UTF_8); + String json = encryptor.decrypt(cipher); + if (json == null) { + throw new SerializationException("敏感缓存解密失败,密文格式或密钥版本无效"); + } + ObjectMapper objectMapper = JacksonUtil.getObjectMapper(); + return objectMapper.readValue(json, Object.class); + } catch (SerializationException e) { + throw e; + } catch (JacksonException e) { + log.error("敏感缓存解密后 JSON 反序列化失败: {}", e.getMessage(), e); + throw new SerializationException("Could not deserialize decrypted cache value: " + e.getMessage(), e); + } catch (Exception e) { + log.error("敏感缓存解密反序列化失败: {}", e.getMessage(), e); + throw new SerializationException("Could not decrypt-deserialize cache value: " + e.getMessage(), e); + } + } +} diff --git a/daxpay-platform/daxpay-platform-capability/capability-cache/src/main/java/cn/daxpay/open/platform/capability/cache/secure/SecureCacheNameMatcher.java b/daxpay-platform/daxpay-platform-capability/capability-cache/src/main/java/cn/daxpay/open/platform/capability/cache/secure/SecureCacheNameMatcher.java new file mode 100644 index 000000000..efbf9d4f1 --- /dev/null +++ b/daxpay-platform/daxpay-platform-capability/capability-cache/src/main/java/cn/daxpay/open/platform/capability/cache/secure/SecureCacheNameMatcher.java @@ -0,0 +1,46 @@ +package cn.daxpay.open.platform.capability.cache.secure; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; + +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/// # 敏感缓存名匹配器 +/// +/// 判断 cacheName 是否为敏感缓存(S2): +/// - 名称以 [securePrefix] 开头(默认 `secure:`) +/// - 或落在 [secureNames] 精确列表中 +/// +/// 匹配的缓存 L2 value 使用整包 AES-GCM 加密;未启用数据加密时则仅 L1、不写 Redis。 +public class SecureCacheNameMatcher { + + private final String securePrefix; + private final Set secureNames; + + public SecureCacheNameMatcher(String securePrefix, List secureNames) { + this.securePrefix = StrUtil.blankToDefault(securePrefix, "secure:"); + if (CollUtil.isEmpty(secureNames)) { + this.secureNames = Collections.emptySet(); + } else { + this.secureNames = new HashSet<>(secureNames); + } + } + + /// 是否为敏感缓存名 + public boolean matches(String cacheName) { + if (StrUtil.isBlank(cacheName)) { + return false; + } + if (cacheName.startsWith(this.securePrefix)) { + return true; + } + return this.secureNames.contains(cacheName); + } + + public String getSecurePrefix() { + return this.securePrefix; + } +} diff --git a/daxpay-platform/daxpay-platform-capability/capability-cache/src/test/java/cn/daxpay/open/platform/capability/cache/secure/EncryptingRedisSerializerTest.java b/daxpay-platform/daxpay-platform-capability/capability-cache/src/test/java/cn/daxpay/open/platform/capability/cache/secure/EncryptingRedisSerializerTest.java new file mode 100644 index 000000000..3a2d961e6 --- /dev/null +++ b/daxpay-platform/daxpay-platform-capability/capability-cache/src/test/java/cn/daxpay/open/platform/capability/cache/secure/EncryptingRedisSerializerTest.java @@ -0,0 +1,98 @@ +package cn.daxpay.open.platform.capability.cache.secure; + +import cn.daxpay.open.platform.common.config.encrypt.SecureAesGcmEncryptor; +import cn.daxpay.open.platform.common.config.properties.EncryptKeyInfo; +import cn.daxpay.open.platform.common.json.util.JacksonUtil; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.serializer.SerializationException; +import tools.jackson.databind.json.JsonMapper; + +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/// # 敏感缓存整包加密序列化器测试 +class EncryptingRedisSerializerTest { + + private EncryptingRedisSerializer serializer; + + @BeforeAll + static void initJackson() { + if (JacksonUtil.getObjectMapper() == null) { + JacksonUtil.setObjectMapper(JsonMapper.builder().build()); + } + } + + @BeforeEach + void setUp() { + EncryptKeyInfo keyInfo = new EncryptKeyInfo(); + keyInfo.setVersion(1); + keyInfo.setKey(generateKey(32)); + SecureAesGcmEncryptor encryptor = new SecureAesGcmEncryptor(List.of(keyInfo)); + serializer = new EncryptingRedisSerializer(encryptor); + } + + @Test + @DisplayName("整包加解密 round-trip 保留字段值") + void shouldRoundTrip() { + Map value = new LinkedHashMap<>(); + value.put("channelMchNo", "MCH001"); + value.put("apiKey", "sk_live_secret_key_value"); + value.put("privateKey", "-----BEGIN PRIVATE KEY-----\nABCxyz\n-----END PRIVATE KEY-----"); + + byte[] bytes = serializer.serialize(value); + Object restored = serializer.deserialize(bytes); + + assertInstanceOf(Map.class, restored); + @SuppressWarnings("unchecked") + Map map = (Map) restored; + assertEquals("MCH001", map.get("channelMchNo")); + assertEquals("sk_live_secret_key_value", map.get("apiKey")); + assertTrue(String.valueOf(map.get("privateKey")).contains("BEGIN PRIVATE KEY")); + } + + @Test + @DisplayName("序列化结果为 v1: 密文且不含明文密钥") + void shouldProduceCipherWithoutPlainSecrets() { + Map value = new LinkedHashMap<>(); + value.put("privateKey", "-----BEGIN PRIVATE KEY-----\nSUPER_SECRET\n-----END PRIVATE KEY-----"); + value.put("apiKey", "sk_live_plain_should_not_appear"); + + byte[] bytes = serializer.serialize(value); + String stored = new String(bytes, StandardCharsets.UTF_8); + + assertTrue(stored.startsWith("v1:"), "密文应带版本前缀 v1:"); + assertFalse(stored.contains("BEGIN PRIVATE KEY"), "Redis 存储不应含 PEM 明文"); + assertFalse(stored.contains("SUPER_SECRET"), "Redis 存储不应含私钥内容"); + assertFalse(stored.contains("sk_live_plain_should_not_appear"), "Redis 存储不应含 apiKey 明文"); + } + + @Test + @DisplayName("null 序列化为空字节") + void shouldSerializeNullAsEmpty() { + assertEquals(0, serializer.serialize(null).length); + assertNull(serializer.deserialize(null)); + assertNull(serializer.deserialize(new byte[0])); + } + + @Test + @DisplayName("非法密文反序列化抛 SerializationException") + void shouldFailOnInvalidCipher() { + assertThrows(SerializationException.class, + () -> serializer.deserialize("not-a-cipher".getBytes(StandardCharsets.UTF_8))); + } + + private static String generateKey(int length) { + byte[] keyBytes = new byte[length]; + new SecureRandom().nextBytes(keyBytes); + return Base64.getEncoder().encodeToString(keyBytes).substring(0, length); + } +} diff --git a/daxpay-platform/daxpay-platform-capability/capability-cache/src/test/java/cn/daxpay/open/platform/capability/cache/secure/SecureCacheNameMatcherTest.java b/daxpay-platform/daxpay-platform-capability/capability-cache/src/test/java/cn/daxpay/open/platform/capability/cache/secure/SecureCacheNameMatcherTest.java new file mode 100644 index 000000000..c60e0413c --- /dev/null +++ b/daxpay-platform/daxpay-platform-capability/capability-cache/src/test/java/cn/daxpay/open/platform/capability/cache/secure/SecureCacheNameMatcherTest.java @@ -0,0 +1,47 @@ +package cn.daxpay.open.platform.capability.cache.secure; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/// # 敏感缓存名匹配器测试 +class SecureCacheNameMatcherTest { + + @Test + @DisplayName("前缀匹配 secure:") + void shouldMatchSecurePrefix() { + SecureCacheNameMatcher matcher = new SecureCacheNameMatcher("secure:", List.of()); + assertTrue(matcher.matches("secure:channel-key:wechat-isv")); + assertTrue(matcher.matches("secure:merchant-credential")); + assertFalse(matcher.matches("system:dict")); + assertFalse(matcher.matches("payment:channel-merchant")); + } + + @Test + @DisplayName("精确列表命中") + void shouldMatchExactNames() { + SecureCacheNameMatcher matcher = new SecureCacheNameMatcher("secure:", List.of("legacy:secret-config")); + assertTrue(matcher.matches("legacy:secret-config")); + assertFalse(matcher.matches("legacy:other")); + } + + @Test + @DisplayName("空名称不匹配") + void shouldNotMatchBlank() { + SecureCacheNameMatcher matcher = new SecureCacheNameMatcher("secure:", null); + assertFalse(matcher.matches(null)); + assertFalse(matcher.matches("")); + assertFalse(matcher.matches(" ")); + } + + @Test + @DisplayName("默认前缀") + void shouldDefaultPrefixWhenBlank() { + SecureCacheNameMatcher matcher = new SecureCacheNameMatcher(null, List.of()); + assertEquals("secure:", matcher.getSecurePrefix()); + assertTrue(matcher.matches("secure:demo")); + } +} diff --git a/daxpay-platform/daxpay-platform-common/common-config/pom.xml b/daxpay-platform/daxpay-platform-common/common-config/pom.xml index 0fce9dcac..1439ee883 100644 --- a/daxpay-platform/daxpay-platform-common/common-config/pom.xml +++ b/daxpay-platform/daxpay-platform-common/common-config/pom.xml @@ -22,6 +22,16 @@ org.springframework.boot spring-boot-starter-validation + + + org.springframework.boot + spring-boot-autoconfigure + + + org.junit.jupiter + junit-jupiter + test + diff --git a/daxpay-platform/daxpay-platform-common/common-config/src/main/java/cn/daxpay/open/platform/common/config/ConfigAutoConfiguration.java b/daxpay-platform/daxpay-platform-common/common-config/src/main/java/cn/daxpay/open/platform/common/config/ConfigAutoConfiguration.java index 96cc0e07a..b8155d2b4 100644 --- a/daxpay-platform/daxpay-platform-common/common-config/src/main/java/cn/daxpay/open/platform/common/config/ConfigAutoConfiguration.java +++ b/daxpay-platform/daxpay-platform-common/common-config/src/main/java/cn/daxpay/open/platform/common/config/ConfigAutoConfiguration.java @@ -1,14 +1,17 @@ package cn.daxpay.open.platform.common.config; +import cn.daxpay.open.platform.common.config.encrypt.EncryptorConfiguration; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.context.annotation.Import; /// # 配置自动配置 /// @Slf4j @AutoConfiguration @ConfigurationPropertiesScan +@Import(EncryptorConfiguration.class) public class ConfigAutoConfiguration { } diff --git a/daxpay-platform/daxpay-platform-common/common-config/src/main/java/cn/daxpay/open/platform/common/config/encrypt/EncryptorConfiguration.java b/daxpay-platform/daxpay-platform-common/common-config/src/main/java/cn/daxpay/open/platform/common/config/encrypt/EncryptorConfiguration.java new file mode 100644 index 000000000..86ccecc25 --- /dev/null +++ b/daxpay-platform/daxpay-platform-common/common-config/src/main/java/cn/daxpay/open/platform/common/config/encrypt/EncryptorConfiguration.java @@ -0,0 +1,35 @@ +package cn.daxpay.open.platform.common.config.encrypt; + +import cn.daxpay.open.platform.common.config.properties.PlatformConfigProperties; +import cn.hutool.core.collection.CollUtil; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/// # 数据加密器 Spring 配置 +/// +/// 全进程唯一 [SecureAesGcmEncryptor] 实例,供 DB TypeHandler 与缓存 L2 加密序列化共用, +/// 避免两套密钥实例不一致。 +/// +/// 仅在 `daxpay.platform.config.encrypt.enable=true` 时注册 Bean。 +@Slf4j +@Configuration +@EnableConfigurationProperties(PlatformConfigProperties.class) +public class EncryptorConfiguration { + + /// 创建 AES-GCM 加密器(启用加密时必须配置 keys) + @Bean + @ConditionalOnProperty(prefix = "daxpay.platform.config.encrypt", name = "enable", havingValue = "true") + public SecureAesGcmEncryptor secureAesGcmEncryptor(PlatformConfigProperties platformConfigProperties) { + var keys = platformConfigProperties.getEncrypt().getKeys(); + if (CollUtil.isEmpty(keys)) { + throw new IllegalStateException( + "启用数据加密时必须配置至少一个密钥,请配置 daxpay.platform.config.encrypt.keys"); + } + SecureAesGcmEncryptor encryptor = new SecureAesGcmEncryptor(keys); + log.info("已注册 SecureAesGcmEncryptor Bean,当前密钥版本: v{}", encryptor.getCurrentVersion()); + return encryptor; + } +} diff --git a/daxpay-platform/daxpay-platform-common/common-mybatis-plus/src/main/java/cn/daxpay/open/platform/common/mybatisplus/handler/encrypt/SecureAesGcmEncryptor.java b/daxpay-platform/daxpay-platform-common/common-config/src/main/java/cn/daxpay/open/platform/common/config/encrypt/SecureAesGcmEncryptor.java similarity index 95% rename from daxpay-platform/daxpay-platform-common/common-mybatis-plus/src/main/java/cn/daxpay/open/platform/common/mybatisplus/handler/encrypt/SecureAesGcmEncryptor.java rename to daxpay-platform/daxpay-platform-common/common-config/src/main/java/cn/daxpay/open/platform/common/config/encrypt/SecureAesGcmEncryptor.java index f1bcc80e1..97327c578 100644 --- a/daxpay-platform/daxpay-platform-common/common-mybatis-plus/src/main/java/cn/daxpay/open/platform/common/mybatisplus/handler/encrypt/SecureAesGcmEncryptor.java +++ b/daxpay-platform/daxpay-platform-common/common-config/src/main/java/cn/daxpay/open/platform/common/config/encrypt/SecureAesGcmEncryptor.java @@ -1,4 +1,4 @@ -package cn.daxpay.open.platform.common.mybatisplus.handler.encrypt; +package cn.daxpay.open.platform.common.config.encrypt; import cn.daxpay.open.platform.common.config.properties.EncryptKeyInfo; import cn.hutool.core.collection.CollUtil; @@ -16,7 +16,7 @@ import java.util.stream.Collectors; /// # 安全的 AES-256-GCM 加密工具(支持多密钥版本) /// -/// 适用于支付系统中加密各类敏感数据使用 +/// 适用于支付系统中加密各类敏感数据:DB 字段 TypeHandler、缓存 L2 整包 value 等。 /// /// 密文格式:v{version}:{base64(IV + AES-GCM-Encrypt(plaintext))} /// 示例:v2:7Kf8jD2mNpQrStUvWxYz... @@ -47,32 +47,32 @@ public class SecureAesGcmEncryptor { if (CollUtil.isEmpty(keys)) { throw new IllegalArgumentException("密钥列表不能为空"); } - + // 校验密钥配置 validateKeys(keys); - + // 第一个密钥为当前密钥 this.currentKey = keys.getFirst(); - + // 构建密钥映射表 this.keyMap = keys.stream() .collect(Collectors.toMap(EncryptKeyInfo::getVersion, k -> k, (k1, k2) -> { throw new IllegalArgumentException("存在重复的密钥版本号: " + k1.getVersion()); })); - + // 初始化密钥缓存 this.secretKeyCache = new HashMap<>(); for (EncryptKeyInfo keyInfo : keys) { secretKeyCache.put(keyInfo.getVersion(), createSecretKey(keyInfo.getKey())); } - + // 记录日志 List historyVersions = keys.stream() .skip(1) .map(EncryptKeyInfo::getVersion) .toList(); - log.info("加密器初始化成功,当前版本: v{},历史版本: {}", - currentKey.getVersion(), + log.info("加密器初始化成功,当前版本: v{},历史版本: {}", + currentKey.getVersion(), historyVersions.isEmpty() ? "无" : historyVersions.stream().map(v -> "v" + v).toList()); } @@ -88,7 +88,7 @@ public class SecureAesGcmEncryptor { throw new IllegalArgumentException("存在重复的密钥版本号: " + keyInfo.getVersion()); } versions.add(keyInfo.getVersion()); - + // 校验密钥长度 if (keyInfo.getKey() == null || keyInfo.getKey().length() != KEY_LENGTH) { throw new IllegalArgumentException("密钥版本 v" + keyInfo.getVersion() + " 的密钥长度需要为32位"); @@ -146,13 +146,13 @@ public class SecureAesGcmEncryptor { log.warn("密文格式错误,缺少版本前缀"); return null; } - + int separatorIndex = ciphertext.indexOf(VERSION_SEPARATOR); if (separatorIndex == -1) { log.warn("密文格式错误,缺少版本分隔符"); return null; } - + int version; try { version = Integer.parseInt(ciphertext.substring(1, separatorIndex)); @@ -160,16 +160,16 @@ public class SecureAesGcmEncryptor { log.warn("密文版本号格式错误"); return null; } - + String encryptedBase64 = ciphertext.substring(separatorIndex + 1); - + // 获取对应版本的密钥 SecretKey secretKey = secretKeyCache.get(version); if (secretKey == null) { log.warn("找不到版本 v{} 对应的密钥", version); return null; } - + // 解密 byte[] combined = Base64.getDecoder().decode(encryptedBase64); byte[] iv = new byte[GCM_IV_LENGTH]; @@ -196,4 +196,3 @@ public class SecureAesGcmEncryptor { } } - diff --git a/daxpay-platform/daxpay-platform-common/common-config/src/main/java/cn/daxpay/open/platform/common/config/properties/PlatformCommonProperties.java b/daxpay-platform/daxpay-platform-common/common-config/src/main/java/cn/daxpay/open/platform/common/config/properties/PlatformCommonProperties.java index 17dcc1fa4..d4de65f61 100644 --- a/daxpay-platform/daxpay-platform-common/common-config/src/main/java/cn/daxpay/open/platform/common/config/properties/PlatformCommonProperties.java +++ b/daxpay-platform/daxpay-platform-common/common-config/src/main/java/cn/daxpay/open/platform/common/config/properties/PlatformCommonProperties.java @@ -4,6 +4,9 @@ import lombok.Data; import lombok.experimental.Accessors; import org.springframework.boot.context.properties.ConfigurationProperties; +import java.util.ArrayList; +import java.util.List; + /// # 平台通用配置属性 /// /// 整合了缓存、异常处理、Spring 和 Swagger 配置 @@ -28,6 +31,10 @@ public class PlatformCommonProperties { private L1 l1 = new L1(); /// L2 Redis 缓存配置 private L2 l2 = new L2(); + /// 敏感缓存名前缀;匹配的 L2 value 整包 AES-GCM 加密(默认 secure:) + private String securePrefix = "secure:"; + /// 额外视为敏感的 cacheName 精确列表(可选) + private List secureNames = new ArrayList<>(); /// # L1 本地缓存配置 /// diff --git a/daxpay-platform/daxpay-platform-common/common-mybatis-plus/src/test/java/cn/daxpay/open/platform/common/mybatisplus/handler/encrypt/SecureAesGcmEncryptorTest.java b/daxpay-platform/daxpay-platform-common/common-config/src/test/java/cn/daxpay/open/platform/common/config/encrypt/SecureAesGcmEncryptorTest.java similarity index 98% rename from daxpay-platform/daxpay-platform-common/common-mybatis-plus/src/test/java/cn/daxpay/open/platform/common/mybatisplus/handler/encrypt/SecureAesGcmEncryptorTest.java rename to daxpay-platform/daxpay-platform-common/common-config/src/test/java/cn/daxpay/open/platform/common/config/encrypt/SecureAesGcmEncryptorTest.java index 909efcd6f..ae99565f0 100644 --- a/daxpay-platform/daxpay-platform-common/common-mybatis-plus/src/test/java/cn/daxpay/open/platform/common/mybatisplus/handler/encrypt/SecureAesGcmEncryptorTest.java +++ b/daxpay-platform/daxpay-platform-common/common-config/src/test/java/cn/daxpay/open/platform/common/config/encrypt/SecureAesGcmEncryptorTest.java @@ -1,4 +1,4 @@ -package cn.daxpay.open.platform.common.mybatisplus.handler.encrypt; +package cn.daxpay.open.platform.common.config.encrypt; import cn.daxpay.open.platform.common.config.properties.EncryptKeyInfo; import org.junit.jupiter.api.BeforeEach; diff --git a/daxpay-platform/daxpay-platform-common/common-mybatis-plus/src/main/java/cn/daxpay/open/platform/common/mybatisplus/MybatisPlusCommonAutoConfiguration.java b/daxpay-platform/daxpay-platform-common/common-mybatis-plus/src/main/java/cn/daxpay/open/platform/common/mybatisplus/MybatisPlusCommonAutoConfiguration.java index 05d2e9972..830e32eaa 100644 --- a/daxpay-platform/daxpay-platform-common/common-mybatis-plus/src/main/java/cn/daxpay/open/platform/common/mybatisplus/MybatisPlusCommonAutoConfiguration.java +++ b/daxpay-platform/daxpay-platform-common/common-mybatis-plus/src/main/java/cn/daxpay/open/platform/common/mybatisplus/MybatisPlusCommonAutoConfiguration.java @@ -1,12 +1,14 @@ package cn.daxpay.open.platform.common.mybatisplus; +import cn.daxpay.open.platform.common.config.ConfigAutoConfiguration; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.context.annotation.ComponentScan; /// # mybatis自动配置 /// -@AutoConfiguration +/// 依赖 [ConfigAutoConfiguration] 先注册 SecureAesGcmEncryptor,供 DataEncryptTypeHandler 使用。 +@AutoConfiguration(after = ConfigAutoConfiguration.class) @ComponentScan @ConfigurationPropertiesScan public class MybatisPlusCommonAutoConfiguration { diff --git a/daxpay-platform/daxpay-platform-common/common-mybatis-plus/src/main/java/cn/daxpay/open/platform/common/mybatisplus/handler/encrypt/DataEncryptTypeHandler.java b/daxpay-platform/daxpay-platform-common/common-mybatis-plus/src/main/java/cn/daxpay/open/platform/common/mybatisplus/handler/encrypt/DataEncryptTypeHandler.java index a731d9ba7..101b53505 100644 --- a/daxpay-platform/daxpay-platform-common/common-mybatis-plus/src/main/java/cn/daxpay/open/platform/common/mybatisplus/handler/encrypt/DataEncryptTypeHandler.java +++ b/daxpay-platform/daxpay-platform-common/common-mybatis-plus/src/main/java/cn/daxpay/open/platform/common/mybatisplus/handler/encrypt/DataEncryptTypeHandler.java @@ -1,5 +1,6 @@ package cn.daxpay.open.platform.common.mybatisplus.handler.encrypt; +import cn.daxpay.open.platform.common.config.encrypt.SecureAesGcmEncryptor; import cn.hutool.core.util.StrUtil; import lombok.extern.slf4j.Slf4j; import org.apache.ibatis.type.BaseTypeHandler; @@ -13,6 +14,7 @@ import java.sql.SQLException; /// # 数据加密类型处理器, 使用 AES-256-GCM 加密 /// /// 支持多密钥版本,密文格式:v{version}:{encrypted} +/// 加密器由 [DataEncryptTypeHandlerConfiguration] 注入全进程唯一的 [SecureAesGcmEncryptor] Bean。 @Slf4j public class DataEncryptTypeHandler extends BaseTypeHandler { diff --git a/daxpay-platform/daxpay-platform-common/common-mybatis-plus/src/main/java/cn/daxpay/open/platform/common/mybatisplus/handler/encrypt/DataEncryptTypeHandlerConfiguration.java b/daxpay-platform/daxpay-platform-common/common-mybatis-plus/src/main/java/cn/daxpay/open/platform/common/mybatisplus/handler/encrypt/DataEncryptTypeHandlerConfiguration.java index 5317e6541..921d45fe5 100644 --- a/daxpay-platform/daxpay-platform-common/common-mybatis-plus/src/main/java/cn/daxpay/open/platform/common/mybatisplus/handler/encrypt/DataEncryptTypeHandlerConfiguration.java +++ b/daxpay-platform/daxpay-platform-common/common-mybatis-plus/src/main/java/cn/daxpay/open/platform/common/mybatisplus/handler/encrypt/DataEncryptTypeHandlerConfiguration.java @@ -1,15 +1,18 @@ package cn.daxpay.open.platform.common.mybatisplus.handler.encrypt; +import cn.daxpay.open.platform.common.config.encrypt.SecureAesGcmEncryptor; import cn.daxpay.open.platform.common.config.properties.PlatformConfigProperties; -import cn.hutool.core.collection.CollUtil; import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Configuration; /// # 数据加密类型处理器初始化配置 /// +/// 复用 [SecureAesGcmEncryptor] Spring Bean(由 [EncryptorConfiguration] 创建), +/// 保证与缓存 L2 加密使用同一加密器实例。 @Slf4j @Configuration @RequiredArgsConstructor @@ -17,6 +20,7 @@ import org.springframework.context.annotation.Configuration; public class DataEncryptTypeHandlerConfiguration { private final PlatformConfigProperties platformConfigProperties; + private final ObjectProvider encryptorProvider; @PostConstruct public void initEncryptTypeHandler() { @@ -27,12 +31,11 @@ public class DataEncryptTypeHandlerConfiguration { return; } - var keys = encrypt.getKeys(); - if (CollUtil.isEmpty(keys)) { - throw new IllegalStateException("启用数据加密时必须配置至少一个密钥,请配置 daxpay.platform.config.encrypt.keys"); + SecureAesGcmEncryptor encryptor = encryptorProvider.getIfAvailable(); + if (encryptor == null) { + throw new IllegalStateException( + "已启用数据加密但未找到 SecureAesGcmEncryptor Bean,请检查 daxpay.platform.config.encrypt 配置"); } - - var encryptor = new SecureAesGcmEncryptor(keys); DataEncryptTypeHandler.initialize(encryptor, true); } }