138 lines
5.5 KiB
PHP
138 lines
5.5 KiB
PHP
<?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) {
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|