Files
FamilyBook/app/Birthday.php
T
laoyang 8f80f3e835 v24 第 2 轮:19 条清单收尾(附件预览/CalDAV/任务分类看板/全局选人/家谱/纪念日/财务)
用户 2026-10-02 提的 19 条需求,本轮完成剩下的 8 条 + CalDAV 服务端:

- ③ 附件:图片/视频给缩略图,PDF/Word/Excel 给**同尺寸**图标;可预览的在本页
     弹层里看(不再新开标签页),渲染不了的点了直接下载;预览层右上角恒有下载按钮。
- ⑦b CalDAV:把「我的日历本」按 RFC 4791 只读子集分享出去(OPTIONS/PROPFIND/
     REPORT/GET,写方法一律 403)。用**专用口令**(库里只存 sha256),不是登录口令。
- ⑨⑩ 任务:分类与看板都由用户自建;已完成的任务从看板消失,改在「已完成」页
     按分类查看/搜索;设置入口在「新建任务」旁的 ☰。
- ⑪ 全局人员选择器:一个片段铺到观影/旅程/资源/日历共享/任务共享/财务共享;
     修掉了老组件「名字和拼音都搜不动」的 bug —— .att-item 自己声明了 display:flex,
     压过了浏览器 [hidden]{display:none},过滤根本没生效。
- ⑫ 家谱:头像改方形不再裁切;姻亲不在图上的卡片给「⇄ 姻亲」入口,
     以 TA 为中心重画 + 「返回我的家谱」。
- ⑬ 纪念日:阴历/阳历都能设且按对应历法重复;名册里每个人的生日默认可见
     (实时派生,不复制一份);全部作为全天日程进日历。
- ⑮ 财务:账本可共享,双方都能记账、都能看全部账目,资产−负债=净资产;
     金额一律以「分」存,不用浮点。

验证:端到端 724 项通过 / 0 失败;几何 368、交互 97、移动端 516、家谱 24、
升级 123 全绿;SQL 列名静态校验 0 处可疑;部署 11 条线上探针全过。
2026-10-03 08:18:05 +08:00

371 lines
17 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(用户 2026-10-02 第 16 条):**身份证生日默认不显示**。
* 它照常存库(户籍事实,永不变),但不出现在任何页面与任何提醒里,
* 对外只保留"阳历生日"与"农历生日"两种真实生日。
*
* 为什么要有这些(而不是把生日当一个字段):
* · 身份证上的那个是**户籍事实**,和证件完全一致,任何人都不该在系统里改它 ——
* 它和真实生日**经常不一样**(当年上户口随手写的、只报农历的、报错的)。
* · 真实阳历生日是"实际过的那天",是给本人过的。
* · 农历生日是长辈那边记的那天,很多人只认这个。
* 三个人可能是同一天,也可能差好几天甚至几个月 —— 所以提醒要分开出。
*
* 存法:
* 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
{
/**
* 会**往外显示**的生日种类 => 人话名字。
*
* ⚠️ v24(用户 2026-10-02 第 16 条):这里原本有三档,含身份证生日。
* 现在只剩阳历与农历两种 —— 身份证生日仍然照常存库
* (`birthday_id_card`,只读、永不变),但**任何地方都不再显示它**:
* 它常常和真实生日不是同一天,两个日期并列摆着反而让人分不清"到底哪天过生日"。
*/
public const KINDS = [
'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']);
// v24 第 16 条:身份证生日不再参与"接下来要过哪个生日"(理由见 describe())。
// 它仍然会照常存库,只是不再产出提醒与日历条目。
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'] ?? '');
// ⚠️ v24(用户 2026-10-02 第 16 条):**身份证生日默认不显示**。
// 它只是"证件上的那一天",常常和真实生日不是同一天,两个日期并列摆在详情页
// 反而让人分不清"到底哪天过生日"。现在只显示阳历与农历两种真实生日。
// 身份证生日仍然照常在库里(birthday_id_card 只读、永不变),只是不往外显示。
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;
}
}