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 @@ 管理员监控面板 +
diff --git a/static/src/admin/AdminDashboardApp.vue b/static/src/admin/AdminDashboardApp.vue index 367ac54..8162560 100644 --- a/static/src/admin/AdminDashboardApp.vue +++ b/static/src/admin/AdminDashboardApp.vue @@ -310,11 +310,44 @@ 已用:{{ inviteSummary.consumed || 0 }} 无限制:{{ inviteSummary.unlimited || 0 }} +
+ + + + +
+

{{ inviteError }}

暂无邀请码数据

{{ code.code }}

{{ inviteStatus(code.remaining) }} +
+ + +
@@ -353,6 +386,11 @@ const balanceLoading = ref(false); const balanceError = ref(null); const balanceData = ref | null>(null); const balanceUpdatedAt = ref(null); +const inviteSubmitting = ref(false); +const inviteError = ref(null); +const newInviteCode = ref(''); +const newInviteRemaining = ref('1'); +const newInviteUnlimited = ref(false); const sectionTabs: Array<{ id: SectionId; label: string }> = [ { id: 'overview', label: '系统总览' }, @@ -422,6 +460,125 @@ const scheduleAutoRefresh = () => { const handleManualRefresh = () => fetchDashboard(true); const handleRetry = () => fetchDashboard(false); +const fetchCsrfToken = async (): Promise => { + const resp = await fetch('/api/csrf-token', { credentials: 'same-origin' }); + if (!resp.ok) { + throw new Error(`获取 CSRF Token 失败:${resp.status}`); + } + const payload = await resp.json(); + const token = payload?.token; + if (!token || typeof token !== 'string') { + throw new Error('获取 CSRF Token 失败:响应无 token'); + } + return token; +}; + +const parseInviteRemaining = (raw: unknown): number | null => { + const text = String(raw ?? '').trim().toLowerCase(); + if (!text || text === 'unlimited' || text === 'null' || text === 'none' || text === '不限' || text === '无限制') { + return null; + } + const parsed = Number(text); + if (!Number.isFinite(parsed) || parsed < 0 || !Number.isInteger(parsed)) { + throw new Error('剩余次数必须是大于等于 0 的整数,或留空表示不限次数'); + } + return parsed; +}; + +const createInvite = async () => { + const code = newInviteCode.value.trim(); + if (!code) { + inviteError.value = '请输入邀请码'; + return; + } + inviteSubmitting.value = true; + inviteError.value = null; + try { + const remaining = newInviteUnlimited.value ? null : parseInviteRemaining(newInviteRemaining.value); + const csrfToken = await fetchCsrfToken(); + const resp = await fetch('/api/admin/invites', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken, + }, + credentials: 'same-origin', + body: JSON.stringify({ code, remaining }), + }); + const payload = await resp.json(); + if (!resp.ok || !payload.success) { + throw new Error(payload.error || `请求失败:${resp.status}`); + } + newInviteCode.value = ''; + newInviteRemaining.value = '1'; + newInviteUnlimited.value = false; + await fetchDashboard(true); + } catch (error) { + inviteError.value = error instanceof Error ? error.message : String(error); + } finally { + inviteSubmitting.value = false; + } +}; + +const editInvite = async (item: any) => { + const current = item?.remaining; + const seed = current === null || typeof current === 'undefined' ? 'unlimited' : String(current); + const value = window.prompt('请输入新的剩余次数(留空或输入 unlimited 表示不限次数)', seed); + if (value === null) return; + inviteSubmitting.value = true; + inviteError.value = null; + try { + const remaining = parseInviteRemaining(value); + const csrfToken = await fetchCsrfToken(); + const resp = await fetch('/api/admin/invites', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken, + }, + credentials: 'same-origin', + body: JSON.stringify({ code: item?.code, remaining }), + }); + const payload = await resp.json(); + if (!resp.ok || !payload.success) { + throw new Error(payload.error || `请求失败:${resp.status}`); + } + await fetchDashboard(true); + } catch (error) { + inviteError.value = error instanceof Error ? error.message : String(error); + } finally { + inviteSubmitting.value = false; + } +}; + +const removeInvite = async (code: string) => { + if (!code) return; + if (!window.confirm(`确认删除邀请码 ${code}?`)) return; + inviteSubmitting.value = true; + inviteError.value = null; + try { + const csrfToken = await fetchCsrfToken(); + const resp = await fetch('/api/admin/invites', { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken, + }, + credentials: 'same-origin', + body: JSON.stringify({ code }), + }); + const payload = await resp.json(); + if (!resp.ok || !payload.success) { + throw new Error(payload.error || `请求失败:${resp.status}`); + } + await fetchDashboard(true); + } catch (error) { + inviteError.value = error instanceof Error ? error.message : String(error); + } finally { + inviteSubmitting.value = false; + } +}; + const fetchBalance = async () => { balanceLoading.value = true; balanceError.value = null; @@ -1152,6 +1309,37 @@ th { gap: 12px; } +.invite-manage { + margin: 10px 0 14px; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; +} + +.invite-input { + min-width: 180px; + height: 38px; + border-radius: 10px; + border: 1px solid rgba(118, 103, 84, 0.35); + background: rgba(255, 255, 255, 0.92); + padding: 0 10px; + box-sizing: border-box; +} + +.invite-input.short { + min-width: 120px; + width: 120px; +} + +.invite-check { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--claude-text-secondary); + font-size: 13px; +} + .invite-card { border: 1px solid rgba(118, 103, 84, 0.3); border-radius: 16px; @@ -1169,6 +1357,24 @@ th { font-size: 13px; } +.invite-actions { + margin-top: 10px; + display: flex; + gap: 8px; +} + +.mini-btn { + border-radius: 10px; + padding: 6px 10px; + font-size: 12px; +} + +.mini-btn.danger { + background: rgba(189, 93, 58, 0.2); + color: #7c3418; + box-shadow: none; +} + .balance-header { display: flex; align-items: center;