Spaces:
Sleeping
Sleeping
Upload 9 files
Browse files- core/account.py +8 -6
- core/config.py +7 -8
- core/google_api.py +20 -8
- core/message.py +30 -17
- core/session_auth.py +20 -17
- core/uptime.py +139 -78
core/account.py
CHANGED
|
@@ -390,12 +390,14 @@ def load_multi_account_config(
|
|
| 390 |
disabled=acc.get("disabled", False) # 读取手动禁用状态,默认为 False
|
| 391 |
)
|
| 392 |
|
| 393 |
-
# 检查账户是否已过期
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
manager.add_account(config, http_client, user_agent, account_failure_threshold, rate_limit_cooldown_seconds, global_stats)
|
|
|
|
|
|
|
| 399 |
|
| 400 |
if not manager.accounts:
|
| 401 |
logger.warning(f"[CONFIG] 没有有效的账户配置,服务将启动但无法处理请求,请在管理面板添加账户")
|
|
|
|
| 390 |
disabled=acc.get("disabled", False) # 读取手动禁用状态,默认为 False
|
| 391 |
)
|
| 392 |
|
| 393 |
+
# 检查账户是否已过期(已过期也加载到管理面板)
|
| 394 |
+
is_expired = config.is_expired()
|
| 395 |
+
if is_expired:
|
| 396 |
+
logger.warning(f"[CONFIG] 账户 {config.account_id} 已过期,仍加载用于展示")
|
| 397 |
+
|
| 398 |
+
manager.add_account(config, http_client, user_agent, account_failure_threshold, rate_limit_cooldown_seconds, global_stats)
|
| 399 |
+
if is_expired:
|
| 400 |
+
manager.accounts[config.account_id].is_available = False
|
| 401 |
|
| 402 |
if not manager.accounts:
|
| 403 |
logger.warning(f"[CONFIG] 没有有效的账户配置,服务将启动但无法处理请求,请在管理面板添加账户")
|
core/config.py
CHANGED
|
@@ -39,6 +39,7 @@ class ImageGenerationConfig(BaseModel):
|
|
| 39 |
default=["gemini-3-pro-preview"],
|
| 40 |
description="支持图片生成的模型列表"
|
| 41 |
)
|
|
|
|
| 42 |
|
| 43 |
|
| 44 |
class RetryConfig(BaseModel):
|
|
@@ -65,7 +66,6 @@ class SessionConfig(BaseModel):
|
|
| 65 |
class SecurityConfig(BaseModel):
|
| 66 |
"""安全配置(仅从环境变量读取,不可热更新)"""
|
| 67 |
admin_key: str = Field(default="", description="管理员密钥(必需)")
|
| 68 |
-
path_prefix: str = Field(default="", description="路径前缀(隐藏管理端点)")
|
| 69 |
session_secret_key: str = Field(..., description="Session密钥")
|
| 70 |
|
| 71 |
|
|
@@ -103,7 +103,7 @@ class ConfigManager:
|
|
| 103 |
加载配置
|
| 104 |
|
| 105 |
优先级规则:
|
| 106 |
-
1. 安全配置(ADMIN_KEY,
|
| 107 |
2. 其他配置:YAML > 环境变量 > 默认值
|
| 108 |
"""
|
| 109 |
# 1. 加载 YAML 配置
|
|
@@ -112,7 +112,6 @@ class ConfigManager:
|
|
| 112 |
# 2. 加载安全配置(仅从环境变量,不允许 Web 修改)
|
| 113 |
security_config = SecurityConfig(
|
| 114 |
admin_key=os.getenv("ADMIN_KEY", ""),
|
| 115 |
-
path_prefix=os.getenv("PATH_PREFIX", ""),
|
| 116 |
session_secret_key=os.getenv("SESSION_SECRET_KEY", self._generate_secret())
|
| 117 |
)
|
| 118 |
|
|
@@ -192,11 +191,6 @@ class ConfigManager:
|
|
| 192 |
"""管理员密钥"""
|
| 193 |
return self._config.security.admin_key
|
| 194 |
|
| 195 |
-
@property
|
| 196 |
-
def path_prefix(self) -> str:
|
| 197 |
-
"""路径前缀"""
|
| 198 |
-
return self._config.security.path_prefix
|
| 199 |
-
|
| 200 |
@property
|
| 201 |
def session_secret_key(self) -> str:
|
| 202 |
"""Session密钥"""
|
|
@@ -232,6 +226,11 @@ class ConfigManager:
|
|
| 232 |
"""支持图片生成的模型列表"""
|
| 233 |
return self._config.image_generation.supported_models
|
| 234 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
@property
|
| 236 |
def session_expire_hours(self) -> int:
|
| 237 |
"""Session过期时间(小时)"""
|
|
|
|
| 39 |
default=["gemini-3-pro-preview"],
|
| 40 |
description="支持图片生成的模型列表"
|
| 41 |
)
|
| 42 |
+
output_format: str = Field(default="base64", description="图片输出格式:base64 或 url")
|
| 43 |
|
| 44 |
|
| 45 |
class RetryConfig(BaseModel):
|
|
|
|
| 66 |
class SecurityConfig(BaseModel):
|
| 67 |
"""安全配置(仅从环境变量读取,不可热更新)"""
|
| 68 |
admin_key: str = Field(default="", description="管理员密钥(必需)")
|
|
|
|
| 69 |
session_secret_key: str = Field(..., description="Session密钥")
|
| 70 |
|
| 71 |
|
|
|
|
| 103 |
加载配置
|
| 104 |
|
| 105 |
优先级规则:
|
| 106 |
+
1. 安全配置(ADMIN_KEY, SESSION_SECRET_KEY):仅从环境变量读取
|
| 107 |
2. 其他配置:YAML > 环境变量 > 默认值
|
| 108 |
"""
|
| 109 |
# 1. 加载 YAML 配置
|
|
|
|
| 112 |
# 2. 加载安全配置(仅从环境变量,不允许 Web 修改)
|
| 113 |
security_config = SecurityConfig(
|
| 114 |
admin_key=os.getenv("ADMIN_KEY", ""),
|
|
|
|
| 115 |
session_secret_key=os.getenv("SESSION_SECRET_KEY", self._generate_secret())
|
| 116 |
)
|
| 117 |
|
|
|
|
| 191 |
"""管理员密钥"""
|
| 192 |
return self._config.security.admin_key
|
| 193 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
@property
|
| 195 |
def session_secret_key(self) -> str:
|
| 196 |
"""Session密钥"""
|
|
|
|
| 226 |
"""支持图片生成的模型列表"""
|
| 227 |
return self._config.image_generation.supported_models
|
| 228 |
|
| 229 |
+
@property
|
| 230 |
+
def image_output_format(self) -> str:
|
| 231 |
+
"""图片输出格式"""
|
| 232 |
+
return self._config.image_generation.output_format
|
| 233 |
+
|
| 234 |
@property
|
| 235 |
def session_expire_hours(self) -> int:
|
| 236 |
"""Session过期时间(小时)"""
|
core/google_api.py
CHANGED
|
@@ -2,11 +2,12 @@
|
|
| 2 |
|
| 3 |
负责与Google Gemini Business API的所有交互操作
|
| 4 |
"""
|
| 5 |
-
import asyncio
|
| 6 |
-
import
|
| 7 |
-
import
|
| 8 |
-
import
|
| 9 |
-
import
|
|
|
|
| 10 |
from typing import TYPE_CHECKING, List
|
| 11 |
|
| 12 |
import httpx
|
|
@@ -162,9 +163,20 @@ async def upload_context_file(
|
|
| 162 |
)
|
| 163 |
|
| 164 |
req_tag = f"[req_{request_id}] " if request_id else ""
|
| 165 |
-
if r.status_code != 200:
|
| 166 |
-
logger.error(f"[FILE] [{account_manager.config.account_id}] {req_tag}文件上传失败: {r.status_code}")
|
| 167 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
|
| 169 |
data = r.json()
|
| 170 |
file_id = data.get("addContextFileResponse", {}).get("fileId")
|
|
|
|
| 2 |
|
| 3 |
负责与Google Gemini Business API的所有交互操作
|
| 4 |
"""
|
| 5 |
+
import asyncio
|
| 6 |
+
import json
|
| 7 |
+
import logging
|
| 8 |
+
import os
|
| 9 |
+
import time
|
| 10 |
+
import uuid
|
| 11 |
from typing import TYPE_CHECKING, List
|
| 12 |
|
| 13 |
import httpx
|
|
|
|
| 163 |
)
|
| 164 |
|
| 165 |
req_tag = f"[req_{request_id}] " if request_id else ""
|
| 166 |
+
if r.status_code != 200:
|
| 167 |
+
logger.error(f"[FILE] [{account_manager.config.account_id}] {req_tag}文件上传失败: {r.status_code}")
|
| 168 |
+
error_text = r.text
|
| 169 |
+
if r.status_code == 400:
|
| 170 |
+
try:
|
| 171 |
+
payload = json.loads(r.text or "{}")
|
| 172 |
+
message = payload.get("error", {}).get("message", "")
|
| 173 |
+
except Exception:
|
| 174 |
+
message = ""
|
| 175 |
+
if "Unsupported file type" in message:
|
| 176 |
+
mime_type = message.split("Unsupported file type:", 1)[-1].strip()
|
| 177 |
+
hint = f"不支持的文件类型: {mime_type}。请转换为 PDF、图片或纯文本后再上传。"
|
| 178 |
+
raise HTTPException(400, hint)
|
| 179 |
+
raise HTTPException(r.status_code, f"Upload failed: {error_text}")
|
| 180 |
|
| 181 |
data = r.json()
|
| 182 |
file_id = data.get("addContextFileResponse", {}).get("fileId")
|
core/message.py
CHANGED
|
@@ -103,23 +103,36 @@ async def parse_last_message(messages: List['Message'], http_client: httpx.Async
|
|
| 103 |
else:
|
| 104 |
logger.warning(f"[FILE] [req_{request_id}] 不支持的文件格式: {url[:30]}...")
|
| 105 |
|
| 106 |
-
# 并行下载所有 URL 文件(支持图片、PDF、文档等)
|
| 107 |
-
if image_urls:
|
| 108 |
-
async def download_url(url: str):
|
| 109 |
-
try:
|
| 110 |
-
resp = await http_client.get(url, timeout=30, follow_redirects=True)
|
| 111 |
-
resp.
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
logger.
|
| 119 |
-
return
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
|
| 124 |
return text_content, images
|
| 125 |
|
|
|
|
| 103 |
else:
|
| 104 |
logger.warning(f"[FILE] [req_{request_id}] 不支持的文件格式: {url[:30]}...")
|
| 105 |
|
| 106 |
+
# 并行下载所有 URL 文件(支持图片、PDF、文档等)
|
| 107 |
+
if image_urls:
|
| 108 |
+
async def download_url(url: str):
|
| 109 |
+
try:
|
| 110 |
+
resp = await http_client.get(url, timeout=30, follow_redirects=True)
|
| 111 |
+
if resp.status_code == 404:
|
| 112 |
+
logger.warning(f"[FILE] [req_{request_id}] URL文件已失效(404),已跳过: {url[:50]}...")
|
| 113 |
+
return None
|
| 114 |
+
resp.raise_for_status()
|
| 115 |
+
content_type = resp.headers.get("content-type", "application/octet-stream").split(";")[0]
|
| 116 |
+
# 移除图片类型限制,支持所有文件类型
|
| 117 |
+
b64 = base64.b64encode(resp.content).decode()
|
| 118 |
+
logger.info(f"[FILE] [req_{request_id}] URL文件下载成功: {url[:50]}... ({len(resp.content)} bytes, {content_type})")
|
| 119 |
+
return {"mime": content_type, "data": b64}
|
| 120 |
+
except httpx.HTTPStatusError as e:
|
| 121 |
+
status_code = e.response.status_code if e.response else "unknown"
|
| 122 |
+
logger.warning(f"[FILE] [req_{request_id}] URL文件下载失败({status_code}): {url[:50]}... - {e}")
|
| 123 |
+
return None
|
| 124 |
+
except Exception as e:
|
| 125 |
+
logger.warning(f"[FILE] [req_{request_id}] URL文件下载失败: {url[:50]}... - {e}")
|
| 126 |
+
return None
|
| 127 |
+
|
| 128 |
+
results = await asyncio.gather(*[download_url(u) for u in image_urls], return_exceptions=True)
|
| 129 |
+
safe_results = []
|
| 130 |
+
for result in results:
|
| 131 |
+
if isinstance(result, Exception):
|
| 132 |
+
logger.warning(f"[FILE] [req_{request_id}] URL文件下载异常: {type(result).__name__}: {str(result)[:120]}")
|
| 133 |
+
continue
|
| 134 |
+
safe_results.append(result)
|
| 135 |
+
images.extend([r for r in safe_results if r])
|
| 136 |
|
| 137 |
return text_content, images
|
| 138 |
|
core/session_auth.py
CHANGED
|
@@ -42,23 +42,26 @@ def require_login(redirect_to_login: bool = True):
|
|
| 42 |
async def wrapper(*args, request: Request, **kwargs):
|
| 43 |
if not is_logged_in(request):
|
| 44 |
if redirect_to_login:
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
return await func(*args, request=request, **kwargs)
|
| 64 |
return wrapper
|
|
|
|
| 42 |
async def wrapper(*args, request: Request, **kwargs):
|
| 43 |
if not is_logged_in(request):
|
| 44 |
if redirect_to_login:
|
| 45 |
+
accept_header = (request.headers.get("accept") or "").lower()
|
| 46 |
+
wants_html = "text/html" in accept_header or request.url.path.endswith("/html")
|
| 47 |
+
|
| 48 |
+
if wants_html:
|
| 49 |
+
# 清理掉 URL 中可能重复的 PATH_PREFIX
|
| 50 |
+
# 避免重定向路径出现多层前缀
|
| 51 |
+
path = request.url.path
|
| 52 |
+
|
| 53 |
+
# 兼容 main 中 PATH_PREFIX 为空的情况
|
| 54 |
+
import main
|
| 55 |
+
prefix = main.PATH_PREFIX
|
| 56 |
+
|
| 57 |
+
if prefix:
|
| 58 |
+
login_url = f"/{prefix}/login"
|
| 59 |
+
else:
|
| 60 |
+
login_url = "/login"
|
| 61 |
+
|
| 62 |
+
return RedirectResponse(url=login_url, status_code=302)
|
| 63 |
+
|
| 64 |
+
raise HTTPException(401, "Unauthorized")
|
| 65 |
|
| 66 |
return await func(*args, request=request, **kwargs)
|
| 67 |
return wrapper
|
core/uptime.py
CHANGED
|
@@ -1,78 +1,139 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Uptime 实时监控
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
from
|
| 7 |
-
from
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
"""
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Uptime 实时监控与心跳历史持久化。
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from collections import deque
|
| 6 |
+
from datetime import datetime, timezone, timedelta
|
| 7 |
+
from typing import Dict, List, Optional
|
| 8 |
+
import json
|
| 9 |
+
import os
|
| 10 |
+
from threading import Lock
|
| 11 |
+
|
| 12 |
+
# 北京时区 UTC+8
|
| 13 |
+
BEIJING_TZ = timezone(timedelta(hours=8))
|
| 14 |
+
|
| 15 |
+
# 每个服务保留最近 60 条心跳
|
| 16 |
+
MAX_HEARTBEATS = 60
|
| 17 |
+
SLOW_THRESHOLD_MS = 40000
|
| 18 |
+
WARNING_STATUS_CODES = {429}
|
| 19 |
+
|
| 20 |
+
_storage_path: Optional[str] = None
|
| 21 |
+
_storage_lock = Lock()
|
| 22 |
+
|
| 23 |
+
# 服务注册表
|
| 24 |
+
SERVICES = {
|
| 25 |
+
"api_service": {"name": "API 服务", "heartbeats": deque(maxlen=MAX_HEARTBEATS)},
|
| 26 |
+
"account_pool": {"name": "服务资源", "heartbeats": deque(maxlen=MAX_HEARTBEATS)},
|
| 27 |
+
"gemini-2.5-flash": {"name": "Gemini 2.5 Flash", "heartbeats": deque(maxlen=MAX_HEARTBEATS)},
|
| 28 |
+
"gemini-2.5-pro": {"name": "Gemini 2.5 Pro", "heartbeats": deque(maxlen=MAX_HEARTBEATS)},
|
| 29 |
+
"gemini-3-flash-preview": {"name": "Gemini 3 Flash Preview", "heartbeats": deque(maxlen=MAX_HEARTBEATS)},
|
| 30 |
+
"gemini-3-pro-preview": {"name": "Gemini 3 Pro Preview", "heartbeats": deque(maxlen=MAX_HEARTBEATS)},
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
SUPPORTED_MODELS = ["gemini-2.5-flash", "gemini-2.5-pro", "gemini-3-flash-preview", "gemini-3-pro-preview"]
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def configure_storage(path: Optional[str]) -> None:
|
| 37 |
+
"""配置心跳持久化路径。"""
|
| 38 |
+
global _storage_path
|
| 39 |
+
_storage_path = path
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _classify_level(success: bool, status_code: Optional[int], latency_ms: Optional[int]) -> str:
|
| 43 |
+
if status_code in WARNING_STATUS_CODES:
|
| 44 |
+
return "warn"
|
| 45 |
+
if success and latency_ms is not None and latency_ms >= SLOW_THRESHOLD_MS:
|
| 46 |
+
return "warn"
|
| 47 |
+
return "up" if success else "down"
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _save_heartbeats() -> None:
|
| 51 |
+
if not _storage_path:
|
| 52 |
+
return
|
| 53 |
+
try:
|
| 54 |
+
payload = {}
|
| 55 |
+
for service_id, service_data in SERVICES.items():
|
| 56 |
+
payload[service_id] = list(service_data["heartbeats"])
|
| 57 |
+
os.makedirs(os.path.dirname(_storage_path), exist_ok=True)
|
| 58 |
+
with _storage_lock, open(_storage_path, "w", encoding="utf-8") as f:
|
| 59 |
+
json.dump(payload, f, ensure_ascii=True, indent=2)
|
| 60 |
+
except Exception:
|
| 61 |
+
return
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def load_heartbeats() -> None:
|
| 65 |
+
if not _storage_path or not os.path.exists(_storage_path):
|
| 66 |
+
return
|
| 67 |
+
try:
|
| 68 |
+
with _storage_lock, open(_storage_path, "r", encoding="utf-8") as f:
|
| 69 |
+
payload = json.load(f)
|
| 70 |
+
for service_id, heartbeats in payload.items():
|
| 71 |
+
if service_id not in SERVICES:
|
| 72 |
+
continue
|
| 73 |
+
SERVICES[service_id]["heartbeats"].clear()
|
| 74 |
+
for beat in heartbeats[-MAX_HEARTBEATS:]:
|
| 75 |
+
SERVICES[service_id]["heartbeats"].append(beat)
|
| 76 |
+
except Exception:
|
| 77 |
+
return
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def record_request(
|
| 81 |
+
service: str,
|
| 82 |
+
success: bool,
|
| 83 |
+
latency_ms: Optional[int] = None,
|
| 84 |
+
status_code: Optional[int] = None
|
| 85 |
+
):
|
| 86 |
+
"""记录一次心跳。"""
|
| 87 |
+
if service not in SERVICES:
|
| 88 |
+
return
|
| 89 |
+
|
| 90 |
+
level = _classify_level(success, status_code, latency_ms)
|
| 91 |
+
heartbeat = {
|
| 92 |
+
"time": datetime.now(BEIJING_TZ).strftime("%H:%M:%S"),
|
| 93 |
+
"success": success,
|
| 94 |
+
"level": level,
|
| 95 |
+
}
|
| 96 |
+
if latency_ms is not None:
|
| 97 |
+
heartbeat["latency_ms"] = latency_ms
|
| 98 |
+
if status_code is not None:
|
| 99 |
+
heartbeat["status_code"] = status_code
|
| 100 |
+
|
| 101 |
+
SERVICES[service]["heartbeats"].append(heartbeat)
|
| 102 |
+
_save_heartbeats()
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def get_realtime_status() -> Dict:
|
| 106 |
+
"""返回实时监控数据。"""
|
| 107 |
+
result = {"services": {}}
|
| 108 |
+
|
| 109 |
+
for service_id, service_data in SERVICES.items():
|
| 110 |
+
heartbeats = list(service_data["heartbeats"])
|
| 111 |
+
total = len(heartbeats)
|
| 112 |
+
success = sum(1 for h in heartbeats if h.get("success"))
|
| 113 |
+
|
| 114 |
+
uptime = (success / total * 100) if total > 0 else 100.0
|
| 115 |
+
|
| 116 |
+
last_status = "unknown"
|
| 117 |
+
if heartbeats:
|
| 118 |
+
last_level = heartbeats[-1].get("level")
|
| 119 |
+
if last_level in {"up", "down", "warn"}:
|
| 120 |
+
last_status = last_level
|
| 121 |
+
else:
|
| 122 |
+
last_status = "up" if heartbeats[-1].get("success") else "down"
|
| 123 |
+
|
| 124 |
+
result["services"][service_id] = {
|
| 125 |
+
"name": service_data["name"],
|
| 126 |
+
"status": last_status,
|
| 127 |
+
"uptime": round(uptime, 1),
|
| 128 |
+
"total": total,
|
| 129 |
+
"success": success,
|
| 130 |
+
"heartbeats": heartbeats[-MAX_HEARTBEATS:],
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
result["updated_at"] = datetime.now(BEIJING_TZ).strftime("%Y-%m-%d %H:%M:%S")
|
| 134 |
+
return result
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
async def get_uptime_summary(days: int = 90) -> Dict:
|
| 138 |
+
"""兼容旧接口。"""
|
| 139 |
+
return get_realtime_status()
|