feat(web): cache dashboard aggregations with version invalidation

This commit is contained in:
kairee committed 2026-11-01 03:27:49 +08:00
1 parent 91aa3e1f95
commit 9fd277c05f
15 files changed
+399 -30

No files matched your search

+69
View File
@@ -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)
+115
View File
@@ -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")
+11 -8
View File
@@ -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");
+20 -7
View File
@@ -12,25 +12,38 @@
</head>
<body>
<div class="app" id="app">
<header class="topbar">
<header class="topbar mobile-topbar">
<a class="brand" href="/home">
<span class="brand-mark">咻</span>
<span class="brand-mark">满</span>
<span class="brand-text">
<strong>Reaction 看板</strong>
<small id="topbar-title">最新动态</small>
<small class="brand-subtitle">最新动态</small>
</span>
</a>
<a class="topbar-hint" id="topbar-auth" href="/login" hidden>登录</a>
<span class="topbar-hint" id="topbar-hint" hidden>预览</span>
<a class="topbar-hint topbar-auth" href="/login" hidden>登录</a>
<span class="topbar-hint topbar-preview" hidden>预览</span>
</header>
<div class="preview-banner" id="preview-banner" hidden>预览数据 · 不影响真实数据库</div>
<main class="main-shell">
<div id="view"></div>
</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 class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="/static/vendor/echarts.min.js"></script>
<script type="module" src="/static/app.js"></script>
</body>
</html>
</html>
+21 -11
View File
@@ -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;
+1 -4
View File
@@ -126,10 +126,7 @@ export async function renderRankings(root, { query }) {
root.innerHTML = `<section class="page rankings-page">
<header class="page-head">
<div>
<h1>榜单</h1>
<p class="page-sub">共 ${formatCount(data.total)} ${scope === "up" ? "个 UP" : "条视频"}</p>
</div>
<h1>榜单 <span class="page-sub">共 ${formatCount(data.total)} ${scope === "up" ? "个 UP" : "条视频"}</span></h1>
</header>
${controls({ scope, sort, within, growthDays, q, folder, minVideos, folders })}
${data.available === false ? `<div class="notice-row warning">