PAYATHON 2026

Sofort 收款后如何在 Node.js 中执行函数?

支付小周

结论

不能只根据 success_url 的跳转判断付款成功。这个地址由用户浏览器访问,用户可能中途关闭页面、重复访问,甚至手动构造请求。

可靠的处理流程如下:

  1. 将 notification_url 配置为 Sofort 服务器可以通过公网访问的 HTTPS 地址。
  2. 在 Node.js 中接收 Sofort 发送的服务器端通知。
  3. 从通知中获取 transaction ID。
  4. 使用 transaction ID 调用 Sofort 的交易查询接口。
  5. 核对金额、币种、订单号和交易状态。
  6. 确认状态符合业务要求后,再执行发货、充值或更新订单等操作。

通知通常只表示交易状态发生了变化。最终状态仍需通过 Sofort API 查询确认。

为什么当前地址收不到通知

代码中的通知地址是:

http://localhost:3000/testo

对 Sofort 来说,localhost 指向 Sofort 自己的服务器,并不是你的开发电脑,所以它无法访问本机运行的 Express 服务。

开发时可以使用具有公网 HTTPS 地址的反向代理或隧道服务,把请求转发到本机。例如:

https://example-tunnel.test/sofort/notification

生产环境应使用正式域名:

https://payments.example.com/sofort/notification

还需要确认该地址:

  • 不要求登录或浏览器 Cookie。
  • 不会被 CSRF 中间件拦截。
  • 可以接收 POST 请求和 XML 请求体。
  • 不会被防火墙或反向代理阻断。
  • 可以在较短时间内返回 2xx 状态码。

配置通知地址

创建付款时,可以保留成功跳转地址,同时添加一个可通过公网访问的通知地址:

Sofort.createPayment(
  101.00,
  'EUR',
  ['ORDER-12345'],
  {
    user_variables: ['ORDER-12345'],
    success_url: 'https://shop.example.com/payment/success',
    success_link_redirect: true,
    notification_urls: 'https://payments.example.com/sofort/notification'
  },
  function (err, data) {
    if (err) {
      console.error(err);
      return res.status(502).send('Unable to create payment');
    }

    // 应在这里保存订单号与 Sofort transaction ID 的对应关系。
    console.log(data);
    return res.redirect(data.payment_url);
  }
);

不同版本的 node-sofort 可能采用不同的 notification_urls 数据结构。有些版本接受字符串,有些底层 API 或封装版本可能要求数组,或者要求包含通知原因的对象。具体格式应以当前安装版本的 README 和 Sofort/Klarna 商户接口文档为准。

在 Express 中接收通知

Sofort 的状态通知通常是服务器发送的 POST 请求,请求体可能采用 XML 格式,其中包含 transaction ID。它未必会像普通表单数据一样出现在 req.body 中。如果应用只启用了 express.json() 或 express.urlencoded(),XML 请求体可能无法解析。

可以为通知路由单独接收文本内容:

const express = require('express');
const { XMLParser } = require('fast-xml-parser');

const router = express.Router();
const parser = new XMLParser();

router.post(
  '/sofort/notification',
  express.text({
    type: ['application/xml', 'text/xml', '*/xml']
  }),
  async function (req, res) {
    let transactionId;

    try {
      const payload = parser.parse(req.body);

      transactionId =
        payload?.status_notification?.transaction ||
        payload?.transaction;

      if (!transactionId) {
        return res.status(400).send('Missing transaction ID');
      }

      /*
       * 尽快响应通知。后续处理最好交给持久化任务队列,
       * 避免 Sofort 因超时而重复发送通知。
       */
      res.sendStatus(204);

      await processSofortTransaction(String(transactionId));
    } catch (err) {
      console.error('Invalid Sofort notification:', err);

      if (!res.headersSent) {
        res.sendStatus(400);
      }
    }
  }
);

实际使用的 XML 节点名称和 Content-Type,应以收到的请求及当前接口文档为准。调试期间可以暂时记录请求头和请求体,但不要将密钥、完整银行信息或其他敏感数据写入公开日志。

查询并确认交易状态

收到通知后,不要立即把订单标记为已支付。应先用 transaction ID 查询 Sofort 的交易详情:

async function processSofortTransaction(transactionId) {
  const payment = await getSofortPaymentData(transactionId);

  const order = await findOrderByTransactionId(transactionId);

  if (!order) {
    throw new Error(`Unknown transaction: ${transactionId}`);
  }

  if (payment.currency_code !== order.currency) {
    throw new Error('Currency mismatch');
  }

  if (Number(payment.amount) !== Number(order.amount)) {
    throw new Error('Amount mismatch');
  }

  if (!isConfirmedPaymentStatus(payment)) {
    // 可能仍在处理中,也可能失败、取消或发生其他状态变化。
    return;
  }

  const changed = await markOrderPaidOnce(order.id, transactionId);

  if (changed) {
    await executeAfterPayment(order);
  }
}

getSofortPaymentData() 需要封装当前 SDK 提供的交易查询方法。例如,某些版本的 node-sofort 可能提供名称类似 getPaymentData 的方法:

function getSofortPaymentData(transactionId) {
  return new Promise((resolve, reject) => {
    Sofort.getPaymentData(transactionId, (err, data) => {
      if (err) {
        reject(err);
      } else {
        resolve(data);
      }
    });
  });
}

这里的方法名只是按照常见 SDK 接口形式编写的示例。如果当前版本没有 getPaymentData,需要改用该版本提供的交易查询方法,或者直接调用 Sofort/Klarna 的交易详情 API,不能直接套用这个名称。

状态判断同样要根据接口的实际返回值编写:

function isConfirmedPaymentStatus(payment) {
  return payment.status === 'received';
}

在没有核对文档前,不要假定 received 一定是当前产品和账户配置中的最终状态。Sofort 的状态字段、状态原因,以及”已发起”、“已确认”和”实际到账”之间的含义,可能因接口版本和支付产品而异。应先确定业务需要哪一级确认,再按照当前官方状态定义进行判断。

必须保证函数只执行一次

支付平台可能重复发送通知,同一笔交易也可能经历多次状态变化,因此通知处理必须具有幂等性。

数据库更新可以采用条件写入:

UPDATE orders
SET payment_status = 'paid',
    paid_at = CURRENT_TIMESTAMP,
    payment_transaction_id = ?
WHERE id = ?
  AND payment_status <> 'paid';

只有受影响行数为 1 时,才继续执行后续操作:

async function markOrderPaidOnce(orderId, transactionId) {
  const result = await db.execute(
    `UPDATE orders
     SET payment_status = 'paid',
         paid_at = CURRENT_TIMESTAMP,
         payment_transaction_id = ?
     WHERE id = ?
       AND payment_status <> 'paid'`,
    [transactionId, orderId]
  );

  return result.affectedRows === 1;
}

更稳妥的做法是使用数据库事务,在同一个事务中更新订单并写入 outbox 事件,再由后台任务执行发货或充值。这样即使进程在响应通知后崩溃,后续任务也不会丢失。

success_url 应该做什么

success_url 只用于改善用户体验,例如显示”付款正在确认”:

router.get('/payment/success', async function (req, res) {
  const order = await findOrderForCurrentUser(req);

  if (order.payment_status === 'paid') {
    return res.render('payment-success', { order });
  }

  return res.render('payment-pending', { order });
});

如果希望用户返回后尽快看到结果,可以在该路由中使用服务器保存的 transaction ID 再查询一次交易状态。即使采用这种方式,查询和订单更新也必须在服务器端完成,不能相信浏览器提交的金额、状态或订单号。

其他可行方案

如果暂时无法接收通知,可以定时查询尚未完成的交易:

async function reconcilePendingPayments() {
  const orders = await findPendingSofortOrders();

  for (const order of orders) {
    try {
      await processSofortTransaction(order.transaction_id);
    } catch (err) {
      console.error(
        `Unable to reconcile transaction ${order.transaction_id}:`,
        err
      );
    }
  }
}

轮询适合作为通知机制的补偿措施,不应过于频繁或无限期查询。查询间隔、重试次数和交易保留时间,应根据接口限流规则及业务需求设置。

较完整的处理方案通常包括:

  • 通知 URL,用于及时处理状态变化。
  • 成功页面查询,用于改善用户返回后的体验。
  • 定时对账,用于补偿通知漏发、临时网络错误和服务故障。

安全注意事项

示例中的 configKey 看起来是可以使用的商户凭据。如果该值曾经出现在公开页面、日志或代码仓库中,应立即在商户后台撤销或轮换,不要继续使用。

凭据应通过环境变量读取:

const Sofort = new (require('node-sofort'))({
  configKey: process.env.SOFORT_CONFIG_KEY
});

此外还需要:

  • 全程使用 HTTPS。
  • 不根据 success_url 参数直接更新付款状态。
  • 查询后核对 transaction ID、订单号、金额和币种。
  • 限制通知请求体的大小。
  • 为 API 查询设置超时和重试。
  • 保存原始交易状态及状态变化时间,方便审计和对账。
  • 不在日志中输出 configKey 或不必要的支付敏感信息。

备注:内容仅供参考。