人话名字。 * * ⚠️ 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 */ 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; } }