mcpField
H
headless-checkout-proxy

headless-checkout-proxy

github.com/vtex开发471.1k 次安装2 周前更新
代码仓库

关于 headless-checkout-proxy

面向无头 VTEX 店铺结账开发的技能,指导购物车、订单表单和下单流程的实现。明确要求 Checkout API 经由 BFF,并将银行卡数据直接发送至 VTEX Payment Gateway。

主要功能

  1. 01Checkout API 经由 BFF 代理
  2. 02服务端会话管理 orderFormId
  3. 03订单表单附件管理
  4. 04三步下单流程指导
  5. 05银行卡数据直达支付网关

适用场景

  1. 01实现购物车增删改操作
  2. 02附加客户、配送和支付信息
  3. 03构建订单提交与处理流程
  4. 04审查结账数据的安全路径

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/vtex/skills --skill headless-checkout-proxy

安装后在 Claude Code 中输入/headless-checkout-proxy确认已加载。

详细介绍

Checkout API 代理与 OrderForm 管理

适用场景

为任何无头 VTEX 店面构建购物车和结账功能时,请使用此技能。所有购物车和结账操作都必须通过 BFF。

  • 实现购物车创建、商品添加/更新/移除操作
  • 向 OrderForm 附加个人资料、配送或支付数据
  • 实现 3 步下单流程(下单 → 支付 → 处理)
  • 在服务端管理 orderFormId 和 CheckoutOrderFormOwnership Cookie

以下情况不要使用此技能:

决策规则

  • 所有 Checkout API 调用(vtexcommercestable.com.br 上的 /api/checkout/...)都必须通过 BFF 代理。Checkout API 会处理敏感个人数据(个人资料、地址、支付方式选择)。
  • **PCI 例外:**只要 Send payments information 调用携带卡数据,就必须从浏览器/应用直接发送至 VTEX Payment Gateway(POST https://{account}.vtexpayments.com.br/api/pub/transactions/{tid}/payments)。商家 BFF 不得位于卡数据传输路径中。请参阅下方的专门硬性约束和 payment-pci-security。
  • 将 orderFormId 存储在服务端会话中,绝不要存储在 localStorage 或 sessionStorage 中。
  • 每次请求都要在 BFF 和 VTEX 之间捕获并转发 CheckoutOrderFormOwnership 和 checkout.vtex.com Cookie。
  • 将任何输入转发给 VTEX 之前,必须在服务端进行验证——绝不要直接传递原始 req.body。
  • 在 5 分钟窗口内,将 3 步下单流程(下单 → 发送支付信息 → 处理订单)作为一次同步用户交互执行。步骤 1(下单)和步骤 3(网关回调)在 BFF 中运行;根据上述 PCI 例外,步骤 2(向 vtexpayments.com.br 发送支付数据)在浏览器中运行。
  • 始终存储并复用会话中已有的 orderFormId——仅在不存在 orderFormId 时创建新购物车。
  • 步骤 1(/place)和步骤 3(/process)之间的 orderGroup 等中间流程值,必须通过请求正文传递,而不是通过 req.session 传递。生产环境中的 BFF 通常采用多副本部署;express-session 的默认 MemoryStore(以及任何按 Pod 缓存)在两个请求落到不同 Pod 时会丢失该值,并返回误导性的“没有待处理订单可处理”400 错误。如果 orderFormId/vtexCookies 已要求使用共享会话存储(Redis、Memcached、数据库),请参阅下方的硬性约束,了解为何跨步骤传递仍应通过请求正文完成——这样在局部故障、粘性路由失败以及标签页重新加载后的重放/刷新情况下仍能正常工作。

OrderForm 附加端点:

附加数据端点用途
商品POST .../orderForm/{id}/items添加、移除或更新购物车商品
clientProfileDataPOST .../orderForm/{id}/attachments/clientProfileData客户个人资料信息
shippingDataPOST .../orderForm/{id}/attachments/shippingData地址和配送选项
paymentDataPOST .../orderForm/{id}/attachments/paymentData支付方式选择
marketingDataPOST .../orderForm/{id}/attachments/marketingData优惠券和 UTM 数据

硬性约束

约束:所有 Checkout API 操作都必须经过 BFF

客户端代码不得直接向 vtexcommercestable.com.br/api/checkout/... 上的任何 VTEX Checkout API 端点发起 HTTP 请求。所有 Checkout API 操作——购物车创建、商品管理、个人资料更新、配送、支付方式选择(附加 paymentData)、下单(/transaction)和订单处理(/gatewayCallback)——都必须通过 BFF 代理。

此规则仅适用于 Checkout API。出于 PCI 原因,下一条约束中关于在 vtexpayments.com.br 上发送支付信息的调用受另一条约束管辖,并且方向相反(由浏览器直接调用);不要将此规则套用于该端点。

为何重要

Checkout 端点会处理敏感个人数据(电子邮件、地址、电话号码、支付方式选择)。前端直接调用会使请求/响应流程暴露给浏览器开发者工具、扩展程序和 XSS 攻击。此外,BFF 层对于在服务器端管理 VtexIdclientAutCookie 和 CheckoutOrderFormOwnership Cookie、验证输入以及防止购物车被篡改(例如价格篡改)必不可少。

检测

如果你在任何客户端代码(由浏览器执行的 JavaScript、前端源文件)中看到对 vtexcommercestable.com.br/api/checkout/... 的 fetch 或 axios 调用 → 立即停止。所有 Checkout API 调用都必须通过 BFF 端点。

正确

// Frontend — calls BFF endpoint, never VTEX directly
async function addItemToCart(
  skuId: string,
  quantity: number,
  seller: string,
): Promise<OrderForm> {
  const response = await fetch("/api/bff/cart/items", {
    method: "POST",
    credentials: "include",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ skuId, quantity, seller }),
  });

  if (!response.ok) {
    throw new Error(`Failed to add item: ${response.status}`);
  }

  return response.json();
}

错误

// Frontend — calls VTEX Checkout API directly (SECURITY VULNERABILITY)
async function addItemToCart(
  skuId: string,
  quantity: number,
  seller: string,
): Promise<OrderForm> {
  const orderFormId = localStorage.getItem("orderFormId"); // Also wrong: see next constraint
  const response = await fetch(
    `https://mystore.vtexcommercestable.com.br/api/checkout/pub/orderForm/${orderFormId}/items`,
    {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        orderItems: [{ id: skuId, quantity, seller }],
      }),
    },
  );
  return response.json();
}

限制:银行卡数据必须从浏览器/应用直接发送到 vtexpayments.com.br,绝不能经过 BFF

发送支付信息调用(POST https://{account}.vtexpayments.com.br/api/pub/transactions/{tid}/payments?orderId={orderGroup})会在购物者使用信用卡、借记卡或联名卡付款时传输银行卡数据。此调用必须从购物者的浏览器或原生应用发起,并且必须直接发送到 vtexpayments.com.br。涉及银行卡数据时,BFF 绝不能代理此调用,即使进行了脱敏、即使服务器端使用了 appKey/appToken,也即使转发的字段看起来只是“令牌化”字段。

这与前一项限制恰好相反。将其当作普通的“结账”调用,并通过 BFF 路由——有些 Agent 会因过度套用“所有结账请求都经 BFF”的规则而这样做——这违反 PCI DSS,而不是提升安全性。

为何这很重要

运营无头店面的商家很少获得 PCI DSS Level 1 认证。将卡号、持卡人姓名或 CVV 经由商家的 BFF 传输,会使 BFF 及其接触到的所有系统——应用日志、APM/可观测性工具、反向代理、负载均衡器、错误跟踪器——都纳入 PCI 范围。运营会处理银行卡数据的非 PCI 环境违反 PCI DSS 第 3 和第 4 项要求,可能导致银行卡组织处以每月 5,000 美元至超过 100,000 美元的罚款、强制取证调查费用、丧失银行卡处理能力以及法律责任。

浏览器 → vtexpayments.com.br 是符合 PCI 要求的模式:VTEX Payment Gateway 已通过 PCI DSS Level 1 认证,是此流程中唯一获准接收原始银行卡数据的环境。发送支付信息调用使用前一步“下单”设置的购物者会话 Cookie 进行身份验证——此跳转不需要 BFF 提供商家凭据。

检测

如果在 BFF/服务器端代码(server/、bff/、api/、路由处理程序、中间件、边缘函数、lambda)中发现以下任一情况,请立即停止:

  • BFF 向 https://*.vtexpayments.com.br/api/pub/transactions/.../payments 发起请求,
  • 某个处理程序接收来自浏览器的 cardNumber、holderName、validationCode、csc、dueDate 或完整支付 fields,并将其转发到任何 VTEX 端点,
  • 某个面向“支付”的客户端/ExternalClient/axios 实例将 vtexpayments.com.br 设为发送支付信息端点的目标。

将该调用移到浏览器/应用中。BFF 应改为在“下单”步骤中返回 transactionId、orderGroup 和 merchantName,以便前端直接向 Payment Gateway 发送支付数据。

即使出现以下情况,这条规则仍然适用:

  • 商家拥有具备支付权限的 VTEX appKey/appToken——拥有凭据并不代表获得 PCI 授权。
  • 只转发“令牌化”的银行卡字段——令牌值仍然关联真实银行卡数据,仍属于 PCI 范围。
  • BFF 代码在记录日志前会“脱敏”敏感字段——请求在脱敏前仍会经过商家的基础设施。
  • BFF 运行在带 TLS 的私有 VPC 中——PCI 范围取决于经过的数据,而非网络配置方式。

正确

async function sendPaymentDataDirect(args: {
  account: string;
  transactionId: string;
  orderGroup: string;
  merchantName: string;
  paymentInformation: PaymentField[];
}): Promise<void> {
  const {
    account,
    transactionId,
    orderGroup,
    merchantName,
    paymentInformation,
  } = args;

  const response = await fetch(
    `https://${account}.vtexpayments.com.br/api/pub/transactions/${transactionId}/payments?orderId=${orderGroup}`,
    {
      method: "POST",
      credentials: "include",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
      },
      body: JSON.stringify(
        paymentInformation.map((p) => ({
          ...p,
          transaction: { id: transactionId, merchantName },
        })),
      ),
    },
  );

  if (!response.ok) {
    throw new Error(`Payment submission failed: ${response.status}`);
  }
}

错误

const VTEX_APP_KEY = process.env.VTEX_APP_KEY!;
const VTEX_APP_TOKEN = process.env.VTEX_APP_TOKEN!;

paymentRoutes.post("/", async (req: Request, res: Response) => {
  const { transactionId, orderGroup, paymentInformation } = req.body as {
    transactionId: string;
    orderGroup: string;
    paymentInformation: Array<{
      paymentSystem: number;
      installments: number;
      value: number;
      fields: {
        cardNumber: string;
        holderName: string;
        validationCode: string;
        dueDate: string;
      };
    }>;
  };

  const url = `https://${process.env.VTEX_ACCOUNT}.vtexpayments.com.br/api/pub/transactions/${transactionId}/payments?orderId=${orderGroup}`;
  const response = await fetch(url, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-VTEX-API-AppKey": VTEX_APP_KEY,
      "X-VTEX-API-AppToken": VTEX_APP_TOKEN,
    },
    body: JSON.stringify(paymentInformation),
  });

  res.json({ status: response.status });
});

约束:orderFormId 必须由服务器端管理

orderFormId 必须存储在安全的服务器端会话中。不应将其存储在 localStorage、sessionStorage 中,也不应以允许直接调用 VTEX API 的方式暴露给前端。

为什么这很重要

orderFormId 是客户购物车及其中所有数据的关键,包括个人资料信息、配送地址和付款详情。如果在客户端暴露,攻击者就能用它直接查询 VTEX 并获取个人数据,或绕过所有验证逻辑,通过直接调用 API 添加或移除商品来操纵购物车。

检测

如果发现 orderFormId 存储在 localStorage 或 sessionStorage 中 → 立即停止。它应由 BFF 会话管理。

正确

// BFF — manages orderFormId in server-side session
import { Router, Request, Response } from "express";
import { vtexCheckoutRequest } from "../vtex-api-client";

export const cartRoutes = Router();

// Get or create cart — orderFormId stays server-side
cartRoutes.get("/", async (req: Request, res: Response) => {
  try {
    let orderFormId = req.session.orderFormId;

    if (orderFormId) {
      // Retrieve existing cart
      const orderForm = await vtexCheckoutRequest({
        path: `/api/checkout/pub/orderForm/${orderFormId}`,
        method: "GET",
        cookies: req.session.vtexCookies,
      });
      return res.json(sanitizeOrderForm(orderForm));
    }

    // Create new cart
    const orderForm = await vtexCheckoutRequest({
      path: "/api/checkout/pub/orderForm",
      method: "GET",
      cookies: req.session.vtexCookies,
    });

    // Store orderFormId in session — never expose raw ID to frontend
    req.session.orderFormId = orderForm.orderFormId;
    req.session.vtexCookies = orderForm._cookies; // Store checkout cookies

    res.json(sanitizeOrderForm(orderForm));
  } catch (error) {
    console.error("Error getting cart:", error);
    res.status(500).json({ error: "Failed to get cart" });
  }
});

// Remove sensitive data before sending to frontend
function sanitizeOrderForm(
  orderForm: Record<string, unknown>,
): Record<string, unknown> {
  const sanitized = { ...orderForm };
  delete sanitized._cookies;
  return sanitized;
}

错误

// Frontend — stores orderFormId in localStorage (INSECURE)
async function getCart(): Promise<OrderForm> {
  let orderFormId = localStorage.getItem("orderFormId"); // EXPOSED to client

  if (!orderFormId) {
    const response = await fetch(
      "https://mystore.vtexcommercestable.com.br/api/checkout/pub/orderForm",
    );
    const data = await response.json();
    orderFormId = data.orderFormId;
    localStorage.setItem("orderFormId", orderFormId!); // Stored client-side!
  }

  const response = await fetch(
    `https://mystore.vtexcommercestable.com.br/api/checkout/pub/orderForm/${orderFormId}`,
  );
  return response.json();
}

约束:多步骤订单流程交接数据必须通过请求正文传递,而不是放在 req.session 中

用于关联 /place(第 1 步)和 /process(第 3 步)的值——最重要的是 orderGroup,以及前端需要回传的每次尝试对应的 transactionId 或商户上下文——必须由 /place 返回给浏览器,并由浏览器放入 /process 请求正文中发回。处理程序不得将 req.session.pendingOrderGroup(或任何按 Pod 缓存)作为进行中订单的唯一事实来源。

为什么这很重要

生产环境中的 BFF 会运行多个副本(Kubernetes 部署、ECS 服务、负载均衡器 ALB 后的 lambda、边缘运行时)。express-session 默认使用 MemoryStore,其数据按 Pod 存储,因此一个 Pod 写入的会话值对其他 Pod 都不可见。即使使用共享会话存储(Redis、DynamoDB、Memcached),第 1 步和第 3 步之间的间隔也只有用户往返 vtexpayments.com.br 所需的时间,因此任何 Redis 的部分故障、写入复制延迟、粘性路由故障、标签页刷新或登录时的会话重新生成,都会导致 400 — no pending order to process。从购物者的角度来看,订单已经付款(其银行卡在第 2 步已扣款),但店面却报告失败,客服不得不手动核对。订单处理窗口只有 5 分钟,没有时间重试。

orderGroup 不是机密信息。它在第 1 步已返回给浏览器,且第 2 步中浏览器直接调用 vtexpayments.com.br 时也需要它。在第 3 步将其回传,比依赖会话状态的暴露面更小。身份验证和所有权仍通过绑定到会话的 vtexCookies、orderFormId 和(如有)vtexAuthToken 在服务器端强制执行——BFF 绝不会仅凭 orderGroup 就予以信任。

检测

如果在 BFF/服务器端结账代码中发现以下任一情况,请立即停止:

  • 读取或写入 req.session.pendingOrderGroup、req.session.orderGroup、req.session.lastTransactionId,或其他类似的流程值,且这些值在 /place 中写入、在 /process 中读取。
  • /process(或 /gatewayCallback)处理程序只从 req.session 获取 orderGroup,没有 req.body.orderGroup 回退逻辑或验证。
  • 前端 placeOrder 流程调用 /api/bff/order/process 时没有请求正文,也没有 orderGroup,依赖 BFF“记住”订单。
  • 出现类似 "// rely on session to recover orderGroup" 的注释,或有提交为了“整洁”将 orderGroup 从请求正文移入会话的更改。

正确

orderRoutes.post("/place", async (req: Request, res: Response) => {
  const orderFormId = req.session.orderFormId;
  if (!orderFormId) return res.status(400).json({ error: "No active cart" });

  const { data } = await vtexCheckout<PlaceOrderResponse>({
    path: `/api/checkout/pub/orderForm/${orderFormId}/transaction`,
    method: "POST",
    body: { referenceId: orderFormId },
    cookies: req.session.vtexCookies || {},
    userToken: req.session.vtexAuthToken,
  });

  res.json({
    account: process.env.VTEX_ACCOUNT,
    orderId: data.orders[0].orderId,
    orderGroup: data.orderGroup,
    transactionId: data.orders[0].transactionData.merchantTransactions[0].transactionId,
    merchantName: data.orders[0].transactionData.merchantTransactions[0].merchantName,
  });
});

orderRoutes.post("/process", async (req: Request, res: Response) => {
  const orderGroup =
    typeof req.body?.orderGroup === "string" ? req.body.orderGroup : undefined;
  if (!orderGroup || !/^[A-Za-z0-9-]+$/.test(orderGroup)) {
    return res.status(400).json({ error: "Missing or invalid orderGroup" });
  }
  if (!req.session.orderFormId) {
    return res.status(400).json({ error: "No active checkout session" });
  }

  await vtexCheckout({
    path: `/api/checkout/pub/gatewayCallback/${orderGroup}`,
    method: "POST",
    cookies: req.session.vtexCookies || {},
  });

  delete req.session.orderFormId;
  delete req.session.vtexCookies;
  res.json({ orderGroup, status: "processed" });
});

下方的首选模式展示了相应的浏览器流程:/place 返回 orderGroup,浏览器再将其放入 /process 请求正文中回传。

错误

orderRoutes.post("/place", async (req: Request, res: Response) => {
  // ... call /transaction ...
  req.session.pendingOrderGroup = orderGroup;
  res.json({ account, orderId, orderGroup, transactionId, merchantName });
});

orderRoutes.post("/process", async (req: Request, res: Response) => {
  // BUG: works in dev with one Node process and MemoryStore, returns
  // `400 — no pending order to process` the moment the load balancer routes
  // /place and /process to different replicas.
  const orderGroup = req.session.pendingOrderGroup;
  if (!orderGroup) {
    return res.status(400).json({ error: "No pending order to process" });
  }

  await vtexCheckout({
    path: `/api/checkout/pub/gatewayCallback/${orderGroup}`,
    method: "POST",
    cookies: req.session.vtexCookies || {},
  });

  delete req.session.pendingOrderGroup;
  res.json({ status: "processed" });
});

约束:转发到 VTEX 前,必须在服务端验证所有输入

BFF 必须在转发请求到 VTEX Checkout API 前验证所有输入数据。这包括验证 SKU ID、数量、电子邮件格式、地址字段和优惠码。

为何这很重要

如果没有服务端验证,恶意用户可能会通过 BFF 向 VTEX 发送精心构造的请求,其中包含无效或具有操纵性的 数据,例如负数数量、文本字段中的 SQL 注入或伪造的卖家 ID。虽然 VTEX 有自己的验证机制,但纵深防御要求在 BFF 层进行验证,以便及早发现问题并提供清晰的错误消息。

检测

如果 BFF 路由处理程序在没有任何验证或清理的情况下,直接将 req.body 传递给 VTEX API 调用 → 立即停止。代理请求前必须验证所有输入。

正确

// BFF — validates inputs before forwarding to VTEX
import { Router, Request, Response } from "express";
import { vtexCheckoutRequest } from "../vtex-api-client";

export const cartItemsRoutes = Router();

interface AddItemRequest {
  skuId: string;
  quantity: number;
  seller: string;
}

function validateAddItemInput(body: unknown): body is AddItemRequest {
  if (typeof body !== "object" || body === null) return false;
  const b = body as Record<string, unknown>;

  return (
    typeof b.skuId === "string" &&
    /^\d+$/.test(b.skuId) &&
    typeof b.quantity === "number" &&
    Number.isInteger(b.quantity) &&
    b.quantity > 0 &&
    b.quantity <= 100 &&
    typeof b.seller === "string" &&
    /^[a-zA-Z0-9]+$/.test(b.seller)
  );
}

cartItemsRoutes.post("/", async (req: Request, res: Response) => {
  if (!validateAddItemInput(req.body)) {
    return res.status(400).json({
      error: "Invalid input",
      details:
        "skuId must be numeric, quantity must be 1-100, seller must be alphanumeric",
    });
  }

  const { skuId, quantity, seller } = req.body;
  const orderFormId = req.session.orderFormId;

  if (!orderFormId) {
    return res.status(400).json({ error: "No active cart" });
  }

  try {
    const orderForm = await vtexCheckoutRequest({
      path: `/api/checkout/pub/orderForm/${orderFormId}/items`,
      method: "POST",
      body: {
        orderItems: [{ id: skuId, quantity, seller }],
      },
      cookies: req.session.vtexCookies,
    });

    res.json(sanitizeOrderForm(orderForm));
  } catch (error) {
    console.error("Error adding item:", error);
    res.status(500).json({ error: "Failed to add item to cart" });
  }
});

错误

// BFF — passes raw input to VTEX without validation (UNSAFE)
cartRoutes.post("/items", async (req: Request, res: Response) => {
  const orderFormId = req.session.orderFormId;

  // No validation — attacker can send any payload
  const orderForm = await vtexCheckoutRequest({
    path: `/api/checkout/pub/orderForm/${orderFormId}/items`,
    method: "POST",
    body: req.body, // Raw, unvalidated input passed directly!
    cookies: req.session.vtexCookies,
  });

  res.json(orderForm);
});

首选模式

结账操作通过 BFF 的请求流程:

Frontend
    │
    └── POST /api/bff/cart/items/add  {skuId, quantity, seller}
            │
            BFF Layer
            │ 1. Validates input (skuId format, quantity > 0, seller exists)
            │ 2. Reads orderFormId from server-side session
            │ 3. Forwards CheckoutOrderFormOwnership cookie
            │ 4. Calls VTEX: POST /api/checkout/pub/orderForm/{id}/items
            │ 5. Updates session with new orderFormId if changed
            │ 6. Returns sanitized orderForm to frontend
            │
            VTEX Checkout API

具有 Cookie 管理功能的 VTEX Checkout API 客户端:

// server/vtex-checkout-client.ts
const VTEX_ACCOUNT = process.env.VTEX_ACCOUNT!;
const VTEX_ENVIRONMENT = process.env.VTEX_ENVIRONMENT || "vtexcommercestable";
const BASE_URL = `https://${VTEX_ACCOUNT}.${VTEX_ENVIRONMENT}.com.br`;

interface CheckoutRequestOptions {
  path: string;
  method?: string;
  body?: unknown;
  cookies?: Record<string, string>;
  userToken?: string;
}

interface CheckoutResponse<T = unknown> {
  data: T;
  cookies: Record<string, string>;
}

export async function vtexCheckout<T>(
  options: CheckoutRequestOptions,
): Promise<CheckoutResponse<T>> {
  const { path, method = "GET", body, cookies = {}, userToken } = options;

  const headers: Record<string, string> = {
    Accept: "application/json",
    "Content-Type": "application/json",
  };

  // Build cookie header from stored cookies
  const cookieParts: string[] = [];
  if (cookies["checkout.vtex.com"]) {
    cookieParts.push(`checkout.vtex.com=${cookies["checkout.vtex.com"]}`);
  }
  if (cookies["CheckoutOrderFormOwnership"]) {
    cookieParts.push(
      `CheckoutOrderFormOwnership=${cookies["CheckoutOrderFormOwnership"]}`,
    );
  }
  if (userToken) {
    cookieParts.push(`VtexIdclientAutCookie=${userToken}`);
  }
  if (cookieParts.length > 0) {
    headers["Cookie"] = cookieParts.join("; ");
  }

  const response = await fetch(`${BASE_URL}${path}`, {
    method,
    headers,
    body: body ? JSON.stringify(body) : undefined,
  });

  if (!response.ok) {
    const errorBody = await response.text();
    throw new Error(
      `Checkout API error: ${response.status} for ${method} ${path}: ${errorBody}`,
    );
  }

  // Extract cookies from response for session storage
  const responseCookies: Record<string, string> = {};
  const setCookieHeaders = response.headers.getSetCookie?.() ?? [];
  for (const setCookie of setCookieHeaders) {
    const [nameValue] = setCookie.split(";");
    const [name, value] = nameValue.split("=");
    if (name && value) {
      responseCookies[name.trim()] = value.trim();
    }
  }

  const data = (await response.json()) as T;
  return { data, cookies: { ...cookies, ...responseCookies } };
}

购物车管理 BFF 路由:

// server/routes/cart.ts
import { Router, Request, Response } from "express";
import { vtexCheckout } from "../vtex-checkout-client";

export const cartRoutes = Router();

// GET /api/bff/cart — get or create cart
cartRoutes.get("/", async (req: Request, res: Response) => {
  try {
    const result = await vtexCheckout<OrderForm>({
      path: req.session.orderFormId
        ? `/api/checkout/pub/orderForm/${req.session.orderFormId}`
        : "/api/checkout/pub/orderForm",
      cookies: req.session.vtexCookies || {},
      userToken: req.session.vtexAuthToken,
    });

    req.session.orderFormId = result.data.orderFormId;
    req.session.vtexCookies = result.cookies;

    res.json(result.data);
  } catch (error) {
    console.error("Error getting cart:", error);
    res.status(500).json({ error: "Failed to get cart" });
  }
});

// POST /api/bff/cart/items — add items to cart
cartRoutes.post("/items", async (req: Request, res: Response) => {
  const { items } = req.body as {
    items: Array<{ id: string; quantity: number; seller: string }>;
  };

  if (!Array.isArray(items) || items.length === 0) {
    return res.status(400).json({ error: "Items array is required" });
  }

  for (const item of items) {
    if (
      !item.id ||
      typeof item.quantity !== "number" ||
      item.quantity < 1 ||
      !item.seller
    ) {
      return res
        .status(400)
        .json({ error: "Each item must have id, quantity (>0), and seller" });
    }
  }

  const orderFormId = req.session.orderFormId;
  if (!orderFormId) {
    return res
      .status(400)
      .json({ error: "No active cart. Call GET /api/bff/cart first." });
  }

  try {
    const result = await vtexCheckout<OrderForm>({
      path: `/api/checkout/pub/orderForm/${orderFormId}/items`,
      method: "POST",
      body: { orderItems: items },
      cookies: req.session.vtexCookies || {},
      userToken: req.session.vtexAuthToken,
    });

    req.session.vtexCookies = result.cookies;
    res.json(result.data);
  } catch (error) {
    console.error("Error adding items:", error);
    res.status(500).json({ error: "Failed to add items to cart" });
  }
});

下单 — 步骤 1 和步骤 3 在 BFF 中运行;根据 PCI 豁免条款,步骤 2 在浏览器中运行。全部 3 个步骤必须在 5 分钟窗口内完成。

// server/routes/order.ts — BFF handles Step 1 (place) and Step 3 (process) only.
// Step 2 (send payment data) runs in the browser; see the card-data carve-out constraint.
import { Router, Request, Response } from "express";
import { vtexCheckout } from "../vtex-checkout-client";

export const orderRoutes = Router();

const VTEX_ACCOUNT = process.env.VTEX_ACCOUNT!;

// POST /api/bff/order/place — Step 1: place order from existing cart.
// Returns the data the browser needs to call vtexpayments.com.br directly in
// Step 2 AND to call /api/bff/order/process in Step 3. The browser is the
// authority for `orderGroup` between Step 1 and Step 3 — the BFF does NOT
// stash it in `req.session`, because that would silently break in any
// multi-replica deployment whose session store is in-memory or sticky-routed.
orderRoutes.post("/place", async (req: Request, res: Response) => {
  const orderFormId = req.session.orderFormId;
  if (!orderFormId) {
    return res.status(400).json({ error: "No active cart" });
  }

  try {
    const placeResult = await vtexCheckout<PlaceOrderResponse>({
      path: `/api/checkout/pub/orderForm/${orderFormId}/transaction`,
      method: "POST",
      body: { referenceId: orderFormId },
      cookies: req.session.vtexCookies || {},
      userToken: req.session.vtexAuthToken,
    });

    const { orders, orderGroup } = placeResult.data;
    if (!orders?.length) {
      return res
        .status(500)
        .json({ error: "Order placement returned no orders" });
    }

    const orderId = orders[0].orderId;
    const merchantTransaction =
      orders[0].transactionData.merchantTransactions[0];
    const transactionId = merchantTransaction?.transactionId;
    const merchantName = merchantTransaction?.merchantName ?? VTEX_ACCOUNT;

    res.json({
      account: VTEX_ACCOUNT,
      orderId,
      orderGroup,
      transactionId,
      merchantName,
    });
  } catch (error) {
    console.error("Error placing order:", error);
    res.status(500).json({ error: "Failed to place order" });
  }
});

// POST /api/bff/order/process — Step 3: process gateway callback after the
// browser has submitted payment data directly to vtexpayments.com.br in Step 2.
// Carries no card data; safe to run server-side. The browser passes back the
// same `orderGroup` it received from /place so the handler is stateless across
// replicas. The BFF still validates ownership through the session-bound
// orderFormId + checkout cookies before calling VTEX.
orderRoutes.post("/process", async (req: Request, res: Response) => {
  const orderGroup =
    typeof req.body?.orderGroup === "string" ? req.body.orderGroup : undefined;
  if (!orderGroup || !/^[A-Za-z0-9-]+$/.test(orderGroup)) {
    return res.status(400).json({ error: "Missing or invalid orderGroup" });
  }
  if (!req.session.orderFormId) {
    return res.status(400).json({ error: "No active checkout session" });
  }

  try {
    await vtexCheckout<unknown>({
      path: `/api/checkout/pub/gatewayCallback/${orderGroup}`,
      method: "POST",
      cookies: req.session.vtexCookies || {},
    });

    delete req.session.orderFormId;
    delete req.session.vtexCookies;

    res.json({ orderGroup, status: "processed" });
  } catch (error) {
    console.error("Error processing order:", error);
    res.status(500).json({ error: "Failed to process order" });
  }
});
// frontend/checkout/placeOrder.ts — Step 2 runs in the browser.
// Card fields stay on the device; the BFF never sees them.
// Step 3 echoes `orderGroup` back to the BFF in the request body so it works
// the same way whether the BFF runs on one pod or fifty.
async function placeOrderWithCard(card: CardPaymentInformation) {
  const placeResp = await fetch("/api/bff/order/place", {
    method: "POST",
    credentials: "include",
  });
  const { account, orderGroup, transactionId, merchantName } =
    await placeResp.json();

  await fetch(
    `https://${account}.vtexpayments.com.br/api/pub/transactions/${transactionId}/payments?orderId=${orderGroup}`,
    {
      method: "POST",
      credentials: "include",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
      },
      body: JSON.stringify([
        { ...card, transaction: { id: transactionId, merchantName } },
      ]),
    },
  );

  await fetch("/api/bff/order/process", {
    method: "POST",
    credentials: "include",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ orderGroup }),
  });
}

常见失败模式

  • 每次页面加载都创建新购物车:每次页面加载时调用不带 orderFormId 的 GET /api/checkout/pub/orderForm,都会创建一个新的空购物车,并遗弃之前的购物车。始终在服务端会话中存储并重复使用 orderFormId。

    // Always check for existing orderFormId first
    cartRoutes.get("/", async (req: Request, res: Response) => {
      const orderFormId = req.session.orderFormId;
    
      const path = orderFormId
        ? `/api/checkout/pub/orderForm/${orderFormId}` // Retrieve existing cart
        : "/api/checkout/pub/orderForm"; // Create new cart only if none exists
    
      const result = await vtexCheckout<OrderForm>({
        path,
        cookies: req.session.vtexCookies || {},
        userToken: req.session.vtexAuthToken,
      });
    
      req.session.orderFormId = result.data.orderFormId;
      req.session.vtexCookies = result.cookies;
      res.json(result.data);
    });
    
  • 忽略 5 分钟的订单处理窗口:下单(步骤 1)后,如果延迟超过 5 分钟才付款或处理,VTEX 会自动将订单取消为 incomplete。应将步骤 1 → 2 → 3 作为一次用户交互,按顺序立即执行。步骤 1(下单)和步骤 3(网关回调)在 BFF 中运行;根据上文的银行卡数据豁免条款,步骤 2(发送付款数据)在浏览器中运行。各步骤之间不要等待额外的 UI 操作。

    // Frontend orchestrates Place (BFF) → Pay (browser → vtexpayments.com.br) → Process (BFF)
    // on a single click, well within the 5-minute window.
    async function onPlaceOrderClick(card: CardPaymentInformation) {
      const { account, orderGroup, transactionId, merchantName } =
        await fetchJson("/api/bff/order/place", { method: "POST" });
    
      await sendPaymentDirectToGateway({
        account,
        transactionId,
        orderGroup,
        merchantName,
        card,
      });
    
      await fetchJson("/api/bff/order/process", {
        method: "POST",
        body: { orderGroup },
      });
    }
    
  • 为了“保持一致性”而通过 BFF 代理 vtexpayments.com.br 付款提交:将 Send payments information 调用通过 BFF 路由,表面上看与 BFF 的其他要求对称;一些 Agent 会将其加入,以“集中处理所有 VTEX 调用”。涉及银行卡数据时,这违反 PCI DSS,并非重构。步骤 2 应保留在浏览器中;BFF 应提供 /api/bff/order/place(返回 transactionId/orderGroup/merchantName)和 /api/bff/order/process(调用 /gatewayCallback),但银行卡流程中绝不要提供 /api/bff/order/payment。

  • 在 /place 和 /process 之间将 orderGroup 存储在 req.session 中:在步骤 1 中写入 req.session.pendingOrderGroup,并在步骤 3 中读取,看起来简洁,在单进程本地开发中也能正常工作。但使用 express-session 的默认 MemoryStore(以及任何按 Pod 划分的缓存)时,横向扩展的 BFF 一旦两次请求落到不同副本上,就会丢失该值,导致 /process 返回 400 — no pending order to process,而购物者的银行卡已在步骤 2 中扣款。应由 /place 将 orderGroup 返回给浏览器,并要求浏览器在 /process 请求正文中将其发回 — 参见上文专门说明的硬性约束。即便已经为 orderFormId/vtexCookies 配置了共享会话存储(Redis、Memcached、数据库),跨步骤传递仍应放在请求正文中,这样即使发生部分故障、粘性路由失效、复制延迟和标签页刷新,流程仍能正确运行。

  • 向前端暴露 VTEX 原始错误消息:直接将 VTEX API 错误响应转发给前端会泄露内部细节(账户名称、API 路径、数据结构)。应在 BFF 中将 VTEX 错误映射为用户友好的消息,并在服务端记录完整错误。

    // Map VTEX errors to safe, user-friendly messages
    function mapCheckoutError(
      vtexError: string,
      statusCode: number,
    ): { code: string; message: string } {
      if (statusCode === 400 && vtexError.includes("item")) {
        return {
          code: "INVALID_ITEM",
          message: "One or more items are unavailable",
        };
      }
      if (statusCode === 400 && vtexError.includes("address")) {
        return {
          code: "INVALID_ADDRESS",
          message: "Please check your shipping address",
        };
      }
      if (statusCode === 409) {
        return {
          code: "CART_CONFLICT",
          message: "Your cart was updated. Please review your items.",
        };
      }
      return {
        code: "CHECKOUT_ERROR",
        message: "An error occurred during checkout. Please try again.",
      };
    }
    

审查清单

  • 是否所有 Checkout API 调用(vtexcommercestable.com.br/api/checkout/...)都通过 BFF 路由(没有直接从前端发起调用)?
  • 涉及卡片数据时,是否由浏览器/应用直接发送 Send payments information 调用(vtexpayments.com.br/api/pub/transactions/{tid}/payments),而不通过 BFF 代理?
  • 所有 BFF 路由处理程序和日志语句中,是否都未出现 cardNumber、holderName、validationCode/csc 和 dueDate?
  • 代码库中对 vtexpayments.com.br 的所有引用是否只出现在浏览器/应用代码中,而从未出现在 server/、bff/、api/ 或其他后端目录中?
  • orderFormId 是否存储在服务器端会话中,而非 localStorage 或 sessionStorage 中?
  • 是否从 VTEX 响应中捕获 CheckoutOrderFormOwnership 和 checkout.vtex.com cookie,并在后续请求中转发?
  • 在将所有输入转发到 VTEX 之前,是否都在服务器端进行验证?
  • 下单的 3 个步骤(下单 → 付款 → 处理)是否都在 5 分钟窗口内、作为一次用户交互执行,其中步骤 1 和 3 在 BFF 中执行,步骤 2 则由浏览器直接发送到 vtexpayments.com.br?
  • 是否从会话中复用现有的 orderFormId,而不是每次页面加载时都创建新购物车?
  • /place 是否会返回 orderGroup,并由浏览器在 /process 请求正文中发回,而不是在两次 BFF 调用之间将其存入 req.session.pendingOrderGroup?
  • 是否在将 VTEX 错误响应发送到前端之前对其进行清理?

参考资料

评论

支持 Markdown