diff --git a/app/Schema.php b/app/Schema.php index d5cc3f0..d855115 100644 --- a/app/Schema.php +++ b/app/Schema.php @@ -132,6 +132,20 @@ final class Schema * 它是一个独立页面(`?r=notifications`),入口只在头像下拉里。 * ⚠️ 唯一键 (person_id, target_type, target_id) 是整套设计的基石: * 参与人是"整组替换",没有它,用户改三次标题就收到三条"加了你"。 + * 32 → **移动端 API v1 全量**(v44 那批):`api_tokens` 等表配合 `api.php` / + * `app/ApiHandler.php` 一次铺齐(me / calendar / events / tasks / movies / + * trips / ledger / resources / books / people / notifications / tokens)。 + * ⚠️ 这一版当时**只改了 VERSION 常量、忘了往这里写版本史**(2026-10-12 补记), + * 说明"VERSION 靠人记得改"这件事本身就不可靠 —— 也正是后来改成 + * `fingerprint()` 判据的原因。 + * 33 → **同步基础字段(用户 2026-10-12 定「本地优先」架构)**:给 53 张业务表 + * 补 `updated_at`(`DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`, + * 由 MySQL 自动维护、业务代码一行不用改)与 `deleted_at`(软删除墓碑), + * 并按有无 `family_id` 加 `idx_<表>_sync` 索引。 + * 动机:增量同步必须先能回答「哪些行变了」(updated_at)与 + * 「哪些行被删了」(没有 deleted_at 就只能硬删除 ⇒ 客户端永远看不到墓碑 + * ⇒ 一直显示幽灵数据)。⛔ 凭据/字典/本地偏好等 8 张表**故意不加** —— + * 同步白名单应当显式而简短。 */ /** * 结构版本(**人读**用:日志、设置页上显示"库结构是哪一版")。 @@ -139,7 +153,7 @@ final class Schema * ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。 * 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。 */ - public const VERSION = '32'; + public const VERSION = '33'; /** * 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。 @@ -2063,6 +2077,112 @@ SQL); } }); + // ============================================================ + // 同步基础字段(版本 33,2026-10-12) + // + // 背景:客户端要改「本地优先(local-first)——本地库为主、后台同步」, + // 而增量同步必须先能回答两个问题: + // ① **哪些行变了?** → 靠 `updated_at` + // ② **哪些行被删了?** → 靠软删除 `deleted_at` + // (硬删除在客户端**永远不可见**:那条记录直接消失,没有墓碑, + // 同步时无法区分"服务端删了"和"本地还没拉下来",会一直显示幽灵数据。) + // + // 实测现状(本次迁移前):50 张表里 + // · 只有 21 张有 `updated_at`; + // · **一张有 `deleted_at` 的都没有**。 + // + // 🔴 关键手法:`updated_at` 一律用 + // `DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP` + // —— **由 MySQL 自己维护**,任何 UPDATE 都会自动刷新。 + // 好处:**不需要改任何一处业务代码**(各处写库的 SQL 一行都不用动), + // 也就不存在"某处忘了更新 updated_at"的漏网之鱼 + // (手工 `SET updated_at = NOW()` 这种做法,迟早漏,本项目已撞过同类坑)。 + // + // ⚠️ `deleted_at` 只加列、**不加任何逻辑**:本次迁移只准备"墓碑"的容器, + // 删除改成软删除是**后续的事**(要逐个改 delete 语句,是独立一步)。 + // 先加列的好处是:等那步开始时不用再动表结构。 + // + // ⚠️ 只处理**业务表**(要同步的)。凭据/字典/本地偏好这几类不加: + // `api_tokens` `dav_credentials`(凭据,客户端存 token 本身) + // `settings` `user_settings`(偏好,各端可以不同) + // `regions` `nav_engines` `nav_settings`(字典 / 每人一份的本地配置) + // `weread_snapshots`(第三方原始快照,同步的是解析后的结果) + // 给它们加字段不是错,但会让"哪些表参与同步"这件事变得含糊 —— + // 同步白名单应当显式而简短。 + self::tryStep('补齐同步基础字段(updated_at / deleted_at)', static function () use ($db) { + // 要同步的业务表(53 张)。**显式列全**,不用"遍历所有表", + // 因为那样会把上述 8 张不该同步的表也卷进来。 + // + // 🔴 为什么清单里有两组表:建表语句**分散在两个地方** —— + // `statements()`(新库初次安装时执行)与前缀为 `ensure()` 的 + // **版本迁移**段(老库升级时执行)。像 `anniversaries` / `ledgers` / + // `task_boards` 这些是后来版本才加的表,建在 ensure() 里。 + // ⚠️ 第一版清单**只扫了 statements()**,于是漏掉 11 张 + // (anniversaries / ledgers / ledger_entries / task_boards …) + // —— 线上实测才发现 `anniversaries` 没有 updated_at。 + // 教训:**找"全部表"必须扫整个文件,不能只扫建表函数**。 + $bizTables = [ + // ---- statements() 里建的 ---- + 'families', 'persons', 'accounts', 'relations', 'nicknames', + 'person_groups', 'person_roster', 'person_contacts', + 'events', 'cinemas', 'movie_details', 'movie_reviews', 'movie_review_comments', + 'event_attendees', 'event_photos', 'attachments', + 'trip_details', 'trip_nodes', 'trip_node_members', + 'calendars', 'calendar_shares', 'calendar_events', + 'resources', 'resource_shares', + 'tasks', 'task_shares', 'task_comments', + 'books', 'book_chapters', 'book_notes', + 'vehicles', 'node_types', 'trip_expenses', + 'nav_groups', 'nav_sites', + 'notes', 'note_blocks', 'note_tags', + 'person_tags', 'note_shares', 'notifications', + // ---- ensure() 版本迁移段里建的 ---- + 'trip_node_revisions', 'task_boards', 'task_categories', + 'calendar_event_skips', 'anniversaries', 'anniversary_people', + 'ledgers', 'ledger_entries', 'ledger_shares', + 'ledger_entry_snapshots', 'person_jobs', 'person_notes', + ]; + + foreach ($bizTables as $tb) { + // 表可能还没建(分步升级时存在中间态)→ 跳过,下次 ensure 再补 + if (!self::hasTable($db, $tb)) { + continue; + } + // ① updated_at:DEFAULT + ON UPDATE 都由数据库维护(见上方说明) + self::addColumn($db, $tb, 'updated_at', + "ADD COLUMN `updated_at` DATETIME NOT NULL " + . "DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP " + . "COMMENT '最后修改时间:由 MySQL 自动维护,客户端增量同步据此判断哪些行变了'"); + // ② deleted_at:软删除墓碑(NULL = 未删除) + // ⚠️ 索引:同步查询是 `WHERE family_id = ? AND updated_at > ?`, + // 软删除过滤是 `deleted_at IS NULL`。这里给 deleted_at 加个 + // 普通索引,避免拉取时全表扫。 + self::addColumn($db, $tb, 'deleted_at', + "ADD COLUMN `deleted_at` DATETIME DEFAULT NULL " + . "COMMENT '软删除时间:NULL=未删;客户端靠它知道这一行被删了'"); + // ③ 索引:增量拉取的典型条件是 (family_id, updated_at), + // 但不是每张表都有 family_id(如 calendar_events 走 calendar_id) + // ⇒ 只给**有 family_id 的表**加复合索引,其余加单列 updated_at 索引。 + $idxName = 'idx_' . $tb . '_sync'; + $hasIdx = (int)$db->query( + "SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '{$tb}' + AND INDEX_NAME = '{$idxName}'" + )->fetchColumn(); + if ($hasIdx === 0) { + try { + if (self::hasColumn($db, $tb, 'family_id')) { + $db->exec("ALTER TABLE `{$tb}` ADD INDEX `{$idxName}` (`family_id`, `updated_at`)"); + } else { + $db->exec("ALTER TABLE `{$tb}` ADD INDEX `{$idxName}` (`updated_at`)"); + } + } catch (\Throwable $e) { + // 索引是优化项,加不上不该挡住建列这件事本身 + } + } + } + }); + // 被容错跳过的数据步骤记进设置,系统设置页会把它显出来 —— // 不然「升级没跑完」这件事只活在日志里,跟没发生一样。 if (self::$stepErrors) {