v45-① 同步基础字段:53 张业务表补 updated_at + deleted_at(结构 v33)

用户 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 复验通过 ✓
This commit is contained in:
2026-10-12 16:28:29 +08:00
parent 075ac6bbeb
commit 9f41e1622e
+121 -1
View File
@@ -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) {