xiaoyukkkk commited on
Commit
e031780
·
verified ·
1 Parent(s): bc4a196

Upload 8 files

Browse files
Files changed (7) hide show
  1. core/account.py +514 -0
  2. core/auth.py +37 -112
  3. core/google_api.py +304 -0
  4. core/jwt.py +101 -0
  5. core/message.py +141 -0
  6. core/session_auth.py +65 -0
  7. core/templates.py +769 -948
core/account.py ADDED
@@ -0,0 +1,514 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """账户管理模块
2
+
3
+ 负责账户配置、多账户协调和会话缓存管理
4
+ """
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import os
9
+ import time
10
+ from dataclasses import dataclass
11
+ from datetime import datetime, timedelta, timezone
12
+ from typing import Dict, List, Optional, TYPE_CHECKING
13
+
14
+ from fastapi import HTTPException
15
+
16
+ if TYPE_CHECKING:
17
+ from core.jwt import JWTManager
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ # 配置文件路径
22
+ ACCOUNTS_FILE = "accounts.json"
23
+
24
+
25
+ @dataclass
26
+ class AccountConfig:
27
+ """单个账户配置"""
28
+ account_id: str
29
+ secure_c_ses: str
30
+ host_c_oses: Optional[str]
31
+ csesidx: str
32
+ config_id: str
33
+ expires_at: Optional[str] = None # 账户过期时间 (格式: "2025-12-23 10:59:21")
34
+ disabled: bool = False # 手动禁用状态
35
+
36
+ def get_remaining_hours(self) -> Optional[float]:
37
+ """计算账户剩余小时数"""
38
+ if not self.expires_at:
39
+ return None
40
+ try:
41
+ # 解析过期时间(假设为北京时间)
42
+ beijing_tz = timezone(timedelta(hours=8))
43
+ expire_time = datetime.strptime(self.expires_at, "%Y-%m-%d %H:%M:%S")
44
+ expire_time = expire_time.replace(tzinfo=beijing_tz)
45
+
46
+ # 当前时间(北京时间)
47
+ now = datetime.now(beijing_tz)
48
+
49
+ # 计算剩余时间
50
+ remaining = (expire_time - now).total_seconds() / 3600
51
+ return remaining
52
+ except Exception:
53
+ return None
54
+
55
+ def is_expired(self) -> bool:
56
+ """检查账户是否已过期"""
57
+ remaining = self.get_remaining_hours()
58
+ if remaining is None:
59
+ return False # 未设置过期时间,默认不过期
60
+ return remaining <= 0
61
+
62
+
63
+ def format_account_expiration(remaining_hours: Optional[float]) -> tuple:
64
+ """
65
+ 格式化账户过期时间显示(基于12小时过期周期)
66
+
67
+ Args:
68
+ remaining_hours: 剩余小时数(None表示未设置过期时间)
69
+
70
+ Returns:
71
+ (status, status_color, expire_display) 元组
72
+ """
73
+ if remaining_hours is None:
74
+ # 未设置过期时间时显示为"未设置"
75
+ return ("未设置", "#9e9e9e", "未设置")
76
+ elif remaining_hours <= 0:
77
+ return ("已过期", "#f44336", "已过期")
78
+ elif remaining_hours < 3: # 少于3小时
79
+ return ("即将过期", "#ff9800", f"{remaining_hours:.1f} 小时")
80
+ else: # 3小时及以上,统一显示小时
81
+ return ("正常", "#4caf50", f"{remaining_hours:.1f} 小时")
82
+
83
+
84
+ class AccountManager:
85
+ """单个账户管理器"""
86
+ def __init__(self, config: AccountConfig, http_client, user_agent: str, account_failure_threshold: int, rate_limit_cooldown_seconds: int):
87
+ self.config = config
88
+ self.http_client = http_client
89
+ self.user_agent = user_agent
90
+ self.account_failure_threshold = account_failure_threshold
91
+ self.rate_limit_cooldown_seconds = rate_limit_cooldown_seconds
92
+ self.jwt_manager: Optional['JWTManager'] = None # 延迟初始化
93
+ self.is_available = True
94
+ self.last_error_time = 0.0
95
+ self.last_429_time = 0.0 # 429错误专属时间戳
96
+ self.error_count = 0
97
+ self.conversation_count = 0 # 累计对话次数
98
+
99
+ async def get_jwt(self, request_id: str = "") -> str:
100
+ """获取 JWT token (带错误处理)"""
101
+ # 检查账户是否过期
102
+ if self.config.is_expired():
103
+ self.is_available = False
104
+ logger.warning(f"[ACCOUNT] [{self.config.account_id}] 账户已过期,已自动禁用")
105
+ raise HTTPException(403, f"Account {self.config.account_id} has expired")
106
+
107
+ try:
108
+ if self.jwt_manager is None:
109
+ # 延迟初始化 JWTManager (避免循环依赖)
110
+ from core.jwt import JWTManager
111
+ self.jwt_manager = JWTManager(self.config, self.http_client, self.user_agent)
112
+ jwt = await self.jwt_manager.get(request_id)
113
+ self.is_available = True
114
+ self.error_count = 0
115
+ return jwt
116
+ except Exception as e:
117
+ self.last_error_time = time.time()
118
+ self.error_count += 1
119
+ # 使用配置的失败阈值
120
+ if self.error_count >= self.account_failure_threshold:
121
+ self.is_available = False
122
+ logger.error(f"[ACCOUNT] [{self.config.account_id}] JWT获取连续失败{self.error_count}次,账户已永久禁用")
123
+ else:
124
+ # 安全:只记录异常类型,不记录详细信息
125
+ logger.warning(f"[ACCOUNT] [{self.config.account_id}] JWT获取失败({self.error_count}/{self.account_failure_threshold}): {type(e).__name__}")
126
+ raise
127
+
128
+ def should_retry(self) -> bool:
129
+ """检查账户是否可重试(429错误10分钟后恢复���普通错误永久禁用)"""
130
+ if self.is_available:
131
+ return True
132
+
133
+ current_time = time.time()
134
+
135
+ # 检查429冷却期(10分钟后自动恢复)
136
+ if self.last_429_time > 0:
137
+ if current_time - self.last_429_time > self.rate_limit_cooldown_seconds:
138
+ return True # 冷却期已过,可以重试
139
+ return False # 仍在冷却期
140
+
141
+ # 普通错误永久禁用
142
+ return False
143
+
144
+ def get_cooldown_info(self) -> tuple[int, str | None]:
145
+ """
146
+ 获取账户冷却信息
147
+
148
+ Returns:
149
+ (cooldown_seconds, cooldown_reason) 元组
150
+ - cooldown_seconds: 剩余冷却秒数,0表示无冷却,-1表示永久禁用
151
+ - cooldown_reason: 冷却原因,None表示无冷却
152
+ """
153
+ current_time = time.time()
154
+
155
+ # 优先检查429冷却期(无论账户是否可用)
156
+ if self.last_429_time > 0:
157
+ remaining_429 = self.rate_limit_cooldown_seconds - (current_time - self.last_429_time)
158
+ if remaining_429 > 0:
159
+ return (int(remaining_429), "429限流")
160
+ # 429冷却期已过
161
+
162
+ # 如果账户可用且没有429冷却,返回正常状态
163
+ if self.is_available:
164
+ return (0, None)
165
+
166
+ # 普通错误永久禁用
167
+ return (-1, "错误禁用")
168
+
169
+
170
+ class MultiAccountManager:
171
+ """多账户协调器"""
172
+ def __init__(self, session_cache_ttl_seconds: int):
173
+ self.accounts: Dict[str, AccountManager] = {}
174
+ self.account_list: List[str] = [] # 账户ID列表 (用于轮询)
175
+ self.current_index = 0
176
+ self._cache_lock = asyncio.Lock() # 缓存操作专用锁
177
+ self._index_lock = asyncio.Lock() # 索引更新专用锁
178
+ # 全局会话缓存:{conv_key: {"account_id": str, "session_id": str, "updated_at": float}}
179
+ self.global_session_cache: Dict[str, dict] = {}
180
+ self.cache_max_size = 1000 # 最大缓存条目数
181
+ self.cache_ttl = session_cache_ttl_seconds # 缓存过期时间(秒)
182
+ # Session级别锁:防止同一对话的并发请求冲突
183
+ self._session_locks: Dict[str, asyncio.Lock] = {}
184
+ self._session_locks_lock = asyncio.Lock() # 保护锁字典的锁
185
+ self._session_locks_max_size = 2000 # 最大锁数量
186
+
187
+ def _clean_expired_cache(self):
188
+ """清理过期的缓存条目"""
189
+ current_time = time.time()
190
+ expired_keys = [
191
+ key for key, value in self.global_session_cache.items()
192
+ if current_time - value["updated_at"] > self.cache_ttl
193
+ ]
194
+ for key in expired_keys:
195
+ del self.global_session_cache[key]
196
+ if expired_keys:
197
+ logger.info(f"[CACHE] 清理 {len(expired_keys)} 个过期会话缓存")
198
+
199
+ def _ensure_cache_size(self):
200
+ """确保缓存不超过最大大小(LRU策略)"""
201
+ if len(self.global_session_cache) > self.cache_max_size:
202
+ # 按更新时间排序,删除最旧的20%
203
+ sorted_items = sorted(
204
+ self.global_session_cache.items(),
205
+ key=lambda x: x[1]["updated_at"]
206
+ )
207
+ remove_count = len(sorted_items) - int(self.cache_max_size * 0.8)
208
+ for key, _ in sorted_items[:remove_count]:
209
+ del self.global_session_cache[key]
210
+ logger.info(f"[CACHE] LRU清理 {remove_count} 个最旧会话缓存")
211
+
212
+ async def start_background_cleanup(self):
213
+ """启动后台缓存清理任务(每5分钟执行一次)"""
214
+ try:
215
+ while True:
216
+ await asyncio.sleep(300) # 5分钟
217
+ async with self._cache_lock:
218
+ self._clean_expired_cache()
219
+ self._ensure_cache_size()
220
+ except asyncio.CancelledError:
221
+ logger.info("[CACHE] 后台清理任务已停止")
222
+ except Exception as e:
223
+ logger.error(f"[CACHE] 后台清理任务异常: {e}")
224
+
225
+ async def set_session_cache(self, conv_key: str, account_id: str, session_id: str):
226
+ """线程安全地设置会话缓存"""
227
+ async with self._cache_lock:
228
+ self.global_session_cache[conv_key] = {
229
+ "account_id": account_id,
230
+ "session_id": session_id,
231
+ "updated_at": time.time()
232
+ }
233
+ # 检查缓存大小
234
+ self._ensure_cache_size()
235
+
236
+ async def update_session_time(self, conv_key: str):
237
+ """线程安全地更新会话时间戳"""
238
+ async with self._cache_lock:
239
+ if conv_key in self.global_session_cache:
240
+ self.global_session_cache[conv_key]["updated_at"] = time.time()
241
+
242
+ async def acquire_session_lock(self, conv_key: str) -> asyncio.Lock:
243
+ """获取指定对话的锁(用于防止同一对话的并发请求冲突)"""
244
+ async with self._session_locks_lock:
245
+ # 清理过多的锁(LRU策略:删除不在缓存中的锁)
246
+ if len(self._session_locks) > self._session_locks_max_size:
247
+ # 只保留当前缓存中存在的锁
248
+ valid_keys = set(self.global_session_cache.keys())
249
+ keys_to_remove = [k for k in self._session_locks if k not in valid_keys]
250
+ for k in keys_to_remove[:len(keys_to_remove)//2]: # 删除一半无效锁
251
+ del self._session_locks[k]
252
+
253
+ if conv_key not in self._session_locks:
254
+ self._session_locks[conv_key] = asyncio.Lock()
255
+ return self._session_locks[conv_key]
256
+
257
+ def add_account(self, config: AccountConfig, http_client, user_agent: str, account_failure_threshold: int, rate_limit_cooldown_seconds: int, global_stats: dict):
258
+ """添加账户"""
259
+ manager = AccountManager(config, http_client, user_agent, account_failure_threshold, rate_limit_cooldown_seconds)
260
+ # 从统计数据加载对话次数
261
+ if "account_conversations" in global_stats:
262
+ manager.conversation_count = global_stats["account_conversations"].get(config.account_id, 0)
263
+ self.accounts[config.account_id] = manager
264
+ self.account_list.append(config.account_id)
265
+ logger.info(f"[MULTI] [ACCOUNT] 添加账户: {config.account_id}")
266
+
267
+ async def get_account(self, account_id: Optional[str] = None, request_id: str = "") -> AccountManager:
268
+ """获取账户 (轮询或指定) - 优化锁粒度,减少竞争"""
269
+ req_tag = f"[req_{request_id}] " if request_id else ""
270
+
271
+ # 如果指定了账户ID(无需锁)
272
+ if account_id:
273
+ if account_id not in self.accounts:
274
+ raise HTTPException(404, f"Account {account_id} not found")
275
+ account = self.accounts[account_id]
276
+ if not account.should_retry():
277
+ raise HTTPException(503, f"Account {account_id} temporarily unavailable")
278
+ return account
279
+
280
+ # 轮询选择可用账户(无锁读取账户列表)
281
+ available_accounts = [
282
+ acc_id for acc_id in self.account_list
283
+ if self.accounts[acc_id].should_retry()
284
+ and not self.accounts[acc_id].config.is_expired()
285
+ and not self.accounts[acc_id].config.disabled
286
+ ]
287
+
288
+ if not available_accounts:
289
+ raise HTTPException(503, "No available accounts")
290
+
291
+ # 只在更新索引时加锁(最小化锁持有时间)
292
+ async with self._index_lock:
293
+ if not hasattr(self, '_available_index'):
294
+ self._available_index = 0
295
+
296
+ account_id = available_accounts[self._available_index % len(available_accounts)]
297
+ self._available_index = (self._available_index + 1) % len(available_accounts)
298
+
299
+ account = self.accounts[account_id]
300
+ logger.info(f"[MULTI] [ACCOUNT] {req_tag}选择账户: {account_id}")
301
+ return account
302
+
303
+
304
+ # ---------- 配置文件管理 ----------
305
+
306
+ def save_accounts_to_file(accounts_data: list):
307
+ """保存账户配置到文件"""
308
+ with open(ACCOUNTS_FILE, 'w', encoding='utf-8') as f:
309
+ json.dump(accounts_data, f, ensure_ascii=False, indent=2)
310
+ logger.info(f"[CONFIG] 配置已保存到 {ACCOUNTS_FILE}")
311
+
312
+
313
+ def load_accounts_from_source() -> list:
314
+ """优先从文件加载,否则从环境变量加载"""
315
+ # 优先从文件加载
316
+ if os.path.exists(ACCOUNTS_FILE):
317
+ try:
318
+ with open(ACCOUNTS_FILE, 'r', encoding='utf-8') as f:
319
+ accounts_data = json.load(f)
320
+ logger.info(f"[CONFIG] 从文件加载配置: {ACCOUNTS_FILE}")
321
+ return accounts_data
322
+ except Exception as e:
323
+ logger.warning(f"[CONFIG] 文件加载失败,尝试环境变量: {str(e)}")
324
+
325
+ # 从环境变量加载
326
+ accounts_json = os.getenv("ACCOUNTS_CONFIG")
327
+ if not accounts_json:
328
+ raise ValueError(
329
+ "未找到配置文件或 ACCOUNTS_CONFIG 环境变量。\n"
330
+ "请在环境变量中配置 JSON 格式的账户列表,格式示例:\n"
331
+ '[{"id":"account_1","csesidx":"xxx","config_id":"yyy","secure_c_ses":"zzz","host_c_oses":null,"expires_at":"2025-12-23 10:59:21"}]'
332
+ )
333
+
334
+ try:
335
+ accounts_data = json.loads(accounts_json)
336
+ if not isinstance(accounts_data, list):
337
+ raise ValueError("ACCOUNTS_CONFIG 必须是 JSON 数组格式")
338
+ # 首次从环境变量加载后,保存到文件
339
+ save_accounts_to_file(accounts_data)
340
+ logger.info(f"[CONFIG] 从环境变量加载配置并保存到文件")
341
+ return accounts_data
342
+ except json.JSONDecodeError as e:
343
+ logger.error(f"[CONFIG] ACCOUNTS_CONFIG JSON 解析失败: {str(e)}")
344
+ raise ValueError(f"ACCOUNTS_CONFIG 格式错误: {str(e)}")
345
+
346
+
347
+ def get_account_id(acc: dict, index: int) -> str:
348
+ """获取账户ID(有显式ID则使用,否则生成默认ID)"""
349
+ return acc.get("id", f"account_{index}")
350
+
351
+
352
+ def load_multi_account_config(
353
+ http_client,
354
+ user_agent: str,
355
+ account_failure_threshold: int,
356
+ rate_limit_cooldown_seconds: int,
357
+ session_cache_ttl_seconds: int,
358
+ global_stats: dict
359
+ ) -> MultiAccountManager:
360
+ """从文件或环境变量加载多账户配置"""
361
+ manager = MultiAccountManager(session_cache_ttl_seconds)
362
+
363
+ accounts_data = load_accounts_from_source()
364
+
365
+ for i, acc in enumerate(accounts_data, 1):
366
+ # 验证必需字段
367
+ required_fields = ["secure_c_ses", "csesidx", "config_id"]
368
+ missing_fields = [f for f in required_fields if f not in acc]
369
+ if missing_fields:
370
+ raise ValueError(f"账户 {i} 缺少必需字段: {', '.join(missing_fields)}")
371
+
372
+ config = AccountConfig(
373
+ account_id=get_account_id(acc, i),
374
+ secure_c_ses=acc["secure_c_ses"],
375
+ host_c_oses=acc.get("host_c_oses"),
376
+ csesidx=acc["csesidx"],
377
+ config_id=acc["config_id"],
378
+ expires_at=acc.get("expires_at"),
379
+ disabled=acc.get("disabled", False) # 读取手动禁用状态,默认为 False
380
+ )
381
+
382
+ # 检查账户是否已过期
383
+ if config.is_expired():
384
+ logger.warning(f"[CONFIG] 账户 {config.account_id} 已过期,跳过加载")
385
+ continue
386
+
387
+ manager.add_account(config, http_client, user_agent, account_failure_threshold, rate_limit_cooldown_seconds, global_stats)
388
+
389
+ if not manager.accounts:
390
+ raise ValueError("没有有效的账户配置(可能全部已过期)")
391
+
392
+ logger.info(f"[CONFIG] 成功加载 {len(manager.accounts)} 个账户")
393
+ return manager
394
+
395
+
396
+ def reload_accounts(
397
+ multi_account_mgr: MultiAccountManager,
398
+ http_client,
399
+ user_agent: str,
400
+ account_failure_threshold: int,
401
+ rate_limit_cooldown_seconds: int,
402
+ session_cache_ttl_seconds: int,
403
+ global_stats: dict
404
+ ) -> MultiAccountManager:
405
+ """重新加载账户配置(清空缓存并重新加载)"""
406
+ multi_account_mgr.global_session_cache.clear()
407
+ new_mgr = load_multi_account_config(
408
+ http_client,
409
+ user_agent,
410
+ account_failure_threshold,
411
+ rate_limit_cooldown_seconds,
412
+ session_cache_ttl_seconds,
413
+ global_stats
414
+ )
415
+ logger.info(f"[CONFIG] 配置已重载,当前账户数: {len(new_mgr.accounts)}")
416
+ return new_mgr
417
+
418
+
419
+ def update_accounts_config(
420
+ accounts_data: list,
421
+ multi_account_mgr: MultiAccountManager,
422
+ http_client,
423
+ user_agent: str,
424
+ account_failure_threshold: int,
425
+ rate_limit_cooldown_seconds: int,
426
+ session_cache_ttl_seconds: int,
427
+ global_stats: dict
428
+ ) -> MultiAccountManager:
429
+ """更新账户配置(保存到文件并重新加载)"""
430
+ save_accounts_to_file(accounts_data)
431
+ return reload_accounts(
432
+ multi_account_mgr,
433
+ http_client,
434
+ user_agent,
435
+ account_failure_threshold,
436
+ rate_limit_cooldown_seconds,
437
+ session_cache_ttl_seconds,
438
+ global_stats
439
+ )
440
+
441
+
442
+ def delete_account(
443
+ account_id: str,
444
+ multi_account_mgr: MultiAccountManager,
445
+ http_client,
446
+ user_agent: str,
447
+ account_failure_threshold: int,
448
+ rate_limit_cooldown_seconds: int,
449
+ session_cache_ttl_seconds: int,
450
+ global_stats: dict
451
+ ) -> MultiAccountManager:
452
+ """删除单个账户"""
453
+ accounts_data = load_accounts_from_source()
454
+
455
+ # 过滤掉要删除的账户
456
+ filtered = [
457
+ acc for i, acc in enumerate(accounts_data, 1)
458
+ if get_account_id(acc, i) != account_id
459
+ ]
460
+
461
+ if len(filtered) == len(accounts_data):
462
+ raise ValueError(f"账户 {account_id} 不存在")
463
+
464
+ save_accounts_to_file(filtered)
465
+ return reload_accounts(
466
+ multi_account_mgr,
467
+ http_client,
468
+ user_agent,
469
+ account_failure_threshold,
470
+ rate_limit_cooldown_seconds,
471
+ session_cache_ttl_seconds,
472
+ global_stats
473
+ )
474
+
475
+
476
+ def update_account_disabled_status(
477
+ account_id: str,
478
+ disabled: bool,
479
+ multi_account_mgr: MultiAccountManager,
480
+ http_client,
481
+ user_agent: str,
482
+ account_failure_threshold: int,
483
+ rate_limit_cooldown_seconds: int,
484
+ session_cache_ttl_seconds: int,
485
+ global_stats: dict
486
+ ) -> MultiAccountManager:
487
+ """更新账户的禁用状态"""
488
+ accounts_data = load_accounts_from_source()
489
+
490
+ # 查找并更新账户
491
+ found = False
492
+ for i, acc in enumerate(accounts_data, 1):
493
+ if get_account_id(acc, i) == account_id:
494
+ acc["disabled"] = disabled
495
+ found = True
496
+ break
497
+
498
+ if not found:
499
+ raise ValueError(f"账户 {account_id} 不存在")
500
+
501
+ save_accounts_to_file(accounts_data)
502
+ new_mgr = reload_accounts(
503
+ multi_account_mgr,
504
+ http_client,
505
+ user_agent,
506
+ account_failure_threshold,
507
+ rate_limit_cooldown_seconds,
508
+ session_cache_ttl_seconds,
509
+ global_stats
510
+ )
511
+
512
+ status_text = "已禁用" if disabled else "已启用"
513
+ logger.info(f"[CONFIG] 账户 {account_id} {status_text}")
514
+ return new_mgr
core/auth.py CHANGED
@@ -1,122 +1,47 @@
1
  """
2
- 认证相关装饰器和工具函数
 
 
3
  """
4
- from functools import wraps
5
- from fastapi import HTTPException, Header
6
  from typing import Optional
 
7
 
8
 
9
- def extract_admin_key(key: Optional[str] = None, authorization: Optional[str] = None) -> Optional[str]:
10
  """
11
- 统一提取管理员密钥
12
-
13
- 优先级:
14
- 1. URL 参数 ?key=xxx
15
- 2. Authorization Header (支持 Bearer token 格式)
16
-
17
- Args:
18
- key: URL 查询参数中的密钥
19
- authorization: Authorization Header 中的密钥
20
-
21
- Returns:
22
- 提取到的密钥,如果都为空则返回 None
23
- """
24
- if key:
25
- return key
26
- if authorization:
27
- # 支持 Bearer token 格式
28
- return authorization.replace("Bearer ", "") if authorization.startswith("Bearer ") else authorization
29
- return None
30
-
31
-
32
- def require_path_prefix(path_prefix_value: str):
33
- """
34
- 验证路径前缀的装饰器
35
-
36
- Args:
37
- path_prefix_value: 正确的路径前缀值
38
-
39
- Returns:
40
- 装饰器函数
41
-
42
- Example:
43
- @app.get("/{path_prefix}/admin")
44
- @require_path_prefix(PATH_PREFIX)
45
- async def admin_home(path_prefix: str, ...):
46
- ...
47
- """
48
- def decorator(func):
49
- @wraps(func)
50
- async def wrapper(*args, path_prefix: str, **kwargs):
51
- if path_prefix != path_prefix_value:
52
- # 返回 404 而不是 401,假装端点不存在(安全性考虑)
53
- raise HTTPException(404, "Not Found")
54
- return await func(*args, path_prefix=path_prefix, **kwargs)
55
- return wrapper
56
- return decorator
57
-
58
-
59
- def require_admin_auth(admin_key_value: str):
60
- """
61
- 验证管理员权限的装饰器
62
-
63
- 支持两种认证方式:
64
- 1. URL 参数:?key=xxx
65
- 2. Authorization Header:Bearer xxx 或直接传密钥
66
-
67
- Args:
68
- admin_key_value: 正确的管理员密钥
69
-
70
- Returns:
71
- 装饰器函数
72
-
73
- Example:
74
- @app.get("/{path_prefix}/admin")
75
- @require_admin_auth(ADMIN_KEY)
76
- async def admin_home(key: str = None, authorization: str = Header(None), ...):
77
- ...
78
- """
79
- def decorator(func):
80
- @wraps(func)
81
- async def wrapper(*args, key: str = None, authorization: str = Header(None), **kwargs):
82
- admin_key = extract_admin_key(key, authorization)
83
- if admin_key != admin_key_value:
84
- # 返回 404 而不是 401,假装端点不存在(安全性考虑)
85
- raise HTTPException(404, "Not Found")
86
- return await func(*args, key=key, authorization=authorization, **kwargs)
87
- return wrapper
88
- return decorator
89
-
90
-
91
- def require_path_and_admin(path_prefix_value: str, admin_key_value: str):
92
- """
93
- 同时验证路径前缀和管理员权限的组合装饰器
94
 
95
  Args:
96
- path_prefix_value: 正确路径前缀
97
- admin_key_value: 正确管理员密钥
98
 
99
  Returns:
100
- 装饰器函数
101
-
102
- Example:
103
- @app.get("/{path_prefix}/admin")
104
- @require_path_and_admin(PATH_PREFIX, ADMIN_KEY)
105
- async def admin_home(path_prefix: str, key: str = None, authorization: str = Header(None), ...):
106
- ...
107
- """
108
- def decorator(func):
109
- @wraps(func)
110
- async def wrapper(*args, path_prefix: str, key: str = None, authorization: str = Header(None), **kwargs):
111
- # 验证路径前缀
112
- if path_prefix != path_prefix_value:
113
- raise HTTPException(404, "Not Found")
114
-
115
- # 验证管理员密钥
116
- admin_key = extract_admin_key(key, authorization)
117
- if admin_key != admin_key_value:
118
- raise HTTPException(404, "Not Found")
119
-
120
- return await func(*args, path_prefix=path_prefix, key=key, authorization=authorization, **kwargs)
121
- return wrapper
122
- return decorator
 
 
 
 
 
 
 
1
  """
2
+ API认证模块
3
+ 提供API Key验证功能(用于API端点)
4
+ 管理端点使用Session认证(见core/session_auth.py)
5
  """
 
 
6
  from typing import Optional
7
+ from fastapi import HTTPException
8
 
9
 
10
+ def verify_api_key(api_key_value: str, authorization: Optional[str] = None) -> bool:
11
  """
12
+ 验证 API Key
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
  Args:
15
+ api_key_value: 配置API Key(如果为空则跳过验证)
16
+ authorization: Authorization Header中
17
 
18
  Returns:
19
+ 验证通过返回True,否则抛出HTTPException
20
+
21
+ 支持格式:
22
+ 1. Bearer YOUR_API_KEY
23
+ 2. YOUR_API_KEY
24
+ """
25
+ # 如果未配置 API_KEY,则跳过验证
26
+ if not api_key_value:
27
+ return True
28
+
29
+ # 检查 Authorization header
30
+ if not authorization:
31
+ raise HTTPException(
32
+ status_code=401,
33
+ detail="Missing Authorization header"
34
+ )
35
+
36
+ # 提取token(支持Bearer格式)
37
+ token = authorization
38
+ if authorization.startswith("Bearer "):
39
+ token = authorization[7:]
40
+
41
+ if token != api_key_value:
42
+ raise HTTPException(
43
+ status_code=401,
44
+ detail="Invalid API Key"
45
+ )
46
+
47
+ return True
core/google_api.py ADDED
@@ -0,0 +1,304 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Google API交互模块
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
13
+ from fastapi import HTTPException
14
+
15
+ if TYPE_CHECKING:
16
+ from main import AccountManager
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ # Google API 基础URL
21
+ GEMINI_API_BASE = "https://biz-discoveryengine.googleapis.com/v1alpha"
22
+
23
+
24
+ def get_common_headers(jwt: str, user_agent: str) -> dict:
25
+ """生成通用请求头"""
26
+ return {
27
+ "accept": "*/*",
28
+ "accept-encoding": "gzip, deflate, br, zstd",
29
+ "accept-language": "zh-CN,zh;q=0.9,en;q=0.8",
30
+ "authorization": f"Bearer {jwt}",
31
+ "content-type": "application/json",
32
+ "origin": "https://business.gemini.google",
33
+ "referer": "https://business.gemini.google/",
34
+ "user-agent": user_agent,
35
+ "x-server-timeout": "1800",
36
+ "sec-ch-ua": '"Chromium";v="124", "Google Chrome";v="124", "Not-A.Brand";v="99"',
37
+ "sec-ch-ua-mobile": "?0",
38
+ "sec-ch-ua-platform": '"Windows"',
39
+ "sec-fetch-dest": "empty",
40
+ "sec-fetch-mode": "cors",
41
+ "sec-fetch-site": "cross-site",
42
+ }
43
+
44
+
45
+ async def make_request_with_jwt_retry(
46
+ account_mgr: "AccountManager",
47
+ method: str,
48
+ url: str,
49
+ http_client: httpx.AsyncClient,
50
+ user_agent: str,
51
+ request_id: str = "",
52
+ **kwargs
53
+ ) -> httpx.Response:
54
+ """通用HTTP请求,自动处理JWT过期重试
55
+
56
+ Args:
57
+ account_mgr: AccountManager实例
58
+ method: HTTP方法 (GET/POST)
59
+ url: 请求URL
60
+ http_client: httpx客户端
61
+ user_agent: User-Agent字符串
62
+ request_id: 请求ID(用于日志)
63
+ **kwargs: 传递给httpx的其他参数(如json, headers等)
64
+
65
+ Returns:
66
+ httpx.Response对象
67
+ """
68
+ jwt = await account_mgr.get_jwt(request_id)
69
+ headers = get_common_headers(jwt, user_agent)
70
+
71
+ # 合并用户提供的headers(如果有)
72
+ if "headers" in kwargs:
73
+ headers.update(kwargs.pop("headers"))
74
+
75
+ # 发起请求
76
+ if method.upper() == "GET":
77
+ resp = await http_client.get(url, headers=headers, **kwargs)
78
+ elif method.upper() == "POST":
79
+ resp = await http_client.post(url, headers=headers, **kwargs)
80
+ else:
81
+ raise ValueError(f"Unsupported HTTP method: {method}")
82
+
83
+ # 如果401,刷新JWT后重试一次
84
+ if resp.status_code == 401:
85
+ jwt = await account_mgr.get_jwt(request_id)
86
+ headers = get_common_headers(jwt, user_agent)
87
+ if "headers" in kwargs:
88
+ headers.update(kwargs["headers"])
89
+
90
+ if method.upper() == "GET":
91
+ resp = await http_client.get(url, headers=headers, **kwargs)
92
+ elif method.upper() == "POST":
93
+ resp = await http_client.post(url, headers=headers, **kwargs)
94
+
95
+ return resp
96
+
97
+
98
+ async def create_google_session(
99
+ account_manager: "AccountManager",
100
+ http_client: httpx.AsyncClient,
101
+ user_agent: str,
102
+ request_id: str = ""
103
+ ) -> str:
104
+ """创建Google Session"""
105
+ jwt = await account_manager.get_jwt(request_id)
106
+ headers = get_common_headers(jwt, user_agent)
107
+ body = {
108
+ "configId": account_manager.config.config_id,
109
+ "additionalParams": {"token": "-"},
110
+ "createSessionRequest": {
111
+ "session": {"name": "", "displayName": ""}
112
+ }
113
+ }
114
+
115
+ req_tag = f"[req_{request_id}] " if request_id else ""
116
+ r = await http_client.post(
117
+ f"{GEMINI_API_BASE}/locations/global/widgetCreateSession",
118
+ headers=headers,
119
+ json=body,
120
+ )
121
+ if r.status_code != 200:
122
+ logger.error(f"[SESSION] [{account_manager.config.account_id}] {req_tag}Session 创建失败: {r.status_code}")
123
+ raise HTTPException(r.status_code, "createSession failed")
124
+ sess_name = r.json()["session"]["name"]
125
+ logger.info(f"[SESSION] [{account_manager.config.account_id}] {req_tag}创建成功: {sess_name[-12:]}")
126
+ return sess_name
127
+
128
+
129
+ async def upload_context_file(
130
+ session_name: str,
131
+ mime_type: str,
132
+ base64_content: str,
133
+ account_manager: "AccountManager",
134
+ http_client: httpx.AsyncClient,
135
+ user_agent: str,
136
+ request_id: str = ""
137
+ ) -> str:
138
+ """上传文件到指定 Session,返回 fileId"""
139
+ jwt = await account_manager.get_jwt(request_id)
140
+ headers = get_common_headers(jwt, user_agent)
141
+
142
+ # 生成随机文件名
143
+ ext = mime_type.split('/')[-1] if '/' in mime_type else "bin"
144
+ file_name = f"upload_{int(time.time())}_{uuid.uuid4().hex[:6]}.{ext}"
145
+
146
+ body = {
147
+ "configId": account_manager.config.config_id,
148
+ "additionalParams": {"token": "-"},
149
+ "addContextFileRequest": {
150
+ "name": session_name,
151
+ "fileName": file_name,
152
+ "mimeType": mime_type,
153
+ "fileContents": base64_content
154
+ }
155
+ }
156
+
157
+ r = await http_client.post(
158
+ f"{GEMINI_API_BASE}/locations/global/widgetAddContextFile",
159
+ headers=headers,
160
+ json=body,
161
+ )
162
+
163
+ req_tag = f"[req_{request_id}] " if request_id else ""
164
+ if r.status_code != 200:
165
+ logger.error(f"[FILE] [{account_manager.config.account_id}] {req_tag}文件上传失败: {r.status_code}")
166
+ raise HTTPException(r.status_code, f"Upload failed: {r.text}")
167
+
168
+ data = r.json()
169
+ file_id = data.get("addContextFileResponse", {}).get("fileId")
170
+ logger.info(f"[FILE] [{account_manager.config.account_id}] {req_tag}文件上传成功: {mime_type}")
171
+ return file_id
172
+
173
+
174
+ async def get_session_file_metadata(
175
+ account_mgr: "AccountManager",
176
+ session_name: str,
177
+ http_client: httpx.AsyncClient,
178
+ user_agent: str,
179
+ request_id: str = ""
180
+ ) -> dict:
181
+ """获取session中的文件元数据,包括正确的session路径"""
182
+ body = {
183
+ "configId": account_mgr.config.config_id,
184
+ "additionalParams": {"token": "-"},
185
+ "listSessionFileMetadataRequest": {
186
+ "name": session_name,
187
+ "filter": "file_origin_type = AI_GENERATED"
188
+ }
189
+ }
190
+
191
+ resp = await make_request_with_jwt_retry(
192
+ account_mgr,
193
+ "POST",
194
+ f"{GEMINI_API_BASE}/locations/global/widgetListSessionFileMetadata",
195
+ http_client,
196
+ user_agent,
197
+ request_id,
198
+ json=body
199
+ )
200
+
201
+ if resp.status_code != 200:
202
+ logger.warning(f"[IMAGE] [{account_mgr.config.account_id}] [req_{request_id}] 获取文件元数据失败: {resp.status_code}")
203
+ return {}
204
+
205
+ data = resp.json()
206
+ result = {}
207
+ file_metadata_list = data.get("listSessionFileMetadataResponse", {}).get("fileMetadata", [])
208
+
209
+ for fm in file_metadata_list:
210
+ fid = fm.get("fileId")
211
+ if fid:
212
+ result[fid] = fm
213
+
214
+ return result
215
+
216
+
217
+ def build_image_download_url(session_name: str, file_id: str) -> str:
218
+ """构造图片下载URL"""
219
+ return f"{GEMINI_API_BASE}/{session_name}:downloadFile?fileId={file_id}&alt=media"
220
+
221
+
222
+ async def download_image_with_jwt(
223
+ account_mgr: "AccountManager",
224
+ session_name: str,
225
+ file_id: str,
226
+ http_client: httpx.AsyncClient,
227
+ user_agent: str,
228
+ request_id: str = "",
229
+ max_retries: int = 3
230
+ ) -> bytes:
231
+ """
232
+ 使用JWT认证下载图片(带超时和重试机制)
233
+
234
+ Args:
235
+ account_mgr: 账户管理器
236
+ session_name: Session名称
237
+ file_id: 文件ID
238
+ http_client: httpx客户端
239
+ user_agent: User-Agent字符串
240
+ request_id: 请求ID
241
+ max_retries: 最大重试次数(默认3次)
242
+
243
+ Returns:
244
+ 图片字节数据
245
+
246
+ Raises:
247
+ HTTPException: 下载失败
248
+ asyncio.TimeoutError: 超时
249
+ """
250
+ url = build_image_download_url(session_name, file_id)
251
+ logger.info(f"[IMAGE] [{account_mgr.config.account_id}] [req_{request_id}] 开始下载图片: {file_id[:8]}...")
252
+
253
+ for attempt in range(max_retries):
254
+ try:
255
+ # 3分钟超时(180秒)
256
+ async with asyncio.timeout(180):
257
+ # 使用通用JWT刷新函数
258
+ resp = await make_request_with_jwt_retry(
259
+ account_mgr,
260
+ "GET",
261
+ url,
262
+ http_client,
263
+ user_agent,
264
+ request_id,
265
+ follow_redirects=True
266
+ )
267
+
268
+ resp.raise_for_status()
269
+ logger.info(f"[IMAGE] [{account_mgr.config.account_id}] [req_{request_id}] 图片下载成功: {file_id[:8]}... ({len(resp.content)} bytes)")
270
+ return resp.content
271
+
272
+ except asyncio.TimeoutError:
273
+ logger.warning(f"[IMAGE] [{account_mgr.config.account_id}] [req_{request_id}] 图片下载超时 (尝试 {attempt + 1}/{max_retries}): {file_id[:8]}...")
274
+ if attempt == max_retries - 1:
275
+ raise HTTPException(504, f"Image download timeout after {max_retries} attempts")
276
+ await asyncio.sleep(2 ** attempt) # 指数退避:2s, 4s, 8s
277
+
278
+ except httpx.HTTPError as e:
279
+ logger.warning(f"[IMAGE] [{account_mgr.config.account_id}] [req_{request_id}] 图片下载失败 (尝试 {attempt + 1}/{max_retries}): {type(e).__name__}")
280
+ if attempt == max_retries - 1:
281
+ raise HTTPException(500, f"Image download failed: {str(e)[:100]}")
282
+ await asyncio.sleep(2 ** attempt) # 指数退避
283
+
284
+ except Exception as e:
285
+ logger.error(f"[IMAGE] [{account_mgr.config.account_id}] [req_{request_id}] 图片下载异常: {type(e).__name__}: {str(e)[:100]}")
286
+ raise
287
+
288
+ # 不应该到达这里
289
+ raise HTTPException(500, "Image download failed unexpectedly")
290
+
291
+
292
+ def save_image_to_hf(image_data: bytes, chat_id: str, file_id: str, mime_type: str, base_url: str, image_dir: str) -> str:
293
+ """保存图片到持久化存储,返回完整的公开URL"""
294
+ ext_map = {"image/png": ".png", "image/jpeg": ".jpg", "image/gif": ".gif", "image/webp": ".webp"}
295
+ ext = ext_map.get(mime_type, ".png")
296
+
297
+ filename = f"{chat_id}_{file_id}{ext}"
298
+ save_path = os.path.join(image_dir, filename)
299
+
300
+ # 目录已在启动时创建,无需重复创建
301
+ with open(save_path, "wb") as f:
302
+ f.write(image_data)
303
+
304
+ return f"{base_url}/images/{filename}"
core/jwt.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """JWT管理模块
2
+
3
+ 负责JWT token的生成、刷新和管理
4
+ """
5
+ import asyncio
6
+ import base64
7
+ import hashlib
8
+ import hmac
9
+ import json
10
+ import logging
11
+ import time
12
+ from typing import TYPE_CHECKING
13
+
14
+ import httpx
15
+ from fastapi import HTTPException
16
+
17
+ if TYPE_CHECKING:
18
+ from main import AccountConfig
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ def urlsafe_b64encode(data: bytes) -> str:
24
+ return base64.urlsafe_b64encode(data).decode().rstrip("=")
25
+
26
+ def kq_encode(s: str) -> str:
27
+ b = bytearray()
28
+ for ch in s:
29
+ v = ord(ch)
30
+ if v > 255:
31
+ b.append(v & 255)
32
+ b.append(v >> 8)
33
+ else:
34
+ b.append(v)
35
+ return urlsafe_b64encode(bytes(b))
36
+
37
+ def create_jwt(key_bytes: bytes, key_id: str, csesidx: str) -> str:
38
+ now = int(time.time())
39
+ header = {"alg": "HS256", "typ": "JWT", "kid": key_id}
40
+ payload = {
41
+ "iss": "https://business.gemini.google",
42
+ "aud": "https://biz-discoveryengine.googleapis.com",
43
+ "sub": f"csesidx/{csesidx}",
44
+ "iat": now,
45
+ "exp": now + 300,
46
+ "nbf": now,
47
+ }
48
+ header_b64 = kq_encode(json.dumps(header, separators=(",", ":")))
49
+ payload_b64 = kq_encode(json.dumps(payload, separators=(",", ":")))
50
+ message = f"{header_b64}.{payload_b64}"
51
+ sig = hmac.new(key_bytes, message.encode(), hashlib.sha256).digest()
52
+ return f"{message}.{urlsafe_b64encode(sig)}"
53
+
54
+
55
+ class JWTManager:
56
+ """JWT token管理器
57
+
58
+ 负责JWT的获取、刷新和缓存
59
+ """
60
+ def __init__(self, config: "AccountConfig", http_client: httpx.AsyncClient, user_agent: str) -> None:
61
+ self.config = config
62
+ self.http_client = http_client
63
+ self.user_agent = user_agent
64
+ self.jwt: str = ""
65
+ self.expires: float = 0
66
+ self._lock = asyncio.Lock()
67
+
68
+ async def get(self, request_id: str = "") -> str:
69
+ """获取JWT token(自动刷新)"""
70
+ async with self._lock:
71
+ if time.time() > self.expires:
72
+ await self._refresh(request_id)
73
+ return self.jwt
74
+
75
+ async def _refresh(self, request_id: str = "") -> None:
76
+ """刷新JWT token"""
77
+ cookie = f"__Secure-C_SES={self.config.secure_c_ses}"
78
+ if self.config.host_c_oses:
79
+ cookie += f"; __Host-C_OSES={self.config.host_c_oses}"
80
+
81
+ req_tag = f"[req_{request_id}] " if request_id else ""
82
+ r = await self.http_client.get(
83
+ "https://business.gemini.google/auth/getoxsrf",
84
+ params={"csesidx": self.config.csesidx},
85
+ headers={
86
+ "cookie": cookie,
87
+ "user-agent": self.user_agent,
88
+ "referer": "https://business.gemini.google/"
89
+ },
90
+ )
91
+ if r.status_code != 200:
92
+ logger.error(f"[AUTH] [{self.config.account_id}] {req_tag}JWT 刷新失败: {r.status_code}")
93
+ raise HTTPException(r.status_code, "getoxsrf failed")
94
+
95
+ txt = r.text[4:] if r.text.startswith(")]}'") else r.text
96
+ data = json.loads(txt)
97
+
98
+ key_bytes = base64.urlsafe_b64decode(data["xsrfToken"] + "==")
99
+ self.jwt = create_jwt(key_bytes, data["keyId"], self.config.csesidx)
100
+ self.expires = time.time() + 270
101
+ logger.info(f"[AUTH] [{self.config.account_id}] {req_tag}JWT 刷新成功")
core/message.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """消息处理模块
2
+
3
+ 负责消息的解析、文本提取和会话指纹生成
4
+ """
5
+ import asyncio
6
+ import base64
7
+ import hashlib
8
+ import logging
9
+ import re
10
+ from typing import List, TYPE_CHECKING
11
+
12
+ import httpx
13
+
14
+ if TYPE_CHECKING:
15
+ from main import Message
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ def get_conversation_key(messages: List[dict], client_identifier: str = "") -> str:
21
+ """
22
+ 生成对话指纹(使用前3条消息+客户端标识,确保唯一性)
23
+
24
+ 策略:
25
+ 1. 使用前3条消息生成指纹(而非仅第1条)
26
+ 2. 加入客户端标识(IP或request_id)避免不同用户冲突
27
+ 3. 保持Session复用能力(同一用户的后续消息仍能找到同一Session)
28
+
29
+ Args:
30
+ messages: 消息列表
31
+ client_identifier: 客户端标识(如IP地址或request_id),用于区分不同用户
32
+ """
33
+ if not messages:
34
+ return f"{client_identifier}:empty" if client_identifier else "empty"
35
+
36
+ # 提取前3条消息的关键信息(角色+内容)
37
+ message_fingerprints = []
38
+ for msg in messages[:3]: # 只取前3条
39
+ role = msg.get("role", "")
40
+ content = msg.get("content", "")
41
+
42
+ # 统一处理内容格式(字符串或数组)
43
+ if isinstance(content, list):
44
+ # 多模态消息:只提取文本部分
45
+ text = extract_text_from_content(content)
46
+ else:
47
+ text = str(content)
48
+
49
+ # 标准化:去除首尾空白,转小写
50
+ text = text.strip().lower()
51
+
52
+ # 组合角色和内容
53
+ message_fingerprints.append(f"{role}:{text}")
54
+
55
+ # 使用前3条消息+客户端标识生成指纹
56
+ conversation_prefix = "|".join(message_fingerprints)
57
+ if client_identifier:
58
+ conversation_prefix = f"{client_identifier}|{conversation_prefix}"
59
+
60
+ return hashlib.md5(conversation_prefix.encode()).hexdigest()
61
+
62
+
63
+ def extract_text_from_content(content) -> str:
64
+ """
65
+ 从消息 content 中提取文本内容
66
+ 统一处理字符串和多模态数组格式
67
+ """
68
+ if isinstance(content, str):
69
+ return content
70
+ elif isinstance(content, list):
71
+ # 多模态消息:只提取文本部分
72
+ return "".join([x.get("text", "") for x in content if x.get("type") == "text"])
73
+ else:
74
+ return str(content)
75
+
76
+
77
+ async def parse_last_message(messages: List['Message'], http_client: httpx.AsyncClient, request_id: str = ""):
78
+ """解析最后一条消息,分离文本和文件(支持图片、PDF、文档等,base64 和 URL)"""
79
+ if not messages:
80
+ return "", []
81
+
82
+ last_msg = messages[-1]
83
+ content = last_msg.content
84
+
85
+ text_content = ""
86
+ images = [] # List of {"mime": str, "data": str_base64} - 兼容变量名,实际支持所有文件
87
+ image_urls = [] # 需要下载的 URL - 兼容变量名,实际支持所有文件
88
+
89
+ if isinstance(content, str):
90
+ text_content = content
91
+ elif isinstance(content, list):
92
+ for part in content:
93
+ if part.get("type") == "text":
94
+ text_content += part.get("text", "")
95
+ elif part.get("type") == "image_url":
96
+ url = part.get("image_url", {}).get("url", "")
97
+ # 解析 Data URI: data:mime/type;base64,xxxxxx (支持所有 MIME 类型)
98
+ match = re.match(r"data:([^;]+);base64,(.+)", url)
99
+ if match:
100
+ images.append({"mime": match.group(1), "data": match.group(2)})
101
+ elif url.startswith(("http://", "https://")):
102
+ image_urls.append(url)
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
+
126
+
127
+ def build_full_context_text(messages: List['Message']) -> str:
128
+ """仅拼接历史文本,图片只处理当��请求的"""
129
+ prompt = ""
130
+ for msg in messages:
131
+ role = "User" if msg.role in ["user", "system"] else "Assistant"
132
+ content_str = extract_text_from_content(msg.content)
133
+
134
+ # 为多模态消息添加图片标记
135
+ if isinstance(msg.content, list):
136
+ image_count = sum(1 for part in msg.content if part.get("type") == "image_url")
137
+ if image_count > 0:
138
+ content_str += "[图片]" * image_count
139
+
140
+ prompt += f"{role}: {content_str}\n\n"
141
+ return prompt
core/session_auth.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Session认证模块
3
+ 提供基于Session的登录认证功能
4
+ """
5
+ import secrets
6
+ from functools import wraps
7
+ from typing import Optional
8
+ from fastapi import HTTPException, Request, Response
9
+ from fastapi.responses import RedirectResponse
10
+
11
+
12
+ def generate_session_secret() -> str:
13
+ """生成随机的session密钥"""
14
+ return secrets.token_hex(32)
15
+
16
+
17
+ def is_logged_in(request: Request) -> bool:
18
+ """检查用户是否已登录"""
19
+ return request.session.get("authenticated", False)
20
+
21
+
22
+ def login_user(request: Request):
23
+ """标记用户为已登录状态"""
24
+ request.session["authenticated"] = True
25
+
26
+
27
+ def logout_user(request: Request):
28
+ """清除用户登录状态"""
29
+ request.session.clear()
30
+
31
+
32
+ def require_login(redirect_to_login: bool = True):
33
+ """
34
+ 要求用户登录的装饰器
35
+
36
+ Args:
37
+ redirect_to_login: 未登录时是否重定向到登录页面(默认True)
38
+ False时返回404错误
39
+ """
40
+ def decorator(func):
41
+ @wraps(func)
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 = "/admin/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
65
+ return decorator
core/templates.py CHANGED
@@ -27,26 +27,14 @@ def generate_admin_html(request: Request, multi_account_mgr, show_hide_tip: bool
27
  error_count += 1
28
 
29
  # --- 1. 构建提示信息 ---
30
- hide_tip = ""
31
- if show_hide_tip:
32
- hide_tip = """
33
- <div class="alert alert-info">
34
- <div class="alert-icon">💡</div>
35
- <div class="alert-content">
36
- <strong>提示</strong>:此页面默认在首页显示。如需隐藏,请设置环境变量:<br>
37
- <code style="margin-top:4px; display:inline-block;">main.HIDE_HOME_PAGE=true</code>
38
- </div>
39
- </div>
40
- """
41
-
42
  api_key_status = ""
43
  if main.API_KEY:
44
  api_key_status = """
45
  <div class="alert alert-success">
46
  <div class="alert-icon">🔒</div>
47
  <div class="alert-content">
48
- <strong>安全模式已启用</strong>
49
- <div class="alert-desc">请求 Header 需携带 Authorization 密钥。</div>
50
  </div>
51
  </div>
52
  """
@@ -55,8 +43,8 @@ def generate_admin_html(request: Request, multi_account_mgr, show_hide_tip: bool
55
  <div class="alert alert-warning">
56
  <div class="alert-icon">⚠️</div>
57
  <div class="alert-content">
58
- <strong>API Key 未设置</strong>
59
- <div class="alert-desc">API 当前允许公开访问建议配置 main.API_KEY。</div>
60
  </div>
61
  </div>
62
  """
@@ -74,7 +62,16 @@ def generate_admin_html(request: Request, multi_account_mgr, show_hide_tip: bool
74
  """
75
 
76
  # API接口信息提示
77
- api_endpoint = f"{current_url}/{main.PATH_PREFIX}/v1/chat/completions"
 
 
 
 
 
 
 
 
 
78
  api_key_display = main.API_KEY if main.API_KEY else '<span style="color: #ff9500;">未设置(公开访问)</span>'
79
 
80
  api_info_tip = f"""
@@ -84,7 +81,15 @@ def generate_admin_html(request: Request, multi_account_mgr, show_hide_tip: bool
84
  <strong>API 接口信息</strong>
85
  <div style="margin-top: 10px;">
86
  <div style="margin-bottom: 12px;">
87
- <div style="color: #86868b; font-size: 11px; margin-bottom: 4px;">聊天接口</div>
 
 
 
 
 
 
 
 
88
  <code style="font-size: 11px; background: rgba(0,0,0,0.05); padding: 4px 8px; border-radius: 4px; display: inline-block; word-break: break-all;">{api_endpoint}</code>
89
  </div>
90
  <div style="margin-bottom: 12px;">
@@ -114,8 +119,8 @@ def generate_admin_html(request: Request, multi_account_mgr, show_hide_tip: bool
114
  </div>
115
  """
116
 
117
- # --- 2. 构建账户卡片 ---
118
- accounts_html = ""
119
  for account_id, account_manager in multi_account_mgr.accounts.items():
120
  config = account_manager.config
121
  remaining_hours = config.get_remaining_hours()
@@ -133,36 +138,36 @@ def generate_admin_html(request: Request, multi_account_mgr, show_hide_tip: bool
133
  status_text = "过期禁用"
134
  status_color = "#9e9e9e"
135
  dot_color = "#9e9e9e"
136
- card_opacity = "0.5"
137
- action_buttons = f'<button onclick="deleteAccount(\'{config.account_id}\')" class="delete-btn" title="删除账户">删除</button>'
138
  elif is_disabled:
139
  status_text = "手动禁用"
140
  status_color = "#9e9e9e"
141
  dot_color = "#9e9e9e"
142
- card_opacity = "0.5"
143
  action_buttons = f'''
144
- <button onclick="enableAccount('{config.account_id}')" class="enable-btn" title="启用账户">启用</button>
145
- <button onclick="deleteAccount('{config.account_id}')" class="delete-btn" title="删除账户">删除</button>
146
  '''
147
  elif cooldown_seconds == -1:
148
  # 错误永久禁用
149
  status_text = cooldown_reason # "错误禁用"
150
  status_color = "#f44336"
151
  dot_color = "#f44336"
152
- card_opacity = "0.5"
153
  action_buttons = f'''
154
- <button onclick="enableAccount('{config.account_id}')" class="enable-btn" title="启用账户">启用</button>
155
- <button onclick="deleteAccount('{config.account_id}')" class="delete-btn" title="删除账户">删除</button>
156
  '''
157
  elif cooldown_seconds > 0:
158
  # 429限流(冷却中)
159
- status_text = cooldown_reason # "429限流"
160
  status_color = "#ff9800"
161
  dot_color = "#ff9800"
162
- card_opacity = "1"
163
  action_buttons = f'''
164
- <button onclick="disableAccount('{config.account_id}')" class="disable-btn" title="禁用账户">禁用</button>
165
- <button onclick="deleteAccount('{config.account_id}')" class="delete-btn" title="删除账户">删除</button>
166
  '''
167
  else:
168
  # 正常状态
@@ -182,43 +187,60 @@ def generate_admin_html(request: Request, multi_account_mgr, show_hide_tip: bool
182
  status_text = "不可用"
183
  status_color = "#f44336"
184
  dot_color = "#ff3b30"
185
- card_opacity = "1"
186
  action_buttons = f'''
187
- <button onclick="disableAccount('{config.account_id}')" class="disable-btn" title="禁用账户">禁用</button>
188
- <button onclick="deleteAccount('{config.account_id}')" class="delete-btn" title="删除账户">删除</button>
189
  '''
190
 
191
- # 构建卡片内容
192
- accounts_html += f"""
193
- <div class="card account-card" style="opacity: {card_opacity}; background: {'#f5f5f5' if float(card_opacity) < 1 else '#fafaf9'};">
194
- <div class="acc-header">
195
- <div class="acc-title">
196
- <span class="status-dot" style="background-color: {dot_color};"></span>
197
- <span>{config.account_id}</span>
198
- </div>
199
- <span class="acc-status" style="color: {status_color};">{status_text}</span>
200
- </div>
201
- <div class="acc-actions">
202
- {action_buttons}
203
- </div>
204
- <div class="acc-body">
205
- <div class="acc-row">
206
- <span>过期间</span>
207
- <span class="font-mono">{config.expires_at or '未设置'}</span>
208
- </div>
209
- <div class="acc-row">
210
- <span>剩余时长</span>
211
- <span style="color: {status_color};">{expire_display}</span>
212
- </div>
213
- <div class="acc-row">
214
- <span>累计对话</span>
215
- <span style="color: #2563eb; font-weight: 600;">{account_manager.conversation_count} 次</span>
216
- </div>
217
- {'<div class="acc-row cooldown-row"><span>冷却倒计时</span><span class="cooldown-text" style="color: ' + status_color + ';">' + str(cooldown_seconds) + '秒 (' + cooldown_reason + ')</span></div>' if cooldown_seconds > 0 else ''}
218
- </div>
219
- </div>
220
  """
221
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
  # --- 3. 构建 HTML ---
223
  html_content = f"""
224
  <!DOCTYPE html>
@@ -326,7 +348,6 @@ def generate_admin_html(request: Request, multi_account_mgr, show_hide_tip: bool
326
  }}
327
  .grid-3 {{ display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; align-items: start; }}
328
  .grid-env {{ display: grid; grid-template-columns: 1fr 1fr; gap: 16px; align-items: start; }}
329
- .account-grid {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }}
330
  .stack-col {{ display: flex; flex-direction: column; gap: 16px; }}
331
 
332
  /* Cards */
@@ -349,67 +370,124 @@ def generate_admin_html(request: Request, multi_account_mgr, show_hide_tip: bool
349
  letter-spacing: 0.5px;
350
  }}
351
 
352
- /* Account & Env Styles */
353
- .account-card .acc-header {{ display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; padding-bottom: 12px; border-bottom: 1px solid #f5f5f5; }}
354
- .acc-title {{ font-weight: 600; font-size: 14px; display: flex; align-items: center; gap: 8px; overflow: hidden; }}
355
- .acc-title span:last-child {{ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 120px; }}
356
- .status-dot {{ width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }}
357
- .acc-status {{ font-size: 12px; font-weight: 600; }}
358
- .acc-actions {{ display: flex; gap: 8px; margin-bottom: 12px; padding-bottom: 12px; border-bottom: 1px solid #f5f5f5; }}
359
- .acc-body {{ }}
360
- .acc-row {{ display: flex; justify-content: space-between; font-size: 12px; margin-top: 6px; color: var(--text-sec); }}
361
- .cooldown-row {{ background: #fff8e6; padding: 8px; border-radius: 6px; margin-top: 8px; }}
362
- .cooldown-text {{ color: #f59e0b; font-weight: 600; }}
363
-
364
- /* Delete Button */
365
- .delete-btn {{
366
  background: #fff;
367
- color: #dc2626;
368
- border: 1px solid #fecaca;
369
- padding: 4px 12px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
370
  border-radius: 6px;
371
  font-size: 11px;
372
  cursor: pointer;
373
  font-weight: 500;
374
  transition: all 0.2s;
 
375
  }}
376
- .delete-btn:hover {{
 
 
 
 
 
377
  background: #dc2626;
378
  color: white;
379
  border-color: #dc2626;
380
  }}
381
-
382
- /* Disable Button */
383
- .disable-btn {{
384
  background: #fff;
385
  color: #f59e0b;
386
- border: 1px solid #fed7aa;
387
- padding: 4px 12px;
388
- border-radius: 6px;
389
- font-size: 11px;
390
- cursor: pointer;
391
- font-weight: 500;
392
- transition: all 0.2s;
393
  }}
394
- .disable-btn:hover {{
395
  background: #f59e0b;
396
  color: white;
397
  border-color: #f59e0b;
398
  }}
399
-
400
- /* Enable Button */
401
- .enable-btn {{
402
  background: #fff;
403
  color: #10b981;
404
- border: 1px solid #a7f3d0;
405
- padding: 4px 12px;
406
- border-radius: 6px;
407
- font-size: 11px;
408
- cursor: pointer;
409
- font-weight: 500;
410
- transition: all 0.2s;
411
  }}
412
- .enable-btn:hover {{
413
  background: #10b981;
414
  color: white;
415
  border-color: #10b981;
@@ -565,10 +643,18 @@ def generate_admin_html(request: Request, multi_account_mgr, show_hide_tip: bool
565
  .info-box-title {{ font-weight: 600; font-size: 12px; color: #1d1d1f; margin-bottom: 6px; }}
566
  .info-box-text {{ font-size: 12px; color: #86868b; line-height: 1.5; }}
567
 
568
- .ep-table {{ width: 100%; border-collapse: collapse; font-size: 13px; }}
 
 
 
 
 
 
 
 
569
  .ep-table tr {{ border-bottom: 1px solid #f5f5f5; }}
570
  .ep-table tr:last-child {{ border-bottom: none; }}
571
- .ep-table td {{ padding: 10px 0; vertical-align: middle; }}
572
 
573
  .method {{
574
  display: inline-block;
@@ -605,6 +691,116 @@ def generate_admin_html(request: Request, multi_account_mgr, show_hide_tip: bool
605
  .header-actions .btn {{ justify-content: center; text-align: center; }}
606
  .ep-table td {{ display: flex; flex-direction: column; align-items: flex-start; gap: 4px; }}
607
  .ep-desc {{ margin-left: 0; }}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
608
  }}
609
  </style>
610
  </head>
@@ -618,35 +814,133 @@ def generate_admin_html(request: Request, multi_account_mgr, show_hide_tip: bool
618
  <div class="header-actions">
619
  <a href="/public/uptime/html" class="btn" target="_blank">📊 状态监控</a>
620
  <a href="/public/log/html" class="btn" target="_blank">📄 公开日志</a>
621
- <a href="/{main.PATH_PREFIX}/admin/log/html?key={main.ADMIN_KEY}" class="btn" target="_blank">🔧 管理日志</a>
622
  <button class="btn" onclick="document.getElementById('fileInput').click()">📥 批量上传</button>
623
  <input type="file" id="fileInput" accept=".json" multiple style="display:none" onchange="handleFileUpload(event)">
624
  <button class="btn" onclick="showEditConfig()" id="edit-btn">✏️ 编辑配置</button>
625
  </div>
626
  </div>
627
 
628
- {hide_tip}
629
- {api_key_status}
630
- {error_alert}
631
- {api_info_tip}
 
 
 
 
 
 
 
 
632
 
633
- <div class="section">
634
- <div class="section-title">账户状态 ({len(multi_account_mgr.accounts)} 个)</div>
635
- <div style="color: #6b6b6b; font-size: 12px; margin-bottom: 12px; padding-left: 4px;">
636
- 过期时间12小时,可以自行修改时间,脚本可能有误差。<br>
637
- 批量上传格式:<code style="font-size: 11px;">[{{"secure_c_ses": "...", "csesidx": "...", "config_id": "...", "id": "account_1"}}]</code>(id 可选)
 
638
  </div>
639
- <div class="account-grid">
640
- {accounts_html if accounts_html else '<div class="card"><p style="color: #6b6b6b; font-size: 14px; text-align:center;">暂无账户</p></div>'}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
641
  </div>
642
  </div>
643
 
644
- <div class="section">
645
- <div class="section-title">环境变量配置</div>
646
- <div class="grid-env">
647
- <div class="stack-col">
648
- <div class="card">
649
- <h3>必需变量 <span class="badge badge-required">REQUIRED</span></h3>
 
 
650
  <div style="margin-top: 12px;">
651
  <div class="env-var">
652
  <div><div class="env-name">ACCOUNTS_CONFIG</div><div class="env-desc">JSON格式账户列表</div></div>
@@ -720,18 +1014,13 @@ def generate_admin_html(request: Request, multi_account_mgr, show_hide_tip: bool
720
  <div><div class="env-name">MODEL_NAME</div><div class="env-desc">模型名称(公开)</div></div>
721
  <div class="env-value">{main.MODEL_NAME}</div>
722
  </div>
723
- <div class="env-var">
724
- <div><div class="env-name">HIDE_HOME_PAGE</div><div class="env-desc">隐藏首页管理面板</div></div>
725
- <div class="env-value">{'已隐藏' if main.HIDE_HOME_PAGE else '未隐藏'}</div>
726
- </div>
727
  </div>
728
  </div>
729
  </div>
730
  </div>
731
 
732
- <div class="section">
733
- <div class="section-title">服务信息</div>
734
- <div class="grid-3">
735
  <div class="card">
736
  <h3>支持的模型</h3>
737
  <div class="model-grid">
@@ -751,93 +1040,6 @@ def generate_admin_html(request: Request, multi_account_mgr, show_hide_tip: bool
751
  </div>
752
  </div>
753
  </div>
754
-
755
- <div class="card" style="grid-column: span 2;">
756
- <h3>API 端点</h3>
757
-
758
- <div class="current-url-row">
759
- <span style="font-size:12px; font-weight:600; color:#0071e3; margin-right:8px;">当前页面:</span>
760
- <code style="background:none; padding:0; color:#1d1d1f;">{current_url}</code>
761
- </div>
762
-
763
- <table class="ep-table">
764
- <tr>
765
- <td width="70"><span class="method m-post">POST</span></td>
766
- <td><span class="ep-path">/{main.PATH_PREFIX}/v1/chat/completions</span></td>
767
- <td><span class="ep-desc">OpenAI 兼容对话接口</span></td>
768
- </tr>
769
- <tr>
770
- <td><span class="method m-get">GET</span></td>
771
- <td><span class="ep-path">/{main.PATH_PREFIX}/v1/models</span></td>
772
- <td><span class="ep-desc">获取模型列表</span></td>
773
- </tr>
774
- <tr>
775
- <td><span class="method m-get">GET</span></td>
776
- <td><span class="ep-path">/{main.PATH_PREFIX}/admin</span></td>
777
- <td><span class="ep-desc">管理首页</span></td>
778
- </tr>
779
- <tr>
780
- <td><span class="method m-get">GET</span></td>
781
- <td><span class="ep-path">/{main.PATH_PREFIX}/admin/health?key={{main.ADMIN_KEY}}</span></td>
782
- <td><span class="ep-desc">健康检查 (需 Key)</span></td>
783
- </tr>
784
- <tr>
785
- <td><span class="method m-get">GET</span></td>
786
- <td><span class="ep-path">/{main.PATH_PREFIX}/admin/accounts?key={{main.ADMIN_KEY}}</span></td>
787
- <td><span class="ep-desc">账户状态 JSON (需 Key)</span></td>
788
- </tr>
789
- <tr>
790
- <td><span class="method m-get">GET</span></td>
791
- <td><span class="ep-path">/{main.PATH_PREFIX}/admin/log?key={{main.ADMIN_KEY}}</span></td>
792
- <td><span class="ep-desc">获取日志 JSON (需 Key)</span></td>
793
- </tr>
794
- <tr>
795
- <td><span class="method m-get">GET</span></td>
796
- <td><span class="ep-path">/{main.PATH_PREFIX}/admin/log/html?key={{main.ADMIN_KEY}}</span></td>
797
- <td><span class="ep-desc">日志查看器 HTML (需 Key)</span></td>
798
- </tr>
799
- <tr>
800
- <td><span class="method m-del">DEL</span></td>
801
- <td><span class="ep-path">/{main.PATH_PREFIX}/admin/log?confirm=yes&key={{main.ADMIN_KEY}}</span></td>
802
- <td><span class="ep-desc">清空系统日志 (需 Key)</span></td>
803
- </tr>
804
- <tr>
805
- <td><span class="method m-get">GET</span></td>
806
- <td><span class="ep-path">/public/stats</span></td>
807
- <td><span class="ep-desc">公开统计数据</span></td>
808
- </tr>
809
- <tr>
810
- <td><span class="method m-get">GET</span></td>
811
- <td><span class="ep-path">/public/log</span></td>
812
- <td><span class="ep-desc">公开日志 (JSON, 脱敏)</span></td>
813
- </tr>
814
- <tr>
815
- <td><span class="method m-get">GET</span></td>
816
- <td><span class="ep-path">/public/log/html</span></td>
817
- <td><span class="ep-desc">公开日志查看器 (HTML)</span></td>
818
- </tr>
819
- <tr>
820
- <td><span class="method m-get">GET</span></td>
821
- <td><span class="ep-path">/public/uptime</span></td>
822
- <td><span class="ep-desc">实时状态监控 (JSON)</span></td>
823
- </tr>
824
- <tr>
825
- <td><span class="method m-get">GET</span></td>
826
- <td><span class="ep-path">/public/uptime/html</span></td>
827
- <td><span class="ep-desc">实时状态监控页面 (HTML)</span></td>
828
- </tr>
829
- <tr>
830
- <td><span class="method m-get">GET</span></td>
831
- <td><span class="ep-path">/docs</span></td>
832
- <td><span class="ep-desc">Swagger API 文档</span></td>
833
- </tr>
834
- <tr>
835
- <td><span class="method m-get">GET</span></td>
836
- <td><span class="ep-path">/redoc</span></td>
837
- <td><span class="ep-desc">ReDoc API 文档</span></td>
838
- </tr>
839
- </table>
840
- </div>
841
  </div>
842
  </div>
843
  </div>
@@ -868,9 +1070,26 @@ def generate_admin_html(request: Request, multi_account_mgr, show_hide_tip: bool
868
  <script>
869
  let currentConfig = null;
870
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
871
  // 统一的页面刷新函数(避免缓存)
872
  function refreshPage() {{
873
- window.location.href = window.location.pathname + '?t=' + Date.now();
874
  }}
875
 
876
  // 统一的错误处理函数
@@ -890,7 +1109,7 @@ def generate_admin_html(request: Request, multi_account_mgr, show_hide_tip: bool
890
  }}
891
 
892
  async function showEditConfig() {{
893
- const config = await fetch('/{main.PATH_PREFIX}/admin/accounts-config?key={main.ADMIN_KEY}').then(r => r.json());
894
  currentConfig = config.accounts;
895
  const json = JSON.stringify(config.accounts, null, 2);
896
  document.getElementById('jsonEditor').value = json;
@@ -937,16 +1156,15 @@ def generate_admin_html(request: Request, multi_account_mgr, show_hide_tip: bool
937
 
938
  try {{
939
  const data = JSON.parse(newJson);
940
- const response = await fetch('/{main.PATH_PREFIX}/admin/accounts-config?key={main.ADMIN_KEY}', {{
941
  method: 'PUT',
942
  headers: {{'Content-Type': 'application/json'}},
943
  body: JSON.stringify(data)
944
  }});
945
 
946
- const result = await handleApiResponse(response);
947
- alert(`配置已更新!\\n当前账户数: ${{result.account_count}}`);
948
  closeModal();
949
- setTimeout(refreshPage, 1000);
950
  }} catch (error) {{
951
  console.error('保存失败:', error);
952
  alert('更新失败: ' + error.message);
@@ -957,12 +1175,11 @@ def generate_admin_html(request: Request, multi_account_mgr, show_hide_tip: bool
957
  if (!confirm(`确定删除账户 ${{accountId}}?`)) return;
958
 
959
  try {{
960
- const response = await fetch('/{main.PATH_PREFIX}/admin/accounts/' + accountId + '?key={main.ADMIN_KEY}', {{
961
  method: 'DELETE'
962
  }});
963
 
964
- const result = await handleApiResponse(response);
965
- alert(`账户已删除!\\n剩余账户数: ${{result.account_count}}`);
966
  refreshPage();
967
  }} catch (error) {{
968
  console.error('删除失败:', error);
@@ -971,15 +1188,12 @@ def generate_admin_html(request: Request, multi_account_mgr, show_hide_tip: bool
971
  }}
972
 
973
  async function disableAccount(accountId) {{
974
- if (!confirm(`确定禁用账户 ${{accountId}}?`)) return;
975
-
976
  try {{
977
- const response = await fetch('/{main.PATH_PREFIX}/admin/accounts/' + accountId + '/disable?key={main.ADMIN_KEY}', {{
978
  method: 'PUT'
979
  }});
980
 
981
- const result = await handleApiResponse(response);
982
- alert(`账户已禁用!`);
983
  refreshPage();
984
  }} catch (error) {{
985
  console.error('禁用失败:', error);
@@ -988,15 +1202,12 @@ def generate_admin_html(request: Request, multi_account_mgr, show_hide_tip: bool
988
  }}
989
 
990
  async function enableAccount(accountId) {{
991
- if (!confirm(`确定启用账户 ${{accountId}}?`)) return;
992
-
993
  try {{
994
- const response = await fetch('/{main.PATH_PREFIX}/admin/accounts/' + accountId + '/enable?key={main.ADMIN_KEY}', {{
995
  method: 'PUT'
996
  }});
997
 
998
- const result = await handleApiResponse(response);
999
- alert(`账户已启用!`);
1000
  refreshPage();
1001
  }} catch (error) {{
1002
  console.error('启用失败:', error);
@@ -1034,7 +1245,7 @@ def generate_admin_html(request: Request, multi_account_mgr, show_hide_tip: bool
1034
 
1035
  try {{
1036
  // 获取现有配置
1037
- const configResp = await fetch('/{main.PATH_PREFIX}/admin/accounts-config?key={main.ADMIN_KEY}');
1038
  const configData = await handleApiResponse(configResp);
1039
  const existing = configData.accounts || [];
1040
 
@@ -1071,16 +1282,15 @@ def generate_admin_html(request: Request, multi_account_mgr, show_hide_tip: bool
1071
  }}
1072
 
1073
  // 保存合并后的配置
1074
- const response = await fetch('/{main.PATH_PREFIX}/admin/accounts-config?key={main.ADMIN_KEY}', {{
1075
  method: 'PUT',
1076
  headers: {{'Content-Type': 'application/json'}},
1077
  body: JSON.stringify(existing)
1078
  }});
1079
 
1080
- const result = await handleApiResponse(response);
1081
- alert(`导入完成!\\n新增: ${{added}} 个\\n覆盖: ${{updated}} 个\\n当前账户数: ${{result.account_count}}`);
1082
  event.target.value = '';
1083
- setTimeout(refreshPage, 1000);
1084
  }} catch (error) {{
1085
  console.error('导入失败:', error);
1086
  alert('导入失败: ' + error.message);
@@ -1101,19 +1311,12 @@ def generate_admin_html(request: Request, multi_account_mgr, show_hide_tip: bool
1101
  return html_content
1102
 
1103
 
1104
- async def admin_logs_html(path_prefix: str, key: str = None, authorization: str = Header(None)):
1105
- """返回美化的 HTML 日志查看界面"""
1106
- # 动态导入 main 模块的变量(避免循环依赖)
1107
- import main
1108
 
1109
- # 验证路径前缀
1110
- if path_prefix != main.PATH_PREFIX:
1111
- raise HTTPException(404, "Not Found")
1112
 
1113
- # 验证管理员密钥
1114
- admin_key = key or (authorization.replace("Bearer ", "") if authorization and authorization.startswith("Bearer ") else authorization)
1115
- if admin_key != main.ADMIN_KEY:
1116
- raise HTTPException(404, "Not Found")
1117
 
1118
  html_content = r"""
1119
  <!DOCTYPE html>
@@ -1121,7 +1324,7 @@ async def admin_logs_html(path_prefix: str, key: str = None, authorization: str
1121
  <head>
1122
  <meta charset="utf-8">
1123
  <meta name="viewport" content="width=device-width, initial-scale=1">
1124
- <title>日志查看器</title>
1125
  <style>
1126
  * { margin: 0; padding: 0; box-sizing: border-box; }
1127
  html, body { height: 100%; overflow: hidden; }
@@ -1135,7 +1338,7 @@ async def admin_logs_html(path_prefix: str, key: str = None, authorization: str
1135
  }
1136
  .container {
1137
  width: 100%;
1138
- max-width: 1400px;
1139
  height: calc(100vh - 30px);
1140
  background: white;
1141
  border-radius: 16px;
@@ -1144,10 +1347,50 @@ async def admin_logs_html(path_prefix: str, key: str = None, authorization: str
1144
  display: flex;
1145
  flex-direction: column;
1146
  }
1147
- h1 { color: #1a1a1a; font-size: 22px; font-weight: 600; margin-bottom: 20px; text-align: center; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1148
  .stats {
1149
  display: grid;
1150
- grid-template-columns: repeat(6, 1fr);
1151
  gap: 12px;
1152
  margin-bottom: 16px;
1153
  }
@@ -1162,43 +1405,6 @@ async def admin_logs_html(path_prefix: str, key: str = None, authorization: str
1162
  .stat:hover { border-color: #d4d4d4; }
1163
  .stat-label { color: #6b6b6b; font-size: 11px; margin-bottom: 4px; }
1164
  .stat-value { color: #1a1a1a; font-size: 18px; font-weight: 600; }
1165
- .controls {
1166
- display: flex;
1167
- gap: 8px;
1168
- margin-bottom: 16px;
1169
- flex-wrap: wrap;
1170
- }
1171
- .controls input, .controls select, .controls button {
1172
- padding: 6px 10px;
1173
- border: 1px solid #e5e5e5;
1174
- border-radius: 8px;
1175
- font-size: 13px;
1176
- }
1177
- .controls select {
1178
- appearance: none;
1179
- -webkit-appearance: none;
1180
- -moz-appearance: none;
1181
- background-image: url("data:image/svg+xml,%3Csvg width='12' height='12' viewBox='0 0 12 12' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M3 5L6 8L9 5' stroke='%236b6b6b' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E");
1182
- background-repeat: no-repeat;
1183
- background-position: right 12px center;
1184
- padding-right: 32px;
1185
- }
1186
- .controls input[type="text"] { flex: 1; min-width: 150px; }
1187
- .controls button {
1188
- background: #1a73e8;
1189
- color: white;
1190
- border: none;
1191
- cursor: pointer;
1192
- font-weight: 500;
1193
- transition: background 0.15s ease;
1194
- display: flex;
1195
- align-items: center;
1196
- gap: 6px;
1197
- }
1198
- .controls button:hover { background: #1557b0; }
1199
- .controls button.danger { background: #dc2626; }
1200
- .controls button.danger:hover { background: #b91c1c; }
1201
- .controls button svg { flex-shrink: 0; }
1202
  .log-container {
1203
  flex: 1;
1204
  background: #fafaf9;
@@ -1209,671 +1415,42 @@ async def admin_logs_html(path_prefix: str, key: str = None, authorization: str
1209
  scrollbar-width: thin;
1210
  scrollbar-color: rgba(0,0,0,0.15) transparent;
1211
  }
1212
- /* Webkit 滚动条样式 - 更窄且不占位 */
1213
- .log-container::-webkit-scrollbar {
1214
- width: 4px;
1215
- }
1216
- .log-container::-webkit-scrollbar-track {
1217
- background: transparent;
1218
- }
1219
  .log-container::-webkit-scrollbar-thumb {
1220
  background: rgba(0,0,0,0.15);
1221
  border-radius: 2px;
1222
  }
1223
- .log-container::-webkit-scrollbar-thumb:hover {
1224
- background: rgba(0,0,0,0.3);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1225
  }
 
 
1226
  .log-entry {
1227
  padding: 8px 10px;
1228
  margin-bottom: 4px;
1229
  background: white;
1230
- border-radius: 6px;
1231
  border: 1px solid #e5e5e5;
1232
- font-size: 12px;
1233
- color: #1a1a1a;
1234
  display: flex;
1235
  align-items: center;
1236
- gap: 8px;
1237
- word-break: break-word;
1238
- }
1239
- .log-entry > div:first-child {
1240
- display: flex;
1241
- align-items: center;
1242
- gap: 8px;
1243
- }
1244
- .log-message {
1245
- flex: 1;
1246
- overflow: hidden;
1247
- text-overflow: ellipsis;
1248
- }
1249
- .log-entry:hover { border-color: #d4d4d4; }
1250
- .log-time { color: #6b6b6b; }
1251
- .log-level {
1252
- display: flex;
1253
- align-items: center;
1254
- gap: 4px;
1255
- padding: 2px 6px;
1256
- border-radius: 3px;
1257
- font-size: 10px;
1258
- font-weight: 600;
1259
- }
1260
- .log-level::before {
1261
- content: '';
1262
- width: 6px;
1263
- height: 6px;
1264
- border-radius: 50%;
1265
- }
1266
- .log-level.INFO { background: #e3f2fd; color: #1976d2; }
1267
- .log-level.INFO::before { background: #1976d2; }
1268
- .log-level.WARNING { background: #fff3e0; color: #f57c00; }
1269
- .log-level.WARNING::before { background: #f57c00; }
1270
- .log-level.ERROR { background: #ffebee; color: #d32f2f; }
1271
- .log-level.ERROR::before { background: #d32f2f; }
1272
- .log-level.DEBUG { background: #f3e5f5; color: #7b1fa2; }
1273
- .log-level.DEBUG::before { background: #7b1fa2; }
1274
- .log-group {
1275
- margin-bottom: 8px;
1276
- border: 1px solid #e5e5e5;
1277
- border-radius: 8px;
1278
- background: white;
1279
- }
1280
- .log-group-header {
1281
- padding: 10px 12px;
1282
- background: #f9f9f9;
1283
- border-radius: 8px 8px 0 0;
1284
- cursor: pointer;
1285
- display: flex;
1286
- align-items: center;
1287
- gap: 8px;
1288
- transition: background 0.15s ease;
1289
- }
1290
- .log-group-header:hover {
1291
- background: #f0f0f0;
1292
- }
1293
- .log-group-content {
1294
- padding: 8px;
1295
- }
1296
- .log-group .log-entry {
1297
- margin-bottom: 4px;
1298
- }
1299
- .log-group .log-entry:last-child {
1300
- margin-bottom: 0;
1301
- }
1302
- .toggle-icon {
1303
- display: inline-block;
1304
- transition: transform 0.2s ease;
1305
- }
1306
- .toggle-icon.collapsed {
1307
- transform: rotate(-90deg);
1308
- }
1309
- @media (max-width: 768px) {
1310
- body { padding: 0; }
1311
- .container { padding: 15px; height: 100vh; border-radius: 0; max-width: 100%; }
1312
- h1 { font-size: 18px; margin-bottom: 12px; }
1313
- .stats { grid-template-columns: repeat(3, 1fr); gap: 8px; }
1314
- .stat { padding: 8px; }
1315
- .controls { gap: 6px; }
1316
- .controls input, .controls select { min-height: 38px; }
1317
- .controls select { flex: 0 0 auto; }
1318
- .controls input[type="text"] { flex: 1 1 auto; min-width: 80px; }
1319
- .controls input[type="number"] { flex: 0 0 60px; }
1320
- .controls button { padding: 10px 8px; font-size: 12px; flex: 1 1 22%; justify-content: center; min-height: 38px; }
1321
- .log-entry {
1322
- font-size: 12px;
1323
- padding: 10px;
1324
- gap: 8px;
1325
- flex-direction: column;
1326
- align-items: flex-start;
1327
- }
1328
- .log-entry > div:first-child {
1329
- display: flex;
1330
- align-items: center;
1331
- gap: 6px;
1332
- width: 100%;
1333
- flex-wrap: wrap;
1334
- }
1335
- .log-time { font-size: 11px; color: #9e9e9e; }
1336
- .log-level { font-size: 10px; }
1337
- .log-message {
1338
- width: 100%;
1339
- white-space: normal;
1340
- word-break: break-word;
1341
- line-height: 1.5;
1342
- margin-top: 4px;
1343
- }
1344
- }
1345
- </style>
1346
- </head>
1347
- <body>
1348
- <div class="container">
1349
- <h1>Gemini API 日志查看器</h1>
1350
- <div class="stats">
1351
- <div class="stat">
1352
- <div class="stat-label">总数</div>
1353
- <div class="stat-value" id="total-count">-</div>
1354
- </div>
1355
- <div class="stat">
1356
- <div class="stat-label">对话</div>
1357
- <div class="stat-value" id="chat-count">-</div>
1358
- </div>
1359
- <div class="stat">
1360
- <div class="stat-label">INFO</div>
1361
- <div class="stat-value" id="info-count">-</div>
1362
- </div>
1363
- <div class="stat">
1364
- <div class="stat-label">WARNING</div>
1365
- <div class="stat-value" id="warning-count">-</div>
1366
- </div>
1367
- <div class="stat">
1368
- <div class="stat-label">ERROR</div>
1369
- <div class="stat-value" id="error-count">-</div>
1370
- </div>
1371
- <div class="stat">
1372
- <div class="stat-label">更新</div>
1373
- <div class="stat-value" id="last-update" style="font-size: 11px;">-</div>
1374
- </div>
1375
- </div>
1376
- <div class="controls">
1377
- <select id="level-filter">
1378
- <option value="">全部</option>
1379
- <option value="INFO">INFO</option>
1380
- <option value="WARNING">WARNING</option>
1381
- <option value="ERROR">ERROR</option>
1382
- </select>
1383
- <input type="text" id="search-input" placeholder="搜索...">
1384
- <input type="number" id="limit-input" value="1500" min="10" max="3000" step="100" style="width: 80px;">
1385
- <button onclick="loadLogs()">
1386
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1387
- <circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
1388
- </svg>
1389
- 查询
1390
- </button>
1391
- <button onclick="exportJSON()">
1392
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1393
- <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
1394
- </svg>
1395
- 导出
1396
- </button>
1397
- <button id="auto-refresh-btn" onclick="toggleAutoRefresh()">
1398
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1399
- <polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
1400
- </svg>
1401
- 自动刷新
1402
- </button>
1403
- <button onclick="clearAllLogs()" class="danger">
1404
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1405
- <polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
1406
- </svg>
1407
- 清空
1408
- </button>
1409
- </div>
1410
- <div class="log-container" id="log-container">
1411
- <div style="color: #6b6b6b;">正在加载...</div>
1412
- </div>
1413
- </div>
1414
- <script>
1415
- let autoRefreshTimer = null;
1416
- async function loadLogs() {
1417
- const level = document.getElementById('level-filter').value;
1418
- const search = document.getElementById('search-input').value;
1419
- const limit = document.getElementById('limit-input').value;
1420
- // 从当前 URL 获取 key 参数
1421
- const urlParams = new URLSearchParams(window.location.search);
1422
- const key = urlParams.get('key');
1423
- // 构建 API URL(使用当前路径的前缀)
1424
- const pathPrefix = window.location.pathname.split('/')[1];
1425
- let url = `/${pathPrefix}/admin/log?limit=${limit}`;
1426
- if (key) url += `&key=${key}`;
1427
- if (level) url += `&level=${level}`;
1428
- if (search) url += `&search=${encodeURIComponent(search)}`;
1429
- try {
1430
- const response = await fetch(url);
1431
- if (!response.ok) {
1432
- throw new Error(`HTTP ${response.status}`);
1433
- }
1434
- const data = await response.json();
1435
- if (data && data.logs) {
1436
- displayLogs(data.logs);
1437
- updateStats(data.stats);
1438
- document.getElementById('last-update').textContent = new Date().toLocaleTimeString('zh-CN', {hour: '2-digit', minute: '2-digit'});
1439
- } else {
1440
- throw new Error('Invalid data format');
1441
- }
1442
- } catch (error) {
1443
- document.getElementById('log-container').innerHTML = '<div class="log-entry ERROR">加载失败: ' + error.message + '</div>';
1444
- }
1445
- }
1446
- function updateStats(stats) {
1447
- document.getElementById('total-count').textContent = stats.memory.total;
1448
- document.getElementById('info-count').textContent = stats.memory.by_level.INFO || 0;
1449
- document.getElementById('warning-count').textContent = stats.memory.by_level.WARNING || 0;
1450
- const errorCount = document.getElementById('error-count');
1451
- errorCount.textContent = stats.memory.by_level.ERROR || 0;
1452
- if (stats.errors && stats.errors.count > 0) errorCount.style.color = '#dc2626';
1453
- document.getElementById('chat-count').textContent = stats.chat_count || 0;
1454
- }
1455
- // 分类颜色配置(提取到外部避免重复定义)
1456
- const CATEGORY_COLORS = {
1457
- 'SYSTEM': '#9e9e9e',
1458
- 'CONFIG': '#607d8b',
1459
- 'LOG': '#9e9e9e',
1460
- 'AUTH': '#4caf50',
1461
- 'SESSION': '#00bcd4',
1462
- 'FILE': '#ff9800',
1463
- 'CHAT': '#2196f3',
1464
- 'API': '#8bc34a',
1465
- 'CACHE': '#9c27b0',
1466
- 'ACCOUNT': '#f44336',
1467
- 'MULTI': '#673ab7'
1468
- };
1469
-
1470
- // 账户颜色配置(提取到外部避免重复定义)
1471
- const ACCOUNT_COLORS = {
1472
- 'account_1': '#9c27b0',
1473
- 'account_2': '#e91e63',
1474
- 'account_3': '#00bcd4',
1475
- 'account_4': '#4caf50',
1476
- 'account_5': '#ff9800'
1477
- };
1478
-
1479
- function getCategoryColor(category) {
1480
- return CATEGORY_COLORS[category] || '#757575';
1481
- }
1482
-
1483
- function getAccountColor(accountId) {
1484
- return ACCOUNT_COLORS[accountId] || '#757575';
1485
- }
1486
-
1487
- function displayLogs(logs) {
1488
- const container = document.getElementById('log-container');
1489
- if (logs.length === 0) {
1490
- container.innerHTML = '<div class="log-entry">暂无日志</div>';
1491
- return;
1492
- }
1493
-
1494
- // 按请求ID分组
1495
- const groups = {};
1496
- const ungrouped = [];
1497
-
1498
- logs.forEach(log => {
1499
- const msg = escapeHtml(log.message);
1500
- const reqMatch = msg.match(/\[req_([a-z0-9]+)\]/);
1501
-
1502
- if (reqMatch) {
1503
- const reqId = reqMatch[1];
1504
- if (!groups[reqId]) {
1505
- groups[reqId] = [];
1506
- }
1507
- groups[reqId].push(log);
1508
- } else {
1509
- ungrouped.push(log);
1510
- }
1511
- });
1512
-
1513
- // 渲染分组
1514
- let html = '';
1515
-
1516
- // 先渲染未分组的日志
1517
- ungrouped.forEach(log => {
1518
- html += renderLogEntry(log);
1519
- });
1520
-
1521
- // 读取折叠状态
1522
- const foldState = JSON.parse(localStorage.getItem('log-fold-state') || '{}');
1523
-
1524
- // 按请求ID分组渲染(最新的组在下面)
1525
- Object.keys(groups).forEach(reqId => {
1526
- const groupLogs = groups[reqId];
1527
- const firstLog = groupLogs[0];
1528
- const lastLog = groupLogs[groupLogs.length - 1];
1529
-
1530
- // 判断状态
1531
- let status = 'in_progress';
1532
- let statusColor = '#ff9800';
1533
- let statusText = '进行中';
1534
-
1535
- if (lastLog.message.includes('响应完成') || lastLog.message.includes('非流式响应完成')) {
1536
- status = 'success';
1537
- statusColor = '#4caf50';
1538
- statusText = '成功';
1539
- } else if (lastLog.level === 'ERROR' || lastLog.message.includes('失败')) {
1540
- status = 'error';
1541
- statusColor = '#f44336';
1542
- statusText = '失败';
1543
- } else {
1544
- // 检查超时(最后日志超过 5 分钟)
1545
- const lastLogTime = new Date(lastLog.time);
1546
- const now = new Date();
1547
- const diffMinutes = (now - lastLogTime) / 1000 / 60;
1548
- if (diffMinutes > 5) {
1549
- status = 'timeout';
1550
- statusColor = '#ffc107';
1551
- statusText = '超时';
1552
- }
1553
- }
1554
-
1555
- // 提取账户ID和模型
1556
- const accountMatch = firstLog.message.match(/\[account_(\d+)\]/);
1557
- const modelMatch = firstLog.message.match(/收到请求: ([^ ]+)/);
1558
- const accountId = accountMatch ? `account_${accountMatch[1]}` : '';
1559
- const model = modelMatch ? modelMatch[1] : '';
1560
-
1561
- // 检查折叠状态
1562
- const isCollapsed = foldState[reqId] === true;
1563
- const contentStyle = isCollapsed ? 'style="display: none;"' : '';
1564
- const iconClass = isCollapsed ? 'class="toggle-icon collapsed"' : 'class="toggle-icon"';
1565
-
1566
- html += `
1567
- <div class="log-group" data-req-id="${reqId}">
1568
- <div class="log-group-header" onclick="toggleGroup('${reqId}')">
1569
- <span style="color: ${statusColor}; font-weight: 600; font-size: 11px;">⬤ ${statusText}</span>
1570
- <span style="color: #666; font-size: 11px; margin-left: 8px;">req_${reqId}</span>
1571
- ${accountId ? `<span style="color: ${getAccountColor(accountId)}; font-size: 11px; margin-left: 8px;">${accountId}</span>` : ''}
1572
- ${model ? `<span style="color: #999; font-size: 11px; margin-left: 8px;">${model}</span>` : ''}
1573
- <span style="color: #999; font-size: 11px; margin-left: 8px;">${groupLogs.length}条日志</span>
1574
- <span ${iconClass} style="margin-left: auto; color: #999;">▼</span>
1575
- </div>
1576
- <div class="log-group-content" ${contentStyle}>
1577
- ${groupLogs.map(log => renderLogEntry(log)).join('')}
1578
- </div>
1579
- </div>
1580
- `;
1581
- });
1582
-
1583
- container.innerHTML = html;
1584
-
1585
- // 自动滚动到底部,显示最新日志
1586
- container.scrollTop = container.scrollHeight;
1587
- }
1588
-
1589
- function renderLogEntry(log) {
1590
- const msg = escapeHtml(log.message);
1591
- let displayMsg = msg;
1592
- let categoryTags = [];
1593
- let accountId = null;
1594
-
1595
- // 解析所有标签:[CATEGORY1] [CATEGORY2] [account_X] [req_X] message
1596
- let remainingMsg = msg;
1597
- const tagRegex = /^\[([A-Z_a-z0-9]+)\]/;
1598
-
1599
- while (true) {
1600
- const match = remainingMsg.match(tagRegex);
1601
- if (!match) break;
1602
-
1603
- const tag = match[1];
1604
- remainingMsg = remainingMsg.substring(match[0].length).trim();
1605
-
1606
- // 跳过req_标签(已在组头部显示)
1607
- if (tag.startsWith('req_')) {
1608
- continue;
1609
- }
1610
- // 判断是否为账户ID
1611
- else if (tag.startsWith('account_')) {
1612
- accountId = tag;
1613
- } else {
1614
- // 普通分类标签
1615
- categoryTags.push(tag);
1616
- }
1617
- }
1618
-
1619
- displayMsg = remainingMsg;
1620
-
1621
- // 生成分类标签HTML
1622
- const categoryTagsHtml = categoryTags.map(cat =>
1623
- `<span class="log-category" style="background: ${getCategoryColor(cat)}; color: white; padding: 2px 6px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 2px;">${cat}</span>`
1624
- ).join('');
1625
-
1626
- // 生成账户标签HTML
1627
- const accountTagHtml = accountId
1628
- ? `<span style="color: ${getAccountColor(accountId)}; font-size: 11px; font-weight: 600; margin-left: 2px;">${accountId}</span>`
1629
- : '';
1630
-
1631
- return `
1632
- <div class="log-entry ${log.level}">
1633
- <div>
1634
- <span class="log-time">${log.time}</span>
1635
- <span class="log-level ${log.level}">${log.level}</span>
1636
- ${categoryTagsHtml}
1637
- ${accountTagHtml}
1638
- </div>
1639
- <div class="log-message">${displayMsg}</div>
1640
- </div>
1641
- `;
1642
- }
1643
-
1644
- function toggleGroup(reqId) {
1645
- const group = document.querySelector(`.log-group[data-req-id="${reqId}"]`);
1646
- const content = group.querySelector('.log-group-content');
1647
- const icon = group.querySelector('.toggle-icon');
1648
-
1649
- const isCollapsed = content.style.display === 'none';
1650
- if (isCollapsed) {
1651
- content.style.display = 'block';
1652
- icon.classList.remove('collapsed');
1653
- } else {
1654
- content.style.display = 'none';
1655
- icon.classList.add('collapsed');
1656
- }
1657
-
1658
- // 保存折叠状态到 localStorage
1659
- const foldState = JSON.parse(localStorage.getItem('log-fold-state') || '{}');
1660
- foldState[reqId] = !isCollapsed;
1661
- localStorage.setItem('log-fold-state', JSON.stringify(foldState));
1662
- }
1663
- function escapeHtml(text) {
1664
- const div = document.createElement('div');
1665
- div.textContent = text;
1666
- return div.innerHTML;
1667
- }
1668
- async function exportJSON() {
1669
- try {
1670
- const urlParams = new URLSearchParams(window.location.search);
1671
- const key = urlParams.get('key');
1672
- const pathPrefix = window.location.pathname.split('/')[1];
1673
- let url = `/${pathPrefix}/admin/log?limit=3000`;
1674
- if (key) url += `&key=${key}`;
1675
- const response = await fetch(url);
1676
- const data = await response.json();
1677
- const blob = new Blob([JSON.stringify({exported_at: new Date().toISOString(), logs: data.logs}, null, 2)], {type: 'application/json'});
1678
- const blobUrl = URL.createObjectURL(blob);
1679
- const a = document.createElement('a');
1680
- a.href = blobUrl;
1681
- a.download = 'logs_' + new Date().toISOString().slice(0, 19).replace(/:/g, '-') + '.json';
1682
- a.click();
1683
- URL.revokeObjectURL(blobUrl);
1684
- alert('导出成功');
1685
- } catch (error) {
1686
- alert('导出失败: ' + error.message);
1687
- }
1688
- }
1689
- async function clearAllLogs() {
1690
- if (!confirm('确定清空所有日志?')) return;
1691
- try {
1692
- const urlParams = new URLSearchParams(window.location.search);
1693
- const key = urlParams.get('key');
1694
- const pathPrefix = window.location.pathname.split('/')[1];
1695
- let url = `/${pathPrefix}/admin/log?confirm=yes`;
1696
- if (key) url += `&key=${key}`;
1697
- const response = await fetch(url, {method: 'DELETE'});
1698
- if (response.ok) {
1699
- alert('已清空');
1700
- loadLogs();
1701
- } else {
1702
- alert('清空失败');
1703
- }
1704
- } catch (error) {
1705
- alert('清空失败: ' + error.message);
1706
- }
1707
- }
1708
- let autoRefreshEnabled = true;
1709
- function toggleAutoRefresh() {
1710
- autoRefreshEnabled = !autoRefreshEnabled;
1711
- const btn = document.getElementById('auto-refresh-btn');
1712
- if (autoRefreshEnabled) {
1713
- btn.style.background = '#1a73e8';
1714
- autoRefreshTimer = setInterval(loadLogs, 5000);
1715
- } else {
1716
- btn.style.background = '#6b6b6b';
1717
- if (autoRefreshTimer) {
1718
- clearInterval(autoRefreshTimer);
1719
- autoRefreshTimer = null;
1720
- }
1721
- }
1722
- }
1723
- document.addEventListener('DOMContentLoaded', () => {
1724
- loadLogs();
1725
- autoRefreshTimer = setInterval(loadLogs, 5000);
1726
- document.getElementById('search-input').addEventListener('keypress', (e) => {
1727
- if (e.key === 'Enter') loadLogs();
1728
- });
1729
- document.getElementById('level-filter').addEventListener('change', loadLogs);
1730
- document.getElementById('limit-input').addEventListener('change', loadLogs);
1731
- });
1732
- </script>
1733
- </body>
1734
- </html>
1735
- """
1736
- return HTMLResponse(content=html_content)
1737
-
1738
-
1739
- async def get_public_logs_html():
1740
- """公开的脱敏日志查看器"""
1741
- # 动态导入 main 模块的变量(避免循环依赖)
1742
- import main
1743
-
1744
- html_content = r"""
1745
- <!DOCTYPE html>
1746
- <html>
1747
- <head>
1748
- <meta charset="utf-8">
1749
- <meta name="viewport" content="width=device-width, initial-scale=1">
1750
- <title>服务状态</title>
1751
- <style>
1752
- * { margin: 0; padding: 0; box-sizing: border-box; }
1753
- html, body { height: 100%; overflow: hidden; }
1754
- body {
1755
- font-family: 'Consolas', 'Monaco', monospace;
1756
- background: #fafaf9;
1757
- display: flex;
1758
- align-items: center;
1759
- justify-content: center;
1760
- padding: 15px;
1761
- }
1762
- .container {
1763
- width: 100%;
1764
- max-width: 1200px;
1765
- height: calc(100vh - 30px);
1766
- background: white;
1767
- border-radius: 16px;
1768
- padding: 30px;
1769
- box-shadow: 0 2px 8px rgba(0,0,0,0.08);
1770
- display: flex;
1771
- flex-direction: column;
1772
- }
1773
- h1 {
1774
- color: #1a1a1a;
1775
- font-size: 22px;
1776
- font-weight: 600;
1777
- margin-bottom: 20px;
1778
- display: flex;
1779
- align-items: center;
1780
- justify-content: center;
1781
- gap: 12px;
1782
- }
1783
- h1 img {
1784
- width: 32px;
1785
- height: 32px;
1786
- border-radius: 8px;
1787
- }
1788
- .info-bar {
1789
- background: #f9f9f9;
1790
- border: 1px solid #e5e5e5;
1791
- border-radius: 8px;
1792
- padding: 12px 16px;
1793
- margin-bottom: 16px;
1794
- display: flex;
1795
- align-items: center;
1796
- justify-content: space-between;
1797
- flex-wrap: wrap;
1798
- gap: 12px;
1799
- }
1800
- .info-item {
1801
- display: flex;
1802
- align-items: center;
1803
- gap: 6px;
1804
- font-size: 13px;
1805
- color: #6b6b6b;
1806
- }
1807
- .info-item strong { color: #1a1a1a; }
1808
- .info-item a {
1809
- color: #1a73e8;
1810
- text-decoration: none;
1811
- font-weight: 500;
1812
- }
1813
- .info-item a:hover { text-decoration: underline; }
1814
- .stats {
1815
- display: grid;
1816
- grid-template-columns: repeat(4, 1fr);
1817
- gap: 12px;
1818
- margin-bottom: 16px;
1819
- }
1820
- .stat {
1821
- background: #fafaf9;
1822
- padding: 12px;
1823
- border: 1px solid #e5e5e5;
1824
- border-radius: 8px;
1825
- text-align: center;
1826
- transition: all 0.15s ease;
1827
- }
1828
- .stat:hover { border-color: #d4d4d4; }
1829
- .stat-label { color: #6b6b6b; font-size: 11px; margin-bottom: 4px; }
1830
- .stat-value { color: #1a1a1a; font-size: 18px; font-weight: 600; }
1831
- .log-container {
1832
- flex: 1;
1833
- background: #fafaf9;
1834
- border: 1px solid #e5e5e5;
1835
- border-radius: 8px;
1836
- padding: 12px;
1837
- overflow-y: auto;
1838
- scrollbar-width: thin;
1839
- scrollbar-color: rgba(0,0,0,0.15) transparent;
1840
- }
1841
- .log-container::-webkit-scrollbar { width: 4px; }
1842
- .log-container::-webkit-scrollbar-track { background: transparent; }
1843
- .log-container::-webkit-scrollbar-thumb {
1844
- background: rgba(0,0,0,0.15);
1845
- border-radius: 2px;
1846
- }
1847
- .log-container::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,0.3); }
1848
- .log-group {
1849
- margin-bottom: 8px;
1850
- border: 1px solid #e5e5e5;
1851
- border-radius: 8px;
1852
- background: white;
1853
- }
1854
- .log-group-header {
1855
- padding: 10px 12px;
1856
- background: #f9f9f9;
1857
- border-radius: 8px 8px 0 0;
1858
- cursor: pointer;
1859
- display: flex;
1860
- align-items: center;
1861
- gap: 8px;
1862
- transition: background 0.15s ease;
1863
- }
1864
- .log-group-header:hover { background: #f0f0f0; }
1865
- .log-group-content { padding: 8px; }
1866
- .log-entry {
1867
- padding: 8px 10px;
1868
- margin-bottom: 4px;
1869
- background: white;
1870
- border: 1px solid #e5e5e5;
1871
- border-radius: 6px;
1872
- display: flex;
1873
- align-items: center;
1874
- gap: 10px;
1875
- font-size: 13px;
1876
- transition: all 0.15s ease;
1877
  }
1878
  .log-entry:hover { border-color: #d4d4d4; }
1879
  .log-time { color: #6b6b6b; font-size: 12px; min-width: 140px; }
@@ -2379,3 +1956,247 @@ async def get_uptime_html():
2379
  </html>
2380
  """
2381
  return HTMLResponse(content=html_content)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  error_count += 1
28
 
29
  # --- 1. 构建提示信息 ---
 
 
 
 
 
 
 
 
 
 
 
 
30
  api_key_status = ""
31
  if main.API_KEY:
32
  api_key_status = """
33
  <div class="alert alert-success">
34
  <div class="alert-icon">🔒</div>
35
  <div class="alert-content">
36
+ <strong>API 安全模式已启用</strong>
37
+ <div class="alert-desc">API 端点携带 Authorization 密钥才能访问。</div>
38
  </div>
39
  </div>
40
  """
 
43
  <div class="alert alert-warning">
44
  <div class="alert-icon">⚠️</div>
45
  <div class="alert-content">
46
+ <strong>API 密钥未设置</strong>
47
+ <div class="alert-desc">API 端点当前允许公开访问建议在 .env 文件中配置 <code>API_KEY</code> 环境变量以提升安全性。</div>
48
  </div>
49
  </div>
50
  """
 
62
  """
63
 
64
  # API接口信息提示
65
+ # 生成正确的API端点URL和管理端点URL
66
+ # admin_path_segment: 有PATH_PREFIX时为 secret123,无时为 admin
67
+ # 管理端点示例:/{admin_path_segment}/accounts
68
+ admin_path_segment = f"{main.PATH_PREFIX}" if main.PATH_PREFIX else "admin"
69
+ api_path_segment = f"{main.PATH_PREFIX}/" if main.PATH_PREFIX else ""
70
+
71
+ # 构建不同客户端需要的接口
72
+ api_base_url = f"{current_url}/{api_path_segment.rstrip('/')}" if api_path_segment else current_url
73
+ api_base_v1 = f"{current_url}/{api_path_segment}v1"
74
+ api_endpoint = f"{current_url}/{api_path_segment}v1/chat/completions"
75
  api_key_display = main.API_KEY if main.API_KEY else '<span style="color: #ff9500;">未设置(公开访问)</span>'
76
 
77
  api_info_tip = f"""
 
81
  <strong>API 接口信息</strong>
82
  <div style="margin-top: 10px;">
83
  <div style="margin-bottom: 12px;">
84
+ <div style="color: #86868b; font-size: 11px; margin-bottom: 4px;">基础端点(部分客户端)</div>
85
+ <code style="font-size: 11px; background: rgba(0,0,0,0.05); padding: 4px 8px; border-radius: 4px; display: inline-block; word-break: break-all;">{api_base_url}</code>
86
+ </div>
87
+ <div style="margin-bottom: 12px;">
88
+ <div style="color: #86868b; font-size: 11px; margin-bottom: 4px;">API Base(OpenAI SDK 等)</div>
89
+ <code style="font-size: 11px; background: rgba(0,0,0,0.05); padding: 4px 8px; border-radius: 4px; display: inline-block; word-break: break-all;">{api_base_v1}</code>
90
+ </div>
91
+ <div style="margin-bottom: 12px;">
92
+ <div style="color: #86868b; font-size: 11px; margin-bottom: 4px;">完整聊天接口(直接调用)</div>
93
  <code style="font-size: 11px; background: rgba(0,0,0,0.05); padding: 4px 8px; border-radius: 4px; display: inline-block; word-break: break-all;">{api_endpoint}</code>
94
  </div>
95
  <div style="margin-bottom: 12px;">
 
119
  </div>
120
  """
121
 
122
+ # --- 2. 构建账户表格行 ---
123
+ accounts_rows = ""
124
  for account_id, account_manager in multi_account_mgr.accounts.items():
125
  config = account_manager.config
126
  remaining_hours = config.get_remaining_hours()
 
138
  status_text = "过期禁用"
139
  status_color = "#9e9e9e"
140
  dot_color = "#9e9e9e"
141
+ row_opacity = "0.5"
142
+ action_buttons = f'<button onclick="deleteAccount(\'{config.account_id}\')" class="btn-sm btn-delete" title="删除">删除</button>'
143
  elif is_disabled:
144
  status_text = "手动禁用"
145
  status_color = "#9e9e9e"
146
  dot_color = "#9e9e9e"
147
+ row_opacity = "0.5"
148
  action_buttons = f'''
149
+ <button onclick="enableAccount('{config.account_id}')" class="btn-sm btn-enable" title="启用">启用</button>
150
+ <button onclick="deleteAccount('{config.account_id}')" class="btn-sm btn-delete" title="删除">删除</button>
151
  '''
152
  elif cooldown_seconds == -1:
153
  # 错误永久禁用
154
  status_text = cooldown_reason # "错误禁用"
155
  status_color = "#f44336"
156
  dot_color = "#f44336"
157
+ row_opacity = "0.5"
158
  action_buttons = f'''
159
+ <button onclick="enableAccount('{config.account_id}')" class="btn-sm btn-enable" title="启用">启用</button>
160
+ <button onclick="deleteAccount('{config.account_id}')" class="btn-sm btn-delete" title="删除">删除</button>
161
  '''
162
  elif cooldown_seconds > 0:
163
  # 429限流(冷却中)
164
+ status_text = f"{cooldown_reason} ({cooldown_seconds}s)"
165
  status_color = "#ff9800"
166
  dot_color = "#ff9800"
167
+ row_opacity = "1"
168
  action_buttons = f'''
169
+ <button onclick="disableAccount('{config.account_id}')" class="btn-sm btn-disable" title="禁用">禁用</button>
170
+ <button onclick="deleteAccount('{config.account_id}')" class="btn-sm btn-delete" title="删除">删除</button>
171
  '''
172
  else:
173
  # 正常状态
 
187
  status_text = "不可用"
188
  status_color = "#f44336"
189
  dot_color = "#ff3b30"
190
+ row_opacity = "1"
191
  action_buttons = f'''
192
+ <button onclick="disableAccount('{config.account_id}')" class="btn-sm btn-disable" title="禁用">禁用</button>
193
+ <button onclick="deleteAccount('{config.account_id}')" class="btn-sm btn-delete" title="删除">删除</button>
194
  '''
195
 
196
+ # 构建表格行
197
+ accounts_rows += f"""
198
+ <tr style="opacity: {row_opacity};">
199
+ <td data-label="账号ID">
200
+ <div style="display: flex; align-items: center; gap: 8px;">
201
+ <span class="status-dot" style="background-color: {dot_color};"></span>
202
+ <span style="font-weight: 600;">{config.account_id}</span>
203
+ </div>
204
+ </td>
205
+ <td data-label="状态">
206
+ <span style="color: {status_color}; font-weight: 600; font-size: 12px;">{status_text}</span>
207
+ </td>
208
+ <td data-label="过期时间">
209
+ <span class="font-mono" style="font-size: 11px; color: #6b6b6b;">{config.expires_at or '未设置'}</span>
210
+ </td>
211
+ <td data-label="剩余长">
212
+ <span style="color: {status_color}; font-weight: 500; font-size: 12px;">{expire_display}</span>
213
+ </td>
214
+ <td data-label="累计对话">
215
+ <span style="color: #2563eb; font-weight: 600;">{account_manager.conversation_count}</span>
216
+ </td>
217
+ <td data-label="操作">
218
+ <div style="display: flex; gap: 6px;">
219
+ {action_buttons}
220
+ </div>
221
+ </td>
222
+ </tr>
 
 
223
  """
224
 
225
+ # 构建完整的账户表格HTML
226
+ accounts_html = f"""
227
+ <table class="account-table">
228
+ <thead>
229
+ <tr>
230
+ <th>账号ID</th>
231
+ <th>状态</th>
232
+ <th>过期时间</th>
233
+ <th>剩余时长</th>
234
+ <th>累计对话</th>
235
+ <th style="text-align: center;">操作</th>
236
+ </tr>
237
+ </thead>
238
+ <tbody>
239
+ {accounts_rows if accounts_rows else '<tr><td colspan="6" style="text-align: center; color: #6b6b6b; padding: 24px;">暂无账户</td></tr>'}
240
+ </tbody>
241
+ </table>
242
+ """
243
+
244
  # --- 3. 构建 HTML ---
245
  html_content = f"""
246
  <!DOCTYPE html>
 
348
  }}
349
  .grid-3 {{ display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; align-items: start; }}
350
  .grid-env {{ display: grid; grid-template-columns: 1fr 1fr; gap: 16px; align-items: start; }}
 
351
  .stack-col {{ display: flex; flex-direction: column; gap: 16px; }}
352
 
353
  /* Cards */
 
370
  letter-spacing: 0.5px;
371
  }}
372
 
373
+ /* Account Table */
374
+ .account-table {{
375
+ width: 100%;
376
+ border-collapse: collapse;
 
 
 
 
 
 
 
 
 
 
377
  background: #fff;
378
+ border: 1px solid #e5e5e5;
379
+ border-radius: 12px;
380
+ overflow: hidden;
381
+ }}
382
+ .account-table thead {{
383
+ background: #fafaf9;
384
+ border-bottom: 2px solid #e5e5e5;
385
+ }}
386
+ .account-table th {{
387
+ padding: 12px 16px;
388
+ text-align: left;
389
+ font-size: 12px;
390
+ font-weight: 600;
391
+ color: #6b6b6b;
392
+ text-transform: uppercase;
393
+ letter-spacing: 0.5px;
394
+ }}
395
+ .account-table tbody tr {{
396
+ border-bottom: 1px solid #f5f5f5;
397
+ transition: background 0.15s ease;
398
+ }}
399
+ .account-table tbody tr:last-child {{
400
+ border-bottom: none;
401
+ }}
402
+ .account-table tbody tr:hover {{
403
+ background: #fafaf9;
404
+ }}
405
+ .account-table td {{
406
+ padding: 14px 16px;
407
+ font-size: 13px;
408
+ color: var(--text-main);
409
+ vertical-align: middle;
410
+ }}
411
+ .status-dot {{
412
+ width: 8px;
413
+ height: 8px;
414
+ border-radius: 50%;
415
+ flex-shrink: 0;
416
+ }}
417
+
418
+ /* Tabs Navigation */
419
+ .tabs-nav {{
420
+ display: flex;
421
+ gap: 4px;
422
+ border-bottom: 1px solid #e5e5e5;
423
+ margin-bottom: 24px;
424
+ padding: 0 4px;
425
+ }}
426
+ .tab-button {{
427
+ padding: 12px 20px;
428
+ background: none;
429
+ border: none;
430
+ font-size: 14px;
431
+ font-weight: 500;
432
+ color: #6b6b6b;
433
+ cursor: pointer;
434
+ transition: all 0.2s;
435
+ border-bottom: 2px solid transparent;
436
+ position: relative;
437
+ top: 1px;
438
+ }}
439
+ .tab-button:hover {{
440
+ color: #1d1d1f;
441
+ background: #f5f5f5;
442
+ }}
443
+ .tab-button.active {{
444
+ color: #0071e3;
445
+ border-bottom-color: #0071e3;
446
+ font-weight: 600;
447
+ }}
448
+ .tab-content {{
449
+ display: none;
450
+ }}
451
+ .tab-content.active {{
452
+ display: block;
453
+ }}
454
+
455
+ /* Small Buttons for Table */
456
+ .btn-sm {{
457
+ padding: 4px 10px;
458
  border-radius: 6px;
459
  font-size: 11px;
460
  cursor: pointer;
461
  font-weight: 500;
462
  transition: all 0.2s;
463
+ border: 1px solid;
464
  }}
465
+ .btn-delete {{
466
+ background: #fff;
467
+ color: #dc2626;
468
+ border-color: #fecaca;
469
+ }}
470
+ .btn-delete:hover {{
471
  background: #dc2626;
472
  color: white;
473
  border-color: #dc2626;
474
  }}
475
+ .btn-disable {{
 
 
476
  background: #fff;
477
  color: #f59e0b;
478
+ border-color: #fed7aa;
 
 
 
 
 
 
479
  }}
480
+ .btn-disable:hover {{
481
  background: #f59e0b;
482
  color: white;
483
  border-color: #f59e0b;
484
  }}
485
+ .btn-enable {{
 
 
486
  background: #fff;
487
  color: #10b981;
488
+ border-color: #a7f3d0;
 
 
 
 
 
 
489
  }}
490
+ .btn-enable:hover {{
491
  background: #10b981;
492
  color: white;
493
  border-color: #10b981;
 
643
  .info-box-title {{ font-weight: 600; font-size: 12px; color: #1d1d1f; margin-bottom: 6px; }}
644
  .info-box-text {{ font-size: 12px; color: #86868b; line-height: 1.5; }}
645
 
646
+ .ep-table {{
647
+ width: 100%;
648
+ border-collapse: collapse;
649
+ font-size: 13px;
650
+ background: #fff;
651
+ border: 1px solid #e5e5e5;
652
+ border-radius: 12px;
653
+ overflow: hidden;
654
+ }}
655
  .ep-table tr {{ border-bottom: 1px solid #f5f5f5; }}
656
  .ep-table tr:last-child {{ border-bottom: none; }}
657
+ .ep-table td {{ padding: 12px 16px; vertical-align: middle; }}
658
 
659
  .method {{
660
  display: inline-block;
 
691
  .header-actions .btn {{ justify-content: center; text-align: center; }}
692
  .ep-table td {{ display: flex; flex-direction: column; align-items: flex-start; gap: 4px; }}
693
  .ep-desc {{ margin-left: 0; }}
694
+
695
+ /* Tabs Mobile */
696
+ .tabs-nav {{
697
+ overflow-x: auto;
698
+ padding: 0;
699
+ gap: 0;
700
+ -webkit-overflow-scrolling: touch;
701
+ }}
702
+ .tab-button {{
703
+ flex: 1;
704
+ min-width: 100px;
705
+ padding: 10px 16px;
706
+ font-size: 13px;
707
+ }}
708
+
709
+ /* Account Table Mobile - Card Layout */
710
+ .account-table {{
711
+ display: block;
712
+ border: none;
713
+ }}
714
+ .account-table thead {{
715
+ display: none;
716
+ }}
717
+ .account-table tbody {{
718
+ display: block;
719
+ }}
720
+ .account-table tr {{
721
+ display: block;
722
+ margin-bottom: 12px;
723
+ border: 1px solid #e5e5e5;
724
+ border-radius: 10px;
725
+ background: #fff;
726
+ padding: 12px;
727
+ position: relative;
728
+ }}
729
+ .account-table td {{
730
+ display: block;
731
+ padding: 0;
732
+ border: none;
733
+ }}
734
+
735
+ /* 账号ID - 卡片头部 */
736
+ .account-table td:nth-child(1) {{
737
+ margin-bottom: 10px;
738
+ padding-bottom: 10px;
739
+ padding-right: 80px;
740
+ border-bottom: 1px solid #f5f5f5;
741
+ }}
742
+ .account-table td:nth-child(1) > div {{
743
+ width: 100%;
744
+ }}
745
+ .account-table td:nth-child(1) span:last-child {{
746
+ word-break: break-all;
747
+ }}
748
+
749
+ /* 状态 - 右上角显示 */
750
+ .account-table td:nth-child(2) {{
751
+ position: absolute;
752
+ top: 12px;
753
+ right: 12px;
754
+ padding: 0;
755
+ }}
756
+ .account-table td:nth-child(2)::before {{
757
+ display: none;
758
+ }}
759
+ .account-table td:nth-child(2) > span {{
760
+ display: inline-block;
761
+ padding: 4px 10px;
762
+ border-radius: 6px;
763
+ font-size: 11px;
764
+ white-space: nowrap;
765
+ font-weight: 600;
766
+ background: rgba(255,255,255,0.95);
767
+ border: 1px solid currentColor;
768
+ opacity: 0.9;
769
+ }}
770
+
771
+ /* 信息行 - 紧凑布局 */
772
+ .account-table td:nth-child(3),
773
+ .account-table td:nth-child(4),
774
+ .account-table td:nth-child(5) {{
775
+ display: flex;
776
+ justify-content: space-between;
777
+ align-items: center;
778
+ padding: 6px 0;
779
+ font-size: 12px;
780
+ }}
781
+ .account-table td:nth-child(3)::before,
782
+ .account-table td:nth-child(4)::before,
783
+ .account-table td:nth-child(5)::before {{
784
+ content: attr(data-label);
785
+ font-weight: 600;
786
+ color: #86868b;
787
+ font-size: 11px;
788
+ margin-right: 8px;
789
+ }}
790
+
791
+ /* 操作按钮 - 底部 */
792
+ .account-table td:nth-child(6) {{
793
+ margin-top: 10px;
794
+ padding-top: 10px;
795
+ border-top: 1px solid #f5f5f5;
796
+ }}
797
+ .account-table td:nth-child(6)::before {{
798
+ display: none;
799
+ }}
800
+ .account-table td:nth-child(6) > div {{
801
+ width: 100%;
802
+ justify-content: flex-end;
803
+ }}
804
  }}
805
  </style>
806
  </head>
 
814
  <div class="header-actions">
815
  <a href="/public/uptime/html" class="btn" target="_blank">📊 状态监控</a>
816
  <a href="/public/log/html" class="btn" target="_blank">📄 公开日志</a>
817
+ <a href="/{admin_path_segment}/log/html" class="btn" target="_blank">🔧 管理日志</a>
818
  <button class="btn" onclick="document.getElementById('fileInput').click()">📥 批量上传</button>
819
  <input type="file" id="fileInput" accept=".json" multiple style="display:none" onchange="handleFileUpload(event)">
820
  <button class="btn" onclick="showEditConfig()" id="edit-btn">✏️ 编辑配置</button>
821
  </div>
822
  </div>
823
 
824
+ <!-- Tabs Navigation -->
825
+ <div class="tabs-nav">
826
+ <button class="tab-button active" onclick="switchTab('accounts')">📋 账户管理</button>
827
+ <button class="tab-button" onclick="switchTab('api')">📚 API文档</button>
828
+ <button class="tab-button" onclick="switchTab('config')">⚙️ 系统配置</button>
829
+ </div>
830
+
831
+ <!-- Tab 1: 账户管理 -->
832
+ <div id="tab-accounts" class="tab-content active">
833
+ {api_key_status}
834
+ {error_alert}
835
+ {api_info_tip}
836
 
837
+ <div class="section">
838
+ <div class="section-title">账户状态 ({len(multi_account_mgr.accounts)} 个)</div>
839
+ <div style="color: #6b6b6b; font-size: 12px; margin-bottom: 12px; padding-left: 4px;">
840
+ 默认过期时间12小时,注意北京时间 • 批量上传使用 <code style="font-size: 11px; background: rgba(0,0,0,0.05); padding: 2px 6px; border-radius: 4px;">script/download-config.js</code> 油猴脚本
841
+ </div>
842
+ {accounts_html}
843
  </div>
844
+ </div>
845
+
846
+ <!-- Tab 2: API文档 -->
847
+ <div id="tab-api" class="tab-content">
848
+ <div class="section">
849
+ <div class="section-title">API 端点列表</div>
850
+
851
+ <div class="current-url-row">
852
+ <span style="font-size:12px; font-weight:600; color:#0071e3; margin-right:8px;">当前页面:</span>
853
+ <code style="background:none; padding:0; color:#1d1d1f;">{current_url}</code>
854
+ </div>
855
+
856
+ <table class="ep-table">
857
+ <tr>
858
+ <td width="70"><span class="method m-post">POST</span></td>
859
+ <td><span class="ep-path">/{api_path_segment}v1/chat/completions</span></td>
860
+ <td><span class="ep-desc">OpenAI 兼容对话接口</span></td>
861
+ </tr>
862
+ <tr>
863
+ <td><span class="method m-get">GET</span></td>
864
+ <td><span class="ep-path">/{api_path_segment}v1/models</span></td>
865
+ <td><span class="ep-desc">获取模型列表</span></td>
866
+ </tr>
867
+ <tr>
868
+ <td><span class="method m-get">GET</span></td>
869
+ <td><span class="ep-path">/{admin_path_segment}</span></td>
870
+ <td><span class="ep-desc">管理首页 (需登录)</span></td>
871
+ </tr>
872
+ <tr>
873
+ <td><span class="method m-get">GET</span></td>
874
+ <td><span class="ep-path">/{admin_path_segment}/health</span></td>
875
+ <td><span class="ep-desc">健康检查 (需登录)</span></td>
876
+ </tr>
877
+ <tr>
878
+ <td><span class="method m-get">GET</span></td>
879
+ <td><span class="ep-path">/{admin_path_segment}/accounts</span></td>
880
+ <td><span class="ep-desc">账户状态 JSON (需登录)</span></td>
881
+ </tr>
882
+ <tr>
883
+ <td><span class="method m-get">GET</span></td>
884
+ <td><span class="ep-path">/{admin_path_segment}/log</span></td>
885
+ <td><span class="ep-desc">获取日志 JSON (需登录)</span></td>
886
+ </tr>
887
+ <tr>
888
+ <td><span class="method m-get">GET</span></td>
889
+ <td><span class="ep-path">/{admin_path_segment}/log/html</span></td>
890
+ <td><span class="ep-desc">日志查看器 HTML (需登录)</span></td>
891
+ </tr>
892
+ <tr>
893
+ <td><span class="method m-del">DEL</span></td>
894
+ <td><span class="ep-path">/{admin_path_segment}/log?confirm=yes</span></td>
895
+ <td><span class="ep-desc">清空系统日志 (需登录)</span></td>
896
+ </tr>
897
+ <tr>
898
+ <td><span class="method m-get">GET</span></td>
899
+ <td><span class="ep-path">/public/stats</span></td>
900
+ <td><span class="ep-desc">公开统计数据</span></td>
901
+ </tr>
902
+ <tr>
903
+ <td><span class="method m-get">GET</span></td>
904
+ <td><span class="ep-path">/public/log</span></td>
905
+ <td><span class="ep-desc">公开日志 (JSON, 脱敏)</span></td>
906
+ </tr>
907
+ <tr>
908
+ <td><span class="method m-get">GET</span></td>
909
+ <td><span class="ep-path">/public/log/html</span></td>
910
+ <td><span class="ep-desc">公开日志查看器 (HTML)</span></td>
911
+ </tr>
912
+ <tr>
913
+ <td><span class="method m-get">GET</span></td>
914
+ <td><span class="ep-path">/public/uptime</span></td>
915
+ <td><span class="ep-desc">实时状态监控 (JSON)</span></td>
916
+ </tr>
917
+ <tr>
918
+ <td><span class="method m-get">GET</span></td>
919
+ <td><span class="ep-path">/public/uptime/html</span></td>
920
+ <td><span class="ep-desc">实时状态监控页面 (HTML)</span></td>
921
+ </tr>
922
+ <tr>
923
+ <td><span class="method m-get">GET</span></td>
924
+ <td><span class="ep-path">/docs</span></td>
925
+ <td><span class="ep-desc">Swagger API 文档</span></td>
926
+ </tr>
927
+ <tr>
928
+ <td><span class="method m-get">GET</span></td>
929
+ <td><span class="ep-path">/redoc</span></td>
930
+ <td><span class="ep-desc">ReDoc API 文档</span></td>
931
+ </tr>
932
+ </table>
933
  </div>
934
  </div>
935
 
936
+ <!-- Tab 3: 系统配置 -->
937
+ <div id="tab-config" class="tab-content">
938
+ <div class="section">
939
+ <div class="section-title">环境变量配置</div>
940
+ <div class="grid-env">
941
+ <div class="stack-col">
942
+ <div class="card">
943
+ <h3>必需变量 <span class="badge badge-required">REQUIRED</span></h3>
944
  <div style="margin-top: 12px;">
945
  <div class="env-var">
946
  <div><div class="env-name">ACCOUNTS_CONFIG</div><div class="env-desc">JSON格式账户列表</div></div>
 
1014
  <div><div class="env-name">MODEL_NAME</div><div class="env-desc">模型名称(公开)</div></div>
1015
  <div class="env-value">{main.MODEL_NAME}</div>
1016
  </div>
 
 
 
 
1017
  </div>
1018
  </div>
1019
  </div>
1020
  </div>
1021
 
1022
+ <div class="section">
1023
+ <div class="section-title">服务信息</div>
 
1024
  <div class="card">
1025
  <h3>支持的模型</h3>
1026
  <div class="model-grid">
 
1040
  </div>
1041
  </div>
1042
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1043
  </div>
1044
  </div>
1045
  </div>
 
1070
  <script>
1071
  let currentConfig = null;
1072
 
1073
+ // 标签页切换函数
1074
+ function switchTab(tabName) {{
1075
+ // 隐藏所有标签页内容
1076
+ document.querySelectorAll('.tab-content').forEach(content => {{
1077
+ content.classList.remove('active');
1078
+ }});
1079
+ // 移除所有标签按钮的active状态
1080
+ document.querySelectorAll('.tab-button').forEach(button => {{
1081
+ button.classList.remove('active');
1082
+ }});
1083
+
1084
+ // 显示选中的标签页
1085
+ document.getElementById('tab-' + tabName).classList.add('active');
1086
+ // 激活对应的按钮
1087
+ event.target.classList.add('active');
1088
+ }}
1089
+
1090
  // 统一的页面刷新函数(避免缓存)
1091
  function refreshPage() {{
1092
+ window.location.reload();
1093
  }}
1094
 
1095
  // 统一的错误处理函数
 
1109
  }}
1110
 
1111
  async function showEditConfig() {{
1112
+ const config = await fetch('/{admin_path_segment}/accounts-config').then(r => r.json());
1113
  currentConfig = config.accounts;
1114
  const json = JSON.stringify(config.accounts, null, 2);
1115
  document.getElementById('jsonEditor').value = json;
 
1156
 
1157
  try {{
1158
  const data = JSON.parse(newJson);
1159
+ const response = await fetch('/{admin_path_segment}/accounts-config', {{
1160
  method: 'PUT',
1161
  headers: {{'Content-Type': 'application/json'}},
1162
  body: JSON.stringify(data)
1163
  }});
1164
 
1165
+ await handleApiResponse(response);
 
1166
  closeModal();
1167
+ refreshPage();
1168
  }} catch (error) {{
1169
  console.error('保存失败:', error);
1170
  alert('更新失败: ' + error.message);
 
1175
  if (!confirm(`确定删除账户 ${{accountId}}?`)) return;
1176
 
1177
  try {{
1178
+ const response = await fetch('/{admin_path_segment}/accounts/' + accountId, {{
1179
  method: 'DELETE'
1180
  }});
1181
 
1182
+ await handleApiResponse(response);
 
1183
  refreshPage();
1184
  }} catch (error) {{
1185
  console.error('删除失败:', error);
 
1188
  }}
1189
 
1190
  async function disableAccount(accountId) {{
 
 
1191
  try {{
1192
+ const response = await fetch('/{admin_path_segment}/accounts/' + accountId + '/disable', {{
1193
  method: 'PUT'
1194
  }});
1195
 
1196
+ await handleApiResponse(response);
 
1197
  refreshPage();
1198
  }} catch (error) {{
1199
  console.error('禁用失败:', error);
 
1202
  }}
1203
 
1204
  async function enableAccount(accountId) {{
 
 
1205
  try {{
1206
+ const response = await fetch('/{admin_path_segment}/accounts/' + accountId + '/enable', {{
1207
  method: 'PUT'
1208
  }});
1209
 
1210
+ await handleApiResponse(response);
 
1211
  refreshPage();
1212
  }} catch (error) {{
1213
  console.error('启用失败:', error);
 
1245
 
1246
  try {{
1247
  // 获取现有配置
1248
+ const configResp = await fetch('/{admin_path_segment}/accounts-config');
1249
  const configData = await handleApiResponse(configResp);
1250
  const existing = configData.accounts || [];
1251
 
 
1282
  }}
1283
 
1284
  // 保存合并后的配置
1285
+ const response = await fetch('/{admin_path_segment}/accounts-config', {{
1286
  method: 'PUT',
1287
  headers: {{'Content-Type': 'application/json'}},
1288
  body: JSON.stringify(existing)
1289
  }});
1290
 
1291
+ await handleApiResponse(response);
 
1292
  event.target.value = '';
1293
+ refreshPage();
1294
  }} catch (error) {{
1295
  console.error('导入失败:', error);
1296
  alert('导入失败: ' + error.message);
 
1311
  return html_content
1312
 
1313
 
 
 
 
 
1314
 
 
 
 
1315
 
1316
+ async def get_public_logs_html():
1317
+ """公开的脱敏日志查看器"""
1318
+ # 动态导入 main 模块的变量(避免循环依赖)
1319
+ import main
1320
 
1321
  html_content = r"""
1322
  <!DOCTYPE html>
 
1324
  <head>
1325
  <meta charset="utf-8">
1326
  <meta name="viewport" content="width=device-width, initial-scale=1">
1327
+ <title>服务状态</title>
1328
  <style>
1329
  * { margin: 0; padding: 0; box-sizing: border-box; }
1330
  html, body { height: 100%; overflow: hidden; }
 
1338
  }
1339
  .container {
1340
  width: 100%;
1341
+ max-width: 1200px;
1342
  height: calc(100vh - 30px);
1343
  background: white;
1344
  border-radius: 16px;
 
1347
  display: flex;
1348
  flex-direction: column;
1349
  }
1350
+ h1 {
1351
+ color: #1a1a1a;
1352
+ font-size: 22px;
1353
+ font-weight: 600;
1354
+ margin-bottom: 20px;
1355
+ display: flex;
1356
+ align-items: center;
1357
+ justify-content: center;
1358
+ gap: 12px;
1359
+ }
1360
+ h1 img {
1361
+ width: 32px;
1362
+ height: 32px;
1363
+ border-radius: 8px;
1364
+ }
1365
+ .info-bar {
1366
+ background: #f9f9f9;
1367
+ border: 1px solid #e5e5e5;
1368
+ border-radius: 8px;
1369
+ padding: 12px 16px;
1370
+ margin-bottom: 16px;
1371
+ display: flex;
1372
+ align-items: center;
1373
+ justify-content: space-between;
1374
+ flex-wrap: wrap;
1375
+ gap: 12px;
1376
+ }
1377
+ .info-item {
1378
+ display: flex;
1379
+ align-items: center;
1380
+ gap: 6px;
1381
+ font-size: 13px;
1382
+ color: #6b6b6b;
1383
+ }
1384
+ .info-item strong { color: #1a1a1a; }
1385
+ .info-item a {
1386
+ color: #1a73e8;
1387
+ text-decoration: none;
1388
+ font-weight: 500;
1389
+ }
1390
+ .info-item a:hover { text-decoration: underline; }
1391
  .stats {
1392
  display: grid;
1393
+ grid-template-columns: repeat(4, 1fr);
1394
  gap: 12px;
1395
  margin-bottom: 16px;
1396
  }
 
1405
  .stat:hover { border-color: #d4d4d4; }
1406
  .stat-label { color: #6b6b6b; font-size: 11px; margin-bottom: 4px; }
1407
  .stat-value { color: #1a1a1a; font-size: 18px; font-weight: 600; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1408
  .log-container {
1409
  flex: 1;
1410
  background: #fafaf9;
 
1415
  scrollbar-width: thin;
1416
  scrollbar-color: rgba(0,0,0,0.15) transparent;
1417
  }
1418
+ .log-container::-webkit-scrollbar { width: 4px; }
1419
+ .log-container::-webkit-scrollbar-track { background: transparent; }
 
 
 
 
 
1420
  .log-container::-webkit-scrollbar-thumb {
1421
  background: rgba(0,0,0,0.15);
1422
  border-radius: 2px;
1423
  }
1424
+ .log-container::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,0.3); }
1425
+ .log-group {
1426
+ margin-bottom: 8px;
1427
+ border: 1px solid #e5e5e5;
1428
+ border-radius: 8px;
1429
+ background: white;
1430
+ }
1431
+ .log-group-header {
1432
+ padding: 10px 12px;
1433
+ background: #f9f9f9;
1434
+ border-radius: 8px 8px 0 0;
1435
+ cursor: pointer;
1436
+ display: flex;
1437
+ align-items: center;
1438
+ gap: 8px;
1439
+ transition: background 0.15s ease;
1440
  }
1441
+ .log-group-header:hover { background: #f0f0f0; }
1442
+ .log-group-content { padding: 8px; }
1443
  .log-entry {
1444
  padding: 8px 10px;
1445
  margin-bottom: 4px;
1446
  background: white;
 
1447
  border: 1px solid #e5e5e5;
1448
+ border-radius: 6px;
 
1449
  display: flex;
1450
  align-items: center;
1451
+ gap: 10px;
1452
+ font-size: 13px;
1453
+ transition: all 0.15s ease;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1454
  }
1455
  .log-entry:hover { border-color: #d4d4d4; }
1456
  .log-time { color: #6b6b6b; font-size: 12px; min-width: 140px; }
 
1956
  </html>
1957
  """
1958
  return HTMLResponse(content=html_content)
1959
+
1960
+
1961
+ async def get_login_html(request: Request, error: str = None) -> HTMLResponse:
1962
+ """生成登录页面"""
1963
+ import main
1964
+
1965
+ # 获取当前URL(用于表单提交)
1966
+ current_path = request.url.path
1967
+
1968
+ # 错误提示
1969
+ error_html = ""
1970
+ if error:
1971
+ error_html = f"""
1972
+ <div class="error-box">
1973
+ ⚠️ {error}
1974
+ </div>
1975
+ """
1976
+
1977
+ html_content = f"""
1978
+ <!DOCTYPE html>
1979
+ <html lang="zh-CN">
1980
+ <head>
1981
+ <meta charset="UTF-8">
1982
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1983
+ <title>登录</title>
1984
+ <style>
1985
+ * {{
1986
+ margin: 0;
1987
+ padding: 0;
1988
+ box-sizing: border-box;
1989
+ }}
1990
+
1991
+ body {{
1992
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
1993
+ background: #fafaf9;
1994
+ min-height: 100vh;
1995
+ display: flex;
1996
+ align-items: center;
1997
+ justify-content: center;
1998
+ padding: 20px;
1999
+ }}
2000
+
2001
+ .container {{
2002
+ background: #fff;
2003
+ border: 1px solid #e5e5e5;
2004
+ border-radius: 12px;
2005
+ width: 100%;
2006
+ max-width: 380px;
2007
+ padding: 40px 32px;
2008
+ }}
2009
+
2010
+ .header {{
2011
+ text-align: center;
2012
+ margin-bottom: 32px;
2013
+ }}
2014
+
2015
+ h1 {{
2016
+ font-size: 22px;
2017
+ font-weight: 600;
2018
+ color: #1d1d1f;
2019
+ margin-bottom: 6px;
2020
+ }}
2021
+
2022
+ .subtitle {{
2023
+ font-size: 14px;
2024
+ color: #86868b;
2025
+ }}
2026
+
2027
+ .error-box {{
2028
+ padding: 12px 14px;
2029
+ background: #fff5f5;
2030
+ border: 1px solid #fecaca;
2031
+ border-radius: 8px;
2032
+ color: #dc2626;
2033
+ font-size: 14px;
2034
+ margin-bottom: 20px;
2035
+ }}
2036
+
2037
+ label {{
2038
+ display: block;
2039
+ font-size: 14px;
2040
+ font-weight: 500;
2041
+ color: #1d1d1f;
2042
+ margin-bottom: 8px;
2043
+ }}
2044
+
2045
+ input[type="password"] {{
2046
+ width: 100%;
2047
+ padding: 12px 14px;
2048
+ border: 1px solid #d4d4d4;
2049
+ border-radius: 8px;
2050
+ font-size: 15px;
2051
+ color: #1d1d1f;
2052
+ background: #fff;
2053
+ transition: border-color 0.15s;
2054
+ outline: none;
2055
+ margin-bottom: 20px;
2056
+ }}
2057
+
2058
+ input[type="password"]:focus {{
2059
+ border-color: #0071e3;
2060
+ }}
2061
+
2062
+ input[type="password"]::placeholder {{
2063
+ color: #c7c7cc;
2064
+ }}
2065
+
2066
+ button {{
2067
+ width: 100%;
2068
+ padding: 12px;
2069
+ background: #0071e3;
2070
+ color: #fff;
2071
+ border: none;
2072
+ border-radius: 8px;
2073
+ font-size: 15px;
2074
+ font-weight: 500;
2075
+ cursor: pointer;
2076
+ transition: background 0.15s;
2077
+ }}
2078
+
2079
+ button:hover {{
2080
+ background: #0077ed;
2081
+ }}
2082
+
2083
+ button:active {{
2084
+ background: #006dd1;
2085
+ }}
2086
+
2087
+ .hint {{
2088
+ margin-top: 20px;
2089
+ padding: 12px;
2090
+ background: #f6f6f8;
2091
+ border-radius: 8px;
2092
+ font-size: 13px;
2093
+ color: #86868b;
2094
+ line-height: 1.5;
2095
+ }}
2096
+
2097
+ @media (max-width: 480px) {{
2098
+ .container {{
2099
+ padding: 32px 24px;
2100
+ }}
2101
+ }}
2102
+ </style>
2103
+ </head>
2104
+ <body>
2105
+ <div class="container">
2106
+ <div class="header">
2107
+ <h1>管理员登录</h1>
2108
+ <p class="subtitle">Gemini Business API</p>
2109
+ </div>
2110
+
2111
+ {error_html}
2112
+
2113
+ <form method="POST" action="{current_path}">
2114
+ <label for="admin_key">密钥</label>
2115
+ <input
2116
+ type="password"
2117
+ id="admin_key"
2118
+ name="admin_key"
2119
+ placeholder="输入 ADMIN_KEY"
2120
+ required
2121
+ autofocus
2122
+ >
2123
+ <button type="submit">登录</button>
2124
+ </form>
2125
+
2126
+ <div class="hint">
2127
+ 会话保持 24 小时
2128
+ </div>
2129
+ </div>
2130
+ </body>
2131
+ </html>
2132
+ """
2133
+ return HTMLResponse(content=html_content)
2134
+
2135
+
2136
+ async def admin_logs_html_no_auth(request):
2137
+ """返回美化的 HTML 日志查看界面(无需认证)"""
2138
+ from fastapi.responses import HTMLResponse
2139
+
2140
+ html_content = r"""
2141
+ <!DOCTYPE html>
2142
+ <html>
2143
+ <head>
2144
+ <meta charset="utf-8">
2145
+ <meta name="viewport" content="width=device-width, initial-scale=1">
2146
+ <title>日志查看器</title>
2147
+ <style>
2148
+ * { margin: 0; padding: 0; box-sizing: border-box; }
2149
+ body {
2150
+ font-family: "Consolas", "Monaco", monospace;
2151
+ background: #fafaf9;
2152
+ padding: 20px;
2153
+ }
2154
+ .container {
2155
+ max-width: 1400px;
2156
+ margin: 0 auto;
2157
+ background: white;
2158
+ border-radius: 16px;
2159
+ padding: 30px;
2160
+ box-shadow: 0 2px 8px rgba(0,0,0,0.08);
2161
+ }
2162
+ h1 { color: #1a1a1a; font-size: 22px; margin-bottom: 20px; }
2163
+ .log-item { padding: 10px; border-bottom: 1px solid #e5e5e5; font-size: 12px; }
2164
+ .log-time { color: #6b6b6b; margin-right: 10px; }
2165
+ .log-level { padding: 2px 6px; border-radius: 4px; margin-right: 10px; }
2166
+ .log-level.INFO { background: #e3f2fd; color: #1976d2; }
2167
+ .log-level.WARNING { background: #fff3e0; color: #f57c00; }
2168
+ .log-level.ERROR { background: #ffebee; color: #c62828; }
2169
+ </style>
2170
+ </head>
2171
+ <body>
2172
+ <div class="container">
2173
+ <h1>📋 系统日志</h1>
2174
+ <div id="logs">加载中...</div>
2175
+ </div>
2176
+ <script>
2177
+ async function loadLogs() {
2178
+ try {
2179
+ const path = window.location.pathname.replace("/log/html", "/log");
2180
+ const res = await fetch(path);
2181
+ const data = await res.json();
2182
+ let html = "";
2183
+ for (const log of data.logs.reverse()) {
2184
+ html += `<div class="log-item">
2185
+ <span class="log-time">${log.time}</span>
2186
+ <span class="log-level ${log.level}">${log.level}</span>
2187
+ <span>${log.message}</span>
2188
+ </div>`;
2189
+ }
2190
+ document.getElementById("logs").innerHTML = html || "暂无日志";
2191
+ } catch (e) {
2192
+ document.getElementById("logs").innerHTML = "加载失败";
2193
+ }
2194
+ }
2195
+ loadLogs();
2196
+ setInterval(loadLogs, 5000);
2197
+ </script>
2198
+ </body>
2199
+ </html>
2200
+ """
2201
+ return HTMLResponse(content=html_content)
2202
+