diff --git a/backend/api/index.py b/backend/api/index.py index b15d9bf..ef73a3f 100644 --- a/backend/api/index.py +++ b/backend/api/index.py @@ -104,6 +104,12 @@ async def dispatch(self, request: Request, call_next): return JSONResponse({"error": "origin_not_allowed"}, status_code=403) normalized = f"{parsed.scheme.lower()}://{parsed.netloc.lower()}" + forwarded_host = request.headers.get("x-forwarded-host") or request.headers.get("host") or request.url.netloc + forwarded_proto = request.headers.get("x-forwarded-proto") or request.url.scheme + same_origin = f"{forwarded_proto.lower()}://{forwarded_host.lower()}" if forwarded_host else "" + if normalized == same_origin: + return await call_next(request) + if normalized in self.allow_origins: return await call_next(request) diff --git a/backend/api/routes/__init__.py b/backend/api/routes/__init__.py index 9bce3d3..17a81eb 100644 --- a/backend/api/routes/__init__.py +++ b/backend/api/routes/__init__.py @@ -1,3 +1,4 @@ +from .admin_analytics import router as admin_analytics_router from .auth import router as auth_router from .config import router as config_router from .device import router as device_router @@ -16,6 +17,7 @@ api_routers = [ render_router, + admin_analytics_router, config_router, device_router, modes_router, diff --git a/backend/api/routes/admin_analytics.py b/backend/api/routes/admin_analytics.py new file mode 100644 index 0000000..793d723 --- /dev/null +++ b/backend/api/routes/admin_analytics.py @@ -0,0 +1,336 @@ +from __future__ import annotations +from typing import Optional + +from fastapi import APIRouter, Cookie, Depends, Request + +from core.activity_store import log_user_activity +from core.auth import decode_session_token, get_current_root_user +from core.db import get_main_db + +router = APIRouter(tags=["admin-analytics"]) + +_LEGACY_NGINX_VISITS_TOTAL = 25070 +_LEGACY_NGINX_VISITORS_TOTAL = 6052 +_LEGACY_NGINX_VISITS_START_DATE = "2026-05-11" + + +def _optional_user_id(request: Request, ink_session: Optional[str]) -> int | None: + tokens = [] + if ink_session: + tokens.append(ink_session) + auth = request.headers.get("authorization", "") + if auth.startswith("Bearer "): + tokens.append(auth[7:]) + for token in tokens: + payload = decode_session_token(token) + if not payload or "sub" not in payload: + continue + try: + return int(payload["sub"]) + except (TypeError, ValueError): + continue + return None + + +@router.post("/analytics/pageview") +async def analytics_pageview( + body: dict, + request: Request, + ink_session: Optional[str] = Cookie(default=None), +): + await log_user_activity( + _optional_user_id(request, ink_session), + "page.view", + request=request, + source=str(body.get("source") or "webapp"), + path=str(body.get("path") or request.headers.get("referer") or "/"), + method="GET", + metadata={"mac": str(body.get("mac") or "")[:17]}, + ) + return {"ok": True} + + +async def _scalar(sql: str, params: tuple = ()) -> int | float: + db = await get_main_db() + cursor = await db.execute(sql, params) + row = await cursor.fetchone() + value = row[0] if row else 0 + return value or 0 + + +async def _rows(sql: str, params: tuple = ()) -> list[dict]: + db = await get_main_db() + cursor = await db.execute(sql, params) + columns = [item[0] for item in cursor.description] + return [dict(zip(columns, row)) for row in await cursor.fetchall()] + + +async def _analytics_overview_payload() -> dict: + """Root-only analytics summary for the operations dashboard.""" + return { + "users": { + "total": await _scalar("SELECT COUNT(*) FROM users"), + "today_new": await _scalar("SELECT COUNT(*) FROM users WHERE date(created_at)=date('now','localtime')"), + "new_7d": await _scalar("SELECT COUNT(*) FROM users WHERE created_at >= datetime('now','localtime','-7 days')"), + "new_30d": await _scalar("SELECT COUNT(*) FROM users WHERE created_at >= datetime('now','localtime','-30 days')"), + "with_device": await _scalar("SELECT COUNT(DISTINCT user_id) FROM device_memberships WHERE status='active'"), + "dau": await _scalar( + """ + SELECT COUNT(DISTINCT user_id) FROM user_activity_events + WHERE user_id IS NOT NULL + AND date(created_at)=date('now','localtime') + AND event_name != 'auth.register' + """ + ), + "wau": await _scalar( + """ + SELECT COUNT(DISTINCT user_id) FROM user_activity_events + WHERE user_id IS NOT NULL + AND created_at >= datetime('now','localtime','-7 days') + AND event_name != 'auth.register' + """ + ), + "mau": await _scalar( + """ + SELECT COUNT(DISTINCT user_id) FROM user_activity_events + WHERE user_id IS NOT NULL + AND created_at >= datetime('now','localtime','-30 days') + AND event_name != 'auth.register' + """ + ), + "device_active_24h": await _scalar( + """ + WITH active_devices AS ( + SELECT DISTINCT mac FROM device_heartbeats WHERE created_at >= datetime('now','localtime','-24 hours') + UNION + SELECT DISTINCT mac FROM render_logs WHERE created_at >= datetime('now','localtime','-24 hours') + ) + SELECT COUNT(DISTINCT dm.user_id) + FROM device_memberships dm + JOIN active_devices ad ON ad.mac = dm.mac + WHERE dm.status = 'active' + """ + ), + }, + "devices": { + "bound": await _scalar("SELECT COUNT(DISTINCT mac) FROM device_memberships WHERE status='active'"), + "active_today": await _scalar( + """ + WITH active_devices AS ( + SELECT DISTINCT mac FROM device_heartbeats WHERE date(created_at)=date('now','localtime') + UNION + SELECT DISTINCT mac FROM render_logs WHERE date(created_at)=date('now','localtime') + ) + SELECT COUNT(*) FROM active_devices + """ + ), + "active_7d": await _scalar( + """ + WITH active_devices AS ( + SELECT DISTINCT mac FROM device_heartbeats WHERE created_at >= datetime('now','localtime','-7 days') + UNION + SELECT DISTINCT mac FROM render_logs WHERE created_at >= datetime('now','localtime','-7 days') + ) + SELECT COUNT(*) FROM active_devices + """ + ), + "heartbeats_today": await _scalar("SELECT COUNT(*) FROM device_heartbeats WHERE date(created_at)=date('now','localtime')"), + }, + "rendering": { + "today": await _scalar("SELECT COUNT(*) FROM render_logs WHERE date(created_at)=date('now','localtime')"), + "last_7d": await _scalar("SELECT COUNT(*) FROM render_logs WHERE created_at >= datetime('now','localtime','-7 days')"), + "avg_ms_today": await _scalar( + "SELECT ROUND(AVG(render_time_ms), 0) FROM render_logs WHERE date(created_at)=date('now','localtime') AND status='success'" + ), + "errors_today": await _scalar("SELECT COUNT(*) FROM render_logs WHERE date(created_at)=date('now','localtime') AND status!='success'"), + "fallback_today": await _scalar("SELECT COUNT(*) FROM render_logs WHERE date(created_at)=date('now','localtime') AND is_fallback=1"), + }, + "content": { + "custom_modes": await _scalar("SELECT COUNT(*) FROM custom_modes"), + "shared_modes": await _scalar("SELECT COUNT(*) FROM shared_modes WHERE is_active=1"), + "users_with_llm_config": await _scalar("SELECT COUNT(*) FROM user_llm_config"), + }, + "traffic": { + "historical_visits": { + "total": _LEGACY_NGINX_VISITS_TOTAL + + await _scalar("SELECT COUNT(*) FROM user_activity_events WHERE event_name='page.view'"), + "legacy_total": _LEGACY_NGINX_VISITS_TOTAL, + "tracked_total": await _scalar("SELECT COUNT(*) FROM user_activity_events WHERE event_name='page.view'"), + "start_date": _LEGACY_NGINX_VISITS_START_DATE, + "source": "one-time nginx estimate plus page.view events", + }, + "historical_visitors": { + "total": _LEGACY_NGINX_VISITORS_TOTAL + + await _scalar( + """ + SELECT COUNT(*) FROM ( + SELECT DISTINCT ip_hash, user_agent + FROM user_activity_events + WHERE event_name='page.view' + AND (ip_hash != '' OR user_agent != '') + ) + """ + ), + "legacy_total": _LEGACY_NGINX_VISITORS_TOTAL, + "tracked_total": await _scalar( + """ + SELECT COUNT(*) FROM ( + SELECT DISTINCT ip_hash, user_agent + FROM user_activity_events + WHERE event_name='page.view' + AND (ip_hash != '' OR user_agent != '') + ) + """ + ), + "start_date": _LEGACY_NGINX_VISITS_START_DATE, + "source": "one-time nginx ip+ua estimate plus distinct page.view ip+ua", + }, + }, + "activity": { + "events_today": await _scalar("SELECT COUNT(*) FROM user_activity_events WHERE date(created_at)=date('now','localtime')"), + "events_7d": await _scalar("SELECT COUNT(*) FROM user_activity_events WHERE created_at >= datetime('now','localtime','-7 days')"), + "events_total": await _scalar("SELECT COUNT(*) FROM user_activity_events"), + "pageviews_today": await _scalar("SELECT COUNT(*) FROM user_activity_events WHERE event_name='page.view' AND date(created_at)=date('now','localtime')"), + "pageviews_7d": await _scalar("SELECT COUNT(*) FROM user_activity_events WHERE event_name='page.view' AND created_at >= datetime('now','localtime','-7 days')"), + "pageviews_total": await _scalar("SELECT COUNT(*) FROM user_activity_events WHERE event_name='page.view'"), + "visitors_today": await _scalar( + """ + SELECT COUNT(*) FROM ( + SELECT DISTINCT ip_hash, user_agent + FROM user_activity_events + WHERE event_name='page.view' + AND date(created_at)=date('now','localtime') + AND (ip_hash != '' OR user_agent != '') + ) + """ + ), + "visitors_7d": await _scalar( + """ + SELECT COUNT(*) FROM ( + SELECT DISTINCT ip_hash, user_agent + FROM user_activity_events + WHERE event_name='page.view' + AND created_at >= datetime('now','localtime','-7 days') + AND (ip_hash != '' OR user_agent != '') + ) + """ + ), + "visitors_total": await _scalar( + """ + SELECT COUNT(*) FROM ( + SELECT DISTINCT ip_hash, user_agent + FROM user_activity_events + WHERE event_name='page.view' + AND (ip_hash != '' OR user_agent != '') + ) + """ + ), + "logins_today": await _scalar("SELECT COUNT(*) FROM user_activity_events WHERE event_name='auth.login' AND date(created_at)=date('now','localtime')"), + "logins_7d": await _scalar("SELECT COUNT(*) FROM user_activity_events WHERE event_name='auth.login' AND created_at >= datetime('now','localtime','-7 days')"), + "logins_total": await _scalar("SELECT COUNT(*) FROM user_activity_events WHERE event_name='auth.login'"), + "events_today_by_name": await _rows( + """ + SELECT event_name, COUNT(*) AS count + FROM user_activity_events + WHERE date(created_at)=date('now','localtime') + GROUP BY event_name + ORDER BY count DESC + LIMIT 12 + """ + ), + "events_7d_by_name": await _rows( + """ + SELECT event_name, COUNT(*) AS count + FROM user_activity_events + WHERE created_at >= datetime('now','localtime','-7 days') + GROUP BY event_name + ORDER BY count DESC + LIMIT 12 + """ + ), + "events_total_by_name": await _rows( + """ + SELECT event_name, COUNT(*) AS count + FROM user_activity_events + GROUP BY event_name + ORDER BY count DESC + LIMIT 12 + """ + ), + }, + "series": { + "new_users": await _rows( + """ + SELECT date(created_at) AS day, COUNT(*) AS count + FROM users + GROUP BY day + ORDER BY day DESC + LIMIT 14 + """ + ), + "active_devices": await _rows( + """ + SELECT day, COUNT(DISTINCT mac) AS count + FROM ( + SELECT date(created_at) AS day, mac FROM device_heartbeats + UNION ALL + SELECT date(created_at) AS day, mac FROM render_logs + ) + GROUP BY day + ORDER BY day DESC + LIMIT 14 + """ + ), + "renders": await _rows( + """ + SELECT date(created_at) AS day, COUNT(*) AS count + FROM render_logs + GROUP BY day + ORDER BY day DESC + LIMIT 14 + """ + ), + "activity_events": await _rows( + """ + SELECT date(created_at) AS day, COUNT(DISTINCT user_id) AS active_users + FROM user_activity_events + WHERE user_id IS NOT NULL AND event_name != 'auth.register' + GROUP BY day + ORDER BY day DESC + LIMIT 14 + """ + ), + }, + "top": { + "events": await _rows( + """ + SELECT event_name, COUNT(*) AS count + FROM user_activity_events + WHERE created_at >= datetime('now','localtime','-7 days') + GROUP BY event_name + ORDER BY count DESC + LIMIT 12 + """ + ), + "modes": await _rows( + """ + SELECT persona AS mode, COUNT(*) AS count + FROM render_logs + WHERE created_at >= datetime('now','localtime','-7 days') + GROUP BY persona + ORDER BY count DESC + LIMIT 12 + """ + ), + }, + } + + +@router.get("/admin/analytics/overview") +async def admin_analytics_overview(_: int = Depends(get_current_root_user)): + return await _analytics_overview_payload() + + +@router.get("/admin/console/summary") +async def admin_console_summary(_: int = Depends(get_current_root_user)): + return await _analytics_overview_payload() diff --git a/backend/api/routes/auth.py b/backend/api/routes/auth.py index bd0ace1..80c00ef 100644 --- a/backend/api/routes/auth.py +++ b/backend/api/routes/auth.py @@ -5,10 +5,11 @@ import aiosqlite import phonenumbers -from fastapi import APIRouter, Depends, Response +from fastapi import APIRouter, Depends, Request, Response from fastapi.responses import JSONResponse from phonenumbers.phonenumberutil import NumberParseException +from core.activity_store import log_user_activity from core.auth import clear_session_cookie, create_session_token, require_user, set_session_cookie from core.config_store import authenticate_user, _hash_password, get_user_api_quota from core.db import get_main_db @@ -62,7 +63,7 @@ async def _phone_exists(db, normalized_phone: str) -> bool: @router.post("/auth/register") -async def auth_register(body: dict, response: Response): +async def auth_register(body: dict, response: Response, request: Request = None): username = (body.get("username") or "").strip() password = body.get("password") or "" phone = (body.get("phone") or "").strip() @@ -133,11 +134,12 @@ async def auth_register(body: dict, response: Response): token = create_session_token(user_id, username) set_session_cookie(response, token) + await log_user_activity(user_id, "auth.register", request=request, metadata={"username": username}) return {"ok": True, "user_id": user_id, "username": username, "token": token} @router.post("/auth/login") -async def auth_login(body: dict, response: Response): +async def auth_login(body: dict, response: Response, request: Request = None): username = (body.get("username") or "").strip() password = body.get("password") or "" user = await authenticate_user(username, password) @@ -145,6 +147,7 @@ async def auth_login(body: dict, response: Response): return JSONResponse({"error": "用户名或密码错误"}, status_code=401) token = create_session_token(user["id"], user["username"]) set_session_cookie(response, token) + await log_user_activity(user["id"], "auth.login", request=request) return {"ok": True, "user_id": user["id"], "username": user["username"], "token": token} diff --git a/backend/api/routes/config.py b/backend/api/routes/config.py index 0c74cff..bf421ac 100644 --- a/backend/api/routes/config.py +++ b/backend/api/routes/config.py @@ -6,6 +6,7 @@ from fastapi.responses import JSONResponse from api.shared import ensure_web_or_device_access, logger +from core.activity_store import log_user_activity from core.auth import is_admin_authorized, require_admin, validate_mac_param from core.config_store import ( activate_config, @@ -32,8 +33,9 @@ async def post_config( ): data = body.model_dump(by_alias=True) mac = data["mac"] + access = None if not is_admin_authorized(authorization): - await ensure_web_or_device_access( + access = await ensure_web_or_device_access( request, mac, x_device_token, @@ -50,6 +52,13 @@ async def post_config( ) config_id = await save_config(mac, data) await set_pending_refresh(mac, True) + if access and access.get("mode") == "user": + await log_user_activity( + int(access["user_id"]), + "config.save", + request=request, + metadata={"mac": mac, "modes": len(modes) if isinstance(modes, list) else 0}, + ) saved_config = await get_active_config(mac) if saved_config: diff --git a/backend/api/routes/discover.py b/backend/api/routes/discover.py index 39fa355..9abe562 100644 --- a/backend/api/routes/discover.py +++ b/backend/api/routes/discover.py @@ -9,10 +9,11 @@ from pathlib import Path from typing import Optional -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi.responses import JSONResponse from api.shared import logger +from core.activity_store import log_user_activity from core.auth import require_user from core.config import SCREEN_HEIGHT, SCREEN_WIDTH from core.config_store import get_main_db @@ -99,6 +100,7 @@ async def list_shared_modes( @router.post("/discover/modes/publish") async def publish_mode( body: dict, + request: Request, user_id: int = Depends(require_user), ): """发布模式到广场(需要认证)""" @@ -329,6 +331,12 @@ async def publish_mode( shared_mode_id = cursor.lastrowid logger.info(f"[DISCOVER] User {user_id} published mode {source_custom_mode_id} as shared mode {shared_mode_id}") + await log_user_activity( + user_id, + "mode.publish", + request=request, + metadata={"mode_id": source_custom_mode_id, "shared_mode_id": shared_mode_id, "category": category, "mac": mac}, + ) return {"ok": True, "id": shared_mode_id} @@ -336,6 +344,7 @@ async def publish_mode( async def install_shared_mode( mode_id: int, body: dict, + request: Request, user_id: int = Depends(require_user), ): """安装共享模式到用户本地设备(需要认证)""" @@ -406,4 +415,10 @@ async def install_shared_mode( return JSONResponse({"error": "模式加载失败"}, status_code=500) logger.info(f"[DISCOVER] User {user_id} installed shared mode {mode_id} as {new_mode_id} on device {mac}") + await log_user_activity( + user_id, + "mode.install", + request=request, + metadata={"shared_mode_id": mode_id, "mode_id": new_mode_id, "mac": mac}, + ) return {"ok": True, "custom_mode_id": new_mode_id} diff --git a/backend/api/routes/modes.py b/backend/api/routes/modes.py index f1ed3fe..18b8ffa 100644 --- a/backend/api/routes/modes.py +++ b/backend/api/routes/modes.py @@ -4,11 +4,12 @@ import json as jsonlib from pathlib import Path import os -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, Query, Request from fastapi.responses import JSONResponse, StreamingResponse from openai import OpenAIError from api.shared import logger +from core.activity_store import log_user_activity from core.auth import require_user, optional_user from core.config import SCREEN_HEIGHT, SCREEN_WIDTH, get_default_llm_model_for_provider from core.config_store import remove_mode_from_all_configs @@ -553,7 +554,7 @@ async def custom_mode_preview( @router.post("/modes/custom") -async def create_custom_mode(body: dict, user_id: int = Depends(require_user)): +async def create_custom_mode(body: dict, request: Request, user_id: int = Depends(require_user)): """Create a custom mode. - With `mac`: persist to DB with user+device isolation. @@ -585,6 +586,7 @@ async def create_custom_mode(body: dict, user_id: int = Depends(require_user)): file_path.unlink(missing_ok=True) return JSONResponse({"error": "Failed to load mode definition"}, status_code=400) logger.info("[MODES] Created legacy custom mode %s for user %s", mode_id, user_id) + await log_user_activity(user_id, "mode.custom.create", request=request, metadata={"mode_id": mode_id, "legacy": True}) return {"ok": True, "mode_id": mode_id} # Validate device ownership for DB path @@ -609,6 +611,7 @@ async def create_custom_mode(body: dict, user_id: int = Depends(require_user)): return JSONResponse({"error": "Failed to load mode definition"}, status_code=400) logger.info(f"[MODES] Created custom mode {mode_id} for user {user_id} on device {mac}") + await log_user_activity(user_id, "mode.custom.create", request=request, metadata={"mode_id": mode_id, "mac": mac}) return {"ok": True, "mode_id": mode_id} diff --git a/backend/api/routes/pages.py b/backend/api/routes/pages.py index 8658c24..76f462c 100644 --- a/backend/api/routes/pages.py +++ b/backend/api/routes/pages.py @@ -7,23 +7,50 @@ from typing import Optional from fastapi import APIRouter -from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response +from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response from PIL import Image, ImageDraw router = APIRouter(tags=["pages"]) -def _load_web_page_html(filename: str) -> str: - project_root = Path(__file__).resolve().parent.parent.parent.parent - html_path = project_root / "webconfig" / filename - if not html_path.exists(): - raise FileNotFoundError(f"Static page not found in webconfig: {filename}") - html = html_path.read_text(encoding="utf-8") - if "/webconfig/i18n.js" not in html: - html = html.replace("
", '')
- if "/webconfig/role-banner.js" not in html:
- html = html.replace("", '')
- return html
+def _project_root() -> Path:
+ return Path(__file__).resolve().parent.parent.parent.parent
+
+
+def _backend_root() -> Path:
+ return Path(__file__).resolve().parent.parent.parent
+
+
+def _console_index_path() -> Path:
+ return _backend_root() / "static" / "console" / "index.html"
+
+
+def _read_file_response(root: Path, asset_path: str, not_found_message: str) -> Response | JSONResponse:
+ root = root.resolve()
+ file_path = (root / asset_path).resolve()
+ try:
+ file_path.relative_to(root)
+ except ValueError:
+ return JSONResponse({"error": "asset_not_found", "message": not_found_message}, status_code=404)
+ if file_path != root and file_path.exists() and file_path.is_file():
+ media_type, _ = mimetypes.guess_type(str(file_path))
+ return Response(content=file_path.read_bytes(), media_type=media_type or "application/octet-stream")
+ return JSONResponse({"error": "asset_not_found", "message": not_found_message}, status_code=404)
+
+
+def _primary_webapp_base() -> str:
+ return os.getenv("INKSIGHT_PRIMARY_WEBAPP_URL", "").strip().rstrip("/")
+
+
+def _primary_webapp_url(path: str, mac: Optional[str] = None) -> str:
+ base = _primary_webapp_base()
+ if not base:
+ return ""
+ target = f"{base}{path}"
+ if mac:
+ separator = "&" if "?" in target else "?"
+ target = f"{target}{separator}mac={mac}"
+ return target
def _build_primary_config_url(mac: Optional[str] = None) -> Optional[str]:
@@ -37,21 +64,12 @@ def _build_primary_config_url(mac: Optional[str] = None) -> Optional[str]:
def _legacy_config_bridge_html(mac: Optional[str] = None) -> str:
- primary_url = _build_primary_config_url(mac)
- primary_link = (
- f''
- "Open primary config"
- ""
+ primary_url = _build_primary_config_url(mac) or _primary_webapp_url("/config", mac)
+ primary_action = (
+ f'Open primary config'
if primary_url
- else (
- ''
- "INKSIGHT_PRIMARY_WEBAPP_URL"
- ""
- )
+ else 'Set INKSIGHT_PRIMARY_WEBAPP_URL to enable redirects'
)
- legacy_href = f"/legacy/config?mac={mac}" if mac else "/legacy/config"
return f"""
@@ -65,30 +83,56 @@ def _legacy_config_bridge_html(mac: Optional[str] = None) -> str:
The backend no longer serves the legacy config page at /config.
- Use the primary web app for daily device configuration. Legacy webconfig remains available only for diagnostics and mode authoring.
+ Use the primary web app for daily device configuration.
- If you want automatic redirects here, set INKSIGHT_PRIMARY_WEBAPP_URL to your web app base URL.
+ Legacy webconfig HTML has been retired. Device APIs remain available on this backend.
+
+ + +