feat(web): cache dashboard aggregations with version invalidation
This commit is contained in:
1 parent
91aa3e1f95
commit
9fd277c05f
15 files changed
+399
-30
No files matched your search
@@ -433,6 +433,8 @@ def cmd_run(args, logger: JsonLogger, *, serve_web: bool = False) -> int:
|
|||||||
ttl_days=cfg.schedule.candidate_ttl_days
|
ttl_days=cfg.schedule.candidate_ttl_days
|
||||||
)
|
)
|
||||||
expired = repo.expire_fav_actions()
|
expired = repo.expire_fav_actions()
|
||||||
|
if removed or expired:
|
||||||
|
repo.bump_cache_version("videos")
|
||||||
logger.info(
|
logger.info(
|
||||||
"cleanup.done",
|
"cleanup.done",
|
||||||
candidates_removed=removed, fav_actions_expired=expired,
|
candidates_removed=removed, fav_actions_expired=expired,
|
||||||
|
|||||||
@@ -555,6 +555,43 @@ class Repository:
|
|||||||
(key, str(value)),
|
(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
|
# ------------------------------------------------------------ candidate
|
||||||
|
|
||||||
def known_aid(self, aid: int) -> bool:
|
def known_aid(self, aid: int) -> bool:
|
||||||
|
|||||||
@@ -106,6 +106,8 @@ class DiscoveryService:
|
|||||||
self._handle_item(item, channel, cfg, summary, pending, now)
|
self._handle_item(item, channel, cfg, summary, pending, now)
|
||||||
|
|
||||||
self._notify(pending, summary, now)
|
self._notify(pending, summary, now)
|
||||||
|
if summary.candidates or summary.notified:
|
||||||
|
self.repo.bump_cache_version("videos")
|
||||||
summary.finished_at = self._now()
|
summary.finished_at = self._now()
|
||||||
self._log(
|
self._log(
|
||||||
"info" if not summary.errors else "warning",
|
"info" if not summary.errors else "warning",
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ class FavoriteActionService:
|
|||||||
aid=aid, folder_id=folder.media_id, position=position,
|
aid=aid, folder_id=folder.media_id, position=position,
|
||||||
already=already,
|
already=already,
|
||||||
)
|
)
|
||||||
|
self.repo.bump_cache_version("videos")
|
||||||
return ActionResult(
|
return ActionResult(
|
||||||
ok=True, aid=aid, action="favorite", state="done",
|
ok=True, aid=aid, action="favorite", state="done",
|
||||||
position=position, already_in_folder=already,
|
position=position, already_in_folder=already,
|
||||||
@@ -201,6 +202,7 @@ class FavoriteActionService:
|
|||||||
message="候选不存在或已处理",
|
message="候选不存在或已处理",
|
||||||
)
|
)
|
||||||
self.repo.mark_candidate_decision(aid, "rejected", now=self._now())
|
self.repo.mark_candidate_decision(aid, "rejected", now=self._now())
|
||||||
|
self.repo.bump_cache_version("videos")
|
||||||
return ActionResult(
|
return ActionResult(
|
||||||
ok=True, aid=aid, action="ignore", state="rejected",
|
ok=True, aid=aid, action="ignore", state="rejected",
|
||||||
message="已忽略",
|
message="已忽略",
|
||||||
@@ -228,6 +230,7 @@ class FavoriteActionService:
|
|||||||
)
|
)
|
||||||
rejected = self.repo.reject_pending_for_up(up_mid, now=self._now())
|
rejected = self.repo.reject_pending_for_up(up_mid, now=self._now())
|
||||||
self.repo.mark_candidate_decision(aid, "rejected", now=self._now())
|
self.repo.mark_candidate_decision(aid, "rejected", now=self._now())
|
||||||
|
self.repo.bump_cache_version("videos")
|
||||||
return ActionResult(
|
return ActionResult(
|
||||||
ok=True, aid=aid, action="blacklist", state="rejected",
|
ok=True, aid=aid, action="blacklist", state="rejected",
|
||||||
message=f"已拉黑 UP,并忽略该 UP 的 {rejected} 条待决策候选",
|
message=f"已拉黑 UP,并忽略该 UP 的 {rejected} 条待决策候选",
|
||||||
|
|||||||
@@ -200,6 +200,8 @@ class FolderSyncService:
|
|||||||
errors=len(summary.errors),
|
errors=len(summary.errors),
|
||||||
elapsed=max(0, summary.finished_at - summary.started_at),
|
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
|
return summary
|
||||||
|
|
||||||
def _sync_folder(
|
def _sync_folder(
|
||||||
|
|||||||
@@ -284,6 +284,11 @@ class SnapshotService:
|
|||||||
|
|
||||||
self._run_backup(cfg, stat_date, summary)
|
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()
|
summary.finished_at = self._now()
|
||||||
self._log(
|
self._log(
|
||||||
"info" if summary.completed else "warning",
|
"info" if summary.completed else "warning",
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""看板聚合结果的内存 TTL 缓存。
|
||||||
|
|
||||||
|
缓存键包含数据库路径和数据版本;版本存放在 ``kv``(``cache:v:<group>``),
|
||||||
|
因此同一个进程内的调度任务和 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)
|
||||||
@@ -28,6 +28,7 @@ from ..analytics import (
|
|||||||
from ..config import ConfigStore
|
from ..config import ConfigStore
|
||||||
from ..database import Database
|
from ..database import Database
|
||||||
from ..timeutil import day_bounds, next_daily_run, shanghai_date, shanghai_datetime
|
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")
|
TRACKED_STATUSES = ("active", "unfaved")
|
||||||
VIDEO_SORTS = {
|
VIDEO_SORTS = {
|
||||||
@@ -179,6 +180,30 @@ class DashboardQueries:
|
|||||||
self.db = db
|
self.db = db
|
||||||
self.config_store = config_store
|
self.config_store = config_store
|
||||||
self._clock = clock
|
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
|
# ------------------------------------------------------------ helpers
|
||||||
|
|
||||||
@@ -358,6 +383,9 @@ class DashboardQueries:
|
|||||||
# ------------------------------------------------------------ overview
|
# ------------------------------------------------------------ overview
|
||||||
|
|
||||||
def overview(self) -> dict[str, Any]:
|
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]:
|
def run(conn: sqlite3.Connection) -> dict[str, Any]:
|
||||||
latest = self._latest_date(conn)
|
latest = self._latest_date(conn)
|
||||||
snapshot_days = self._snapshot_days(conn)
|
snapshot_days = self._snapshot_days(conn)
|
||||||
@@ -602,6 +630,38 @@ class DashboardQueries:
|
|||||||
q: str | None = None,
|
q: str | None = None,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
page_size: int = 20,
|
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]:
|
) -> dict[str, Any]:
|
||||||
if sort not in VIDEO_SORTS:
|
if sort not in VIDEO_SORTS:
|
||||||
raise ValueError(f"不支持的排序: {sort}")
|
raise ValueError(f"不支持的排序: {sort}")
|
||||||
@@ -809,6 +869,35 @@ class DashboardQueries:
|
|||||||
min_videos: int = 1,
|
min_videos: int = 1,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
page_size: int = 20,
|
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]:
|
) -> dict[str, Any]:
|
||||||
if sort not in UP_SORTS:
|
if sort not in UP_SORTS:
|
||||||
raise ValueError(f"不支持的 UP 排序: {sort}")
|
raise ValueError(f"不支持的 UP 排序: {sort}")
|
||||||
@@ -981,6 +1070,32 @@ class DashboardQueries:
|
|||||||
start: str | None = None,
|
start: str | None = None,
|
||||||
end: str | None = None,
|
end: str | None = None,
|
||||||
recent_days: int = 14,
|
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]:
|
) -> dict[str, Any]:
|
||||||
if days < 1:
|
if days < 1:
|
||||||
raise ValueError("days 必须大于 0")
|
raise ValueError("days 必须大于 0")
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ function icon(name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderNav() {
|
function renderNav() {
|
||||||
const nav = document.querySelector("#bottom-nav");
|
const nav = document.querySelector("#nav-items");
|
||||||
const items = NAV_ITEMS.filter(
|
const items = NAV_ITEMS.filter(
|
||||||
(item) => !item.protected || canAccessProtected(),
|
(item) => !item.protected || canAccessProtected(),
|
||||||
);
|
);
|
||||||
@@ -86,8 +86,9 @@ function activeNav(path) {
|
|||||||
function setTitle(route, path) {
|
function setTitle(route, path) {
|
||||||
const title = route?.title || "看板";
|
const title = route?.title || "看板";
|
||||||
document.title = `${title} · 咻咻满 Reaction`;
|
document.title = `${title} · 咻咻满 Reaction`;
|
||||||
const element = document.querySelector("#topbar-title");
|
document.querySelectorAll(".brand-subtitle").forEach((element) => {
|
||||||
if (element) element.textContent = title;
|
element.textContent = title;
|
||||||
|
});
|
||||||
activeNav(path);
|
activeNav(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,8 +114,6 @@ window.addEventListener("error", (event) => {
|
|||||||
|
|
||||||
async function boot() {
|
async function boot() {
|
||||||
const banner = document.querySelector("#preview-banner");
|
const banner = document.querySelector("#preview-banner");
|
||||||
const hint = document.querySelector("#topbar-hint");
|
|
||||||
const authLink = document.querySelector("#topbar-auth");
|
|
||||||
try {
|
try {
|
||||||
const data = await session();
|
const data = await session();
|
||||||
authState = {
|
authState = {
|
||||||
@@ -123,10 +122,14 @@ async function boot() {
|
|||||||
};
|
};
|
||||||
if (data?.preview) {
|
if (data?.preview) {
|
||||||
banner.hidden = false;
|
banner.hidden = false;
|
||||||
if (hint) hint.hidden = false;
|
document.querySelectorAll(".topbar-preview").forEach((element) => {
|
||||||
|
element.hidden = false;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (authLink && authState.authEnabled && !authState.authenticated) {
|
if (authState.authEnabled && !authState.authenticated) {
|
||||||
authLink.hidden = false;
|
document.querySelectorAll(".topbar-auth").forEach((element) => {
|
||||||
|
element.hidden = false;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast("无法连接看板服务", "error");
|
toast("无法连接看板服务", "error");
|
||||||
|
|||||||
@@ -12,25 +12,38 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app" id="app">
|
<div class="app" id="app">
|
||||||
<header class="topbar">
|
<header class="topbar mobile-topbar">
|
||||||
<a class="brand" href="/home">
|
<a class="brand" href="/home">
|
||||||
<span class="brand-mark">咻</span>
|
<span class="brand-mark">满</span>
|
||||||
<span class="brand-text">
|
<span class="brand-text">
|
||||||
<strong>Reaction 看板</strong>
|
<strong>Reaction 看板</strong>
|
||||||
<small id="topbar-title">最新动态</small>
|
<small class="brand-subtitle">最新动态</small>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
<a class="topbar-hint" id="topbar-auth" href="/login" hidden>登录</a>
|
<a class="topbar-hint topbar-auth" href="/login" hidden>登录</a>
|
||||||
<span class="topbar-hint" id="topbar-hint" hidden>预览</span>
|
<span class="topbar-hint topbar-preview" hidden>预览</span>
|
||||||
</header>
|
</header>
|
||||||
<div class="preview-banner" id="preview-banner" hidden>预览数据 · 不影响真实数据库</div>
|
<div class="preview-banner" id="preview-banner" hidden>预览数据 · 不影响真实数据库</div>
|
||||||
<main class="main-shell">
|
<main class="main-shell">
|
||||||
<div id="view"></div>
|
<div id="view"></div>
|
||||||
</main>
|
</main>
|
||||||
<nav class="bottom-nav" id="bottom-nav" aria-label="主导航"></nav>
|
<nav class="bottom-nav" id="bottom-nav" aria-label="主导航">
|
||||||
|
<div class="sidebar-topbar">
|
||||||
|
<a class="brand" href="/home">
|
||||||
|
<span class="brand-mark">满</span>
|
||||||
|
<span class="brand-text">
|
||||||
|
<strong>Reaction 看板</strong>
|
||||||
|
<small class="brand-subtitle">最新动态</small>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<a class="topbar-hint topbar-auth" href="/login" hidden>登录</a>
|
||||||
|
<span class="topbar-hint topbar-preview" hidden>预览</span>
|
||||||
|
</div>
|
||||||
|
<div class="nav-items" id="nav-items"></div>
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
||||||
<script src="/static/vendor/echarts.min.js"></script>
|
<script src="/static/vendor/echarts.min.js"></script>
|
||||||
<script type="module" src="/static/app.js"></script>
|
<script type="module" src="/static/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -123,6 +123,16 @@ h1, h2, h3, p { margin: 0; }
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 11px;
|
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 {
|
.preview-banner {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
@@ -160,6 +170,7 @@ h1, h2, h3, p { margin: 0; }
|
|||||||
}
|
}
|
||||||
.page-head h1 { font-size: 24px; letter-spacing: -0.4px; }
|
.page-head h1 { font-size: 24px; letter-spacing: -0.4px; }
|
||||||
.page-sub { margin-top: 4px; color: var(--muted); font-size: 12.5px; }
|
.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; }
|
.home-head { align-items: center; }
|
||||||
.back-link { color: var(--muted); font-size: 13px; }
|
.back-link { color: var(--muted); font-size: 13px; }
|
||||||
.back-link:hover { color: var(--accent); }
|
.back-link:hover { color: var(--accent); }
|
||||||
@@ -825,17 +836,12 @@ h1, h2, h3, p { margin: 0; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 900px) {
|
@media (min-width: 900px) {
|
||||||
.topbar {
|
.mobile-topbar { display: none; }
|
||||||
position: fixed;
|
.sidebar-topbar { display: flex; }
|
||||||
left: var(--nav-width);
|
.preview-banner { top: 0; margin-left: var(--nav-width); }
|
||||||
right: 0;
|
|
||||||
height: 64px;
|
|
||||||
padding: 0 28px;
|
|
||||||
}
|
|
||||||
.preview-banner { top: 64px; margin-left: var(--nav-width); }
|
|
||||||
.main-shell {
|
.main-shell {
|
||||||
margin-left: var(--nav-width);
|
margin-left: var(--nav-width);
|
||||||
padding: 82px 28px 40px;
|
padding: 16px 28px 40px;
|
||||||
}
|
}
|
||||||
.bottom-nav {
|
.bottom-nav {
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -845,11 +851,15 @@ h1, h2, h3, p { margin: 0; }
|
|||||||
height: 100vh;
|
height: 100vh;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
gap: 4px;
|
gap: 10px;
|
||||||
padding: 82px 12px 20px;
|
padding: 18px 12px 20px;
|
||||||
border-top: 0;
|
border-top: 0;
|
||||||
border-right: 1px solid var(--border);
|
border-right: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
.nav-items {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
.nav-item {
|
.nav-item {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
|||||||
@@ -126,10 +126,7 @@ export async function renderRankings(root, { query }) {
|
|||||||
|
|
||||||
root.innerHTML = `<section class="page rankings-page">
|
root.innerHTML = `<section class="page rankings-page">
|
||||||
<header class="page-head">
|
<header class="page-head">
|
||||||
<div>
|
<h1>榜单 <span class="page-sub">共 ${formatCount(data.total)} ${scope === "up" ? "个 UP" : "条视频"}</span></h1>
|
||||||
<h1>榜单</h1>
|
|
||||||
<p class="page-sub">共 ${formatCount(data.total)} ${scope === "up" ? "个 UP" : "条视频"}</p>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
${controls({ scope, sort, within, growthDays, q, folder, minVideos, folders })}
|
${controls({ scope, sort, within, growthDays, q, folder, minVideos, folders })}
|
||||||
${data.available === false ? `<div class="notice-row warning">
|
${data.available === false ? `<div class="notice-row warning">
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -11,6 +11,8 @@ sys.stdout.reconfigure(encoding="utf-8")
|
|||||||
|
|
||||||
from reaction_board.config import ConfigStore # noqa: E402
|
from reaction_board.config import ConfigStore # noqa: E402
|
||||||
from reaction_board.database import Database # 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 reaction_board.web.queries import DashboardQueries # noqa: E402
|
||||||
from tests.web_fixtures import DATES, build_db, write_config # 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(
|
db.write(lambda conn, day=day: conn.execute(
|
||||||
"DELETE FROM snapshot WHERE stat_date = ?", (day,)
|
"DELETE FROM snapshot WHERE stat_date = ?", (day,)
|
||||||
))
|
))
|
||||||
|
DASHBOARD_CACHE.clear()
|
||||||
cold = q.list_videos(sort="growth", growth_days=2)
|
cold = q.list_videos(sort="growth", growth_days=2)
|
||||||
assert cold["available"] is False
|
assert cold["available"] is False
|
||||||
assert cold["items"] == []
|
assert cold["items"] == []
|
||||||
@@ -197,6 +200,39 @@ def test_up_queries():
|
|||||||
db.close()
|
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():
|
def test_fans_custom_range_modes():
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
db, q = setup(tmp)
|
db, q = setup(tmp)
|
||||||
@@ -214,6 +250,7 @@ def test_fans_custom_range_modes():
|
|||||||
"(stat_date, mid, follower, following, captured_at, ok) "
|
"(stat_date, mid, follower, following, captured_at, ok) "
|
||||||
"VALUES ('2025-01-01', 37754047, 500, NULL, 1, 1)"
|
"VALUES ('2025-01-01', 37754047, 500, NULL, 1, 1)"
|
||||||
))
|
))
|
||||||
|
DASHBOARD_CACHE.clear()
|
||||||
monthly = q.fans(start="2025-01-01", end="2026-10-31")
|
monthly = q.fans(start="2025-01-01", end="2026-10-31")
|
||||||
assert monthly["mode"] == "monthly"
|
assert monthly["mode"] == "monthly"
|
||||||
assert monthly["span_days"] == 669
|
assert monthly["span_days"] == 669
|
||||||
@@ -229,6 +266,7 @@ def test_fans_custom_range_modes():
|
|||||||
"VALUES ('2026-10-15', 37754047, 5000, NULL, 1, 1), "
|
"VALUES ('2026-10-15', 37754047, 5000, NULL, 1, 1), "
|
||||||
"('2026-10-16', 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")
|
monthly = q.fans(start="2025-01-01", end="2026-10-31")
|
||||||
assert monthly["dates"][-1] == "2026-10-16"
|
assert monthly["dates"][-1] == "2026-10-16"
|
||||||
assert monthly["series"][-1] == 5000
|
assert monthly["series"][-1] == 5000
|
||||||
@@ -266,6 +304,7 @@ def test_fans_candidates_system_and_export():
|
|||||||
"VALUES (?, ?, ?, NULL, 1, 1)",
|
"VALUES (?, ?, ?, NULL, 1, 1)",
|
||||||
("2026-10-01", 37754047, 900),
|
("2026-10-01", 37754047, 900),
|
||||||
))
|
))
|
||||||
|
DASHBOARD_CACHE.clear()
|
||||||
recent = q.fans(days=90)
|
recent = q.fans(days=90)
|
||||||
assert recent["recent_days"] == 14
|
assert recent["recent_days"] == 14
|
||||||
assert [point["stat_date"] for point in recent["recent_points"]] == list(DATES)
|
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=?",
|
"DELETE FROM up_stat WHERE mid=37754047 AND stat_date=?",
|
||||||
("2026-10-30",),
|
("2026-10-30",),
|
||||||
))
|
))
|
||||||
|
DASHBOARD_CACHE.clear()
|
||||||
gapped = q.fans(days=3)
|
gapped = q.fans(days=3)
|
||||||
assert gapped["delta_1d"] is None
|
assert gapped["delta_1d"] is None
|
||||||
assert gapped["delta_7d"] is None
|
assert gapped["delta_7d"] is None
|
||||||
@@ -312,6 +352,7 @@ def main_test():
|
|||||||
test_growth_and_cold_start()
|
test_growth_and_cold_start()
|
||||||
test_video_detail_and_trend()
|
test_video_detail_and_trend()
|
||||||
test_up_queries()
|
test_up_queries()
|
||||||
|
test_query_cache_invalidation()
|
||||||
test_fans_custom_range_modes()
|
test_fans_custom_range_modes()
|
||||||
test_fans_candidates_system_and_export()
|
test_fans_candidates_system_and_export()
|
||||||
print("OK: Web 查询层总览/榜单/详情/UP/粉丝/候选/系统测试全部通过")
|
print("OK: Web 查询层总览/榜单/详情/UP/粉丝/候选/系统测试全部通过")
|
||||||
|
|||||||
@@ -202,6 +202,8 @@ def import_fans_series(
|
|||||||
ok=True,
|
ok=True,
|
||||||
captured_at=_day_timestamp(date.fromisoformat(day)),
|
captured_at=_day_timestamp(date.fromisoformat(day)),
|
||||||
)
|
)
|
||||||
|
if not dry_run and (inserted or updated or deleted):
|
||||||
|
repo.bump_cache_version("fans")
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"dry_run": bool(dry_run),
|
"dry_run": bool(dry_run),
|
||||||
|
|||||||
Reference in new issue
Block a user