ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

若依验证码扩展:中文验证码实现与生产环境避坑指南

若依验证码扩展:中文验证码实现与生产环境避坑指南 1. 先把若依验证码的“流水线”走一遍开关、生成、缓存、校验做若依二次开发的人大概率都被验证码这个“小功能”折腾过。默认的 char 验证码在手机上看经常分不清 0 和 O、1 和 l换成 math 算术验证码用户又嫌“登录还要算一遍麻烦”。需求方最后往往丢过来一句话能不能直接显示汉字我这次就是被这句话推着把若依验证码从内到外翻了一遍顺便把算术、字符、中文、GIF 这些形态全部打通了。这篇文章就按我实际改完并上线的顺序把整个链路和坑一次讲清楚。1.1 一次 /captchaImage 请求背后发生了什么若依验证码的入口很集中就是CaptchaController里的getCode方法对应前端登录页加载时发起的GET /captchaImage请求。这个接口做的事情可以拆成四段第一段是查开关。configService.selectCaptchaEnabled()读的不是配置文件而是sys_config表里的sys.account.captchaEnabled配置项。这个值默认 true如果配置不存在也按 true 处理。也就是说运维可以在不重启项目的情况下随时关掉全站验证码。改验证码类型的同学要留意别试完功能随手把这个开关改成 false后面排查登录问题时会绕一大圈。第二段是造 Redis key。IdUtils.simpleUUID()生成一个 uuid拼成captcha_codes:{uuid}作为这张验证码的唯一标识。TTL 由Constants.CAPTCHA_EXPIRATION控制默认 2 分钟存的是验证码的正确答案。第三段是选类型、出题、画图。这一步最核心也是我们后面动刀最多的地方。RuoYiConfig.getCaptchaType()读的是 application.yml 里的ruoyi.captchaType当前只有 math 和 char 两种写法。第四段是回包图片转成 jpg 再 Base64 编码通过 AjaxResult 把uuid和img两个字段返回给前端。1.2 math 和 char 的差异藏在两套 Bean 配置里很多人以为 math 和 char 只是“画的内容不同”实际上它们在若依里就是两个独立的DefaultKaptchaBean分别叫captchaProducer和captchaProducerMath定义在CaptchaConfig里。char 类型走的是captchaProducer.createText()直接生成 4 位字母数字混合串图片上画什么正确答案就是什么。而 math 类型走captchaProducerMath注意它的文本生成器被配置成了若依自带的KaptchaTextCreator。这个类会生成类似12?3这样的原始串Controller 拿到后从处切开左边是画到图片上的表达式右边是正确答案。所以用户看到的是一道算术题输入的是数字答案。两种类型的对比如下类型文本生成器图片上显示存 Redis 的答案用户输入mathKaptchaTextCreator算式如 3*5?15数字charDefaultTextCreator4 位字母数字同一段字符串字母或数字理解这个差异有个关键结论不管图片上画的是什么最终和用户输入做比对的就是一段字符串。这为中文验证码铺平了路——汉字在比对层面并不会增加任何额外成本。1.3 校验侧答案最终都是一段字符串登录接口收到前端提交的code和uuid后SysLoginService会调validateCaptcha用 uuid 拼 key 去 Redis 取值取不到就抛“验证码已失效”取到了但和code不一致就抛“验证码错误”校验通过后立刻把 key 删掉保证一次性使用。这个逻辑意味着只要图片上生成的中文文本能正确存进 Redis并且用户输入的中文能和它匹配上校验侧一行代码都不用改。真正要改的是“出题”和“画图”这一侧也就是下一节要讲的三个地方。2. 确定改造方案继续用 kaptcha 还是换 easy-captcha2.1 为什么不直接换库网上搜“若依 中文验证码”很容易搜到引入easy-captcha的解法因为这个库原生支持ChineseCaptcha、GifCaptcha、ArithmeticCaptcha看起来一行代码就能搞定。但仔细翻完若依源码后我建议主路线还是别换库原因很实际。现成的验证码链路全部建立在 kaptcha 之上CaptchaConfig返回的是DefaultKaptchaController 注入的是Producermath 的算数逻辑写在KaptchaTextCreator里。如果换成 easy-captcha相当于把CaptchaConfig和CaptchaController两个文件重写一遍而且两套 API 的对象模型完全不同改动面大、回滚难。更麻烦的是若依的captchaType配置语义会被打破原来只改配置就能切换类型换库后得改代码。继续用 kaptcha扩展中文验证码只需要三步写一个返回中文文本的TextCreator、在CaptchaConfig里新增一个 Bean、在 Controller 里加一个chinese分支。这三步是纯增量对 math 和 char 的原有逻辑零侵入。2.2 三步扩展法结构风险最小打个比方kaptcha 是照相机TextCreator是出题人CaptchaConfig是摄影棚CaptchaController是导播。若依默认搭了两个棚一个拍算式一个拍字符。我们要做的就是再搭一个拍中文的棚然后指挥导播在看到chinese指令时切过来。原有的拍照流程、胶片冲洗、归档环节全都不碰自然不容易出问题。需要提醒一句如果你用的是 RuoYi-Cloud 微服务版或者 RuoYi-Plus 这类增强分支先打开ruoyi-framework模块看一眼CaptchaConfig是不是已经被改过了。有些第三方版本把验证码库换成了 easy-captcha或者加了滑块验证码这时候不能照抄我的代码得先摸清自己手上的版本底细。2.3 安全与体验的取舍中文验证码的体验优势不用多说国内后台管理系统里基本都是中文用户输入几个常用汉字毫无压力。从安全角度讲汉字分类空间远大于 10 个数字和 36 个字母数字同等级别的图片质量下机器识别成本更高抗撞库能力更强。局限性也明显如果系统有海外用户或者用户输入法常年不在中文状态中文验证码就是个灾难。所以我的建议是中文验证码更适合内网运营后台、管理后台这类封闭场景公网开放注册的系统慎用最好做成多类型随机切换让每类验证码只承担一部分流量。3. 中文验证码落地文本生成器、新 Bean、控制器分支一次讲清3.1 写一个不会让用户看哭的文本生成器在com.ruoyi.framework.config包下新建ChineseTextCreator.javapackage com.ruoyi.framework.config; import java.util.Random; import com.google.code.kaptcha.text.impl.DefaultTextCreator; /** * 中文验证码文本生成器 */ public class ChineseTextCreator extends DefaultTextCreator { private static final String[] CHARS { 春, 夏, 秋, 冬, 风, 花, 雪, 月, 山, 水, 云, 雨, 星, 天, 地, 人, 龙, 凤, 鹤, 梅, 兰, 竹, 菊, 安, 康, 乐, 福, 和, 顺, 宁, 静 }; Override public String getText() { Random random new Random(); StringBuilder builder new StringBuilder(); for (int i 0; i 4; i) { builder.append(CHARS[random.nextInt(CHARS.length)]); } return builder.toString(); } }说几个选字时的实操经验字符池宁小勿乱。不要贪心把几千个常用汉字塞进去生僻字用户看不清只会反复刷新验证码白白增加服务器压力。我自己的项目里固定了 30 个左右笔画清晰、结构差异大的字。避免形近字。“未/末”“己/已”“土/士”这种在 40px 见方的图片里真人都会看错加了等于没加。长度固定 4 个字。太少容易被枚举太多在 160px 宽度里排不开字号只能缩小又回到看不清的老问题。如果你想要成语诗句风格也可以直接从成语池里随机取 4 个字体验更友好但机器识别难度会略微下降因为成语有强先验概率。安全优先还是体验优先自己权衡。3.2 在 CaptchaConfig 里搭一个“中文棚”打开CaptchaConfig.java在现有两个 Bean 后面追加代码如下Bean(name captchaProducerChinese) public DefaultKaptcha getKaptchaBeanChinese() { DefaultKaptcha defaultKaptcha new DefaultKaptcha(); Properties properties new Properties(); properties.setProperty(KAPTCHA_BORDER, no); properties.setProperty(KAPTCHA_TEXTPRODUCER_FONT_COLOR, black,blue,red); properties.setProperty(KAPTCHA_TEXTPRODUCER_CHAR_SPACE, 6); properties.setProperty(KAPTCHA_TEXTPRODUCER_CHAR_LENGTH, 4); properties.setProperty(KAPTCHA_TEXTPRODUCER_FONT_NAMES, 宋体,楷体,微软雅黑,SimSun,STKaiti); properties.setProperty(KAPTCHA_TEXTPRODUCER_FONT_SIZE, 38); properties.setProperty(KAPTCHA_IMAGE_WIDTH, 160); properties.setProperty(KAPTCHA_IMAGE_HEIGHT, 55); properties.setProperty(KAPTCHA_NOISE_IMPL, com.google.code.kaptcha.impl.NoNoise); properties.setProperty(KAPTCHA_OBSCURIFICATOR_IMPL, com.google.code.kaptcha.impl.ShadowGimpy); properties.setProperty(KAPTCHA_TEXTPRODUCER_IMPL, com.ruoyi.framework.config.ChineseTextCreator); Config config new Config(properties); defaultKaptcha.setConfig(config); return defaultKaptcha; }这些参数都是中文场景特调的逐个说原因图片尺寸从原来的 120x40 提到 160x55。汉字结构比英文字母复杂40px 高度下 4 个字很容易糊成一片高度提到 55、宽度到 160每个字才有足够的留白。字号 38px在 55px 高的图里基本占满格子压缩到前端显示的尺寸后仍然清晰。太小认不出太大放不下。字体名列表里既有中文名又有英文别名。Windows 上写“宋体”没问题但 Linux 服务器不一定认识这个中文别名所以我把SimSun、STKaiti这种英文家族名也加上让不同系统能各自匹配到可用字体。中文验证码建议少加噪点。汉字笔画本身就密再加一堆干扰线人眼读取会非常吃力。我用NoNoise关掉了额外噪点靠ShadowGimpy的阴影效果来增加机器识别难度。3.3 控制器加分支配置切类型打开CaptchaController.java注入新 Bean 并加分支。这里有个重要细节Autowired的字段名必须和Bean的 name 保持一致这样 Spring 才会按名字精确注入。如果你写成private Producer captchaProducerChineseSomeThing容器里同时存在多个Producer类型的 Bean启动时会直接报NoUniqueBeanDefinitionException。Autowired private Producer captchaProducer; Autowired private Producer captchaProducerMath; Autowired private Producer captchaProducerChinese;然后在getCode方法里两个已有分支之后、写 Redis 之前加一段} else if (chinese.equals(captchaType)) { capStr code captchaProducerChinese.createText(); image captchaProducerChinese.createImage(capStr); }最后改 application.ymlruoyi: # 验证码类型 math 数组计算 char 字符验证 chinese 中文验证 captchaType: chinese改完重启登录页就能看到中文验证码了。如果你用的是 RuoYi-Vue 前后端分离版或者 RuoYi 单体版前端模板都不需要动因为登录页只是展示一份 Base64 图片、提交一个 code 字符串它根本不关心图片里画的是什么。4. 前后端衔接细节改完别急着上线先确认这四件事代码改完只是第一步我每次改验证码功能都会花几分钟确认下面四件事能省掉后面大把联调时间。4.1 Base64 图片的格式前缀若依后端写的是ImageIO.write(image, jpg, os)输出的是 JPEG 数据但前端 RuoYi-Vue 拼的是data:image/gif;base64,前缀。浏览器通常会按实际内容解码所以一直能显示这个“bug”被大家默认了。既然动了这块代码建议顺手把前端改成data:image/jpeg;base64,语义正确以后排查问题也不会被前缀误导。4.2 code 输入框别做字符过滤默认登录页的验证码输入框没有任何字符过滤中文输入无压力。但很多团队会自己做二次开发比如只允许数字、字母的pattern校验或者onkeyup里把非数字字符直接清掉。如果你们登录页有这种过滤中文验证码一上线就会“永远输不对”。改之前先搜一下前端代码里有没有code相关的正则限制。4.3 uuid 与 code 的配对关系前端每次刷新验证码都会调用getCode()拿到新的 uuid 和图片同时把 uuid 写进loginForm.uuid。登录请求提交时后端会用这个 uuid 去 Redis 里找正确答案。如果你们的前端被改过比如刷新图片后没有更新 uuid或者登录失败后没有重新获取验证码就会出现“明明看清楚了却提示验证码错误/失效”的诡异现象。排查时抓接口请求对比一下前端提交的 uuid 和最近一次/captchaImage返回的 uuid 是否一致。4.4 完整跑一遍“失败-刷新-成功”闭环手工验证时我建议按这个顺序操作先故意输错一次确认登录失败且前端自动刷新了验证码再输对一次确认能登录成功最后用同一个 uuid 重复提交一次确认会提示“验证码已失效”。这能把“生成、存储、比对、删除”整条链路的每一步都验证到位任何一个环节崩溃都会在这三个动作里暴露出来。5. 生产环境最容易翻车的点服务器字体、图片缓存与 Redis 值5.1 方块字问题的完整排查链路“本地是汉字部署到服务器变成方块”是我见过最多的问题原因不在若依也不在代码而在 JVM 渲染字体这一步。kaptcha 画字时会拿着KAPTCHA_TEXTPRODUCER_FONT_NAMES里的字体名去系统字体库找字体。找不到中文字体时它不会报错而是悄悄 fallback 到默认字体很多精简安装的 Linux 服务器、官方 OpenJDK 镜像根本没有 CJK 字体于是汉字映射不到字形只能画出一个个方块或问号。排查链路按顺序走先确认服务器有没有中文字体fc-list :langzh如果输出为空基本可以断定是字体缺失。接着装字体。CentOS / TencentOS 这类系统# 安装字体管理工具 yum install -y fontconfig mkfontscale # 建字体目录 mkdir -p /usr/share/fonts/chinese # 把 Windows 的 C:\Windows\Fonts 下的 simsun.ttc、msyh.ttc、simhei.ttf 上传到该目录 # 或者下载开源的文泉驿、思源黑体 # 刷新字体缓存 cd /usr/share/fonts/chinese mkfontscale mkfontdir fc-cache -fvUbuntu / Debianapt-get install -y fonts-wqy-zenhei fonts-wqy-microhei fc-cache -fvDocker 容器场景# 以 openjdk:8-jdk-alpine 为例容器非常精简缺字体是必然的 RUN apk add --no-cache fontconfig ttf-dejavu # 再拷贝中文字体进容器 COPY simsun.ttc /usr/share/fonts/chinese/ RUN fc-cache -fv装完字体重启 Java 进程。有个细节Java 的字体缓存比较顽固如果重启后还是方块把~/.fontconfig目录删掉再启动。另外注意kaptcha 配置的字体名必须和系统里的字体家族名完全一致。比如 Ubuntu 安装文泉驿正黑后字体家族名可能是WenQuanYi Zen Hei这时配置里写“宋体”是匹配不上的要用fc-list输出里的名字。这也是我在 3.2 里强调要同时写中文名和英文别名的原因。5.2 nginx 缓存导致验证码“万年不变”字体问题解决后还有一类现象是“验证码图片不刷新”。前端登录失败后会重新请求/captchaImage但如果你在生产环境前面挂了 nginx 并开启了代理缓存浏览器拿到的可能是上一张图。RuoYi 前端在请求 URL 里加了时间戳参数能绕开浏览器缓存但 nginx 层的强缓存比如proxy_cache_valid不一定吃这套。排查方法是绕过前端直接用 curl 打后端接口连续请求两次看返回的 uuid 是否变化curl -s http://你的服务地址/captchaImage | python3 -c import sys, json, base64; datajson.load(sys.stdin); open(/tmp/captcha.jpg,wb).write(base64.b64decode(data[img])); print(data[uuid])如果 uuid 在变说明后端和 Redis 链路正常问题在前端代理缓存如果 uuid 不变再检查后端的验证码开关和配置有没有生效。5.3 Redis 里明明有 key 却提示已失效最后一种高频问题。验证码校验走的是 Redis最直接的原因就是 2 分钟 TTL 到了用户输得太慢。但还有几个隐蔽场景多实例部署时如果项目被改造成用本地缓存Caffeine/内存 Map替代 Redis验证码在 A 实例生成登录请求被负载均衡转到了 B 实例B 实例查不到 key必然提示失效。若依默认用 Redis 没问题改造过的要注意。key 前缀不一致。Constants.CAPTCHA_CODE_KEY在部分二开项目里被改过或者不同环境配置的 Redis 库号不同也会出现“存和取不在同一个地方”。Redis 序列化问题。RuoYi 默认的 RedisTemplate 用 FastJson 序列化存字符串取字符串是正常的但如果你二开时改过 value 序列化方式或者往验证码 key 里写过对象取回来可能不是 String 类型equalsIgnoreCase直接抛异常。排查时redis-cli get captcha_codes:xxx看一眼值类型是字符串还是 JSON。还有一个小细节很多人忽略sys.account.captchaEnabled这个配置项如果是 false前端登录页直接不显示验证码输入框也不会提交code和uuid。测试时想当然地以为验证码坏了其实是被开关关掉了。6. 继续加料多类型随机切换、GIF 动图与公共服务化6.1 配置多个类型随机抽一个中文验证码跑通后这套“文本生成器 Bean 分支”的模式基本可以复制到任意你想要的验证码形态上。不想把系统绑定在单一类型可以把captchaType配置成逗号分隔的多个值然后在 Controller 里做一个随机选择ruoyi: captchaType: math,char,chinese取类型时写一个小方法private String pickCaptchaType() { String configured RuoYiConfig.getCaptchaType(); if (configured ! null configured.contains(,)) { String[] types configured.split(,); return types[new Random().nextInt(types.length)].trim(); } return configured; }然后在getCode里用String captchaType pickCaptchaType();替换原来的直接读取。这样做的收益很直接攻击者如果要针对验证码训练识别模型得同时处理算式、字符、中文三类图片成本明显更高而系统只是多了一个分支判断开销可以忽略。6.2 GIF 动态验证码交给 easy-captcha如果你的需求升级到动图kaptcha 原生不支持 GIF这时候再坚持自己造轮子就不划算了。com.github.whvcse:easy-captcha这个库是个很成熟的选择原生支持SpecCaptcha、GifCaptcha、ChineseCaptcha、ChineseGifCaptcha、ArithmeticCaptcha五种类型。接入思路和前面完全一致实例化一个 captcha 对象拿到展示文本和正确答案图片转 Base64 返回前端正确答案存 Redis。只是 API 从 kaptcha 的createText createImage变成了 easy-captcha 的text toBase64这类形式。具体方法名不同版本略有调整建议以官方 README 为准直接抄博客里的方法名容易踩版本坑。有一个点要提前想清楚GIF 动图体积比静态 jpg 大不少验证码频繁刷新时会占用更多接口带宽和前端渲染资源。内网管理系统影响不大公网高并发登录场景建议先测一下单张图片大小再决定。6.3 多个后台系统共用一套验证码服务如果公司有多个后台系统共用登录态比如用户中心、运营后台、代理商后台每个系统各存一份验证码逻辑会造成重复建设和策略不统一。可以考虑把验证码接口收拢到一个公共服务里共用同一个 Redis 集群key 设计加上业务标识前缀比如captcha_codes:{bizCode}:{uuid}。这样既统一了验证码强度策略比如全公司强制中文或动态混用也方便后续接入风控体系。不过这个改造已经超出本文的增量范围属于架构层面的事等有真实需求再动手也不迟。最后再分享一个小经验改造验证码这类细节功能最忌讳一上来就改代码。先把配置开关、Redis key、字体环境摸清楚再用最小改动跑通一条路径剩下的工作其实都是横向复制。我这次从接入中文验证码到上线真正改的代码就两个文件加一个配置文件反而是给 Linux 服务器装字体花的时间最多。希望这篇能把那部分时间也帮你省下来。
返回列表