<form id="myForm" method="post" action="/submit">
<input type="text" name="content" required>
<button type="submit" id="submitBtn">提交</button>
</form>
<script>
document.getElementById('myForm').addEventListener('submit', function(e) {
const btn = document.getElementById('submitBtn');
if (btn.dataset.submitted === 'true') {
e.preventDefault();
alert('請勿重複提交,正在處理中...');
return;
}
btn.dataset.submitted = 'true';
btn.disabled = true;
btn.textContent = '處理中...';
});
</script>
<script>
let formSubmitted = false;
function checkForm(form) {
if (formSubmitted) {
alert('表單已提交,請稍候...');
return false;
}
formSubmitted = true;
return true;
}
</script>
<form onsubmit="return checkForm(this)" method="post">...</form>
<?php
session_start();
// 產生表單時
$_SESSION['form_token'] = bin2hex(random_bytes(32));
$token = $_SESSION['form_token'];
?>
<form method="post">
<input type="hidden" name="form_token" value="<?= $token ?>">
<input type="text" name="content">
<button type="submit">提交</button>
</form>
<?php
// 處理提交
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$submittedToken = $_POST['form_token'] ?? '';
$sessionToken = $_SESSION['form_token'] ?? '';
// 驗證 Token 存在且匹配
if (empty($submittedToken) || !hash_equals($sessionToken, $submittedToken)) {
die('重複提交或 Token 無效');
}
// ✅ 驗證通過:處理業務邏輯
// ...
// 🔑 關鍵:處理成功後銷毀 Token,防止重整重複提交
unset($_SESSION['form_token']);
// 🔄 PRG 模式:重導向至成功頁,徹底解決 F5 重整問題
header('Location: /success');
exit;
}
<?php
// 訂單表:user_id + order_no 唯一索引
// CREATE UNIQUE INDEX idx_user_order ON orders(user_id, order_no);
try {
$pdo->beginTransaction();
$stmt = $pdo->prepare('INSERT INTO orders (user_id, order_no, amount) VALUES (?, ?, ?)');
$stmt->execute([$userId, $orderNo, $amount]);
$pdo->commit();
} catch (PDOException $e) {
$pdo->rollBack();
if ($e->getCode() === '23000') { // 唯一約束違反
die('訂單已存在,請勿重複下單');
}
throw $e;
}
<?php
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$lockKey = "form_lock:{$userId}:{$action}";
$lockVal = microtime(true) . '_' . random_bytes(8);
// 嘗試加鎖(NX=只在不存在時設定,EX=過期秒數)
$acquired = $redis->set($lockKey, $lockVal, ['nx', 'ex' => 10]);
if (!$acquired) {
die('操作過於頻繁,請稍後再試');
}
try {
// 執行業務邏輯
// ...
} finally {
// 釋放鎖(Lua 腳本保證原子性)
$lua = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
$redis->eval($lua, 1, $lockKey, $lockVal);
}
處理完 POST 請求後,必須 302/303 重導向至 GET 頁面
<?php
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// 1. 驗證 Token
// 2. 處理業務
// 3. 設定成功訊息到 Session
$_SESSION['flash_success'] = '提交成功!';
// 4. 重導向(瀏覽器記錄變為 GET,F5 不會重新 POST)
header('Location: /form-success');
exit;
}
// GET 頁面顯示成功訊息
if (isset($_SESSION['flash_success'])) {
echo '<div class="alert success">' . $_SESSION['flash_success'] . '</div>';
unset($_SESSION['flash_success']);
}
| 層級 | 機制 | 防禦對象 | 備註 |
|---|---|---|---|
| 前端 | 按鈕禁用 + JS 旗標 | 誤操作、連點 | 可繞過,非安全防線 |
| 後端 | Session Token + PRG | 重整、重複 POST | 核心防線,單機有效 |
| 業務/資料層 | 唯一索引 / Redis 鎖 | 高併發、分散式、惡意攻擊 | 根本保障,冪等性設計 |
<?php
// 支援瀏覽器「返回」鍵保留表單資料
session_cache_limiter('private, must-revalidate'); // 必須在 session_start() 前
session_start();
// 或使用 Header
header('Cache-Control: private, must-revalidate');
header('Pragma: no-cache');