如何对手机号、身份证号、邮箱实现自动加解密,并支持模糊查询

0 阅读10分钟

一、环境准备

创建表

-- 创建数据库
create database if not exists chaoo;
-- 使用数据库
use chaoo;
-- 创建用户表
create table if not exists `user`
(
    `id`                  bigint      not null auto_increment comment '主键id',
    `user_name`           varchar(64) not null default '无名氏' comment '用户名',
    `phone`               varchar(512) comment '手机号密文',
    `email`               varchar(512) comment '邮箱密文',
    `id_card`             varchar(1024) comment '身份证号密文',
    `phone_last4_fuzzy`   varchar(64) comment '手机号后4位模糊索引',
    `id_card_last6_fuzzy` varchar(64) comment '身份证后6位模糊索引',
    `email_prefix_fuzzy`  varchar(64) comment '邮箱前缀模糊索引',
    `created_at`          datetime    not null default current_timestamp comment '创建时间',
    `updated_at`          datetime    not null default current_timestamp on update current_timestamp comment '更新时间',
    `deleted`             tinyint     not null default 1 comment '逻辑删除标记(-1:已删除,1:未删除)',
    primary key (`id`),
    key `idx_phone_last4` (`phone_last4_fuzzy`),
    key `idx_id_card_last6` (`id_card_last6_fuzzy`),
    key `idx_email_prefix` (`email_prefix_fuzzy`)
) engine = innodb
  default charset utf8mb4
  collate = utf8mb4_unicode_ci comment '用户表';

完善依赖和配置

在父工程中引入相关依赖,使得子模块都能使用


ext {
    mybatisPlusVersion = "3.5.3.1"
    druidVersion = "1.2.22"
    mysqlVersion = "8.0.33"
}
// 操作数据库的orm框架
implementation "com.baomidou:mybatis-plus-boot-starter:${mybatisPlusVersion}"
// 数据库连接池
implementation "com.alibaba:druid-spring-boot-starter:${druidVersion}"
// mysql驱动
implementation("com.mysql:mysql-connector-j:${mysqlVersion}")

在nacos上创建数据库相关的配置文件 db-mysql.yaml作为各模块的公共数据库配置

spring:
  datasource:
    type: com.alibaba.druid.pool.DruidDataSource
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/chaoo?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
    username: root
    password: root
    # 初始化连接数
    initial-size: 5
    # 最小空闲连接数
    min-idle: 5
    # 最大活跃连接数
    max-active: 20
mybatis-plus:
  configuration:
    # 开启日志打印完整sql
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

在各服务的bootstrap.yml中引入db-mysql.yaml

spring:
  cloud:
    nacos:
      config:
        extension-configs: # 加载公共配置
          - data-id: db-mysql.yaml
            group: DEFAULT_GROUP
            refresh: true

Mybatisplus配置类

package com.chaoup.provider.config;

import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class MyBatisPlusConfig {

    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor(){
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        return interceptor;
    }
}

代码生成

使用mybatisx插件,生成entity、xml、mapper、service、serviceImpl,这样基础的增删改查功能就有了

二、功能代码

加密注解

定义一个字段加密注解,用于标记实体类的哪些字段需要加密、怎么加密等

package com.chaoup.provider.annotations;

import java.lang.annotation.*;

/**
 * 敏感字段加密注解(用于标记实体类中需要加解密的字段)
 */
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
    public @interface Encrypted {
    /**
     * 加密算法,默认使用最安全的 AES/GCM 算法
     */
    String algorithm() default "AES/GCM/NoPadding";

    /**
     * 密钥ID
     */
    String keyId() default "";

    /**
     * 是否开启模糊查询(true:自动生成模糊查询辅助字段)
     */
    boolean fuzzyQuery() default false;

    /**
     * 模糊查询类型
     */
    FuzzyType fuzzyType() default FuzzyType.NONE;


    enum FuzzyType {
        /**
         * 不支持模糊查询
         */
        NONE,

        /**
         * 手机号后四位
         */
        PHONE_LAST4,

        /**
         * 邮箱前缀(@之前的部分)
         */
        EMAIL_PREFIX,

        /**
         * 身份证后六位
         */
        ID_CARD_LAST6
    }
}

加密服务

定义一个密钥服务,用于 加载、存储、提供专用密钥

package com.chaoup.provider.components;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * 密钥管理服务
 * 作用:加载、存储、提供加密或模糊查询专用密钥
 */
@Component
public class KeyService {

    /**
     * 用于保存所有的密钥(key为密钥id,value为AES密钥对象)
     */
    private final Map<String, SecretKey> keyMap = new ConcurrentHashMap<>();

    /**
     * 密钥id
     */
    @Value("${encryption.default-key-id:default}")
    private String defaultKeyId;

    /**
     * 密钥(Base64编码格式)
     */
    @Value("${encryption.key.default}")
    private String defaultKeyBase64;

    /**
     * 模糊查询专用密钥ID
     */
    @Value("${encryption.fuzzy.deterministic-key:fuzzy-key}")
    private String fuzzyKeyId;

    /**
     * 模糊查询专用密钥
     */
    @Value("${encryption.fuzzy.key}")
    private String fuzzyKeyBase64;

    /**
     * 模糊查询固定IV向量(16字节)
     */
    @Value("${encryption.fuzzy.fixed-iv}")
    private String fixedIvHex;


    /**
     * 加载所有密钥到map中
     */
   @PostConstruct
   public void init(){
       loadKey(defaultKeyId, defaultKeyBase64);
       loadKey(fuzzyKeyId, fuzzyKeyBase64);
   }

    /**
     * 根据密钥id获取密钥对象
     */
   public SecretKey getKey(String keyId){
       keyId = (keyId == null || keyId.isEmpty()) ? defaultKeyId : keyId;
       SecretKey secretKey = keyMap.get(keyId);
       if (secretKey == null) {
           throw new RuntimeException("未找到对应密钥:" + keyId);
       }
       return secretKey;
   }

    /**
     * 获取模糊查询专用密钥
     */
    public SecretKey getFuzzyKey() {
        return getKey(fuzzyKeyId);
    }

    /**
     * 获取模糊查询固定IV向量
     * @return 字节数组格式的IV
     */
    public byte[] getFixedIv() {
        return hexToByte(fixedIvHex);
    }

    /**
     * 十六进制字符串转字节数组(IV转换专用)
     */
    private byte[] hexToByte(String hex) {
        int len = hex.length();
        byte[] data = new byte[len / 2];
        for (int i = 0; i < len; i += 2) {
            data[i / 2] = (byte) ((Character.digit(hex.charAt(i), 16) << 4)
                    + Character.digit(hex.charAt(i+1), 16));
        }
        return data;
    }

    /**
     * 加载密钥:Base64字符串 → 密钥对象
     */
    private void loadKey(String keyId, String keyBase64) {
        // Base64解码为字节数组
        byte[] decodedKey = Base64.getDecoder().decode(keyBase64);
        // 转换为AES密钥
        SecretKey secretKey = new SecretKeySpec(decodedKey, "AES");
        // 存入密钥Map
        keyMap.put(keyId, secretKey);
    }
}

加密配置

对应的nacos配置(在nacos上的db-mysql.yml文件中新增如下配置)

# 加密配置
encryption:
  enabled: true
  default-key-id: default
  # 标准加密密钥
  key:
    default: 8Z/T8L+QeK2sG9dS7aP5xN3vB0nM6jH1
  # 模糊查询专用配置
  fuzzy:
    key: 9aF7gH2kL5zXcV9bN3mR5tY7uI1oP0sD
    fixed-iv: 0123456789ABCDEF0123456789ABCDEF
    deterministic-key: fuzzy-key

密钥工具类

创建密钥工具类,用于加解密操作

package com.chaoup.provider.utils;

import com.chaoup.provider.components.KeyService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.IvParameterSpec;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Base64;

/**
 * 加密工具类
 */
@Slf4j
@Component
public class CryptoUtil {

    /**
     * 标准加密算法:AES-GCM(高安全)
     */
    private static final String GCM_ALG = "AES/GCM/NoPadding";

    /**
     * 确定性加密算法:AES-CBC(模糊查询专用)
     */
    private static final String CBC_ALG = "AES/CBC/PKCS5Padding";

    /**
     * 密文前缀:标记该字符串是加密后的密文
     */
    private static final String ENCRYPTED_PREFIX = "ENC:";

    /**
     * GCM推荐的12字节IV
     */
    private static final int IV_LEN = 12;

    /**
     * 认证标签长度
     */
    private static final int TAG_LEN = 128;

    @Resource
    private KeyService keyService;


    /**
     * 加密操作
     *
     * @param plainText 明文
     * @param keyId     密钥标识
     * @return 密文
     */
    public String encrypt(String plainText, String keyId) {
        if (plainText == null || plainText.isEmpty()) {
            return plainText;
        }
        try {
            // 获取密钥
            SecretKey secretKey = keyService.getKey(keyId);

            // 生成随机IV向量(GCM推荐12字节)
            byte[] ivs = new byte[IV_LEN];
            new SecureRandom().nextBytes(ivs);

            // 创建加密器
            Cipher cipher = Cipher.getInstance(GCM_ALG);
            // GCM参数
            GCMParameterSpec spec = new GCMParameterSpec(TAG_LEN, ivs);
            // 初始化加密模式
            cipher.init(Cipher.ENCRYPT_MODE, secretKey, spec);
            // 执行加密
            byte[] cipherText = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));

            // 拼接IV + 密文
            byte[] encrypted = new byte[ivs.length + cipherText.length];
            System.arraycopy(ivs, 0, encrypted, 0, ivs.length);
            System.arraycopy(cipherText, 0, encrypted, ivs.length, cipherText.length);

            // 添加前缀并Base64编码返回
            return ENCRYPTED_PREFIX + Base64.getEncoder().encodeToString(encrypted);
        } catch (Exception e) {
            throw new RuntimeException("加密失败", e);
        }

    }

    /**
     * 解密操作
     *
     * @param encryptedText 密文
     * @param keyId         密钥标识
     * @return 明文
     */
    public String decrypt(String encryptedText, String keyId) {
        if (!isEncrypted(encryptedText)) {
            return encryptedText;
        }
        try {
            // 去掉密文前缀
            encryptedText = encryptedText.substring(ENCRYPTED_PREFIX.length());
            // Base64解码
            byte[] encryptedData = Base64.getDecoder().decode(encryptedText);
            if (encryptedData.length < IV_LEN) {
                throw new RuntimeException("密文不合法");
            }

            // 获取密钥
            SecretKey secretKey = keyService.getKey(keyId);
            Cipher cipher = Cipher.getInstance(GCM_ALG);

            // 解析IV向量(前12字节)
            byte[] ivs = Arrays.copyOfRange(encryptedData, 0, IV_LEN);
            byte[] cipherText = Arrays.copyOfRange(encryptedData, IV_LEN, encryptedData.length);
            GCMParameterSpec spec = new GCMParameterSpec(TAG_LEN, ivs);
            cipher.init(Cipher.DECRYPT_MODE, secretKey, spec);

            // 执行解密
            byte[] plaintext = cipher.doFinal(cipherText);
            return new String(plaintext, StandardCharsets.UTF_8);
        } catch (Exception e) {
            log.error("解密失败", e);
            return "DECRYPT_FAILED";
        }
    }

    /**
     * 模糊查询专用加密
     */
    public String encryptDeterministic(String plainText) {
        if (plainText == null || plainText.isEmpty()) {
            return plainText;
        }
        try {
            // 获取模糊查询专用密钥
            SecretKey key = keyService.getFuzzyKey();
            // 获取固定IV向量
            byte[] iv = keyService.getFixedIv();
            Cipher cipher = Cipher.getInstance(CBC_ALG);
            IvParameterSpec spec = new IvParameterSpec(iv);
            // 初始化加密
            cipher.init(Cipher.ENCRYPT_MODE, key, spec);
            byte[] encrypted = cipher.doFinal(plainText.getBytes());
            // Base64编码返回
            return Base64.getEncoder().encodeToString(encrypted);
        } catch (Exception e) {
            throw new RuntimeException("模糊查询加密失败", e);
        }
    }

    /**
     * 判断是否是密文
     */
    public boolean isEncrypted(String text) {
        return text != null && text.startsWith(ENCRYPTED_PREFIX);
    }

}

Mybatis拦截器

mybatis拦截器,拦截sql,进行加解密操作

package com.chaoup.provider.components;

import com.chaoup.provider.annotations.Encrypted;
import com.chaoup.provider.utils.CryptoUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.cache.CacheKey;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.plugin.*;
import org.apache.ibatis.session.ResultHandler;
import org.apache.ibatis.session.RowBounds;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.lang.reflect.Field;
import java.util.Collection;
import java.util.Map;
import java.util.Objects;

/**
 * MyBatis加解密拦截器(拦截所有增删改查SQL,自动加密或解密)
 *
 * @Intercepts 标注该类是mybatis的拦截器
 * @Signature 标注要拦截的方法
 */
@Intercepts({
        // 拦截新增或更新(insert或update)操作
        @Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class}),
        // 拦截普通查询操作
        @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
        // 拦截复杂查询(比如:分页查询、关联查询、带缓存的查询等)操作
        @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class,
                CacheKey.class, BoundSql.class})
})
@Component
@Slf4j
public class EncryptionInterceptor implements Interceptor {
    @Resource
    private CryptoUtil cryptoUtil;

    /**
     * 解密开关,默认开启
     */
    @Value("${encryption.enabled:true}")
    private boolean encryptionEnabled;

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        if (!encryptionEnabled) {
            return invocation.proceed();
        }
        Object[] args = invocation.getArgs();
        String methodName = invocation.getMethod().getName();

        // 新增或修改加密
        if ("update".equals(methodName)) {
            Object parameter = args[1];
            if (parameter != null) {
                // true=加密,false=解密
                processParameter(parameter, Boolean.TRUE);
            }
        }

        // 执行原方法
        Object result = invocation.proceed();

        // 2. 查询解密
        if ("query".equals(methodName) && Objects.nonNull(result)) {
            processQueryResult(result);
        }
        return result;
    }

    /**
     * 递归处理查询结果
     */
    private void processQueryResult(Object result) {
        // 集合类型(list或者set)
        if (result instanceof Collection) {
            ((Collection<?>) result).forEach(this::decryptObject);
            return;
        }
        // 数组类型
        if (result.getClass().isArray()) {
            for (Object obj : ((Object[]) result)) {
                decryptObject(obj);
            }
            return;
        }
        // 普通实体对象
        if (!isBasicType(result.getClass())) {
            decryptObject(result);
        }

    }

    /**
     * 递归处理参数中的实体对象,进行加密
     */
    private void processParameter(Object param, boolean encrypt) {
        if (param == null || isBasicType(param.getClass())) {
            return;
        }
        Class<?> clazz = param.getClass();

        // 处理集合
        if (param instanceof Collection) {
            ((Collection<?>) param).forEach(item -> processParameter(item, encrypt));
            return;
        }

        // 处理Map
        if (param instanceof Map) {
            ((Map<?, ?>) param).values().forEach(val -> processParameter(val, encrypt));
            return;
        }

        // 处理数组
        if (clazz.isArray()) {
            for (Object item : (Object[]) param) {
                processParameter(item, encrypt);
            }
            return;
        }

        // 处理实体类字段加解密
        processEncryptedFields(param, encrypt);
    }

    /**
     * 处理带@Encrypted注解的字段
     */
    private void processEncryptedFields(Object obj, boolean encrypt) {
        if (obj == null) {
            return;
        }
        Class<?> clazz = obj.getClass();
        // 递归处理父类字段
        while (clazz != null && clazz != Object.class) {
            for (Field field : clazz.getDeclaredFields()) {
                if (java.lang.reflect.Modifier.isStatic(field.getModifiers()) || java.lang.reflect.Modifier.isFinal(field.getModifiers())) {
                    // 跳过static或者final修饰的字段
                    continue;
                }
                if (field.isAnnotationPresent(Encrypted.class)) {
                    // 仅处理带 @Encrypted 注解的字段
                    field.setAccessible(true);
                    try {
                        Object fieldValue = field.get(obj);
                        if (fieldValue instanceof String) {
                            // 仅支持字符串类型的加解密
                            String value = (String) fieldValue;
                            Encrypted annotation = field.getAnnotation(Encrypted.class);
                            handleStringField(obj, field, value, annotation, encrypt);
                        }

                    } catch (IllegalAccessException e) {
                        log.error("字段{}反射赋值失败", field.getName(), e);
                    } catch (Exception e) {
                        log.error("字段{}加解密执行失败", field.getName(), e);
                    }
                }
            }
            clazz = clazz.getSuperclass();
        }
    }

    /**
     * 处理String类型字段的加密/解密
     */
    private void handleStringField(Object obj, Field field, String value, Encrypted annotation, boolean encrypt) throws IllegalAccessException {
        if (encrypt) {
            // 加密:防止重复加密
            if (org.springframework.util.StringUtils.hasText(value) && !cryptoUtil.isEncrypted(value)) {
                String encryptedValue = cryptoUtil.encrypt(value, annotation.keyId());
                field.set(obj, encryptedValue);
                // 自动生成模糊查询字段
                generateFuzzyField(obj, field, annotation, value);
                log.debug("字段加密成功 → 类:{} 字段:{}", obj.getClass().getSimpleName(), field.getName());
            }
        } else {
            // 解密:仅解密密文
            if (org.springframework.util.StringUtils.hasText(value) && cryptoUtil.isEncrypted(value)) {
                String decryptedValue = cryptoUtil.decrypt(value, annotation.keyId());
                field.set(obj, decryptedValue);
                log.debug("字段解密成功 → 类:{} 字段:{}", obj.getClass().getSimpleName(), field.getName());
            }
        }
    }

    /**
     * 自动生成模糊查询辅助字段
     */
    private void generateFuzzyField(Object obj, Field field, Encrypted anno, String plainText) {
        if (!anno.fuzzyQuery() || anno.fuzzyType() == Encrypted.FuzzyType.NONE) return;

        String fragment = switch (anno.fuzzyType()) {
            case PHONE_LAST4 -> plainText.length() >= 4 ? plainText.substring(plainText.length() - 4) : plainText;
            case ID_CARD_LAST6 -> plainText.length() >= 6 ? plainText.substring(plainText.length() - 6) : plainText;
            case EMAIL_PREFIX -> plainText.split("@")[0];
            default -> null;
        };
        if (fragment == null) return;

        // 确定性加密
        String encryptedFragment = cryptoUtil.encryptDeterministic(fragment);
        // 自动赋值辅助字段
        try {
            String fuzzyFieldName = getFuzzyFieldName(field.getName(), anno.fuzzyType());
            Field fuzzyField = obj.getClass().getDeclaredField(fuzzyFieldName);
            fuzzyField.setAccessible(true);
            fuzzyField.set(obj, encryptedFragment);
        } catch (Exception e) {
            log.warn("模糊字段赋值失败", e);
        }
    }

    private String getFuzzyFieldName(String fieldName, Encrypted.FuzzyType type) {
        return switch (type) {
            case PHONE_LAST4 -> fieldName + "Last4Fuzzy";
            case ID_CARD_LAST6 -> fieldName + "Last6Fuzzy";
            case EMAIL_PREFIX -> fieldName + "PrefixFuzzy";
            default -> fieldName + "Fuzzy";
        };
    }

    /**
     * 解密对象
     */
    private void decryptObject(Object obj) {
        processEncryptedFields(obj, Boolean.FALSE);
    }

    /**
     * 判断是否为基础类型数据
     */
    private boolean isBasicType(Class<?> clazz) {
        return clazz.isPrimitive()
                || clazz == String.class
                || Number.class.isAssignableFrom(clazz)
                || clazz == Boolean.class
                || clazz == Character.class
                || clazz.getClassLoader() == null; // jdk原生类(如:Date、LocalDateTime等)
    }

    /**
     * 拦截器注册
     */
    @Override
    public Object plugin(Object target) {
        return Plugin.wrap(target, this);
    }

}

三、业务代码

实体类

package com.chaoup.provider.domain;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.chaoup.provider.annotations.Encrypted;
import lombok.Data;

import java.time.LocalDate;

/**
 * 用户表
 * @TableName user
 */
@TableName(value ="user")
@Data
public class User {
    /**
     * 主键id
     */
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;

    /**
     * 用户名
     */
    @TableField(value = "user_name")
    private String userName;

    /**
     * 手机号(密文)
     */
    @Encrypted(fuzzyQuery = true, fuzzyType = Encrypted.FuzzyType.PHONE_LAST4)
    @TableField(value = "phone")
    private String phone;

    /**
     * 邮箱(密文)
     */
    @Encrypted(fuzzyQuery = true, fuzzyType = Encrypted.FuzzyType.EMAIL_PREFIX)
    @TableField(value = "email")
    private String email;

    /**
     * 身份证号(密文)
     */
    @Encrypted(fuzzyQuery = true, fuzzyType = Encrypted.FuzzyType.ID_CARD_LAST6)
    @TableField(value = "id_card")
    private String idCard;

    /**
     * 手机后4位(密文)
     */
    @TableField(value = "phone_last4_fuzzy")
    private String phoneLast4Fuzzy;

    /**
     * 身份证后6位(密文)
     */
    @TableField(value = "id_card_last6_fuzzy")
    private String idCardLast6Fuzzy;

    /**
     * 邮箱前缀(密文)
     */
    @TableField(value = "email_prefix_fuzzy")
    private String emailPrefixFuzzy;

    /**
     * 创建时间
     */
    @TableField(value = "created_at")
    private LocalDate createdAt;

    /**
     * 更新时间
     */
    @TableField(value = "updated_at")
    private LocalDate updatedAt;

    /**
     * 逻辑删除标记(-1:已删除,1:未删除)
     */
    @TableField(value = "deleted")
    private Integer deleted;
}

Mapper接口

package com.chaoup.provider.mapper;

import com.chaoup.provider.domain.User;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;

import java.util.List;

/**
* @author chaoy
* @description 针对表【user(用户表)】的数据库操作Mapper
* @createDate 2026-03-15 23:16:05
* @Entity com.chaoup.provider.domain.User
*/
@Mapper
public interface UserMapper extends BaseMapper<User> {

    @Select("select * from user where phone_last4_fuzzy = #{encrypted}")
    List<User> selectByPhoneLast4(@Param("encrypted") String encrypted);

    @Select("select * from user where id_card_last6_fuzzy = #{encrypted}")
    List<User> selectByIdCardLast6(@Param("encrypted") String encrypted);

    @Select("select * from user where email_prefix_fuzzy = #{encrypted}")
    List<User> selectByEmailPrefix(@Param("encrypted") String encrypted);
}

Service实现类

package com.chaoup.provider.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.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.chaoup.provider.domain.User;
import com.chaoup.provider.domain.dto.UserAddOrUpdateDTO;
import com.chaoup.provider.domain.dto.UserPageQueryDTO;
import com.chaoup.provider.mapper.UserMapper;
import com.chaoup.provider.service.UserService;
import com.chaoup.provider.utils.CryptoUtil;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;

import javax.annotation.Resource;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;

/**
 * @author chaoy
 * @description 针对表【user(用户表)】的数据库操作Service实现
 * @createDate 2026-03-15 23:16:05
 */
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User>
        implements UserService {

    @Resource
    private UserMapper userMapper;

    @Resource
    private CryptoUtil cryptoUtil;

    @Override
    @Transactional(rollbackFor = Exception.class)
    public void save(UserAddOrUpdateDTO addOrUpdateDTO) {
        User user = new User();
        user.setUserName(addOrUpdateDTO.getUserName());
        user.setPhone(addOrUpdateDTO.getPhone());
        user.setEmail(addOrUpdateDTO.getEmail());
        user.setIdCard(addOrUpdateDTO.getIdCard());
        user.setCreatedAt(LocalDate.now());
        userMapper.insert(user);
    }


    @Override
    @Transactional(rollbackFor = Exception.class)
    public void update(UserAddOrUpdateDTO addOrUpdateDTO) {
        Long id = addOrUpdateDTO.getId();
        if (id == null) {
            throw new RuntimeException("主键id不能为空");
        }
        User oldUser = userMapper.selectById(id);
        if (oldUser == null) {
            throw new RuntimeException("用户不存在");
        }
        Optional.ofNullable(addOrUpdateDTO.getUserName()).ifPresent(oldUser::setUserName);
        Optional.ofNullable(addOrUpdateDTO.getEmail()).ifPresent(oldUser::setEmail);
        Optional.ofNullable(addOrUpdateDTO.getPhone()).ifPresent(oldUser::setPhone);
        Optional.ofNullable(addOrUpdateDTO.getIdCard()).ifPresent(oldUser::setIdCard);
        oldUser.setUpdatedAt(LocalDate.now());
        userMapper.insert(oldUser);
    }

    @Override
    public List<User> listByPhoneLast4(String last4) {
        String encrypted = cryptoUtil.encryptDeterministic(last4);
        return userMapper.selectByPhoneLast4(encrypted);
    }

    @Override
    public List<User> findByIdCardLast6(String last6) {
        String encrypted = cryptoUtil.encryptDeterministic(last6);
        return userMapper.selectByIdCardLast6(encrypted);
    }

    @Override
    public List<User> findByEmailPrefix(String prefix) {
        String encrypted = cryptoUtil.encryptDeterministic(prefix);
        return userMapper.selectByEmailPrefix(encrypted);
    }

    @Override
    public IPage<User> pageQuery(UserPageQueryDTO pageQueryDTO) {
        Page<User> page = new Page<>(pageQueryDTO.getPageNum(), pageQueryDTO.getPageSize());
        LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
        String phoneLast4 = pageQueryDTO.getPhoneLast4();
        String idCardLast6 = pageQueryDTO.getIdCardLast6();
        String emailPrefix = pageQueryDTO.getEmailPrefix();

        wrapper.eq(phoneLast4 != null && !phoneLast4.isEmpty(), User::getPhoneLast4Fuzzy,cryptoUtil.encryptDeterministic(phoneLast4))
                .eq(idCardLast6 != null && !idCardLast6.isEmpty(), User::getIdCardLast6Fuzzy,cryptoUtil.encryptDeterministic(idCardLast6))
                .eq(emailPrefix != null && !emailPrefix.isEmpty(), User::getEmailPrefixFuzzy,cryptoUtil.encryptDeterministic(emailPrefix));
        
        return userMapper.selectPage(page, wrapper);
    }


}





Service接口

package com.chaoup.provider.service;

import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.IService;
import com.chaoup.provider.domain.User;
import com.chaoup.provider.domain.dto.UserAddOrUpdateDTO;
import com.chaoup.provider.domain.dto.UserPageQueryDTO;

import java.util.List;

/**
* @author chaoy
* @description 针对表【user(用户表)】的数据库操作Service
* @createDate 2026-03-15 23:16:05
*/
public interface UserService extends IService<User> {

    void save(UserAddOrUpdateDTO addOrUpdateDTO);

    void update(UserAddOrUpdateDTO addOrUpdateDTO);

    List<User> listByPhoneLast4(String last4);

    List<User> findByIdCardLast6(String last6);

    List<User> findByEmailPrefix(String prefix);

    IPage<User> pageQuery(UserPageQueryDTO pageQueryDTO);
}

Controller控制器

package com.chaoup.provider.controller;

import com.baomidou.mybatisplus.core.metadata.IPage;
import com.chaoup.provider.domain.User;
import com.chaoup.provider.domain.dto.UserAddOrUpdateDTO;
import com.chaoup.provider.domain.dto.UserPageQueryDTO;
import com.chaoup.provider.service.UserService;
import org.springframework.web.bind.annotation.*;

import javax.annotation.Resource;
import java.util.List;

@RestController
@RequestMapping("/users")
public class UserController {

    @Resource
    private UserService userService;


    @PostMapping
    public String add(@RequestBody UserAddOrUpdateDTO addOrUpdateDTO){
        userService.save(addOrUpdateDTO);
        return "保存成功";
    }

    @PutMapping
    public String update(@RequestBody UserAddOrUpdateDTO addOrUpdateDTO){
        userService.update(addOrUpdateDTO);
        return "保存成功";
    }

    @GetMapping("phone/last4/{last4}")
    public List<User> searchByPhoneLast4(@PathVariable String last4){
      return  userService.listByPhoneLast4(last4);
    }

    @GetMapping("/idcard/last6/{last6}")
    public List<User> findByIdCardLast6(@PathVariable String last6) {
        return userService.findByIdCardLast6(last6);
    }

    // 3. 邮箱前缀查询(如 zhangsan 对应 zhangsan@xxx.com)
    @GetMapping("/email/prefix/{prefix}")
    public List<User> findByEmailPrefix(@PathVariable String prefix) {
        return userService.findByEmailPrefix(prefix);
    }

    @GetMapping("/page")
    public IPage<User> pageQuery(UserPageQueryDTO pageQueryDTO){
        return userService.pageQuery(pageQueryDTO);
    }

}

四、测试

新增

插入数据(可以插入多条)

POST http://localhost:8081/users
Content-Type: application/json

{
  "userName": "chaoo3",
  "phone": "13276528371",
  "email": "13276528371@163.com",
  "idCard": "965550853939952010"
}

插入了6条数据

查询

按手机号后4位查询

GET http://localhost:8081/users/phone/last4/5678

按身份证后6位查询

GET http://localhost:8081/users/idcard/last6/952010

按邮箱前缀查询

GET http://localhost:8081/users/email/prefix/13276528371

再插入几条数据,手机号后4位、身份证后6位、邮箱前缀其中有相同的

分页查询:手机号后4位为8371,且身份证号后4位为952010的用户有哪些

GET http://localhost:8081/users/page?pageNum=1&pageSize=3&phoneLast4=8371&idCardLast6=952010