如何实现自动付款系统?
结论
自动付款不应直接读取 user.€CREDIT 字段并发起转账。比较稳妥的方案是:
- 选择支持批量付款或平台分账的支付服务,例如银行批量付款接口或合规的 marketplace payout 服务。
- 使用独立账本记录余额变动,金额统一以欧分整数保存。
- 通过定时任务按月生成付款批次。
- 为每笔付款设置唯一的幂等键,避免重复支付。
- 根据支付服务发送的 webhook 更新付款状态,并定期对账。
如果 credits 只是站内积分,还要先确定兑换比例、最低付款金额和退款规则,并明确它在法律和会计上是否属于可提现余额。
为什么不能直接遍历用户表付款
下面这种处理方式风险很高:
查询 CREDIT > 0 的用户
逐个付款
将 CREDIT 清零
如果任务在付款成功之后、余额清零之前崩溃,再次运行时,同一用户可能收到重复付款。如果先清零再付款,一旦付款失败,又可能导致余额丢失。
付款通常还是异步的。支付接口返回”已受理”,并不表示资金已经到账。系统至少要区分以下状态:
- 待处理
- 已提交
- 处理中
- 已到账
- 失败
- 已取消
因此,用户余额、付款记录和支付服务返回的状态不能共用一个字段。
推荐的数据结构
金额不要使用 FLOAT 或 DOUBLE。欧元金额应保存为整数欧分,例如将 €12.34 保存为 1234。
CREATE TABLE users (
id BIGINT PRIMARY KEY,
payout_account_id VARCHAR(255),
payout_enabled BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE credit_ledger (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
amount_cents BIGINT NOT NULL,
entry_type VARCHAR(32) NOT NULL,
reference_id VARCHAR(255) NOT NULL,
created_at TIMESTAMP NOT NULL,
UNIQUE (entry_type, reference_id),
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE payouts (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
amount_cents BIGINT NOT NULL,
currency CHAR(3) NOT NULL DEFAULT 'EUR',
period VARCHAR(7) NOT NULL,
status VARCHAR(32) NOT NULL,
idempotency_key VARCHAR(255) NOT NULL UNIQUE,
provider_payout_id VARCHAR(255),
failure_reason TEXT,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
UNIQUE (user_id, period),
FOREIGN KEY (user_id) REFERENCES users(id)
);
credit_ledger 是一份不能随意修改的流水账。例如:
- 用户获得收入:
+1500 - 退款或撤销:
-300 - 创建付款:
-1200
可用余额通过汇总流水计算:
SELECT COALESCE(SUM(amount_cents), 0) AS balance_cents
FROM credit_ledger
WHERE user_id = ?;
数据量较大时,可以增加余额汇总表来加快查询,但账本仍然是审计依据。
每月付款流程
1. 完成收款人验证
用户需要先绑定支付账户或银行账户。付款前应确认:
payout_account_id已存在;- 身份验证和必要的 KYC 检查已经完成;
- 用户所在国家或地区受支付服务支持;
- 用户没有被冻结或受到付款限制;
- 可用余额已经达到最低付款金额。
不要自行保存银行卡号。敏感账户信息应由支付服务托管,系统只保存对应的账户标识。
2. 固定结算周期
例如,每月 1 日结算上一个自然月。付款金额应以结算截止时间为准,不能直接读取任务运行瞬间的余额,否则任务延迟或重试可能改变付款结果。
结算周期可以表示为:
period = 2026-08
cutoff = 2026-09-01T00:00:00Z
统计时只计算 cutoff 之前已经确认,并且尚未进入其他付款批次的 credits。
3. 在数据库事务中创建付款记录
先创建内部付款记录,冻结或扣减相应余额,然后再调用外部付款接口。不要在数据库事务中执行耗时的网络请求。
BEGIN;
SELECT id
FROM users
WHERE id = ?
FOR UPDATE;
SELECT COALESCE(SUM(amount_cents), 0) AS balance_cents
FROM credit_ledger
WHERE user_id = ?
AND created_at < ?;
-- 应用最低付款额、退款准备金等规则后得到 payout_amount。
INSERT INTO payouts (
id,
user_id,
amount_cents,
currency,
period,
status,
idempotency_key,
created_at,
updated_at
) VALUES (
?, ?, ?, 'EUR', ?, 'pending', ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
);
INSERT INTO credit_ledger (
id,
user_id,
amount_cents,
entry_type,
reference_id,
created_at
) VALUES (
?, ?, -?, 'payout_reserved', ?, CURRENT_TIMESTAMP
);
COMMIT;
UNIQUE (user_id, period) 可以防止同一用户在一个结算期内生成两笔付款。如果业务允许拆分同一周期的付款,就要改用粒度更细的批次编号。
4. 通过队列提交付款
定时任务只负责生成付款记录,再由后台 worker 调用支付服务。下面使用抽象接口表示,不绑定具体供应商:
type Payout = {
id: string;
userId: string;
amountCents: number;
currency: "EUR";
payoutAccountId: string;
idempotencyKey: string;
};
interface PaymentProvider {
createPayout(input: {
destination: string;
amount: number;
currency: string;
idempotencyKey: string;
metadata: Record<string, string>;
}): Promise<{
providerPayoutId: string;
status: "processing" | "paid" | "failed";
}>;
}
async function submitPayout(
payout: Payout,
provider: PaymentProvider
): Promise<void> {
const result = await provider.createPayout({
destination: payout.payoutAccountId,
amount: payout.amountCents,
currency: payout.currency,
idempotencyKey: payout.idempotencyKey,
metadata: {
internalPayoutId: payout.id
}
});
await updatePayoutAfterSubmission({
payoutId: payout.id,
providerPayoutId: result.providerPayoutId,
status: result.status
});
}
幂等键应在创建付款记录时生成并永久保存,例如:
monthly-payout:{userId}:{period}
任务重试时必须继续使用原来的幂等键,不能重新生成随机值。
5. 使用 webhook 确认最终结果
支付服务通常会异步通知付款成功或失败。系统收到 webhook 后需要:
- 验证签名;
- 保存事件 ID;
- 忽略已经处理过的事件;
- 根据
provider_payout_id查找内部付款记录; - 更新状态;
- 付款失败时释放预留金额,或者转入人工处理。
async function handlePayoutWebhook(event: ProviderEvent): Promise<void> {
await database.transaction(async (tx) => {
const inserted = await tx.insertWebhookEventIfAbsent(event.id);
if (!inserted) {
return;
}
const payout = await tx.findPayoutByProviderId(
event.providerPayoutId
);
if (!payout) {
throw new Error("Unknown provider payout");
}
if (event.type === "payout.paid") {
await tx.markPayoutPaid(payout.id);
}
if (event.type === "payout.failed") {
await tx.markPayoutFailed(payout.id, event.reason);
await tx.addLedgerEntry({
userId: payout.userId,
amountCents: payout.amountCents,
entryType: "payout_released",
referenceId: payout.id
});
}
});
}
这里的事件名称和字段只是抽象示例,实际值应以所选支付服务的官方 API 文档为准。
定时任务如何安排
可以使用 cron、云调度器或任务队列,在每月的固定时间启动付款批次:
0 2 1 * *
这条表达式表示每月 1 日 02
运行,具体时区由运行环境决定。结算系统最好统一使用 UTC,界面显示时再转换成用户所在时区。定时任务必须能够安全重跑。不要假设任务每月只会执行一次,而要依靠数据库唯一约束和幂等键,保证重复运行不会造成重复付款。
必须处理的异常
生产环境至少要处理以下情况:
- 用户没有绑定有效的收款账户;
- 余额低于最低付款额,需要自动结转到下个月;
- 提交付款时发生超时,无法确认服务端是否已经接受请求;
- webhook 重复、乱序或延迟到达;
- 付款失败后需要恢复余额;
- credits 在付款前被退款或撤销;
- 多个 worker 同时处理同一笔付款;
- 支付币种与用户账户币种不一致;
- 支付服务收取手续费;
- 实际付款金额与账本金额不一致;
- 用户账户已被冻结,但付款记录已经生成;
- 月末任务只有部分付款成功,其余付款失败。
遇到网络超时,不要马上创建一笔新付款。应使用原幂等键重试,或者向支付服务查询原请求的状态。
安全与合规注意事项
自动付款涉及真实资金,通常还要处理:
- 身份验证和反洗钱要求;
- 税务资料及年度申报;
- 用户收入凭证或付款明细;
- 退款、争议和负余额;
- 数据保护和访问审计;
- API 密钥的安全存储与轮换;
- webhook 防伪和重放保护;
- 大额或异常付款的人工审核。
具体义务取决于运营主体、用户所在地、付款性质和支付服务条款,需要请会计或法律专业人士确认。
最小可行实现
一个可靠的初始版本可以先限定为:
- 只支持 EUR;
- 每月结算一次;
- 余额以欧分保存;
- 余额达到最低金额后才付款;
- 每位用户每月最多收到一笔付款;
- 使用支付服务托管收款账户;
- 由定时任务生成付款记录;
- 由队列 worker 提交付款;
- 通过 webhook 确认最终状态;
- 每日自动对账;
- 将失败付款放入可重试队列或人工处理队列。
重点不在于”每月执行一次付款循环”,而在于建立可靠的账本和状态流程。即使任务中断、请求超时或通知重复,系统也不能重复付款或丢失余额。
备注:内容仅供参考。
