| """ |
| Web路由模块 - 处理认证相关的HTTP请求和控制面板功能 |
| 用于与上级web.py集成 |
| """ |
|
|
| import asyncio |
| import datetime |
| import io |
| import json |
| import os |
| import time |
| import zipfile |
| from collections import deque |
| from typing import List |
|
|
| from fastapi import ( |
| APIRouter, |
| Depends, |
| File, |
| HTTPException, |
| Request, |
| UploadFile, |
| WebSocket, |
| WebSocketDisconnect, |
| ) |
| from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response |
| from starlette.websockets import WebSocketState |
|
|
| import config |
| from log import log |
|
|
| from src.auth import ( |
| asyncio_complete_auth_flow, |
| complete_auth_flow_from_callback_url, |
| create_auth_url, |
| get_auth_status, |
| verify_password, |
| ) |
| from src.credential_manager import CredentialManager |
| from .models import ( |
| LoginRequest, |
| AuthStartRequest, |
| AuthCallbackRequest, |
| AuthCallbackUrlRequest, |
| CredFileActionRequest, |
| CredFileBatchActionRequest, |
| ConfigSaveRequest, |
| ) |
| from src.storage_adapter import get_storage_adapter |
| from src.utils import verify_panel_token, GEMINICLI_USER_AGENT, ANTIGRAVITY_USER_AGENT |
| from src.api.antigravity import fetch_quota_info |
| from src.google_oauth_api import Credentials, fetch_project_id |
| from config import get_code_assist_endpoint, get_antigravity_api_url |
|
|
| |
| router = APIRouter() |
|
|
| |
| credential_manager = CredentialManager() |
|
|
| |
|
|
|
|
| class ConnectionManager: |
| def __init__(self, max_connections: int = 3): |
| |
| self.active_connections: deque = deque(maxlen=max_connections) |
| self.max_connections = max_connections |
| self._last_cleanup = 0 |
| self._cleanup_interval = 120 |
|
|
| async def connect(self, websocket: WebSocket): |
| |
| self._auto_cleanup() |
|
|
| |
| if len(self.active_connections) >= self.max_connections: |
| await websocket.close(code=1008, reason="Too many connections") |
| return False |
|
|
| await websocket.accept() |
| self.active_connections.append(websocket) |
| log.debug(f"WebSocket连接建立,当前连接数: {len(self.active_connections)}") |
| return True |
|
|
| def disconnect(self, websocket: WebSocket): |
| |
| try: |
| self.active_connections.remove(websocket) |
| except ValueError: |
| pass |
| log.debug(f"WebSocket连接断开,当前连接数: {len(self.active_connections)}") |
|
|
| async def send_personal_message(self, message: str, websocket: WebSocket): |
| try: |
| await websocket.send_text(message) |
| except Exception: |
| self.disconnect(websocket) |
|
|
| async def broadcast(self, message: str): |
| |
| dead_connections = [] |
| for conn in self.active_connections: |
| try: |
| await conn.send_text(message) |
| except Exception: |
| dead_connections.append(conn) |
|
|
| |
| for dead_conn in dead_connections: |
| self.disconnect(dead_conn) |
|
|
| def _auto_cleanup(self): |
| """自动清理死连接""" |
| current_time = time.time() |
| if current_time - self._last_cleanup > self._cleanup_interval: |
| self.cleanup_dead_connections() |
| self._last_cleanup = current_time |
|
|
| def cleanup_dead_connections(self): |
| """清理已断开的连接""" |
| original_count = len(self.active_connections) |
| |
| alive_connections = deque( |
| [ |
| conn |
| for conn in self.active_connections |
| if hasattr(conn, "client_state") |
| and conn.client_state != WebSocketState.DISCONNECTED |
| ], |
| maxlen=self.max_connections, |
| ) |
|
|
| self.active_connections = alive_connections |
| cleaned = original_count - len(self.active_connections) |
| if cleaned > 0: |
| log.debug(f"清理了 {cleaned} 个死连接,剩余连接数: {len(self.active_connections)}") |
|
|
|
|
| manager = ConnectionManager() |
|
|
|
|
| async def ensure_credential_manager_initialized(): |
| """确保credential manager已初始化""" |
| if not credential_manager._initialized: |
| await credential_manager.initialize() |
|
|
|
|
| async def get_credential_manager(): |
| """获取全局凭证管理器实例(已废弃,直接使用模块级的 credential_manager)""" |
| global credential_manager |
| |
| await credential_manager._ensure_initialized() |
| return credential_manager |
|
|
|
|
| def is_mobile_user_agent(user_agent: str) -> bool: |
| """检测是否为移动设备用户代理""" |
| if not user_agent: |
| return False |
|
|
| user_agent_lower = user_agent.lower() |
| mobile_keywords = [ |
| "mobile", |
| "android", |
| "iphone", |
| "ipad", |
| "ipod", |
| "blackberry", |
| "windows phone", |
| "samsung", |
| "htc", |
| "motorola", |
| "nokia", |
| "palm", |
| "webos", |
| "opera mini", |
| "opera mobi", |
| "fennec", |
| "minimo", |
| "symbian", |
| "psp", |
| "nintendo", |
| "tablet", |
| ] |
|
|
| return any(keyword in user_agent_lower for keyword in mobile_keywords) |
|
|
|
|
| @router.get("/", response_class=HTMLResponse) |
| async def serve_control_panel(request: Request): |
| """提供统一控制面板""" |
| try: |
| user_agent = request.headers.get("user-agent", "") |
| is_mobile = is_mobile_user_agent(user_agent) |
|
|
| if is_mobile: |
| html_file_path = "front/control_panel_mobile.html" |
| else: |
| html_file_path = "front/control_panel.html" |
|
|
| with open(html_file_path, "r", encoding="utf-8") as f: |
| html_content = f.read() |
| return HTMLResponse(content=html_content) |
|
|
| except Exception as e: |
| log.error(f"加载控制面板页面失败: {e}") |
| raise HTTPException(status_code=500, detail="服务器内部错误") |
|
|
|
|
| @router.post("/auth/login") |
| async def login(request: LoginRequest): |
| """用户登录(简化版:直接返回密码作为token)""" |
| try: |
| if await verify_password(request.password): |
| |
| return JSONResponse(content={"token": request.password, "message": "登录成功"}) |
| else: |
| raise HTTPException(status_code=401, detail="密码错误") |
| except HTTPException: |
| raise |
| except Exception as e: |
| log.error(f"登录失败: {e}") |
| raise HTTPException(status_code=500, detail=str(e)) |
|
|
|
|
| @router.post("/auth/start") |
| async def start_auth(request: AuthStartRequest, token: str = Depends(verify_panel_token)): |
| """开始认证流程,支持自动检测项目ID""" |
| try: |
| |
| project_id = request.project_id |
| if not project_id: |
| log.info("用户未提供项目ID,后续将使用自动检测...") |
|
|
| |
| user_session = token if token else None |
| result = await create_auth_url( |
| project_id, user_session, mode=request.mode |
| ) |
|
|
| if result["success"]: |
| return JSONResponse( |
| content={ |
| "auth_url": result["auth_url"], |
| "state": result["state"], |
| "auto_project_detection": result.get("auto_project_detection", False), |
| "detected_project_id": result.get("detected_project_id"), |
| } |
| ) |
| else: |
| raise HTTPException(status_code=500, detail=result["error"]) |
|
|
| except HTTPException: |
| raise |
| except Exception as e: |
| log.error(f"开始认证流程失败: {e}") |
| raise HTTPException(status_code=500, detail=str(e)) |
|
|
|
|
| @router.post("/auth/callback") |
| async def auth_callback(request: AuthCallbackRequest, token: str = Depends(verify_panel_token)): |
| """处理认证回调,支持自动检测项目ID""" |
| try: |
| |
| project_id = request.project_id |
|
|
| |
| user_session = token if token else None |
| |
| result = await asyncio_complete_auth_flow( |
| project_id, user_session, mode=request.mode |
| ) |
|
|
| if result["success"]: |
| |
| return JSONResponse( |
| content={ |
| "credentials": result["credentials"], |
| "file_path": result["file_path"], |
| "message": "认证成功,凭证已保存", |
| "auto_detected_project": result.get("auto_detected_project", False), |
| } |
| ) |
| else: |
| |
| if result.get("requires_manual_project_id"): |
| |
| return JSONResponse( |
| status_code=400, |
| content={"error": result["error"], "requires_manual_project_id": True}, |
| ) |
| elif result.get("requires_project_selection"): |
| |
| return JSONResponse( |
| status_code=400, |
| content={ |
| "error": result["error"], |
| "requires_project_selection": True, |
| "available_projects": result["available_projects"], |
| }, |
| ) |
| else: |
| raise HTTPException(status_code=400, detail=result["error"]) |
|
|
| except HTTPException: |
| raise |
| except Exception as e: |
| log.error(f"处理认证回调失败: {e}") |
| raise HTTPException(status_code=500, detail=str(e)) |
|
|
|
|
| @router.post("/auth/callback-url") |
| async def auth_callback_url(request: AuthCallbackUrlRequest, token: str = Depends(verify_panel_token)): |
| """从回调URL直接完成认证""" |
| try: |
| |
| if not request.callback_url or not request.callback_url.startswith(("http://", "https://")): |
| raise HTTPException(status_code=400, detail="请提供有效的回调URL") |
|
|
| |
| result = await complete_auth_flow_from_callback_url( |
| request.callback_url, request.project_id, mode=request.mode |
| ) |
|
|
| if result["success"]: |
| |
| return JSONResponse( |
| content={ |
| "credentials": result["credentials"], |
| "file_path": result["file_path"], |
| "message": "从回调URL认证成功,凭证已保存", |
| "auto_detected_project": result.get("auto_detected_project", False), |
| } |
| ) |
| else: |
| |
| if result.get("requires_manual_project_id"): |
| return JSONResponse( |
| status_code=400, |
| content={"error": result["error"], "requires_manual_project_id": True}, |
| ) |
| elif result.get("requires_project_selection"): |
| return JSONResponse( |
| status_code=400, |
| content={ |
| "error": result["error"], |
| "requires_project_selection": True, |
| "available_projects": result["available_projects"], |
| }, |
| ) |
| else: |
| raise HTTPException(status_code=400, detail=result["error"]) |
|
|
| except HTTPException: |
| raise |
| except Exception as e: |
| log.error(f"从回调URL处理认证失败: {e}") |
| raise HTTPException(status_code=500, detail=str(e)) |
|
|
|
|
| @router.get("/auth/status/{project_id}") |
| async def check_auth_status(project_id: str, token: str = Depends(verify_panel_token)): |
| """检查认证状态""" |
| try: |
| if not project_id: |
| raise HTTPException(status_code=400, detail="Project ID 不能为空") |
|
|
| status = get_auth_status(project_id) |
| return JSONResponse(content=status) |
|
|
| except Exception as e: |
| log.error(f"检查认证状态失败: {e}") |
| raise HTTPException(status_code=500, detail=str(e)) |
|
|
|
|
| |
| |
| |
|
|
|
|
| def validate_mode(mode: str = "geminicli") -> str: |
| """ |
| 验证 mode 参数 |
| |
| Args: |
| mode: 模式字符串 ("geminicli" 或 "antigravity") |
| |
| Returns: |
| str: 验证后的 mode 字符串 |
| |
| Raises: |
| HTTPException: 如果 mode 参数无效 |
| """ |
| if mode not in ["geminicli", "antigravity"]: |
| raise HTTPException( |
| status_code=400, |
| detail=f"无效的 mode 参数: {mode},只支持 'geminicli' 或 'antigravity'" |
| ) |
| return mode |
|
|
|
|
| def get_env_locked_keys() -> set: |
| """获取被环境变量锁定的配置键集合""" |
| env_locked_keys = set() |
|
|
| |
| for env_key, config_key in config.ENV_MAPPINGS.items(): |
| if os.getenv(env_key): |
| env_locked_keys.add(config_key) |
|
|
| return env_locked_keys |
|
|
|
|
| async def extract_json_files_from_zip(zip_file: UploadFile) -> List[dict]: |
| """从ZIP文件中提取JSON文件""" |
| try: |
| |
| zip_content = await zip_file.read() |
|
|
| |
|
|
| files_data = [] |
|
|
| with zipfile.ZipFile(io.BytesIO(zip_content), "r") as zip_ref: |
| |
| file_list = zip_ref.namelist() |
| json_files = [ |
| f for f in file_list if f.endswith(".json") and not f.startswith("__MACOSX/") |
| ] |
|
|
| if not json_files: |
| raise HTTPException(status_code=400, detail="ZIP文件中没有找到JSON文件") |
|
|
| log.info(f"从ZIP文件 {zip_file.filename} 中找到 {len(json_files)} 个JSON文件") |
|
|
| for json_filename in json_files: |
| try: |
| |
| with zip_ref.open(json_filename) as json_file: |
| content = json_file.read() |
|
|
| try: |
| content_str = content.decode("utf-8") |
| except UnicodeDecodeError: |
| log.warning(f"跳过编码错误的文件: {json_filename}") |
| continue |
|
|
| |
| filename = os.path.basename(json_filename) |
| files_data.append({"filename": filename, "content": content_str}) |
|
|
| except Exception as e: |
| log.warning(f"处理ZIP中的文件 {json_filename} 时出错: {e}") |
| continue |
|
|
| log.info(f"成功从ZIP文件中提取 {len(files_data)} 个有效的JSON文件") |
| return files_data |
|
|
| except zipfile.BadZipFile: |
| raise HTTPException(status_code=400, detail="无效的ZIP文件格式") |
| except Exception as e: |
| log.error(f"处理ZIP文件失败: {e}") |
| raise HTTPException(status_code=500, detail=f"处理ZIP文件失败: {str(e)}") |
|
|
|
|
| async def upload_credentials_common( |
| files: List[UploadFile], mode: str = "geminicli" |
| ) -> JSONResponse: |
| """批量上传凭证文件的通用函数""" |
| mode = validate_mode(mode) |
|
|
| if not files: |
| raise HTTPException(status_code=400, detail="请选择要上传的文件") |
|
|
| |
| if len(files) > 100: |
| raise HTTPException( |
| status_code=400, detail=f"文件数量过多,最多支持100个文件,当前:{len(files)}个" |
| ) |
|
|
| files_data = [] |
| for file in files: |
| |
| if file.filename.endswith(".zip"): |
| zip_files_data = await extract_json_files_from_zip(file) |
| files_data.extend(zip_files_data) |
| log.info(f"从ZIP文件 {file.filename} 中提取了 {len(zip_files_data)} 个JSON文件") |
|
|
| elif file.filename.endswith(".json"): |
| |
| content_chunks = [] |
| while True: |
| chunk = await file.read(8192) |
| if not chunk: |
| break |
| content_chunks.append(chunk) |
|
|
| content = b"".join(content_chunks) |
| try: |
| content_str = content.decode("utf-8") |
| except UnicodeDecodeError: |
| raise HTTPException( |
| status_code=400, detail=f"文件 {file.filename} 编码格式不支持" |
| ) |
|
|
| files_data.append({"filename": file.filename, "content": content_str}) |
| else: |
| raise HTTPException( |
| status_code=400, detail=f"文件 {file.filename} 格式不支持,只支持JSON和ZIP文件" |
| ) |
|
|
| |
|
|
| batch_size = 1000 |
| all_results = [] |
| total_success = 0 |
|
|
| for i in range(0, len(files_data), batch_size): |
| batch_files = files_data[i : i + batch_size] |
|
|
| async def process_single_file(file_data): |
| try: |
| filename = file_data["filename"] |
| |
| filename = os.path.basename(filename) |
| content_str = file_data["content"] |
| credential_data = json.loads(content_str) |
|
|
| |
| if mode == "antigravity": |
| await credential_manager.add_antigravity_credential(filename, credential_data) |
| else: |
| await credential_manager.add_credential(filename, credential_data) |
|
|
| log.debug(f"成功上传 {mode} 凭证文件: {filename}") |
| return {"filename": filename, "status": "success", "message": "上传成功"} |
|
|
| except json.JSONDecodeError as e: |
| return { |
| "filename": file_data["filename"], |
| "status": "error", |
| "message": f"JSON格式错误: {str(e)}", |
| } |
| except Exception as e: |
| return { |
| "filename": file_data["filename"], |
| "status": "error", |
| "message": f"处理失败: {str(e)}", |
| } |
|
|
| log.info(f"开始并发处理 {len(batch_files)} 个 {mode} 文件...") |
| concurrent_tasks = [process_single_file(file_data) for file_data in batch_files] |
| batch_results = await asyncio.gather(*concurrent_tasks, return_exceptions=True) |
|
|
| processed_results = [] |
| batch_uploaded_count = 0 |
| for result in batch_results: |
| if isinstance(result, Exception): |
| processed_results.append( |
| { |
| "filename": "unknown", |
| "status": "error", |
| "message": f"处理异常: {str(result)}", |
| } |
| ) |
| else: |
| processed_results.append(result) |
| if result["status"] == "success": |
| batch_uploaded_count += 1 |
|
|
| all_results.extend(processed_results) |
| total_success += batch_uploaded_count |
|
|
| batch_num = (i // batch_size) + 1 |
| total_batches = (len(files_data) + batch_size - 1) // batch_size |
| log.info( |
| f"批次 {batch_num}/{total_batches} 完成: 成功 " |
| f"{batch_uploaded_count}/{len(batch_files)} 个 {mode} 文件" |
| ) |
|
|
| if total_success > 0: |
| return JSONResponse( |
| content={ |
| "uploaded_count": total_success, |
| "total_count": len(files_data), |
| "results": all_results, |
| "message": f"批量上传完成: 成功 {total_success}/{len(files_data)} 个 {mode} 文件", |
| } |
| ) |
| else: |
| raise HTTPException(status_code=400, detail=f"没有 {mode} 文件上传成功") |
|
|
|
|
| async def get_creds_status_common( |
| offset: int, limit: int, status_filter: str, mode: str = "geminicli", |
| error_code_filter: str = None, cooldown_filter: str = None |
| ) -> JSONResponse: |
| """获取凭证文件状态的通用函数""" |
| mode = validate_mode(mode) |
| |
| if offset < 0: |
| raise HTTPException(status_code=400, detail="offset 必须大于等于 0") |
| if limit not in [20, 50, 100, 200, 500, 1000]: |
| raise HTTPException(status_code=400, detail="limit 只能是 20、50、100、200、500 或 1000") |
| if status_filter not in ["all", "enabled", "disabled"]: |
| raise HTTPException(status_code=400, detail="status_filter 只能是 all、enabled 或 disabled") |
| if cooldown_filter and cooldown_filter not in ["all", "in_cooldown", "no_cooldown"]: |
| raise HTTPException(status_code=400, detail="cooldown_filter 只能是 all、in_cooldown 或 no_cooldown") |
|
|
| |
|
|
| storage_adapter = await get_storage_adapter() |
| backend_info = await storage_adapter.get_backend_info() |
| backend_type = backend_info.get("backend_type", "unknown") |
|
|
| |
| if hasattr(storage_adapter._backend, 'get_credentials_summary'): |
| result = await storage_adapter._backend.get_credentials_summary( |
| offset=offset, |
| limit=limit, |
| status_filter=status_filter, |
| mode=mode, |
| error_code_filter=error_code_filter if error_code_filter and error_code_filter != "all" else None, |
| cooldown_filter=cooldown_filter if cooldown_filter and cooldown_filter != "all" else None |
| ) |
|
|
| creds_list = [] |
| for summary in result["items"]: |
| cred_info = { |
| "filename": os.path.basename(summary["filename"]), |
| "user_email": summary["user_email"], |
| "disabled": summary["disabled"], |
| "error_codes": summary["error_codes"], |
| "last_success": summary["last_success"], |
| "backend_type": backend_type, |
| "model_cooldowns": summary.get("model_cooldowns", {}), |
| } |
|
|
| creds_list.append(cred_info) |
|
|
| return JSONResponse(content={ |
| "items": creds_list, |
| "total": result["total"], |
| "offset": offset, |
| "limit": limit, |
| "has_more": (offset + limit) < result["total"], |
| "stats": result.get("stats", {"total": 0, "normal": 0, "disabled": 0}), |
| }) |
|
|
| |
| all_credentials = await storage_adapter.list_credentials(mode=mode) |
| all_states = await storage_adapter.get_all_credential_states(mode=mode) |
|
|
| |
| filtered_credentials = [] |
| for filename in all_credentials: |
| file_status = all_states.get(filename, {"disabled": False}) |
| is_disabled = file_status.get("disabled", False) |
|
|
| if status_filter == "all": |
| filtered_credentials.append(filename) |
| elif status_filter == "enabled" and not is_disabled: |
| filtered_credentials.append(filename) |
| elif status_filter == "disabled" and is_disabled: |
| filtered_credentials.append(filename) |
|
|
| total_count = len(filtered_credentials) |
| paginated_credentials = filtered_credentials[offset:offset + limit] |
|
|
| creds_list = [] |
| for filename in paginated_credentials: |
| file_status = all_states.get(filename, { |
| "error_codes": [], |
| "disabled": False, |
| "last_success": time.time(), |
| "user_email": None, |
| }) |
|
|
| cred_info = { |
| "filename": os.path.basename(filename), |
| "user_email": file_status.get("user_email"), |
| "disabled": file_status.get("disabled", False), |
| "error_codes": file_status.get("error_codes", []), |
| "last_success": file_status.get("last_success", time.time()), |
| "backend_type": backend_type, |
| "model_cooldowns": file_status.get("model_cooldowns", {}), |
| } |
|
|
| creds_list.append(cred_info) |
|
|
| return JSONResponse(content={ |
| "items": creds_list, |
| "total": total_count, |
| "offset": offset, |
| "limit": limit, |
| "has_more": (offset + limit) < total_count, |
| }) |
|
|
|
|
| async def download_all_creds_common(mode: str = "geminicli") -> Response: |
| """打包下载所有凭证文件的通用函数""" |
| mode = validate_mode(mode) |
| zip_filename = "antigravity_credentials.zip" if mode == "antigravity" else "credentials.zip" |
|
|
| storage_adapter = await get_storage_adapter() |
| credential_filenames = await storage_adapter.list_credentials(mode=mode) |
|
|
| if not credential_filenames: |
| raise HTTPException(status_code=404, detail=f"没有找到 {mode} 凭证文件") |
|
|
| log.info(f"开始打包 {len(credential_filenames)} 个 {mode} 凭证文件...") |
|
|
| zip_buffer = io.BytesIO() |
|
|
| with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file: |
| success_count = 0 |
| for idx, filename in enumerate(credential_filenames, 1): |
| try: |
| credential_data = await storage_adapter.get_credential(filename, mode=mode) |
| if credential_data: |
| content = json.dumps(credential_data, ensure_ascii=False, indent=2) |
| zip_file.writestr(os.path.basename(filename), content) |
| success_count += 1 |
|
|
| if idx % 10 == 0: |
| log.debug(f"打包进度: {idx}/{len(credential_filenames)}") |
|
|
| except Exception as e: |
| log.warning(f"处理 {mode} 凭证文件 {filename} 时出错: {e}") |
| continue |
|
|
| log.info(f"打包完成: 成功 {success_count}/{len(credential_filenames)} 个文件") |
|
|
| zip_buffer.seek(0) |
| return Response( |
| content=zip_buffer.getvalue(), |
| media_type="application/zip", |
| headers={"Content-Disposition": f"attachment; filename={zip_filename}"}, |
| ) |
|
|
|
|
| async def fetch_user_email_common(filename: str, mode: str = "geminicli") -> JSONResponse: |
| """获取指定凭证文件用户邮箱的通用函数""" |
| mode = validate_mode(mode) |
|
|
| filename_only = os.path.basename(filename) |
| if not filename_only.endswith(".json"): |
| raise HTTPException(status_code=404, detail="无效的文件名") |
|
|
| storage_adapter = await get_storage_adapter() |
| credential_data = await storage_adapter.get_credential(filename_only, mode=mode) |
| if not credential_data: |
| raise HTTPException(status_code=404, detail="凭证文件不存在") |
|
|
| email = await credential_manager.get_or_fetch_user_email(filename_only, mode=mode) |
|
|
| if email: |
| return JSONResponse( |
| content={ |
| "filename": filename_only, |
| "user_email": email, |
| "message": "成功获取用户邮箱", |
| } |
| ) |
| else: |
| return JSONResponse( |
| content={ |
| "filename": filename_only, |
| "user_email": None, |
| "message": "无法获取用户邮箱,可能凭证已过期或权限不足", |
| }, |
| status_code=400, |
| ) |
|
|
|
|
| async def refresh_all_user_emails_common(mode: str = "geminicli") -> JSONResponse: |
| """刷新所有凭证文件用户邮箱的通用函数 - 只为没有邮箱的凭证获取 |
| |
| 利用 get_all_credential_states 批量获取状态 |
| """ |
| mode = validate_mode(mode) |
|
|
| storage_adapter = await get_storage_adapter() |
| |
| |
| all_states = await storage_adapter.get_all_credential_states(mode=mode) |
|
|
| results = [] |
| success_count = 0 |
| skipped_count = 0 |
|
|
| |
| for filename, state in all_states.items(): |
| try: |
| cached_email = state.get("user_email") |
|
|
| if cached_email: |
| |
| skipped_count += 1 |
| results.append({ |
| "filename": os.path.basename(filename), |
| "user_email": cached_email, |
| "success": True, |
| "skipped": True, |
| }) |
| continue |
|
|
| |
| email = await credential_manager.get_or_fetch_user_email(filename, mode=mode) |
| if email: |
| success_count += 1 |
| results.append({ |
| "filename": os.path.basename(filename), |
| "user_email": email, |
| "success": True, |
| }) |
| else: |
| results.append({ |
| "filename": os.path.basename(filename), |
| "user_email": None, |
| "success": False, |
| "error": "无法获取邮箱", |
| }) |
| except Exception as e: |
| results.append({ |
| "filename": os.path.basename(filename), |
| "user_email": None, |
| "success": False, |
| "error": str(e), |
| }) |
|
|
| total_count = len(all_states) |
| return JSONResponse( |
| content={ |
| "success_count": success_count, |
| "total_count": total_count, |
| "skipped_count": skipped_count, |
| "results": results, |
| "message": f"成功获取 {success_count}/{total_count} 个邮箱地址,跳过 {skipped_count} 个已有邮箱的凭证", |
| } |
| ) |
|
|
|
|
| async def deduplicate_credentials_by_email_common(mode: str = "geminicli") -> JSONResponse: |
| """批量去重凭证文件的通用函数 - 删除邮箱相同的凭证(只保留一个)""" |
| mode = validate_mode(mode) |
| storage_adapter = await get_storage_adapter() |
|
|
| try: |
| duplicate_info = await storage_adapter._backend.get_duplicate_credentials_by_email( |
| mode=mode |
| ) |
|
|
| duplicate_groups = duplicate_info.get("duplicate_groups", []) |
| no_email_files = duplicate_info.get("no_email_files", []) |
| total_count = duplicate_info.get("total_count", 0) |
|
|
| if not duplicate_groups: |
| return JSONResponse( |
| content={ |
| "deleted_count": 0, |
| "kept_count": total_count, |
| "total_count": total_count, |
| "unique_emails_count": duplicate_info.get("unique_email_count", 0), |
| "no_email_count": len(no_email_files), |
| "duplicate_groups": [], |
| "delete_errors": [], |
| "message": "没有发现重复的凭证(相同邮箱)", |
| } |
| ) |
|
|
| |
| deleted_count = 0 |
| delete_errors = [] |
| result_duplicate_groups = [] |
|
|
| for group in duplicate_groups: |
| email = group["email"] |
| kept_file = group["kept_file"] |
| duplicate_files = group["duplicate_files"] |
|
|
| deleted_files_in_group = [] |
| for filename in duplicate_files: |
| try: |
| success = await credential_manager.remove_credential(filename, mode=mode) |
| if success: |
| deleted_count += 1 |
| deleted_files_in_group.append(os.path.basename(filename)) |
| log.info(f"去重删除凭证: {filename} (邮箱: {email}) (mode={mode})") |
| else: |
| delete_errors.append(f"{os.path.basename(filename)}: 删除失败") |
| except Exception as e: |
| delete_errors.append(f"{os.path.basename(filename)}: {str(e)}") |
| log.error(f"去重删除凭证 {filename} 时出错: {e}") |
|
|
| result_duplicate_groups.append({ |
| "email": email, |
| "kept_file": os.path.basename(kept_file), |
| "deleted_files": deleted_files_in_group, |
| "duplicate_count": len(deleted_files_in_group), |
| }) |
|
|
| kept_count = total_count - deleted_count |
|
|
| return JSONResponse( |
| content={ |
| "deleted_count": deleted_count, |
| "kept_count": kept_count, |
| "total_count": total_count, |
| "unique_emails_count": duplicate_info.get("unique_email_count", 0), |
| "no_email_count": len(no_email_files), |
| "duplicate_groups": result_duplicate_groups, |
| "delete_errors": delete_errors, |
| "message": f"去重完成:删除 {deleted_count} 个重复凭证,保留 {kept_count} 个凭证({duplicate_info.get('unique_email_count', 0)} 个唯一邮箱)", |
| } |
| ) |
|
|
| except Exception as e: |
| log.error(f"批量去重凭证时出错: {e}") |
| return JSONResponse( |
| status_code=500, |
| content={ |
| "deleted_count": 0, |
| "kept_count": 0, |
| "total_count": 0, |
| "message": f"去重操作失败: {str(e)}", |
| } |
| ) |
|
|
|
|
| |
| |
| |
|
|
|
|
| @router.post("/auth/upload") |
| async def upload_credentials( |
| files: List[UploadFile] = File(...), |
| token: str = Depends(verify_panel_token), |
| mode: str = "geminicli" |
| ): |
| """批量上传认证文件""" |
| try: |
| mode = validate_mode(mode) |
| return await upload_credentials_common(files, mode=mode) |
| except HTTPException: |
| raise |
| except Exception as e: |
| log.error(f"批量上传失败: {e}") |
| raise HTTPException(status_code=500, detail=str(e)) |
|
|
|
|
| @router.get("/creds/status") |
| async def get_creds_status( |
| token: str = Depends(verify_panel_token), |
| offset: int = 0, |
| limit: int = 50, |
| status_filter: str = "all", |
| error_code_filter: str = "all", |
| cooldown_filter: str = "all", |
| mode: str = "geminicli" |
| ): |
| """ |
| 获取凭证文件的状态(轻量级摘要,不包含完整凭证数据,支持分页和状态筛选) |
| |
| Args: |
| offset: 跳过的记录数(默认0) |
| limit: 每页返回的记录数(默认50,可选:20, 50, 100, 200, 500, 1000) |
| status_filter: 状态筛选(all=全部, enabled=仅启用, disabled=仅禁用) |
| error_code_filter: 错误码筛选(all=全部, 或具体错误码如"400", "403") |
| cooldown_filter: 冷却状态筛选(all=全部, in_cooldown=冷却中, no_cooldown=未冷却) |
| mode: 凭证模式(geminicli 或 antigravity) |
| |
| Returns: |
| 包含凭证列表、总数、分页信息的响应 |
| """ |
| try: |
| mode = validate_mode(mode) |
| return await get_creds_status_common( |
| offset, limit, status_filter, mode=mode, |
| error_code_filter=error_code_filter, |
| cooldown_filter=cooldown_filter |
| ) |
| except HTTPException: |
| raise |
| except Exception as e: |
| log.error(f"获取凭证状态失败: {e}") |
| raise HTTPException(status_code=500, detail=str(e)) |
|
|
|
|
| @router.get("/creds/detail/{filename}") |
| async def get_cred_detail( |
| filename: str, |
| token: str = Depends(verify_panel_token), |
| mode: str = "geminicli" |
| ): |
| """ |
| 按需获取单个凭证的详细数据(包含完整凭证内容) |
| 用于用户查看/编辑凭证详情 |
| """ |
| try: |
| mode = validate_mode(mode) |
| |
| if not filename.endswith(".json"): |
| raise HTTPException(status_code=400, detail="无效的文件名") |
|
|
| |
|
|
| storage_adapter = await get_storage_adapter() |
| backend_info = await storage_adapter.get_backend_info() |
| backend_type = backend_info.get("backend_type", "unknown") |
|
|
| |
| credential_data = await storage_adapter.get_credential(filename, mode=mode) |
| if not credential_data: |
| raise HTTPException(status_code=404, detail="凭证不存在") |
|
|
| |
| file_status = await storage_adapter.get_credential_state(filename, mode=mode) |
| if not file_status: |
| file_status = { |
| "error_codes": [], |
| "disabled": False, |
| "last_success": time.time(), |
| "user_email": None, |
| } |
|
|
| result = { |
| "status": file_status, |
| "content": credential_data, |
| "filename": os.path.basename(filename), |
| "backend_type": backend_type, |
| "user_email": file_status.get("user_email"), |
| "model_cooldowns": file_status.get("model_cooldowns", {}), |
| } |
|
|
| if backend_type == "file" and os.path.exists(filename): |
| result.update({ |
| "size": os.path.getsize(filename), |
| "modified_time": os.path.getmtime(filename), |
| }) |
|
|
| return JSONResponse(content=result) |
|
|
| except HTTPException: |
| raise |
| except Exception as e: |
| log.error(f"获取凭证详情失败 {filename}: {e}") |
| raise HTTPException(status_code=500, detail=str(e)) |
|
|
|
|
| @router.post("/creds/action") |
| async def creds_action( |
| request: CredFileActionRequest, |
| token: str = Depends(verify_panel_token), |
| mode: str = "geminicli" |
| ): |
| """对凭证文件执行操作(启用/禁用/删除)""" |
| try: |
| mode = validate_mode(mode) |
|
|
| log.info(f"Received request: {request}") |
|
|
| filename = request.filename |
| action = request.action |
|
|
| log.info(f"Performing action '{action}' on file: {filename} (mode={mode})") |
|
|
| |
| if not filename.endswith(".json"): |
| log.error(f"无效的文件名: {filename}(不是.json文件)") |
| raise HTTPException(status_code=400, detail=f"无效的文件名: {filename}") |
|
|
| |
| storage_adapter = await get_storage_adapter() |
|
|
| |
| |
| if action != "delete": |
| |
| credential_data = await storage_adapter.get_credential(filename, mode=mode) |
| if not credential_data: |
| log.error(f"凭证未找到: {filename} (mode={mode})") |
| raise HTTPException(status_code=404, detail="凭证文件不存在") |
|
|
| if action == "enable": |
| log.info(f"Web请求: 启用文件 {filename} (mode={mode})") |
| result = await credential_manager.set_cred_disabled(filename, False, mode=mode) |
| log.info(f"[WebRoute] set_cred_disabled 返回结果: {result}") |
| if result: |
| log.info(f"Web请求: 文件 {filename} 已成功启用 (mode={mode})") |
| return JSONResponse(content={"message": f"已启用凭证文件 {os.path.basename(filename)}"}) |
| else: |
| log.error(f"Web请求: 文件 {filename} 启用失败 (mode={mode})") |
| raise HTTPException(status_code=500, detail="启用凭证失败,可能凭证不存在") |
|
|
| elif action == "disable": |
| log.info(f"Web请求: 禁用文件 {filename} (mode={mode})") |
| result = await credential_manager.set_cred_disabled(filename, True, mode=mode) |
| log.info(f"[WebRoute] set_cred_disabled 返回结果: {result}") |
| if result: |
| log.info(f"Web请求: 文件 {filename} 已成功禁用 (mode={mode})") |
| return JSONResponse(content={"message": f"已禁用凭证文件 {os.path.basename(filename)}"}) |
| else: |
| log.error(f"Web请求: 文件 {filename} 禁用失败 (mode={mode})") |
| raise HTTPException(status_code=500, detail="禁用凭证失败,可能凭证不存在") |
|
|
| elif action == "delete": |
| try: |
| |
| success = await credential_manager.remove_credential(filename, mode=mode) |
| if success: |
| log.info(f"通过管理器成功删除凭证: {filename} (mode={mode})") |
| return JSONResponse( |
| content={"message": f"已删除凭证文件 {os.path.basename(filename)}"} |
| ) |
| else: |
| raise HTTPException(status_code=500, detail="删除凭证失败") |
| except Exception as e: |
| log.error(f"删除凭证 {filename} 时出错: {e}") |
| raise HTTPException(status_code=500, detail=f"删除文件失败: {str(e)}") |
|
|
| else: |
| raise HTTPException(status_code=400, detail="无效的操作类型") |
|
|
| except HTTPException: |
| raise |
| except Exception as e: |
| log.error(f"凭证文件操作失败: {e}") |
| raise HTTPException(status_code=500, detail=str(e)) |
|
|
|
|
| @router.post("/creds/batch-action") |
| async def creds_batch_action( |
| request: CredFileBatchActionRequest, |
| token: str = Depends(verify_panel_token), |
| mode: str = "geminicli" |
| ): |
| """批量对凭证文件执行操作(启用/禁用/删除)""" |
| try: |
| mode = validate_mode(mode) |
|
|
| action = request.action |
| filenames = request.filenames |
|
|
| if not filenames: |
| raise HTTPException(status_code=400, detail="文件名列表不能为空") |
|
|
| log.info(f"对 {len(filenames)} 个文件执行批量操作 '{action}'") |
|
|
| success_count = 0 |
| errors = [] |
|
|
| storage_adapter = await get_storage_adapter() |
|
|
| for filename in filenames: |
| try: |
| |
| if not filename.endswith(".json"): |
| errors.append(f"{filename}: 无效的文件类型") |
| continue |
|
|
| |
| |
| if action != "delete": |
| credential_data = await storage_adapter.get_credential(filename, mode=mode) |
| if not credential_data: |
| errors.append(f"{filename}: 凭证不存在") |
| continue |
|
|
| |
| if action == "enable": |
| await credential_manager.set_cred_disabled(filename, False, mode=mode) |
| success_count += 1 |
|
|
| elif action == "disable": |
| await credential_manager.set_cred_disabled(filename, True, mode=mode) |
| success_count += 1 |
|
|
| elif action == "delete": |
| try: |
| delete_success = await credential_manager.remove_credential(filename, mode=mode) |
| if delete_success: |
| success_count += 1 |
| log.info(f"成功删除批量中的凭证: {filename}") |
| else: |
| errors.append(f"{filename}: 删除失败") |
| continue |
| except Exception as e: |
| errors.append(f"{filename}: 删除文件失败 - {str(e)}") |
| continue |
| else: |
| errors.append(f"{filename}: 无效的操作类型") |
| continue |
|
|
| except Exception as e: |
| log.error(f"处理 {filename} 时出错: {e}") |
| errors.append(f"{filename}: 处理失败 - {str(e)}") |
| continue |
|
|
| |
| result_message = f"批量操作完成:成功处理 {success_count}/{len(filenames)} 个文件" |
| if errors: |
| result_message += "\n错误详情:\n" + "\n".join(errors) |
|
|
| response_data = { |
| "success_count": success_count, |
| "total_count": len(filenames), |
| "errors": errors, |
| "message": result_message, |
| } |
|
|
| return JSONResponse(content=response_data) |
|
|
| except HTTPException: |
| raise |
| except Exception as e: |
| log.error(f"批量凭证文件操作失败: {e}") |
| raise HTTPException(status_code=500, detail=str(e)) |
|
|
|
|
| @router.get("/creds/download/{filename}") |
| async def download_cred_file( |
| filename: str, |
| token: str = Depends(verify_panel_token), |
| mode: str = "geminicli" |
| ): |
| """下载单个凭证文件""" |
| try: |
| mode = validate_mode(mode) |
| |
| if not filename.endswith(".json"): |
| raise HTTPException(status_code=404, detail="无效的文件名") |
|
|
| |
| storage_adapter = await get_storage_adapter() |
|
|
| |
| credential_data = await storage_adapter.get_credential(filename, mode=mode) |
| if not credential_data: |
| raise HTTPException(status_code=404, detail="文件不存在") |
|
|
| |
| content = json.dumps(credential_data, ensure_ascii=False, indent=2) |
|
|
| from fastapi.responses import Response |
|
|
| return Response( |
| content=content, |
| media_type="application/json", |
| headers={"Content-Disposition": f"attachment; filename={filename}"}, |
| ) |
|
|
| except HTTPException: |
| raise |
| except Exception as e: |
| log.error(f"下载凭证文件失败: {e}") |
| raise HTTPException(status_code=500, detail=str(e)) |
|
|
|
|
| @router.post("/creds/fetch-email/{filename}") |
| async def fetch_user_email( |
| filename: str, |
| token: str = Depends(verify_panel_token), |
| mode: str = "geminicli" |
| ): |
| """获取指定凭证文件的用户邮箱地址""" |
| try: |
| mode = validate_mode(mode) |
| return await fetch_user_email_common(filename, mode=mode) |
| except HTTPException: |
| raise |
| except Exception as e: |
| log.error(f"获取用户邮箱失败: {e}") |
| raise HTTPException(status_code=500, detail=str(e)) |
|
|
|
|
| @router.post("/creds/refresh-all-emails") |
| async def refresh_all_user_emails( |
| token: str = Depends(verify_panel_token), |
| mode: str = "geminicli" |
| ): |
| """刷新所有凭证文件的用户邮箱地址""" |
| try: |
| mode = validate_mode(mode) |
| return await refresh_all_user_emails_common(mode=mode) |
| except Exception as e: |
| log.error(f"批量获取用户邮箱失败: {e}") |
| raise HTTPException(status_code=500, detail=str(e)) |
|
|
|
|
| @router.post("/creds/deduplicate-by-email") |
| async def deduplicate_credentials_by_email( |
| token: str = Depends(verify_panel_token), |
| mode: str = "geminicli" |
| ): |
| """批量去重凭证文件 - 删除邮箱相同的凭证(只保留一个)""" |
| try: |
| mode = validate_mode(mode) |
| return await deduplicate_credentials_by_email_common(mode=mode) |
| except Exception as e: |
| log.error(f"批量去重凭证失败: {e}") |
| raise HTTPException(status_code=500, detail=str(e)) |
|
|
|
|
| @router.get("/creds/download-all") |
| async def download_all_creds( |
| token: str = Depends(verify_panel_token), |
| mode: str = "geminicli" |
| ): |
| """ |
| 打包下载所有凭证文件(流式处理,按需加载每个凭证数据) |
| 只在实际下载时才加载完整凭证内容,最大化性能 |
| """ |
| try: |
| mode = validate_mode(mode) |
| return await download_all_creds_common(mode=mode) |
| except HTTPException: |
| raise |
| except Exception as e: |
| log.error(f"打包下载失败: {e}") |
| raise HTTPException(status_code=500, detail=str(e)) |
|
|
|
|
| @router.get("/config/get") |
| async def get_config(token: str = Depends(verify_panel_token)): |
| """获取当前配置""" |
| try: |
| |
|
|
| |
| current_config = {} |
|
|
| |
| current_config["code_assist_endpoint"] = await config.get_code_assist_endpoint() |
| current_config["credentials_dir"] = await config.get_credentials_dir() |
| current_config["proxy"] = await config.get_proxy_config() or "" |
|
|
| |
| current_config["oauth_proxy_url"] = await config.get_oauth_proxy_url() |
| current_config["googleapis_proxy_url"] = await config.get_googleapis_proxy_url() |
| current_config["resource_manager_api_url"] = await config.get_resource_manager_api_url() |
| current_config["service_usage_api_url"] = await config.get_service_usage_api_url() |
| current_config["antigravity_api_url"] = await config.get_antigravity_api_url() |
|
|
| |
| current_config["auto_ban_enabled"] = await config.get_auto_ban_enabled() |
| current_config["auto_ban_error_codes"] = await config.get_auto_ban_error_codes() |
|
|
| |
| current_config["retry_429_max_retries"] = await config.get_retry_429_max_retries() |
| current_config["retry_429_enabled"] = await config.get_retry_429_enabled() |
| current_config["retry_429_interval"] = await config.get_retry_429_interval() |
|
|
| |
| current_config["anti_truncation_max_attempts"] = await config.get_anti_truncation_max_attempts() |
|
|
| |
| current_config["compatibility_mode_enabled"] = await config.get_compatibility_mode_enabled() |
|
|
| |
| current_config["return_thoughts_to_frontend"] = await config.get_return_thoughts_to_frontend() |
|
|
| |
| current_config["antigravity_stream2nostream"] = await config.get_antigravity_stream2nostream() |
|
|
| |
| current_config["host"] = await config.get_server_host() |
| current_config["port"] = await config.get_server_port() |
| current_config["api_password"] = await config.get_api_password() |
| current_config["panel_password"] = await config.get_panel_password() |
| current_config["password"] = await config.get_server_password() |
|
|
| |
| storage_adapter = await get_storage_adapter() |
| storage_config = await storage_adapter.get_all_config() |
|
|
| |
| env_locked_keys = get_env_locked_keys() |
|
|
| |
| for key, value in storage_config.items(): |
| if key not in env_locked_keys: |
| current_config[key] = value |
|
|
| return JSONResponse(content={"config": current_config, "env_locked": list(env_locked_keys)}) |
|
|
| except Exception as e: |
| log.error(f"获取配置失败: {e}") |
| raise HTTPException(status_code=500, detail=str(e)) |
|
|
|
|
| @router.post("/config/save") |
| async def save_config(request: ConfigSaveRequest, token: str = Depends(verify_panel_token)): |
| """保存配置""" |
| try: |
| |
| new_config = request.config |
|
|
| log.debug(f"收到的配置数据: {list(new_config.keys())}") |
| log.debug(f"收到的password值: {new_config.get('password', 'NOT_FOUND')}") |
|
|
| |
| if "retry_429_max_retries" in new_config: |
| if ( |
| not isinstance(new_config["retry_429_max_retries"], int) |
| or new_config["retry_429_max_retries"] < 0 |
| ): |
| raise HTTPException(status_code=400, detail="最大429重试次数必须是大于等于0的整数") |
|
|
| if "retry_429_enabled" in new_config: |
| if not isinstance(new_config["retry_429_enabled"], bool): |
| raise HTTPException(status_code=400, detail="429重试开关必须是布尔值") |
|
|
| |
| if "retry_429_interval" in new_config: |
| try: |
| interval = float(new_config["retry_429_interval"]) |
| if interval < 0.01 or interval > 10: |
| raise HTTPException(status_code=400, detail="429重试间隔必须在0.01-10秒之间") |
| except (ValueError, TypeError): |
| raise HTTPException(status_code=400, detail="429重试间隔必须是有效的数字") |
|
|
| if "anti_truncation_max_attempts" in new_config: |
| if ( |
| not isinstance(new_config["anti_truncation_max_attempts"], int) |
| or new_config["anti_truncation_max_attempts"] < 1 |
| or new_config["anti_truncation_max_attempts"] > 10 |
| ): |
| raise HTTPException( |
| status_code=400, detail="抗截断最大重试次数必须是1-10之间的整数" |
| ) |
|
|
| if "compatibility_mode_enabled" in new_config: |
| if not isinstance(new_config["compatibility_mode_enabled"], bool): |
| raise HTTPException(status_code=400, detail="兼容性模式开关必须是布尔值") |
|
|
| if "return_thoughts_to_frontend" in new_config: |
| if not isinstance(new_config["return_thoughts_to_frontend"], bool): |
| raise HTTPException(status_code=400, detail="思维链返回开关必须是布尔值") |
|
|
| if "antigravity_stream2nostream" in new_config: |
| if not isinstance(new_config["antigravity_stream2nostream"], bool): |
| raise HTTPException(status_code=400, detail="Antigravity流式转非流式开关必须是布尔值") |
|
|
| |
| if "host" in new_config: |
| if not isinstance(new_config["host"], str) or not new_config["host"].strip(): |
| raise HTTPException(status_code=400, detail="服务器主机地址不能为空") |
|
|
| if "port" in new_config: |
| if ( |
| not isinstance(new_config["port"], int) |
| or new_config["port"] < 1 |
| or new_config["port"] > 65535 |
| ): |
| raise HTTPException(status_code=400, detail="端口号必须是1-65535之间的整数") |
|
|
| if "api_password" in new_config: |
| if not isinstance(new_config["api_password"], str): |
| raise HTTPException(status_code=400, detail="API访问密码必须是字符串") |
|
|
| if "panel_password" in new_config: |
| if not isinstance(new_config["panel_password"], str): |
| raise HTTPException(status_code=400, detail="控制面板密码必须是字符串") |
|
|
| if "password" in new_config: |
| if not isinstance(new_config["password"], str): |
| raise HTTPException(status_code=400, detail="访问密码必须是字符串") |
|
|
| |
| env_locked_keys = get_env_locked_keys() |
|
|
| |
| storage_adapter = await get_storage_adapter() |
| for key, value in new_config.items(): |
| if key not in env_locked_keys: |
| await storage_adapter.set_config(key, value) |
| if key in ("password", "api_password", "panel_password"): |
| log.debug(f"设置{key}字段为: {value}") |
|
|
| |
| await config.reload_config() |
|
|
| |
| test_api_password = await config.get_api_password() |
| test_panel_password = await config.get_panel_password() |
| test_password = await config.get_server_password() |
| log.debug(f"保存后立即读取的API密码: {test_api_password}") |
| log.debug(f"保存后立即读取的面板密码: {test_panel_password}") |
| log.debug(f"保存后立即读取的通用密码: {test_password}") |
|
|
| |
| response_data = { |
| "message": "配置保存成功", |
| "saved_config": {k: v for k, v in new_config.items() if k not in env_locked_keys}, |
| } |
|
|
| return JSONResponse(content=response_data) |
|
|
| except HTTPException: |
| raise |
| except Exception as e: |
| log.error(f"保存配置失败: {e}") |
| raise HTTPException(status_code=500, detail=str(e)) |
|
|
|
|
| |
| |
| |
|
|
|
|
| @router.post("/auth/logs/clear") |
| async def clear_logs(token: str = Depends(verify_panel_token)): |
| """清空日志文件""" |
| try: |
| |
| log_file_path = os.getenv("LOG_FILE", "log.txt") |
|
|
| |
| if os.path.exists(log_file_path): |
| try: |
| |
| with open(log_file_path, "w", encoding="utf-8", newline="") as f: |
| f.write("") |
| f.flush() |
| log.info(f"日志文件已清空: {log_file_path}") |
|
|
| |
| await manager.broadcast("--- 日志文件已清空 ---") |
|
|
| return JSONResponse( |
| content={"message": f"日志文件已清空: {os.path.basename(log_file_path)}"} |
| ) |
| except Exception as e: |
| log.error(f"清空日志文件失败: {e}") |
| raise HTTPException(status_code=500, detail=f"清空日志文件失败: {str(e)}") |
| else: |
| return JSONResponse(content={"message": "日志文件不存在"}) |
|
|
| except Exception as e: |
| log.error(f"清空日志文件失败: {e}") |
| raise HTTPException(status_code=500, detail=f"清空日志文件失败: {str(e)}") |
|
|
|
|
| @router.get("/auth/logs/download") |
| async def download_logs(token: str = Depends(verify_panel_token)): |
| """下载日志文件""" |
| try: |
| |
| log_file_path = os.getenv("LOG_FILE", "log.txt") |
|
|
| |
| if not os.path.exists(log_file_path): |
| raise HTTPException(status_code=404, detail="日志文件不存在") |
|
|
| |
| file_size = os.path.getsize(log_file_path) |
| if file_size == 0: |
| raise HTTPException(status_code=404, detail="日志文件为空") |
|
|
| |
| timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") |
| filename = f"gcli2api_logs_{timestamp}.txt" |
|
|
| log.info(f"下载日志文件: {log_file_path}") |
|
|
| return FileResponse( |
| path=log_file_path, |
| filename=filename, |
| media_type="text/plain", |
| headers={"Content-Disposition": f"attachment; filename={filename}"}, |
| ) |
|
|
| except HTTPException: |
| raise |
| except Exception as e: |
| log.error(f"下载日志文件失败: {e}") |
| raise HTTPException(status_code=500, detail=f"下载日志文件失败: {str(e)}") |
|
|
|
|
| @router.websocket("/auth/logs/stream") |
| async def websocket_logs(websocket: WebSocket): |
| """WebSocket端点,用于实时日志流""" |
| |
| if not await manager.connect(websocket): |
| return |
|
|
| try: |
| |
| log_file_path = os.getenv("LOG_FILE", "log.txt") |
|
|
| |
| if os.path.exists(log_file_path): |
| try: |
| with open(log_file_path, "r", encoding="utf-8") as f: |
| lines = f.readlines() |
| |
| for line in lines[-50:]: |
| if line.strip(): |
| await websocket.send_text(line.strip()) |
| except Exception as e: |
| await websocket.send_text(f"Error reading log file: {e}") |
|
|
| |
| last_size = os.path.getsize(log_file_path) if os.path.exists(log_file_path) else 0 |
| max_read_size = 8192 |
| check_interval = 2 |
|
|
| |
| |
| async def listen_for_disconnect(): |
| try: |
| while True: |
| await websocket.receive_text() |
| except Exception: |
| pass |
|
|
| listener_task = asyncio.create_task(listen_for_disconnect()) |
|
|
| try: |
| while websocket.client_state == WebSocketState.CONNECTED: |
| |
| |
| done, pending = await asyncio.wait( |
| [listener_task], |
| timeout=check_interval, |
| return_when=asyncio.FIRST_COMPLETED |
| ) |
|
|
| |
| if listener_task in done: |
| break |
|
|
| if os.path.exists(log_file_path): |
| current_size = os.path.getsize(log_file_path) |
| if current_size > last_size: |
| |
| read_size = min(current_size - last_size, max_read_size) |
|
|
| try: |
| with open(log_file_path, "r", encoding="utf-8", errors="replace") as f: |
| f.seek(last_size) |
| new_content = f.read(read_size) |
|
|
| |
| if not new_content: |
| last_size = current_size |
| continue |
|
|
| |
| lines = new_content.splitlines(keepends=True) |
| if lines: |
| |
| if not lines[-1].endswith("\n") and len(lines) > 1: |
| |
| for line in lines[:-1]: |
| if line.strip(): |
| await websocket.send_text(line.rstrip()) |
| |
| last_size += len(new_content.encode("utf-8")) - len( |
| lines[-1].encode("utf-8") |
| ) |
| else: |
| |
| for line in lines: |
| if line.strip(): |
| await websocket.send_text(line.rstrip()) |
| last_size += len(new_content.encode("utf-8")) |
| except UnicodeDecodeError as e: |
| |
| log.warning(f"WebSocket日志读取编码错误: {e}, 跳过部分内容") |
| last_size = current_size |
| except Exception as e: |
| await websocket.send_text(f"Error reading new content: {e}") |
| |
| last_size = current_size |
|
|
| |
| elif current_size < last_size: |
| last_size = 0 |
| await websocket.send_text("--- 日志已清空 ---") |
|
|
| finally: |
| |
| if not listener_task.done(): |
| listener_task.cancel() |
| try: |
| await listener_task |
| except asyncio.CancelledError: |
| pass |
|
|
| except WebSocketDisconnect: |
| pass |
| except Exception as e: |
| log.error(f"WebSocket logs error: {e}") |
| finally: |
| manager.disconnect(websocket) |
|
|
|
|
| async def verify_credential_project_common(filename: str, mode: str = "geminicli") -> JSONResponse: |
| """验证并重新获取凭证的project id的通用函数""" |
| mode = validate_mode(mode) |
|
|
| |
| if not filename.endswith(".json"): |
| raise HTTPException(status_code=400, detail="无效的文件名") |
|
|
|
|
| storage_adapter = await get_storage_adapter() |
|
|
| |
| credential_data = await storage_adapter.get_credential(filename, mode=mode) |
| if not credential_data: |
| raise HTTPException(status_code=404, detail="凭证不存在") |
|
|
| |
| credentials = Credentials.from_dict(credential_data) |
|
|
| |
| token_refreshed = await credentials.refresh_if_needed() |
|
|
| |
| if token_refreshed: |
| log.info(f"Token已自动刷新: {filename} (mode={mode})") |
| credential_data = credentials.to_dict() |
| await storage_adapter.store_credential(filename, credential_data, mode=mode) |
|
|
| |
| if mode == "antigravity": |
| api_base_url = await get_antigravity_api_url() |
| user_agent = ANTIGRAVITY_USER_AGENT |
| else: |
| api_base_url = await get_code_assist_endpoint() |
| user_agent = GEMINICLI_USER_AGENT |
|
|
| |
| project_id = await fetch_project_id( |
| access_token=credentials.access_token, |
| user_agent=user_agent, |
| api_base_url=api_base_url |
| ) |
|
|
| if project_id: |
| |
| credential_data["project_id"] = project_id |
| await storage_adapter.store_credential(filename, credential_data, mode=mode) |
|
|
| |
| await storage_adapter.update_credential_state(filename, { |
| "disabled": False, |
| "error_codes": [] |
| }, mode=mode) |
|
|
| log.info(f"检验 {mode} 凭证成功: {filename} - Project ID: {project_id} - 已解除禁用并清除错误码") |
|
|
| return JSONResponse(content={ |
| "success": True, |
| "filename": filename, |
| "project_id": project_id, |
| "message": "检验成功!Project ID已更新,已解除禁用状态并清除错误码,403错误应该已恢复" |
| }) |
| else: |
| return JSONResponse( |
| status_code=400, |
| content={ |
| "success": False, |
| "filename": filename, |
| "message": "检验失败:无法获取Project ID,请检查凭证是否有效" |
| } |
| ) |
|
|
|
|
| @router.post("/creds/verify-project/{filename}") |
| async def verify_credential_project( |
| filename: str, |
| token: str = Depends(verify_panel_token), |
| mode: str = "geminicli" |
| ): |
| """ |
| 检验凭证的project id,重新获取project id |
| 检验成功可以使403错误恢复 |
| """ |
| try: |
| mode = validate_mode(mode) |
| return await verify_credential_project_common(filename, mode=mode) |
| except HTTPException: |
| raise |
| except Exception as e: |
| log.error(f"检验凭证Project ID失败 {filename}: {e}") |
| raise HTTPException(status_code=500, detail=f"检验失败: {str(e)}") |
|
|
|
|
| @router.get("/creds/quota/{filename}") |
| async def get_credential_quota( |
| filename: str, |
| token: str = Depends(verify_panel_token), |
| mode: str = "antigravity" |
| ): |
| """ |
| 获取指定凭证的额度信息(仅支持 antigravity 模式) |
| """ |
| try: |
| mode = validate_mode(mode) |
| |
| if not filename.endswith(".json"): |
| raise HTTPException(status_code=400, detail="无效的文件名") |
|
|
| |
| storage_adapter = await get_storage_adapter() |
|
|
| |
| credential_data = await storage_adapter.get_credential(filename, mode=mode) |
| if not credential_data: |
| raise HTTPException(status_code=404, detail="凭证不存在") |
|
|
| |
| from .google_oauth_api import Credentials |
|
|
| creds = Credentials.from_dict(credential_data) |
|
|
| |
| await creds.refresh_if_needed() |
|
|
| |
| updated_data = creds.to_dict() |
| if updated_data != credential_data: |
| log.info(f"Token已自动刷新: {filename}") |
| await storage_adapter.store_credential(filename, updated_data, mode=mode) |
| credential_data = updated_data |
|
|
| |
| access_token = credential_data.get("access_token") or credential_data.get("token") |
| if not access_token: |
| raise HTTPException(status_code=400, detail="凭证中没有访问令牌") |
|
|
| |
| quota_info = await fetch_quota_info(access_token) |
|
|
| if quota_info.get("success"): |
| return JSONResponse(content={ |
| "success": True, |
| "filename": filename, |
| "models": quota_info.get("models", {}) |
| }) |
| else: |
| return JSONResponse( |
| status_code=400, |
| content={ |
| "success": False, |
| "filename": filename, |
| "error": quota_info.get("error", "未知错误") |
| } |
| ) |
|
|
| except HTTPException: |
| raise |
| except Exception as e: |
| log.error(f"获取凭证额度失败 {filename}: {e}") |
| raise HTTPException(status_code=500, detail=f"获取额度失败: {str(e)}") |
|
|
|
|
| @router.get("/version/info") |
| async def get_version_info(check_update: bool = False): |
| """ |
| 获取当前版本信息 - 从version.txt读取 |
| 可选参数 check_update: 是否检查GitHub上的最新版本 |
| """ |
| try: |
| |
| project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) |
| version_file = os.path.join(project_root, "version.txt") |
|
|
| |
| if not os.path.exists(version_file): |
| return JSONResponse({ |
| "success": False, |
| "error": "version.txt文件不存在" |
| }) |
|
|
| version_data = {} |
| with open(version_file, 'r', encoding='utf-8') as f: |
| for line in f: |
| line = line.strip() |
| if '=' in line: |
| key, value = line.split('=', 1) |
| version_data[key] = value |
|
|
| |
| if 'short_hash' not in version_data: |
| return JSONResponse({ |
| "success": False, |
| "error": "version.txt格式错误" |
| }) |
|
|
| response_data = { |
| "success": True, |
| "version": version_data.get('short_hash', 'unknown'), |
| "full_hash": version_data.get('full_hash', ''), |
| "message": version_data.get('message', ''), |
| "date": version_data.get('date', '') |
| } |
|
|
| |
| if check_update: |
| try: |
| from src.httpx_client import get_async |
|
|
| |
| github_version_url = "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/version.txt" |
|
|
| |
| resp = await get_async(github_version_url, timeout=10.0) |
|
|
| if resp.status_code == 200: |
| |
| remote_version_data = {} |
| for line in resp.text.strip().split('\n'): |
| line = line.strip() |
| if '=' in line: |
| key, value = line.split('=', 1) |
| remote_version_data[key] = value |
|
|
| latest_hash = remote_version_data.get('full_hash', '') |
| latest_short_hash = remote_version_data.get('short_hash', '') |
| current_hash = version_data.get('full_hash', '') |
|
|
| has_update = (current_hash != latest_hash) if current_hash and latest_hash else None |
|
|
| response_data['check_update'] = True |
| response_data['has_update'] = has_update |
| response_data['latest_version'] = latest_short_hash |
| response_data['latest_hash'] = latest_hash |
| response_data['latest_message'] = remote_version_data.get('message', '') |
| response_data['latest_date'] = remote_version_data.get('date', '') |
| else: |
| |
| response_data['check_update'] = False |
| response_data['update_error'] = f"GitHub返回错误: {resp.status_code}" |
|
|
| except Exception as e: |
| log.debug(f"检查更新失败: {e}") |
| response_data['check_update'] = False |
| response_data['update_error'] = str(e) |
|
|
| return JSONResponse(response_data) |
|
|
| except Exception as e: |
| log.error(f"获取版本信息失败: {e}") |
| return JSONResponse({ |
| "success": False, |
| "error": str(e) |
| }) |
|
|
|
|
|
|
|
|
|
|