v25 第 25 批(9 条):名册分组不刷页 / 纪念日多人 / 首页等高 / 移出不回流 /
名册点击看详情拖动才排序 / 任务复选框与状态 / 财务标签多选 / 微信读书同步与统计 /
导航线上数据与图标
用户本轮 9 条需求(原话见 templates/ 与 app/ 内的逐条注释):
1. 名册:列表里给某人分组改为 AJAX 就地挪卡片(不再整页刷新)。
2. 纪念日:关联人改**多选**(复用 _person_picker,字段 person_ids[]);
自建纪念日的按钮与其它模块统一(改=蓝 .btn、删=黄 .btn-warn);
列表新增「关联人」列并给人名前加头像。
3. 首页:日程/任务两块**完全等高**(align-items:stretch + 固定总高 + flex 子项
min-height:0);修「0 件待办」——数字与右侧列表同源(原来各算各的)。
4. 🔴 名册里被移出的人过一阵又回来:根因是 PersonRoster::seedFromPersons()
**每次 Schema::ensure() 都无条件重灌**(线上实测每跑一次 +1 行)。
修:给它加自守卫(只有在 persons 还留着 group_id/sort_order 的**老库**才跑)。
5. 名册:点击=看详情、**只有拖 ⠿ 手柄**才是排序(原来整行既能点又能拖,语义混乱)。
6. 任务:所有任务前加复选框,任何位置点勾即完成;
状态语义照业界整理为 To Do / Doing / Done 并写出说明(用户要求参考别人的做法)。
7. 财务:类别标签改**多选非互斥**(再点一次取消该类别),上下两图 + 图例 + 表格
全部按同一个集合联动;高亮条刻意不给「合计」(多选下各类金额不能相加)。
8. 微信读书:新增**懒同步**(访问时顺手做,6h TTL,只补空字段不覆盖用户填的)、
封面图**拉下来存本站存储**(原来只存外链 → 看不到)、接官方 /readdata/detail 统计。
9. 导航:线上数据只搬了一半(5 板块 29 站 → 7 板块 67 站 + 67 图标)、
展示页头像不显示 / 搜索按钮错位 / 点登录 404。三个真根因:
· 相对地址在 /nav/ 下全 404 → 新增 Nav::abs();
· 搜索容器丢了 id="search"(CSS 是 #search button.submit 绝对定位);
· Nav::iconKey() 与 SimpleNav 的 icon_key() **算法不一致**(少一次 md5 归一化)
→ 搬来的图标文件名对不上;且 uploads/ 整目录 Require all denied。
新增两个**免登录公开出口** nav_icon.php / nav_avatar.php。
顺带修掉一个「数据损坏级」的真 bug(做第 4 条线上反证时发现的,之前一直没人注意):
app/Region.php 三处用中文标点当 trim/ltrim 的 charlist,而 PHP 的 charlist 是
**按字节**匹配的 —— 「。」= E3 80 **82** → 字节 82 进了可剥集合 →
「层」= E5 B1 **82** 被切掉尾字节 → 非法 UTF-8 → backfillAddress 的 UPDATE 报
`1366 Incorrect string value` → **地址拆分升级从上线起一直在失败被跳过**(每请求报一次)。
改成字符级 preg_replace('/…/u');线上实测 ensure() 现在无报错、待拆行数真的在下降。
验证战绩:
- e2e 1289/0(新增 Region 字节级用例等)
- 真机探针:选人 19/0 · 首页/导航 24/0 · 导航模块 34/0 · b18 暂存+确定 10/0 ·
第23批 52/0
- 桌面几何 428/0 · 移动端几何 680/0 · 交互 92/0 · 窄屏 24/0
- 老库升级 153/0(含 anniversary_people 迁移 + 「再跑 ensure() 名册行数不变」)
- 部署静态断言 180 OK / 0 FAIL · 反向验证 3/3 + Region 三变体各被两条断言同时抓住
- 线上 26 个文件与本地**逐字节一致**;展示页 200、图标 66/66 200、登录 200、
越权/不存在均 404、签发短语后 ensure() 不再报错。
This commit is contained in:
+478
@@ -50,6 +50,21 @@ final class WeRead
|
||||
/** 用户设置里存加密 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;
|
||||
|
||||
// ================================================================
|
||||
// Key 的存取(加密)
|
||||
// ================================================================
|
||||
@@ -325,6 +340,239 @@ final class WeRead
|
||||
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
|
||||
{
|
||||
@@ -409,4 +657,234 @@ final class WeRead
|
||||
$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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user