From 7e9b4520c0fb427bc0b66a306584eca7b5a823dc Mon Sep 17 00:00:00 2001 From: laoyang Date: Wed, 30 Sep 2026 09:44:55 +0800 Subject: [PATCH] =?UTF-8?q?=E7=99=BB=E5=BD=95=E5=B1=85=E4=B8=AD=20=C2=B7?= =?UTF-8?q?=20=E4=B8=AA=E4=BA=BA=20slogan=20=C2=B7=20=E4=B8=89=E7=A7=8D?= =?UTF-8?q?=E7=94=9F=E6=97=A5=20=C2=B7=20=E7=A7=B0=E5=91=BC=E8=A7=86?= =?UTF-8?q?=E8=A7=92=E5=8C=96=20=C2=B7=20=E9=99=84=E4=BB=B6=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E8=B5=B0=E4=BB=A3=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按九点反馈一次性改造: 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 条(生日整框可点、登录居中)。 --- app/Birthday.php | 361 ++++++++++++++++++++++++++++++++++++ app/Lunar.php | 273 +++++++++++++++++++++++++++ app/Nickname.php | 157 ++++++++++++++++ app/Person.php | 195 +++++++++++++------ app/Schema.php | 111 ++++++++++- app/Storage/Http.php | 5 +- app/Storage/OssDriver.php | 140 ++++++++++++++ app/Storage/Storage.php | 50 +++-- app/helpers.php | 96 ++++++++-- assets/css/app.css | 141 +++++++++++++- assets/js/app.js | 67 +++++++ index.php | 222 ++++++++++++++++++++-- install.php | 6 +- media.php | 161 ++++++++++++++++ templates/_datetime.php | 63 +++++++ templates/home.php | 83 +++++++-- templates/install.php | 4 +- templates/layout.php | 8 +- templates/login.php | 8 +- templates/people/form.php | 117 +++++++++--- templates/people/index.php | 19 +- templates/people/invite.php | 2 +- templates/people/me.php | 189 +++++++++++++++++++ templates/people/show.php | 132 ++++++++++--- templates/settings.php | 118 ++++++++++-- uploads/.htaccess | 37 +++- 26 files changed, 2561 insertions(+), 204 deletions(-) create mode 100644 app/Birthday.php create mode 100644 app/Lunar.php create mode 100644 app/Nickname.php create mode 100644 media.php create mode 100644 templates/_datetime.php create mode 100644 templates/people/me.php diff --git a/app/Birthday.php b/app/Birthday.php new file mode 100644 index 0000000..aeb3d55 --- /dev/null +++ b/app/Birthday.php @@ -0,0 +1,361 @@ + 人话名字 */ + 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 + */ + 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; + } +} diff --git a/app/Lunar.php b/app/Lunar.php new file mode 100644 index 0000000..48be4a0 --- /dev/null +++ b/app/Lunar.php @@ -0,0 +1,273 @@ += 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; + } +} diff --git a/app/Nickname.php b/app/Nickname.php new file mode 100644 index 0000000..1ff4d3c --- /dev/null +++ b/app/Nickname.php @@ -0,0 +1,157 @@ +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(); + } +} diff --git a/app/Person.php b/app/Person.php index a5e3355..f4ec7dc 100644 --- a/app/Person.php +++ b/app/Person.php @@ -1,9 +1,14 @@ 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; } diff --git a/app/Schema.php b/app/Schema.php index d618fac..6b645e5 100644 --- a/app/Schema.php +++ b/app/Schema.php @@ -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']]); + } + } } /** 列不存在才加 */ diff --git a/app/Storage/Http.php b/app/Storage/Http.php index 3e44d16..52a340a 100644 --- a/app/Storage/Http.php +++ b/app/Storage/Http.php @@ -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}", ]; } diff --git a/app/Storage/OssDriver.php b/app/Storage/OssDriver.php index 2009e2b..83982e5 100644 --- a/app/Storage/OssDriver.php +++ b/app/Storage/OssDriver.php @@ -335,4 +335,144 @@ final class OssDriver implements StorageDriver } return $this->objectUrl($key); } + + // ---------------- 连通性诊断 ---------------- + + /** + * 一步一步把「连不上 OSS」的原因定位到具体的哪一环。 + * + * 为什么不能只用一个"测试存储是否可用"的按钮: + * 失败时它只会说"写入失败" —— 而失败可能是 6 种完全不同的原因 + * (没填 bucket / 凭据取不到 / 桶地域与本机不同 / 内网域名解析不了 / + * AK 没这个桶的权限 / 桶 ACL 不是私有…),用户根本无从下手。 + * 这里把每一步的**结论与耗时**都摊开,一眼能看出卡在哪。 + * + * @return array + */ + 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; + } } \ No newline at end of file diff --git a/app/Storage/Storage.php b/app/Storage/Storage.php index 1a673fa..9934c99 100644 --- a/app/Storage/Storage.php +++ b/app/Storage/Storage.php @@ -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; } } \ No newline at end of file diff --git a/app/helpers.php b/app/helpers.php index 60086d1..5b4eba1 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -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 产出)。 + * + * 为什么不用 :它的显示格式由**浏览器 UI 语言 / + * 系统时间格式**决定, 压不过它 —— 中文环境会渲染成「下午 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) { diff --git a/assets/css/app.css b/assets/css/app.css index 3f6696a..f5b4cd7 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -64,7 +64,12 @@ a:hover { text-decoration: underline; } .topbar-right { margin-left: auto; display: flex; align-items: center; gap: 12px; } .who { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 14px; } +a.who:hover { text-decoration: none; } +/* 头像/名字是个「编辑我自己」的入口 —— hover 时给点反馈,让人知道它可点 */ +a.who:hover .who-name { color: var(--brand); text-decoration: underline; } +a.who:hover img { box-shadow: 0 0 0 2px var(--brand-2); } .who img { width: 30px; height: 30px; border-radius: 50%; object-fit: cover; } +.who img { transition: box-shadow .15s ease; } .link-quiet { color: var(--muted); font-size: 14px; } .link-quiet:hover { color: var(--brand); } @@ -104,8 +109,22 @@ a:hover { text-decoration: underline; } display: flex; align-items: center; gap: 20px; flex-wrap: wrap; } .hero-logo { width: 64px; height: 64px; border-radius: 16px; flex: 0 0 auto; } +.hero-main { flex: 1 1 240px; min-width: 0; } .hero h1 { font-size: 24px; font-weight: 600; margin: 0 0 4px; } .hero .motto { color: var(--muted); margin: 0; font-size: 14px; } +/* 那句小字到底是谁的:自己写的那句要标一下,否则用户会以为这是系统统一发的 */ +.motto-from { + display: inline-block; margin-left: 6px; padding: 0 7px; + border-radius: 999px; background: var(--line-2); + font-size: 11px; color: var(--faint); +} +.motto-edit { margin-left: 8px; font-size: 12px; color: var(--brand-2); white-space: nowrap; } +/* 详情页顶部引用本人的一句话 */ +.motto-line { + margin: -8px 0 16px; padding-left: 12px; + border-left: 3px solid var(--brand-2); + color: var(--muted); font-size: 14px; +} .hero-stats { margin-left: auto; display: flex; gap: 28px; } .stat { text-align: center; } .stat b { display: block; font-size: 24px; font-weight: 600; line-height: 1.2; } @@ -139,17 +158,39 @@ a:hover { text-decoration: underline; } } .badge-admin { background: #F6E3D7; color: var(--brand-ink); } .badge-open { background: #E6F0DC; color: #4A7226; } +.badge-me { background: var(--brand); color: #fff; } -/* 生日条 */ +/* 生日条 —— 一个人可能同时出两条(农历与阳历不在同一天), + 落在同一天时并成一条、kinds 里挂多个标签,正好对应"要分别提醒"。 */ .bdays { display: flex; gap: 12px; flex-wrap: wrap; } .bday { - display: flex; align-items: center; gap: 10px; + display: flex; align-items: center; gap: 12px; background: var(--surface); border: 1px solid var(--line); - border-radius: 999px; padding: 6px 16px 6px 6px; box-shadow: var(--shadow); + border-radius: var(--radius); padding: 10px 18px 10px 10px; + box-shadow: var(--shadow); + color: var(--ink); text-decoration: none; + transition: transform .15s ease, box-shadow .15s ease; } -.bday img { width: 32px; height: 32px; border-radius: 50%; object-fit: cover; } +.bday:hover { transform: translateY(-2px); box-shadow: var(--shadow-l); text-decoration: none; } +.bday img { width: 40px; height: 40px; border-radius: 50%; object-fit: cover; flex: 0 0 auto; } .bday b { font-weight: 500; } .bday .d { color: var(--brand); font-size: 13px; } +.bday-meta { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 3px; } +.bday-date { font-size: 12px; color: var(--faint); font-variant-numeric: tabular-nums; } +/* 「身份证生日 / 阳历生日 / 农历生日」的标签,首页与详情页共用 */ +.bd-kind { + display: inline-block; padding: 0 7px; border-radius: 999px; + font-size: 11px; line-height: 1.8; + background: var(--line-2); color: var(--muted); +} +.bd-kind-id { background: #E6F0DC; color: #4A7226; } +.bd-kind-solar { background: #F6E3D7; color: var(--brand-ink); } +.bd-kind-lunar { background: #E8E4F2; color: #55477E; } + +/* 详情页的三个生日 */ +.bd-list { list-style: none; margin: 0; padding: 0; } +.bd-list li { display: flex; align-items: baseline; gap: 8px; margin-bottom: 3px; flex-wrap: wrap; } +.bd-list .bd-val { font-variant-numeric: tabular-nums; } /* ---------------- 表单 ---------------- */ .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } @@ -233,7 +274,14 @@ textarea.input { min-height: 76px; resize: vertical; } border: 1px solid var(--line); border-radius: 20px; box-shadow: var(--shadow-l); padding: 32px; } -.auth-card .logo { width: 56px; height: 56px; border-radius: 14px; margin-bottom: 14px; } +/* 图标 + 「用手机号登录你的家书」+ 家庭名,整块水平居中。 + ⚠️ 只有这一块居中:下面的表单仍然左对齐(label 左对齐才好读), + 「忘记密码」那行本来就是居中,所以视觉上不会歪。 */ +.auth-head { text-align: center; } +.auth-card .logo { + display: block; margin: 0 auto 14px; + width: 56px; height: 56px; border-radius: 14px; +} .auth-card h1 { font-size: 20px; font-weight: 600; margin: 0 0 4px; } .auth-card .lead { color: var(--muted); font-size: 14px; margin: 0 0 24px; } .auth-card .btn { width: 100%; margin-top: 20px; } @@ -246,6 +294,40 @@ textarea.input { min-height: 76px; resize: vertical; } .search { display: flex; gap: 8px; margin-bottom: 16px; } .search .input { max-width: 260px; } +/* ---------------- 日期 + 时:分:秒 组合控件(templates/_datetime.php) ---------------- + 原生 datetime-local 的显示格式由浏览器 UI 语言/系统时间格式决定, + 中文环境渲染成「下午 03:30」且没有秒,所以自己拼一个。 + 日期部分仍是原生 date 控件 → app.js 的 showPicker 让它整框可点。 */ +.dt-input { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } +.dt-input .dt-date { + flex: 1 1 150px; min-width: 0; + padding: 9px 11px; border: 1px solid var(--line); border-radius: var(--radius-s); + background: var(--surface); color: var(--ink); font: inherit; + accent-color: var(--brand); +} +.dt-input .dt-date { cursor: pointer; } /* 整框可点,光标也要说明这一点 */ +.dt-input .dt-date::-webkit-calendar-picker-indicator { cursor: pointer; opacity: .65; } +.dt-time { display: flex; align-items: center; gap: 3px; } +.dt-input .dt-hour, +.dt-input .dt-minute, +.dt-input .dt-second { + padding: 9px 6px; border: 1px solid var(--line); border-radius: var(--radius-s); + background: var(--surface); color: var(--ink); font: inherit; + font-variant-numeric: tabular-nums; cursor: pointer; +} +.dt-input .dt-colon { color: var(--muted); font-weight: 600; } + +/* 「以哪个生日为准」的两块:被选中的那块描一圈,一眼看出填的是哪边 */ +.bd-source { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 6px; } +.bd-pane { border: 1px solid transparent; border-radius: var(--radius); padding: 10px 12px 2px; } +.bd-pane.is-primary { border-color: var(--brand-2); background: #FDF6F1; } +.bd-pane .bd-pane-k { font-size: 13px; font-weight: 600; margin: 0 0 8px; } +.bd-readonly { + display: inline-block; padding: 6px 10px; border-radius: var(--radius-s); + background: var(--line-2); color: var(--muted); font-size: 14px; + font-variant-numeric: tabular-nums; +} + /* ---------------- 称谓标签 ---------------- */ .rel-label { display: inline-block; padding: 1px 9px; border-radius: 999px; @@ -387,3 +469,52 @@ textarea.input { min-height: 76px; resize: vertical; } .search { flex-wrap: wrap; } .search .input { max-width: none; flex: 1 1 auto; } } + +/* ---------------- 「我怎么称呼家人」(视角属性,见 app/Nickname.php) ---------------- */ +.nick-list { display: flex; flex-direction: column; gap: 8px; } +.nick-row { + display: grid; grid-template-columns: minmax(120px, auto) 1fr auto; + gap: 10px; align-items: center; + padding: 8px 10px; border-radius: var(--radius-s); + background: #FAF7F1; border: 1px solid var(--line-2); +} +.nick-who { display: flex; align-items: center; gap: 8px; font-size: 14px; min-width: 0; } +.nick-who img { width: 28px; height: 28px; border-radius: 50%; object-fit: cover; flex: 0 0 auto; } +.nick-who span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/* 详情页上的单个"我叫他什么"输入行 */ +.nick-inline { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } +.nick-inline .input { flex: 1 1 220px; max-width: 340px; } + +@media (max-width: 720px) { + .nick-row { grid-template-columns: 1fr; } + .nick-inline .input { max-width: none; } +} + +/* ---------------- 连通性测试与诊断(设置页) ---------------- */ +.test-actions { display: flex; flex-direction: column; gap: 10px; } +.test-actions form { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } + +.oss-diag { + margin-top: 16px; border-radius: var(--radius-s); overflow: hidden; + border: 1px solid var(--line); +} +.oss-diag-head { + display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; + padding: 10px 14px; font-size: 13px; + background: var(--line-2); +} +.oss-diag.is-ok .oss-diag-head { background: #E6F0DC; color: #3F6021; } +.oss-diag.is-bad .oss-diag-head { background: #F6E3D7; color: var(--brand-ink); } +.oss-diag-table { width: 100%; border-collapse: collapse; font-size: 13px; background: var(--surface); } +.oss-diag-table th, +.oss-diag-table td { padding: 8px 12px; text-align: left; vertical-align: top; border-top: 1px solid var(--line-2); } +.oss-diag-table th { font-weight: 500; color: var(--muted); font-size: 12px; } +.oss-diag-table td:nth-child(1) { white-space: nowrap; } +.oss-diag-table td:nth-child(3) { white-space: nowrap; } +.oss-diag-table tr.row-ok td:nth-child(2) { color: #4A7226; } +.oss-diag-table tr.row-bad td:nth-child(2) { color: var(--red); } + +@media (max-width: 720px) { + .oss-diag-table th:nth-child(3), + .oss-diag-table td:nth-child(3) { display: none; } +} diff --git a/assets/js/app.js b/assets/js/app.js index 8fbf267..83c9dba 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -2,6 +2,30 @@ (function () { 'use strict'; + // ===== 时间框:点输入框任意一处都弹出选择器 ===== + // 原生行为只在点右侧那个小日历图标时才弹(而且各浏览器还不一致), + // 用户会以为框只能读、或者找不到那个图标在哪。 + // showPicker() 是标准做法:在 click 里调它,等于把整块输入区都当成了那个图标。 + // + // 为什么能跟浏览器自带的那次弹出共存:若本次点击已经弹过,再调一次是空操作 + // (规范里「show the picker」对已经显示的选择器直接返回),不会闪两下。 + // 不支持的浏览器(老 Safari 等)静默跳过 —— 退化成原生行为,不会比现在更差。 + (function () { + var pickers = document.querySelectorAll('input[type="date"], input[type="datetime-local"]'); + function hook(el) { + el.addEventListener('click', function () { + if (typeof el.showPicker !== 'function') { return; } + try { + el.showPicker(); + } catch (err) { + // NotAllowedError:不是用户手势触发的(或被跨域 iframe 挡住)。 + // 忽略即可 —— 原生那套点击行为还在,用户仍有办法选时间。 + } + }); + } + for (var i = 0; i < pickers.length; i++) { hook(pickers[i]); } + })(); + // 头像选好后即时预览,别等上传完才知道选错了 var fileInput = document.getElementById('avatar'); if (fileInput && window.FileReader) { @@ -24,4 +48,47 @@ img.src = '/assets/avatar-default.svg'; }); }); + + // ===== 三个生日:选择"以哪个为准" ===== + // 阳历与农历其实是同一个人的同一天,不能各填各的(那样就自相矛盾了)。 + // 表单上让用户明确指定"我这次填的是哪一个",服务端据此反推另一个。 + (function () { + var sel = document.getElementById('bdSource'); + if (!sel) { return; } + var solarBox = document.getElementById('bdSolarWrap'); + var lunarBox = document.getElementById('bdLunarWrap'); + function sync() { + var isLunar = sel.value === 'lunar'; + if (solarBox) { solarBox.classList.toggle('is-primary', !isLunar); } + if (lunarBox) { lunarBox.classList.toggle('is-primary', isLunar); } + } + sel.addEventListener('change', sync); + sync(); + })(); + + // ===== 身份证号 → 即时算出「身份证生日」 ===== + // 让用户当场看到"系统从证件里读出来的是哪一天", + // 而不是存下去之后才发现跟自己的生日差了一个月。 + (function () { + var idc = document.getElementById('id_card'); + var out = document.getElementById('idCardBirthday'); + if (!idc || !out) { return; } + function pad(n) { return (n < 10 ? '0' : '') + n; } + function calc() { + var s = (idc.value || '').toUpperCase().replace(/\s/g, ''); + var y, m, d; + if (/^\d{17}[\dX]$/.test(s)) { + y = +s.substr(6, 4); m = +s.substr(10, 2); d = +s.substr(12, 2); + } else if (/^\d{15}$/.test(s)) { + y = 1900 + +s.substr(6, 2); m = +s.substr(8, 2); d = +s.substr(10, 2); + } else { + out.textContent = ''; + return; + } + if (m < 1 || m > 12 || d < 1 || d > 31) { out.textContent = ''; return; } + out.textContent = y + '-' + pad(m) + '-' + pad(d); + } + idc.addEventListener('input', calc); + calc(); + })(); })(); diff --git a/index.php b/index.php index 020f7ae..9b5206d 100644 --- a/index.php +++ b/index.php @@ -12,10 +12,12 @@ require __DIR__ . '/app/bootstrap.php'; use App\Account; use App\Auth; +use App\Birthday; use App\Crypto; use App\Database; use App\Family; use App\Kinship; +use App\Nickname; use App\Person; use App\Relations; use App\Schema; @@ -35,7 +37,7 @@ if (!Database::hasTables()) { * 新的 guest 角色。靠人去手动 ALTER 迟早会漏,所以在每次请求的开头比一下版本号, * 落后了就补一次 —— 平时只是一次字符串比较,几乎零成本。 */ -const FB_SCHEMA_VERSION = '2'; +const FB_SCHEMA_VERSION = Schema::VERSION; if (Database::hasTables()) { $familyIdForSchema = isset($_SESSION['family_id']) ? (int)$_SESSION['family_id'] : 0; // 登录前还没有 family_id,用首个家庭兜底(本系统是单家庭部署) @@ -71,6 +73,33 @@ if (!Auth::check() && !in_array($r, $publicRoutes, true)) { redirect('auth/login'); } +/** + * 从表单里读「三个生日」。 + * + * 为什么要有 `birthday_source` 这个显式字段: + * 阳历生日与农历生日说的是**同一个人的同一天**,不能各填各的(会自相矛盾)。 + * 用户手填时必然是在其中一边操作,所以让他明确"我这次填的是哪一个", + * 服务端据此**反推另一个** —— 不依赖 JS,也就不会有"JS 没跑到就静默存错"。 + * 兼容:source=solar 但阳历留空、农历有值 → 按农历算(用户只填了一边)。 + */ +function read_birthday_input(array $existing = []): array +{ + $source = (($_POST['birthday_source'] ?? 'solar') === 'lunar') ? 'lunar' : 'solar'; + $solar = (string)(read_dt_input('birthday_solar') ?? ''); + $lunar = (string)(read_dt_input('birthday_lunar') ?? ''); + if ($source === 'solar' && $solar === '' && $lunar !== '') { + $source = 'lunar'; + } + return [ + 'birthday_source' => $source, + 'birthday_solar' => $solar, + 'birthday_lunar' => $lunar, + 'birthday_lunar_leap' => !empty($_POST['birthday_lunar_leap']) ? 1 : 0, + // 库里已有的身份证生日要**带回来**:它是"只进不出"的,不能被一次提交清掉 + 'birthday_id_card' => (string)($existing['birthday_id_card'] ?? ''), + ]; +} + switch ($r) { // ==================== 登录 / 退出 ==================== @@ -164,6 +193,8 @@ switch ($r) { foreach ($birthdays as &$b) { $b['label'] = isset($people[(int)$b['id']]) ? Kinship::label($graph, $me, (int)$b['id'], $people) : ''; + // 名字优先用"我叫他什么" —— 同一个孩子,爸爸看到"儿子"、奶奶看到"小宝" + $b['shown'] = Nickname::display($me, $people[(int)$b['id']] ?? null) ?: $b['name']; } unset($b); @@ -176,6 +207,9 @@ switch ($r) { 'allCount' => count($people), 'relCount' => Relations::count($familyId), 'birthdays' => $birthdays, + // 个人 slogan 优先,没填才回落到家庭统一的那句(见 settings 页) + 'mySlogan' => trim((string)($people[$me]['slogan'] ?? '')), + 'myNicknames'=> Nickname::mapFor($me), ], 'home'); break; @@ -201,12 +235,13 @@ switch ($r) { $q = trim((string)($_GET['q'] ?? '')); $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); - $rows = Person::all($familyId, $q); + $rows = Person::all($familyId, $q, 'member', $me); $people = Person::map($familyId); $graph = Kinship::graph($familyId); // 名册里也标出「我该怎么称呼他」 foreach ($rows as &$row) { $row['label'] = Kinship::label($graph, $me, (int)$row['id'], $people); + $row['shown'] = Nickname::display($me, $row) ?: (string)$row['name']; } unset($row); @@ -218,6 +253,107 @@ switch ($r) { ], 'people'); break; + // ==================== 我自己的资料 ==================== + // 点顶栏的头像/名字直接落到这里。访客也能改自己的资料 —— + // 「个人信息」是每个人自己的事,不该因为角色是 guest 就连名字都写不了。 + case 'people/me': + Auth::requireLogin(); + $me = (int)Auth::personId(); + $p = Person::find($me); + if (!$p) { + flash('err', '找不到你自己的名册记录。'); + redirect('home'); + } + clear_old(); + View::render('people/me', [ + 'title' => '我的资料', + 'person' => $p, + 'errors' => [], + 'familyMotto'=> (string)(Family::find((int)Auth::familyId())['motto'] ?? ''), + 'nicknames' => Nickname::mineWithNames((int)Auth::familyId(), $me), + 'options' => Person::options((int)Auth::familyId(), $me, $me), + 'birthdays' => Birthday::describe($p), + ], 'people'); + break; + + case 'people/me_save': + Auth::requireLogin(); + csrf_check(); + $me = (int)Auth::personId(); + $p = Person::find($me); + if (!$p) { + flash('err', '找不到你自己的名册记录。'); + redirect('home'); + } + $d = [ + 'name' => trim((string)($_POST['name'] ?? '')), + 'gender' => (string)($_POST['gender'] ?? ''), + 'email' => trim((string)($_POST['email'] ?? '')), + 'address' => trim((string)($_POST['address'] ?? '')), + 'note' => trim((string)($_POST['note'] ?? '')), + 'slogan' => trim((string)($_POST['slogan'] ?? '')), + 'avatar' => (string)$p['avatar'], + ] + read_birthday_input($p); + + $errors = Person::validate($d); + $up = Upload::avatar((int)Auth::familyId(), $_FILES['avatar'] ?? []); + if (!$up['ok'] && ($up['error'] ?? '') !== '') { + $errors['avatar'] = $up['error']; + } + if ($errors) { + set_old($_POST); + View::render('people/me', [ + 'title' => '我的资料', + 'person' => $p, + 'errors' => $errors, + 'familyMotto'=> (string)(Family::find((int)Auth::familyId())['motto'] ?? ''), + 'nicknames' => Nickname::mineWithNames((int)Auth::familyId(), $me), + 'options' => Person::options((int)Auth::familyId(), $me, $me), + 'birthdays' => Birthday::describe($p), + ], 'people'); + exit; + } + if ($up['ok']) { + Upload::remove((int)Auth::familyId(), (string)$p['avatar']); + $d['avatar'] = $up['file']; + } + $warn = Birthday::normalize($d)['warning']; + Person::updateSelf($me, $d); + if ($warn !== '') { + flash('err', $warn); + } + // 姓名/头像改了要同步会话,否则顶栏还是旧的 + $_SESSION['name'] = $d['name']; + Auth::refreshAvatar((string)$d['avatar']); + clear_old(); + flash('ok', '已保存。'); + redirect('people/me'); + break; + + // ==================== 别人怎么称呼(只有我自己能定) ==================== + case 'nickname/save': + Auth::requireLogin(); + csrf_check(); + $familyId = (int)Auth::familyId(); + $me = (int)Auth::personId(); + $target = (int)($_POST['target_id'] ?? 0); + $nick = (string)($_POST['nickname'] ?? ''); + $t = Person::find($target); + $back = (string)($_POST['back'] ?? 'people/show'); + if (!$t || (int)$t['family_id'] !== $familyId) { + flash('err', '找不到这个人。'); + redirect('people'); + } + Nickname::set($familyId, $me, $target, $nick); + flash('ok', trim($nick) === '' + ? '已恢复成用姓名显示。' + : '记下了:你叫他「' . trim($nick) . '」。这只影响你自己看到的称呼。'); + if ($back === 'people/me') { + redirect('people/me'); + } + redirect('people/show', ['id' => $target]); + break; + case 'people/new': Auth::requireWrite(); clear_old(); @@ -233,14 +369,13 @@ switch ($r) { csrf_check(); $d = [ 'name' => trim((string)($_POST['name'] ?? '')), - 'nickname' => trim((string)($_POST['nickname'] ?? '')), 'gender' => (string)($_POST['gender'] ?? ''), - 'birthday' => trim((string)($_POST['birthday'] ?? '')), 'phone' => trim((string)($_POST['phone'] ?? '')), 'id_card' => trim((string)($_POST['id_card'] ?? '')), 'email' => trim((string)($_POST['email'] ?? '')), 'address' => trim((string)($_POST['address'] ?? '')), 'note' => trim((string)($_POST['note'] ?? '')), + 'slogan' => trim((string)($_POST['slogan'] ?? '')), 'sort_order'=> (int)($_POST['sort_order'] ?? 1000), 'avatar' => '', 'kind' => 'member', @@ -248,7 +383,7 @@ switch ($r) { // 表现是「排行填了没用、虚拟人物标记不生效」,且不会有任何报错。 'birth_rank' => trim((string)($_POST['birth_rank'] ?? '')), 'is_placeholder' => !empty($_POST['is_placeholder']) ? 1 : 0, - ]; + ] + read_birthday_input(); $errors = Person::validate($d); $up = Upload::avatar((int)Auth::familyId(), $_FILES['avatar'] ?? []); @@ -315,19 +450,18 @@ switch ($r) { } $d = [ 'name' => trim((string)($_POST['name'] ?? '')), - 'nickname' => trim((string)($_POST['nickname'] ?? '')), 'gender' => (string)($_POST['gender'] ?? ''), - 'birthday' => trim((string)($_POST['birthday'] ?? '')), 'phone' => trim((string)($_POST['phone'] ?? '')), 'id_card' => trim((string)($_POST['id_card'] ?? '')), 'email' => trim((string)($_POST['email'] ?? '')), 'address' => trim((string)($_POST['address'] ?? '')), 'note' => trim((string)($_POST['note'] ?? '')), + 'slogan' => trim((string)($_POST['slogan'] ?? '')), 'sort_order'=> (int)($_POST['sort_order'] ?? 1000), 'avatar' => (string)$p['avatar'], 'birth_rank' => trim((string)($_POST['birth_rank'] ?? '')), 'is_placeholder' => !empty($_POST['is_placeholder']) ? 1 : 0, - ]; + ] + read_birthday_input($p); $errors = Person::validate($d); $up = Upload::avatar((int)Auth::familyId(), $_FILES['avatar'] ?? []); if (!$up['ok'] && ($up['error'] ?? '') !== '') { @@ -373,7 +507,7 @@ switch ($r) { $familyId = (int)Auth::familyId(); $me = (int)Auth::personId(); $id = (int)($_GET['id'] ?? 0); - $p = Person::findWithAccount($id); + $p = Person::findWithAccount($id, $me); if (!$p || (int)$p['family_id'] !== $familyId) { flash('err', '找不到这个人。'); redirect('people'); @@ -395,8 +529,13 @@ switch ($r) { // 以这个人为中心的两代亲属,供详情页展示 'theirTree' => $tree, 'theirRels' => Relations::forPerson($familyId, $id), - 'options' => Person::options($familyId, $id), + 'options' => Person::options($familyId, $id, $me), 'kinReady' => Kinship::graph($familyId) !== [], + // 「我叫他什么」—— 只有我自己能改,改了也只影响我看到的 + 'myNickname' => $id === $me ? '' : Nickname::get($me, $id), + 'shown' => $id === $me ? (string)$p['name'] : (Nickname::display($me, $p) ?: (string)$p['name']), + 'birthdays' => Birthday::describe($p), + 'birthdayNext'=> Birthday::nextOccurrences($p, date('Y-m-d'), 400), ], 'people'); break; @@ -543,9 +682,11 @@ switch ($r) { case 'accounts/invite': Auth::requireAdmin(); clear_old(); + // ⚠️ 访客建出来是 kind='contact'(不进家谱),而 Person::all() 默认只取 'member' —— + // 早先这里没传 kind,于是"已有访客"永远列不出人来(页面看着像是从没开通过)。 View::render('people/invite', [ 'title' => '邀请访客', - 'people' => Person::all((int)Auth::familyId()), + 'people' => Person::all((int)Auth::familyId(), '', 'contact', (int)Auth::personId()), 'options' => Person::options((int)Auth::familyId()), ], 'people'); break; @@ -574,9 +715,9 @@ switch ($r) { try { // 访客也进名册(kind=contact,与家庭成员区分开),但不参与家谱 $pid = Person::create($familyId, [ - 'name' => $name, 'nickname' => '', 'gender' => '', 'birthday' => '', - 'avatar' => '', 'phone' => $phone, 'id_card' => '', 'email' => '', - 'address' => '', 'note' => $note, 'sort_order' => 2000, 'kind' => 'contact', + 'name' => $name, 'gender' => '', 'avatar' => '', 'phone' => $phone, + 'id_card' => '', 'email' => '', 'address' => '', 'note' => $note, + 'slogan' => '', 'sort_order' => 2000, 'kind' => 'contact', ]); Account::create($pid, $pass, 'guest'); clear_old(); @@ -589,7 +730,7 @@ switch ($r) { set_old($_POST); View::render('people/invite', [ 'title' => '邀请访客', - 'people' => Person::all($familyId), + 'people' => Person::all($familyId, '', 'contact', (int)Auth::personId()), 'options' => Person::options($familyId), 'errors' => $errors, ], 'people'); @@ -606,6 +747,9 @@ switch ($r) { } $rawKey = (string)fb_config('app_key', ''); $acc = Account::roleCounts(); + // 诊断结果由 settings/test_oss 写进会话,这里取出即用即清(POST → 302 → GET) + $diag = $_SESSION['_oss_diag'] ?? null; + unset($_SESSION['_oss_diag']); View::render('settings', [ 'title' => '设置', 'family' => Family::find((int)Auth::familyId()), @@ -617,9 +761,48 @@ switch ($r) { 'storageOss' => \App\Storage\Storage::config((int)Auth::familyId()), 'storageOk' => \App\Storage\Storage::configComplete((int)Auth::familyId()), 'storageEffective' => \App\Storage\Storage::effectiveDriver((int)Auth::familyId()), + 'ossDiag' => $diag, ], 'settings'); break; + /** + * 阿里云 OSS 连通性测试。 + * + * 与 settings/test_storage 的分工: + * · test_storage 测的是**当前生效的驱动**(可能是本地),判"能不能存东西"; + * · 这一个测的是**已填的 OSS 配置本身**,即使当前跑在本地也能测 —— + * 这样就能"先把 OSS 测通、再切过去",而不是"先切、发现不行、再切回来"。 + * 它会把每一步的结论摊开(配置/凭据/地域/公网/内网/读写), + * 让"连不上"这件事有具体原因,而不是一句"写入失败"。 + */ + case 'settings/test_oss': + Auth::requireAdmin(); + csrf_check(); + $familyId = (int)Auth::familyId(); + $disk = \App\Storage\Storage::ossDiskForDiagnosis($familyId); + if ($disk === null) { + $_SESSION['_oss_diag'] = [ + 'ok' => false, + 'steps' => [[ + 'name' => '配置完整性', 'ok' => false, 'ms' => 0, + 'detail' => 'OSS 参数还没填全(至少要 Bucket、Endpoint 和一种凭据)。先在下面填好并保存,再测。', + ]], + 'at' => time(), + ]; + redirect('settings'); + } + $steps = $disk->diagnose(); + $ok = true; + foreach ($steps as $s) { + if (empty($s['ok'])) { + $ok = false; + break; + } + } + $_SESSION['_oss_diag'] = ['ok' => $ok, 'steps' => $steps, 'at' => time()]; + redirect('settings'); + break; + case 'settings/storage': Auth::requireAdmin(); csrf_check(); @@ -679,9 +862,14 @@ switch ($r) { case 'settings/update': Auth::requireAdmin(); csrf_check(); + $fid = (int)Auth::familyId(); + // ⚠️ 「家庭名称」已从设置里撤掉(见 settings 模板的说明): + // 首页标题现在是「某某某的家书」,一个家庭级名字既没人看、也容易和它打架。 + // 这里沿用已有值,不再接受表单输入 —— 免得"某次提交把名字清空"。 + $cur = Family::find($fid); Family::update( - (int)Auth::familyId(), - trim((string)($_POST['name'] ?? '家书')), + $fid, + (string)($cur['name'] ?? '家书'), trim((string)($_POST['motto'] ?? '')) ); flash('ok', '已保存。'); diff --git a/install.php b/install.php index 9e1b3e2..5ca71e2 100644 --- a/install.php +++ b/install.php @@ -54,17 +54,19 @@ if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { // 所以老库重新跑一次就能自动升级,不必手工改表。 Schema::ensure(); $familyId = Family::create($familyName, $motto); + // 装完就把结构版本盖上:不盖的话,第一次进首页发现"版本对不上", + // 会再跑一次 ensure()(幂等,不出错,但白跑一遍 ALTER 查询)。 + \App\Setting::set($familyId, 'schema_version', Schema::VERSION); $personId = Person::create($familyId, [ 'name' => $name, - 'nickname' => '', 'gender' => '', - 'birthday' => '', 'avatar' => '', 'phone' => $phone, 'id_card' => '', 'email' => '', 'address' => '', 'note' => '', + 'slogan' => '', 'sort_order'=> 1, 'kind' => 'member', ]); diff --git a/media.php b/media.php new file mode 100644 index 0000000..e3a62db --- /dev/null +++ b/media.php @@ -0,0 +1,161 @@ +`。 + * + * ⚠️ 为什么要一个代理,而不是直接把地址给浏览器: + * ① **统一**:本地存储的 `/uploads/xxx`、OSS 的 `https://bucket.oss-...`、 + * 挂了 CDN 的自定义域名 —— 三种形态完全不同,前端与数据库都得跟着变。 + * 走代理后,谁都只看到一个 `media.php?k=…`,地址形态恒定。 + * ② **安全**:直连意味着"知道文件名就能看到",也意味着桶必须开公共读。 + * 走代理后,能否取到附件由**登录态**决定;桶可以设成私有; + * `uploads/` 可以整个禁止 Web 访问(见 uploads/.htaccess), + * 连目录探测、历史遗留文件的意外暴露一并消掉。 + * ③ **标准**:Content-Type / 缓存 / 下载头由这里统一给, + * 不再依赖"桶的元数据配得对不对"。 + * + * 安全约束(都集中在这一处,便于审阅): + * · 必须登录;未登录一律 401,不吐任何字节 + * · 键必须是**规范化的相对路径**:拒绝 `..`、绝对路径、反斜杠、空字节、点文件 + * · 键的字符集白名单(字母数字与 . _ - / ),杜绝编码绕过 + * · MIME 一律由**内容嗅探**决定,绝不信扩展名、也用云端给的 Content-Type + * · 能被浏览器当 HTML/JS 执行的一律降级为下载;SVG 额外加 CSP sandbox + */ + +declare(strict_types=1); + +require __DIR__ . '/app/bootstrap.php'; + +use App\Auth; +use App\Storage\Storage; + +/** 统一的失败出口 */ +function media_fail(int $code, string $message): void +{ + http_response_code($code); + header('Content-Type: text/plain; charset=utf-8'); + header('Cache-Control: no-store'); + echo $message; + exit; +} + +$key = (string)($_GET['k'] ?? ''); + +// ---- 1. 鉴权 ---- +if (!Auth::check()) { + // 401 而不是 302:这是给 用的端点,跳登录页只会返回一坨 HTML 当成图片 + media_fail(401, '需要登录'); +} +$familyId = (int)Auth::familyId(); + +// ---- 2. 键净化 ---- +$key = str_replace('\\', '/', $key); +$key = ltrim($key, '/'); +if ($key === '' || strpos($key, "\0") !== false || strpos($key, '..') !== false) { + media_fail(404, '没有这个文件'); +} +if (!preg_match('#^[A-Za-z0-9][A-Za-z0-9._/-]*$#', $key)) { + media_fail(404, '没有这个文件'); +} +foreach (explode('/', $key) as $seg) { + // 点文件(.htaccess 等)永远不给出去;另外挡掉空段 + if ($seg === '' || $seg === '.' || $seg[0] === '.') { + media_fail(404, '没有这个文件'); + } +} + +// ---- 3. 取内容(本地优先,再由当前驱动兜底)---- +$res = Storage::read($familyId, $key); +$status = (int)($res['status'] ?? 0); +$body = (string)($res['body'] ?? ''); + +if (empty($res['ok']) || $status < 200 || $status >= 300) { + if ($status === 404 || $status === 0) { + media_fail(404, '没有这个文件'); + } + // 云端出错(403 / 签名失败等)不能假装成 404 —— + // 那会让「桶权限配错了」看起来像「这个文件没了」,排查方向整个跑偏。 + media_fail(502, '附件存储暂时取不到:' . (string)($res['error'] ?? '未知错误')); +} + +// ---- 4. MIME:一律嗅探内容,不用扩展名、不用云端声明 ---- +$extMap = [ + 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', + 'gif' => 'image/gif', 'webp' => 'image/webp', 'svg' => 'image/svg+xml', + 'pdf' => 'application/pdf', 'txt' => 'text/plain; charset=utf-8', + 'mp4' => 'video/mp4', 'mov' => 'video/quicktime', 'mp3' => 'audio/mpeg', +]; +$ext = strtolower(pathinfo($key, PATHINFO_EXTENSION)); +$mime = (new \finfo(FILEINFO_MIME_TYPE))->buffer($body) ?: ''; +if ($mime === '' || $mime === 'application/octet-stream') { + $mime = $extMap[$ext] ?? 'application/octet-stream'; +} +if ($mime === 'image/svg+xml') { + // 有些环境下 finfo 认不出 SVG(会给成 text/plain / text/xml),按扩展名补回 + $mime = 'image/svg+xml'; +} + +/** 能被浏览器当页面/脚本执行的一律降级成下载,避免"上传 SVG / HTML 拿到 XSS" */ +$dangerous = (strpos($mime, 'text/html') !== false) + || (strpos($mime, 'xhtml') !== false) + || (strpos($mime, 'javascript') !== false) + || (strpos($mime, 'x-shockwave') !== false); +if ($dangerous) { + $mime = 'application/octet-stream'; +} + +$size = strlen($body); +$etag = '"' . md5($key . '|' . $size . '|' . $mime) . '"'; + +// ---- 5. 条件请求 ---- +$inm = trim((string)($_SERVER['HTTP_IF_NONE_MATCH'] ?? '')); +if ($inm !== '' && $inm === $etag) { + http_response_code(304); + header('ETag: ' . $etag); + header('Cache-Control: private, max-age=604800'); + exit; +} + +// ---- 6. Range(附件将来可能是音视频,播放器会拖进度条)---- +$start = 0; +$end = $size - 1; +$isRange = false; +if (($range = trim((string)($_SERVER['HTTP_RANGE'] ?? ''))) !== '' + && preg_match('/^bytes=(\d*)-(\d*)$/', $range, $m) + && !($m[1] === '' && $m[2] === '')) { + if ($m[1] === '') { // bytes=-N → 最后 N 字节 + $start = max(0, $size - (int)$m[2]); + } else { + $start = (int)$m[1]; + $end = $m[2] === '' ? $size - 1 : min((int)$m[2], $size - 1); + } + if ($start > $end || $start >= $size) { + http_response_code(416); + header('Content-Range: bytes */' . $size); + exit; + } + $isRange = true; +} + +// ---- 7. 输出 ---- +http_response_code($isRange ? 206 : 200); +header('Content-Type: ' . $mime); +header('Content-Length: ' . (($end - $start) + 1)); +header('Cache-Control: private, max-age=604800'); // 登录态相关 → 绝不进共享缓存 +header('ETag: ' . $etag); +header('X-Content-Type-Options: nosniff'); +if ($mime === 'image/svg+xml') { + // SVG 里能带脚本 → 即便要显示,也禁止它执行脚本、加载外部资源 + header("Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox"); +} +if ($isRange) { + header('Content-Range: bytes ' . $start . '-' . $end . '/' . $size); +} +header('Accept-Ranges: bytes'); +$filename = basename($key); +$disp = !empty($_GET['dl']) ? 'attachment' : 'inline'; +header('Content-Disposition: ' . $disp . '; filename="' . rawurlencode($filename) . '"'); + +echo substr($body, $start, ($end - $start) + 1); +exit; diff --git a/templates/_datetime.php b/templates/_datetime.php new file mode 100644 index 0000000..0323872 --- /dev/null +++ b/templates/_datetime.php @@ -0,0 +1,63 @@ +: + * 它的显示格式由**浏览器 UI 语言 / 系统时间格式**决定, 与元素 lang + * 都压不过它。中文环境实测会渲染成「下午 03:30」,用户很容易把上下午填反; + * 而且它只有时:分,**没有秒**。拆成「原生日期选择器 + 三个下拉」后, + * 24 小时制与"精确到秒"都由控件本身保证,浏览器改不掉。 + * + * 日期部分仍是原生 ``(app.js 里的 showPicker 脚本对它同样生效, + * 点输入框任意位置都能弹出日历 —— 这正是"整个输入框点击都可以弹出"的落点)。 + * + * 用法(include 前 set 好这些变量;模板里只读它们): + * $dtName 字段基名,如 'birthday_solar' → 产出 *_date / _hour / _minute / _second + * $dtValue 可空;'Y-m-d H:i:s' / 'Y-m-d H:i' / 'Y-m-d'。服务端用 read_dt_input($dtName) 取值 + * $dtRequired 日期是否必填(默认 false) + * $dtId 可选;日期控件的 id(供