如何使用 PHP 和 MySQL event scheduler 创建月度付款周期?
结论
不要通过比较注册时间 date_time 和 last_paid 来判断是否已经付款,也不要为 last_paid 设置 ON UPDATE CURRENT_TIMESTAMP。只要合作伙伴记录发生更新,这种设置就会改写付款时间,意外重置付款周期。
建议采用以下方式:
- 为每个合作伙伴单独保存
next_payment_at。 - 使用 MySQL Event Scheduler,每小时或每天扫描到期记录。
- 在同一条
UPDATE语句中增加balance,同时将next_payment_at向后推进 30 天。 - 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()逻辑。
备注:内容仅供参考。
