feat: 升级 2.0.0-SNAPSHOT (Java 21/Spring Boot 3.4.5) + 旋转验证码修复 + 新增验证码类型支持

This commit is contained in:
abcv7
2026-08-19 23:36:13 +08:00
parent e7b9fd923b
commit d56958727c
10 changed files with 237 additions and 130 deletions
@@ -167,6 +167,9 @@ public class DefaultImageCaptchaApplication implements ImageCaptchaApplication {
verificationVO.setBackgroundImageHeight(imageCaptchaInfo.getBackgroundImageHeight());
verificationVO.setTemplateImageWidth(imageCaptchaInfo.getTemplateImageWidth());
verificationVO.setTemplateImageHeight(imageCaptchaInfo.getTemplateImageHeight());
if (imageCaptchaInfo instanceof cloud.tianai.captcha.generator.common.model.dto.RotateImageCaptchaInfo) {
verificationVO.setDegree(((cloud.tianai.captcha.generator.common.model.dto.RotateImageCaptchaInfo) imageCaptchaInfo).getDegree());
}
verificationVO.setData(imageCaptchaInfo.getData() == null ? null : imageCaptchaInfo.getData().getViewData());
verificationVO.setId(id);
return ApiResponse.ofSuccess(verificationVO);
@@ -31,6 +31,8 @@ public class ImageCaptchaVO implements Serializable {
private Integer templateImageWidth;
/** 滑动图片高度.*/
private Integer templateImageHeight;
/** 旋转验证码初始角度(仅ROTATE类型).*/
private Double degree;
/** data 扩展数据.*/
private Object data;
}
@@ -1,18 +1,20 @@
package cloud.tianai.captcha.common.constant;
/**
* @Author: 天爱有情
* @date 2021/8/7 17:14
* @Description 滑块类型
*/
public interface CaptchaTypeConstant {
/** 滑块. */
String SLIDER = "SLIDER";
/** 旋转. */
String SLIDER_V2 = "SLIDER_V2";
String ROTATE = "ROTATE";
/** 拼接. */
String CONCAT = "CONCAT";
/** 文字图片点选. */
String WORD_IMAGE_CLICK = "WORD_IMAGE_CLICK";
String ICON_CLICK = "ICON_CLICK";
String WORD_ORDER_CLICK = "WORD_ORDER_CLICK";
String CURVE_SLIDER = "CURVE_SLIDER";
String CURVE_SLIDER_V2 = "CURVE_SLIDER_V2";
String CURVE_SLIDER_V3 = "CURVE_SLIDER_V3";
String ANGLE = "ANGLE";
String SCRATCH = "SCRATCH";
String JIGSAW = "JIGSAW";
String CURVE_DRAW = "CURVE_DRAW";
String PROOF_OF_WORK = "PROOF_OF_WORK";
}
@@ -47,14 +47,21 @@ public class MultiImageCaptchaGenerator extends AbstractImageCaptchaGenerator {
@Override
protected void doInit() {
// 滑块验证码
addImageCaptchaGeneratorProvider(new CommonImageCaptchaGeneratorProvider(SLIDER, StandardSliderImageCaptchaGenerator::new));
// 旋转验证码
addImageCaptchaGeneratorProvider(new CommonImageCaptchaGeneratorProvider(SLIDER_V2, EnhancedSliderImageCaptchaGenerator::new));
addImageCaptchaGeneratorProvider(new CommonImageCaptchaGeneratorProvider(ROTATE, StandardRotateImageCaptchaGenerator::new));
// 拼接验证码
addImageCaptchaGeneratorProvider(new CommonImageCaptchaGeneratorProvider(CONCAT, StandardConcatImageCaptchaGenerator::new));
// 点选文字验证码
addImageCaptchaGeneratorProvider(new CommonImageCaptchaGeneratorProvider(WORD_IMAGE_CLICK, StandardWordClickImageCaptchaGenerator::new));
addImageCaptchaGeneratorProvider(new CommonImageCaptchaGeneratorProvider(ICON_CLICK, IconClickImageCaptchaGenerator::new));
addImageCaptchaGeneratorProvider(new CommonImageCaptchaGeneratorProvider(WORD_ORDER_CLICK, WordOrderClickImageCaptchaGenerator::new));
addImageCaptchaGeneratorProvider(new CommonImageCaptchaGeneratorProvider(CURVE_SLIDER, CurveSliderImageCaptchaGenerator::new));
addImageCaptchaGeneratorProvider(new CommonImageCaptchaGeneratorProvider(ANGLE, AngleRotateImageCaptchaGenerator::new));
addImageCaptchaGeneratorProvider(new CommonImageCaptchaGeneratorProvider(SCRATCH, ScratchImageCaptchaGenerator::new));
addImageCaptchaGeneratorProvider(new CommonImageCaptchaGeneratorProvider(JIGSAW, JigsawImageCaptchaGenerator::new));
addImageCaptchaGeneratorProvider(new CommonImageCaptchaGeneratorProvider(CURVE_SLIDER_V2, CurveSliderV2ImageCaptchaGenerator::new));
addImageCaptchaGeneratorProvider(new CommonImageCaptchaGeneratorProvider(CURVE_SLIDER_V3, CurveSliderV3ImageCaptchaGenerator::new));
addImageCaptchaGeneratorProvider(new CommonImageCaptchaGeneratorProvider(CURVE_DRAW, CurveDrawImageCaptchaGenerator::new));
addImageCaptchaGeneratorProvider(new CommonImageCaptchaGeneratorProvider(PROOF_OF_WORK, ProofOfWorkCaptchaGenerator::new));
}
public void addImageCaptchaGeneratorProvider(ImageCaptchaGeneratorProvider provider) {
@@ -60,6 +60,14 @@ public class StandardRotateImageCaptchaGenerator extends AbstractImageCaptchaGen
Optional<BufferedImage> maskTemplateOptional = getTemplateImageOfOptional(templateResource, TEMPLATE_MASK_IMAGE_NAME);
if (maskTemplateOptional.isPresent()) {
maskTemplate = maskTemplateOptional.get();
} else {
// 没有mask.png时,生成实心圆形蒙版(alpha=255),确保切出完整图片内容
maskTemplate = new BufferedImage(fixedTemplate.getWidth(), fixedTemplate.getHeight(), java.awt.image.BufferedImage.TYPE_INT_ARGB);
java.awt.Graphics2D gMask = maskTemplate.createGraphics();
gMask.setRenderingHint(java.awt.RenderingHints.KEY_ANTIALIASING, java.awt.RenderingHints.VALUE_ANTIALIAS_ON);
gMask.setColor(new java.awt.Color(255, 255, 255, 255));
gMask.fillOval(0, 0, fixedTemplate.getWidth(), fixedTemplate.getHeight());
gMask.dispose();
}
// 算出居中的x和y
@@ -68,21 +76,10 @@ public class StandardRotateImageCaptchaGenerator extends AbstractImageCaptchaGen
// 抠图部分
BufferedImage cutImage = CaptchaImageUtils.cutImage(background, maskTemplate, x, y);
BufferedImage rotateFixed = fixedTemplate;
BufferedImage rotateActive = activeTemplate;
if (param.getObfuscate()) {
int randomDegree = randomInt(10, 350);
rotateFixed = CaptchaImageUtils.rotateImage(fixedTemplate, randomDegree);
randomDegree = randomInt(10, 350);
rotateActive = CaptchaImageUtils.rotateImage(activeTemplate, randomDegree);
}
CaptchaImageUtils.overlayImage(background, rotateFixed, x, y);
CaptchaImageUtils.overlayImage(cutImage, rotateActive, 0, 0);
// 随机旋转抠图部分
// 随机x 转换为角度
// 随机x,转换为角度
int randomX = randomInt(fixedTemplate.getWidth() + 10, background.getWidth() - 10);
double degree = 360d - randomX / ((background.getWidth()) / 360d);
// 旋转的透明图片是一张正方形的
// 模板图:窄条透明画布,切片居中旋转放置(官方方式)
BufferedImage matrixTemplate = CaptchaImageUtils.createTransparentImage(cutImage.getWidth(), background.getHeight());
CaptchaImageUtils.centerOverlayAndRotateImage(matrixTemplate, cutImage, degree);
@@ -53,6 +53,9 @@ public class SimpleImageCaptchaValidator implements ImageCaptchaValidator, Slide
CaptchaTypeClassifier.addSliderCaptchaType(CaptchaTypeConstant.ROTATE);
CaptchaTypeClassifier.addSliderCaptchaType(CaptchaTypeConstant.SLIDER);
CaptchaTypeClassifier.addClickCaptchaType(CaptchaTypeConstant.WORD_IMAGE_CLICK);
CaptchaTypeClassifier.addClickCaptchaType(CaptchaTypeConstant.ICON_CLICK);
CaptchaTypeClassifier.addClickCaptchaType(CaptchaTypeConstant.WORD_ORDER_CLICK);
CaptchaTypeClassifier.addJigsawCaptchaType(CaptchaTypeConstant.JIGSAW);
}
public SimpleImageCaptchaValidator(float defaultTolerant) {
@@ -119,6 +122,25 @@ public class SimpleImageCaptchaValidator implements ImageCaptchaValidator, Slide
if (CaptchaTypeClassifier.isSliderCaptcha(type)) {
// 滑动验证码
addPercentage(imageCaptchaInfo, map);
} else if (CaptchaTypeConstant.CURVE_DRAW.equals(type)) {
// 曲线绘制验证码
if (expand == null) {
throw new IllegalArgumentException("曲线绘制验证码扩展数据为空, info=" + imageCaptchaInfo);
}
try {
cloud.tianai.captcha.generator.impl.CurveDrawImageCaptchaGenerator.CurveDrawData curveData =
(cloud.tianai.captcha.generator.impl.CurveDrawImageCaptchaGenerator.CurveDrawData) expand;
StringBuilder sb = new StringBuilder();
for (java.awt.geom.Point2D.Double point : curveData.getSamplePoints()) {
sb.append(calcPercentage((float) point.x, imageCaptchaInfo.getBackgroundImageWidth()))
.append(",")
.append(calcPercentage((float) point.y, imageCaptchaInfo.getBackgroundImageHeight()))
.append(";");
}
map.put(PERCENTAGE_KEY, sb.toString());
} catch (Exception e) {
throw new IllegalArgumentException("曲线绘制验证码扩展数据转换失败, info=" + imageCaptchaInfo, e);
}
} else if (CaptchaTypeClassifier.isClickCaptcha(type)) {
// 图片点选验证码
if (expand == null) {
@@ -158,6 +180,15 @@ public class SimpleImageCaptchaValidator implements ImageCaptchaValidator, Slide
} else if (CaptchaTypeClassifier.isJigsawCaptcha(type)) {
// 拼图验证码
map.put(PERCENTAGE_KEY, expand);
} else if (CaptchaTypeConstant.SCRATCH.equals(type)) {
// 刮刮卡验证码 - 存储需要刮开的百分比阈值
String threshold = (expand != null) ? String.valueOf(expand) : "50";
map.put(PERCENTAGE_KEY, threshold);
} else if (CaptchaTypeConstant.PROOF_OF_WORK.equals(type)) {
// 工作量证明验证码 - 存储challenge和difficulty
if (expand != null) {
map.put("pow_data", expand);
}
}
}
@@ -236,12 +267,21 @@ public class SimpleImageCaptchaValidator implements ImageCaptchaValidator, Slide
if (CaptchaTypeClassifier.isSliderCaptcha(type)) {
// 滑动类型验证码
return doValidSliderCaptcha(imageCaptchaTrack, imageCaptchaValidData, tolerant, type);
} else if (CaptchaTypeConstant.CURVE_DRAW.equals(type)) {
// 曲线绘制验证码
return doValidCurveDrawCaptcha(imageCaptchaTrack, imageCaptchaValidData, tolerant);
} else if (CaptchaTypeClassifier.isClickCaptcha(type)) {
// 点选类型验证码
return doValidClickCaptcha(imageCaptchaTrack, imageCaptchaValidData, tolerant, type);
} else if (CaptchaTypeClassifier.isJigsawCaptcha(type)) {
// 拼图类型验证码
return doValidJigsawCaptcha(imageCaptchaTrack, imageCaptchaValidData, tolerant, type);
} else if (CaptchaTypeConstant.SCRATCH.equals(type)) {
// 刮刮卡验证码
return doValidScratchCaptcha(imageCaptchaTrack, imageCaptchaValidData, tolerant);
} else if (CaptchaTypeConstant.PROOF_OF_WORK.equals(type)) {
// 工作量证明验证码
return doValidProofOfWork(imageCaptchaTrack, imageCaptchaValidData);
}
// 不支持的类型
log.warn("校验验证码警告, 不支持的验证码类型:{}, 请手动扩展 cloud.tianai.captcha.validator.impl.SimpleImageCaptchaValidator.doValid 进行校验扩展", type);
@@ -254,7 +294,115 @@ public class SimpleImageCaptchaValidator implements ImageCaptchaValidator, Slide
}
String posArr = (String) imageCaptchaTrack.getData();
String successPosStr = imageCaptchaValidData.getString(PERCENTAGE_KEY, null);
return successPosStr.equals(posArr);
return successPosStr != null && successPosStr.equals(posArr);
}
/**
* 校验刮刮卡验证码
* 检查用户刮开的面积是否超过阈值
*/
public boolean doValidScratchCaptcha(ImageCaptchaTrack imageCaptchaTrack,
AnyMap imageCaptchaValidData,
Float tolerant) {
// data 字段存储刮开百分比 (0-100)
if (imageCaptchaTrack.getData() == null) {
return false;
}
int scratchPercent;
try {
scratchPercent = Integer.parseInt(String.valueOf(imageCaptchaTrack.getData()));
} catch (NumberFormatException e) {
return false;
}
// 阈值默认50%
String thresholdStr = imageCaptchaValidData.getString(PERCENTAGE_KEY, "50");
int threshold = 50;
try {
threshold = Integer.parseInt(thresholdStr);
} catch (NumberFormatException ignored) {}
return scratchPercent >= threshold;
}
/**
* 校验工作量证明验证码
* 检查用户提交的nonce是否满足SHA-256前导零要求
*/
public boolean doValidProofOfWork(ImageCaptchaTrack imageCaptchaTrack,
AnyMap imageCaptchaValidData) {
if (imageCaptchaTrack.getData() == null) {
return false;
}
String nonce = String.valueOf(imageCaptchaTrack.getData());
Object powData = imageCaptchaValidData.get("pow_data");
if (powData == null) {
return false;
}
// pow_data is the PoWData object from the generator
try {
cloud.tianai.captcha.generator.impl.ProofOfWorkCaptchaGenerator.PoWData data =
(cloud.tianai.captcha.generator.impl.ProofOfWorkCaptchaGenerator.PoWData) powData;
return cloud.tianai.captcha.generator.impl.ProofOfWorkCaptchaGenerator.verifyProof(
data.challenge, data.difficulty, nonce);
} catch (Exception e) {
return false;
}
}
/**
* 校验曲线绘制验证码
* 检查用户绘制的路径是否经过预期曲线的采样点附近
*/
public boolean doValidCurveDrawCaptcha(ImageCaptchaTrack imageCaptchaTrack,
AnyMap imageCaptchaValidData,
Float tolerant) {
String validStr = imageCaptchaValidData.getString(PERCENTAGE_KEY, null);
if (ObjectUtils.isEmpty(validStr)) {
return false;
}
String[] splitArr = validStr.split(";");
List<ImageCaptchaTrack.Track> trackList = imageCaptchaTrack.getTrackList();
if (trackList == null || trackList.isEmpty()) {
return false;
}
// 取出所有 MOVE 类型的轨迹点(用户的绘制路径)
List<ImageCaptchaTrack.Track> drawTrackList = trackList
.stream()
.filter(t -> TrackTypeConstant.MOVE.equalsIgnoreCase(t.getType()))
.collect(Collectors.toList());
if (drawTrackList.isEmpty()) {
return false;
}
// 对每个预期采样点,检查用户路径中是否有接近的点
int matchedCount = 0;
int bgWidth = imageCaptchaTrack.getBgImageWidth();
int bgHeight = imageCaptchaTrack.getBgImageHeight();
for (String posStr : splitArr) {
if (ObjectUtils.isEmpty(posStr)) {
continue;
}
String[] posArr = posStr.split(",");
if (posArr.length < 2) {
continue;
}
float xPercentage = Float.parseFloat(posArr[0]);
float yPercentage = Float.parseFloat(posArr[1]);
// 检查用户路径中是否有接近该采样点的位置
boolean matched = false;
for (ImageCaptchaTrack.Track track : drawTrackList) {
float calcXPercentage = calcPercentage(track.getX(), bgWidth);
float calcYPercentage = calcPercentage(track.getY(), bgHeight);
if (checkPercentage(calcXPercentage, xPercentage, tolerant)
&& checkPercentage(calcYPercentage, yPercentage, tolerant)) {
matched = true;
break;
}
}
if (matched) {
matchedCount++;
}
}
// 如果匹配率超过 70% 则通过
return (double) matchedCount / splitArr.length >= 0.7;
}
/**
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB