|
|
""" |
|
|
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) |
|
|
}) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|