From 9a2bae04b5001cc0ccf3452a56e77f9e9183bf73 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Tue, 7 Apr 2026 18:23:09 +0800 Subject: [PATCH] fix: repair admin monitor auth and add invite CRUD --- modules/user_manager.py | 32 ++++ server/admin.py | 59 +++++++ server/auth_helpers.py | 8 +- server/conversation_stats.py | 22 ++- static/admin_dashboard/index.html | 1 + static/src/admin/AdminDashboardApp.vue | 206 +++++++++++++++++++++++++ 6 files changed, 321 insertions(+), 7 deletions(-) diff --git a/modules/user_manager.py b/modules/user_manager.py index 197d5d2..8b2f588 100644 --- a/modules/user_manager.py +++ b/modules/user_manager.py @@ -153,6 +153,38 @@ class UserManager: def list_invite_codes(self): return list(self._invites.values()) + def upsert_invite_code(self, code: str, remaining: Optional[int]) -> Dict: + """新增或更新邀请码。remaining=None 表示不限次数。""" + invite_code = (code or "").strip() + if not invite_code: + raise ValueError("邀请码不能为空。") + if len(invite_code) > 64: + raise ValueError("邀请码长度不能超过 64 个字符。") + if remaining is not None: + try: + remaining = int(remaining) + except (TypeError, ValueError): + raise ValueError("remaining 必须是整数或 null。") + if remaining < 0: + raise ValueError("remaining 不能小于 0。") + entry = { + "code": invite_code, + "remaining": remaining, + } + self._invites[invite_code] = entry + self._save_invite_codes() + return dict(entry) + + def delete_invite_code(self, code: str) -> bool: + invite_code = (code or "").strip() + if not invite_code: + raise ValueError("邀请码不能为空。") + existed = invite_code in self._invites + if existed: + self._invites.pop(invite_code, None) + self._save_invite_codes() + return existed + def list_users(self) -> Dict[str, UserRecord]: """返回当前注册用户的浅拷贝字典,键为用户名。""" return dict(self._users) diff --git a/server/admin.py b/server/admin.py index 8289b36..2fb1300 100644 --- a/server/admin.py +++ b/server/admin.py @@ -19,6 +19,7 @@ from .conversation import ( collect_upload_events, summarize_upload_events, ) +from .conversation_stats import summarize_invite_codes from modules import admin_policy_manager, balance_client from collections import Counter from config import ADMIN_SECONDARY_PASSWORD_HASH, ADMIN_SECONDARY_PASSWORD, ADMIN_SECONDARY_TTL_SECONDS @@ -58,6 +59,14 @@ def admin_monitor_page(): """管理员监控页面入口""" return send_from_directory(str(Path(current_app.static_folder)/"admin_dashboard"), 'index.html') + +@admin_bp.route('/monitor') +@login_required +@admin_required +def admin_monitor_page_alias(): + """兼容旧入口:/monitor -> /admin/monitor""" + return redirect('/admin/monitor') + @admin_bp.route('/admin/policy') @login_required @admin_required @@ -233,6 +242,50 @@ def admin_dashboard_snapshot_api(): return jsonify({"success": False, "error": str(exc)}), 500 +@admin_bp.route('/api/admin/invites', methods=['GET', 'POST', 'PUT', 'DELETE']) +@api_login_required +@admin_api_required +def admin_invites_api(): + """邀请码管理:查询 / 新增 / 更新 / 删除。""" + guard = _secondary_required() + if guard: + return guard + + try: + if request.method == 'GET': + codes = user_manager.list_invite_codes() + return jsonify({ + "success": True, + "data": { + "summary": summarize_invite_codes(codes), + "codes": codes, + }, + }) + + payload = request.get_json(silent=True) or {} + code = (payload.get("code") or request.args.get("code") or "").strip() + + if request.method in {'POST', 'PUT'}: + has_remaining_key = "remaining" in payload + remaining = payload.get("remaining") if has_remaining_key else None + # 未传 remaining 时默认 1,便于快速新增邀请码 + if not has_remaining_key: + remaining = 1 + saved = user_manager.upsert_invite_code(code, remaining) + return jsonify({"success": True, "data": saved}) + + # DELETE + deleted = user_manager.delete_invite_code(code) + if not deleted: + return jsonify({"success": False, "error": "邀请码不存在"}), 404 + return jsonify({"success": True, "data": {"deleted": code}}) + except ValueError as exc: + return jsonify({"success": False, "error": str(exc)}), 400 + except Exception as exc: + logging.exception("invites API error") + return jsonify({"success": False, "error": str(exc)}), 500 + + def build_admin_dashboard_snapshot(): """ 复用对话模块的完整统计逻辑,返回带用量/Token/存储/上传等数据的仪表盘快照。 @@ -243,6 +296,8 @@ def build_admin_dashboard_snapshot(): # 兜底:若高级统计失败,仍返回最小信息,避免前端 500 logging.getLogger(__name__).warning("rich dashboard snapshot failed: %s", exc) handle_map = state.container_manager.list_containers() + invite_codes = state.user_manager.list_invite_codes() + invite_summary = summarize_invite_codes(invite_codes) return { "generated_at": time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), "overview": { @@ -257,6 +312,10 @@ def build_admin_dashboard_snapshot(): "containers": [ {"username": u, "status": h} for u, h in handle_map.items() ], + "invites": { + "summary": invite_summary, + "codes": invite_codes, + }, } diff --git a/server/auth_helpers.py b/server/auth_helpers.py index 0544091..1e04c5a 100644 --- a/server/auth_helpers.py +++ b/server/auth_helpers.py @@ -73,8 +73,9 @@ def resolve_admin_policy(record=None) -> Dict[str, Any]: def admin_required(view_func): @wraps(view_func) def wrapped(*args, **kwargs): - record = get_current_user_record() - if not record or not is_admin_user(record): + # host 模式下可能不存在 users.json 记录(如 username=host), + # 但 session.role 已是 admin,此时仍应允许访问管理页。 + if not is_admin_user(): return redirect('/new') return view_func(*args, **kwargs) return wrapped @@ -83,8 +84,7 @@ def admin_required(view_func): def admin_api_required(view_func): @wraps(view_func) def wrapped(*args, **kwargs): - record = get_current_user_record() - if not record or not is_admin_user(record): + if not is_admin_user(): return jsonify({"success": False, "error": "需要管理员权限"}), 403 return view_func(*args, **kwargs) return wrapped diff --git a/server/conversation_stats.py b/server/conversation_stats.py index 1163235..eaf8285 100644 --- a/server/conversation_stats.py +++ b/server/conversation_stats.py @@ -2,15 +2,18 @@ from __future__ import annotations import json import os -from collections import Counter -from datetime import datetime +import time +from collections import Counter, deque +from datetime import datetime, timedelta from pathlib import Path from typing import Any, Dict, List, Optional from modules.user_manager import UserWorkspace +from modules.usage_tracker import QUOTA_DEFAULTS from utils.conversation_manager import ConversationManager +from .context import get_or_create_usage_tracker -from config import PROJECT_MAX_STORAGE_MB, PROJECT_MAX_STORAGE_BYTES, UPLOAD_SCAN_LOG_SUBDIR +from config import PROJECT_MAX_STORAGE_MB, PROJECT_MAX_STORAGE_BYTES, UPLOAD_SCAN_LOG_SUBDIR, LOGS_DIR from .state import ( RECENT_UPLOAD_EVENT_LIMIT, RECENT_UPLOAD_FEED_LIMIT, @@ -82,6 +85,19 @@ def compute_workspace_storage(workspace: UserWorkspace) -> Dict[str, Any]: def collect_usage_snapshot(username: str, workspace: UserWorkspace, role: Optional[str]) -> Dict[str, Any]: tracker = get_or_create_usage_tracker(username, workspace) + if not tracker: + # 兜底:理论上网页用户应始终有 tracker;这里避免因异常导致整个 dashboard 失败 + snapshot: Dict[str, Any] = {} + for metric in ("fast", "thinking", "search"): + default_limit = QUOTA_DEFAULTS.get("default", {}).get(metric, {}).get("limit", 0) + snapshot[metric] = { + "count": 0, + "window_start": None, + "reset_at": None, + "limit": default_limit, + } + snapshot["role"] = role or "user" + return snapshot stats = tracker.get_stats() quotas = stats.get("quotas") or {} windows = stats.get("windows") or {} diff --git a/static/admin_dashboard/index.html b/static/admin_dashboard/index.html index d95f7f3..33d296f 100644 --- a/static/admin_dashboard/index.html +++ b/static/admin_dashboard/index.html @@ -5,6 +5,7 @@
{{ inviteError }}