PAYATHON 2026

如何使用 PHP 和 MySQL event scheduler 创建月度付款周期?

支付老李

结论

不要通过比较注册时间 date_time 和 last_paid 来判断是否已经付款,也不要为 last_paid 设置 ON UPDATE CURRENT_TIMESTAMP。只要合作伙伴记录发生更新,这种设置就会改写付款时间,意外重置付款周期。

建议采用以下方式:

  1. 为每个合作伙伴单独保存 next_payment_at。
  2. 使用 MySQL Event Scheduler,每小时或每天扫描到期记录。
  3. 在同一条 UPDATE 语句中增加 balance,同时将 next_payment_at 向后推进 30 天。
  4. PHP 只在注册时初始化付款日期,并按需展示或管理付款记录。

需要注意,“每 30 天”和”每个自然月”不是一回事。下面的方案按固定 30 天计算。

调整表结构

假设合作伙伴表名为 partners,先增加下一次付款时间字段:

ALTER TABLE partners
    ADD COLUMN next_payment_at DATETIME NULL,
    ADD INDEX idx_partner_payment_due (next_payment_at, package);

注册合作伙伴时,将首次付款时间设为注册时间的 30 天后:

INSERT INTO partners (
    package,
    balance,
    date_time,
    last_paid,
    next_payment_at
) VALUES (
    'a',
    0,
    NOW(),
    NULL,
    DATE_ADD(NOW(), INTERVAL 30 DAY)
);

last_paid 应由付款操作显式更新,不应使用 ON UPDATE CURRENT_TIMESTAMP。例如:

ALTER TABLE partners
    MODIFY COLUMN last_paid DATETIME NULL DEFAULT NULL;

这样只有在实际完成付款后,last_paid 才会更新。

创建定时付款事件

假设 package = 'a' 每个周期发放 100.00,可以创建一个每小时执行一次的事件:

SET GLOBAL event_scheduler = ON;

启用 Event Scheduler 通常需要相应的数据库管理权限。有些托管数据库不允许普通用户修改全局配置,需要在管理后台开启,或者联系数据库管理员处理。

创建事件:

DELIMITER $$

CREATE EVENT pay_due_partners
ON SCHEDULE EVERY 1 HOUR
STARTS CURRENT_TIMESTAMP
DO
BEGIN
    UPDATE partners
    SET
        balance = balance + 100.00,
        last_paid = NOW(),
        next_payment_at = DATE_ADD(next_payment_at, INTERVAL 30 DAY)
    WHERE package = 'a'
      AND next_payment_at IS NOT NULL
      AND next_payment_at <= NOW();
END$$

DELIMITER ;

事件不需要正好每 30 天执行一次。每小时检查一次到期记录更合适,因为合作伙伴的注册时间可能各不相同。

使用以下语句检查事件:

SHOW EVENTS;

查看 Event Scheduler 是否正在运行:

SHOW VARIABLES LIKE 'event_scheduler';

处理多种 package

如果不同套餐对应不同的付款金额,可以使用 CASE:

DELIMITER $$

CREATE EVENT pay_due_partners
ON SCHEDULE EVERY 1 HOUR
STARTS CURRENT_TIMESTAMP
DO
BEGIN
    UPDATE partners
    SET
        balance = balance + CASE package
            WHEN 'a' THEN 100.00
            WHEN 'b' THEN 250.00
            WHEN 'c' THEN 500.00
            ELSE 0.00
        END,
        last_paid = NOW(),
        next_payment_at = DATE_ADD(next_payment_at, INTERVAL 30 DAY)
    WHERE package IN ('a', 'b', 'c')
      AND next_payment_at IS NOT NULL
      AND next_payment_at <= NOW();
END$$

DELIMITER ;

实际项目中,付款金额通常更适合保存在套餐表里,而不是硬编码到事件中。例如:

CREATE TABLE packages (
    code VARCHAR(50) PRIMARY KEY,
    payment_amount DECIMAL(12, 2) NOT NULL
);

然后通过关联更新合作伙伴记录:

DELIMITER $$

CREATE EVENT pay_due_partners
ON SCHEDULE EVERY 1 HOUR
STARTS CURRENT_TIMESTAMP
DO
BEGIN
    UPDATE partners AS p
    JOIN packages AS pkg ON pkg.code = p.package
    SET
        p.balance = p.balance + pkg.payment_amount,
        p.last_paid = NOW(),
        p.next_payment_at = DATE_ADD(p.next_payment_at, INTERVAL 30 DAY)
    WHERE p.next_payment_at IS NOT NULL
      AND p.next_payment_at <= NOW();
END$$

DELIMITER ;

PHP 注册示例

付款不需要依赖持续运行的 PHP 进程。PHP 只需在注册时写入正确的初始数据:

<?php

$sql = <<<'SQL'
INSERT INTO partners (
    package,
    balance,
    date_time,
    last_paid,
    next_payment_at
) VALUES (
    :package,
    0,
    NOW(),
    NULL,
    DATE_ADD(NOW(), INTERVAL 30 DAY)
)
SQL;

$stmt = $pdo->prepare($sql);
$stmt->execute([
    'package' => 'a',
]);

金额字段应使用 MySQL 的 DECIMAL 类型,不建议用 FLOAT 或 DOUBLE 保存货币金额:

ALTER TABLE partners
    MODIFY COLUMN balance DECIMAL(12, 2) NOT NULL DEFAULT 0.00;

数据库停机后的补发策略

上面的事件每次最多发放一个周期。如果数据库停机超过 30 天,恢复后第一次执行会发放一次,之后每小时继续补发,直到 next_payment_at 推进到未来时间。

如果需要一次补齐所有遗漏周期,可以先计算已经到期的周期数:

DELIMITER $$

CREATE EVENT pay_due_partners
ON SCHEDULE EVERY 1 HOUR
STARTS CURRENT_TIMESTAMP
DO
BEGIN
    UPDATE partners AS p
    JOIN packages AS pkg ON pkg.code = p.package
    SET
        p.balance = p.balance
            + pkg.payment_amount
            * (FLOOR(TIMESTAMPDIFF(SECOND, p.next_payment_at, NOW()) / 2592000) + 1),
        p.last_paid = NOW(),
        p.next_payment_at = DATE_ADD(
            p.next_payment_at,
            INTERVAL (
                FLOOR(TIMESTAMPDIFF(SECOND, p.next_payment_at, NOW()) / 2592000) + 1
            ) * 30 DAY
        )
    WHERE p.next_payment_at IS NOT NULL
      AND p.next_payment_at <= NOW();
END$$

DELIMITER ;

这里的 2592000 等于 30 × 24 × 60 × 60 秒。是否允许一次补发多个周期取决于业务规则,应先确定规则,再选择对应的实现。

重要注意事项

  • 不要为 last_paid 设置 ON UPDATE CURRENT_TIMESTAMP。修改姓名、状态或余额都可能改写这个字段。
  • 不要使用 DATEDIFF(...) = 30。如果定时任务当天没有执行,第 31 天就无法再匹配。应使用 next_payment_at <= NOW()。
  • DATE_ADD(..., INTERVAL 30 DAY) 表示固定增加 30 天。如果业务要求在每月同一天付款,应改用 INTERVAL 1 MONTH。
  • 建议增加付款流水表,记录每次付款的金额、周期和执行时间。只保存余额而不记录流水,会给审计、退款和重复付款排查带来困难。
  • 如果金额发放和流水写入必须同时完成,应将它们放入存储过程或事务,并为付款周期添加唯一约束,防止重复入账。
  • 如果运行环境禁用了 MySQL Event Scheduler,可以通过系统 cron 定时调用 PHP 命令行脚本,业务判断仍使用相同的 next_payment_at <= NOW() 逻辑。

备注:内容仅供参考。