PAYATHON 2026

如何实现自动付款系统?

支付老李

结论

自动付款不应直接读取 user.€CREDIT 字段并发起转账。比较稳妥的方案是:

  1. 选择支持批量付款或平台分账的支付服务,例如银行批量付款接口或合规的 marketplace payout 服务。
  2. 使用独立账本记录余额变动,金额统一以欧分整数保存。
  3. 通过定时任务按月生成付款批次。
  4. 为每笔付款设置唯一的幂等键,避免重复支付。
  5. 根据支付服务发送的 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 后需要:

  1. 验证签名;
  2. 保存事件 ID;
  3. 忽略已经处理过的事件;
  4. 根据 provider_payout_id 查找内部付款记录;
  5. 更新状态;
  6. 付款失败时释放预留金额,或者转入人工处理。
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 确认最终状态;
  • 每日自动对账;
  • 将失败付款放入可重试队列或人工处理队列。

重点不在于”每月执行一次付款循环”,而在于建立可靠的账本和状态流程。即使任务中断、请求超时或通知重复,系统也不能重复付款或丢失余额。

备注:内容仅供参考。