${Array.from({length:24},(_,i)=>{const h=String(i).padStart(2,'0');const v=hrs[h]||0;
return `
`;}).join('')}
diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py
index f2479a9..29ea2fa 100644
--- a/backend/app/api/routes.py
+++ b/backend/app/api/routes.py
@@ -9,6 +9,7 @@
from app.api.history_api import router as history_router
from app.api.screenshot_api import router as screenshot_router
from app.api.optimize_api import router as optimize_router
+from app.api.visit_api import router as visit_router
router = APIRouter()
@@ -26,3 +27,4 @@ async def api_health():
# router.include_router(history_router, tags=["history"])
router.include_router(screenshot_router, tags=["screenshot"])
router.include_router(optimize_router, tags=["optimize"])
+router.include_router(visit_router, tags=["visit"])
diff --git a/backend/app/api/visit_api.py b/backend/app/api/visit_api.py
new file mode 100644
index 0000000..274d15f
--- /dev/null
+++ b/backend/app/api/visit_api.py
@@ -0,0 +1,78 @@
+"""
+Page visit tracking for anonymous PV/UV statistics.
+"""
+from __future__ import annotations
+
+import hashlib
+import json
+import logging
+import os
+import sqlite3
+
+from fastapi import APIRouter, Request
+from pydantic import BaseModel
+
+from app.api.usage_tracker import get_client_ip
+
+logger = logging.getLogger("noterx.visit")
+DB_PATH = os.path.join(os.path.dirname(__file__), "..", "..", "data", "baseline.db")
+VISITOR_SALT = os.getenv("VISITOR_HASH_SALT", "noterx-visitor")
+
+router = APIRouter()
+
+
+class VisitPayload(BaseModel):
+ path: str = "/"
+ referrer: str = ""
+
+
+async def _read_visit_payload(request: Request) -> VisitPayload:
+ try:
+ data = await request.json()
+ except json.JSONDecodeError:
+ body = (await request.body()).decode("utf-8", errors="ignore")
+ try:
+ data = json.loads(body) if body else {}
+ except json.JSONDecodeError:
+ data = {}
+ if not isinstance(data, dict):
+ data = {}
+ return VisitPayload.model_validate(data)
+
+
+def _visitor_hash(ip: str, user_agent: str) -> str:
+ raw = f"{VISITOR_SALT}:{ip}:{user_agent}"
+ return hashlib.sha256(raw.encode()).hexdigest()[:32]
+
+
+def _user_agent_hash(user_agent: str) -> str:
+ raw = f"{VISITOR_SALT}:{user_agent}"
+ return hashlib.sha256(raw.encode()).hexdigest()[:32]
+
+
+def log_visit(ip: str, user_agent: str, path: str, referrer: str = "") -> str:
+ visitor_hash = _visitor_hash(ip, user_agent)
+ safe_path = (path or "/")[:200]
+ try:
+ conn = sqlite3.connect(DB_PATH)
+ conn.execute(
+ """
+ INSERT INTO visit_log (visitor_hash, user_agent_hash, path)
+ VALUES (?, ?, ?)
+ """,
+ (visitor_hash, _user_agent_hash(user_agent or ""), safe_path),
+ )
+ conn.commit()
+ conn.close()
+ except Exception as e:
+ logger.warning("Failed to log visit: %s", e)
+ return visitor_hash
+
+
+@router.post("/visit")
+async def track_visit(request: Request):
+ payload = await _read_visit_payload(request)
+ ip = get_client_ip(request)
+ user_agent = request.headers.get("user-agent", "")
+ log_visit(ip, user_agent, payload.path, payload.referrer)
+ return {"ok": True}
diff --git a/backend/app/main.py b/backend/app/main.py
index 77cef06..113bf9d 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -54,6 +54,19 @@ def _ensure_history_table():
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_usage_created ON usage_log(created_at DESC)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_usage_ip ON usage_log(ip)")
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS visit_log (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ visitor_hash TEXT NOT NULL,
+ user_agent_hash TEXT DEFAULT '',
+ path TEXT NOT NULL,
+ referrer TEXT DEFAULT '',
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ )
+ """)
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_visit_created ON visit_log(created_at DESC)")
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_visit_visitor ON visit_log(visitor_hash)")
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_visit_path ON visit_log(path)")
conn.commit()
conn.close()
local_memory.ensure_memory_md()
diff --git a/backend/tests/test_admin_visit_stats.py b/backend/tests/test_admin_visit_stats.py
new file mode 100644
index 0000000..e5dfeb5
--- /dev/null
+++ b/backend/tests/test_admin_visit_stats.py
@@ -0,0 +1,132 @@
+import os
+import sqlite3
+import sys
+import tempfile
+
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
+
+from app.api import admin_api, visit_api
+
+
+def _create_admin_test_db():
+ fd, path = tempfile.mkstemp(suffix=".db")
+ os.close(fd)
+ conn = sqlite3.connect(path)
+ conn.execute("CREATE TABLE notes (id INTEGER PRIMARY KEY, category TEXT)")
+ conn.execute("INSERT INTO notes (category) VALUES ('food')")
+ conn.execute("""
+ CREATE TABLE baseline_stats (
+ id INTEGER PRIMARY KEY,
+ category TEXT,
+ metric_name TEXT,
+ metric_value REAL,
+ metric_json TEXT,
+ updated_at TIMESTAMP
+ )
+ """)
+ conn.execute("""
+ CREATE TABLE usage_log (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ ip TEXT NOT NULL,
+ action TEXT NOT NULL DEFAULT 'diagnose',
+ title TEXT DEFAULT '',
+ category TEXT DEFAULT '',
+ total_tokens INTEGER DEFAULT 0,
+ duration_sec REAL DEFAULT 0,
+ status TEXT DEFAULT 'ok',
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ )
+ """)
+ conn.execute("""
+ CREATE TABLE visit_log (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ visitor_hash TEXT NOT NULL,
+ user_agent_hash TEXT DEFAULT '',
+ path TEXT NOT NULL,
+ referrer TEXT DEFAULT '',
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ )
+ """)
+ conn.commit()
+ conn.close()
+ return path
+
+
+def test_admin_stats_reports_real_visit_uv_separate_from_diagnosis_usage():
+ db = _create_admin_test_db()
+ original_db = admin_api.DB_PATH
+ admin_api.DB_PATH = db
+ try:
+ conn = sqlite3.connect(db)
+ conn.execute("INSERT INTO usage_log (ip, action, title, category) VALUES ('1.1.1.1', 'diagnose', 'A', 'food')")
+ conn.execute("INSERT INTO visit_log (visitor_hash, user_agent_hash, path) VALUES ('visitor-a', 'ua', '/app')")
+ conn.execute("INSERT INTO visit_log (visitor_hash, user_agent_hash, path) VALUES ('visitor-a', 'ua', '/report')")
+ conn.execute("INSERT INTO visit_log (visitor_hash, user_agent_hash, path) VALUES ('visitor-b', 'ua', '/privacy')")
+ conn.commit()
+ conn.close()
+
+ stats = admin_api._get_stats()
+
+ assert stats["total_requests"] == 1
+ assert stats["unique_ips"] == 1
+ assert stats["total_pv"] == 3
+ assert stats["total_uv"] == 2
+ assert stats["today_pv"] == 3
+ assert stats["today_uv"] == 2
+ assert stats["visits_by_path"] == {"/app": 1, "/privacy": 1, "/report": 1}
+ finally:
+ admin_api.DB_PATH = original_db
+ os.unlink(db)
+
+
+def test_visit_endpoint_accepts_text_plain_beacon_payload():
+ db = _create_admin_test_db()
+ original_db = visit_api.DB_PATH
+ visit_api.DB_PATH = db
+ try:
+ app = FastAPI()
+ app.include_router(visit_api.router, prefix="/api")
+ client = TestClient(app)
+
+ response = client.post(
+ "/api/visit",
+ content='{"path":"/app","referrer":"https://example.com/from"}',
+ headers={"content-type": "text/plain;charset=UTF-8", "user-agent": "BeaconBrowser"},
+ )
+
+ assert response.status_code == 200
+ assert response.json() == {"ok": True}
+
+ conn = sqlite3.connect(db)
+ cur = conn.cursor()
+ cur.execute("SELECT path FROM visit_log")
+ assert cur.fetchone() == ("/app",)
+ conn.close()
+ finally:
+ visit_api.DB_PATH = original_db
+ os.unlink(db)
+
+
+def test_log_visit_deduplicates_visitor_by_ip_and_user_agent():
+ db = _create_admin_test_db()
+ original_db = visit_api.DB_PATH
+ visit_api.DB_PATH = db
+ try:
+ first = visit_api.log_visit("9.9.9.9", "Mozilla/5.0", "/app", "")
+ second = visit_api.log_visit("9.9.9.9", "Mozilla/5.0", "/report", "")
+ third = visit_api.log_visit("8.8.8.8", "Mozilla/5.0", "/app", "")
+
+ assert first == second
+ assert third != first
+
+ conn = sqlite3.connect(db)
+ cur = conn.cursor()
+ cur.execute("SELECT COUNT(*), COUNT(DISTINCT visitor_hash) FROM visit_log")
+ assert cur.fetchone() == (3, 2)
+ conn.close()
+ finally:
+ visit_api.DB_PATH = original_db
+ os.unlink(db)
diff --git a/docs/privacy.html b/docs/privacy.html
index d611096..bccfcea 100644
--- a/docs/privacy.html
+++ b/docs/privacy.html
@@ -6,6 +6,13 @@
隐私政策 — 薯医 NoteRx
+