diff --git a/backend/app/api/admin_api.py b/backend/app/api/admin_api.py index 1f7e9b0..b960e45 100644 --- a/backend/app/api/admin_api.py +++ b/backend/app/api/admin_api.py @@ -87,6 +87,41 @@ def _get_stats() -> dict: stats["unique_ips"] = 0 stats["recent_usage"] = [] + try: + cur.execute("SELECT COUNT(*), COUNT(DISTINCT visitor_hash) FROM visit_log") + row = cur.fetchone() + stats["total_pv"] = row[0] + stats["total_uv"] = row[1] + + cur.execute(""" + SELECT COUNT(*), COUNT(DISTINCT visitor_hash) + FROM visit_log WHERE date(created_at)=date('now') + """) + row = cur.fetchone() + stats["today_pv"] = row[0] + stats["today_uv"] = row[1] + + cur.execute(""" + SELECT path, COUNT(*) as c + FROM visit_log + GROUP BY path ORDER BY c DESC LIMIT 20 + """) + stats["visits_by_path"] = {r[0]: r[1] for r in cur.fetchall()} + + cur.execute(""" + SELECT strftime('%H', created_at) as hour, COUNT(*) as c + FROM visit_log WHERE created_at > datetime('now', '-24 hours') + GROUP BY hour ORDER BY hour + """) + stats["visit_hourly_24h"] = {r[0]: r[1] for r in cur.fetchall()} + except Exception: + stats["total_pv"] = 0 + stats["total_uv"] = 0 + stats["today_pv"] = 0 + stats["today_uv"] = 0 + stats["visits_by_path"] = {} + stats["visit_hourly_24h"] = {} + # Engagement cur.execute(""" SELECT category, metric_name, metric_value FROM baseline_stats @@ -170,22 +205,34 @@ def _get_stats() -> dict: function esc(s){return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');} function showDash(d){ const hrs=d.hourly_24h||{};const maxH=Math.max(...Object.values(hrs),1); + const visitHrs=d.visit_hourly_24h||{};const maxVisitH=Math.max(...Object.values(visitHrs),1); const topIps=d.top_ips||[];const maxIp=topIps[0]?.count||1; const usage=d.recent_usage||[]; const cats=Object.entries(d.usage_by_category||{});const maxCat=Math.max(...cats.map(c=>c[1]),1); + const paths=Object.entries(d.visits_by_path||{});const maxPath=Math.max(...paths.map(c=>c[1]),1); app.innerHTML=`

薯医 Admin

${d.timestamp} · uptime ${Math.round(d.uptime_seconds/60)}min
-
今日诊断
${d.today_requests||0}
-
今日 UV
${d.today_ips||0}
-
总诊断
${d.total_requests||0}
-
总 UV
${d.unique_ips||0}
+
今日访问 UV
${d.today_uv||0}
+
今日访问 PV
${d.today_pv||0}
+
总访问 UV
${d.total_uv||0}
+
总访问 PV
${d.total_pv||0}
+
今日诊断
${d.today_requests||0}
+
诊断 IP
${d.today_ips||0}
+
总诊断
${d.total_requests||0}
总 Token
${(d.total_tokens||0).toLocaleString()}
平均耗时
${d.avg_duration_sec||0}s
训练笔记
${d.total_notes||0}
内存
${d.system?.memory_used_mb||'?'}/${d.system?.memory_total_mb||'?'}
-
24h 请求分布
+
24h 访问分布
+
+ ${Array.from({length:24},(_,i)=>{const h=String(i).padStart(2,'0');const v=visitHrs[h]||0; + return `
`;}).join('')} +
+
访问路径分布
+ ${paths.map(([p,n])=>`
${esc(p).slice(0,18)}
${n}
`).join('')} +
24h 诊断请求分布
${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 +