PAYATHON 2026

如何用 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 内的记录时,这个索引可以避免扫描整张表。

备注:内容仅供参考。