Files
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

1133 lines
54 KiB
PHP
Raw Permalink 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
/**
* 微信读书笔记导入(v24 第 24 批 用户第 5 条)。
*
* 用户原话:
* 「读书这个模块应该加上读书笔记的功能。另外你看一下能不能导入微信的读书笔记,
* 就是每一个人设置一下,比如说扫一下微信的这个码呀,或者什么之类的授权一下。
* 这样的话,每个人都可以把自己的微信读书的笔记给导入进来,放在我们这个读书的
* 模块里面,看看以什么样的形式去进行展示,比较合适。」
*
* ────────────────────────────────────────────────────────────────
* 用户后续补充「用微信读书 api key」—— 这个方向是对的,而且**2026 年官方已经开了**:
*
* 官方开放平台(微信读书 Skill):
* · 网关:`POST https://i.weread.qq.com/api/agent/gateway`
* · 认证:`Authorization: Bearer wrk-xxxxxxxx`(**API Key 绑定账号身份**)
* · Key 获取:https://weread.qq.com/r/weread-skills(扫码登录后一键生成)
* · 请求体:`{api_name, ...业务参数平铺..., skill_version}`(**不能包在 params 里**)
*
* 本类用到的三个接口(都来自官方 `notes.md`):
* · `/user/notebooks` —— 有笔记的书清单(游标分页,用 lastSort 翻页)
* · `/book/bookmarklist` —— 单本书的**划线**(markText + chapterUid + createTime)
* · `/review/list/mine` —— 单本书的**想法/点评**(content + abstract 原文 + chapterName)
* · `/book/chapterinfo` —— 章节表(把 chapterUid 换成章节标题)
*
* ⚠️ API Key **等价于账号凭据**(官方原话:绑定用户身份,泄露等于账号泄露)。
* 所以:
* ① 一律 **Crypto::encrypt 加密后落库**,明文只在那一次表单提交里存在;
* ② 界面上只回显**掩码**(wrk-****abcd),绝不回显完整 Key;
* ③ 与 DAV 口令同一套思路(那个也是"长期凭据",也是加密/只显示一次)。
*
* ⚠️ 这个类**只负责取数据**,不碰数据库 —— 落库交给 BookNoteImport(已有)。
* 这样"网络"与"存储"分开,测试可以只测解析、不必连外网。
*/
declare(strict_types=1);
namespace App;
final class WeRead
{
/** 官方 Agent 网关(见文件头) */
public const GATEWAY = 'https://i.weread.qq.com/api/agent/gateway';
/** 官方 Skill 版本号(每次请求必须带;对不上官方可能拒) */
public const SKILL_VERSION = '1.0.4';
/** Key 的形式:wrk- 开头 */
public const KEY_RE = '/^wrk-[A-Za-z0-9_-]{8,80}$/';
/** 用户设置里存加密 Key 的键名 */
public const SETTING_KEY = 'weread_api_key';
/** 上次自动同步的时间戳(存秒;不是字符串,别再解析一次) */
public const SYNC_AT_KEY = 'weread_sync_at';
/** 自动同步总开关('1' 开 / '0' 关) */
public const AUTO_KEY = 'weread_auto_sync';
/**
* 自动同步的最小间隔(秒)——**默认 6 小时**。
*
* ⚠️ 为什么是 6 小时而不是"每天一次":
* 读书是随时发生的。用户下午读完一本,晚上打开家书希望"它已经是读完的"。
* 而 6 小时也足够克制 —— 一天最多 4 次 API 调用,不会触发频率限制。
*/
public const SYNC_TTL = 21600;
/**
* 一次同步的**总时长预算**(秒)。
*
* 🔴🔴 v28 用户第 9 条:「读书,同步微信读书后,感觉有时候点击进来很慢,
* 需要加载很长时间,后来就好了,过一段时间又慢。」
*
* 根因(实测推演,症状完全吻合):
* 自动同步挂在**读书列表页**上(见 index.php 的 books 路由),
* 而 `syncNow()` 最多扫 60 本书,**每一本**都要打 1~3 次网关
* (bookInfo / progress / notes,缺一座就补一次封面还要再下图片)。
* 单次 `call()` 的超时是 **20 秒**(见 call() 的 CURLOPT_TIMEOUT)——
* 于是"6 小时到期的那一次"最坏能挂几分钟:用户点进来就是白屏等待,
* 过后(时间戳已写、间隔未到)再进来只读一个时间戳,立刻就快了;
* 再过 6 小时又是那一下 —— 这正是用户描述的"时快时慢、过一阵又慢"。
*
* ⇒ 修法:给整轮同步一个**总预算**。到点就**停下来、正常返回**,
* 已处理的部分照常落库,剩下的等下一轮接着做(游标从头再来一遍也无妨:
* `matchLocalBook` 与 `store` 都是幂等的,"已存在的会跳过")。
* ⚠️ 为什么设 8 秒:这是"用户能忍的一次页面等待"的量级。
* 真实耗时取决于网关,宁少勿多 —— 慢一点同步完,比页面打不开好得多。
*/
public const SYNC_BUDGET = 8;
/** 每次同步最多扫几本(与预算双保险:预算防"慢",条数防"多") */
public const SYNC_MAX_BOOKS = 25;
/**
* 快照的**保鲜期**(秒)—— 默认 24 小时。
*
* 🔴 v29 用户第 4 条:「读书还是慢。是不是可以做一个同步,然后落盘,
* 这样就不用每次都读取微信读书的数据了。」
*
* ⇒ 落盘之后,"多久算旧"就成了新问题:
* · 太短(比如和 SYNC_TTL 一样 6 小时)→ 老是触发同步,等于没落盘;
* · 太长(比如一年)→ 用户读完了新书、打开还是旧数据,会觉得"同步坏了"。
* 选 24 小时:读书统计本来就不是实时数据(读了多少分钟是慢慢累积的),
* 一天内看到的是昨天的数字完全可接受;且每天最多触发一次拉取。
* ⚠️ 关键:**渲染路径永远不等这次拉取**(拿旧快照先渲染)。
* 过期只意味着"下次访问时顺手把它刷新一下",不意味着"这次要等"。
*/
public const SNAP_TTL = 86400;
// ================================================================
// Key 的存取(加密)
// ================================================================
/** 用户填了原始 Key → 校验形式后加密落库。返回 ['ok'=>bool,'error'=>string] */
public static function saveKey(int $personId, string $rawKey): array
{
$rawKey = trim($rawKey);
if ($rawKey === '') {
// 清空 = 断开授权(保留"他曾经授权过"这件事没有必要)
UserSetting::set($personId, self::SETTING_KEY, '');
return ['ok' => true, 'error' => ''];
}
if (!preg_match(self::KEY_RE, $rawKey)) {
return ['ok' => false, 'error' => 'API Key 的格式不对(应该是 wrk- 开头的一串)。'];
}
// ⚠️ 加密后再落库 —— 它等价于账号凭据(见文件头)
UserSetting::set($personId, self::SETTING_KEY, Crypto::encrypt($rawKey));
return ['ok' => true, 'error' => ''];
}
/** 取出明文 Key(仅供本类发请求用;**绝不要**把它回显到页面上) */
public static function key(int $personId): string
{
$blob = trim((string)UserSetting::get($personId, self::SETTING_KEY, ''));
if ($blob === '') {
return '';
}
try {
return (string)Crypto::decrypt($blob);
} catch (\Throwable $e) {
// 换过 db/.settings.key 之后老密文解不开 —— 当成"没配",让用户重填
return '';
}
}
/** 界面上回显用的掩码(wrk-****abcd)—— 绝不回显完整 Key */
public static function maskedKey(int $personId): string
{
$k = self::key($personId);
if ($k === '') {
return '';
}
return substr($k, 0, 4) . str_repeat('*', 8) . substr($k, -4);
}
public static function hasKey(int $personId): bool
{
return self::key($personId) !== '';
}
// ================================================================
// 调官方网关
// ================================================================
/**
* 调一次官方网关。
*
* @return array{ok:bool,data:array,error:string}
*/
public static function call(int $personId, string $apiName, array $params = []): array
{
$key = self::key($personId);
if ($key === '') {
return ['ok' => false, 'data' => [], 'error' => '还没配置微信读书 API Key。'];
}
// ⚠️ 业务参数必须**平铺在顶层**,不能包进 params(官方文档专门列了这个反例)
$body = array_merge(['api_name' => $apiName], $params, ['skill_version' => self::SKILL_VERSION]);
$json = json_encode($body, JSON_UNESCAPED_UNICODE);
$ch = curl_init(self::GATEWAY);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $json,
// 🔴 v28 用户第 9 条:单次调用超时从 20 秒收到 **8 秒**。
// 同步一轮要打好几次网关,20 秒 × N 次可以直接把页面拖垮;
// 而网关正常时是零点几秒就回的(见 nav_suggest.php 里那批实测:
// 同类接口 0.1~1.6 秒)。8 秒足以覆盖"网关偶发慢",又不会让
// 一次页面请求长时间挂着。慢的代价只是"这一轮少同步几本",
// 下一轮会接着做(幂等)。
CURLOPT_TIMEOUT => 8,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $key,
'Content-Type: application/json',
'Accept: application/json',
],
]);
$raw = curl_exec($ch);
$code = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$err = (string)curl_error($ch);
curl_close($ch);
if ($raw === false || $err !== '') {
return ['ok' => false, 'data' => [], 'error' => '连不上微信读书网关:' . $err];
}
if ($code === 401 || $code === 403) {
return ['ok' => false, 'data' => [],
'error' => 'API Key 无效或已失效,请去微信读书开放平台重新生成一个。'];
}
if ($code >= 400) {
return ['ok' => false, 'data' => [], 'error' => '网关返回 HTTP ' . $code];
}
$j = json_decode((string)$raw, true);
if (!is_array($j)) {
return ['ok' => false, 'data' => [], 'error' => '网关返回的不是 JSON'];
}
// 有些错误是 200 + 业务错误码
if (isset($j['errcode']) && (int)$j['errcode'] !== 0) {
return ['ok' => false, 'data' => [], 'error' => '微信读书返回错误:'
. (string)($j['errmsg'] ?? ('errcode ' . $j['errcode']))];
}
return ['ok' => true, 'data' => $j, 'error' => ''];
}
// ================================================================
// 取数据
// ================================================================
/**
* 有笔记的书清单(**游标分页**:官方不支持 offset/limit)。
*
* @param int $maxBooks 最多取多少本(防止有人几百本书时一次跑太久)
* @return array{ok:bool,books:array,error:string}
* books[] = ['bookId','title','author','reviewCount','noteCount','bookmarkCount']
*/
public static function notebooks(int $personId, int $maxBooks = 200): array
{
$out = [];
$last = 0;
for ($page = 0; $page < 20; $page++) {
$params = ['count' => 50];
if ($last > 0) {
$params['lastSort'] = $last;
}
$r = self::call($personId, '/user/notebooks', $params);
if (!$r['ok']) {
return ['ok' => false, 'books' => $out, 'error' => $r['error']];
}
$books = (array)($r['data']['books'] ?? []);
foreach ($books as $b) {
$bk = (array)($b['book'] ?? []);
$out[] = [
'bookId' => (string)($b['bookId'] ?? ''),
'title' => (string)($bk['title'] ?? ''),
'author' => (string)($bk['author'] ?? ''),
'reviewCount' => (int)($b['reviewCount'] ?? 0),
'noteCount' => (int)($b['noteCount'] ?? 0),
'bookmarkCount' => (int)($b['bookmarkCount'] ?? 0),
'sort' => (int)($b['sort'] ?? 0),
];
if (count($out) >= $maxBooks) {
break 2;
}
}
// hasMore=1 时用**本页最后一项的 sort** 当下一页游标
if ((int)($r['data']['hasMore'] ?? 0) !== 1 || !$books) {
break;
}
$lastIdx = count($books) - 1;
$last = (int)($books[$lastIdx]['sort'] ?? 0);
if ($last <= 0) {
break;
}
}
return ['ok' => true, 'books' => $out, 'error' => ''];
}
/**
* 取一本书的**章节表**(chapterUid → 章节标题)。
*
* 官方把章节信息分散在 bookmarklist / bestbookmarks 的回包里,
* 但单独拉一次 `/book/chapterinfo` 更省事(一次拿全)。
*/
public static function chapters(int $personId, string $bookId): array
{
$r = self::call($personId, '/book/chapterinfo', ['bookId' => $bookId]);
if (!$r['ok']) {
return [];
}
$map = [];
foreach ((array)($r['data']['chapters'] ?? []) as $c) {
$uid = (string)($c['chapterUid'] ?? '');
if ($uid !== '') {
$map[$uid] = (string)($c['title'] ?? '');
}
}
return $map;
}
/**
* 取一本书的**划线与想法**,合成统一结构。
*
* @return array{ok:bool,items:array,error:string}
* items[] = [
* 'kind' => 'quote'(划线)/ 'note'(划线想法)/ 'review'(整本或章节点评),
* 'content' => 想法正文(划线时为空),
* 'quote' => 划线原文(想法对应的原文,可能为空),
* 'chapter' => 章节名(可能为空),
* 'loc' => 位置范围(可能为空),
* 'at' => 'Y-m-d H:i:s',
* ]
*/
public static function notes(int $personId, string $bookId): array
{
$chap = self::chapters($personId, $bookId);
// ---- ① 划线(/book/bookmarklist)----
$r = self::call($personId, '/book/bookmarklist', ['bookId' => $bookId]);
if (!$r['ok']) {
return ['ok' => false, 'items' => [], 'error' => $r['error']];
}
// 这个接口自己也会带 chapters(省一次请求);优先用它补全章节名
foreach ((array)($r['data']['chapters'] ?? []) as $c) {
$uid = (string)($c['chapterUid'] ?? '');
if ($uid !== '' && !isset($chap[$uid])) {
$chap[$uid] = (string)($c['title'] ?? '');
}
}
$items = [];
foreach ((array)($r['data']['updated'] ?? []) as $u) {
$txt = trim((string)($u['markText'] ?? ''));
if ($txt === '') {
continue;
}
$items[] = [
'kind' => 'quote',
'content' => '',
'quote' => $txt,
'chapter' => $chap[(string)($u['chapterUid'] ?? '')] ?? '',
'loc' => (string)($u['range'] ?? ''),
'at' => self::ts((int)($u['createTime'] ?? 0)),
];
}
// ---- ② 想法 / 点评(/review/list/mine,**需翻页**)----
$synckey = 0;
for ($page = 0; $page < 20; $page++) {
$params = ['bookid' => $bookId, 'count' => 50];
if ($synckey > 0) {
$params['synckey'] = $synckey;
}
$rr = self::call($personId, '/review/list/mine', $params);
if (!$rr['ok']) {
// ⚠️ 想法取不到**不**让整次导入失败:划线已经拿到了,
// 为了"少一个想法"把整本书丢掉,是更糟的取舍。
break;
}
foreach ((array)($rr['data']['reviews'] ?? []) as $rv) {
$rvv = (array)($rv['review'] ?? []);
$content = trim((string)($rvv['content'] ?? ''));
$abstract = trim((string)($rvv['abstract'] ?? ''));
if ($content === '' && $abstract === '') {
continue;
}
// 有原文对应 = 划线想法(note);没有 = 整本/章节点评(review)
$items[] = [
'kind' => $abstract !== '' ? 'note' : 'review',
'content' => $content,
'quote' => $abstract,
'chapter' => trim((string)($rvv['chapterName'] ?? ''))
?: ($chap[(string)($rvv['chapterUid'] ?? '')] ?? ''),
'loc' => (string)($rvv['range'] ?? ''),
'at' => self::ts((int)($rvv['createTime'] ?? 0)),
];
}
if ((int)($rr['data']['hasMore'] ?? 0) !== 1) {
break;
}
$next = (int)($rr['data']['synckey'] ?? 0);
if ($next <= 0 || $next === $synckey) {
break;
}
$synckey = $next;
}
return ['ok' => true, 'items' => $items, 'error' => ''];
}
/**
* 取一本书的**基本信息**(v24 第 25 批 用户第 8 条:封面看不到)。
*
* 用户原话:「微信读书导入,需要能够做到定期完成,自动同步。现在可以导入了,
* 不过**封面图看不到**,另外也缺少一些对微信读书的统计功能,需要加强。」
*
* 根因:导入时只建了 title + author + status,**从来没写过 cover_key** ——
* 而官方 ` /book/info ` 明明给了 `cover`(封面 URL)、`isbn`、`publisher`、
* `category`、`intro`、`newRating`、`wordCount`。
* 所以书架里那一排全是「📖 空封面」。这里把该拿的一次拿齐。
*
* @return array{ok:bool,book:array,error:string}
*/
public static function bookInfo(int $personId, string $bookId): array
{
$r = self::call($personId, '/book/info', ['bookId' => $bookId]);
if (!$r['ok']) {
return ['ok' => false, 'book' => [], 'error' => $r['error']];
}
$d = (array)$r['data'];
return ['ok' => true, 'error' => '', 'book' => [
'title' => (string)($d['title'] ?? ''),
'author' => (string)($d['author'] ?? ''),
'translator' => (string)($d['translator'] ?? ''),
'cover' => (string)($d['cover'] ?? ''),
'intro' => (string)($d['intro'] ?? ''),
'category' => (string)($d['category'] ?? ''),
'publisher' => (string)($d['publisher'] ?? ''),
'published' => (string)($d['publishTime'] ?? ''),
'isbn' => (string)($d['isbn'] ?? ''),
'wordCount' => (int)($d['wordCount'] ?? 0),
'rating' => (int)round((int)($d['newRating'] ?? 0) / 10), // 官方是百分制
]];
}
/**
* 阅读进度(`/book/getprogress`)—— 用来判断"这本书读完了没"。
*
* ⚠️ 官方文档专门强调:`progress` 是 **0~100 的整数**,而且
* **只有 100 才代表读完**(1 表示 1%,不是 100%)。
* 这一条极易写错(把 1 当"已读"),所以在这里就把判据封装好。
*
* @return array{ok:bool,progress:int,finished:bool,done_at:string,seconds:int,error:string}
*/
public static function progress(int $personId, string $bookId): array
{
$r = self::call($personId, '/book/getprogress', ['bookId' => $bookId]);
if (!$r['ok']) {
return ['ok' => false, 'progress' => 0, 'finished' => false,
'done_at' => '', 'seconds' => 0, 'error' => $r['error']];
}
$b = (array)($r['data']['book'] ?? []);
$p = max(0, min(100, (int)($b['progress'] ?? 0)));
$fin = $p >= 100;
return [
'ok' => true,
'error' => '',
'progress' => $p,
'finished' => $fin,
'done_at' => $fin ? self::ts((int)($b['finishTime'] ?? 0)) : '',
'seconds' => (int)($b['recordReadingTime'] ?? 0),
];
}
/**
* 阅读统计(`/readdata/detail`)—— 用户第 8 条要的"统计功能加强"。
*
* 官方给的东西比本项目原来的统计丰富得多,这里只挑**能直接用**的几样:
* · `totalReadTime` —— 该周期总时长(**秒**,官方文档反复强调别当分钟)
* · `readDays` —— 有效阅读天数(官方口径:单日满 1 分钟)
* · `readStat` —— 「读过 N 本 / 读完 N 本 / 阅读 N 天 / 笔记 N 条」
* · `readLongest` —— 读得最多的书(含封面)
* · `preferCategory` —— 偏好分类(带时长)
* · `preferAuthor` —— 偏好作者
*
* ⚠️ 官方只支持**固定自然周期**(weekly/monthly/annually/overall),
* 不支持任意起止日期。默认给 `overall`("一共读了多久"最常被问)。
*
* @param string $mode weekly / monthly / annually / overall
* @return array{ok:bool,data:array,error:string}
*/
public static function readStats(int $personId, string $mode = 'overall', int $baseTime = 0): array
{
if (!in_array($mode, ['weekly', 'monthly', 'annually', 'overall'], true)) {
$mode = 'overall';
}
$params = ['mode' => $mode];
if ($baseTime > 0) {
$params['baseTime'] = $baseTime;
}
$r = self::call($personId, '/readdata/detail', $params);
if (!$r['ok']) {
return ['ok' => false, 'data' => [], 'error' => $r['error']];
}
$d = (array)$r['data'];
// 读得最多的书(最多 10 条,官方已按 readTime 降序、且过滤掉不足 5 分钟的)
$top = [];
foreach ((array)($d['readLongest'] ?? []) as $it) {
$bk = (array)($it['book'] ?? []);
$top[] = [
'title' => (string)($bk['title'] ?? ''),
'author' => (string)($bk['author'] ?? ''),
'cover' => (string)($bk['cover'] ?? ''),
'bookId' => (string)($bk['bookId'] ?? ''),
'seconds' => (int)($it['readTime'] ?? 0),
'tags' => array_map('strval', (array)($it['tags'] ?? [])),
];
}
// 偏好分类(带时长与本数)
$cats = [];
foreach ((array)($d['preferCategory'] ?? []) as $c) {
$cats[] = [
'name' => (string)($c['categoryTitle'] ?? ''),
'seconds' => (int)($c['readingTime'] ?? 0),
'books' => (int)($c['readingCount'] ?? 0),
'val' => (int)($c['val'] ?? 0),
];
}
// 偏好作者(注意:官方的 readTime 这里是**格式化字符串**,不是秒)
$authors = [];
foreach ((array)($d['preferAuthor'] ?? []) as $a) {
$authors[] = [
'name' => (string)($a['name'] ?? ''),
'books' => (int)($a['count'] ?? 0),
'timeWord' => (string)($a['readTime'] ?? ''),
];
}
// 摘要那几行(读过/读完/阅读/笔记)
$stat = [];
foreach ((array)($d['readStat'] ?? []) as $s) {
$stat[] = ['name' => (string)($s['stat'] ?? ''), 'count' => (string)($s['counts'] ?? '')];
}
return ['ok' => true, 'error' => '', 'data' => [
'mode' => $mode,
'seconds' => (int)($d['totalReadTime'] ?? 0),
'days' => (int)($d['readDays'] ?? 0),
'avgSec' => (int)($d['dayAverageReadTime'] ?? 0),
'compare' => $d['compare'] ?? null,
'stat' => $stat,
'topBooks' => $top,
'categories'=> $cats,
'authors' => $authors,
'preferWord'=> (string)($d['preferCategoryWord'] ?? ''),
'preferTime'=> (string)($d['preferTimeWord'] ?? ''),
'yearReport'=> (array)($d['yearReport'] ?? []),
]];
}
/**
* 把一张封面图**拉下来存进本站存储**(用户第 8 条:封面看不到)。
*
* ⚠️ 为什么不直接把官方的 `cover` URL 存进 `books.cover_key`:
* ① 那个 URL 是**第三方 CDN**(微信读书的),可能带防盗链/时效;
* 直接把外链塞进 cover_key,展示时要么不显示、要么哪天忽然全变破图;
* ② 本站所有附件都走 `media.php` 代理(登录态 + 存储驱动),
* 混一个外链进去就破了"所有媒体一个出口"这条规矩;
* ③ 用户可能清理缓存/换账号,外链失效后书架就一排空封面。
* ⇒ 拉下来走**与用户手传封面完全同一条路**(`Upload::image`,
* slot = `bookcover`),这样它不仅存好了,还顺带做了 2:3 居中裁剪 ——
* 与用户自己传的封面在页面上长得**一模一样**(否则两种来源两种比例,
* 书架格子会被撑得参差不齐)。
*
* @return string 存好的 cover_key(失败返回 '')
*/
public static function fetchCover(int $familyId, string $url, string $title): string
{
$url = trim($url);
if ($url === '' || !preg_match('#^https?://#i', $url)) {
return '';
}
$binary = self::httpBinary($url, 4 * 1024 * 1024, 12);
if ($binary === null || strlen($binary) < 64) {
return '';
}
$tmp = tempnam(sys_get_temp_dir(), 'fbcv');
if ($tmp === false) {
return '';
}
if (@file_put_contents($tmp, $binary) === false) {
@unlink($tmp);
return '';
}
// 伪装成 $_FILES 的形态交给 Upload::image(它只认 tmp_name/error/name/type)
$fake = [
'name' => self::safeBase($title) . '.jpg',
'type' => 'image/jpeg',
'tmp_name' => $tmp,
'error' => UPLOAD_ERR_OK,
'size' => strlen($binary),
];
$up = \App\Upload::image($familyId, $fake, 'bookcover', '');
@unlink($tmp);
return !empty($up['ok']) ? (string)$up['file'] : '';
}
/**
* 标题 → 可做文件名的安全基名(去掉路径符号,限长) */
private static function safeBase(string $s): string
{
$s = preg_replace('#[\\\\/:*?"<>|\x00-\x1f]#u', '', $s) ?? '';
$s = trim(preg_replace('/\s+/u', '_', $s) ?? '');
return $s === '' ? 'cover' : mb_substr($s, 0, 60, 'UTF-8');
}
// ================================================================
// v29 用户第 4 条:**本地快照优先**的取数入口
// ================================================================
//
// 规矩(只此一条,别处照做):
// · **渲染路径**一律调 `*Cached()`,它永不联网;
// · **同步路径**(手动「立即同步」/ 6 小时到期)才调真正的 `notebooks()`/`readStats()`,
// 拿到结果后用 `snapNotebooks()` / `snapStats()` 落盘。
//
// ⚠️ 缓存里**没有**(第一次用、刚换 Key)时怎么办:
// 这时页面没有任何数据可显示,只能真去打一次 —— 但那是"首次",不是常态。
// 之后的每一次访问都命中本地,这就是用户要的"落盘,不用每次去读"。
/**
* 有笔记的书清单(**优先读本地快照**)。
*
* @param bool $allowFetch 本地没有时,是否允许真的去联网取(默认允许)
* @return array{ok:bool,books:array,error:string,cached:bool,updated_at:int}
*/
public static function notebooksCached(int $personId, bool $allowFetch = true): array
{
$snap = WeReadCache::get($personId, WeReadCache::K_NOTEBOOKS);
if ($snap['ok'] && is_array($snap['data'])) {
return ['ok' => true, 'books' => (array)($snap['data']['books'] ?? []),
'error' => '', 'cached' => true, 'updated_at' => (int)$snap['updated_at']];
}
if (!$allowFetch) {
return ['ok' => true, 'books' => [], 'error' => '', 'cached' => true, 'updated_at' => 0];
}
$r = self::notebooks($personId);
if ($r['ok']) {
self::snapNotebooks($personId, (array)$r['books']);
}
return ['ok' => (bool)$r['ok'], 'books' => (array)($r['books'] ?? []),
'error' => (string)($r['error'] ?? ''), 'cached' => false, 'updated_at' => time()];
}
/** 把书清单写进快照 */
public static function snapNotebooks(int $personId, array $books): void
{
WeReadCache::put($personId, WeReadCache::K_NOTEBOOKS, ['books' => $books]);
}
/** 阅读统计(**优先读本地快照**,按 mode 各存一份) */
public static function readStatsCached(int $personId, string $mode = 'overall',
bool $allowFetch = true): array
{
if (!in_array($mode, ['weekly', 'monthly', 'annually', 'overall'], true)) {
$mode = 'overall';
}
$key = WeReadCache::statsKey($mode);
$snap = WeReadCache::get($personId, $key);
if ($snap['ok'] && is_array($snap['data'])) {
return ['ok' => true, 'data' => (array)$snap['data'], 'error' => '',
'cached' => true, 'updated_at' => (int)$snap['updated_at']];
}
if (!$allowFetch) {
return ['ok' => true, 'data' => [], 'error' => '', 'cached' => true, 'updated_at' => 0];
}
$r = self::readStats($personId, $mode);
if ($r['ok']) {
self::snapStats($personId, $mode, (array)$r['data']);
}
return ['ok' => (bool)$r['ok'], 'data' => (array)($r['data'] ?? []),
'error' => (string)($r['error'] ?? ''), 'cached' => false, 'updated_at' => time()];
}
/** 把统计写进快照 */
public static function snapStats(int $personId, string $mode, array $data): void
{
WeReadCache::put($personId, WeReadCache::statsKey($mode), $data);
}
/** 快照的"数据更新于"时间(页面上用;0 = 从没同步过) */
public static function snapshotAt(int $personId): int
{
return WeReadCache::updatedAt($personId, WeReadCache::K_NOTEBOOKS);
}
/** @var array<int,bool> 本次请求已经排过后台刷新的(避免重复注册 shutdown 钩子) */
private static array $bgQueued = [];
/**
* 排一次"**响应发完之后**再去同步"。
*
* 🔴 v29 用户第 4 条的另一半:光把数据落盘还不够 ——
* 原来那段自动同步挂在**渲染之前**(`maybeSync` 在 `View::render` 上面),
* 所以"6 小时到期的那一次"仍然要等它跑完才出页面。
* 落盘解决了"每次都要去读",但没解决"那一 次要等"。
* ⇒ 照 `Holiday::queueBackgroundFetch()` 的成熟做法:
* `fastcgi_finish_request()` 把响应**立刻发给浏览器**,
* 之后脚本继续跑同步、写快照,下次访问自然就是新的。
*
* ⚠️ php-fpm 才有 `fastcgi_finish_request`;`php -S`(本地)没有,
* 那就是"同步仍在请求内发生" —— 与改造前一致,不会更差。
* ⚠️ 用 `register_shutdown_function` 而不是在这里直接调:
* 必须等**页面已经渲染完**(输出都发出去)才切断,否则会把页面截断。
* ⚠️ 同一个请求里只排一次(`$bgQueued`):读书页会连着调好几次取数。
*
* 🔴🔴 v47(用户 2026-10-13:「读书现在还是非常慢,进入这个页面刚开始非常慢」)
* —— **必须把会话锁也放掉,否则"后台化"是假的。**
*
* 现场实测(线上,真公网,同一会话连打 3 次):
* · 第 1 次 TTFB 0.08s ← 触发同步
* · 第 2 次 TTFB **8.73s** ← 就是用户说的"刚开始非常慢"
* · 第 3 次 TTFB 0.07s
* 服务端时间线(插桩取得)把因果钉死了:
* 21:18:05.48 QUEUED ← 第 1 个请求排队
* 21:18:05.50 FINISH 2ms ← 响应确实提前发出去了(这步是好的)
* 21:18:14.67 SYNC 9.2s ← 但它仍**占着这个人的会话文件锁**跑完 9.2 秒
* 21:18:14.68 QUEUED ← 第 2 个请求此刻才真正开始执行
* ⇒ PHP 的文件会话是**独占**的:`fastcgi_finish_request()` 只断开"客户端连接",
* **不会释放会话锁**。同一个人的下一个请求(刷新 / 点进读书 / 点别的页面)
* 会一路等到后台同步跑完 —— 于是"页面秒回、但下一个页面卡 9 秒",
* 用户感觉就是"进读书页非常慢"。
*
* ⚠️ 必须**两处**都放锁:
* ① 关掉的这里(在 `fastcgi_finish_request()` **之前**)——
* 覆盖"本次是排同步的那个请求"。
* ② 页面路由里、渲染**之前**(见 index.php 的 books 路由)——
* 因为后端的 shutdown 回调**不一定真的执行**(用户中断 / 服务器超时),
* 那时锁会一直挂到请求结束;提前放掉最稳妥。
* 放锁前要用的会话数据(familyId / personId)都在上方作为**参数**传进来了,
* 此后不再读 `$_SESSION`,所以放锁是安全的。
* ⚠️ 读操作不受影响:放锁只影响"写",而这里之后只做 HTTP + 写数据库。
*/
public static function queueBackgroundSync(int $familyId, int $personId): void
{
if ($personId <= 0 || $familyId <= 0) {
return;
}
if (isset(self::$bgQueued[$personId])) {
return;
}
self::$bgQueued[$personId] = true;
register_shutdown_function(static function () use ($familyId, $personId): void {
// 🔴 v47:先放会话锁,再断连接 —— 顺序反了就没意义
//(`fastcgi_finish_request()` 会让 `session_write_close()` 变成 no-op 的时机
// 更早,所以锁必须在它之前放)。
if (session_status() === PHP_SESSION_ACTIVE) {
session_write_close();
}
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request();
}
try {
// 走到这里时"间隔到没到"要重新判一次(queue 时不判、执行时判):
// 期间可能已经有别的请求同步过了,那就别再打一次接口。
self::maybeSync($familyId, $personId, false);
} catch (\Throwable $e) {
// 后台任务绝不能把响应打挂(此时响应早已发完,报错只会进日志)
}
});
}
/** 抓一段二进制(3 秒超时不够,封面有时几 MB;失败返回 null,绝不抛) */
private static function httpBinary(string $url, int $maxBytes = 3145728, int $timeout = 10): ?string
{
if (!function_exists('curl_init')) {
$ctx = stream_context_create(['http' => ['timeout' => $timeout, 'ignore_errors' => true]]);
$r = @file_get_contents($url, false, $ctx, 0, $maxBytes);
return $r === false ? null : (string)$r;
}
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 3,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_USERAGENT => 'FamilyBook/1.0',
// ⚠️ 微信读书的 CDN 可能挑 Referer —— 给一个自己站点的 Referer
CURLOPT_HTTPHEADER => ['Referer: https://weread.qq.com/'],
]);
$r = curl_exec($ch);
$code = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($r === false || $code < 200 || $code >= 300) {
return null;
}
return strlen((string)$r) > $maxBytes ? substr((string)$r, 0, $maxBytes) : (string)$r;
}
/** Unix 时间戳 → 'Y-m-d H:i:s'(0 或非法时给当前时间,别让库里出现 1970) */
private static function ts(int $t): string
{
if ($t <= 0) {
return date('Y-m-d H:i:s');
}
// 官方有些字段给的是**毫秒**
if ($t > 99999999999) {
$t = (int)($t / 1000);
}
return date('Y-m-d H:i:s', $t);
}
/**
* 把这批 items 落库到某本书(**跳过完全重复的**)。
*
* ⚠️ 去重判据 = (kind + 内容 + 位置)三者相同。为什么不只看内容:
* 同一段原文被划两次(不同时间)在微信读书里是两条,内容是同一个字符串 ——
* 只看内容会误删;而位置(range)不同,正好能把它们区分开。
*
* @return array{added:int,skipped:int}
*/
public static function store(int $bookId, int $personId, array $items): array
{
$db = Database::get();
$find = $db->prepare(
'SELECT id FROM book_notes WHERE book_id = ? AND kind = ? AND content = ? AND COALESCE(loc, \'\') = ?'
);
$ins = $db->prepare(
'INSERT INTO book_notes (book_id, person_id, kind, chapter, loc, content, source, created_at)
VALUES (?,?,?,?,?,?,?,?)'
);
$added = $skipped = 0;
foreach ($items as $it) {
$kind = (string)($it['kind'] ?? 'quote');
if (!in_array($kind, ['quote', 'note', 'review'], true)) {
$kind = 'quote';
}
// 划线:内容就是原文;想法/点评:内容优先用想法正文,没有就用原文
$content = trim((string)($it['content'] ?? ''));
$quote = trim((string)($it['quote'] ?? ''));
if ($kind === 'quote') {
$content = $quote;
} elseif ($content === '') {
$content = $quote;
}
if ($content === '') {
continue;
}
$loc = trim((string)($it['loc'] ?? ''));
$find->execute([$bookId, $kind, $content, $loc]);
if ($find->fetchColumn()) {
$skipped++;
continue;
}
$ins->execute([
$bookId, $personId > 0 ? $personId : null, $kind,
trim((string)($it['chapter'] ?? '')), $loc, $content,
'weread', (string)($it['at'] ?? date('Y-m-d H:i:s')),
]);
$added++;
}
return ['added' => $added, 'skipped' => $skipped];
}
/**
* 按书名找本地的书(**用已有的 Book 表**)。
*
* ⚠️ 只做"去空白后完全相等"的匹配,**不做模糊匹配**:
* 书名相近的两本(《活着》与《活着(余华自选集)》)被合并成一本,
* 用户是看不出来的 —— 而"笔记挂错了书"比"多建一本书"难发现得多。
* 匹配不上时由调用方决定:默认建新书(让用户自己合并)。
*/
public static function matchLocalBook(int $familyId, string $title): int
{
$t = trim($title);
if ($t === '') {
return 0;
}
$st = Database::get()->prepare(
'SELECT id FROM books WHERE family_id = ? AND TRIM(title) = ? ORDER BY id LIMIT 1');
$st->execute([$familyId, $t]);
return (int)($st->fetchColumn() ?: 0);
}
// ================================================================
// 定期自动同步(v24 第 25 批 用户第 8 条)
// ================================================================
/**
* 用户原话:「微信读书导入,需要能够做到定期完成,自动同步。」
*
* ────────────────────────────────────────────────────────────────
* **为什么不用系统 cron**(这是一个刻意的取舍,写清楚免得下一个人来加 crontab)
*
* 本项目其它"定期"的事都是**访问时顺手做**(见 StagedUpload::sweep 的注释:
* "在 business 路由里顺手调一次,不必挂定时任务")。理由:
* ① 这是**家用**系统,可能几周没人访问。挂个 cron 每天凌晨去调微信读书 API,
* 在没人用的时候白烧请求、还可能触发官方的频率限制;
* ② cron 要配服务器(宝塔面板 + 路径 + PHP 版本),用户自己维护不了,
* 而"配错了"是静默的(任务不跑,谁也不知道);
* ③ 访问时同步的**体验完全等价**:用户打开读书页 → 数据是新的。
* 他不在的时候,同步不同步没有任何人能察觉。
*
* ⚠️ 所以判据是「**距上次同步过了多久**」而不是"今天跑过没":
* 间隔到期才真的去打接口(默认 6 小时,见 SYNC_TTL),
* 否则只读一个时间戳。这样"每天开十次书页"也只会同步两次。
*
* ⚠️ 失败**不重试、不阻塞**:网络不通/Key 失效时静默记一下时间(下次再试),
* 绝不让"同步失败"把读书页也打不开 —— 外部依赖不能拖死主流程。
*
* @param bool $force true = 忽略间隔,立刻同步(用户点「立即同步」时用)
* 🔴 v28 用户第 9 条:$force 时给**更大的扫描量**(用户是
* 主动点的,愿意等;而且手动同步本来就该一次做完整)。
* 页面上的自动同步则用小量 —— 它挂在读书列表页上,
* 每多扫一本就多一次"打开页面就等"。
* @return array{ok:bool,ran:bool,error:string,books:int,notes:int,covers:int,at:string}
*/
public static function maybeSync(int $familyId, int $personId, bool $force = false): array
{
$empty = ['ok' => true, 'ran' => false, 'error' => '', 'books' => 0,
'notes' => 0, 'covers' => 0, 'at' => self::lastSync($personId)];
if (!self::hasKey($personId) || $familyId <= 0 || $personId <= 0) {
return $empty;
}
$last = (int)UserSetting::get($personId, self::SYNC_AT_KEY, '0');
if (!$force && $last > 0 && (time() - $last) < self::SYNC_TTL) {
return $empty;
}
// ⚠️ 先记时间再干活:万一下面某一轮超时/抛错,也不会在同一个页面
// 反复重试("每次打开读书页都卡 20 秒"是最糟的症状)。
// 🔴 v28:现在同步**有总预算**(SYNC_BUDGET),不会再长时间卡住;
// 这条"先记时间"依然要留 —— 它是"网关整个挂掉时别每次访问都重试"的兜底。
UserSetting::set($personId, self::SYNC_AT_KEY, (string)time());
try {
// 自动同步只扫一小批(页面等待),手动同步扫完整批(用户愿意等)
$limit = $force ? 60 : self::SYNC_MAX_BOOKS;
$res = self::syncNow($familyId, $personId, $limit);
} catch (\Throwable $e) {
return ['ok' => false, 'ran' => true, 'error' => $e->getMessage(),
'books' => 0, 'notes' => 0, 'covers' => 0, 'at' => date('Y-m-d H:i:s')];
}
return [
'ok' => true, 'ran' => true, 'error' => (string)($res['error'] ?? ''),
'books' => (int)($res['books'] ?? 0), 'notes' => (int)($res['notes'] ?? 0),
'covers' => (int)($res['covers'] ?? 0), 'at' => date('Y-m-d H:i:s'),
];
}
/**
* 真的同步一遍:把有笔记的书补进书架、补封面、补进度、补笔记。
*
* 「补」而不是"重建":已经在书架里的书**不覆盖用户改过的东西**
* (用户可能改了分类、打了分、改了状态)—— 只补**空着的**那几个字段。
* ⚠️ 这是"自动同步"能不能长期开着的关键:只要有一次把用户手动填的
* 评分/分类冲掉,他就再也不敢让它自动跑了。
*
* @return array{ok:bool,error:string,books:int,notes:int,covers:int,scanned:int}
*/
public static function syncNow(int $familyId, int $personId, int $maxBooks = 60): array
{
/* 🔴 v28 用户第 9 条:**整轮的总时长预算**(见 SYNC_BUDGET 的注释)。
* 到点就停、正常返回已处理的部分,剩下的下一轮继续。
* ⚠️ 用"开始时刻 + 每轮循环都检查"而不是"包一个超时异常":
* PHP 的 curl 超时是按**单次调用**算的,没有"整个函数超时"这种机制;
* 要在中途刹车,只能在循环里自己判。
* ⚠️ 判据放在**每本书的开头**(不是每本书之间某处):一本书内部还会打
* 2~3 次网关,若在书中途判断就会留下半本书(字段补一半)。
* 整本为单位地停,重来时那本书会被幂等地重做一遍。 */
$deadline = microtime(true) + self::SYNC_BUDGET;
$nb = self::notebooks($personId);
if (!$nb['ok']) {
return ['ok' => false, 'error' => (string)$nb['error'], 'books' => 0,
'notes' => 0, 'covers' => 0, 'scanned' => 0];
}
$newBooks = $newNotes = $newCovers = 0;
$scanned = 0;
foreach ($nb['books'] as $b) {
if ($scanned >= $maxBooks) {
break;
}
// 总预算到点 → 停在这里(已处理的都已落库;剩下的下一轮再做)
if (microtime(true) >= $deadline) {
break;
}
$title = trim((string)$b['title']);
if ($title === '') {
continue;
}
$scanned++;
$bid = self::matchLocalBook($familyId, $title);
// ---- ① 书不在书架 → 建一本(顺便把该拿的元数据一次拿齐)----
if ($bid <= 0) {
$info = self::bookInfo($personId, (string)$b['bookId']);
$meta = $info['ok'] ? $info['book'] : [];
$coverKey = '';
if (!empty($meta['cover'])) {
$coverKey = self::fetchCover($familyId, (string)$meta["cover"], $title);
if ($coverKey !== '') { $newCovers++; }
}
$bid = Book::create($familyId, $personId, [
'title' => $title,
'author' => (string)($meta['author'] ?? $b['author']),
'translator' => (string)($meta['translator'] ?? ''),
'publisher' => (string)($meta['publisher'] ?? ''),
'published' => (string)($meta['published'] ?? ''),
'isbn' => (string)($meta['isbn'] ?? ''),
'category' => (string)($meta['category'] ?? ''),
'summary' => (string)($meta['intro'] ?? ''),
'words' => (int)($meta['wordCount'] ?? 0),
'rating' => (int)($meta['rating'] ?? 0) ?: null,
'cover_key' => $coverKey,
'status' => 'reading',
]);
if ($bid > 0) {
$newBooks++;
}
} else {
// ---- ② 已在书架 → **只补空的**(绝不覆盖用户填过的)----
$row = Book::find($familyId, $bid);
if ($row) {
$patch = [];
// 封面空着就补(用户第 8 条:封面看不到)
if (trim((string)($row['cover_key'] ?? '')) === '') {
$info = self::bookInfo($personId, (string)$b['bookId']);
if ($info['ok'] && !empty($info['book']['cover'])) {
$ck = self::fetchCover($familyId,
(string)$info['book']['cover'], $title);
if ($ck !== '') { $patch['cover_key'] = $ck; $newCovers++; }
}
}
// 简介/ISBN/出版社/字数空着就补
$needMore = trim((string)($row['summary'] ?? '')) === ''
|| trim((string)($row['publisher'] ?? '')) === ''
|| empty($row['words']);
if ($needMore) {
$info = self::bookInfo($personId, (string)$b['bookId']);
if ($info['ok']) {
$m = $info['book'];
if (trim((string)($row['summary'] ?? '')) === '' && $m['intro'] !== '') {
$patch['summary'] = (string)$m['intro'];
}
if (trim((string)($row['publisher'] ?? '')) === '' && $m['publisher'] !== '') {
$patch['publisher'] = (string)$m['publisher'];
}
if (trim((string)($row['isbn'] ?? '')) === '' && $m['isbn'] !== '') {
$patch['isbn'] = (string)$m['isbn'];
}
if (empty($row['words']) && (int)$m['wordCount'] > 0) {
$patch['words'] = (int)$m['wordCount'];
}
}
}
if ($patch) {
self::patchBook($familyId, $bid, $patch);
}
}
}
if ($bid <= 0) {
continue;
}
// ---- ③ 进度:读完了就把本地状态也标成读完(用户第 8 条"统计"要它)----
$pg = self::progress($personId, (string)$b['bookId']);
if ($pg['ok'] && $pg['finished']) {
$cur = Book::find($familyId, $bid);
if ($cur && (string)$cur['status'] !== 'done') {
Book::setStatus($familyId, $bid, 'done');
}
}
// ---- ④ 笔记(已有的会自动跳过,见 store 的去重判据)----
$nt = self::notes($personId, (string)$b['bookId']);
if ($nt['ok']) {
$r = self::store($bid, $personId, (array)$nt['items']);
$newNotes += (int)$r['added'];
}
}
/* 🔴 v29 用户第 4 条:同步完把**书清单与统计**一起落盘。
这一步是"落盘"方案的核心收益所在 —— 之后页面渲染只读这张快照,
不再每次去问官方要(用户原话:「不用每次都读取微信读书的数据了」)。
⚠️ 放在这里(同步的末尾)而不是"每次保存某本书时":
书清单是一份**整体**,逐本保存会让"某几次成功、某一次失败"
留下一份拼凑的清单;这里存的是"这一轮真实扫到的完整清单"。
⚠️ 即使本轮因预算中途停下,也要落盘:清单本身就是 notebooks() 的
原始返回(完整、未被裁剪),"扫了几本"是另一件事(那是书架的补全进度)。
⚠️ 统计与清单都**只在自己成功时**才覆盖:拉失败就保留旧快照,
宁可是旧数据,也不要把页面变成空的。 */
if (!empty($nb['books'])) {
self::snapNotebooks($personId, (array)$nb['books']);
}
$st = self::readStats($personId, 'overall');
if (!empty($st['ok'])) {
self::snapStats($personId, 'overall', (array)$st['data']);
}
return ['ok' => true, 'error' => '', 'books' => $newBooks, 'notes' => $newNotes,
'covers' => $newCovers, 'scanned' => $scanned];
}
/** 只更新**指定几列**(自动同步补空专用;不走 Book::update —— 那会把其它列一起写) */
public static function patchBook(int $familyId, int $bookId, array $fields): void
{
$allow = ['cover_key', 'summary', 'publisher', 'isbn', 'words', 'translator',
'published', 'category', 'rating', 'author'];
$sets = [];
$args = [];
foreach ($fields as $k => $v) {
if (!in_array($k, $allow, true)) {
continue;
}
$sets[] = "`{$k}` = ?";
$args[] = $v === '' ? null : $v;
}
if (!$sets) {
return;
}
$args[] = $bookId;
$args[] = $familyId;
Database::get()->prepare(
'UPDATE books SET ' . implode(', ', $sets) . ' WHERE id = ? AND family_id = ?'
)->execute($args);
}
/** 上次自动同步的时间('Y-m-d H:i:s';从没同步过返回空串) */
public static function lastSync(int $personId): string
{
$t = (int)UserSetting::get($personId, self::SYNC_AT_KEY, '0');
return $t > 0 ? date('Y-m-d H:i:s', $t) : '';
}
/** 下次该自动同步的时间('Y-m-d H:i:s';没配 Key 返回空串) */
public static function nextSync(int $personId): string
{
if (!self::hasKey($personId)) {
return '';
}
$t = (int)UserSetting::get($personId, self::SYNC_AT_KEY, '0');
return date('Y-m-d H:i:s', ($t > 0 ? $t : time()) + self::SYNC_TTL);
}
/** 是否打开了自动同步(默认开;用户可在导入页关掉) */
public static function autoSyncOn(int $personId): bool
{
return (string)UserSetting::get($personId, self::AUTO_KEY, '1') === '1';
}
public static function setAutoSync(int $personId, bool $on): void
{
UserSetting::set($personId, self::AUTO_KEY, $on ? '1' : '0');
}
}