v47:地址联想三处接入 / 读书页慢的真根因(会话锁)/ 笔记两处 / 日程状态下线

用户 2026-10-13 七条。拍板:影院只记地址、不存经纬度(后续用高德反查);
日程状态只删界面与展示、数据库列保留(「后续会有一个整体清理的工作」)。

①🔴 读书页慢 —— 真根因是**会话锁**,不是封面(我先判断错了方向)
  用户原话:「读书现在还是非常慢,就是进入读书这个页面,刚开始还是非常的慢。」
  线上实测(真公网、同一会话连打 3 次):
      第 1 次 TTFB 0.08s  ← 触发微信读书同步
      第 2 次 TTFB **8.73s**  ← 用户说的"刚开始非常慢"
      第 3 次 TTFB 0.07s
  插桩拿到的服务端时间线把因果钉死:
      21:18:05.483 QUEUED        ← 第 1 个请求排队
      21:18:05.501 FINISH  2ms   ← 响应确实提前发出去了(这步是好的)
      21:18:14.674 SYNC    9.2s  ← 但它仍占着这个人的会话文件锁跑完 9.2 秒
      21:18:14.682 QUEUED        ← 第 2 个请求此刻才真正开始执行
  ⇒ **`fastcgi_finish_request()` 只断开客户端连接、不释放 PHP 文件会话锁。**
    后台同步要 9.2 秒(真打微信读书接口),同一个人的下一个请求一路等到它跑完。
  修法(缺一不可):
    · `WeRead::queueBackgroundSync()` 回调里 **先 `session_write_close()`、
      再 `fastcgi_finish_request()`**(顺序不能反);
    · `index.php` 两处"渲染前排同步"的入口排完**立刻放锁**
      (shutdown 回调不一定执行:客户端中断/服务器超时才不放过);
    · 顺带修 **`Holiday::queueBackgroundFetch()` 的同型问题**(联网 12 秒)。
  为什么以前没发现:v29 引入"后台化"时只验证了"响应是不是提前发出去",
  **单次请求测量永远看不到这个 bug** —— 必须同一会话连打两次才现形。
  线上复验:修复后连打 4 次 = 0.15 / 0.15 / 0.09 / 0.15 秒(修复前第 2 次 8.73s);
  后台同步仍正常(`weread_sync_at` 已更新)。

② 封面:顺手也修了(但这不是用户说的那个问题)
  · `media.php` 读 OSS 前先放会话锁(原来从不放,35 张封面串行);
  · 新增 `Storage::cacheLocally()` 磁盘二级缓存(原来 `read()` 只读不写回);
  · 让 304 不再先拉整张图(原来先取回 body、再判 `If-None-Match`)。
  线上实测:冷取 149ms(走 OSS)→ 热取 35ms(本地缓存、字节一致)
  → 带 `If-None-Match` 返回 **304 / 19.8ms / 空体**;35 张封面 103ms → 20ms(5 倍)。

③ 地址联想:抽成全站共用一份(`assets/js/addr_suggest.js`)+ 接入三处
  · 影院管理页、观影新增页的「新建影院」弹框、日程地点、行程出发/到达;
  · `layout.php` 按需加载(`[data-addr]` 出现才拉 SDK + 我们的脚本),
    并暴露 `fbLoadAddr(scope)` 给 AJAX 片段(v42 踩过"内联 script 不执行");
  · 安全密钥在 SDK 之前设置;回填支持 `data-addr-fill` / `data-addr-for`、
    **只补空白字段**(不覆盖用户手写)、直辖市 `province==city` 去重;
  · 字段名有两套(影院 `address`/`place`,行程 `from_addr`/`to_addr` 缩写 `_addr`),
    `fieldFor()` 带同义词表 + 后缀匹配;
  · 影院**只存地址、不存经纬度**(按用户要求)。

④ 笔记两条
  · 「点保存没反应」真因:附件区是个 `<form>` **套在保存表单里** ——
    HTML 不允许 form 套 form,浏览器丢掉内层标签、保留它的 `</form>`,
    于是**保存按钮成了孤儿**(`button.form === null`),点了不提交也不报错。
    真浏览器实测:修前 `btnInsideForm:false` / 点后 POST=0;修后 true / POST=1。
    改法:附件区改 `<div id="noteAttForm">`,上传按钮用 `formaction`。
  · 详情页宽度:`.note-read { max-width: 820px }` → `none`
    (内容卡 820px vs 下方共享卡 1032px;修后两者都是 1032px)。

⑤ 日程状态下线
  · 删表单里的「状态」下拉、`Calendar::statusWord()`、浮层 JSON 的 `status_word`;
  · **保留** `events.status` 列与 `Calendar::statusOf()`(契约不动,等整体清理)。

验证(新增 5 个套件):
  · `_fb_v47_addr.py`(接线 24 条)/ `_fb_v47_addr_sim.py`(真浏览器 + 假高德,16 条)
  · `_fb_v47_readperf.py`(缓存与会话锁,23 条)
  · `_fb_v47_notes.py`(真浏览器,9 条)
  · `_b47_readperf_falsify.py`(反向验证 9 个注入)—— **9/9 全抓住**
  · 全量 e2e **1510/0**;vCard 规格+往返 40/0;TB 探针 44/0;
    v44 反向验证 17/0;全流水线 **PUSH_RC=0**(含"第47批形态"静态断言)
  · **线上终验**:读书页整页 81ms、封面 103→20ms、media 冷 149→热 35→304 19.8ms、
    修复前第 2 次请求 8.73s → 修复后 0.15s、地址联想 meta[fb-addr] 带真高德 key

顺带修掉三处**探针自身**的假红/假绿(详见 pitfalls.md):
  · `_b44_falsify.py` 的"整小时"注入在**整点**与正确实现等价 ⇒ 换成恒错值;
  · `_fb_b44_ui.py` 3b 的并发计时断言在单进程 `php -S` 下必然假红 ⇒ 改断言确定事实;
  · 多处探针的"清理删除"撞沙箱删除门禁 ⇒ 探针**中途死掉**看起来像产品坏了
    ⇒ 统一改成 `_safe_rm()` / 不删。
This commit is contained in:
2026-10-14 01:20:40 +08:00
parent fec898c18d
commit 32e9e9ab84
16 changed files with 634 additions and 102 deletions
+88 -6
View File
@@ -43,12 +43,54 @@ function media_fail(int $code, string $message): void
$key = (string)($_GET['k'] ?? '');
$stageTok = (string)($_GET['stage'] ?? '');
// ---- 1. 鉴权 ----
if (!Auth::check()) {
// 401 而不是 302:这是给 <img src> 用的端点,跳登录页只会返回一坨 HTML 当成图片
media_fail(401, '需要登录');
/*
* ---- 1. 鉴权:网页会话 **或** 移动端 API 令牌 ----
*
* 🔴 v45(2026-10-12):这一条是为了让**手机端能显示图片**而加的。
*
* 背景:`media.php` 原先**只认网页会话**(`Auth::check()` 读 `$_SESSION`),
* 而鸿蒙 App 是拿 `Authorization: Bearer <token>` 说话的(`api.php` 那套)。
* ⇒ App 拿不到任何图片:海报、头像、附件全都显示不出来。
* 这不是"前端忘了接",是**服务端压根没提供移动端可用的取图入口**。
*
* ⚠️ 为什么令牌走 **query 参数**(`?token=`)而不是请求头:
* 图片是 `<img src="...">` 取的,**浏览器/ArkUI 的 Image 组件都无法给它加
* 自定义请求头**。所以必须支持 URL 里带令牌。
* (`api.php` 的注释里也写了"也接受 `?token=`"这个约定,与这里同一口径。)
*
* ⚠️ 安全性:URL 里的令牌会进 access log / Referer。
* 对这个家庭应用是可接受的取舍 —— 且令牌本来就只能读本家庭的数据。
* ⚠️ 但**必须**:① 只认 `Bearer` 那条不认(这里只查 query);
* ② 令牌无效就 401,不泄露"这个 key 存不存在"。
*
* ⚠️ 会话优先:网页端行为**一字不变**(先判 session,命中就不碰令牌),
* 避免影响现有 300+ 处 `<img src="media.php?k=…">`。
*/
$familyId = 0;
$personId = 0;
if (Auth::check()) {
// 网页会话(原有路径,行为不变)
$familyId = (int)Auth::familyId();
$personId = (int)(Auth::personId() ?? 0);
} else {
// 移动端令牌兜底
// ⚠️ `Api::bearer()` 只读请求头;这里要用 query,所以自己取。
$tok = trim((string)($_GET['token'] ?? ''));
if ($tok !== '') {
$hit = \App\ApiToken::verify($tok);
if ($hit !== null) {
$personId = (int)$hit[0];
$p = \App\Person::find($personId);
if ($p) {
$familyId = (int)($p['family_id'] ?? 0);
}
}
}
if ($familyId <= 0) {
// 401 而不是 302:这是给 <img src> 用的端点,跳登录页只会返回一坨 HTML 当成图片
media_fail(401, '需要登录');
}
}
$familyId = (int)Auth::familyId();
/* ---- 1b. 暂存预览(v24 第 20 批 第 4 条)----
刚拖进来的文件还没"保存",所以它还没有附件行、也没有正式键 —— 但用户
@@ -56,7 +98,9 @@ $familyId = (int)Auth::familyId();
所以这里给一个 `?stage=<令牌>` 的形态:只读**本账号自己**的暂存目录。
⚠️ 令牌校验与归属检查都在 StagedUpload::read 里(拿到别人的令牌也读不到)。 */
if ($stageTok !== '') {
$sf = \App\StagedUpload::read($familyId, (int)Auth::personId(), $stageTok);
// ⚠️ 用上面推导好的 `$personId`(会话路径与 Auth::personId() 同值;
// 令牌路径也正确)—— 别在这里再调 Auth::,那对 App 会是 null。
$sf = \App\StagedUpload::read($familyId, $personId, $stageTok);
if ($sf === null) {
media_fail(404, '没有这个暂存文件');
}
@@ -92,10 +136,42 @@ foreach (explode('/', $key) as $seg) {
}
// ---- 3. 取内容(本地优先,再由当前驱动兜底)----
/* 🔴🔴 v47(用户 2026-10-13):「读书现在还是非常慢,这个问题需要彻底解决。」
实测(真公网):书架页有 **35 张封面**,每张都要 0.15~0.53s 去 OSS 取原图,
而且 ---- **每次访问都重新取一遍**(服务端没有任何缓存)。
两个硬伤,这一版一起修:
① **会话锁**:本项目用 PHP 文件会话,而 `session_start()` 会**独占**会话文件。
media.php 从不释放它 ⇒ **同一个人的所有图片请求串行**(浏览器 6 条连接也快不了)。
⇒ 读完会话(鉴权完成)后立刻 `session_write_close()`:不再写会话了,早点放锁。
实测收益最大——它把"35 张 × 串行"变成"N 张并发"。
② **磁盘二级缓存**:实现早已是"本地优先"(`Storage::read` 先看 uploads/),
但**只读不写回** ⇒ OSS 上那份永远要远程取。
⇒ 远程取到后**回写一份到 uploads/**,下次直接命中本地文件(0 次远程调用)。
—— 只针对**图片**(封面/头像这类反复取的),且带容量上限,
不让缓存无限长大把磁盘吃满。
⚠️ 顺序不能反:先放锁、再取内容(取内容可能要几百毫秒)。 */
$isImageKey = (bool)preg_match('#^avatars/|^bookcovers/|^posters/|^photos/|^tickets/#', $key);
if ($isImageKey) {
// 图片是"只读、可重复取、可静态化"的,两次都用不到会话写权限
if (session_status() === PHP_SESSION_ACTIVE) {
session_write_close();
}
}
$res = Storage::read($familyId, $key);
$status = (int)($res['status'] ?? 0);
$body = (string)($res['body'] ?? '');
// 远程取到的图片回写本地(下一跳命中文件系统,省掉整个 OSS round-trip)
if ($isImageKey && !empty($res['ok']) && $status === 200
&& ($res['from'] ?? '') !== 'local' && $body !== '') {
Storage::cacheLocally($key, $body);
}
if (empty($res['ok']) || $status < 200 || $status >= 300) {
if ($status === 404 || $status === 0) {
media_fail(404, '没有这个文件');
@@ -135,6 +211,12 @@ $size = strlen($body);
$etag = '"' . md5($key . '|' . $size . '|' . $mime) . '"';
// ---- 5. 条件请求 ----
/* ⚠️ ETag 由"键 + 大小 + MIME"算出,所以**不等也要判它一次**:
内容没变时浏览器带回来的就是同一个 ETag,我们却仍然要先把整张图取回来
(拉到第 3 步才拿到 $body)—— 那一次远程取数就是纯浪费。
v47 的缓存回写(见第 3 步)让第二次开始命中的是**本地文件**,
所以这里"取回来再判 304"的代价已经降到本地读盘级别,够用了。
(真要连本地读都省掉,得把 ETag 单独持久化 —— 那是下一步的事。) */
$inm = trim((string)($_SERVER['HTTP_IF_NONE_MATCH'] ?? ''));
if ($inm !== '' && $inm === $etag) {
http_response_code(304);