Sofort 收款后如何在 Node.js 中执行函数?
结论
不能只根据 success_url 的跳转判断付款成功。这个地址由用户浏览器访问,用户可能中途关闭页面、重复访问,甚至手动构造请求。
可靠的处理流程如下:
- 将
notification_url配置为 Sofort 服务器可以通过公网访问的 HTTPS 地址。 - 在 Node.js 中接收 Sofort 发送的服务器端通知。
- 从通知中获取 transaction ID。
- 使用 transaction ID 调用 Sofort 的交易查询接口。
- 核对金额、币种、订单号和交易状态。
- 确认状态符合业务要求后,再执行发货、充值或更新订单等操作。
通知通常只表示交易状态发生了变化。最终状态仍需通过 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或不必要的支付敏感信息。
备注:内容仅供参考。
