Files
FamilyBook/app/Birthday.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

362 lines
16 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
/**
* 三个生日:身份证生日 / 真实阳历生日 / 农历生日。
*
* 为什么要有三个(而不是把生日当一个字段):
* · 身份证上的那个是**户籍事实**,和证件完全一致,任何人都不该在系统里改它 ——
* 它和真实生日**经常不一样**(当年上户口随手写的、只报农历的、报错的)。
* · 真实阳历生日是"实际过的那天",是给本人过的。
* · 农历生日是长辈那边记的那天,很多人只认这个。
* 三个人可能是同一天,也可能差好几天甚至几个月 —— 所以提醒要分开出。
*
* 存法:
* birthday DATETIME —— 真实阳历生日(精确到时分秒)。
* ⚠️ 列名就叫 birthday(历史原因:它原本是个 DATE 字段),
* 而表单里叫 birthday_solar。读取一律走 Birthday::solarOf()。
* birthday_id_card DATE —— 由身份证第 7-14 位推导,只读、永不变
* birthday_lunar DATETIME —— **日期部分是农历年月日**,时间部分与阳历一致
* birthday_lunar_leap TINYINT —— 该农历月是否闰月
*
* ⚠️ `birthday_lunar` 的日期部分不是公历!比如 '1954-11-01' 表示
* 「农历一九五四年冬月初一」。这样存是为了让"农历生日"这个数据本身
* 可读、可编辑、可回溯,而不是只存一个算出来的公历日期
* (那样第二年就没法用它反推农历了)。
*/
declare(strict_types=1);
namespace App;
final class Birthday
{
/** 三种生日的键 => 人话名字 */
public const KINDS = [
'id' => '身份证生日',
'solar' => '阳历生日',
'lunar' => '农历生日',
];
/**
* 从身份证号里截出生日。
* 18 位:第 7-14 位是 YYYYMMDD;15 位(老证):第 7-12 位是 YYMMDD,年份补 19。
*/
public static function fromIdCard(?string $idCard): ?string
{
$s = strtoupper(trim((string)$idCard));
if ($s === '') {
return null;
}
if (preg_match('/^\d{17}[\dX]$/', $s)) {
$y = (int)substr($s, 6, 4);
$m = (int)substr($s, 10, 2);
$d = (int)substr($s, 12, 2);
} elseif (preg_match('/^\d{15}$/', $s)) {
$y = 1900 + (int)substr($s, 6, 2);
$m = (int)substr($s, 8, 2);
$d = (int)substr($s, 10, 2);
} else {
return null;
}
if (!checkdate($m, $d, $y) || $y < Lunar::MIN_YEAR || $y > Lunar::MAX_YEAR) {
return null;
}
return sprintf('%04d-%02d-%02d', $y, $m, $d);
}
/** 'Y-m-d H:i:s' 的日期部分(空/非法输入返回 '') */
public static function dateOnly(?string $dt): string
{
return substr(trim((string)$dt), 0, 10);
}
/**
* 取一「行数据」里的阳历生日原值。
*
* ⚠️ 为什么要有这个函数:**同一个东西有两个键名** ——
* · 数据库列叫 `birthday`(历史原因:它原本就是一个 DATE 字段,升级时只改了类型)。
* · 表单字段叫 `birthday_solar`(要和 `birthday_lunar` 对称,否则读表单的人
* 会以为 `birthday` 是"总生日")。
* 两边混用就会出"线上永远看不到阳历生日"这类静默 bug:
* 直接读 `$row['birthday_solar']` 时,来自 `SELECT * FROM persons` 的行里
* **根本没有这个键**,取到 null,代码不报错、只是那一条生日消失了。
* 所以凡是"读一行人"的地方都走这里,两个键都认。
*/
public static function solarOf(array $person): string
{
foreach (['birthday', 'birthday_solar'] as $k) {
$v = trim((string)($person[$k] ?? ''));
if ($v !== '') {
return $v;
}
}
return '';
}
/** 把 'Y-m-d' 或 'Y-m-d H:i:s' 统一成 'Y-m-d H:i:s' */
public static function toDateTime(?string $v, string $fallbackTime = '00:00:00'): ?string
{
$v = trim((string)$v);
if ($v === '') {
return null;
}
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $v, $m)) {
return checkdate((int)$m[2], (int)$m[3], (int)$m[1])
? sprintf('%04d-%02d-%02d %s', $m[1], $m[2], $m[3], $fallbackTime)
: null;
}
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?$/', $v, $m)) {
if (!checkdate((int)$m[2], (int)$m[3], (int)$m[1])) {
return null;
}
$h = min(23, max(0, (int)$m[4]));
$i = min(59, max(0, (int)$m[5]));
$s = isset($m[6]) ? min(59, max(0, (int)$m[6])) : 0;
return sprintf('%04d-%02d-%02d %02d:%02d:%02d', $m[1], $m[2], $m[3], $h, $i, $s);
}
return null;
}
/**
* 把表单送来的三个生日规范化成可落库的一行。
*
* $in 里认这些键:
* id_card 身份证号(用来推导身份证生日)
* birthday_id_card 库里已有的身份证生日(**只进不出**:一旦有了就不再改、更不会清掉)
* birthday_solar 阳历生日 'Y-m-d H:i:s'
* birthday_lunar 农历生日 'Y-m-d H:i:s'(日期部分是农历)
* birthday_lunar_leap 该农历月是否闰月
* birthday_source 'solar' | 'lunar' —— 用户这次填的是哪一个,以它为准
*
* @return array{id_birthday:?string,solar:?string,lunar:?string,lunar_leap:int,warning:string}
*/
public static function normalize(array $in): array
{
$warning = '';
// ① 身份证生日:由证件推导。证件没填/不合法就沿用已有的 ——
// 「永远不要发生改变」的落点就在这里:它只可能被证件本身改写,不会被人手清掉。
$idBirthday = self::fromIdCard($in['id_card'] ?? null) ?? self::dateOnly($in['birthday_id_card'] ?? null);
if ($idBirthday === '') {
$idBirthday = null;
}
$solar = self::toDateTime($in['birthday_solar'] ?? null);
$lunarRaw = trim((string)($in['birthday_lunar'] ?? ''));
$lunarDate = self::toDateTime($lunarRaw);
$lunarLeap = !empty($in['birthday_lunar_leap']) ? 1 : 0;
$source = (string)($in['birthday_source'] ?? 'solar');
// 「几点生的」只有一个,所以时:分:秒要跟着**用户这次填的那一边**走。
// ⚠️ 早先这里只从阳历取时间,于是"只填农历、时辰填了 06:15:30"会被静默
// 存成 00:00:00 —— 农历那边填的时辰像没填一样。两边都可能带时刻,得挑对不对。
$timedSrc = $source === 'lunar' ? ($lunarDate ?? $solar) : ($solar ?? $lunarDate);
$time = $timedSrc !== null ? substr($timedSrc, 11) : null;
if ($source === 'lunar' && $lunarDate !== null) {
// 以农历为准 → 反推阳历
[$ly, $lm, $ld] = array_map('intval', explode('-', self::dateOnly($lunarDate)));
$sol = Lunar::lunarToSolar($ly, $lm, $ld, $lunarLeap === 1);
if ($sol === null) {
// 该年没有这个闰月 / 该月没有这一天(如腊月三十遇到 29 天的腊月)
$warning = '农历' . Lunar::monthName($lm, $lunarLeap === 1) . Lunar::dayName($ld)
. '在' . Lunar::yearLabel($ly) . '年不存在,已按「非闰月」重算,请确认。';
$sol = Lunar::lunarToSolar($ly, $lm, $ld, false);
$lunarLeap = 0;
}
if ($sol !== null) {
$solar = sprintf('%04d-%02d-%02d %s', $sol['year'], $sol['month'], $sol['day'],
$time ?? '00:00:00');
}
}
if ($solar === null && $idBirthday !== null) {
// 阳历生日默认就是身份证上的那天
$solar = $idBirthday . ' 00:00:00';
}
// 以阳历为准 → 算农历(用户没手填农历时,或者刚才走的就是阳历分支)
if ($solar !== null && ($source !== 'lunar' || $lunarDate === null)) {
[$y, $m, $d] = array_map('intval', explode('-', self::dateOnly($solar)));
$lu = Lunar::solarToLunar($y, $m, $d);
if ($lu !== null) {
// 时间部分跟着阳历走:同一个人"几点生的"只有一个
$lunarDate = sprintf('%04d-%02d-%02d %s', $lu['year'], $lu['month'], $lu['day'], substr($solar, 11));
$lunarLeap = $lu['is_leap'] ? 1 : 0;
}
}
// 阳历生日倒过来对齐身份证:不强制 —— 真实生日本来就允许与证件不同。
return [
'id_birthday' => $idBirthday,
'solar' => $solar,
'lunar' => $lunarDate,
'lunar_leap' => $lunarLeap,
'warning' => $warning,
];
}
/** 把生日字符串渲染成「1990-05-12 14:30:00」里好看的一段 */
public static function pretty(?string $dt): string
{
$dt = trim((string)$dt);
if ($dt === '') {
return '';
}
$date = self::dateOnly($dt);
$time = substr($dt, 11, 8);
if ($time === '' || $time === '00:00:00') {
return $date;
}
return $date . ' ' . $time;
}
/**
* 下一次过这个"阳历类"生日(身份证生日 / 阳历生日都走这里)。
* 2 月 29 日在平年退到 2 月 28 日 —— 总得有个说法,比"今年不提醒"合理。
*/
private static function nextSolarDate(string $dateYmd, string $today): string
{
$md = substr($dateYmd, 5, 5);
$year = (int)substr($today, 0, 4);
for ($i = 0; $i <= 2; $i++) {
$y = $year + $i;
$cand = sprintf('%04d-%s', $y, $md);
if (!checkdate((int)substr($md, 0, 2), (int)substr($md, 3, 2), $y)) {
$cand = sprintf('%04d-02-28', $y); // 2/29 落到平年
}
if ($cand >= $today) {
return $cand;
}
}
return sprintf('%04d-%s', $year + 1, $md);
}
private static function daysFrom(string $today, string $date): int
{
$utc = new \DateTimeZone('UTC');
$a = \DateTimeImmutable::createFromFormat('!Y-m-d', $today, $utc);
$b = \DateTimeImmutable::createFromFormat('!Y-m-d', $date, $utc);
return (int)$a->diff($b)->days;
}
/**
* 这个人接下来要过哪几个生日。
*
* 三个生日落在同一天时**只出一条**(合并 kinds)—— 用户不需要看三遍
* 「今天生日快乐」;不在同一天就分开出,这正是"要分别提醒"的意思。
*
* @return array<int, array{date:string,days:int,kinds:string[],lunar_label:string,age:?int}>
*/
public static function nextOccurrences(array $person, ?string $today = null, int $withinDays = 60): array
{
$today = $today ?: date('Y-m-d');
$buckets = []; // date => ['kinds'=>[], 'lunar_label'=>'']
$solarBd = self::dateOnly(self::solarOf($person));
$idBd = self::dateOnly($person['birthday_id_card'] ?? '');
$lunarBd = self::dateOnly($person['birthday_lunar'] ?? '');
$lunarLeap = !empty($person['birthday_lunar_leap']);
if ($idBd !== '') {
$d = self::nextSolarDate($idBd, $today);
$buckets[$d]['kinds'][] = 'id';
}
if ($solarBd !== '') {
$d = self::nextSolarDate($solarBd, $today);
$buckets[$d]['kinds'][] = 'solar';
}
if ($lunarBd !== '') {
[$ly, $lm, $ld] = array_map('intval', explode('-', $lunarBd));
$baseYear = (int)substr($today, 0, 4);
// 农历新年在公历 1~2 月,所以要看前后各一年才不漏
for ($ly2 = $baseYear - 1; $ly2 <= $baseYear + 2; $ly2++) {
if (!Lunar::inRange($ly2)) {
continue;
}
$leap = $lunarLeap && Lunar::leapMonth($ly2) === $lm;
$ok = $leap ? ($ld <= Lunar::leapDays($ly2)) : ($ld <= Lunar::monthDays($ly2, $lm));
if (!$ok) {
// 该年这个月没有这一天(如「腊月三十」遇到 29 天的腊月)→ 退到该月最后一天
$ldUse = min($ld, $leap ? Lunar::leapDays($ly2) : Lunar::monthDays($ly2, $lm));
$sol = Lunar::lunarToSolar($ly2, $lm, $ldUse, $leap);
if ($sol === null) {
continue;
}
$ld = $ldUse;
} else {
$sol = Lunar::lunarToSolar($ly2, $lm, $ld, $leap);
}
if ($sol === null) {
continue;
}
$cand = sprintf('%04d-%02d-%02d', $sol['year'], $sol['month'], $sol['day']);
if ($cand >= $today) {
$buckets[$cand]['kinds'][] = 'lunar';
$buckets[$cand]['lunar_label'] = Lunar::monthName($lm, $leap) . Lunar::dayName($ld);
break;
}
}
}
$out = [];
foreach ($buckets as $date => $info) {
$days = self::daysFrom($today, $date);
if ($days > $withinDays) {
continue;
}
$out[] = [
'date' => $date,
'days' => $days,
'kinds' => array_values(array_unique($info['kinds'])),
'lunar_label' => (string)($info['lunar_label'] ?? ''),
'age' => $solarBd !== '' ? self::ageOn($solarBd, $date) : null,
];
}
usort($out, fn($a, $b) => $a['days'] <=> $b['days']);
return $out;
}
/** 到某个公历日期时的周岁 */
public static function ageOn(string $birthYmd, string $onDate): int
{
$utc = new \DateTimeZone('UTC');
$b = \DateTimeImmutable::createFromFormat('!Y-m-d', self::dateOnly($birthYmd), $utc);
$o = \DateTimeImmutable::createFromFormat('!Y-m-d', self::dateOnly($onDate), $utc);
if ($b === false || $o === false) {
return 0;
}
return (int)$b->diff($o)->y;
}
/** 三种生日的展示用清单(详情页/个人信息页都吃这个) */
public static function describe(array $person): array
{
$rows = [];
$idBd = self::dateOnly($person['birthday_id_card'] ?? '');
$solarRaw = self::solarOf($person);
$solarBd = self::dateOnly($solarRaw);
$lunarBd = self::dateOnly($person['birthday_lunar'] ?? '');
if ($idBd !== '') {
$rows[] = ['kind' => 'id', 'text' => $idBd, 'note' => '由身份证推导,不可修改', 'date' => $idBd];
}
if ($solarBd !== '') {
$rows[] = ['kind' => 'solar', 'text' => self::pretty($solarRaw), 'note' => '', 'date' => $solarBd];
}
if ($lunarBd !== '') {
[$ly, $lm, $ld] = array_map('intval', explode('-', $lunarBd));
$leap = !empty($person['birthday_lunar_leap']);
$rows[] = [
'kind' => 'lunar',
'text' => Lunar::label($ly, $lm, $ld, $leap) . (substr((string)$person['birthday_lunar'], 11, 8) !== '00:00:00'
? ' ' . substr((string)$person['birthday_lunar'], 11, 8) : ''),
'note' => '',
'date' => $lunarBd,
];
}
return $rows;
}
}