feat(captcha):重构点选验证码生成逻辑并优化验证轨迹处理

- 修改 AbstractClickImageCaptchaGenerator 中的图片提示资源结构,支持 ResourceMap 类型
- 引入 CommonConstant 常量用于区分提示图标与点击图标资源
- 新增 Block 类用于管理背景图上的可点击区域分块逻辑
- 更新点击图片的位置计算方式,从随机坐标改为基于分块的确定位置
- 添加 obfuscateImage 方法用于后续图像混淆处理扩展
- 调整时间戳字段类型由 Date 改为 Long,提升性能及一致性
- 在 ParamKeyEnum 中新增 FONT_TAG 参数键,支持按标签读取字体资源
- 标准化字体获取方法,允许通过 GenerateParam 指定字体标签
-修正 DefaultBuiltInResources 中字体路径拼写错误(fontS → fonts)
- 补充 ResourceStore 接口的 getTarget 默认方法实现
- 更新测试类 TACBuilderTest2 示例代码中的验证码类型调用参数
This commit is contained in:
天爱有情
2025-10-15 17:12:15 +08:00
parent 3d28302db5
commit af2df2c7e2
11 changed files with 120 additions and 59 deletions
@@ -6,6 +6,7 @@ import lombok.Data;
import lombok.NoArgsConstructor;
import java.awt.*;
import java.awt.image.BufferedImage;
/**
* @Author: 天爱有情
@@ -16,17 +17,35 @@ import java.awt.*;
@NoArgsConstructor
@AllArgsConstructor
public class ClickImageCheckDefinition {
/** 提示.*/
/** 提示. */
private Resource tip;
/** x.*/
private ImgWrapper tipImage;
/** x. */
private Integer x;
/** y.*/
/** y. */
private Integer y;
/** 宽.*/
/** 宽. */
private Integer width;
/** 高.*/
/** 高. */
private Integer height;
/** 颜色.*/
/** 颜色. */
private Color imageColor;
/**
* @Author: 天爱有情
* @date 2022/4/28 14:26
* @Description 点击图片包装
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class ImgWrapper {
/** 图片. */
private BufferedImage image;
/** 提示. */
private Resource tip;
/** 图片颜色. */
private Color imageColor;
}
}
@@ -13,7 +13,8 @@ public class ParamKeyEnum<T> implements ParamKey<T> {
public static final ParamKey<Integer> CLICK_CHECK_CLICK_COUNT = new ParamKeyEnum<>("checkClickCount");
/** 点选验证码干扰数量. 值为Integer */
public static final ParamKey<Integer> CLICK_INTERFERENCE_COUNT = new ParamKeyEnum<>("interferenceCount");
/** 读取字体时,可指定字体TAG,可用于给不同的验证码指定不同的字体包.*/
public static final ParamKey<String> FONT_TAG = new ParamKeyEnum<>("fontTag");
private String key;
}
@@ -1,5 +1,6 @@
package cloud.tianai.captcha.generator.impl;
import cloud.tianai.captcha.common.constant.CommonConstant;
import cloud.tianai.captcha.generator.AbstractImageCaptchaGenerator;
import cloud.tianai.captcha.generator.common.model.dto.CaptchaExchange;
import cloud.tianai.captcha.generator.common.model.dto.ClickImageCheckDefinition;
@@ -7,6 +8,7 @@ import cloud.tianai.captcha.generator.common.model.dto.GenerateParam;
import cloud.tianai.captcha.generator.common.util.CaptchaImageUtils;
import cloud.tianai.captcha.resource.ImageCaptchaResourceManager;
import cloud.tianai.captcha.resource.common.model.dto.Resource;
import cloud.tianai.captcha.resource.common.model.dto.ResourceMap;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@@ -16,6 +18,7 @@ import java.awt.*;
import java.awt.image.BufferedImage;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ThreadLocalRandom;
/**
* @Author: 天爱有情
@@ -42,40 +45,67 @@ public abstract class AbstractClickImageCaptchaGenerator extends AbstractImageCa
BufferedImage bgImage = getResourceImage(resourceImage);
List<Resource> imgTips = randomGetClickImgTips(param);
List<ResourceMap> imgTips = randomGetClickImgTips(param);
int allImages = imgTips.size();
List<ClickImageCheckDefinition> clickImageCheckDefinitionList = new ArrayList<>(allImages);
int avg = bgImage.getWidth() / allImages;
if (allImages < imgTips.size()) {
throw new IllegalStateException("随机生成点击图片小于请求数量, 请求生成数量=" + allImages + ",实际生成数量=" + imgTips.size());
}
List<Block> blocks = new ArrayList<>();
for (int i = 0; i < allImages; i++) {
ResourceMap resourceMap = imgTips.get(i);
Resource tipResource = resourceMap.get(CommonConstant.IMAGE_TIP_ICON);
Resource clickResource = resourceMap.get(CommonConstant.IMAGE_CLICK_ICON);
if (clickResource == null) {
throw new IllegalStateException("随机生成点击图片失败,资源中必须包含[" + CommonConstant.IMAGE_CLICK_ICON + "]" + resourceMap);
}
if (tipResource == null) {
tipResource = clickResource;
}
// 随机获取点击图片
ImgWrapper imgWrapper = getClickImg(imgTips.get(i),null);
ClickImageCheckDefinition.ImgWrapper imgWrapper = getClickImg(param, clickResource, null);
BufferedImage image = imgWrapper.getImage();
// 增加功能,是否需要扭曲图片
image = obfuscateImage(image, param);
int clickImgWidth = image.getWidth();
int clickImgHeight = image.getHeight();
// 随机x
int randomX;
if (i == 0) {
randomX = 1;
} else {
randomX = avg * i;
// 假设每个icon的大小都是一样的, 按照宽高进行分块
int w = clickImgWidth + clickImgWidth / 2;
int h = clickImgHeight + clickImgHeight / 2;
int xNum = (int) Math.floor((double) bgImage.getWidth() / w);
int yNum = (int) Math.floor((double) bgImage.getHeight() / h);
for (int x = 0; x < xNum; x++) {
for (int y = 0; y < yNum; y++) {
blocks.add(new Block(x * w + clickImgWidth / 2, clickImgWidth, y * h + clickImgHeight / 2, clickImgHeight));
}
}
}
// 随机y
int randomY = randomInt(10, bgImage.getHeight() - clickImgHeight);
// 通过随机x和y 进行覆盖图片
CaptchaImageUtils.overlayImage(bgImage, image, randomX, randomY);
Block block = blocks.remove(ThreadLocalRandom.current().nextInt(0, blocks.size()));
// // 随机x
// int randomX;
// if (i == 0) {
// randomX = 1;
// } else {
// randomX = avg * i;
// }
// // 随机y
// int randomY = randomInt(10, bgImage.getHeight() - clickImgHeight);
// 通过随机x和y 进行覆盖图片7
CaptchaImageUtils.overlayImage(bgImage, image, block.startX, block.startY);
ClickImageCheckDefinition clickImageCheckDefinition = new ClickImageCheckDefinition();
clickImageCheckDefinition.setTip(imgWrapper.getTip());
clickImageCheckDefinition.setX(randomX + clickImgWidth / 2);
clickImageCheckDefinition.setY(randomY + clickImgHeight / 2);
clickImageCheckDefinition.setTip(tipResource);
clickImageCheckDefinition.setTipImage(imgWrapper);
clickImageCheckDefinition.setX(block.startX + clickImgWidth / 2);
clickImageCheckDefinition.setY(block.startY + clickImgHeight / 2);
clickImageCheckDefinition.setWidth(clickImgWidth);
clickImageCheckDefinition.setHeight(clickImgHeight);
clickImageCheckDefinition.setImageColor(imgWrapper.getImageColor());
clickImageCheckDefinitionList.add(clickImageCheckDefinition);
}
List<ClickImageCheckDefinition> checkClickImageCheckDefinitionList = filterAndSortClickImageCheckDefinition(captchaExchange,clickImageCheckDefinitionList);
List<ClickImageCheckDefinition> checkClickImageCheckDefinitionList = filterAndSortClickImageCheckDefinition(captchaExchange, clickImageCheckDefinitionList);
captchaExchange.setBackgroundImage(bgImage);
captchaExchange.setTransferData(checkClickImageCheckDefinitionList);
captchaExchange.setResourceImage(resourceImage);
@@ -88,20 +118,24 @@ public abstract class AbstractClickImageCaptchaGenerator extends AbstractImageCa
}
private BufferedImage obfuscateImage(BufferedImage image, GenerateParam param) {
return image;
}
/**
* 过滤并排序校验的图片点选顺序
*
* @param allCheckDefinitionList 总的点选图片
* @return List<ClickImageCheckDefinition>
*/
protected abstract List<ClickImageCheckDefinition> filterAndSortClickImageCheckDefinition(CaptchaExchange captchaExchange,List<ClickImageCheckDefinition> allCheckDefinitionList);
protected abstract List<ClickImageCheckDefinition> filterAndSortClickImageCheckDefinition(CaptchaExchange captchaExchange, List<ClickImageCheckDefinition> allCheckDefinitionList);
/**
* 随机获取一组数据用于生成随机图
*
* @return List<String>
*/
protected abstract List<Resource> randomGetClickImgTips(GenerateParam param);
protected abstract List<ResourceMap> randomGetClickImgTips(GenerateParam param);
/**
* 随机获取点击的图片
@@ -109,22 +143,16 @@ public abstract class AbstractClickImageCaptchaGenerator extends AbstractImageCa
* @param tip 提示数据,根据改数据生成图片
* @return ImgWrapper
*/
public abstract ImgWrapper getClickImg(Resource tip, Color randomColor);
public abstract ClickImageCheckDefinition.ImgWrapper getClickImg(GenerateParam param, Resource tip, Color randomColor);
/**
* @Author: 天爱有情
* @date 2022/4/28 14:26
* @Description 点击图片包装
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class ImgWrapper {
/** 图片. */
private BufferedImage image;
/** 提示. */
private Resource tip;
/** 图片颜色. */
private Color imageColor;
private static class Block {
private int startX;
private int width;
private int startY;
private int height;
}
}
@@ -12,6 +12,7 @@ import cloud.tianai.captcha.interceptor.CaptchaInterceptor;
import cloud.tianai.captcha.resource.FontCache;
import cloud.tianai.captcha.resource.ImageCaptchaResourceManager;
import cloud.tianai.captcha.resource.common.model.dto.Resource;
import cloud.tianai.captcha.resource.common.model.dto.ResourceMap;
import lombok.Getter;
import lombok.Setter;
@@ -77,15 +78,18 @@ public class StandardWordClickImageCaptchaGenerator extends AbstractClickImageCa
@Override
protected List<Resource> randomGetClickImgTips(GenerateParam param) {
protected List<ResourceMap> randomGetClickImgTips(GenerateParam param) {
Integer checkClickCount = param.getOrDefault(ParamKeyEnum.CLICK_CHECK_CLICK_COUNT, getCheckClickCount());
Integer interferenceCount = param.getOrDefault(ParamKeyEnum.CLICK_INTERFERENCE_COUNT, getInterferenceCount());
int tipSize = interferenceCount + checkClickCount;
ThreadLocalRandom random = ThreadLocalRandom.current();
List<Resource> tipList = new ArrayList<>(tipSize);
List<ResourceMap> tipList = new ArrayList<>(tipSize);
for (int i = 0; i < tipSize; i++) {
String randomWord = FontUtils.getRandomChar(random);
tipList.add(new Resource(null, randomWord));
ResourceMap resourceMap = new ResourceMap(param.getTemplateImageTag());
resourceMap.put(CommonConstant.IMAGE_TIP_ICON, new Resource(null, randomWord));
resourceMap.put(CommonConstant.IMAGE_CLICK_ICON, new Resource(null, randomWord));
tipList.add(resourceMap);
}
// 随机文字
return tipList;
@@ -101,8 +105,9 @@ public class StandardWordClickImageCaptchaGenerator extends AbstractClickImageCa
// resourceStore.addResource(CaptchaTypeConstant.WORD_IMAGE_CLICK, new Resource(ClassPathResourceProvider.NAME, DEFAULT_SLIDER_IMAGE_RESOURCE_PATH.concat("/1.jpg"), DEFAULT_TAG));
}
public FontWrapper randomFont() {
Resource resource = requiredRandomGetResource(FontCache.FONT_TYPE, CommonConstant.DEFAULT_TAG);
public FontWrapper randomFont(GenerateParam param) {
String fontTag = param.getOrDefault(ParamKeyEnum.FONT_TAG, CommonConstant.DEFAULT_TAG);
Resource resource = requiredRandomGetResource(FontCache.FONT_TYPE, fontTag);
Object extra = resource.getExtra();
if (extra instanceof FontWrapper) {
return (FontWrapper) extra;
@@ -110,8 +115,8 @@ public class StandardWordClickImageCaptchaGenerator extends AbstractClickImageCa
throw new ImageCaptchaException("随机获取字体失败, resource中没有读到字体包, resource=" + resource);
}
public ImgWrapper genTipImage(List<ClickImageCheckDefinition> imageCheckDefinitions) {
FontWrapper fontWrapper = randomFont();
public ClickImageCheckDefinition.ImgWrapper genTipImage(List<ClickImageCheckDefinition> imageCheckDefinitions, GenerateParam param) {
FontWrapper fontWrapper = randomFont(param);
Font font = fontWrapper.getFont();
float currentFontTopCoef = fontWrapper.getCurrentFontTopCoef();
String tips = imageCheckDefinitions.stream().map(c -> c.getTip().getData()).collect(Collectors.joining());
@@ -123,7 +128,7 @@ public class StandardWordClickImageCaptchaGenerator extends AbstractClickImageCa
float top = 6 / 2f + font.getSize() - currentFontTopCoef;
BufferedImage bufferedImage = CaptchaImageUtils.genSimpleImgCaptcha(tips,
font, width, height, left, top, tipImageInterferenceLineNum, tipImageInterferencePointNum);
return new ImgWrapper(bufferedImage, new Resource(null, tips), null);
return new ClickImageCheckDefinition.ImgWrapper(bufferedImage, new Resource(null, tips), null);
}
// @Override
@@ -136,14 +141,14 @@ public class StandardWordClickImageCaptchaGenerator extends AbstractClickImageCa
@Override
public ImgWrapper getClickImg(Resource tip, Color randomColor) {
public ClickImageCheckDefinition.ImgWrapper getClickImg(GenerateParam param, Resource tip, Color randomColor) {
if (randomColor == null) {
ThreadLocalRandom random = ThreadLocalRandom.current();
randomColor = CaptchaImageUtils.getRandomColor(random);
}
// 随机角度
int randomDeg = randomInt(0, 85);
FontWrapper fontWrapper = randomFont();
FontWrapper fontWrapper = randomFont(param);
Font font = fontWrapper.getFont();
float currentFontTopCoef = fontWrapper.getCurrentFontTopCoef();
BufferedImage fontImage = CaptchaImageUtils.drawWordImg(randomColor,
@@ -153,11 +158,11 @@ public class StandardWordClickImageCaptchaGenerator extends AbstractClickImageCa
clickImgWidth,
clickImgHeight,
randomDeg);
return new ImgWrapper(fontImage, tip, randomColor);
return new ClickImageCheckDefinition.ImgWrapper(fontImage, tip, randomColor);
}
@Override
protected List<ClickImageCheckDefinition> filterAndSortClickImageCheckDefinition(CaptchaExchange captchaExchange,List<ClickImageCheckDefinition> allCheckDefinitionList) {
protected List<ClickImageCheckDefinition> filterAndSortClickImageCheckDefinition(CaptchaExchange captchaExchange, List<ClickImageCheckDefinition> allCheckDefinitionList) {
GenerateParam param = captchaExchange.getParam();
Integer checkClickCount = param.getOrDefault(ParamKeyEnum.CLICK_CHECK_CLICK_COUNT, getCheckClickCount());
// 打乱
@@ -179,7 +184,7 @@ public class StandardWordClickImageCaptchaGenerator extends AbstractClickImageCa
Resource resourceImage = captchaExchange.getResourceImage();
CustomData data = captchaExchange.getCustomData();
// 提示图片
BufferedImage tipImage = genTipImage(checkClickImageCheckDefinitionList).getImage();
BufferedImage tipImage = genTipImage(checkClickImageCheckDefinitionList, param).getImage();
ImageTransformData transform = getImageTransform().transform(param, bgImage, tipImage, resourceImage, checkClickImageCheckDefinitionList, data);
ImageCaptchaInfo clickImageCaptchaInfo = new ImageCaptchaInfo();
clickImageCaptchaInfo.setBackgroundImage(transform.getBackgroundImageUrl());
@@ -35,8 +35,8 @@ public class BasicTrackCaptchaInterceptor implements CaptchaInterceptor {
}
ImageCaptchaTrack imageCaptchaTrack = matchData.getTrack();
// 进行行为轨迹检测
long startSlidingTime = imageCaptchaTrack.getStartTime().getTime();
long endSlidingTime = imageCaptchaTrack.getStopTime().getTime();
long startSlidingTime = imageCaptchaTrack.getStartTime();
long endSlidingTime = imageCaptchaTrack.getStopTime();
Integer bgImageWidth = imageCaptchaTrack.getBgImageWidth();
List<ImageCaptchaTrack.Track> trackList = imageCaptchaTrack.getTrackList();
// 这里只进行基本检测, 用一些简单算法进行校验,如有需要可扩展
@@ -69,7 +69,7 @@ public class DefaultBuiltInResources {
// 字体包
defaultTemplateResource.put(FontCache.FONT_TYPE, resourceStore -> {
resourceStore.addResource(FontCache.FONT_TYPE,new Resource(type, finalPathPrefix.concat("/fontS/SIMSUN.TTC")));
resourceStore.addResource(FontCache.FONT_TYPE,new Resource(type, finalPathPrefix.concat("/fonts/SIMSUN.TTC")));
});
}
@@ -77,4 +77,9 @@ public class FontCache implements ResourceStore {
public List<ResourceMap> randomGetTemplateByTypeAndTag(String type, String tag, Integer quantity) {
return resourceStore.randomGetTemplateByTypeAndTag(type, tag, quantity);
}
@Override
public ResourceStore getTarget() {
return resourceStore;
}
}
@@ -29,4 +29,8 @@ public interface ResourceStore {
* @return Map<String, Resource>
*/
List<ResourceMap> randomGetTemplateByTypeAndTag(String type, String tag,Integer quantity);
default ResourceStore getTarget() {
return this;
}
}
@@ -5,7 +5,6 @@ import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Date;
import java.util.List;
/**
@@ -25,9 +24,9 @@ public class ImageCaptchaTrack {
/** 模板图片高度. */
private Integer templateImageHeight;
/** 滑动开始时间. */
private Date startTime;
private Long startTime;
/** 滑动结束时间. */
private Date stopTime;
private Long stopTime;
private Integer left;
private Integer top;
/** 滑动的轨迹. */
@@ -42,8 +42,8 @@ public class BasicCaptchaTrackValidator extends SimpleImageCaptchaValidator {
return ApiResponse.ofSuccess();
}
// 进行行为轨迹检测
long startSlidingTime = imageCaptchaTrack.getStartTime().getTime();
long endSlidingTime = imageCaptchaTrack.getStopTime().getTime();
long startSlidingTime = imageCaptchaTrack.getStartTime();
long endSlidingTime = imageCaptchaTrack.getStopTime();
Integer bgImageWidth = imageCaptchaTrack.getBgImageWidth();
List<ImageCaptchaTrack.Track> trackList = imageCaptchaTrack.getTrackList();
// 这里只进行基本检测, 用一些简单算法进行校验,如有需要可扩展
@@ -26,7 +26,7 @@ public class TACBuilderTest2 {
.addResource("WORD_IMAGE_CLICK", new Resource("classpath", "META-INF/cut-image/resource/1.jpg"))
.addResource("ROTATE", new Resource("classpath", "META-INF/cut-image/resource/1.jpg"))
.build();
ApiResponse<ImageCaptchaVO> response = application.generateCaptcha("WORD_IMAGE_CLICK");
ApiResponse<ImageCaptchaVO> response = application.generateCaptcha("SLIDER");
System.out.println(response);
}