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' => '',