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:
+121
-1
@@ -132,6 +132,20 @@ final class Schema
|
|||||||
* 它是一个独立页面(`?r=notifications`),入口只在头像下拉里。
|
* 它是一个独立页面(`?r=notifications`),入口只在头像下拉里。
|
||||||
* ⚠️ 唯一键 (person_id, target_type, target_id) 是整套设计的基石:
|
* ⚠️ 唯一键 (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() 的说明。
|
* ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 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) {
|
if (self::$stepErrors) {
|
||||||
|
|||||||
Reference in New Issue
Block a user