fix: repair admin monitor auth and add invite CRUD

This commit is contained in:
JOJO 2026-04-07 18:23:09 +08:00
parent 7e1d9bfede
commit 9a2bae04b5
6 changed files with 321 additions and 7 deletions

View File

@ -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)

View File

@ -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,
},
}

View File

@ -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

View File

@ -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 {}

View File

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>管理员监控面板</title>
<link rel="stylesheet" href="/static/dist/assets/admin.css" />
<script src="/static/security.js"></script>
</head>
<body>
<div id="admin-app"></div>

View File

@ -310,11 +310,44 @@
<span>已用{{ inviteSummary.consumed || 0 }}</span>
<span>无限制{{ inviteSummary.unlimited || 0 }}</span>
</div>
<div class="invite-manage">
<input
v-model.trim="newInviteCode"
class="invite-input"
type="text"
placeholder="新邀请码"
:disabled="inviteSubmitting"
/>
<input
v-model="newInviteRemaining"
class="invite-input short"
type="number"
min="0"
placeholder="剩余次数"
:disabled="inviteSubmitting || newInviteUnlimited"
/>
<label class="invite-check">
<input type="checkbox" v-model="newInviteUnlimited" :disabled="inviteSubmitting" />
不限次数
</label>
<button type="button" :disabled="inviteSubmitting" @click="createInvite">
{{ inviteSubmitting ? '处理中...' : '新增邀请码' }}
</button>
</div>
<p v-if="inviteError" class="secondary-error">{{ inviteError }}</p>
<div class="invite-grid">
<div v-if="!inviteCodes.length" class="invite-card">暂无邀请码数据</div>
<div v-for="code in inviteCodes" :key="code.code" class="invite-card">
<h4>{{ code.code }}</h4>
<span>{{ inviteStatus(code.remaining) }}</span>
<div class="invite-actions">
<button type="button" class="mini-btn" :disabled="inviteSubmitting" @click="editInvite(code)">
调整次数
</button>
<button type="button" class="mini-btn danger" :disabled="inviteSubmitting" @click="removeInvite(code.code)">
删除
</button>
</div>
</div>
</div>
</section>
@ -353,6 +386,11 @@ const balanceLoading = ref(false);
const balanceError = ref<string | null>(null);
const balanceData = ref<Record<string, any> | null>(null);
const balanceUpdatedAt = ref<number | null>(null);
const inviteSubmitting = ref(false);
const inviteError = ref<string | null>(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<string> => {
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;