表單重複提交是 Web 應用高頻痛點,由用戶多次點擊、瀏覽器刷新/後退/歷史、網絡重發等觸發。單一防禦不可靠,**需構建多層防禦體系**。
| 層級 | 策略 | 防禦範圍 | 可靠性 | 用戶體驗 |
|---|---|---|---|---|
| 前端 | 提交按鈕置灰/禁用 | 誤點擊 | 低(可繞過 JS) | 良好 |
| 前端+後端 | Post-Redirect-Get (PRG) | 刷新、後退、重發 | 高 | 良好 |
| 後端 | Token 唯一標識 (Session/Redis) | 所有重複提交 | 極高 | 需處理失效提示 |
| 後端 | 業務冪等性設計 | 業務邏輯級重複 | 極高 | 透明 |
| 數據庫 | 唯一約束/索引 | 數據級重複 | 終極防線 | 拋異常需捕獲轉換 |
// 原生 JS
form.addEventListener('submit', function() {
submitBtn.disabled = true;
submitBtn.textContent = '提交中...';
});
// jQuery
$('#submitBtn').prop('disabled', true);
// 防抖:短時間內只觸發一次
let submitting = false;
form.onsubmit = () => {
if (submitting) return false;
submitting = true;
setTimeout(() => submitting = false, 3000);
};
缺陷:用戶禁用 JS、瀏覽器自動填充、爬蟲直連 API 完全失效。
@PostMapping("/order")
public String createOrder(OrderForm form, RedirectAttributes attr) {
Order order = orderService.create(form);
attr.addFlashAttribute("msg", "下單成功");
return "redirect:/order/" + order.getId(); // 302 重定向到 GET
}
@GetMapping("/order/{id}")
public String detail(@PathVariable Long id, Model model) {
model.addAttribute("order", orderService.get(id));
return "order/detail";
}
原理:POST 處理完成後 302 重定向到 GET 頁面,瀏覽器歷史記錄替換為 GET,刷新/後退不再觸發 POST。
適用:傳統表單跳轉場景。不適用:AJAX 異步提交、文件上傳、需保持 POST 響應數據場景。
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface PreventDuplicateSubmit {
int expireSeconds() default 300;
}
@Component
public class TokenInterceptor implements HandlerInterceptor {
@Autowired StringRedisTemplate redis;
@Override
public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) {
if (!(handler instanceof HandlerMethod)) return true;
PreventDuplicateSubmit ann = ((HandlerMethod) handler).getMethodAnnotation(PreventDuplicateSubmit.class);
if (ann == null) return true;
String token = req.getHeader("X-Token"); // 或 request 參數
if (StringUtils.isBlank(token)) return fail(resp, "缺少 Token");
String key = "token:" + token;
Boolean deleted = redis.delete(key); // 原子刪除,成功返回 true
if (Boolean.TRUE.equals(deleted)) return true;
return fail(resp, "重複提交或 Token 失效");
}
private boolean fail(HttpServletResponse resp, String msg) throws IOException {
resp.setStatus(400);
resp.getWriter().write(JSON.toJSONString(Result.error(msg)));
return false;
}
}
// 頁面生成 Token
@GetMapping("/form")
public String form(Model model) {
String token = UUID.randomUUID().toString().replace("-", "");
redis.opsForValue().set("token:" + token, "1", 5, TimeUnit.MINUTES);
model.addAttribute("token", token);
return "form";
}
無論提交幾次,**業務結果相同**。核心手段:
UNIQUE KEY uk_biz_key (biz_key)@Transactional
public void handlePayCallback(PayNotify notify) {
// 1. 唯一索引防重
PayRecord record = new PayRecord();
record.setOutTradeNo(notify.getOutTradeNo());
record.setStatus(PayStatus.SUCCESS);
try {
payRecordMapper.insert(record); // 唯一索引衝突拋 DuplicateKeyException
} catch (DuplicateKeyException e) {
log.info("重複回調: {}", notify.getOutTradeNo());
return; // 已處理,直接返回成功
}
// 2. 狀態機校驗
Order order = orderMapper.selectByOutTradeNo(notify.getOutTradeNo());
if (order.getStatus() == OrderStatus.PAID) return;
// 3. 業務處理
order.setStatus(OrderStatus.PAID);
orderMapper.updateById(order);
// 發貨、積分、通知...
}