feat(stats): buffer visits in memory and expose POST /api/visit + /api/stats

This commit is contained in:
kairee committed 2026-11-01 15:34:53 +08:00
1 parent cdd11823b9
commit 3c16caac1a
8 files changed
+905 -5

No files matched your search

+85
View File
@@ -1560,6 +1560,91 @@ class DashboardQueries:
}
return self.db.read(run)
# ------------------------------------------------------------ stats
def stats(
self,
*,
days: int = 30,
pending: Sequence[Mapping[str, Any]] | None = None,
) -> dict[str, Any]:
"""访问统计:区间内每日 PV/UV 与 Top 页面。
``pending`` 为内存缓冲中尚未落库的上报行;与已落库数据按
(日期, IP, 路由) 合并去重后统一聚合,因此页面基本是实时可见的。
"""
def run(conn: sqlite3.Connection) -> dict[str, Any]:
cfg = self.config_store.get()
today = self._today()
start = _date_minus(today, int(days) - 1)
rows: list[Mapping[str, Any]] = [
dict(row) for row in conn.execute(
"SELECT stat_date, ip, path, hits, first_at, last_at FROM visit "
"WHERE stat_date >= ? AND stat_date <= ?",
(start, today),
).fetchall()
]
if pending:
rows.extend(dict(row) for row in pending)
buckets: dict[tuple[str, str, str], list[int]] = {}
for row in rows:
day = str(row["stat_date"])
ip = str(row["ip"])
path = str(row["path"])
hits = int(row.get("hits") or 0)
first = int(row.get("first_at") or 0)
last = int(row.get("last_at") or 0)
bucket = buckets.get((day, ip, path))
if bucket is None:
buckets[(day, ip, path)] = [hits, first, last]
else:
bucket[0] += hits
bucket[1] = min(bucket[1], first) if first else bucket[1]
bucket[2] = max(bucket[2], last)
dates = _date_range(start, today)
daily_pv = {day: 0 for day in dates}
daily_uv: dict[str, set[str]] = {day: set() for day in dates}
page_pv: dict[str, int] = {}
page_uv: dict[str, set[str]] = {}
total_pv = 0
total_ips: set[str] = set()
for (day, ip, path), bucket in buckets.items():
if day not in daily_pv:
continue
daily_pv[day] += bucket[0]
daily_uv[day].add(ip)
total_pv += bucket[0]
total_ips.add(ip)
page_pv[path] = page_pv.get(path, 0) + bucket[0]
page_uv.setdefault(path, set()).add(ip)
top_pages = [
{"path": path, "pv": hits, "uv": len(page_uv[path])}
for path, hits in sorted(
page_pv.items(), key=lambda kv: (-kv[1], kv[0])
)[:10]
]
return {
"enabled": bool(cfg.web.track.enabled),
"days": int(days),
"range": {"start": start, "end": today},
"today": today,
"today_pv": daily_pv.get(today, 0),
"today_uv": len(daily_uv.get(today, set())),
"total_pv": total_pv,
"total_uv": len(total_ips),
"series": {
"dates": dates,
"pv": [daily_pv[day] for day in dates],
"uv": [len(daily_uv[day]) for day in dates],
},
"top_pages": top_pages,
"pending": bool(pending),
}
return self.db.read(run)
def candidates(self) -> dict[str, Any]:
def run(conn: sqlite3.Connection) -> dict[str, Any]:
cfg = self.config_store.get()