前端用户误操作防护
概述
前端用户误操作是导致重复提交的常见原因,包括:
- 重复点击提交按钮
- 页面刷新导致表单重提交
- 浏览器后退后再次提交
- 网络延迟时用户多次点击
常见场景
场景 1:按钮重复点击
用户点击 [提交] -> 请求发送中... (等待响应)
-> 用户再次点击 [提交] -> 第二个请求发送
-> 第三个请求...
-> 产生多条重复数据 ❌场景 2:页面刷新
用户提交表单 -> 服务器处理成功 -> 返回结果页面
-> 用户刷新页面 -> 浏览器重新发送 POST 请求 -> 重复提交 ❌前端防护方案
1. 按钮禁用(基础方案)
html
<!-- HTML -->
<form id="orderForm">
<button type="submit" id="submitBtn">提交订单</button>
</form>
<script>
document.getElementById('orderForm').addEventListener('submit', async function(e) {
e.preventDefault();
const submitBtn = document.getElementById('submitBtn');
// 禁用按钮
submitBtn.disabled = true;
submitBtn.textContent = '提交中...';
try {
const response = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
});
if (!response.ok) throw new Error('Submit failed');
// 成功后跳转,防止刷新重提交
window.location.href = '/orders/success';
} catch (error) {
// 失败时恢复按钮
submitBtn.disabled = false;
submitBtn.textContent = '提交订单';
alert('提交失败,请重试');
}
});
</script>缺点:用户可以绕过前端验证,直接调用 API。
2. React Hook 实现
tsx
import { useState, useCallback } from 'react';
function useSubmitWithDebounce() {
const [isSubmitting, setIsSubmitting] = useState(false);
const [lastSubmitTime, setLastSubmitTime] = useState(0);
const submit = useCallback(async (action: () => Promise<any>, debounceMs = 1000) => {
const now = Date.now();
// 防抖:如果距离上次提交时间太短,忽略
if (now - lastSubmitTime < debounceMs) {
console.warn('Submit debounced');
return;
}
// 防止并发提交
if (isSubmitting) {
console.warn('Already submitting');
return;
}
setIsSubmitting(true);
setLastSubmitTime(now);
try {
return await action();
} finally {
setIsSubmitting(false);
}
}, [isSubmitting, lastSubmitTime]);
return { submit, isSubmitting };
}
// 使用示例
function OrderForm() {
const { submit, isSubmitting } = useSubmitWithDebounce();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await submit(async () => {
const response = await fetch('/api/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': generateIdempotencyKey()
},
body: JSON.stringify(orderData)
});
return response.json();
});
};
return (
<form onSubmit={handleSubmit}>
{/* 表单字段 */}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? '提交中...' : '提交订单'}
</button>
</form>
);
}3. Vue 3 Composition API
vue
<template>
<form @submit.prevent="handleSubmit">
<input v-model="formData.name" />
<button type="submit" :disabled="isSubmitting">
{{ isSubmitting ? '提交中...' : '提交' }}
</button>
</form>
</template>
<script setup>
import { ref } from 'vue';
const isSubmitting = ref(false);
const formData = ref({ name: '' });
const handleSubmit = async () => {
if (isSubmitting.value) return;
isSubmitting.value = true;
try {
const idempotencyKey = crypto.randomUUID();
const response = await fetch('/api/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey
},
body: JSON.stringify(formData.value)
});
if (response.ok) {
// 成功后清除表单并跳转
formData.value = { name: '' };
window.location.href = '/success';
}
} finally {
isSubmitting.value = false;
}
};
</script>后端防护方案
1. Token 机制(推荐)
csharp
// 生成表单 Token
[HttpGet("orders/create")]
public async Task<IActionResult> CreateOrderPage()
{
var token = Guid.NewGuid().ToString("N");
// 存储 Token(Redis,5分钟过期)
await _redis.StringSetAsync(
$"form_token:{token}",
"pending",
TimeSpan.FromMinutes(5));
ViewBag.FormToken = token;
return View();
}
// 处理提交
[HttpPost("orders")]
[ValidateAntiForgeryToken]
public async Task<ActionResult<Order>> CreateOrder(
[FromBody] CreateOrderRequest request,
[FromHeader(Name = "X-Form-Token")] string formToken)
{
// 验证 Token
if (string.IsNullOrWhiteSpace(formToken))
{
return BadRequest("Missing form token");
}
var tokenKey = $"form_token:{formToken}";
var tokenExists = await _redis.KeyExistsAsync(tokenKey);
if (!tokenExists)
{
return Conflict(new { error = "Form already submitted or token expired" });
}
// 删除 Token(一次性使用)
await _redis.KeyDeleteAsync(tokenKey);
// 处理业务逻辑
var order = await _orderService.CreateOrderAsync(request);
return CreatedAtAction(nameof(GetOrder), new { id = order.Id }, order);
}html
<!-- Razor 视图 -->
<form id="orderForm">
<input type="hidden" id="formToken" value="@ViewBag.FormToken" />
<!-- 其他字段 -->
<button type="submit">提交</button>
</form>
<script>
document.getElementById('orderForm').addEventListener('submit', async function(e) {
e.preventDefault();
const formToken = document.getElementById('formToken').value;
const response = await fetch('/api/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Form-Token': formToken
},
body: JSON.stringify(formData)
});
if (response.status === 409) {
alert('表单已提交,请勿重复提交');
return;
}
// 处理成功...
});
</script>2. PRG 模式(Post-Redirect-Get)
csharp
[HttpPost("orders")]
public async Task<IActionResult> CreateOrder([FromBody] CreateOrderRequest request)
{
var order = await _orderService.CreateOrderAsync(request);
// 关键:创建成功后重定向到 GET 请求
return RedirectToAction(nameof(OrderSuccess), new { id = order.Id });
}
[HttpGet("orders/success/{id}")]
public IActionResult OrderSuccess(Guid id)
{
// 即使用户刷新这个页面,也只是重复 GET 请求(幂等)
return View(id);
}优势:
- 浏览器刷新时只重复 GET 请求
- 避免"确认重新提交表单"的警告
- 用户体验更好
完整实战示例
ASP.NET Core + React 完整实现
后端:Token 服务
csharp
public interface IFormTokenService
{
Task<string> GenerateTokenAsync(Guid userId, TimeSpan? expiry = null);
Task<bool> ValidateAndConsumeTokenAsync(string token, Guid userId);
}
public class FormTokenService : IFormTokenService
{
private readonly IDatabase _redis;
public async Task<string> GenerateTokenAsync(Guid userId, TimeSpan? expiry = null)
{
var token = $"{userId}_{Guid.NewGuid():N}";
var ttl = expiry ?? TimeSpan.FromMinutes(5);
await _redis.StringSetAsync(
$"form_token:{token}",
userId.ToString(),
ttl);
return token;
}
public async Task<bool> ValidateAndConsumeTokenAsync(string token, Guid userId)
{
var key = $"form_token:{token}";
var storedUserId = await _redis.StringGetAsync(key);
if (storedUserId.IsNullOrEmpty || storedUserId != userId.ToString())
{
return false;
}
// 原子性删除
return await _redis.KeyDeleteAsync(key);
}
}控制器
csharp
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class OrdersController : ControllerBase
{
private readonly IFormTokenService _tokenService;
[HttpGet("token")]
public async Task<ActionResult<string>> GetFormToken()
{
var userId = User.GetUserId();
var token = await _tokenService.GenerateTokenAsync(userId);
return Ok(new { token });
}
[HttpPost]
public async Task<ActionResult<Order>> CreateOrder(
[FromBody] CreateOrderRequest request,
[FromHeader(Name = "X-Form-Token")] string formToken)
{
var userId = User.GetUserId();
// 验证 Token
if (!await _tokenService.ValidateAndConsumeTokenAsync(formToken, userId))
{
return StatusCode(423, new { error = "Invalid or expired form token" });
}
// 生成幂等键
var idempotencyKey = $"{userId}_{request.ProductId}_{DateTime.UtcNow:yyyyMMdd}";
try
{
var order = await _orderService.CreateOrderAsync(request, idempotencyKey);
return CreatedAtAction(nameof(GetOrder), new { id = order.Id }, order);
}
catch (DuplicateOrderException)
{
return Conflict(new { error = "Duplicate order detected" });
}
}
}React 前端
tsx
import { useEffect, useState } from 'react';
function OrderForm() {
const [formToken, setFormToken] = useState<string>('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState({ productId: '', quantity: 1 });
// 获取表单 Token
useEffect(() => {
fetch('/api/orders/token', {
headers: { 'Authorization': `Bearer ${getAuthToken()}` }
})
.then(res => res.json())
.then(data => setFormToken(data.token));
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (isSubmitting || !formToken) return;
setIsSubmitting(true);
try {
const response = await fetch('/api/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${getAuthToken()}`,
'X-Form-Token': formToken
},
body: JSON.stringify(formData)
});
if (response.status === 423) {
alert('表单已提交或令牌已过期');
return;
}
if (response.ok) {
const order = await response.json();
window.location.href = `/orders/${order.id}/success`;
}
} finally {
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit}>
<select
value={formData.productId}
onChange={e => setFormData({...formData, productId: e.target.value})}
>
<option value="">选择商品</option>
{/* 商品选项 */}
</select>
<input
type="number"
value={formData.quantity}
onChange={e => setFormData({...formData, quantity: parseInt(e.target.value)})}
min="1"
/>
<button type="submit" disabled={isSubmitting || !formToken}>
{isSubmitting ? '提交中...' : '提交订单'}
</button>
</form>
);
}防止页面刷新的技巧
1. Session Storage 标记
javascript
// 提交前设置标记
sessionStorage.setItem('orderSubmitted', 'true');
// 页面加载时检查
window.addEventListener('load', () => {
if (sessionStorage.getItem('orderSubmitted') === 'true') {
// 清除标记并重定向
sessionStorage.removeItem('orderSubmitted');
window.location.replace('/orders/success');
}
});2. History API
javascript
// 提交成功后替换历史记录
window.history.replaceState({}, '', '/orders/success');
// 这样后退不会回到表单页面监控与统计
csharp
public class FormSubmissionMetrics
{
private readonly Counter<long> _duplicateSubmissions;
public void RecordDuplicateSubmission(string endpoint, string userId)
{
_duplicateSubmissions.Add(1,
new KeyValuePair<string, object?>("endpoint", endpoint),
new KeyValuePair<string, object?>("user_id", userId));
}
}
// 在中间件中检测
public class DuplicateSubmissionDetector
{
public async Task InvokeAsync(HttpContext context)
{
var token = context.Request.Headers["X-Form-Token"].FirstOrDefault();
if (!string.IsNullOrEmpty(token))
{
var isDuplicate = await CheckDuplicateAsync(token);
if (isDuplicate)
{
_metrics.RecordDuplicateSubmission(
context.Request.Path,
context.User.GetUserId());
}
}
await _next(context);
}
}最佳实践总结
✅ DO
- 前后端双重防护:前端防误操作,后端防重复
- 使用 Token 机制:每个表单有唯一 Token
- 实施 PRG 模式:提交后重定向
- 禁用提交按钮:提供视觉反馈
- 设置合理过期时间:Token 5-10 分钟
- 记录重复提交:用于分析和优化
❌ DON'T
- 不要仅依赖前端:用户可以绕过
- 不要无限期 Token:设置过期时间
- 不要忽略用户体验:提供明确的加载状态
- 不要静默失败:告知用户重复提交
总结
前端用户误操作防护需要多层策略:
- 前端层:按钮禁用、防抖、加载状态
- 传输层:Form Token、幂等键
- 应用层:PRG 模式、唯一索引
- 数据层:数据库约束
通过组合使用这些技术,可以有效防止重复提交,提升用户体验和数据质量。