agent-Specialization/server/admin.py
JOJO d6fb59e1d8 refactor: split web_server into modular architecture
- Refactor 6000+ line web_server.py into server/ module
- Create separate modules: auth, chat, conversation, files, admin, etc.
- Keep web_server.py as backward-compatible entry point
- Add container running status field in user_container_manager
- Improve admin dashboard API with credentials and debug support

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-22 09:21:53 +08:00

285 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
from pathlib import Path
import time
import mimetypes
import logging
import json
from flask import Blueprint, jsonify, send_from_directory, request, current_app, redirect, abort
from .auth_helpers import login_required, admin_required, admin_api_required, api_login_required, get_current_user_record, resolve_admin_policy
from .security import rate_limited
from .utils_common import debug_log
from . import state # 使用动态 state确保与入口实例保持一致
from .state import custom_tool_registry, user_manager, container_manager, PROJECT_MAX_STORAGE_MB
from .conversation import build_admin_dashboard_snapshot as _build_dashboard_rich
from modules import admin_policy_manager, balance_client
from collections import Counter
admin_bp = Blueprint('admin', __name__)
@admin_bp.route('/admin/monitor')
@login_required
@admin_required
def admin_monitor_page():
"""管理员监控页面入口"""
return send_from_directory(str(Path(current_app.static_folder)/"admin_dashboard"), 'index.html')
@admin_bp.route('/admin/policy')
@login_required
@admin_required
def admin_policy_page():
"""管理员策略配置页面"""
return send_from_directory(Path(current_app.static_folder) / 'admin_policy', 'index.html')
@admin_bp.route('/admin/custom-tools')
@login_required
@admin_required
def admin_custom_tools_page():
"""自定义工具管理页面"""
return send_from_directory(str(Path(current_app.static_folder)/"custom_tools"), 'index.html')
@admin_bp.route('/api/admin/balance', methods=['GET'])
@login_required
@admin_required
def admin_balance_api():
"""查询第三方账户余额Kimi/DeepSeek/Qwen"""
data = balance_client.fetch_all_balances()
return jsonify({"success": True, "data": data})
@admin_bp.route('/admin/assets/<path:filename>')
@login_required
@admin_required
def admin_asset_file(filename: str):
return send_from_directory(str(Path(current_app.static_folder)/"admin_dashboard"), filename)
@admin_bp.route('/user_upload/<path:filename>')
@login_required
def serve_user_upload(filename: str):
"""
直接向前端暴露当前登录用户的上传目录文件,用于 <show_image src="/user_upload/..."> 等场景。
- 仅登录用户可访问
- 路径穿越校验:目标必须位于用户自己的 uploads_dir 内
"""
user = get_current_user_record()
if not user:
return redirect('/login')
workspace = user_manager.ensure_user_workspace(user.username)
uploads_dir = workspace.uploads_dir.resolve()
target = (uploads_dir / filename).resolve()
try:
target.relative_to(uploads_dir)
except ValueError:
abort(403)
if not target.exists() or not target.is_file():
abort(404)
return send_from_directory(str(uploads_dir), str(target.relative_to(uploads_dir)))
@admin_bp.route('/workspace/<path:filename>')
@login_required
def serve_workspace_file(filename: str):
"""
暴露当前登录用户项目目录下的文件(主要用于图片展示)。
- 仅登录用户可访问自己的项目文件
- 路径穿越校验:目标必须位于用户自己的 project_path 内
- 非图片直接拒绝,避免误暴露其他文件
"""
user = get_current_user_record()
if not user:
return redirect('/login')
workspace = user_manager.ensure_user_workspace(user.username)
project_root = workspace.project_path.resolve()
target = (project_root / filename).resolve()
try:
target.relative_to(project_root)
except ValueError:
abort(403)
if not target.exists() or not target.is_file():
abort(404)
mime_type, _ = mimetypes.guess_type(str(target))
if not mime_type or not mime_type.startswith("image/"):
abort(415)
return send_from_directory(str(target.parent), target.name)
@admin_bp.route('/static/<path:filename>')
def static_files(filename):
"""提供静态文件"""
if filename.startswith('admin_dashboard'):
abort(404)
return send_from_directory('static', filename)
@admin_bp.route('/api/admin/dashboard')
@api_login_required
@admin_api_required
def admin_dashboard_snapshot_api():
try:
# 若当前管理员没有容器句柄,主动确保容器存在,避免面板始终显示“宿主机模式”
try:
record = get_current_user_record()
uname = record.username if record else None
if uname:
handles = state.container_manager.list_containers()
if uname not in handles:
state.container_manager.ensure_container(uname, str(state.user_manager.ensure_user_workspace(uname).project_path))
except Exception as ensure_exc:
logging.getLogger(__name__).warning("ensure_container for admin failed: %s", ensure_exc)
snapshot = build_admin_dashboard_snapshot()
# 双通道输出:标准日志 + 调试文件
try:
logging.getLogger(__name__).info(
"[admin_dashboard] snapshot=%s",
json.dumps(snapshot, ensure_ascii=False)[:2000],
)
except Exception:
pass
try:
debug_log(f"[admin_dashboard] {json.dumps(snapshot, ensure_ascii=False)[:2000]}")
except Exception:
pass
return jsonify({"success": True, "data": snapshot})
except Exception as exc:
logging.exception("Failed to build admin dashboard")
return jsonify({"success": False, "error": str(exc)}), 500
def build_admin_dashboard_snapshot():
"""
复用对话模块的完整统计逻辑,返回带用量/Token/存储/上传等数据的仪表盘快照。
"""
try:
return _build_dashboard_rich()
except Exception as exc:
# 兜底:若高级统计失败,仍返回最小信息,避免前端 500
logging.getLogger(__name__).warning("rich dashboard snapshot failed: %s", exc)
handle_map = state.container_manager.list_containers()
return {
"generated_at": time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
"overview": {
"totals": {
"users": len(state.user_manager.list_users() or {}),
"active_users": len(handle_map),
"containers_active": len(handle_map),
"containers_max": getattr(state.container_manager, "max_containers", len(handle_map)),
"available_container_slots": None,
}
},
"containers": [
{"username": u, "status": h} for u, h in handle_map.items()
],
}
@admin_bp.route('/api/admin/policy', methods=['GET', 'POST'])
@api_login_required
@admin_api_required
def admin_policy_api():
if request.method == 'GET':
try:
data = admin_policy_manager.load_policy()
defaults = admin_policy_manager.describe_defaults()
return jsonify({"success": True, "data": data, "defaults": defaults})
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 500
# POST 更新
payload = request.get_json() or {}
target_type = payload.get("target_type")
target_value = payload.get("target_value") or ""
config = payload.get("config") or {}
try:
saved = admin_policy_manager.save_scope_policy(target_type, target_value, config)
return jsonify({"success": True, "data": saved})
except ValueError as exc:
return jsonify({"success": False, "error": str(exc)}), 400
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 500
@admin_bp.route('/api/admin/custom-tools', methods=['GET', 'POST', 'DELETE'])
@api_login_required
@admin_api_required
def admin_custom_tools_api():
"""自定义工具管理(仅全局管理员)。"""
try:
if request.method == 'GET':
return jsonify({"success": True, "data": custom_tool_registry.list_tools()})
if request.method == 'POST':
payload = request.get_json() or {}
saved = custom_tool_registry.upsert_tool(payload)
return jsonify({"success": True, "data": saved})
# DELETE
tool_id = request.args.get("id") or (request.get_json() or {}).get("id")
if not tool_id:
return jsonify({"success": False, "error": "缺少 id"}), 400
removed = custom_tool_registry.delete_tool(tool_id)
if removed:
return jsonify({"success": True, "data": {"deleted": tool_id}})
return jsonify({"success": False, "error": "未找到该工具"}), 404
except ValueError as exc:
return jsonify({"success": False, "error": str(exc)}), 400
except Exception as exc:
logging.exception("custom-tools API error")
return jsonify({"success": False, "error": str(exc)}), 500
@admin_bp.route('/api/admin/custom-tools/file', methods=['GET', 'POST'])
@api_login_required
@admin_api_required
def admin_custom_tools_file_api():
tool_id = request.args.get("id") or (request.get_json() or {}).get("id")
name = request.args.get("name") or (request.get_json() or {}).get("name")
if not tool_id or not name:
return jsonify({"success": False, "error": "缺少 id 或 name"}), 400
tool_dir = Path(custom_tool_registry.root) / tool_id
if not tool_dir.exists():
return jsonify({"success": False, "error": "工具不存在"}), 404
target = tool_dir / name
if request.method == 'GET':
if not target.exists():
return jsonify({"success": False, "error": "文件不存在"}), 404
try:
return target.read_text(encoding="utf-8")
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 500
# POST 保存文件
payload = request.get_json() or {}
content = payload.get("content")
try:
target.write_text(content or "", encoding="utf-8")
return jsonify({"success": True})
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 500
@admin_bp.route('/api/admin/custom-tools/reload', methods=['POST'])
@api_login_required
@admin_api_required
def admin_custom_tools_reload_api():
try:
custom_tool_registry.reload()
return jsonify({"success": True})
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 500
@admin_bp.route('/api/effective-policy', methods=['GET'])
@api_login_required
def effective_policy_api():
record = get_current_user_record()
policy = resolve_admin_policy(record)
return jsonify({"success": True, "data": policy})