xiaoyukkkk commited on
Commit
2974aa2
·
verified ·
1 Parent(s): c95db98

Upload 9 files

Browse files
Files changed (6) hide show
  1. core/account.py +8 -6
  2. core/config.py +7 -8
  3. core/google_api.py +20 -8
  4. core/message.py +30 -17
  5. core/session_auth.py +20 -17
  6. 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
- if config.is_expired():
395
- logger.warning(f"[CONFIG] 账户 {config.account_id} 已过期,跳过加载")
396
- continue
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, PATH_PREFIX, SESSION_SECRET_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 logging
7
- import os
8
- import time
9
- import uuid
 
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
- raise HTTPException(r.status_code, f"Upload failed: {r.text}")
 
 
 
 
 
 
 
 
 
 
 
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.raise_for_status()
112
- content_type = resp.headers.get("content-type", "application/octet-stream").split(";")[0]
113
- # 移除图片类型限制,支持所有文件类型
114
- b64 = base64.b64encode(resp.content).decode()
115
- logger.info(f"[FILE] [req_{request_id}] URL文件下载成功: {url[:50]}... ({len(resp.content)} bytes, {content_type})")
116
- return {"mime": content_type, "data": b64}
117
- except Exception as e:
118
- logger.warning(f"[FILE] [req_{request_id}] URL文件下载失败: {url[:50]}... - {e}")
119
- return None
120
-
121
- results = await asyncio.gather(*[download_url(u) for u in image_urls])
122
- images.extend([r for r in results if r])
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- # 构建登录页面URL(支持可选的PATH_PREFIX)
46
- # 从请求路径中提取PATH_PREFIX(如果有)
47
- path = request.url.path
48
-
49
- # 动态导入main模块获取PATH_PREFIX(避免循环依赖)
50
- import main
51
- prefix = main.PATH_PREFIX
52
-
53
- if prefix:
54
- login_url = f"/{prefix}/login"
55
- else:
56
- login_url = "/login"
57
-
58
- return RedirectResponse(url=login_url, status_code=302)
59
- else:
60
- # 返回404假装端点不存在
61
- raise HTTPException(404, "Not Found")
 
 
 
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
- 类似 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
- "account_pool": {"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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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()