- 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>
285 lines
10 KiB
Python
285 lines
10 KiB
Python
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})
|