使用验证码防止表单重复提交:基于 JSP + Servlet 的实战方案
在开发 Web 应用时,你有没有遇到过用户疯狂点击“提交”按钮导致服务雪崩的情况?尤其是在涉及高计算成本的操作中,比如 AI 图像生成、订单支付或注册流程,这种行为轻则造成资源浪费,重则直接拖垮服务器。
最近我在体验阿里推出的Z-Image-ComfyUI文生图工具时就碰到了这个问题。这个模型基于 60 亿参数的 Z-Image 大模型构建,支持中英文输入、细节还原精准,推理质量非常高——但代价也很明显:一次图像生成可能消耗数秒 GPU 时间和几 GB 显存。
如果用户因为页面没反应连续点了十次“生成”,后台就得处理十个几乎相同的任务请求。更糟的是,这些请求还可能是来自脚本的恶意刷调用。这时候,光靠前端禁用按钮是不够的,我们必须从服务端入手,建立真正的防护机制。
今天我们就来实现一套完整的防重复提交方案,使用经典的JSP + Servlet技术栈,结合验证码机制,确保每个操作只能成功执行一次。
验证码不只是为了防机器人
很多人觉得验证码是个“反人类”的设计,但它其实是一种非常有效的安全控制手段。除了识别机器流量外,它还能很好地解决人为误操作带来的重复请求问题。
核心思路很简单:
- 每次页面加载时生成一个唯一的验证码,并存储在用户的 Session 中;
- 用户提交表单时必须携带该验证码;
- 后端比对验证码是否匹配;
- 一旦验证通过,立即清除 Session 中的验证码,使其失效。
这样一来,即使用户快速连点多次,也只有第一次能成功,后续所有请求都会因“验证码无效”而被拒绝。
这就像给每张火车票打上“已检票”戳一样——进站后就不能再用了。
自定义字体支持中文验证码
为了让验证码看起来更美观,特别是支持中文字符显示(虽然我们这里用的是字母数字组合),我们可以封装一个字体加载工具类。
package org.zimage.util; import java.io.ByteArrayInputStream; import java.awt.Font; /** * 自定义字体加载工具类(用于验证码中文渲染) */ public class ImgFontByte { public Font getFont(int fontHeight) { try { byte[] fontBytes = getFontData(); Font baseFont = Font.createFont(Font.TRUETYPE_FONT, new ByteArrayInputStream(fontBytes)); return baseFont.deriveFont(Font.PLAIN, fontHeight); } catch (Exception e) { return new Font("Arial", Font.PLAIN, fontHeight); } } /** * 返回嵌入式字体数据(简化版,实际项目可替换为真实ttf文件读取) */ private byte[] getFontData() { // 注意:此处应放入真实的TTF字节码,或改为读取classpath下的资源文件 // 示例中省略具体字节数组以保持简洁 return new byte[0]; } }💡 提示:如果你希望验证码中包含中文字符(如“验”“证”“码”等),建议将一份轻量级中文字体(如思源黑体精简版)转为字节数组嵌入代码,或通过
ClassLoader.getResourceAsStream()动态加载。
实现验证码图片生成器
接下来我们编写一个验证码绘图类,负责生成带干扰线和随机字符的 JPEG 图片。
package org.zimage.servlet; import org.zimage.util.ImgFontByte; import javax.imageio.ImageIO; import java.awt.*; import java.awt.image.BufferedImage; import java.io.IOException; import java.io.OutputStream; import java.util.Random; /** * 验证码生成工具类 */ public class ValidateCode { private int width = 120; // 图片宽 private int height = 40; // 图片高 private int codeCount = 4; // 验证码长度 private int lineCount = 50; // 干扰线条数 private String code; // 当前验证码字符串 private BufferedImage buffImg; // 缓存图像 // 可选字符集(去除了容易混淆的0、O、I、l等) private static final char[] CODE_SEQUENCE = { 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'K', 'M', 'N', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '2', '3', '4', '5', '6', '7', '8', '9' }; public ValidateCode() { this(120, 40, 4, 50); } public ValidateCode(int width, int height, int codeCount, int lineCount) { this.width = width; this.height = height; this.codeCount = codeCount; this.lineCount = lineCount; createCode(); } private void createCode() { int x = width / (codeCount + 2); int fontHeight = height - 6; int codeY = height - 6; // 创建图像缓冲区 buffImg = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); Graphics2D g = buffImg.createGraphics(); // 设置背景为白色 g.setColor(Color.WHITE); g.fillRect(0, 0, width, height); // 加载字体 ImgFontByte imgFont = new ImgFontByte(); Font font = imgFont.getFont(fontHeight); g.setFont(font); Random random = new Random(); // 绘制干扰线 for (int i = 0; i < lineCount; i++) { int xs = random.nextInt(width); int ys = random.nextInt(height); int xe = xs + random.nextInt(width >> 3); int ye = ys + random.nextInt(height >> 3); int red = random.nextInt(255); int green = random.nextInt(255); int blue = random.nextInt(255); g.setColor(new Color(red, green, blue)); g.drawLine(xs, ys, xe, ye); } // 生成验证码文本 StringBuilder sb = new StringBuilder(); for (int i = 0; i < codeCount; i++) { char c = CODE_SEQUENCE[random.nextInt(CODE_SEQUENCE.length)]; sb.append(c); // 每个字符颜色不同 int red = random.nextInt(180); int green = random.nextInt(180); int blue = random.nextInt(180); g.setColor(new Color(red, green, blue)); g.drawString(String.valueOf(c), (i + 1) * x, codeY); } code = sb.toString(); } public void write(OutputStream os) throws IOException { ImageIO.write(buffImg, "jpeg", os); os.flush(); os.close(); } public String getCode() { return code; } public BufferedImage getBuffImg() { return buffImg; } }这个类不仅生成了视觉上难以被 OCR 自动识别的验证码图像,还通过随机颜色、偏移位置和干扰线提升了安全性。
提供验证码图片接口
我们需要一个 Servlet 来输出这张图片,并把正确的验证码保存到当前用户的 Session 中。
package org.zimage.servlet; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import java.io.IOException; @WebServlet("/vcode") public class ValidateCodeServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 设置响应头:告诉浏览器这是张图片,不要缓存 resp.setContentType("image/jpeg"); resp.setHeader("Pragma", "no-cache"); resp.setHeader("Cache-Control", "no-cache"); resp.setDateHeader("Expires", 0); // 获取 session HttpSession session = req.getSession(); // 创建验证码对象 ValidateCode vCode = new ValidateCode(120, 40, 4, 50); // 将验证码文本保存到 session session.setAttribute("verify_code", vCode.getCode()); // 输出图片到客户端 vCode.write(resp.getOutputStream()); } }每次访问/vcode路径时,都会刷新验证码并更新 Session 值。前端可以通过加时间戳的方式强制刷新图片,避免浏览器缓存。
构建前端交互页面
下面是用户界面generate.jsp,允许用户输入提示词并填写验证码。
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%> <% String path = request.getContextPath(); String basePath = request.getScheme() + "://" + request.getServerName() + ":" + request.getServerPort() + path + "/"; %> <!DOCTYPE html> <html> <head> <base href="<%=basePath%>"> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> <title>Z-Image AI绘图</title> <script type="text/javascript"> window.onload = function () { var img = document.getElementById("vcode_img"); img.onclick = function () { this.src = "vcode?date=" + new Date().getTime(); }; } </script> </head> <body> <h2 style="color:#333;">🎨 使用 Z-Image 生成你的专属图像</h2> <form action="Generate" method="post"> 提示词:<input type="text" name="prompt" placeholder="例如:一只戴着墨镜的猫,在月球上冲浪" style="width:300px"/><br><br> 验证码:<input type="text" name="vcode" maxlength="4" style="width:60px"/> <img src="vcode" id="vcode_img" alt="点击刷新" style="cursor:pointer;"><br><br> <input type="submit" value="立即生成" /> </form> <!-- 显示结果 --> <% String msg = (String) request.getAttribute("msg"); if (msg != null) { %> <div style="margin-top:20px; color:red; font-weight:bold;"> <%= msg %> </div> <% } %> </body> </html>页面加载时自动获取验证码图片,点击图片即可刷新。表单提交后由GenerateServlet处理。
核心逻辑:防止重复提交的关键一步
这是整个机制中最关键的部分——验证与销毁验证码。
package org.zimage.servlet; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import java.io.IOException; @WebServlet("/Generate") public class GenerateServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); // 获取用户输入 String prompt = req.getParameter("prompt"); String userInputCode = req.getParameter("vcode"); HttpSession session = req.getSession(); String realCode = (String) session.getAttribute("verify_code"); // 判断验证码是否为空或错误 if (userInputCode == null || realCode == null || !userInputCode.equalsIgnoreCase(realCode)) { req.setAttribute("msg", "❌ 验证码错误,请重新输入!"); req.getRequestDispatcher("/generate.jsp").forward(req, resp); return; } // ✅ 验证码正确,清除 session 中的验证码(防止二次提交) session.removeAttribute("verify_code"); // 模拟调用 Z-Image 模型进行推理(真实场景中会调用 Python 或 API) System.out.println("正在使用 Z-Image 生成图像..."); System.out.println("提示词:" + prompt); // 模拟耗时操作(比如GPU推理) try { Thread.sleep(2000); // 假设生成耗时2秒 } catch (InterruptedException ignored) {} // 成功返回 req.setAttribute("msg", "✅ 图像已生成成功!请查看本地输出目录。"); req.getRequestDispatcher("/generate.jsp").forward(req, resp); } @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { doPost(req, resp); } }注意这一行:
session.removeAttribute("verify_code");正是这一步实现了“一次性使用”的效果。只要验证码被成功验证一次,它就从 Session 中消失了。下次哪怕拿着同样的值来提交,也会因为realCode == null而失败。
设计要点总结
| 环节 | 关键实现 |
|---|---|
| 验证码生成 | 使用ValidateCode类动态绘制图像 |
| 存储方式 | 放入HttpSession,绑定当前用户会话 |
| 安全传输 | 每次刷新更换内容,配合前端防缓存策略 |
| 提交验证 | 比对用户输入与 Session 中的原始值 |
| 防重复核心 | 验证成功后立即删除 Session 中的验证码 |
这套机制简单却极其有效。它不依赖复杂的框架,也不需要数据库支持,仅靠 HTTP 会话就能完成防重放攻击。
生产环境增强建议
虽然上述方案已经可以应对大多数场景,但在实际部署中还可以进一步加固:
设置验证码有效期
在 Session 中同时记录验证码生成时间,超过 60 秒自动作废,防止长期占用内存。增加 IP 请求频率限制
对同一 IP 地址单位时间内请求/vcode或/Generate的次数进行监控,异常行为可临时封禁。引入 Token 双重校验机制
除了验证码,还可额外添加 CSRF Token,防止跨站伪造请求。异步化处理高耗时任务
不要让主线程阻塞等待 GPU 推理完成。应将任务推入消息队列(如 RabbitMQ、Kafka),由工作进程异步处理,提升系统吞吐能力。前后端分离架构下的替代方案
如果采用 Vue/React + Spring Boot 架构,可以用 UUID Token 替代验证码:
- 页面加载时请求一个 token;
- 提交时携带 token;
- 后端用 Redis 缓存 token 并设置过期时间;
- 成功后删除 token。
这种方式更适合无图形界面的 API 接口防护。
写在最后
在这个追求极致用户体验的时代,我们常常忽略了服务器的承受能力。但作为开发者,不能让用户的行为自由放纵。
通过一个小小的验证码机制,我们不仅能防止恶意刷接口,还能显著降低因网络延迟引发的误操作风险。尤其对于 AI 推理这类资源密集型应用,这种保护几乎是必需的。
记住一句话:
🛡️ 用户体验很重要,但服务器不该为用户的“多点几次”买单。
只要我们在关键操作前加上一道简单的验证防线,就能极大提升系统的健壮性和可用性。
如果你也在玩 Z-Image-ComfyUI 或类似的文生图项目,欢迎留言交流部署经验和优化技巧!