(重复到某天为止) * * ⚠️ 展开只按**日期**比对范围:日程的时刻在事件本身(starts_at), * 重复出来的每一次共用同一个时刻。这是日历的通行做法 * ("每周三 19:00"里的 19:00 属于事件,不属于规则)。 */ declare(strict_types=1); namespace App; final class Repeat { /** 界面上的重复周期 → FREQ 片段 */ public const FREQS = [ '' => '不重复', 'daily' => '每天', 'weekly' => '每周', 'monthly' => '每月', 'yearly' => '每年', 'weekday' => '每个工作日(周一至周五)', ]; /** 结束条件 */ public const ENDS = [ 'never' => '一直重复', 'count' => '重复指定次数', 'until' => '重复到某天为止', ]; /** 周一..周日(RFC 5545 的 BYDAY 记号;0=周日,与 date('w') 对齐后转换) */ private const WEEKDAY_CODE = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA']; /** 展开的硬上限:防止 COUNT 极大或 UNTIL 很远时把请求拖死 */ private const MAX_OCCURRENCES = 2000; /** * 界面字段 → RRULE 原文。返回 '' 表示不重复。 * * @param array $d freq / rep_end / rep_count / rep_until */ public static function build(array $d): string { $freq = (string)($d['freq'] ?? ''); if (!isset(self::FREQS[$freq]) || $freq === '') { return ''; } $parts = []; switch ($freq) { case 'daily': $parts[] = 'FREQ=DAILY'; break; case 'weekly': $parts[] = 'FREQ=WEEKLY'; break; case 'monthly': $parts[] = 'FREQ=MONTHLY'; break; case 'yearly': $parts[] = 'FREQ=YEARLY'; break; case 'weekday': // 工作日 = 每周 + 明确点名周一到周五(不要用 INTERVAL=1 靠"跳过周末"实现, // 那样节假日与调休都对不上,客户端也不认) $parts[] = 'FREQ=WEEKLY'; $parts[] = 'BYDAY=MO,TU,WE,TH,FR'; break; } $end = (string)($d['rep_end'] ?? 'never'); if ($end === 'count') { $n = (int)($d['rep_count'] ?? 0); if ($n >= 1) { // ⚠️ COUNT 含**首次**:用户填 10 就是"一共 10 次"(含第一次)。 // RFC 也是这么算的,别自作聪明 +1。 $parts[] = 'COUNT=' . min($n, self::MAX_OCCURRENCES); } } elseif ($end === 'until') { $u = trim((string)($d['rep_until'] ?? '')); if (preg_match('/^(\d{4})-(\d{2})-(\d{2})/', $u, $m) && checkdate((int)$m[2], (int)$m[3], (int)$m[1])) { // UNTIL 一律写成**当天的 23:59:59 UTC**:这样"重复到 12-31"意味着 // 12-31 这一天也包含在内(写成 00:00:00 会把最后那天砍掉)。 $parts[] = 'UNTIL=' . $m[1] . $m[2] . $m[3] . 'T235959Z'; } } return implode(';', $parts); } /** RRULE 原文 → 结构化(认不出的键忽略,不报错) */ public static function parse(string $rrule): array { $out = ['freq' => '', 'interval' => 1, 'byday' => [], 'count' => 0, 'until' => '']; $rrule = trim($rrule); if ($rrule === '') { return $out; } foreach (explode(';', $rrule) as $kv) { $kv = trim($kv); if ($kv === '') { continue; } $eq = strpos($kv, '='); if ($eq === false) { continue; } $k = strtoupper(substr($kv, 0, $eq)); $v = substr($kv, $eq + 1); switch ($k) { case 'FREQ': $f = strtoupper($v); if ($f === 'WEEKLY' && strtoupper($v) !== '' && strpos(strtoupper($rrule), 'BYDAY=MO,TU,WE,TH,FR') !== false) { $out['freq'] = 'weekday'; } else { $out['freq'] = ['DAILY' => 'daily', 'WEEKLY' => 'weekly', 'MONTHLY' => 'monthly', 'YEARLY' => 'yearly'][$f] ?? ''; } break; case 'INTERVAL': $out['interval'] = max(1, (int)$v); break; case 'BYDAY': foreach (explode(',', strtoupper($v)) as $d) { $d = trim($d); if ($d !== '') { $out['byday'][] = $d; } } break; case 'COUNT': $out['count'] = max(0, (int)$v); break; case 'UNTIL': // 20261231T235959Z → 2026-12-31 if (preg_match('/^(\d{4})(\d{2})(\d{2})/', $v, $m)) { $out['until'] = $m[1] . '-' . $m[2] . '-' . $m[3]; } break; } } return $out; } /** RRULE → 一句人话(列表/详情页用) */ public static function describe(string $rrule): string { $p = self::parse($rrule); if ($p['freq'] === '') { return ''; } $word = self::FREQS[$p['freq']]; if ($p['interval'] > 1) { $unit = ['daily' => '天', 'weekly' => '周', 'monthly' => '个月', 'yearly' => '年'][$p['freq']] ?? ''; if ($unit !== '' && $p['freq'] !== 'weekday') { $word = '每 ' . $p['interval'] . ' ' . $unit; } } if ($p['count'] > 0) { $word .= ',共 ' . $p['count'] . ' 次'; } elseif ($p['until'] !== '') { $word .= ',直到 ' . $p['until']; } else { $word .= ',一直重复'; } return $word; } /** * 把一条日程在 [from, to] 内的**每一次发生**列出来。 * * @param string $dtstart 事件开始时刻 'Y-m-d H:i:s'(重复的锚点) * @param string $rrule 规则原文;'' 表示不重复 * @param array $skips 被跳过的日期 ['Y-m-d' => true](单个发生被取消/被挪走) * @return array 日期列表('Y-m-d'),升序,**不含**被跳过的 */ public static function occurrences(string $dtstart, string $rrule, string $from, string $to, array $skips = []): array { if ($rrule === '') { $d = substr($dtstart, 0, 10); return ($d >= $from && $d <= $to) ? [$d] : []; } $p = self::parse($rrule); if ($p['freq'] === '') { $d = substr($dtstart, 0, 10); return ($d >= $from && $d <= $to) ? [$d] : []; } $start = substr($dtstart, 0, 10); if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $start)) { return []; } $count = 0; // 已生成的发生次数(**含**被跳过的,RFC 就是这么数的) $out = []; $guard = 0; $emit = static function (string $d) use (&$out, &$count, $from, $to, $skips): void { $count++; if ($d > $to) { return; // 超出范围就不再关心 } if ($d >= $from && !isset($skips[$d])) { $out[] = $d; } }; // 是否还可以继续生成:COUNT 到了 / UNTIL 过了 / 超出范围 / 硬上限 $canGo = static function (string $d) use (&$count, $p, $to): bool { if ($p['count'] > 0 && $count >= $p['count']) { return false; } if ($p['until'] !== '' && $d > $p['until']) { return false; } return $d <= $to; // 已经超过范围末端 }; $interval = max(1, (int)$p['interval']); switch ($p['freq']) { case 'daily': $d = $start; while ($guard++ < self::MAX_OCCURRENCES && $canGo($d)) { $emit($d); $d = date('Y-m-d', strtotime($d . ' +' . $interval . ' day')); } break; case 'weekly': case 'weekday': // BYDAY 缺省 = 与 DTSTART 同一个星期几 $byday = $p['byday']; if (!$byday) { $byday = [self::WEEKDAY_CODE[(int)date('w', strtotime($start))]]; } $codes = array_flip(self::WEEKDAY_CODE); // 'MO' => 1(与 date('w') 一致) // 从 DTSTART 所在那一周的周一开始走 $weekStart = date('Y-m-d', strtotime($start . ' -' . ((int)date('w', strtotime($start)) + 6) % 7 . ' day')); $wk = $weekStart; while ($guard++ < self::MAX_OCCURRENCES && $canGo($wk)) { // 一周内按周一→周日顺序 $days = []; for ($i = 0; $i < 7; $i++) { $day = date('Y-m-d', strtotime($wk . ' +' . $i . ' day')); $code = self::WEEKDAY_CODE[(int)date('w', strtotime($day))]; if (in_array($code, $byday, true)) { $days[] = $day; } } sort($days); foreach ($days as $day) { if ($day < $start) { continue; // DTSTART 之前的不算 } if (!$canGo($day)) { break 2; } $emit($day); } $wk = date('Y-m-d', strtotime($wk . ' +' . ($interval * 7) . ' day')); } break; case 'monthly': $dom = (int)substr($start, 8, 2); $y = (int)substr($start, 0, 4); $m = (int)substr($start, 5, 2); $d = $start; while ($guard++ < self::MAX_OCCURRENCES && $canGo($d)) { $emit($d); // ⚠️ 该月没有这一天(如 31 号遇到只有 30 天的月)→ 按 RFC **跳过该月**, // 绝不能"顺延到下月 1 号" —— 那样"每月 31 号"会变成一堆 1 号, // 而且 1 号往往还有别的事,看起来像重复错乱(实测踩到过)。 $m += $interval; while ($m > 12) { $m -= 12; $y++; } if (!checkdate($m, $dom, $y)) { continue; // 这个月没有该日 → 直接看下一个月 } $d = sprintf('%04d-%02d-%02d', $y, $m, $dom); } break; case 'yearly': $mm = (int)substr($start, 5, 2); $dom = (int)substr($start, 8, 2); $y = (int)substr($start, 0, 4); $d = $start; while ($guard++ < self::MAX_OCCURRENCES && $canGo($d)) { $emit($d); $y += $interval; // 2/29 遇平年 → 跳过那年(RFC 行为;要"每年 2/28 提醒"请用纪念日模块) if (!checkdate($mm, $dom, $y)) { $d = sprintf('%04d-%02d-01', $y, $mm); continue; } $d = sprintf('%04d-%02d-%02d', $y, $mm, $dom); } break; } sort($out); return array_values(array_unique($out)); } }