做一个图形验证码,是每个 Web 开发者迟早都要面对的需求。登录、注册、发帖、秒杀、支付确认,几乎只要有用户输入和接口调用的地方,就能看到它的影子。SpringBoot 因为起步快、生态好,成了很多人实现这个功能的首选框架,但“能出图”和“能用得稳”完全是两码事。我见过不少项目上线之后被脚本刷爆接口,也见过验证码图片死活刷不出来、或者用户明明输对了却一直报错的尴尬场面。这篇就用 SpringBoot 把图形验证码从生成、存储到校验的完整链路拆开讲一遍,包含可直接落地的代码、参数选择背后的逻辑,以及我在实际项目中踩过的坑。
1. 项目整体设计与方案选型
1.1 验证码到底在防什么
先把问题想清楚:我们做图形验证码,不是为了让界面看起来“更安全”,而是要拦住那些不想被拦的东西。验证码的核心目标是区分“真人操作”和“自动化脚本”。具体的攻击场景很典型:
- 登录接口被暴力撞库,攻击者拿着泄露的账号密码批量尝试。
- 注册接口被脚本刷量,批量注册垃圾账号。
- 短信验证码接口被恶意调用,导致短信费用飙升,也就是常说的短信轰炸。
- 投票、秒杀、抢购类接口被机器人抢占资源。
- 论坛、评论区被自动发帖机灌入大量的广告内容。
这些场景的共同点是:它们都依赖一个可以被自动化调用的接口。图形验证码的作用,就是在这些关键接口前面加一道“人机校验”的门槛。因为当前的 OCR 技术虽然能识别简单验证码,但识别成本和处理耗时都远高于普通请求,攻击者需要额外投入算力和代码,一大批低价值脚本就会被直接过滤掉。
既然如此,在设计验证码方案时,就要先明确一个原则:验证码不是越复杂越好,而是“够用且不影响体验”。如果验证码难到用户自己都看不清,那它防住的就不是机器人,而是真实用户。
1.2 主流实现方案横向对比
在 SpringBoot 里做图形验证码,常见的路线有三条:引入老牌的 Kaptcha、使用 Hutool 的图形验证码工具、以及完全自己手写生成逻辑。我三个方案都用过,简单说一下各自的定位。
| 方案 | 上手难度 | 定制灵活性 | 依赖体积 | 适合场景 |
|---|---|---|---|---|
| Kaptcha | 低 | 一般,主要靠 properties 配置 | 小,但很久没大版本更新 | 快速集成、验证码要求不高的内部系统 |
| Hutool 验证码工具 | 低 | 中等,可以设置长度、干扰方式 | 中,Hutool 依赖整体较大 | 项目里已经用了 Hutool,不想重复造轮子 |
| 自研生成器 | 较高 | 高,从图片绘制到校验逻辑完全可控 | 无额外依赖,JDK 自带 API 就能画 | 需要定制样式、有安全合规要求、想深入理解原理 |
Kaptcha 的老问题是停止更新很多年了,在新的 SpringBoot 版本上容易出现兼容性小毛病,而且它的验证码风格比较老旧,都是那种粗体字符加噪点的形式,跟现代项目的视觉风格不太搭。Hutool 很方便,但如果你只是为了一个验证码引入整个 Hutool,又会觉得有点笨重。
我自己更推荐自研。原因很现实:图形验证码的生成原理并不复杂,核心就是 Java 2D 绘图 API,写一个工具类也就是一两百行的事。自研意味着你可以完全控制字符集、干扰方式、颜色方案、宽高比例、旋转角度,甚至把公司的品牌色放进去,这在对接客户演示或者过等保、合规审查时特别好用。而且自己掌控生成和校验的逻辑,排查问题的时候心里有底,不用去翻开源库的源码猜它干了什么。
1.3 整体架构与请求链路设计
验证码功能虽然小,但它牵涉到前后端的交互设计。我给你画一下我推荐的请求链路:
- 前端页面加载完成后,向后端发起一个 GET 请求:
/captcha/generate。 - 后端生成一个唯一的验证码标识符
captchaId,同时在服务端生成图形验证码图片,并把正确的答案以captchaId为 key 存入存储介质(Redis 或本地内存),设置有效期。 - 后端把
captchaId和验证码图片的 Base64 字符串返回给前端。 - 前端把 Base64 图片渲染到
<img>标签上展示给用户。 - 用户输入验证码后,连同表单数据一起提交到业务接口。
- 后端从请求中取出
captchaId和用户输入的验证码文本,先校验验证码,通过后再执行真正的业务逻辑。
这个设计里有两个关键点值得强调。第一,验证码图片本身不一定要经过前端文件流,直接用 Base64 塞进图片的src属性就够了,省掉一次图片请求,前后端联调非常省事。第二,服务端不能只存“答案”,还要存captchaId,这个 id 就是后面校验时找到对应答案的钥匙。
还有一个容易忽略的设计细节:验证码的校验时机。一般来说,校验操作应该放在业务接口内部,而不是单独开放一个校验接口。如果你单独开一个/captcha/check接口,相当于给了攻击者一个绕过入口,他完全可以直接调业务接口,跳过校验步骤。
2. 核心细节解析与关键参数设计
2.1 图形绘制背后的绘图原理
自研验证码的底层就是 Java 的BufferedImage和Graphics2D。打个比方,BufferedImage相当于一张空白的画布,Graphics2D就是拿在手里的画笔。你先在画布上设定好背景色,然后用画笔写字符、画干扰线、撒噪点,最后把这张画布编码成图片流输出。
具体流程是这样的:先创建指定宽高的BufferedImage,类型一般用TYPE_INT_RGB,然后获取Graphics2D对象。绘制时先setColor设置背景色,再fillRect填充整个画布,之后逐字绘制验证码字符,最后用drawLine和drawOval画干扰元素。绘制完成之后,用ImageIO.write()把BufferedImage写入输出流,或者用Base64.getEncoder().encodeToString()转成 Base64 字符串。
这里有一个很重要但容易被新手忽略的点:每次绘制前都要重新获取Graphics2D对象,并且绘制完成后要调用g.dispose()释放图形资源。虽然 JVM 的垃圾回收最终会处理,但在高并发场景下不主动释放,会拖慢内存回收速度,导致频繁 Full GC。
2.2 验证码字符集和干扰策略怎么定
验证码的字符集选择直接关系到识别率和安全性。我见过不少项目直接用0123456789纯数字,图省事,但纯数字的验证码太容易被 OCR 工具识别。推荐的组合是去掉容易混淆的字符,从大写字母加数字中剔除0、O、1、I、L这类难区分的字符。我常用的字符集是:
private static final char[] CHAR_ARRAY = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789".toCharArray();长度为 4 到 6 位比较合适。太短容易被暴力枚举,太长用户输入麻烦。我一般默认用 4 位,在安全要求高的场景(比如支付确认)会动态调整为 6 位。
干扰策略是视觉识别和用户体验的平衡点。常见的干扰元素有三类:背景噪点、干扰线、字符旋转或扭曲。噪点是在画布上随机撒一些彩色小点;干扰线是画几条横穿字符的曲线;字符旋转则是让每个字母有正负 30 度以内的随机倾斜。还可以给字符加随机颜色渐变,让 OCR 程序更难提取到清晰的字符轮廓。
实际测试下来,四类干扰方式不用全上,否则用户看半天输不对,体验很糟糕。我的经验是:字符随机旋转 + 一到两条干扰线 + 适量噪点,识别难度和用户体验处在一个比较满意的平衡点。我平时会在噪点数量上做控制,大约在画布面积的 1% 到 2% 就够了。
2.3 存储方案:为什么推荐 Redis 而不是 Session
验证码的答案存哪里,是个需要认真思考的问题。早期项目里最朴素的做法是存 Session,前端带 Cookie,后端从 Session 里读。这种方式在单机部署、用户量不大的时候能跑,但一旦上了负载均衡,Session 同步就是噩梦。
Redis 方案的优势非常明显:第一,多实例共享一份数据,不会出现用户在 A 机器生成、请求被转发到 B 机器就校验不上的问题。第二,Redis 自带过期机制,set key value EX 120就可以控制验证码有效期,不用自己写定时任务清理。第三,校验成功后可以原子地删除 key,从根本上防止验证码被重复使用。
用 Redis 时,redis key 的设计也有讲究。我建议用业务前缀加唯一标识的方式,比如captcha:login:a3f2b0c1-xxxx,这样不同业务的验证码互不干扰,排查问题时用keys captcha:login:*也能快速看到该业务下的验证码分布。captchaId直接用 UUID 就好,不需要用自增数字,因为验证码标识要避免被猜测和枚举。
2.4 校验策略与防重放设计
校验这步的细节,能拉开一个“能用”和“稳”的差距。最基本的校验逻辑是:从 Redis 取出captchaId对应的正确答案,与用户输入做忽略大小写的比较。但这里有很多值得雕琢的细节。
第一个细节是校验后立即删除 key。不管用户输入的验证码对还是错,只要执行了校验,就把 Redis 里的记录删掉。这样做的目的是防止同一个验证码被反复尝试。如果校验失败不删除,攻击者就能对同一个验证码图片做无限次 OCR 识别,变相帮他迭代识别模型。
第二个细节是失败次数限制。只设置验证码 2 分钟过期还不够,因为攻击者可能在高频调接口。我一般在业务接口上加一个基于 IP 的滑动窗口限流,比如 1 分钟内同一个 IP 最多允许 10 次校验请求,超过就拒绝并提示“操作过于频繁”。这个逻辑可以用 SpringBoot 拦截器实现,也可以用 Redis 的INCR加EXPIRE做一个简单的计数器。
第三个细节是区分“验证码错误”和“验证码过期”。这两个场景的前端提示应该不一样。错误了提示用户重新输入,过期了就要提示用户刷新验证码。如果只有一个笼统的“验证码不正确”,用户会反复输入同一次加载的验证码,永远输入不对,体验非常差。实现上也不复杂,Redis key 不存在就是过期,存在但不匹配就是错误。
3. 实操过程与核心环节实现
3.1 工程准备:依赖与项目结构
我们先从零搭建一个 SpringBoot 工程。我用的是 SpringBoot 2.7.18,这个版本在稳定性和兼容性上都是 2.x 系列的巅峰之选。你只做验证码的话,依赖清单非常短:
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-lang3</artifactId> </dependency> </dependencies>如果你用的是 SpringBoot 3.x,注意把javax.servlet相关的包全部换成jakarta.servlet,这一点在后面的兼容性章节会展开说明。
工程结构建议按功能分包,不要把所有类都堆在一起:
com.example.captcha ├── config │ └── RedisConfig.java ├── controller │ └── CaptchaController.java ├── service │ ├── CaptchaService.java │ └── impl │ └── CaptchaServiceImpl.java ├── utils │ └── CaptchaGenerator.java └── vo └── CaptchaVO.java3.2 绘制验证码图片的工具类
验证码工具类是核心中的核心。我用了一个静态方法,传入验证码文本和图片宽高,返回BufferedImage。这里的参数都是经过实测调整的:
public class CaptchaGenerator { private static final char[] CHAR_ARRAY = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789".toCharArray(); private static final int WIDTH = 140; private static final int HEIGHT = 44; private static final int FONT_SIZE = 28; private static final int NOISE_COUNT = 80; private static final int LINE_COUNT = 2; public static String generateText(int length) { StringBuilder sb = new StringBuilder(); Random random = new Random(); for (int i = 0; i < length; i++) { sb.append(CHAR_ARRAY[random.nextInt(CHAR_ARRAY.length)]); } return sb.toString(); } public static BufferedImage generateImage(String text) { BufferedImage image = new BufferedImage(WIDTH, HEIGHT, BufferedImage.TYPE_INT_RGB); Graphics2D g = image.createGraphics(); Random random = new Random(); g.setColor(new Color(245, 247, 250)); g.fillRect(0, 0, WIDTH, HEIGHT); g.setFont(new Font("Arial", Font.BOLD, FONT_SIZE)); // 绘制验证码字符,每个字符独立随机旋转 for (int i = 0; i < text.length(); i++) { g.setColor(new Color(20 + random.nextInt(80), 20 + random.nextInt(80), 20 + random.nextInt(80))); double angle = (random.nextInt(60) - 30) * Math.PI / 180; g.rotate(angle, 20 + i * 30, HEIGHT / 2 + 6); g.drawString(String.valueOf(text.charAt(i)), 18 + i * 30, HEIGHT / 2 + 10); g.rotate(-angle, 20 + i * 30, HEIGHT / 2 + 6); } // 绘制干扰线 for (int i = 0; i < LINE_COUNT; i++) { g.setColor(new Color(150 + random.nextInt(60), 150 + random.nextInt(60), 150 + random.nextInt(60))); g.drawLine(random.nextInt(WIDTH), random.nextInt(HEIGHT), random.nextInt(WIDTH), random.nextInt(HEIGHT)); } // 绘制噪点 for (int i = 0; i < NOISE_COUNT; i++) { g.setColor(new Color(100 + random.nextInt(100), 100 + random.nextInt(100), 100 + random.nextInt(100))); g.drawRect(random.nextInt(WIDTH), random.nextInt(HEIGHT), 1, 1); } g.dispose(); return image; } public static String toBase64(BufferedImage image) { try { ByteArrayOutputStream baos = new ByteArrayOutputStream(); ImageIO.write(image, "png", baos); byte[] bytes = baos.toByteArray(); return Base64.getEncoder().encodeToString(bytes); } catch (IOException e) { throw new RuntimeException("验证码图片生成失败", e); } } }这里有几个值得注意的细节。字符位置的横坐标用18 + i * 30,是因为图片宽度 140px、验证码 4 位时,每个字符的绘制区域大约 30px,两位数从第 18px 开始可以保证字符居中且不会溢出边界。字符的纵坐标HEIGHT / 2 + 10是一个经验值,结合字体大小 28px 和图片高度 44px,可以让字符在垂直方向上处于视觉中心。
有人会问为什么确认字符旋转用g.rotate()然后再画完就立刻rotate(-angle)转回去。这是非常关键的一步,目的是保证每个字符的旋转互不影响。如果不转回去,第二个字符画的时候坐标系还停留在第一个字符的旋转角度上,画出来的字符位置和角度全乱了。
3.3 Service 层:生成与校验的业务逻辑
工具类负责画图,Service 层负责组织业务逻辑。生成验证码的接口要做的事:生成验证码文本,生成图片文件,存 Redis,返回给前端 VO。校验接口要做的事:从 Redis 取答案,比对,删除 key,返回校验结果。
@Service public class CaptchaServiceImpl implements CaptchaService { private static final String CAPTCHA_KEY_PREFIX = "captcha:"; private static final long CAPTCHA_EXPIRE_SECONDS = 120; @Resource private StringRedisTemplate stringRedisTemplate; @Override public CaptchaVO generateCaptcha() { String captchaText = CaptchaGenerator.generateText(4); BufferedImage image = CaptchaGenerator.generateImage(captchaText); String base64 = CaptchaGenerator.toBase64(image); String captchaId = UUID.randomUUID().toString().replace("-", ""); stringRedisTemplate.opsForValue().set( CAPTCHA_KEY_PREFIX + captchaId, captchaText, CAPTCHA_EXPIRE_SECONDS, TimeUnit.SECONDS ); CaptchaVO vo = new CaptchaVO(); vo.setCaptchaId(captchaId); vo.setImgBase64("data:image/png;base64," + base64); return vo; } @Override public boolean verifyCaptcha(String captchaId, String userInput) { if (StringUtils.isBlank(captchaId) || StringUtils.isBlank(userInput)) { return false; } String key = CAPTCHA_KEY_PREFIX + captchaId; String correct = stringRedisTemplate.opsForValue().get(key); if (StringUtils.isBlank(correct)) { return false; } stringRedisTemplate.delete(key); return correct.equalsIgnoreCase(userInput.trim()); } }我在generateCaptcha里犯过一个很多人都会犯的错:一开始把data:image/png;base64,前缀放在 VO 里拼接,导致前端拿到图片后一直显示不出来。后来排查才发现,前端img.src需要的是完整的 Data URL,不能只给纯 Base64 字符串。这个前缀和后缀忘写了,前端就会把 Base64 当成一个普通 URL 去请求,结果是 404 加控制台报错。
校验方法这里我故意把“验证码过期”和“验证码错误”都统一返回 false。如果业务上需要区分,可以返回一个枚举值,但大多数场景下前端只需要知道“校验没通过”。如果我想在前端提示里区分过期和错误,我会额外增加一个查询接口,不过一般不建议这么做,多一个接口就多一个攻击入口。
3.4 Controller 层与 Redis 配置
Controller 层就简单多了。生成接口用 GET,因为它是幂等的请求,不会修改业务数据;校验逻辑我放在业务接口里面,不单独暴露校验接口,这也呼应了前面说的安全设计。
@RestController @RequestMapping("/captcha") public class CaptchaController { @Resource private CaptchaService captchaService; @GetMapping("/generate") public Result<CaptchaVO> generate() { return Result.success(captchaService.generateCaptcha()); } }RedisConfig 负责配置RedisTemplate的序列化器。用StringRedisTemplate的话其实不需要额外配置,但如果你们项目统一用RedisTemplate,一定要把 key 和 value 的序列化器都设置成StringRedisSerializer。否则你存进去的是字符串,取出来的是带着转义符的二进制数据,比对永远不相等。这个坑我见过不下三次。
@Configuration public class RedisConfig { @Bean public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, String> redisTemplate = new RedisTemplate<>(); redisTemplate.setConnectionFactory(factory); StringRedisSerializer stringRedisSerializer = new StringRedisSerializer(); redisTemplate.setKeySerializer(stringRedisSerializer); redisTemplate.setValueSerializer(stringRedisSerializer); redisTemplate.setHashKeySerializer(stringRedisSerializer); redisTemplate.setHashValueSerializer(stringRedisSerializer); return redisTemplate; } }3.5 前端联调完整示例
后端接口就绪后,前端拿到接口返回的captchaId和imgBase64,直接把 Base64 赋给img.src就能显示图片。下面是一个完整的前端示例,包含获取验证码、刷新验证码、提交校验三个动作。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>登录</title> </head> <body> <img id="captchaImg" src="" alt="验证码" title="点击刷新" onclick="loadCaptcha()" /> <input type="text" id="captchaInput" placeholder="请输入验证码" /> <button onclick="login()">登录</button> <script> function loadCaptcha() { fetch('/captcha/generate') .then(res => res.json()) .then(data => { document.getElementById('captchaImg').src = data.data.imgBase64; window.captchaId = data.data.captchaId; }); } function login() { const body = { username: document.getElementById('username').value, password: document.getElementById('password').value, captchaId: window.captchaId, captchaInput: document.getElementById('captchaInput').value }; fetch('/user/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(res => res.json()).then(data => { if (data.code !== 200) { loadCaptcha(); } }); } window.onload = loadCaptcha; </script> </body> </html>前端这里有个常见问题:用户点击图片刷新验证码时,如果不小心连续点了两次,就会触发两个并发请求,后返回的结果可能覆盖前一个,导致用户看到的和实际存储的captchaId对不上。解决方式也很简单,给loadCaptcha加一个锁标记,请求期间重复点击直接忽略。
3.6 在登录流程中整合验证码校验
最后把验证码和登录接口整合起来。这里的核心思想是:验证码校验和业务逻辑在同一个事务里,验证码先于密码校验执行。这样做有两个好处:一是如果验证码都没过,就不会继续走密码比对和用户查询,减少数据库压力;二是避免了单独暴露验证码校验接口可能带来的绕过风险。
@PostMapping("/login") public Result<String> login(@RequestBody LoginRequest loginRequest) { boolean captchaValid = captchaService.verifyCaptcha( loginRequest.getCaptchaId(), loginRequest.getCaptchaInput() ); if (!captchaValid) { return Result.error("验证码不正确或已过期,请重新输入"); } // 这里再执行用户名密码校验逻辑 // 建议在密码校验失败超过一定次数后,强制要求重新获取验证码 return Result.success("登录成功"); }我给一个建议:在密码校验失败的时候,不仅前端要刷新验证码,后端最好也主动把旧的captchaId对应记录作废,而不是等它自然过期。假如密码错误但验证码还在有效期,攻击者只需要识别一次验证码,就能在剩余有效期内不断尝试密码,验证码的防护效果会大打折扣。
4. 常见问题与排查技巧实录
4.1 验证码图片显示不出来的几种原因
图片显示不出来是反馈最多的问题。根据我的排查经验,绝大部分原因集中在三处:
第一,Base64 前缀缺失。前端的img.src必须是完整的data:image/png;base64,xxxx,如果只传了xxxx,浏览器会把它当成相对路径去请求,自然 404。检查方法很简单:用浏览器开发者工具看img.src的值,如果开头不是data:image,就是服务端返回格式有问题。
第二,图片编码后的字符串里有特殊字符。Java 的Base64.getEncoder()生成的字符串是标准的 Base64,本身不会携带特殊字符。但如果你用了java.util.Base64和sun.misc.BASE64Encoder混用,或者经过了 JSON 序列化被转义,就可能出问题。确保整个链路都用标准java.util.Base64。
第三,接口返回的数据结构不对。前端的data.data.imgBase64和你的 VO 字段名必须一一对应,比如字段叫imgBase64,前端写成imageBase64,自然拿不到值。这种问题要多看接口返回的 JSON 原文。
4.2 校验总是失败的排查思路
校验失败,第一步要确认 Redis 里到底有没有数据。用redis-cli连接到 Redis,执行keys captcha:*看 key 是否存在,再执行get captcha:xxx查一下 key 对应的 value。这里最容易出现的坑是序列化器不一致导致的乱码:用默认的 JdkSerializationRedisSerializer 存进去的字符串,存的时候是String,取出来可能带着\xAC\xED前缀,那字符串比对肯定失败。解决方案就是前面说的,统一用StringRedisSerializer。
还有一个很低级但很常见的错误:前后端captchaId传递丢失。前端发起业务请求时没有把captchaId放进请求体,后端拿到的captchaId是 null,自然找不到 Redis key。前端联调时先打印请求体,确认里面有两个字段再排查后端。
4.3 验证码刷新后老验证码还能不能用的业务决策
这是一个典型的产品和开发博弈问题。从安全角度说,用户点击刷新时,旧的验证码应该立即作废。但从用户体验说,如果用户填到一半不小心点了图片,结果提交时报“验证码已过期”,体验很糟糕。
我的处理方式是这样的:前端点击刷新时,先调一个新接口作废旧验证码,再加载新验证码。这样彻底杜绝了老验证码残留的问题。如果不想多一次请求,也可以在后端生成新验证码时,删除同一用户之前的所有验证码。在极少数业务要求宽松的项目里,也可以接受旧验证码在有效期内继续可用,但这是安全妥协,不适合安全要求高的场景。
4.4 高并发场景下的性能与存储注意事项
验证码接口虽然小,但它在登录、注册等场景下访问频率极高,尤其是在营销活动或恶意攻击的背景下。我遇到过一次真实事件:某系统在做活动时,验证码生成接口的 QPS 瞬时飙升到几千,图片生成非常吃 CPU 和内存,直接把应用服务器拖垮。
应对措施有几个方向。第一,给验证码生成接口加限流,比如用 Redis 做计数器,每个 IP 每分钟最多生成 30 次,防止恶意刷图片。第二,控制 Redis 里验证码 key 的总量,通过合理的过期时间和容量监控,防止 Redis 内存被打满。第三,图片生成过程要避免每次 new 大对象,BufferedImage是重量级对象,尽量复用宽度、高度、字体等常量,减少 GC 压力。
4.5 SpringBoot 2.7 升级到 3.x 的兼容性改造
代码写完后,如果你打算把 SpringBoot 从 2.7 升级到 3.x,有两点必须注意。第一,原来的javax.servlet包已经换成了jakarta.servlet,所有涉及 Servlet API 的导入路径都要改。第二,Spring Security 6 的配置方式变化较大,如果你用了自定义过滤器做验证码校验,原有的WebSecurityConfigurerAdapter已经被废弃,需要用SecurityFilterChain的方式重新配置。
好消息是我们这个自研验证码方案几乎没有第三方依赖,升级时只需要检查一下javax到jakarta的包名替换,以及 Redis 客户端连接配置的兼容性。相比那些深度依赖 Kaptcha 的老项目,这种自研方案的迁移成本要小很多,这也是我坚持自研的原因之一。
4.6 常见问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 图片显示为空 | 缺少data:image/png;base64,前缀 | VO 拼接完整 Data URL |
| 图片显示为默认图标 | 前端字段名与后端 VO 不一致 | 用开发者工具检查 JSON 结构 |
| 校验永远失败 | Redis 序列化器配置错误 | 统一使用 StringRedisSerializer |
| 验证码过期太快 | 过期时间设置过短 | 检查 Redis key 的 TTL,建议 120 秒 |
| 同一验证码可重复使用 | 校验后没有删除 key | 校验后立即执行 delete 操作 |
| 高频刷验证码导致接口超时 | 缺少限流机制 | 增加 IP 维度的 Redis 计数器限流 |
| 升级 SpringBoot 3.x 后启动报错 | javax 包不存在 | 替换为 jakarta 开头的包 |
这个验证码组件后来被我用在了好几个项目里,从后台管理系统的登录页到用户端的找回密码流程,基本上一次写好到处复用。回看整个方案,最核心的收获其实就一句话:图形验证码的重点不在“画图”,而在“存储与校验的闭环设计”。
如果你打算把它做得更完善,可以沿着这些方向继续扩展:识别难度更高的滑块验证码、行为轨迹验证码、或者是验证码嵌套在短信发送接口前做二次风控。但无论怎么变,后端的存储、过期、防重放、限流这套骨架,和今天讲的是一模一样的。先把这套骨架跑通,后面再加什么都稳。