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
@@ -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"]
|
||||
|
||||
Reference in new issue
Block a user