如何存储和跟踪多步骤 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 页面时,应执行以下操作:
- 从 Session 或安全 Cookie 中取得 token。
- 根据 token 查询购物车。
- 检查购物车状态是否仍为
active,并确认它没有过期。 - 读取
checkout_drafts.current_step和已经保存的字段。 - 重新验证价格、库存、配送方式和税费。
- 让用户返回尚未完成的步骤。
不要只凭客户端提交的 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_startedstep_completedvalidation_failedcheckout_resumedorder_submittedpayment_failedcheckout_completed
current_step 用于恢复流程,事件表用于漏斗分析,两者用途不同。
Session、数据库和 Cookie 的取舍
仅使用 Session
适合原型、访问量较小,并且不需要跨设备恢复或分析流失的场景。
这种方式实现简单,也不会产生草稿数据。但 Session 过期后无法恢复流程,也很难可靠地统计 abandoned cart。即使 Session 使用数据库驱动,它通常仍是临时基础设施,不适合保存长期业务记录。
使用 checkout draft
这种方式适合正式的 ecommerce 系统。
它支持恢复结账流程、分析用户退出步骤,也方便以后发送召回邮件。相应地,系统需要处理数据过期、隐私、并发更新和清理问题。
使用 orders.status = 'draft'
这种设计在技术上可行,尤其是系统已经把订单设计为完整状态机时。不过,必须确保报表、库存、通知和支付逻辑只处理对应状态。对于小型项目,独立的 draft 表通常更不容易被误用。
把全部内容放入 Cookie
不推荐这样做。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。 - 匿名化或删除超过保留期限的个人信息。
- 删除没有业务价值的旧事件。
- 保留聚合后的漏斗统计。
具体保留期限应根据业务需要、向用户告知的内容以及适用的隐私法规确定。
实际落地建议
对于四步结账流程,可以采用下面的最小方案:
- 用户添加第一件商品时,创建
carts和cart_items。 - 用户进入 checkout 时,创建
checkout_drafts。 - 用户每完成一步,都验证并更新 draft,同时推进
current_step。 - Session 只保存随机 checkout token。
- Review 页面从数据库重新读取数据并计算结果。
- 用户确认后,在事务中创建
orders和order_items。 - 把 cart 和 draft 标记为已转换,不要立即物理删除。
- 定时把长期没有更新的 draft 标记为 abandoned,并按保留策略清理。
购物车和结账草稿属于临时业务数据,订单则是用户确认后生成的正式快照。把两者分开,既能保留 abandoned cart 的分析能力,也能避免未完成的数据干扰正式订单。
备注:内容仅供参考。
