Files
laoyang 821cfd1542 Holiday:去掉三处 400 天上限,农历/节日/节气在全量范围下不再丢失
## 问题

lunarRange / traditionalRange 都有 `$i < 400` 硬上限。
2026-10-30 客户端改成全量同步(1970~2100,47846 天)后,
这个「防异常」的保险变成「削正常功能」:
- 只算前 400 天 ⇒ **今天的农历是空的**
- 传统节日同样只剩前 400 天 ⇒ 今年的春节/中秋全丢

solarTermRange 另有一个问题:只取首末两年,
130 年范围下只算 1970 和 2100 的节气,中间 128 年全丢。

## 修法

- 三处统一改为:夹到 Lunar::MIN_YEAR~MAX_YEAR,再按
  MAX_LUNAR_YEARS(30 年)截断,去掉按天计数上限
- solarTermRange 改为逐年遍历

## 实测(部署后)

今天 2026-11-02 → 廿四(修复前为空)
今年 11 个传统节日、24 个节气,齐全
2021~2031 农历 4017 条(修复前 400),节气 264 条(修复前 48)
1970~2100 农历 10958 条 / 0.47 秒(按 30 年上限截断,符合设计)

## 注意

本文件不在客户端仓库,需单独上传服务器。
验证前必须比对服务器 md5,否则会拿旧代码验出假结果(本次踩过)。
2026-11-02 08:51:34 +08:00

830 lines
36 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 法定节假日 / 调休 + 农历 —— 日程页两个**不可删除的只读日历**的数据源。
*
* v24 第 10 批 #4(用户原话):
* 「日程中,增加两个不可删除的日历,一个是农历,一个是法定假日的休息和调休信息,
* 法定假日这个,需要有自动更新功能,例如明年的放假调休情况定下来了,那么就要及时
* 更新进去,**不是让你更新,而是让系统,代码自动更新**。」
*
* 所以这里的核心不是"填一份 2026 年的数据",而是**把"自动更新"做成机制**:
*
* ┌─ 第一级:内置规则(离线可用、永不失效)
* │ 元旦 / 清明 / 劳动节 / 国庆 是**公历固定或节气**,可以直接推导;
* │ 春节 / 端午 / 中秋 由农历日期推导(Lunar 类,1900-2100 全覆盖)。
* │ → 即使断网、即使没有任何外部数据,每年也会自动"长出"这 7 个节日的放假日期。
* │ 只是"调休哪几天"官方没公布之前推不出来(那本来就是行政决定)。
* └─ 第二级:官方公布数据(自动补齐调休)
* 国务院办公厅每年 10-11 月公布次年安排。数据源用**公开的 JSON 镜像**
* (NateScarlet/holiday-cn,按年一个文件),抓到就写进库并缓存。
* → 这就是用户要的"明年的放假调休情况定下来了,系统自动更新进去"。
*
* ⚠️ 两级的关系是**互补而非替代**:拉取失败必须仍然有第一级的结果(只是少调休),
* 绝不能因为外部源挂了就让日历空掉——那样用户会以为"功能坏了"。
*
* ⚠️ 缓存放 settings 表(`holiday_cache_<年>`),而不是每次请求都去抓:
* 日历页是高频页面,把外网请求放渲染路径上会让它随外网抖动而变慢。
*/
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<string,array{name:string,off:bool}>}
* 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<int,bool> 本次请求已经排过队的年份(避免重复注册 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<int,array{date:string,name:string,title:string}>
*/
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<int,array{date:string,name:string,title:string}>
*/
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<int,array{date:string,title:string,name:string,off:bool}>
*/
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';
}
}