From 9f41e1622ee97c0a5c2345963a1a34534ce4c9cb Mon Sep 17 00:00:00 2001 From: laoyang Date: Mon, 12 Oct 2026 16:28:29 +0800 Subject: [PATCH] =?UTF-8?q?v45-=E2=91=A0=20=E5=90=8C=E6=AD=A5=E5=9F=BA?= =?UTF-8?q?=E7=A1=80=E5=AD=97=E6=AE=B5=EF=BC=9A53=20=E5=BC=A0=E4=B8=9A?= =?UTF-8?q?=E5=8A=A1=E8=A1=A8=E8=A1=A5=20updated=5Fat=20+=20deleted=5Fat?= =?UTF-8?q?=EF=BC=88=E7=BB=93=E6=9E=84=20v33=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户 2026-10-12 定「本地优先(local-first)」架构,客户端要落盘 + 增量同步。 本批只做**服务端地基**(不含客户端代码)。 ## 为什么必须补这两个字段 增量同步要先能回答两个问题,而实测现状两个都答不了: · **哪些行变了?** —— 50 张表里只有 21 张有 `updated_at` (`anniversaries` / `ledger_entries` / `task_boards` 等都没有); · **哪些行被删了?** —— **一张有 `deleted_at` 的都没有**。 硬删除在客户端**永远不可见**(没有墓碑,无法区分"服务端删了"与"本地没拉到") ⇒ 会一直显示幽灵数据。 ## 做法 · 53 张业务表补 `updated_at`: `DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP` —— **由 MySQL 自己维护**,任何真实 UPDATE 都自动刷新。 ⇒ **业务代码一行都不用改**,也就不存在"某处忘了更新 updated_at"的漏网之鱼 (手工 `SET updated_at = NOW()` 那种做法迟早会漏)。 · 53 张业务表补 `deleted_at`(软删除墓碑,NULL = 未删)。 ⚠️ 本批**只加列、不改删除逻辑** —— 改成软删除是后续独立一步, 先加列的好处是那步开始时不必再动表结构。 · 按有无 `family_id` 加 `idx_<表>_sync` 索引,供 `WHERE family_id=? AND updated_at>?` 用。 · ⛔ 8 张**故意不加**(凭据/字典/本地偏好):`api_tokens` `dav_credentials` `settings` `user_settings` `regions` `nav_engines` `nav_settings` `weread_snapshots` —— 同步白名单应当显式而简短。 ## 过程中发现并修正的一个清单缺陷(重要) 第一版清单**只扫了 `statements()`**,漏掉 11 张"建在 `ensure()` 迁移段里"的表 (`anniversaries` / `ledgers` / `ledger_entries` / `ledger_shares` / `ledger_entry_snapshots` / `task_boards` / `task_categories` / `calendar_event_skips` / `anniversary_people` / `person_jobs` / `person_notes`)。 **线上实测才发现 `anniversaries` 没有 updated_at。** ⇒ 教训:找"全部表"必须扫**整个文件**(本文件建表语句分散在两个函数里), 只扫建表函数必然漏。 ## 验证(线上实测) · `deleted_at` 53 列 / `idx_*_sync` 53 张 —— 与清单精确一致 ✓ · **`ON UPDATE` 真的生效**:改 `cinemas.name` 后 `updated_at` 16:19:19 → 16:27:28 自动刷新 ✓(业务代码未插手) ⚠️ 首次测试误报"没生效":当时用 `SET name = name`(**值没变**), MySQL 本来就不触发 ON UPDATE —— 是测试方法错,不是缺陷。 · 回归:首页/people/calendar/api 均 302/401(未登录的正常响应),无 500 ✓ · 诊断页用完即删、404 复验通过 ✓ --- app/Schema.php | 122 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 121 insertions(+), 1 deletion(-) 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) {