PAYATHON 2026

CodeIgniter 如何防止返回上一页导致重复提交?

支付老李

结论

浏览器的“后退”按钮无法可靠禁用,防重复提交也不能只靠前端。更稳妥的处理方式是:

  1. 使用 POST/Redirect/GET(PRG),提交成功后立即跳转到结果页。
  2. 为每次表单提交生成一次性令牌,服务端校验后立即将其作废。
  3. 在数据库中添加唯一约束,防止并发请求造成重复写入。
  4. 支付业务使用支付平台的交易号或幂等键去重。

这样即使用户后退、刷新页面或双击提交按钮,服务端也只会完成一次实际写入。

timestamp 方法为什么没有生效

如果表单中的 timestamp 和 session 中的 timestamp 是在展示同一份表单时生成并保存的,这两个值本来就应该相同:

$token = (string) time();

$this->session->set_userdata('form_token', $token);
<input type="hidden" name="form_token" value="<?= html_escape($token) ?>">

用户提交后,两个值相同,说明请求携带了服务端签发的令牌。服务端应允许第一次提交,并立即删除 session 中的令牌。

原方案很可能写反了判断条件:

// 不合理:令牌不相同反而允许写入
if ($postedToken !== $sessionToken) {
    // insert
}

正确逻辑应该是:

if ($postedToken === $sessionToken) {
    // 消耗令牌,然后执行一次写入
}

如果表单和 session 分别调用 time() 生成 timestamp,两个值可能相同,也可能因为跨秒而不同,这样的比较没有可靠意义。timestamp 还容易碰撞,不适合作为一次性提交令牌。这里应使用安全的随机值。

使用一次性表单令牌

下面以 CodeIgniter 3 的常见写法为例。CodeIgniter 4 需要改用相应的 session、request 和 redirect API,但处理原则相同。

展示表单

public function details()
{
    $token = bin2hex(random_bytes(32));

    $this->session->set_userdata('payment_form_token', $token);

    $data = [
        'form_token' => $token,
    ];

    $this->load->view('payment/details', $data);
}

在表单中携带这个令牌:

<form method="post" action="<?= site_url('payment/submit') ?>">
    <input
        type="hidden"
        name="form_token"
        value="<?= html_escape($form_token) ?>"
    >

    <!-- 其他字段 -->

    <button type="submit">确认支付</button>
</form>

接收并处理提交

public function submit()
{
    $postedToken = (string) $this->input->post('form_token', true);
    $sessionToken = (string) $this->session->userdata(
        'payment_form_token'
    );

    if (
        $postedToken === '' ||
        $sessionToken === '' ||
        !hash_equals($sessionToken, $postedToken)
    ) {
        show_error('该表单已提交或已经失效,请勿重复操作。', 409);
        return;
    }

    // 先消耗令牌,避免同一个令牌再次使用。
    $this->session->unset_userdata('payment_form_token');

    try {
        $orderId = $this->order_model->create_order([
            'user_id' => $this->session->userdata('user_id'),
            'amount'  => $this->input->post('amount'),
        ]);
    } catch (Throwable $e) {
        log_message('error', $e->getMessage());
        show_error('订单处理失败,请查询订单状态后再试。', 500);
        return;
    }

    // PRG:POST 完成后跳转到一个 GET 页面。
    redirect('payment/result/' . $orderId, 'location', 303);
}

令牌的状态变化如下:

展示表单 → 生成并保存令牌
第一次提交 → 校验成功 → 删除令牌 → 写入
再次提交 → session 中已无令牌 → 拒绝写入

不要使用 redirect($this->agent->referrer()) 处理重复提交。Referer 请求头可能不存在或被修改,也可能仍指向可以提交的页面。收到重复请求时,应跳转到固定的订单结果页,或者直接返回 409 Conflict。

提交后使用 PRG 模式

如果 POST 处理完成后直接渲染成功页面,用户刷新时,浏览器可能询问是否重新提交表单。PRG 模式可以避免这种情况:

GET /payment/details
        ↓
POST /payment/submit
        ↓
303 Redirect
        ↓
GET /payment/result/123

提交接口只处理 POST,成功后重定向到只读结果页:

public function result($orderId)
{
    $order = $this->order_model->find_for_user(
        $orderId,
        $this->session->userdata('user_id')
    );

    if (!$order) {
        show_404();
        return;
    }

    $this->load->view('payment/result', [
        'order' => $order,
    ]);
}

用户在结果页点击后退,最多只会回到旧表单。由于一次性令牌已经失效,再次提交也不会产生第二条记录。

数据库必须提供最终保护

session 令牌可以拦截普通的重复提交,但不能完全解决并发问题。例如,用户双击按钮后,两个请求可能几乎同时到达服务器,并在令牌被删除前先后通过校验。

因此,还要为业务中的唯一标识建立数据库唯一约束。例如,每次结算时先生成一个 idempotency_key:

ALTER TABLE orders
ADD COLUMN idempotency_key VARCHAR(64) NOT NULL,
ADD UNIQUE KEY uq_orders_idempotency_key (idempotency_key);

写入订单时带上这个值:

$data = [
    'user_id'         => $userId,
    'amount'          => $amount,
    'idempotency_key' => $postedToken,
];

$this->db->insert('orders', $data);

如果两个请求同时插入相同的 idempotency_key,数据库只会允许其中一个成功。应用层应捕获重复键错误,查询并返回已经创建的订单,而不是再次创建订单。

涉及多张表时,应将相关写入放在同一个事务中:

$this->db->trans_start();

$this->db->insert('orders', $orderData);
$orderId = $this->db->insert_id();

$this->db->insert('order_items', [
    'order_id'   => $orderId,
    'product_id' => $productId,
    'quantity'   => $quantity,
]);

$this->db->trans_complete();

if ($this->db->trans_status() === false) {
    throw new RuntimeException('订单写入失败');
}

唯一约束使用哪个字段,要根据实际业务确定。不能简单地将 user_id 或金额设为唯一,否则可能阻止用户正常重复购买。

支付回调必须单独做幂等处理

支付场景不能只依赖浏览器提交。支付平台可能重复发送回调,用户也可能关闭页面或遇到断网,因此支付状态应以服务端查询结果或支付平台回调为准。

需要保存支付平台提供的唯一交易号,并为它建立数据库唯一约束:

ALTER TABLE payments
ADD UNIQUE KEY uq_payments_provider_transaction_id
(provider_transaction_id);

处理回调时:

public function callback()
{
    $payload = $this->input->raw_input_stream;

    // 具体签名验证方式取决于支付平台。
    if (!$this->payment_gateway->verify_signature($payload)) {
        show_error('Invalid signature', 400);
        return;
    }

    $event = $this->payment_gateway->parse_callback($payload);
    $transactionId = $event->transaction_id;

    $existing = $this->payment_model
        ->find_by_transaction_id($transactionId);

    if ($existing) {
        // 已处理过,直接返回成功,避免平台继续重试。
        $this->output->set_status_header(200);
        return;
    }

    $this->db->trans_start();

    $this->payment_model->create([
        'order_id'                => $event->order_id,
        'provider_transaction_id' => $transactionId,
        'amount'                  => $event->amount,
        'status'                  => 'paid',
    ]);

    $this->order_model->mark_as_paid($event->order_id);

    $this->db->trans_complete();

    if ($this->db->trans_status() === false) {
        show_error('Payment processing failed', 500);
        return;
    }

    $this->output->set_status_header(200);
}

处理回调时还要核对订单号、支付金额、币种和当前订单状态,不能只根据前端传来的“支付成功”标记更新数据库。

其他注意事项

  • CSRF token 用于防止跨站请求伪造,不一定等同于业务幂等键。它是否随请求轮换取决于 CodeIgniter 的版本和配置,不能默认将其用作订单去重机制。
  • JavaScript 禁用提交按钮只能改善用户体验,不能代替服务端校验:
<script>
document.querySelector('form').addEventListener('submit', function () {
    const button = this.querySelector('button[type="submit"]');
    button.disabled = true;
    button.textContent = '处理中…';
});
</script>
  • 不要通过隐藏字段保存价格、用户 ID 或支付状态。隐藏字段可以被修改,关键数据必须来自数据库或可信的支付回调。
  • 浏览器缓存头可以影响页面的缓存方式,但无法保证用户不能后退,也不能可靠地防止重复写入。
  • 令牌应使用 random_bytes() 生成,并通过 hash_equals() 比较,不要使用可预测的 timestamp。
  • 如果允许同一用户同时打开多个支付页面,不要只在 session 中保存一个令牌,否则后打开的页面会覆盖前一个令牌。可以分别保存多个令牌,或者在数据库中记录幂等键及其状态。

可靠的组合方式是:一次性令牌拦截普通重复提交,PRG 避免刷新时重发请求,数据库唯一约束处理并发,支付交易号保证支付回调的幂等性。

备注:内容仅供参考。