如何用 PHP 计算用户 billing cycle 的日期范围?
数据小许
结论
不要用 mktime() 手动计算月份。可以将注册日期作为 billing cycle 的锚点,用 DateTimeImmutable 算出目标时间所在的周期。周期采用左闭右开区间:
cycle_start <= timestamp < cycle_end
输入日期有两种可能的解释:
- 按
d/m/Y解析时,03/07/2012是2012-07-03,04/09/2013是2013-09-04。对应的周期为2013-09-03到2013-10-03,不包含结束时间。 - 按美式
m/d/Y解析时,两者分别是2012-03-07和2013-04-09。对应的周期为2013-04-07到2013-05-07。
PHP 实现
下面的函数会保留注册时刻的时、分、秒,也能处理注册日在每月 29、30 或 31 日的情况。
<?php
/**
* 返回指定时间所属的 billing cycle。
*
* @return array{start: DateTimeImmutable, end: DateTimeImmutable}
*/
function getBillingCycle(
DateTimeImmutable $registeredAt,
DateTimeImmutable $targetAt
): array {
if ($targetAt < $registeredAt) {
throw new InvalidArgumentException(
'目标时间不能早于用户注册时间'
);
}
$yearDiff = (int) $targetAt->format('Y')
- (int) $registeredAt->format('Y');
$monthDiff = (int) $targetAt->format('n')
- (int) $registeredAt->format('n');
$cycleIndex = $yearDiff * 12 + $monthDiff;
$cycleStart = getCycleBoundary($registeredAt, $cycleIndex);
// 目标日期可能还没有到当月的周期起点。
if ($targetAt < $cycleStart) {
$cycleIndex--;
$cycleStart = getCycleBoundary($registeredAt, $cycleIndex);
}
$cycleEnd = getCycleBoundary($registeredAt, $cycleIndex + 1);
return [
'start' => $cycleStart,
'end' => $cycleEnd,
];
}
function getCycleBoundary(
DateTimeImmutable $registeredAt,
int $monthOffset
): DateTimeImmutable {
$month = $registeredAt
->modify('first day of this month')
->modify(sprintf('%+d months', $monthOffset));
$billingDay = (int) $registeredAt->format('j');
$lastDayOfMonth = (int) $month->format('t');
return $month->setDate(
(int) $month->format('Y'),
(int) $month->format('n'),
min($billingDay, $lastDayOfMonth)
);
}
假设输入日期采用 d/m/Y:
$timezone = new DateTimeZone('Asia/Shanghai');
$registeredAt = DateTimeImmutable::createFromFormat(
'!d/m/Y',
'03/07/2012',
$timezone
);
$targetAt = DateTimeImmutable::createFromFormat(
'!d/m/Y',
'04/09/2013',
$timezone
);
if ($registeredAt === false || $targetAt === false) {
throw new RuntimeException('日期格式无效');
}
$cycle = getBillingCycle($registeredAt, $targetAt);
echo $cycle['start']->format('Y-m-d H:i:s');
// 2013-09-03 00:00:00
echo $cycle['end']->format('Y-m-d H:i:s');
// 2013-10-03 00:00:00
可以这样判断目标时间是否在该周期内:
$isInCycle =
$targetAt >= $cycle['start']
&& $targetAt < $cycle['end'];
查询当前周期内的 offer 数量
数据库查询同样使用左闭右开的时间范围:
SELECT COUNT(*) AS offer_count
FROM offers
WHERE user_id = :user_id
AND created_at >= :cycle_start
AND created_at < :cycle_end;
PHP 参数可以按下面的方式生成:
$params = [
'user_id' => $userId,
'cycle_start' => $cycle['start']->format('Y-m-d H:i:s'),
'cycle_end' => $cycle['end']->format('Y-m-d H:i:s'),
];
如果规则是”每个周期最多提交三次”,用户准备提交第四次时,查到的已有数量就是 3:
if ($offerCount >= 3) {
throw new RuntimeException('当前 billing cycle 的 offer 次数已达上限');
}
为什么使用左闭右开区间
不要将结束时间设为 23:59:59。数据库字段可能保存微秒,周期边界也可能包含具体的时、分、秒。使用下面的条件:
created_at >= cycle_start
AND created_at < cycle_end
可以防止同一条记录被相邻两个周期重复统计。
需要注意的边界情况
- 输入格式必须明确指定为
d/m/Y或m/d/Y,不要让 PHP 自动猜测。 - PHP 的”日期加一个月”存在溢出行为。例如,直接给
January 31加一个月,结果不一定是预期的二月末。示例代码会先找到目标月份,再把日期限制在该月的最后一天。 - 如果用户在 31 日注册,没有 31 日的月份会把当月最后一天作为周期边界。如果业务采用其他规则,需要调整
getCycleBoundary()。 - PHP、MySQL 和应用配置应使用相同的时区。较常见的做法是数据库统一保存 UTC,展示时再转换为用户所在的时区。
- 可以为
offers表的查询字段建立联合索引:
CREATE INDEX idx_offers_user_created
ON offers (user_id, created_at);
统计某个用户在指定 billing cycle 内的记录时,这个索引可以避免扫描整张表。
备注:内容仅供参考。
