PAYATHON 2026

如何在应用中集成信用卡支付?

支付阿杰

结论

不要让应用服务器直接接收、保存或记录信用卡号、有效期和 CVV。建议接入具备 PCI DSS 合规能力的支付服务商,如 Stripe、Adyen、Checkout.com 或当地持牌收单机构,通过服务商的托管支付页或安全支付组件采集卡片信息。

常见的支付流程是:

  1. 用户选择账单并发起支付。
  2. 后端根据账单数据创建支付订单。
  3. 前端使用支付服务商的安全组件收集信用卡信息。
  4. 支付服务商处理验证、3D Secure 和扣款。
  5. 后端通过 Webhook 确认最终结果。
  6. 支付成功后更新账单状态,并向用户展示支付凭证。

前端显示的”支付成功”不能作为入账依据。只有后端收到并验证 Webhook 后,才能更新账单状态。

接入前需要准备什么

1. 选择支付服务商

选择服务商时,需要确认以下事项:

  • 是否支持你的注册国家或地区。
  • 是否支持目标用户使用的银行卡和结算币种。
  • 手续费、退款费、拒付费和跨境费用如何计算。
  • 是否支持 3D Secure、Apple Pay、Google Pay 等支付方式。
  • 结算周期、最低结算金额和保证金要求。
  • 是否提供测试环境、Webhook、退款和对账 API。
  • 是否支持平台型业务。

如果分类应用只向用户收取自身提供的服务费,普通商户账户通常就够用。如果应用代多个商家收款并向商家分账,则属于平台支付场景,需要使用服务商提供的 Connect、Marketplace 或 Split Payment 能力,还可能涉及商户入驻、身份审核和资金流合规。

2. 完成商户审核

支付服务商通常会要求提交:

  • 企业或个人经营主体资料。
  • 法定代表人或实际控制人的身份信息。
  • 银行结算账户。
  • 网站域名和业务说明。
  • 商品或服务价格。
  • 服务条款、隐私政策和退款政策。
  • 客服联系方式。
  • 发货或服务交付说明。

具体要求由服务商、业务类型和经营地区决定。数字商品、订阅、跨境业务和部分高风险行业可能需要额外审核。

3. 使用 HTTPS

生产环境中的付款页面和接口必须使用 HTTPS,同时需要做好这些基础配置:

  • 将密钥存放在环境变量或密钥管理服务中。
  • 分开管理测试环境和生产环境。
  • 不要在前端代码中暴露服务端密钥。
  • 限制后台支付接口的访问权限。
  • 不要在日志、监控数据或错误信息中记录敏感支付数据。

推荐的系统结构

支付集成至少由四部分组成:

  • 前端支付页面:展示账单并加载支付服务商的安全组件。
  • 应用后端:校验账单、计算金额并创建支付订单。
  • 支付服务商:采集卡片信息,执行身份验证和扣款。
  • Webhook 接口:接收异步支付结果并更新账单。

金额必须由后端根据数据库中的账单计算,不能采用前端提交的金额。例如,前端只提交 billId,后端根据 billId 查询应付金额:

浏览器 -> 应用后端:billId
应用后端 -> 数据库:查询账单及应付金额
应用后端 -> 支付服务商:创建支付订单
支付服务商 -> 浏览器:完成信用卡验证
支付服务商 -> Webhook:通知最终支付结果
Webhook -> 数据库:将账单标记为已支付

集成步骤

1. 建立本地订单

建议先创建本地支付订单,再调用支付服务商。订单至少可以包含:

payment_order
- id
- bill_id
- user_id
- amount
- currency
- status
- provider
- provider_payment_id
- created_at
- paid_at

status 可以根据业务需要设为:

pending
processing
paid
failed
refunded
partially_refunded

金额应以最小货币单位保存,例如人民币使用分,美元使用美分。不要用浮点数保存金额。

2. 后端创建支付对象

下面以 Stripe Payment Intents 为例。其他服务商使用的名称和参数可能不同,但整体流程相近。

安装依赖:

npm install express stripe dotenv

创建支付接口:

import express from "express";
import Stripe from "stripe";
import "dotenv/config";

const app = express();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

app.use(express.json());

app.post("/api/payments", async (req, res) => {
  try {
    const userId = req.user.id;
    const { billId } = req.body;

    // 必须从数据库读取账单,不能使用前端提交的金额。
    const bill = await findBillById(billId);

    if (!bill || bill.userId !== userId) {
      return res.status(404).json({ error: "Bill not found" });
    }

    if (bill.status === "paid") {
      return res.status(409).json({ error: "Bill already paid" });
    }

    const paymentIntent = await stripe.paymentIntents.create(
      {
        amount: bill.amountInMinorUnit,
        currency: bill.currency.toLowerCase(),
        automatic_payment_methods: {
          enabled: true
        },
        metadata: {
          billId: String(bill.id),
          userId: String(userId)
        }
      },
      {
        idempotencyKey: `bill-${bill.id}`
      }
    );

    await savePaymentOrder({
      billId: bill.id,
      userId,
      amount: bill.amountInMinorUnit,
      currency: bill.currency,
      provider: "stripe",
      providerPaymentId: paymentIntent.id,
      status: "pending"
    });

    res.json({
      clientSecret: paymentIntent.client_secret
    });
  } catch (error) {
    console.error("Create payment failed:", error);
    res.status(500).json({ error: "Unable to create payment" });
  }
});

示例中的 req.user、findBillById() 和 savePaymentOrder() 需要按照应用现有的认证系统和数据库实现。

幂等键可以降低重复请求导致重复扣款的风险。实际项目需要根据业务规则确定幂等范围。如果同一账单在支付失败后可以重新创建支付对象,就不能永久复用同一个键。

3. 前端确认付款

前端应使用支付服务商提供的 SDK 或托管组件,不要自行创建普通 <input> 来采集完整卡号和 CVV。

以 Stripe Payment Element 为例:

npm install @stripe/stripe-js @stripe/react-stripe-js

初始化支付组件:

import { Elements } from "@stripe/react-stripe-js";
import { loadStripe } from "@stripe/stripe-js";
import CheckoutForm from "./CheckoutForm";

const stripePromise = loadStripe(
  import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY
);

export default function PaymentPage({ clientSecret }) {
  return (
    <Elements stripe={stripePromise} options={{ clientSecret }}>
      <CheckoutForm />
    </Elements>
  );
}

提交付款:

import { PaymentElement, useElements, useStripe } from "@stripe/react-stripe-js";
import { useState } from "react";

export default function CheckoutForm() {
  const stripe = useStripe();
  const elements = useElements();
  const [message, setMessage] = useState("");
  const [submitting, setSubmitting] = useState(false);

  async function handleSubmit(event) {
    event.preventDefault();

    if (!stripe || !elements || submitting) {
      return;
    }

    setSubmitting(true);
    setMessage("");

    const { error } = await stripe.confirmPayment({
      elements,
      confirmParams: {
        return_url: `${window.location.origin}/payment/result`
      }
    });

    if (error) {
      setMessage(error.message || "支付未完成,请稍后重试");
      setSubmitting(false);
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <PaymentElement />

      <button type="submit" disabled={!stripe || submitting}>
        {submitting ? "处理中…" : "确认支付"}
      </button>

      {message && <p role="alert">{message}</p>}
    </form>
  );
}

可发布密钥可以放在前端,服务端密钥则只能保存在后端。支付 SDK 的初始化方式可能随服务商版本变化,上线前需要核对所选服务商当前的官方文档。

4. 处理 Webhook

银行卡支付可能涉及异步验证,所以浏览器的跳转结果未必是最终状态。后端必须验证 Webhook 签名,并根据支付服务商返回的结果更新账单。

Stripe Webhook 示例:

app.post(
  "/webhooks/stripe",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    let event;

    try {
      event = stripe.webhooks.constructEvent(
        req.body,
        req.headers["stripe-signature"],
        process.env.STRIPE_WEBHOOK_SECRET
      );
    } catch (error) {
      return res.status(400).send(`Invalid webhook: ${error.message}`);
    }

    try {
      if (event.type === "payment_intent.succeeded") {
        const paymentIntent = event.data.object;
        const billId = paymentIntent.metadata.billId;

        await markBillPaidOnce({
          billId,
          providerPaymentId: paymentIntent.id,
          paidAmount: paymentIntent.amount_received,
          currency: paymentIntent.currency
        });
      }

      if (event.type === "payment_intent.payment_failed") {
        const paymentIntent = event.data.object;

        await markPaymentFailed({
          providerPaymentId: paymentIntent.id
        });
      }

      res.sendStatus(200);
    } catch (error) {
      console.error("Webhook processing failed:", error);
      res.sendStatus(500);
    }
  }
);

使用 Express 时,Webhook 路由需要读取原始请求体。如果全局 express.json() 先解析了该请求体,签名验证可能失败。可以先注册 Webhook 路由,再注册 JSON 中间件:

app.post(
  "/webhooks/stripe",
  express.raw({ type: "application/json" }),
  stripeWebhookHandler
);

app.use(express.json());

Webhook 可能重复发送,也可能乱序到达,因此 markBillPaidOnce() 必须保证幂等。更新账单前还要核对:

  • 支付对象是否属于当前系统。
  • billId 是否存在。
  • 实收金额是否与订单金额相同。
  • 币种是否一致。
  • 该支付是否已经处理。
  • 支付对象是否已关联其他账单。

支付成功后的处理

确认支付成功后,可以在同一个数据库事务中完成以下操作:

  1. 将支付订单状态更新为 paid。
  2. 将账单更新为已支付。
  3. 保存服务商支付编号和实收金额。
  4. 记录支付时间。
  5. 写入不可随意修改的审计记录。

邮件、短信和站内通知可以交给异步任务队列处理。即使通知发送失败,也不会回滚已经确认的支付结果。

建议提供单独的支付结果查询接口:

app.get("/api/bills/:billId/payment-status", async (req, res) => {
  const bill = await findBillForUser(req.params.billId, req.user.id);

  if (!bill) {
    return res.status(404).json({ error: "Bill not found" });
  }

  res.json({
    billId: bill.id,
    paymentStatus: bill.paymentStatus
  });
});

支付结果页应通过这个接口读取服务端状态,不能只根据 URL 参数显示支付成功。

安全与合规注意事项

不保存敏感卡信息

应用数据库不应保存:

  • 完整信用卡号。
  • CVV 或 CVC。
  • 磁道数据。
  • PIN。
  • 未经保护的身份验证数据。

如果页面需要展示卡片信息,通常只保存支付服务商返回的支付方式标识、卡品牌、后四位和有效期摘要。CVV 即使经过加密,通常也不应保存。

降低 PCI DSS 范围

托管支付页通常最容易接入。嵌入式安全组件可以让用户留在站内完成支付,但仍需根据实际集成方式完成相应的 PCI DSS 自评和安全管理。

使用第三方支付不代表可以免除全部合规责任。具体适用范围需要由支付服务商或合规专业人员结合业务架构确认。

防止重复支付

需要同时采取这些措施:

  • 提交按钮进入处理中状态后立即禁用。
  • 创建支付请求时使用幂等键。
  • 为服务商支付编号建立数据库唯一约束。
  • 禁止已支付账单再次发起正常付款。
  • 保证 Webhook 更新操作幂等。
  • 遇到网络超时时先查询支付状态,不要直接重新扣款。

不信任前端数据

以下信息必须由后端核实:

  • 用户是否有权支付该账单。
  • 账单是否仍然有效。
  • 应付金额和币种。
  • 优惠券是否可用。
  • 是否已经完成支付。
  • 商品库存或服务资格。
  • 支付成功后应该授予哪些权益。

处理退款和拒付

生产系统还需要处理:

  • 全额退款和部分退款。
  • 退款状态同步。
  • 用户取消和商户取消。
  • 拒付通知及证据提交。
  • 对账差异。
  • 手续费和净结算金额记录。

退款也应由后端调用支付服务商 API,并记录操作人、退款原因、退款金额和服务商退款编号。

上线前检查

正式开放付款前,至少要测试以下情况:

  • 正常支付成功。
  • 卡片被拒绝。
  • 余额不足。
  • 3D Secure 验证成功、失败和取消。
  • 用户重复点击付款。
  • 请求超时后重新进入页面。
  • Webhook 重复到达。
  • Webhook 延迟或乱序。
  • 金额或币种不一致。
  • 支付成功但浏览器没有返回。
  • 全额退款和部分退款。
  • 已支付账单再次付款。
  • 用户尝试支付他人的账单。

先在服务商的测试环境中使用测试卡完成全流程验证,再切换到生产密钥。生产环境还需要配置日志告警、Webhook 重试监控、每日对账和异常订单处理机制。

备注:内容仅供参考。