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/') @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/') @login_required def serve_user_upload(filename: str): """ 直接向前端暴露当前登录用户的上传目录文件,用于 等场景。 - 仅登录用户可访问 - 路径穿越校验:目标必须位于用户自己的 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/') @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/') 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})