From 9348dd37963d5b30ad4845e0661fd3799a2bc02d Mon Sep 17 00:00:00 2001 From: laoyang Date: Sun, 18 Oct 2026 16:01:08 +0800 Subject: [PATCH] =?UTF-8?q?v58=EF=BC=9A=E5=8F=98=E6=9B=B4=E6=8C=87?= =?UTF-8?q?=E7=BA=B9=E6=8E=A5=E5=8F=A3=EF=BC=88=E7=AB=AF=E4=B8=8A=E5=A2=9E?= =?UTF-8?q?=E9=87=8F=E5=90=8C=E6=AD=A5=E7=9A=84=E6=9C=8D=E5=8A=A1=E7=AB=AF?= =?UTF-8?q?=E4=B8=80=E5=8D=8A=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户 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。 --- app/ApiHandler.php | 156 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) diff --git a/app/ApiHandler.php b/app/ApiHandler.php index fb95e06..8ac423d 100644 --- a/app/ApiHandler.php +++ b/app/ApiHandler.php @@ -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; + } } \ No newline at end of file