做在线商城项目,登录注册这块你早晚会撞上验证码。Vue2+SpringBoot的经典组合里,验证码不是一个孤立功能,它牵扯到后端图形生成、缓存存储、接口校验,以及前端的表单正则预检。这篇文章我把自己在商城用户模块里用Hutool生成图形验证码、Redis保存验证码、Vue2做正则式验证的完整做法拆开讲一遍,包括为什么这么设计、代码怎么写、上线前要注意哪些坑。适合正在做全栈项目但还没想清楚验证码链路的人,也适合已经把功能跑通但想优化细节的朋友。
1. 验证码模块的整体设计思路
先别急着写代码。验证码这个功能看起来简单,但如果你不把“生成、存储、校验、刷新”这几个环节的设计意图想清楚,后面会反复改。
前端需要一张图片,用户在图片里读出一串字符,提交表单时带着这串字符给后端。后端要验证这串字符是不是当初图片里生成的那串。这里有两个核心问题:第一,用什么方式生成图片验证码;第二,生成后这串验证码存在哪里。第三,前端在提交之前要不要先做一遍输入格式检查。
1.1 为什么选Hutool而不是自己用Graphics2D画
可能有人觉得,画一个验证码图片没什么难的,用Java自带的BufferedImage和Graphics2D就能画,加几条干扰线、旋转几个字符就行。我早年间也这么干过,后来发现自研验证码图片是个无底洞。
你画字符时要考虑字体、字号、颜色、旋转角度、字符间距,为了让验证码不那么容易被OCR识别,还得加噪点、干扰线、扭曲变形。代码量少说一百多行,而且不同操作系统渲染字体效果不一样,在Windows上看着正常的图片,部署到Linux服务器上可能字体全变成方块。Hutool的hutool-captcha模块把这些都封装好了,几行代码就能生成验证码,而且底层用的是java.awt,不需要额外引入第三方框架。
Hutool提供了几种常见的验证码类型:LineCaptcha线段干扰、CircleCaptcha圆圈干扰、ShearCaptcha扭曲干扰。我在商城项目里用的是LineCaptcha,4位字符,加了少量干扰线,识别难度适中,用户看起来也友好。如果你需要更复杂的算术验证码,Hutool也有ArithmeticCaptcha,返回的是表达式结果而不是字符,防脚本效果更好。
每个人的项目口味不一样,但核心结论是:验证码属于“通用基础设施”,没必要重复造轮子。把时间花在业务逻辑上更值。
1.2 验证码为什么要放Redis而不是Session
验证码生成之后要存起来,很多初学者第一反应是放进Session。单体本地开发时Session确实能用,在本地起一个SpringBoot,浏览器访问一次,Session里存个验证码,再提交时从Session里取出来比较,一气呵成。但商城项目迟早要部署多实例,后面加一台服务器做负载均衡,用户的第一次请求落在A机器,第二次请求落在B机器,Session在A机器上,B机器取不到,验证码就永远校验不过。
即使你引入Spring Session做Session共享,本质上也是把Session数据放到Redis,那还不如直接把验证码设计成纯Redis存储,逻辑更清晰,还不占Session内存。
用Redis存验证码还有三个天然优势。第一,可以设置过期时间,key两分钟自动消失,不用手动清理。第二,key-value结构天然适合“验证码标识 -> 验证码内容”这种关系,前端拿着一个随机uuid,后端拿着uuid拼key查Redis。第三,校验成功后可以用原子命令删除key,避免同一个验证码被重放多次。
我的key设计是mall:captcha:{uuid},前缀带业务名,避免以后其他模块也存验证码时key冲突。value就是验证码本身。过期时间两分钟,太短用户来不及输入,太长增加被暴力猜解的风险。
1.3 前端正则式验证在整个环节里的定位
前端正则式验证,很多人理解成“防机器人”,这话只说对了一半。真正防机器人是后端图形验证码的事,前端正则的作用是提前拦住一批明显不合法的输入,改善用户体验,减少无效请求。
比如登录注册表单有手机号、邮箱、密码、验证码。用户如果手机号少了一位,前端直接提示“手机号格式不正确”,根本不用等后端接口返回错误。验证码输入了3个字符,前端直接提示“验证码为4位字母或数字”,用户马上就能改,不需要白白提交一次。
这里要强调一句:前端的正则校验永远只能作为体验优化,不能作为安全边界。因为前端代码对用户完全可见,绕过前端校验直接调接口太容易了。真正的合法性校验,必须在后端再做一遍。后面我会讲到,后端校验验证码的值是否正确,同时后端也应该校验手机号、邮箱这些字段的格式,防止有人绕过前端直接打接口。
2. 后端验证码接口实操:Hutool + Redis
这块是重头戏。我先从依赖和Redis配置讲起,再给出生成验证码接口和校验接口的完整代码。
2.1 引入依赖与Redis序列化配置
项目是SpringBoot,Redis客户端用的是Spring Data Redis。验证码生成选Hutool,我习惯单独引入hutool-captcha,而不是一把梭引入整个hutool-all,依赖体积能小一点。
<dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-captcha</artifactId> <version>5.8.25</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>Redis本身在SpringBoot里默认配置就能用,spring-boot-starter-data-redis会为你创建RedisTemplate和StringRedisTemplate。但这里有个非常大的坑:RedisTemplate默认使用JDK序列化,往Redis里写入的中文、字符串会变成一串类似\xAC\xED\x00\x05t...的二进制内容,在Redis Manager里看着像乱码,而且用命令行完全没法查。验证码这种纯字符串场景,直接使用StringRedisTemplate最省事,它的key和value都是String序列化。
如果你项目中其他地方确实需要RedisTemplate存对象,建议单独配置一个JSON序列化的RedisTemplate,只处理对象缓存,验证码这个场景千万别混用。
2.2 生成验证码接口:返回UUID和Base64图片
接口设计上,我返回两个关键字段:uuid和img。uuid是验证码的唯一标识,前端提交表单时要把它原样带回;img是图片的Base64字符串,前端直接用来渲染图片。
@RestController @RequestMapping("/api/auth") public class AuthController { @Resource private StringRedisTemplate stringRedisTemplate; private static final String CAPTCHA_KEY_PREFIX = "mall:captcha:"; private static final long CAPTCHA_EXPIRE_SECONDS = 120; @GetMapping("/captcha") public Result<CaptchaVO> getCaptcha() { // 生成uuid,作为Redis key的一部分 String uuid = UUID.randomUUID().toString().replace("-", ""); // 生成线干扰验证码,4位字符,20条干扰线 LineCaptcha captcha = CaptchaUtil.createLineCaptcha(120, 40, 4, 20); String code = captcha.getCode(); // 存入Redis,并设置过期时间 String key = CAPTCHA_KEY_PREFIX + uuid; stringRedisTemplate.opsForValue().set(key, code, CAPTCHA_EXPIRE_SECONDS, TimeUnit.SECONDS); CaptchaVO vo = new CaptchaVO(); vo.setUuid(uuid); // Hutool返回的是纯Base64字符串,前端需要拼上data:image/png;base64, vo.setImg("data:image/png;base64," + captcha.getImageBase64Data()); return Result.ok(vo); } }这里我要特别说明几点。
第一,验证码的明文值只写进Redis,绝对不要放进接口响应里。有些人调试方便会把验证码也返回给前端,看起来省事,实际等于把安全门拆了。
第二,图片返回Base64字符串,相比返回一张图片地址,少一次HTTP请求,前端渲染也更方便,商城项目这种内网/低延迟场景完全够用。
第三,每次点击刷新验证码,前端都应该重新请求这个接口,获取一个新的uuid。不要复用旧的uuid只刷新图片,因为旧uuid对应的Redis里还躺着上一次的验证码,会有脏数据。
createLineCaptcha(120, 40, 4, 20)四个参数分别是宽度、高度、字符个数、干扰线条数。120x40是我试下来适合表单布局的尺寸。如果你要更高的识别难度,可以改成圆环干扰CaptchaUtil.createCircleCaptcha(120, 40, 4, 10),或者扭曲干扰CaptchaUtil.createShearCaptcha(120, 40, 4, 4)。
2.3 校验接口:验证码一次性消费
验证码校验发生在注册、登录、找回密码等接口内部。我这里以注册接口为例。前端提交codeId和code两个字段,后端从Redis里取出真正的验证码,比较后立即删除。
@PostMapping("/register") public Result<String> register(@RequestBody RegisterDTO dto) { // 1. 校验图形验证码 String key = CAPTCHA_KEY_PREFIX + dto.getCodeId(); String expectCode = stringRedisTemplate.opsForValue().getAndDelete(key); if (StrUtil.isBlank(expectCode) || !expectCode.equalsIgnoreCase(dto.getCode())) { return Result.fail("验证码错误或已过期"); } // 2. 校验手机号/邮箱格式,后端正则再守一遍 if (!RegexUtil.isMatch(RegexUtil.REGEX_MOBILE, dto.getMobile())) { return Result.fail("手机号格式不正确"); } // 3. 真正的注册逻辑 // userService.register(dto); return Result.ok("注册成功"); }getAndDelete是Redis取key并删除的原子操作,在Spring Data Redis较新的版本里可以直接用。它比“先get再delete”好在哪里?假设同一个验证码被并发提交两次,先get再delete的写法可能两次都拿到同一个验证码,都校验通过,这就不符合一次性验证码的语义。getAndDelete原子地取出来再删掉,第二次请求拿到的是null,校验自然失败。
如果你们项目里Spring Data Redis版本比较老,没有getAndDelete,也可以用Lua脚本实现原子操作,或者接受“先get后delete”的写法。说实话,商城注册场景遇到并发重复提交同一个验证码的概率极低,但既然能做对,顺手做对不亏。
验证码比较时我用的是equalsIgnoreCase忽略大小写。因为图片里的字符本身就有大小写,用户看错大小写的概率不低,没必要因为这种原因让用户重新输入。如果你希望严格区分大小写,可以改成equals,但要做好用户体验下降的心理准备。
再讨论一个问题:校验失败了要不要删key?我建议删。原因是对验证码这种资源,不应该给攻击者无限重试机会。假如一个key永远可以反复尝试,暴力枚举4位字符的验证码也就一万种组合,脚本跑一会儿总能撞对。过期时间两分钟加上错误即删,能把爆破窗口彻底关掉。用户输错了重新点一下图片,换一个验证码就行,这个交互成本可以接受。
3. 前端Vue2表单验证落地细节
后端接口就绪,前端这边要把图片渲染出来、表单校验规则配好。Vue2项目里我用的UI库是Element UI,下面以el-form为例。
3.1 验证码图片刷新与回显
模板部分有一个输入框和一个图片。
<el-form ref="registerForm" :model="form" :rules="rules" label-width="80px"> <el-form-item label="验证码" prop="code"> <el-input v-model.trim="form.code" placeholder="请输入验证码" maxlength="4" /> <img :src="captcha.img" alt="验证码" class="captcha-img" @click="refreshCaptcha" @error="refreshCaptcha" /> </el-form-item> </el-form>对应的逻辑:
export default { data() { return { form: { codeId: '', code: '' }, captcha: { img: '' } }; }, created() { this.refreshCaptcha(); }, methods: { refreshCaptcha() { getCaptcha().then(res => { this.captcha.img = res.data.img; this.form.codeId = res.data.uuid; this.form.code = ''; }); } } };几个细节要注意。
第一,@click和@error都绑定refreshCaptcha。图片偶尔会因为网络原因加载失败,监听error事件刷新一张新图,比用户干瞪眼强。点击图片刷新是验证码功能的标准交互,用户已经习惯了。
第二,刷新后要清空form.code。如果用户之前输入了3位,换个验证码,旧输入显然无效,不清空会导致表单校验提示“验证码为4位字母或数字”却无法通过后端校验。
第三,v-model.trim可以去掉验证码首尾空格。这个细节有时候很关键,用户复制粘贴时容易带个空格,后端的equalsIgnoreCase可不会替你trim,前端先去空格能减少一次无意义的提交。
第四,图片样式上我一般加cursor: pointer和适当的宽高,120px宽、40px高比较合适。有次我看别人项目里验证码图片拉伸得面目全非,用户根本读不出来,那种体验很糟糕。
3.2 正则式验证规则怎么设计才不啰嗦
Element UI的rules支持pattern正则校验。我习惯把手机号、邮箱、密码、验证码这类字段都配上正则,让用户在提交前就发现错误。
rules: { username: [ { required: true, message: '请输入用户名', trigger: 'blur' }, { pattern: /^[a-zA-Z0-9_]{4,16}$/, message: '用户名由4-16位字母、数字、下划线组成', trigger: 'blur' } ], mobile: [ { required: true, message: '请输入手机号', trigger: 'blur' }, { pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' } ], email: [ { required: true, message: '请输入邮箱', trigger: 'blur' }, { type: 'email', message: '邮箱格式不正确', trigger: 'blur' } ], password: [ { required: true, message: '请输入密码', trigger: 'blur' }, { pattern: /^(?=.*[A-Za-z])(?=.*\d)[\S]{8,20}$/, message: '密码需8-20位且包含字母和数字', trigger: 'blur' } ], code: [ { required: true, message: '请输入验证码', trigger: 'blur' }, { pattern: /^[a-zA-Z0-9]{4}$/, message: '验证码为4位字母或数字', trigger: 'blur' } ] }手机号正则^1[3-9]\d{9}$是目前市面上最常用的,它要求第一位是1,第二位是3到9,后面9位任意数字。这个正则不是100%精确,因为未来号段可能有新增,但足以拦截90%的格式错误。
邮箱正则我用Element UI自带的type: 'email',底层是一个比较严谨的邮箱正则,比自己随手写的/^\w+@\w+\.\w+$/强不少。
密码正则^(?=.*[A-Za-z])(?=.*\d)[\S]{8,20}$用的是两个前瞻断言:(?=.*[A-Za-z])确保至少有一个字母,(?=.*\d)确保至少有一个数字,[\S]{8,20}限定总长度8到20位且不包含空格。这种正则看起来很花哨,但不需要写出一堆条件判断,规则清晰,值得抄。
验证码正则^[a-zA-Z0-9]{4}$必须和后端Hutool生成策略保持一致。Hutool的LineCaptcha默认生成4位字母数字组合,那前端就按4位校验。如果哪天后端改成5位或者改成算术验证码,前端这个正则也得同步改,两边不一致的话会出现“前端提示格式正确、后端一直报错”的灵异问题。
3.3 自定义validator的常见坑
pattern正则能解决固定格式校验,但有些校验需要配合业务逻辑,这时要用自定义validator。比如注册时要校验手机号是否已存在,或者密码需要确认两次一致。
const validateConfirmPassword = (rule, value, callback) => { if (!value) { callback(new Error('请再次输入密码')); } else if (value !== this.form.password) { callback(new Error('两次输入的密码不一致')); } else { callback(); } };这里最大的坑是this指向。Element UI在校验时调用validator,并不会把组件实例绑定成this。如果你把自定义validator写成普通函数,函数体里的this很可能不是Vue组件,取this.form.password会报错或者拿到undefined。
解决方案有两个。一个是上面这样,在methods里用箭头函数定义,箭头函数没有自己的this,会继承组件实例的this。另一个是在data里用箭头函数定义,逻辑也是一样的。千万不要用普通函数然后又到处bind(this),又丑又容易漏。
还有一个坑是trigger时机。trigger: 'blur'适合输入框失焦时校验,但如果用户正在输入过程中就点提交按钮,blur不一定触发,required校验会拦住空值。如果你希望用户输入完实时反馈,可以把trigger改成change,但注意change在校验码输入框上可能不够即时,我一般验证码用blur就好。
自定义validator里异步请求还有一个经典问题:校验函数在请求返回之前就已经执行了回调,导致校验结果不准。比如手机号是否已存在要等后端接口返回,如果直接发请求后立即callback(), 那等于没校验。正确做法是在Ajax回调里再调用callback。Element UI的validator支持异步,你在回调里不调用callback,它就一直等你,直到你调用callback为止。
4. 常见问题与排查技巧实录
这部分是我实际开发中反复踩过的、也是后台同学问得最多的几个问题。整理成速查思路,比背代码更有用。
4.1 Redis里明明存了,却一直提示验证码无效
这个现象很经典。代码看着逻辑没问题,Redis里也有key,但前端提交上来一校验就失败。我一般按下面顺序排查。
第一,看key是否一致。前端提交的codeId和后端拼接key时用的uuid是不是同一个?有些人前端表单里有两个字段都叫uuid,一个绑定在图片上,一个绑在隐藏域里,刷新图片时只改了其中一个,提交时用了另一个,key对不上,自然取不到。
第二,看序列化方式。如果你用的是RedisTemplate默认的JDK序列化,存进去的key会带\xAC\xED前缀,看起来像乱码。Spring Boot对StringRedisTemplate默认用String序列化,不会出现这个问题。混用两套template时,一定要确认读取和写入用的是同一个template。
第三,看环境。本地连的是本地Redis,测试环境连的是测试Redis,配置没切换时,前端连测试服、后端代码却连本地Redis,验证码当然失效。排查时直接看启动日志里Connected to Redis那一行,确认连的是哪个地址。
第四,看过期时间。两分钟过期,用户输得慢一点就过期了。如果你把过期时间设置成30秒,那用户基本必踩这个坑。我建议至少给用户留足一分钟以上,推荐两分钟。
4.2 前端正则“明明匹配却校验失败”
前端正则校验不过,最常见的不是正则写错,而是输入值里带了不可见字符。
比如用户从Excel或者聊天工具里复制手机号,前面带了一个换行符或者零宽空格,v-model.trim只能去掉首尾空格,去不掉换行和零宽空格。这时候正则怎么看都不匹配。处理方式是提交前再做一次replace(/[\s\uFEFF\xA0]+/g, ''),把空白字符清干净。
还有一种是正则字面量在后端和前端写法不一致。比如后端用Java的String.matches,它是全量匹配;JS正则是部分匹配,如果不写^和$,会导致abc123能通过/\d+/的校验。解决方案是统一使用^...$包裹正则,并前后端共用一份规则文档。
Element UI里还有个容易忽视的坑:如果你的表单校验规则写在data()里用箭头函数引用组件数据,初始化时机不对可能导致rules没生效。尤其是在校验项里用this.form时,this可能还没有绑定完成。稳妥做法是把校验规则定义在computed里返回,或者直接在data里用普通对象,然后自定义校验函数改成箭头函数。
4.3 生产环境里的补充建议
验证码能上线跑通只是第一步,生产环境要考虑的事情更多。
接口限流是必须的。生成验证码的接口虽然只是返回一张图片,但如果被人写个脚本疯狂调用,Redis会堆积大量无用的key,白白占内存。我一般在网关或者统一过滤器里对同一个IP做一分钟内最多请求20次的限制。如果项目还没接网关,也可以在后端切面里简单统计一下。
图形验证码应该配合短信验证码使用。商城注册场景更常见的是“图形验证码 + 短信验证码”双验证:先输对图形验证码,才能点击发送短信验证码,前后端都加一层依赖关系,这能有效防短信轰炸。短信验证码的存储、校验逻辑和图形验证码完全一样,只是过期时间更短、位数更长。
日志里不要打印验证码。调试阶段有人习惯把dto整个对象打出来,验证码明文直接进了日志。日志文件一旦泄露,验证码就形同虚设。我个人的习惯是打印日志时只打codeId,不打code。
Redis的过期策略也要留意。验证码key量级不大,但模式都是短时间内创建、短时间删除,容易产生过期键。如果生产Redis出现大量过期键扫描导致CPU抖动,可以考虑给验证码key加随机过期时间,比如120秒到180秒之间取随机值,避免同一时间点大量key同时过期。
4.4 我在这个模块上犯过的几个错
最后说点个人体会。
我最早做验证码时,图省事把验证码明文直接放在接口响应里,前端拿到后回显在页面上专门用来调试。后来被同事提醒,这样等于把门锁钥匙贴在门上,才意识到这个错误的严重性。从那以后,我给自己立了一条规矩:凡是验证码、密码、Token这类数据,响应对象里禁止出现明文,日志里也一律不打。
还有一次是前端图片刷新逻辑。我之前只在点击图片时刷新,没监听@error事件。上线后有个用户反馈验证码图片偶尔显示不出来,不管怎么刷新都只有一个小图标,后来发现是他的浏览器缓存了失效的Base64图片,请求没重新发出去。加了@error事件后,图片加载失败会自动调用刷新接口,这个问题再没出现过。
还有一个小技巧可以分享:如果后期要接滑块验证码或者点选验证码,后端校验接口尽量设计成统一的“验证码凭证校验”,也就是前端完成验证后拿到一个凭证token,后端只认token。这样图形验证码、短信验证码、滑块验证码都能走同一套逻辑,不会每加一种验证方式就重构一次接口。
验证码模块看起来小,但它夹在前端交互、后端安全、缓存存储三者中间,牵扯到的问题一点都不少。把生成、存储、校验、刷新这条链路理顺了,以后加支付密码、找回密码、风控校验这些功能都会顺畅很多。