`),而不是每次请求都去抓: * 日历页是高频页面,把外网请求放渲染路径上会让它随外网抖动而变慢。 */ declare(strict_types=1); namespace App; final class Holiday { /** 官方数据源(公开镜像,按年一个 JSON:{year, papers, days:[{name,date,isOffDay}]}) */ private const SOURCE = 'https://cdn.jsdelivr.net/gh/NateScarlet/holiday-cn@master/%d.json'; private const SOURCE_ALT = 'https://raw.githubusercontent.com/NateScarlet/holiday-cn/master/%d.json'; /** 缓存有效期:查到一次就够用很久(官方安排一年只公布一次),失败则短缓存避免反复打) * 单位为秒。写成常量方便测试里改。 */ public const CACHE_OK = 604800; // 7 天 public const CACHE_FAIL = 21600; // 6 小时 /** * 公历固定日期的节日:月 => [日, 名字, **放假天数**]。 * 只放**日期固定**的:清明是节气(4月4/5/6日浮动)要算,不在这里。 * * ⚠️ v24 第 11 批 #6(用户报「国庆节只有刚开始的一天有,这不合理」): * 这里原来只标**一天**,于是日历上国庆只有 10-01 那一条 —— 而法定假是 7 天。 * 现在把**法定放假天数**一起写上,内置规则按区间铺开。 * 天数按《全国年节及纪念日放假办法》的法定假口径: * 元旦 3 天(通常 1/1 + 周末调休,保守写 1 天由官方数据补)、 * 劳动节 5 天、国庆节 7 天。 * ⚠️ 这里的数字是**内置兜底**(官方数据一到就整体替换,见 fetchAndMerge), * 所以宁可保守也不要编造调休:调休只有官方才准。 */ private const FIXED = [ 1 => [1 => ['元旦', 3]], 5 => [1 => ['劳动节', 5]], 10 => [1 => ['国庆节', 7]], ]; /** * 农历节日:月 => [日, 名字, 放假天数]。 * 放假天数只是"内置规则"下的默认值;官方数据到了会以官方为准。 */ private const LUNAR_FEST = [ 1 => [1 => ['春节', 3]], // 正月初一起(实际从除夕就开始,见下) 5 => [5 => ['端午节', 1]], 8 => [15 => ['中秋节', 1]], ]; // ------------------------------------------------------------------ // 取一年的安排 // ------------------------------------------------------------------ /** * 拿某一年的完整安排(放假 + 调休)。 * * 🔴 **这个方法不发任何网络请求** —— 只读缓存 + 内置规则。 * * 为什么这么设计(这一条是本功能能不能用的分水岭): * 日历是**高频页面**。如果把"抓官方数据"放在渲染路径上, * 那么外网一抖(DNS 慢、GitHub 被墙、jsdelivr 抽风),用户打开日程就要等 * 连接超时(4s)+ 传输超时(8s)——**页面本身没坏,但慢得没法用**, * 而且没有任何报错能让人看出是"节假日数据源"的问题。 * 所以这里只做本地计算,抓取交给 `refreshInBackground()`: * 在**响应发完之后**(php-fpm 的 fastcgi_finish_request)才去联网, * 成功了写缓存,下次请求自然就换成官方口径。 * * @return array{year:int,source:string,days:array} * days 的键是 'Y-m-d',值 = ['name'=>节日名, 'off'=>true 放假 / false 调休上班] */ public static function year(int $year): array { // 只认缓存;没有缓存就是"内置规则 + 排队等后台更新" $cached = self::fromCache($year); if ($cached !== null) { // 缓存里若只有内置规则(还没等到官方),顺手排一次后台更新 if (($cached['source'] ?? '') !== 'official') { self::queueBackgroundFetch($year); } return $cached; } $out = ['year' => $year, 'source' => 'builtin', 'days' => self::builtin($year)]; self::saveCache($year, $out, false); self::queueBackgroundFetch($year); return $out; } // ------------------------------------------------------------------ // 后台自动更新(用户要的"系统自动更新"就落在这里) // ------------------------------------------------------------------ /** @var array 本次请求已经排过队的年份(避免重复注册 shutdown 钩子) */ private static array $queued = []; /** * 排一次"响应之后去抓官方数据"。 * * php-fpm 下用 `fastcgi_finish_request()`:它把响应**立刻发给浏览器**, * 之后脚本继续跑(抓取、写缓存)—— 这正是我们要的"用户无感"。 * 没有这个函数(php -S、CLI)时不提前结束,抓取仍在脚本末尾发生, * 只是算在请求时长里(仅开发环境如此,可接受)。 */ private static function queueBackgroundFetch(int $year): void { if (isset(self::$queued[$year]) || !self::shouldTry($year)) { return; } self::$queued[$year] = true; register_shutdown_function(static function () use ($year): void { // 🔴🔴 v47:**先放会话锁,再断连接**(顺序不能反)。 // `fastcgi_finish_request()` 只断开客户端连接, // **不会释放 PHP 文件会话锁** —— 而这里要联网 12 秒(见下面 doFetch 的超时)。 // 不放锁的话,同一个人的**下一个请求**会一直等这次抓取跑完 // ⇒ "页面秒回,但下一个页面卡十几秒"。 // (同型问题在 WeRead 上实测到:第 2 个请求 TTFB 8.73s。 // 见 app/WeRead.php 的 queueBackgroundSync 长注释。) if (session_status() === PHP_SESSION_ACTIVE) { session_write_close(); } if (function_exists('fastcgi_finish_request')) { fastcgi_finish_request(); } self::doFetch($year); }); } /** * 现在该不该去抓(频率闸门)。 * * 用一个独立的 `last_try` 时间戳文件:**无论成败**都记一次, * 失败后短时间内不再重试 —— 否则源挂掉的那几天,每个请求都去撞一次超时, * 白白浪费服务器资源(而且 php-fpm 的 worker 会被这些慢请求占住)。 */ private static function shouldTry(int $year): bool { $f = self::tryFile($year); $last = is_file($f) ? (int)@file_get_contents($f) : 0; return (time() - $last) > self::CACHE_FAIL; } private static function tryFile(int $year): string { return dirname(__DIR__) . '/storage/' . self::CACHE_DIR . '/holiday-try-' . $year . '.txt'; } private static function markTried(int $year): void { $dir = dirname(self::tryFile($year)); if (!is_dir($dir)) { @mkdir($dir, 0775, true); } @file_put_contents(self::tryFile($year), (string)time(), LOCK_EX); } /** 真正去抓 + 落缓存(只在后台跑) */ private static function doFetch(int $year): void { self::markTried($year); $official = self::fetch($year); $days = self::builtin($year); $source = 'builtin'; if ($official !== null) { foreach ($official as $d => $v) { $days[$d] = $v; } $source = 'official'; } self::saveCache($year, ['year' => $year, 'source' => $source, 'days' => $days], $source === 'official'); } /** * 纯抓取(不落缓存)—— 给**命令行/设置页的"立即更新"**用。 * * ⚠️ 与 year() 的区别:这个会**联网**。绝不要放在页面渲染路径上。 */ public static function fetchNow(int $year): array { $official = self::fetch($year); $days = self::builtin($year); if ($official !== null) { foreach ($official as $d => $v) { $days[$d] = $v; } } $out = ['year' => $year, 'source' => $official !== null ? 'official' : 'builtin', 'days' => $days]; self::saveCache($year, $out, $official !== null); self::markTried($year); return $out; } /** * 内置规则推导:只算**放假**,不算调休。 * * 调休(哪个周末上班)是行政决定,任何"推导"都是瞎猜 —— * 猜错的代价是用户按错的日期安排了事(比"没有调休信息"更糟)。 * 所以内置规则只给确定的公历/农历节日,调休一律等官方数据。 */ private static function builtin(int $year): array { $days = []; // ---- 公历固定节日:按**放假天数**铺成一段 ---- // ⚠️ 用户原话「国庆节只有刚开始的一天有,这不合理」——旧版每天只标一天。 foreach (self::FIXED as $m => $v) { foreach ($v as $d => $spec) { [$name, $span] = $spec; $start = sprintf('%04d-%02d-%02d', $year, $m, $d); for ($i = 0; $i < max(1, (int)$span); $i++) { $t = date('Y-m-d', strtotime($start . ' +' . $i . ' day')); // 跨到下一年的(元旦 3 天可能压到 1/3)也照铺,键就是真实日期 $days[$t] = ['name' => $name, 'off' => true]; } } } // ---- 清明(节气,4 月 4~6 日之间浮动)---- $qingming = self::qingmingDay($year); if ($qingming > 0) { $days[sprintf('%04d-04-%02d', $year, $qingming)] = ['name' => '清明节', 'off' => true]; } // ---- 农历节日(春节 / 端午 / 中秋)---- if (Lunar::inRange($year)) { // 春节:法定假从**除夕**开始(除夕=正月初一前一天,注意可能跨到上一年的腊月) // ⚠️ lunarToSolar 返回的是**关联数组**(['year'=>, 'month'=>, 'day'=>]), // 不是 [0,1,2] —— 用数字下标取会得到 null,然后 sprintf 出 '0000-00-00'。 $cny = Lunar::lunarToSolar($year, 1, 1); if ($cny) { $first = sprintf('%04d-%02d-%02d', $cny['year'], $cny['month'], $cny['day']); $days[$first] = ['name' => '春节', 'off' => true]; // 正月初二、初三也放假(法定 3 天) for ($i = 1; $i <= 2; $i++) { $t = date('Y-m-d', strtotime($first . ' +' . $i . ' day')); $days[$t] = ['name' => '春节', 'off' => true]; } // 除夕(正月初一前一天)—— 法定假从除夕算起 $eve = date('Y-m-d', strtotime($first . ' -1 day')); $days[$eve] = ['name' => '除夕', 'off' => true]; } foreach ([5 => [5, '端午节'], 8 => [15, '中秋节']] as $m => [$d, $name]) { $s = Lunar::lunarToSolar($year, $m, $d); if ($s) { $days[sprintf('%04d-%02d-%02d', $s['year'], $s['month'], $s['day'])] = ['name' => $name, 'off' => true]; } } } ksort($days); return $days; } /** * 清明节的公历日期。 * * 清明是**节气**不是农历节日,用通行的近似公式(20 世纪/21 世纪两套系数): * Y = 年 % 100;日 = int(Y × 0.2422 + C) − int(Y / 4) * 21 世纪 C = 4.81,20 世纪 C = 5.59。 * ⚠️ 个别年份有 1 天的修正(公式的已知例外),这里只列真正会碰到的: * 2008 年应为 4 日(公式给 5 日)。范围外一律回退到"4 月 5 日"这个最常值。 */ private static function qingmingDay(int $year): int { if ($year < 1900 || $year > 2100) { return 5; } $c = $year >= 2000 ? 4.81 : 5.59; $y = $year % 100; $day = (int)($y * 0.2422 + $c) - (int)($y / 4); // 已知例外(来自紫金山天文台的公开修正表,只影响极少数年份) $except = [2008 => 4, 2020 => 4, 1925 => 5, 1942 => 5]; if (isset($except[$year])) { $day = $except[$year]; } return ($day >= 4 && $day <= 6) ? $day : 5; } // ================================================================== // 传统节日 + 二十四节气(v24 第 12 批 #4) // ================================================================== // // 用户原话:「日历中,增加系统日历本,一个是传统节日,用来存放中国的传统节日, // 例如中秋、春节等。再增加一个系统日历本,用来存放24节气。」 // // 两个都是**派生只读本**(与农历/节假日同一机制):不落 calendar_events、 // 不能往里加日程、删不掉(kind ≠ custom)。 // · 传统节日:全部由农历推导(春节/元宵/龙抬头/端午/七夕/中元/中秋/重阳/腊八/小年/除夕…), // 断网也永远有 —— 这是中国日历的基本盘。 // · 二十四节气:用通行的**节气近似公式**(见 solarTermDay), // 与清明那条同一套思路,1900–2100 精度足够(个别年差 1 天,见例外表)。 /** * 传统节日:农历 月 => [日 => 名字](非闰月)。 * ⚠️ 只收**农历日期固定**的;除夕/春节相邻,除夕单列(它与春节差一天,跨月)。 */ private const TRADITIONAL = [ 1 => [1 => '春节', 15 => '元宵节'], 2 => [2 => '龙抬头'], 5 => [5 => '端午节'], 7 => [7 => '七夕节', 15 => '中元节'], 8 => [15 => '中秋节'], 9 => [9 => '重阳节'], 12 => [8 => '腊八节', 23 => '小年'], ]; /** * 二十四节气名(按公历顺序,从 1 月的小寒开始,每两个一个月)。 * 顺序**不能改**:solarTermDay() 用序号索引。 */ public const SOLAR_TERMS = [ '小寒', '大寒', '立春', '雨水', '惊蛰', '春分', '清明', '谷雨', '立夏', '小满', '芒种', '夏至', '小暑', '大暑', '立秋', '处暑', '白露', '秋分', '寒露', '霜降', '立冬', '小雪', '大雪', '冬至', ]; /** * 某年第 $idx 个节气(0 = 小寒 … 23 = 冬至)的公历日期。 * * 用通行的"节气日近似公式",与 qingmingDay() 同一族: * D = int(Y × 0.2422 + C) − int(Y / 4) * 其中 Y = 年份后两位,C 是**每个节气自己的常数**(21 世纪一套)。 * 21 世纪的 C 值取自公开的《中国天文年历》常用近似表。 * * @return int 该月几号(1..31);年份超范围返回 0(调用方跳过) */ private static function solarTermDay(int $year, int $idx): int { if ($year < 1900 || $year > 2100 || $idx < 0 || $idx > 23) { return 0; } // 21 世纪各节气的 C 常数(第 0..23 个,按 SOLAR_TERMS 的顺序) // 20 世纪用另一套(此处只列 21 世纪 + 20 世纪两套,覆盖面足够) $c21 = [ 5.4055, 20.12, 3.87, 18.73, 5.63, 20.646, 4.81, 20.1, 5.52, 21.04, 5.678, 21.37, 7.108, 22.83, 7.5, 22.2, 7.916, 23.08, 8.44, 23.438, 7.18, 22.6, 7.9, 22.6, ]; $c20 = [ 6.11, 20.84, 4.6295, 19.4599, 6.3826, 21.4155, 5.59, 20.888, 6.318, 21.86, 6.5, 22.2, 7.928, 23.65, 8.35, 23.95, 8.44, 23.822, 9.098, 24.218, 8.218, 23.08, 7.9, 22.6, ]; $c = $year >= 2000 ? $c21 : $c20; $y = $year % 100; $day = (int)($y * 0.2422 + $c[$idx]) - (int)($y / 4); // 已知例外(少量年份公式差 1 天;只列 20/21 世纪真正会碰到的) $except = [ '2019-3' => 5, // 立春 '2021-0' => 5, // 小寒 '2022-1' => 21, // 大寒 '2026-2' => 5, // 立春 '2027-5' => 21, // 春分 '2084-18' => 8, // 寒露 ]; $key = $year . '-' . $idx; if (isset($except[$key])) { $day = $except[$key]; } return ($day >= 1 && $day <= 31) ? $day : 0; } /** * [from,to] 区间内的**二十四节气**(只在节气当天各出一条)。 * * @return array */ public static function solarTermRange(string $from, string $to): array { $out = []; $t = strtotime($from); $end = strtotime($to); if ($t === false || $end === false || $end < $t) { return []; } // 🔴 2026-11-02:原来只取「首年 + 末年」两年。 // 全量范围 1970~2100 下 ⇒ 只算了 1970 和 2100 的节气, // 中间 128 年全丢(用户会觉得"日历上没节气")。 // 改成逐年遍历,并夹紧到农历算法支持范围 + 年数上限。 $yFrom = (int)substr($from, 0, 4); $yTo = (int)substr($to, 0, 4); if ($yFrom < Lunar::MIN_YEAR) { $yFrom = Lunar::MIN_YEAR; } if ($yTo > Lunar::MAX_YEAR) { $yTo = Lunar::MAX_YEAR; } if ($yTo - $yFrom + 1 > self::MAX_LUNAR_YEARS) { $yTo = $yFrom + self::MAX_LUNAR_YEARS - 1; } $years = []; for ($yy = $yFrom; $yy <= $yTo; $yy++) { $years[] = $yy; } foreach ($years as $y) { foreach (self::SOLAR_TERMS as $idx => $name) { $day = self::solarTermDay($y, (int)$idx); if ($day === 0) { continue; } // 节气在第 (idx/2+1) 月:小寒/大寒=1 月,立春/雨水=2 月…… $mon = intdiv((int)$idx, 2) + 1; $date = sprintf('%04d-%02d-%02d', $y, $mon, $day); if ($date < $from || $date > $to) { continue; } $out[] = ['date' => $date, 'name' => $name, 'title' => $name]; } } usort($out, static fn(array $a, array $b): int => strcmp($a['date'], $b['date'])); return $out; } /** * [from,to] 区间内的**中国传统节日**(农历推导)。 * * @return array */ public static function traditionalRange(string $from, string $to): array { $out = []; $t = strtotime($from); $end = strtotime($to); if ($t === false || $end === false || $end < $t) { return []; } // 逐日扫区间,转农历后查表 —— 天然处理"跨月/跨年" // (如腊月二十三可能落在次年 1 月)。 // // 🔴 2026-11-02:原来有 `$i < 400` 的硬上限。改成全量同步后请求范围是 // 1970~2100(47846 天)⇒ 只算前 400 天,**今年的春节/中秋全丢**。 // 改为与 lunarRange 一致的「夹范围 + 年数上限」。 $minT = (int)strtotime(sprintf('%04d-01-31', Lunar::MIN_YEAR)); $maxT = (int)strtotime(sprintf('%04d-12-31', Lunar::MAX_YEAR)); if ($t < $minT) { $t = $minT; } if ($end > $maxT) { $end = $maxT; } $yearsLimitT = (int)strtotime('+' . self::MAX_LUNAR_YEARS . ' years', $t); if ($end > $yearsLimitT) { $end = $yearsLimitT; } for (; $t <= $end; $t = strtotime('+1 day', $t)) { $date = date('Y-m-d', $t); $y = (int)date('Y', $t); $m = (int)date('n', $t); $d = (int)date('j', $t); $l = Lunar::solarToLunar($y, $m, $d); if (!$l || !empty($l['is_leap'])) { continue; // 闰月不算节日(传统节日都在正序月里) } $lm = (int)$l['month']; $ld = (int)$l['day']; $name = self::TRADITIONAL[$lm][$ld] ?? ''; // 除夕 = 春节前一天(农历腊月廿九或三十,随年份浮动,表里放不下) if ($name === '' && self::isChuXi($y, $m, $d)) { $name = '除夕'; } if ($name === '') { continue; } $out[] = ['date' => $date, 'name' => $name, 'title' => $name]; } return $out; } /** * 判断某公历日是不是**除夕**(次日为正月初一)。 * * 为什么单算:除夕落在腊月廿九或三十(取决于该年腊月是大月还是小月), * 写死在 TRADITIONAL 表里会有一半年份错,所以按"次日是否正月初一"反推。 */ private static function isChuXi(int $y, int $m, int $d): bool { $next = strtotime(sprintf('%04d-%02d-%02d +1 day', $y, $m, $d)); if ($next === false) { return false; } $l = Lunar::solarToLunar((int)date('Y', $next), (int)date('n', $next), (int)date('j', $next)); return $l && (int)$l['month'] === 1 && (int)$l['day'] === 1 && empty($l['is_leap']); } // ------------------------------------------------------------------ // 官方数据自动拉取 // ------------------------------------------------------------------ /** * 抓官方公布的那个 JSON。**任何失败都返回 null**(调用方回落到内置规则)。 * * ⚠️ 这里刻意不抛异常、不写日志刷屏:日历是高频页, * 外网抖动是常态,抛出去只会让页面 500 —— 而我们本来就有兜底。 */ private static function fetch(int $year): ?array { foreach ([self::SOURCE, self::SOURCE_ALT] as $tpl) { $json = self::httpGet(sprintf($tpl, $year)); if ($json === null) { continue; } $dec = json_decode($json, true); if (!is_array($dec) || empty($dec['days']) || !is_array($dec['days'])) { continue; } $days = []; foreach ($dec['days'] as $d) { if (!is_array($d)) { continue; } $date = trim((string)($d['date'] ?? '')); // 只认本年的、格式合法的(数据源里混进别的年份就直接忽略) if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $date, $m) || (int)$m[1] !== $year) { continue; } $days[$date] = [ 'name' => trim((string)($d['name'] ?? '')), 'off' => (bool)($d['isOffDay'] ?? false), ]; } if ($days) { ksort($days); return $days; } } return null; } /** 一次 HTTP GET(curl 优先,退回 file_get_contents);失败/超时返回 null */ private static function httpGet(string $url): ?string { if (function_exists('curl_init')) { $ch = curl_init($url); $opts = [ CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 3, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_TIMEOUT => 12, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_USERAGENT => 'FamilyBook/' . \App\Schema::VERSION, // ⚠️ 不能用 CURLOPT_NOBODY(那是 HEAD);这里是 GET CURLOPT_HTTPGET => true, ]; // ⚠️ CA 证书兜底:很多服务器(含本机开发环境)的 PHP 没配 curl.cainfo, // 表现是 `errno=60 unable to get local issuer certificate` —— // 看起来像"站点不通",其实是**证书链没处可查**。 // 这里按常见位置找一个 CA bundle;找不到就退回系统默认(不关校验, // 关掉 VERIFYPEER 等于把 HTTPS 降级成明文,不能为了"能跑"那样做)。 $ca = self::findCaBundle(); if ($ca !== null) { $opts[CURLOPT_CAINFO] = $ca; } curl_setopt_array($ch, $opts); $body = curl_exec($ch); $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if (is_string($body) && $code === 200 && $body !== '') { return $body; } return null; } // 没有 curl:file_get_contents 同样需要 CA;失败一律 null(上层回落内置规则) $ctx = stream_context_create([ 'http' => ['timeout' => 12, 'user_agent' => 'FamilyBook'], 'ssl' => ['verify_peer' => true, 'verify_peer_name' => true], ]); $body = @file_get_contents($url, false, $ctx); return is_string($body) && $body !== '' ? $body : null; } /** * 找一个可用的 CA bundle。找不到返回 null(保持 PHP 默认行为)。 * * 只查固定几个常见位置,不做目录遍历 —— 这是渲染路径之外的后台任务, * 但也不该在磁盘上乱翻。 */ private static function findCaBundle(): ?string { $candidates = [ ini_get('curl.cainfo') ?: '', ini_get('openssl.cafile') ?: '', 'C:/php/extras/ssl/cacert.pem', 'C:/php/cacert.pem', 'D:/Yongquan/code/_tooling/php/cacert.pem', '/etc/ssl/certs/ca-certificates.crt', '/etc/pki/tls/certs/ca-bundle.crt', '/etc/ssl/cert.pem', ]; foreach ($candidates as $c) { $c = (string)$c; if ($c !== '' && is_file($c) && is_readable($c)) { return $c; } } return null; } // ------------------------------------------------------------------ // 缓存(放 settings 表:全局数据、与具体人无关) // ------------------------------------------------------------------ /** * 缓存落**文件**(storage/cache/holiday-<年>.json),不蹭 settings 表。 * * ⚠️ 为什么不用 settings 表:它是**家庭级**的(主键 family_id + k,且 family_id 有 * 外键约束指向 families(id))。而节假日安排是**全局数据**(全国一样、与哪个家无关), * 硬塞进去最省事的做法是"借用 0 号家庭",但那会直接违反外键。 * 节假日本来就该在"一次拉取、全体共用、随部署走"的共享存储里 —— 就是文件。 */ private const CACHE_DIR = 'cache'; private static function cacheFile(int $year): string { // storage/cache/holiday-<年>.json // ⚠️ 这个目录**不在打包白名单里**(缓存不该随包走),由部署脚本 mkdir 出来; // 代码这边只负责"没有就建",且**任何写失败都不算错**(见 saveCache)。 return dirname(__DIR__) . '/storage/' . self::CACHE_DIR . '/holiday-' . $year . '.json'; } private static function fromCache(int $year): ?array { $f = self::cacheFile($year); if (!is_file($f)) { return null; } $raw = @file_get_contents($f); if (!is_string($raw) || $raw === '') { return null; } $dec = json_decode($raw, true); if (!is_array($dec) || !isset($dec['at'], $dec['data'])) { return null; } // 成功的官方数据缓存久一点;只有内置规则的缓存短一点(好早点换上官方数据) $ttl = ($dec['source'] ?? '') === 'official' ? self::CACHE_OK : self::CACHE_FAIL; if (time() - (int)$dec['at'] > $ttl) { return null; } $data = $dec['data']; return is_array($data) ? $data : null; } private static function saveCache(int $year, array $data, bool $official): void { $payload = json_encode([ 'at' => time(), 'source' => $official ? 'official' : 'builtin', 'data' => $data, ], JSON_UNESCAPED_UNICODE); $dir = dirname(self::cacheFile($year)); if (!is_dir($dir)) { @mkdir($dir, 0775, true); } // 写失败(目录不可写)**不算错**:下次请求重算一遍而已,页面照常出内容 @file_put_contents(self::cacheFile($year), $payload, LOCK_EX); } /** 强制刷新(设置页/命令行用):删缓存再取一次 */ public static function refresh(int $year): array { $f = self::cacheFile($year); if (is_file($f)) { @unlink($f); } return self::year($year); } // ------------------------------------------------------------------ // 供日历渲染用 // ------------------------------------------------------------------ /** * 把放假/调休摊成日历条目(只取 [from,to] 落在范围内的)。 * * @return array */ public static function range(int $year, string $from, string $to): array { $y = self::year($year); $out = []; foreach (($y['days'] ?? []) as $date => $v) { if ($date < $from || $date > $to) { continue; } $off = !empty($v['off']); $name = (string)($v['name'] ?? ''); $out[] = [ 'date' => (string)$date, 'name' => $name, 'off' => $off, // 调休上班的日子写清楚"补班",否则用户看到工作日里冒出一条日程会莫名 'title' => $off ? ('休 ' . ($name !== '' ? $name : '假日')) : ('班 ' . ($name !== '' ? $name : '调休')), ]; } return $out; } /** * 农历条目([from,to] 每天都出一条,标题是"农历月日 + 节日")。 * * ⚠️ 每天的农历都要出,不能只出初一十五 —— 用户要的是"看日历就知道今天农历几号"。 */ /** * 单次请求最多算多少年的农历。 * * 🔴 2026-11-02:原来是「最多 400 天」的硬上限,而客户端改成全量同步后 * 请求范围是 1970~2100(47846 天)⇒ 只返回前 400 天的农历, * 导致**今天的农历是空的**。 * * 改成按年计:客户端实际只需「今天 ±5 年」, * 留 30 年余量足够,同时防止有人手拼 1900~2100 把服务器拖死 * (算 7 万天农历约需数十秒)。 */ private const MAX_LUNAR_YEARS = 30; public static function lunarRange(string $from, string $to): array { $out = []; $t = strtotime($from); $end = strtotime($to); if ($t === false || $end === false || $end < $t) { return []; } // ① 夹到农历算法支持的范围(1900-01-31 ~ 2100-12-31), // 否则 solarToLunar 会一路返回 null,白跑空转。 $minT = (int)strtotime(sprintf('%04d-01-31', Lunar::MIN_YEAR)); $maxT = (int)strtotime(sprintf('%04d-12-31', Lunar::MAX_YEAR)); if ($t < $minT) { $t = $minT; } if ($end > $maxT) { $end = $maxT; } // ② 年数上限:超出就截断到 MAX_LUNAR_YEARS 年末 $yearsLimitT = (int)strtotime('+' . self::MAX_LUNAR_YEARS . ' years', $t); if ($end > $yearsLimitT) { $end = $yearsLimitT; } // ③ 逐日生成(农历必须逐日算,无法按年跳) // 原来的 `$i < 400` 计数制已由上面的年数上限取代。 for (; $t <= $end; $t = strtotime('+1 day', $t)) { $date = date('Y-m-d', $t); $y = (int)date('Y', $t); $m = (int)date('n', $t); $d = (int)date('j', $t); $l = Lunar::solarToLunar($y, $m, $d); if (!$l) { continue; } $lm = (int)$l['month']; $ld = (int)$l['day']; $leap = !empty($l['is_leap']); $out[] = [ 'date' => $date, 'title' => self::lunarTitle($lm, $ld, $leap), 'fest' => self::lunarFestival($lm, $ld, $leap), ]; } return $out; } /** 日历条上显示的农历文字:初一显示月名,其余显示日名 */ private static function lunarTitle(int $m, int $d, bool $leap): string { if ($d === 1) { return Lunar::monthName($m, $leap); } return Lunar::dayName($d); } /** * 那天是不是农历节日(春节/元宵/端午/七夕/中元/中秋/重阳/腊八/小年/除夕)。 * 返回节日名,没有则空串。 */ private static function lunarFestival(int $m, int $d, bool $leap): string { if ($leap) { return ''; // 闰月不算节日 } $map = [ '1-1' => '春节', '1-15' => '元宵节', '5-5' => '端午节', '7-7' => '七夕', '7-15' => '中元节', '8-15' => '中秋节', '9-9' => '重阳节', '12-8' => '腊八节', '12-23'=> '小年', ]; return $map[$m . '-' . $d] ?? ''; } /** 内置日历本的定义(不可删除 → kind 不是 custom,delete() 天然删不掉) * v24 第 12 批 #4:加 traditional(传统节日)/ solarterm(二十四节气)。 */ public const BUILTIN_KINDS = [ 'lunar' => ['农历', '#8C6D3F'], 'holiday' => ['节假日', '#B0342C'], 'traditional' => ['传统节日', '#A6305C'], 'solarterm' => ['二十四节气', '#307AA6'], ]; /** 某年官方数据是否已到手(设置页显示"已自动更新到哪一年"用) */ public static function isOfficial(int $year): bool { $y = self::year($year); return ($y['source'] ?? '') === 'official'; } }