From 29279e8c567b988605e09570ada363f17de480f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A9=E7=88=B1=E6=9C=89=E6=83=85?= Date: Wed, 14 Jan 2026 17:09:59 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=B5=84=E6=BA=90=E6=B3=84?= =?UTF-8?q?=E9=9C=B2=E9=97=AE=E9=A2=98=E5=B9=B6=E4=BC=98=E5=8C=96=E8=B5=84?= =?UTF-8?q?=E6=BA=90=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 AutoCloseable 接口实现,支持自动资源释放 - 修复 Graphics2D、ImageOutputStream 等资源未正确关闭的问题 - 为 Spring Bean 添加 destroyMethod 配置 - 添加定时任务线程池的正确关闭逻辑 - FontCache 添加缓存大小限制防止内存泄露 - 优化 StandardWordClickImageCaptchaGenerator 代码结构 --- .../CacheStoreAutoConfiguration.java | 24 +- .../ImageCaptchaAutoConfiguration.java | 43 ++- .../captcha/spring/common/util/URL.java | 59 ++++ .../exception/CaptchaValidException.java | 41 --- .../spring/plugins/RedisResourceStore.java | 286 ++++++++++++++++++ .../spring/store/impl/RedisCacheStore.java | 5 + .../DefaultImageCaptchaApplication.java | 56 +++- .../FilterImageCaptchaApplication.java | 5 + .../application/ImageCaptchaApplication.java | 2 +- .../captcha/application/TACBuilder.java | 66 ++-- .../tianai/captcha/cache/CacheStore.java | 2 +- .../cache/impl/ConCurrentExpiringMap.java | 23 +- .../captcha/cache/impl/LocalCacheStore.java | 11 + .../captcha/generator/common/FontWrapper.java | 34 ++- .../common/model/dto/GenerateParam.java | 3 + .../common/model/dto/ParamKeyEnum.java | 4 + .../common/util/CaptchaImageUtils.java | 116 +++++-- .../generator/common/util/ImgWriter.java | 20 +- .../impl/CacheImageCaptchaGenerator.java | 24 ++ .../StandardSliderImageCaptchaGenerator.java | 14 +- ...tandardWordClickImageCaptchaGenerator.java | 56 ++-- .../impl/transform/Base64ImageTransform.java | 26 +- .../tianai/captcha/resource/FontCache.java | 27 +- .../java/example/readme/ApplicationTest.java | 2 +- 24 files changed, 762 insertions(+), 187 deletions(-) create mode 100644 tianai-captcha-springboot-starter/src/main/java/cloud/tianai/captcha/spring/common/util/URL.java delete mode 100644 tianai-captcha-springboot-starter/src/main/java/cloud/tianai/captcha/spring/exception/CaptchaValidException.java create mode 100644 tianai-captcha-springboot-starter/src/main/java/cloud/tianai/captcha/spring/plugins/RedisResourceStore.java diff --git a/tianai-captcha-springboot-starter/src/main/java/cloud/tianai/captcha/spring/autoconfiguration/CacheStoreAutoConfiguration.java b/tianai-captcha-springboot-starter/src/main/java/cloud/tianai/captcha/spring/autoconfiguration/CacheStoreAutoConfiguration.java index 49a03b4..f8eb52e 100644 --- a/tianai-captcha-springboot-starter/src/main/java/cloud/tianai/captcha/spring/autoconfiguration/CacheStoreAutoConfiguration.java +++ b/tianai-captcha-springboot-starter/src/main/java/cloud/tianai/captcha/spring/autoconfiguration/CacheStoreAutoConfiguration.java @@ -2,11 +2,15 @@ package cloud.tianai.captcha.spring.autoconfiguration; import cloud.tianai.captcha.cache.CacheStore; import cloud.tianai.captcha.cache.impl.LocalCacheStore; +import cloud.tianai.captcha.resource.ResourceStore; +import cloud.tianai.captcha.resource.impl.LocalMemoryResourceStore; +import cloud.tianai.captcha.spring.plugins.RedisResourceStore; import cloud.tianai.captcha.spring.store.impl.RedisCacheStore; import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.Order; @@ -17,8 +21,6 @@ import org.springframework.data.redis.core.StringRedisTemplate; * * @author Hccake */ -@AutoConfigureAfter(name = {"org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration", - "org.springframework.boot.data.redis.autoconfigure.DataRedisAutoConfiguration"}) @Configuration(proxyBeanMethods = false) public class CacheStoreAutoConfiguration { @@ -31,15 +33,23 @@ public class CacheStoreAutoConfiguration { @Order(1) @Configuration(proxyBeanMethods = false) @ConditionalOnClass(StringRedisTemplate.class) + @AutoConfigureAfter({RedisAutoConfiguration.class}) public static class RedisCacheStoreConfiguration { - @Bean + @Bean(destroyMethod = "") @ConditionalOnBean(StringRedisTemplate.class) @ConditionalOnMissingBean(CacheStore.class) public CacheStore redis(StringRedisTemplate redisTemplate) { return new RedisCacheStore(redisTemplate); } + + @Bean + @ConditionalOnBean(StringRedisTemplate.class) + @ConditionalOnMissingBean(ResourceStore.class) + public ResourceStore redisResourceStore(StringRedisTemplate redisTemplate) { + return new RedisResourceStore(redisTemplate); + } } /** @@ -52,12 +62,18 @@ public class CacheStoreAutoConfiguration { @Configuration(proxyBeanMethods = false) public static class LocalCacheStoreConfiguration { - @Bean + @Bean(destroyMethod = "close") @ConditionalOnMissingBean(CacheStore.class) public CacheStore local() { return new LocalCacheStore(); } + @Bean + @ConditionalOnMissingBean(ResourceStore.class) + public ResourceStore resourceStore() { + return new LocalMemoryResourceStore(); + } + } } diff --git a/tianai-captcha-springboot-starter/src/main/java/cloud/tianai/captcha/spring/autoconfiguration/ImageCaptchaAutoConfiguration.java b/tianai-captcha-springboot-starter/src/main/java/cloud/tianai/captcha/spring/autoconfiguration/ImageCaptchaAutoConfiguration.java index 2e7aad3..269e217 100644 --- a/tianai-captcha-springboot-starter/src/main/java/cloud/tianai/captcha/spring/autoconfiguration/ImageCaptchaAutoConfiguration.java +++ b/tianai-captcha-springboot-starter/src/main/java/cloud/tianai/captcha/spring/autoconfiguration/ImageCaptchaAutoConfiguration.java @@ -4,33 +4,31 @@ package cloud.tianai.captcha.spring.autoconfiguration; import cloud.tianai.captcha.application.ImageCaptchaApplication; import cloud.tianai.captcha.application.TACBuilder; import cloud.tianai.captcha.cache.CacheStore; -import cloud.tianai.captcha.common.util.CollectionUtils; import cloud.tianai.captcha.generator.ImageCaptchaGenerator; import cloud.tianai.captcha.generator.ImageTransform; import cloud.tianai.captcha.generator.impl.transform.Base64ImageTransform; import cloud.tianai.captcha.interceptor.CaptchaInterceptor; -import cloud.tianai.captcha.interceptor.CaptchaInterceptorGroup; -import cloud.tianai.captcha.interceptor.impl.ParamCheckCaptchaInterceptor; +import cloud.tianai.captcha.interceptor.EmptyCaptchaInterceptor; import cloud.tianai.captcha.resource.ImageCaptchaResourceManager; import cloud.tianai.captcha.resource.ResourceProviders; import cloud.tianai.captcha.resource.ResourceStore; import cloud.tianai.captcha.resource.impl.DefaultImageCaptchaResourceManager; -import cloud.tianai.captcha.resource.impl.LocalMemoryResourceStore; +import cloud.tianai.captcha.spring.common.util.URL; import cloud.tianai.captcha.spring.plugins.SpringMultiImageCaptchaGenerator; import cloud.tianai.captcha.spring.plugins.secondary.SecondaryVerificationApplication; import cloud.tianai.captcha.validator.ImageCaptchaValidator; import cloud.tianai.captcha.validator.impl.SimpleImageCaptchaValidator; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.BeanFactory; -import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Role; import org.springframework.core.annotation.Order; +import org.springframework.util.CollectionUtils; /** * @Author: 天爱有情 @@ -44,17 +42,13 @@ import org.springframework.core.annotation.Order; @EnableConfigurationProperties({SpringImageCaptchaProperties.class}) public class ImageCaptchaAutoConfiguration { - @Bean - @ConditionalOnMissingBean - public ResourceStore resourceStore() { - return new LocalMemoryResourceStore(); - } @Bean @ConditionalOnMissingBean + @ConditionalOnBean(ResourceStore.class) public ImageCaptchaResourceManager imageCaptchaResourceManager(ResourceStore resourceStore) { ResourceProviders resourceProviders = new ResourceProviders(); - return new DefaultImageCaptchaResourceManager(resourceStore,resourceProviders); + return new DefaultImageCaptchaResourceManager(resourceStore, resourceProviders); } @Bean @@ -70,7 +64,9 @@ public class ImageCaptchaAutoConfiguration { ImageCaptchaResourceManager captchaResourceManager, ImageTransform imageTransform, BeanFactory beanFactory) { - return new SpringMultiImageCaptchaGenerator(captchaResourceManager, imageTransform, beanFactory); + // 构建多验证码生成器 + ImageCaptchaGenerator captchaGenerator = new SpringMultiImageCaptchaGenerator(captchaResourceManager, imageTransform, beanFactory); + return captchaGenerator; } @Bean @@ -80,26 +76,24 @@ public class ImageCaptchaAutoConfiguration { } @Bean - @Role(BeanDefinition.ROLE_INFRASTRUCTURE) @ConditionalOnMissingBean public CaptchaInterceptor captchaInterceptor() { - CaptchaInterceptorGroup group = new CaptchaInterceptorGroup(); - group.addInterceptor(new ParamCheckCaptchaInterceptor()); -// group.addInterceptor(new BasicTrackCaptchaInterceptor()); - return group; + return new EmptyCaptchaInterceptor(); } - - @Bean + @Bean(destroyMethod = "close") @ConditionalOnMissingBean + @ConditionalOnBean(CacheStore.class) public ImageCaptchaApplication imageCaptchaApplication(ImageCaptchaGenerator captchaGenerator, ImageCaptchaValidator imageCaptchaValidator, CacheStore cacheStore, ResourceStore resourceStore, SpringImageCaptchaProperties prop, CaptchaInterceptor captchaInterceptor, - ApplicationContext applicationContext) { - TACBuilder tacBuilder = TACBuilder.builder(resourceStore) + ApplicationContext applicationContext + ) { + TACBuilder tacBuilder = TACBuilder.builder() + .setResourceStore(resourceStore) .setGenerator(captchaGenerator) .setValidator(imageCaptchaValidator) .setCacheStore(cacheStore) @@ -116,7 +110,10 @@ public class ImageCaptchaAutoConfiguration { String[] split = index > 0 ? new String[]{fontPath.substring(0, index), fontPath.substring(index + 1)} : new String[]{"", fontPath}; String type = split[0]; String path = split[1]; - tacBuilder.addFont(new cloud.tianai.captcha.resource.common.model.dto.Resource(type, path)); + + URL fontUrl = URL.valueOf(fontPath); + String tag = fontUrl.getParam(URL.PARAM_TAG_KEY, null); + tacBuilder.addFont(new cloud.tianai.captcha.resource.common.model.dto.Resource(type, path, tag)); } } ImageCaptchaApplication target = tacBuilder.build(); diff --git a/tianai-captcha-springboot-starter/src/main/java/cloud/tianai/captcha/spring/common/util/URL.java b/tianai-captcha-springboot-starter/src/main/java/cloud/tianai/captcha/spring/common/util/URL.java new file mode 100644 index 0000000..f49d8d1 --- /dev/null +++ b/tianai-captcha-springboot-starter/src/main/java/cloud/tianai/captcha/spring/common/util/URL.java @@ -0,0 +1,59 @@ +package cloud.tianai.captcha.spring.common.util; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.HashMap; +import java.util.Map; + +@Getter +@AllArgsConstructor +public class URL { + + public static final String PARAM_TAG_KEY = "tag"; + + private String protocol; + private String path; + private Map params; + + public String getParam(String key, String defaultValue) { + return params.getOrDefault(key, defaultValue); + } + + public static URL valueOf(String input) { + // 分割协议和剩余部分 + String[] parts = input.split(":", 2); + String protocol = parts[0]; + String remaining = parts[1]; + + // 分割路径和查询参数 + String path; + String query = null; + + if (remaining.contains("?")) { + String[] pathQuerySplit = remaining.split("\\?", 2); + path = pathQuerySplit[0]; + query = pathQuerySplit[1]; + } else { + path = remaining; + } + if (path.startsWith("//")) { + path = path.substring(2); + } + // 处理查询参数,提取键值对 + Map queryParams = new HashMap<>(); + if (query != null) { + for (String param : query.split("&")) { + String[] keyValue = param.split("=", 2); + String key = keyValue[0]; + String value = keyValue.length > 1 ? keyValue[1] : ""; + queryParams.put(key, value); + } + } + + return new URL(protocol, path, queryParams); + + } + + +} diff --git a/tianai-captcha-springboot-starter/src/main/java/cloud/tianai/captcha/spring/exception/CaptchaValidException.java b/tianai-captcha-springboot-starter/src/main/java/cloud/tianai/captcha/spring/exception/CaptchaValidException.java deleted file mode 100644 index 90fe82d..0000000 --- a/tianai-captcha-springboot-starter/src/main/java/cloud/tianai/captcha/spring/exception/CaptchaValidException.java +++ /dev/null @@ -1,41 +0,0 @@ -package cloud.tianai.captcha.spring.exception; - -import cloud.tianai.captcha.common.exception.ImageCaptchaException; -import lombok.Getter; -import lombok.Setter; - -/** - * @Author: 天爱有情 - * @Date 2020/6/19 16:36 - * @Description 验证码验证失败异常 - */ -@Getter -@Setter -public class CaptchaValidException extends ImageCaptchaException { - - private String captchaType; - private Integer code; - public CaptchaValidException() { - } - - public CaptchaValidException(String captchaType,String message) { - super(message); - this.captchaType = captchaType; - } - public CaptchaValidException(String captchaType,Integer code, String message) { - super(message); - this.code = code; - this.captchaType = captchaType; - } - public CaptchaValidException(String message, Throwable cause) { - super(message, cause); - } - - public CaptchaValidException(Throwable cause) { - super(cause); - } - - public CaptchaValidException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) { - super(message, cause, enableSuppression, writableStackTrace); - } -} diff --git a/tianai-captcha-springboot-starter/src/main/java/cloud/tianai/captcha/spring/plugins/RedisResourceStore.java b/tianai-captcha-springboot-starter/src/main/java/cloud/tianai/captcha/spring/plugins/RedisResourceStore.java new file mode 100644 index 0000000..33a6f5a --- /dev/null +++ b/tianai-captcha-springboot-starter/src/main/java/cloud/tianai/captcha/spring/plugins/RedisResourceStore.java @@ -0,0 +1,286 @@ +package cloud.tianai.captcha.spring.plugins; + +import cloud.tianai.captcha.common.constant.CommonConstant; +import cloud.tianai.captcha.common.util.CollectionUtils; +import cloud.tianai.captcha.resource.CrudResourceStore; +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 com.google.gson.Gson; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import org.apache.commons.lang3.StringUtils; +import org.springframework.data.redis.core.StringRedisTemplate; + +import java.util.*; +import java.util.concurrent.ThreadLocalRandom; + +/** + * @Author: 天爱有情 + * @date 2023/8/23 10:52 + * @Description 基于redis的store + */ +@RequiredArgsConstructor +public class RedisResourceStore implements CrudResourceStore { + + + private final StringRedisTemplate redisTemplate; + + @Getter + @Setter + private String resourcePrefix = "captcha:config:resource:"; + @Getter + @Setter + private String templatePrefix = "captcha:config:template:"; + private Gson gson = new Gson(); + + public String joinResourceKey(String type, String tag) { + if (tag == null) { + tag = CommonConstant.DEFAULT_TAG; + } + type = type.toUpperCase(); + return resourcePrefix + tag + ":" + type; + } + + public String joinTemplateKey(String type, String tag) { + if (tag == null) { + tag = CommonConstant.DEFAULT_TAG; + } + type = type.toUpperCase(); + return templatePrefix + tag + ":" + type; + } + + public List getResources(String type, String tag) { + String key = joinResourceKey(type, tag); + Long size = redisTemplate.opsForList().size(key); + if (size == null || size < 1) { + return Collections.emptyList(); + } + List range = redisTemplate.opsForList().range(key, 0, size); + List result = new ArrayList<>(range.size()); + for (String json : range) { + result.add(gson.fromJson(json, Resource.class)); + } + + return result; + } + + public List getTemplates(String type, String tag) { + String key = joinTemplateKey(type, tag); + Long size = redisTemplate.opsForList().size(key); + if (size == null || size < 1) { + return Collections.emptyList(); + } + List range = redisTemplate.opsForList().range(key, 0, size); + List result = new ArrayList<>(range.size()); + for (String json : range) { + result.add(gson.fromJson(json, ResourceMap.class)); + } + return result; + } + + + public void setResources(String type, String tag, List resources) { + String key = joinResourceKey(type, tag); + Long size = redisTemplate.opsForList().size(key); + if (size != null && size > 0) { + redisTemplate.delete(key); + } + for (Resource resource : resources) { + addResource(type, resource); + } + } + + public void setTemplates(String type, String tag, List templates) { + String key = joinTemplateKey(type, tag); + Long size = redisTemplate.opsForList().size(key); + if (size != null && size > 0) { + redisTemplate.delete(key); + } + for (ResourceMap template : templates) { + addTemplate(type, template); + } + } + + + @Override + public void addResource(String type, Resource resource) { + // 添加tag标签字典 + redisTemplate.opsForList().rightPush(joinResourceKey(type, resource.getTag()), gson.toJson(resource)); + } + + @Override + public void addTemplate(String type, ResourceMap template) { + // 添加tag标签字典 + redisTemplate.opsForList().rightPush(joinTemplateKey(type, template.getTag()), gson.toJson(template)); + } + + @Override + public Resource deleteResource(String type, String id) { + Set keys = redisTemplate.keys(joinResourceKey(type, "*")); + if (!CollectionUtils.isEmpty(keys)) { + for (String key : keys) { + Long size = redisTemplate.opsForList().size(key); + if (size == null || size < 1) { + continue; + } + List range = redisTemplate.opsForList().range(key, 0, size); + if (range != null) { + for (String json : range) { + Resource resource = gson.fromJson(json, Resource.class); + if (resource.getId().equals(id)) { + redisTemplate.opsForList().remove(key, 1, json); + return resource; + } + } + } + } + } + return null; + } + + @Override + public ResourceMap deleteTemplate(String type, String id) { + Set keys = redisTemplate.keys(joinTemplateKey(type, "*")); + if (!CollectionUtils.isEmpty(keys)) { + for (String key : keys) { + Long size = redisTemplate.opsForList().size(key); + if (size == null || size < 1) { + continue; + } + List range = redisTemplate.opsForList().range(key, 0, size); + if (range != null) { + for (String json : range) { + ResourceMap resourceMap = gson.fromJson(json, ResourceMap.class); + if (resourceMap.getId().equals(id)) { + redisTemplate.opsForList().remove(key, 1, json); + return resourceMap; + } + } + } + } + } + return null; + } + + @Override + public List listResourcesByTypeAndTag(String type, String tag) { + if (StringUtils.isNotBlank(tag)) { + return getResources(type, tag); + } + Set keys = redisTemplate.keys(joinResourceKey(type, "*")); + if (!CollectionUtils.isEmpty(keys)) { + List resources = new ArrayList<>(); + for (String key : keys) { + Long size1 = redisTemplate.opsForList().size(key); + if (size1 == null || size1 < 1) { + continue; + } + List range = redisTemplate.opsForList().range(key, 0, size1); + if (range != null) { + for (String json : range) { + Resource resource = gson.fromJson(json, Resource.class); + resources.add(resource); + } + } + } + return resources; + } + return Collections.emptyList(); + } + + @Override + public List listTemplatesByTypeAndTag(String type, String tag) { + if (StringUtils.isNotBlank(tag)) { + return getTemplates(type, tag); + } + Set keys = redisTemplate.keys(joinTemplateKey(type, "*")); + if (!CollectionUtils.isEmpty(keys)) { + List templates = new ArrayList<>(); + for (String key : keys) { + Long size1 = redisTemplate.opsForList().size(key); + if (size1 == null || size1 < 1) { + continue; + } + List range = redisTemplate.opsForList().range(key, 0, size1); + if (range != null) { + for (String json : range) { + ResourceMap template = gson.fromJson(json, ResourceMap.class); + templates.add(template); + } + } + } + return templates; + } + return Collections.emptyList(); + } + + @Override + public void init(ImageCaptchaResourceManager resourceManager) { + + } + + @Override + public List randomGetResourceByTypeAndTag(String type, String tag, Integer quantity) { + String key = joinResourceKey(type, tag); + Long size = redisTemplate.opsForList().size(key); + if (size == null || quantity > size) { + throw new IllegalArgumentException("请求的资源数量超过可用资源总数"); + } + + Set indexes = new HashSet<>(quantity); + while (indexes.size() < quantity) { + indexes.add(ThreadLocalRandom.current().nextLong(size)); + } + List result = new ArrayList<>(quantity); + for (Long index : indexes) { + String resourceJson = redisTemplate.opsForList().index(key, index); + result.add(gson.fromJson(resourceJson, Resource.class)); + + } + return result; + } + + @Override + public List randomGetTemplateByTypeAndTag(String type, String tag, Integer quantity) { + String key = joinTemplateKey(type, tag); + Long size = redisTemplate.opsForList().size(key); + if (size == null || size < 1) { + throw new IllegalStateException("随机获取模板错误,store中模板为空, type:" + type); + } + if (quantity > size) { + throw new IllegalArgumentException("请求的模板数量超过可用模板总数"); + } + + Set indexes = new HashSet<>(quantity); + while (indexes.size() < quantity) { + indexes.add(ThreadLocalRandom.current().nextLong(size)); + } + + List result = new ArrayList<>(quantity); + + for (Long index : indexes) { + String resourceJson = redisTemplate.opsForList().index(key, index); + result.add(gson.fromJson(resourceJson, ResourceMap.class)); + } + return result; + } + + @Override + public void clearAllTemplates() { + Set keys = redisTemplate.keys(templatePrefix + "*"); + if (!CollectionUtils.isEmpty(keys)) { + redisTemplate.delete(keys); + } + } + + @Override + public void clearAllResources() { + Set keys = redisTemplate.keys(resourcePrefix + "*"); + if (!CollectionUtils.isEmpty(keys)) { + redisTemplate.delete(keys); + } + } + +} diff --git a/tianai-captcha-springboot-starter/src/main/java/cloud/tianai/captcha/spring/store/impl/RedisCacheStore.java b/tianai-captcha-springboot-starter/src/main/java/cloud/tianai/captcha/spring/store/impl/RedisCacheStore.java index aff4394..a854396 100644 --- a/tianai-captcha-springboot-starter/src/main/java/cloud/tianai/captcha/spring/store/impl/RedisCacheStore.java +++ b/tianai-captcha-springboot-starter/src/main/java/cloud/tianai/captcha/spring/store/impl/RedisCacheStore.java @@ -68,4 +68,9 @@ public class RedisCacheStore implements CacheStore { } return Long.valueOf(value); } + + @Override + public void close() throws Exception { + + } } diff --git a/tianai-captcha/src/main/java/cloud/tianai/captcha/application/DefaultImageCaptchaApplication.java b/tianai-captcha/src/main/java/cloud/tianai/captcha/application/DefaultImageCaptchaApplication.java index 41b8fd8..874e2da 100644 --- a/tianai-captcha/src/main/java/cloud/tianai/captcha/application/DefaultImageCaptchaApplication.java +++ b/tianai-captcha/src/main/java/cloud/tianai/captcha/application/DefaultImageCaptchaApplication.java @@ -2,16 +2,17 @@ package cloud.tianai.captcha.application; import cloud.tianai.captcha.application.vo.ImageCaptchaVO; import cloud.tianai.captcha.cache.CacheStore; -import cloud.tianai.captcha.cache.StoreCacheKeyPrefix; import cloud.tianai.captcha.common.AnyMap; import cloud.tianai.captcha.common.constant.CaptchaTypeConstant; import cloud.tianai.captcha.common.exception.ImageCaptchaException; import cloud.tianai.captcha.common.response.ApiResponse; import cloud.tianai.captcha.common.response.ApiResponseStatusConstant; import cloud.tianai.captcha.common.util.CollectionUtils; +import cloud.tianai.captcha.common.util.ObjectUtils; import cloud.tianai.captcha.generator.ImageCaptchaGenerator; import cloud.tianai.captcha.generator.common.model.dto.GenerateParam; import cloud.tianai.captcha.generator.common.model.dto.ImageCaptchaInfo; +import cloud.tianai.captcha.generator.common.model.dto.ParamKeyEnum; import cloud.tianai.captcha.generator.impl.CacheImageCaptchaGenerator; import cloud.tianai.captcha.interceptor.CaptchaInterceptor; import cloud.tianai.captcha.interceptor.EmptyCaptchaInterceptor; @@ -42,8 +43,6 @@ public class DefaultImageCaptchaApplication implements ImageCaptchaApplication { private CacheStore cacheStore; /** 验证码配置属性. */ private final ImageCaptchaProperties prop; - /** 缓存key 前缀处理器. */ - private StoreCacheKeyPrefix storeCacheKeyPrefix; /** 默认的过期时间. */ private long defaultExpire = 20000L; @@ -53,10 +52,9 @@ public class DefaultImageCaptchaApplication implements ImageCaptchaApplication { ImageCaptchaValidator imageCaptchaValidator, CacheStore cacheStore, ImageCaptchaProperties prop, - CaptchaInterceptor captchaInterceptor, - StoreCacheKeyPrefix storeCacheKeyPrefix) { + CaptchaInterceptor captchaInterceptor) { this.prop = prop; - this.storeCacheKeyPrefix = null != storeCacheKeyPrefix? storeCacheKeyPrefix: StoreCacheKeyPrefix.prefixed(prop.getPrefix()); + setImageCaptchaValidator(imageCaptchaValidator); setCacheStore(cacheStore); // 默认过期时间 @@ -99,9 +97,13 @@ public class DefaultImageCaptchaApplication implements ImageCaptchaApplication { if (captchaResponse != null) { return captchaResponse; } + String id = generatorId(param); + ImageCaptchaInfo imageCaptchaInfo = getImageCaptchaGenerator().generateCaptchaImage(param); - captchaResponse = convertToCaptchaResponse(imageCaptchaInfo); + captchaResponse = convertToCaptchaResponse(id, imageCaptchaInfo); afterGenerateCaptcha(imageCaptchaInfo, captchaResponse); + + param.removeParam(ParamKeyEnum.ID); return captchaResponse; } @@ -125,14 +127,14 @@ public class DefaultImageCaptchaApplication implements ImageCaptchaApplication { } - public ApiResponse convertToCaptchaResponse(ImageCaptchaInfo imageCaptchaInfo) { + public ApiResponse convertToCaptchaResponse(String id, ImageCaptchaInfo imageCaptchaInfo) { if (imageCaptchaInfo == null) { // 要是生成失败 throw new ImageCaptchaException("生成验证码失败,验证码生成为空"); } // 生成ID - String id = generatorId(imageCaptchaInfo); - ApiResponse response = beforeGenerateImageCaptchaValidData(imageCaptchaInfo); + + ApiResponse response = beforeGenerateImageCaptchaValidData( imageCaptchaInfo); if (response != null) { return response; } @@ -209,8 +211,14 @@ public class DefaultImageCaptchaApplication implements ImageCaptchaApplication { return null; } - protected String generatorId(ImageCaptchaInfo imageCaptchaInfo) { - return imageCaptchaInfo.getType() + ID_SPLIT + UUID.randomUUID().toString().replace("-", ""); + protected String generatorId(GenerateParam param) { + String id = param.getParam(ParamKeyEnum.ID); + if (ObjectUtils.isEmpty(id)) { + id = param.getType() + ID_SPLIT + UUID.randomUUID().toString().replace("-", ""); + + param.addParam(ParamKeyEnum.ID, id); + } + return id; } /** @@ -239,8 +247,7 @@ public class DefaultImageCaptchaApplication implements ImageCaptchaApplication { } protected String getKey(String id) { - // 改为通过接口扩展 - return storeCacheKeyPrefix.compute(id); + return prop.getPrefix().concat(":").concat(id); } @Override @@ -292,6 +299,7 @@ public class DefaultImageCaptchaApplication implements ImageCaptchaApplication { // ============== 一些模板方法 ================ private void afterGenerateCaptcha(ImageCaptchaInfo imageCaptchaInfo, ApiResponse captchaResponse) { + captchaInterceptor.afterGenerateCaptcha(captchaInterceptor.createContext(), imageCaptchaInfo.getType(), imageCaptchaInfo, captchaResponse); } @@ -315,4 +323,24 @@ public class DefaultImageCaptchaApplication implements ImageCaptchaApplication { return captchaInterceptor.afterValid(captchaInterceptor.createContext(), getCaptchaTypeById(id), matchParam, validData, basicValid); } + /** + * 销毁应用程序,释放资源 + * 建议在不再使用或应用关闭时调用 + */ + @Override + public void close() { + // 如果生成器是 CacheImageCaptchaGenerator,需要关闭其定时任务 + if (captchaGenerator instanceof CacheImageCaptchaGenerator) { + ((CacheImageCaptchaGenerator) captchaGenerator).destroy(); + } + // 如果缓存存储是 AutoCloseable,关闭它 + if (cacheStore instanceof AutoCloseable) { + try { + ((AutoCloseable) cacheStore).close(); + } catch (Exception e) { + log.warn("关闭 CacheStore 时出错", e); + } + } + } + } diff --git a/tianai-captcha/src/main/java/cloud/tianai/captcha/application/FilterImageCaptchaApplication.java b/tianai-captcha/src/main/java/cloud/tianai/captcha/application/FilterImageCaptchaApplication.java index 55aedf6..faf54b0 100644 --- a/tianai-captcha/src/main/java/cloud/tianai/captcha/application/FilterImageCaptchaApplication.java +++ b/tianai-captcha/src/main/java/cloud/tianai/captcha/application/FilterImageCaptchaApplication.java @@ -114,4 +114,9 @@ public class FilterImageCaptchaApplication implements ImageCaptchaApplication { public CacheStore getCacheStore() { return target.getCacheStore(); } + + @Override + public void close() throws Exception { + target.close(); + } } diff --git a/tianai-captcha/src/main/java/cloud/tianai/captcha/application/ImageCaptchaApplication.java b/tianai-captcha/src/main/java/cloud/tianai/captcha/application/ImageCaptchaApplication.java index a89502b..6240e9c 100644 --- a/tianai-captcha/src/main/java/cloud/tianai/captcha/application/ImageCaptchaApplication.java +++ b/tianai-captcha/src/main/java/cloud/tianai/captcha/application/ImageCaptchaApplication.java @@ -17,7 +17,7 @@ import cloud.tianai.captcha.validator.common.model.dto.MatchParam; * @Date 2020/5/29 8:33 * @Description 滑块验证码应用程序 */ -public interface ImageCaptchaApplication { +public interface ImageCaptchaApplication extends AutoCloseable{ /** * 生成滑块验证码 diff --git a/tianai-captcha/src/main/java/cloud/tianai/captcha/application/TACBuilder.java b/tianai-captcha/src/main/java/cloud/tianai/captcha/application/TACBuilder.java index 9f831f4..7c19809 100644 --- a/tianai-captcha/src/main/java/cloud/tianai/captcha/application/TACBuilder.java +++ b/tianai-captcha/src/main/java/cloud/tianai/captcha/application/TACBuilder.java @@ -3,6 +3,7 @@ package cloud.tianai.captcha.application; import cloud.tianai.captcha.cache.CacheStore; import cloud.tianai.captcha.cache.StoreCacheKeyPrefix; import cloud.tianai.captcha.cache.impl.LocalCacheStore; +import cloud.tianai.captcha.common.util.CollectionUtils; import cloud.tianai.captcha.generator.ImageCaptchaGenerator; import cloud.tianai.captcha.generator.ImageTransform; import cloud.tianai.captcha.generator.impl.MultiImageCaptchaGenerator; @@ -16,6 +17,9 @@ import cloud.tianai.captcha.resource.impl.LocalMemoryResourceStore; import cloud.tianai.captcha.validator.ImageCaptchaValidator; import cloud.tianai.captcha.validator.impl.SimpleImageCaptchaValidator; +import java.util.LinkedHashMap; +import java.util.Map; + /** * @Author: 天爱有情 * @date 2024/7/14 16:41 @@ -30,22 +34,23 @@ public class TACBuilder { private ImageCaptchaProperties prop = new ImageCaptchaProperties(); private ResourceStore resourceStore; private ImageTransform imageTransform; - private StoreCacheKeyPrefix cacheKeyPrefix; // private List fontWrappers = new ArrayList<>(); + private Map resourceCache; + private Map templateCache; public static TACBuilder builder() { - return TACBuilder.builder(new LocalMemoryResourceStore()); + return TACBuilder.builder(); } - public static TACBuilder builder(ResourceStore resourceStore) { - TACBuilder builder = new TACBuilder(resourceStore); - builder.prop = new ImageCaptchaProperties(); - return builder; - } + private TACBuilder(ResourceStore resourceStore) { this.resourceStore = resourceStore; } + public TACBuilder setResourceStore(ResourceStore resourceStore) { + this.resourceStore = resourceStore; + return this; + } public TACBuilder addDefaultTemplate(String defaultPathPrefix) { DefaultBuiltInResources defaultBuiltInResources = new DefaultBuiltInResources(defaultPathPrefix); @@ -77,10 +82,6 @@ public class TACBuilder { return this; } - public TACBuilder setCacheKeyPrefix(StoreCacheKeyPrefix cacheKeyPrefix) { - this.cacheKeyPrefix = cacheKeyPrefix; - return this; - } public TACBuilder addFont(Resource resource) { this.addResource(FontCache.FONT_TYPE, resource); return this; @@ -118,16 +119,18 @@ public class TACBuilder { public TACBuilder addResource(String captchaType, Resource imageResource) { - if (resourceStore instanceof CrudResourceStore) { - ((CrudResourceStore) resourceStore).addResource(captchaType, imageResource); - } +// if (resourceStore instanceof CrudResourceStore) { +// ((CrudResourceStore) resourceStore).addResource(captchaType, imageResource); +// } + cacheResource(captchaType, imageResource); return this; } public TACBuilder addTemplate(String captchaType, ResourceMap resourceMap) { - if (resourceStore instanceof CrudResourceStore) { - ((CrudResourceStore) resourceStore).addTemplate(captchaType, resourceMap); - } +// if (resourceStore instanceof CrudResourceStore) { +// ((CrudResourceStore) resourceStore).addTemplate(captchaType, resourceMap); +// } + cacheTemplate(captchaType, resourceMap); return this; } @@ -140,6 +143,20 @@ public class TACBuilder { if (cacheStore == null) { cacheStore = new LocalCacheStore(); } + if (resourceStore == null) { + resourceStore = new LocalMemoryResourceStore(); + } + if (resourceStore instanceof CrudResourceStore) { + CrudResourceStore crudResourceStore = (CrudResourceStore) resourceStore; + if (!CollectionUtils.isEmpty(resourceCache)) { + resourceCache.forEach(crudResourceStore::addResource); + resourceCache = null; + } + if (!CollectionUtils.isEmpty(templateCache)) { + templateCache.forEach(crudResourceStore::addTemplate); + templateCache = null; + } + } if (generator == null) { ResourceProviders resourceProviders = new ResourceProviders(); DefaultImageCaptchaResourceManager resourceManager = new DefaultImageCaptchaResourceManager(resourceStore, resourceProviders); @@ -155,7 +172,20 @@ public class TACBuilder { interceptor = EmptyCaptchaInterceptor.INSTANCE; } // 增加前缀处理接口 - DefaultImageCaptchaApplication application = new DefaultImageCaptchaApplication(generator, validator, cacheStore, prop, interceptor, cacheKeyPrefix); + DefaultImageCaptchaApplication application = new DefaultImageCaptchaApplication(generator, validator, cacheStore, prop, interceptor); return application; } + + private void cacheResource(String captchaType, Resource imageResource) { + if (resourceCache == null) { + resourceCache = new LinkedHashMap<>(8); + } + resourceCache.put(captchaType, imageResource); + } + private void cacheTemplate(String captchaType, ResourceMap resourceMap) { + if (templateCache == null) { + templateCache = new LinkedHashMap<>(8); + } + templateCache.put(captchaType, resourceMap); + } } diff --git a/tianai-captcha/src/main/java/cloud/tianai/captcha/cache/CacheStore.java b/tianai-captcha/src/main/java/cloud/tianai/captcha/cache/CacheStore.java index a27aee4..87aee9e 100644 --- a/tianai-captcha/src/main/java/cloud/tianai/captcha/cache/CacheStore.java +++ b/tianai-captcha/src/main/java/cloud/tianai/captcha/cache/CacheStore.java @@ -9,7 +9,7 @@ import java.util.concurrent.TimeUnit; * @date 2022/3/2 14:35 * @Description 提取出用于缓存的接口 */ -public interface CacheStore { +public interface CacheStore extends AutoCloseable { /** * 读取缓存数据通过key diff --git a/tianai-captcha/src/main/java/cloud/tianai/captcha/cache/impl/ConCurrentExpiringMap.java b/tianai-captcha/src/main/java/cloud/tianai/captcha/cache/impl/ConCurrentExpiringMap.java index 0ee9359..7d5e12d 100644 --- a/tianai-captcha/src/main/java/cloud/tianai/captcha/cache/impl/ConCurrentExpiringMap.java +++ b/tianai-captcha/src/main/java/cloud/tianai/captcha/cache/impl/ConCurrentExpiringMap.java @@ -12,7 +12,6 @@ import java.util.stream.Collectors; /** * @Author: 天爱有情 * @date 2020/10/12 10:02 - * @Description 给予本人以前写的 expiring-map(redis淘汰策略的java实现) 项目进行改造 */ @Slf4j @Accessors(chain = true) @@ -198,6 +197,28 @@ public class ConCurrentExpiringMap implements ExpiringMap { throw new IllegalArgumentException("timemap not impl entrySet."); } + /** + * 销毁方法,关闭定时任务线程池 + * 应该在不再使用此 Map 时调用,以防止线程泄露 + * + * @since 0.0.3 + */ + public void destroy() { + try { + scheduledExecutor.shutdown(); + if (!scheduledExecutor.awaitTermination(5, TimeUnit.SECONDS)) { + scheduledExecutor.shutdownNow(); + if (!scheduledExecutor.awaitTermination(5, TimeUnit.SECONDS)) { + log.warn("ConCurrentExpiringMap 定时任务线程池未能正常关闭"); + } + } + } catch (InterruptedException e) { + scheduledExecutor.shutdownNow(); + Thread.currentThread().interrupt(); + log.warn("ConCurrentExpiringMap 定时任务线程池关闭时被中断", e); + } + } + /** * 定时执行任务 * diff --git a/tianai-captcha/src/main/java/cloud/tianai/captcha/cache/impl/LocalCacheStore.java b/tianai-captcha/src/main/java/cloud/tianai/captcha/cache/impl/LocalCacheStore.java index cf3c546..e6d60df 100644 --- a/tianai-captcha/src/main/java/cloud/tianai/captcha/cache/impl/LocalCacheStore.java +++ b/tianai-captcha/src/main/java/cloud/tianai/captcha/cache/impl/LocalCacheStore.java @@ -63,4 +63,15 @@ public class LocalCacheStore implements CacheStore { } return null; } + + /** + * 关闭缓存存储,释放资源 + * 建议在不再使用时调用,或在 Spring Bean 销毁时自动调用 + */ + @Override + public void close() { + if (cache instanceof ConCurrentExpiringMap) { + ((ConCurrentExpiringMap) cache).destroy(); + } + } } diff --git a/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/common/FontWrapper.java b/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/common/FontWrapper.java index b09c311..3d3fa16 100644 --- a/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/common/FontWrapper.java +++ b/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/common/FontWrapper.java @@ -5,28 +5,34 @@ import lombok.Data; import lombok.NoArgsConstructor; import java.awt.*; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; @Data @NoArgsConstructor @AllArgsConstructor public class FontWrapper { - private Font font; - private Float currentFontTopCoef; + // private Font font; +// private Float currentFontTopCoef; + + private Map fontCache = new ConcurrentHashMap<>(4); + private Font baseFont; + public static final int DEFAULT_FONT_SIZE = 70; + + + public Font getFont() { + return getFont(DEFAULT_FONT_SIZE); + } + + public Font getFont(float size) { + return fontCache.computeIfAbsent(size, k -> baseFont.deriveFont(Font.BOLD, size)); + } public FontWrapper(Font font) { - this(font, 70); + this.baseFont = font; } - public FontWrapper(Font font, int fontSize) { - this.font = font; - this.font = font.deriveFont(Font.BOLD, fontSize); - } - - public float getCurrentFontTopCoef() { - if (currentFontTopCoef != null) { - return currentFontTopCoef; - } - currentFontTopCoef = 0.14645833f * font.getSize() + 0.39583333f; - return currentFontTopCoef; + public float getFontTopCoef(Font font) { + return 0.14645833f * font.getSize() + 0.39583333f; } } diff --git a/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/common/model/dto/GenerateParam.java b/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/common/model/dto/GenerateParam.java index 808b02b..b66a8fe 100644 --- a/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/common/model/dto/GenerateParam.java +++ b/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/common/model/dto/GenerateParam.java @@ -65,6 +65,9 @@ public class GenerateParam { } return param.remove(key); } + public Object removeParam(ParamKey paramKey) { + return removeParam(paramKey.getKey()); + } public Object getOrDefault(String key, Object defaultValue) { if (param == null) { diff --git a/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/common/model/dto/ParamKeyEnum.java b/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/common/model/dto/ParamKeyEnum.java index 76fb029..1fef4dc 100644 --- a/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/common/model/dto/ParamKeyEnum.java +++ b/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/common/model/dto/ParamKeyEnum.java @@ -15,6 +15,10 @@ public class ParamKeyEnum implements ParamKey { public static final ParamKey CLICK_INTERFERENCE_COUNT = new ParamKeyEnum<>("interferenceCount"); /** 读取字体时,可指定字体TAG,可用于给不同的验证码指定不同的字体包.*/ public static final ParamKey FONT_TAG = new ParamKeyEnum<>("fontTag"); + + /** 验证码ID,内部使用.*/ + public static final ParamKey ID = new ParamKeyEnum<>("_id"); + private String key; } diff --git a/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/common/util/CaptchaImageUtils.java b/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/common/util/CaptchaImageUtils.java index 8e5f809..e3978d3 100644 --- a/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/common/util/CaptchaImageUtils.java +++ b/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/common/util/CaptchaImageUtils.java @@ -4,6 +4,7 @@ import lombok.SneakyThrows; import javax.imageio.ImageIO; import java.awt.*; +import java.awt.geom.AffineTransform; import java.awt.geom.Area; import java.awt.geom.CubicCurve2D; import java.awt.geom.QuadCurve2D; @@ -387,24 +388,29 @@ public class CaptchaImageUtils { int interferencePointNum) { BufferedImage bufferedImage = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); Graphics2D g = bufferedImage.createGraphics(); - ThreadLocalRandom random = ThreadLocalRandom.current(); - g.setFont(font); - char[] chars = data.toCharArray(); + try { + ThreadLocalRandom random = ThreadLocalRandom.current(); + g.setFont(font); + char[] chars = data.toCharArray(); - for (int i = 0; i < chars.length; i++) { - g.setColor(Color.gray); - g.drawString(String.valueOf(chars[i]), startX + i * font.getSize(), startY); + for (int i = 0; i < chars.length; i++) { + g.setColor(Color.gray); + g.drawString(String.valueOf(chars[i]), startX + i * font.getSize(), startY); + } + // 干扰点 + if (interferencePointNum > 0) { + drawOval(interferencePointNum, null, g, width, height, random); + } + if (interferencePointNum > 0) { + g.setStroke(new BasicStroke(1.2f, BasicStroke.CAP_BUTT, BasicStroke.JOIN_BEVEL)); + // 干扰线 + drawBesselLine(interferenceLineNum, null, g, width, height, random); + } + return bufferedImage; + } finally { + // fixme #IDIG16 + g.dispose(); } - // 干扰点 - if (interferencePointNum > 0) { - drawOval(interferencePointNum, null, g, width, height, random); - } - if (interferencePointNum > 0) { - g.setStroke(new BasicStroke(1.2f, BasicStroke.CAP_BUTT, BasicStroke.JOIN_BEVEL)); - // 干扰线 - drawBesselLine(interferenceLineNum, null, g, width, height, random); - } - return bufferedImage; } @@ -520,5 +526,81 @@ public class CaptchaImageUtils { return TYPE_PNG.equalsIgnoreCase(type); } -} + /** + * 绘制水印 + * @param bgImage 背景图片 + * @param watermark 水印文字 + * @param x x坐标起始位置 + * @param y y坐标起始位置 + * @param color 水印颜色 + * @param font 水印字体 + */ + public static void drawWatermark(BufferedImage bgImage, String watermark, int x, int y, Color color, Font font) { + // 参数校验 + if (bgImage == null || watermark == null || watermark.isEmpty() || font == null) { + return; + } + // 获取Graphics2D对象 + Graphics2D g2d = bgImage.createGraphics(); + try { + // 设置渲染质量 + g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + + g2d.setColor(color); + + // 设置字体 + g2d.setFont(font); + + // 计算文字尺寸 + FontMetrics fontMetrics = g2d.getFontMetrics(); + int textWidth = fontMetrics.stringWidth(watermark); + int textHeight = fontMetrics.getHeight(); + + // 计算旋转角度(45度角倾斜) + double angle = Math.toRadians(45); + + // 计算水印间距,确保铺满整个图片 + int spacingX = textWidth + 50; + int spacingY = textHeight + 50; + + // 获取图片尺寸 + int imageWidth = bgImage.getWidth(); + int imageHeight = bgImage.getHeight(); + + // 计算需要绘制的水印数量 + int rows = (int) Math.ceil((double) imageHeight / spacingY) + 2; + int cols = (int) Math.ceil((double) imageWidth / spacingX) + 2; + + // 绘制水印,铺满整个图片 + for (int row = 0; row < rows; row++) { + for (int col = 0; col < cols; col++) { + // 计算当前水印位置 + int currentX = col * spacingX + x; + int currentY = row * spacingY + y; + + // 保存当前坐标系 + AffineTransform originalTransform = g2d.getTransform(); + + try { + // 平移到当前位置 + g2d.translate(currentX, currentY); + + // 旋转文字 + g2d.rotate(angle); + + // 绘制文字 + g2d.drawString(watermark, 0, fontMetrics.getAscent()); + } finally { + // 恢复原始坐标系 + g2d.setTransform(originalTransform); + } + } + } + } finally { + // 释放资源 + g2d.dispose(); + } + } +} diff --git a/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/common/util/ImgWriter.java b/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/common/util/ImgWriter.java index 47e099f..9be2281 100644 --- a/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/common/util/ImgWriter.java +++ b/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/common/util/ImgWriter.java @@ -34,10 +34,22 @@ public class ImgWriter { if (ObjectUtils.isEmpty(imageType)) { imageType = CaptchaImageUtils.TYPE_JPG; } - ImageOutputStream imageOutputStream = transformImageOutputStream(destImageStream); - final BufferedImage bufferedImage = CaptchaImageUtils.toBufferedImage(image, imageType); - final ImageWriter writer = getWriter(bufferedImage, imageType); - return write(bufferedImage, writer, imageOutputStream, quality); + ImageOutputStream imageOutputStream = null; + try { + imageOutputStream = transformImageOutputStream(destImageStream); + final BufferedImage bufferedImage = CaptchaImageUtils.toBufferedImage(image, imageType); + final ImageWriter writer = getWriter(bufferedImage, imageType); + return write(bufferedImage, writer, imageOutputStream, quality); + } finally { + // 关闭 ImageOutputStream 防止资源泄露 + if (imageOutputStream != null) { + try { + imageOutputStream.close(); + } catch (IOException e) { + // 忽略关闭异常 + } + } + } } /** diff --git a/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/impl/CacheImageCaptchaGenerator.java b/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/impl/CacheImageCaptchaGenerator.java index f512ac4..548ffd4 100644 --- a/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/impl/CacheImageCaptchaGenerator.java +++ b/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/impl/CacheImageCaptchaGenerator.java @@ -212,4 +212,28 @@ public class CacheImageCaptchaGenerator implements ImageCaptchaGenerator { target.setInterceptor(interceptor); } + /** + * 销毁方法,关闭定时任务线程池并清理缓存 + * 应该在不再使用此 Generator 时调用,以防止线程和内存泄露 + */ + public void destroy() { + try { + scheduledExecutor.shutdown(); + if (!scheduledExecutor.awaitTermination(5, TimeUnit.SECONDS)) { + scheduledExecutor.shutdownNow(); + if (!scheduledExecutor.awaitTermination(5, TimeUnit.SECONDS)) { + log.warn("CacheImageCaptchaGenerator 定时任务线程池未能正常关闭"); + } + } + } catch (InterruptedException e) { + scheduledExecutor.shutdownNow(); + Thread.currentThread().interrupt(); + log.warn("CacheImageCaptchaGenerator 定时任务线程池关闭时被中断", e); + } + // 清理缓存 + queueMap.clear(); + posMap.clear(); + lastUpdateMap.clear(); + } + } diff --git a/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/impl/StandardSliderImageCaptchaGenerator.java b/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/impl/StandardSliderImageCaptchaGenerator.java index e4ced7b..d124db0 100644 --- a/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/impl/StandardSliderImageCaptchaGenerator.java +++ b/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/impl/StandardSliderImageCaptchaGenerator.java @@ -113,11 +113,15 @@ public class StandardSliderImageCaptchaGenerator extends AbstractImageCaptchaGen int type = fixedImage.getColorModel().getTransparency(); BufferedImage image = new BufferedImage(width, height, type); Graphics2D graphics = image.createGraphics(); - // 透明度 - double alpha = ThreadLocalRandom.current().nextDouble(0.5, 0.8); - AlphaComposite alphaComposite = AlphaComposite.Src.derive((float) alpha); - graphics.setComposite(alphaComposite); - graphics.drawImage(fixedImage, 0, 0, width, height, null); + try { + // 透明度 + double alpha = ThreadLocalRandom.current().nextDouble(0.5, 0.8); + AlphaComposite alphaComposite = AlphaComposite.Src.derive((float) alpha); + graphics.setComposite(alphaComposite); + graphics.drawImage(fixedImage, 0, 0, width, height, null); + } finally { + graphics.dispose(); + } return image; } diff --git a/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/impl/StandardWordClickImageCaptchaGenerator.java b/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/impl/StandardWordClickImageCaptchaGenerator.java index 5a40fab..632ef1c 100644 --- a/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/impl/StandardWordClickImageCaptchaGenerator.java +++ b/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/impl/StandardWordClickImageCaptchaGenerator.java @@ -35,11 +35,7 @@ public class StandardWordClickImageCaptchaGenerator extends AbstractClickImageCa // @Getter // @Setter // protected List fonts = new ArrayList<>(); - @Getter - @Setter protected Integer clickImgWidth = 100; - @Getter - @Setter protected Integer clickImgHeight = 100; @Getter @Setter @@ -95,6 +91,33 @@ public class StandardWordClickImageCaptchaGenerator extends AbstractClickImageCa return tipList; } + @Override + 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); + // 缩放 + double factor = 600d; + + FontWrapper fontWrapper = randomFont(param); + Font font = fontWrapper.getFont((float) (FontWrapper.DEFAULT_FONT_SIZE * factor)); + float currentFontTopCoef = fontWrapper.getFontTopCoef(font); + // 图片点击宽度 + int clickImgWidth = (int) (font.getSize() * 1.428571428571429); + + BufferedImage fontImage = CaptchaImageUtils.drawWordImg(randomColor, + tip.getData(), + font, + currentFontTopCoef, + clickImgWidth, + clickImgWidth, + randomDeg); + return new ClickImageCheckDefinition.ImgWrapper(fontImage, tip, randomColor); + } + @Override protected void doInit() { // if (CollectionUtils.isEmpty(fonts)) { @@ -115,10 +138,12 @@ public class StandardWordClickImageCaptchaGenerator extends AbstractClickImageCa throw new ImageCaptchaException("随机获取字体失败, resource中没有读到字体包, resource=" + resource); } + + public ClickImageCheckDefinition.ImgWrapper genTipImage(List imageCheckDefinitions, GenerateParam param) { FontWrapper fontWrapper = randomFont(param); Font font = fontWrapper.getFont(); - float currentFontTopCoef = fontWrapper.getCurrentFontTopCoef(); + float currentFontTopCoef = fontWrapper.getFontTopCoef(font); String tips = imageCheckDefinitions.stream().map(c -> c.getTip().getData()).collect(Collectors.joining()); // 生成随机颜色 int fontWidth = tips.length() * font.getSize(); @@ -140,27 +165,6 @@ public class StandardWordClickImageCaptchaGenerator extends AbstractClickImageCa // } - @Override - 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(param); - Font font = fontWrapper.getFont(); - float currentFontTopCoef = fontWrapper.getCurrentFontTopCoef(); - BufferedImage fontImage = CaptchaImageUtils.drawWordImg(randomColor, - tip.getData(), - font, - currentFontTopCoef, - clickImgWidth, - clickImgHeight, - randomDeg); - return new ClickImageCheckDefinition.ImgWrapper(fontImage, tip, randomColor); - } - @Override protected List filterAndSortClickImageCheckDefinition(CaptchaExchange captchaExchange, List allCheckDefinitionList) { GenerateParam param = captchaExchange.getParam(); diff --git a/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/impl/transform/Base64ImageTransform.java b/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/impl/transform/Base64ImageTransform.java index 6cbe398..a39ab5d 100644 --- a/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/impl/transform/Base64ImageTransform.java +++ b/tianai-captcha/src/main/java/cloud/tianai/captcha/generator/impl/transform/Base64ImageTransform.java @@ -29,16 +29,24 @@ public class Base64ImageTransform implements ImageTransform { return result; } ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); - if (CaptchaImageUtils.isPng(transformType) || CaptchaImageUtils.isJpeg(transformType)) { - // 如果是 jpg 或者 png图片的话 用hutool的生成 - ImgWriter.write(bufferedImage, transformType, byteArrayOutputStream, -1); - } else { - ImageIO.write(bufferedImage, transformType, byteArrayOutputStream); + try { + if (CaptchaImageUtils.isPng(transformType) || CaptchaImageUtils.isJpeg(transformType)) { + // 如果是 jpg 或者 png图片的话 用hutool的生成 + ImgWriter.write(bufferedImage, transformType, byteArrayOutputStream, -1); + } else { + ImageIO.write(bufferedImage, transformType, byteArrayOutputStream); + } + //转换成字节码 + byte[] data = byteArrayOutputStream.toByteArray(); + String base64 = Base64.getEncoder().encodeToString(data); + return "data:image/" + transformType + ";base64,".concat(base64); + } finally { + try { + byteArrayOutputStream.close(); + } catch (IOException e) { + // 忽略关闭异常 + } } - //转换成字节码 - byte[] data = byteArrayOutputStream.toByteArray(); - String base64 = Base64.getEncoder().encodeToString(data); - return "data:image/" + transformType + ";base64,".concat(base64); } public String beforeTransform(BufferedImage bufferedImage, String formatType) { diff --git a/tianai-captcha/src/main/java/cloud/tianai/captcha/resource/FontCache.java b/tianai-captcha/src/main/java/cloud/tianai/captcha/resource/FontCache.java index cec77f9..5d8714e 100644 --- a/tianai-captcha/src/main/java/cloud/tianai/captcha/resource/FontCache.java +++ b/tianai-captcha/src/main/java/cloud/tianai/captcha/resource/FontCache.java @@ -3,8 +3,6 @@ package cloud.tianai.captcha.resource; import cloud.tianai.captcha.generator.common.FontWrapper; import cloud.tianai.captcha.resource.common.model.dto.Resource; import cloud.tianai.captcha.resource.common.model.dto.ResourceMap; -import lombok.Getter; -import lombok.Setter; import lombok.extern.slf4j.Slf4j; import java.awt.*; @@ -24,15 +22,14 @@ public class FontCache implements ResourceStore { public static final String FONT_TYPE = "font"; + /** + * 字体缓存最大数量,防止内存泄露 + */ + private static final int MAX_FONT_CACHE_SIZE = 100; private final Map fontMap = new ConcurrentHashMap<>(); private ResourceStore resourceStore; private ImageCaptchaResourceManager resourceManager; - @Setter - @Getter - private int fontSize = 70; - - public FontCache(ResourceStore resourceStore) { this.resourceStore = resourceStore; @@ -48,7 +45,7 @@ public class FontCache implements ResourceStore { public FontWrapper getFont(Resource resource) { try (InputStream stream = resourceManager.getResourceInputStream(resource)) { Font font = Font.createFont(0, stream); - return new FontWrapper(font, fontSize); + return new FontWrapper(font); } catch (FontFormatException | IOException e) { throw new RuntimeException(e); } @@ -60,11 +57,25 @@ public class FontCache implements ResourceStore { return resource.getType() + "_" + resource.getData(); } + /** + * 检查缓存大小,如果超过限制则清理 + */ + private void checkCacheSize() { + if (fontMap.size() > MAX_FONT_CACHE_SIZE) { + log.warn("字体缓存超过限制大小: {},执行清理", fontMap.size()); + // 简单清理策略:清空缓存 + // 如果需要更精细的 LRU 策略,可以使用 LinkedHashMap 或第三方缓存库 + fontMap.clear(); + } + } + @Override public List randomGetResourceByTypeAndTag(String type, String tag, Integer quantity) { List resources = resourceStore.randomGetResourceByTypeAndTag(type, tag, quantity); // 字体增强 if (FONT_TYPE.equalsIgnoreCase(type)) { + // 在添加新字体前检查缓存大小 + checkCacheSize(); for (Resource resource : resources) { FontWrapper fontWrapper = fontMap.computeIfAbsent(calcId(resource), v -> getFont(resource)); resource.setExtra(fontWrapper); diff --git a/tianai-captcha/src/test/java/example/readme/ApplicationTest.java b/tianai-captcha/src/test/java/example/readme/ApplicationTest.java index f6d313a..edc4b78 100644 --- a/tianai-captcha/src/test/java/example/readme/ApplicationTest.java +++ b/tianai-captcha/src/test/java/example/readme/ApplicationTest.java @@ -66,7 +66,7 @@ public class ApplicationTest { ImageCaptchaProperties prop = new ImageCaptchaProperties(); // application 验证码封装, prop为所需的一些扩展参数 - ImageCaptchaApplication application = new DefaultImageCaptchaApplication(generator, imageCaptchaValidator, cacheStore, prop, group, null); + ImageCaptchaApplication application = new DefaultImageCaptchaApplication(generator, imageCaptchaValidator, cacheStore, prop, group); return application; } }