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
+70
-1
@@ -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__":
|
||||
|
||||
@@ -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()
|
||||
@@ -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__":
|
||||
|
||||
@@ -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__":
|
||||
|
||||
Reference in new issue
Block a user