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
@@ -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