v58:变更指纹接口(端上增量同步的服务端一半)

用户 2026-10-18:首次全量、之后增量同步;进来先展示本地数据,一边同步
服务器最新数据一边更新(附件走后台队列)。

服务端只补一件事:GET /api/v1/changes —— 每个模块返回一个变更指纹,
端上只做相等比较,不相同才拉。

指纹三件套 COUNT(*) : MAX(id) : MAX(updated_at):
- 61 张表里只有 16 张有 updated_at,单用它抓不到新增与删除;
- 只有行数则抓不到"改已有行"(靠端上 24h 强制全量兜底,响应带 stale_after_ms)。

CHANGE_TABLES 里 home 故意不在(派生页,端上跟着别的模块重拉);
kin 与 roster 同源(persons/relations/nicknames),名册一变家谱跟着拉。

必须带令牌(Api::me() 开头):指纹本身不算机密,但没理由让匿名者试探
"家里有没有新数据"。

验证:_fb_changes_api.py 33/0(新增/修改/删除各自改变指纹、12 模块齐全、
无令牌 401、两设备拿到同一个 all、被踢令牌 401);本地 e2e 1519/0;
部署静态断言补「第58批形态」8 条;全量流水线 PUSH_RC=0。
This commit is contained in:
2026-10-18 16:01:08 +08:00
parent f513703491
commit 9348dd3796
+156
View File
@@ -42,6 +42,9 @@ final class ApiHandler
// `/devices` 可在网页端/端上踢设备
'device_session' => 1,
'home_memories' => 1,
// v58:同步可以"先问一句哪些模块变了",只拉变了的
// (见 `changes()` 的说明;老端上没有这个标志也不会坏)
'changes' => 1,
],
]);
}
@@ -52,6 +55,9 @@ final class ApiHandler
case 'logout': $this->logout($method); return;
case 'devices': $this->devices($id, $method); return;
case 'tokens': $this->tokens($id, $method); return;
// 🔴 v58:增量同步的"变更指纹"。端上先问一遍,只拉真的变了的模块
// (首次全量、之后增量)。详见 `changes()`。
case 'changes': $this->changes($method); return;
case 'home': $this->home(); return;
case 'people': $this->people($id, $sub, $method); return;
// 🔴 v45:名册(**我名册里的人**,不是全家 800 人)。
@@ -1966,4 +1972,154 @@ final class ApiHandler
'items' => Note::all($fam, $pid, Api::str('keyword'), Api::str('tag'), $per, $off),
]);
}
/* ═══════════════════════════════════════════════════════════════
v58 · 增量同步:各模块的「变更指纹」
═══════════════════════════════════════════════════════════════ */
/**
* 每个模块的指纹由哪些表合成。
*
* 🔴 为什么要这个接口(用户 2026-10-17:「首次全量,之后增量」):
* 端上每次打开都全量拉 12 个模块,其中 11 个绝大多数时候没有任何变化 ——
* 白耗流量、白等时间,用户还看不到"同步到底在干什么"。
* 有了指纹,端上先问一句「哪些模块变了」,**只拉变了的**。
*
* 🔴 指纹为什么是「行数 + 最大 id + 最大 updated_at」三件而不是只用 updated_at:
* 本项目**有一半的表根本没有 `updated_at` 列**(`movie_details`、
* `person_roster`、`anniversaries`、`ledger_entries` … 实测 61 张表里只有 16 张有)。
* 只用 updated_at 会把"新增一部电影"判成"没变" ⇒ 端上永远看不到新数据。
* 加入 `COUNT(*)` + `MAX(id)` 后,**新增与删除一定被抓到**。
*
* ⚠️ **已知局限(必须写在这里,别让后人以为它是万能的)**:
* "**修改**一张没有 updated_at 的表里**已有的一行**"(比如改某部电影的海报 key)
* 合成指纹前后一模一样 ⇒ 判成"没变"。
* 端上对此的兜底是**陈旧上限**:每过一段时间(或每 N 轮)强制全量一次
* (见端上 `FbSyncAll` 的 `FORCE_FULL_AFTER_MS`)。这是缓存一致性的标准折中,
* 不是漏洞 —— 但不能当成"已解决"。
*
* ⚠️ 表名是**写死的**(这里才知道"哪个模块由哪几张表组成")。
* 写错了不会报错,只会让那个模块的指纹偏旧。所以取指纹时一旦**一张表都没查到**
* 就返回 `null`,端上把 `null` 视为「无法判断 ⇒ 每次都拉」。
* ⇒ 失败方向是"多拉一次",**永远不会是"漏同步"**。
*
* ⚠️ 指纹**不按家庭过滤**(`calendar_events`/`movie_details` 这类表连 family_id 都没有):
* 别家写入只会让我们多拉一次,不会串数据(内容接口本身按家庭隔离)。
* 单家庭部署下等于没有影响。
*/
private const CHANGE_TABLES = [
'movies' => ['movie_details', 'movie_reviews', 'cinemas', 'movie_review_comments'],
'roster' => ['persons', 'person_roster', 'person_tags', 'person_groups',
'person_contacts', 'person_jobs', 'person_notes', 'nicknames', 'relations'],
'calendar' => ['calendar_events', 'calendar_event_overrides', 'events',
'event_attendees', 'event_photos', 'calendars'],
'tasks' => ['tasks', 'task_comments', 'task_boards', 'task_shares'],
'days' => ['anniversaries', 'anniversary_people'],
'trips' => ['trip_details', 'trip_nodes', 'trip_node_members',
'trip_node_revisions', 'trip_expenses'],
'ledger' => ['ledgers', 'ledger_entries', 'ledger_entry_snapshots', 'ledger_shares'],
'resources' => ['resources', 'resource_shares', 'attachments'],
'books' => ['books', 'book_chapters', 'book_notes'],
'notes' => ['notes', 'note_blocks', 'note_tags', 'note_shares'],
'notifications' => ['notifications'],
// 🔴 `kin`(家谱)与 `roster` **同源**:它整页都是从 persons / relations /
// nicknames 现算出来的,没有自己的表。所以指纹用的就是名册那三张 ——
// 名册一变,家谱必然要重拉;反过来也一样。分开列不会更准,只会多查几遍。
'kin' => ['persons', 'relations', 'nicknames'],
// 'home' 故意**不在**这里:它是派生页(天气、农历、统计都要现算),
// "变没变"由上面这些模块决定,而端上单独判它没有意义
// ⇒ 端上对 home 的处理是"只要别的模块变了就跟着重拉",见 FbSyncAll。
];
/**
* `GET /api/v1/changes` —— 返回各模块的变更指纹。
*
* 响应:
* ```
* { ok: true, data: {
* server_time: '2026-10-18T09:00:00+08:00',
* modules: { movies: '120:120:2026-10-17 21:03:11', roster: null, ... },
* stale_after_ms: 86400000
* } }
* ```
* 指纹是**不透明字符串**,端上只做「相等 / 不相等」比较,不解析。
* `null` = 服务端自己都判断不了(表缺失/查询失败)⇒ 端上必须拉。
*/
private function changes(string $method): void
{
Api::me(); // 必须带令牌:指纹本身不算机密,但没理由让匿名者试探"家里有没有新数据"
if ($method !== 'GET') {
Api::fail('只支持 GET。', 'method_not_allowed', 405);
}
$db = Database::get();
$out = [];
$unknown = [];
foreach (self::CHANGE_TABLES as $mod => $tables) {
$fp = self::moduleFingerprint($db, $tables);
$out[$mod] = $fp;
if ($fp === null) {
$unknown[] = $mod;
}
}
// 全库水位:所有模块指纹拼起来的哈希。
// ⚠️ 端上主要用它做**快速路径**(水位没变 ⇒ 连逐个比都不用比,整轮跳过)。
Api::ok([
'server_time' => date('c'),
'modules' => $out,
'all' => hash('sha256', json_encode($out) . ''),
'unknown' => $unknown,
// 端上"陈旧上限"的建议值(1 天);端上也可以自己定更短
'stale_after_ms' => 24 * 3600 * 1000,
]);
}
/**
* 算一个模块的指纹:`行数:最大id:最大updated_at`,各表累加/取最大。
*
* @param string[] $tables 候选表名(不存在或没权限的会被跳过)
* @return string|null 一张表都没查到 ⇒ null(无法判断)
*/
private static function moduleFingerprint(\PDO $db, array $tables): ?string
{
$rows = 0;
$maxId = 0;
$maxUpd = '';
$touched = 0;
foreach ($tables as $t) {
// ⚠️ 表名要拼进 SQL(标识符不能走占位符)⇒ 先严格白名单,防注入
if (preg_match('/^[a-z][a-z0-9_]{0,40}$/', $t) !== 1) {
continue;
}
try {
$st = $db->query("SELECT COUNT(*) AS c, COALESCE(MAX(`id`), 0) AS m FROM `$t`");
$row = $st !== false ? $st->fetch() : false;
if ($row === false) {
continue;
}
$touched++;
$rows += (int)$row['c'];
$maxId = max($maxId, (int)$row['m']);
} catch (\Throwable $e) {
continue; // 表不存在(老库)/ 没这张表 —— 跳过,不影响别的表
}
// updated_at 单独 try:**很多表没有这一列**(见 CHANGE_TABLES 的说明)
try {
$u = $db->query("SELECT COALESCE(MAX(`updated_at`), '') FROM `$t`");
$s = $u !== false ? (string)$u->fetchColumn() : '';
if ($s > $maxUpd) {
$maxUpd = $s;
}
} catch (\Throwable $e) {
// 这一列不存在:只靠行数 + 最大 id 兜(新增/删除仍能被抓到)
}
}
if ($touched === 0) {
return null;
}
return $rows . ':' . $maxId . ':' . $maxUpd;
}
}