PAYATHON 2026

如何安全收集并提交 Direct Debit 信息

支付老李

结论

不要先建立一张要求字段完全匹配的数据库,再让 Direct Debit 服务商直接访问。银行资料也不能通过邮件、前端 JavaScript 或浏览器直接发送。

通常应优先使用服务商提供的托管开户页面(hosted payment/mandate page)或嵌入式安全组件。用户在服务商页面填写银行账户信息,你的网站只保存 customer_id、mandate_id、付款周期和状态等引用数据。只有 London & Zurich 明确要求商户自行采集资料时,才由你的后端接收表单,并通过其正式 API/SOAP 接口提交。

Direct Debit 通常需要银行账户名、account number、sort code 和 mandate 信息,与 debit card payment 不是一回事。除非服务商明确要求,否则不要采集卡号、有效期或 CVV。

先向服务商确认接入方式

现有的 inputs/outputs 文档更像是接口字段说明,未必是完整的实施指南。示例中的 GetScheduledFuturePaymentsRequest 从名称来看是查询未来付款的请求,不是创建客户或建立 Direct Debit mandate 的入口。

开发前,需要请 London & Zurich 提供或确认以下信息:

  • 商户测试环境和正式环境地址;
  • 身份验证方式及测试凭据的申请流程;
  • 创建客户、创建 mandate 和创建付款计划分别对应哪个操作;
  • 是否提供 hosted page、iframe、JavaScript SDK 或 tokenization;
  • SOAP 的 WSDL 地址、版本和 XML namespace;
  • 月付和每三个月付款所需的字段及日期规则;
  • webhook/callback 的签名验证方式;
  • mandate 成功、失败、取消、逾期和退款状态的通知方式;
  • 是否需要显示 Direct Debit Guarantee;
  • 测试银行账户、测试场景和上线认证流程;
  • 敏感数据的保存期限和删除要求。

如果文档只有字段列表,却没有 endpoint、WSDL、认证方法和错误码,就无法据此安全实施。应先向服务商索取 integration guide,或联系其技术支持。

推荐的集成流程

安全性较高、维护成本较低的流程通常是:

用户选择付款周期
        ↓
你的后端创建会员注册记录
        ↓
你的后端向服务商申请 mandate session
        ↓
用户跳转到服务商托管页面并填写银行资料
        ↓
服务商完成校验并返回 mandate reference
        ↓
服务商通过签名 webhook 通知最终状态
        ↓
你的网站更新会员和付款状态

数据库只需保存业务需要的引用信息,例如:

users
  id
  name
  email

memberships
  id
  user_id
  billing_interval       // monthly 或 quarterly
  provider_customer_id
  provider_mandate_id
  mandate_status
  next_payment_date
  created_at
  updated_at

不要保存 CVV。使用托管页面时,通常也不需要保存完整的 account number 或 sort code。如果业务页面需要显示账户信息,可以保存服务商返回的掩码,例如 ******1234。

付款周期应在服务端映射为明确的业务枚举,不能直接接受浏览器提交的任意金额或周期:

const plans = {
  monthly: {
    amountPence: 4000,
    intervalMonths: 1
  },
  quarterly: {
    amountPence: 12000,
    intervalMonths: 3
  }
};

实际金额、首次扣款日期和周期规则必须以合同及服务商文档为准。

托管页面的后端示例

下面是一个通用流程示例。其中的字段名和 URL 都是占位符,并不代表 London & Zurich 的真实 API。实施时必须替换为官方文档规定的内容。

import express from "express";

const app = express();
app.use(express.json());

const plans = {
  monthly: { amountPence: 4000, intervalMonths: 1 },
  quarterly: { amountPence: 12000, intervalMonths: 3 }
};

app.post("/api/direct-debit/session", requireLogin, async (req, res) => {
  const plan = plans[req.body.billingInterval];

  if (!plan) {
    return res.status(400).json({ error: "Invalid billing interval" });
  }

  // API 凭据只能保存在服务器端。
  const providerResponse = await fetch(
    process.env.DD_PROVIDER_SESSION_URL,
    {
      method: "POST",
      headers: {
        "Authorization": `Bearer ${process.env.DD_PROVIDER_API_KEY}`,
        "Content-Type": "application/json",
        "Idempotency-Key": crypto.randomUUID()
      },
      body: JSON.stringify({
        externalCustomerId: String(req.user.id),
        amount: plan.amountPence,
        intervalMonths: plan.intervalMonths,
        returnUrl: "https://example.com/direct-debit/return"
      })
    }
  );

  if (!providerResponse.ok) {
    // 不要把响应中的敏感内容直接写入日志或返回浏览器。
    return res.status(502).json({ error: "Unable to create mandate session" });
  }

  const session = await providerResponse.json();

  // redirectUrl 应当来自受信任的服务商响应。
  res.json({ redirectUrl: session.redirectUrl });
});

浏览器收到 redirectUrl 后,再跳转到服务商页面。API key、SOAP 用户名、密码和签名密钥绝不能放入 HTML、移动应用包或前端 JavaScript。

用户从服务商页面返回,只能说明浏览器完成了跳转,不能据此直接激活会员。最终状态需要通过服务端查询,或由经过验签的 webhook 确认。

如果只能自行采集银行资料

如果服务商不提供托管页面,并要求你提交 SOAP 请求,表单应通过 HTTPS 发送到你自己的后端:

浏览器表单 → 你的 HTTPS 后端 → London & Zurich SOAP API

不能使用下面的流程:

浏览器 → 公开数据库
服务商 → 直接读取你的会员数据库

简化后的 HTML 表单可以写成:

<form method="post" action="/direct-debit/mandates">
  <label>
    Account holder
    <input
      name="accountHolder"
      type="text"
      autocomplete="name"
      maxlength="100"
      required
    >
  </label>

  <label>
    Sort code
    <input
      name="sortCode"
      type="text"
      inputmode="numeric"
      autocomplete="off"
      pattern="[0-9 -]{6,8}"
      required
    >
  </label>

  <label>
    Account number
    <input
      name="accountNumber"
      type="text"
      inputmode="numeric"
      autocomplete="off"
      pattern="[0-9]{8}"
      required
    >
  </label>

  <select name="billingInterval" required>
    <option value="monthly">Monthly</option>
    <option value="quarterly">Every 3 months</option>
  </select>

  <input type="hidden" name="csrfToken" value="SERVER_GENERATED_TOKEN">
  <button type="submit">Set up Direct Debit</button>
</form>

字段格式必须按照服务商的要求实现。不能仅根据这个示例,假定所有账户都使用相同的长度或校验规则。

后端收到资料后,应立即验证并提交,尽量不要先写入业务数据库。生成 XML 时应使用成熟的 SOAP/XML 库,避免手动拼接字符串:

const payload = {
  MerchantReference: membership.id,
  AccountHolderName: accountHolder,
  SortCode: normalizedSortCode,
  AccountNumber: normalizedAccountNumber,
  BillingInterval: billingInterval
};

const result = await directDebitClient.CreateMandateAsync(payload);

真实的 operation 名称、对象结构、namespace 和认证头必须从 WSDL 及服务商文档中获取。不要根据示例猜测 CreateMandate 等接口名称。

如果所用语言支持根据 WSDL 生成客户端,应优先生成强类型客户端。例如,.NET 通常可以使用 Visual Studio 的 Connected Services 或 dotnet-svcutil。这种方式是否适用于该接口,仍取决于服务商提供的 WSDL。

SOAP 集成需要特别注意的事项

使用 SOAP 并不代表接口自然就是安全的。实际安全性取决于传输、认证、证书校验和数据处理方式。

至少需要做到:

  • 只连接服务商提供的固定 HTTPS endpoint;
  • 正常校验 TLS 证书,不能关闭证书验证;
  • 将凭据保存在 secret manager 或受保护的环境配置中;
  • 如果服务商要求 WS-Security、客户端证书或 IP allowlist,严格按照文档配置;
  • 使用库生成 XML,并确保 namespace 和字符转义正确;
  • 设置连接超时和读取超时;
  • 网络超时后,通过安全的重试和状态查询机制处理;
  • 使用唯一的 merchant reference 或 idempotency key,避免重复建立 mandate;
  • 不在日志中记录完整的 SOAP request/response;
  • 将错误信息脱敏后再返回前端;
  • 不允许用户控制 SOAP endpoint,以免产生 SSRF 风险;
  • 校验服务商响应的结构,不能直接信任其中的跳转地址或状态值。

发生超时时,不要简单地再次创建 mandate,因为第一次请求可能已经成功。应使用唯一引用查询原请求的结果,或按照服务商提供的幂等机制重试。

Webhook 必须验签

状态变化通常会异步发生。服务商可能稍后通知 mandate 已确认、付款失败或付款被取消。收到 webhook 后,必须先验签,再更新数据库。

app.post(
  "/webhooks/direct-debit",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    const signature = req.get("X-Provider-Signature");

    if (!verifyProviderSignature(
      req.body,
      signature,
      process.env.DD_WEBHOOK_SECRET
    )) {
      return res.sendStatus(401);
    }

    const event = JSON.parse(req.body.toString("utf8"));

    if (await webhookAlreadyProcessed(event.id)) {
      return res.sendStatus(200);
    }

    await processProviderEvent(event);
    await markWebhookProcessed(event.id);

    res.sendStatus(200);
  }
);

X-Provider-Signature、签名算法和 event 字段都只是示意。实施时必须使用 London & Zurich 规定的 header、原始请求体和验证算法,同时处理重复通知、乱序通知和暂时性数据库故障。

必须落实的安全措施

传输和应用安全

  • 所有注册、登录和付款页面都强制使用 HTTPS;
  • 启用 HSTS,并为 session cookie 设置 Secure、HttpOnly 和适当的 SameSite;
  • 为表单启用 CSRF 防护;
  • 对所有输入执行服务端白名单验证;
  • 实施速率限制并监控异常请求;
  • 后端只能访问服务商指定的地址;
  • 生产、测试和开发环境使用不同的凭据;
  • 定期轮换 API key、密码和 webhook secret。

数据最小化

只收集建立 mandate 必需的信息。如果电话号码、地址或出生日期不是服务商或合规流程要求的字段,就不要因为以后可能有用而收集。

如果确实必须临时保存银行账户资料,应采取以下措施:

  • 使用受管理的密钥进行字段级加密;
  • 将密钥与数据库分开保存;
  • 严格限制后台、客服和开发人员的访问权限;
  • 展示账户信息时进行掩码处理;
  • 建立访问审计、保留期限和自动删除机制;
  • 确保备份、日志、错误追踪系统和分析平台不会复制明文资料。

普通哈希不适用于需要再次提交给服务商的账户资料,因为哈希无法还原。需要还原时应使用加密;如果不再需要原值,就不要保存。

日志和监控

银行资料很容易通过调试日志意外泄露,需要检查以下位置:

  • HTTP access log 是否记录 request body 或 query string;
  • APM、错误追踪和 session replay 是否捕获表单内容;
  • SOAP 调试日志是否记录完整 envelope;
  • 客服后台是否显示完整账户;
  • 数据库快照和备份是否受到同等保护。

日志只应保留内部会员编号、服务商引用、状态、错误码和必要的时间信息。

PCI DSS 与数据保护责任

如果实际采集的是银行卡号、有效期或 CVV,这属于 card payment 场景,需要按照支付服务商的要求评估 PCI DSS 范围。CVV 不得在授权后保存。

如果采集的是 Direct Debit 银行账户信息,通常不属于传统的银行卡数据处理,但这些资料仍是敏感的个人和金融信息。你仍需遵守适用的数据保护法规、Bacs 规则、服务商合同以及组织自身的安全义务。具体责任取决于所在司法辖区、商户角色和服务商的接入模式,应请服务商的合规团队或具备资质的顾问确认。

实施顺序

实际落地时,可以按以下顺序进行:

  1. 确认要收集的是 Direct Debit 银行账户资料,而不是 debit card details。
  2. 向 London & Zurich 索取完整的 onboarding/integration guide、测试环境、WSDL 和凭据。
  3. 优先选择 hosted mandate page 或 tokenized component。
  4. 在后端建立服务商适配层,不能让浏览器直接持有 API 凭据。
  5. 数据库只保存业务状态和服务商引用。
  6. 实现并验证 monthly 和 quarterly 两种计划的映射。
  7. 实现 webhook 验签、幂等处理和状态同步。
  8. 完成日志脱敏、权限控制、密钥管理和数据删除策略。
  9. 在测试环境中覆盖成功、拒绝、重复提交、超时、逾期、取消和 webhook 重放等场景。
  10. 通过服务商要求的上线审核后,再切换到正式 endpoint 和正式凭据。

这类集成不存在一个能够安全套用于所有供应商的通用 SOAP 请求。开始实施前,不应急着创建数据库,而要先确认服务商支持的正式接入模式,并尽可能让敏感银行资料直接进入服务商控制的环境。

备注:内容仅供参考。