业务场景中经常会遇到诸如用户手机号,身份证号,银行卡号,邮箱,地址,密码等等信息,属于敏感信息,需要保存在数据库中。而很多公司会会要求对数据库中的此类数据进行加密存储。
敏感数据脱敏需要处理的两个问题:
通过查阅资料,发现思路二目前普遍有两种处理方式:
- 采用mybatis插件在mybatis SQL执行和查询结果填充操作上进行切入
- 使用mybatis框架提供的TypeHandler来实现在持久层处理数据 (见https://blog.csdn.net/qq_39052947/article/details/128148805)
本文介绍第3种方式,即使用Mybatis的插件,通过拦截器实现敏感数据加解密
Mybatis的插件,是采用责任链机制,通过JDK动态代理来实现的。默认情况下,Mybatis允许使用插件来拦截四个对象:
编写插件需要标识拦截方法和实现拦截逻辑。
标识拦截拦截方法是通过注解org.apache.ibatis.plugin.Intercepts和注解org.apache.ibatis.plugin.Signature实现的。
基于上面两种要求,我们只需要对ParameterHandler和ResultSetHandler进行切入。
定义特定注解,在切入时只需要检查字段中是否包含该注解来决定是否加解密
包含两个注解:
- SensitiveData注解:用在实体类上,表示此类有些字段需要加密,需要结合@SensitiveField一起使用
- SensitiveField注解:用在类的字段上或者方法的参数上,表示该字段或参数需要加密
package com.zsx.annotation; import java.lang.annotation.*; /** * 该注解定义在类上 * 插件通过扫描类对象是否包含这个注解来决定是否继续扫描其中的字段注解 * 这个注解要配合SensitiveField注解 * @author zhousx * @create 2023/10/01-22:45 **/ @Inherited @Target({ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface SensitiveData { }
package com.zsx.annotation; import java.lang.annotation.*; /** * 该注解有两种使用方式 * ①:配合@SensitiveData加在类中的字段上 * ②:直接在Mapper中的方法参数上使用 * @author zhousx * @create 2023/10/01-22:45 **/ @Documented @Inherited @Target({ElementType.FIELD, ElementType.PARAMETER}) @Retention(RetentionPolicy.RUNTIME) public @interface SensitiveField { }
切入mybatis设置参数时对敏感数据进行加密
Mybatis插件的使用就是通过实现Mybatis中的Interceptor接口
再配合@Intercepts注解
// 使用mybatis插件时需要定义签名
// type标识需要切入的Handler
// method表示要要切入的方法
// args表示要切入的方法的参数
@Intercepts({
@Signature(type = ParameterHandler.class, method = “setParameters”, args = PreparedStatement.class),
})
上面这个签名就表示:切入ParameterHandler类的setParameters(PreparedStatement preparedStatement)方法
ParameterInterceptor .java
package com.zsx.intercepter; import com.baomidou.mybatisplus.core.MybatisParameterHandler; import com.zsx.annotation.SensitiveField; import com.zsx.annotation.SensitiveData; import com.zsx.utils.DBAESUtil; import lombok.extern.slf4j.Slf4j; import org.apache.ibatis.executor.parameter.ParameterHandler; import org.apache.ibatis.mapping.BoundSql; import org.apache.ibatis.mapping.MappedStatement; import org.apache.ibatis.plugin.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.annotation.AnnotationUtils; import org.springframework.stereotype.Component; import org.springframework.util.CollectionUtils; import java.lang.annotation.Annotation; import java.lang.reflect.Field; import java.lang.reflect.Method; import java.lang.reflect.Parameter; import java.sql.PreparedStatement; import java.util.*; /** * @author zhousx * @create 2023/10/01-22:45 **/ @Slf4j // 注入Spring @Component @Intercepts({ @Signature(type = ParameterHandler.class, method = "setParameters", args = PreparedStatement.class), }) public class ParameterInterceptor implements Interceptor { @Autowired private com.zsx.utils.IEncryptUtil IEncryptUtil; @Override public Object intercept(Invocation invocation) throws Throwable { //@Signature 指定了 type= parameterHandler 后,这里的 invocation.getTarget() 便是parameterHandler //若指定ResultSetHandler ,这里则能强转为ResultSetHandler MybatisParameterHandler parameterHandler = (MybatisParameterHandler) invocation.getTarget(); // 获取参数对像,即 mapper 中 paramsType 的实例 Field parameterField = parameterHandler.getClass().getDeclaredField("parameterObject"); parameterField.setAccessible(true); //取出实例 Object parameterObject = parameterField.get(parameterHandler); // 搜索该方法中是否有需要加密的普通字段 ListparamNames = searchParamAnnotation(parameterHandler); if (parameterObject != null) { Class> parameterObjectClass = parameterObject.getClass(); //对类字段进行加密 //校验该实例的类是否被@SensitiveData所注解 SensitiveData sensitiveData = AnnotationUtils.findAnnotation(parameterObjectClass, SensitiveData.class); if (Objects.nonNull(sensitiveData)) { //取出当前当前类所有字段,传入加密方法 Field[] declaredFields = parameterObjectClass.getDeclaredFields(); IEncryptUtil.encrypt(declaredFields, parameterObject); } //如果传参为List类型,对list里的对象进行加密 processListParam(parameterObject); // 对普通字段进行加密 if (!CollectionUtils.isEmpty(paramNames)) { // 反射获取 BoundSql 对象,此对象包含生成的sql和sql的参数map映射 Field boundSqlField = parameterHandler.getClass().getDeclaredField("boundSql"); boundSqlField.setAccessible(true); BoundSql boundSql = (BoundSql) boundSqlField.get(parameterHandler); PreparedStatement ps = (PreparedStatement) invocation.getArgs()[0]; // 改写参数 processParam(parameterObject, paramNames); // 改写的参数设置到原parameterHandler对象 parameterField.set(parameterHandler, parameterObject); parameterHandler.setParameters(ps); } } return invocation.proceed(); } /** * 如果传参为List类型,对list里的对象判断,是否进行加密 * @param parameterObject * @throws IllegalAccessException */ private void processListParam(Object parameterObject) throws IllegalAccessException { // mybatis会把list封装到一个ParamMap中 if (parameterObject instanceof Map) { //1. 如果不对传参users使用@Param注解,则会在map中放入collection、list、users三个键值对,这三个指向同一个内存地址即内容相同。 if (((Map) parameterObject).containsKey("list")) { Map map = (Map) parameterObject; ArrayList list = (ArrayList) map.get("list"); Object element = list.get(0); Class> elementClass = element.getClass(); SensitiveData tempSensitiveData = AnnotationUtils.findAnnotation(elementClass, SensitiveData.class); if (Objects.nonNull(tempSensitiveData)) { for (Object elementObject : list) { Field[] declaredFields = elementClass.getDeclaredFields(); IEncryptUtil.encrypt(declaredFields, elementObject); } } } //2. 如果使用了@Param注解对参数重命名为users,那么map中只会放入users、param1两个键值对,这2个指向同一个内存地址即内容相同。 if (((Map) parameterObject).containsKey("param1")) { Map map = (Map) parameterObject; Object param1 = map.get("param1"); //如果param1是ArrayList,则转为arrayList。否则不转换 if (param1 instanceof ArrayList) { ArrayList list = (ArrayList) param1; Object element = list.get(0); Class> elementClass = element.getClass(); SensitiveData tempSensitiveData = AnnotationUtils.findAnnotation(elementClass, SensitiveData.class); if (Objects.nonNull(tempSensitiveData)) { for (Object elementObject : list) { Field[] declaredFields = elementClass.getDeclaredFields(); IEncryptUtil.encrypt(declaredFields, elementObject); } } } } } } /** * 处理普通参数,对params中的String参数进行加密 * @param parameterObject * @param params * @throws Exception */ private void processParam(Object parameterObject, List params) throws Exception { // 处理参数对象 如果是 map 且map的key 中没有 tenantId,添加到参数map中 // 如果参数是bean,反射设置值 if (parameterObject instanceof Map) { @SuppressWarnings("unchecked") Map map = ((Map ) parameterObject); for (String param : params) { String value = map.get(param); map.put(param, value == null ? null : DBAESUtil.encrypt(value)); } // parameterObject = map; } } /** * 查找参数的注解是否是含有 @EncryptTransaction注解,如果是,则存储参数名 * @param parameterHandler * @return * @throws NoSuchFieldException * @throws ClassNotFoundException * @throws IllegalAccessException */ private List searchParamAnnotation(ParameterHandler parameterHandler) throws NoSuchFieldException, ClassNotFoundException, IllegalAccessException { Class handlerClass = MybatisParameterHandler.class; Field mappedStatementFiled = handlerClass.getDeclaredField("mappedStatement"); mappedStatementFiled.setAccessible(true); MappedStatement mappedStatement = (MappedStatement) mappedStatementFiled.get(parameterHandler); String methodName = mappedStatement.getId(); Class> mapperClass = Class.forName(methodName.substring(0, methodName.lastIndexOf('.'))); methodName = methodName.substring(methodName.lastIndexOf('.') + 1); Method[] methods = mapperClass.getDeclaredMethods(); Method method = null; for (Method m : methods) { if (m.getName().equals(methodName)) { method = m; break; } } List paramNames = null; if (method != null) { Annotation[][] pa = method.getParameterAnnotations(); Parameter[] parameters = method.getParameters(); for (int i = 0; i < pa.length; i++) { for (Annotation annotation : pa[i]) { if (annotation instanceof SensitiveField) { if (paramNames == null) { paramNames = new ArrayList<>(); } paramNames.add(parameters[i].getName()); } } } } return paramNames; } @Override public Object plugin(Object target) { return Plugin.wrap(target, this); } @Override public void setProperties(Properties properties) { } }
ResultSetInterceptor .java
package com.zsx.intercepter; import com.zsx.annotation.SensitiveData; import lombok.extern.slf4j.Slf4j; import org.apache.ibatis.executor.resultset.ResultSetHandler; import org.apache.ibatis.plugin.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.annotation.AnnotationUtils; import org.springframework.stereotype.Component; import org.springframework.util.CollectionUtils; import java.sql.Statement; import java.util.ArrayList; import java.util.Objects; import java.util.Properties; /** * @author zhousx * @create 2023/10/01-22:45 **/ @Slf4j @Component @Intercepts({ @Signature(type = ResultSetHandler.class, method = "handleResultSets", args = {Statement.class}) }) public class ResultSetInterceptor implements Interceptor { @Autowired private com.zsx.utils.IDecryptUtil IDecryptUtil; @Override public Object intercept(Invocation invocation) throws Throwable { //取出查询的结果 Object resultObject = invocation.proceed(); if (Objects.isNull(resultObject)) { return null; } //基于selectList if (resultObject instanceof ArrayList) { @SuppressWarnings("unchecked") ArrayListresultList = (ArrayList ) resultObject; if (!CollectionUtils.isEmpty(resultList) && needToDecrypt(resultList.get(0))) { for (Object result : resultList) { //逐一解密 IDecryptUtil.decrypt(result); } } //基于selectOne } else { if (needToDecrypt(resultObject)) { IDecryptUtil.decrypt(resultObject); } } return resultObject; } private boolean needToDecrypt(Object object) { Class> objectClass = object.getClass(); SensitiveData sensitiveData = AnnotationUtils.findAnnotation(objectClass, SensitiveData.class); return Objects.nonNull(sensitiveData); } @Override public Object plugin(Object target) { return Plugin.wrap(target, this); } @Override public void setProperties(Properties properties) { } }
IEncryptUtil .java
package com.zsx.utils; import java.lang.reflect.Field; /** * @author zhousx * @create 2023/10/01-22:45 **/ public interface IEncryptUtil { /** * 加密 * * @param declaredFields 加密字段 * @param paramsObject 对象 * @param入参类型 * @return 返回加密 * @throws IllegalAccessException 不可访问 */ T encrypt(Field[] declaredFields, T paramsObject) throws IllegalAccessException; }
IDecryptUtil .java
package com.zsx.utils; /** * @author zhousx * @create 2023/10/01-22:45 **/ public interface IDecryptUtil { /** * 解密 * * @param result resultType的实例 * @return T * @throws IllegalAccessException 字段不可访问异常 */T decrypt(T result) throws IllegalAccessException; }
EncryptImpl .java
package com.zsx.utils; import com.zsx.annotation.SensitiveField; import org.springframework.stereotype.Component; import java.lang.reflect.Field; import java.util.Objects; /** * @author zhousx * @create 2023/10/01-22:45 **/ @Component public class EncryptImpl implements IEncryptUtil { @Override publicT encrypt(Field[] declaredFields, T paramsObject) throws IllegalAccessException { //取出所有被EncryptTransaction注解的字段 for (Field field : declaredFields) { SensitiveField sensitiveField = field.getAnnotation(SensitiveField.class); if (!Objects.isNull(sensitiveField)) { field.setAccessible(true); Object object = field.get(paramsObject); //暂时只实现String类型的加密 if (object instanceof String) { String value = (String) object; //修改: 如果有标识则不加密,没有则加密并加上标识前缀。(防止重复加密) String encrypt = value; //开始对字段加密使用自定义的AES加密工具 try { if(!value.startsWith(DBAESUtil.KEY_SENSITIVE)) { encrypt = DBAESUtil.encrypt(value); encrypt = DBAESUtil.KEY_SENSITIVE + encrypt; } //修改字段值 field.set(paramsObject, encrypt); } catch (Exception e) { e.printStackTrace(); } } } } return paramsObject; } }
DecryptImpl.java
package com.zsx.utils; import com.zsx.annotation.SensitiveField; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import java.lang.reflect.Field; import java.util.Objects; /** * @author zhousx * @create 2023/10/01-22:45 **/ @Component public class DecryptImpl implements IDecryptUtil { /** * 解密 * * @param result resultType的实例 */ @Override publicT decrypt(T result) throws IllegalAccessException { //取出resultType的类 Class> resultClass = result.getClass(); Field[] declaredFields = resultClass.getDeclaredFields(); for (Field field : declaredFields) { //取出所有被DecryptTransaction注解的字段 SensitiveField sensitiveField = field.getAnnotation(SensitiveField.class); if (!Objects.isNull(sensitiveField)) { field.setAccessible(true); Object object = field.get(result); //String的解密 if (object instanceof String) { String value = (String) object; //对注解的字段进行逐一解密 try { //修改:没有标识则不解密(防止重复解密) if(value.startsWith(DBAESUtil.KEY_SENSITIVE)) { value = value.substring(10); value = DBAESUtil.decrypt(value); } //修改字段值 field.set(result, value); } catch (Exception e) { e.printStackTrace(); } } } } return result; } }
package com.zsx.utils; import javax.crypto.Cipher; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.util.Base64; /** * @author zhousx * @create 2023/10/01-22:45 **/ public class DBAESUtil { /** * 加密标识:字符串有这个前缀就说明已经加密过 */ public static final String KEY_SENSITIVE = "sensitive_"; private static final String DEFAULT_V = "6859505890402435"; // 自己填写 private static final String KEY = "***"; private static final String ALGORITHM = "AES"; private static SecretKeySpec getKey() { byte[] arrBTmp = DBAESUtil.KEY.getBytes(); // 创建一个空的16位字节数组(默认值为0) byte[] arrB = new byte[16]; for (int i = 0; i < arrBTmp.length && i < arrB.length; i++) { arrB[i] = arrBTmp[i]; } return new SecretKeySpec(arrB, ALGORITHM); } /** * 加密 */ public static String encrypt(String content) throws Exception { final Base64.Encoder encoder = Base64.getEncoder(); SecretKeySpec keySpec = getKey(); Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); IvParameterSpec iv = new IvParameterSpec(DEFAULT_V.getBytes()); cipher.init(Cipher.ENCRYPT_MODE, keySpec, iv); byte[] encrypted = cipher.doFinal(content.getBytes()); return encoder.encodeToString(encrypted); } /** * 解密 */ public static String decrypt(String content) throws Exception { final Base64.Decoder decoder = Base64.getDecoder(); SecretKeySpec keySpec = getKey(); Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); IvParameterSpec iv = new IvParameterSpec(DEFAULT_V.getBytes()); cipher.init(Cipher.DECRYPT_MODE, keySpec, iv); byte[] base64 = decoder.decode(content); byte[] original = cipher.doFinal(base64); return new String(original); } }
package com.zsx.entity; import com.zsx.annotation.SensitiveField; import com.zsx.annotation.SensitiveData; import lombok.*; import java.io.Serializable; /** * @author zhousx */ @With @Builder @Data @NoArgsConstructor @AllArgsConstructor @SensitiveData // 插件只对加了该注解的类进行扫描,只有加了这个注解的类才会生效 public class User implements Serializable { private Integer id; private String name; // 表明对该字段进行加密 @SensitiveField private String email; // 表明对该字段进行加密 @SensitiveField private String phone; }
package com.zsx.mapper; import com.zsx.annotation.SensitiveField; import com.zsx.annotation.SensitiveData; import com.zsx.entity.User; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import java.util.List; @Mapper public interface IUserDao { /** * 测试查询 普通参数加密 * @param phone * @return */ ListgetUserByPhone(@SensitiveField@Param("phone") String phone); /** * 测试插入 普通参数加密,多个需要加密的字段 * @param name * @param email * @param phone * @return */ int insertUserByParam(@Param("name") String name, @SensitiveField@Param("email") String email, @SensitiveField@Param("phone") String phone); }
UserController.java
/** * Project Name: test-zsx * File Name: UserController * Package Name: com.zsx.controller * Date: 2023/9/13 11:21 * Copyright (c) 2023 天翼数字生活科技有限公司 All Rights Reserved. */ package com.zsx.controller; import com.alibaba.fastjson2.JSON; import com.zsx.entity.User; import com.zsx.service.IUserService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.List; /** * @description: 测试mybatis拦截器+注解 实现数据的自动加解密功能 * @author: zhoushaoxiong * @date: 2023/9/13 11:21 */ @RestController @RequestMapping("/user") public class UserController { @Autowired private IUserService userService; /** * 插入一条记录 * @param user * @return */ @PostMapping("/add") public String addUser(@RequestBody User user){ userService.addUser(user); return "success"; } /** * 查询一条记录 * @param id * @return */ @PostMapping("/get/one") public String getUser(Long id){ User user = userService.getUserById(id); return user.toString(); } /** * 查询全部 * @return */ @PostMapping("/get/list") public String getUserAll(){ Listusers = userService.getAllUser(); return JSON.toJSONString(users); } /** * 通过手机号查询 * @param phone * @return */ @PostMapping("/get/phone") public String getUserByPhone(String phone){ List users = userService.getUserByPhone(phone); return JSON.toJSONString(users); } /** * 通过对象查询 * @param phone * @return */ @PostMapping("/get/user/phone") public String getUserByUserPhone(String phone){ List users = userService.getUser(phone); return JSON.toJSONString(users); } /** * 批量插入 * @return */ @PostMapping("/add/list") public String addUserList(){ List users = userService.addUserList(); return JSON.toJSONString(users); } /** * 插入 dao使用@Param注解 * @return */ @PostMapping("/add/user/param") public String addUserParam(){ int result = userService.addUserByParam(); return JSON.toJSONString(result); } }
IUserDao.java
package com.zsx.mapper; import com.zsx.annotation.SensitiveField; import com.zsx.entity.User; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import java.util.List; @Mapper public interface IUserDao { /** * 测试查询 普通参数 拦截器对结果对象进行解密 * @param id * @return */ User getUserById(Long id); /** * 测试插入 传参为对象,拦截器对含有加密注解的对象的属性 进行自动加密 * @param user * @return */ int addUser(User user); /** * 测试查询 查询结果为list 需要对list结果里的对象属性进行解密 * @return */ ListgetAllUsers(); /** * 测试查询 普通参数加密 * @param phone * @return */ List getUserByPhone(@SensitiveField @Param("phone") String phone); /** * 测试查询 传参为对象 对象中的phone参数需要拦截器进行加密才能查询 * @param user * @return */ List getUserByUser(User user); /** * 测试插入 对list进行加密 * @param users * @return */ int insertBatch(List users); /** * 测试插入 使用@Param注解 对list进行加密 * @param users * @return */ int insertBatchByParam(@Param("users") List users); /** * 测试插入 普通参数加密,多个需要加密的字段 * @param name * @param email * @param phone * @return */ int insertUserByParam(@Param("name") String name, @SensitiveField @Param("email") String email, @SensitiveField @Param("phone") String phone); }
UserMapper.xml
insert into user (name, email, phone) values (#{name}, #{email}, #{phone}) insert into user (name, email, phone) values (#{item.name}, #{item.email}, #{item.phone}) insert into user (name, email, phone) values (#{item.name}, #{item.email}, #{item.phone}) insert into user (name, email, phone) values (#{name}, #{email}, #{phone})
引入依赖
com.baomidou mybatis-plus-boot-starter 3.5.2 com.alibaba.fastjson2 fastjson2 2.0.26
需要对List中的对象元素进行判断.如果对象是需要加密的,则List元素要逐一加密处理。
比如dao层方法:
int insertBatch(Listusers);
因此需要对List元素进行判断和处理:
//如果传参为List类型,对list里的对象进行加密 processListParam(parameterObject); /** * 如果传参为List类型,对list里的对象判断,是否进行加密 * @param parameterObject * @throws IllegalAccessException */ private void processListParam(Object parameterObject) throws IllegalAccessException { // mybatis会把list封装到一个ParamMap中 if (parameterObject instanceof Map) { //1. 如果不对传参users使用@Param注解,则会在map中放入collection、list、users三个键值对,这三个指向同一个内存地址即内容相同。 if (((Map) parameterObject).containsKey("list")) { Map map = (Map) parameterObject; ArrayList list = (ArrayList) map.get("list"); Object element = list.get(0); Class> elementClass = element.getClass(); SensitiveData tempSensitiveData = AnnotationUtils.findAnnotation(elementClass, SensitiveData.class); if (Objects.nonNull(tempSensitiveData)) { for (Object elementObject : list) { Field[] declaredFields = elementClass.getDeclaredFields(); IEncryptUtil.encrypt(declaredFields, elementObject); } } } //2. 如果使用了@Param注解对参数重命名为users,那么map中只会放入users、param1两个键值对,这2个指向同一个内存地址即内容相同。 if (((Map) parameterObject).containsKey("param1")) { Map map = (Map) parameterObject; Object param1 = map.get("param1"); //如果param1是ArrayList,则转为arrayList。否则不转换 if (param1 instanceof ArrayList) { ArrayList list = (ArrayList) param1; Object element = list.get(0); Class> elementClass = element.getClass(); SensitiveData tempSensitiveData = AnnotationUtils.findAnnotation(elementClass, SensitiveData.class); if (Objects.nonNull(tempSensitiveData)) { for (Object elementObject : list) { Field[] declaredFields = elementClass.getDeclaredFields(); IEncryptUtil.encrypt(declaredFields, elementObject); } } } } } }
这里需要注意,不能使用parameterObject instanceof List或parameterObject instanceof ArrayList来判断是否参数是否是列表类型。理由如下:
int insertBatch(Listusers);
int insertBatchByParam(@Param("users") Listusers);
同一个对象在进行过dao层的更新后,进行了一次加密,后续如果再用该对象进行更新操作,又会被加密一次,这导致加密了两次,而且解密不出错。
解决方法是:在加密过的字段前添加加密标识,加解密的时候先判断是否被加密过。
比如:
@Override public int saveOrUpdateUser(){ User user = new User(31,"小二", "111.com", "123411112222"); int result = userDao.updateUserByPrimaryKey(user); log.info("user: {}", user); if (result != 1){ result = userDao.addUser(user); log.info("user: {}", user); } return result; }
因此需要加密前需要判断(解密同理):
try { if(!value.startsWith(DBAESUtil.KEY_SENSITIVE)) { encrypt = DBAESUtil.encrypt(value); encrypt = DBAESUtil.KEY_SENSITIVE + encrypt; } //修改字段值 field.set(paramsObject, encrypt); } catch (Exception e) { e.printStackTrace(); }
参考链接:
- https://blog.csdn.net/relosy/article/details/123494036
- https://blog.csdn.net/relosy/article/details/123494036
- https://blog.csdn.net/wtmdcnm/article/details/115211183
- https://blog.csdn.net/qq_45454294/article/details/122012444
上一篇:MySQL经典50题