diff --git a/pom.xml b/pom.xml index 1ae5f43..0f794b0 100644 --- a/pom.xml +++ b/pom.xml @@ -48,6 +48,7 @@ 33.0.0-jre 5.8.26 3.12.0 + 2.0.0 @@ -168,6 +169,13 @@ commons-lang3 ${commons-lang3.version} + + + + com.aliyun + dypnsapi20170525 + ${dypnsapi.version} + diff --git a/xiaoyi-auth/pom.xml b/xiaoyi-auth/pom.xml index 94d3c06..dc79e32 100644 --- a/xiaoyi-auth/pom.xml +++ b/xiaoyi-auth/pom.xml @@ -76,6 +76,12 @@ org.apache.commons commons-pool2 + + + + com.aliyun + dypnsapi20170525 + diff --git a/xiaoyi-auth/src/main/java/top/crushtj/xiaoyishu/auth/constant/RedisKeyConstants.java b/xiaoyi-auth/src/main/java/top/crushtj/xiaoyishu/auth/constant/RedisKeyConstants.java index c28763b..2f60fc2 100644 --- a/xiaoyi-auth/src/main/java/top/crushtj/xiaoyishu/auth/constant/RedisKeyConstants.java +++ b/xiaoyi-auth/src/main/java/top/crushtj/xiaoyishu/auth/constant/RedisKeyConstants.java @@ -15,7 +15,10 @@ public class RedisKeyConstants { * 验证码 KEY 前缀 */ private static final String VERIFICATION_CODE_KEY_PREFIX = "verification_code:"; - + /** + * 验证码 KEY 过期时间 (分钟) + */ + public static final long VERIFICATION_CODE_EXPIRE_TIME = 3; /** * 构建验证码 KEY * diff --git a/xiaoyi-auth/src/main/java/top/crushtj/xiaoyishu/auth/enums/ResponseCodeEnum.java b/xiaoyi-auth/src/main/java/top/crushtj/xiaoyishu/auth/enums/ResponseCodeEnum.java index 37fe4b0..096307b 100644 --- a/xiaoyi-auth/src/main/java/top/crushtj/xiaoyishu/auth/enums/ResponseCodeEnum.java +++ b/xiaoyi-auth/src/main/java/top/crushtj/xiaoyishu/auth/enums/ResponseCodeEnum.java @@ -2,8 +2,6 @@ package top.crushtj.xiaoyishu.auth.enums; import lombok.AllArgsConstructor; import lombok.Getter; -import java.lang.String; - import top.crushtj.framework.common.exception.BaseExceptionInterface; /** @@ -18,9 +16,10 @@ import top.crushtj.framework.common.exception.BaseExceptionInterface; @AllArgsConstructor public enum ResponseCodeEnum implements BaseExceptionInterface { // -------- 通用异常状态码 -------- - SYSTEM_ERROR("AUTH-10000", "系统错误"), - PARAM_NOT_VALID("AUTH-10001", "参数错误"), - VERIFICATION_CODE_SEND_FREQUENTLY("AUTH-20000", "请求太频繁,请3分钟后再试"); + SYSTEM_ERROR("AUTH-10000", "系统错误"), PARAM_NOT_VALID("AUTH-10001", "参数错误"), // -------- 验证码异常状态码 -------- + VERIFICATION_CODE_SEND_FREQUENTLY("AUTH-20000", "请求太频繁,请3分钟后再试"), + SMS_SEND_FAILED("AUTH-20001", "短信发送失败,请稍后再试"), + SMS_SEND_TIMEOUT("AUTH-20002", "短信发送超时,请稍后再试"); /** * 错误码 diff --git a/xiaoyi-auth/src/main/java/top/crushtj/xiaoyishu/auth/model/vo/verificationcode/SendVerificationCodeReqVO.java b/xiaoyi-auth/src/main/java/top/crushtj/xiaoyishu/auth/model/vo/verificationcode/SendVerificationCodeReqVO.java index 80c727b..19f022b 100644 --- a/xiaoyi-auth/src/main/java/top/crushtj/xiaoyishu/auth/model/vo/verificationcode/SendVerificationCodeReqVO.java +++ b/xiaoyi-auth/src/main/java/top/crushtj/xiaoyishu/auth/model/vo/verificationcode/SendVerificationCodeReqVO.java @@ -25,6 +25,6 @@ public class SendVerificationCodeReqVO { * 手机号 */ @NotBlank(message = "手机号不能为空") - private String phone; + private String phoneNumber; } diff --git a/xiaoyi-auth/src/main/java/top/crushtj/xiaoyishu/auth/service/impl/VerificationCodeServiceImpl.java b/xiaoyi-auth/src/main/java/top/crushtj/xiaoyishu/auth/service/impl/VerificationCodeServiceImpl.java index a08993f..84b9a27 100644 --- a/xiaoyi-auth/src/main/java/top/crushtj/xiaoyishu/auth/service/impl/VerificationCodeServiceImpl.java +++ b/xiaoyi-auth/src/main/java/top/crushtj/xiaoyishu/auth/service/impl/VerificationCodeServiceImpl.java @@ -1,51 +1,102 @@ package top.crushtj.xiaoyishu.auth.service.impl; + import cn.hutool.core.util.RandomUtil; -import top.crushtj.framework.common.exception.BizException; -import top.crushtj.framework.common.response.Response; -import top.crushtj.xiaoyishu.auth.constant.RedisKeyConstants; -import top.crushtj.xiaoyishu.auth.enums.ResponseCodeEnum; -import top.crushtj.xiaoyishu.auth.model.vo.verificationcode.SendVerificationCodeReqVO; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import org.springframework.stereotype.Service; - +import top.crushtj.framework.common.exception.BizException; +import top.crushtj.framework.common.response.Response; +import top.crushtj.framework.common.utils.MaskUtils; +import top.crushtj.xiaoyishu.auth.constant.RedisKeyConstants; +import top.crushtj.xiaoyishu.auth.enums.ResponseCodeEnum; +import top.crushtj.xiaoyishu.auth.model.vo.verificationcode.SendVerificationCodeReqVO; import top.crushtj.xiaoyishu.auth.service.VerificationCodeService; +import top.crushtj.xiaoyishu.auth.sms.AliyunSmsHelper; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import static top.crushtj.xiaoyishu.auth.constant.RedisKeyConstants.VERIFICATION_CODE_EXPIRE_TIME; + +/** + * 验证码服务实现类 + * 修复点:异步异常捕获、Redis过期时间、验证码日志脱敏、手机号校验、线程任务跟踪 + */ @Service @Slf4j public class VerificationCodeServiceImpl implements VerificationCodeService { + // 短信发送超时时间(秒) + private static final int SMS_SEND_TIMEOUT_SECONDS = 5; @Resource private RedisTemplate redisTemplate; + @Resource(name = "taskExecutor") + private ThreadPoolTaskExecutor threadPoolTaskExecutor; + + @Resource + private AliyunSmsHelper aliyunSmsHelper; + @Override public Response send(SendVerificationCodeReqVO sendVerificationCodeReqVO) { - // 手机号 - String phone = sendVerificationCodeReqVO.getPhone(); + // 1. 获取并校验手机号格式 + String phoneNumber = sendVerificationCodeReqVO.getPhoneNumber(); - // 构建验证码 redis key - String key = RedisKeyConstants.buildVerificationCodeKey(phone); - - // 判断是否已发送验证码 + // 2. 构建Redis Key并检查发送频率 + String key = RedisKeyConstants.buildVerificationCodeKey(phoneNumber); boolean isSent = redisTemplate.hasKey(key); if (isSent) { - // 若之前发送的验证码未过期,则提示发送频繁 throw new BizException(ResponseCodeEnum.VERIFICATION_CODE_SEND_FREQUENTLY); } - // 生成 6 位随机数字验证码 + // 3. 生成6位随机验证码 String verificationCode = RandomUtil.randomNumbers(6); - // todo: 调用第三方短信发送服务 + // 4. 异步发送短信(用CompletableFuture跟踪任务状态,捕获异常) + CompletableFuture smsSendFuture = CompletableFuture.supplyAsync(() -> { + // 设置线程名称,便于日志排查 + Thread.currentThread().setName("sms-send-" + MaskUtils.maskMobile(phoneNumber)); + String signName = "速通互联验证码"; + String templateCode = "100001"; + String templateParam = String.format("{\"code\":\"%s\",\"min\":\"%d\"}", verificationCode, VERIFICATION_CODE_EXPIRE_TIME); + try { + return aliyunSmsHelper.sendMessage(signName, templateCode, phoneNumber, templateParam); + } catch (Exception e) { + log.error("==> 手机号: {}, 短信发送接口调用异常", MaskUtils.maskMobile(phoneNumber), e); + return false; + } + }, threadPoolTaskExecutor); - log.info("==> 手机号: {}, 已发送验证码:【{}】", phone, verificationCode); + // 5. 同步等待短信发送结果(超时控制,避免主线程阻塞过久) + boolean smsSendSuccess; + try { + smsSendSuccess = smsSendFuture.get(SMS_SEND_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (InterruptedException e) { + log.error("==> 手机号: {}, 短信发送任务被中断", MaskUtils.maskMobile(phoneNumber), e); + Thread.currentThread().interrupt(); // 恢复中断状态 + throw new BizException(ResponseCodeEnum.SMS_SEND_FAILED); + } catch (ExecutionException e) { + log.error("==> 手机号: {}, 短信发送任务执行异常", MaskUtils.maskMobile(phoneNumber), e); + throw new BizException(ResponseCodeEnum.SMS_SEND_FAILED); + } catch (TimeoutException e) { + log.error("==> 手机号: {}, 短信发送任务超时({}秒)", MaskUtils.maskMobile(phoneNumber), SMS_SEND_TIMEOUT_SECONDS, e); + throw new BizException(ResponseCodeEnum.SMS_SEND_TIMEOUT); + } - // 存储验证码到 redis, 并设置过期时间为 3 分钟 - redisTemplate.opsForValue().set(key, verificationCode, 3, TimeUnit.MINUTES); + // 6. 短信发送失败则直接抛异常,不存储Redis + if (!smsSendSuccess) { + log.error("==> 手机号: {}, 发送验证码失败(第三方接口返回失败)", MaskUtils.maskMobile(phoneNumber)); + throw new BizException(ResponseCodeEnum.SMS_SEND_FAILED); + } + + // 7. 短信发送成功后,记录日志(验证码脱敏,仅保留后2位)+ 存储Redis + log.info("==> 手机号: {}, 已发送验证码:【****{}】", MaskUtils.maskMobile(phoneNumber), verificationCode.substring(4)); + redisTemplate.opsForValue().set(key, verificationCode, VERIFICATION_CODE_EXPIRE_TIME, TimeUnit.MINUTES); return Response.success(); } -} +} \ No newline at end of file diff --git a/xiaoyi-auth/src/main/java/top/crushtj/xiaoyishu/auth/sms/AliyunAccessKeyProperties.java b/xiaoyi-auth/src/main/java/top/crushtj/xiaoyishu/auth/sms/AliyunAccessKeyProperties.java new file mode 100644 index 0000000..13fdcc2 --- /dev/null +++ b/xiaoyi-auth/src/main/java/top/crushtj/xiaoyishu/auth/sms/AliyunAccessKeyProperties.java @@ -0,0 +1,21 @@ +package top.crushtj.xiaoyishu.auth.sms; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * @author ayi + * @version V1.0 + * @title AliyunAccessKeyProperties + * @date 2026/01/16 23:31 + * @description 阿里云访问密钥配置 + */ + +@ConfigurationProperties(prefix = "aliyun") +@Component +@Data +public class AliyunAccessKeyProperties { + private String accessKeyId; + private String accessKeySecret; +} diff --git a/xiaoyi-auth/src/main/java/top/crushtj/xiaoyishu/auth/sms/AliyunSmsClientConfig.java b/xiaoyi-auth/src/main/java/top/crushtj/xiaoyishu/auth/sms/AliyunSmsClientConfig.java new file mode 100644 index 0000000..e81a5ab --- /dev/null +++ b/xiaoyi-auth/src/main/java/top/crushtj/xiaoyishu/auth/sms/AliyunSmsClientConfig.java @@ -0,0 +1,42 @@ +package top.crushtj.xiaoyishu.auth.sms; + +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * @author ayi + * @version V1.0 + * @title AliyunSmsClientConfig + * @date 2026/01/16 23:48 + * @description 阿里云短信配置 + */ + +@Slf4j +@Configuration +public class AliyunSmsClientConfig { + @Resource + private AliyunAccessKeyProperties aliyunAccessKeyProperties; + + @Bean + public com.aliyun.dypnsapi20170525.Client smsClient() { + try { + com.aliyun.credentials.Client credential = new com.aliyun.credentials.Client(); + + com.aliyun.teaopenapi.models.Config config = new com.aliyun.teaopenapi.models.Config() + .setCredential(credential); + + // Endpoint 请参考 https://api.aliyun.com/product/Dypnsapi + config.endpoint = "dypnsapi.aliyuncs.com"; + + config.accessKeyId = aliyunAccessKeyProperties.getAccessKeyId(); + config.accessKeySecret = aliyunAccessKeyProperties.getAccessKeySecret(); + + return new com.aliyun.dypnsapi20170525.Client(config); + } catch (Exception e) { + log.error("初始化阿里云短信发送客户端错误: ", e); + return null; + } + } +} diff --git a/xiaoyi-auth/src/main/java/top/crushtj/xiaoyishu/auth/sms/AliyunSmsHelper.java b/xiaoyi-auth/src/main/java/top/crushtj/xiaoyishu/auth/sms/AliyunSmsHelper.java new file mode 100644 index 0000000..41bff0f --- /dev/null +++ b/xiaoyi-auth/src/main/java/top/crushtj/xiaoyishu/auth/sms/AliyunSmsHelper.java @@ -0,0 +1,49 @@ +package top.crushtj.xiaoyishu.auth.sms; + +import com.aliyun.dypnsapi20170525.models.SendSmsVerifyCodeResponse; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import top.crushtj.framework.common.utils.JsonUtils; +import top.crushtj.framework.common.utils.MaskUtils; + +/** + * @author ayi + * @version V1.0 + * @title AliyunSmsHelper + * @date 2026/01/16 23:36 + * @description 阿里云短信工具 + */ + +@Slf4j +@Component +public class AliyunSmsHelper { + + @Resource + private com.aliyun.dypnsapi20170525.Client client; + + public boolean sendMessage(String signName, String templateCode, String phoneNumber, String templateParam) { + com.aliyun.dypnsapi20170525.models.SendSmsVerifyCodeRequest sendSmsVerifyCodeRequest = new com.aliyun.dypnsapi20170525.models.SendSmsVerifyCodeRequest() + .setSignName(signName) + .setTemplateCode(templateCode) + .setPhoneNumber(phoneNumber) + .setTemplateParam(templateParam); + + com.aliyun.teautil.models.RuntimeOptions runtime = new com.aliyun.teautil.models.RuntimeOptions(); + + try { + log.info("==> 开始短信发送, phone: {}, signName: {}, templateCode: {}, templateParam: {}", MaskUtils.maskMobile(phoneNumber), signName, templateCode, templateParam); + + // 发送短信 + SendSmsVerifyCodeResponse response = client.sendSmsVerifyCodeWithOptions(sendSmsVerifyCodeRequest, runtime); + + boolean success = response.getBody().success; + log.info("==> 短信发送状态: {}, response: {}", success, JsonUtils.toJsonString(response)); + return success; + } catch (Exception error) { + log.error("==> 短信发送错误: ", error); + return false; + } + } + +} diff --git a/xiaoyi-auth/src/main/resources/config/application-dev.yml b/xiaoyi-auth/src/main/resources/config/application-dev.yml index 0a3d0bc..b75cb09 100644 --- a/xiaoyi-auth/src/main/resources/config/application-dev.yml +++ b/xiaoyi-auth/src/main/resources/config/application-dev.yml @@ -71,4 +71,8 @@ mybatis-plus: logging: level: - top.crushtj.xiaoyishu.auth.domain.mappers: debug \ No newline at end of file + top.crushtj.xiaoyishu.auth.domain.mappers: debug + +aliyun: # 接入阿里云(发送短信使用) + accessKeyId: + accessKeySecret: \ No newline at end of file diff --git a/xiaoyi-auth/src/test/java/top/crushtj/xiaoyishu/auth/DruidTest.java b/xiaoyi-auth/src/test/java/top/crushtj/xiaoyishu/auth/DruidTest.java deleted file mode 100644 index 654f273..0000000 --- a/xiaoyi-auth/src/test/java/top/crushtj/xiaoyishu/auth/DruidTest.java +++ /dev/null @@ -1,40 +0,0 @@ -package top.crushtj.xiaoyishu.auth; - -import com.alibaba.druid.filter.config.ConfigTools; -import lombok.SneakyThrows; -import lombok.extern.slf4j.Slf4j; -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -/** - * @author ayi - * @version V1.0 - * @title DruidTest - * @description - * @date 2025/12/01 - */ - -@SpringBootTest -@Slf4j -public class DruidTest { - - /** - * Druid 密码加密 - */ - @Test - @SneakyThrows - void testEncodePassword() { - // 你的密码 - String password = "HhpxE2HWE4bGTyB5"; - String[] arr = ConfigTools.genKeyPair(512); - - // 私钥 - log.info("privateKey: {}", arr[0]); - // 公钥 - log.info("publicKey: {}", arr[1]); - - // 通过私钥加密密码 - String encodePassword = ConfigTools.encrypt(arr[0], password); - log.info("password: {}", encodePassword); - } -} diff --git a/xiaoyi-auth/src/test/java/top/crushtj/xiaoyishu/auth/EncryptTest.java b/xiaoyi-auth/src/test/java/top/crushtj/xiaoyishu/auth/EncryptTest.java new file mode 100644 index 0000000..8d0c99d --- /dev/null +++ b/xiaoyi-auth/src/test/java/top/crushtj/xiaoyishu/auth/EncryptTest.java @@ -0,0 +1,63 @@ +package top.crushtj.xiaoyishu.auth; + +import com.alibaba.druid.filter.config.ConfigTools; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.jasypt.encryption.pbe.PooledPBEStringEncryptor; +import org.jasypt.encryption.pbe.config.SimpleStringPBEConfig; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * @author ayi + * @version V1.0 + * @title DruidTest + * @description + * @date 2025/12/01 + */ + +@SpringBootTest +@Slf4j +public class EncryptTest { + //@Autowired + //private StringEncryptor defaultLazyEncryptor; + //private StringEncryptor pooledPbeStringEncryptor; + + /** + * Druid 密码加密 + */ + @Test + @SneakyThrows + void testEncodePassword() { + // 你的密码 + String password = "HhpxE2HWE4bGTyB5"; + String[] arr = ConfigTools.genKeyPair(512); + + // 私钥 + log.info("privateKey: {}", arr[0]); + // 公钥 + log.info("publicKey: {}", arr[1]); + + // 通过私钥加密密码 + String encodePassword = ConfigTools.encrypt(arr[0], password); + log.info("password: {}", encodePassword); + } + + @Test + void smsEncode() { + String accessKeyId = manualEncrypt("", "Yu020320."); + System.out.println("accessKeyId:" + accessKeyId); + String accessKeySecret = manualEncrypt("", "Yu020320."); + System.out.println("accessKeySecret:" + accessKeySecret); + } + + private String manualEncrypt(String plainText, String secretKey) { + PooledPBEStringEncryptor encryptor = new PooledPBEStringEncryptor(); + SimpleStringPBEConfig config = new SimpleStringPBEConfig(); + config.setPassword(secretKey); // 密钥 + config.setAlgorithm("PBEWithMD5AndDES"); // 算法 + config.setPoolSize(1); // 池大小(默认) + encryptor.setConfig(config); + return encryptor.encrypt(plainText); + } +} diff --git a/xiaoyi-framework/xiaoyi-common/src/main/java/top/crushtj/framework/common/utils/MaskUtils.java b/xiaoyi-framework/xiaoyi-common/src/main/java/top/crushtj/framework/common/utils/MaskUtils.java new file mode 100644 index 0000000..8a22336 --- /dev/null +++ b/xiaoyi-framework/xiaoyi-common/src/main/java/top/crushtj/framework/common/utils/MaskUtils.java @@ -0,0 +1,143 @@ +package top.crushtj.framework.common.utils; + +import org.apache.commons.lang3.StringUtils; + +/** + * @author ayi + * @version V1.0 + * @title MaskUtils + * @date 2026/01/17 13:22 + * @description 数据脱敏工具类 + */ + +public class MaskUtils { + + // ===================== 通用脱敏方法(基础) ===================== + /** + * 通用脱敏方法:保留前prefixLen位,后suffixLen位,中间用*填充 + * @param content 原始字符串 + * @param prefixLen 保留前缀长度 + * @param suffixLen 保留后缀长度 + * @return 脱敏后的字符串 + */ + public static String maskGeneral(String content, int prefixLen, int suffixLen) { + // 空值处理:null/空串直接返回,避免NPE + if (StringUtils.isBlank(content)) { + return content; + } + int contentLen = content.length(); + // 若长度小于等于前缀+后缀,直接返回原字符串(避免过度脱敏) + if (contentLen <= prefixLen + suffixLen) { + return content; + } + // 拼接前缀 + 中间* + 后缀 + StringBuilder sb = new StringBuilder(); + // 添加前缀 + sb.append(content.substring(0, prefixLen)); + // 添加中间掩码(长度=总长度-前缀-后缀) + sb.append("*".repeat(Math.max(0, contentLen - prefixLen - suffixLen))); + // 添加后缀 + sb.append(content.substring(contentLen - suffixLen)); + return sb.toString(); + } + + // ===================== 常用敏感字段脱敏方法(业务封装) ===================== + + /** + * 手机号脱敏:保留前3位,后4位,中间4位掩码(如:138****1234) + * @param mobile 手机号(支持11位常规手机号) + * @return 脱敏后的手机号 + */ + public static String maskMobile(String mobile) { + // 先校验手机号格式(简单校验,可根据业务调整) + if (StringUtils.isBlank(mobile) || !mobile.matches("^1[3-9]\\d{9}$")) { + return mobile; + } + return maskGeneral(mobile, 3, 4); + } + + /** + * 邮箱脱敏:保留前缀前3位,@及域名完整,中间掩码(如:123****@qq.com) + * @param email 邮箱地址 + * @return 脱敏后的邮箱 + */ + public static String maskEmail(String email) { + if (StringUtils.isBlank(email) || !email.contains("@")) { + return email; + } + String[] parts = email.split("@"); + String prefix = parts[0]; + String domain = parts[1]; + // 前缀长度<=3时不脱敏,否则保留前3位 + String maskedPrefix = prefix.length() <= 3 ? prefix : maskGeneral(prefix, 3, 0); + return maskedPrefix + "@" + domain; + } + + /** + * 姓名脱敏: + * - 单字名:直接返回(如:李 → 李) + * - 两字名:保留姓,名掩码(如:李白 → 李*) + * - 三字及以上:保留姓和最后一个字,中间掩码(如:李世民 → 李*民) + * @param name 姓名 + * @return 脱敏后的姓名 + */ + public static String maskName(String name) { + if (StringUtils.isBlank(name)) { + return name; + } + int nameLen = name.length(); + if (nameLen == 1) { + return name; + } else if (nameLen == 2) { + return name.substring(0, 1) + "*"; + } else { + // 姓 + 中间* + 最后一个字 + return name.substring(0, 1) + + "*".repeat(nameLen - 2) + + name.substring(nameLen - 1); + } + } + + /** + * 身份证号脱敏:保留前3位,后4位,中间掩码(如:110***********1234) + * @param idCard 身份证号(支持15位/18位) + * @return 脱敏后的身份证号 + */ + public static String maskIdCard(String idCard) { + if (StringUtils.isBlank(idCard) || (idCard.length() != 15 && idCard.length() != 18)) { + return idCard; + } + return maskGeneral(idCard, 3, 4); + } + + /** + * 银行卡号脱敏:保留前6位,后4位,中间掩码(如:622260**********1234) + * @param bankCard 银行卡号(常规16/19位) + * @return 脱敏后的银行卡号 + */ + public static String maskBankCard(String bankCard) { + if (StringUtils.isBlank(bankCard) || bankCard.length() < 10) { + return bankCard; + } + return maskGeneral(bankCard, 6, 4); + } + + // ===================== 测试用例(可直接运行验证) ===================== + public static void main(String[] args) { + // 测试手机号脱敏 + System.out.println("手机号脱敏:" + maskMobile("13812345678")); // 输出:138****5678 + // 测试邮箱脱敏 + System.out.println("邮箱脱敏:" + maskEmail("1234567890@qq.com")); // 输出:123****@qq.com + // 测试姓名脱敏 + System.out.println("单字名:" + maskName("李")); // 输出:李 + System.out.println("两字名:" + maskName("李白")); // 输出:李* + System.out.println("三字名:" + maskName("李世民")); // 输出:李*民 + // 测试身份证脱敏 + System.out.println("身份证脱敏:" + maskIdCard("110101199001011234")); // 输出:110***********1234 + // 测试银行卡脱敏 + System.out.println("银行卡脱敏:" + maskBankCard("6222600000000001234")); // 输出:622260**********1234 + // 测试空值/异常值 + System.out.println("空手机号:" + maskMobile(null)); // 输出:null + System.out.println("无效邮箱:" + maskEmail("123456")); // 输出:123456 + } +}