Skip to content

前端用户误操作防护 ​

概述 ​

前端用户误操作是导致重复提交的常见原因,包括:

  • 重复点击提交按钮
  • 页面刷新导致表单重提交
  • 浏览器后退后再次提交
  • 网络延迟时用户多次点击

常见场景 ​

场景 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 ​

  1. 前后端双重防护:前端防误操作,后端防重复
  2. 使用 Token 机制:每个表单有唯一 Token
  3. 实施 PRG 模式:提交后重定向
  4. 禁用提交按钮:提供视觉反馈
  5. 设置合理过期时间:Token 5-10 分钟
  6. 记录重复提交:用于分析和优化

❌ DON'T ​

  1. 不要仅依赖前端:用户可以绕过
  2. 不要无限期 Token:设置过期时间
  3. 不要忽略用户体验:提供明确的加载状态
  4. 不要静默失败:告知用户重复提交

总结 ​

前端用户误操作防护需要多层策略:

  1. 前端层:按钮禁用、防抖、加载状态
  2. 传输层:Form Token、幂等键
  3. 应用层:PRG 模式、唯一索引
  4. 数据层:数据库约束

通过组合使用这些技术,可以有效防止重复提交,提升用户体验和数据质量。

Released under the MIT License.