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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user