ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Vue2+SpringBoot商城项目:Hutool生成验证码+Redis存储校验全链路

Vue2+SpringBoot商城项目:Hutool生成验证码+Redis存储校验全链路 商城项目做到验证码这一环其实就到了一个很微妙的节点功能看着不大但牵扯到后端接口设计、缓存存储策略、前端交互体验和表单校验四条线任何一个环节处理不好都会直接影响用户注册、登录和下单。我做的这个Vue2SpringBoot在线商城项目里用户模块正好推进到这里就借这篇记录把验证码这套完整链路——Hutool生成图片验证码、Redis存储与校验、前端Vue2展示刷新、正则式表单验证——全部摊开来讲包含可以直接抄走的代码和踩坑记录。不管你是刚接触这类技术组合的新手还是在老项目里补验证码功能的开发者这篇都能给你一条走得通的落地路径。1. 验证码方案选型与整体设计思路1.1 验证码在商城项目里的几个真实场景先说清楚为什么商城项目必须有验证码。很多同学觉得“我做个登录注册还要验证码干嘛”但真上线之后会发现没有验证码的注册接口就是一个裸奔的接口随便写个脚本就能批量注册账号、刷优惠券、刷评论甚至用撞库的方式试密码。我在这个商城项目里规划了三个必须用验证码的场景用户注册防止脚本批量注册账号这是最基础的一个入口。账号登录尤其是密码连续输错几次之后用验证码阻断暴力破解。下单或修改关键信息某些敏感性操作二次验证能拦住CSRF或者被诱导的操作。所以验证码不是“用户体验的敌人”而是保护账号和数据的一道闸门。这道闸门的设计好坏直接影响安全强度也直接影响用户愿不愿意继续用你的产品——太复杂的验证码比如让你选一堆红绿灯虽然安全但用户烦太简单的比如固定四位数字又等于没拦。1.2 为什么选Hutool Redis而不是纯前端验证方案选型的时候我其实对比过几种做法方案优点缺点是否采用纯前端生成验证码实现最简单不占后端资源验证逻辑在前端等于摆设随便绕过去不采用后端生成图片Session存储比纯前端靠谱经典做法分布式环境下Session不共享多实例要额外处理不采用后端生成图片Redis存储分布式友好过期和清理可控多引入一个Redis依赖本来项目就有采用第三方验证码服务安全等级最高接入快有费用依赖外部服务不作首选因为这个项目本身就是SpringBootRedis的技术栈用Redis做验证码存储几乎是零成本的事情。Hutool的CaptchaUtil封装好了图形验证码的生成逻辑开箱即用不用自己画干扰线也不用自己处理扭曲效果一行代码就能出一个标准的图片验证码。这两个组合起来既绕开了Session的分布式问题又不需要额外引第三方SDK是我能想到的性价比最高的方案。1.3 验证码的完整生命周期一个验证码从生成到销毁完整路径是这样的前端打开登录或注册页向后端请求验证码。后端用Hutool生成一张图片同时生成一个唯一的UUID标识这个验证码。验证码的“答案”那几个字符被转成小写存进Rediskey是captcha:{uuid}过期时间我设成5分钟。后端把图片的base64字符串和UUID一起返回给前端前端用img标签展示出来。用户输入看到的字符提交表单时把“UUID 用户输入的验证码”一起带给后端。后端拿到UUID从Redis取出正确验证码跟用户输入的比对。无论比对结果如何只要走到了校验这一步Redis里的验证码就删除——保证验证码是一次性的防止重放攻击。校验通过继续走注册或登录逻辑不通过提示错误并让用户刷新验证码。这个设计里最关键的一点是“一次性”验证码无论输对输错用完就销毁。如果不销毁同一个验证码可以被反复尝试暴力破解就没被真正阻断。2. 后端接口实现生成与校验2.1 引入Hutool依赖与验证码生成第一步先引入Hutool的captcha模块。这里要注意Hutool的包是可以按需引入的不是一整个hutool-all全塞进来。我项目里用的是Maven直接在pom.xml加这一段dependency groupIdcn.hutool/groupId artifactIdhutool-captcha/artifactId version5.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 ResultMapString, String getCaptcha() { // 生成宽150、高40、4位验证码、干扰线数量10条的图片验证码 LineCaptcha captcha CaptchaUtil.createLineCaptcha(150, 40, 4, 10); String code captcha.getCode(); String uuid UUID.randomUUID().toString().replace(-, ); // 存入Redis5分钟有效 stringRedisTemplate.opsForValue().set( captcha: uuid, code.toLowerCase(), 5, TimeUnit.MINUTES ); MapString, 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 ResultVoid 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 classcaptcha-row input v-modelform.captchaCode placeholder请输入验证码 maxlength4 classcaptcha-input / img :srccaptchaImg alt验证码 title点击刷新验证码 classcaptcha-img clickhandleRefreshCaptcha / /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里的值才知道。我还给输入框加了maxlength4限制用户最多输入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里存的内容从图形验证码换成短信验证码流程骨架完全不用动。这也是我把这块实现单独记录下来分享的原因一套通用方案能帮你把后续好几个功能的底子一起打好。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进