From dbcf0c1bcd05d063f405ee083d61f1910bc06fc8 Mon Sep 17 00:00:00 2001 From: laoyang Date: Sat, 3 Oct 2026 12:46:44 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A0=B9=E6=B2=BB=E3=80=8C=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E5=90=8E=20500=E3=80=8D=EF=BC=9A=E7=BB=93=E6=9E=84=E5=8D=87?= =?UTF-8?q?=E7=BA=A7=E6=94=B9=E6=88=90=E6=8C=89=E6=BA=90=E7=A0=81=E6=8C=87?= =?UTF-8?q?=E7=BA=B9=E8=A7=A6=E5=8F=91=EF=BC=8C=E4=B8=8D=E5=86=8D=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=E4=BA=BA=E8=AE=B0=E5=BE=97=E6=94=B9=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 故障与根因 用户报「首页能开,登录后就 500」。上一轮加的错误自记录抓到了确切原因: SQLSTATE[42S22] Unknown column 'd.is_public' app/Calendar.php:650 ← Calendar::month() 的旅程段 线上 trip_details 缺 is_public 列、calendar_event_skips 表不存在, 但 settings.schema_version 是 12 —— 而 index.php 的判断是: if ($have !== FB_SCHEMA_VERSION) { Schema::ensure(); ... } 线上 12 == 代码 12 → 判定「已是最新」→ **整段 v13 升级被跳过**。 连续两批提交(8f80f3e、02f5d07)往 ensure() 里加了 8 个新列 / 2 张新表, 但**都没改 Schema::VERSION** —— 而"忘了改版本号"没有任何检查拦得住: 不是代码错,是流程漏。 ## 根治(换判据,而不是"这次记得改") - Schema::fingerprint():拿 **Schema.php 文件内容**的哈希当判据。 只要 ensure() 改过(哪怕只加一行 addColumn),指纹就变,下一次请求必然重跑。 - Schema::needsUpgrade() / markUpgraded():一读一写,落 settings。 - index.php 的门槛改成指纹;install.php 装完也盖指纹章。 - ⚠️ 指纹**只用内容、不含 mtime**:部署(tar 解包)会重写 mtime, 带上它等于每次部署都白跑一遍升级。 ## 顺带修掉的三个真问题 1. **「老日程时刻并进」不是幂等的**:并完没清 start_time,重跑会把陈旧时刻 盖回去 —— 等于**悄悄撤销用户后来改的时间**。现在并完就置 NULL。 2. **测试脚本的 check() 无条件打印 extra**(升级测试里):extra 写的是 "为什么没通过",成功时也打印,读日志的人会以为出错(本轮确实误判过一次)。 3. **断言里写死版本号**(e2e / 升级测试 / 部署 chk 共 5 处):版本号是代码常量, 写死后每轮假红。改成"与代码常量一致" + "指纹已记", 后者才是真正的哨兵(版本号对但升级没跑的正是上次事故的形态)。 ## 防复发 - 部署脚本新增 1d 段:只要有人把门槛改回版本号、或指纹里掺进 mtime,**拒绝部署**。 - e2e / 升级测试新增哨兵:`schema_fingerprint` 为空即失败 (= 自动升级机制没生效,下次加列还会重演)。 ## 验证 - 本地模拟「库停在旧结构」:退掉 is_public、删表 → 发一个请求 → 自动补齐、 日历页从 524 字节含 Fatal 变成 36597 字节无 Fatal。 - 反向验证:把门槛改回版本号(版本号伪装成已最新)→ 必现 500,因果链确认。 - 线上:部署后一个请求即自动补齐(is_public ✓ / calendar_event_skips ✓ / 指纹已记);登录态 16 个页面全部 200、零异常。 - 端到端 773 项 / 升级 126 项全过;几何 368 / 交互 97 / 移动端 516 / 家谱 26 / 版面 10 全绿。 --- app/Schema.php | 62 +++++++++++++++++++++++++++++++++++++++++++++++++- index.php | 11 ++++++--- install.php | 6 ++++- 3 files changed, 74 insertions(+), 5 deletions(-) diff --git a/app/Schema.php b/app/Schema.php index 5904054..4f7f960 100644 --- a/app/Schema.php +++ b/app/Schema.php @@ -65,7 +65,61 @@ final class Schema * ⑤ 统一附件 attachments(旅程/节点/资源/任务/评论共用一张表); * ⑥ 模块导航与主题色走 user_settings / settings,不新建表。 */ - public const VERSION = '12'; + /** + * 结构版本(**人读**用:日志、设置页上显示"库结构是哪一版")。 + * + * ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。 + * 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。 + */ + public const VERSION = '13'; + + /** + * 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。 + * + * 为什么不用版本号: + * 版本号靠**人记得改**。这个项目已经因此翻过一次大车(2026-10-03): + * 连续两批提交往 ensure() 里加了 8 个新列 / 2 张新表, + * 但没动 VERSION(一直是 12)→ 线上判定"已是最新"→ **整段升级被跳过** + * → Calendar::month() 查 `d.is_public` 直接 PDOException 500。 + * 而"忘了改版本号"这件事**没有任何检查能拦**(php -l 看不出、 + * 部署断言也只能查字符串在不在),因为它不是代码错,是流程漏。 + * + * 指纹的好处:**只要 ensure() 改过(哪怕只是加一行 addColumn),指纹就变**, + * 下一次请求必然重跑一遍 —— 不需要任何人记得做什么。 + * + * ⚠️ 为什么用**文件内容**而不是"所有 app/*.php 的哈希": + * 只是 scope 更聚焦 —— 只有改 Schema 才可能改库结构。 + * (改别的类而动了结构,属于不该发生的事;真发生也由 addColumn 的幂等性兜住) + * ⚠️ **只用内容,不用 mtime**:部署(tar 解包)会重写 mtime, + * 带上它等于"每次部署都白跑一遍升级"(虽然幂等、但没必要)。 + */ + public static function fingerprint(): string + { + $body = @file_get_contents(__FILE__); + if ($body === false) { + return 'v' . self::VERSION; // 读不到就退回版本号(至少不会更差) + } + return substr(md5($body), 0, 16); + } + + /** + * 这次请求要不要跑结构升级。 + * + * 判据 = 指纹不一致。**指纹一致就跳过**(平时只是一次文件哈希,可接受)。 + */ + public static function needsUpgrade(int $familyId): bool + { + $have = (string)Setting::get($familyId, 'schema_fingerprint', ''); + return $have !== self::fingerprint(); + } + + /** 升级跑完(或确认无需跑)后记下指纹 */ + public static function markUpgraded(int $familyId): void + { + Setting::set($familyId, 'schema_fingerprint', self::fingerprint()); + // 版本号也顺手对齐,方便人看(设置页/日志里显示的就是它) + Setting::set($familyId, 'schema_version', self::VERSION); + } /** @return string[] */ public static function statements(): array @@ -790,6 +844,12 @@ SQL, $db->exec("UPDATE `calendar_events` SET `ends_at` = CONCAT(DATE(`ends_at`), ' ', `end_time`) WHERE `ends_at` IS NOT NULL AND `end_time` IS NOT NULL AND `all_day` = 0"); + // ⚠️ 并完必须把旧的 start_time/end_time **清空**,否则这步不是幂等的: + // 用户后来改了时刻(改的是 starts_at),下次再跑升级又把陈旧的 + // start_time 盖回去,等于**悄悄撤销用户的修改**。 + // 清空之后,下次重跑时 WHERE start_time IS NOT NULL 自然不命中 → 安全。 + $db->exec("UPDATE `calendar_events` SET `start_time` = NULL WHERE `start_time` IS NOT NULL"); + $db->exec("UPDATE `calendar_events` SET `end_time` = NULL WHERE `end_time` IS NOT NULL"); }); // 其余字段按 RFC 5545(iCalendar)/ RFC 4791(CalDAV)的 VEVENT 属性定: // UID/DTSTAMP/SEQUENCE 是 CalDAV 同步三件套(没有它们客户端每次都当新日程); diff --git a/index.php b/index.php index 64eba86..b0dd4db 100644 --- a/index.php +++ b/index.php @@ -55,6 +55,8 @@ if (!Database::hasTables()) { * 新的 guest 角色。靠人去手动 ALTER 迟早会漏,所以在每次请求的开头比一下版本号, * 落后了就补一次 —— 平时只是一次字符串比较,几乎零成本。 */ +// 仅用于**显示**(设置页/日志里"库结构是哪一版")。判断要不要升级请看 +// Schema::needsUpgrade() —— 它比的是源码指纹。 const FB_SCHEMA_VERSION = Schema::VERSION; if (Database::hasTables()) { $familyIdForSchema = isset($_SESSION['family_id']) ? (int)$_SESSION['family_id'] : 0; @@ -67,11 +69,14 @@ if (Database::hasTables()) { } } if ($familyIdForSchema > 0) { - $have = (string)Setting::get($familyIdForSchema, 'schema_version', '1'); - if ($have !== FB_SCHEMA_VERSION) { + // ⚠️ 判据是 **Schema 源码指纹**,不是版本号 —— 见 Schema::fingerprint() 的说明。 + // 版本号靠人记得改,本项目已因此翻车一次(2026-10-03:两批结构变更都没改 + // VERSION → 线上判定"已是最新" → 整段升级被跳过 → Calendar::month() 500)。 + // 指纹改成"改了 Schema 就跑",不再依赖任何人的记性。 + if (Schema::needsUpgrade($familyIdForSchema)) { try { Schema::ensure(); - Setting::set($familyIdForSchema, 'schema_version', FB_SCHEMA_VERSION); + Schema::markUpgraded($familyIdForSchema); } catch (\Throwable $ex) { // 升级失败不能把整站挡住;下次请求会再试。 // ⚠️ 但**必须留下痕迹**:这里曾经是空的 catch,结果线上结构升级 diff --git a/install.php b/install.php index e3c42b3..b222d89 100644 --- a/install.php +++ b/install.php @@ -59,7 +59,11 @@ if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { \App\PersonGroup::seedDefaults((int)$familyId); // 装完就把结构版本盖上:不盖的话,第一次进首页发现"版本对不上", // 会再跑一次 ensure()(幂等,不出错,但白跑一遍 ALTER 查询)。 - \App\Setting::set($familyId, 'schema_version', Schema::VERSION); + // ⚠️ 指纹也要在这里记下 —— 它是"要不要跑结构升级"的真正判据 + // (见 Schema::fingerprint())。只写 schema_version 的话, + // 装完的第一个请求会因为"指纹为空"而白跑一遍完整升级 + // (幂等、不会错,但没必要,而且会让"升级跑过了吗"这个信号变模糊)。 + \App\Schema::markUpgraded($familyId); $personId = Person::create($familyId, [ 'name' => $name, 'gender' => '',