mirror of
https://github.com/TheSmallHanCat/flow2api.git
synced 2026-06-08 18:12:15 +08:00
性能优化:日志按需加载与 Token/统计链路优化
This commit is contained in:
149
src/api/admin.py
149
src/api/admin.py
@@ -391,39 +391,34 @@ async def change_password(
|
||||
@router.get("/api/tokens")
|
||||
async def get_tokens(token: str = Depends(verify_admin_token)):
|
||||
"""Get all tokens with statistics"""
|
||||
tokens = await token_manager.get_all_tokens()
|
||||
result = []
|
||||
token_rows = await db.get_all_tokens_with_stats()
|
||||
to_iso = lambda value: value.isoformat() if hasattr(value, "isoformat") else value
|
||||
|
||||
for t in tokens:
|
||||
stats = await db.get_token_stats(t.id)
|
||||
|
||||
result.append({
|
||||
"id": t.id,
|
||||
"st": t.st, # Session Token for editing
|
||||
"at": t.at, # Access Token for editing (从ST转换而来)
|
||||
"at_expires": t.at_expires.isoformat() if t.at_expires else None, # 🆕 AT过期时间
|
||||
"token": t.at, # 兼容前端 token.token 的访问方式
|
||||
"email": t.email,
|
||||
"name": t.name,
|
||||
"remark": t.remark,
|
||||
"is_active": t.is_active,
|
||||
"created_at": t.created_at.isoformat() if t.created_at else None,
|
||||
"last_used_at": t.last_used_at.isoformat() if t.last_used_at else None,
|
||||
"use_count": t.use_count,
|
||||
"credits": t.credits, # 🆕 余额
|
||||
"user_paygate_tier": t.user_paygate_tier,
|
||||
"current_project_id": t.current_project_id, # 🆕 项目ID
|
||||
"current_project_name": t.current_project_name, # 🆕 项目名称
|
||||
"image_enabled": t.image_enabled,
|
||||
"video_enabled": t.video_enabled,
|
||||
"image_concurrency": t.image_concurrency,
|
||||
"video_concurrency": t.video_concurrency,
|
||||
"image_count": stats.image_count if stats else 0,
|
||||
"video_count": stats.video_count if stats else 0,
|
||||
"error_count": stats.error_count if stats else 0
|
||||
})
|
||||
|
||||
return result # 直接返回数组,兼容前端
|
||||
return [{
|
||||
"id": row.get("id"),
|
||||
"st": row.get("st"), # Session Token for editing
|
||||
"at": row.get("at"), # Access Token for editing (从ST转换而来)
|
||||
"at_expires": to_iso(row.get("at_expires")) if row.get("at_expires") else None, # 🆕 AT过期时间
|
||||
"token": row.get("at"), # 兼容前端 token.token 的访问方式
|
||||
"email": row.get("email"),
|
||||
"name": row.get("name"),
|
||||
"remark": row.get("remark"),
|
||||
"is_active": bool(row.get("is_active")),
|
||||
"created_at": to_iso(row.get("created_at")) if row.get("created_at") else None,
|
||||
"last_used_at": to_iso(row.get("last_used_at")) if row.get("last_used_at") else None,
|
||||
"use_count": row.get("use_count"),
|
||||
"credits": row.get("credits"), # 🆕 余额
|
||||
"user_paygate_tier": row.get("user_paygate_tier"),
|
||||
"current_project_id": row.get("current_project_id"), # 🆕 项目ID
|
||||
"current_project_name": row.get("current_project_name"), # 🆕 项目名称
|
||||
"image_enabled": bool(row.get("image_enabled")),
|
||||
"video_enabled": bool(row.get("video_enabled")),
|
||||
"image_concurrency": row.get("image_concurrency"),
|
||||
"video_concurrency": row.get("video_concurrency"),
|
||||
"image_count": row.get("image_count", 0),
|
||||
"video_count": row.get("video_count", 0),
|
||||
"error_count": row.get("error_count", 0)
|
||||
} for row in token_rows] # 直接返回数组,兼容前端
|
||||
|
||||
|
||||
@router.post("/api/tokens")
|
||||
@@ -653,6 +648,11 @@ async def import_tokens(
|
||||
added = 0
|
||||
updated = 0
|
||||
errors = []
|
||||
# 保持与历史逻辑一致:按 created_at DESC 的结果中,优先命中同邮箱“最新一条”
|
||||
existing_by_email = {}
|
||||
for existing_token in await token_manager.get_all_tokens():
|
||||
if existing_token.email and existing_token.email not in existing_by_email:
|
||||
existing_by_email[existing_token.email] = existing_token
|
||||
|
||||
for idx, item in enumerate(request.tokens):
|
||||
try:
|
||||
@@ -686,8 +686,7 @@ async def import_tokens(
|
||||
pass
|
||||
|
||||
# 使用邮箱检查是否已存在
|
||||
existing_tokens = await token_manager.get_all_tokens()
|
||||
existing = next((t for t in existing_tokens if t.email == email), None)
|
||||
existing = existing_by_email.get(email)
|
||||
|
||||
if existing:
|
||||
# 更新现有Token
|
||||
@@ -704,6 +703,14 @@ async def import_tokens(
|
||||
# 如果过期则禁用
|
||||
if is_expired:
|
||||
await token_manager.disable_token(existing.id)
|
||||
existing.is_active = False
|
||||
existing.st = st
|
||||
existing.at = at
|
||||
existing.at_expires = at_expires
|
||||
existing.image_enabled = item.image_enabled
|
||||
existing.video_enabled = item.video_enabled
|
||||
existing.image_concurrency = item.image_concurrency
|
||||
existing.video_concurrency = item.video_concurrency
|
||||
updated += 1
|
||||
else:
|
||||
# 添加新Token
|
||||
@@ -717,6 +724,8 @@ async def import_tokens(
|
||||
# 如果过期则禁用
|
||||
if is_expired:
|
||||
await token_manager.disable_token(new_token.id)
|
||||
new_token.is_active = False
|
||||
existing_by_email[email] = new_token
|
||||
added += 1
|
||||
|
||||
except Exception as e:
|
||||
@@ -894,17 +903,14 @@ async def update_generation_config(
|
||||
@router.get("/api/system/info")
|
||||
async def get_system_info(token: str = Depends(verify_admin_token)):
|
||||
"""Get system information"""
|
||||
tokens = await token_manager.get_all_tokens()
|
||||
active_tokens = [t for t in tokens if t.is_active]
|
||||
|
||||
total_credits = sum(t.credits for t in active_tokens)
|
||||
stats = await db.get_system_info_stats()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"info": {
|
||||
"total_tokens": len(tokens),
|
||||
"active_tokens": len(active_tokens),
|
||||
"total_credits": total_credits,
|
||||
"total_tokens": stats["total_tokens"],
|
||||
"active_tokens": stats["active_tokens"],
|
||||
"total_credits": stats["total_credits"],
|
||||
"version": "1.0.0"
|
||||
}
|
||||
}
|
||||
@@ -927,37 +933,7 @@ async def logout(token: str = Depends(verify_admin_token)):
|
||||
@router.get("/api/stats")
|
||||
async def get_stats(token: str = Depends(verify_admin_token)):
|
||||
"""Get statistics for dashboard"""
|
||||
tokens = await token_manager.get_all_tokens()
|
||||
active_tokens = [t for t in tokens if t.is_active]
|
||||
|
||||
# Calculate totals
|
||||
total_images = 0
|
||||
total_videos = 0
|
||||
total_errors = 0
|
||||
today_images = 0
|
||||
today_videos = 0
|
||||
today_errors = 0
|
||||
|
||||
for t in tokens:
|
||||
stats = await db.get_token_stats(t.id)
|
||||
if stats:
|
||||
total_images += stats.image_count
|
||||
total_videos += stats.video_count
|
||||
total_errors += stats.error_count # Historical total errors
|
||||
today_images += stats.today_image_count
|
||||
today_videos += stats.today_video_count
|
||||
today_errors += stats.today_error_count
|
||||
|
||||
return {
|
||||
"total_tokens": len(tokens),
|
||||
"active_tokens": len(active_tokens),
|
||||
"total_images": total_images,
|
||||
"total_videos": total_videos,
|
||||
"total_errors": total_errors,
|
||||
"today_images": today_images,
|
||||
"today_videos": today_videos,
|
||||
"today_errors": today_errors
|
||||
}
|
||||
return await db.get_dashboard_stats()
|
||||
|
||||
|
||||
@router.get("/api/logs")
|
||||
@@ -965,10 +941,33 @@ async def get_logs(
|
||||
limit: int = 100,
|
||||
token: str = Depends(verify_admin_token)
|
||||
):
|
||||
"""Get request logs with token email"""
|
||||
logs = await db.get_logs(limit=limit)
|
||||
"""Get lightweight request logs for list view"""
|
||||
limit = max(1, min(limit, 100))
|
||||
logs = await db.get_logs(limit=limit, include_payload=False)
|
||||
|
||||
return [{
|
||||
"id": log.get("id"),
|
||||
"token_id": log.get("token_id"),
|
||||
"token_email": log.get("token_email"),
|
||||
"token_username": log.get("token_username"),
|
||||
"operation": log.get("operation"),
|
||||
"status_code": log.get("status_code"),
|
||||
"duration": log.get("duration"),
|
||||
"created_at": log.get("created_at")
|
||||
} for log in logs]
|
||||
|
||||
|
||||
@router.get("/api/logs/{log_id}")
|
||||
async def get_log_detail(
|
||||
log_id: int,
|
||||
token: str = Depends(verify_admin_token)
|
||||
):
|
||||
"""Get single request log detail (payload loaded on demand)"""
|
||||
log = await db.get_log_detail(log_id)
|
||||
if not log:
|
||||
raise HTTPException(status_code=404, detail="日志不存在")
|
||||
|
||||
return {
|
||||
"id": log.get("id"),
|
||||
"token_id": log.get("token_id"),
|
||||
"token_email": log.get("token_email"),
|
||||
@@ -979,7 +978,7 @@ async def get_logs(
|
||||
"created_at": log.get("created_at"),
|
||||
"request_body": log.get("request_body"),
|
||||
"response_body": log.get("response_body")
|
||||
} for log in logs]
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/api/logs")
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import aiosqlite
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from typing import Optional, List, Dict, Any
|
||||
from pathlib import Path
|
||||
from .models import Token, TokenStats, Task, RequestLog, AdminConfig, ProxyConfig, GenerationConfig, CacheConfig, Project, CaptchaConfig, PluginConfig
|
||||
|
||||
@@ -577,10 +577,19 @@ class Database:
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_task_id ON tasks(task_id)")
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_token_st ON tokens(st)")
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_project_id ON projects(project_id)")
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_tokens_email ON tokens(email)")
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_tokens_is_active_last_used_at ON tokens(is_active, last_used_at)")
|
||||
|
||||
# Migrate request_logs table if needed
|
||||
await self._migrate_request_logs(db)
|
||||
|
||||
# Request logs query indexes (列表按 created_at 排序 / token 过滤)
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_request_logs_created_at ON request_logs(created_at DESC)")
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_request_logs_token_id_created_at ON request_logs(token_id, created_at DESC)")
|
||||
|
||||
# Token stats lookup index
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_token_stats_token_id ON token_stats(token_id)")
|
||||
|
||||
await db.commit()
|
||||
|
||||
async def _migrate_request_logs(self, db):
|
||||
@@ -700,6 +709,81 @@ class Database:
|
||||
rows = await cursor.fetchall()
|
||||
return [Token(**dict(row)) for row in rows]
|
||||
|
||||
async def get_all_tokens_with_stats(self) -> List[Dict[str, Any]]:
|
||||
"""Get all tokens with merged statistics in one query"""
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
cursor = await db.execute("""
|
||||
SELECT
|
||||
t.*,
|
||||
COALESCE(ts.image_count, 0) AS image_count,
|
||||
COALESCE(ts.video_count, 0) AS video_count,
|
||||
COALESCE(ts.error_count, 0) AS error_count
|
||||
FROM tokens t
|
||||
LEFT JOIN token_stats ts ON ts.token_id = t.id
|
||||
ORDER BY t.created_at DESC
|
||||
""")
|
||||
rows = await cursor.fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
async def get_dashboard_stats(self) -> Dict[str, int]:
|
||||
"""Get dashboard counters with aggregated SQL queries"""
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
|
||||
token_cursor = await db.execute("""
|
||||
SELECT
|
||||
COUNT(*) AS total_tokens,
|
||||
COALESCE(SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END), 0) AS active_tokens
|
||||
FROM tokens
|
||||
""")
|
||||
token_row = await token_cursor.fetchone()
|
||||
|
||||
stats_cursor = await db.execute("""
|
||||
SELECT
|
||||
COALESCE(SUM(image_count), 0) AS total_images,
|
||||
COALESCE(SUM(video_count), 0) AS total_videos,
|
||||
COALESCE(SUM(error_count), 0) AS total_errors,
|
||||
COALESCE(SUM(today_image_count), 0) AS today_images,
|
||||
COALESCE(SUM(today_video_count), 0) AS today_videos,
|
||||
COALESCE(SUM(today_error_count), 0) AS today_errors
|
||||
FROM token_stats
|
||||
""")
|
||||
stats_row = await stats_cursor.fetchone()
|
||||
|
||||
token_data = dict(token_row) if token_row else {}
|
||||
stats_data = dict(stats_row) if stats_row else {}
|
||||
|
||||
return {
|
||||
"total_tokens": int(token_data.get("total_tokens") or 0),
|
||||
"active_tokens": int(token_data.get("active_tokens") or 0),
|
||||
"total_images": int(stats_data.get("total_images") or 0),
|
||||
"total_videos": int(stats_data.get("total_videos") or 0),
|
||||
"total_errors": int(stats_data.get("total_errors") or 0),
|
||||
"today_images": int(stats_data.get("today_images") or 0),
|
||||
"today_videos": int(stats_data.get("today_videos") or 0),
|
||||
"today_errors": int(stats_data.get("today_errors") or 0)
|
||||
}
|
||||
|
||||
async def get_system_info_stats(self) -> Dict[str, int]:
|
||||
"""Get lightweight system counters used by admin dashboard"""
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
cursor = await db.execute("""
|
||||
SELECT
|
||||
COUNT(*) AS total_tokens,
|
||||
COALESCE(SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END), 0) AS active_tokens,
|
||||
COALESCE(SUM(CASE WHEN is_active = 1 THEN credits ELSE 0 END), 0) AS total_credits
|
||||
FROM tokens
|
||||
""")
|
||||
row = await cursor.fetchone()
|
||||
data = dict(row) if row else {}
|
||||
return {
|
||||
"total_tokens": int(data.get("total_tokens") or 0),
|
||||
"active_tokens": int(data.get("active_tokens") or 0),
|
||||
"total_credits": int(data.get("total_credits") or 0)
|
||||
}
|
||||
|
||||
async def get_active_tokens(self) -> List[Token]:
|
||||
"""Get all active tokens"""
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
@@ -1062,19 +1146,19 @@ class Database:
|
||||
log.status_code, log.duration))
|
||||
await db.commit()
|
||||
|
||||
async def get_logs(self, limit: int = 100, token_id: Optional[int] = None):
|
||||
"""Get request logs with token email"""
|
||||
async def get_logs(self, limit: int = 100, token_id: Optional[int] = None, include_payload: bool = False):
|
||||
"""Get request logs with token info, optionally including payload fields"""
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
payload_columns = "rl.request_body, rl.response_body," if include_payload else ""
|
||||
|
||||
if token_id:
|
||||
cursor = await db.execute("""
|
||||
cursor = await db.execute(f"""
|
||||
SELECT
|
||||
rl.id,
|
||||
rl.token_id,
|
||||
rl.operation,
|
||||
rl.request_body,
|
||||
rl.response_body,
|
||||
{payload_columns}
|
||||
rl.status_code,
|
||||
rl.duration,
|
||||
rl.created_at,
|
||||
@@ -1087,13 +1171,12 @@ class Database:
|
||||
LIMIT ?
|
||||
""", (token_id, limit))
|
||||
else:
|
||||
cursor = await db.execute("""
|
||||
cursor = await db.execute(f"""
|
||||
SELECT
|
||||
rl.id,
|
||||
rl.token_id,
|
||||
rl.operation,
|
||||
rl.request_body,
|
||||
rl.response_body,
|
||||
{payload_columns}
|
||||
rl.status_code,
|
||||
rl.duration,
|
||||
rl.created_at,
|
||||
@@ -1108,6 +1191,30 @@ class Database:
|
||||
rows = await cursor.fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
async def get_log_detail(self, log_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get single request log detail including payload fields"""
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
cursor = await db.execute("""
|
||||
SELECT
|
||||
rl.id,
|
||||
rl.token_id,
|
||||
rl.operation,
|
||||
rl.request_body,
|
||||
rl.response_body,
|
||||
rl.status_code,
|
||||
rl.duration,
|
||||
rl.created_at,
|
||||
t.email as token_email,
|
||||
t.name as token_username
|
||||
FROM request_logs rl
|
||||
LEFT JOIN tokens t ON rl.token_id = t.id
|
||||
WHERE rl.id = ?
|
||||
LIMIT 1
|
||||
""", (log_id,))
|
||||
row = await cursor.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
async def clear_all_logs(self):
|
||||
"""Clear all request logs"""
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
|
||||
@@ -793,7 +793,8 @@ class GenerationHandler:
|
||||
if stream:
|
||||
yield self._create_stream_chunk("初始化生成环境...\n")
|
||||
|
||||
if not await self.token_manager.is_at_valid(token.id):
|
||||
token = await self.token_manager.ensure_valid_token(token)
|
||||
if not token:
|
||||
error_msg = "Token AT无效或刷新失败"
|
||||
debug_logger.log_error(f"[GENERATION] {error_msg}")
|
||||
if stream:
|
||||
@@ -801,9 +802,6 @@ class GenerationHandler:
|
||||
yield self._create_error_response(error_msg)
|
||||
return
|
||||
|
||||
# 重新获取token (AT可能已刷新)
|
||||
token = await self.token_manager.get_token(token.id)
|
||||
|
||||
# 4. 确保Project存在
|
||||
debug_logger.log_info(f"[GENERATION] 检查/创建Project...")
|
||||
|
||||
|
||||
@@ -144,9 +144,11 @@ class LoadBalancer:
|
||||
# 只为候选列表中真正尝试到的 token 做 AT 校验,避免每次请求把所有 token 全扫一遍
|
||||
for item in available_tokens:
|
||||
token = item["token"]
|
||||
token_id = token.id
|
||||
|
||||
if not await self.token_manager.is_at_valid(token.id):
|
||||
debug_logger.log_info(f"[LOAD_BALANCER] 跳过 Token {token.id}: AT无效或已过期")
|
||||
token = await self.token_manager.ensure_valid_token(token)
|
||||
if not token:
|
||||
debug_logger.log_info(f"[LOAD_BALANCER] 跳过 Token {token_id}: AT无效或已过期")
|
||||
continue
|
||||
|
||||
if reserve and not await self._reserve_slot(token.id, for_image_generation, for_video_generation):
|
||||
|
||||
@@ -230,43 +230,58 @@ class TokenManager:
|
||||
|
||||
# ========== AT自动刷新逻辑 (核心) ==========
|
||||
|
||||
async def is_at_valid(self, token_id: int) -> bool:
|
||||
"""检查AT是否有效,如果无效或即将过期则自动刷新
|
||||
|
||||
Returns:
|
||||
True if AT is valid or refreshed successfully
|
||||
False if AT cannot be refreshed
|
||||
"""
|
||||
token = await self.db.get_token(token_id)
|
||||
if not token:
|
||||
return False
|
||||
|
||||
# 如果AT不存在,需要刷新
|
||||
def _should_refresh_at(self, token: Token) -> bool:
|
||||
"""根据当前 token 快照判断是否需要刷新 AT。"""
|
||||
if not token.at:
|
||||
debug_logger.log_info(f"[AT_CHECK] Token {token_id}: AT不存在,需要刷新")
|
||||
return await self._refresh_at(token_id)
|
||||
debug_logger.log_info(f"[AT_CHECK] Token {token.id}: AT不存在,需要刷新")
|
||||
return True
|
||||
|
||||
# 如果没有过期时间,假设需要刷新
|
||||
if not token.at_expires:
|
||||
debug_logger.log_info(f"[AT_CHECK] Token {token_id}: AT过期时间未知,尝试刷新")
|
||||
return await self._refresh_at(token_id)
|
||||
debug_logger.log_info(f"[AT_CHECK] Token {token.id}: AT过期时间未知,尝试刷新")
|
||||
return True
|
||||
|
||||
# 检查是否即将过期 (提前1小时刷新)
|
||||
now = datetime.now(timezone.utc)
|
||||
# 确保at_expires也是timezone-aware
|
||||
if token.at_expires.tzinfo is None:
|
||||
at_expires_aware = token.at_expires.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
at_expires_aware = token.at_expires
|
||||
|
||||
time_until_expiry = at_expires_aware - now
|
||||
if time_until_expiry.total_seconds() < 3600:
|
||||
debug_logger.log_info(
|
||||
f"[AT_CHECK] Token {token.id}: AT即将过期 "
|
||||
f"(剩余 {time_until_expiry.total_seconds():.0f} 秒),需要刷新"
|
||||
)
|
||||
return True
|
||||
|
||||
if time_until_expiry.total_seconds() < 3600: # 1 hour (3600 seconds)
|
||||
debug_logger.log_info(f"[AT_CHECK] Token {token_id}: AT即将过期 (剩余 {time_until_expiry.total_seconds():.0f} 秒),需要刷新")
|
||||
return await self._refresh_at(token_id)
|
||||
return False
|
||||
|
||||
# AT有效
|
||||
return True
|
||||
async def ensure_valid_token(self, token: Optional[Token]) -> Optional[Token]:
|
||||
"""确保 token 的 AT 可用,并在必要时返回刷新后的最新对象。"""
|
||||
if not token:
|
||||
return None
|
||||
|
||||
if not self._should_refresh_at(token):
|
||||
return token
|
||||
|
||||
if not await self._refresh_at(token.id):
|
||||
return None
|
||||
|
||||
return await self.db.get_token(token.id)
|
||||
|
||||
async def is_at_valid(self, token_id: int, token: Optional[Token] = None) -> bool:
|
||||
"""检查AT是否有效,如果无效或即将过期则自动刷新
|
||||
|
||||
Returns:
|
||||
True if AT is valid or refreshed successfully
|
||||
False if AT cannot be refreshed
|
||||
"""
|
||||
token_obj = token if token and token.id == token_id else await self.db.get_token(token_id)
|
||||
if not token_obj:
|
||||
return False
|
||||
|
||||
valid_token = await self.ensure_valid_token(token_obj)
|
||||
return valid_token is not None
|
||||
|
||||
|
||||
async def _refresh_at(self, token_id: int) -> bool:
|
||||
@@ -572,12 +587,10 @@ class TokenManager:
|
||||
return 0
|
||||
|
||||
# 确保AT有效
|
||||
if not await self.is_at_valid(token_id):
|
||||
token = await self.ensure_valid_token(token)
|
||||
if not token:
|
||||
return 0
|
||||
|
||||
# 重新获取token (AT可能已刷新)
|
||||
token = await self.db.get_token(token_id)
|
||||
|
||||
try:
|
||||
result = await self.flow_client.get_credits(token.at)
|
||||
credits = result.get("credits", 0)
|
||||
|
||||
@@ -785,11 +785,11 @@
|
||||
generateRandomToken=()=>{const chars='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';let token='';for(let i=0;i<32;i++){token+=chars.charAt(Math.floor(Math.random()*chars.length))}$('cfgPluginConnectionToken').value=token;showToast('随机Token已生成','success')},
|
||||
toggleATAutoRefresh=async()=>{try{const enabled=$('atAutoRefreshToggle').checked;const r=await apiRequest('/api/token-refresh/enabled',{method:'POST',body:JSON.stringify({enabled:enabled})});if(!r){$('atAutoRefreshToggle').checked=!enabled;return}const d=await r.json();if(d.success){showToast(enabled?'AT自动刷新已启用':'AT自动刷新已禁用','success')}else{showToast('操作失败: '+(d.detail||'未知错误'),'error');$('atAutoRefreshToggle').checked=!enabled}}catch(e){showToast('操作失败: '+e.message,'error');$('atAutoRefreshToggle').checked=!enabled}},
|
||||
loadATAutoRefreshConfig=async()=>{try{const r=await apiRequest('/api/token-refresh/config');if(!r)return;const d=await r.json();if(d.success&&d.config){$('atAutoRefreshToggle').checked=d.config.at_auto_refresh_enabled||false}else{console.error('AT自动刷新配置数据格式错误:',d)}}catch(e){console.error('加载AT自动刷新配置失败:',e)}},
|
||||
loadLogs=async()=>{try{const r=await apiRequest('/api/logs?limit=100');if(!r)return;const logs=await r.json();window.allLogs=logs;const tb=$('logsTableBody');tb.innerHTML=logs.map(l=>`<tr><td class="py-2.5 px-3">${l.operation}</td><td class="py-2.5 px-3"><span class="text-xs ${l.token_email?'text-blue-600':'text-muted-foreground'}">${l.token_email||'未知'}</span></td><td class="py-2.5 px-3"><span class="inline-flex items-center rounded px-2 py-0.5 text-xs ${l.status_code===200?'bg-green-50 text-green-700':'bg-red-50 text-red-700'}">${l.status_code}</span></td><td class="py-2.5 px-3">${l.duration.toFixed(2)}</td><td class="py-2.5 px-3 text-xs text-muted-foreground">${l.created_at?new Date(l.created_at).toLocaleString('zh-CN'):'-'}</td><td class="py-2.5 px-3"><button onclick="showLogDetail(${l.id})" class="inline-flex items-center justify-center rounded-md hover:bg-blue-50 hover:text-blue-700 h-7 px-2 text-xs">查看</button></td></tr>`).join('')}catch(e){console.error('加载日志失败:',e)}},
|
||||
loadLogs=async()=>{try{const r=await apiRequest('/api/logs?limit=100');if(!r)return;const logs=await r.json();window.allLogs=logs;window.logDetailCache=window.logDetailCache||Object.create(null);const tb=$('logsTableBody');if(!logs.length){tb.innerHTML='<tr><td colspan="6" class="py-8 px-3 text-center text-sm text-muted-foreground">暂无日志</td></tr>';return}tb.innerHTML=logs.map(l=>`<tr><td class="py-2.5 px-3">${escapeLogHtml(l.operation||'-')}</td><td class="py-2.5 px-3"><span class="text-xs ${l.token_email?'text-blue-600':'text-muted-foreground'}">${escapeLogHtml(l.token_email||'未知')}</span></td><td class="py-2.5 px-3"><span class="inline-flex items-center rounded px-2 py-0.5 text-xs ${l.status_code===200?'bg-green-50 text-green-700':'bg-red-50 text-red-700'}">${escapeLogHtml(l.status_code??'-')}</span></td><td class="py-2.5 px-3">${Number(l.duration||0).toFixed(2)}</td><td class="py-2.5 px-3 text-xs text-muted-foreground">${l.created_at?new Date(l.created_at).toLocaleString('zh-CN'):'-'}</td><td class="py-2.5 px-3"><button onclick="showLogDetail(${l.id})" class="inline-flex items-center justify-center rounded-md hover:bg-blue-50 hover:text-blue-700 h-7 px-2 text-xs">查看</button></td></tr>`).join('')}catch(e){console.error('加载日志失败:',e);showToast('加载日志失败: '+e.message,'error')}},
|
||||
refreshLogs=async()=>{await loadLogs()},
|
||||
clearAllLogs=async()=>{if(!confirm('确定要清空所有日志吗?此操作不可恢复!'))return;try{const r=await apiRequest('/api/logs',{method:'DELETE'});if(!r)return;const d=await r.json();if(d.success){showToast('日志已清空','success');await loadLogs()}else{showToast('清空失败: '+(d.message||'未知错误'),'error')}}catch(e){showToast('清空失败: '+e.message,'error')}},
|
||||
showLogDetail=(logId)=>{renderLogDetail(logId)},
|
||||
closeLogDetailModal=()=>{$('logDetailModal').classList.add('hidden')},
|
||||
clearAllLogs=async()=>{if(!confirm('确定要清空所有日志吗?此操作不可恢复!'))return;try{const r=await apiRequest('/api/logs',{method:'DELETE'});if(!r)return;const d=await r.json();if(d.success){window.logDetailCache=Object.create(null);window.activeLogDetailId=null;resetLogMediaCache();showToast('日志已清空','success');await loadLogs()}else{showToast('清空失败: '+(d.message||'未知错误'),'error')}}catch(e){showToast('清空失败: '+e.message,'error')}},
|
||||
showLogDetail=async(logId)=>{const modal=$('logDetailModal');const content=$('logDetailContent');window.logDetailCache=window.logDetailCache||Object.create(null);window.logDetailRequestSeq=(window.logDetailRequestSeq||0)+1;const requestSeq=window.logDetailRequestSeq;window.activeLogDetailId=logId;modal.classList.remove('hidden');content.innerHTML='<div class="rounded-md border border-border p-4 bg-muted/30 text-sm text-muted-foreground">日志详情加载中...</div>';try{let log=window.logDetailCache[logId];if(!log){const r=await apiRequest(`/api/logs/${logId}`);if(!r)return;if(window.logDetailRequestSeq!==requestSeq||window.activeLogDetailId!==logId||modal.classList.contains('hidden'))return;if(r.status===404){content.innerHTML='<div class="rounded-md border border-red-200 p-4 bg-red-50 text-sm text-red-700">日志不存在或已被删除</div>';return}log=await r.json();window.logDetailCache[logId]=log}if(window.logDetailRequestSeq!==requestSeq||window.activeLogDetailId!==logId||modal.classList.contains('hidden'))return;renderLogDetail(log)}catch(e){if(window.logDetailRequestSeq!==requestSeq||window.activeLogDetailId!==logId||modal.classList.contains('hidden'))return;console.error('加载日志详情失败:',e);content.innerHTML=`<div class="rounded-md border border-red-200 p-4 bg-red-50 text-sm text-red-700">加载日志详情失败: ${escapeLogHtml(e.message||'未知错误')}</div>`;showToast('加载日志详情失败: '+e.message,'error')}},
|
||||
closeLogDetailModal=()=>{window.activeLogDetailId=null;resetLogMediaCache();$('logDetailModal').classList.add('hidden')},
|
||||
showToast=(m,t='info')=>{const d=document.createElement('div'),bc={success:'bg-green-600',error:'bg-destructive',info:'bg-primary'};d.className=`fixed bottom-4 right-4 ${bc[t]||bc.info} text-white px-4 py-2.5 rounded-lg shadow-lg text-sm font-medium z-50 animate-slide-up`;d.textContent=m;document.body.appendChild(d);setTimeout(()=>{d.style.opacity='0';d.style.transition='opacity .3s';setTimeout(()=>d.parentNode&&document.body.removeChild(d),300)},2000)},
|
||||
logout=()=>{if(!confirm('确定要退出登录吗?'))return;localStorage.removeItem('adminToken');location.href='/login'},
|
||||
switchTab=t=>{const cap=n=>n.charAt(0).toUpperCase()+n.slice(1);['tokens','settings','logs'].forEach(n=>{const active=n===t;$(`panel${cap(n)}`).classList.toggle('hidden',!active);$(`tab${cap(n)}`).classList.toggle('border-primary',active);$(`tab${cap(n)}`).classList.toggle('text-primary',active);$(`tab${cap(n)}`).classList.toggle('border-transparent',!active);$(`tab${cap(n)}`).classList.toggle('text-muted-foreground',!active)});if(t==='settings'){loadAdminConfig();loadProxyConfig();loadCacheConfig();loadGenerationTimeout();loadCaptchaConfig();loadPluginConfig();loadATAutoRefreshConfig()}else if(t==='logs'){loadLogs()}};
|
||||
@@ -804,8 +804,43 @@
|
||||
try{return JSON.parse(raw)}catch(_){return null}
|
||||
}
|
||||
|
||||
function formatLogPayload(raw){
|
||||
const parsed=parseLogJson(raw);
|
||||
if(parsed){
|
||||
return JSON.stringify(parsed,(_,value)=>{
|
||||
if(typeof value!=='string') return value;
|
||||
if(value.length<=4096) return value;
|
||||
if(/^data:(image|video)\//i.test(value)) return `[数据URL已省略,长度=${value.length}]`;
|
||||
const sample=value.slice(0,256);
|
||||
if(/^[A-Za-z0-9+/=\r\n]+$/.test(sample)) return `[大体积Base64已省略,长度=${value.length}]`;
|
||||
return `${value.slice(0,800)}... [已截断,长度=${value.length}]`;
|
||||
},2);
|
||||
}
|
||||
if(!raw) return '无';
|
||||
const text=String(raw);
|
||||
if(text.length<=6000) return text;
|
||||
return `${text.slice(0,1200)}... [已截断,长度=${text.length}]`;
|
||||
}
|
||||
|
||||
function resetLogMediaCache(){
|
||||
window.logMediaUrlCache=Object.create(null);
|
||||
window.logMediaSeq=0;
|
||||
}
|
||||
|
||||
function cacheLogMediaUrl(url){
|
||||
if(!url) return '';
|
||||
window.logMediaUrlCache=window.logMediaUrlCache||Object.create(null);
|
||||
window.logMediaSeq=(window.logMediaSeq||0)+1;
|
||||
const key=`log-media-${window.logMediaSeq}`;
|
||||
window.logMediaUrlCache[key]=url;
|
||||
return key;
|
||||
}
|
||||
|
||||
function renderLogLink(label,url){
|
||||
if(!url) return '';
|
||||
if(/^data:/i.test(String(url))){
|
||||
return `<p class="text-xs"><span class="font-medium">${label}:</span> <span class="text-muted-foreground">data URL(长度 ${String(url).length})</span></p>`;
|
||||
}
|
||||
const safeUrl=escapeLogHtml(url);
|
||||
return `<p class="text-xs"><span class="font-medium">${label}:</span> <a href="${safeUrl}" target="_blank" class="text-blue-600 hover:underline break-all">${safeUrl}</a></p>`;
|
||||
}
|
||||
@@ -826,25 +861,38 @@
|
||||
|
||||
function renderMediaPreview(label,url,withUrl=true){
|
||||
if(!url) return '';
|
||||
const safeUrl=escapeLogHtml(url);
|
||||
let previewHtml='';
|
||||
if(isVideoUrl(url)){
|
||||
previewHtml=`<video src="${safeUrl}" controls class="w-full max-h-80 rounded-md border border-border bg-black"></video>`;
|
||||
}else if(isImageUrl(url)){
|
||||
previewHtml=`<img src="${safeUrl}" alt="${escapeLogHtml(label)}" loading="lazy" class="max-h-80 rounded-md border border-border object-contain bg-background">`;
|
||||
}
|
||||
return `<div class="space-y-2"><p class="text-xs font-medium">${escapeLogHtml(label)}</p>${withUrl?renderLogLink('URL',url):''}${previewHtml}</div>`;
|
||||
const mediaType=isVideoUrl(url)?'video':(isImageUrl(url)?'image':'');
|
||||
const mediaKey=mediaType?cacheLogMediaUrl(url):'';
|
||||
const previewTrigger=mediaType?`<button onclick="loadLogMediaPreview(this)" data-media-key="${mediaKey}" data-label="${escapeLogHtml(label)}" data-media-type="${mediaType}" class="inline-flex items-center justify-center rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent">点击加载预览</button><div class="space-y-2"></div>`:'';
|
||||
return `<div class="space-y-2"><p class="text-xs font-medium">${escapeLogHtml(label)}</p>${withUrl?renderLogLink('URL',url):''}${previewTrigger}</div>`;
|
||||
}
|
||||
|
||||
function renderLogDetail(logId){
|
||||
const log=(window.allLogs||[]).find(l=>l.id===logId);
|
||||
if(!log){showToast('日志不存在','error');return}
|
||||
function loadLogMediaPreview(button){
|
||||
if(!button) return;
|
||||
const container=button.nextElementSibling;
|
||||
const mediaKey=button.dataset.mediaKey||'';
|
||||
const url=(window.logMediaUrlCache&&window.logMediaUrlCache[mediaKey])||'';
|
||||
const label=button.dataset.label||'';
|
||||
const mediaType=button.dataset.mediaType||'';
|
||||
if(!container||!url||!mediaType) return;
|
||||
if(mediaType==='video'){
|
||||
container.innerHTML=`<video src="${escapeLogHtml(url)}" controls preload="metadata" class="w-full max-h-80 rounded-md border border-border bg-black"></video>`;
|
||||
}else{
|
||||
container.innerHTML=`<img src="${escapeLogHtml(url)}" alt="${escapeLogHtml(label)}" loading="lazy" decoding="async" class="max-h-80 rounded-md border border-border object-contain bg-background">`;
|
||||
}
|
||||
button.remove();
|
||||
}
|
||||
|
||||
function renderLogDetail(log){
|
||||
if(!log){showToast('日志不存在','error');return}
|
||||
resetLogMediaCache();
|
||||
const requestBodyObj=parseLogJson(log.request_body);
|
||||
const responseBodyObj=parseLogJson(log.response_body);
|
||||
const requestPayloadText=formatLogPayload(log.request_body);
|
||||
const responsePayloadText=formatLogPayload(log.response_body);
|
||||
let detailHtml='';
|
||||
|
||||
detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm">请求数据</h4><pre class="rounded-md border border-border p-3 bg-muted/30 text-xs overflow-x-auto">${escapeLogHtml(requestBodyObj?JSON.stringify(requestBodyObj,null,2):(log.request_body||'无'))}</pre></div>`;
|
||||
detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm">请求数据</h4><pre class="rounded-md border border-border p-3 bg-muted/30 text-xs overflow-x-auto">${escapeLogHtml(requestPayloadText)}</pre></div>`;
|
||||
|
||||
if(log.status_code===200){
|
||||
if(responseBodyObj){
|
||||
@@ -860,7 +908,7 @@
|
||||
if(assets.upscaled_image&&typeof assets.upscaled_image==='object'){
|
||||
const up=assets.upscaled_image;
|
||||
const upResolution=up.resolution||'放大';
|
||||
const upPreviewUrl=up.local_url||up.url||(up.base64?`data:image/jpeg;base64,${up.base64}`:'');
|
||||
const upPreviewUrl=up.local_url||up.url||null;
|
||||
assetsHtml+=`<p class="text-xs"><span class="font-medium">放大分辨率:</span> ${escapeLogHtml(upResolution)}</p>`;
|
||||
if(upPreviewUrl){
|
||||
assetsHtml+=renderMediaPreview(`${upResolution}结果`,upPreviewUrl,false);
|
||||
@@ -879,21 +927,20 @@
|
||||
detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm">2K/4K 资产信息</h4><div class="rounded-md border border-border p-3 bg-muted/30 space-y-2">${assetsHtml||'<p class="text-xs text-muted-foreground">无资产详情</p>'}</div></div>`;
|
||||
}
|
||||
|
||||
detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm">完整响应</h4><pre class="rounded-md border border-border p-3 bg-muted/30 text-xs overflow-x-auto">${escapeLogHtml(JSON.stringify(responseBodyObj,null,2))}</pre></div>`;
|
||||
detailHtml+=`<details class="space-y-2"><summary class="cursor-pointer text-sm font-medium">完整响应(大字段已截断)</summary><pre class="mt-2 rounded-md border border-border p-3 bg-muted/30 text-xs overflow-x-auto">${escapeLogHtml(responsePayloadText)}</pre></details>`;
|
||||
}else{
|
||||
detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm">响应数据</h4><pre class="rounded-md border border-border p-3 bg-muted/30 text-xs overflow-x-auto">${escapeLogHtml(log.response_body||'无')}</pre></div>`;
|
||||
detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm">响应数据</h4><pre class="rounded-md border border-border p-3 bg-muted/30 text-xs overflow-x-auto">${escapeLogHtml(responsePayloadText)}</pre></div>`;
|
||||
}
|
||||
}else{
|
||||
if(responseBodyObj&&responseBodyObj.error){
|
||||
detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm text-red-600">错误原因</h4><div class="rounded-md border border-red-200 p-3 bg-red-50"><p class="text-sm text-red-700">${escapeLogHtml(responseBodyObj.error.message||responseBodyObj.error||'未知错误')}</p></div></div>`;
|
||||
}
|
||||
detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm text-red-600">错误响应</h4><pre class="rounded-md border border-red-200 p-3 bg-red-50 text-xs overflow-x-auto">${escapeLogHtml(responseBodyObj?JSON.stringify(responseBodyObj,null,2):(log.response_body||'无'))}</pre></div>`;
|
||||
detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm text-red-600">错误响应</h4><pre class="rounded-md border border-red-200 p-3 bg-red-50 text-xs overflow-x-auto">${escapeLogHtml(responsePayloadText)}</pre></div>`;
|
||||
}
|
||||
|
||||
detailHtml+=`<div class="space-y-2 pt-4 border-t border-border"><h4 class="font-medium text-sm">基本信息</h4><div class="grid grid-cols-2 gap-2 text-sm"><div><span class="text-muted-foreground">操作:</span> ${escapeLogHtml(log.operation||'-')}</div><div><span class="text-muted-foreground">状态码:</span> <span class="inline-flex items-center rounded px-2 py-0.5 text-xs ${log.status_code===200?'bg-green-50 text-green-700':'bg-red-50 text-red-700'}">${log.status_code}</span></div><div><span class="text-muted-foreground">耗时:</span> ${Number(log.duration||0).toFixed(2)}秒</div><div><span class="text-muted-foreground">时间:</span> ${log.created_at?new Date(log.created_at).toLocaleString('zh-CN'):'-'}</div></div></div>`;
|
||||
detailHtml+=`<div class="space-y-2 pt-4 border-t border-border"><h4 class="font-medium text-sm">基本信息</h4><div class="grid grid-cols-2 gap-2 text-sm"><div><span class="text-muted-foreground">操作:</span> ${escapeLogHtml(log.operation||'-')}</div><div><span class="text-muted-foreground">状态码:</span> <span class="inline-flex items-center rounded px-2 py-0.5 text-xs ${log.status_code===200?'bg-green-50 text-green-700':'bg-red-50 text-red-700'}">${escapeLogHtml(log.status_code??'-')}</span></div><div><span class="text-muted-foreground">耗时:</span> ${Number(log.duration||0).toFixed(2)}秒</div><div><span class="text-muted-foreground">时间:</span> ${log.created_at?new Date(log.created_at).toLocaleString('zh-CN'):'-'}</div><div><span class="text-muted-foreground">Token:</span> ${escapeLogHtml(log.token_email||log.token_username||'未知')}</div><div><span class="text-muted-foreground">日志ID:</span> ${escapeLogHtml(log.id??'-')}</div></div></div>`;
|
||||
|
||||
$('logDetailContent').innerHTML=detailHtml;
|
||||
$('logDetailModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
window.addEventListener('DOMContentLoaded',()=>{checkAuth();refreshTokens();loadATAutoRefreshConfig()});
|
||||
|
||||
Reference in New Issue
Block a user