diff --git a/core/src/main/java/com/kakarote/core/common/Const.java b/core/src/main/java/com/kakarote/core/common/Const.java index 0dbefc8..7c29cc2 100644 --- a/core/src/main/java/com/kakarote/core/common/Const.java +++ b/core/src/main/java/com/kakarote/core/common/Const.java @@ -75,4 +75,10 @@ public class Const implements Serializable { public static final String ADMIN_USER_DEPT_CACHE_NAME = "ADMIN:USER:DEPT:CACHE:"; + /** + * 加密前缀 + */ + public static final String ENCRYPTED_PREFIX = "ENCRYPTED:"; + + } diff --git a/core/src/main/java/com/kakarote/core/config/MybatisPlusConfig.java b/core/src/main/java/com/kakarote/core/config/MybatisPlusConfig.java index a6291b9..a716320 100644 --- a/core/src/main/java/com/kakarote/core/config/MybatisPlusConfig.java +++ b/core/src/main/java/com/kakarote/core/config/MybatisPlusConfig.java @@ -5,8 +5,10 @@ import com.baomidou.mybatisplus.core.incrementer.IdentifierGenerator; import com.baomidou.mybatisplus.extension.MybatisMapWrapperFactory; import com.baomidou.mybatisplus.extension.plugins.PaginationInterceptor; import com.baomidou.mybatisplus.extension.plugins.pagination.optimize.JsqlParserCountOptimize; +import com.kakarote.core.security.converter.SensitiveDataConverter; import com.kakarote.core.utils.BaseUtil; import lombok.extern.slf4j.Slf4j; +import org.apache.ibatis.type.TypeHandlerRegistry; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -22,11 +24,22 @@ public class MybatisPlusConfig { return paginationInterceptor; } +// @Bean +// public ConfigurationCustomizer configurationCustomizer() { +// return i -> i.setObjectWrapperFactory(new MybatisMapWrapperFactory()); +// } + @Bean public ConfigurationCustomizer configurationCustomizer() { - return i -> i.setObjectWrapperFactory(new MybatisMapWrapperFactory()); + return i -> { + i.setObjectWrapperFactory(new MybatisMapWrapperFactory()); + // 注册敏感数据类型处理器 + TypeHandlerRegistry registry = i.getTypeHandlerRegistry(); + registry.register(SensitiveDataConverter.class); + }; } + @Bean public IdentifierGenerator idGenerator() { return new CustomIdGenerator(); diff --git a/core/src/main/java/com/kakarote/core/security/EncryptionService.java b/core/src/main/java/com/kakarote/core/security/EncryptionService.java new file mode 100644 index 0000000..4bf10e7 --- /dev/null +++ b/core/src/main/java/com/kakarote/core/security/EncryptionService.java @@ -0,0 +1,105 @@ +package com.kakarote.core.security; + +import lombok.extern.slf4j.Slf4j; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +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.Security; +import java.security.SecureRandom; +import java.util.Base64; + +@Slf4j +@Component +public class EncryptionService { + // AES-GCM参数配置 + private static final int GCM_IV_LENGTH = 12; + private static final int GCM_TAG_LENGTH = 16; + private static final String AES_ALGORITHM = "AES/GCM/NoPadding"; + private static final String AES_KEY_ALGORITHM = "AES"; + + // 系统主密钥(生产环境应从KMS获取) + @Value("${encryption.system-key}") + private String systemKey; + + static { + // 注册BouncyCastle加密提供者 + Security.addProvider(new BouncyCastleProvider()); + } + + /** + * AES加密 + * @param plaintext 明文 + * @return Base64编码的密文 + */ + public String encryptAes(String plaintext) { + try { + // 生成随机IV + byte[] iv = new byte[GCM_IV_LENGTH]; + SecureRandom random = new SecureRandom(); + random.nextBytes(iv); + + // 初始化密钥 + SecretKey secretKey = new SecretKeySpec(Base64.getDecoder().decode(systemKey), AES_KEY_ALGORITHM); + + // 初始化加密器 + Cipher cipher = Cipher.getInstance(AES_ALGORITHM); + GCMParameterSpec parameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH * 8, iv); + cipher.init(Cipher.ENCRYPT_MODE, secretKey, parameterSpec); + + // 执行加密 + byte[] ciphertext = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); + + // 组合IV和密文 + byte[] result = new byte[iv.length + ciphertext.length]; + System.arraycopy(iv, 0, result, 0, iv.length); + System.arraycopy(ciphertext, 0, result, iv.length, ciphertext.length); + + return Base64.getEncoder().encodeToString(result); + } catch (Exception e) { + log.error("AES加密失败", e); + throw new SecurityException("数据加密失败", e); + } + } + + /** + * AES解密 + * @param ciphertext Base64编码的密文 + * @return 明文 + */ + public String decryptAes(String ciphertext) { + try { + // 解码Base64密文 + byte[] decoded = Base64.getDecoder().decode(ciphertext); + + // 提取IV + byte[] iv = new byte[GCM_IV_LENGTH]; + System.arraycopy(decoded, 0, iv, 0, iv.length); + + // 提取实际密文 + byte[] encryptedData = new byte[decoded.length - iv.length]; + System.arraycopy(decoded, iv.length, encryptedData, 0, encryptedData.length); + + // 初始化密钥 + SecretKey secretKey = new SecretKeySpec(Base64.getDecoder().decode(systemKey), AES_KEY_ALGORITHM); + + // 初始化解密器 + Cipher cipher = Cipher.getInstance(AES_ALGORITHM); + GCMParameterSpec parameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH * 8, iv); + cipher.init(Cipher.DECRYPT_MODE, secretKey, parameterSpec); + + // 执行解密 + byte[] plaintext = cipher.doFinal(encryptedData); + return new String(plaintext, StandardCharsets.UTF_8); + } catch (Exception e) { + log.error("AES解密失败", e); + throw new SecurityException("数据解密失败", e); + } + } +} \ No newline at end of file diff --git a/core/src/main/java/com/kakarote/core/security/KeyManager.java b/core/src/main/java/com/kakarote/core/security/KeyManager.java new file mode 100644 index 0000000..d0b5a49 --- /dev/null +++ b/core/src/main/java/com/kakarote/core/security/KeyManager.java @@ -0,0 +1,57 @@ +package com.kakarote.core.security; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; +import java.security.SecureRandom; +import java.util.Base64; + +@Slf4j +@Component +public class KeyManager { + // 密钥版本 + @Value("${encryption.key-version:V1}") + private String keyVersion; + + // 主密钥(生产环境应从KMS获取) + @Value("${encryption.system-key}") + private String systemKey; + + /** + * 生成新的AES密钥 + * @return Base64编码的密钥 + */ + public String generateNewAesKey() { + try { + KeyGenerator keyGenerator = KeyGenerator.getInstance("AES"); + keyGenerator.init(256, new SecureRandom()); + SecretKey secretKey = keyGenerator.generateKey(); + return Base64.getEncoder().encodeToString(secretKey.getEncoded()); + } catch (Exception e) { + log.error("生成AES密钥失败", e); + throw new SecurityException("密钥生成失败", e); + } + } + + /** + * 密钥轮换管理 + */ + public void rotateKey() { + // 1. 生成新密钥 + String newKey = generateNewAesKey(); + + // 2. 记录密钥版本 + String newVersion = "V" + (Integer.parseInt(keyVersion.substring(1)) + 1); + + // 3. 将新密钥存储到安全位置 + // ... 密钥存储逻辑 ... + + // 4. 异步更新数据库中所有旧版本加密数据 + // ... 数据迁移逻辑 ... + + log.info("密钥轮换完成,新版本: {}", newVersion); + } +} \ No newline at end of file diff --git a/core/src/main/java/com/kakarote/core/security/converter/SensitiveDataConverter.java b/core/src/main/java/com/kakarote/core/security/converter/SensitiveDataConverter.java new file mode 100644 index 0000000..bbb288d --- /dev/null +++ b/core/src/main/java/com/kakarote/core/security/converter/SensitiveDataConverter.java @@ -0,0 +1,92 @@ +package com.kakarote.core.security.converter; + +import com.baomidou.mybatisplus.extension.handlers.AbstractJsonTypeHandler; +import com.kakarote.core.common.Const; +import com.kakarote.core.security.EncryptionService; +import org.apache.ibatis.type.JdbcType; +import org.apache.ibatis.type.MappedJdbcTypes; +import org.apache.ibatis.type.MappedTypes; +import org.springframework.beans.BeansException; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.stereotype.Component; + +import java.sql.CallableStatement; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +@MappedTypes({String.class}) +@MappedJdbcTypes({JdbcType.VARCHAR}) +@Component +public class SensitiveDataConverter extends AbstractJsonTypeHandler implements ApplicationContextAware { + + private static ApplicationContext applicationContext; + private EncryptionService encryptionService; + + // 无参构造函数,供MyBatis使用 + public SensitiveDataConverter() { + } + + // 带参构造函数,供Spring使用 + public SensitiveDataConverter(EncryptionService encryptionService) { + this.encryptionService = encryptionService; + } + + @Override + public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { + SensitiveDataConverter.applicationContext = applicationContext; + } + + private EncryptionService getEncryptionService() { + if (encryptionService == null) { + encryptionService = applicationContext.getBean(EncryptionService.class); + } + return encryptionService; + } + + @Override + public void setNonNullParameter(PreparedStatement preparedStatement, int i, String s, JdbcType jdbcType) throws SQLException { + if (s != null && !s.isEmpty() && !s.startsWith(Const.ENCRYPTED_PREFIX)) { + s = getEncryptionService().encryptAes(s); + } + preparedStatement.setString(i, s); + } + + @Override + public String getNullableResult(ResultSet resultSet, String s) throws SQLException { + String value = resultSet.getString(s); + if (value != null && value.startsWith(Const.ENCRYPTED_PREFIX)) { + value = getEncryptionService().decryptAes(value); + } + return value; + } + + @Override + public String getNullableResult(ResultSet resultSet, int i) throws SQLException { + String value = resultSet.getString(i); + if (value != null && value.startsWith(Const.ENCRYPTED_PREFIX)) { + value = getEncryptionService().decryptAes(value); + } + return value; + } + + @Override + public String getNullableResult(CallableStatement callableStatement, int i) throws SQLException { + String value = callableStatement.getString(i); + if (value != null && value.startsWith(Const.ENCRYPTED_PREFIX)) { + value = getEncryptionService().decryptAes(value); + } + return value; + } + + @Override + protected String parse(String json) { + return ""; + } + + @Override + protected String toJson(String obj) { + return ""; + } +} \ No newline at end of file diff --git a/core/src/main/resources/application-core.yml b/core/src/main/resources/application-core.yml index 0065aa6..252da3a 100644 --- a/core/src/main/resources/application-core.yml +++ b/core/src/main/resources/application-core.yml @@ -50,7 +50,7 @@ jetcache: maxTotal: 50 host: ${spring.redis.host} port: ${spring.redis.port} - password: ${spring.redis.password} + password: expireAfterWriteInMillis: 1800000 crm: @@ -86,4 +86,9 @@ crm: bucketName: 0: 1: +encryption: + system-key: AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8= + key-version: V1 +mybatis: + type-handlers-package: com.kakarote.core.security.converter diff --git a/crm/src/main/java/com/kakarote/crm/controller/CrmCustomerController.java b/crm/src/main/java/com/kakarote/crm/controller/CrmCustomerController.java index db86ae6..1649ed6 100644 --- a/crm/src/main/java/com/kakarote/crm/controller/CrmCustomerController.java +++ b/crm/src/main/java/com/kakarote/crm/controller/CrmCustomerController.java @@ -29,10 +29,7 @@ import com.kakarote.crm.entity.VO.CrmDataCheckVO; import com.kakarote.crm.entity.VO.CrmInfoNumVO; import com.kakarote.crm.entity.VO.CrmMembersSelectVO; import com.kakarote.crm.entity.VO.CrmModelFiledVO; -import com.kakarote.crm.service.CrmUploadExcelService; -import com.kakarote.crm.service.ICrmCustomerService; -import com.kakarote.crm.service.ICrmOpenApiService; -import com.kakarote.crm.service.ICrmTeamMembersService; +import com.kakarote.crm.service.*; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import io.swagger.annotations.ApiParam; @@ -75,6 +72,9 @@ public class CrmCustomerController { @Autowired private ICrmOpenApiService crmOpenApiService; + @Autowired + private IBatchEncryptionService batchEncryptionService; + @PostMapping("/queryPageList") @ApiOperation("查询列表页数据") public Result>> queryPageList(@RequestBody CrmSearchBO search) { @@ -553,5 +553,13 @@ public class CrmCustomerController { String customerName = crmCustomerService.getCustomerName(customerId); return R.ok(customerName); } + + @PostMapping("/queryEncryptCustomerData") + @ApiOperation("批量加密客户数据") + @ParamAspect + public Result encryptCustomerData(@RequestParam(defaultValue = "1000") int pageSize) { + batchEncryptionService.batchEncryptCustomerData(pageSize); + return Result.ok(); + } } diff --git a/crm/src/main/java/com/kakarote/crm/entity/PO/CrmCustomer.java b/crm/src/main/java/com/kakarote/crm/entity/PO/CrmCustomer.java index f76c793..5b7bc7b 100644 --- a/crm/src/main/java/com/kakarote/crm/entity/PO/CrmCustomer.java +++ b/crm/src/main/java/com/kakarote/crm/entity/PO/CrmCustomer.java @@ -1,11 +1,13 @@ package com.kakarote.crm.entity.PO; import com.baomidou.mybatisplus.annotation.*; +import com.kakarote.core.security.converter.SensitiveDataConverter; import io.swagger.annotations.ApiModel; import io.swagger.annotations.ApiModelProperty; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.experimental.Accessors; +import org.apache.ibatis.type.JdbcType; import java.io.Serializable; import java.util.Date; @@ -52,17 +54,33 @@ public class CrmCustomer implements Serializable { private Integer contactsId; @ApiModelProperty(value = "手机") + @TableField(typeHandler = SensitiveDataConverter.class, jdbcType = JdbcType.VARCHAR) private String mobile; @ApiModelProperty(value = "电话") + @TableField(typeHandler = SensitiveDataConverter.class, jdbcType = JdbcType.VARCHAR) private String telephone; - @ApiModelProperty(value = "网址") - private String website; - @ApiModelProperty(value = "邮箱") + @TableField(typeHandler = SensitiveDataConverter.class, jdbcType = JdbcType.VARCHAR) private String email; + @ApiModelProperty(value = "省市区") + @TableField(typeHandler = SensitiveDataConverter.class, jdbcType = JdbcType.VARCHAR) + private String address; + + @ApiModelProperty(value = "详细地址") + @TableField(typeHandler = SensitiveDataConverter.class, jdbcType = JdbcType.VARCHAR) + private String detailAddress; + + @ApiModelProperty(value = "地理位置经度") + @TableField(typeHandler = SensitiveDataConverter.class, jdbcType = JdbcType.VARCHAR) + private String lng; + + @ApiModelProperty(value = "地理位置维度") + @TableField(typeHandler = SensitiveDataConverter.class, jdbcType = JdbcType.VARCHAR) + private String lat; + @ApiModelProperty(value = "备注") private String remark; @@ -73,21 +91,11 @@ public class CrmCustomer implements Serializable { @ApiModelProperty(value = "负责人ID") private Long ownerUserId; - @ApiModelProperty(value = "省市区") - private String address; @ApiModelProperty(value = "定位信息") + @TableField(typeHandler = SensitiveDataConverter.class, jdbcType = JdbcType.VARCHAR) private String location; - @ApiModelProperty(value = "详细地址") - private String detailAddress; - - @ApiModelProperty(value = "地理位置经度") - private String lng; - - @ApiModelProperty(value = "地理位置维度") - private String lat; - @ApiModelProperty(value = "创建时间") @TableField(fill = FieldFill.INSERT) private Date createTime; diff --git a/crm/src/main/java/com/kakarote/crm/service/IBatchEncryptionService.java b/crm/src/main/java/com/kakarote/crm/service/IBatchEncryptionService.java new file mode 100644 index 0000000..8a1867c --- /dev/null +++ b/crm/src/main/java/com/kakarote/crm/service/IBatchEncryptionService.java @@ -0,0 +1,11 @@ +package com.kakarote.crm.service; + +import com.kakarote.core.servlet.BaseService; + +public interface IBatchEncryptionService { + /** + * 批量加密客户数据 + * @param pageSize 每页处理数量 + */ + void batchEncryptCustomerData(int pageSize); +} \ No newline at end of file diff --git a/crm/src/main/java/com/kakarote/crm/service/impl/BatchEncryptionServiceImpl.java b/crm/src/main/java/com/kakarote/crm/service/impl/BatchEncryptionServiceImpl.java new file mode 100644 index 0000000..df3db9d --- /dev/null +++ b/crm/src/main/java/com/kakarote/crm/service/impl/BatchEncryptionServiceImpl.java @@ -0,0 +1,87 @@ +package com.kakarote.crm.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.kakarote.core.common.Const; +import com.kakarote.core.security.converter.SensitiveDataConverter; +import com.kakarote.core.security.EncryptionService; +import com.kakarote.crm.entity.PO.CrmCustomer; +import com.kakarote.crm.mapper.CrmCustomerMapper; +import com.kakarote.crm.service.IBatchEncryptionService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +public class BatchEncryptionServiceImpl implements IBatchEncryptionService { + + @Autowired + private CrmCustomerMapper customerMapper; + + @Autowired + private EncryptionService encryptionService; + + // 我们可以移除不需要的SensitiveDataConverter依赖 + // @Autowired + // private SensitiveDataConverter sensitiveDataConverter; + + /** + * 批量加密客户数据 + * @param pageSize 每页处理数量 + */ + @Override + @Transactional + public void batchEncryptCustomerData(int pageSize) { + int currentPage = 1; + boolean hasMoreData = true; + + while (hasMoreData) { + // 分页查询客户数据 + IPage page = new Page<>(currentPage, pageSize); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + // 可以添加条件来过滤未加密的数据 + // wrapper.isNull(CrmCustomer::getEncryptedFlag); + + IPage result = customerMapper.selectPage(page, wrapper); + List customerList = result.getRecords(); + + if (customerList.isEmpty()) { + hasMoreData = false; + break; + } + + // 加密每个客户的敏感数据 + for (CrmCustomer customer : customerList) { + // 加密敏感字段 + if (customer.getMobile() != null) { + customer.setMobile(encryptionService.encryptAes(customer.getMobile())); + } + if (customer.getTelephone() != null) { + customer.setTelephone(encryptionService.encryptAes(customer.getTelephone())); + } + if (customer.getEmail() != null) { + customer.setEmail(encryptionService.encryptAes(customer.getEmail())); + } + if (customer.getAddress() != null) { + customer.setAddress(encryptionService.encryptAes(customer.getAddress())); + } + if (customer.getDetailAddress() != null) { + customer.setDetailAddress(encryptionService.encryptAes(customer.getDetailAddress())); + } + if (customer.getLocation() != null) { + customer.setLocation(encryptionService.encryptAes(customer.getLocation())); + } + // 标记为已加密 +// customer.setEncryptedFlag(1); + + // 更新客户数据 + customerMapper.updateById(customer); + } + + currentPage++; + } + } +} \ No newline at end of file diff --git a/crm/src/main/java/com/kakarote/crm/service/impl/CrmCustomerServiceImpl.java b/crm/src/main/java/com/kakarote/crm/service/impl/CrmCustomerServiceImpl.java index 474c958..0591c7a 100644 --- a/crm/src/main/java/com/kakarote/crm/service/impl/CrmCustomerServiceImpl.java +++ b/crm/src/main/java/com/kakarote/crm/service/impl/CrmCustomerServiceImpl.java @@ -32,6 +32,7 @@ import com.kakarote.core.feign.crm.entity.QueryEventCrmPageBO; import com.kakarote.core.feign.crm.entity.SimpleCrmEntity; import com.kakarote.core.field.FieldService; import com.kakarote.core.redis.Redis; +import com.kakarote.core.security.EncryptionService; import com.kakarote.core.servlet.ApplicationContextHolder; import com.kakarote.core.servlet.BaseServiceImpl; import com.kakarote.core.servlet.upload.FileEntity; @@ -127,6 +128,8 @@ public class CrmCustomerServiceImpl extends BaseServiceImpl