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