支付埋点里出现三条“成功”记录,不一定代表三笔订单;订单收到两次部分退款,也不能简单把订单金额扣两遍。运营看板需要的不是一个不断增长的事件计数,而是可以逐项解释的业务记录。
先区分三件事:业务上发生了什么、网络投递了几次、指标最终计算了多少。支付成功操作、订单、退款操作、回调投递各自都有标识,不能拿同一个字段包办全部含义。
本文由 SensorFlow 项目维护方提供,AI辅助整理。以下订单与金额全部为合成教学数据,代码只做本机离线校验,不请求支付接口、不发送 SDK 数据,也不是财务核算或清结算系统。
一、先确认“成功”的业务时机
用户点击付款,只能说明发起了尝试。支付页面展示成功、服务端确认支付、第三方异步通知到达,是不同的观察位置,应由能确认对应状态的一端产生业务事件。
例如,支付尝试事件用于分析交互和失败原因;支付成功事件用于记录已确认的成功操作。不要因为用户刷新成功页,就再创造一次业务上的支付成功。
Google 的 GA4 电商文档分别定义了 purchase 与 refund,并通过 transaction_id 关联交易;币种和金额也属于明确的事件信息。这可以作为建模参考,但下面的字段和规则是独立教学契约,不是 GA4 或某个 SDK 的请求协议。
埋点文档至少写清:事件由谁触发、在哪个状态触发、业务标识从哪里取得、重试时哪些字段保持不变。退款申请和退款成功也要分开,不能收到申请就立刻扣减已支付金额。
二、金额必须同时带上单位和币种
100 可能是100元、100分,也可能只是一个字符串。若上报端、导出任务和看板各自猜测,图上的数字可能相差一百倍,却看不出格式错误。
本文只演示人民币 CNY,金额以“分”的正整数字符串保存,例如100元写成 "10000"。这只是本文约定,不能把所有货币都按两位小数处理,也不能直接套进要求其他类型的 SDK 属性。
JavaScript 的 Number 有安全整数范围,MDN 的 isSafeInteger 文档解释了超出范围后的精度问题。本例不先把字符串转成 Number,而是在格式检查后用 BigInt求和,输出时再转回字符串,避免把 BigInt 直接交给 JSON.stringify。
这个做法解决的是示例中的整数精度,不会自动解决折扣分摊、税费、汇率或四舍五入。实际系统应单独定义订单实付、商品金额、税费和运费,不能把不同口径都叫 amount。
三、部分退款要有自己的操作标识
同一笔100元支付,可以分两次成功退款20元和10元。它们关联同一订单,但应有两个不同的退款操作标识;第二次投递同一个20元退款,只能作为重复投递处理。
这里设计两个不同的键:order_id 关联订单,operation_id 识别一次业务操作。operation_id 必须在业务操作产生时确定并在重试中复用,不能每收到一次 HTTP 请求就生成一个新值。
示例约定一个订单只有一次成功支付,允许多个成功退款,累计退款不超过该订单成功支付额。分期、多次成功扣款、撤销、拒付、退款失败和补款不在这个最小模型内,遇到这些业务应扩展模型,而不是删掉断言让数据通过。
四、运行一个可失败的验收脚本
将代码保存为 verify-payment.cjs,在 Node.js 20 或更新版本运行 node verify-payment.cjs。没有数据库、容器和第三方依赖;使用 Node 内置的严格断言模块,测试“哪些记录必须拒绝”,不只是展示一条正常结果。
去重集合使用 Map,但 Map 的键唯一不等于业务数据天然正确。代码必须先比较同键内容,发现冲突时报错,不能悄悄覆盖上一次记录。
'use strict';
const assert = require('node:assert/strict');
function summarize(rows) {
assert.ok(Array.isArray(rows), '输入必须是数组');
const operations = new Map();
for (const r of rows) {
assert.ok(r && ['paid', 'refunded'].includes(r.kind), '未知状态');
for (const k of ['order_id', 'operation_id']) {
assert.ok(typeof r[k] === 'string' &&
/^[a-z0-9-]{1,64}$/.test(r[k]), '标识格式错误:' + k);
}
assert.equal(r.currency, 'CNY', '本例只接收CNY');
assert.ok(typeof r.amount_minor === 'string' &&
/^[1-9][0-9]{0,17}$/.test(r.amount_minor), '金额必须为正整数字符串');
const key = JSON.stringify([r.kind, r.operation_id]);
const content = JSON.stringify([r.order_id, r.currency, r.amount_minor]);
if (operations.has(key)) {
assert.equal(operations.get(key).content, content, '同操作异内容');
} else operations.set(key, {
...r, content });
}
const purchases = new Map();
for (const r of operations.values()) {
if (r.kind !== 'paid') continue;
assert.ok(!purchases.has(r.order_id), '本例每订单只能成功支付一次');
purchases.set(r.order_id, BigInt(r.amount_minor));
}
const refundedByOrder = new Map();
let refunds = 0;
for (const r of operations.values()) {
if (r.kind !== 'refunded') continue;
assert.ok(purchases.has(r.order_id), '退款缺少对应支付');
const sum = (refundedByOrder.get(r.order_id) || 0n) + BigInt(r.amount_minor);
assert.ok(sum <= purchases.get(r.order_id), '累计退款超过支付额');
refundedByOrder.set(r.order_id, sum);
refunds++;
}
const sum = values => [...values].reduce((a, b) => a + b, 0n);
const paid = sum(purchases.values());
const refunded = sum(refundedByOrder.values());
return {
deliveries: rows.length, operations: operations.size,
retries: rows.length - operations.size, paidOrders: purchases.size, refunds,
currency: 'CNY', paidMinor: String(paid), refundedMinor: String(refunded),
netMinor: String(paid - refunded),
};
}
const event = (kind, order_id, operation_id, amount_minor) =>
({
kind, order_id, operation_id, amount_minor, currency: 'CNY' });
const rows = [
event('paid', 'synthetic-a', 'pay-a', '10000'),
event('paid', 'synthetic-b', 'pay-b', '6000'),
event('refunded', 'synthetic-a', 'refund-a1', '2000'),
event('refunded', 'synthetic-a', 'refund-a2', '1000'),
];
rows.push({
...rows[2] }); // 同一退款再次投递,不是再次退款。
let tests = 0;
function test(name, fn) {
fn(); tests++; console.log('PASS', name); }
const expected = {
deliveries: 5, operations: 4, retries: 1, paidOrders: 2, refunds: 2,
currency: 'CNY', paidMinor: '16000', refundedMinor: '3000', netMinor: '13000',
};
test('正常支付与两次部分退款', () => assert.deepEqual(summarize(rows), expected));
test('输入乱序不改变结果', () =>
assert.deepEqual(summarize([...rows].reverse()), expected));
test('没有退款时净额等于支付额', () =>
assert.equal(summarize(rows.slice(0, 2)).netMinor, '16000'));
test('同操作金额冲突被拒绝', () => assert.throws(() =>
summarize([...rows, {
...rows[2], amount_minor: '2001' }]), /同操作异内容/));
test('孤立退款被拒绝', () => assert.throws(() =>
summarize([rows[2]]), /缺少对应支付/));
test('累计超额退款被拒绝', () => assert.throws(() =>
summarize([...rows, event('refunded', 'synthetic-a', 'refund-a3', '8000')]), /超过/));
test('小数金额被拒绝', () => assert.throws(() =>
summarize([{
...rows[0], amount_minor: '100.00' }]), /正整数字符串/));
test('数字类型金额被拒绝', () => assert.throws(() =>
summarize([{
...rows[0], amount_minor: 10000 }]), /正整数字符串/));
test('其他币种不混算', () => assert.throws(() =>
summarize([{
...rows[0], currency: 'USD' }]), /只接收CNY/));
test('同订单第二次支付需要扩展模型', () => assert.throws(() =>
summarize([...rows, event('paid', 'synthetic-a', 'pay-a2', '10000')]), /一次/));
test('未知业务状态被拒绝', () => assert.throws(() =>
summarize([{
...rows[0], kind: 'clicked' }]), /未知状态/));
test('缺少操作标识被拒绝', () => assert.throws(() =>
summarize([{
...rows[0], operation_id: '' }]), /标识格式/));
console.log(JSON.stringify({
tests, result: summarize(rows) }, null, 2));
预期输出12条 PASS。5次投递对应4次唯一业务操作:2次成功支付、2次成功退款、1次重复投递。支付额16000分,退款额3000分,支付净额13000分,也就是本例的130元;不能把5次投递当成5笔订单。
反例测试中的 PASS 表示程序确实拒绝了错误数据,不表示超额退款也能被当成正常金额。输入倒序仍能得到同一结果,是因为程序先收集完整批次再关联支付与退款,不是宣称一个流式接收器可以忽略到达顺序。
五、把验收结果放回真实业务
先从支付业务系统取得可以核对的成功操作和退款状态,再对照埋点导出记录。若导出批次只有退款、没有原支付,应查缺失原因或补齐关联范围,不应把它当作零元订单,也不应直接删掉退款。
指标应同时展示支付额、退款额与支付净额。退款发生日统计、原订单归属日统计、渠道分摊统计可以有不同结果,必须说明时间与归属口径;本例没有时间字段,不能直接作为“今日收入”查询。
代码也没有持久化、并发控制、账户校验或业务授权。生产链路还要处理多个商户的键空间、失败与处理中状态、补发及纠错,并使用可信的业务来源;一段导出数据校验代码不是生产支付账本。
产品和运营需要的是能解释差异的事件契约。下一步可以选一笔测试订单:完成支付、分两次退款、重放一次相同退款通知,写下预期的操作数和金额,再从原始记录核对到报表。先让这一笔说得清楚,再扩大到整批数据。