fix: repair admin monitor auth and add invite CRUD
This commit is contained in:
parent
7e1d9bfede
commit
9a2bae04b5
@ -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)
|
||||
|
||||
@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user