按九点反馈一次性改造: 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 条(生日整框可点、登录居中)。
362 lines
16 KiB
PHP
362 lines
16 KiB
PHP
<?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;
|
||
}
|
||
}
|