Spaces:
Running
Running
Upload 11 files
Browse files- Dockerfile +2 -0
- main.py +361 -168
- requirements.txt +3 -1
- uptime_tracker.py +78 -0
Dockerfile
CHANGED
|
@@ -8,6 +8,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
| 8 |
&& apt-get autoremove -y \
|
| 9 |
&& rm -rf /var/lib/apt/lists/*
|
| 10 |
COPY main.py .
|
|
|
|
|
|
|
| 11 |
# 复制 core 模块
|
| 12 |
COPY core ./core
|
| 13 |
# 复制 util 目录
|
|
|
|
| 8 |
&& apt-get autoremove -y \
|
| 9 |
&& rm -rf /var/lib/apt/lists/*
|
| 10 |
COPY main.py .
|
| 11 |
+
# 复制 uptime_tracker 模块
|
| 12 |
+
COPY uptime_tracker.py .
|
| 13 |
# 复制 core 模块
|
| 14 |
COPY core ./core
|
| 15 |
# 复制 util 目录
|
main.py
CHANGED
|
@@ -6,6 +6,7 @@ import logging
|
|
| 6 |
from dotenv import load_dotenv
|
| 7 |
|
| 8 |
import httpx
|
|
|
|
| 9 |
from fastapi import FastAPI, HTTPException, Header, Request, Body
|
| 10 |
from fastapi.responses import StreamingResponse, HTMLResponse, JSONResponse
|
| 11 |
from fastapi.staticfiles import StaticFiles
|
|
@@ -18,6 +19,9 @@ from functools import wraps
|
|
| 18 |
# 导入认证装饰器
|
| 19 |
from core.auth import require_path_prefix, require_admin_auth, require_path_and_admin
|
| 20 |
|
|
|
|
|
|
|
|
|
|
| 21 |
# ---------- 日志配置 ----------
|
| 22 |
|
| 23 |
# 内存日志缓冲区 (保留最近 3000 条日志,重启后清空)
|
|
@@ -26,14 +30,15 @@ log_lock = Lock()
|
|
| 26 |
|
| 27 |
# 统计数据持久化
|
| 28 |
STATS_FILE = "stats.json"
|
| 29 |
-
stats_lock = Lock()
|
| 30 |
|
| 31 |
-
def load_stats():
|
| 32 |
-
"""
|
| 33 |
try:
|
| 34 |
if os.path.exists(STATS_FILE):
|
| 35 |
-
with open(STATS_FILE, 'r', encoding='utf-8') as f:
|
| 36 |
-
|
|
|
|
| 37 |
except Exception:
|
| 38 |
pass
|
| 39 |
return {
|
|
@@ -44,16 +49,22 @@ def load_stats():
|
|
| 44 |
"account_conversations": {} # {account_id: conversation_count} 账户对话次数
|
| 45 |
}
|
| 46 |
|
| 47 |
-
def save_stats(stats):
|
| 48 |
-
"""
|
| 49 |
try:
|
| 50 |
-
with open(STATS_FILE, 'w', encoding='utf-8') as f:
|
| 51 |
-
json.
|
| 52 |
except Exception as e:
|
| 53 |
logger.error(f"[STATS] 保存统计数据失败: {str(e)[:50]}")
|
| 54 |
|
| 55 |
-
#
|
| 56 |
-
global_stats =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
|
| 58 |
class MemoryLogHandler(logging.Handler):
|
| 59 |
"""自定义日志处理器,将日志写入内存缓冲区"""
|
|
@@ -127,7 +138,10 @@ http_client = httpx.AsyncClient(
|
|
| 127 |
verify=False,
|
| 128 |
http2=False,
|
| 129 |
timeout=httpx.Timeout(TIMEOUT_SECONDS, connect=60.0),
|
| 130 |
-
limits=httpx.Limits(
|
|
|
|
|
|
|
|
|
|
| 131 |
)
|
| 132 |
|
| 133 |
# ---------- 工具函数 ----------
|
|
@@ -340,11 +354,16 @@ class MultiAccountManager:
|
|
| 340 |
self.accounts: Dict[str, AccountManager] = {}
|
| 341 |
self.account_list: List[str] = [] # 账户ID列表 (用于轮询)
|
| 342 |
self.current_index = 0
|
| 343 |
-
self.
|
|
|
|
| 344 |
# 全局会话缓存:{conv_key: {"account_id": str, "session_id": str, "updated_at": float}}
|
| 345 |
self.global_session_cache: Dict[str, dict] = {}
|
| 346 |
self.cache_max_size = 1000 # 最大缓存条目数
|
| 347 |
self.cache_ttl = SESSION_CACHE_TTL_SECONDS # 缓存过期时间(秒)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 348 |
|
| 349 |
def _clean_expired_cache(self):
|
| 350 |
"""清理过期的缓存条目"""
|
|
@@ -376,7 +395,7 @@ class MultiAccountManager:
|
|
| 376 |
try:
|
| 377 |
while True:
|
| 378 |
await asyncio.sleep(300) # 5分钟
|
| 379 |
-
async with self.
|
| 380 |
self._clean_expired_cache()
|
| 381 |
self._ensure_cache_size()
|
| 382 |
except asyncio.CancelledError:
|
|
@@ -386,7 +405,7 @@ class MultiAccountManager:
|
|
| 386 |
|
| 387 |
async def set_session_cache(self, conv_key: str, account_id: str, session_id: str):
|
| 388 |
"""线程安全地设置会话缓存"""
|
| 389 |
-
async with self.
|
| 390 |
self.global_session_cache[conv_key] = {
|
| 391 |
"account_id": account_id,
|
| 392 |
"session_id": session_id,
|
|
@@ -397,10 +416,25 @@ class MultiAccountManager:
|
|
| 397 |
|
| 398 |
async def update_session_time(self, conv_key: str):
|
| 399 |
"""线程安全地更新会话时间戳"""
|
| 400 |
-
async with self.
|
| 401 |
if conv_key in self.global_session_cache:
|
| 402 |
self.global_session_cache[conv_key]["updated_at"] = time.time()
|
| 403 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 404 |
def add_account(self, config: AccountConfig):
|
| 405 |
"""添加账户"""
|
| 406 |
manager = AccountManager(config)
|
|
@@ -412,43 +446,40 @@ class MultiAccountManager:
|
|
| 412 |
logger.info(f"[MULTI] [ACCOUNT] 添加账户: {config.account_id}")
|
| 413 |
|
| 414 |
async def get_account(self, account_id: Optional[str] = None, request_id: str = "") -> AccountManager:
|
| 415 |
-
"""获取账户 (轮询或指定)"""
|
| 416 |
-
|
| 417 |
-
# 定期清理过期缓存(每次获取账户时检查)
|
| 418 |
-
self._clean_expired_cache()
|
| 419 |
-
|
| 420 |
-
req_tag = f"[req_{request_id}] " if request_id else ""
|
| 421 |
-
|
| 422 |
-
# 如果指定了账户ID
|
| 423 |
-
if account_id:
|
| 424 |
-
if account_id not in self.accounts:
|
| 425 |
-
raise HTTPException(404, f"Account {account_id} not found")
|
| 426 |
-
account = self.accounts[account_id]
|
| 427 |
-
if not account.should_retry():
|
| 428 |
-
raise HTTPException(503, f"Account {account_id} temporarily unavailable")
|
| 429 |
-
return account
|
| 430 |
-
|
| 431 |
-
# 轮询选择可用账户(排除过期账户和手动禁用账户)
|
| 432 |
-
available_accounts = [
|
| 433 |
-
acc_id for acc_id in self.account_list
|
| 434 |
-
if self.accounts[acc_id].should_retry()
|
| 435 |
-
and not self.accounts[acc_id].config.is_expired()
|
| 436 |
-
and not self.accounts[acc_id].config.disabled
|
| 437 |
-
]
|
| 438 |
|
| 439 |
-
|
| 440 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 441 |
|
| 442 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 443 |
if not hasattr(self, '_available_index'):
|
| 444 |
self._available_index = 0
|
| 445 |
|
| 446 |
account_id = available_accounts[self._available_index % len(available_accounts)]
|
| 447 |
self._available_index = (self._available_index + 1) % len(available_accounts)
|
| 448 |
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
|
| 453 |
# ---------- 配置文件管理 ----------
|
| 454 |
ACCOUNTS_FILE = "accounts.json"
|
|
@@ -708,41 +739,51 @@ async def upload_context_file(session_name: str, mime_type: str, base64_content:
|
|
| 708 |
|
| 709 |
# ---------- 消息处理逻辑 ----------
|
| 710 |
def get_conversation_key(messages: List[dict]) -> str:
|
| 711 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 712 |
if not messages:
|
| 713 |
return "empty"
|
| 714 |
|
| 715 |
-
#
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
|
|
|
| 719 |
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
|
|
|
|
|
|
|
|
|
|
| 723 |
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
text = "".join([x.get("text", "") for x in content if x.get("type") == "text"])
|
| 727 |
-
else:
|
| 728 |
-
text = str(content)
|
| 729 |
|
| 730 |
-
|
| 731 |
-
|
| 732 |
|
| 733 |
-
#
|
| 734 |
-
|
|
|
|
| 735 |
|
| 736 |
-
def parse_last_message(messages: List['Message']):
|
| 737 |
-
"""
|
| 738 |
if not messages:
|
| 739 |
return "", []
|
| 740 |
-
|
| 741 |
last_msg = messages[-1]
|
| 742 |
content = last_msg.content
|
| 743 |
-
|
| 744 |
text_content = ""
|
| 745 |
images = [] # List of {"mime": str, "data": str_base64}
|
|
|
|
| 746 |
|
| 747 |
if isinstance(content, str):
|
| 748 |
text_content = content
|
|
@@ -756,8 +797,29 @@ def parse_last_message(messages: List['Message']):
|
|
| 756 |
match = re.match(r"data:(image/[^;]+);base64,(.+)", url)
|
| 757 |
if match:
|
| 758 |
images.append({"mime": match.group(1), "data": match.group(2)})
|
|
|
|
|
|
|
| 759 |
else:
|
| 760 |
-
logger.warning(f"[FILE] 不支持的图片格式: {url[:30]}...")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 761 |
|
| 762 |
return text_content, images
|
| 763 |
|
|
@@ -782,6 +844,43 @@ def build_full_context_text(messages: List['Message']) -> str:
|
|
| 782 |
# ---------- OpenAI 兼容接口 ----------
|
| 783 |
app = FastAPI(title="Gemini-Business OpenAI Gateway")
|
| 784 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 785 |
# ---------- 图片静态服务初始化 ----------
|
| 786 |
os.makedirs(IMAGE_DIR, exist_ok=True)
|
| 787 |
app.mount("/images", StaticFiles(directory=IMAGE_DIR), name="images")
|
|
@@ -794,10 +893,20 @@ else:
|
|
| 794 |
@app.on_event("startup")
|
| 795 |
async def startup_event():
|
| 796 |
"""应用启动时初始化后台任务"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 797 |
# 启动缓存清理任务
|
| 798 |
asyncio.create_task(multi_account_mgr.start_background_cleanup())
|
| 799 |
logger.info("[SYSTEM] 后台缓存清理任务已启动(间隔: 5分钟)")
|
| 800 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 801 |
# ---------- 导入模板模块 ----------
|
| 802 |
# 注意:必须在所有全局变量初始化之后导入,避免循环依赖
|
| 803 |
from core import templates
|
|
@@ -1335,10 +1444,10 @@ async def chat(
|
|
| 1335 |
request_id = str(uuid.uuid4())[:6]
|
| 1336 |
|
| 1337 |
# 记录请求统计
|
| 1338 |
-
with stats_lock:
|
| 1339 |
global_stats["total_requests"] += 1
|
| 1340 |
global_stats["request_timestamps"].append(time.time())
|
| 1341 |
-
save_stats(global_stats)
|
| 1342 |
|
| 1343 |
# 2. 模型校验
|
| 1344 |
if req.model not in MODEL_MAPPING:
|
|
@@ -1348,45 +1457,56 @@ async def chat(
|
|
| 1348 |
detail=f"Model '{req.model}' not found. Available models: {list(MODEL_MAPPING.keys())}"
|
| 1349 |
)
|
| 1350 |
|
| 1351 |
-
#
|
| 1352 |
-
|
| 1353 |
-
cached_session = multi_account_mgr.global_session_cache.get(conv_key)
|
| 1354 |
-
|
| 1355 |
-
if cached_session:
|
| 1356 |
-
# 使用已绑定的账户
|
| 1357 |
-
account_id = cached_session["account_id"]
|
| 1358 |
-
account_manager = await multi_account_mgr.get_account(account_id, request_id)
|
| 1359 |
-
google_session = cached_session["session_id"]
|
| 1360 |
-
is_new_conversation = False
|
| 1361 |
-
logger.info(f"[CHAT] [{account_id}] [req_{request_id}] 继续会话: {google_session[-12:]}")
|
| 1362 |
-
else:
|
| 1363 |
-
# 新对话:轮询选择可用账户,失败时尝试其他账户
|
| 1364 |
-
max_account_tries = min(MAX_NEW_SESSION_TRIES, len(multi_account_mgr.accounts))
|
| 1365 |
-
last_error = None
|
| 1366 |
|
| 1367 |
-
|
| 1368 |
-
|
| 1369 |
-
|
| 1370 |
-
|
| 1371 |
-
|
| 1372 |
-
|
| 1373 |
-
|
| 1374 |
-
|
| 1375 |
-
|
| 1376 |
-
|
| 1377 |
-
|
| 1378 |
-
|
| 1379 |
-
|
| 1380 |
-
|
| 1381 |
-
|
| 1382 |
-
|
| 1383 |
-
|
| 1384 |
-
|
| 1385 |
-
|
| 1386 |
-
|
| 1387 |
-
|
| 1388 |
-
|
| 1389 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1390 |
|
| 1391 |
# 提取用户消息内容用于日志
|
| 1392 |
if req.messages:
|
|
@@ -1409,7 +1529,7 @@ async def chat(
|
|
| 1409 |
logger.info(f"[CHAT] [{account_manager.config.account_id}] [req_{request_id}] 用户消息: {preview}")
|
| 1410 |
|
| 1411 |
# 3. 解析请求内容
|
| 1412 |
-
last_text, current_images = parse_last_message(req.messages)
|
| 1413 |
|
| 1414 |
# 4. 准备文本内容
|
| 1415 |
if is_new_conversation:
|
|
@@ -1493,11 +1613,11 @@ async def chat(
|
|
| 1493 |
account_manager.conversation_count += 1 # 增加对话次数
|
| 1494 |
|
| 1495 |
# 保存对话次数到统计数据
|
| 1496 |
-
with stats_lock:
|
| 1497 |
if "account_conversations" not in global_stats:
|
| 1498 |
global_stats["account_conversations"] = {}
|
| 1499 |
global_stats["account_conversations"][account_manager.config.account_id] = account_manager.conversation_count
|
| 1500 |
-
save_stats(global_stats)
|
| 1501 |
|
| 1502 |
break
|
| 1503 |
|
|
@@ -1715,25 +1835,66 @@ def build_image_download_url(session_name: str, file_id: str) -> str:
|
|
| 1715 |
return f"https://biz-discoveryengine.googleapis.com/v1alpha/{session_name}:downloadFile?fileId={file_id}&alt=media"
|
| 1716 |
|
| 1717 |
|
| 1718 |
-
async def download_image_with_jwt(account_mgr: AccountManager, session_name: str, file_id: str, request_id: str = "") -> bytes:
|
| 1719 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1720 |
url = build_image_download_url(session_name, file_id)
|
| 1721 |
-
logger.info(f"[IMAGE] [
|
| 1722 |
-
logger.info(f"[IMAGE] [DEBUG] Session完整路径: {session_name}")
|
| 1723 |
-
jwt = await account_mgr.get_jwt(request_id)
|
| 1724 |
-
headers = get_common_headers(jwt)
|
| 1725 |
|
| 1726 |
-
|
| 1727 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1728 |
|
| 1729 |
-
|
| 1730 |
-
|
| 1731 |
-
|
| 1732 |
-
|
| 1733 |
-
|
|
|
|
| 1734 |
|
| 1735 |
-
resp.raise_for_status()
|
| 1736 |
-
return resp.content
|
| 1737 |
|
| 1738 |
|
| 1739 |
def save_image_to_hf(image_data: bytes, chat_id: str, file_id: str, mime_type: str, base_url: str) -> str:
|
|
@@ -1845,28 +2006,44 @@ async def stream_chat_generator(session: str, text_content: str, file_ids: List[
|
|
| 1845 |
|
| 1846 |
# 获取文件元数据,找到正确的session路径
|
| 1847 |
file_metadata = await get_session_file_metadata(account_manager, session_name, request_id)
|
| 1848 |
-
logger.info(f"[IMAGE] [
|
| 1849 |
|
| 1850 |
-
|
| 1851 |
-
|
| 1852 |
-
|
| 1853 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1854 |
|
| 1855 |
-
|
| 1856 |
-
|
| 1857 |
-
|
| 1858 |
-
logger.info(f"[IMAGE] [DEBUG] 文件{fid}使用session: {correct_session}")
|
| 1859 |
|
| 1860 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1861 |
image_url = save_image_to_hf(image_data, chat_id, fid, mime, base_url)
|
| 1862 |
-
logger.info(f"[IMAGE] [{account_manager.config.account_id}] [req_{request_id}]
|
| 1863 |
|
| 1864 |
# 返回Markdown格式图片
|
| 1865 |
markdown = f"\n\n\n\n"
|
| 1866 |
chunk = create_chunk(chat_id, created_time, model_name, {"content": markdown}, None)
|
| 1867 |
yield f"data: {chunk}\n\n"
|
| 1868 |
except Exception as e:
|
| 1869 |
-
logger.error(f"[IMAGE] [{account_manager.config.account_id}] [req_{request_id}]
|
|
|
|
| 1870 |
|
| 1871 |
except Exception as e:
|
| 1872 |
logger.error(f"[IMAGE] [{account_manager.config.account_id}] [req_{request_id}] 图片处理失败: {str(e)}")
|
|
@@ -1887,10 +2064,22 @@ async def stream_chat_generator(session: str, text_content: str, file_ids: List[
|
|
| 1887 |
yield "data: [DONE]\n\n"
|
| 1888 |
|
| 1889 |
# ---------- 公开端点(无需认证) ----------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1890 |
@app.get("/public/stats")
|
| 1891 |
async def get_public_stats():
|
| 1892 |
"""获取公开统计信息"""
|
| 1893 |
-
with stats_lock:
|
| 1894 |
# 清理1小时前的请求时间戳
|
| 1895 |
current_time = time.time()
|
| 1896 |
global_stats["request_timestamps"] = [
|
|
@@ -1927,41 +2116,45 @@ async def get_public_stats():
|
|
| 1927 |
@app.get("/public/log")
|
| 1928 |
async def get_public_logs(request: Request, limit: int = 100):
|
| 1929 |
"""获取脱敏后的日志(JSON格式)"""
|
| 1930 |
-
|
| 1931 |
-
|
| 1932 |
-
|
| 1933 |
-
|
| 1934 |
-
|
| 1935 |
-
|
| 1936 |
-
|
| 1937 |
-
|
| 1938 |
-
|
|
|
|
| 1939 |
|
| 1940 |
-
|
| 1941 |
|
| 1942 |
-
|
| 1943 |
-
|
| 1944 |
-
|
| 1945 |
-
|
| 1946 |
|
| 1947 |
-
|
| 1948 |
-
|
| 1949 |
-
|
| 1950 |
-
|
| 1951 |
-
|
| 1952 |
-
|
| 1953 |
|
| 1954 |
-
|
| 1955 |
-
|
| 1956 |
-
|
| 1957 |
-
|
| 1958 |
-
|
| 1959 |
|
| 1960 |
-
|
| 1961 |
-
|
| 1962 |
-
|
| 1963 |
-
|
| 1964 |
-
|
|
|
|
|
|
|
|
|
|
| 1965 |
|
| 1966 |
@app.get("/public/log/html")
|
| 1967 |
async def get_public_logs_html():
|
|
|
|
| 6 |
from dotenv import load_dotenv
|
| 7 |
|
| 8 |
import httpx
|
| 9 |
+
import aiofiles
|
| 10 |
from fastapi import FastAPI, HTTPException, Header, Request, Body
|
| 11 |
from fastapi.responses import StreamingResponse, HTMLResponse, JSONResponse
|
| 12 |
from fastapi.staticfiles import StaticFiles
|
|
|
|
| 19 |
# 导入认证装饰器
|
| 20 |
from core.auth import require_path_prefix, require_admin_auth, require_path_and_admin
|
| 21 |
|
| 22 |
+
# 导入 Uptime 追踪器
|
| 23 |
+
import uptime_tracker
|
| 24 |
+
|
| 25 |
# ---------- 日志配置 ----------
|
| 26 |
|
| 27 |
# 内存日志缓冲区 (保留最近 3000 条日志,重启后清空)
|
|
|
|
| 30 |
|
| 31 |
# 统计数据持久化
|
| 32 |
STATS_FILE = "stats.json"
|
| 33 |
+
stats_lock = asyncio.Lock() # 改为异步锁
|
| 34 |
|
| 35 |
+
async def load_stats():
|
| 36 |
+
"""加载统计数据(异步)"""
|
| 37 |
try:
|
| 38 |
if os.path.exists(STATS_FILE):
|
| 39 |
+
async with aiofiles.open(STATS_FILE, 'r', encoding='utf-8') as f:
|
| 40 |
+
content = await f.read()
|
| 41 |
+
return json.loads(content)
|
| 42 |
except Exception:
|
| 43 |
pass
|
| 44 |
return {
|
|
|
|
| 49 |
"account_conversations": {} # {account_id: conversation_count} 账户对话次数
|
| 50 |
}
|
| 51 |
|
| 52 |
+
async def save_stats(stats):
|
| 53 |
+
"""保存统计数据(异步,避免阻塞事件循环)"""
|
| 54 |
try:
|
| 55 |
+
async with aiofiles.open(STATS_FILE, 'w', encoding='utf-8') as f:
|
| 56 |
+
await f.write(json.dumps(stats, ensure_ascii=False, indent=2))
|
| 57 |
except Exception as e:
|
| 58 |
logger.error(f"[STATS] 保存统计数据失败: {str(e)[:50]}")
|
| 59 |
|
| 60 |
+
# 初始化统计数据(需要在启动时异步加载)
|
| 61 |
+
global_stats = {
|
| 62 |
+
"total_visitors": 0,
|
| 63 |
+
"total_requests": 0,
|
| 64 |
+
"request_timestamps": [],
|
| 65 |
+
"visitor_ips": {},
|
| 66 |
+
"account_conversations": {}
|
| 67 |
+
}
|
| 68 |
|
| 69 |
class MemoryLogHandler(logging.Handler):
|
| 70 |
"""自定义日志处理器,将日志写入内存缓冲区"""
|
|
|
|
| 138 |
verify=False,
|
| 139 |
http2=False,
|
| 140 |
timeout=httpx.Timeout(TIMEOUT_SECONDS, connect=60.0),
|
| 141 |
+
limits=httpx.Limits(
|
| 142 |
+
max_keepalive_connections=100, # 增加5倍:20 -> 100
|
| 143 |
+
max_connections=200 # 增加4倍:50 -> 200
|
| 144 |
+
)
|
| 145 |
)
|
| 146 |
|
| 147 |
# ---------- 工具函数 ----------
|
|
|
|
| 354 |
self.accounts: Dict[str, AccountManager] = {}
|
| 355 |
self.account_list: List[str] = [] # 账户ID列表 (用于轮询)
|
| 356 |
self.current_index = 0
|
| 357 |
+
self._cache_lock = asyncio.Lock() # 缓存操作专用锁
|
| 358 |
+
self._index_lock = asyncio.Lock() # 索引更新专用锁
|
| 359 |
# 全局会话缓存:{conv_key: {"account_id": str, "session_id": str, "updated_at": float}}
|
| 360 |
self.global_session_cache: Dict[str, dict] = {}
|
| 361 |
self.cache_max_size = 1000 # 最大缓存条目数
|
| 362 |
self.cache_ttl = SESSION_CACHE_TTL_SECONDS # 缓存过期时间(秒)
|
| 363 |
+
# Session级别锁:防止同一对话的并发请求冲突
|
| 364 |
+
self._session_locks: Dict[str, asyncio.Lock] = {}
|
| 365 |
+
self._session_locks_lock = asyncio.Lock() # 保护锁字典的锁
|
| 366 |
+
self._session_locks_max_size = 2000 # 最大锁数量
|
| 367 |
|
| 368 |
def _clean_expired_cache(self):
|
| 369 |
"""清理过期的缓存条目"""
|
|
|
|
| 395 |
try:
|
| 396 |
while True:
|
| 397 |
await asyncio.sleep(300) # 5分钟
|
| 398 |
+
async with self._cache_lock:
|
| 399 |
self._clean_expired_cache()
|
| 400 |
self._ensure_cache_size()
|
| 401 |
except asyncio.CancelledError:
|
|
|
|
| 405 |
|
| 406 |
async def set_session_cache(self, conv_key: str, account_id: str, session_id: str):
|
| 407 |
"""线程安全地设置会话缓存"""
|
| 408 |
+
async with self._cache_lock:
|
| 409 |
self.global_session_cache[conv_key] = {
|
| 410 |
"account_id": account_id,
|
| 411 |
"session_id": session_id,
|
|
|
|
| 416 |
|
| 417 |
async def update_session_time(self, conv_key: str):
|
| 418 |
"""线程安全地更新会话时间戳"""
|
| 419 |
+
async with self._cache_lock:
|
| 420 |
if conv_key in self.global_session_cache:
|
| 421 |
self.global_session_cache[conv_key]["updated_at"] = time.time()
|
| 422 |
|
| 423 |
+
async def acquire_session_lock(self, conv_key: str) -> asyncio.Lock:
|
| 424 |
+
"""获取指定对话的锁(用于防止同一对话的并发请求冲突)"""
|
| 425 |
+
async with self._session_locks_lock:
|
| 426 |
+
# 清理过多的锁(LRU策略:删除不在缓存中的锁)
|
| 427 |
+
if len(self._session_locks) > self._session_locks_max_size:
|
| 428 |
+
# 只保留当前缓存中存在的锁
|
| 429 |
+
valid_keys = set(self.global_session_cache.keys())
|
| 430 |
+
keys_to_remove = [k for k in self._session_locks if k not in valid_keys]
|
| 431 |
+
for k in keys_to_remove[:len(keys_to_remove)//2]: # 删除一半无效锁
|
| 432 |
+
del self._session_locks[k]
|
| 433 |
+
|
| 434 |
+
if conv_key not in self._session_locks:
|
| 435 |
+
self._session_locks[conv_key] = asyncio.Lock()
|
| 436 |
+
return self._session_locks[conv_key]
|
| 437 |
+
|
| 438 |
def add_account(self, config: AccountConfig):
|
| 439 |
"""添加账户"""
|
| 440 |
manager = AccountManager(config)
|
|
|
|
| 446 |
logger.info(f"[MULTI] [ACCOUNT] 添加账户: {config.account_id}")
|
| 447 |
|
| 448 |
async def get_account(self, account_id: Optional[str] = None, request_id: str = "") -> AccountManager:
|
| 449 |
+
"""获取账户 (轮询或指定) - 优化锁粒度,减少竞争"""
|
| 450 |
+
req_tag = f"[req_{request_id}] " if request_id else ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 451 |
|
| 452 |
+
# 如果指定了账户ID(无需锁)
|
| 453 |
+
if account_id:
|
| 454 |
+
if account_id not in self.accounts:
|
| 455 |
+
raise HTTPException(404, f"Account {account_id} not found")
|
| 456 |
+
account = self.accounts[account_id]
|
| 457 |
+
if not account.should_retry():
|
| 458 |
+
raise HTTPException(503, f"Account {account_id} temporarily unavailable")
|
| 459 |
+
return account
|
| 460 |
|
| 461 |
+
# 轮询选择可用账户(无锁读取账户列表)
|
| 462 |
+
available_accounts = [
|
| 463 |
+
acc_id for acc_id in self.account_list
|
| 464 |
+
if self.accounts[acc_id].should_retry()
|
| 465 |
+
and not self.accounts[acc_id].config.is_expired()
|
| 466 |
+
and not self.accounts[acc_id].config.disabled
|
| 467 |
+
]
|
| 468 |
+
|
| 469 |
+
if not available_accounts:
|
| 470 |
+
raise HTTPException(503, "No available accounts")
|
| 471 |
+
|
| 472 |
+
# 只在更新索引时加锁(最小化锁持有时间)
|
| 473 |
+
async with self._index_lock:
|
| 474 |
if not hasattr(self, '_available_index'):
|
| 475 |
self._available_index = 0
|
| 476 |
|
| 477 |
account_id = available_accounts[self._available_index % len(available_accounts)]
|
| 478 |
self._available_index = (self._available_index + 1) % len(available_accounts)
|
| 479 |
|
| 480 |
+
account = self.accounts[account_id]
|
| 481 |
+
logger.info(f"[MULTI] [ACCOUNT] {req_tag}选择账户: {account_id}")
|
| 482 |
+
return account
|
| 483 |
|
| 484 |
# ---------- 配置文件管理 ----------
|
| 485 |
ACCOUNTS_FILE = "accounts.json"
|
|
|
|
| 739 |
|
| 740 |
# ---------- 消息处理逻辑 ----------
|
| 741 |
def get_conversation_key(messages: List[dict]) -> str:
|
| 742 |
+
"""
|
| 743 |
+
生成对话指纹(使用前3条消息,平衡唯一性和Session复用)
|
| 744 |
+
|
| 745 |
+
策略:
|
| 746 |
+
1. 使用前3条消息生成指纹(而非仅第1条)
|
| 747 |
+
2. 大幅降低不同用户共享Session的概率
|
| 748 |
+
3. 保持Session复用能力(后续消息仍能找到同一Session)
|
| 749 |
+
"""
|
| 750 |
if not messages:
|
| 751 |
return "empty"
|
| 752 |
|
| 753 |
+
# 提取前3条消息的关键信息(角色+内容)
|
| 754 |
+
message_fingerprints = []
|
| 755 |
+
for msg in messages[:3]: # 只取前3条
|
| 756 |
+
role = msg.get("role", "")
|
| 757 |
+
content = msg.get("content", "")
|
| 758 |
|
| 759 |
+
# 统一处理内容格式(字符串或数组)
|
| 760 |
+
if isinstance(content, list):
|
| 761 |
+
# 多模态消息:只提取文本部分
|
| 762 |
+
text = "".join([x.get("text", "") for x in content if x.get("type") == "text"])
|
| 763 |
+
else:
|
| 764 |
+
text = str(content)
|
| 765 |
|
| 766 |
+
# 标准化:去除首尾空白,转小写
|
| 767 |
+
text = text.strip().lower()
|
|
|
|
|
|
|
|
|
|
| 768 |
|
| 769 |
+
# 组合角色和内容
|
| 770 |
+
message_fingerprints.append(f"{role}:{text}")
|
| 771 |
|
| 772 |
+
# 使用前3条消息生成指纹
|
| 773 |
+
conversation_prefix = "|".join(message_fingerprints)
|
| 774 |
+
return hashlib.md5(conversation_prefix.encode()).hexdigest()
|
| 775 |
|
| 776 |
+
async def parse_last_message(messages: List['Message'], request_id: str = ""):
|
| 777 |
+
"""解析最后一条消息,分离文本和图片(支持 base64 和 URL)"""
|
| 778 |
if not messages:
|
| 779 |
return "", []
|
| 780 |
+
|
| 781 |
last_msg = messages[-1]
|
| 782 |
content = last_msg.content
|
| 783 |
+
|
| 784 |
text_content = ""
|
| 785 |
images = [] # List of {"mime": str, "data": str_base64}
|
| 786 |
+
image_urls = [] # 需要下载的 URL
|
| 787 |
|
| 788 |
if isinstance(content, str):
|
| 789 |
text_content = content
|
|
|
|
| 797 |
match = re.match(r"data:(image/[^;]+);base64,(.+)", url)
|
| 798 |
if match:
|
| 799 |
images.append({"mime": match.group(1), "data": match.group(2)})
|
| 800 |
+
elif url.startswith(("http://", "https://")):
|
| 801 |
+
image_urls.append(url)
|
| 802 |
else:
|
| 803 |
+
logger.warning(f"[FILE] [req_{request_id}] 不支持的图片格式: {url[:30]}...")
|
| 804 |
+
|
| 805 |
+
# 并行下载所有 URL 图片
|
| 806 |
+
if image_urls:
|
| 807 |
+
async def download_url(url: str):
|
| 808 |
+
try:
|
| 809 |
+
resp = await http_client.get(url, timeout=30, follow_redirects=True)
|
| 810 |
+
resp.raise_for_status()
|
| 811 |
+
content_type = resp.headers.get("content-type", "image/jpeg").split(";")[0]
|
| 812 |
+
if not content_type.startswith("image/"):
|
| 813 |
+
content_type = "image/jpeg"
|
| 814 |
+
b64 = base64.b64encode(resp.content).decode()
|
| 815 |
+
logger.info(f"[FILE] [req_{request_id}] URL图片下载成功: {url[:50]}... ({len(resp.content)} bytes)")
|
| 816 |
+
return {"mime": content_type, "data": b64}
|
| 817 |
+
except Exception as e:
|
| 818 |
+
logger.warning(f"[FILE] [req_{request_id}] URL图片下载失败: {url[:50]}... - {e}")
|
| 819 |
+
return None
|
| 820 |
+
|
| 821 |
+
results = await asyncio.gather(*[download_url(u) for u in image_urls])
|
| 822 |
+
images.extend([r for r in results if r])
|
| 823 |
|
| 824 |
return text_content, images
|
| 825 |
|
|
|
|
| 844 |
# ---------- OpenAI 兼容接口 ----------
|
| 845 |
app = FastAPI(title="Gemini-Business OpenAI Gateway")
|
| 846 |
|
| 847 |
+
# ---------- Uptime 追踪中间件 ----------
|
| 848 |
+
@app.middleware("http")
|
| 849 |
+
async def track_uptime_middleware(request: Request, call_next):
|
| 850 |
+
"""追踪每个请求的成功/失败状态,用于 Uptime 监控"""
|
| 851 |
+
# 只追踪 API 请求(排除静态文件、管理端点等)
|
| 852 |
+
path = request.url.path
|
| 853 |
+
if path.startswith("/images/") or path.startswith("/public/") or path.startswith("/favicon"):
|
| 854 |
+
return await call_next(request)
|
| 855 |
+
|
| 856 |
+
start_time = time.time()
|
| 857 |
+
success = False
|
| 858 |
+
model = None
|
| 859 |
+
|
| 860 |
+
try:
|
| 861 |
+
response = await call_next(request)
|
| 862 |
+
success = response.status_code < 400
|
| 863 |
+
|
| 864 |
+
# 尝试从请求中提取模型信息
|
| 865 |
+
if hasattr(request.state, "model"):
|
| 866 |
+
model = request.state.model
|
| 867 |
+
|
| 868 |
+
# 记录 API 主服务状态
|
| 869 |
+
uptime_tracker.record_request("api_service", success)
|
| 870 |
+
|
| 871 |
+
# 如果有模型信息,记录模型状态
|
| 872 |
+
if model and model in uptime_tracker.SUPPORTED_MODELS:
|
| 873 |
+
uptime_tracker.record_request(model, success)
|
| 874 |
+
|
| 875 |
+
return response
|
| 876 |
+
|
| 877 |
+
except Exception as e:
|
| 878 |
+
# 请求失败
|
| 879 |
+
uptime_tracker.record_request("api_service", False)
|
| 880 |
+
if model and model in uptime_tracker.SUPPORTED_MODELS:
|
| 881 |
+
uptime_tracker.record_request(model, False)
|
| 882 |
+
raise
|
| 883 |
+
|
| 884 |
# ---------- 图片静态服务初始化 ----------
|
| 885 |
os.makedirs(IMAGE_DIR, exist_ok=True)
|
| 886 |
app.mount("/images", StaticFiles(directory=IMAGE_DIR), name="images")
|
|
|
|
| 893 |
@app.on_event("startup")
|
| 894 |
async def startup_event():
|
| 895 |
"""应用启动时初始化后台任务"""
|
| 896 |
+
global global_stats
|
| 897 |
+
|
| 898 |
+
# 加载统计数据
|
| 899 |
+
global_stats = await load_stats()
|
| 900 |
+
logger.info(f"[SYSTEM] 统计数据已加载: {global_stats['total_requests']} 次请求, {global_stats['total_visitors']} 位访客")
|
| 901 |
+
|
| 902 |
# 启动缓存清理任务
|
| 903 |
asyncio.create_task(multi_account_mgr.start_background_cleanup())
|
| 904 |
logger.info("[SYSTEM] 后台缓存清理任务已启动(间隔: 5分钟)")
|
| 905 |
|
| 906 |
+
# 启动 Uptime 数据聚合任务
|
| 907 |
+
asyncio.create_task(uptime_tracker.uptime_aggregation_task())
|
| 908 |
+
logger.info("[SYSTEM] Uptime 数据聚合任务已启动(间隔: 240秒)")
|
| 909 |
+
|
| 910 |
# ---------- 导入模板模块 ----------
|
| 911 |
# 注意:必须在所有全局变量初始化之后导入,避免循环依赖
|
| 912 |
from core import templates
|
|
|
|
| 1444 |
request_id = str(uuid.uuid4())[:6]
|
| 1445 |
|
| 1446 |
# 记录请求统计
|
| 1447 |
+
async with stats_lock:
|
| 1448 |
global_stats["total_requests"] += 1
|
| 1449 |
global_stats["request_timestamps"].append(time.time())
|
| 1450 |
+
await save_stats(global_stats)
|
| 1451 |
|
| 1452 |
# 2. 模型校验
|
| 1453 |
if req.model not in MODEL_MAPPING:
|
|
|
|
| 1457 |
detail=f"Model '{req.model}' not found. Available models: {list(MODEL_MAPPING.keys())}"
|
| 1458 |
)
|
| 1459 |
|
| 1460 |
+
# 保存模型信息到 request.state(用于 Uptime 追踪)
|
| 1461 |
+
request.state.model = req.model
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1462 |
|
| 1463 |
+
# 3. 生成会话指纹,获取Session锁(防止同一对话的并发请求冲突)
|
| 1464 |
+
conv_key = get_conversation_key([m.dict() for m in req.messages])
|
| 1465 |
+
session_lock = await multi_account_mgr.acquire_session_lock(conv_key)
|
| 1466 |
+
|
| 1467 |
+
# 4. 在锁的保护下检查缓存和处理Session(保证同一对话的请求串行化)
|
| 1468 |
+
async with session_lock:
|
| 1469 |
+
cached_session = multi_account_mgr.global_session_cache.get(conv_key)
|
| 1470 |
+
|
| 1471 |
+
if cached_session:
|
| 1472 |
+
# 使用已绑定的账户
|
| 1473 |
+
account_id = cached_session["account_id"]
|
| 1474 |
+
account_manager = await multi_account_mgr.get_account(account_id, request_id)
|
| 1475 |
+
google_session = cached_session["session_id"]
|
| 1476 |
+
is_new_conversation = False
|
| 1477 |
+
logger.info(f"[CHAT] [{account_id}] [req_{request_id}] 继续会话: {google_session[-12:]}")
|
| 1478 |
+
else:
|
| 1479 |
+
# 新对话:轮询选择可用账户,失败时尝试其他账户
|
| 1480 |
+
max_account_tries = min(MAX_NEW_SESSION_TRIES, len(multi_account_mgr.accounts))
|
| 1481 |
+
last_error = None
|
| 1482 |
+
|
| 1483 |
+
for attempt in range(max_account_tries):
|
| 1484 |
+
try:
|
| 1485 |
+
account_manager = await multi_account_mgr.get_account(None, request_id)
|
| 1486 |
+
google_session = await create_google_session(account_manager, request_id)
|
| 1487 |
+
# 线程安全地绑定账户到此对话
|
| 1488 |
+
await multi_account_mgr.set_session_cache(
|
| 1489 |
+
conv_key,
|
| 1490 |
+
account_manager.config.account_id,
|
| 1491 |
+
google_session
|
| 1492 |
+
)
|
| 1493 |
+
is_new_conversation = True
|
| 1494 |
+
logger.info(f"[CHAT] [{account_manager.config.account_id}] [req_{request_id}] 新会话创建并绑定账户")
|
| 1495 |
+
# 记录服务状态(账户可用)
|
| 1496 |
+
uptime_tracker.record_request("service_status", True)
|
| 1497 |
+
break
|
| 1498 |
+
except Exception as e:
|
| 1499 |
+
last_error = e
|
| 1500 |
+
error_type = type(e).__name__
|
| 1501 |
+
# 安全获取账户ID
|
| 1502 |
+
account_id = account_manager.config.account_id if 'account_manager' in locals() and account_manager else 'unknown'
|
| 1503 |
+
logger.error(f"[CHAT] [req_{request_id}] 账户 {account_id} 创建会话失败 (尝试 {attempt + 1}/{max_account_tries}) - {error_type}: {str(e)}")
|
| 1504 |
+
if attempt == max_account_tries - 1:
|
| 1505 |
+
logger.error(f"[CHAT] [req_{request_id}] 所有账户均不可用")
|
| 1506 |
+
# 记录服务状态(账户不可用)
|
| 1507 |
+
uptime_tracker.record_request("service_status", False)
|
| 1508 |
+
raise HTTPException(503, f"All accounts unavailable: {str(last_error)[:100]}")
|
| 1509 |
+
# 继续尝试下一个账户
|
| 1510 |
|
| 1511 |
# 提取用户消息内容用于日志
|
| 1512 |
if req.messages:
|
|
|
|
| 1529 |
logger.info(f"[CHAT] [{account_manager.config.account_id}] [req_{request_id}] 用户消息: {preview}")
|
| 1530 |
|
| 1531 |
# 3. 解析请求内容
|
| 1532 |
+
last_text, current_images = await parse_last_message(req.messages, request_id)
|
| 1533 |
|
| 1534 |
# 4. 准备文本内容
|
| 1535 |
if is_new_conversation:
|
|
|
|
| 1613 |
account_manager.conversation_count += 1 # 增加对话次数
|
| 1614 |
|
| 1615 |
# 保存对话次数到统计数据
|
| 1616 |
+
async with stats_lock:
|
| 1617 |
if "account_conversations" not in global_stats:
|
| 1618 |
global_stats["account_conversations"] = {}
|
| 1619 |
global_stats["account_conversations"][account_manager.config.account_id] = account_manager.conversation_count
|
| 1620 |
+
await save_stats(global_stats)
|
| 1621 |
|
| 1622 |
break
|
| 1623 |
|
|
|
|
| 1835 |
return f"https://biz-discoveryengine.googleapis.com/v1alpha/{session_name}:downloadFile?fileId={file_id}&alt=media"
|
| 1836 |
|
| 1837 |
|
| 1838 |
+
async def download_image_with_jwt(account_mgr: AccountManager, session_name: str, file_id: str, request_id: str = "", max_retries: int = 3) -> bytes:
|
| 1839 |
+
"""
|
| 1840 |
+
使用JWT认证下载图片(带超时和重试机制)
|
| 1841 |
+
|
| 1842 |
+
Args:
|
| 1843 |
+
account_mgr: 账户管理器
|
| 1844 |
+
session_name: Session名称
|
| 1845 |
+
file_id: 文件ID
|
| 1846 |
+
request_id: 请求ID
|
| 1847 |
+
max_retries: 最大重试次数(默认3次)
|
| 1848 |
+
|
| 1849 |
+
Returns:
|
| 1850 |
+
图片字节数据
|
| 1851 |
+
|
| 1852 |
+
Raises:
|
| 1853 |
+
HTTPException: 下载失败
|
| 1854 |
+
asyncio.TimeoutError: 超时
|
| 1855 |
+
"""
|
| 1856 |
url = build_image_download_url(session_name, file_id)
|
| 1857 |
+
logger.info(f"[IMAGE] [{account_mgr.config.account_id}] [req_{request_id}] 开始下载图片: {file_id[:8]}...")
|
|
|
|
|
|
|
|
|
|
| 1858 |
|
| 1859 |
+
for attempt in range(max_retries):
|
| 1860 |
+
try:
|
| 1861 |
+
# 3分钟超时(180秒)
|
| 1862 |
+
async with asyncio.timeout(180):
|
| 1863 |
+
jwt = await account_mgr.get_jwt(request_id)
|
| 1864 |
+
headers = get_common_headers(jwt)
|
| 1865 |
+
|
| 1866 |
+
# 复用全局http_client
|
| 1867 |
+
resp = await http_client.get(url, headers=headers, follow_redirects=True)
|
| 1868 |
+
|
| 1869 |
+
if resp.status_code == 401:
|
| 1870 |
+
# JWT过期,刷新后重试
|
| 1871 |
+
jwt = await account_mgr.get_jwt(request_id)
|
| 1872 |
+
headers = get_common_headers(jwt)
|
| 1873 |
+
resp = await http_client.get(url, headers=headers, follow_redirects=True)
|
| 1874 |
+
|
| 1875 |
+
resp.raise_for_status()
|
| 1876 |
+
logger.info(f"[IMAGE] [{account_mgr.config.account_id}] [req_{request_id}] 图片下载成功: {file_id[:8]}... ({len(resp.content)} bytes)")
|
| 1877 |
+
return resp.content
|
| 1878 |
+
|
| 1879 |
+
except asyncio.TimeoutError:
|
| 1880 |
+
logger.warning(f"[IMAGE] [{account_mgr.config.account_id}] [req_{request_id}] 图片下载超时 (尝试 {attempt + 1}/{max_retries}): {file_id[:8]}...")
|
| 1881 |
+
if attempt == max_retries - 1:
|
| 1882 |
+
raise HTTPException(504, f"Image download timeout after {max_retries} attempts")
|
| 1883 |
+
await asyncio.sleep(2 ** attempt) # 指数退避:2s, 4s, 8s
|
| 1884 |
+
|
| 1885 |
+
except httpx.HTTPError as e:
|
| 1886 |
+
logger.warning(f"[IMAGE] [{account_mgr.config.account_id}] [req_{request_id}] 图片下载失败 (尝试 {attempt + 1}/{max_retries}): {type(e).__name__}")
|
| 1887 |
+
if attempt == max_retries - 1:
|
| 1888 |
+
raise HTTPException(500, f"Image download failed: {str(e)[:100]}")
|
| 1889 |
+
await asyncio.sleep(2 ** attempt) # 指数退避
|
| 1890 |
|
| 1891 |
+
except Exception as e:
|
| 1892 |
+
logger.error(f"[IMAGE] [{account_mgr.config.account_id}] [req_{request_id}] 图片下载异常: {type(e).__name__}: {str(e)[:100]}")
|
| 1893 |
+
raise
|
| 1894 |
+
|
| 1895 |
+
# 不应该到达这里
|
| 1896 |
+
raise HTTPException(500, "Image download failed unexpectedly")
|
| 1897 |
|
|
|
|
|
|
|
| 1898 |
|
| 1899 |
|
| 1900 |
def save_image_to_hf(image_data: bytes, chat_id: str, file_id: str, mime_type: str, base_url: str) -> str:
|
|
|
|
| 2006 |
|
| 2007 |
# 获取文件元数据,找到正确的session路径
|
| 2008 |
file_metadata = await get_session_file_metadata(account_manager, session_name, request_id)
|
| 2009 |
+
logger.info(f"[IMAGE] [{account_manager.config.account_id}] [req_{request_id}] 获取到{len(file_metadata)}个文件元数据")
|
| 2010 |
|
| 2011 |
+
# 并行下载所有图片(提升多图响应速度)
|
| 2012 |
+
download_tasks = []
|
| 2013 |
+
for file_info in file_ids:
|
| 2014 |
+
fid = file_info["fileId"]
|
| 2015 |
+
mime = file_info["mimeType"]
|
| 2016 |
+
|
| 2017 |
+
# 从元数据中获取正确的session路径
|
| 2018 |
+
meta = file_metadata.get(fid, {})
|
| 2019 |
+
correct_session = meta.get("session") or session_name
|
| 2020 |
|
| 2021 |
+
# 创建下载任务
|
| 2022 |
+
task = download_image_with_jwt(account_manager, correct_session, fid, request_id)
|
| 2023 |
+
download_tasks.append((fid, mime, task))
|
|
|
|
| 2024 |
|
| 2025 |
+
# 并行执行所有下载任务
|
| 2026 |
+
results = await asyncio.gather(*[task for _, _, task in download_tasks], return_exceptions=True)
|
| 2027 |
+
|
| 2028 |
+
# 处理下载结果
|
| 2029 |
+
for idx, ((fid, mime, _), result) in enumerate(zip(download_tasks, results), 1):
|
| 2030 |
+
try:
|
| 2031 |
+
if isinstance(result, Exception):
|
| 2032 |
+
logger.error(f"[IMAGE] [{account_manager.config.account_id}] [req_{request_id}] 图片{idx}下载失败: {type(result).__name__}: {str(result)[:100]}")
|
| 2033 |
+
continue
|
| 2034 |
+
|
| 2035 |
+
# 保存图片
|
| 2036 |
+
image_data = result
|
| 2037 |
image_url = save_image_to_hf(image_data, chat_id, fid, mime, base_url)
|
| 2038 |
+
logger.info(f"[IMAGE] [{account_manager.config.account_id}] [req_{request_id}] 图片{idx}已保存: {image_url}")
|
| 2039 |
|
| 2040 |
# 返回Markdown格式图片
|
| 2041 |
markdown = f"\n\n\n\n"
|
| 2042 |
chunk = create_chunk(chat_id, created_time, model_name, {"content": markdown}, None)
|
| 2043 |
yield f"data: {chunk}\n\n"
|
| 2044 |
except Exception as e:
|
| 2045 |
+
logger.error(f"[IMAGE] [{account_manager.config.account_id}] [req_{request_id}] 图片{idx}处理失败: {str(e)}")
|
| 2046 |
+
|
| 2047 |
|
| 2048 |
except Exception as e:
|
| 2049 |
logger.error(f"[IMAGE] [{account_manager.config.account_id}] [req_{request_id}] 图片处理失败: {str(e)}")
|
|
|
|
| 2064 |
yield "data: [DONE]\n\n"
|
| 2065 |
|
| 2066 |
# ---------- 公开端点(无需认证) ----------
|
| 2067 |
+
@app.get("/public/uptime")
|
| 2068 |
+
async def get_public_uptime(days: int = 90):
|
| 2069 |
+
"""获取 Uptime 监控数据(JSON格式)"""
|
| 2070 |
+
if days < 1 or days > 90:
|
| 2071 |
+
days = 90
|
| 2072 |
+
return await uptime_tracker.get_uptime_summary(days)
|
| 2073 |
+
|
| 2074 |
+
@app.get("/public/uptime/html")
|
| 2075 |
+
async def get_public_uptime_html():
|
| 2076 |
+
"""Uptime 监控页面(类似 status.openai.com)"""
|
| 2077 |
+
return await templates.get_uptime_html()
|
| 2078 |
+
|
| 2079 |
@app.get("/public/stats")
|
| 2080 |
async def get_public_stats():
|
| 2081 |
"""获取公开统计信息"""
|
| 2082 |
+
async with stats_lock:
|
| 2083 |
# 清理1小时前的请求时间戳
|
| 2084 |
current_time = time.time()
|
| 2085 |
global_stats["request_timestamps"] = [
|
|
|
|
| 2116 |
@app.get("/public/log")
|
| 2117 |
async def get_public_logs(request: Request, limit: int = 100):
|
| 2118 |
"""获取脱敏后的日志(JSON格式)"""
|
| 2119 |
+
try:
|
| 2120 |
+
# 基于IP的访问统计(24小时内去重)
|
| 2121 |
+
# 优先从 X-Forwarded-For 获取真实IP(处理代理情况)
|
| 2122 |
+
client_ip = request.headers.get("x-forwarded-for")
|
| 2123 |
+
if client_ip:
|
| 2124 |
+
# X-Forwarded-For 可能包含多个IP,取第一个
|
| 2125 |
+
client_ip = client_ip.split(",")[0].strip()
|
| 2126 |
+
else:
|
| 2127 |
+
# 没有代理时使用直连IP
|
| 2128 |
+
client_ip = request.client.host if request.client else "unknown"
|
| 2129 |
|
| 2130 |
+
current_time = time.time()
|
| 2131 |
|
| 2132 |
+
async with stats_lock:
|
| 2133 |
+
# 清理24小时前的IP记录
|
| 2134 |
+
if "visitor_ips" not in global_stats:
|
| 2135 |
+
global_stats["visitor_ips"] = {}
|
| 2136 |
|
| 2137 |
+
expired_ips = [
|
| 2138 |
+
ip for ip, timestamp in global_stats["visitor_ips"].items()
|
| 2139 |
+
if current_time - timestamp > 86400 # 24小时
|
| 2140 |
+
]
|
| 2141 |
+
for ip in expired_ips:
|
| 2142 |
+
del global_stats["visitor_ips"][ip]
|
| 2143 |
|
| 2144 |
+
# 记录新访问(24小时内同一IP只计数一次)
|
| 2145 |
+
if client_ip not in global_stats["visitor_ips"]:
|
| 2146 |
+
global_stats["visitor_ips"][client_ip] = current_time
|
| 2147 |
+
global_stats["total_visitors"] = len(global_stats["visitor_ips"])
|
| 2148 |
+
await save_stats(global_stats)
|
| 2149 |
|
| 2150 |
+
sanitized_logs = get_sanitized_logs(limit=min(limit, 1000))
|
| 2151 |
+
return {
|
| 2152 |
+
"total": len(sanitized_logs),
|
| 2153 |
+
"logs": sanitized_logs
|
| 2154 |
+
}
|
| 2155 |
+
except Exception as e:
|
| 2156 |
+
logger.error(f"[LOG] 获取公开日志失败: {e}")
|
| 2157 |
+
return {"total": 0, "logs": [], "error": str(e)}
|
| 2158 |
|
| 2159 |
@app.get("/public/log/html")
|
| 2160 |
async def get_public_logs_html():
|
requirements.txt
CHANGED
|
@@ -1,4 +1,6 @@
|
|
| 1 |
fastapi==0.110.0
|
| 2 |
uvicorn[standard]==0.29.0
|
| 3 |
httpx==0.27.0
|
| 4 |
-
pydantic==2.7.0
|
|
|
|
|
|
|
|
|
| 1 |
fastapi==0.110.0
|
| 2 |
uvicorn[standard]==0.29.0
|
| 3 |
httpx==0.27.0
|
| 4 |
+
pydantic==2.7.0
|
| 5 |
+
aiofiles==24.1.0
|
| 6 |
+
python-dotenv==1.0.1
|
uptime_tracker.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Uptime 实时监控追踪器
|
| 3 |
+
类似 Uptime Kuma 的心跳监控,显示最近请求状态
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from collections import deque
|
| 7 |
+
from datetime import datetime, timezone, timedelta
|
| 8 |
+
from typing import Dict, List
|
| 9 |
+
|
| 10 |
+
# 北京时区 UTC+8
|
| 11 |
+
BEIJING_TZ = timezone(timedelta(hours=8))
|
| 12 |
+
|
| 13 |
+
# 每个服务保留最近 60 条心跳记录
|
| 14 |
+
MAX_HEARTBEATS = 60
|
| 15 |
+
|
| 16 |
+
# 服务配置
|
| 17 |
+
SERVICES = {
|
| 18 |
+
"api_service": {"name": "API 服务", "heartbeats": deque(maxlen=MAX_HEARTBEATS)},
|
| 19 |
+
"service_status": {"name": "服务资源", "heartbeats": deque(maxlen=MAX_HEARTBEATS)},
|
| 20 |
+
"gemini-2.5-flash": {"name": "Gemini 2.5 Flash", "heartbeats": deque(maxlen=MAX_HEARTBEATS)},
|
| 21 |
+
"gemini-2.5-pro": {"name": "Gemini 2.5 Pro", "heartbeats": deque(maxlen=MAX_HEARTBEATS)},
|
| 22 |
+
"gemini-3-flash-preview": {"name": "Gemini 3 Flash Preview", "heartbeats": deque(maxlen=MAX_HEARTBEATS)},
|
| 23 |
+
"gemini-3-pro-preview": {"name": "Gemini 3 Pro Preview", "heartbeats": deque(maxlen=MAX_HEARTBEATS)},
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
SUPPORTED_MODELS = ["gemini-2.5-flash", "gemini-2.5-pro", "gemini-3-flash-preview", "gemini-3-pro-preview"]
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def record_request(service: str, success: bool):
|
| 30 |
+
"""记录请求心跳"""
|
| 31 |
+
if service not in SERVICES:
|
| 32 |
+
return
|
| 33 |
+
|
| 34 |
+
SERVICES[service]["heartbeats"].append({
|
| 35 |
+
"time": datetime.now(BEIJING_TZ).strftime("%H:%M:%S"),
|
| 36 |
+
"success": success
|
| 37 |
+
})
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def get_realtime_status() -> Dict:
|
| 41 |
+
"""获取实时状态数据"""
|
| 42 |
+
result = {"services": {}}
|
| 43 |
+
|
| 44 |
+
for service_id, service_data in SERVICES.items():
|
| 45 |
+
heartbeats = list(service_data["heartbeats"])
|
| 46 |
+
total = len(heartbeats)
|
| 47 |
+
success = sum(1 for h in heartbeats if h["success"])
|
| 48 |
+
|
| 49 |
+
# 计算可用率
|
| 50 |
+
uptime = (success / total * 100) if total > 0 else 100.0
|
| 51 |
+
|
| 52 |
+
# 最近状态
|
| 53 |
+
last_status = "unknown"
|
| 54 |
+
if heartbeats:
|
| 55 |
+
last_status = "up" if heartbeats[-1]["success"] else "down"
|
| 56 |
+
|
| 57 |
+
result["services"][service_id] = {
|
| 58 |
+
"name": service_data["name"],
|
| 59 |
+
"status": last_status,
|
| 60 |
+
"uptime": round(uptime, 1),
|
| 61 |
+
"total": total,
|
| 62 |
+
"success": success,
|
| 63 |
+
"heartbeats": heartbeats[-MAX_HEARTBEATS:] # 最近的心跳
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
result["updated_at"] = datetime.now(BEIJING_TZ).strftime("%Y-%m-%d %H:%M:%S")
|
| 67 |
+
return result
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
# 兼容旧接口
|
| 71 |
+
async def get_uptime_summary(days: int = 90) -> Dict:
|
| 72 |
+
"""兼容旧接口,返回实时数据"""
|
| 73 |
+
return get_realtime_status()
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
async def uptime_aggregation_task():
|
| 77 |
+
"""后台任务(保留兼容性,实际不需要聚合)"""
|
| 78 |
+
pass
|