登录居中 · 个人 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 条(生日整框可点、登录居中)。
This commit is contained in:
@@ -0,0 +1,361 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
/**
|
||||
* 「我怎么称呼他」—— 称呼是**视角属性**,不是人的属性。
|
||||
*
|
||||
* 为什么不再放 persons.nickname(这是被明确纠正过的一处设计):
|
||||
* 同一个「小明」,妈妈说"明明"、姐姐说"小弟"、爷爷说"小孙子"。
|
||||
* 小名不是这个人自带的属性,而是**每个称呼者各自定的**。
|
||||
* 存在 persons 上就变成了"全家人共用一个小名",
|
||||
* 而且当事人一改,全家人的叫法都跟着变 —— 这正是要避免的。
|
||||
*
|
||||
* 所以换成一张 (viewer_id, target_id) → nickname 的关系表:
|
||||
* · 每个人只影响自己的视角,改自己的称呼不影响别人;
|
||||
* · 没设过就用姓名,永远有得显示,不会出现空标签。
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class Nickname
|
||||
{
|
||||
public const MAX_LEN = 50;
|
||||
|
||||
/** 我对某个人的称呼;没设过返回 '' */
|
||||
public static function get(int $viewerId, int $targetId): string
|
||||
{
|
||||
if ($viewerId <= 0 || $targetId <= 0) {
|
||||
return '';
|
||||
}
|
||||
$st = Database::get()->prepare(
|
||||
'SELECT nickname FROM nicknames WHERE viewer_id = ? AND target_id = ? LIMIT 1'
|
||||
);
|
||||
$st->execute([$viewerId, $targetId]);
|
||||
$v = $st->fetchColumn();
|
||||
return $v === false ? '' : (string)$v;
|
||||
}
|
||||
|
||||
/** 设置(空串 = 删掉这条,恢复成用姓名) */
|
||||
public static function set(int $familyId, int $viewerId, int $targetId, string $nick): void
|
||||
{
|
||||
if ($viewerId <= 0 || $targetId <= 0 || $viewerId === $targetId) {
|
||||
return; // 自己不给自己起称呼
|
||||
}
|
||||
$nick = trim(preg_replace('/\s+/u', ' ', $nick));
|
||||
$db = Database::get();
|
||||
if ($nick === '') {
|
||||
$db->prepare('DELETE FROM nicknames WHERE viewer_id = ? AND target_id = ?')
|
||||
->execute([$viewerId, $targetId]);
|
||||
return;
|
||||
}
|
||||
if (mb_strlen($nick, 'UTF-8') > self::MAX_LEN) {
|
||||
$nick = mb_substr($nick, 0, self::MAX_LEN, 'UTF-8');
|
||||
}
|
||||
$db->prepare(
|
||||
'INSERT INTO nicknames (family_id, viewer_id, target_id, nickname) VALUES (?,?,?,?)
|
||||
ON DUPLICATE KEY UPDATE nickname = VALUES(nickname)'
|
||||
)->execute([$familyId, $viewerId, $targetId, $nick]);
|
||||
}
|
||||
|
||||
/** 我设过的全部称呼:target_id => nickname */
|
||||
public static function mapFor(int $viewerId): array
|
||||
{
|
||||
if ($viewerId <= 0) {
|
||||
return [];
|
||||
}
|
||||
$st = Database::get()->prepare('SELECT target_id, nickname FROM nicknames WHERE viewer_id = ?');
|
||||
$st->execute([$viewerId]);
|
||||
$out = [];
|
||||
foreach ($st->fetchAll() as $r) {
|
||||
$out[(int)$r['target_id']] = (string)$r['nickname'];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示名:有「我怎么称呼他」就用它,否则用姓名。
|
||||
* 页面上凡是"给人看的人名"都该走这里,而不是直接 echo name。
|
||||
*/
|
||||
public static function display(int $viewerId, ?array $person): string
|
||||
{
|
||||
if (!$person) {
|
||||
return '';
|
||||
}
|
||||
$nick = self::get($viewerId, (int)($person['id'] ?? 0));
|
||||
return $nick !== '' ? $nick : (string)($person['name'] ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次性把老库里的 persons.nickname 搬进来。
|
||||
*
|
||||
* 老语义是"全家共用一个小名",新语义是"每人各自称呼"。
|
||||
* 迁移时**给家里每个账号都写一份**,这样迁移前后大家看到的叫法完全一样;
|
||||
* 之后谁想改成自己的叫法,改就是了。
|
||||
* 幂等:靠 settings 里的标记,搬过一次就不再搬(否则会把用户后来的修改冲掉)。
|
||||
*/
|
||||
public static function migrateLegacy(int $familyId): int
|
||||
{
|
||||
if ((string)Setting::get($familyId, 'nickname_migrated', '0') === '1') {
|
||||
return 0;
|
||||
}
|
||||
$db = Database::get();
|
||||
|
||||
// 旧列已经不在了(将来的清理,或全新安装)→ 没有任何东西可搬,直接记成已搬。
|
||||
// 这一步让迁移"以后删掉那列也不会炸",否则升级会在信息模式查询上直接抛异常。
|
||||
$has = (int)$db->query(
|
||||
"SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'persons' AND COLUMN_NAME = 'nickname'"
|
||||
)->fetchColumn();
|
||||
if ($has === 0) {
|
||||
Setting::set($familyId, 'nickname_migrated', '1');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$rows = $db->prepare(
|
||||
"SELECT id, nickname FROM persons
|
||||
WHERE family_id = ? AND nickname IS NOT NULL AND nickname <> ''"
|
||||
);
|
||||
$rows->execute([$familyId]);
|
||||
$legacy = $rows->fetchAll();
|
||||
|
||||
$viewers = $db->prepare(
|
||||
"SELECT p.id FROM persons p JOIN accounts a ON a.person_id = p.id
|
||||
WHERE p.family_id = ? AND a.status = 'active'"
|
||||
);
|
||||
$viewers->execute([$familyId]);
|
||||
$viewerIds = array_map('intval', array_column($viewers->fetchAll(), 'id'));
|
||||
|
||||
$ins = $db->prepare(
|
||||
'INSERT IGNORE INTO nicknames (family_id, viewer_id, target_id, nickname) VALUES (?,?,?,?)'
|
||||
);
|
||||
$n = 0;
|
||||
foreach ($legacy as $p) {
|
||||
foreach ($viewerIds as $vid) {
|
||||
if ($vid === (int)$p['id']) {
|
||||
continue; // 自己不给自己起称呼
|
||||
}
|
||||
$ins->execute([$familyId, $vid, (int)$p['id'], (string)$p['nickname']]);
|
||||
$n++;
|
||||
}
|
||||
}
|
||||
Setting::set($familyId, 'nickname_migrated', '1');
|
||||
return $n;
|
||||
}
|
||||
|
||||
/** 我给别人起的全部称呼(个人信息页用),带被称呼人的姓名 */
|
||||
public static function mineWithNames(int $familyId, int $viewerId): array
|
||||
{
|
||||
$st = Database::get()->prepare(
|
||||
'SELECT n.target_id, n.nickname, p.name, p.avatar, p.is_placeholder
|
||||
FROM nicknames n JOIN persons p ON p.id = n.target_id
|
||||
WHERE n.viewer_id = ? AND n.family_id = ?
|
||||
ORDER BY p.sort_order ASC, p.id ASC'
|
||||
);
|
||||
$st->execute([$viewerId, $familyId]);
|
||||
return $st->fetchAll();
|
||||
}
|
||||
}
|
||||
+137
-58
@@ -1,9 +1,14 @@
|
||||
<?php
|
||||
/**
|
||||
* 家庭名册(家庭成员 + 将来的外部联系人共用一张表)。
|
||||
* 家庭名册(家庭成员 + 外部联系人共用一张表)。
|
||||
*
|
||||
* 之所以不拆成两张表:两者字段几乎一样;而且需要互相转换 ——
|
||||
* 一个「认识的人」随时可能变成家庭成员,一个家庭成员也可能只作为联系人存在。
|
||||
*
|
||||
* 三处刻意的设计(都是被纠正过的):
|
||||
* · **小名不在这里**。称呼是"谁在叫"的属性,存在 nicknames 表(见 Nickname 类)。
|
||||
* · 生日不是一个字段,是三个(身份证 / 阳历 / 农历),见 Birthday 类。
|
||||
* · 姓名与称呼的读取都要过 `Nickname::display()`,页面不要直接 echo name。
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
@@ -12,17 +17,25 @@ namespace App;
|
||||
|
||||
final class Person
|
||||
{
|
||||
/** 名册列表(连带账号信息,用于显示「是否已开通登录」) */
|
||||
public static function all(int $familyId, string $q = '', string $kind = 'member'): array
|
||||
/**
|
||||
* 名册列表(连带账号信息与「我叫他什么」)。
|
||||
*
|
||||
* `my_nickname` 是**当前登录者**给这个人起的称呼 —— 同一份名册,
|
||||
* 爸爸看到的是"儿子",妈妈看到的是"小宝",靠的就是这个字段。
|
||||
*/
|
||||
public static function all(int $familyId, string $q = '', string $kind = 'member', int $viewerId = 0): array
|
||||
{
|
||||
$sql = 'SELECT p.*, a.id AS account_id, a.role AS account_role, a.status AS account_status,
|
||||
a.last_login_at
|
||||
a.last_login_at, n.nickname AS my_nickname
|
||||
FROM persons p
|
||||
LEFT JOIN accounts a ON a.person_id = p.id
|
||||
WHERE p.family_id = ? AND p.kind = ?';
|
||||
$args = [$familyId, $kind];
|
||||
LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ?';
|
||||
$args = [$viewerId];
|
||||
$sql .= ' WHERE p.family_id = ? AND p.kind = ?';
|
||||
$args[] = $familyId;
|
||||
$args[] = $kind;
|
||||
if ($q !== '') {
|
||||
$sql .= ' AND (p.name LIKE ? OR p.nickname LIKE ? OR p.email LIKE ?)';
|
||||
$sql .= ' AND (p.name LIKE ? OR p.email LIKE ? OR n.nickname LIKE ?)';
|
||||
$like = '%' . $q . '%';
|
||||
$args[] = $like;
|
||||
$args[] = $like;
|
||||
@@ -42,16 +55,18 @@ final class Person
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
/** 带账号信息的详情(详情页用) */
|
||||
public static function findWithAccount(int $id): ?array
|
||||
/** 带账号信息的详情(详情页用;$viewerId 用来取「我叫他什么」) */
|
||||
public static function findWithAccount(int $id, int $viewerId = 0): ?array
|
||||
{
|
||||
$st = Database::get()->prepare(
|
||||
'SELECT p.*, a.id AS account_id, a.role AS account_role,
|
||||
a.status AS account_status, a.last_login_at
|
||||
FROM persons p LEFT JOIN accounts a ON a.person_id = p.id
|
||||
a.status AS account_status, a.last_login_at, n.nickname AS my_nickname
|
||||
FROM persons p
|
||||
LEFT JOIN accounts a ON a.person_id = p.id
|
||||
LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ?
|
||||
WHERE p.id = ?'
|
||||
);
|
||||
$st->execute([$id]);
|
||||
$st->execute([$viewerId, $id]);
|
||||
$row = $st->fetch();
|
||||
return $row ?: null;
|
||||
}
|
||||
@@ -65,33 +80,38 @@ final class Person
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增。手机号是敏感字段,进来就加密 + 哈希。
|
||||
* 新增。手机号是敏感字段,进来就加密 + 哈希;三个生日由 Birthday 规范化。
|
||||
* @return int 新 id
|
||||
*/
|
||||
public static function create(int $familyId, array $d): int
|
||||
{
|
||||
$phone = $d['phone'] ?? '';
|
||||
$phone = (string)($d['phone'] ?? '');
|
||||
$bd = Birthday::normalize($d);
|
||||
$sql = 'INSERT INTO persons
|
||||
(family_id, kind, name, nickname, gender, birth_rank, birthday, avatar,
|
||||
(family_id, kind, name, gender, birth_rank, birthday,
|
||||
birthday_id_card, birthday_lunar, birthday_lunar_leap, slogan, avatar,
|
||||
phone_hash, phone_encrypted, id_card_encrypted, email, address, note,
|
||||
is_placeholder, sort_order)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)';
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)';
|
||||
$st = Database::get()->prepare($sql);
|
||||
$st->execute([
|
||||
$familyId,
|
||||
$d['kind'] ?? 'member',
|
||||
$d['name'],
|
||||
$d['nickname'] !== '' ? $d['nickname'] : null,
|
||||
$d['gender'] !== '' ? $d['gender'] : null,
|
||||
($d['gender'] ?? '') !== '' ? $d['gender'] : null,
|
||||
!empty($d['birth_rank']) ? (int)$d['birth_rank'] : null,
|
||||
$d['birthday'] !== '' ? $d['birthday'] : null,
|
||||
$d['avatar'] !== '' ? $d['avatar'] : null,
|
||||
$bd['solar'],
|
||||
$bd['id_birthday'],
|
||||
$bd['lunar'],
|
||||
$bd['lunar_leap'],
|
||||
($d['slogan'] ?? '') !== '' ? $d['slogan'] : null,
|
||||
($d['avatar'] ?? '') !== '' ? $d['avatar'] : null,
|
||||
$phone !== '' ? Crypto::hash($phone) : null,
|
||||
$phone !== '' ? Crypto::encrypt($phone) : null,
|
||||
($d['id_card'] ?? '') !== '' ? Crypto::encrypt($d['id_card']) : null,
|
||||
$d['email'] !== '' ? $d['email'] : null,
|
||||
$d['address'] !== '' ? $d['address'] : null,
|
||||
$d['note'] !== '' ? $d['note'] : null,
|
||||
($d['email'] ?? '') !== '' ? $d['email'] : null,
|
||||
($d['address'] ?? '') !== '' ? $d['address'] : null,
|
||||
($d['note'] ?? '') !== '' ? $d['note'] : null,
|
||||
!empty($d['is_placeholder']) ? 1 : 0,
|
||||
(int)($d['sort_order'] ?? 1000),
|
||||
]);
|
||||
@@ -100,31 +120,68 @@ final class Person
|
||||
|
||||
public static function update(int $id, array $d): void
|
||||
{
|
||||
$phone = $d['phone'] ?? '';
|
||||
$phone = (string)($d['phone'] ?? '');
|
||||
$bd = Birthday::normalize($d);
|
||||
$sql = 'UPDATE persons SET
|
||||
name = ?, nickname = ?, gender = ?, birth_rank = ?, birthday = ?, avatar = ?,
|
||||
name = ?, gender = ?, birth_rank = ?, birthday = ?,
|
||||
birthday_id_card = ?, birthday_lunar = ?, birthday_lunar_leap = ?, slogan = ?, avatar = ?,
|
||||
phone_hash = ?, phone_encrypted = ?, id_card_encrypted = ?,
|
||||
email = ?, address = ?, note = ?, is_placeholder = ?, sort_order = ?
|
||||
WHERE id = ?';
|
||||
Database::get()->prepare($sql)->execute([
|
||||
$d['name'],
|
||||
$d['nickname'] !== '' ? $d['nickname'] : null,
|
||||
$d['gender'] !== '' ? $d['gender'] : null,
|
||||
($d['gender'] ?? '') !== '' ? $d['gender'] : null,
|
||||
!empty($d['birth_rank']) ? (int)$d['birth_rank'] : null,
|
||||
$d['birthday'] !== '' ? $d['birthday'] : null,
|
||||
$d['avatar'] !== '' ? $d['avatar'] : null,
|
||||
$bd['solar'],
|
||||
$bd['id_birthday'],
|
||||
$bd['lunar'],
|
||||
$bd['lunar_leap'],
|
||||
($d['slogan'] ?? '') !== '' ? $d['slogan'] : null,
|
||||
($d['avatar'] ?? '') !== '' ? $d['avatar'] : null,
|
||||
$phone !== '' ? Crypto::hash($phone) : null,
|
||||
$phone !== '' ? Crypto::encrypt($phone) : null,
|
||||
($d['id_card'] ?? '') !== '' ? Crypto::encrypt($d['id_card']) : null,
|
||||
$d['email'] !== '' ? $d['email'] : null,
|
||||
$d['address'] !== '' ? $d['address'] : null,
|
||||
$d['note'] !== '' ? $d['note'] : null,
|
||||
($d['email'] ?? '') !== '' ? $d['email'] : null,
|
||||
($d['address'] ?? '') !== '' ? $d['address'] : null,
|
||||
($d['note'] ?? '') !== '' ? $d['note'] : null,
|
||||
!empty($d['is_placeholder']) ? 1 : 0,
|
||||
(int)($d['sort_order'] ?? 1000),
|
||||
$id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 自助改自己的资料(个人信息页用)。
|
||||
*
|
||||
* 与上面的 update() 分开是有意的:那个是"管理员编名册",字段全、权限高;
|
||||
* 这个只允许改**属于自己的**东西 —— 姓名、性别、三个生日、一句话、头像、联系方式。
|
||||
* 排行、排序号、虚拟人物标记、以及身份证号(会牵动身份证生日)都只能由管理员改名册时动。
|
||||
*/
|
||||
public static function updateSelf(int $id, array $d): void
|
||||
{
|
||||
$bd = Birthday::normalize($d);
|
||||
Database::get()->prepare(
|
||||
'UPDATE persons SET
|
||||
name = ?, gender = ?, birthday = ?,
|
||||
birthday_id_card = ?, birthday_lunar = ?, birthday_lunar_leap = ?, slogan = ?, avatar = ?,
|
||||
email = ?, address = ?, note = ?
|
||||
WHERE id = ?'
|
||||
)->execute([
|
||||
$d['name'],
|
||||
($d['gender'] ?? '') !== '' ? $d['gender'] : null,
|
||||
$bd['solar'],
|
||||
$bd['id_birthday'],
|
||||
$bd['lunar'],
|
||||
$bd['lunar_leap'],
|
||||
($d['slogan'] ?? '') !== '' ? $d['slogan'] : null,
|
||||
($d['avatar'] ?? '') !== '' ? $d['avatar'] : null,
|
||||
($d['email'] ?? '') !== '' ? $d['email'] : null,
|
||||
($d['address'] ?? '') !== '' ? $d['address'] : null,
|
||||
($d['note'] ?? '') !== '' ? $d['note'] : null,
|
||||
$id,
|
||||
]);
|
||||
}
|
||||
|
||||
/** 只改头像(上传后单独调用,避免整条记录被表单覆盖) */
|
||||
public static function setAvatar(int $id, ?string $avatar): void
|
||||
{
|
||||
@@ -149,42 +206,49 @@ final class Person
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** 只给「选人」用的精简列表(下拉框、关系选择器) */
|
||||
public static function options(int $familyId, int $exceptId = 0): array
|
||||
/** 只给「选人」用的精简列表(下拉框、关系选择器),带「我叫他什么」 */
|
||||
public static function options(int $familyId, int $exceptId = 0, int $viewerId = 0): array
|
||||
{
|
||||
$st = Database::get()->prepare(
|
||||
'SELECT id, name, nickname, gender, is_placeholder FROM persons
|
||||
WHERE family_id = ? AND kind = "member" AND id <> ?
|
||||
ORDER BY sort_order ASC, id ASC'
|
||||
'SELECT p.id, p.name, p.gender, p.avatar, p.is_placeholder, n.nickname AS my_nickname
|
||||
FROM persons p
|
||||
LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ?
|
||||
WHERE p.family_id = ? AND p.kind = "member" AND p.id <> ?
|
||||
ORDER BY p.sort_order ASC, p.id ASC'
|
||||
);
|
||||
$st->execute([$familyId, $exceptId]);
|
||||
$st->execute([$viewerId, $familyId, $exceptId]);
|
||||
return $st->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* 最近要过生日的人(首页用)。
|
||||
* 直接算「今天是今年的第几天」与生日的差值,跨年自动处理。
|
||||
*
|
||||
* ⚠️ 这里返回的是「**一个生日事件**」而不是「一个人」:
|
||||
* 一个人可能同时有身份证生日与农历生日两条(不重合时),
|
||||
* 也可能它们正好落在同一天(那就是 1 条,kinds 里有两个)。
|
||||
* 早先按"人"来算,于是农历生日完全不会被提醒 —— 而家里老人过的正是农历。
|
||||
*/
|
||||
public static function upcomingBirthdays(int $familyId, int $withinDays = 60): array
|
||||
{
|
||||
$st = Database::get()->prepare(
|
||||
"SELECT id, name, avatar, birthday FROM persons
|
||||
WHERE family_id = ? AND kind = 'member' AND birthday IS NOT NULL
|
||||
ORDER BY DAYOFYEAR(birthday)"
|
||||
);
|
||||
$st->execute([$familyId]);
|
||||
$rows = $st->fetchAll();
|
||||
|
||||
$today = date('Y-m-d');
|
||||
$out = [];
|
||||
foreach ($rows as $r) {
|
||||
$days = days_until_birthday($r['birthday']);
|
||||
if ($days !== null && $days <= $withinDays) {
|
||||
$r['days_left'] = $days;
|
||||
$out[] = $r;
|
||||
foreach (self::map($familyId) as $p) {
|
||||
foreach (Birthday::nextOccurrences($p, $today, $withinDays) as $occ) {
|
||||
$out[] = [
|
||||
'id' => (int)$p['id'],
|
||||
'name' => (string)$p['name'],
|
||||
'avatar' => $p['avatar'],
|
||||
'date' => $occ['date'],
|
||||
'days_left' => $occ['days'],
|
||||
'kinds' => $occ['kinds'],
|
||||
'kind_words' => array_map(fn($k) => Birthday::KINDS[$k] ?? $k, $occ['kinds']),
|
||||
'lunar_label' => $occ['lunar_label'],
|
||||
'age' => $occ['age'],
|
||||
];
|
||||
}
|
||||
}
|
||||
usort($out, function ($a, $b) {
|
||||
return $a['days_left'] <=> $b['days_left'];
|
||||
return $a['days_left'] <=> $b['days_left'] ?: strcmp($a['date'], $b['date']);
|
||||
});
|
||||
return $out;
|
||||
}
|
||||
@@ -204,19 +268,34 @@ final class Person
|
||||
if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$err['email'] = '邮箱格式看起来不对';
|
||||
}
|
||||
$b = trim((string)($d['birthday'] ?? ''));
|
||||
if ($b !== '' && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $b)) {
|
||||
$err['birthday'] = '生日请按 年-月-日 填写';
|
||||
// 生日现在是「日期 + 时分秒」,但要宽松:只填日期也必须收下
|
||||
foreach (['birthday_solar' => '阳历生日', 'birthday_lunar' => '农历生日'] as $key => $word) {
|
||||
$v = trim((string)($d[$key] ?? ''));
|
||||
if ($v === '') {
|
||||
continue;
|
||||
}
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}([ ]\d{2}:\d{2}(:\d{2})?)?$/', $v)) {
|
||||
$err[$key] = $word . '格式不对';
|
||||
continue;
|
||||
}
|
||||
[$yy, $mm, $dd] = array_map('intval', explode('-', substr($v, 0, 10)));
|
||||
if (!checkdate($mm, $dd, $yy) || $yy < Lunar::MIN_YEAR || $yy > Lunar::MAX_YEAR) {
|
||||
$err[$key] = $word . '看起来不是一个真实的日子';
|
||||
}
|
||||
}
|
||||
// 身份证号只做很松的校验:家庭场景里要不要存它,本来就值得三思
|
||||
$idc = trim((string)($d['id_card'] ?? ''));
|
||||
if ($idc !== '' && !preg_match('/^[0-9Xx]{15,18}$/', $idc)) {
|
||||
$err['id_card'] = '身份证号应为 15 或 18 位';
|
||||
if ($idc !== '' && !preg_match('/^\d{17}[\dXx]$|^\d{15}$/', $idc)) {
|
||||
$err['id_card'] = '身份证号应为 18 位(或 15 位老证)';
|
||||
}
|
||||
$rank = $d['birth_rank'] ?? '';
|
||||
if ($rank !== '' && $rank !== null && (!ctype_digit((string)$rank) || (int)$rank < 1 || (int)$rank > 99)) {
|
||||
$err['birth_rank'] = '排行请填 1-99 的整数(老大填 1)';
|
||||
}
|
||||
$slogan = trim((string)($d['slogan'] ?? ''));
|
||||
if (mb_strlen($slogan, 'UTF-8') > 200) {
|
||||
$err['slogan'] = '一句话请控制在 200 字以内';
|
||||
}
|
||||
return $err;
|
||||
}
|
||||
|
||||
|
||||
+110
-1
@@ -16,6 +16,17 @@ namespace App;
|
||||
|
||||
final class Schema
|
||||
{
|
||||
/**
|
||||
* 当前结构版本。**唯一来源** —— index.php 的 FB_SCHEMA_VERSION 与
|
||||
* install.php 的盖章都取自这里,别在两处各写一个字符串(迟早会漂移)。
|
||||
*
|
||||
* 版本史:
|
||||
* 1 → 初始(families/persons/accounts/settings/relations)
|
||||
* 2 → birth_rank、is_placeholder、accounts.role 加 guest
|
||||
* 3 → 三个生日(birthday 升 DATETIME + id_card/lunar/leap)、slogan、nicknames 表
|
||||
*/
|
||||
public const VERSION = '3';
|
||||
|
||||
/** @return string[] */
|
||||
public static function statements(): array
|
||||
{
|
||||
@@ -35,7 +46,7 @@ CREATE TABLE IF NOT EXISTS `persons` (
|
||||
`family_id` INT UNSIGNED NOT NULL,
|
||||
`kind` ENUM('member','contact') NOT NULL DEFAULT 'member' COMMENT '家庭成员 / 外部联系人',
|
||||
`name` VARCHAR(50) NOT NULL COMMENT '姓名',
|
||||
`nickname` VARCHAR(50) DEFAULT NULL COMMENT '小名',
|
||||
`nickname` VARCHAR(50) DEFAULT NULL COMMENT '【旧语义,已废弃】全家共用的小名;升级时搬进 nicknames 表,新代码不再读写它',
|
||||
`gender` ENUM('m','f','x') DEFAULT NULL COMMENT '男 / 女 / 未填写',
|
||||
`birth_rank` INT DEFAULT NULL COMMENT '家中排行(同一对父母的子女里,1=老大),用来算长子/次女',
|
||||
`birthday` DATE DEFAULT NULL COMMENT '生日(自动生成日历上的生日日程用)',
|
||||
@@ -97,6 +108,21 @@ CREATE TABLE IF NOT EXISTS `settings` (
|
||||
PRIMARY KEY (`family_id`, `k`),
|
||||
CONSTRAINT `fk_settings_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='家庭级键值对设置'
|
||||
SQL,
|
||||
<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `nicknames` (
|
||||
`family_id` INT UNSIGNED NOT NULL,
|
||||
`viewer_id` INT UNSIGNED NOT NULL COMMENT '谁在称呼别人(当前登录的人)',
|
||||
`target_id` INT UNSIGNED NOT NULL COMMENT '被称呼的人',
|
||||
`nickname` VARCHAR(50) NOT NULL COMMENT '我叫他/她什么',
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`viewer_id`, `target_id`),
|
||||
KEY `idx_nick_family` (`family_id`),
|
||||
KEY `idx_nick_target` (`target_id`),
|
||||
CONSTRAINT `fk_nick_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_nick_viewer` FOREIGN KEY (`viewer_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_nick_target` FOREIGN KEY (`target_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='称呼是视角属性:每个人各自决定怎么称呼别人,不挂在被称呼者身上'
|
||||
SQL,
|
||||
];
|
||||
}
|
||||
@@ -119,6 +145,89 @@ SQL,
|
||||
self::addColumn($db, 'persons', 'is_placeholder',
|
||||
"ADD COLUMN `is_placeholder` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '虚拟人物:还没弄清真实身份的占位(如「我的父亲」)' AFTER `note`");
|
||||
self::widenRoleEnum($db);
|
||||
|
||||
// ---- 三个生日(版本 3)----
|
||||
// birthday 原本是 DATE,现在要精确到时分秒 → 升成 DATETIME。
|
||||
// MODIFY 是幂等的(已经是 DATETIME 时再 MODIFY 也不报错、不丢数据)。
|
||||
$db->exec("ALTER TABLE `persons` MODIFY COLUMN `birthday`
|
||||
DATETIME DEFAULT NULL COMMENT '真实阳历生日(精确到时分秒)'");
|
||||
self::addColumn($db, 'persons', 'birthday_id_card',
|
||||
"ADD COLUMN `birthday_id_card` DATE DEFAULT NULL COMMENT '身份证上的生日:由身份证第 7-14 位推导,只读、永不变' AFTER `birthday`");
|
||||
self::addColumn($db, 'persons', 'birthday_lunar',
|
||||
"ADD COLUMN `birthday_lunar` DATETIME DEFAULT NULL COMMENT '农历生日:**日期部分是农历年月日**,时间部分与阳历一致' AFTER `birthday_id_card`");
|
||||
self::addColumn($db, 'persons', 'birthday_lunar_leap',
|
||||
"ADD COLUMN `birthday_lunar_leap` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'birthday_lunar 的月份是否为闰月' AFTER `birthday_lunar`");
|
||||
self::addColumn($db, 'persons', 'slogan',
|
||||
"ADD COLUMN `slogan` VARCHAR(200) DEFAULT NULL COMMENT '个人的一句话;没填就用家庭统一的那句' AFTER `note`");
|
||||
|
||||
// 老数据里 birthday 若是 DATE,升成 DATETIME 后时间部分自动是 00:00:00,无需回填。
|
||||
// 把老数据补出农历生日:只在还没算过时算一次(幂等)。
|
||||
self::backfillLunarBirthday($db);
|
||||
self::backfillIdCardBirthday($db);
|
||||
|
||||
// ---- 称呼从「人的属性」改成「视角属性」----
|
||||
// 老语义:persons.nickname 是全家共用的小名。
|
||||
// 新语义:nicknames 表,(viewer, target) → 我叫他什么。
|
||||
// ⚠️ 必须在这里搬:不然升级之后界面上再没有人读 persons.nickname,
|
||||
// 用户会觉得"以前填的小名一夜之间全没了"。搬的时候给每个账号都写一份,
|
||||
// 迁移前后大家看到的叫法完全一致(`Nickname::migrateLegacy` 里说明了原因)。
|
||||
// 放在结构升级里、而不是"某次请求里顺手做",是为了升级完就已经搬好。
|
||||
foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) {
|
||||
\App\Nickname::migrateLegacy((int)$fam['id']);
|
||||
}
|
||||
}
|
||||
|
||||
/** 老库已有阳历生日但没有农历生日 → 补算一次。用「为空」作条件,天然幂等。 */
|
||||
private static function backfillLunarBirthday(\PDO $db): void
|
||||
{
|
||||
$st = $db->query(
|
||||
"SELECT id, birthday FROM persons
|
||||
WHERE birthday IS NOT NULL AND (birthday_lunar IS NULL OR birthday_lunar = '')"
|
||||
);
|
||||
$rows = $st->fetchAll();
|
||||
if (!$rows) {
|
||||
return;
|
||||
}
|
||||
$up = $db->prepare('UPDATE persons SET birthday_lunar = ?, birthday_lunar_leap = ? WHERE id = ?');
|
||||
foreach ($rows as $r) {
|
||||
$bd = substr((string)$r['birthday'], 0, 10);
|
||||
[$y, $m, $d] = array_map('intval', explode('-', $bd));
|
||||
$lu = \App\Lunar::solarToLunar($y, $m, $d);
|
||||
if ($lu === null) {
|
||||
continue;
|
||||
}
|
||||
$up->execute([
|
||||
sprintf('%04d-%02d-%02d %s', $lu['year'], $lu['month'], $lu['day'], substr((string)$r['birthday'], 11, 8) ?: '00:00:00'),
|
||||
$lu['is_leap'] ? 1 : 0,
|
||||
(int)$r['id'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/** 老库已有身份证号但没有「身份证生日」→ 推导一次。同样以「为空」为条件。 */
|
||||
private static function backfillIdCardBirthday(\PDO $db): void
|
||||
{
|
||||
$st = $db->query(
|
||||
"SELECT id, id_card_encrypted FROM persons
|
||||
WHERE id_card_encrypted IS NOT NULL AND id_card_encrypted <> ''
|
||||
AND (birthday_id_card IS NULL)"
|
||||
);
|
||||
$rows = $st->fetchAll();
|
||||
if (!$rows) {
|
||||
return;
|
||||
}
|
||||
$up = $db->prepare('UPDATE persons SET birthday_id_card = ? WHERE id = ?');
|
||||
foreach ($rows as $r) {
|
||||
try {
|
||||
$id = \App\Crypto::decrypt((string)$r['id_card_encrypted']);
|
||||
} catch (\Throwable $ex) {
|
||||
continue; // 密钥换过 / 解不开就跳过,绝不能因此让整次升级失败
|
||||
}
|
||||
$bd = \App\Birthday::fromIdCard($id);
|
||||
if ($bd !== null) {
|
||||
$up->execute([$bd, (int)$r['id']]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 列不存在才加 */
|
||||
|
||||
@@ -64,15 +64,17 @@ final class Http
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
}
|
||||
|
||||
$t0 = microtime(true);
|
||||
$out = curl_exec($ch);
|
||||
$err = curl_error($ch);
|
||||
$errno = curl_errno($ch);
|
||||
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
curl_close($ch);
|
||||
$ms = (int)round((microtime(true) - $t0) * 1000);
|
||||
|
||||
if ($out === false) {
|
||||
return [
|
||||
'ok' => false, 'status' => 0, 'headers' => [], 'body' => '',
|
||||
'ok' => false, 'status' => 0, 'headers' => [], 'body' => '', 'ms' => $ms,
|
||||
'error' => $errno === CURLE_OPERATION_TIMEDOUT
|
||||
? "请求超时({$timeout}s)"
|
||||
: "网络错误({$errno}): {$err}",
|
||||
@@ -83,6 +85,7 @@ final class Http
|
||||
'status' => $status,
|
||||
'headers' => $respHeaders,
|
||||
'body' => $out,
|
||||
'ms' => $ms,
|
||||
'error' => $status >= 200 && $status < 300 ? '' : "HTTP {$status}",
|
||||
];
|
||||
}
|
||||
|
||||
@@ -335,4 +335,144 @@ final class OssDriver implements StorageDriver
|
||||
}
|
||||
return $this->objectUrl($key);
|
||||
}
|
||||
|
||||
// ---------------- 连通性诊断 ----------------
|
||||
|
||||
/**
|
||||
* 一步一步把「连不上 OSS」的原因定位到具体的哪一环。
|
||||
*
|
||||
* 为什么不能只用一个"测试存储是否可用"的按钮:
|
||||
* 失败时它只会说"写入失败" —— 而失败可能是 6 种完全不同的原因
|
||||
* (没填 bucket / 凭据取不到 / 桶地域与本机不同 / 内网域名解析不了 /
|
||||
* AK 没这个桶的权限 / 桶 ACL 不是私有…),用户根本无从下手。
|
||||
* 这里把每一步的**结论与耗时**都摊开,一眼能看出卡在哪。
|
||||
*
|
||||
* @return array<int, array{name:string,ok:bool,detail:string,ms:int}>
|
||||
*/
|
||||
public function diagnose(): array
|
||||
{
|
||||
$steps = [];
|
||||
|
||||
// ① 配置完整性(纯本地判断,最快,先挡掉一半问题)
|
||||
$err = $this->validateConfig();
|
||||
$steps[] = [
|
||||
'name' => '配置完整性',
|
||||
'ok' => $err === '',
|
||||
'detail' => $err !== '' ? $err : sprintf('bucket=%s endpoint=%s 前缀=%s 凭据方式=%s',
|
||||
$this->cfg['bucket'] ?: '(空)',
|
||||
$this->endpoint() ?: '(空)',
|
||||
trim((string)($this->cfg['prefix'] ?? '')) !== '' ? trim((string)$this->cfg['prefix']) : '(无)',
|
||||
($this->cfg['auth_mode'] ?? 'ak') === 'ecs_role' ? '服务器绑定角色' : 'AccessKey'),
|
||||
'ms' => 0,
|
||||
];
|
||||
if ($err !== '') {
|
||||
return $steps; // 配置都没填全,后面每一步都会以同样的原因失败,不必刷屏
|
||||
}
|
||||
|
||||
// ② 凭据
|
||||
$t0 = microtime(true);
|
||||
$cred = $this->credentials();
|
||||
$ms = (int)round((microtime(true) - $t0) * 1000);
|
||||
$steps[] = [
|
||||
'name' => '取凭据',
|
||||
'ok' => !empty($cred['ok']),
|
||||
'detail' => !empty($cred['ok'])
|
||||
? (($this->cfg['auth_mode'] ?? 'ak') === 'ecs_role'
|
||||
? '已从实例 RAM 角色取到临时凭据' : '使用填写的 AccessKey')
|
||||
: (string)($cred['error'] ?? '取不到凭据'),
|
||||
'ms' => $ms,
|
||||
];
|
||||
if (empty($cred['ok'])) {
|
||||
return $steps;
|
||||
}
|
||||
|
||||
// ③ 地域比对 —— 跨地域时内网域名能解析但永远连不上,必须先说出来
|
||||
$bucketRegion = $this->bucketRegion();
|
||||
$ecsRegion = EcsMeta::region($this->familyId);
|
||||
$sameRegion = ($bucketRegion === '' || $ecsRegion === '' || $bucketRegion === $ecsRegion);
|
||||
$steps[] = [
|
||||
'name' => '地域',
|
||||
'ok' => $sameRegion,
|
||||
'detail' => $bucketRegion === '' || $ecsRegion === ''
|
||||
? sprintf('本机地域=%s 桶地域=%s(信息不全,跳过内网加速)',
|
||||
$ecsRegion ?: '未知', $bucketRegion ?: '未知')
|
||||
: ($sameRegion
|
||||
? "本机与桶都在 {$bucketRegion},内网可达"
|
||||
: "本机在 {$ecsRegion}、桶在 {$bucketRegion} —— **不是同一地域**,内网走不通,只能走公网"),
|
||||
'ms' => 0,
|
||||
];
|
||||
|
||||
// ④ 公网 endpoint 可达性(HEAD 桶,即使 403 也证明网络通了)
|
||||
$url = $this->scheme() . '://' . $this->host() . '/';
|
||||
$res = Http::send('HEAD', $url, $this->readHeaders('/', $cred, [], 'HEAD'), null, 10, 5);
|
||||
$steps[] = [
|
||||
'name' => '公网 endpoint 可达',
|
||||
'ok' => (int)$res['status'] > 0,
|
||||
'detail' => (int)$res['status'] > 0
|
||||
? sprintf('%s → HTTP %d%s', $url, (int)$res['status'],
|
||||
(int)$res['status'] === 403 ? '(可达;403 只是没有 ListBucket 权限,正常)' : '')
|
||||
: (string)($res['error'] ?? '连不上'),
|
||||
'ms' => (int)($res['ms'] ?? 0),
|
||||
];
|
||||
if ((int)$res['status'] === 0) {
|
||||
return $steps; // 公网都不通,后面没意义
|
||||
}
|
||||
|
||||
// ⑤ 内网 endpoint(只在同地域且配了内网时才测;跨地域测它是白等超时)
|
||||
$internal = $this->internalEndpoint();
|
||||
if ($internal !== '') {
|
||||
if (!$sameRegion) {
|
||||
$steps[] = ['name' => '内网 endpoint', 'ok' => true,
|
||||
'detail' => '跳过:跨地域时内网域名能解析但连不上,测了只会白等一次超时', 'ms' => 0];
|
||||
} else {
|
||||
$iurl = $this->scheme() . '://' . trim((string)$this->cfg['bucket']) . '.' . $internal . '/';
|
||||
$ires = Http::send('HEAD', $iurl, $this->readHeaders('/', $cred, [], 'HEAD'), null, 5, 2);
|
||||
$steps[] = [
|
||||
'name' => '内网 endpoint(服务端读取更快且免流量费)',
|
||||
'ok' => (int)$ires['status'] > 0,
|
||||
'detail' => (int)$ires['status'] > 0
|
||||
? sprintf('%s → HTTP %d', $iurl, (int)$ires['status'])
|
||||
: sprintf('%s → %s(会自动回退公网,功能不受影响)', $iurl, (string)($ires['error'] ?? '连不上')),
|
||||
'ms' => (int)($ires['ms'] ?? 0),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// ⑥ 真写一个对象再读回来再删掉 —— 唯一能证明"有权限"的一步
|
||||
$probeKey = 'avatars/.diag-' . bin2hex(random_bytes(6)) . '.txt';
|
||||
$payload = 'familybook-diag-' . time();
|
||||
$t0 = microtime(true);
|
||||
$put = $this->putContents($payload, $probeKey, 'text/plain');
|
||||
$putMs = (int)round((microtime(true) - $t0) * 1000);
|
||||
if (empty($put['ok'])) {
|
||||
$steps[] = ['name' => '写入(PUT)', 'ok' => false,
|
||||
'detail' => (string)($put['error'] ?? '写入失败')
|
||||
. ' —— 常见原因:这个 AK 对这个桶没有 PutObject 权限,或桶 ACL 不是私有/公共读写。',
|
||||
'ms' => $putMs];
|
||||
return $steps;
|
||||
}
|
||||
$steps[] = ['name' => '写入(PUT)', 'ok' => true, 'detail' => '对象已写入 ' . $probeKey, 'ms' => $putMs];
|
||||
|
||||
$get = $this->get($probeKey);
|
||||
$same = !empty($get['ok']) && trim((string)$get['body']) === $payload;
|
||||
$steps[] = [
|
||||
'name' => '读回(GET)',
|
||||
'ok' => $same,
|
||||
'detail' => $same
|
||||
? '读回内容与写入一致'
|
||||
: sprintf('写入成功但读回来对不上(HTTP %d)—— 多半是内网/外网 endpoint 指错,或桶有版本/加密设置',
|
||||
(int)($get['status'] ?? 0)),
|
||||
'ms' => (int)($get['ms'] ?? 0),
|
||||
];
|
||||
|
||||
$del = $this->delete($probeKey);
|
||||
$steps[] = [
|
||||
'name' => '删除(DELETE)',
|
||||
'ok' => !empty($del['ok']),
|
||||
'detail' => !empty($del['ok']) ? '探针对象已清理' : (string)($del['error'] ?? '删不掉(会留下垃圾文件)'),
|
||||
'ms' => 0,
|
||||
];
|
||||
|
||||
return $steps;
|
||||
}
|
||||
}
|
||||
+35
-15
@@ -146,9 +146,20 @@ final class Storage
|
||||
}
|
||||
}
|
||||
|
||||
/** 读取内容(导出、代理等场景) */
|
||||
/**
|
||||
* 读取内容(代理、导出等场景)。
|
||||
*
|
||||
* 读取顺序与 exists() 一致,刻意设计成「本地优先」:
|
||||
* 从本地搬到云端(或反过来)时,历史文件可能只在一边,
|
||||
* 本地优先能保证迁移期的老文件照样打得开,也让回滚随时可行。
|
||||
*/
|
||||
public static function read(int $familyId, string $key): array
|
||||
{
|
||||
$local = FB_UPLOADS . '/' . ltrim($key, '/');
|
||||
if ($key !== '' && is_file($local)) {
|
||||
$body = (string)@file_get_contents($local);
|
||||
return ['ok' => true, 'status' => 200, 'headers' => [], 'body' => $body, 'error' => '', 'from' => 'local'];
|
||||
}
|
||||
return self::disk($familyId)->get($key);
|
||||
}
|
||||
|
||||
@@ -166,10 +177,14 @@ final class Storage
|
||||
/**
|
||||
* **唯一出口**:把相对键变成可访问地址。
|
||||
*
|
||||
* 读取顺序刻意设计成「本地优先」:
|
||||
* ① 已是绝对地址 → 原样返回
|
||||
* ② 本地有文件 → 走本地(迁移期老文件仍可访问,随时可回滚)
|
||||
* ③ 交给当前驱动
|
||||
* ⚠️ 现在一律指向 `media.php` 代理,不再直接给 uploads/ 或 OSS 的地址:
|
||||
* · 统一:本地与云端的差别被代理吃掉,前端只认一个地址形态;
|
||||
* · 安全:桶可以设成私有、uploads/ 可以整个封死,能否看到附件由**登录态**决定,
|
||||
* 而不是"知道文件名就能看";
|
||||
* · 标准:Content-Type / 缓存 / 下载头由服务端统一给。
|
||||
*
|
||||
* 为什么保留这个方法:`media.php` 自己不调它(它用 read() 取字节),
|
||||
* 所以不存在自递归;留在这里是为了让"谁想把键变成地址"永远只有一条路。
|
||||
*/
|
||||
public static function url(int $familyId, ?string $key): string
|
||||
{
|
||||
@@ -180,15 +195,20 @@ final class Storage
|
||||
if (preg_match('#^https?://#i', $key)) {
|
||||
return $key;
|
||||
}
|
||||
$key = ltrim($key, '/');
|
||||
$local = FB_UPLOADS . '/' . $key;
|
||||
if (is_file($local)) {
|
||||
return asset('/uploads/' . $key);
|
||||
}
|
||||
$disk = self::disk($familyId);
|
||||
if ($disk->id() === 'local') {
|
||||
return asset('/uploads/' . $key); // 本地也没有 → 让默认头像逻辑兜住
|
||||
}
|
||||
return $disk->url($key);
|
||||
return \media_url($key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接拿「已保存的 OSS 配置」建一个 OSS 驱动来做连通性诊断。
|
||||
*
|
||||
* 与 disk() 的区别很重要:disk() 会按 effectiveDriver() 决定用哪个驱动,
|
||||
* 当前选的是"本地"时就只会给你 LocalDriver —— 于是**用户没法在切到 OSS 之前
|
||||
* 先把 OSS 测通**,只能"先切换、发现不行、再切回来"。这里绕开那个判断。
|
||||
*/
|
||||
public static function ossDiskForDiagnosis(int $familyId): ?OssDriver
|
||||
{
|
||||
$cfg = self::config($familyId);
|
||||
$driver = new OssDriver($familyId, $cfg);
|
||||
return $driver->validateConfig() === '' ? $driver : null;
|
||||
}
|
||||
}
|
||||
+84
-12
@@ -23,19 +23,82 @@ function asset(string $rel): string
|
||||
|
||||
/**
|
||||
* 附件(头像等)地址 —— 全站唯一出口。
|
||||
* 走这里是纪律:数据库只存相对键,云端/本地的差异由 Storage 自己吸收。
|
||||
*
|
||||
* ⚠️ 现在**一律指向 media.php 代理**,不再直接把 uploads/ 或 OSS 的地址吐给浏览器:
|
||||
* · 统一:本地/云端的形态差别被代理吃掉,前端只认一个 media.php?k=…
|
||||
* · 安全:桶不必开公共读;uploads/ 可以直接封死不让 Web 访问,
|
||||
* 附件是否可读由**登录态**决定,而不是"知道文件名就能看"。
|
||||
* · 标准:Content-Type / 缓存 / 下载头由服务端统一给,而不是靠存储桶配得对不对。
|
||||
*/
|
||||
function storage_url(?string $key): string
|
||||
{
|
||||
$fid = \App\Auth::familyId();
|
||||
return \App\Storage\Storage::url((int)$fid, $key);
|
||||
return media_url($key);
|
||||
}
|
||||
|
||||
/** 头像地址(无头像自动回退内置剪影) */
|
||||
function avatar_url(?string $key): string
|
||||
{
|
||||
$fid = (int)\App\Auth::familyId();
|
||||
return \App\Upload::avatarUrl($fid, $key);
|
||||
$key = trim((string)$key);
|
||||
if ($key === '') {
|
||||
return asset('/assets/avatar-default.svg');
|
||||
}
|
||||
return media_url($key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 附件代理地址。
|
||||
* @param array $extra 额外参数,如 ['dl' => 1] 表示按下附件下载
|
||||
*/
|
||||
function media_url(?string $key, array $extra = []): string
|
||||
{
|
||||
$key = trim((string)$key);
|
||||
if ($key === '') {
|
||||
return asset('/assets/avatar-default.svg');
|
||||
}
|
||||
// 已经是绝对地址(历史数据/外部链接)→ 不代理,原样用
|
||||
if (preg_match('#^https?://#i', $key)) {
|
||||
return $key;
|
||||
}
|
||||
return 'media.php?' . http_build_query(array_merge(['k' => ltrim($key, '/')], $extra));
|
||||
}
|
||||
|
||||
/**
|
||||
* 读「日期 + 24 小时制时:分:秒」组合控件(templates/_datetime.php 产出)。
|
||||
*
|
||||
* 为什么不用 <input type="datetime-local">:它的显示格式由**浏览器 UI 语言 /
|
||||
* 系统时间格式**决定,<html lang> 压不过它 —— 中文环境会渲染成「下午 03:30」,
|
||||
* 和"精确到时分秒"的要求对不上,用户还会把上午下午填反。
|
||||
* 拆成「原生日期选择器 + 时/分/秒三个下拉」后,24 小时制由控件本身保证。
|
||||
*
|
||||
* 只选了时间没选日期 → 视为没填(与 TripLog 的行为一致)。
|
||||
*/
|
||||
function read_dt_input(string $name): ?string
|
||||
{
|
||||
$date = trim((string)($_POST[$name . '_date'] ?? ''));
|
||||
$hRaw = trim((string)($_POST[$name . '_hour'] ?? ''));
|
||||
$mRaw = trim((string)($_POST[$name . '_minute'] ?? ''));
|
||||
$sRaw = trim((string)($_POST[$name . '_second'] ?? ''));
|
||||
|
||||
// 兼容一次性传整串的老表单(如 CSV 导入、内部调用)
|
||||
if ($date === '' && $hRaw === '' && $mRaw === '') {
|
||||
return \App\Birthday::toDateTime((string)($_POST[$name] ?? ''));
|
||||
}
|
||||
if ($date === '') {
|
||||
return null;
|
||||
}
|
||||
if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $date, $mt)) {
|
||||
return null;
|
||||
}
|
||||
if (!checkdate((int)$mt[2], (int)$mt[3], (int)$mt[1])) {
|
||||
return null;
|
||||
}
|
||||
$h = $hRaw === '' ? 0 : (int)$hRaw;
|
||||
$i = $mRaw === '' ? 0 : (int)$mRaw;
|
||||
$s = $sRaw === '' ? 0 : (int)$sRaw;
|
||||
if ($h < 0 || $h > 23 || $i < 0 || $i > 59 || $s < 0 || $s > 59) {
|
||||
return null;
|
||||
}
|
||||
return sprintf('%s %02d:%02d:%02d', $date, $h, $i, $s);
|
||||
}
|
||||
|
||||
/** 站内链接(统一走 query-string 路由,不依赖 Apache 重写) */
|
||||
@@ -133,27 +196,36 @@ function clear_old(): void
|
||||
unset($_SESSION['_old']);
|
||||
}
|
||||
|
||||
/** 生日相关:返回年龄与距下次生日天数 */
|
||||
function age_from(string $birthday): ?int
|
||||
/**
|
||||
* 生日相关:年龄与距下次生日天数。
|
||||
*
|
||||
* ⚠️ 输入可能是 DATETIME('1990-05-12 14:30:00')—— 一律先截到日期再算,
|
||||
* 否则 'm-d' 之类会带上时间,闰年/跨年边界容易算歪。
|
||||
* 真正的"下一次生日"请用 App\Birthday::nextOccurrences():
|
||||
* 它会分别处理身份证/阳历/农历三个生日,这里只是给页面用的简版。
|
||||
*/
|
||||
function age_from(?string $birthday): ?int
|
||||
{
|
||||
if ($birthday === '' || $birthday === null) {
|
||||
$d = \App\Birthday::dateOnly($birthday);
|
||||
if ($d === '') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$b = new DateTimeImmutable($birthday);
|
||||
$b = new DateTimeImmutable($d);
|
||||
} catch (Exception $ex) {
|
||||
return null;
|
||||
}
|
||||
return (int)$b->diff(new DateTimeImmutable('today'))->y;
|
||||
}
|
||||
|
||||
function days_until_birthday(string $birthday): ?int
|
||||
function days_until_birthday(?string $birthday): ?int
|
||||
{
|
||||
if (!$birthday) {
|
||||
$d = \App\Birthday::dateOnly($birthday);
|
||||
if ($d === '') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$b = new DateTimeImmutable($birthday);
|
||||
$b = new DateTimeImmutable($d);
|
||||
$now = new DateTimeImmutable('today');
|
||||
$next = new DateTimeImmutable($now->format('Y') . '-' . $b->format('m-d'));
|
||||
if ($next < $now) {
|
||||
|
||||
Reference in New Issue
Block a user