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

+84
View File
@@ -16,6 +16,12 @@ from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Mapping
from .client_ip import (
DEFAULT_CLIENT_IP_HEADERS,
DEFAULT_TRUSTED_PROXIES,
parse_networks,
)
DEFAULT_CLASSIFY: dict[str, Any] = {
"require_title": "咻咻满",
"self_mid": 37754047,
@@ -57,6 +63,15 @@ DEFAULT_CONFIG: dict[str, Any] = {
"port": 8080,
"auth_token": "",
"public_base_url": "",
"track": {
"enabled": True,
"header_order": list(DEFAULT_CLIENT_IP_HEADERS),
"trusted_proxies": list(DEFAULT_TRUSTED_PROXIES),
"count_bots": False,
"flush_seconds": 60,
"keep_days": 180,
"max_hits_per_ip_path_per_day": 0,
},
},
"storage": {
"database_path": "data/app.db",
@@ -130,6 +145,18 @@ class WebConfig:
port: int
auth_token: str
public_base_url: str
track: "TrackConfig"
@dataclass(frozen=True)
class TrackConfig:
enabled: bool
header_order: tuple[str, ...]
trusted_proxies: tuple[str, ...]
count_bots: bool
flush_seconds: int
keep_days: int
max_hits_per_ip_path_per_day: int
@dataclass(frozen=True)
@@ -270,6 +297,30 @@ def _as_str_list(value: Any, name: str) -> list[str]:
return out
def _as_str_tuple(value: Any, name: str, *, allow_empty: bool) -> tuple[str, ...]:
if not isinstance(value, list):
raise ConfigError(f"{name} 必须是数组")
out: list[str] = []
for item in value:
if not isinstance(item, str) or not item.strip():
raise ConfigError(f"{name}[] 必须是非空字符串")
text = item.strip()
if text not in out:
out.append(text)
if not allow_empty and not out:
raise ConfigError(f"{name} 不能为空")
return tuple(out)
def _validate_networks(value: Any, name: str) -> tuple[str, ...]:
"""校验 CIDR / 裸 IP 列表,任意非法项都视为配置错误。"""
texts = _as_str_tuple(value, name, allow_empty=True)
parsed = parse_networks(texts)
if len(parsed) != len(texts):
raise ConfigError(f"{name} 含非法 IP/CIDR")
return tuple(network.with_prefixlen for network in parsed)
def _validate_folders(value: Any) -> tuple[FolderConfig, ...]:
if not isinstance(value, list):
raise ConfigError("folders 必须是数组")
@@ -321,6 +372,38 @@ def _build_config(data: Mapping[str, Any], path: Path) -> AppConfig:
web_raw = data["web"]
if not isinstance(web_raw, Mapping):
raise ConfigError("web 必须是对象")
track_raw = web_raw.get("track")
if track_raw is not None and not isinstance(track_raw, Mapping):
raise ConfigError("web.track 必须是对象")
track_raw = track_raw or {}
track = TrackConfig(
enabled=bool(track_raw.get("enabled", True)),
header_order=_as_str_tuple(
track_raw.get("header_order", list(DEFAULT_CLIENT_IP_HEADERS)),
"web.track.header_order",
allow_empty=False,
),
trusted_proxies=_validate_networks(
track_raw.get("trusted_proxies", list(DEFAULT_TRUSTED_PROXIES)),
"web.track.trusted_proxies",
),
count_bots=bool(track_raw.get("count_bots", False)),
flush_seconds=_as_int(
track_raw.get("flush_seconds", 60),
"web.track.flush_seconds",
minimum=0,
),
keep_days=_as_int(
track_raw.get("keep_days", 180),
"web.track.keep_days",
minimum=1,
),
max_hits_per_ip_path_per_day=_as_int(
track_raw.get("max_hits_per_ip_path_per_day", 0),
"web.track.max_hits_per_ip_path_per_day",
minimum=0,
),
)
web = WebConfig(
host=_as_str(web_raw.get("host", "127.0.0.1"), "web.host"),
port=_as_int(web_raw.get("port", 8080), "web.port", minimum=1, maximum=65535),
@@ -328,6 +411,7 @@ def _build_config(data: Mapping[str, Any], path: Path) -> AppConfig:
public_base_url=_as_str(
web_raw.get("public_base_url", "") or "", "web.public_base_url"
),
track=track,
)
storage_raw = data["storage"]