v29 第 29 批(9 条):日程定位/复选框提示/名册错乱三处真因修复 + 读书落盘 + 导航四设置 + 笔记阅读态与共享
前 4 条是"上批改了但用户仍报"的,这次先拿真机证据再动手,三处真因如下:
1. 首页没定位到红线 —— 真根因:定位脚本的 PHP 条件是
`if (!$todayEvents || empty($timed))`,**今天只有全天日程时整段脚本不渲染**
(实测 hasScript=False、scrollTop=0),而 .ag-grid(0–24h) 与红线都在。
有分时段日程时定位其实完全正确(偏差 0px)。已改成 `if (!$todayEvents)`。
2. 任务复选框提示还在 —— 真根因:`_task_check.php` 里那句无障碍文字
`<span class="visually-hidden">` 用的类名**在 app.css 里从来没有定义过**
(项目一直只用 .sr-only)→ 没有规则 = 普通 span = 直接可见。
已把两个类名合并到同一条规则(clip 隐藏,仍可读屏)。
3. 名册完全错乱 —— 真根因:v29 重写平铺列表时**漏了一个 `</div>`**(闭合 .person),
浏览器把下一张卡嵌进上一张里,实测 .person-main 只剩 24~151px(本该 ~800px)。
`php -l` 与全部静态断言都抓不到,只有真机量 DOM 才现形。已补上闭合。
另把 v28 引入的多列 grid 改回单列(它与 content-visibility 冲突)+窄屏动作区折行。
4. 读书慢 —— 按用户给的思路「做一个同步,然后落盘」:
新增 weread_snapshots 表 + App\WeReadCache(按人+数据集存 JSON);
渲染路径改走 notebooksCached()/readStatsCached()(永不联网);
同步从"渲染之前"挪到"**响应之后**"(queueBackgroundSync + fastcgi_finish_request)。
5. 导航设置改成右上角**下拉菜单 + 四视图**(addr/general/board/engine),
表单带 back 域 → 保存后回原来那一项。
🔴 顺带修掉一个真 bug:`if (!$inSettings && $canWrite)` 的 endif 漂到了文件末尾,
把 board/engine 两个视图也包进了"非设置态" → 打开时标题对、**内容是空的**。
6. 笔记列表缩略图:cover → contain + 固定高度(按比例完整显示,不裁切)。
7. 笔记段落编辑器:加 Markdown 工具条 + 斜线命令(行首打 /co → 回车补全代码块),
两者共用同一份命令表。
8. 笔记点进来默认**阅读态**(图文混排、附件在最下方、图片四档宽度、点「编辑」才可改);
清单块并入 Markdown —— 但**历史 list 块转写成 `- 项` 文本,老数据不丢**。
9. 笔记共享改成**逐人 + 权限**(note_shares,与 resource_shares 同构);
可见性收敛成全站唯一的 Note::VIS_COND;迁移用一次性哨兵。
This commit is contained in:
+234
-31
@@ -43,8 +43,15 @@ final class Note
|
||||
/** 附件(图片块 / 笔记底部附件)在统一 attachments 表里的宿主类型 */
|
||||
public const ATTACH = 'note';
|
||||
|
||||
/** 块类型白名单(写在服务端;前端传别的值一律归一成 text) */
|
||||
public const BLOCK_KINDS = ['text', 'image', 'list'];
|
||||
/** 块类型白名单(写在服务端;前端传别的值一律归一成 text)
|
||||
*
|
||||
* 🔴 v29 用户第 8 条:「清单功能不要了吧,markdown 里面包含这个功能,
|
||||
* 把段落做的完善一点就可以了。」
|
||||
* ⇒ 收成 `text` / `image` 两种。
|
||||
* ⚠️ 老库里可能还有 `kind = 'list'` 的行 —— **不要**把它们当非法值删掉,
|
||||
* 见 `cleanBlocks()` 里对历史 list 块的**转写**(转成 Markdown 列表文本)。
|
||||
* 白名单只决定"新写入时允许什么",不决定"老数据怎么活"。 */
|
||||
public const BLOCK_KINDS = ['text', 'image'];
|
||||
|
||||
/**
|
||||
* 单条笔记最多几块、每块正文多少字(挡住"把一整本书贴进来"这类)
|
||||
@@ -59,6 +66,22 @@ final class Note
|
||||
/** 列表页默认显示多少条(超过给"加载更多") */
|
||||
public const LIST_LIMIT = 30;
|
||||
|
||||
/**
|
||||
* **可见性的唯一判据**(v29 第 9 条)。
|
||||
*
|
||||
* 「这条笔记是我写的」**或**「有一条指向我的共享记录」。
|
||||
* ⚠️ 全站(列表 / 计数 / 详情 / 标签云)都拼这**同一个**串 ——
|
||||
* 各写各的迟早出现"列表里看得到、点进去 404"这种鬼故事。
|
||||
* ⚠️ 绑定参数顺序:调用方在它**前面**绑 `$viewer`(对应 n.person_id)。
|
||||
* ⚠️ 用 EXISTS 而不是 JOIN:共享给多个人时 JOIN 会让同一条笔记出现多次。
|
||||
*/
|
||||
public const VIS_COND =
|
||||
'(n.person_id = ? OR EXISTS (SELECT 1 FROM note_shares s
|
||||
WHERE s.note_id = n.id AND s.person_id = ?))';
|
||||
|
||||
/** 共享权限的白名单(与 resources 同一套语义) */
|
||||
public const PERMS = ['read', 'write'];
|
||||
|
||||
// =====================================================================
|
||||
// 读
|
||||
// =====================================================================
|
||||
@@ -81,12 +104,22 @@ final class Note
|
||||
int $limit = 0,
|
||||
int $offset = 0
|
||||
): array {
|
||||
/* 🔴 v29 用户第 9 条:可见性判据换成**逐人共享**:
|
||||
「我写的」**或**「有一条指向我的共享记录」。
|
||||
⚠️ 这是全站唯一的可见性定义(列表 / 计数 / 详情 / 标签云都读它)——
|
||||
v27 那个 `is_shared = 1`(共享给全家)已不再是判据,
|
||||
升级时它被展开成逐人记录(见 Schema 的迁移)。
|
||||
⚠️ 用 EXISTS 而不是 JOIN `note_shares`:JOIN 会让"共享给 3 个人"的
|
||||
笔记在列表里出现 3 次(每条共享一行)。EXISTS 只判"有没有"。 */
|
||||
$vis = self::VIS_COND;
|
||||
$sql = 'SELECT n.*, p.name AS author_name, p.avatar, p.avatar_color,
|
||||
CASE WHEN n.person_id = ? THEN 1 ELSE 0 END AS is_mine
|
||||
FROM notes n
|
||||
JOIN persons p ON p.id = n.person_id
|
||||
WHERE n.family_id = ? AND (n.person_id = ? OR n.is_shared = 1)';
|
||||
$bind = [$viewer, $familyId, $viewer];
|
||||
WHERE n.family_id = ? AND ' . $vis;
|
||||
// ⚠️ VIS_COND 里有**两个** `?`(可见性判据里 person_id 出现两次),
|
||||
// 所以 $viewer 要绑两遍。漏一个会让后面的关键词绑定整体错位。
|
||||
$bind = [$viewer, $familyId, $viewer, $viewer];
|
||||
|
||||
$kw = trim($keyword);
|
||||
if ($kw !== '') {
|
||||
@@ -123,8 +156,8 @@ final class Note
|
||||
public static function countVisible(int $familyId, int $viewer, string $keyword = '', string $tag = ''): int
|
||||
{
|
||||
$sql = 'SELECT COUNT(*) FROM notes n
|
||||
WHERE n.family_id = ? AND (n.person_id = ? OR n.is_shared = 1)';
|
||||
$bind = [$familyId, $viewer];
|
||||
WHERE n.family_id = ? AND ' . self::VIS_COND;
|
||||
$bind = [$familyId, $viewer, $viewer];
|
||||
$kw = trim($keyword);
|
||||
if ($kw !== '') {
|
||||
$sql .= ' AND (n.title LIKE ?
|
||||
@@ -162,10 +195,7 @@ final class Note
|
||||
"SELECT * FROM note_blocks WHERE note_id IN ($in) ORDER BY note_id, position, id"
|
||||
);
|
||||
foreach ($st->fetchAll() as $b) {
|
||||
// v28:text 块同时算好 Markdown 渲染结果(列表页要显示富文本)
|
||||
$b['html'] = (string)$b['kind'] === 'text'
|
||||
? Markdown::render((string)$b['content'])
|
||||
: '';
|
||||
$b['html'] = self::blockHtml((string)$b['kind'], (string)$b['content']);
|
||||
$blocks[(int)$b['note_id']][] = $b;
|
||||
}
|
||||
|
||||
@@ -228,9 +258,9 @@ final class Note
|
||||
$st = Database::get()->prepare(
|
||||
'SELECT n.*, p.name AS author_name, p.avatar, p.avatar_color
|
||||
FROM notes n JOIN persons p ON p.id = n.person_id
|
||||
WHERE n.id = ? AND n.family_id = ? AND (n.person_id = ? OR n.is_shared = 1)'
|
||||
);
|
||||
$st->execute([$id, $familyId, $viewer]);
|
||||
WHERE n.id = ? AND n.family_id = ? AND ' . self::VIS_COND);
|
||||
// ⚠️ VIS_COND 两个 `?`,$viewer 绑两遍(顺序:id / family / person_id / person_id)
|
||||
$st->execute([$id, $familyId, $viewer, $viewer]);
|
||||
$r = $st->fetch();
|
||||
if (!$r) {
|
||||
return null;
|
||||
@@ -296,11 +326,7 @@ final class Note
|
||||
$st->execute([$noteId]);
|
||||
$rows = $st->fetchAll();
|
||||
foreach ($rows as &$r) {
|
||||
$kind = (string)$r['kind'];
|
||||
// 只有段落支持 Markdown(清单走自己的渲染、图片走 media)
|
||||
$r['html'] = $kind === 'text'
|
||||
? Markdown::render((string)$r['content'])
|
||||
: '';
|
||||
$r['html'] = self::blockHtml((string)$r['kind'], (string)$r['content']);
|
||||
}
|
||||
unset($r);
|
||||
return $rows;
|
||||
@@ -329,10 +355,10 @@ final class Note
|
||||
$st = Database::get()->prepare(
|
||||
'SELECT t.tag, COUNT(DISTINCT n.id) AS n
|
||||
FROM note_tags t JOIN notes n ON n.id = t.note_id
|
||||
WHERE n.family_id = ? AND (n.person_id = ? OR n.is_shared = 1)
|
||||
WHERE n.family_id = ? AND ' . self::VIS_COND . '
|
||||
GROUP BY t.tag ORDER BY n DESC, t.tag ASC'
|
||||
);
|
||||
$st->execute([$familyId, $viewer]);
|
||||
$st->execute([$familyId, $viewer, $viewer]);
|
||||
return $st->fetchAll();
|
||||
}
|
||||
|
||||
@@ -369,13 +395,15 @@ final class Note
|
||||
return 0;
|
||||
}
|
||||
$db = Database::get();
|
||||
// ⚠️ v29:新建时**一律不共享**(`is_shared` 恒 0)——
|
||||
// 共享改成"保存完在详情页选人 + 权限"(与资源同一个交互),
|
||||
// 新建表单里不再有那个勾选框。共享表记录由 setShare 单独维护。
|
||||
$db->prepare(
|
||||
'INSERT INTO notes (family_id, person_id, title, is_shared, created_at, updated_at)
|
||||
VALUES (?,?,?,?,NOW(),NOW())'
|
||||
VALUES (?,?,?,0,NOW(),NOW())'
|
||||
)->execute([
|
||||
$familyId, $personId,
|
||||
mb_substr($title, 0, 200, 'UTF-8'),
|
||||
!empty($d['is_shared']) ? 1 : 0,
|
||||
]);
|
||||
$id = (int)$db->lastInsertId();
|
||||
self::writeBlocks($id, $blocks);
|
||||
@@ -386,15 +414,22 @@ final class Note
|
||||
}
|
||||
|
||||
/**
|
||||
* 改一条笔记。**仅作者本人可改**(共享出去只给别人看,不给别人改)。
|
||||
* @return bool 是否改到了(改不到 = 不是我的/不存在)
|
||||
* 改一条笔记。
|
||||
*
|
||||
* 🔴 v29 用户第 9 条:改成**有 write 权限就能改** —— 用户要的是
|
||||
* 「和资源类似…选择是只读还是可以读写」,那就得让"可读写"真的能写。
|
||||
* v27 的"仅作者可改"(共享出去只给别人看)是当时只有"共享给全家"这一个
|
||||
* 开关时的简化;现在有了逐人权限,就该按权限来。
|
||||
* ⚠️ 删除**仍然只限作者**(见 destroy):删是不可逆的,
|
||||
* "可读写"给的是编辑权,不是销毁权。这条差异要显式写在按钮上。
|
||||
* @return bool 是否改到了(改不到 = 看不到 / 没有写权限)
|
||||
*/
|
||||
public static function update(int $familyId, int $id, int $personId, array $d): bool
|
||||
{
|
||||
$db = Database::get();
|
||||
$st = $db->prepare('SELECT id FROM notes WHERE id = ? AND family_id = ? AND person_id = ?');
|
||||
$st->execute([$id, $familyId, $personId]);
|
||||
if (!$st->fetchColumn()) {
|
||||
// 先按**可见性**取出来(含 family 校验),再用 permOf 判能不能改
|
||||
$note = self::find($familyId, $id, $personId);
|
||||
if (!$note || !self::canWrite($note, $personId)) {
|
||||
return false;
|
||||
}
|
||||
$title = trim((string)($d['title'] ?? ''));
|
||||
@@ -404,9 +439,11 @@ final class Note
|
||||
if ($title === '' && !$blocks) {
|
||||
$title = '(空笔记)';
|
||||
}
|
||||
$db->prepare(
|
||||
'UPDATE notes SET title = ?, is_shared = ?, updated_at = NOW() WHERE id = ?'
|
||||
)->execute([mb_substr($title, 0, 200, 'UTF-8'), !empty($d['is_shared']) ? 1 : 0, $id]);
|
||||
// ⚠️ 不再写 `is_shared`:它的语义已被 note_shares 取代(逐人 + 权限)。
|
||||
// 老列留着不动(升级迁移时读过它一次),但**不再由这里改** ——
|
||||
// 否则会出现"列里写着 1、共享表里却没有记录"这种自相矛盾的状态。
|
||||
$db->prepare('UPDATE notes SET title = ?, updated_at = NOW() WHERE id = ?')
|
||||
->execute([mb_substr($title, 0, 200, 'UTF-8'), $id]);
|
||||
// 块与标签都是"整组替换":与 trip_nodes 的错误清单不同,笔记的块没有历史价值,
|
||||
// 全量重写最简单可靠(不会出现"删掉的块下一轮又回来")。
|
||||
self::writeBlocks($id, $blocks);
|
||||
@@ -455,6 +492,95 @@ final class Note
|
||||
)->execute([$familyId]);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// 共享(v29 用户第 9 条)—— 与 App\Resource 同一套语义
|
||||
// =====================================================================
|
||||
|
||||
/** 这条笔记共享给了谁(带权限与对方的名字/头像) */
|
||||
public static function shares(int $noteId): array
|
||||
{
|
||||
$st = Database::get()->prepare(
|
||||
'SELECT s.person_id, s.perm, p.name, p.avatar, p.avatar_color
|
||||
FROM note_shares s JOIN persons p ON p.id = s.person_id
|
||||
WHERE s.note_id = ? ORDER BY p.id'
|
||||
);
|
||||
$st->execute([$noteId]);
|
||||
return $st->fetchAll();
|
||||
}
|
||||
|
||||
/** 一条笔记共享给了几个人的映射(列表页一次性取,避免每条一次查询) */
|
||||
public static function shareCounts(array $noteIds): array
|
||||
{
|
||||
$ids = array_values(array_filter(array_map('intval', $noteIds)));
|
||||
if (!$ids) {
|
||||
return [];
|
||||
}
|
||||
$in = implode(',', $ids);
|
||||
$out = [];
|
||||
foreach (Database::get()->query(
|
||||
"SELECT note_id, COUNT(*) AS n FROM note_shares
|
||||
WHERE note_id IN ($in) GROUP BY note_id"
|
||||
)->fetchAll() as $r) {
|
||||
$out[(int)$r['note_id']] = (int)$r['n'];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设共享。
|
||||
*
|
||||
* ⚠️ 作者本人**不进**共享表 —— 他本来就全能,
|
||||
* 写进去只会让"共享给谁"的列表里冒出自己(同 Resource::setShare)。
|
||||
*/
|
||||
public static function setShare(int $noteId, int $ownerId, int $personId, string $perm): void
|
||||
{
|
||||
if ($personId <= 0 || $personId === $ownerId) {
|
||||
self::dropShare($noteId, $personId);
|
||||
return;
|
||||
}
|
||||
Database::get()->prepare(
|
||||
'INSERT INTO note_shares (note_id, person_id, perm) VALUES (?,?,?)
|
||||
ON DUPLICATE KEY UPDATE perm = VALUES(perm)'
|
||||
)->execute([$noteId, $personId, in_array($perm, self::PERMS, true) ? $perm : 'read']);
|
||||
}
|
||||
|
||||
public static function dropShare(int $noteId, int $personId): void
|
||||
{
|
||||
Database::get()->prepare('DELETE FROM note_shares WHERE note_id = ? AND person_id = ?')
|
||||
->execute([$noteId, $personId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 我对某条笔记的权限:'owner' / 'write' / 'read' / ''(看不到)。
|
||||
*
|
||||
* ⚠️ 这是**唯一**的权限判定(页面按钮、服务端写入校验都读它)——
|
||||
* 两处各写一套的话,迟早出现"按钮没显示但接口能改"(或者反过来)。
|
||||
*/
|
||||
public static function permOf(array $note, int $viewer): string
|
||||
{
|
||||
if ((int)$note['person_id'] === $viewer) {
|
||||
return 'owner';
|
||||
}
|
||||
$st = Database::get()->prepare(
|
||||
'SELECT perm FROM note_shares WHERE note_id = ? AND person_id = ?'
|
||||
);
|
||||
$st->execute([(int)$note['id'], $viewer]);
|
||||
$p = (string)($st->fetchColumn() ?: '');
|
||||
return in_array($p, self::PERMS, true) ? $p : '';
|
||||
}
|
||||
|
||||
/** 能改吗(作者或 write 权限)。删除**只限作者** —— 见 destroy()。 */
|
||||
public static function canWrite(array $note, int $viewer): bool
|
||||
{
|
||||
$p = self::permOf($note, $viewer);
|
||||
return $p === 'owner' || $p === 'write';
|
||||
}
|
||||
|
||||
public static function permWord(string $perm): string
|
||||
{
|
||||
return $perm === 'owner' ? '本人' : ($perm === 'write' ? '可读写' : '只读');
|
||||
}
|
||||
|
||||
/** 删除(仅作者本人)。块/标签/附件靠外键 ON DELETE CASCADE 一起走。 */
|
||||
public static function destroy(int $familyId, int $id, int $personId): void
|
||||
{
|
||||
@@ -498,13 +624,83 @@ final class Note
|
||||
// 内部:块 / 标签的清洗与写入
|
||||
// =====================================================================
|
||||
|
||||
/**
|
||||
* 一个块的渲染结果(HTML)。**全站唯一**的块渲染入口。
|
||||
*
|
||||
* 🔴 v29 第 8 条:清单块并入 Markdown 之后,这里要**同时认老数据**:
|
||||
* 库里可能还躺着 `kind = 'list'` 的块(v27/v28 写的),
|
||||
* 用户没编辑过它们 → 仍是 list → 必须照样渲染出列表,不能变成空白。
|
||||
* ⚠️ 走同一套 `listToMarkdown` 转换(与 cleanBlocks 共用),
|
||||
* 这样"老块在页面上"与"老库保存一次之后的文本"长得完全一样。
|
||||
*
|
||||
* @return string 图片块返回 ''(图片不用 HTML,由模板直接出 <img>)
|
||||
*/
|
||||
public static function blockHtml(string $kind, string $content): string
|
||||
{
|
||||
if ($kind === 'image') {
|
||||
return '';
|
||||
}
|
||||
if ($kind === 'list') {
|
||||
// 历史清单块 → 转成 Markdown 列表再渲染(数据不丢,样子不变)
|
||||
return Markdown::render(self::listToMarkdown($content));
|
||||
}
|
||||
return Markdown::render($content);
|
||||
}
|
||||
|
||||
/** 历史清单块 → Markdown 文本的**对外**入口(模板把老块放进编辑器时用)。
|
||||
*
|
||||
* ⚠️ 为什么要暴露它:编辑态要把"库里的块"变成"编辑器里的文本",
|
||||
* 而 list 块的转换逻辑只该有一份(`listToMarkdown`)。
|
||||
* 模板直接调它,比在模板里再写一遍正则可靠得多。
|
||||
* @param string $content 清单块的原始内容(每行一项,缩进表层级)
|
||||
*/
|
||||
public static function blockHtmlOut(string $content): string
|
||||
{
|
||||
return self::listToMarkdown($content);
|
||||
}
|
||||
|
||||
/**
|
||||
* 历史清单块 → Markdown 无序列表文本(v29 收掉清单块时用)。
|
||||
*
|
||||
* 约定(v27 定下的):每行一项,**行首缩进表示层级**。
|
||||
* 这里按缩进深度换算成 Markdown 的缩进(同样表层级),最多 3 层 ——
|
||||
* 更深的层级在 20px 的行高里已经看不出来了,硬转反而让源码难读。
|
||||
*/
|
||||
private static function listToMarkdown(string $content): string
|
||||
{
|
||||
$lines = [];
|
||||
foreach (preg_split('/\R/u', $content) ?: [] as $line) {
|
||||
$raw = rtrim($line, " \t");
|
||||
if (trim($raw) === '') {
|
||||
continue;
|
||||
}
|
||||
$indent = strlen($raw) - strlen(ltrim($raw, " \t"));
|
||||
$depth = min(3, (int)floor($indent / 2));
|
||||
$lines[] = str_repeat(' ', $depth) . '- ' . trim($raw);
|
||||
}
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清洗前端提交的块:
|
||||
* · kind 归一(只认白名单里的三种,其它一律当 text)
|
||||
* · kind 归一(只认白名单里的两种,其它一律当 text)
|
||||
* · **历史 list 块转成 Markdown 列表文本**(见下面的说明)
|
||||
* · 内容裁剪(去首尾空白;图片块 content = 附件 key)
|
||||
* · 丢掉**空块**(用户加了块又没填,不该留一堆空段)
|
||||
* · 上限 MAX_BLOCKS
|
||||
*
|
||||
* 🔴 v29 用户第 8 条:「清单功能不要了吧,markdown 里面包含这个功能,
|
||||
* 把段落做的完善一点就可以了。」
|
||||
* ⇒ 块类型收成 `text` / `image` 两种。
|
||||
* ⚠️ **但绝不丢历史数据**:老库里已经有 `kind = 'list'` 的块。
|
||||
* 直接不再认它 → 那些块的正文会在用户下次保存时**静默消失**
|
||||
* (最糟的一种数据丢失:用户什么都没做,内容自己没了)。
|
||||
* 所以这里把 list 块**转写**成 Markdown 的无序列表(每个非空行前加 `- `),
|
||||
* 并归一成 text 块 —— 渲染出来长得一模一样(`- x` 就是列表),
|
||||
* 而用户以后可以在它上面用 Markdown 继续写。
|
||||
* ⚠️ 列表的**层级**也保留:原来"行首缩进"表示层级,转写时按
|
||||
* 缩进深度换算成 Markdown 的缩进(Markdown 同样是缩进表层级)。
|
||||
*
|
||||
* @return array<int,array{kind:string,content:string}>
|
||||
*/
|
||||
private static function cleanBlocks($raw): array
|
||||
@@ -522,6 +718,13 @@ final class Note
|
||||
$kind = 'text';
|
||||
}
|
||||
$content = (string)($b['content'] ?? '');
|
||||
|
||||
// ---- 历史 list 块 → Markdown 无序列表(v29,见上面的说明)----
|
||||
if ($kind === 'list') {
|
||||
$content = self::listToMarkdown($content);
|
||||
$kind = 'text';
|
||||
}
|
||||
|
||||
// 图片块 content 是附件 key:不允许换行/首尾空白(它要当文件名查)
|
||||
$content = $kind === 'image'
|
||||
? trim(preg_replace('/\s+/', '', $content) ?? '')
|
||||
|
||||
+110
-1
@@ -108,6 +108,22 @@ final class Schema
|
||||
* ⚠️ `person_roster.group_id` **保留**(vCard CATEGORIES 与老代码的读点),
|
||||
* 启动时把已有分组**逐一迁成同名标签**(幂等:按 (owner,person,tag) 唯一键)。
|
||||
* 名册页从「按分栏展示」改成「平铺 + 顶部标签筛选」,拖动仍可排序。
|
||||
* 29 → **微信读书数据落盘(用户 2026-10-08 第 4 条)**:新增 `weread_snapshots`
|
||||
* (按 (人, 数据集) 存 JSON 快照)。动机是用户的原话:
|
||||
* 「读书还是慢。是不是可以做一个同步,然后落盘,这样就不用每次都读取
|
||||
* 微信读书的数据了。」
|
||||
* ⇒ `notebooks()` / `readStats()` 在**页面渲染路径**上不再打网关,
|
||||
* 只读快照;打接口只发生在同步时(手动 / 6 小时到期)。
|
||||
* ⚠️ 快照有 `updated_at`,过期(默认 24 小时)仍会触发一次后台同步,
|
||||
* 但**渲染永远不等它** —— 拿旧快照先渲染,同步结果下次访问可见。
|
||||
* 30 → **笔记共享改成"逐人 + 权限"(用户 2026-10-08 第 9 条)**:新增
|
||||
* `note_shares`(与 `resource_shares` 同构)。动机是用户的原话:
|
||||
* 「笔记的共享,和其他的模块一样,选择共享人,选择是只读还是可以读写。和资源类似。」
|
||||
* ⇒ 取消 v27 的"一个 `is_shared` 布尔(共享给全家)",
|
||||
* 老数据升级时**展开成逐人记录**(给家里每个人一条 read)。
|
||||
* ⚠️ 用一次性哨兵(`note_shares_migrated_v29`),理由同 v28 那次:
|
||||
* 迁移是"一次性数据转换",不是"持续同步",
|
||||
* 否则用户取消共享后下次启动又被灌回来(本项目已栽过 4 次)。
|
||||
*/
|
||||
/**
|
||||
* 结构版本(**人读**用:日志、设置页上显示"库结构是哪一版")。
|
||||
@@ -115,7 +131,7 @@ final class Schema
|
||||
* ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。
|
||||
* 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。
|
||||
*/
|
||||
public const VERSION = '28';
|
||||
public const VERSION = '30';
|
||||
|
||||
/**
|
||||
* 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。
|
||||
@@ -1008,6 +1024,62 @@ CREATE TABLE IF NOT EXISTS `person_tags` (
|
||||
CONSTRAINT `fk_pt_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='名册标签(v28:一个人可挂多个标签,取代"一人一组")'
|
||||
SQL,
|
||||
<<<'SQL'
|
||||
/* 🔴 v29 用户第 4 条:**微信读书的数据落盘,页面只读本地**。
|
||||
用户原话:「读书还是慢。是不是可以做一个同步,然后落盘,这样就不用每次都
|
||||
读取微信读书的数据了。」
|
||||
⇒ 症结确实在这:`notebooks()` 与 `readStats()` 每次渲染都**实时打网关**,
|
||||
一次读书页要打好几次外部请求(且官方接口本身就慢)。
|
||||
实测口径:以前每个请求都要等外部接口返回,`SYNC_BUDGET` 只能限制"同步"那一段,
|
||||
限不住"每次渲染都去读统计"那一段。
|
||||
⇒ 做法:**一份按 (人, 数据集) 存的 JSON 快照**。
|
||||
· 页面渲染只读这张表(本地查询,毫秒级);
|
||||
· 真正打接口只发生在**同步**时(手动「立即同步」或 6 小时到期的自动同步);
|
||||
· 同步成功后把相关快照一起刷新。
|
||||
⚠️ 为什么用"一个 JSON 列"而不是把统计拆成多张表:
|
||||
这些数据是**只读的远端结果**、字段完全由官方决定(说改就改),
|
||||
本站从不按其中任何字段做查询。拆表=把别人的字段设计背到自己身上,
|
||||
官方一变就要迁移。存 JSON = 官方怎么变都能原样兜住。
|
||||
⚠️ 为什么不像封面那样塞进 `book`/`attachment`:它不是"我们的实体",
|
||||
是一次外部调用的缓存,有明确的**时效性**(`updated_at`),
|
||||
过期就该重新拉 —— 与"实体数据"的生命周期完全不同,混在一起会后患无穷。 */
|
||||
CREATE TABLE IF NOT EXISTS `weread_snapshots` (
|
||||
`person_id` INT UNSIGNED NOT NULL,
|
||||
`k` VARCHAR(40) NOT NULL COMMENT '数据集名:notebooks / stats_overall / stats_weekly …',
|
||||
`v` LONGTEXT DEFAULT NULL COMMENT '原样存下来的 JSON(官方字段,本站不解析、不索引)',
|
||||
`updated_at` DATETIME NOT NULL COMMENT '这次快照是什么时候拉的(判过期用)',
|
||||
PRIMARY KEY (`person_id`, `k`),
|
||||
KEY `idx_ws_updated` (`updated_at`),
|
||||
CONSTRAINT `fk_ws_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='微信读书的远端数据快照(v29:页面只读本地,不再每次打网关)'
|
||||
SQL,
|
||||
<<<'SQL'
|
||||
/* 🔴 v29 用户第 9 条:「笔记的共享,和其他的模块一样,选择共享人,
|
||||
选择是只读还是可以读写。和资源类似。」
|
||||
⇒ 把 v27 那个"一个 is_shared 布尔(共享给全家)"升级成**逐人共享 + 权限**。
|
||||
⚠️ 为什么不是"保留 is_shared,再加一张人表":
|
||||
那样会出现两种共享语义并存("全家可见" + "给这几个人"),
|
||||
判定优先级、列表怎么算、谁能看到 —— 全是边界问题。
|
||||
统一成一种(逐人 + 权限)之后,判定只有一处:
|
||||
`owner 本人 or 在某条 share 记录里`。
|
||||
⚠️ `notes.is_shared` 列**保留但不再作为判据**(升级时把老的"1"展开成
|
||||
"给全家每个人一条 read 记录",迁移完就没它的事了)。
|
||||
保留列是为了老库升级时能读到它(见 migrateSharedNotes)。
|
||||
⚠️ 与资源共享表**同构**(列名/枚举都一样):两处的权限语义要一致,
|
||||
表结构一样,读代码的人一眼就知道"跟资源那套是一回事"。 */
|
||||
CREATE TABLE IF NOT EXISTS `note_shares` (
|
||||
`note_id` INT UNSIGNED NOT NULL,
|
||||
`person_id` INT UNSIGNED NOT NULL COMMENT '共享给谁(**不含**作者 —— 自己写的本来就能改)',
|
||||
`perm` ENUM('read','write') NOT NULL DEFAULT 'read' COMMENT 'read=只读 / write=可读写',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`note_id`, `person_id`),
|
||||
KEY `idx_ns_person` (`person_id`),
|
||||
CONSTRAINT `fk_ns_note` FOREIGN KEY (`note_id`) REFERENCES `notes` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_ns_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='笔记共享给别人:可只读、可读写(v29:取代"共享给全家"那个布尔)'
|
||||
SQL,
|
||||
];
|
||||
}
|
||||
@@ -1894,6 +1966,43 @@ SQL);
|
||||
}
|
||||
});
|
||||
|
||||
/* 🔴 v29 用户第 9 条:「笔记的共享,和其他的模块一样,选择共享人,
|
||||
选择是只读还是可以读写。和资源类似。」
|
||||
⇒ 把 v27 的"`is_shared = 1`(共享给全家)"展开成**逐人记录**。
|
||||
怎么做:对每一条 `is_shared = 1` 的笔记,给**家里其他每个人**写一条
|
||||
`perm = 'read'`(读得到,但不能改 —— 原来的语义就是"全家可见",
|
||||
并没有给过他们改的权限,迁移必须**保持原语义**,不能顺手扩大权限)。
|
||||
⚠️ 排除作者自己:作者本来就全能,给自己写一条共享记录会污染"共享给谁"的列表。
|
||||
⚠️ 一次性哨兵(`note_shares_migrated_v29`)—— 理由与 v28 那次完全一样:
|
||||
迁移是"一次性数据转换",不是"持续同步"。否则用户把某人从共享里去掉后,
|
||||
下次启动又被灌回来(本项目已栽过 4 次,见 v28 的详细记录)。
|
||||
⚠️ 这个迁移**必须在 note_shares 建好之后**跑(外键指向 notes/persons,
|
||||
两张表都在 statements() 里先建出来了)。 */
|
||||
self::tryStep('笔记共享展开成逐人记录(全家可见 → 逐人只读)', static function () use ($db) {
|
||||
$flag = 'note_shares_migrated_v29';
|
||||
$has = $db->query("SELECT COUNT(*) FROM `settings` WHERE `k` = '" . $flag . "'")->fetchColumn();
|
||||
if ((int)$has > 0) {
|
||||
return;
|
||||
}
|
||||
if (self::hasTable($db, 'note_shares')) {
|
||||
// 只处理"曾经标过共享"的笔记;逐人展开,权限一律 read(保持原语义)
|
||||
$db->exec(
|
||||
"INSERT IGNORE INTO `note_shares` (note_id, person_id, perm)
|
||||
SELECT n.id, p.id, 'read'
|
||||
FROM `notes` n
|
||||
JOIN `persons` p ON p.family_id = n.family_id
|
||||
WHERE n.is_shared = 1
|
||||
AND p.id <> n.person_id
|
||||
AND p.kind = 'member'"
|
||||
);
|
||||
}
|
||||
$fid = (int)$db->query('SELECT MIN(id) FROM families')->fetchColumn();
|
||||
if ($fid > 0) {
|
||||
$db->prepare("INSERT IGNORE INTO `settings` (family_id, `k`, `v`) VALUES (?,?,?)")
|
||||
->execute([$fid, $flag, date('Y-m-d H:i:s')]);
|
||||
}
|
||||
});
|
||||
|
||||
// 被容错跳过的数据步骤记进设置,系统设置页会把它显出来 ——
|
||||
// 不然「升级没跑完」这件事只活在日志里,跟没发生一样。
|
||||
if (self::$stepErrors) {
|
||||
|
||||
+157
-1
@@ -91,6 +91,22 @@ final class WeRead
|
||||
/** 每次同步最多扫几本(与预算双保险:预算防"慢",条数防"多") */
|
||||
public const SYNC_MAX_BOOKS = 25;
|
||||
|
||||
/**
|
||||
* 快照的**保鲜期**(秒)—— 默认 24 小时。
|
||||
*
|
||||
* 🔴 v29 用户第 4 条:「读书还是慢。是不是可以做一个同步,然后落盘,
|
||||
* 这样就不用每次都读取微信读书的数据了。」
|
||||
*
|
||||
* ⇒ 落盘之后,"多久算旧"就成了新问题:
|
||||
* · 太短(比如和 SYNC_TTL 一样 6 小时)→ 老是触发同步,等于没落盘;
|
||||
* · 太长(比如一年)→ 用户读完了新书、打开还是旧数据,会觉得"同步坏了"。
|
||||
* 选 24 小时:读书统计本来就不是实时数据(读了多少分钟是慢慢累积的),
|
||||
* 一天内看到的是昨天的数字完全可接受;且每天最多触发一次拉取。
|
||||
* ⚠️ 关键:**渲染路径永远不等这次拉取**(拿旧快照先渲染)。
|
||||
* 过期只意味着"下次访问时顺手把它刷新一下",不意味着"这次要等"。
|
||||
*/
|
||||
public const SNAP_TTL = 86400;
|
||||
|
||||
// ================================================================
|
||||
// Key 的存取(加密)
|
||||
// ================================================================
|
||||
@@ -568,7 +584,8 @@ final class WeRead
|
||||
return !empty($up['ok']) ? (string)$up['file'] : '';
|
||||
}
|
||||
|
||||
/** 标题 → 可做文件名的安全基名(去掉路径符号,限长) */
|
||||
/**
|
||||
* 标题 → 可做文件名的安全基名(去掉路径符号,限长) */
|
||||
private static function safeBase(string $s): string
|
||||
{
|
||||
$s = preg_replace('#[\\\\/:*?"<>|\x00-\x1f]#u', '', $s) ?? '';
|
||||
@@ -576,6 +593,128 @@ final class WeRead
|
||||
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`):读书页会连着调好几次取数。
|
||||
*/
|
||||
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 {
|
||||
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
|
||||
{
|
||||
@@ -886,6 +1025,23 @@ final class WeRead
|
||||
$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];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
/**
|
||||
* 微信读书远端数据的**本地快照**(v29 用户 2026-10-08 第 4 条)。
|
||||
*
|
||||
* 用户原话:
|
||||
* 「读书还是慢。是不是可以做一个同步,然后落盘,这样就不用每次都读取
|
||||
* 微信读书的数据了。」
|
||||
*
|
||||
* ────────────────────────────────────────────────────────────────
|
||||
* 为什么单开一个类、而不是塞进 `WeRead`:
|
||||
* `WeRead` 的头部写得很清楚 ——「这个类**只负责取数据**,不碰数据库」。
|
||||
* 那条约定是有价值的(网络与存储分开,测试可以只测取数不连外网)。
|
||||
* 落盘是**另一件事**,所以放另一个类;`WeRead` 保持"干净的网络层"。
|
||||
*
|
||||
* 为什么是"JSON 快照"而不是"拆成表":
|
||||
* 这些数据是**只读的远端结果**,字段完全由官方决定(说改就改),
|
||||
* 本站从不按其中任何字段做查询、排序、JOIN。
|
||||
* 拆表 = 把别人的字段设计背到自己身上,官方一变就要写迁移;
|
||||
* 存 JSON = 官方怎么变都能原样兜住(渲染端按需取,取不到就当空)。
|
||||
*
|
||||
* 为什么渲染路径**永远不等**刷新(关键设计):
|
||||
* `get()` 只读本地,永不联网。若快照过期,调用方可以"顺手"触发一次刷新,
|
||||
* 但**必须先拿旧快照把页面渲染出来**,刷新结果留到下次访问可见。
|
||||
* 反面做法是"过期就同步完再渲染" —— 那就又回到"点进来要等很久",
|
||||
* 也就是用户这次报的那个问题。**"数据新鲜"永远排在"页面能立刻打开"之后。**
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class WeReadCache
|
||||
{
|
||||
/** 快照的数据集名 —— 只在这里登记,别处一律用常量,避免拼错字符串 */
|
||||
public const K_NOTEBOOKS = 'notebooks';
|
||||
|
||||
/** 阅读统计(按 mode 各存一份:overall / weekly / monthly / annually) */
|
||||
public static function statsKey(string $mode): string
|
||||
{
|
||||
return 'stats_' . $mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读一份快照。
|
||||
*
|
||||
* @param bool $freshOnly true = 过期就当没有(返回 null)——
|
||||
* 给那些"宁可不显示也不要旧数据"的场景用
|
||||
* @return array{ok:bool,data:mixed,updated_at:int,stale:bool}
|
||||
* ok=false 表示**本地没有**(从没同步过)
|
||||
*/
|
||||
public static function get(int $personId, string $key, bool $freshOnly = false): array
|
||||
{
|
||||
$miss = ['ok' => false, 'data' => null, 'updated_at' => 0, 'stale' => true];
|
||||
if ($personId <= 0 || $key === '') {
|
||||
return $miss;
|
||||
}
|
||||
$db = Database::get();
|
||||
try {
|
||||
$st = $db->prepare(
|
||||
'SELECT v, UNIX_TIMESTAMP(updated_at) AS ts
|
||||
FROM weread_snapshots WHERE person_id = ? AND k = ?'
|
||||
);
|
||||
$st->execute([$personId, $key]);
|
||||
$row = $st->fetch();
|
||||
} catch (\Throwable $e) {
|
||||
// 表还没建(升级中)→ 当作"没有快照",绝不让缓存层把页面打挂
|
||||
return $miss;
|
||||
}
|
||||
if (!$row) {
|
||||
return $miss;
|
||||
}
|
||||
$ts = (int)($row['ts'] ?? 0);
|
||||
$stale = (time() - $ts) > WeRead::SNAP_TTL;
|
||||
if ($freshOnly && $stale) {
|
||||
return $miss;
|
||||
}
|
||||
$data = json_decode((string)($row['v'] ?? ''), true);
|
||||
return ['ok' => true, 'data' => $data, 'updated_at' => $ts, 'stale' => $stale];
|
||||
}
|
||||
|
||||
/** 写一份快照(覆盖旧的)。失败静默 —— 缓存写不进去不该影响功能。 */
|
||||
public static function put(int $personId, string $key, $data): void
|
||||
{
|
||||
if ($personId <= 0 || $key === '') {
|
||||
return;
|
||||
}
|
||||
$json = json_encode($data, JSON_UNESCAPED_UNICODE);
|
||||
if ($json === false) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$db = Database::get();
|
||||
$st = $db->prepare(
|
||||
'INSERT INTO weread_snapshots (person_id, k, v, updated_at)
|
||||
VALUES (?, ?, ?, NOW())
|
||||
ON DUPLICATE KEY UPDATE v = VALUES(v), updated_at = VALUES(updated_at)'
|
||||
);
|
||||
$st->execute([$personId, $key, $json]);
|
||||
} catch (\Throwable $e) {
|
||||
// 静默(缓存是加速手段,不是功能本体)
|
||||
}
|
||||
}
|
||||
|
||||
/** 最后一次落盘是什么时候(页面上显示"数据更新于 …") */
|
||||
public static function updatedAt(int $personId, string $key): int
|
||||
{
|
||||
return (int)self::get($personId, $key)['updated_at'];
|
||||
}
|
||||
|
||||
/** 这个人有没有**任何**快照(判断"是不是第一次用") */
|
||||
public static function hasAny(int $personId): bool
|
||||
{
|
||||
if ($personId <= 0) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
$st = Database::get()->prepare(
|
||||
'SELECT COUNT(*) FROM weread_snapshots WHERE person_id = ?'
|
||||
);
|
||||
$st->execute([$personId]);
|
||||
return (int)$st->fetchColumn() > 0;
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 清掉一个人的全部快照(换 Key / 停用自动同步时用) */
|
||||
public static function forget(int $personId): void
|
||||
{
|
||||
if ($personId <= 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Database::get()->prepare('DELETE FROM weread_snapshots WHERE person_id = ?')
|
||||
->execute([$personId]);
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user