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

+118 -2
View File
@@ -21,15 +21,20 @@ from typing import Any, Callable, Mapping
from urllib.parse import parse_qs, quote, unquote, urlsplit
from ..analytics import is_loopback
from ..client_ip import extract_client_ip, is_bot, normalize_route, parse_networks
from ..config import ConfigStore
from ..database import Database
from .queries import DashboardQueries
from .visits import VisitCollector
STATIC_ROOT = Path(__file__).resolve().parent / "static"
COOKIE_NAME = "rb_session"
SESSION_TTL = 7 * 86400
PROTECTED_API = {"/api/candidates", "/api/system"}
PROTECTED_API = {"/api/candidates", "/api/system", "/api/stats"}
PROTECTED_PAGES = {"/candidates", "/system"}
# 显式放行的 POST 端点:访问统计埋点(业务写操作仍统一 405)。
POST_API = {"/api/visit"}
VISIT_BODY_LIMIT = 2048
class ApiError(Exception):
@@ -49,6 +54,7 @@ class WebContext:
clock: Callable[[], float] = time.time
db_path: str | None = None
schema_version: int | None = None
visits: VisitCollector | None = None
@property
def auth_token(self) -> str:
@@ -63,6 +69,13 @@ class DashboardHTTPServer(ThreadingHTTPServer):
self.context = context
super().__init__(address, DashboardHandler)
def server_close(self) -> None:
# 关闭前停止访问统计刷新线程,保证最后一次缓冲落库。
collector = getattr(self.context, "visits", None)
if collector is not None:
collector.stop()
super().server_close()
class DashboardHandler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
@@ -142,6 +155,16 @@ class DashboardHandler(BaseHTTPRequestHandler):
self._send_error(HTTPStatus.UNAUTHORIZED, "unauthorized", "需要登录")
return
if method == "POST":
if path in POST_API:
self._api_post(path, query)
return
self._send_error(
HTTPStatus.METHOD_NOT_ALLOWED,
"method_not_allowed",
"不支持该操作",
)
return
if path in POST_API:
self._send_error(
HTTPStatus.METHOD_NOT_ALLOWED,
"method_not_allowed",
@@ -285,6 +308,61 @@ border-color:#3a2c22;color:#f3e9dc}button{background:#e9b97c;color:#2a1a10}}
# ------------------------------------------------------------ API
def _api_post(self, path: str, query: Mapping[str, list[str]]) -> None:
if path == "/api/visit":
self._visit_post(query)
return
raise ApiError(HTTPStatus.METHOD_NOT_ALLOWED, "method_not_allowed", "不支持该操作")
def _visit_post(self, query: Mapping[str, list[str]]) -> None:
"""记录一次页面浏览;前端 sendBeacon 上报,写内存后定期批量落库。"""
track = self.context.config_store.get().web.track
collector = self.context.visits
if not track.enabled or collector is None:
self._send_json(HTTPStatus.OK, {"recorded": False, "reason": "disabled"})
return
path_value = _param(query, "path", "")
if not path_value:
body = self._read_small_body()
if body:
path_value = _json_path(body)
user_agent = self.headers.get("User-Agent")
if not track.count_bots and is_bot(user_agent):
self._send_json(HTTPStatus.OK, {"recorded": False, "reason": "bot"})
return
peer = self.client_address[0] if self.client_address else None
ip = extract_client_ip(
self.headers,
peer,
trusted_proxies=parse_networks(track.trusted_proxies),
header_order=track.header_order,
)
if not ip:
self._send_json(HTTPStatus.OK, {"recorded": False, "reason": "no_ip"})
return
route = normalize_route(path_value)
recorded = collector.record(ip, route)
self._send_json(HTTPStatus.OK, {
"recorded": bool(recorded),
"route": route,
"reason": None if recorded else "capped",
})
def _read_small_body(self) -> bytes:
try:
length = int(self.headers.get("Content-Length", "0"))
except (TypeError, ValueError):
length = 0
if length <= 0:
return b""
if length > VISIT_BODY_LIMIT:
raise ApiError(HTTPStatus.BAD_REQUEST, "bad_request", "请求体过大")
return self.rfile.read(length)
def _api(self, path: str, query: Mapping[str, list[str]], method: str) -> None:
queries = self.context.queries
if path == "/api/overview":
@@ -392,6 +470,16 @@ border-color:#3a2c22;color:#f3e9dc}button{background:#e9b97c;color:#2a1a10}}
if path == "/api/about":
self._send_json(HTTPStatus.OK, queries.about())
return
if path == "/api/stats":
pending = (
self.context.visits.pending_rows()
if self.context.visits is not None else []
)
self._send_json(HTTPStatus.OK, queries.stats(
days=_int_param(query, "days", 30, 1, 3650),
pending=pending,
))
return
if path == "/api/candidates":
self._send_json(HTTPStatus.OK, queries.candidates())
return
@@ -510,6 +598,22 @@ def _safe_next(value: str | None) -> str:
return text
def _json_path(body: bytes) -> str:
"""从 JSON(或表单编码)请求体里取 ``path`` 字段;失败返回空串。"""
text = body.decode("utf-8", errors="replace").strip()
if not text:
return ""
try:
data = json.loads(text)
except (ValueError, TypeError):
data = None
if isinstance(data, Mapping):
value = data.get("path")
return value if isinstance(value, str) else ""
values = parse_qs(text).get("path")
return values[0] if values else ""
def _param(query: Mapping[str, list[str]], name: str, default: str) -> str:
values = query.get(name)
return default if not values else str(values[0])
@@ -604,6 +708,15 @@ def create_server(
)
if preview and not _preview_marker(db):
raise ValueError("目标数据库不是预览库(缺少 preview:seeded=1 标记)")
track = cfg.web.track
visits = VisitCollector(
db,
clock=clock,
flush_seconds=track.flush_seconds,
keep_days=track.keep_days,
max_hits_per_key=track.max_hits_per_ip_path_per_day,
logger=logger,
)
context = WebContext(
queries=DashboardQueries(db, config_store, clock=clock),
config_store=config_store,
@@ -612,8 +725,11 @@ def create_server(
clock=clock,
db_path=str(db.path),
schema_version=db.schema_version,
visits=visits,
)
return DashboardHTTPServer((bind_host, bind_port), context)
server = DashboardHTTPServer((bind_host, bind_port), context)
visits.start()
return server
def serve_forever(server: DashboardHTTPServer, *, logger: Any = None) -> None: