用户 2026-10-13 七条。拍板:影院只记地址、不存经纬度(后续用高德反查);
日程状态只删界面与展示、数据库列保留(「后续会有一个整体清理的工作」)。
①🔴 读书页慢 —— 真根因是**会话锁**,不是封面(我先判断错了方向)
用户原话:「读书现在还是非常慢,就是进入读书这个页面,刚开始还是非常的慢。」
线上实测(真公网、同一会话连打 3 次):
第 1 次 TTFB 0.08s ← 触发微信读书同步
第 2 次 TTFB **8.73s** ← 用户说的"刚开始非常慢"
第 3 次 TTFB 0.07s
插桩拿到的服务端时间线把因果钉死:
21:18:05.483 QUEUED ← 第 1 个请求排队
21:18:05.501 FINISH 2ms ← 响应确实提前发出去了(这步是好的)
21:18:14.674 SYNC 9.2s ← 但它仍占着这个人的会话文件锁跑完 9.2 秒
21:18:14.682 QUEUED ← 第 2 个请求此刻才真正开始执行
⇒ **`fastcgi_finish_request()` 只断开客户端连接、不释放 PHP 文件会话锁。**
后台同步要 9.2 秒(真打微信读书接口),同一个人的下一个请求一路等到它跑完。
修法(缺一不可):
· `WeRead::queueBackgroundSync()` 回调里 **先 `session_write_close()`、
再 `fastcgi_finish_request()`**(顺序不能反);
· `index.php` 两处"渲染前排同步"的入口排完**立刻放锁**
(shutdown 回调不一定执行:客户端中断/服务器超时才不放过);
· 顺带修 **`Holiday::queueBackgroundFetch()` 的同型问题**(联网 12 秒)。
为什么以前没发现:v29 引入"后台化"时只验证了"响应是不是提前发出去",
**单次请求测量永远看不到这个 bug** —— 必须同一会话连打两次才现形。
线上复验:修复后连打 4 次 = 0.15 / 0.15 / 0.09 / 0.15 秒(修复前第 2 次 8.73s);
后台同步仍正常(`weread_sync_at` 已更新)。
② 封面:顺手也修了(但这不是用户说的那个问题)
· `media.php` 读 OSS 前先放会话锁(原来从不放,35 张封面串行);
· 新增 `Storage::cacheLocally()` 磁盘二级缓存(原来 `read()` 只读不写回);
· 让 304 不再先拉整张图(原来先取回 body、再判 `If-None-Match`)。
线上实测:冷取 149ms(走 OSS)→ 热取 35ms(本地缓存、字节一致)
→ 带 `If-None-Match` 返回 **304 / 19.8ms / 空体**;35 张封面 103ms → 20ms(5 倍)。
③ 地址联想:抽成全站共用一份(`assets/js/addr_suggest.js`)+ 接入三处
· 影院管理页、观影新增页的「新建影院」弹框、日程地点、行程出发/到达;
· `layout.php` 按需加载(`[data-addr]` 出现才拉 SDK + 我们的脚本),
并暴露 `fbLoadAddr(scope)` 给 AJAX 片段(v42 踩过"内联 script 不执行");
· 安全密钥在 SDK 之前设置;回填支持 `data-addr-fill` / `data-addr-for`、
**只补空白字段**(不覆盖用户手写)、直辖市 `province==city` 去重;
· 字段名有两套(影院 `address`/`place`,行程 `from_addr`/`to_addr` 缩写 `_addr`),
`fieldFor()` 带同义词表 + 后缀匹配;
· 影院**只存地址、不存经纬度**(按用户要求)。
④ 笔记两条
· 「点保存没反应」真因:附件区是个 `<form>` **套在保存表单里** ——
HTML 不允许 form 套 form,浏览器丢掉内层标签、保留它的 `</form>`,
于是**保存按钮成了孤儿**(`button.form === null`),点了不提交也不报错。
真浏览器实测:修前 `btnInsideForm:false` / 点后 POST=0;修后 true / POST=1。
改法:附件区改 `<div id="noteAttForm">`,上传按钮用 `formaction`。
· 详情页宽度:`.note-read { max-width: 820px }` → `none`
(内容卡 820px vs 下方共享卡 1032px;修后两者都是 1032px)。
⑤ 日程状态下线
· 删表单里的「状态」下拉、`Calendar::statusWord()`、浮层 JSON 的 `status_word`;
· **保留** `events.status` 列与 `Calendar::statusOf()`(契约不动,等整体清理)。
验证(新增 5 个套件):
· `_fb_v47_addr.py`(接线 24 条)/ `_fb_v47_addr_sim.py`(真浏览器 + 假高德,16 条)
· `_fb_v47_readperf.py`(缓存与会话锁,23 条)
· `_fb_v47_notes.py`(真浏览器,9 条)
· `_b47_readperf_falsify.py`(反向验证 9 个注入)—— **9/9 全抓住**
· 全量 e2e **1510/0**;vCard 规格+往返 40/0;TB 探针 44/0;
v44 反向验证 17/0;全流水线 **PUSH_RC=0**(含"第47批形态"静态断言)
· **线上终验**:读书页整页 81ms、封面 103→20ms、media 冷 149→热 35→304 19.8ms、
修复前第 2 次请求 8.73s → 修复后 0.15s、地址联想 meta[fb-addr] 带真高德 key
顺带修掉三处**探针自身**的假红/假绿(详见 pitfalls.md):
· `_b44_falsify.py` 的"整小时"注入在**整点**与正确实现等价 ⇒ 换成恒错值;
· `_fb_b44_ui.py` 3b 的并发计时断言在单进程 `php -S` 下必然假红 ⇒ 改断言确定事实;
· 多处探针的"清理删除"撞沙箱删除门禁 ⇒ 探针**中途死掉**看起来像产品坏了
⇒ 统一改成 `_safe_rm()` / 不删。
767 lines
33 KiB
PHP
767 lines
33 KiB
PHP
<?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 [];
|
||
}
|
||
$years = [(int)substr($from, 0, 4)];
|
||
if ((int)substr($to, 0, 4) !== $years[0]) {
|
||
$years[] = (int)substr($to, 0, 4);
|
||
}
|
||
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 [];
|
||
}
|
||
// 逐日扫区间,转农历后查表 —— 区间很短(月视图 ≤42 天),成本可忽略。
|
||
// 这样天然处理"跨月/跨年"(如腊月二十三可能落在次年 1 月)。
|
||
for ($i = 0; $t <= $end && $i < 400; $i++, $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] 每天都出一条,标题是"农历月日 + 节日")。
|
||
*
|
||
* ⚠️ 每天的农历都要出,不能只出初一十五 —— 用户要的是"看日历就知道今天农历几号"。
|
||
*/
|
||
public static function lunarRange(string $from, string $to): array
|
||
{
|
||
$out = [];
|
||
$t = strtotime($from);
|
||
$end = strtotime($to);
|
||
if ($t === false || $end === false || $end < $t) {
|
||
return [];
|
||
}
|
||
// 上限保险:范围异常大时不至于把请求拖死(正常月视图最多 42 天)
|
||
for ($i = 0; $t <= $end && $i < 400; $i++, $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';
|
||
}
|
||
} |