diff --git a/reaction_board/cli.py b/reaction_board/cli.py index 904346e..2357bf0 100644 --- a/reaction_board/cli.py +++ b/reaction_board/cli.py @@ -433,6 +433,8 @@ def cmd_run(args, logger: JsonLogger, *, serve_web: bool = False) -> int: ttl_days=cfg.schedule.candidate_ttl_days ) expired = repo.expire_fav_actions() + if removed or expired: + repo.bump_cache_version("videos") logger.info( "cleanup.done", candidates_removed=removed, fav_actions_expired=expired, diff --git a/reaction_board/repository.py b/reaction_board/repository.py index 4681b45..b7a6dab 100644 --- a/reaction_board/repository.py +++ b/reaction_board/repository.py @@ -555,6 +555,43 @@ class Repository: (key, str(value)), )) + # ------------------------------------------------------------ cache version + + @staticmethod + def cache_version_key(group: str) -> str: + return f"cache:v:{group}" + + def cache_version(self, group: str) -> int: + def run(conn: sqlite3.Connection) -> int: + row = conn.execute( + "SELECT v FROM kv WHERE k = ?", (self.cache_version_key(group),) + ).fetchone() + if row is None: + return 0 + try: + return int(str(row["v"])) + except ValueError: + return 0 + return self.db.read(run) + + def bump_cache_version(self, group: str, *, now: int | None = None) -> int: + now = self._now() if now is None else int(now) + key = self.cache_version_key(group) + + def run(conn: sqlite3.Connection) -> int: + row = conn.execute("SELECT v FROM kv WHERE k = ?", (key,)).fetchone() + try: + value = int(str(row["v"])) + 1 if row is not None else 1 + except ValueError: + value = 1 + conn.execute( + "INSERT INTO kv(k, v) VALUES (?, ?) " + "ON CONFLICT(k) DO UPDATE SET v = excluded.v", + (key, str(value)), + ) + return value + return self.db.write(run) + # ------------------------------------------------------------ candidate def known_aid(self, aid: int) -> bool: diff --git a/reaction_board/services/discovery.py b/reaction_board/services/discovery.py index 0773d82..10978ce 100644 --- a/reaction_board/services/discovery.py +++ b/reaction_board/services/discovery.py @@ -106,6 +106,8 @@ class DiscoveryService: self._handle_item(item, channel, cfg, summary, pending, now) self._notify(pending, summary, now) + if summary.candidates or summary.notified: + self.repo.bump_cache_version("videos") summary.finished_at = self._now() self._log( "info" if not summary.errors else "warning", diff --git a/reaction_board/services/favorites.py b/reaction_board/services/favorites.py index e5a3c7d..a4a2d67 100644 --- a/reaction_board/services/favorites.py +++ b/reaction_board/services/favorites.py @@ -176,6 +176,7 @@ class FavoriteActionService: aid=aid, folder_id=folder.media_id, position=position, already=already, ) + self.repo.bump_cache_version("videos") return ActionResult( ok=True, aid=aid, action="favorite", state="done", position=position, already_in_folder=already, @@ -201,6 +202,7 @@ class FavoriteActionService: message="候选不存在或已处理", ) self.repo.mark_candidate_decision(aid, "rejected", now=self._now()) + self.repo.bump_cache_version("videos") return ActionResult( ok=True, aid=aid, action="ignore", state="rejected", message="已忽略", @@ -228,6 +230,7 @@ class FavoriteActionService: ) rejected = self.repo.reject_pending_for_up(up_mid, now=self._now()) self.repo.mark_candidate_decision(aid, "rejected", now=self._now()) + self.repo.bump_cache_version("videos") return ActionResult( ok=True, aid=aid, action="blacklist", state="rejected", message=f"已拉黑 UP,并忽略该 UP 的 {rejected} 条待决策候选", diff --git a/reaction_board/services/folder_sync.py b/reaction_board/services/folder_sync.py index 4aebafb..b14450a 100644 --- a/reaction_board/services/folder_sync.py +++ b/reaction_board/services/folder_sync.py @@ -200,6 +200,8 @@ class FolderSyncService: errors=len(summary.errors), elapsed=max(0, summary.finished_at - summary.started_at), ) + if summary.added or summary.removed or summary.refreshed: + self.repo.bump_cache_version("videos") return summary def _sync_folder( diff --git a/reaction_board/services/snapshot.py b/reaction_board/services/snapshot.py index f171e61..879fa26 100644 --- a/reaction_board/services/snapshot.py +++ b/reaction_board/services/snapshot.py @@ -284,6 +284,11 @@ class SnapshotService: self._run_backup(cfg, stat_date, summary) + if summary.success or summary.removed or summary.failed: + self.repo.bump_cache_version("videos") + if summary.fans_ok or summary.fans_failed: + self.repo.bump_cache_version("fans") + summary.finished_at = self._now() self._log( "info" if summary.completed else "warning", diff --git a/reaction_board/web/cache.py b/reaction_board/web/cache.py new file mode 100644 index 0000000..a047f2a --- /dev/null +++ b/reaction_board/web/cache.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +"""看板聚合结果的内存 TTL 缓存。 + +缓存键包含数据库路径和数据版本;版本存放在 ``kv``(``cache:v:``), +因此同一个进程内的调度任务和 Web 线程都会看到一致的失效,跨进程的 CLI +导入也可以通过提升版本让旧缓存失效。 +""" + +from __future__ import annotations + +import threading +import time +from typing import Any, Callable, Hashable + +TTL_VIDEOS = 3600 +TTL_FANS = 86400 + + +class TTLCache: + def __init__(self, *, clock: Callable[[], float] = time.time, max_items: int = 256) -> None: + self._clock = clock + self._max_items = max(16, int(max_items)) + self._lock = threading.RLock() + self._items: dict[Hashable, tuple[float, Any]] = {} + + def get(self, key: Hashable) -> Any | None: + with self._lock: + entry = self._items.get(key) + if entry is None: + return None + expires_at, value = entry + if expires_at <= self._clock(): + self._items.pop(key, None) + return None + return value + + def set(self, key: Hashable, ttl: float, value: Any) -> None: + with self._lock: + now = self._clock() + self._items[key] = (now + float(ttl), value) + if len(self._items) > self._max_items: + expired = [k for k, (exp, _) in self._items.items() if exp <= now] + for item in expired: + self._items.pop(item, None) + if len(self._items) > self._max_items: + oldest = sorted(self._items.items(), key=lambda kv: kv[1][0]) + for item, _ in oldest[: len(self._items) - self._max_items]: + self._items.pop(item, None) + + def clear(self) -> None: + with self._lock: + self._items.clear() + + def __len__(self) -> int: + with self._lock: + return len(self._items) + + +DASHBOARD_CACHE = TTLCache() + + +def build_cache_key( + scope: str, + group: str, + version: int, + params: dict[str, Any] | None = None, +) -> tuple[Any, ...]: + frozen = tuple(sorted((str(key), str(value)) for key, value in (params or {}).items())) + return (scope, group, int(version), frozen) \ No newline at end of file diff --git a/reaction_board/web/queries.py b/reaction_board/web/queries.py index c813e70..81b6941 100644 --- a/reaction_board/web/queries.py +++ b/reaction_board/web/queries.py @@ -28,6 +28,7 @@ from ..analytics import ( from ..config import ConfigStore from ..database import Database from ..timeutil import day_bounds, next_daily_run, shanghai_date, shanghai_datetime +from .cache import DASHBOARD_CACHE, TTL_FANS, TTL_VIDEOS, build_cache_key TRACKED_STATUSES = ("active", "unfaved") VIDEO_SORTS = { @@ -179,6 +180,30 @@ class DashboardQueries: self.db = db self.config_store = config_store self._clock = clock + self._cache_scope = str(getattr(db, "path", "db")) + + def _cache_version(self, group: str) -> int: + def run(conn: sqlite3.Connection) -> int: + row = conn.execute( + "SELECT v FROM kv WHERE k = ?", (f"cache:v:{group}",) + ).fetchone() + if row is None: + return 0 + try: + return int(str(row["v"])) + except ValueError: + return 0 + return self.db.read(run) + + def _cached(self, group: str, params: dict[str, Any], ttl: float, builder): + version = self._cache_version(group) + key = build_cache_key(self._cache_scope, group, version, params) + hit = DASHBOARD_CACHE.get(key) + if hit is not None: + return hit + value = builder() + DASHBOARD_CACHE.set(key, ttl, value) + return value # ------------------------------------------------------------ helpers @@ -358,6 +383,9 @@ class DashboardQueries: # ------------------------------------------------------------ overview def overview(self) -> dict[str, Any]: + return self._cached("videos", {}, TTL_VIDEOS, self._overview_uncached) + + def _overview_uncached(self) -> dict[str, Any]: def run(conn: sqlite3.Connection) -> dict[str, Any]: latest = self._latest_date(conn) snapshot_days = self._snapshot_days(conn) @@ -602,6 +630,38 @@ class DashboardQueries: q: str | None = None, page: int = 1, page_size: int = 20, + ) -> dict[str, Any]: + params = { + "sort": sort, + "within_days": within_days, + "growth_days": growth_days, + "folder_id": folder_id, + "q": q or "", + "page": page, + "page_size": page_size, + } + return self._cached( + "videos", params, TTL_VIDEOS, lambda: self._list_videos_uncached( + sort=sort, + within_days=within_days, + growth_days=growth_days, + folder_id=folder_id, + q=q, + page=page, + page_size=page_size, + ), + ) + + def _list_videos_uncached( + self, + *, + sort: str = "heat", + within_days: int | None = None, + growth_days: int = 7, + folder_id: int | None = None, + q: str | None = None, + page: int = 1, + page_size: int = 20, ) -> dict[str, Any]: if sort not in VIDEO_SORTS: raise ValueError(f"不支持的排序: {sort}") @@ -809,6 +869,35 @@ class DashboardQueries: min_videos: int = 1, page: int = 1, page_size: int = 20, + ) -> dict[str, Any]: + params = { + "sort": sort, + "within_days": within_days, + "q": q or "", + "min_videos": min_videos, + "page": page, + "page_size": page_size, + } + return self._cached( + "videos", params, TTL_VIDEOS, lambda: self._list_ups_uncached( + sort=sort, + within_days=within_days, + q=q, + min_videos=min_videos, + page=page, + page_size=page_size, + ), + ) + + def _list_ups_uncached( + self, + *, + sort: str = "avg_views", + within_days: int | None = None, + q: str | None = None, + min_videos: int = 1, + page: int = 1, + page_size: int = 20, ) -> dict[str, Any]: if sort not in UP_SORTS: raise ValueError(f"不支持的 UP 排序: {sort}") @@ -981,6 +1070,32 @@ class DashboardQueries: start: str | None = None, end: str | None = None, recent_days: int = 14, + ) -> dict[str, Any]: + params = { + "mid": mid if mid is not None else "", + "days": days, + "start": start or "", + "end": end or "", + "recent_days": recent_days, + } + return self._cached( + "fans", params, TTL_FANS, lambda: self._fans_uncached( + mid=mid, + days=days, + start=start, + end=end, + recent_days=recent_days, + ), + ) + + def _fans_uncached( + self, + mid: int | None = None, + *, + days: int = 90, + start: str | None = None, + end: str | None = None, + recent_days: int = 14, ) -> dict[str, Any]: if days < 1: raise ValueError("days 必须大于 0") diff --git a/reaction_board/web/static/app.js b/reaction_board/web/static/app.js index 88b72bf..2280cda 100644 --- a/reaction_board/web/static/app.js +++ b/reaction_board/web/static/app.js @@ -62,7 +62,7 @@ function icon(name) { } function renderNav() { - const nav = document.querySelector("#bottom-nav"); + const nav = document.querySelector("#nav-items"); const items = NAV_ITEMS.filter( (item) => !item.protected || canAccessProtected(), ); @@ -86,8 +86,9 @@ function activeNav(path) { function setTitle(route, path) { const title = route?.title || "看板"; document.title = `${title} · 咻咻满 Reaction`; - const element = document.querySelector("#topbar-title"); - if (element) element.textContent = title; + document.querySelectorAll(".brand-subtitle").forEach((element) => { + element.textContent = title; + }); activeNav(path); } @@ -113,8 +114,6 @@ window.addEventListener("error", (event) => { async function boot() { const banner = document.querySelector("#preview-banner"); - const hint = document.querySelector("#topbar-hint"); - const authLink = document.querySelector("#topbar-auth"); try { const data = await session(); authState = { @@ -123,10 +122,14 @@ async function boot() { }; if (data?.preview) { banner.hidden = false; - if (hint) hint.hidden = false; + document.querySelectorAll(".topbar-preview").forEach((element) => { + element.hidden = false; + }); } - if (authLink && authState.authEnabled && !authState.authenticated) { - authLink.hidden = false; + if (authState.authEnabled && !authState.authenticated) { + document.querySelectorAll(".topbar-auth").forEach((element) => { + element.hidden = false; + }); } } catch { toast("无法连接看板服务", "error"); diff --git a/reaction_board/web/static/index.html b/reaction_board/web/static/index.html index 3d8c704..278a998 100644 --- a/reaction_board/web/static/index.html +++ b/reaction_board/web/static/index.html @@ -12,25 +12,38 @@
-
+
- 咻 + 满 Reaction 看板 - 最新动态 + 最新动态 - - + +
- +
- + \ No newline at end of file diff --git a/reaction_board/web/static/style.css b/reaction_board/web/static/style.css index fef7888..2434893 100644 --- a/reaction_board/web/static/style.css +++ b/reaction_board/web/static/style.css @@ -123,6 +123,16 @@ h1, h2, h3, p { margin: 0; } color: var(--muted); font-size: 11px; } +.sidebar-topbar { + display: none; + flex-direction: column; + align-items: flex-start; + gap: 8px; + width: 100%; + padding: 0 4px 12px; + border-bottom: 1px solid var(--border); +} +.nav-items { display: flex; flex-direction: row; width: 100%; height: 100%; } .preview-banner { position: sticky; @@ -160,6 +170,7 @@ h1, h2, h3, p { margin: 0; } } .page-head h1 { font-size: 24px; letter-spacing: -0.4px; } .page-sub { margin-top: 4px; color: var(--muted); font-size: 12.5px; } +.page-head h1 .page-sub { margin-top: 0; margin-left: 8px; font-weight: 400; } .home-head { align-items: center; } .back-link { color: var(--muted); font-size: 13px; } .back-link:hover { color: var(--accent); } @@ -825,17 +836,12 @@ h1, h2, h3, p { margin: 0; } } @media (min-width: 900px) { - .topbar { - position: fixed; - left: var(--nav-width); - right: 0; - height: 64px; - padding: 0 28px; - } - .preview-banner { top: 64px; margin-left: var(--nav-width); } + .mobile-topbar { display: none; } + .sidebar-topbar { display: flex; } + .preview-banner { top: 0; margin-left: var(--nav-width); } .main-shell { margin-left: var(--nav-width); - padding: 82px 28px 40px; + padding: 16px 28px 40px; } .bottom-nav { top: 0; @@ -845,11 +851,15 @@ h1, h2, h3, p { margin: 0; } height: 100vh; flex-direction: column; justify-content: flex-start; - gap: 4px; - padding: 82px 12px 20px; + gap: 10px; + padding: 18px 12px 20px; border-top: 0; border-right: 1px solid var(--border); } + .nav-items { + flex-direction: column; + gap: 4px; + } .nav-item { flex: 0 0 auto; flex-direction: row; diff --git a/reaction_board/web/static/views/rankings.js b/reaction_board/web/static/views/rankings.js index 68f1d0b..1746b9e 100644 --- a/reaction_board/web/static/views/rankings.js +++ b/reaction_board/web/static/views/rankings.js @@ -126,10 +126,7 @@ export async function renderRankings(root, { query }) { root.innerHTML = `
-
-

榜单

-

共 ${formatCount(data.total)} ${scope === "up" ? "个 UP" : "条视频"}

-
+

榜单 共 ${formatCount(data.total)} ${scope === "up" ? "个 UP" : "条视频"}

${controls({ scope, sort, within, growthDays, q, folder, minVideos, folders })} ${data.available === false ? `
diff --git a/tests/test_cache.py b/tests/test_cache.py new file mode 100644 index 0000000..9d6a136 --- /dev/null +++ b/tests/test_cache.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +"""看板缓存与版本失效测试(离线): uv run python tests/test_cache.py""" + +import os +import sys +import tempfile +import time +from pathlib import Path + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +sys.stdout.reconfigure(encoding="utf-8") + +from reaction_board.database import Database # noqa: E402 +from reaction_board.repository import Repository # noqa: E402 +from reaction_board.web.cache import TTLCache, build_cache_key # noqa: E402 + + +def test_ttl_cache_expiry_and_params(): + now = [1000.0] + cache = TTLCache(clock=lambda: now[0], max_items=32) + key = build_cache_key("db", "videos", 0, {"sort": "heat", "page": 1}) + assert cache.get(key) is None + cache.set(key, 10.0, {"value": 1}) + now[0] += 5 + assert cache.get(key) == {"value": 1} + now[0] += 6 + assert cache.get(key) is None + + same = build_cache_key("db", "videos", 0, {"page": 1, "sort": "heat"}) + assert same == key + other = build_cache_key("db", "videos", 1, {"page": 1, "sort": "heat"}) + assert other != key + + +def test_ttl_cache_prunes_to_max_items(): + now = [0.0] + cache = TTLCache(clock=lambda: now[0], max_items=16) + for index in range(40): + cache.set(("k", index), 100.0, index) + assert len(cache) <= 16 + + +def test_repository_cache_version(): + with tempfile.TemporaryDirectory() as tmp: + db = Database(Path(tmp) / "app.db") + db.initialize() + try: + repo = Repository(db) + assert repo.cache_version("videos") == 0 + assert repo.bump_cache_version("videos") == 1 + assert repo.bump_cache_version("videos") == 2 + assert repo.cache_version("videos") == 2 + assert repo.cache_version("fans") == 0 + repo.bump_cache_version("fans") + assert repo.cache_version("fans") == 1 + finally: + db.close() + + +def main_test(): + test_ttl_cache_expiry_and_params() + test_ttl_cache_prunes_to_max_items() + test_repository_cache_version() + print("OK: 缓存 TTL/键/版本失效测试全部通过") + + +if __name__ == "__main__": + main_test() \ No newline at end of file diff --git a/tests/test_web_queries.py b/tests/test_web_queries.py index 05215cc..fe598d2 100644 --- a/tests/test_web_queries.py +++ b/tests/test_web_queries.py @@ -11,6 +11,8 @@ sys.stdout.reconfigure(encoding="utf-8") from reaction_board.config import ConfigStore # noqa: E402 from reaction_board.database import Database # noqa: E402 +from reaction_board.repository import Repository # noqa: E402 +from reaction_board.web.cache import DASHBOARD_CACHE # noqa: E402 from reaction_board.web.queries import DashboardQueries # noqa: E402 from tests.web_fixtures import DATES, build_db, write_config # noqa: E402 @@ -116,6 +118,7 @@ def test_growth_and_cold_start(): db.write(lambda conn, day=day: conn.execute( "DELETE FROM snapshot WHERE stat_date = ?", (day,) )) + DASHBOARD_CACHE.clear() cold = q.list_videos(sort="growth", growth_days=2) assert cold["available"] is False assert cold["items"] == [] @@ -197,6 +200,39 @@ def test_up_queries(): db.close() +def test_query_cache_invalidation(): + with tempfile.TemporaryDirectory() as tmp: + db, q = setup(tmp) + try: + DASHBOARD_CACHE.clear() + first = q.overview() + db.write(lambda conn: conn.execute( + "UPDATE snapshot SET \"view\" = 999999 WHERE aid=101 AND stat_date=?", + (DATES[-1],), + )) + cached = q.overview() + assert cached["totals"]["view"] == first["totals"]["view"] + + repo = Repository(db) + repo.bump_cache_version("videos") + refreshed = q.overview() + assert refreshed["totals"]["view"] > first["totals"]["view"] + + fans_first = q.fans(days=3) + db.write(lambda conn: conn.execute( + "UPDATE up_stat SET follower=999999 WHERE mid=37754047 AND stat_date=?", + (DATES[-1],), + )) + fans_cached = q.fans(days=3) + assert fans_cached["follower"] == fans_first["follower"] + + repo.bump_cache_version("fans") + fans_refreshed = q.fans(days=3) + assert fans_refreshed["follower"] == 999999 + finally: + db.close() + + def test_fans_custom_range_modes(): with tempfile.TemporaryDirectory() as tmp: db, q = setup(tmp) @@ -214,6 +250,7 @@ def test_fans_custom_range_modes(): "(stat_date, mid, follower, following, captured_at, ok) " "VALUES ('2025-01-01', 37754047, 500, NULL, 1, 1)" )) + DASHBOARD_CACHE.clear() monthly = q.fans(start="2025-01-01", end="2026-10-31") assert monthly["mode"] == "monthly" assert monthly["span_days"] == 669 @@ -229,6 +266,7 @@ def test_fans_custom_range_modes(): "VALUES ('2026-10-15', 37754047, 5000, NULL, 1, 1), " "('2026-10-16', 37754047, 5000, NULL, 1, 1)" )) + DASHBOARD_CACHE.clear() monthly = q.fans(start="2025-01-01", end="2026-10-31") assert monthly["dates"][-1] == "2026-10-16" assert monthly["series"][-1] == 5000 @@ -266,6 +304,7 @@ def test_fans_candidates_system_and_export(): "VALUES (?, ?, ?, NULL, 1, 1)", ("2026-10-01", 37754047, 900), )) + DASHBOARD_CACHE.clear() recent = q.fans(days=90) assert recent["recent_days"] == 14 assert [point["stat_date"] for point in recent["recent_points"]] == list(DATES) @@ -281,6 +320,7 @@ def test_fans_candidates_system_and_export(): "DELETE FROM up_stat WHERE mid=37754047 AND stat_date=?", ("2026-10-30",), )) + DASHBOARD_CACHE.clear() gapped = q.fans(days=3) assert gapped["delta_1d"] is None assert gapped["delta_7d"] is None @@ -312,6 +352,7 @@ def main_test(): test_growth_and_cold_start() test_video_detail_and_trend() test_up_queries() + test_query_cache_invalidation() test_fans_custom_range_modes() test_fans_candidates_system_and_export() print("OK: Web 查询层总览/榜单/详情/UP/粉丝/候选/系统测试全部通过") diff --git a/tools/import_fans_csv.py b/tools/import_fans_csv.py index 7c2f89f..0552986 100644 --- a/tools/import_fans_csv.py +++ b/tools/import_fans_csv.py @@ -202,6 +202,8 @@ def import_fans_series( ok=True, captured_at=_day_timestamp(date.fromisoformat(day)), ) + if not dry_run and (inserted or updated or deleted): + repo.bump_cache_version("fans") return { "ok": True, "dry_run": bool(dry_run),