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, CURLOPT_TIMEOUT => 20, CURLOPT_CONNECTTIMEOUT => 8, 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'); } /** 抓一段二进制(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 = 忽略间隔,立刻同步(用户点「立即同步」时用) * @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 秒里超时/抛错,也不会在同一个页面 // 反复重试("每次打开读书页都卡 20 秒"是最糟的症状)。 UserSetting::set($personId, self::SYNC_AT_KEY, (string)time()); try { $res = self::syncNow($familyId, $personId); } 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 { $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; } $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']; } } 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'); } }