PAYATHON 2026

如何设计安全高效且支持 guest 的 Shopping cart?

支付阿杰

结论

建议把购物车保存在服务端,客户端只保存随机生成的购物车标识。

  • guest:Cookie 中只保存不可预测的 cart_token,商品明细放在数据库或 Redis。
  • member:购物车与 user_id 绑定。用户登录时,合并 guest cart 和 member cart。
  • 购物车中的价格只作为展示快照。结算时必须根据商品、库存和促销系统的数据重新计算。
  • 超时可通过 expires_at、访问时惰性过期和定时批量清理来处理,无需为每次操作单独创建数据库清理任务。
  • 所有增删改操作都必须经过服务端校验,并配合 CSRF 防护、限流和数据库约束。

Option 1 的思路基本正确,但不应直接把 PHPSESSID 当作购物车的业务标识,也不能信任购物车表中的 unit_price。Option 2 如果把完整购物车放进普通 Cookie,虽然能减少数据库访问,却会带来数据篡改、容量限制、信息暴露和同步困难等问题,通常得不偿失。

推荐的数据模型

购物车和商品行应分成两张表:

CREATE TABLE cart (
    id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    token CHAR(64) NOT NULL,
    user_id BIGINT UNSIGNED NULL,
    status VARCHAR(16) NOT NULL DEFAULT 'active',
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    expires_at DATETIME NOT NULL,
    PRIMARY KEY (id),
    UNIQUE KEY uk_cart_token (token),
    KEY idx_cart_user_status (user_id, status),
    KEY idx_cart_expires (expires_at)
) ENGINE=InnoDB;

CREATE TABLE cart_item (
    cart_id BIGINT UNSIGNED NOT NULL,
    product_id BIGINT UNSIGNED NOT NULL,
    quantity INT UNSIGNED NOT NULL,
    unit_price_snapshot DECIMAL(12, 2) NULL,
    updated_at DATETIME NOT NULL,
    PRIMARY KEY (cart_id, product_id),
    KEY idx_cart_item_product (product_id),
    CONSTRAINT fk_cart_item_cart
        FOREIGN KEY (cart_id) REFERENCES cart(id)
        ON DELETE CASCADE
) ENGINE=InnoDB;

各字段和约束的用途如下:

  • token 是购物车标识,不是 PHP Session ID。
  • user_id 为 NULL 时,表示这是 guest cart。
  • unit_price_snapshot 只用于显示用户添加商品时看到的价格。
  • status 可使用 active、converted、expired 等有限状态。
  • expires_at 用于判断购物车是否已经失效。
  • (cart_id, product_id) 唯一约束可以防止同一商品出现多行记录。

如果商品还有 SKU、规格或销售单位,应保存 sku_id,不能只保存 product_id。

guest cart 的标识方式

用户第一次使用购物车时,由服务端生成高强度随机 token:

function generateCartToken()
{
    if (function_exists('random_bytes')) {
        return bin2hex(random_bytes(32));
    }

    if (function_exists('openssl_random_pseudo_bytes')) {
        $strong = false;
        $bytes = openssl_random_pseudo_bytes(32, $strong);

        if ($bytes !== false && $strong) {
            return bin2hex($bytes);
        }
    }

    throw new RuntimeException('No cryptographically secure random source available');
}

这里需要注意 PHP 5 的具体小版本,因为 random_bytes() 只在较新的 PHP 版本中可用。旧环境可以使用 openssl_random_pseudo_bytes(),但不能退回到 rand()、时间戳或递增 ID。

Cookie 中只保存 token:

$cookie = new CHttpCookie('cart_token', $token);
$cookie->httpOnly = true;
$cookie->secure = Yii::app()->request->isSecureConnection;
$cookie->path = '/';
$cookie->expire = time() + 30 * 60;

Yii::app()->request->cookies['cart_token'] = $cookie;

生产环境应全站使用 HTTPS,并启用 Secure、HttpOnly 和适合业务场景的 SameSite 策略。较老的 PHP 或 Yii 版本可能没有直接设置 SameSite 的 API,此时需要在 Web 服务器、响应头或框架扩展层处理。

以下数据不能放进 Cookie 后直接采用:

  • 商品价格
  • 折扣金额
  • 库存状态
  • 用户身份
  • 订单总额
  • 是否已付款

用户看到随机 token 本身没有关系。重点是 token 必须难以猜测,而且不能出现在 URL、日志或第三方跳转参数中。

添加和更新商品

客户端只提交商品或 SKU 标识以及期望数量,服务端负责检查:

  1. 商品是否存在并且允许销售。
  2. 数量是否为整数。
  3. 数量是否在允许范围内。
  4. 购物车是否属于当前 guest 或 member。
  5. 购物车是否已经过期或完成结算。

下面是 Yii 1 风格的示例:

public function addItem($cartId, $productId, $quantity)
{
    $cartId = (int) $cartId;
    $productId = (int) $productId;
    $quantity = (int) $quantity;

    if ($cartId <= 0 || $productId <= 0) {
        throw new InvalidArgumentException('Invalid cart or product');
    }

    if ($quantity < 1 || $quantity > 100) {
        throw new InvalidArgumentException('Invalid quantity');
    }

    $product = Product::model()->findByPk($productId);

    if ($product === null || !$product->is_available) {
        throw new DomainException('Product is not available');
    }

    $sql = "
        INSERT INTO cart_item
            (cart_id, product_id, quantity, unit_price_snapshot, updated_at)
        VALUES
            (:cart_id, :product_id, :quantity, :price, NOW())
        ON DUPLICATE KEY UPDATE
            quantity = LEAST(quantity + VALUES(quantity), 100),
            unit_price_snapshot = VALUES(unit_price_snapshot),
            updated_at = NOW()
    ";

    Yii::app()->db->createCommand($sql)->execute(array(
        ':cart_id' => $cartId,
        ':product_id' => $productId,
        ':quantity' => $quantity,
        ':price' => $product->price,
    ));

    Yii::app()->db->createCommand(
        "UPDATE cart
         SET updated_at = NOW(),
             expires_at = DATE_ADD(NOW(), INTERVAL 30 MINUTE)
         WHERE id = :cart_id
           AND status = 'active'"
    )->execute(array(':cart_id' => $cartId));
}

调用 addItem() 前仍要验证购物车所有权。客户端提交了 cart_id,不代表它有权修改对应的购物车。

更安全的方式是始终根据当前用户或 Cookie token 查找购物车,不允许客户端直接指定 cart_id。

价格和库存必须在结算时重新验证

购物车不是订单,也不能作为价格凭证。即使攻击者无法修改数据库,购物车也可能长期保留旧价格。因此,进入结算流程后必须重新读取权威数据:

$transaction = Yii::app()->db->beginTransaction();

try {
    $cart = $cartRepository->findActiveCartForUpdate($cartId);

    if ($cart === null || strtotime($cart->expires_at) <= time()) {
        throw new DomainException('Cart has expired');
    }

    $items = $cartRepository->findItems($cart->id);
    $validatedItems = array();
    $subtotal = '0.00';

    foreach ($items as $item) {
        $product = $productRepository->findForCheckout($item->product_id);

        if ($product === null || !$product->is_available) {
            throw new DomainException('A product is no longer available');
        }

        if ($item->quantity > $product->available_quantity) {
            throw new DomainException('Insufficient inventory');
        }

        $currentPrice = $product->price;
        $lineTotal = bcmul($currentPrice, (string) $item->quantity, 2);
        $subtotal = bcadd($subtotal, $lineTotal, 2);

        $validatedItems[] = array(
            'product_id' => $product->id,
            'quantity' => $item->quantity,
            'unit_price' => $currentPrice,
            'line_total' => $lineTotal,
        );
    }

    // 在独立的结算服务中应用 coupon、shipping、tax 等规则。
    $order = $checkoutService->createOrder(
        $cart->user_id,
        $validatedItems,
        $subtotal
    );

    $cartRepository->markConverted($cart->id, $order->id);
    $transaction->commit();
} catch (Exception $e) {
    $transaction->rollback();
    throw $e;
}

金额计算不能使用二进制浮点数。可以使用数据库的 DECIMAL、PHP BCMath,或者先把金额转换成最小货币单位,再用整数计算。币种和舍入方式也要按照业务规则处理。

购物车页面可以提示价格发生变化,但订单价格必须以结算时的服务端计算结果为准。

guest 登录后的购物车合并

用户登录成功后,需要同时处理 guest cart 中的商品和该用户此前保存的 active cart。

常见的合并规则包括:

  • 相同 SKU 的数量相加,但不能超过单次购买上限和库存限制。
  • 不同 SKU 继续保留。
  • 已下架商品应移除或标记为不可购买。
  • 不沿用原价格,重新读取当前价格。
  • 合并完成后,把 guest cart 标记为 converted 或 merged。
  • 生成新的 Session ID,防止 session fixation。
  • 清除或轮换原来的 guest cart_token。

整个合并过程应放在同一个数据库事务中,并锁定相关购物车,避免用户同时打开多个页面时发生重复合并。

超时与数据清理

“30 分钟没有活动就清空”可以分三层处理。

第一层是在每次读取或修改购物车时检查:

status = 'active' AND expires_at > NOW()

过期购物车应立即视为空购物车。即使清理任务还没有执行,系统也不能继续使用其中的数据。

第二层是只在购物车确实发生修改时延长 expires_at。如果每次刷新页面都延期,机器人或后台请求可能让购物车一直不过期。

第三层是通过 cron、队列任务或 MySQL Event 定时分批删除:

DELETE FROM cart
WHERE status = 'active'
  AND expires_at < NOW()
LIMIT 1000;

清理时应循环执行小批量删除,避免一次处理大量数据。大事务可能引发锁等待、复制延迟和磁盘 I/O 峰值。

MySQL Event 足以完成简单清理,但应用层定时任务通常更便于监控、记录失败和控制批次。无论采用哪种方式,判断购物车是否有效都不能依赖清理任务成功执行。

在实际业务中,30 分钟可能太短。如果希望用户几天后还能继续购物,可以分别设置 guest 和 member 的有效期。这属于产品策略,并不是安全方面的固定标准。

如何防止大量 session 或 cart 攻击

数据库是否容易被填满,很大程度上取决于系统会不会无条件创建购物车。可以采用以下措施:

  • 不要在用户访问首页时创建购物车,只在第一次添加商品时创建。
  • 限制单个购物车的商品种类和每种商品的数量。
  • 按 IP、账号和设备信号限制创建购物车、添加商品等接口的请求频率。
  • 对异常来源启用验证码或网关级防护。
  • 空购物车不写入数据库,或者设置更短的有效期。
  • 使用批量清理和容量监控。
  • 避免每次修改都插入一条新的购物车记录。
  • 限制每个 member 可拥有的 active cart 数量。
  • 为数据库账号配置最小权限。

仅仅改用 Cookie 并不能解决资源攻击。攻击者仍可持续发送伪造 Cookie 或大量请求,因此服务端必须做好限流和容量控制。

还需要注意的安全边界

购物车接口至少要做到:

  • 使用参数化查询或 ORM 参数绑定,防止 SQL injection。
  • 使用 CSRF token,保护添加、删除和修改数量等写操作。
  • 对输出内容进行编码,防止商品名称等数据造成 XSS。
  • 严格检查购物车所有权,防止 IDOR。
  • 登录后轮换 Session ID。
  • 全站使用 HTTPS。
  • 不在日志中记录 session、cart token、支付信息和个人信息。
  • 对关键更新使用事务。
  • 对重复提交使用幂等键,尤其是创建订单和支付环节。
  • 在服务端校验数量、价格、库存、商品状态和币种。
  • 结算成功后冻结购物车,禁止再次生成订单。

Redis 是否更合适

如果购物车访问量很大、生命周期较短,而且 guest cart 可以在缓存故障后丢失,可以把它放在 Redis 中并直接设置 TTL:

cart:{token}:items
cart:{token}:meta

每次有效修改后刷新 TTL。Redis 可以减少关系数据库中的短期数据和清理压力,但仍要处理以下问题:

  • Redis 的内存上限和淘汰策略。
  • 是否开启持久化。
  • 单个购物车的大小限制。
  • 限流和恶意 key 创建。
  • 登录后的合并与一致性。
  • Redis 故障时的降级方式。

如果 member cart 需要跨设备长期保存,通常更适合存入 SQL 数据库。常见做法是用 Redis 保存活跃的 guest cart,用数据库保存 member cart 和订单。规模不大时,也可以全部使用数据库,让系统保持简单。

至少要用服务端密钥进行 HMAC 签名,同时验证签名、版本和过期时间。加密只能隐藏内容,签名才能证明内容没有被修改。如果既要保密又要防篡改,应使用经过验证的 authenticated encryption 实现。

即便如此,Cookie 方案仍会受到浏览器容量、每次请求携带数据、密钥轮换、跨设备同步和撤销困难等限制。结算时也仍然必须重新验证价格和库存。

因此,完整 Cookie cart 只适合商品很少、不含敏感信息且允许数据丢失的简单场景。对于常规电商系统,服务端购物车配合不透明的随机 token,通常是更稳妥的默认方案。

备注:内容仅供参考。