Download app/admin/api.py from bg5/z: direct link, hf CLI and curl.
- Browser
- Download file 46.4 kB
-
https://huggingface.co/spaces/bg5/z/resolve/main/app/admin/api.py
- Command line
-
hf download hf://spaces/bg5/z/app/admin/api.py
-
curl -L -o api.py https://huggingface.co/spaces/bg5/z/resolve/main/app/admin/api.py
46.4 kB
| """ | |
| 管理后台 API 接口 | |
| 用于 htmx 调用的 HTML 片段返回 | |
| """ | |
| from datetime import datetime | |
| from html import escape | |
| from pathlib import Path | |
| import re | |
| from typing import Optional | |
| from fastapi import APIRouter, Depends, Request, UploadFile, File | |
| from fastapi.responses import HTMLResponse, JSONResponse | |
| from app.admin.auth import ( | |
| CSRF_COOKIE_NAME, | |
| SESSION_COOKIE_NAME, | |
| check_login_rate_limit, | |
| generate_csrf_token, | |
| record_login_failure, | |
| require_auth, | |
| require_csrf, | |
| reset_login_failures, | |
| ) | |
| from app.admin.config_manager import ( | |
| read_env_content, | |
| reset_env_to_example, | |
| save_form_config, | |
| save_source_config, | |
| ) | |
| from app.admin.stats import collect_admin_stats, normalize_trend_window | |
| from app.admin.template_loader import templates | |
| from app.core.config import settings | |
| from app.services.request_log_dao import get_request_log_dao | |
| from app.utils.format import format_compact_number | |
| from app.utils.logger import ( | |
| DEFAULT_LOG_DIR, | |
| logger, | |
| log_exception, | |
| resolve_log_file_path, | |
| ) | |
| from app.utils.utlis import mask_token | |
| router = APIRouter(prefix="/admin/api", tags=["admin-api"]) | |
| DEFAULT_TOKEN_NAMESPACE = "zai" | |
| # ==================== 认证 API ==================== | |
| async def login(request: Request): | |
| """管理后台登录""" | |
| from app.admin.auth import create_session | |
| try: | |
| client_id = request.client.host if request.client else "unknown" | |
| if not check_login_rate_limit(client_id): | |
| return JSONResponse( | |
| { | |
| "success": False, | |
| "message": "登录尝试过于频繁,请稍后再试", | |
| }, | |
| status_code=429, | |
| ) | |
| data = await request.json() | |
| password = data.get("password", "") | |
| # 创建 session | |
| session_token = create_session(password) | |
| if session_token: | |
| reset_login_failures(client_id) | |
| csrf_token = generate_csrf_token(session_token) | |
| is_secure = request.url.scheme == "https" | |
| # 登录成功,设置 cookie | |
| response = JSONResponse({ | |
| "success": True, | |
| "message": "登录成功" | |
| }) | |
| response.set_cookie( | |
| key=SESSION_COOKIE_NAME, | |
| value=session_token, | |
| httponly=True, | |
| max_age=86400, # 24小时 | |
| samesite="strict", | |
| secure=is_secure, | |
| ) | |
| response.set_cookie( | |
| key=CSRF_COOKIE_NAME, | |
| value=csrf_token, | |
| httponly=False, | |
| max_age=86400, | |
| samesite="strict", | |
| secure=is_secure, | |
| ) | |
| logger.info("✅ 管理后台登录成功") | |
| return response | |
| else: | |
| record_login_failure(client_id) | |
| # 密码错误 | |
| logger.warning("❌ 管理后台登录失败:密码错误") | |
| return JSONResponse({ | |
| "success": False, | |
| "message": "密码错误" | |
| }, status_code=401) | |
| except Exception as e: | |
| log_exception(logger, "❌ 登录异常") | |
| return JSONResponse({ | |
| "success": False, | |
| "message": "登录失败" | |
| }, status_code=500) | |
| async def logout(request: Request): | |
| """管理后台登出""" | |
| from app.admin.auth import delete_session, get_session_token_from_request | |
| session_token = get_session_token_from_request(request) | |
| delete_session(session_token) | |
| # 清除 cookie | |
| response = JSONResponse({ | |
| "success": True, | |
| "message": "已登出" | |
| }) | |
| response.delete_cookie(SESSION_COOKIE_NAME) | |
| response.delete_cookie(CSRF_COOKIE_NAME) | |
| logger.info("✅ 管理后台已登出") | |
| return response | |
| async def reload_settings(): | |
| """热重载配置(重新加载环境变量并更新 settings 对象)""" | |
| from dotenv import load_dotenv | |
| from app.core.openai import reset_upstream_client | |
| from app.core.config import settings | |
| from app.utils.guest_session_pool import close_guest_session_pool, get_guest_session_pool | |
| from app.utils.logger import setup_logger | |
| # 重新加载 .env 文件 | |
| load_dotenv(override=True) | |
| # 重新创建 Settings 对象并更新全局配置 | |
| new_settings = type(settings)() | |
| # 更新全局 settings 的所有属性 | |
| for field_name in new_settings.model_fields.keys(): | |
| setattr(settings, field_name, getattr(new_settings, field_name)) | |
| # 从数据库覆盖配置(优先级: 数据库 > 环境变量) | |
| from app.admin.config_manager import apply_db_overrides | |
| await apply_db_overrides(settings) | |
| from app.utils.token_pool import normalize_token_load_balance_strategy | |
| settings.TOKEN_LOAD_BALANCE_STRATEGY = normalize_token_load_balance_strategy( | |
| settings.TOKEN_LOAD_BALANCE_STRATEGY | |
| ) | |
| # 重新初始化 logger(使用新的 DEBUG_LOGGING 配置) | |
| setup_logger(log_dir=DEFAULT_LOG_DIR, debug_mode=settings.DEBUG_LOGGING) | |
| # 重建上游客户端单例,确保新的 API_ENDPOINT/base_url 立即生效。 | |
| await reset_upstream_client() | |
| if not settings.ANONYMOUS_MODE and get_guest_session_pool() is not None: | |
| logger.info("[guest_session.reload] ANONYMOUS_MODE=false, closing existing guest session pool") | |
| await close_guest_session_pool() | |
| try: | |
| from app.utils.token_pool import get_token_pool | |
| token_pool = get_token_pool() | |
| if token_pool is not None: | |
| await token_pool.set_strategy(settings.TOKEN_LOAD_BALANCE_STRATEGY) | |
| except Exception: | |
| logger.debug("更新 Token 池负载策略失败", exc_info=settings.DEBUG_LOGGING) | |
| logger.info(f"🔄 配置已热重载 (DEBUG_LOGGING={settings.DEBUG_LOGGING})") | |
| def _build_alert( | |
| message: str, | |
| *, | |
| title: str, | |
| level: str, | |
| status_code: int = 200, | |
| ) -> HTMLResponse: | |
| level_classes = { | |
| "success": "bg-green-100 border-green-400 text-green-700", | |
| "warning": "bg-yellow-100 border-yellow-400 text-yellow-700", | |
| "error": "bg-red-100 border-red-400 text-red-700", | |
| "info": "bg-blue-100 border-blue-400 text-blue-700", | |
| } | |
| classes = level_classes.get(level, level_classes["info"]) | |
| safe_title = escape(title) | |
| safe_message = escape(message) | |
| return HTMLResponse( | |
| f""" | |
| <div class="{classes} border px-4 py-3 rounded relative" role="alert"> | |
| <strong class="font-bold">{safe_title}</strong> | |
| <span class="block sm:inline">{safe_message}</span> | |
| </div> | |
| """, | |
| status_code=status_code, | |
| ) | |
| def _with_hx_trigger(response: HTMLResponse, event_name: str) -> HTMLResponse: | |
| response.headers["HX-Trigger"] = event_name | |
| return response | |
| def _get_int_query_param( | |
| request: Request, | |
| name: str, | |
| default: int, | |
| *, | |
| minimum: int = 1, | |
| maximum: Optional[int] = None, | |
| ) -> int: | |
| """解析查询参数中的正整数,非法值回退到默认值。""" | |
| raw_value = request.query_params.get(name) | |
| if raw_value is None: | |
| return default | |
| try: | |
| value = int(str(raw_value).strip()) | |
| except (TypeError, ValueError): | |
| return default | |
| value = max(minimum, value) | |
| if maximum is not None: | |
| value = min(value, maximum) | |
| return value | |
| def _build_pagination( | |
| *, | |
| total_items: int, | |
| page: int, | |
| page_size: int, | |
| ) -> dict: | |
| """构建分页上下文。""" | |
| total_items = max(0, int(total_items)) | |
| page_size = max(1, int(page_size)) | |
| total_pages = max(1, (total_items + page_size - 1) // page_size) | |
| current_page = min(max(1, int(page)), total_pages) | |
| if total_items == 0: | |
| start_item = 0 | |
| end_item = 0 | |
| else: | |
| start_item = (current_page - 1) * page_size + 1 | |
| end_item = min(total_items, current_page * page_size) | |
| return { | |
| "current_page": current_page, | |
| "page_size": page_size, | |
| "total_items": total_items, | |
| "total_pages": total_pages, | |
| "has_previous": current_page > 1, | |
| "has_next": current_page < total_pages, | |
| "previous_page": max(1, current_page - 1), | |
| "next_page": min(total_pages, current_page + 1), | |
| "start_item": start_item, | |
| "end_item": end_item, | |
| } | |
| def _normalize_display_value(value: str) -> str: | |
| normalized = re.sub(r"[^a-z0-9]+", "", str(value or "").casefold()) | |
| return normalized | |
| def _is_redundant_source(source: str, client_name: str) -> bool: | |
| normalized_source = _normalize_display_value(source) | |
| normalized_client = _normalize_display_value(client_name) | |
| if not normalized_source: | |
| return True | |
| if not normalized_client: | |
| return False | |
| return normalized_source == normalized_client | |
| def _humanize_protocol(protocol: str) -> str: | |
| normalized = str(protocol or "").strip().lower() | |
| if normalized == "openai": | |
| return "OpenAI" | |
| if normalized == "unknown": | |
| return "Unknown" | |
| return normalized or "Unknown" | |
| def _read_log_tail(log_path: Path, max_bytes: int) -> tuple[str, bool, int]: | |
| if not log_path.exists() or not log_path.is_file(): | |
| return "", False, 0 | |
| file_size = log_path.stat().st_size | |
| read_size = min(file_size, max(1024, max_bytes)) | |
| with log_path.open("rb") as fh: | |
| if file_size > read_size: | |
| fh.seek(file_size - read_size) | |
| data = fh.read(read_size) | |
| truncated = file_size > read_size | |
| if truncated: | |
| first_newline = data.find(b"\n") | |
| if first_newline >= 0: | |
| data = data[first_newline + 1 :] | |
| return data.decode("utf-8", errors="replace"), truncated, file_size | |
| async def get_dashboard_usage_trend(request: Request): | |
| """返回仪表盘趋势图数据。""" | |
| trend_window = normalize_trend_window( | |
| request.query_params.get("window") | |
| ) | |
| dao = get_request_log_dao() | |
| trend_points = await dao.get_provider_usage_trend( | |
| DEFAULT_TOKEN_NAMESPACE, | |
| window=trend_window, | |
| ) | |
| return JSONResponse( | |
| { | |
| "window": trend_window, | |
| "points": trend_points, | |
| } | |
| ) | |
| async def get_runtime_log_tail(request: Request): | |
| """返回运行日志文件尾部内容。""" | |
| max_bytes = _get_int_query_param( | |
| request, | |
| "max_bytes", | |
| 65536, | |
| minimum=4096, | |
| maximum=262144, | |
| ) | |
| log_path = resolve_log_file_path(settings.LOG_FILE_PATH, log_dir=DEFAULT_LOG_DIR) | |
| text, truncated, file_size = _read_log_tail(log_path, max_bytes) | |
| context = { | |
| "request": request, | |
| "log_path": str(log_path), | |
| "content": text, | |
| "truncated": truncated, | |
| "file_size": file_size, | |
| "max_bytes": max_bytes, | |
| "updated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), | |
| } | |
| return templates.TemplateResponse( | |
| request, | |
| "components/runtime_log_tail.html", | |
| context, | |
| ) | |
| def _validate_directory_path(source_dir: str) -> str: | |
| if not source_dir: | |
| raise ValueError("请先填写服务端可访问的本地目录路径。") | |
| source_path = Path(source_dir).expanduser() | |
| if not source_path.exists(): | |
| raise ValueError(f"导入目录不存在: {source_path}") | |
| if not source_path.is_dir(): | |
| raise ValueError(f"导入路径不是目录: {source_path}") | |
| return str(source_path) | |
| async def get_token_pool_status(request: Request): | |
| """获取 Token 池状态(HTML 片段)""" | |
| from app.utils.token_pool import get_token_pool | |
| token_pool = get_token_pool() | |
| if not token_pool: | |
| # Token 池未初始化 | |
| context = { | |
| "request": request, | |
| "tokens": [], | |
| } | |
| return templates.TemplateResponse( | |
| request, | |
| "components/token_pool.html", | |
| context, | |
| ) | |
| # 获取 token 状态统计 | |
| pool_status = await token_pool.get_pool_status() | |
| tokens_info = [] | |
| for idx, token_info in enumerate(pool_status.get("tokens", []), 1): | |
| is_available = token_info.get("is_available", False) | |
| is_healthy = token_info.get("is_healthy", False) | |
| # 确定状态和颜色 | |
| if is_healthy: | |
| status = "健康" | |
| status_color = "bg-green-100 text-green-800" | |
| elif is_available: | |
| status = "可用" | |
| status_color = "bg-yellow-100 text-yellow-800" | |
| else: | |
| status = "失败" | |
| status_color = "bg-red-100 text-red-800" | |
| # 格式化最后使用时间 | |
| last_success = token_info.get("last_success_time", 0) | |
| if last_success > 0: | |
| from datetime import datetime | |
| last_used = datetime.fromtimestamp(last_success).strftime("%Y-%m-%d %H:%M:%S") | |
| else: | |
| last_used = "从未使用" | |
| tokens_info.append({ | |
| "index": idx, | |
| "key": token_info.get("token", "")[:20] + "...", | |
| "status": status, | |
| "status_color": status_color, | |
| "last_used": last_used, | |
| "failure_count": token_info.get("failure_count", 0), | |
| "success_rate": token_info.get("success_rate", "0%"), | |
| "token_type": token_info.get("token_type", "unknown"), | |
| }) | |
| context = { | |
| "request": request, | |
| "tokens": tokens_info, | |
| } | |
| return templates.TemplateResponse( | |
| request, | |
| "components/token_pool.html", | |
| context, | |
| ) | |
| async def get_recent_logs(request: Request): | |
| """获取最近的请求日志(HTML 片段)""" | |
| dao = get_request_log_dao() | |
| page_size = _get_int_query_param( | |
| request, | |
| "page_size", | |
| 12, | |
| maximum=50, | |
| ) | |
| requested_page = _get_int_query_param(request, "page", 1, maximum=100000) | |
| total_count = await dao.count_logs() | |
| pagination = _build_pagination( | |
| total_items=total_count, | |
| page=requested_page, | |
| page_size=page_size, | |
| ) | |
| rows = await dao.get_recent_logs( | |
| limit=page_size, | |
| offset=(pagination["current_page"] - 1) * page_size, | |
| ) | |
| logs = [] | |
| for row in rows: | |
| ts_raw = row.get("timestamp") or row.get("created_at") | |
| if isinstance(ts_raw, datetime): | |
| timestamp = ts_raw.strftime("%Y-%m-%d %H:%M:%S") | |
| elif ts_raw: | |
| timestamp = str(ts_raw) | |
| else: | |
| timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") | |
| success = bool(row.get("success")) | |
| try: | |
| status_code = int(row.get("status_code") or (200 if success else 500)) | |
| except (ValueError, TypeError): | |
| status_code = 200 if success else 500 | |
| try: | |
| duration_value = float(row.get("duration") or 0.0) | |
| except (ValueError, TypeError): | |
| duration_value = 0.0 | |
| try: | |
| first_token_value = float(row.get("first_token_time") or 0.0) | |
| except (ValueError, TypeError): | |
| first_token_value = 0.0 | |
| source = str(row.get("source") or "unknown") | |
| client_name = str(row.get("client_name") or "Unknown") | |
| provider = str(row.get("provider") or "-") | |
| source_display = ( | |
| "" | |
| if _is_redundant_source(source, client_name) | |
| else source | |
| ) | |
| provider_display = "" if provider == "zai" else provider | |
| logs.append( | |
| { | |
| "timestamp": timestamp, | |
| "endpoint": str(row.get("endpoint") or "-"), | |
| "model": str(row.get("model") or "-"), | |
| "provider": provider, | |
| "provider_display": provider_display, | |
| "source": source, | |
| "source_display": source_display, | |
| "protocol": str(row.get("protocol") or "unknown"), | |
| "protocol_display": _humanize_protocol( | |
| str(row.get("protocol") or "unknown") | |
| ), | |
| "client_name": client_name, | |
| "auth_token_display": mask_token(str(row.get("auth_token") or "")), | |
| "upstream_auth_token_display": mask_token( | |
| str(row.get("upstream_auth_token") or "") | |
| ), | |
| "success": success, | |
| "status_code": status_code, | |
| "duration_display": f"{duration_value:.2f}s", | |
| "first_token_display": ( | |
| f"{first_token_value:.2f}s" | |
| if first_token_value > 0 | |
| else "--" | |
| ), | |
| "input_tokens": int(row.get("input_tokens") or 0), | |
| "input_tokens_display": format_compact_number(row.get("input_tokens")), | |
| "output_tokens": int(row.get("output_tokens") or 0), | |
| "output_tokens_display": format_compact_number(row.get("output_tokens")), | |
| "cache_creation_tokens": int( | |
| row.get("cache_creation_tokens") or 0 | |
| ), | |
| "cache_creation_tokens_display": format_compact_number(row.get("cache_creation_tokens")), | |
| "cache_read_tokens": int( | |
| row.get("cache_read_tokens") or 0 | |
| ), | |
| "cache_read_tokens_display": format_compact_number(row.get("cache_read_tokens")), | |
| "error_message": str(row.get("error_message") or ""), | |
| } | |
| ) | |
| context = { | |
| "request": request, | |
| "logs": logs, | |
| "page": pagination, | |
| } | |
| return templates.TemplateResponse( | |
| request, | |
| "components/recent_logs.html", | |
| context, | |
| ) | |
| async def save_config(request: Request): | |
| """保存结构化配置并热重载。""" | |
| try: | |
| form_data = await request.form() | |
| await save_form_config( | |
| form_data, | |
| reload_callback=reload_settings, | |
| ) | |
| logger.info("✅ 结构化配置已保存") | |
| return _with_hx_trigger( | |
| _build_alert( | |
| "配置已保存并热重载,页面即将刷新。", | |
| title="保存成功!", | |
| level="success", | |
| ), | |
| "admin-config-refresh", | |
| ) | |
| except ValueError as exc: | |
| return _build_alert( | |
| str(exc), | |
| title="校验失败!", | |
| level="error", | |
| status_code=200, # return 200 so HTMX swaps the feedback | |
| ) | |
| except Exception as exc: | |
| log_exception(logger, "❌ 配置保存失败") | |
| return _build_alert( | |
| f"保存失败: {exc}", | |
| title="错误!", | |
| level="error", | |
| status_code=500, | |
| ) | |
| async def save_config_source(request: Request): | |
| """保存 .env 源文件并热重载。""" | |
| try: | |
| form_data = await request.form() | |
| await save_source_config( | |
| str(form_data.get("env_content", "")), | |
| reload_callback=reload_settings, | |
| ) | |
| logger.info("✅ 配置源文件已保存") | |
| return _with_hx_trigger( | |
| _build_alert( | |
| ".env 源文件已保存并热重载,页面即将刷新。", | |
| title="保存成功!", | |
| level="success", | |
| ), | |
| "admin-config-refresh", | |
| ) | |
| except ValueError as exc: | |
| return _build_alert( | |
| str(exc), | |
| title="源文件校验失败!", | |
| level="error", | |
| status_code=400, | |
| ) | |
| except Exception as exc: | |
| log_exception(logger, "❌ 源文件保存失败") | |
| return _build_alert( | |
| f"源文件保存失败: {exc}", | |
| title="错误!", | |
| level="error", | |
| status_code=500, | |
| ) | |
| async def reset_config(): | |
| """将配置重置为 .env.example 并热重载。""" | |
| try: | |
| await reset_env_to_example(reload_callback=reload_settings) | |
| logger.info("✅ 配置已重置为 .env.example 默认值") | |
| return _with_hx_trigger( | |
| _build_alert( | |
| "配置已恢复为 .env.example 默认值,页面即将刷新。", | |
| title="已重置!", | |
| level="success", | |
| ), | |
| "admin-config-refresh", | |
| ) | |
| except FileNotFoundError: | |
| logger.error("❌ 未找到 .env.example,无法重置配置") | |
| return _build_alert( | |
| "未找到 .env.example,无法重置配置。", | |
| title="错误!", | |
| level="error", | |
| status_code=404, | |
| ) | |
| except Exception as exc: | |
| log_exception(logger, "❌ 配置重置失败") | |
| return _build_alert( | |
| f"重置失败: {exc}", | |
| title="错误!", | |
| level="error", | |
| status_code=500, | |
| ) | |
| async def get_env_preview(): | |
| """获取 .env 文件预览""" | |
| try: | |
| content = read_env_content() | |
| if not content: | |
| content = "# .env 文件不存在" | |
| return HTMLResponse(f"<pre>{escape(content)}</pre>") | |
| except Exception as exc: | |
| return HTMLResponse(f"<pre># 读取失败: {escape(str(exc))}</pre>") | |
| # ==================== Token 管理 API ==================== | |
| async def get_tokens_list(request: Request): | |
| """获取 Token 列表(HTML 片段)""" | |
| from app.services.token_dao import get_token_dao | |
| dao = get_token_dao() | |
| page_size = _get_int_query_param( | |
| request, | |
| "page_size", | |
| 20, | |
| maximum=100, | |
| ) | |
| requested_page = _get_int_query_param(request, "page", 1, maximum=100000) | |
| total_count = await dao.count_tokens_by_provider( | |
| DEFAULT_TOKEN_NAMESPACE, | |
| enabled_only=False, | |
| ) | |
| pagination = _build_pagination( | |
| total_items=total_count, | |
| page=requested_page, | |
| page_size=page_size, | |
| ) | |
| tokens = await dao.get_tokens_by_provider( | |
| DEFAULT_TOKEN_NAMESPACE, | |
| enabled_only=False, | |
| limit=page_size, | |
| offset=(pagination["current_page"] - 1) * page_size, | |
| ) | |
| context = { | |
| "request": request, | |
| "tokens": tokens, | |
| "page": pagination, | |
| } | |
| return templates.TemplateResponse( | |
| request, | |
| "components/token_list.html", | |
| context, | |
| ) | |
| async def add_tokens(request: Request): | |
| """添加 Token""" | |
| from app.services.token_dao import get_token_dao | |
| from app.utils.token_pool import get_token_pool | |
| form_data = await request.form() | |
| single_token = form_data.get("single_token", "").strip() | |
| bulk_tokens = form_data.get("bulk_tokens", "").strip() | |
| dao = get_token_dao() | |
| added_count = 0 | |
| failed_count = 0 | |
| # 添加单个 Token(带验证) | |
| if single_token: | |
| token_id = await dao.add_token( | |
| DEFAULT_TOKEN_NAMESPACE, | |
| single_token, | |
| validate=True, | |
| ) | |
| if token_id: | |
| added_count += 1 | |
| else: | |
| failed_count += 1 | |
| # 批量添加 Token(带验证) | |
| if bulk_tokens: | |
| # 支持换行和逗号分隔 | |
| tokens = [] | |
| for line in bulk_tokens.split('\n'): | |
| line = line.strip() | |
| if ',' in line: | |
| tokens.extend([t.strip() for t in line.split(',') if t.strip()]) | |
| elif line: | |
| tokens.append(line) | |
| success, failed = await dao.bulk_add_tokens( | |
| DEFAULT_TOKEN_NAMESPACE, | |
| tokens, | |
| validate=True, | |
| ) | |
| added_count += success | |
| failed_count += failed | |
| # 同步 Token 池状态(如果有新增成功的 Token) | |
| if added_count > 0: | |
| pool = get_token_pool() | |
| if pool: | |
| await pool.sync_from_database(DEFAULT_TOKEN_NAMESPACE) | |
| logger.info(f"✅ Token 池已同步,新增 {added_count} 个 Token") | |
| # 生成响应 | |
| if added_count > 0 and failed_count == 0: | |
| return HTMLResponse(f""" | |
| <div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded relative" role="alert"> | |
| <strong class="font-bold">成功!</strong> | |
| <span class="block sm:inline">已添加 {added_count} 个有效 Token</span> | |
| </div> | |
| """) | |
| elif added_count > 0 and failed_count > 0: | |
| return HTMLResponse(f""" | |
| <div class="bg-yellow-100 border border-yellow-400 text-yellow-700 px-4 py-3 rounded relative" role="alert"> | |
| <strong class="font-bold">部分成功!</strong> | |
| <span class="block sm:inline">已添加 {added_count} 个 Token,{failed_count} 个失败(可能是重复、无效或匿名 Token)</span> | |
| </div> | |
| """) | |
| else: | |
| return HTMLResponse(""" | |
| <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert"> | |
| <strong class="font-bold">失败!</strong> | |
| <span class="block sm:inline">所有 Token 添加失败(可能是重复、无效或匿名 Token)</span> | |
| </div> | |
| """) | |
| async def import_tokens_from_directory_api(request: Request): | |
| """从本地目录导入 token 文件。""" | |
| from app.core.config import settings | |
| from app.services.token_automation import run_directory_import | |
| form_data = await request.form() | |
| source_dir = str( | |
| form_data.get("source_dir") | |
| or settings.TOKEN_AUTO_IMPORT_SOURCE_DIR | |
| or "" | |
| ).strip() | |
| try: | |
| source_dir = _validate_directory_path(source_dir) | |
| except ValueError as exc: | |
| return _build_alert( | |
| str(exc), | |
| title="导入失败!", | |
| level="error", | |
| status_code=400, | |
| ) | |
| try: | |
| summary = await run_directory_import( | |
| source_dir, | |
| provider=DEFAULT_TOKEN_NAMESPACE, | |
| validate=True, | |
| ) | |
| except (FileNotFoundError, NotADirectoryError) as exc: | |
| return _build_alert( | |
| str(exc), | |
| title="导入失败!", | |
| level="error", | |
| status_code=400, | |
| ) | |
| except RuntimeError as exc: | |
| return _build_alert( | |
| str(exc), | |
| title="导入稍后重试", | |
| level="warning", | |
| status_code=409, | |
| ) | |
| except Exception as exc: | |
| log_exception(logger, "❌ 本地目录导入 Token 失败") | |
| return _build_alert( | |
| f"目录扫描或入库异常: {exc}", | |
| title="导入失败!", | |
| level="error", | |
| status_code=500, | |
| ) | |
| if summary.imported_count > 0: | |
| title = "导入成功!" if summary.failed_count == 0 else "导入完成!" | |
| detail = ( | |
| f"目录 {summary.source_dir} 共扫描 {summary.scanned_files} 个文件," | |
| f"成功导入 {summary.imported_count} 个 Token," | |
| f"重复 {summary.duplicate_count} 个," | |
| f"无效 JSON {summary.invalid_json_count} 个," | |
| f"缺少 token {summary.missing_token_count} 个," | |
| f"验证失败 {summary.invalid_token_count} 个。" | |
| ) | |
| return _build_alert( | |
| detail, | |
| title=title, | |
| level="success" if summary.failed_count == 0 else "warning", | |
| ) | |
| return _build_alert( | |
| ( | |
| f"目录 {summary.source_dir} 共扫描 {summary.scanned_files} 个文件," | |
| f"其中重复 {summary.duplicate_count} 个,无效 JSON {summary.invalid_json_count} 个," | |
| f"缺少 token {summary.missing_token_count} 个,验证失败 {summary.invalid_token_count} 个。" | |
| ), | |
| title="未导入任何 Token!", | |
| level="warning", | |
| ) | |
| async def save_auto_import_settings(request: Request): | |
| """兼容旧入口,提示用户改到配置管理页。""" | |
| return _build_alert( | |
| "自动导入配置入口已迁移到 /admin/config#tokens,当前页面仅保留手动执行入口。", | |
| title="入口已迁移", | |
| level="info", | |
| ) | |
| async def import_tokens_from_upload_api( | |
| files: list[UploadFile] = File(...), | |
| ): | |
| """通过上传 JSON 文件导入 Token(替代本地目录读取)。""" | |
| from typing import List, Tuple | |
| from app.services.token_importer import import_tokens_from_uploaded_files | |
| from app.utils.token_pool import get_token_pool | |
| if not files: | |
| return _build_alert( | |
| "未选择任何文件,请选择至少一个 JSON 文件。", | |
| title="上传失败!", | |
| level="error", | |
| status_code=400, | |
| ) | |
| # 读取所有上传文件内容 | |
| file_data: List[Tuple[str, bytes]] = [] | |
| for f in files: | |
| content = await f.read() | |
| file_data.append((f.filename or "unknown.json", content)) | |
| try: | |
| summary = await import_tokens_from_uploaded_files( | |
| file_data, | |
| provider=DEFAULT_TOKEN_NAMESPACE, | |
| validate=True, | |
| ) | |
| except Exception as exc: | |
| log_exception(logger, "❌ 上传 JSON 文件导入 Token 失败") | |
| return _build_alert( | |
| f"文件解析或入库异常: {exc}", | |
| title="导入失败!", | |
| level="error", | |
| status_code=500, | |
| ) | |
| # 导入成功后同步 Token 池 | |
| if summary.imported_count > 0: | |
| pool = get_token_pool() | |
| if pool: | |
| await pool.sync_from_database(DEFAULT_TOKEN_NAMESPACE) | |
| logger.info(f"✅ 上传导入后已同步 Token 池,新增 {summary.imported_count} 个 Token") | |
| if summary.imported_count > 0: | |
| title = "导入成功!" if summary.failed_count == 0 else "导入完成!" | |
| detail = ( | |
| f"共上传 {summary.scanned_files} 个文件," | |
| f"成功导入 {summary.imported_count} 个 Token," | |
| f"重复 {summary.duplicate_count} 个," | |
| f"无效 JSON {summary.invalid_json_count} 个," | |
| f"缺少 token {summary.missing_token_count} 个," | |
| f"验证失败 {summary.invalid_token_count} 个。" | |
| ) | |
| return _build_alert( | |
| detail, | |
| title=title, | |
| level="success" if summary.failed_count == 0 else "warning", | |
| ) | |
| return _build_alert( | |
| ( | |
| f"共上传 {summary.scanned_files} 个文件," | |
| f"其中重复 {summary.duplicate_count} 个,无效 JSON {summary.invalid_json_count} 个," | |
| f"缺少 token {summary.missing_token_count} 个,验证失败 {summary.invalid_token_count} 个。" | |
| ), | |
| title="未导入任何 Token!", | |
| level="warning", | |
| ) | |
| async def save_auto_maintenance_settings(request: Request): | |
| """兼容旧入口,提示用户改到配置管理页。""" | |
| return _build_alert( | |
| "自动维护配置入口已迁移到 /admin/config#tokens,当前页面仅保留手动执行入口。", | |
| title="入口已迁移", | |
| level="info", | |
| ) | |
| async def run_token_maintenance_api(request: Request): | |
| """立即执行一次 Token 维护。""" | |
| from app.core.config import settings | |
| from app.services.token_automation import run_token_maintenance | |
| form_data = await request.form() | |
| action_fields = ( | |
| "auto_remove_duplicates", | |
| "auto_health_check", | |
| "auto_delete_invalid", | |
| ) | |
| has_explicit_actions = any(field in form_data for field in action_fields) | |
| if has_explicit_actions: | |
| remove_duplicates = "auto_remove_duplicates" in form_data | |
| run_health_check = "auto_health_check" in form_data | |
| delete_invalid = "auto_delete_invalid" in form_data | |
| else: | |
| remove_duplicates = settings.TOKEN_AUTO_REMOVE_DUPLICATES | |
| run_health_check = settings.TOKEN_AUTO_HEALTH_CHECK | |
| delete_invalid = settings.TOKEN_AUTO_DELETE_INVALID | |
| if not any((remove_duplicates, run_health_check, delete_invalid)): | |
| return _build_alert( | |
| "当前没有可执行的维护动作,请先到 /admin/config#tokens 配置至少一个维护动作。", | |
| title="未执行维护!", | |
| level="warning", | |
| status_code=400, | |
| ) | |
| try: | |
| summary = await run_token_maintenance( | |
| provider=DEFAULT_TOKEN_NAMESPACE, | |
| remove_duplicates=remove_duplicates, | |
| run_health_check=run_health_check, | |
| delete_invalid_tokens=delete_invalid, | |
| ) | |
| except RuntimeError as exc: | |
| return _build_alert( | |
| str(exc), | |
| title="维护稍后重试", | |
| level="warning", | |
| status_code=409, | |
| ) | |
| except Exception as exc: | |
| log_exception(logger, "❌ 手动执行 Token 维护失败") | |
| return _build_alert( | |
| f"Token 维护失败: {exc}", | |
| title="维护失败!", | |
| level="error", | |
| status_code=500, | |
| ) | |
| return _build_alert( | |
| ( | |
| f"本次维护共去重 {summary.duplicate_removed_count} 个," | |
| f"测活 {summary.checked_count} 个(有效 {summary.valid_count} / " | |
| f"匿名 {summary.guest_count} / 无效 {summary.invalid_count})," | |
| f"删除失效 Token {summary.deleted_invalid_count} 个。" | |
| ), | |
| title="维护完成!", | |
| level="success", | |
| ) | |
| async def toggle_token(token_id: int, enabled: bool): | |
| """切换 Token 启用状态""" | |
| from app.services.token_dao import get_token_dao | |
| from app.utils.token_pool import get_token_pool | |
| dao = get_token_dao() | |
| await dao.update_token_status(token_id, enabled) | |
| # 同步 Token 池状态 | |
| pool = get_token_pool() | |
| if pool: | |
| # 获取 Token 的提供商信息 | |
| token_info = await dao.get_token_by_id(token_id) | |
| if token_info: | |
| provider = token_info["provider"] | |
| await pool.sync_from_database(provider) | |
| logger.info("✅ Token 池已同步") | |
| # 根据状态返回不同样式的按钮 | |
| if enabled: | |
| button_class = "bg-green-100 text-green-800 hover:bg-green-200" | |
| indicator_class = "bg-green-500" | |
| label = "已启用" | |
| next_state = "false" | |
| else: | |
| button_class = "bg-red-100 text-red-800 hover:bg-red-200" | |
| indicator_class = "bg-red-500" | |
| label = "已禁用" | |
| next_state = "true" | |
| return HTMLResponse(f""" | |
| <button hx-post="/admin/api/tokens/toggle/{token_id}?enabled={next_state}" | |
| hx-swap="outerHTML" | |
| class="inline-flex items-center px-2.5 py-0.5 text-xs font-semibold rounded-full transition-colors {button_class}"> | |
| <span class="h-2 w-2 rounded-full mr-1.5 {indicator_class}"></span> | |
| {label} | |
| </button> | |
| """) | |
| async def delete_token(token_id: int): | |
| """删除 Token""" | |
| from app.services.token_dao import get_token_dao | |
| from app.utils.token_pool import get_token_pool | |
| dao = get_token_dao() | |
| # 获取 Token 信息以确定提供商 | |
| token_info = await dao.get_token_by_id(token_id) | |
| provider = token_info["provider"] if token_info else "zai" | |
| await dao.delete_token(token_id) | |
| # 同步 Token 池状态 | |
| pool = get_token_pool() | |
| if pool: | |
| await pool.sync_from_database(provider) | |
| logger.info("✅ Token 池已同步") | |
| return HTMLResponse("") # 返回空内容,让 htmx 移除元素 | |
| async def get_tokens_stats(request: Request): | |
| """获取 Token 统计信息(HTML 片段)""" | |
| stats_data = await collect_admin_stats(DEFAULT_TOKEN_NAMESPACE) | |
| context = { | |
| "request": request, | |
| "stats": stats_data, | |
| } | |
| return templates.TemplateResponse( | |
| request, | |
| "components/token_stats.html", | |
| context, | |
| ) | |
| async def validate_tokens(): | |
| """批量验证 Token""" | |
| from app.services.token_dao import get_token_dao | |
| from app.utils.token_pool import get_token_pool | |
| dao = get_token_dao() | |
| # 执行批量验证 | |
| stats = await dao.validate_all_tokens(DEFAULT_TOKEN_NAMESPACE) | |
| pool = get_token_pool() | |
| if pool: | |
| await pool.sync_from_database(DEFAULT_TOKEN_NAMESPACE) | |
| valid_count = stats.get("valid", 0) | |
| guest_count = stats.get("guest", 0) | |
| invalid_count = stats.get("invalid", 0) | |
| # 生成通知消息 | |
| if guest_count > 0: | |
| message_class = "bg-yellow-100 border-yellow-400 text-yellow-700" | |
| message = f"验证完成:有效 {valid_count} 个,匿名 {guest_count} 个,无效 {invalid_count} 个。匿名 Token 已标记。" | |
| elif invalid_count > 0: | |
| message_class = "bg-blue-100 border-blue-400 text-blue-700" | |
| message = f"验证完成:有效 {valid_count} 个,无效 {invalid_count} 个。" | |
| else: | |
| message_class = "bg-green-100 border-green-400 text-green-700" | |
| message = f"验证完成:所有 {valid_count} 个 Token 均有效!" | |
| return HTMLResponse(f""" | |
| <div class="{message_class} border px-4 py-3 rounded relative" role="alert"> | |
| <strong class="font-bold">批量验证完成!</strong> | |
| <span class="block sm:inline">{message}</span> | |
| </div> | |
| """) | |
| async def validate_single_token(request: Request, token_id: int): | |
| """验证单个 Token 并返回更新后的行""" | |
| from app.services.token_dao import get_token_dao | |
| from app.utils.token_pool import get_token_pool | |
| dao = get_token_dao() | |
| # 验证 Token | |
| await dao.validate_and_update_token(token_id) | |
| pool = get_token_pool() | |
| if pool: | |
| await pool.sync_from_database(DEFAULT_TOKEN_NAMESPACE) | |
| # 获取更新后的 Token 信息 | |
| token = await dao.get_token_by_id(token_id) | |
| if token: | |
| # 返回更新后的单行 HTML | |
| context = { | |
| "request": request, | |
| "token": token, | |
| } | |
| # 使用单行模板渲染 | |
| return templates.TemplateResponse( | |
| request, | |
| "components/token_row.html", | |
| context, | |
| ) | |
| else: | |
| return HTMLResponse("") | |
| async def health_check_tokens(): | |
| """执行 Token 池健康检查""" | |
| from app.utils.token_pool import get_token_pool | |
| pool = get_token_pool() | |
| if not pool: | |
| return HTMLResponse(""" | |
| <div class="bg-yellow-100 border border-yellow-400 text-yellow-700 px-4 py-3 rounded relative" role="alert"> | |
| <strong class="font-bold">提示!</strong> | |
| <span class="block sm:inline">Token 池未初始化,请重启服务。</span> | |
| </div> | |
| """) | |
| # 执行健康检查 | |
| await pool.health_check_all() | |
| # 获取健康状态 | |
| status = await pool.get_pool_status() | |
| healthy_count = status.get("healthy_tokens", 0) | |
| total_count = status.get("total_tokens", 0) | |
| if healthy_count == total_count: | |
| message_class = "bg-green-100 border-green-400 text-green-700" | |
| message = f"所有 {total_count} 个 Token 均健康!" | |
| elif healthy_count > 0: | |
| message_class = "bg-blue-100 border-blue-400 text-blue-700" | |
| message = f"健康检查完成:{healthy_count}/{total_count} 个 Token 健康。" | |
| else: | |
| message_class = "bg-red-100 border-red-400 text-red-700" | |
| message = f"警告:0/{total_count} 个 Token 健康,请检查配置。" | |
| return HTMLResponse(f""" | |
| <div class="{message_class} border px-4 py-3 rounded relative" role="alert"> | |
| <strong class="font-bold">健康检查完成!</strong> | |
| <span class="block sm:inline">{message}</span> | |
| </div> | |
| """) | |
| async def sync_token_pool(): | |
| """手动同步 Token 池(从数据库重新加载)""" | |
| from app.utils.token_pool import get_token_pool | |
| pool = get_token_pool() | |
| if not pool: | |
| return HTMLResponse(""" | |
| <div class="bg-yellow-100 border border-yellow-400 text-yellow-700 px-4 py-3 rounded relative" role="alert"> | |
| <strong class="font-bold">提示!</strong> | |
| <span class="block sm:inline">Token 池未初始化,请重启服务。</span> | |
| </div> | |
| """) | |
| # 从数据库同步 | |
| await pool.sync_from_database(DEFAULT_TOKEN_NAMESPACE) | |
| # 获取同步后的状态 | |
| status = await pool.get_pool_status() | |
| total_count = status.get("total_tokens", 0) | |
| available_count = status.get("available_tokens", 0) | |
| user_count = status.get("user_tokens", 0) | |
| logger.info( | |
| f"✅ Token 池手动同步完成,总计 {total_count} 个 Token, 可用 {available_count} 个, 认证用户 {user_count} 个" | |
| ) | |
| if total_count == 0: | |
| message_class = "bg-yellow-100 border-yellow-400 text-yellow-700" | |
| message = "同步完成:当前没有可用 Token,请在数据库中启用 Token。" | |
| elif available_count == 0: | |
| message_class = "bg-orange-100 border-orange-400 text-orange-700" | |
| message = f"同步完成:共 {total_count} 个 Token,但无可用 Token(可能都已禁用)。" | |
| else: | |
| message_class = "bg-green-100 border-green-400 text-green-700" | |
| message = f"同步完成:共 {total_count} 个 Token,{available_count} 个可用,{user_count} 个认证用户。" | |
| return HTMLResponse(f""" | |
| <div class="{message_class} border px-4 py-3 rounded relative" role="alert"> | |
| <strong class="font-bold">Token 池同步完成!</strong> | |
| <span class="block sm:inline">{message}</span> | |
| </div> | |
| """) | |
| # ==================== 模型管理 API ==================== | |
| async def refresh_online_models(): | |
| """手动刷新在线模型列表(从上游拉取并重新解析变体)。""" | |
| from app.core.openai import get_upstream_client | |
| client = get_upstream_client() | |
| # 强制清除缓存时间,使 get_online_models 重新请求上游 | |
| client._online_models_time = 0.0 | |
| try: | |
| models = await client.get_online_models() | |
| count = len(models) | |
| supported = client.get_supported_models() | |
| variant_count = len(supported) | |
| return _with_hx_trigger( | |
| _build_alert( | |
| f"从上游获取 {count} 个模型,生成 {variant_count} 个变体。", | |
| title="模型刷新成功!", | |
| level="success", | |
| ), | |
| "admin-models-refresh", | |
| ) | |
| except Exception as exc: | |
| log_exception(logger, "❌ 在线模型刷新失败") | |
| return _build_alert( | |
| f"刷新失败: {exc}", | |
| title="错误!", | |
| level="error", | |
| status_code=500, | |
| ) | |