☰
Vue2+SpringBoot商城项目:Hutool生成验证码+Redis存储校验全链路
2026/10/5 2:43:38 网站建设 项目流程

商城项目做到验证码这一环,其实就到了一个很微妙的节点:功能看着不大,但牵扯到后端接口设计、缓存存储策略、前端交互体验和表单校验四条线,任何一个环节处理不好,都会直接影响用户注册、登录和下单。我做的这个Vue2+SpringBoot在线商城项目里,用户模块正好推进到这里,就借这篇记录把验证码这套完整链路——Hutool生成图片验证码、Redis存储与校验、前端Vue2展示刷新、正则式表单验证——全部摊开来讲,包含可以直接抄走的代码和踩坑记录。不管你是刚接触这类技术组合的新手,还是在老项目里补验证码功能的开发者,这篇都能给你一条走得通的落地路径。

1. 验证码方案选型与整体设计思路

1.1 验证码在商城项目里的几个真实场景

先说清楚,为什么商城项目必须有验证码。很多同学觉得“我做个登录注册,还要验证码干嘛”,但真上线之后会发现,没有验证码的注册接口就是一个裸奔的接口,随便写个脚本就能批量注册账号、刷优惠券、刷评论,甚至用撞库的方式试密码。我在这个商城项目里规划了三个必须用验证码的场景:

  • 用户注册:防止脚本批量注册账号,这是最基础的一个入口。
  • 账号登录:尤其是密码连续输错几次之后,用验证码阻断暴力破解。
  • 下单或修改关键信息:某些敏感性操作,二次验证能拦住CSRF或者被诱导的操作。

所以验证码不是“用户体验的敌人”,而是保护账号和数据的一道闸门。这道闸门的设计好坏,直接影响安全强度,也直接影响用户愿不愿意继续用你的产品——太复杂的验证码(比如让你选一堆红绿灯)虽然安全,但用户烦;太简单的(比如固定四位数字)又等于没拦。

1.2 为什么选Hutool + Redis而不是纯前端验证

方案选型的时候我其实对比过几种做法:

方案优点缺点是否采用
纯前端生成验证码实现最简单,不占后端资源验证逻辑在前端,等于摆设,随便绕过去不采用
后端生成图片,Session存储比纯前端靠谱,经典做法分布式环境下Session不共享,多实例要额外处理不采用
后端生成图片,Redis存储分布式友好,过期和清理可控多引入一个Redis依赖(本来项目就有)采用
第三方验证码服务安全等级最高,接入快有费用,依赖外部服务不作首选

因为这个项目本身就是SpringBoot+Redis的技术栈,用Redis做验证码存储几乎是零成本的事情。Hutool的CaptchaUtil封装好了图形验证码的生成逻辑,开箱即用,不用自己画干扰线也不用自己处理扭曲效果,一行代码就能出一个标准的图片验证码。这两个组合起来,既绕开了Session的分布式问题,又不需要额外引第三方SDK,是我能想到的性价比最高的方案。

1.3 验证码的完整生命周期

一个验证码从生成到销毁,完整路径是这样的:

  1. 前端打开登录或注册页,向后端请求验证码。
  2. 后端用Hutool生成一张图片,同时生成一个唯一的UUID标识这个验证码。
  3. 验证码的“答案”(那几个字符)被转成小写,存进Redis,key是captcha:{uuid},过期时间我设成5分钟。
  4. 后端把图片的base64字符串和UUID一起返回给前端,前端用<img>标签展示出来。
  5. 用户输入看到的字符,提交表单时把“UUID + 用户输入的验证码”一起带给后端。
  6. 后端拿到UUID,从Redis取出正确验证码,跟用户输入的比对。
  7. 无论比对结果如何,只要走到了校验这一步,Redis里的验证码就删除——保证验证码是一次性的,防止重放攻击。
  8. 校验通过,继续走注册或登录逻辑;不通过,提示错误并让用户刷新验证码。

这个设计里最关键的一点是“一次性”:验证码无论输对输错,用完就销毁。如果不销毁,同一个验证码可以被反复尝试,暴力破解就没被真正阻断。

2. 后端接口实现:生成与校验

2.1 引入Hutool依赖与验证码生成

第一步先引入Hutool的captcha模块。这里要注意,Hutool的包是可以按需引入的,不是一整个hutool-all全塞进来。我项目里用的是Maven,直接在pom.xml加这一段:

<dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-captcha</artifactId> <version>5.8.25</version> </dependency>

如果你的项目里已经用了hutool-all,那captcha模块也包含在里面,不需要再单独加。Hutool提供了三种图形验证码:LineCaptcha(线段干扰)、CircleCaptcha(圆圈干扰)、ShearCaptcha(扭曲干扰)。我用的是LineCaptcha,它画出来是四位字符加一堆干扰线段,识别难度适中,生成的资源开销也小。ShearCaptcha扭曲得更厉害,安全性更高一些,但有些用户会看不太清,我最终没有选它。

生成代码我直接放在Controller层,为了测试方便暂时没有封装过多的Service逻辑:

@RestController @RequestMapping("/api/captcha") public class CaptchaController { @Autowired private StringRedisTemplate stringRedisTemplate; @GetMapping("/get") public Result<Map<String, String>> getCaptcha() { // 生成宽150、高40、4位验证码、干扰线数量10条的图片验证码 LineCaptcha captcha = CaptchaUtil.createLineCaptcha(150, 40, 4, 10); String code = captcha.getCode(); String uuid = UUID.randomUUID().toString().replace("-", ""); // 存入Redis,5分钟有效 stringRedisTemplate.opsForValue().set( "captcha:" + uuid, code.toLowerCase(), 5, TimeUnit.MINUTES ); Map<String, String> data = new HashMap<>(); data.put("uuid", uuid); data.put("imageBase64", captcha.getImageBase64Data()); return Result.success(data); } }

这里有几个细节值得多说两句。第一,captcha.getImageBase64Data()返回的是纯base64字符串,不带data:image/png;base64,前缀,前端拼接的时候要自己加,这个很多新手会漏掉,导致图片怎么都显示不出来,后面排查章节再详细说。第二,图片的尺寸150x40是我调过的,Hutool默认生成的图片比较小,放大之后文字边缘会糙,用户体验多少受影响。我测试下来,150宽对手机端和PC端都比较合适,太宽了在小屏上会挤。

2.2 Redis存储设计:key怎么定、TTL设多少

Redis里存验证码,看似只是set一个值,但key的设计是有讲究的。我见过有人直接把验证码存成captcha:code这种固定key,这样所有用户共用同一个验证码,A把验证码刷新了,B那边的验证码就失效了,完全不能用。

正确的做法是每个验证码独立一个key,用UUID区分。UUID直接用UUID.randomUUID().toString().replace("-", "")生成,去掉中间的横杠,一是减少长度,二是避免前端传参时对横杠的转义问题。完整的key就是captcha:7f3a9b2c1d4e4f5a8b6c9d0e1f2a3b4c这种格式。

TTL的设置我纠结了一下,短了用户来不及输入,长了又增加被暴破的风险。最终选了5分钟。这是一个比较均衡的数值:正常用户从看到验证码到手动输入提交,30秒到1分钟足够;5分钟的时间窗口对暴力破解来说还是太短了,加上校验失败会刷新验证码,基本封死了穷举的路径。

另外还有一点,验证码的值在存Redis之前,统一转成小写(code.toLowerCase())。这样做的目的是消除大小写的比较成本,用户输入大写字母也能通过校验,不用憋着劲去区分“那是O还是0、是I还是l”。转小写之后再比对,对用户友好得多,也不带来任何安全损失。

2.3 校验接口与一次性消费机制

校验接口是验证码后端的核心,前端提交注册或登录表单的时候会先调这个接口,或者把验证码参数一起放在注册/登录请求里由后端一并校验。我为了逻辑清晰,单独拆了一个校验接口:

@PostMapping("/verify") public Result<Void> verify(@RequestBody CaptchaVerifyDTO dto) { if (StrUtil.isBlank(dto.getUuid()) || StrUtil.isBlank(dto.getCode())) { return Result.error("验证码参数不完整"); } String key = "captcha:" + dto.getUuid(); String correctCode = stringRedisTemplate.opsForValue().get(key); // 无论校验结果如何,先删除key,保证一次性 stringRedisTemplate.delete(key); if (correctCode == null) { return Result.error("验证码已过期,请刷新"); } if (!correctCode.equals(dto.getCode().trim().toLowerCase())) { return Result.error("验证码错误"); } return Result.success(null); }

注意这里我是先delete(key)再判断,而不是校验成功后才删。这样设计是为了防止绕过:如果只在成功时删除,那验证码错误的情况下同一个UUID可以反复提交,脚本只需要把验证码的答案跑一遍字典就能撞出来。先删再用,等于不管结果如何,这个验证码已经“作废”了,后续请求必然提示过期或错误。

DTO里有几个字段:uuid、code。这个DTO按正常Java Bean的规范写就行,我加了一个@NotBlank做参数判空,然后在Controller里再用StrUtil兜底检查,双保险。

校验通过之后,后续的注册或登录流程才是真正要处理的事务,验证码校验是前置条件,一定不能放在事务内部——验证码校验应该是无状态、幂等的操作,放在事务里只会延长事务时间,增加锁冲突的概率。

3. 前端展示与交互:Vue2里的验证码组件

3.1 调用接口与base64图片展示

前端我用的是Vue2 + axios,接口请求封装在$http里。验证码展示是一个很简单的结构,一个输入框加一张图片:

<template> <div class="captcha-row"> <input v-model="form.captchaCode" placeholder="请输入验证码" maxlength="4" class="captcha-input" /> <img :src="captchaImg" alt="验证码" title="点击刷新验证码" class="captcha-img" @click="handleRefreshCaptcha" /> </div> </template>

对应的脚本逻辑:

export default { data() { return { captchaImg: '', form: { uuid: '', captchaCode: '', // ...其他字段 } } }, created() { this.getCaptcha() }, methods: { async getCaptcha() { const res = await this.$http.get('/api/captcha/get') if (res.data.code === 200) { this.form.uuid = res.data.data.uuid // 注意这里一定要加 data:image/png;base64, 前缀 this.captchaImg = 'data:image/png;base64,' + res.data.data.imageBase64 } }, handleRefreshCaptcha() { this.getCaptcha() } } }

标题里提到的Vue2老项目改造,其实在这一步就体现出来了:Vue2的组件写法跟Vue3差异不小,这里用的是Options API而不是Composition API,老项目里到处都是这种写法,维护成本低,直接复制这段代码进任何Vue2项目都能跑。

3.2 点击刷新与防连点

点击图片刷新验证码是一个常规交互,但有个细节要处理:如果用户快速连点,会同时发起多个验证码请求,导致前面几个请求返回的UUID已经失效,只有最后一个有效。虽然不影响最终功能,但会造成无意义的请求流量,后端日志也会被刷得很难看。

我给刷新方法加了一个简单的节流处理:

methods: { handleRefreshCaptcha() { if (this.refreshing) return this.refreshing = true this.getCaptcha().finally(() => { this.refreshing = false }) } }

用一个refreshing标志位挡住重复点击,等上一次请求真正返回了再放开。这种节流比setTimeout防抖更可靠,因为它是跟实际请求状态绑定的,网络慢的时候不会出现“计时器到了但请求还没完成”的窗口期。

另外还有一个体验优化:验证码图片在加载出来之前,给<img>一个固定高度,避免图片加载完成后页面元素跳动。我在CSS里给captcha-img设置了height: 40px,跟后端生成图片的高度保持一致,这样布局就很稳。

3.3 表单提交时携带uuid一起校验

验证码的校验时机,很多人习惯放在“提交表单之后,后端一起校验”,前端只校验格式。但这里有一个更好的做法:在提交前先做前端正则式格式校验,格式不对的直接拦截,减少无效请求;格式对了之后,把uuid和验证码一起提交到后端,由后端做最终比对。

我在handleSubmit里的流程是这样的:

async handleSubmit() { // 第一步:前端正则校验 const phoneError = validatePhone(this.form.phone) const codeError = validateCaptcha(this.form.captchaCode) if (phoneError || codeError) { this.$message.error(phoneError || codeError) return } // 第二步:提交到后端,由后端统一校验验证码 const res = await this.$http.post('/api/user/register', this.form) if (res.data.code === 200) { this.$router.push('/login') } }

这里的this.form已经把uuid和captchaCode都带上去了,后端在注册逻辑里先查Redis比对,比对通过再走注册逻辑。之所以不在前端单独调/verify接口两次请求,是为了减少一次网络往返——注册接口内部把验证码校验作为前置条件处理掉,既省时间又简化了前后端交互的流程。

4. 前端正则式验证实战

4.1 手机号与邮箱的常用正则

标题里专门提到了“正则式验证”,这一块我展开讲讲商城项目里高频用到的几条正则。

手机号校验,国内目前最通用的规则是“1开头,第二位是3-9的任意数字,后面跟上9位数字”:

export function validatePhone(phone) { const reg = /^1[3-9]\d{9}$/ return reg.test(phone) ? '' : '请输入正确的手机号' }

注意这里没有把号段具体到“13几、15几、18几”这种级别,因为号段更新很快,写死具体号段容易没过多久就误伤新号段用户。[3-9]这个区间已经覆盖了当前所有已开放的手机号段,又留了未来的扩展空间。

邮箱校验的正则,网上能找到的版本五花八门,很多复杂的正则连合规邮箱都会误杀。我实际用的是这条:

export function validateEmail(email) { const reg = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/ return reg.test(email) ? '' : '请输入正确的邮箱' }

这条正则的思路是:邮箱名部分允许多种常见字符,域名部分允许字母和点横线,最后顶级域至少两个字母。不追求绝对严谨,但保证用户正常输入的邮箱都能通过。

4.2 验证码输入格式的即时校验

验证码本身也是要校验的,这里“格式校验”和“后端比对”是两回事。格式校验指的是:验证码是不是4位、是不是由字母和数字组成。我项目的Hutool配置生成的就是4位字母数字混合,所以前端正则直接写死:

export function validateCaptcha(code) { const reg = /^[a-zA-Z0-9]{4}$/ return reg.test(code) ? '' : '请输入4位验证码' }

这条正则只用在前端做即时拦截:用户还没输完就点击提交,或者输入了中文、特殊字符,直接提示,省掉一次无意义的后端请求。但要注意,这条正则不能替代后端的比对逻辑,正则只保证“格式对”,不保证“内容对”,内容对不对只有后端比对Redis里的值才知道。

我还给输入框加了maxlength="4",限制用户最多输入4位,但这只是一个软限制,提交时依然过一遍正则,防止绕过maxlength直接构造请求。

4.3 把正则校验封装成公共方法

商城项目里验证正则的地方不止这一处,注册页、登录页、个人中心改手机号、绑定邮箱等多个表单都要用。与其在每个页面的methods里各写一份正则,不如抽出一个公共文件维护。

我在项目src/utils/validate.js里统一管理:

// src/utils/validate.js const patterns = { phone: /^1[3-9]\d{9}$/, email: /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/, captcha: /^[a-zA-Z0-9]{4}$/, password: /^(?=.*[a-zA-Z])(?=.*\d).{6,20}$/ } export function validateField(value, type) { if (!value) return '该项不能为空' return patterns[type].test(value) ? '' : getErrorMsg(type) } export function validatePhone(phone) { return validateField(phone, 'phone') } export function validateEmail(email) { return validateField(email, 'email') } export function validateCaptcha(code) { return validateField(code, 'captcha') }

封装之后,各个页面的调用方式统一,后续如果验证码改成5位数字,或者手机号规则有变化,只需要改patterns里对应的正则,所有页面的校验逻辑同步更新,不会出现“注册页改了登录页忘了改”的尴尬。

5. 常见问题与排查实录

这一部分,我把实际开发中遇到的典型问题和排查思路整理成一个速查表,每一条都是我或者项目组成员真实踩过的坑。

5.1 验证码图片裂了不显示

这个坑出现频率极高,基本十个新手九个遇到。表象就是<img>标签位置空空如也,浏览器控制台报Failed to load resource或者图片破图标。

排查路径按优先级来:

第一步:确认接口返回了数据。打开Chrome DevTools的Network面板,找到/api/captcha/get请求,看Response里imageBase64字段有值没有。如果接口直接500,那问题在后端,看后端日志。

第二步:确认base64前缀。这是最常见的错误。Hutool的getImageBase64Data()方法返回的字符串长这样:iVBORw0KGgoAAAANSUhEUgAA...,没有data:image/png;base64,前缀。而<img>的src想正确解码base64图片,必须带MIME类型前缀。我一开始就漏了这一步,前端src直接用的'data:image/png;base64,' + res.data.data.imageBase64,后来发现漏了前缀,补上之后立刻正常。如果你看到图片区域是一块空白,优先怀疑这个。

第三步:检查跨域。开发环境用Vite或Webpack代理了/api前缀,如果代理配置漏掉或写错,请求会404或跨域报错。

曾经遇到一次很隐蔽的情况:后端返回的是BufferedImage转base64的核心逻辑自己写的,图片能生成,但转base64时用了Base64.getEncoder().encodeToString后没有去掉换行符,结果图片显示一半。换成Hutool的getImageBase64Data()之后就没这问题了,封装得好的工具库确实能省心。

5.2 验证码校验总失败:大小写与TTL

“明明看清楚了,输进去却提示验证码错误”,这是用户反馈最多的一句话。原因通常是大小写。用户输了小写,后端比对时Redis里存的是大写(或者反过来),一比对就不通过。

我项目里的方案是两边都转小写:Redis存储时code.toLowerCase(),后端比对时dto.getCode().trim().toLowerCase(),这样无论用户输入大写还是小写,最终都会归一化到小写再比对。这个习惯建议从第一天就养成,不要等用户来投诉才补。

还有一个容易被忽略的问题:TTL。如果5分钟过期了,用户提交时后端从Redis取到的是null,返回“验证码已过期”。但如果前端没有在上一次校验失败后自动刷新验证码,用户盯着同一张图片反复试,试到天荒地老也是过期的。所以校验失败后,前端一定要联动刷新验证码,别让用户对着一个已经作废的图片干着急。

5.3 Redis数据没清理与接口防刷

“一次性”机制如果没做对,Redis里会堆积大量永远不再使用的验证码key。堆积原因有两个:

一是只在校验成功时删除key,校验失败或用户直接离开页面的情况不处理。这种情况时间一长,Redis里全是垃圾key,虽然设置了TTL能兜底过期清理,但在TTL到期之前,无谓占用内存。

二是TTL设得太长。我见过有人把验证码TTL设成30分钟,理由是“怕用户输入太慢”,结果就是Redis验证码数据量膨胀得很厉害。对于验证码这种敏感数据,5分钟已经是底线偏上的数值了,再长就失去了时效性意义。

防刷方面,我在生成接口上加了一个简单的IP频率限制:同一个IP在1分钟内最多请求5次验证码图片。实现方式也很简单,用Redis的INCR配合过期时间:

String ipKey = "captcha:ip:" + ip; Long count = stringRedisTemplate.opsForValue().increment(ipKey); if (count == 1) { stringRedisTemplate.expire(ipKey, 1, TimeUnit.MINUTES); } if (count > 5) { throw new RuntimeException("请求验证码过于频繁,请稍后再试"); }

这种简易限流对防脚本刷已经足够了,真要上更复杂的滑动窗口或令牌桶,那是网关层面的事,验证码接口用简单计数器即可。

5.4 前后端联调与部署环境的踩坑记录

联调阶段最容易出的问题:字段名不一致。前端传的字段叫uuid,后端DTO里叫sid,这种低级错误调试半小时。排查时先把前后端的字段对齐检查一遍,再去查逻辑。我建议直接前后端用同一套命名,比如都用uuid和code,避免转换层。

部署之后遇到的一个坑:服务器时区问题。验证码的TTL用TimeUnit.MINUTES,这个不涉及时区,所以没踩到。但如果你往后存了时间戳或者日期字段,注意服务器时区要设置成Asia/Shanghai,否则会出现“存储时减了8小时”之类的诡异问题。

还有一次Redis连接池耗尽的问题。商城项目登录页被压测工具扫了一遍,验证码接口每秒钟几百个请求,默认的Lettuce连接池撑不住,报Redis command timed out。解决方法是调整连接池参数,把max-active从默认值调大,并增加max-wait。这个问题在开发环境基本不会暴露,压测或者刚上线流量突然大了才会现形。

项目走到这里的一点体会

验证码功能单独拎出来看,确实不算大,但它逼着我把前后端交互的每个细节都重新审视了一遍:UUID怎么生成、Redis key怎么设计才能避免碰撞、TTL怎么设才能兼顾体验和安全、前端怎么在Vue2的Options API体系下把组件写清楚、正则表达式怎么封装才能让整个项目一起复用。等这套验证码机制稳定跑起来之后,商城项目的注册登录模块就真正有了一个可靠的前置防线。后面再做“忘记密码”和“手机号绑定”这类需要二次验证的功能时,直接把这套方案平移过去就行——把用户输入框换成发送短信验证码,把Redis里存的内容从图形验证码换成短信验证码,流程骨架完全不用动。这也是我把这块实现单独记录下来分享的原因:一套通用方案,能帮你把后续好几个功能的底子一起打好。

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

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

立即咨询