Files
FamilyBook/app/Holiday.php
T
laoyang b2ca4f336e v24 第 10 批:名册真性能(根因是每卡内联表单)等九条
- #6 名册线上真实数据(1184 人)卡顿:根因是**每张卡片内联一整份分组弹层**——
  全页 2369 个 form / 27234 个 input / 22496 个 radio / 98100 DOM 节点,
  密码管理器(Bitwarden 等)逐个扫描 form 把主线程占死(用户报的就是这个冲突)。
  改为卡片只带 data 属性 + 全页**唯一一份**共享浮层 #rosterMenus。
  实测:form 2369→3、input 27234→25、DOM 98100→17659、解压 7694→2306 KB、
  长任务 1/52ms → 0。新增哨兵 _fb_formbloat_check.py(含反向验证)。
- #5 日视图翻页失效:day_columns() 无条件吸到周一,天视图只有 1 列 → 永远显示那周周一。
  改为仅 span>1 时吸附(周视图行为不变)。
- #2 财务「欠款」类别(负债);并修一个真 bug:负债填负数会让净资产**反向变大**。
  写入口 normalizeAmount 归一化 + summary/timeline 侧 abs 防御 + Schema v17 纠正老数据。
- #3 账目行「盘点」「修改」两个并列按钮(各自就地弹面板);图移到账目上方,
  总金额图跨整行 + 每个账目一条线 + 类别筛选条;charts() 改为覆盖全部账目。
- #4 日程新增「农历」「节假日」两本不可删除只读日历(app/Holiday.php):
  两级自动更新 —— 内置规则(公历固定 + 清明节气 + 农历节日)保证断网也有;
  官方 JSON 镜像自动抓取补齐调休。抓取**不在渲染路径联网**(响应后 fastcgi_finish_request
  + 频率闸门),缓存落 storage/cache。仅派生本不可写日程。
- #6 日程页设置按钮前加「跳到某一天」(选中即跳)。
- #7 起止范围改用一个 Semi dateTimeRange 控件(观影/旅程/节点/日程共 4 处);
  新增 window.fbSetDT / fbUpgradeRange;修范围重挂(React 非受控组件须 unmount 再 createRoot)
  与范围里 required 未摘除导致表单无法提交两个真实问题。

验证:e2e 786/0、batch10 44/0、batch9 38/0、batch_e 91/0、upgrade 126/0、picker 28/0、
semi_integrate 27/0、formbloat 5/0(+反向 3 红)、mobile 688/0、interact 97/0、drag 5/0、geom 5/5;
线上自检 19/19 + 线上真实账号巡检 28/0(含 Firefox 实测)。
2026-10-04 10:46:50 +08:00

562 lines
24 KiB
PHP
Raw 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日浮动)要算,不在这里。
*/
private const FIXED = [
1 => [1 => '元旦'],
5 => [1 => '劳动节'],
10 => [1 => '国庆节'],
];
/**
* 农历节日:月 => [日, 名字, 放假天数]。
* 放假天数只是"内置规则"下的默认值;官方数据到了会以官方为准。
*/
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 {
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 => $name) {
$days[sprintf('%04d-%02d-%02d', $year, $m, $d)] = ['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;
}
// ------------------------------------------------------------------
// 官方数据自动拉取
// ------------------------------------------------------------------
/**
* 抓官方公布的那个 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() 天然删不掉) */
public const BUILTIN_KINDS = [
'lunar' => ['农历', '#8C6D3F'],
'holiday' => ['节假日', '#B0342C'],
];
/** 某年官方数据是否已到手(设置页显示"已自动更新到哪一年"用) */
public static function isOfficial(int $year): bool
{
$y = self::year($year);
return ($y['source'] ?? '') === 'official';
}
}