Files
FamilyBook/app/Storage/Storage.php
T
laoyang 32e9e9ab84 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()` / 不删。
2026-10-14 01:20:40 +08:00

273 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 存储门面:全站**唯一**的附件读写出口。
*
* 三条纪律(照 MovieLog 的教训定,别绕过):
* ① 数据库只存「相对键」(如 avatars/ab12.jpg),绝不存完整 URL ——
* 换域名、换桶、换前缀都不用改历史数据。
* ② 页面绝不手拼路径,一律走 url()。
* ③ 云端失败**自动回退本地**,绝不因云配置问题丢用户的文件。
*/
declare(strict_types=1);
namespace App\Storage;
use App\Setting;
final class Storage
{
public const AVATAR_DIR = 'avatars';
/** OSS 默认配置模板 */
public static function ossDefaults(): array
{
return [
'auth_mode' => 'ak', // ak | ecs_role
'access_key_id' => '',
'access_key_secret' => '',
'bucket' => '',
'endpoint' => '', // 例:oss-cn-beijing.aliyuncs.com(不带 https://)
'prefix' => '', // 桶内前缀,一个桶放多个站点时用
'ram_role_name' => '', // 留空 = 自动取实例绑定的角色
'custom_domain' => '', // 可选,CDN / 自定义域名
'internal_endpoint' => '1', // 服务端读取优先走内网
'https' => '1',
];
}
// ---------------- 配置读写 ----------------
public static function driver(int $familyId): string
{
$d = (string)Setting::get($familyId, 'storage_driver', 'local');
return in_array($d, ['local', 'oss'], true) ? $d : 'local';
}
public static function config(int $familyId): array
{
$raw = json_decode((string)Setting::get($familyId, 'storage_config_oss', ''), true);
$saved = is_array($raw) ? $raw : [];
return array_merge(self::ossDefaults(), $saved);
}
public static function saveConfig(int $familyId, string $driver, array $oss): void
{
$driver = in_array($driver, ['local', 'oss'], true) ? $driver : 'local';
Setting::set($familyId, 'storage_driver', $driver);
Setting::set($familyId, 'storage_config_oss',
json_encode(array_merge(self::ossDefaults(), $oss), JSON_UNESCAPED_UNICODE));
EcsMeta::clearCache($familyId);
}
/** 配置是否填齐 —— 没填齐就强制退回本地,避免「传到云端、地址却按本地算」的错配 */
public static function configComplete(int $familyId): bool
{
if (self::driver($familyId) !== 'oss') {
return true;
}
$c = self::config($familyId);
$auth = ($c['auth_mode'] === 'ecs_role')
|| (trim((string)$c['access_key_id']) !== '' && trim((string)$c['access_key_secret']) !== '');
return $auth && trim((string)$c['bucket']) !== '' && trim((string)$c['endpoint']) !== '';
}
/** 实际生效的驱动(配置不全时是 local) */
public static function effectiveDriver(int $familyId): string
{
$want = self::driver($familyId);
if ($want !== 'local' && !self::configComplete($familyId)) {
return 'local';
}
return $want;
}
private static ?StorageDriver $instance = null;
private static ?int $instanceFor = null;
public static function disk(int $familyId): StorageDriver
{
$id = self::effectiveDriver($familyId);
if (self::$instance === null || self::$instanceFor !== $familyId || self::$instance->id() !== $id) {
self::$instance = $id === 'oss'
? new OssDriver($familyId, self::config($familyId))
: new LocalDriver(FB_UPLOADS);
self::$instanceFor = $familyId;
}
return self::$instance;
}
// ---------------- 读写 ----------------
/** 存一个上传的文件;云端失败自动回退本地 */
public static function putFile(int $familyId, string $srcPath, string $key, ?string $mime = null): array
{
$disk = self::disk($familyId);
$r = $disk->putFile($srcPath, $key, $mime);
if (!empty($r['ok'])) {
return $r;
}
if ($disk->id() !== 'local') {
$local = new LocalDriver(FB_UPLOADS);
$l = $local->putFile($srcPath, $key, $mime);
if (!empty($l['ok'])) {
return ['ok' => true, 'error' => '', 'note' => '云端上传失败,已自动存到本地:' . $r['error']];
}
}
return $r;
}
public static function putContents(int $familyId, string $binary, string $key, ?string $mime = null): array
{
$disk = self::disk($familyId);
$r = $disk->putContents($binary, $key, $mime);
if (!empty($r['ok'])) {
return $r;
}
if ($disk->id() !== 'local') {
$local = new LocalDriver(FB_UPLOADS);
$l = $local->putContents($binary, $key, $mime);
if (!empty($l['ok'])) {
return ['ok' => true, 'error' => '', 'note' => '云端上传失败,已自动存到本地:' . $r['error']];
}
}
return $r;
}
public static function delete(int $familyId, string $key): void
{
if ($key === '') {
return;
}
// 两个地方都试一遍:历史文件可能只在本地
self::disk($familyId)->delete($key);
if (self::effectiveDriver($familyId) !== 'local') {
(new LocalDriver(FB_UPLOADS))->delete($key);
}
}
/**
* 读取内容(代理、导出等场景)。
*
* 读取顺序与 exists() 一致,刻意设计成「本地优先」:
* 从本地搬到云端(或反过来)时,历史文件可能只在一边,
* 本地优先能保证迁移期的老文件照样打得开,也让回滚随时可行。
*/
public static function read(int $familyId, string $key): array
{
$local = FB_UPLOADS . '/' . ltrim($key, '/');
if ($key !== '' && is_file($local)) {
$body = (string)@file_get_contents($local);
return ['ok' => true, 'status' => 200, 'headers' => [], 'body' => $body, 'error' => '', 'from' => 'local'];
}
return self::disk($familyId)->get($key);
}
public static function exists(int $familyId, string $key): bool
{
if ($key === '') {
return false;
}
if (is_file(FB_UPLOADS . '/' . $key)) {
return true; // 本地优先(迁移期两者并存)
}
return self::disk($familyId)->exists($key);
}
/**
* 把刚取到的内容**回写一份到本地磁盘**,给下一次读用。
*
* 🔴 v47(用户 2026-10-13):「读书现在还是非常慢,这个问题需要彻底解决。」
* 实测:书架 35 张封面,每张 0.15~0.53s 去 OSS 取,**每次访问都重新取**。
* `read()` 本来就是"本地优先",但**只读不写** ⇒ OSS 上那份永远要远程拿。
* 这里补上"写回",于是第二次起直接命中本地文件(0 次远程调用)。
*
* ⚠️ 只给**图片**用(调用方判断)。理由:
* · 图片是"反复读、几乎不写、内容不变"的典型 —— 缓存收益最大;
* · 附件/原书文件可能是几十上百 MB,缓存它们会把磁盘吃满,
* 而且下载本来就是低频、一次性的事,缓存它没有意义。
*
* ⚠️ **绝不因为缓存失败而影响这次请求**:磁盘满、权限不对、路径怪,
* 一律静默跳过 —— 缓存是纯优化,不是功能。
*
* @return bool 真的写成功了吗(调用方一般不关心)
*/
public static function cacheLocally(string $key, string $binary): bool
{
$key = ltrim(str_replace('\\', '/', $key), '/');
if ($key === '' || $binary === '') {
return false;
}
// 与 media.php 同一条键净化判据:拒绝 `..`、绝对路径、点文件
if (strpos($key, "\0") !== false || strpos($key, '..') !== false
|| !preg_match('#^[A-Za-z0-9][A-Za-z0-9._/-]*$#', $key)) {
return false;
}
foreach (explode('/', $key) as $seg) {
if ($seg === '' || $seg === '.' || $seg[0] === '.') {
return false;
}
}
$dst = FB_UPLOADS . '/' . $key;
// 已经有了就不重复写(两个请求同时到也不会互相踩:内容一样)
if (is_file($dst) && filesize($dst) === strlen($binary)) {
return true;
}
$dir = dirname($dst);
if (!is_dir($dir)) {
if (!@mkdir($dir, 0775, true) && !is_dir($dir)) {
return false;
}
}
// 先写临时文件再 rename:避免"写了一半被另一个请求读到"(读到半张图)
$tmp = $dst . '.tmp' . getmypid();
if (@file_put_contents($tmp, $binary) === false) {
@unlink($tmp);
return false;
}
if (!@rename($tmp, $dst)) {
@unlink($tmp);
return false;
}
@chmod($dst, 0664);
return true;
}
/**
* **唯一出口**:把相对键变成可访问地址。
*
* ⚠️ 现在一律指向 `media.php` 代理,不再直接给 uploads/ 或 OSS 的地址:
* · 统一:本地与云端的差别被代理吃掉,前端只认一个地址形态;
* · 安全:桶可以设成私有、uploads/ 可以整个封死,能否看到附件由**登录态**决定,
* 而不是"知道文件名就能看";
* · 标准:Content-Type / 缓存 / 下载头由服务端统一给。
*
* 为什么保留这个方法:`media.php` 自己不调它(它用 read() 取字节),
* 所以不存在自递归;留在这里是为了让"谁想把键变成地址"永远只有一条路。
*/
public static function url(int $familyId, ?string $key): string
{
$key = trim((string)$key);
if ($key === '') {
return asset('/assets/avatar-default.svg');
}
if (preg_match('#^https?://#i', $key)) {
return $key;
}
return \media_url($key);
}
/**
* 直接拿「已保存的 OSS 配置」建一个 OSS 驱动来做连通性诊断。
*
* 与 disk() 的区别很重要:disk() 会按 effectiveDriver() 决定用哪个驱动,
* 当前选的是"本地"时就只会给你 LocalDriver —— 于是**用户没法在切到 OSS 之前
* 先把 OSS 测通**,只能"先切换、发现不行、再切回来"。这里绕开那个判断。
*/
public static function ossDiskForDiagnosis(int $familyId): ?OssDriver
{
$cfg = self::config($familyId);
$driver = new OssDriver($familyId, $cfg);
return $driver->validateConfig() === '' ? $driver : null;
}
}