PAYATHON 2026

如何存储和跟踪多步骤 checkout flow 数据?

支付小周

结论

如果要跟踪用户放弃结账的购物车,应从结账开始就把数据持久化到数据库,但不要直接写入正式的 orders 表。

可以采用下面的设计:

  • 用 carts 和 cart_items 保存购物车内容。
  • 用 checkout_drafts 保存结账进度及用户填写的信息。
  • Session 只保存 cart_id、checkout_token 等短标识。
  • 用户确认并支付时,通过数据库事务创建正式订单。
  • 根据 current_step、updated_at 和状态字段判断购物车是否已被放弃。

这样既能记录用户在哪一步离开,也不会让大量未完成的数据进入正式订单。小型原型如果不需要恢复结账流程或分析用户流失,仅使用服务器端 Session 也可以。客户端 Cookie 则不适合保存完整地址、邮箱等结账信息。

为什么不建议直接写入 orders

订单通常表示用户已经明确提交的商业记录。后台管理、库存、财务、支付、通知和统计系统都可能读取订单数据。

如果用户每次进入 checkout 都会创建一张订单,容易带来这些问题:

  • 订单列表中会出现大量未完成记录。
  • “订单数”、“转化率”和”销售额”等统计指标容易失真。
  • 支付失败、返回修改和重复提交会让状态管理变得复杂。
  • 其他系统可能把草稿误认为真实订单。
  • 清理废弃数据时,很难判断哪些记录已经涉及财务或审计。

也可以在 orders 中同时保存草稿和正式订单,例如设置 status = 'draft'。不过,这要求所有查询都正确过滤状态。对于刚开始设计的系统,把 checkout draft 和正式订单分开通常更清楚。

推荐的数据结构

下面是一种简单的关系模型:

CREATE TABLE carts (
    id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    customer_id BIGINT UNSIGNED NULL,
    session_token CHAR(64) NOT NULL UNIQUE,
    status VARCHAR(20) NOT NULL DEFAULT 'active',
    currency CHAR(3) NOT NULL,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    expires_at DATETIME NULL
);

CREATE TABLE cart_items (
    id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    cart_id BIGINT UNSIGNED NOT NULL,
    product_id BIGINT UNSIGNED NOT NULL,
    product_name VARCHAR(255) NOT NULL,
    unit_price DECIMAL(12, 2) NOT NULL,
    quantity INT UNSIGNED NOT NULL,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    CONSTRAINT fk_cart_items_cart
        FOREIGN KEY (cart_id) REFERENCES carts(id)
);

CREATE TABLE checkout_drafts (
    id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    cart_id BIGINT UNSIGNED NOT NULL UNIQUE,
    current_step TINYINT UNSIGNED NOT NULL DEFAULT 1,
    email VARCHAR(320) NULL,
    customer_name VARCHAR(255) NULL,
    shipping_country CHAR(2) NULL,
    shipping_option VARCHAR(100) NULL,
    shipping_address_json JSON NULL,
    billing_option VARCHAR(100) NULL,
    billing_address_json JSON NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'in_progress',
    version INT UNSIGNED NOT NULL DEFAULT 1,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    completed_at DATETIME NULL,
    CONSTRAINT fk_checkout_drafts_cart
        FOREIGN KEY (cart_id) REFERENCES carts(id)
);

能否使用 JSON 类型取决于具体的数据库及其版本。如果数据库不支持,可以换成 TEXT,也可以把地址拆成结构化字段。国家、邮编等经常用于搜索、统计或筛选的数据,更适合使用独立字段。只用于恢复表单的数据可以放在 JSON 中。

正式订单应保存用户提交时的商品、价格和地址快照。不要在之后继续读取可能已经变化的商品表或用户地址表:

CREATE TABLE orders (
    id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    order_number VARCHAR(50) NOT NULL UNIQUE,
    customer_id BIGINT UNSIGNED NULL,
    email VARCHAR(320) NOT NULL,
    currency CHAR(3) NOT NULL,
    subtotal DECIMAL(12, 2) NOT NULL,
    shipping_amount DECIMAL(12, 2) NOT NULL,
    tax_amount DECIMAL(12, 2) NOT NULL,
    total_amount DECIMAL(12, 2) NOT NULL,
    shipping_address_json JSON NOT NULL,
    billing_address_json JSON NOT NULL,
    status VARCHAR(30) NOT NULL,
    created_at DATETIME NOT NULL
);

还需要单独建立 order_items 表,保存成交时的商品名称、SKU、单价、数量和税额快照。

每一步如何保存

用户完成一个步骤后,先验证输入,再更新 checkout draft。不要等到最后一页才一次性持久化全部内容。

例如,第一步可以执行类似的更新:

$data = [
    'customer_name'   => $validatedName,
    'email'           => $validatedEmail,
    'shipping_country'=> $validatedCountry,
    'current_step'    => 2,
    'updated_at'      => date('Y-m-d H:i:s'),
];

$db->table('checkout_drafts')
   ->where('cart_id', $cartId)
   ->update($data);

CodeIgniter 不同主版本的数据库 API 写法有所不同。上面的代码只用于说明保存流程,实际调用应以项目使用的版本为准。

Session 中只需保留无法被猜测的标识:

$session->set([
    'cart_id'        => $cartId,
    'checkout_token' => $checkoutToken,
]);

每次打开 checkout 页面时,应执行以下操作:

  1. 从 Session 或安全 Cookie 中取得 token。
  2. 根据 token 查询购物车。
  3. 检查购物车状态是否仍为 active,并确认它没有过期。
  4. 读取 checkout_drafts.current_step 和已经保存的字段。
  5. 重新验证价格、库存、配送方式和税费。
  6. 让用户返回尚未完成的步骤。

不要只凭客户端提交的 cart_id 查询数据。否则,攻击者可能修改 ID,访问其他用户的购物车。应使用足够随机的 token,或者同时验证当前登录用户是否拥有该购物车。

最终提交应使用事务

Review 页面只负责展示数据。正式创建订单应发生在用户点击确认之后。创建订单、复制商品项以及修改购物车状态,最好放在同一个事务中:

$db->transStart();

$cart = $db->table('carts')
    ->where('id', $cartId)
    ->where('status', 'active')
    ->get()
    ->getRowArray();

$draft = $db->table('checkout_drafts')
    ->where('cart_id', $cartId)
    ->where('status', 'in_progress')
    ->get()
    ->getRowArray();

$items = $db->table('cart_items')
    ->where('cart_id', $cartId)
    ->get()
    ->getResultArray();

if (!$cart || !$draft || !$items) {
    throw new RuntimeException('Checkout data is incomplete.');
}

/*
 * 在服务器端重新查询商品、库存、配送规则和税率,
 * 然后重新计算 subtotal、shipping、tax 和 total。
 */
$totals = calculateCheckoutTotals($items, $draft);

$orderData = [
    'order_number'          => generateOrderNumber(),
    'email'                 => $draft['email'],
    'currency'              => $cart['currency'],
    'subtotal'              => $totals['subtotal'],
    'shipping_amount'       => $totals['shipping'],
    'tax_amount'            => $totals['tax'],
    'total_amount'          => $totals['total'],
    'shipping_address_json' => $draft['shipping_address_json'],
    'billing_address_json'  => $draft['billing_address_json'],
    'status'                => 'pending_payment',
    'created_at'            => date('Y-m-d H:i:s'),
];

$db->table('orders')->insert($orderData);
$orderId = $db->insertID();

foreach ($items as $item) {
    $db->table('order_items')->insert([
        'order_id'     => $orderId,
        'product_id'   => $item['product_id'],
        'product_name' => $item['product_name'],
        'unit_price'   => $item['unit_price'],
        'quantity'     => $item['quantity'],
    ]);
}

$db->table('checkout_drafts')
    ->where('cart_id', $cartId)
    ->update([
        'status'       => 'completed',
        'completed_at' => date('Y-m-d H:i:s'),
    ]);

$db->table('carts')
    ->where('id', $cartId)
    ->update([
        'status'     => 'converted',
        'updated_at' => date('Y-m-d H:i:s'),
    ]);

$db->transComplete();

if ($db->transStatus() === false) {
    throw new RuntimeException('Order creation failed.');
}

金额必须由服务器重新计算。不要信任 Cookie、Session 表单数据或隐藏字段传回的价格和总金额。

如何判断 abandoned cart

不要在用户关闭浏览器时立即把购物车标记为 abandoned。页面关闭、断网、设备切换和浏览器崩溃都无法可靠检测。

常见做法是把”超过一段时间没有更新”定义为放弃:

SELECT
    c.id,
    d.email,
    d.current_step,
    c.updated_at
FROM carts AS c
JOIN checkout_drafts AS d ON d.cart_id = c.id
WHERE c.status = 'active'
  AND d.status = 'in_progress'
  AND c.updated_at < :abandoned_before;

超时时间由业务决定,没有统一标准。可以结合商品类型、平均结账时间和召回策略,将时间设为几小时或几天。进行分析时,应明确记录所采用的定义。

如果需要更细的分析,还可以记录结账事件:

CREATE TABLE checkout_events (
    id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    cart_id BIGINT UNSIGNED NOT NULL,
    event_type VARCHAR(50) NOT NULL,
    step_number TINYINT UNSIGNED NULL,
    created_at DATETIME NOT NULL,
    CONSTRAINT fk_checkout_events_cart
        FOREIGN KEY (cart_id) REFERENCES carts(id)
);

事件可以包括:

  • checkout_started
  • step_completed
  • validation_failed
  • checkout_resumed
  • order_submitted
  • payment_failed
  • checkout_completed

current_step 用于恢复流程,事件表用于漏斗分析,两者用途不同。

仅使用 Session

适合原型、访问量较小,并且不需要跨设备恢复或分析流失的场景。

这种方式实现简单,也不会产生草稿数据。但 Session 过期后无法恢复流程,也很难可靠地统计 abandoned cart。即使 Session 使用数据库驱动,它通常仍是临时基础设施,不适合保存长期业务记录。

使用 checkout draft

这种方式适合正式的 ecommerce 系统。

它支持恢复结账流程、分析用户退出步骤,也方便以后发送召回邮件。相应地,系统需要处理数据过期、隐私、并发更新和清理问题。

使用 orders.status = 'draft'

这种设计在技术上可行,尤其是系统已经把订单设计为完整状态机时。不过,必须确保报表、库存、通知和支付逻辑只处理对应状态。对于小型项目,独立的 draft 表通常更不容易被误用。

不推荐这样做。Cookie 容量有限,每次请求都会携带相关 Cookie,而且客户端能够查看或篡改其中的内容。即使 Cookie 已签名,也只能防止篡改,不代表内容经过加密。

姓名、邮箱和地址等个人信息应尽量保存在服务器端。Cookie 中只保存随机 token,并设置适当的属性:

Secure
HttpOnly
SameSite=Lax

具体采用哪种 SameSite 策略,需要结合支付跳转和跨站回调方式判断,不能直接机械地设置为最严格的值。

还需要注意的几个问题

不要存储敏感支付数据

不要把银行卡号、CVV 或完整支付凭据写入 Session、checkout draft 或日志。应保存支付服务商生成的 token 或 payment intent ID,并遵守支付服务商的安全要求。

每一步都必须重新验证

前端的步骤限制只能改善用户体验,不能代替服务器端校验。用户可能跳过页面,也可能直接构造请求。提交订单前,应再次检查所有必填字段以及各步骤之间的依赖关系。

服务器也不能直接接受客户端提交的任意 current_step 值。只有相关数据通过验证并成功保存后,服务器才能推进状态。

防止重复下单

用户可能双击提交按钮,也可能在网络超时后重试。最终提交接口应支持幂等性,例如为每次 checkout 分配唯一的提交 key:

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

再次收到相同 key 时,应返回已经创建的订单,而不是再创建一张订单。

处理多标签页和并发修改

用户可能同时打开两个 checkout 页面。可以使用 version 字段实现乐观锁:

UPDATE checkout_drafts
SET
    shipping_option = :shipping_option,
    current_step = 3,
    version = version + 1,
    updated_at = :updated_at
WHERE cart_id = :cart_id
  AND version = :expected_version;

如果受影响的行数为零,说明另一个请求已经修改了数据。此时应重新加载数据,不能静默覆盖。

制定数据保留策略

未完成的草稿不应永久保存。系统可以定期执行这些操作:

  • 把超时记录标记为 abandoned。
  • 匿名化或删除超过保留期限的个人信息。
  • 删除没有业务价值的旧事件。
  • 保留聚合后的漏斗统计。

具体保留期限应根据业务需要、向用户告知的内容以及适用的隐私法规确定。

实际落地建议

对于四步结账流程,可以采用下面的最小方案:

  1. 用户添加第一件商品时,创建 carts 和 cart_items。
  2. 用户进入 checkout 时,创建 checkout_drafts。
  3. 用户每完成一步,都验证并更新 draft,同时推进 current_step。
  4. Session 只保存随机 checkout token。
  5. Review 页面从数据库重新读取数据并计算结果。
  6. 用户确认后,在事务中创建 orders 和 order_items。
  7. 把 cart 和 draft 标记为已转换,不要立即物理删除。
  8. 定时把长期没有更新的 draft 标记为 abandoned,并按保留策略清理。

购物车和结账草稿属于临时业务数据,订单则是用户确认后生成的正式快照。把两者分开,既能保留 abandoned cart 的分析能力,也能避免未完成的数据干扰正式订单。

备注:内容仅供参考。