☰
Java验证码实战:文字点选与滑块拖动的实现与校验
2026/10/9 6:36:08 网站建设 项目流程

简介:本资源面向Java后端与全栈开发者,提供一套可直接用于生产环境的用户行为验证码方案,涵盖点击中文文字图片验证码与拖动/滑动图片验证码两种形态,适合需要提升登录、注册等场景防刷能力的中高级开发者参考落地。压缩包共178个文件,约8.92MB,以68个java源码为核心,辅以xml配置、css与js前端资源、png/jpg/gif图片素材及yml、ftl等模板文件,结构完整、层次清晰。资源内含demo工程与单元测试,基于JDK1.8、Spring Boot 2.1.17与Redis构建,图片生成、随机中文文字、随机抠图与拼图均依托AWT的BufferedImage与Graphics2D实现,坐标传输采用AES或DES加密,前端则结合点击坐标、图片位置与滚动位置计算相对坐标。已有3079人学习下载,读者可据此快速搭建可运行的验证码服务,理解从图片合成到前后端校验的完整链路与实现思路。

1. 从一次登录接口被刷说起:Java 验证码到底该怎么做

去年帮一个做 SaaS 的朋友看后台日志,发现某个登录接口在凌晨两点被同一个 IP 刷了四万多次,账号密码字典跑得飞起。他们当时用的是最原始的图形验证码,四位数字加干扰线,结果被人用现成的 OCR 接口按在地上摩擦。这件事之后我把验证码方案整个换了一遍,从纯文字点选到滑块拖动,前后端加校验逻辑全部重写,顺带补了一套单元测试。今天就把这套 Java 实现点击文字验证码与拖动/滑动图片验证码的思路完整拆开讲,源码结构、demo 跑法、单元测试怎么写、参数怎么调,都会落到能直接抄的程度。

这套方案适合谁?如果你正在做登录注册、短信发送、支付确认这类需要人机校验的接口,又不想直接买第三方服务,或者想在自己的项目里加一层可控的验证逻辑,那这篇内容就是给你准备的。核心思路是:文字点选负责语义层面的校验,滑块拖动负责行为轨迹层面的校验,两者可以独立使用,也可以组合成多因素验证。下面从选型理由开始,一步步把代码和坑都摊开。

2. 文字点选验证码:从字符定位到坐标校验的完整链路

2.1 为什么选文字点选而不是传统字符输入

传统字符验证码的交互成本其实很高。用户要辨认扭曲字符,输错一次就烦躁,输错三次直接关页面。而文字点选把「识别」和「输入」合并成一个动作——看到提示文字,在图上按顺序点过去就行。从工程角度看,它有两个明显优势:第一,服务端只需要存目标字符的坐标和顺序,校验逻辑简单;第二,前端交互天然带上了点击坐标和时间戳,这些数据可以用来做行为分析。

我一般会把文字点选设计成「按顺序点击指定文字」的形式。比如图上随机分布「春眠不觉晓」五个字,提示区显示「请依次点击:眠、晓、春」,用户按顺序点中三个字的中心区域就算通过。这里的关键是坐标容差和顺序校验,后面会细说。

2.2 服务端生成文字点选验证码的核心代码

先看生成逻辑。整体流程是:准备字库 → 随机选字 → 在画布上随机落点 → 记录每个字的中心坐标 → 生成唯一 token 存 Redis → 返回图片和提示文字。

import javax.imageio.ImageIO; import java.awt.*; import java.awt.image.BufferedImage; import java.io.ByteArrayOutputStream; import java.util.*; import java.util.List; public class TextClickCaptchaGenerator { // 字库,实际项目可以换成更丰富的字符集 private static final String CHAR_POOL = "春眠不觉晓处处闻啼鸟夜来风雨声花落知多少"; private static final int WIDTH = 300; private static final int HEIGHT = 200; private static final int CHAR_COUNT = 5; // 图上显示几个字 private static final int TARGET_COUNT = 3; // 需要点击几个字 private static final int FONT_SIZE = 36; private static final int PADDING = 40; // 边距,防止字被裁切 public static CaptchaResult generate() { BufferedImage image = new BufferedImage(WIDTH, HEIGHT, BufferedImage.TYPE_INT_RGB); Graphics2D g = image.createGraphics(); g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); g.setColor(new Color(245, 245, 245)); g.fillRect(0, 0, WIDTH, HEIGHT); Random random = new Random(); List<String> pool = new ArrayList<>(); for (char c : CHAR_POOL.toCharArray()) pool.add(String.valueOf(c)); Collections.shuffle(pool, random); List<CharPosition> positions = new ArrayList<>(); Set<String> usedAreas = new HashSet<>(); for (int i = 0; i < CHAR_COUNT; i++) { String ch = pool.get(i); int x, y; int attempts = 0; // 简单防重叠:随机落点,如果和已有字太近就重试 do { x = PADDING + random.nextInt(WIDTH - 2 * PADDING); y = PADDING + random.nextInt(HEIGHT - 2 * PADDING); attempts++; } while (isTooClose(x, y, positions) && attempts < 50); g.setColor(new Color(random.nextInt(120), random.nextInt(120), random.nextInt(120))); g.setFont(new Font("SimSun", Font.BOLD, FONT_SIZE)); g.drawString(ch, x, y); positions.add(new CharPosition(ch, x, y)); } // 随机选 TARGET_COUNT 个字作为点击目标,并打乱顺序 List<CharPosition> targets = new ArrayList<>(positions); Collections.shuffle(targets, random); targets = targets.subList(0, TARGET_COUNT); // 提示文字的顺序就是用户需要点击的顺序 StringBuilder tip = new StringBuilder("请依次点击:"); for (CharPosition t : targets) tip.append(t.ch).append("、"); g.dispose(); ByteArrayOutputStream baos = new ByteArrayOutputStream(); try { ImageIO.write(image, "png", baos); } catch (Exception e) { throw new RuntimeException("验证码图片生成失败", e); } String token = UUID.randomUUID().toString().replace("-", ""); CaptchaResult result = new CaptchaResult(); result.token = token; result.imageBase64 = Base64.getEncoder().encodeToString(baos.toByteArray()); result.tip = tip.substring(0, tip.length() - 1); result.targets = targets; // 这个只存服务端,不能返回给前端 return result; } private static boolean isTooClose(int x, int y, List<CharPosition> positions) { for (CharPosition p : positions) { if (Math.abs(p.x - x) < FONT_SIZE && Math.abs(p.y - y) < FONT_SIZE) return true; } return false; } public static class CharPosition { public String ch; public int x; public int y; public CharPosition(String ch, int x, int y) { this.ch = ch; this.x = x; this.y = y; } } public static class CaptchaResult { public String token; public String imageBase64; public String tip; public List<CharPosition> targets; } }

这段代码有几个参数需要重点关注。CHAR_COUNT控制图上显示的字数,太少容易被穷举,太多视觉杂乱,我一般设 5 到 7 个。TARGET_COUNT是需要点击的目标数,设 3 个比较平衡,既不会让用户点太多次,又能保证组合空间足够大。PADDING是边距,防止字被画到边缘导致用户点不到。isTooClose里的阈值用的是FONT_SIZE,实际可以调成FONT_SIZE * 0.8让字排得更紧凑一些。

生成之后,targets列表里的坐标和顺序必须存到服务端,比如 Redis,key 是 token,value 是序列化后的坐标数组,过期时间设 2 到 3 分钟。前端只拿到图片 Base64 和提示文字,拿不到坐标,这样才安全。

2.3 前端点击采集与坐标提交格式

前端拿到图片后,监听点击事件,把点击坐标相对于图片左上角的位置记录下来。注意要做缩放适配,因为图片在页面上显示的大小可能和原始尺寸不一致。

// 假设 img 元素已经渲染,原始宽高从接口返回或已知为 300x200 const img = document.getElementById('captcha-img'); const originalWidth = 300; const originalHeight = 200; const clicks = []; img.addEventListener('click', function (e) { const rect = img.getBoundingClientRect(); // 计算相对于图片原始尺寸的坐标 const scaleX = originalWidth / rect.width; const scaleY = originalHeight / rect.height; const x = Math.round((e.clientX - rect.left) * scaleX); const y = Math.round((e.clientY - rect.top) * scaleY); clicks.push({ x, y, t: Date.now() }); // 可以在这里画个标记点给用户反馈 drawMarker(e.clientX - rect.left, e.clientY - rect.top); }); // 提交时把 clicks 数组和 token 一起发给服务端 function submitCaptcha(token) { return fetch('/api/captcha/verify/text', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, clicks }) }).then(r => r.json()); }

这里有个容易翻车的地方:如果图片被 CSS 设置了object-fit: contain或者有内边距,getBoundingClientRect拿到的区域可能和实际图片内容区域不一致。稳妥的做法是让图片元素本身没有额外内边距,或者用naturalWidth和naturalHeight来计算缩放比例。我一般会在图片加载完成后读取naturalWidth,而不是硬编码 300 和 200。

2.4 服务端校验:顺序、容差与一次性消费

校验逻辑要同时检查三件事:点击顺序是否和提示一致、每个点击坐标是否落在目标字的容差范围内、token 是否未被使用过。

public boolean verifyTextClick(String token, List<ClickPoint> clicks) { String key = "captcha:text:" + token; String cached = redisTemplate.opsForValue().get(key); if (cached == null) { return false; // token 不存在或已过期 } // 立即删除,保证一次性使用 redisTemplate.delete(key); List<CharPosition> targets = JSON.parseArray(cached, CharPosition.class); if (clicks.size() != targets.size()) { return false; } int tolerance = 30; // 容差半径,单位像素 for (int i = 0; i < targets.size(); i++) { CharPosition target = targets.get(i); ClickPoint click = clicks.get(i); double distance = Math.sqrt( Math.pow(target.x - click.x, 2) + Math.pow(target.y - click.y, 2) ); if (distance > tolerance) { return false; } } return true; }

容差tolerance设 30 像素是我试出来的经验值。太小了用户点不准,太大了容易被暴力枚举。如果图片尺寸不是 300x200,容差要按比例缩放。另外注意redisTemplate.delete(key)要在校验之前执行,防止同一个 token 被并发提交多次。如果业务上允许用户点错后重试,那就不能立即删除,而是记录失败次数,超过三次再失效。

3. 滑动图片验证码:缺口检测、轨迹采集与行为校验

3.1 滑块验证码的两种形态与选型建议

滑块验证码常见两种形态:一种是拖动滑块让拼图块对齐缺口,另一种是拖动滑块把图片横向滑动到指定位置。前者视觉反馈更直观,后者实现更简单。我一般推荐拼图缺口式,因为它的校验维度更多——除了最终位置,还能分析拖动轨迹。

从安全角度看,滑块验证码的核心不是「能不能拖到位置」,而是「拖动过程像不像人」。机器可以瞬间把滑块移到正确位置,但人的拖动一定有加速、减速、微小抖动和回拉。所以服务端校验必须包含轨迹分析,只看最终坐标的滑块验证码基本等于没有。

3.2 生成带缺口的背景图与拼图块

生成逻辑分三步:准备一张背景图、随机选一个缺口位置、把缺口区域抠出来作为拼图块。

public class SliderCaptchaGenerator { private static final int BG_WIDTH = 300; private static final int BG_HEIGHT = 150; private static final int PIECE_SIZE = 50; // 拼图块边长 private static final int BORDER = 10; // 缺口离边缘的最小距离 public static SliderResult generate(BufferedImage background) throws IOException { // 统一缩放背景图到目标尺寸 BufferedImage bg = resize(background, BG_WIDTH, BG_HEIGHT); Random random = new Random(); // 缺口左上角坐标,y 轴留出上下边距 int gapX = BORDER + random.nextInt(BG_WIDTH - PIECE_SIZE - 2 * BORDER); int gapY = BORDER + random.nextInt(BG_HEIGHT - PIECE_SIZE - 2 * BORDER); // 抠出拼图块 BufferedImage piece = bg.getSubimage(gapX, gapY, PIECE_SIZE, PIECE_SIZE); // 在背景图上画一个半透明黑色缺口 Graphics2D g = bg.createGraphics(); g.setColor(new Color(0, 0, 0, 120)); g.fillRect(gapX, gapY, PIECE_SIZE, PIECE_SIZE); g.dispose(); // 拼图块加个边框,视觉上更清晰 BufferedImage pieceWithBorder = new BufferedImage(PIECE_SIZE, PIECE_SIZE, BufferedImage.TYPE_INT_ARGB); Graphics2D pg = pieceWithBorder.createGraphics(); pg.drawImage(piece, 0, 0, null); pg.setColor(Color.WHITE); pg.setStroke(new BasicStroke(2)); pg.drawRect(1, 1, PIECE_SIZE - 3, PIECE_SIZE - 3); pg.dispose(); SliderResult result = new SliderResult(); result.bgBase64 = toBase64(bg); result.pieceBase64 = toBase64(pieceWithBorder); result.gapX = gapX; // 只存服务端 result.gapY = gapY; result.token = UUID.randomUUID().toString().replace("-", ""); return result; } private static BufferedImage resize(BufferedImage src, int w, int h) { BufferedImage dst = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB); Graphics2D g = dst.createGraphics(); g.drawImage(src, 0, 0, w, h, null); g.dispose(); return dst; } private static String toBase64(BufferedImage img) throws IOException { ByteArrayOutputStream baos = new ByteArrayOutputStream(); ImageIO.write(img, "png", baos); return Base64.getEncoder().encodeToString(baos.toByteArray()); } public static class SliderResult { public String token; public String bgBase64; public String pieceBase64; public int gapX; public int gapY; } }

PIECE_SIZE设 50 像素是比较合适的值,太小了用户看不清,太大了缺口位置容易被猜。BORDER保证缺口不会贴边,否则拼图块拖到边缘时视觉上很难判断是否对齐。gapX和gapY只存服务端,前端只拿到背景图和拼图块,拼图块的初始位置固定在左侧。

3.3 前端拖动轨迹采集:采样频率与数据结构

轨迹采集的关键是采样频率。太低了分析不出行为特征,太高了数据量大且包含大量冗余。我一般用mousemove或touchmove事件,每 16 到 20 毫秒记录一次,每次记录时间戳、当前 x 坐标、当前 y 坐标。

const track = []; let startTime = null; let isDragging = false; slider.addEventListener('mousedown', (e) => { isDragging = true; startTime = Date.now(); track.length = 0; track.push({ t: 0, x: 0, y: 0 }); }); document.addEventListener('mousemove', (e) => { if (!isDragging) return; const now = Date.now(); const last = track[track.length - 1]; // 控制采样间隔,避免过于密集 if (now - last.t < 16) return; track.push({ t: now - startTime, x: e.clientX - startX, y: e.clientY - startY }); }); document.addEventListener('mouseup', () => { if (!isDragging) return; isDragging = false; // 提交 track 和 token submitSlider(token, track); });

这里有个细节:track的第一个点固定为{t:0, x:0, y:0},表示拖动起点。后续每个点的x是相对于起点的偏移量,不是绝对屏幕坐标。这样服务端拿到的数据更干净,也避免了不同屏幕分辨率带来的干扰。y轴数据虽然滑块验证码主要看横向移动,但纵向的微小抖动也是判断真人操作的重要特征,所以也要采集。

3.4 服务端轨迹校验:三个必调参数

服务端拿到轨迹后,要做三层校验:最终位置是否对齐、轨迹是否连续、行为特征是否像人。

public boolean verifySlider(String token, List<TrackPoint> track) { String key = "captcha:slider:" + token; String cached = redisTemplate.opsForValue().get(key); if (cached == null) return false; redisTemplate.delete(key); SliderData data = JSON.parseObject(cached, SliderData.class); if (track == null || track.size() < 5) return false; // 第一层:最终位置校验,容差 8 像素 TrackPoint last = track.get(track.size() - 1); if (Math.abs(last.x - data.gapX) > 8) return false; // 第二层:轨迹连续性校验,相邻点时间差不能超过 200ms for (int i = 1; i < track.size(); i++) { long dt = track.get(i).t - track.get(i - 1).t; if (dt > 200) return false; } // 第三层:行为特征校验 // 3.1 总耗时不能太短,机器往往在 100ms 内完成 long totalTime = last.t - track.get(0).t; if (totalTime < 300) return false; // 3.2 轨迹要有加速和减速过程,计算速度变化 int accelerateCount = 0; int decelerateCount = 0; for (int i = 2; i < track.size(); i++) { double v1 = (track.get(i - 1).x - track.get(i - 2).x) / Math.max(1, track.get(i - 1).t - track.get(i - 2).t); double v2 = (track.get(i).x - track.get(i - 1).x) / Math.max(1, track.get(i).t - track.get(i - 1).t); if (v2 > v1) accelerateCount++; else if (v2 < v1) decelerateCount++; } // 真人操作一定有加速和减速,两者都不能为零 if (accelerateCount == 0 || decelerateCount == 0) return false; // 3.3 纵向抖动不能完全为零,真人手会抖 boolean hasYJitter = false; for (TrackPoint p : track) { if (Math.abs(p.y) > 1) { hasYJitter = true; break; } } if (!hasYJitter) return false; return true; }

三个必调参数分别是:位置容差8像素、最短耗时300毫秒、纵向抖动阈值1像素。位置容差根据背景图宽度调整,300 像素宽的图用 8 像素比较合适。最短耗时设 300 毫秒是底线,实际可以设到 500 毫秒。纵向抖动阈值不要设太大,否则正常用户手稳一点就被拒了。

注意:轨迹校验的参数没有绝对标准,建议先上线观察一周,把真实用户的轨迹数据采样分析,再反过来调参数。我一般会记录每次校验的轨迹特征值,用百分位统计来确定阈值。

4. 单元测试:怎么让验证码逻辑可测且不依赖 Redis

4.1 把生成逻辑和存储逻辑拆开

验证码代码最难测的地方在于它依赖 Redis 和随机数。我的做法是把生成逻辑做成纯函数,输入是随机种子和配置参数,输出是图片和坐标数据,不碰任何外部存储。存储和校验逻辑单独抽成服务类,通过接口注入。

public interface CaptchaStore { void save(String token, String data, long ttlSeconds); String load(String token); void delete(String token); } public class TextClickCaptchaService { private final CaptchaStore store; private final Random random; public TextClickCaptchaService(CaptchaStore store, Random random) { this.store = store; this.random = random; } public CaptchaResult generate() { // 生成逻辑使用注入的 random,测试时可以传固定种子 // ... } public boolean verify(String token, List<ClickPoint> clicks) { String data = store.load(token); if (data == null) return false; store.delete(token); // 校验逻辑 // ... } }

这样在单元测试里,CaptchaStore可以用内存 Map 实现,Random可以传new Random(42)固定种子,每次生成的验证码完全可预测。

4.2 文字点选校验的单元测试用例

import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import java.util.*; import static org.junit.jupiter.api.Assertions.*; class TextClickCaptchaServiceTest { private InMemoryCaptchaStore store; private TextClickCaptchaService service; @BeforeEach void setUp() { store = new InMemoryCaptchaStore(); service = new TextClickCaptchaService(store, new Random(42)); } @Test void testGenerateAndVerifySuccess() { CaptchaResult result = service.generate(); assertNotNull(result.token); assertNotNull(result.imageBase64); assertNotNull(result.tip); assertEquals(3, result.targets.size()); // 模拟用户按正确顺序点击目标字中心 List<ClickPoint> clicks = new ArrayList<>(); for (CharPosition target : result.targets) { clicks.add(new ClickPoint(target.x, target.y)); } assertTrue(service.verify(result.token, clicks)); } @Test void testVerifyWrongOrder() { CaptchaResult result = service.generate(); List<ClickPoint> clicks = new ArrayList<>(); // 故意把顺序反过来 List<CharPosition> reversed = new ArrayList<>(result.targets); Collections.reverse(reversed); for (CharPosition target : reversed) { clicks.add(new ClickPoint(target.x, target.y)); } assertFalse(service.verify(result.token, clicks)); } @Test void testVerifyOutOfTolerance() { CaptchaResult result = service.generate(); List<ClickPoint> clicks = new ArrayList<>(); for (CharPosition target : result.targets) { // 偏移 50 像素,超出容差 clicks.add(new ClickPoint(target.x + 50, target.y + 50)); } assertFalse(service.verify(result.token, clicks)); } @Test void testTokenCannotBeReused() { CaptchaResult result = service.generate(); List<ClickPoint> clicks = new ArrayList<>(); for (CharPosition target : result.targets) { clicks.add(new ClickPoint(target.x, target.y)); } assertTrue(service.verify(result.token, clicks)); // 第二次用同一个 token 应该失败 assertFalse(service.verify(result.token, clicks)); } @Test void testExpiredToken() { CaptchaResult result = service.generate(); store.clear(); // 模拟过期 List<ClickPoint> clicks = new ArrayList<>(); for (CharPosition target : result.targets) { clicks.add(new ClickPoint(target.x, target.y)); } assertFalse(service.verify(result.token, clicks)); } }

这几个用例覆盖了正常通过、顺序错误、坐标超差、token 复用、token 过期五种情况。InMemoryCaptchaStore就是一个简单的ConcurrentHashMap包装,实现CaptchaStore接口即可。

4.3 滑块轨迹校验的单元测试怎么写

滑块校验的测试重点是构造不同特征的轨迹数据,验证校验逻辑的边界。

class SliderCaptchaServiceTest { private InMemoryCaptchaStore store; private SliderCaptchaService service; @BeforeEach void setUp() { store = new InMemoryCaptchaStore(); service = new SliderCaptchaService(store, new Random(42)); } @Test void testHumanLikeTrackPasses() { SliderResult result = service.generate(); List<TrackPoint> track = buildHumanTrack(result.gapX); assertTrue(service.verify(result.token, track)); } @Test void testTooFastTrackFails() { SliderResult result = service.generate(); List<TrackPoint> track = new ArrayList<>(); track.add(new TrackPoint(0, 0, 0)); track.add(new TrackPoint(50, result.gapX / 2, 0)); track.add(new TrackPoint(100, result.gapX, 0)); // 100ms 完成 assertFalse(service.verify(result.token, track)); } @Test void testNoYJitterFails() { SliderResult result = service.generate(); List<TrackPoint> track = buildHumanTrack(result.gapX); // 把所有 y 坐标清零 track.forEach(p -> p.y = 0); assertFalse(service.verify(result.token, track)); } @Test void testPositionOutOfTolerance() { SliderResult result = service.generate(); List<TrackPoint> track = buildHumanTrack(result.gapX + 20); assertFalse(service.verify(result.token, track)); } // 构造一条模拟真人的轨迹:先加速后减速,带轻微 y 抖动 private List<TrackPoint> buildHumanTrack(int targetX) { List<TrackPoint> track = new ArrayList<>(); track.add(new TrackPoint(0, 0, 0)); int steps = 20; for (int i = 1; i <= steps; i++) { double progress = (double) i / steps; // 缓动函数模拟加速减速 double eased = 1 - Math.pow(1 - progress, 3); int x = (int) (targetX * eased); int y = (i % 3 == 0) ? 1 : (i % 5 == 0 ? -1 : 0); track.add(new TrackPoint(i * 30, x, y)); } return track; } }

buildHumanTrack用三次缓动函数模拟了先快后慢的拖动过程,时间间隔 30 毫秒,总耗时 600 毫秒,y 轴有正负 1 像素的抖动。这条轨迹能通过校验,说明参数设置是合理的。如果测试跑不过,优先检查totalTime和hasYJitter这两个判断。

5. 避坑与排查:验证码上线后最容易翻车的五个地方

5.1 图片 Base64 太大导致接口响应慢

现象:验证码接口返回时间超过 2 秒,前端图片加载有明显延迟。

原因:背景图没有压缩,直接用了高清原图转 Base64,一张图几百 KB。

解决:生成图片时统一缩放到 300x150 或 320x160,输出格式用 PNG 但降低色彩深度,或者用 JPEG 质量 80 压缩。实测 300x150 的 PNG 图片 Base64 后大约 15 到 25 KB,完全够用。

5.2 前端坐标缩放算错导致永远点不中

现象:用户明明点在了字上,服务端校验始终返回失败。

原因:图片在页面上被 CSS 缩放,前端提交的是屏幕坐标而不是图片原始坐标。

解决:用img.naturalWidth和img.getBoundingClientRect().width计算缩放比例,所有点击坐标先除以缩放比例再提交。另外确保图片元素没有padding和border,否则getBoundingClientRect包含边框区域,坐标会偏移。

5.3 Redis 里 token 没设过期时间导致内存泄漏

现象:运行几天后 Redis 内存持续增长,大量验证码 token 堆积。

原因:保存 token 时只调了set没调expire,或者用了setIfAbsent但没传过期参数。

解决:统一用redisTemplate.opsForValue().set(key, value, 3, TimeUnit.MINUTES),把过期时间作为必传参数封装到CaptchaStore的实现里,从接口层面杜绝忘记设过期的问题。

5.4 滑块轨迹在移动端采集不到 y 轴数据

现象:移动端用户滑动验证码通过率极低,日志显示轨迹的 y 坐标全是零。

原因:移动端用的是touchmove事件,e.clientY在部分浏览器上返回的是触摸点相对于视口的位置,但代码里只监听了mousemove。

解决:同时监听touchmove和mousemove,在touchmove里用e.touches[0].clientX和e.touches[0].clientY取值。另外移动端的采样间隔可以放宽到 20 到 25 毫秒,因为触摸事件的触发频率本身比鼠标低。

5.5 单元测试里用了真实 Redis 导致 CI 跑不过

现象:本地测试全绿,CI 环境上单元测试大面积失败,报连接超时。

原因:测试类直接注入了RedisTemplate,CI 环境没有 Redis 服务。

解决:所有验证码测试都用InMemoryCaptchaStore,通过@TestConfiguration或构造函数注入替换掉真实实现。集成测试单独放在@SpringBootTest里,用 Testcontainers 起一个临时 Redis,不要和单元测试混在一起跑。

6. 进阶技巧:用行为指纹做无感验证与降级策略

前面讲的都是「用户主动操作」的验证码。实际生产环境里,我一般会再加一层无感验证:在用户打开页面到提交验证码之间,采集鼠标移动、键盘输入、页面停留时间等信号,算一个行为指纹分数。分数高的用户直接放行,分数低的才弹出滑块或文字点选。这样正常用户的体验几乎无感,只有可疑流量才会被拦截。

行为指纹的采集很简单,在页面加载时挂一个全局监听:

const behavior = { mouseMoves: 0, keyPresses: 0, startTime: Date.now(), lastMouseTime: 0 }; document.addEventListener('mousemove', () => { behavior.mouseMoves++; behavior.lastMouseTime = Date.now(); }); document.addEventListener('keydown', () => { behavior.keyPresses++; }); function getBehaviorScore() { const duration = Date.now() - behavior.startTime; let score = 0; // 停留时间超过 3 秒加 30 分 if (duration > 3000) score += 30; // 鼠标移动超过 20 次加 40 分 if (behavior.mouseMoves > 20) score += 40; // 有键盘输入加 30 分 if (behavior.keyPresses > 0) score += 30; return score; }

服务端收到这个分数后,如果大于 70 就直接放行,不再要求滑块验证。分数在 30 到 70 之间弹出滑块,低于 30 直接拒绝并记录 IP。这个阈值需要根据实际流量调,我一般会先跑一周只记录不拦截,统计正常用户和异常流量的分数分布,再定阈值。

降级策略也很重要。如果 Redis 挂了或者验证码服务超时,不能直接把登录接口也拖死。我的做法是:验证码服务不可用时,降级到「短信验证码」或「邮箱验证码」作为备用校验手段,同时记录告警。如果备用手段也不可用,那就放行但标记风险,后续人工审核。宁可放行可疑请求,也不能让正常用户完全无法登录。

最后说一个我踩过的坑:滑块验证码的缺口位置不要用纯随机。纯随机的缺口有时候会落在图片的纯色区域,用户肉眼根本看不出缺口在哪。我后来改成先检测背景图的边缘密度,只在纹理丰富的区域选缺口位置。这个改动让滑块验证码的通过率从 70% 提升到了 92%。具体做法是用简单的 Sobel 算子算一下每个候选区域的梯度均值,选梯度最高的几个位置之一。代码不复杂,但效果立竿见影。

这套方案我在三个项目里跑过,最久的已经稳定运行一年多。核心经验就一条:验证码的安全性和用户体验永远在打架,参数不要拍脑袋定,上线后拿真实数据回来调。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询