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。这个类会生成类似1+2=?@3这样的原始串,Controller 拿到后从@处切开:@左边是画到图片上的表达式,@右边是正确答案。所以用户看到的是一道算术题,输入的是数字答案。
两种类型的对比如下:
| 类型 | 文本生成器 | 图片上显示 | 存 Redis 的答案 | 用户输入 |
|---|---|---|---|---|
| math | KaptchaTextCreator | 算式,如 3*5=? | 15 | 数字 |
| char | DefaultTextCreator | 4 位字母数字 | 同一段字符串 | 字母或数字 |
理解这个差异有个关键结论:不管图片上画的是什么,最终和用户输入做比对的就是一段字符串。这为中文验证码铺平了路——汉字在比对层面并不会增加任何额外成本。
1.3 校验侧:答案最终都是一段字符串
登录接口收到前端提交的code和uuid后,SysLoginService会调validateCaptcha:用 uuid 拼 key 去 Redis 取值,取不到就抛“验证码已失效”,取到了但和code不一致就抛“验证码错误”,校验通过后立刻把 key 删掉,保证一次性使用。
这个逻辑意味着,只要图片上生成的中文文本能正确存进 Redis,并且用户输入的中文能和它匹配上,校验侧一行代码都不用改。真正要改的是“出题”和“画图”这一侧,也就是下一节要讲的三个地方。
2. 确定改造方案:继续用 kaptcha 还是换 easy-captcha
2.1 为什么不直接换库
网上搜“若依 中文验证码”,很容易搜到引入easy-captcha的解法,因为这个库原生支持ChineseCaptcha、GifCaptcha、ArithmeticCaptcha,看起来一行代码就能搞定。但仔细翻完若依源码后,我建议主路线还是别换库,原因很实际。
现成的验证码链路全部建立在 kaptcha 之上:CaptchaConfig返回的是DefaultKaptcha,Controller 注入的是Producer,math 的算数逻辑写在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.java:
package 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.yml:
ruoyi: # 验证码类型 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 :lang=zh如果输出为空,基本可以断定是字体缺失。接着装字体。
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 / Debian:
apt-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; data=json.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 服务器装字体花的时间最多。希望这篇能把那部分时间也帮你省下来。