feat(stats): buffer visits in memory and expose POST /api/visit + /api/stats
This commit is contained in:
1 parent
cdd11823b9
commit
3c16caac1a
8 files changed
+905
-5
No files matched your search
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user