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

+70 -1
View File
@@ -172,13 +172,82 @@ def test_analytics_defaults_and_validation():
raise AssertionError(f"非法 analytics 必须拒绝: {bad}")
def test_web_track_defaults_and_validation():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "config.json"
write(path, base_config())
cfg = load_config(path, env={})
track = cfg.web.track
assert track.enabled is True
assert track.header_order == ("X-Real-IP", "X-Forwarded-For")
assert "172.16.0.0/12" in track.trusted_proxies
assert track.count_bots is False
assert track.flush_seconds == 60
assert track.keep_days == 180
assert track.max_hits_per_ip_path_per_day == 0
data = base_config()
data["web"] = {
"host": "0.0.0.0", "port": 8080, "auth_token": "t",
"public_base_url": "",
"track": {
"enabled": False,
"header_order": ["X-Forwarded-For", "X-Real-IP", "X-Real-IP"],
"trusted_proxies": ["10.0.0.0/8", "192.168.1.5"],
"count_bots": True,
"flush_seconds": 0,
"keep_days": 30,
"max_hits_per_ip_path_per_day": 500,
},
}
write(path, data)
cfg = load_config(path, env={})
assert cfg.web.track.enabled is False
# 去重且保持顺序
assert cfg.web.track.header_order == ("X-Forwarded-For", "X-Real-IP")
assert cfg.web.track.trusted_proxies == ("10.0.0.0/8", "192.168.1.5/32")
assert cfg.web.track.count_bots is True
assert cfg.web.track.flush_seconds == 0
assert cfg.web.track.max_hits_per_ip_path_per_day == 500
for bad, hint in (
({"header_order": []}, "header_order"),
({"header_order": ["X-Real-IP", 5]}, "header_order"),
({"trusted_proxies": ["not-a-cidr"]}, "trusted_proxies"),
({"flush_seconds": -1}, "flush_seconds"),
({"keep_days": 0}, "keep_days"),
({"max_hits_per_ip_path_per_day": -1}, "max_hits_per_ip_path_per_day"),
):
data = base_config()
data["web"] = {"track": bad}
write(path, data)
try:
load_config(path, env={})
except ConfigError as exc:
assert hint in str(exc), (bad, str(exc))
else:
raise AssertionError(f"非法 web.track 必须拒绝: {bad}")
# web.track 不是对象
data = base_config()
data["web"] = {"track": "nope"}
write(path, data)
try:
load_config(path, env={})
except ConfigError as exc:
assert "web.track" in str(exc)
else:
raise AssertionError("web.track 必须是对象")
def main():
test_dual_accounts_and_env_override()
test_flat_and_missing_account_rejected()
test_validation_require_read_auth_and_hot_reload()
test_invalid_values()
test_analytics_defaults_and_validation()
print("OK: 双账号配置加载、拒绝 flat 格式、ENV 覆盖、热加载与 analytics 测试全部通过")
test_web_track_defaults_and_validation()
print("OK: 双账号配置加载、拒绝 flat 格式、ENV 覆盖、热加载与 analytics/web.track 测试全部通过")
if __name__ == "__main__":
+203
View File
@@ -0,0 +1,203 @@
# -*- coding: utf-8 -*-
"""访问统计内存缓冲与批量落库测试(离线)。
运行:uv run python tests/test_visits.py
"""
import os
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.stdout.reconfigure(encoding="utf-8")
from reaction_board.database import Database # noqa: E402
from reaction_board.timeutil import day_bounds # noqa: E402
from reaction_board.web.visits import VisitCollector # noqa: E402
DAY_A = "2026-10-30"
DAY_B = "2026-10-31"
def ts(day: str, hour: int = 12) -> int:
return int(day_bounds(day)[0]) + hour * 3600
class _StubDB:
"""只让 transaction/write 抛错,用于验证 flush 失败时的并回行为。"""
def transaction(self, fn):
raise RuntimeError("db down")
def write(self, fn):
raise RuntimeError("db down")
def _open(tmp: str) -> Database:
db = Database(Path(tmp) / "app.db")
db.initialize()
return db
def _rows(db: Database):
return db.read(lambda conn: [
dict(row) for row in conn.execute(
"SELECT stat_date, ip, path, hits, first_at, last_at FROM visit "
"ORDER BY stat_date, ip, path"
).fetchall()
])
def test_record_and_aggregate():
with tempfile.TemporaryDirectory() as tmp:
db = _open(tmp)
try:
collector = VisitCollector(db, flush_seconds=0)
assert collector.record("1.1.1.1", "/home", now=ts(DAY_A)) is True
assert collector.record("1.1.1.1", "/home", now=ts(DAY_A)) is True
assert collector.record("1.1.1.1", "/videos", now=ts(DAY_A)) is True
assert collector.record("2.2.2.2", "/home", now=ts(DAY_A)) is True
# 跨日单独成键
assert collector.record("1.1.1.1", "/home", now=ts(DAY_B)) is True
assert collector.pending_keys == 4
rows = {(r["stat_date"], r["ip"], r["path"]): r
for r in collector.pending_rows()}
home_a = rows[(DAY_A, "1.1.1.1", "/home")]
assert home_a["hits"] == 2
assert home_a["first_at"] == ts(DAY_A)
assert home_a["last_at"] == ts(DAY_A)
assert rows[(DAY_B, "1.1.1.1", "/home")]["hits"] == 1
# 空值不计数
assert collector.record("", "/home", now=ts(DAY_A)) is False
assert collector.record("1.1.1.1", "", now=ts(DAY_A)) is False
finally:
db.close()
def test_flush_writes_and_is_idempotent():
with tempfile.TemporaryDirectory() as tmp:
db = _open(tmp)
try:
collector = VisitCollector(db, flush_seconds=0)
assert collector.flush() == 0 # 空缓冲 no-op
collector.record("1.1.1.1", "/home", now=ts(DAY_A))
collector.record("1.1.1.1", "/home", now=ts(DAY_A))
assert collector.flush() == 1
assert collector.pending_keys == 0
rows = _rows(db)
assert len(rows) == 1
assert rows[0]["hits"] == 2
assert rows[0]["ip"] == "1.1.1.1"
assert rows[0]["path"] == "/home"
# 再次 flush 不重复累加
assert collector.flush() == 0
assert _rows(db)[0]["hits"] == 2
# 与已有行累加(先在库中放一条 hits=5 的记录)
db.write(lambda conn: conn.execute(
"INSERT INTO visit(stat_date, ip, path, hits, first_at, last_at) "
"VALUES (?, '3.3.3.3', '/home', 5, ?, ?)",
(DAY_A, ts(DAY_A), ts(DAY_A)),
))
collector.record("3.3.3.3", "/home", now=ts(DAY_A, hour=13))
assert collector.flush() == 1
row = next(r for r in _rows(db) if r["ip"] == "3.3.3.3")
assert row["hits"] == 6
assert row["first_at"] == ts(DAY_A)
assert row["last_at"] == ts(DAY_A, hour=13)
finally:
db.close()
def test_flush_failure_merges_back():
with tempfile.TemporaryDirectory() as tmp:
db = _open(tmp)
try:
collector = VisitCollector(db, flush_seconds=0)
collector.record("1.1.1.1", "/home", now=ts(DAY_A))
collector.record("1.1.1.1", "/home", now=ts(DAY_A))
original = collector._db
collector._db = _StubDB()
try:
assert collector.flush() == 0
assert collector.pending_keys == 1
assert collector.pending_rows()[0]["hits"] == 2
finally:
collector._db = original
# 恢复后能正常落库且计数不丢、不重复
assert collector.flush() == 1
assert _rows(db)[0]["hits"] == 2
finally:
db.close()
def test_max_hits_per_key_cap():
with tempfile.TemporaryDirectory() as tmp:
db = _open(tmp)
try:
collector = VisitCollector(db, flush_seconds=0, max_hits_per_key=2)
assert collector.record("1.1.1.1", "/home", now=ts(DAY_A)) is True
assert collector.record("1.1.1.1", "/home", now=ts(DAY_A)) is True
assert collector.record("1.1.1.1", "/home", now=ts(DAY_A)) is False
assert collector.pending_rows()[0]["hits"] == 2
# 不同键各自独立
assert collector.record("1.1.1.1", "/videos", now=ts(DAY_A)) is True
finally:
db.close()
def test_prune_old_rows():
with tempfile.TemporaryDirectory() as tmp:
db = _open(tmp)
try:
collector = VisitCollector(db, flush_seconds=0, keep_days=7)
# 30 天前的老记录
old_day = "2026-09-30"
db.write(lambda conn: conn.execute(
"INSERT INTO visit(stat_date, ip, path, hits, first_at, last_at) "
"VALUES (?, '9.9.9.9', '/home', 1, 1, 1)",
(old_day,),
))
collector.record("1.1.1.1", "/home", now=ts(DAY_A))
assert collector.flush() == 1
remaining = {row["stat_date"] for row in _rows(db)}
assert old_day not in remaining
assert DAY_A in remaining
finally:
db.close()
def test_stop_flushes_pending():
with tempfile.TemporaryDirectory() as tmp:
db = _open(tmp)
try:
collector = VisitCollector(db, flush_seconds=0)
collector.start() # flush_seconds=0 → 不启动线程
assert collector._thread is None
collector.record("1.1.1.1", "/home", now=ts(DAY_A))
collector.stop()
assert collector.pending_keys == 0
assert _rows(db)[0]["hits"] == 1
finally:
db.close()
def main():
test_record_and_aggregate()
test_flush_writes_and_is_idempotent()
test_flush_failure_merges_back()
test_max_hits_per_key_cap()
test_prune_old_rows()
test_stop_flushes_pending()
print("OK: 访问统计内存缓冲与批量落库测试全部通过")
if __name__ == "__main__":
main()
+61 -1
View File
@@ -477,6 +477,65 @@ def test_about_payload():
db.close()
def test_stats_query():
with tempfile.TemporaryDirectory() as tmp:
db, q = setup(tmp)
try:
empty = q.stats(days=7)
assert empty["today"] == "2026-10-31"
assert empty["today_pv"] == 0 and empty["today_uv"] == 0
assert len(empty["series"]["dates"]) == 7
assert empty["series"]["dates"][-1] == "2026-10-31"
assert empty["total_pv"] == 0 and empty["total_uv"] == 0
assert empty["top_pages"] == []
assert empty["enabled"] is True
assert empty["pending"] is False
def insert(conn, day, ip, path, hits, first, last):
conn.execute(
"INSERT INTO visit(stat_date, ip, path, hits, first_at, last_at) "
"VALUES (?, ?, ?, ?, ?, ?)",
(day, ip, path, hits, first, last),
)
db.write(lambda conn: [
insert(conn, "2026-10-31", "1.1.1.1", "/home", 3, 1, 2),
insert(conn, "2026-10-31", "2.2.2.2", "/home", 1, 1, 1),
insert(conn, "2026-10-31", "1.1.1.1", "/videos", 2, 1, 1),
insert(conn, "2026-10-30", "1.1.1.1", "/home", 5, 1, 1),
insert(conn, "2026-09-01", "9.9.9.9", "/home", 100, 1, 1),
])
stats = q.stats(days=7)
assert stats["today_pv"] == 6
assert stats["today_uv"] == 2
assert stats["total_pv"] == 11 # 今天 6 + 昨天 5,区间外不计
assert stats["total_uv"] == 2
assert stats["series"]["pv"][-1] == 6
assert stats["series"]["pv"][-2] == 5
assert stats["series"]["uv"][-1] == 2
pages = {page["path"]: page for page in stats["top_pages"]}
assert pages["/home"]["pv"] == 9 and pages["/home"]["uv"] == 2
assert pages["/videos"]["pv"] == 2 and pages["/videos"]["uv"] == 1
assert stats["top_pages"][0]["path"] == "/home"
# 内存缓冲合并:不同键相加,重叠键去重合并
pending = [
{"stat_date": "2026-10-31", "ip": "3.3.3.3",
"path": "/about", "hits": 4, "first_at": 1, "last_at": 1},
{"stat_date": "2026-10-31", "ip": "1.1.1.1",
"path": "/home", "hits": 1, "first_at": 3, "last_at": 3},
]
merged = q.stats(days=7, pending=pending)
assert merged["today_pv"] == 11
assert merged["today_uv"] == 3
assert merged["pending"] is True
pages = {page["path"]: page for page in merged["top_pages"]}
assert pages["/about"]["pv"] == 4
assert pages["/home"]["pv"] == 10
finally:
db.close()
def main_test():
test_overview()
test_video_listing_filters_and_sorting()
@@ -487,9 +546,10 @@ def main_test():
test_duration_efficiency_queries()
test_year_filter_and_published_sort()
test_about_payload()
test_stats_query()
test_fans_custom_range_modes()
test_fans_candidates_system_and_export()
print("OK: Web 查询层总览/榜单/详情/UP/时长效率/粉丝/候选/系统测试全部通过")
print("OK: Web 查询层总览/榜单/详情/UP/时长效率/粉丝/候选/系统/访问统计测试全部通过")
if __name__ == "__main__":
+87 -1
View File
@@ -189,6 +189,87 @@ def test_readonly_routes_and_static():
running.close()
def test_visit_endpoint_and_stats():
with tempfile.TemporaryDirectory() as tmp:
running = RunningServer(tmp)
try:
ua = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/155.0"}
# 上报:可信对端 127.0.0.1 + X-Real-IP → 记录真实 IP 与归一化路由
status, _, payload = running.json(
"/api/visit", method="POST",
headers={**ua, "Content-Type": "application/json", "X-Real-IP": "203.0.113.7"},
body=json.dumps({"path": "/video/123"}),
)
assert status == 200 and payload["data"]["recorded"] is True
assert payload["data"]["route"] == "/video/:aid"
status, _, payload = running.json(
"/api/visit", method="POST",
headers={**ua, "Content-Type": "application/json", "X-Real-IP": "203.0.113.7"},
body=json.dumps({"path": "/videos"}),
)
assert status == 200 and payload["data"]["recorded"] is True
collector = running.server.context.visits
assert collector.pending_keys == 2
# 机器人 UA 被过滤,不计数
status, _, payload = running.json(
"/api/visit", method="POST",
headers={"User-Agent": "Googlebot/2.1", "Content-Type": "application/json"},
body=json.dumps({"path": "/home"}),
)
assert status == 200 and payload["data"]["recorded"] is False
assert payload["data"]["reason"] == "bot"
assert collector.pending_keys == 2
# 超大请求体被拒绝
status, _, _ = running.json(
"/api/visit", method="POST",
headers={**ua, "Content-Type": "application/json"},
body=json.dumps({"path": "/" + "x" * 4000}),
)
assert status == 400
# GET 埋点端点 → 405;其他业务 POST 仍 405
status, _, _ = running.request("/api/visit")
assert status == 405
status, _, _ = running.request("/api/videos/101", method="POST")
assert status == 405
# 未 flush 前库里没有行,但 /api/stats 合并内存缓冲可见
assert running.db.read(
lambda c: c.execute("SELECT COUNT(*) FROM visit").fetchone()[0]
) == 0
status, _, payload = running.json("/api/stats?days=7")
assert status == 200
data = payload["data"]
assert data["pending"] is True
assert data["today_pv"] == 2
assert data["today_uv"] == 1
pages = {page["path"]: page for page in data["top_pages"]}
assert pages["/video/:aid"]["pv"] == 1
assert pages["/videos"]["pv"] == 1
# 手动 flush 后落库,且 IP 正确
assert collector.flush() == 2
assert running.db.read(
lambda c: c.execute("SELECT COUNT(*) FROM visit").fetchone()[0]
) == 2
ips = running.db.read(
lambda c: [row[0] for row in c.execute("SELECT DISTINCT ip FROM visit")]
)
assert ips == ["203.0.113.7"]
status, _, payload = running.json("/api/stats?days=7")
data = payload["data"]
assert data["pending"] is False
assert data["today_pv"] == 2
assert data["range"]["end"] == "2026-10-31"
finally:
running.close()
def test_token_auth_and_login():
with tempfile.TemporaryDirectory() as tmp:
running = RunningServer(tmp, token="test-token")
@@ -210,6 +291,8 @@ def test_token_auth_and_login():
assert status == 401 and body["error"]["code"] == "unauthorized"
status, _, body = running.json("/api/system")
assert status == 401
status, _, body = running.json("/api/stats")
assert status == 401
status, headers, _ = running.request("/candidates")
assert status == 303 and headers["Location"] == "/login?next=/candidates"
status, headers, _ = running.request("/system")
@@ -246,6 +329,8 @@ def test_token_auth_and_login():
assert status == 200 and body["ok"] is True
status, _, body = running.json("/api/system", headers={"Cookie": cookie})
assert status == 200 and body["ok"] is True
status, _, body = running.json("/api/stats", headers={"Cookie": cookie})
assert status == 200 and body["ok"] is True
status, _, body = running.json(
"/api/candidates", headers={"Authorization": "Bearer test-token"}
)
@@ -299,9 +384,10 @@ def test_preview_and_loopback_guards():
def main_test():
test_readonly_routes_and_static()
test_visit_endpoint_and_stats()
test_token_auth_and_login()
test_preview_and_loopback_guards()
print("OK: Web 只读路由/鉴权/预览/安全边界测试全部通过")
print("OK: Web 只读路由/鉴权/预览/访问统计/安全边界测试全部通过")
if __name__ == "__main__":