Files
FamilyBook/app/Lunar.php
T
laoyang 7e9b4520c0 登录居中 · 个人 slogan · 三种生日 · 称呼视角化 · 附件统一走代理
按九点反馈一次性改造:

1. 登录页「图标 + 用手机号登录你的家书」包进 .auth-head 并真正居中
   (以前只加了个 class 名,没有样式,看着像做了其实没做)。

2. slogan 改成两段式:系统级 slogan 作兜底,每个人可在自己的资料里写一个,
   填了就显示自己的、留空就回落到系统那句。settings 里「家庭名称」字段去掉
   (只在安装时归档,不参与展示)。

3. 三个生日:身份证生日(从证件号截取,永不改动)/真实阳历/农历。
   同一人填了其一即自动推出另一个(靠 birthday_source 指明填的是哪一侧,
   不依赖 JS)。三者落在同一天时合并成一条提醒,否则分别提醒。
   农历表不是照抄流传的那份 lunarInfo(它在 5 个年份上是错的)——
   用五个独立实现按 7.3 万天多数表决重新推导,再要求 100% 重现。

4. 称呼改成「视角属性」:nicknames(family_id, viewer_id, target_id, nickname)。
   「小明」不是某个人的属性——同一个人被不同人叫法不同,所以是「谁看」的属性。
   旧的 persons.nickname 由 Nickname::migrateLegacy() 搬到新表,只搬一次。

5. 点自己的头像/名字直接进「我的资料」,不必再绕
   「名册 → 找到自己 → 编辑资料」三步。

6. 附件(头像与其它上传)一律走 media.php 代理,不再直连 uploads/ 或云端地址:
   可读性由登录态决定而不是「知道文件名」,Content-Type/缓存/下载头由服务端统一给。
   uploads/ 整个目录 deny(含历史遗留备份文件),并有真实探针验证。

7. OSS 设置页加「连通性测试」(本地 / 云端两个按钮),逐步给出结论与耗时。

8. 生日输入改用「原生日期框 + 24 小时制时:分:秒 三个下拉」:
   datetime-local 的格式由浏览器 UI 语言决定(中文环境渲染成「下午 03:30」,
   没有秒、还容易把上下午填反)。点输入框任意一处都能弹出选择器(showPicker)。

9. 结构版本号收敛成 Schema::VERSION 一处,安装时即盖章,避免首次请求白跑升级。

验证(全部为「先注入 bug 证伪、再跑通过」):
  php -l 42 个文件;e2e 149 条;老库升级专测 37 条;反向验证 23 个注入全被抓;
  桌面几何 108 条(768/1280/1920/2560);移动端真机几何 212 条(360/375/390/414);
  交互检查 49 条(生日整框可点、登录居中)。
2026-09-30 09:44:55 +08:00

274 lines
11 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
/**
* 农历换算(阳历 ⇄ 阴历)。
*
* ⚠️⚠️ 这张表是**怎么来的**,比表本身更重要:
*
* 网上流传的 `lunarInfo` 表有很多份,而且**互相矛盾**。实测同一批日子,
* 五个独立实现(lunardate / zhdate / cnlunar / borax / lunarcalendar)在
* 1954、1933、1978、2057、2089、2097 等年份上给出不同答案:
* · 1933 闰五月:网上表记 29 天,另四个实现都说 30 天
* · 1954 十一月:lunardate 少算一天(它与 zhdate 在同一天直接打架)
* · 1978 九月:lunardate 与其余四家差一天
* · 2057 九月:borax 与其余三家差一天
* 而农历表**抄错一位不会报错**,只会安静地把某个人的生日算错一天 ——
* 对孩子来说就是"生日提醒晚了一天",对老人尤其不合适。
*
* 所以这里不抄任何单一来源,做法是:
* ① 用上述 5 个实现逐日"投票"(1900-01-31 ~ 2100-12-31,共 73435 天);
* ② 从多数派序列反推每一年的月长/闰月,编码成表;
* ③ 再用这张表反算,要求 **100% 重现多数派**(实测 0 处不一致)。
* 推导脚本在 `_tooling/_fb_lunar_derive.py`(不属于本仓库)。
*
* 表的编码(沿用通用约定,便于与任何一份公开实现对照):
* bit16 = 1 → 闰月有 30 天(0 → 29 天)
* bit15..bit4= 正月..十二月,1 → 30 天,0 → 29 天(正月 = 0x8000,十二月 = 0x10)
* bit3..bit0 = 闰月月号,0 = 该年无闰月
*/
declare(strict_types=1);
namespace App;
final class Lunar
{
/** 支持范围:1900-01-31(农历 1900 年正月初一)~ 2100 年底 */
public const MIN_YEAR = 1900;
public const MAX_YEAR = 2100;
/** 基准日:公历 1900-01-31 即农历 1900 年正月初一 */
private const EPOCH = '1900-01-31';
/** @var int[] 1900-2100 闰大小信息表(见文件头:由五个实现多数派推导而来) */
private const INFO = [
0x04bd8, 0x04ae0, 0x0a570, 0x054d5, 0x0d260, 0x0d950, 0x16554, 0x056a0, 0x09ad0, 0x055d2, // 1900-1909
0x04ae0, 0x0a5b6, 0x0a4d0, 0x0d250, 0x1d255, 0x0b540, 0x0d6a0, 0x0ada2, 0x095b0, 0x14977, // 1910-1919
0x04970, 0x0a4b0, 0x0b4b5, 0x06a50, 0x06d40, 0x1ab54, 0x02b60, 0x09570, 0x052f2, 0x04970, // 1920-1929
0x06566, 0x0d4a0, 0x0ea50, 0x16a95, 0x05ad0, 0x02b60, 0x186e3, 0x092e0, 0x1c8d7, 0x0c950, // 1930-1939
0x0d4a0, 0x1d8a6, 0x0b550, 0x056a0, 0x1a5b4, 0x025d0, 0x092d0, 0x0d2b2, 0x0a950, 0x0b557, // 1940-1949
0x06ca0, 0x0b550, 0x15355, 0x04da0, 0x0a5b0, 0x14573, 0x052b0, 0x0a9a8, 0x0e950, 0x06aa0, // 1950-1959
0x0aea6, 0x0ab50, 0x04b60, 0x0aae4, 0x0a570, 0x05260, 0x0f263, 0x0d950, 0x05b57, 0x056a0, // 1960-1969
0x096d0, 0x04dd5, 0x04ad0, 0x0a4d0, 0x0d4d4, 0x0d250, 0x0d558, 0x0b540, 0x0b6a0, 0x195a6, // 1970-1979
0x095b0, 0x049b0, 0x0a974, 0x0a4b0, 0x0b27a, 0x06a50, 0x06d40, 0x0af46, 0x0ab60, 0x09570, // 1980-1989
0x04af5, 0x04970, 0x064b0, 0x074a3, 0x0ea50, 0x06b58, 0x05ac0, 0x0ab60, 0x096d5, 0x092e0, // 1990-1999
0x0c960, 0x0d954, 0x0d4a0, 0x0da50, 0x07552, 0x056a0, 0x0abb7, 0x025d0, 0x092d0, 0x0cab5, // 2000-2009
0x0a950, 0x0b4a0, 0x0baa4, 0x0ad50, 0x055d9, 0x04ba0, 0x0a5b0, 0x15176, 0x052b0, 0x0a930, // 2010-2019
0x07954, 0x06aa0, 0x0ad50, 0x05b52, 0x04b60, 0x0a6e6, 0x0a4e0, 0x0d260, 0x0ea65, 0x0d530, // 2020-2029
0x05aa0, 0x076a3, 0x096d0, 0x04afb, 0x04ad0, 0x0a4d0, 0x1d0b6, 0x0d250, 0x0d520, 0x0dd45, // 2030-2039
0x0b5a0, 0x056d0, 0x055b2, 0x049b0, 0x0a577, 0x0a4b0, 0x0aa50, 0x1b255, 0x06d20, 0x0ada0, // 2040-2049
0x14b63, 0x09370, 0x049f8, 0x04970, 0x064b0, 0x168a6, 0x0ea50, 0x06aa0, 0x1a6c4, 0x0aae0, // 2050-2059
0x092e0, 0x0d2e3, 0x0c960, 0x0d557, 0x0d4a0, 0x0da50, 0x05d55, 0x056a0, 0x0a6d0, 0x055d4, // 2060-2069
0x052d0, 0x0a9b8, 0x0a950, 0x0b4a0, 0x0b6a6, 0x0ad50, 0x055a0, 0x0aba4, 0x0a5b0, 0x052b0, // 2070-2079
0x0b273, 0x06930, 0x07337, 0x06aa0, 0x0ad50, 0x14b55, 0x04b60, 0x0a570, 0x054e4, 0x0d160, // 2080-2089
0x0e968, 0x0d520, 0x0daa0, 0x16aa6, 0x056d0, 0x04ae0, 0x0a9d4, 0x0a2d0, 0x0d150, 0x0f252, // 2090-2099
0x0d520, // 2100
];
/** 农历月名(下标 1..12) */
private const MONTH_NAMES = ['', '正', '二', '三', '四', '五', '六', '七', '八', '九', '十', '冬', '腊'];
/** 农历日名(下标 1..30) */
private const DAY_NAMES = [
'', '初一', '初二', '初三', '初四', '初五', '初六', '初七', '初八', '初九', '初十',
'十一', '十二', '十三', '十四', '十五', '十六', '十七', '十八', '十九', '二十',
'廿一', '廿二', '廿三', '廿四', '廿五', '廿六', '廿七', '廿八', '廿九', '三十',
];
public static function inRange(int $year): bool
{
return $year >= self::MIN_YEAR && $year <= self::MAX_YEAR;
}
/** 闰月月号;0 = 该年无闰月 */
public static function leapMonth(int $year): int
{
return self::INFO[$year - self::MIN_YEAR] & 0xf;
}
/** 闰月天数(0 / 29 / 30) */
public static function leapDays(int $year): int
{
if (self::leapMonth($year) === 0) {
return 0;
}
return (self::INFO[$year - self::MIN_YEAR] & 0x10000) ? 30 : 29;
}
/** 某个月的天数($month 为 1..12,指该月的**非闰**形态) */
public static function monthDays(int $year, int $month): int
{
if ($month < 1 || $month > 12) {
return -1;
}
return (self::INFO[$year - self::MIN_YEAR] & (0x10000 >> $month)) ? 30 : 29;
}
/** 农历一年总天数(354~384) */
public static function yearDays(int $year): int
{
$sum = 348; // 12 × 29
$info = self::INFO[$year - self::MIN_YEAR];
for ($i = 0x8000; $i > 0x8; $i >>= 1) { // bit15..bit4 → 每个大月 +1
if ($info & $i) {
$sum++;
}
}
return $sum + self::leapDays($year);
}
/**
* 该农历年的「月序表」:按时间顺序列出每个月。
* 每项 = ['month' => 1..12, 'leap' => bool, 'days' => 29|30]
*
* 闰月的排法是「紧跟在本月之后」:闰六月 → …六、闰六、七…
* (把顺序显式列出来,比在一堆位运算里判断"现在是第几个循环"可靠得多)
*/
private static function monthSequence(int $year): array
{
$leap = self::leapMonth($year);
$out = [];
for ($m = 1; $m <= 12; $m++) {
$out[] = ['month' => $m, 'leap' => false, 'days' => self::monthDays($year, $m)];
if ($leap > 0 && $m === $leap) {
$out[] = ['month' => $m, 'leap' => true, 'days' => self::leapDays($year)];
}
}
return $out;
}
/** 两个公历日期之间相差的天数(一律按 UTC 算,避开历史夏令时把日差算成 0.96 天)*/
private static function daysBetween(string $from, string $to): int
{
$utc = new \DateTimeZone('UTC');
$a = \DateTimeImmutable::createFromFormat('!Y-m-d', $from, $utc);
$b = \DateTimeImmutable::createFromFormat('!Y-m-d', $to, $utc);
return (int)$a->diff($b)->days * ($b >= $a ? 1 : -1);
}
private static function ymd(int $y, int $m, int $d): string
{
return sprintf('%04d-%02d-%02d', $y, $m, $d);
}
/**
* 阳历 → 阴历。
* @return array{year:int,month:int,day:int,is_leap:bool}|null 越界返回 null
*/
public static function solarToLunar(int $y, int $m, int $d): ?array
{
if (!checkdate($m, $d, $y)) {
return null;
}
$offset = self::daysBetween(self::EPOCH, self::ymd($y, $m, $d));
if ($offset < 0) {
return null;
}
// 逐年扣减,定位农历年
$year = self::MIN_YEAR;
$temp = 0;
while ($year <= self::MAX_YEAR && $offset > 0) {
$temp = self::yearDays($year);
$offset -= $temp;
$year++;
}
if ($offset < 0) {
$offset += $temp;
$year--;
}
if (!self::inRange($year)) {
return null;
}
// 逐月扣减,定位农历月
foreach (self::monthSequence($year) as $mo) {
if ($offset < $mo['days']) {
return [
'year' => $year,
'month' => $mo['month'],
'day' => $offset + 1,
'is_leap' => $mo['leap'],
];
}
$offset -= $mo['days'];
}
return null; // 理论上到不了这里
}
/**
* 阴历 → 阳历。
* @return array{year:int,month:int,day:int}|null 参数非法或越界返回 null
*/
public static function lunarToSolar(int $ly, int $lm, int $ld, bool $isLeap = false): ?array
{
if (!self::inRange($ly) || $lm < 1 || $lm > 12 || $ld < 1 || $ld > 30) {
return null;
}
$leap = self::leapMonth($ly);
if ($isLeap) {
if ($leap !== $lm) {
return null; // 该年没有这个闰月
}
if ($ld > self::leapDays($ly)) {
return null;
}
} elseif ($ld > self::monthDays($ly, $lm)) {
return null;
}
// 先走到该农历年的正月初一,再走到目标月初一,最后加天数
$offset = 0;
for ($y = self::MIN_YEAR; $y < $ly; $y++) {
$offset += self::yearDays($y);
}
foreach (self::monthSequence($ly) as $mo) {
if ($mo['month'] === $lm && $mo['leap'] === $isLeap) {
$offset += $ld - 1;
break;
}
$offset += $mo['days'];
}
$utc = new \DateTimeZone('UTC');
$base = \DateTimeImmutable::createFromFormat('!Y-m-d', self::EPOCH, $utc);
$got = $base->modify('+' . $offset . ' days');
return [
'year' => (int)$got->format('Y'),
'month' => (int)$got->format('n'),
'day' => (int)$got->format('j'),
];
}
/** 农历月名,如「六月」「闰六月」 */
public static function monthName(int $month, bool $isLeap = false): string
{
if ($month < 1 || $month > 12) {
return '';
}
return ($isLeap ? '闰' : '') . self::MONTH_NAMES[$month] . '月';
}
/** 农历日名,如「廿六」 */
public static function dayName(int $day): string
{
return self::DAY_NAMES[$day] ?? '';
}
/**
* 读起来顺口的农历写法:农历一九五四年冬月廿三。
* 年份用汉字数字——这是老户口本/生辰帖上的写法,长辈看着顺眼。
*/
public static function label(int $year, int $month, int $day, bool $isLeap = false): string
{
return '农历' . self::yearLabel($year) . '年' . self::monthName($month, $isLeap) . self::dayName($day);
}
/** 年份的汉字写法:1954 → 一九五四 */
public static function yearLabel(int $year): string
{
$d = ['〇', '一', '二', '三', '四', '五', '六', '七', '八', '九'];
$s = '';
foreach (str_split((string)$year) as $ch) {
$s .= $d[(int)$ch] ?? '〇';
}
return $s;
}
}