xiaoyukkkk commited on
Commit
990d959
·
verified ·
1 Parent(s): 7f0011a

Upload 11 files

Browse files
Files changed (4) hide show
  1. Dockerfile +2 -0
  2. main.py +361 -168
  3. requirements.txt +3 -1
  4. 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
- return json.load(f)
 
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.dump(stats, f, ensure_ascii=False, indent=2)
52
  except Exception as e:
53
  logger.error(f"[STATS] 保存统计数据失败: {str(e)[:50]}")
54
 
55
- # 初始化统计数据
56
- global_stats = load_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(max_keepalive_connections=20, max_connections=50)
 
 
 
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._lock = asyncio.Lock()
 
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._lock:
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._lock:
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._lock:
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
- async with self._lock:
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
- if not available_accounts:
440
- raise HTTPException(503, "No available accounts")
 
 
 
 
 
 
441
 
442
- # Round-robin(修复:基于可用账户列表的索引)
 
 
 
 
 
 
 
 
 
 
 
 
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
- account = self.accounts[account_id]
450
- logger.info(f"[MULTI] [ACCOUNT] {req_tag}选择账户: {account_id}")
451
- return account
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
- """使用第一条user消息生成对话指纹"""
 
 
 
 
 
 
 
712
  if not messages:
713
  return "empty"
714
 
715
- # 只使用第一条user消息生成指纹(对话起点不变)
716
- user_messages = [msg for msg in messages if msg.get("role") == "user"]
717
- if not user_messages:
718
- return "no_user_msg"
 
719
 
720
- # 只取第一条user消息
721
- first_user_msg = user_messages[0]
722
- content = first_user_msg.get("content", "")
 
 
 
723
 
724
- # 统一处理内容格式(字符串或数组)
725
- if isinstance(content, list):
726
- text = "".join([x.get("text", "") for x in content if x.get("type") == "text"])
727
- else:
728
- text = str(content)
729
 
730
- # 标准化:去除首尾空白,转小写(避免因空格/大小写导致指纹不同)
731
- text = text.strip().lower()
732
 
733
- # 生成指纹
734
- return hashlib.md5(text.encode()).hexdigest()
 
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
- # 3. 生成会话指纹,检查是否已有绑定的账户
1352
- conv_key = get_conversation_key([m.dict() for m in req.messages])
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
- for attempt in range(max_account_tries):
1368
- try:
1369
- account_manager = await multi_account_mgr.get_account(None, request_id)
1370
- google_session = await create_google_session(account_manager, request_id)
1371
- # 线程安全地绑定账户到此对话
1372
- await multi_account_mgr.set_session_cache(
1373
- conv_key,
1374
- account_manager.config.account_id,
1375
- google_session
1376
- )
1377
- is_new_conversation = True
1378
- logger.info(f"[CHAT] [{account_manager.config.account_id}] [req_{request_id}] 新会话创建并绑定账户")
1379
- break
1380
- except Exception as e:
1381
- last_error = e
1382
- error_type = type(e).__name__
1383
- # 安全获取账户ID
1384
- account_id = account_manager.config.account_id if 'account_manager' in locals() and account_manager else 'unknown'
1385
- logger.error(f"[CHAT] [req_{request_id}] 账户 {account_id} 创建会话失败 (尝试 {attempt + 1}/{max_account_tries}) - {error_type}: {str(e)}")
1386
- if attempt == max_account_tries - 1:
1387
- logger.error(f"[CHAT] [req_{request_id}] 所有账户均不可用")
1388
- raise HTTPException(503, f"All accounts unavailable: {str(last_error)[:100]}")
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
- """使用JWT认证下载图片"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1720
  url = build_image_download_url(session_name, file_id)
1721
- logger.info(f"[IMAGE] [DEBUG] 下载URL: {url}")
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
- # 复用全局http_client
1727
- resp = await http_client.get(url, headers=headers, follow_redirects=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1728
 
1729
- if resp.status_code == 401:
1730
- # JWT过期,刷新后重试
1731
- jwt = await account_mgr.get_jwt(request_id)
1732
- headers = get_common_headers(jwt)
1733
- resp = await http_client.get(url, headers=headers, follow_redirects=True)
 
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] [DEBUG] 获取到{len(file_metadata)}个文件元数据")
1849
 
1850
- for idx, file_info in enumerate(file_ids, 1):
1851
- try:
1852
- fid = file_info["fileId"]
1853
- mime = file_info["mimeType"]
 
 
 
 
 
1854
 
1855
- # 从元数据中获取正确的session路径
1856
- meta = file_metadata.get(fid, {})
1857
- correct_session = meta.get("session") or session_name
1858
- logger.info(f"[IMAGE] [DEBUG] 文件{fid}使用session: {correct_session}")
1859
 
1860
- image_data = await download_image_with_jwt(account_manager, correct_session, fid, request_id)
 
 
 
 
 
 
 
 
 
 
 
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}] 图片已保存: {image_url}")
1863
 
1864
  # 返回Markdown格式图片
1865
  markdown = f"\n\n![生成的图片]({image_url})\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}] 单张图片处理失败: {str(e)}")
 
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
- # 基于IP的访问统计(24小时内去重)
1931
- # 优先从 X-Forwarded-For 获取真实IP(处理代理情况)
1932
- client_ip = request.headers.get("x-forwarded-for")
1933
- if client_ip:
1934
- # X-Forwarded-For 可能包含多个IP,取第一个
1935
- client_ip = client_ip.split(",")[0].strip()
1936
- else:
1937
- # 没有代理时使用直连IP
1938
- client_ip = request.client.host if request.client else "unknown"
 
1939
 
1940
- current_time = time.time()
1941
 
1942
- with stats_lock:
1943
- # 清理24小时前的IP记录
1944
- if "visitor_ips" not in global_stats:
1945
- global_stats["visitor_ips"] = {}
1946
 
1947
- expired_ips = [
1948
- ip for ip, timestamp in global_stats["visitor_ips"].items()
1949
- if current_time - timestamp > 86400 # 24小时
1950
- ]
1951
- for ip in expired_ips:
1952
- del global_stats["visitor_ips"][ip]
1953
 
1954
- # 记录新访问(24小时内同一IP只计数一次)
1955
- if client_ip not in global_stats["visitor_ips"]:
1956
- global_stats["visitor_ips"][client_ip] = current_time
1957
- global_stats["total_visitors"] = len(global_stats["visitor_ips"])
1958
- save_stats(global_stats)
1959
 
1960
- sanitized_logs = get_sanitized_logs(limit=min(limit, 1000))
1961
- return {
1962
- "total": len(sanitized_logs),
1963
- "logs": sanitized_logs
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![生成的图片]({image_url})\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