Compare commits

...

20 Commits

Author SHA1 Message Date
TheSmallHanCat
29fddfa85b feat: 增强Token禁用状态管理,区分失效与禁用 2026-02-07 20:36:41 +08:00
TheSmallHanCat
5a0ccbe2de feat: 新增外部pow获取 2026-02-02 12:57:54 +08:00
genz27
fc95de0f28 feat: 集成轻量级Playwright sentinel_token获取方案并添加缓存复用
- 从get_sentinel_token.py同步轻量级Playwright方案
- 添加全局浏览器实例复用,减少资源消耗
- 实现sentinel_token缓存,只在nf/create返回400时刷新
- 获取oai-did时遇到403/429直接抛出错误,不再重试

Co-Authored-By: Warp <agent@warp.dev>
2026-01-29 19:55:24 +08:00
TheSmallHanCat
92015882cc feat: 新增获取Sentinel Token及POW代理配置 2026-01-28 20:58:40 +08:00
TheSmallHanCat
5570fa35a6 fix: 修复任务取消时间计算及日志状态显示逻辑 2026-01-27 00:22:10 +08:00
TheSmallHanCat
06c2bea806 fix: 修复管理配置更新缺失字段及日志状态更新机制
- 修复update_admin_config方法未更新task_retry_enabled、task_max_retries、auto_disable_on_401字段的问题
- 新增finally块确保请求日志在异常情况下也能正确更新状态,避免卡在status_code=-1
2026-01-26 20:12:20 +08:00
TheSmallHanCat
576310c50c feat: 增加状态筛选框 2026-01-25 17:53:26 +08:00
TheSmallHanCat
dab1f13310 feat: 支持纯RT导入 2026-01-25 15:27:05 +08:00
TheSmallHanCat
50e004d722 Add fallback_on_failure option to settings 2026-01-25 00:05:46 +08:00
TheSmallHanCat
ef49e3e670 feat: 新增去水印失败自动回退配置、优化批量删除逻辑及错误处理机制 2026-01-24 19:19:33 +08:00
TheSmallHanCat
d300f94683 fix: 思维链内容输出 2026-01-24 15:42:33 +08:00
TheSmallHanCat
ef1d1f90de feat: 新增时区配置功能、支持UTC时间自动转换为本地时区及环境变量配置 2026-01-24 13:35:38 +08:00
TheSmallHanCat
5342435512 feat: 新增401错误自动禁用Token功能、优化任务进度显示及日志状态判断逻辑 2026-01-24 13:02:58 +08:00
TheSmallHanCat
4b471ccb2b feat: 新增任务失败自动重试机制、支持配置重试次数及智能错误判断 2026-01-24 11:55:34 +08:00
TheSmallHanCat
1703876ffa feat: 新增代理连接测试功能、支持自定义测试域名及实时状态反馈 2026-01-24 02:13:07 +08:00
TheSmallHanCat
447079f863 feat: 新增客户端请求响应日志记录、优化错误处理及日志源标识 2026-01-24 02:01:09 +08:00
TheSmallHanCat
a1ba92e8f6 feat: 新增账号调用逻辑配置、支持随机轮询和逐个轮询模式切换 2026-01-24 01:43:58 +08:00
TheSmallHanCat
a93d81bfc0 fix: pow逻辑 2026-01-21 01:56:59 +08:00
TheSmallHanCat
0cc1c2e32d fix: 无水印配置报错异常 2026-01-17 13:17:52 +08:00
TheSmallHanCat
881366175f fix: 可用次数显示 2026-01-17 13:01:35 +08:00
19 changed files with 2544 additions and 223 deletions

View File

@@ -1,10 +1,49 @@
FROM python:3.11-slim
# Set timezone to Asia/Shanghai (UTC+8) by default
# Can be overridden with -e TZ=<timezone> when running container
ENV TZ=Asia/Shanghai \
TIMEZONE_OFFSET=8
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
WORKDIR /app
# Install system dependencies for Playwright
RUN apt-get update && apt-get install -y \
wget \
gnupg \
ca-certificates \
fonts-liberation \
libatk1.0-0 \
libatk-bridge2.0-0 \
libcups2 \
libxcomposite1 \
libxdamage1 \
libxfixes3 \
libxrandr2 \
libgbm1 \
libpango-1.0-0 \
libcairo2 \
libasound2 \
libatspi2.0-0 \
libxshmfence1 \
libnss3 \
libnspr4 \
libdbus-1-3 \
libdrm2 \
libxkbcommon0 \
libx11-6 \
libxcb1 \
libxext6 \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Install Playwright browsers
RUN playwright install chromium
COPY . .
EXPOSE 8000

View File

@@ -582,7 +582,8 @@ for line in response.iter_lines():
---
## 🙏 致谢
* 感谢 [@庚崽](https://github.com/genz27) 提供的POW验证解决方案
* 感谢 [@星火集市~小鑫学渣(93418328)](http://linggan10s.shop/) 提供的新的pow验证解决方案
感谢所有贡献者和使用者的支持!
---

View File

@@ -31,6 +31,11 @@ video_timeout = 3000
[admin]
error_ban_threshold = 3
# 任务失败重试配置
task_retry_enabled = true
task_max_retries = 3
# 401错误自动禁用token
auto_disable_on_401 = true
[proxy]
proxy_enabled = false
@@ -41,6 +46,29 @@ watermark_free_enabled = false
parse_method = "third_party"
custom_parse_url = ""
custom_parse_token = ""
fallback_on_failure = true # Auto fallback to watermarked video on failure
[token_refresh]
at_auto_refresh_enabled = false
[call_logic]
call_mode = "default"
[timezone]
# 时区偏移小时数,默认为东八区(中国标准时间)
# 可选值:-12 到 +14 的整数
# 常用时区:中国/新加坡 +8, 日本/韩国 +9, 印度 +5.5, 伦敦 0, 纽约 -5, 洛杉矶 -8
timezone_offset = 8
[pow_service]
# beta测试目前仍处于测试阶段
# POW 计算模式local本地计算或 external外部服务
mode = "external"
# 外部 POW 服务地址(仅在 external 模式下使用)
server_url = "http://localhost:8002"
# 外部 POW 服务访问密钥(仅在 external 模式下使用)
api_key = "your-secure-api-key-here"
# POW 代理配置
proxy_enabled = false
proxy_url = ""

View File

@@ -11,4 +11,6 @@ services:
- ./config/setting.toml:/app/config/setting.toml
environment:
- PYTHONUNBUFFERED=1
- TZ=Asia/Shanghai
- TIMEZONE_OFFSET=8
restart: unless-stopped

View File

@@ -13,3 +13,4 @@ toml
faker==24.0.0
python-dateutil==2.8.2
APScheduler==3.10.4
playwright==1.48.0

View File

@@ -115,13 +115,26 @@ class ImportTokensRequest(BaseModel):
tokens: List[ImportTokenItem]
mode: str = "at" # Import mode: offline/at/st/rt
class PureRtImportRequest(BaseModel):
refresh_tokens: List[str] # List of Refresh Tokens
client_id: str # Client ID (required)
proxy_url: Optional[str] = None # Proxy URL (optional)
image_concurrency: int = 1 # Image concurrency limit (default: 1)
video_concurrency: int = 3 # Video concurrency limit (default: 3)
class UpdateAdminConfigRequest(BaseModel):
error_ban_threshold: int
task_retry_enabled: Optional[bool] = None
task_max_retries: Optional[int] = None
auto_disable_on_401: Optional[bool] = None
class UpdateProxyConfigRequest(BaseModel):
proxy_enabled: bool
proxy_url: Optional[str] = None
class TestProxyRequest(BaseModel):
test_url: Optional[str] = "https://sora.chatgpt.com"
class UpdateAdminPasswordRequest(BaseModel):
old_password: str
new_password: str
@@ -147,6 +160,23 @@ class UpdateWatermarkFreeConfigRequest(BaseModel):
watermark_free_enabled: bool
parse_method: Optional[str] = "third_party" # "third_party" or "custom"
custom_parse_url: Optional[str] = None
custom_parse_token: Optional[str] = None
fallback_on_failure: Optional[bool] = True # Auto fallback to watermarked video on failure
class UpdateCallLogicConfigRequest(BaseModel):
call_mode: Optional[str] = None # "default" or "polling"
polling_mode_enabled: Optional[bool] = None # Legacy support
class UpdatePowProxyConfigRequest(BaseModel):
pow_proxy_enabled: bool
pow_proxy_url: Optional[str] = None
class UpdatePowServiceConfigRequest(BaseModel):
mode: str # "local" or "external"
server_url: Optional[str] = None
api_key: Optional[str] = None
proxy_enabled: Optional[bool] = None
proxy_url: Optional[str] = None
class BatchDisableRequest(BaseModel):
token_ids: List[int]
@@ -219,7 +249,10 @@ async def get_tokens(token: str = Depends(verify_admin_token)) -> List[dict]:
"video_enabled": token.video_enabled,
# 并发限制
"image_concurrency": token.image_concurrency,
"video_concurrency": token.video_concurrency
"video_concurrency": token.video_concurrency,
# 过期和禁用信息
"is_expired": token.is_expired,
"disabled_reason": token.disabled_reason
})
return result
@@ -419,14 +452,16 @@ async def batch_enable_all(request: BatchDisableRequest = None, token: str = Dep
@router.post("/api/tokens/batch/delete-disabled")
async def batch_delete_disabled(request: BatchDisableRequest = None, token: str = Depends(verify_admin_token)):
"""Delete selected tokens or all disabled tokens"""
"""Delete selected disabled tokens or all disabled tokens"""
try:
if request and request.token_ids:
# Delete only selected tokens
# Delete only selected tokens that are disabled
deleted_count = 0
for token_id in request.token_ids:
await token_manager.delete_token(token_id)
deleted_count += 1
token_obj = await db.get_token(token_id)
if token_obj and not token_obj.is_active:
await token_manager.delete_token(token_id)
deleted_count += 1
else:
# Delete all disabled tokens (backward compatibility)
tokens = await db.get_all_tokens()
@@ -438,7 +473,7 @@ async def batch_delete_disabled(request: BatchDisableRequest = None, token: str
return {
"success": True,
"message": f"已删除 {deleted_count} 个Token",
"message": f"已删除 {deleted_count}禁用Token",
"deleted_count": deleted_count
}
except Exception as e:
@@ -461,6 +496,23 @@ async def batch_disable_selected(request: BatchDisableRequest, token: str = Depe
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/tokens/batch/delete-selected")
async def batch_delete_selected(request: BatchDisableRequest, token: str = Depends(verify_admin_token)):
"""Delete selected tokens (regardless of their status)"""
try:
deleted_count = 0
for token_id in request.token_ids:
await token_manager.delete_token(token_id)
deleted_count += 1
return {
"success": True,
"message": f"已删除 {deleted_count} 个Token",
"deleted_count": deleted_count
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/tokens/batch/update-proxy")
async def batch_update_proxy(request: BatchUpdateProxyRequest, token: str = Depends(verify_admin_token)):
"""Batch update proxy for selected tokens"""
@@ -631,6 +683,111 @@ async def import_tokens(request: ImportTokensRequest, token: str = Depends(verif
"results": results
}
@router.post("/api/tokens/import/pure-rt")
async def import_pure_rt(request: PureRtImportRequest, token: str = Depends(verify_admin_token)):
"""Import tokens using pure RT mode (batch RT conversion and import)"""
added_count = 0
updated_count = 0
failed_count = 0
results = []
for rt in request.refresh_tokens:
try:
# Step 1: Use RT + client_id + proxy to refresh and get AT
rt_result = await token_manager.rt_to_at(
rt,
client_id=request.client_id,
proxy_url=request.proxy_url
)
access_token = rt_result.get("access_token")
new_refresh_token = rt_result.get("refresh_token", rt) # Use new RT if returned, else use original
if not access_token:
raise ValueError("Failed to get access_token from RT conversion")
# Step 2: Parse AT to get user info (email)
# The rt_to_at already includes email in the response
email = rt_result.get("email")
# If email not in rt_result, parse it from access_token
if not email:
import jwt
try:
decoded = jwt.decode(access_token, options={"verify_signature": False})
email = decoded.get("https://api.openai.com/profile", {}).get("email")
except Exception as e:
raise ValueError(f"Failed to parse email from access_token: {str(e)}")
if not email:
raise ValueError("Failed to extract email from access_token")
# Step 3: Check if token with this email already exists
existing_token = await db.get_token_by_email(email)
if existing_token:
# Update existing token
await token_manager.update_token(
token_id=existing_token.id,
token=access_token,
st=None, # No ST in pure RT mode
rt=new_refresh_token, # Use refreshed RT
client_id=request.client_id,
proxy_url=request.proxy_url,
remark=None, # Keep existing remark
image_enabled=True,
video_enabled=True,
image_concurrency=request.image_concurrency,
video_concurrency=request.video_concurrency,
skip_status_update=False # Update status with new AT
)
updated_count += 1
results.append({
"email": email,
"status": "updated",
"message": "Token updated successfully"
})
else:
# Add new token
new_token = await token_manager.add_token(
token_value=access_token,
st=None, # No ST in pure RT mode
rt=new_refresh_token, # Use refreshed RT
client_id=request.client_id,
proxy_url=request.proxy_url,
remark=None,
update_if_exists=False,
image_enabled=True,
video_enabled=True,
image_concurrency=request.image_concurrency,
video_concurrency=request.video_concurrency,
skip_status_update=False, # Update status with new AT
email=email # Pass email for new token
)
added_count += 1
results.append({
"email": email,
"status": "added",
"message": "Token added successfully"
})
except Exception as e:
failed_count += 1
results.append({
"email": "unknown",
"status": "failed",
"message": str(e)
})
return {
"success": True,
"message": f"Pure RT import completed: {added_count} added, {updated_count} updated, {failed_count} failed",
"added": added_count,
"updated": updated_count,
"failed": failed_count,
"results": results
}
@router.put("/api/tokens/{token_id}")
async def update_token(
token_id: int,
@@ -670,6 +827,9 @@ async def get_admin_config(token: str = Depends(verify_admin_token)) -> dict:
admin_config = await db.get_admin_config()
return {
"error_ban_threshold": admin_config.error_ban_threshold,
"task_retry_enabled": admin_config.task_retry_enabled,
"task_max_retries": admin_config.task_max_retries,
"auto_disable_on_401": admin_config.auto_disable_on_401,
"api_key": config.api_key,
"admin_username": config.admin_username,
"debug_enabled": config.debug_enabled
@@ -685,9 +845,17 @@ async def update_admin_config(
# Get current admin config to preserve username and password
current_config = await db.get_admin_config()
# Update only the error_ban_threshold, preserve username and password
# Update error_ban_threshold
current_config.error_ban_threshold = request.error_ban_threshold
# Update retry settings if provided
if request.task_retry_enabled is not None:
current_config.task_retry_enabled = request.task_retry_enabled
if request.task_max_retries is not None:
current_config.task_max_retries = request.task_max_retries
if request.auto_disable_on_401 is not None:
current_config.auto_disable_on_401 = request.auto_disable_on_401
await db.update_admin_config(current_config)
return {"success": True, "message": "Configuration updated"}
except Exception as e:
@@ -789,6 +957,50 @@ async def update_proxy_config(
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/api/proxy/test")
async def test_proxy_config(
request: TestProxyRequest,
token: str = Depends(verify_admin_token)
) -> dict:
"""Test proxy connectivity with custom URL"""
from curl_cffi.requests import AsyncSession
config_obj = await proxy_manager.get_proxy_config()
if not config_obj.proxy_enabled or not config_obj.proxy_url:
return {"success": False, "message": "代理未启用或地址为空"}
# Use provided test URL or default
test_url = request.test_url or "https://sora.chatgpt.com"
try:
async with AsyncSession() as session:
response = await session.get(
test_url,
timeout=15,
impersonate="chrome",
proxy=config_obj.proxy_url
)
status_code = response.status_code
if 200 <= status_code < 400:
return {
"success": True,
"message": f"代理可用 (HTTP {status_code})",
"status_code": status_code,
"test_url": test_url
}
return {
"success": False,
"message": f"代理响应异常: HTTP {status_code}",
"status_code": status_code,
"test_url": test_url
}
except Exception as e:
return {
"success": False,
"message": f"代理连接失败: {str(e)}",
"test_url": test_url
}
# Watermark-free config endpoints
@router.get("/api/watermark-free/config")
async def get_watermark_free_config(token: str = Depends(verify_admin_token)) -> dict:
@@ -798,7 +1010,8 @@ async def get_watermark_free_config(token: str = Depends(verify_admin_token)) ->
"watermark_free_enabled": config_obj.watermark_free_enabled,
"parse_method": config_obj.parse_method,
"custom_parse_url": config_obj.custom_parse_url,
"custom_parse_token": config_obj.custom_parse_token
"custom_parse_token": config_obj.custom_parse_token,
"fallback_on_failure": config_obj.fallback_on_failure
}
@router.post("/api/watermark-free/config")
@@ -812,7 +1025,8 @@ async def update_watermark_free_config(
request.watermark_free_enabled,
request.parse_method,
request.custom_parse_url,
request.custom_parse_token
request.custom_parse_token,
request.fallback_on_failure
)
# Update in-memory config
@@ -862,9 +1076,16 @@ async def get_stats(token: str = Depends(verify_admin_token)):
@router.get("/api/logs")
async def get_logs(limit: int = 100, token: str = Depends(verify_admin_token)):
"""Get recent logs with token email and task progress"""
from src.utils.timezone import convert_utc_to_local
logs = await db.get_recent_logs(limit)
result = []
for log in logs:
# Convert UTC time to local timezone
created_at = log.get("created_at")
if created_at:
created_at = convert_utc_to_local(created_at)
log_data = {
"id": log.get("id"),
"token_id": log.get("token_id"),
@@ -873,14 +1094,14 @@ async def get_logs(limit: int = 100, token: str = Depends(verify_admin_token)):
"operation": log.get("operation"),
"status_code": log.get("status_code"),
"duration": log.get("duration"),
"created_at": log.get("created_at"),
"created_at": created_at,
"request_body": log.get("request_body"),
"response_body": log.get("response_body"),
"task_id": log.get("task_id")
}
# If task_id exists and status is in-progress, get task progress
if log.get("task_id") and log.get("status_code") == -1:
# If task_id exists, get task progress and status
if log.get("task_id"):
task = await db.get_task(log.get("task_id"))
if task:
log_data["progress"] = task.progress
@@ -1120,6 +1341,132 @@ async def update_at_auto_refresh_enabled(
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to update AT auto refresh enabled status: {str(e)}")
# Call logic config endpoints
@router.get("/api/call-logic/config")
async def get_call_logic_config(token: str = Depends(verify_admin_token)) -> dict:
"""Get call logic configuration"""
config_obj = await db.get_call_logic_config()
call_mode = getattr(config_obj, "call_mode", None)
if call_mode not in ("default", "polling"):
call_mode = "polling" if config_obj.polling_mode_enabled else "default"
return {
"success": True,
"config": {
"call_mode": call_mode,
"polling_mode_enabled": call_mode == "polling"
}
}
@router.post("/api/call-logic/config")
async def update_call_logic_config(
request: UpdateCallLogicConfigRequest,
token: str = Depends(verify_admin_token)
):
"""Update call logic configuration"""
try:
call_mode = request.call_mode if request.call_mode in ("default", "polling") else None
if call_mode is None and request.polling_mode_enabled is not None:
call_mode = "polling" if request.polling_mode_enabled else "default"
if call_mode is None:
raise HTTPException(status_code=400, detail="Invalid call_mode")
await db.update_call_logic_config(call_mode)
config.set_call_logic_mode(call_mode)
return {
"success": True,
"message": "Call logic configuration updated",
"call_mode": call_mode,
"polling_mode_enabled": call_mode == "polling"
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to update call logic configuration: {str(e)}")
# POW proxy config endpoints (redirected to pow_service config for unified management)
@router.get("/api/pow-proxy/config")
async def get_pow_proxy_config(token: str = Depends(verify_admin_token)) -> dict:
"""Get POW proxy configuration (unified with pow_service config)"""
# Read from pow_service config for unified management
config_obj = await db.get_pow_service_config()
return {
"success": True,
"config": {
"pow_proxy_enabled": config_obj.proxy_enabled,
"pow_proxy_url": config_obj.proxy_url or ""
}
}
@router.post("/api/pow-proxy/config")
async def update_pow_proxy_config(
request: UpdatePowProxyConfigRequest,
token: str = Depends(verify_admin_token)
):
"""Update POW proxy configuration (unified with pow_service config)"""
try:
# Update pow_service config instead for unified management
config_obj = await db.get_pow_service_config()
await db.update_pow_service_config(
mode=config_obj.mode,
server_url=config_obj.server_url,
api_key=config_obj.api_key,
proxy_enabled=request.pow_proxy_enabled,
proxy_url=request.pow_proxy_url
)
# Update in-memory config
config.set_pow_service_proxy_enabled(request.pow_proxy_enabled)
config.set_pow_service_proxy_url(request.pow_proxy_url or "")
return {
"success": True,
"message": "POW proxy configuration updated"
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to update POW proxy configuration: {str(e)}")
# POW service config endpoints
@router.get("/api/pow/config")
async def get_pow_service_config(token: str = Depends(verify_admin_token)) -> dict:
"""Get POW service configuration"""
config_obj = await db.get_pow_service_config()
return {
"success": True,
"config": {
"mode": config_obj.mode,
"server_url": config_obj.server_url or "",
"api_key": config_obj.api_key or "",
"proxy_enabled": config_obj.proxy_enabled,
"proxy_url": config_obj.proxy_url or ""
}
}
@router.post("/api/pow/config")
async def update_pow_service_config(
request: UpdatePowServiceConfigRequest,
token: str = Depends(verify_admin_token)
):
"""Update POW service configuration"""
try:
await db.update_pow_service_config(
mode=request.mode,
server_url=request.server_url,
api_key=request.api_key,
proxy_enabled=request.proxy_enabled,
proxy_url=request.proxy_url
)
# Update runtime config
config.set_pow_service_mode(request.mode)
config.set_pow_service_server_url(request.server_url or "")
config.set_pow_service_api_key(request.api_key or "")
config.set_pow_service_proxy_enabled(request.proxy_enabled or False)
config.set_pow_service_proxy_url(request.proxy_url or "")
return {
"success": True,
"message": "POW service configuration updated"
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to update POW service configuration: {str(e)}")
# Task management endpoints
@router.post("/api/tasks/{task_id}/cancel")
async def cancel_task(task_id: str, token: str = Depends(verify_admin_token)):
@@ -1142,7 +1489,26 @@ async def cancel_task(task_id: str, token: str = Depends(verify_admin_token)):
for log in logs:
if log.get("task_id") == task_id and log.get("status_code") == -1:
import time
duration = time.time() - (log.get("created_at").timestamp() if log.get("created_at") else time.time())
from datetime import datetime
# Calculate duration
created_at = log.get("created_at")
if created_at:
# If created_at is a string, parse it
if isinstance(created_at, str):
try:
created_at = datetime.fromisoformat(created_at.replace('Z', '+00:00'))
duration = time.time() - created_at.timestamp()
except:
duration = 0
# If it's already a datetime object
elif isinstance(created_at, datetime):
duration = time.time() - created_at.timestamp()
else:
duration = 0
else:
duration = 0
await db.update_request_log(
log.get("id"),
response_body='{"error": "用户手动取消任务"}',

View File

@@ -1,13 +1,15 @@
"""API routes - OpenAI compatible endpoints"""
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse, JSONResponse
from datetime import datetime
from typing import List
import json
import re
import time
from ..core.auth import verify_api_key_header
from ..core.models import ChatCompletionRequest
from ..services.generation_handler import GenerationHandler, MODEL_CONFIG
from ..core.logger import debug_logger
router = APIRouter()
@@ -71,10 +73,22 @@ async def list_models(api_key: str = Depends(verify_api_key_header)):
@router.post("/v1/chat/completions")
async def create_chat_completion(
request: ChatCompletionRequest,
api_key: str = Depends(verify_api_key_header)
api_key: str = Depends(verify_api_key_header),
http_request: Request = None
):
"""Create chat completion (unified endpoint for image and video generation)"""
start_time = time.time()
try:
# Log client request
debug_logger.log_request(
method="POST",
url="/v1/chat/completions",
headers=dict(http_request.headers) if http_request else {},
body=request.dict(),
source="Client"
)
# Extract prompt from messages
if not request.messages:
raise HTTPException(status_code=400, detail="Messages cannot be empty")
@@ -142,7 +156,7 @@ async def create_chat_completion(
if not request.stream:
# Non-streaming mode: only check availability
result = None
async for chunk in generation_handler.handle_generation(
async for chunk in generation_handler.handle_generation_with_retry(
model=request.model,
prompt=prompt,
image=image_data,
@@ -153,25 +167,43 @@ async def create_chat_completion(
result = chunk
if result:
return JSONResponse(content=json.loads(result))
duration_ms = (time.time() - start_time) * 1000
response_data = json.loads(result)
debug_logger.log_response(
status_code=200,
headers={"Content-Type": "application/json"},
body=response_data,
duration_ms=duration_ms,
source="Client"
)
return JSONResponse(content=response_data)
else:
duration_ms = (time.time() - start_time) * 1000
error_response = {
"error": {
"message": "Availability check failed",
"type": "server_error",
"param": None,
"code": None
}
}
debug_logger.log_response(
status_code=500,
headers={"Content-Type": "application/json"},
body=error_response,
duration_ms=duration_ms,
source="Client"
)
return JSONResponse(
status_code=500,
content={
"error": {
"message": "Availability check failed",
"type": "server_error",
"param": None,
"code": None
}
}
content=error_response
)
# Handle streaming
if request.stream:
async def generate():
try:
async for chunk in generation_handler.handle_generation(
async for chunk in generation_handler.handle_generation_with_retry(
model=request.model,
prompt=prompt,
image=image_data,
@@ -218,7 +250,7 @@ async def create_chat_completion(
else:
# Non-streaming response (availability check only)
result = None
async for chunk in generation_handler.handle_generation(
async for chunk in generation_handler.handle_generation_with_retry(
model=request.model,
prompt=prompt,
image=image_data,
@@ -229,31 +261,64 @@ async def create_chat_completion(
result = chunk
if result:
return JSONResponse(content=json.loads(result))
duration_ms = (time.time() - start_time) * 1000
response_data = json.loads(result)
debug_logger.log_response(
status_code=200,
headers={"Content-Type": "application/json"},
body=response_data,
duration_ms=duration_ms,
source="Client"
)
return JSONResponse(content=response_data)
else:
# Return OpenAI-compatible error format
duration_ms = (time.time() - start_time) * 1000
error_response = {
"error": {
"message": "Availability check failed",
"type": "server_error",
"param": None,
"code": None
}
}
debug_logger.log_response(
status_code=500,
headers={"Content-Type": "application/json"},
body=error_response,
duration_ms=duration_ms,
source="Client"
)
return JSONResponse(
status_code=500,
content={
"error": {
"message": "Availability check failed",
"type": "server_error",
"param": None,
"code": None
}
}
content=error_response
)
except Exception as e:
# Return OpenAI-compatible error format
duration_ms = (time.time() - start_time) * 1000
error_response = {
"error": {
"message": str(e),
"type": "server_error",
"param": None,
"code": None
}
}
debug_logger.log_error(
error_message=str(e),
status_code=500,
response_text=str(e),
source="Client"
)
debug_logger.log_response(
status_code=500,
headers={"Content-Type": "application/json"},
body=error_response,
duration_ms=duration_ms,
source="Client"
)
return JSONResponse(
status_code=500,
content={
"error": {
"message": str(e),
"type": "server_error",
"param": None,
"code": None
}
}
content=error_response
)

View File

@@ -208,5 +208,126 @@ class Config:
self._config["token_refresh"] = {}
self._config["token_refresh"]["at_auto_refresh_enabled"] = enabled
@property
def polling_mode_enabled(self) -> bool:
"""Get polling mode enabled status"""
return self.call_logic_mode == "polling"
@property
def call_logic_mode(self) -> str:
"""Get call logic mode (default or polling)"""
call_logic = self._config.get("call_logic", {})
mode = call_logic.get("call_mode")
if mode in ("default", "polling"):
return mode
if call_logic.get("polling_mode_enabled", False):
return "polling"
return "default"
def set_polling_mode_enabled(self, enabled: bool):
"""Set polling mode enabled/disabled"""
self.set_call_logic_mode("polling" if enabled else "default")
def set_call_logic_mode(self, mode: str):
"""Set call logic mode (default or polling)"""
normalized = "polling" if mode == "polling" else "default"
if "call_logic" not in self._config:
self._config["call_logic"] = {}
self._config["call_logic"]["call_mode"] = normalized
self._config["call_logic"]["polling_mode_enabled"] = normalized == "polling"
@property
def pow_proxy_enabled(self) -> bool:
"""Get POW proxy enabled status
DEPRECATED: This configuration is deprecated. Use pow_service_proxy_enabled instead.
All POW proxy settings are now unified under [pow_service] section.
"""
return self._config.get("pow_proxy", {}).get("pow_proxy_enabled", False)
def set_pow_proxy_enabled(self, enabled: bool):
"""Set POW proxy enabled/disabled
DEPRECATED: This configuration is deprecated. Use set_pow_service_proxy_enabled instead.
All POW proxy settings are now unified under [pow_service] section.
"""
if "pow_proxy" not in self._config:
self._config["pow_proxy"] = {}
self._config["pow_proxy"]["pow_proxy_enabled"] = enabled
@property
def pow_proxy_url(self) -> str:
"""Get POW proxy URL
DEPRECATED: This configuration is deprecated. Use pow_service_proxy_url instead.
All POW proxy settings are now unified under [pow_service] section.
"""
return self._config.get("pow_proxy", {}).get("pow_proxy_url", "")
def set_pow_proxy_url(self, url: str):
"""Set POW proxy URL
DEPRECATED: This configuration is deprecated. Use set_pow_service_proxy_url instead.
All POW proxy settings are now unified under [pow_service] section.
"""
if "pow_proxy" not in self._config:
self._config["pow_proxy"] = {}
self._config["pow_proxy"]["pow_proxy_url"] = url
@property
def pow_service_mode(self) -> str:
"""Get POW service mode (local or external)"""
return self._config.get("pow_service", {}).get("mode", "local")
def set_pow_service_mode(self, mode: str):
"""Set POW service mode"""
if "pow_service" not in self._config:
self._config["pow_service"] = {}
self._config["pow_service"]["mode"] = mode
@property
def pow_service_server_url(self) -> str:
"""Get POW service server URL"""
return self._config.get("pow_service", {}).get("server_url", "")
def set_pow_service_server_url(self, url: str):
"""Set POW service server URL"""
if "pow_service" not in self._config:
self._config["pow_service"] = {}
self._config["pow_service"]["server_url"] = url
@property
def pow_service_api_key(self) -> str:
"""Get POW service API key"""
return self._config.get("pow_service", {}).get("api_key", "")
def set_pow_service_api_key(self, api_key: str):
"""Set POW service API key"""
if "pow_service" not in self._config:
self._config["pow_service"] = {}
self._config["pow_service"]["api_key"] = api_key
@property
def pow_service_proxy_enabled(self) -> bool:
"""Get POW service proxy enabled status"""
return self._config.get("pow_service", {}).get("proxy_enabled", False)
def set_pow_service_proxy_enabled(self, enabled: bool):
"""Set POW service proxy enabled status"""
if "pow_service" not in self._config:
self._config["pow_service"] = {}
self._config["pow_service"]["proxy_enabled"] = enabled
@property
def pow_service_proxy_url(self) -> str:
"""Get POW service proxy URL"""
return self._config.get("pow_service", {}).get("proxy_url", "")
def set_pow_service_proxy_url(self, url: str):
"""Set POW service proxy URL"""
if "pow_service" not in self._config:
self._config["pow_service"] = {}
self._config["pow_service"]["proxy_url"] = url
# Global config instance
config = Config()

View File

@@ -55,6 +55,9 @@ class Database:
admin_password = "admin"
api_key = "han1234"
error_ban_threshold = 3
task_retry_enabled = True
task_max_retries = 3
auto_disable_on_401 = True
if config_dict:
global_config = config_dict.get("global", {})
@@ -64,11 +67,14 @@ class Database:
admin_config = config_dict.get("admin", {})
error_ban_threshold = admin_config.get("error_ban_threshold", 3)
task_retry_enabled = admin_config.get("task_retry_enabled", True)
task_max_retries = admin_config.get("task_max_retries", 3)
auto_disable_on_401 = admin_config.get("auto_disable_on_401", True)
await db.execute("""
INSERT INTO admin_config (id, admin_username, admin_password, api_key, error_ban_threshold)
VALUES (1, ?, ?, ?, ?)
""", (admin_username, admin_password, api_key, error_ban_threshold))
INSERT INTO admin_config (id, admin_username, admin_password, api_key, error_ban_threshold, task_retry_enabled, task_max_retries, auto_disable_on_401)
VALUES (1, ?, ?, ?, ?, ?, ?, ?)
""", (admin_username, admin_password, api_key, error_ban_threshold, task_retry_enabled, task_max_retries, auto_disable_on_401))
# Ensure proxy_config has a row
cursor = await db.execute("SELECT COUNT(*) FROM proxy_config")
@@ -99,6 +105,7 @@ class Database:
parse_method = "third_party"
custom_parse_url = None
custom_parse_token = None
fallback_on_failure = True # Default to True
if config_dict:
watermark_config = config_dict.get("watermark_free", {})
@@ -106,15 +113,16 @@ class Database:
parse_method = watermark_config.get("parse_method", "third_party")
custom_parse_url = watermark_config.get("custom_parse_url", "")
custom_parse_token = watermark_config.get("custom_parse_token", "")
fallback_on_failure = watermark_config.get("fallback_on_failure", True)
# Convert empty strings to None
custom_parse_url = custom_parse_url if custom_parse_url else None
custom_parse_token = custom_parse_token if custom_parse_token else None
await db.execute("""
INSERT INTO watermark_free_config (id, watermark_free_enabled, parse_method, custom_parse_url, custom_parse_token)
VALUES (1, ?, ?, ?, ?)
""", (watermark_free_enabled, parse_method, custom_parse_url, custom_parse_token))
INSERT INTO watermark_free_config (id, watermark_free_enabled, parse_method, custom_parse_url, custom_parse_token, fallback_on_failure)
VALUES (1, ?, ?, ?, ?, ?)
""", (watermark_free_enabled, parse_method, custom_parse_url, custom_parse_token, fallback_on_failure))
# Ensure cache_config has a row
cursor = await db.execute("SELECT COUNT(*) FROM cache_config")
@@ -172,6 +180,78 @@ class Database:
VALUES (1, ?)
""", (at_auto_refresh_enabled,))
# Ensure call_logic_config has a row
cursor = await db.execute("SELECT COUNT(*) FROM call_logic_config")
count = await cursor.fetchone()
if count[0] == 0:
# Get call logic config from config_dict if provided, otherwise use defaults
call_mode = "default"
polling_mode_enabled = False
if config_dict:
call_logic_config = config_dict.get("call_logic", {})
call_mode = call_logic_config.get("call_mode", "default")
# Normalize call_mode
if call_mode not in ("default", "polling"):
# Check legacy polling_mode_enabled field
polling_mode_enabled = call_logic_config.get("polling_mode_enabled", False)
call_mode = "polling" if polling_mode_enabled else "default"
else:
polling_mode_enabled = call_mode == "polling"
await db.execute("""
INSERT INTO call_logic_config (id, call_mode, polling_mode_enabled)
VALUES (1, ?, ?)
""", (call_mode, polling_mode_enabled))
# Ensure pow_proxy_config has a row
cursor = await db.execute("SELECT COUNT(*) FROM pow_proxy_config")
count = await cursor.fetchone()
if count[0] == 0:
# Get POW proxy config from config_dict if provided, otherwise use defaults
pow_proxy_enabled = False
pow_proxy_url = None
if config_dict:
pow_proxy_config = config_dict.get("pow_proxy", {})
pow_proxy_enabled = pow_proxy_config.get("pow_proxy_enabled", False)
pow_proxy_url = pow_proxy_config.get("pow_proxy_url", "")
# Convert empty string to None
pow_proxy_url = pow_proxy_url if pow_proxy_url else None
await db.execute("""
INSERT INTO pow_proxy_config (id, pow_proxy_enabled, pow_proxy_url)
VALUES (1, ?, ?)
""", (pow_proxy_enabled, pow_proxy_url))
# Ensure pow_service_config has a row
cursor = await db.execute("SELECT COUNT(*) FROM pow_service_config")
count = await cursor.fetchone()
if count[0] == 0:
# Get POW service config from config_dict if provided, otherwise use defaults
mode = "local"
server_url = None
api_key = None
proxy_enabled = False
proxy_url = None
if config_dict:
pow_service_config = config_dict.get("pow_service", {})
mode = pow_service_config.get("mode", "local")
server_url = pow_service_config.get("server_url", "")
api_key = pow_service_config.get("api_key", "")
proxy_enabled = pow_service_config.get("proxy_enabled", False)
proxy_url = pow_service_config.get("proxy_url", "")
# Convert empty strings to None
server_url = server_url if server_url else None
api_key = api_key if api_key else None
proxy_url = proxy_url if proxy_url else None
await db.execute("""
INSERT INTO pow_service_config (id, mode, server_url, api_key, proxy_enabled, proxy_url)
VALUES (1, ?, ?, ?, ?, ?)
""", (mode, server_url, api_key, proxy_enabled, proxy_url))
async def check_and_migrate_db(self, config_dict: dict = None):
"""Check database integrity and perform migrations if needed
@@ -245,6 +325,7 @@ class Database:
("parse_method", "TEXT DEFAULT 'third_party'"),
("custom_parse_url", "TEXT"),
("custom_parse_token", "TEXT"),
("fallback_on_failure", "BOOLEAN DEFAULT 1"),
]
for col_name, col_type in columns_to_add:
@@ -312,7 +393,8 @@ class Database:
video_enabled BOOLEAN DEFAULT 1,
image_concurrency INTEGER DEFAULT -1,
video_concurrency INTEGER DEFAULT -1,
is_expired BOOLEAN DEFAULT 0
is_expired BOOLEAN DEFAULT 0,
disabled_reason TEXT
)
""")
@@ -377,6 +459,9 @@ class Database:
admin_password TEXT DEFAULT 'admin',
api_key TEXT DEFAULT 'han1234',
error_ban_threshold INTEGER DEFAULT 3,
task_retry_enabled BOOLEAN DEFAULT 1,
task_max_retries INTEGER DEFAULT 3,
auto_disable_on_401 BOOLEAN DEFAULT 1,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
@@ -400,6 +485,7 @@ class Database:
parse_method TEXT DEFAULT 'third_party',
custom_parse_url TEXT,
custom_parse_token TEXT,
fallback_on_failure BOOLEAN DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
@@ -438,6 +524,42 @@ class Database:
)
""")
# Call logic config table
await db.execute("""
CREATE TABLE IF NOT EXISTS call_logic_config (
id INTEGER PRIMARY KEY DEFAULT 1,
call_mode TEXT DEFAULT 'default',
polling_mode_enabled BOOLEAN DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# POW proxy config table
await db.execute("""
CREATE TABLE IF NOT EXISTS pow_proxy_config (
id INTEGER PRIMARY KEY DEFAULT 1,
pow_proxy_enabled BOOLEAN DEFAULT 0,
pow_proxy_url TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# Create pow_service_config table
await db.execute("""
CREATE TABLE IF NOT EXISTS pow_service_config (
id INTEGER PRIMARY KEY DEFAULT 1,
mode TEXT DEFAULT 'local',
server_url TEXT,
api_key TEXT,
proxy_enabled BOOLEAN DEFAULT 0,
proxy_url TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# Create indexes
await db.execute("CREATE INDEX IF NOT EXISTS idx_task_id ON tasks(task_id)")
await db.execute("CREATE INDEX IF NOT EXISTS idx_task_status ON tasks(status)")
@@ -453,6 +575,34 @@ class Database:
if not await self._column_exists(db, "token_stats", "today_date"):
await db.execute("ALTER TABLE token_stats ADD COLUMN today_date DATE")
# Migration: Add retry_count column to tasks table if it doesn't exist
if not await self._column_exists(db, "tasks", "retry_count"):
await db.execute("ALTER TABLE tasks ADD COLUMN retry_count INTEGER DEFAULT 0")
# Migration: Add task retry config columns to admin_config table if they don't exist
if not await self._column_exists(db, "admin_config", "task_retry_enabled"):
await db.execute("ALTER TABLE admin_config ADD COLUMN task_retry_enabled BOOLEAN DEFAULT 1")
if not await self._column_exists(db, "admin_config", "task_max_retries"):
await db.execute("ALTER TABLE admin_config ADD COLUMN task_max_retries INTEGER DEFAULT 3")
if not await self._column_exists(db, "admin_config", "auto_disable_on_401"):
await db.execute("ALTER TABLE admin_config ADD COLUMN auto_disable_on_401 BOOLEAN DEFAULT 1")
# Migration: Add disabled_reason column to tokens table if it doesn't exist
if not await self._column_exists(db, "tokens", "disabled_reason"):
await db.execute("ALTER TABLE tokens ADD COLUMN disabled_reason TEXT")
# For existing disabled tokens without a reason, set to 'manual'
await db.execute("""
UPDATE tokens
SET disabled_reason = 'manual'
WHERE is_active = 0 AND disabled_reason IS NULL
""")
# For existing expired tokens, set to 'expired'
await db.execute("""
UPDATE tokens
SET disabled_reason = 'expired'
WHERE is_expired = 1 AND disabled_reason IS NULL
""")
await db.commit()
async def init_config_from_toml(self, config_dict: dict, is_first_startup: bool = True):
@@ -565,27 +715,35 @@ class Database:
""", (token_id,))
await db.commit()
async def update_token_status(self, token_id: int, is_active: bool):
"""Update token status"""
async def update_token_status(self, token_id: int, is_active: bool, disabled_reason: Optional[str] = None):
"""Update token status and disabled reason"""
async with aiosqlite.connect(self.db_path) as db:
await db.execute("""
UPDATE tokens SET is_active = ? WHERE id = ?
""", (is_active, token_id))
UPDATE tokens SET is_active = ?, disabled_reason = ? WHERE id = ?
""", (is_active, disabled_reason, token_id))
await db.commit()
async def mark_token_expired(self, token_id: int):
"""Mark token as expired and disable it"""
"""Mark token as expired and disable it with reason"""
async with aiosqlite.connect(self.db_path) as db:
await db.execute("""
UPDATE tokens SET is_expired = 1, is_active = 0 WHERE id = ?
""", (token_id,))
UPDATE tokens SET is_expired = 1, is_active = 0, disabled_reason = ? WHERE id = ?
""", ("expired", token_id))
await db.commit()
async def mark_token_invalid(self, token_id: int):
"""Mark token as invalid (401 error) and disable it"""
async with aiosqlite.connect(self.db_path) as db:
await db.execute("""
UPDATE tokens SET is_expired = 1, is_active = 0, disabled_reason = ? WHERE id = ?
""", ("token_invalid", token_id))
await db.commit()
async def clear_token_expired(self, token_id: int):
"""Clear token expired flag"""
"""Clear token expired flag and disabled reason"""
async with aiosqlite.connect(self.db_path) as db:
await db.execute("""
UPDATE tokens SET is_expired = 0 WHERE id = ?
UPDATE tokens SET is_expired = 0, disabled_reason = NULL WHERE id = ?
""", (token_id,))
await db.commit()
@@ -977,9 +1135,12 @@ class Database:
async with aiosqlite.connect(self.db_path) as db:
await db.execute("""
UPDATE admin_config
SET admin_username = ?, admin_password = ?, api_key = ?, error_ban_threshold = ?, updated_at = CURRENT_TIMESTAMP
SET admin_username = ?, admin_password = ?, api_key = ?, error_ban_threshold = ?,
task_retry_enabled = ?, task_max_retries = ?, auto_disable_on_401 = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = 1
""", (config.admin_username, config.admin_password, config.api_key, config.error_ban_threshold))
""", (config.admin_username, config.admin_password, config.api_key, config.error_ban_threshold,
config.task_retry_enabled, config.task_max_retries, config.auto_disable_on_401))
await db.commit()
# Proxy config operations
@@ -1019,10 +1180,11 @@ class Database:
return WatermarkFreeConfig(watermark_free_enabled=False, parse_method="third_party")
async def update_watermark_free_config(self, enabled: bool, parse_method: str = None,
custom_parse_url: str = None, custom_parse_token: str = None):
custom_parse_url: str = None, custom_parse_token: str = None,
fallback_on_failure: bool = None):
"""Update watermark-free configuration"""
async with aiosqlite.connect(self.db_path) as db:
if parse_method is None and custom_parse_url is None and custom_parse_token is None:
if parse_method is None and custom_parse_url is None and custom_parse_token is None and fallback_on_failure is None:
# Only update enabled status
await db.execute("""
UPDATE watermark_free_config
@@ -1034,9 +1196,10 @@ class Database:
await db.execute("""
UPDATE watermark_free_config
SET watermark_free_enabled = ?, parse_method = ?, custom_parse_url = ?,
custom_parse_token = ?, updated_at = CURRENT_TIMESTAMP
custom_parse_token = ?, fallback_on_failure = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = 1
""", (enabled, parse_method or "third_party", custom_parse_url, custom_parse_token))
""", (enabled, parse_method or "third_party", custom_parse_url, custom_parse_token,
fallback_on_failure if fallback_on_failure is not None else True))
await db.commit()
# Cache config operations
@@ -1141,3 +1304,87 @@ class Database:
""", (at_auto_refresh_enabled,))
await db.commit()
# Call logic config operations
async def get_call_logic_config(self) -> "CallLogicConfig":
"""Get call logic configuration"""
from .models import CallLogicConfig
async with aiosqlite.connect(self.db_path) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute("SELECT * FROM call_logic_config WHERE id = 1")
row = await cursor.fetchone()
if row:
row_dict = dict(row)
if not row_dict.get("call_mode"):
row_dict["call_mode"] = "polling" if row_dict.get("polling_mode_enabled") else "default"
return CallLogicConfig(**row_dict)
return CallLogicConfig(call_mode="default", polling_mode_enabled=False)
async def update_call_logic_config(self, call_mode: str):
"""Update call logic configuration"""
normalized = "polling" if call_mode == "polling" else "default"
polling_mode_enabled = normalized == "polling"
async with aiosqlite.connect(self.db_path) as db:
# Use INSERT OR REPLACE to ensure the row exists
await db.execute("""
INSERT OR REPLACE INTO call_logic_config (id, call_mode, polling_mode_enabled, updated_at)
VALUES (1, ?, ?, CURRENT_TIMESTAMP)
""", (normalized, polling_mode_enabled))
await db.commit()
# POW proxy config operations
async def get_pow_proxy_config(self) -> "PowProxyConfig":
"""Get POW proxy configuration"""
from .models import PowProxyConfig
async with aiosqlite.connect(self.db_path) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute("SELECT * FROM pow_proxy_config WHERE id = 1")
row = await cursor.fetchone()
if row:
return PowProxyConfig(**dict(row))
return PowProxyConfig(pow_proxy_enabled=False, pow_proxy_url=None)
async def get_pow_service_config(self) -> "PowServiceConfig":
"""Get POW service configuration"""
from .models import PowServiceConfig
async with aiosqlite.connect(self.db_path) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute("SELECT * FROM pow_service_config WHERE id = 1")
row = await cursor.fetchone()
if row:
return PowServiceConfig(**dict(row))
return PowServiceConfig(
mode="local",
server_url=None,
api_key=None,
proxy_enabled=False,
proxy_url=None
)
async def update_pow_proxy_config(self, pow_proxy_enabled: bool, pow_proxy_url: Optional[str] = None):
"""Update POW proxy configuration"""
async with aiosqlite.connect(self.db_path) as db:
# Use INSERT OR REPLACE to ensure the row exists
await db.execute("""
INSERT OR REPLACE INTO pow_proxy_config (id, pow_proxy_enabled, pow_proxy_url, updated_at)
VALUES (1, ?, ?, CURRENT_TIMESTAMP)
""", (pow_proxy_enabled, pow_proxy_url))
await db.commit()
async def update_pow_service_config(
self,
mode: str,
server_url: Optional[str] = None,
api_key: Optional[str] = None,
proxy_enabled: Optional[bool] = None,
proxy_url: Optional[str] = None
):
"""Update POW service configuration"""
async with aiosqlite.connect(self.db_path) as db:
# Use INSERT OR REPLACE to ensure the row exists
await db.execute("""
INSERT OR REPLACE INTO pow_service_config (id, mode, server_url, api_key, proxy_enabled, proxy_url, updated_at)
VALUES (1, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
""", (mode, server_url, api_key, proxy_enabled, proxy_url))
await db.commit()

View File

@@ -68,9 +68,20 @@ class DebugLogger:
headers: Dict[str, str],
body: Optional[Any] = None,
files: Optional[Dict] = None,
proxy: Optional[str] = None
proxy: Optional[str] = None,
source: str = "Server"
):
"""Log API request details to log.txt"""
"""Log API request details to log.txt
Args:
method: HTTP method
url: Request URL
headers: Request headers
body: Request body
files: Files to upload
proxy: Proxy URL
source: Request source - "Client" for user->sora2api, "Server" for sora2api->Sora
"""
# Check if debug mode is enabled
if not config.debug_enabled:
@@ -78,7 +89,7 @@ class DebugLogger:
try:
self._write_separator()
self.logger.info(f"🔵 [REQUEST] {self._format_timestamp()}")
self.logger.info(f"🔵 [REQUEST][{source}] {self._format_timestamp()}")
self._write_separator("-")
# Basic info
@@ -136,9 +147,18 @@ class DebugLogger:
status_code: int,
headers: Dict[str, str],
body: Any,
duration_ms: Optional[float] = None
duration_ms: Optional[float] = None,
source: str = "Server"
):
"""Log API response details to log.txt"""
"""Log API response details to log.txt
Args:
status_code: HTTP status code
headers: Response headers
body: Response body
duration_ms: Request duration in milliseconds
source: Request source - "Client" for user->sora2api, "Server" for sora2api->Sora
"""
# Check if debug mode is enabled
if not config.debug_enabled:
@@ -146,7 +166,7 @@ class DebugLogger:
try:
self._write_separator()
self.logger.info(f"🟢 [RESPONSE] {self._format_timestamp()}")
self.logger.info(f"🟢 [RESPONSE][{source}] {self._format_timestamp()}")
self._write_separator("-")
# Status
@@ -192,9 +212,17 @@ class DebugLogger:
self,
error_message: str,
status_code: Optional[int] = None,
response_text: Optional[str] = None
response_text: Optional[str] = None,
source: str = "Server"
):
"""Log API error details to log.txt"""
"""Log API error details to log.txt
Args:
error_message: Error message
status_code: HTTP status code
response_text: Response text
source: Request source - "Client" for user->sora2api, "Server" for sora2api->Sora
"""
# Check if debug mode is enabled
if not config.debug_enabled:
@@ -202,7 +230,7 @@ class DebugLogger:
try:
self._write_separator()
self.logger.info(f"🔴 [ERROR] {self._format_timestamp()}")
self.logger.info(f"🔴 [ERROR][{source}] {self._format_timestamp()}")
self._write_separator("-")
if status_code:
@@ -242,6 +270,18 @@ class DebugLogger:
except Exception as e:
self.logger.error(f"Error logging info: {e}")
def log_warning(self, message: str):
"""Log warning message to log.txt"""
# Check if debug mode is enabled
if not config.debug_enabled:
return
try:
self.logger.warning(f"⚠️ [{self._format_timestamp()}] {message}")
except Exception as e:
self.logger.error(f"Error logging warning: {e}")
# Global debug logger instance
debug_logger = DebugLogger()

View File

@@ -40,6 +40,8 @@ class Token(BaseModel):
video_concurrency: int = -1 # 视频并发数限制,-1表示不限制
# 过期标记
is_expired: bool = False # Token是否已过期401 token_invalidated
# 禁用原因: manual=手动禁用, error_limit=错误次数超限, token_invalid=Token失效, expired=过期失效
disabled_reason: Optional[str] = None
class TokenStats(BaseModel):
"""Token statistics"""
@@ -66,6 +68,7 @@ class Task(BaseModel):
progress: float = 0.0
result_urls: Optional[str] = None # JSON array
error_message: Optional[str] = None
retry_count: int = 0 # 当前重试次数
created_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
@@ -89,6 +92,9 @@ class AdminConfig(BaseModel):
admin_password: str # Read from database, initialized from setting.toml on first startup
api_key: str # Read from database, initialized from setting.toml on first startup
error_ban_threshold: int = 3
task_retry_enabled: bool = True # 是否启用任务失败重试
task_max_retries: int = 3 # 任务最大重试次数
auto_disable_on_401: bool = True # 遇到401错误自动禁用token
updated_at: Optional[datetime] = None
class ProxyConfig(BaseModel):
@@ -106,6 +112,7 @@ class WatermarkFreeConfig(BaseModel):
parse_method: str # Read from database, initialized from setting.toml on first startup
custom_parse_url: Optional[str] = None # Read from database, initialized from setting.toml on first startup
custom_parse_token: Optional[str] = None # Read from database, initialized from setting.toml on first startup
fallback_on_failure: bool = True # Auto fallback to watermarked video on failure, default True
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
@@ -133,6 +140,33 @@ class TokenRefreshConfig(BaseModel):
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class CallLogicConfig(BaseModel):
"""Call logic configuration"""
id: int = 1
call_mode: str = "default" # "default" or "polling"
polling_mode_enabled: bool = False # Read from database, initialized from setting.toml on first startup
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class PowProxyConfig(BaseModel):
"""POW proxy configuration"""
id: int = 1
pow_proxy_enabled: bool = False # Whether to enable POW proxy
pow_proxy_url: Optional[str] = None # POW proxy URL (e.g., http://127.0.0.1:7890)
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class PowServiceConfig(BaseModel):
"""POW service configuration"""
id: int = 1
mode: str = "local" # "local" or "external"
server_url: Optional[str] = None # External POW service URL
api_key: Optional[str] = None # External POW service API key
proxy_enabled: bool = False # Whether to enable proxy for POW service
proxy_url: Optional[str] = None # Proxy URL for POW service
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
# API Request/Response models
class ChatMessage(BaseModel):
role: str

View File

@@ -139,6 +139,20 @@ async def startup_event():
token_refresh_config = await db.get_token_refresh_config()
config.set_at_auto_refresh_enabled(token_refresh_config.at_auto_refresh_enabled)
# Load call logic configuration from database
call_logic_config = await db.get_call_logic_config()
config.set_call_logic_mode(call_logic_config.call_mode)
print(f"✓ Call logic mode: {call_logic_config.call_mode}")
# Load POW service configuration from database
pow_service_config = await db.get_pow_service_config()
config.set_pow_service_mode(pow_service_config.mode)
config.set_pow_service_server_url(pow_service_config.server_url or "")
config.set_pow_service_api_key(pow_service_config.api_key or "")
config.set_pow_service_proxy_enabled(pow_service_config.proxy_enabled)
config.set_pow_service_proxy_url(pow_service_config.proxy_url or "")
print(f"✓ POW service mode: {pow_service_config.mode}")
# Initialize concurrency manager with all tokens
all_tokens = await db.get_all_tokens()
await concurrency_manager.initialize(all_tokens)

View File

@@ -17,6 +17,13 @@ from ..core.models import Task, RequestLog
from ..core.config import config
from ..core.logger import debug_logger
# Custom exception to carry token_id information
class GenerationError(Exception):
"""Custom exception for generation errors that includes token_id"""
def __init__(self, message: str, token_id: Optional[int] = None):
super().__init__(message)
self.token_id = token_id
# Model configuration
MODEL_CONFIG = {
"gpt-image": {
@@ -242,6 +249,26 @@ class GenerationHandler:
video_str = video_str.split(",", 1)[1]
return base64.b64decode(video_str)
def _should_retry_on_error(self, error: Exception) -> bool:
"""判断错误是否应该触发重试
Args:
error: 捕获的异常
Returns:
True if should retry, False otherwise
"""
error_str = str(error).lower()
# 排除 CF Shield/429 错误(这些错误重试也会失败)
if "cf_shield" in error_str or "cloudflare" in error_str:
return False
if "429" in error_str or "rate limit" in error_str:
return False
# 其他所有错误都可以重试
return True
def _process_character_username(self, username_hint: str) -> str:
"""Process character username from API response
@@ -380,7 +407,8 @@ class GenerationHandler:
image: Optional[str] = None,
video: Optional[str] = None,
remix_target_id: Optional[str] = None,
stream: bool = True) -> AsyncGenerator[str, None]:
stream: bool = True,
show_init_message: bool = True) -> AsyncGenerator[str, None]:
"""Handle generation request
Args:
@@ -390,6 +418,7 @@ class GenerationHandler:
video: Base64 encoded video or video URL
remix_target_id: Sora share link video ID for remix
stream: Whether to stream response
show_init_message: Whether to show "Generation Process Begins" message
"""
start_time = time.time()
log_id = None # Initialize log_id to avoid reference before assignment
@@ -492,6 +521,7 @@ class GenerationHandler:
task_id = None
is_first_chunk = True # Track if this is the first chunk
log_id = None # Initialize log_id
log_updated = False # Track if log has been updated
try:
# Create initial log entry BEFORE submitting task to upstream
@@ -525,7 +555,7 @@ class GenerationHandler:
)
# Generate
if stream:
if stream and show_init_message:
if is_first_chunk:
yield self._format_stream_chunk(
reasoning_content="**Generation Process Begins**\n\nInitializing generation request...\n",
@@ -651,6 +681,7 @@ class GenerationHandler:
status_code=200,
duration=duration
)
log_updated = True # Mark log as updated
except Exception as e:
# Release lock for image generation on error
@@ -698,6 +729,7 @@ class GenerationHandler:
status_code=status_code,
duration=duration
)
log_updated = True # Mark log as updated
else:
# Generic error
await self.db.update_request_log(
@@ -706,8 +738,124 @@ class GenerationHandler:
status_code=500,
duration=duration
)
raise e
log_updated = True # Mark log as updated
# Wrap exception with token_id information
if token_obj:
raise GenerationError(str(e), token_id=token_obj.id)
else:
raise e
finally:
# Ensure log is updated even if exception handling fails
# This prevents logs from being stuck at status_code = -1
if log_id and not log_updated:
try:
# Log was not updated in try or except blocks, update it now
duration = time.time() - start_time
await self.db.update_request_log(
log_id,
response_body=json.dumps({"error": "Task failed or interrupted during processing"}),
status_code=500,
duration=duration
)
debug_logger.log_info(f"Updated stuck log entry {log_id} from status -1 to 500 in finally block")
except Exception as finally_error:
# Don't let finally block errors break the flow
debug_logger.log_error(
error_message=f"Failed to update log in finally block: {str(finally_error)}",
status_code=500,
response_text=str(finally_error)
)
async def handle_generation_with_retry(self, model: str, prompt: str,
image: Optional[str] = None,
video: Optional[str] = None,
remix_target_id: Optional[str] = None,
stream: bool = True) -> AsyncGenerator[str, None]:
"""Handle generation request with automatic retry on failure
Args:
model: Model name
prompt: Generation prompt
image: Base64 encoded image
video: Base64 encoded video or video URL
remix_target_id: Sora share link video ID for remix
stream: Whether to stream response
"""
# Get admin config for retry settings
admin_config = await self.db.get_admin_config()
retry_enabled = admin_config.task_retry_enabled
max_retries = admin_config.task_max_retries if retry_enabled else 0
auto_disable_on_401 = admin_config.auto_disable_on_401
retry_count = 0
last_error = None
last_token_id = None # Track the token that caused the error
while retry_count <= max_retries:
try:
# Try generation
# Only show init message on first attempt (not on retries)
show_init = (retry_count == 0)
async for chunk in self.handle_generation(model, prompt, image, video, remix_target_id, stream, show_init_message=show_init):
yield chunk
# If successful, return
return
except Exception as e:
last_error = e
error_str = str(e)
# Extract token_id from GenerationError if available
if isinstance(e, GenerationError) and e.token_id:
last_token_id = e.token_id
# Check if this is a 401 error
is_401_error = "401" in error_str or "unauthorized" in error_str.lower() or "token_invalidated" in error_str.lower()
# If 401 error and auto-disable is enabled, disable the token
if is_401_error and auto_disable_on_401 and last_token_id:
debug_logger.log_info(f"Detected 401 error, auto-disabling token {last_token_id}")
try:
await self.db.update_token_status(last_token_id, False)
if stream:
yield self._format_stream_chunk(
reasoning_content=f"**检测到401错误已自动禁用Token {last_token_id}**\\n\\n正在使用其他Token重试...\\n\\n"
)
except Exception as disable_error:
debug_logger.log_error(
error_message=f"Failed to disable token {last_token_id}: {str(disable_error)}",
status_code=500,
response_text=str(disable_error)
)
# Check if we should retry
should_retry = (
retry_enabled and
retry_count < max_retries and
self._should_retry_on_error(e)
)
if should_retry:
retry_count += 1
debug_logger.log_info(f"Generation failed, retrying ({retry_count}/{max_retries}): {str(e)}")
# Send retry notification to user if streaming
if stream:
yield self._format_stream_chunk(
reasoning_content=f"**生成失败,正在重试**\n\n{retry_count} 次重试(共 {max_retries} 次)...\n\n失败原因:{str(e)}\n\n"
)
# Small delay before retry
await asyncio.sleep(2)
else:
# No more retries, raise the error
raise last_error
# If we exhausted all retries, raise the last error
if last_error:
raise last_error
async def _poll_task_result(self, task_id: str, token: str, is_video: bool,
stream: bool, prompt: str, token_id: int = None,
log_id: int = None, start_time: float = None) -> AsyncGenerator[str, None]:
@@ -864,11 +1012,15 @@ class GenerationHandler:
watermark_free_config = await self.db.get_watermark_free_config()
watermark_free_enabled = watermark_free_config.watermark_free_enabled
# Initialize variables
local_url = None
watermark_free_failed = False
if watermark_free_enabled:
# Watermark-free mode: post video and get watermark-free URL
debug_logger.log_info(f"Entering watermark-free mode for task {task_id}")
debug_logger.log_info(f"[Watermark-Free] Entering watermark-free mode for task {task_id}")
generation_id = item.get("id")
debug_logger.log_info(f"Generation ID: {generation_id}")
debug_logger.log_info(f"[Watermark-Free] Generation ID: {generation_id}")
if not generation_id:
raise Exception("Generation ID not found in video draft")
@@ -968,60 +1120,80 @@ class GenerationHandler:
)
except Exception as publish_error:
# Fallback to normal mode if publish fails
# Watermark-free mode failed
watermark_free_failed = True
import traceback
error_traceback = traceback.format_exc()
debug_logger.log_error(
error_message=f"Watermark-free mode failed: {str(publish_error)}",
error_message=f"[Watermark-Free] ❌ FAILED - Error: {str(publish_error)}",
status_code=500,
response_text=str(publish_error)
response_text=f"{str(publish_error)}\n\nTraceback:\n{error_traceback}"
)
if stream:
yield self._format_stream_chunk(
reasoning_content=f"Warning: Failed to get watermark-free version - {str(publish_error)}\nFalling back to normal video...\n"
)
# Use downloadable_url instead of url
url = item.get("downloadable_url") or item.get("url")
if not url:
raise Exception("Video URL not found")
if config.cache_enabled:
try:
cached_filename = await self.file_cache.download_and_cache(url, "video", token_id=token_id)
local_url = f"{self._get_base_url()}/tmp/{cached_filename}"
except Exception as cache_error:
local_url = url
else:
local_url = url
else:
# Normal mode: use downloadable_url instead of url
url = item.get("downloadable_url") or item.get("url")
if url:
# Cache video file (if cache enabled)
if config.cache_enabled:
# Check if fallback is enabled
if watermark_config.fallback_on_failure:
debug_logger.log_info(f"[Watermark-Free] Fallback enabled, falling back to normal mode (original URL)")
if stream:
yield self._format_stream_chunk(
reasoning_content="**Video Generation Completed**\n\nVideo generation successful. Now caching the video file...\n"
reasoning_content=f"⚠️ Warning: Failed to get watermark-free version - {str(publish_error)}\nFalling back to normal video...\n"
)
else:
# Fallback disabled, mark task as failed
debug_logger.log_error(
error_message=f"[Watermark-Free] Fallback disabled, marking task as failed",
status_code=500,
response_text=str(publish_error)
)
if stream:
yield self._format_stream_chunk(
reasoning_content=f"❌ Error: Failed to get watermark-free version - {str(publish_error)}\nFallback is disabled. Task marked as failed.\n"
)
# Re-raise the exception to mark task as failed
raise
try:
cached_filename = await self.file_cache.download_and_cache(url, "video", token_id=token_id)
local_url = f"{self._get_base_url()}/tmp/{cached_filename}"
if stream:
# If watermark-free mode is disabled or failed (with fallback enabled), use normal mode
if not watermark_free_enabled or (watermark_free_failed and watermark_config.fallback_on_failure):
# Normal mode: use downloadable_url instead of url
url = item.get("downloadable_url") or item.get("url")
if not url:
raise Exception("Video URL not found in draft")
debug_logger.log_info(f"Using original URL from draft: {url[:100]}...")
if config.cache_enabled:
# Show appropriate message based on mode
if stream and not watermark_free_failed:
# Normal mode (watermark-free disabled)
yield self._format_stream_chunk(
reasoning_content="**Video Generation Completed**\n\nVideo generation successful. Now caching the video file...\n"
)
try:
cached_filename = await self.file_cache.download_and_cache(url, "video", token_id=token_id)
local_url = f"{self._get_base_url()}/tmp/{cached_filename}"
if stream:
if watermark_free_failed:
yield self._format_stream_chunk(
reasoning_content="Video file cached successfully (fallback mode). Preparing final response...\n"
)
else:
yield self._format_stream_chunk(
reasoning_content="Video file cached successfully. Preparing final response...\n"
)
except Exception as cache_error:
# Fallback to original URL if caching fails
local_url = url
if stream:
yield self._format_stream_chunk(
reasoning_content=f"Warning: Failed to cache file - {str(cache_error)}\nUsing original URL instead...\n"
)
else:
# Cache disabled: use original URL directly
except Exception as cache_error:
local_url = url
if stream:
yield self._format_stream_chunk(
reasoning_content="**Video Generation Completed**\n\nCache is disabled. Using original URL directly...\n"
reasoning_content=f"Warning: Failed to cache file - {str(cache_error)}\nUsing original URL instead...\n"
)
else:
# Cache disabled
local_url = url
if stream and not watermark_free_failed:
# Normal mode (watermark-free disabled)
yield self._format_stream_chunk(
reasoning_content="**Video Generation Completed**\n\nCache is disabled. Using original URL directly...\n"
)
# Task completed
await self.db.update_task(

View File

@@ -1,6 +1,8 @@
"""Load balancing module"""
import random
import asyncio
from typing import Optional
from collections import defaultdict
from ..core.models import Token
from ..core.config import config
from .token_manager import TokenManager
@@ -9,13 +11,38 @@ from .concurrency_manager import ConcurrencyManager
from ..core.logger import debug_logger
class LoadBalancer:
"""Token load balancer with random selection and image generation lock"""
"""Token load balancer with random selection and round-robin polling"""
def __init__(self, token_manager: TokenManager, concurrency_manager: Optional[ConcurrencyManager] = None):
self.token_manager = token_manager
self.concurrency_manager = concurrency_manager
# Use image timeout from config as lock timeout
self.token_lock = TokenLock(lock_timeout=config.image_timeout)
# Round-robin state: stores last used token_id for each scenario (image/video/default)
# Resets to None on restart
self._round_robin_state = {"image": None, "video": None, "default": None}
self._rr_lock = asyncio.Lock()
async def _select_round_robin(self, tokens: list[Token], scenario: str) -> Optional[Token]:
"""Select tokens in round-robin order for the given scenario"""
if not tokens:
return None
tokens_sorted = sorted(tokens, key=lambda t: t.id)
async with self._rr_lock:
last_id = self._round_robin_state.get(scenario)
start_idx = 0
if last_id is not None:
# Find the position of last used token and move to next
for idx, token in enumerate(tokens_sorted):
if token.id == last_id:
start_idx = (idx + 1) % len(tokens_sorted)
break
selected = tokens_sorted[start_idx]
# Update state for next selection
self._round_robin_state[scenario] = selected.id
return selected
async def select_token(self, for_image_generation: bool = False, for_video_generation: bool = False, require_pro: bool = False) -> Optional[Token]:
"""
@@ -89,6 +116,11 @@ class LoadBalancer:
if not available_tokens:
return None
# Check if polling mode is enabled
if config.call_logic_mode == "polling":
scenario = "image"
return await self._select_round_robin(available_tokens, scenario)
# Random selection from available tokens
return random.choice(available_tokens)
else:
@@ -100,7 +132,18 @@ class LoadBalancer:
available_tokens.append(token)
if not available_tokens:
return None
# Check if polling mode is enabled
if config.call_logic_mode == "polling":
scenario = "video"
return await self._select_round_robin(available_tokens, scenario)
return random.choice(available_tokens)
else:
# For video generation without concurrency manager, no additional filtering
# Check if polling mode is enabled
if config.call_logic_mode == "polling":
scenario = "video" if for_video_generation else "default"
return await self._select_round_robin(active_tokens, scenario)
return random.choice(active_tokens)

View File

@@ -0,0 +1,136 @@
"""POW Service Client - External POW service integration"""
import json
from typing import Optional, Tuple
from curl_cffi.requests import AsyncSession
from ..core.config import config
from ..core.logger import debug_logger
class POWServiceClient:
"""Client for external POW service API"""
async def get_sentinel_token(self) -> Optional[Tuple[str, str, str]]:
"""Get sentinel token from external POW service
Returns:
Tuple of (sentinel_token, device_id, user_agent) or None on failure
"""
# Read configuration dynamically on each call
server_url = config.pow_service_server_url
api_key = config.pow_service_api_key
proxy_enabled = config.pow_service_proxy_enabled
proxy_url = config.pow_service_proxy_url if proxy_enabled else None
if not server_url or not api_key:
debug_logger.log_error(
error_message="POW service not configured: missing server_url or api_key",
status_code=0,
response_text="Configuration error",
source="POWServiceClient"
)
return None
# Construct API endpoint
api_url = f"{server_url.rstrip('/')}/api/pow/token"
headers = {
"Authorization": f"Bearer {api_key}",
"Accept": "application/json"
}
try:
debug_logger.log_info(f"[POW Service] Requesting token from {api_url}")
async with AsyncSession(impersonate="chrome131") as session:
response = await session.get(
api_url,
headers=headers,
proxy=proxy_url,
timeout=30
)
if response.status_code != 200:
error_msg = f"POW service request failed: {response.status_code}"
debug_logger.log_error(
error_message=error_msg,
status_code=response.status_code,
response_text=response.text,
source="POWServiceClient"
)
return None
data = response.json()
if not data.get("success"):
debug_logger.log_error(
error_message="POW service returned success=false",
status_code=response.status_code,
response_text=response.text,
source="POWServiceClient"
)
return None
token = data.get("token")
device_id = data.get("device_id")
user_agent = data.get("user_agent")
cached = data.get("cached", False)
if not token:
debug_logger.log_error(
error_message="POW service returned empty token",
status_code=response.status_code,
response_text=response.text,
source="POWServiceClient"
)
return None
# Parse token to extract device_id if not provided
token_data = None
if not device_id:
try:
token_data = json.loads(token)
device_id = token_data.get("id")
except:
pass
# 记录详细的 token 信息
cache_status = "cached" if cached else "fresh"
debug_logger.log_info("=" * 100)
debug_logger.log_info(f"[POW Service] Token obtained successfully ({cache_status})")
debug_logger.log_info(f"[POW Service] Token length: {len(token)}")
debug_logger.log_info(f"[POW Service] Device ID: {device_id}")
debug_logger.log_info(f"[POW Service] User Agent: {user_agent}")
# 解析并显示 token 结构
if not token_data:
try:
token_data = json.loads(token)
except:
debug_logger.log_info(f"[POW Service] Token is not valid JSON")
token_data = None
if token_data:
debug_logger.log_info(f"[POW Service] Token structure keys: {list(token_data.keys())}")
for key, value in token_data.items():
if isinstance(value, str) and len(value) > 100:
debug_logger.log_info(f"[POW Service] Token[{key}]: <string, length={len(value)}>")
else:
debug_logger.log_info(f"[POW Service] Token[{key}]: {value}")
debug_logger.log_info("=" * 100)
return token, device_id, user_agent
except Exception as e:
debug_logger.log_error(
error_message=f"POW service request exception: {str(e)}",
status_code=0,
response_text=str(e),
source="POWServiceClient"
)
return None
# Global instance
pow_service_client = POWServiceClient()

File diff suppressed because it is too large Load Diff

View File

@@ -946,19 +946,21 @@ class TokenManager:
async def update_token_status(self, token_id: int, is_active: bool):
"""Update token active status"""
await self.db.update_token_status(token_id, is_active)
# When manually changing status, set appropriate disabled_reason
disabled_reason = None if is_active else "manual"
await self.db.update_token_status(token_id, is_active, disabled_reason)
async def enable_token(self, token_id: int):
"""Enable a token and reset error count"""
await self.db.update_token_status(token_id, True)
await self.db.update_token_status(token_id, True, None) # Clear disabled_reason
# Reset error count when enabling (in token_stats table)
await self.db.reset_error_count(token_id)
# Clear expired flag when enabling
await self.db.clear_token_expired(token_id)
async def disable_token(self, token_id: int):
"""Disable a token"""
await self.db.update_token_status(token_id, False)
"""Disable a token (manual disable)"""
await self.db.update_token_status(token_id, False, "manual")
async def test_token(self, token_id: int) -> dict:
"""Test if a token is valid by calling Sora API and refresh account info (subscription + Sora2)"""
@@ -1048,6 +1050,14 @@ class TokenManager:
"valid": False,
"message": "Token已过期token_invalidated"
}
# Check if error is "Failed to get user info:401"
if "Failed to get user info:401" in error_msg or "Failed to get user info: 401" in error_msg:
# Mark token as invalid and disable it
await self.db.mark_token_invalid(token_id)
return {
"valid": False,
"message": "Token无效: Token is invalid: Failed to get user info:401"
}
return {
"valid": False,
"message": f"Token is invalid: {error_msg}"
@@ -1077,7 +1087,8 @@ class TokenManager:
admin_config = await self.db.get_admin_config()
if stats and stats.consecutive_error_count >= admin_config.error_ban_threshold:
await self.db.update_token_status(token_id, False)
# Disable token with error_limit reason
await self.db.update_token_status(token_id, False, "error_limit")
async def record_success(self, token_id: int, is_video: bool = False):
"""Record successful request (reset error count)"""

95
src/utils/timezone.py Normal file
View File

@@ -0,0 +1,95 @@
"""Timezone utilities for consistent time handling across the application"""
from datetime import datetime, timezone, timedelta
from typing import Optional
import os
def get_timezone_offset() -> int:
"""Get timezone offset in hours from environment variable or default to UTC+8
Returns:
int: Timezone offset in hours (default: 8 for China/Asia/Shanghai)
"""
try:
return int(os.getenv("TIMEZONE_OFFSET", "8"))
except ValueError:
return 8
def get_timezone() -> timezone:
"""Get timezone object based on configured offset
Returns:
timezone: Timezone object with configured offset
"""
offset_hours = get_timezone_offset()
return timezone(timedelta(hours=offset_hours))
def convert_utc_to_local(utc_time_str: Optional[str]) -> Optional[str]:
"""Convert UTC timestamp string to local timezone with ISO format
Args:
utc_time_str: UTC timestamp string (e.g., "2024-01-24 10:30:45")
Returns:
str: ISO formatted timestamp with timezone info (e.g., "2024-01-24T18:30:45+08:00")
None: If conversion fails or input is None
"""
if not utc_time_str:
return None
try:
# Parse SQLite timestamp (UTC) - handle both with and without 'Z' suffix
dt = datetime.fromisoformat(utc_time_str.replace('Z', '+00:00'))
# If no timezone info, assume UTC
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
# Convert to local timezone
local_tz = get_timezone()
dt_local = dt.astimezone(local_tz)
# Return ISO format with timezone
return dt_local.isoformat()
except Exception as e:
# If conversion fails, return original value
print(f"Warning: Failed to convert timestamp '{utc_time_str}': {e}")
return utc_time_str
def get_current_local_time() -> datetime:
"""Get current time in local timezone
Returns:
datetime: Current datetime with local timezone
"""
return datetime.now(get_timezone())
def format_local_time(dt: Optional[datetime], fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
"""Format datetime to string in local timezone
Args:
dt: Datetime object to format
fmt: Format string (default: "%Y-%m-%d %H:%M:%S")
Returns:
str: Formatted time string, or "-" if dt is None
"""
if not dt:
return "-"
try:
# Convert to local timezone if needed
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
local_tz = get_timezone()
dt_local = dt.astimezone(local_tz)
return dt_local.strftime(fmt)
except Exception as e:
print(f"Warning: Failed to format datetime: {e}")
return str(dt)

View File

@@ -79,7 +79,27 @@
<!-- Token 列表 -->
<div class="rounded-lg border border-border bg-background">
<div class="flex items-center justify-between gap-4 p-4 border-b border-border">
<h3 class="text-lg font-semibold">Token 列表</h3>
<div class="flex items-center gap-2">
<h3 class="text-lg font-semibold">Token 列表</h3>
<!-- 状态筛选按钮 -->
<div class="relative">
<button id="statusFilterBtn" onclick="toggleStatusFilterDropdown()" class="inline-flex items-center justify-center gap-1.5 rounded-md border border-input bg-background px-3 h-8 text-sm transition-colors hover:bg-accent hover:border-primary" title="筛选状态">
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>
</svg>
<span id="statusFilterLabel">全部</span>
<svg class="h-3 w-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
<!-- 下拉菜单 -->
<div id="statusFilterDropdown" class="hidden absolute left-0 mt-2 w-32 rounded-md border border-border bg-background shadow-lg z-50">
<div class="py-1">
<button onclick="selectStatusFilter('')" class="w-full text-left px-4 py-2 text-sm hover:bg-accent transition-colors" data-filter-option="">全部</button>
</div>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<!-- 自动刷新AT标签和开关 -->
<div class="flex items-center gap-2">
@@ -223,6 +243,15 @@
</svg>
<span>清理禁用</span>
</button>
<button onclick="batchDeleteSelected()" class="batch-dropdown-item red" title="删除所有选中的Token不管是否禁用">
<svg class="batch-dropdown-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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"/>
<line x1="9" y1="9" x2="15" y2="15"/>
<line x1="15" y1="9" x2="9" y2="15"/>
</svg>
<span>删除选中</span>
</button>
<button onclick="openBatchProxyModal()" class="batch-dropdown-item blue" title="批量修改选中Token的代理">
<svg class="batch-dropdown-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
@@ -270,6 +299,7 @@
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">Client ID</th>
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">过期时间</th>
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">账户类型</th>
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">可用次数</th>
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">图片</th>
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">视频</th>
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">错误</th>
@@ -344,7 +374,61 @@
<input id="cfgProxyUrl" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="http://127.0.0.1:7890 或 socks5://127.0.0.1:1080">
<p class="text-xs text-muted-foreground mt-1">支持 HTTP 和 SOCKS5 代理</p>
</div>
<button onclick="saveProxyConfig()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 w-full">保存配置</button>
<div>
<label class="text-sm font-medium mb-2 block">测试域名</label>
<input id="cfgProxyTestUrl" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="https://sora.chatgpt.com" value="https://sora.chatgpt.com">
<p class="text-xs text-muted-foreground mt-1">用于测试代理连接的目标域名</p>
</div>
<div class="grid grid-cols-2 gap-2">
<button onclick="saveProxyConfig()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 w-full">保存配置</button>
<button onclick="testProxyConfig()" class="inline-flex items-center justify-center rounded-md bg-muted hover:bg-muted/80 h-9 px-4 w-full">测试代理</button>
</div>
<div id="proxyStatusMessage" class="text-xs hidden"></div>
<div class="text-xs text-amber-600 bg-amber-50 dark:bg-amber-950/20 p-3 rounded-md border border-amber-200 dark:border-amber-900">
<strong>⚠️ 提示:</strong>代理测试成功仅表示代理服务器可以正常连接到目标域名,并不能保证代理 IP 所在地区可以使用 Sora 服务。请确保您的代理 IP 位于支持 Sora 的地区。
</div>
</div>
</div>
<!-- POW配置 -->
<div class="rounded-lg border border-border bg-background p-6">
<h3 class="text-lg font-semibold mb-4">POW配置</h3>
<div class="space-y-4">
<div>
<label class="text-sm font-medium mb-2 block">计算模式</label>
<select id="cfgPowMode" onchange="togglePowFields()" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
<option value="local">本地计算</option>
<option value="external">外部服务</option>
</select>
<p class="text-xs text-muted-foreground mt-1">选择 POW 计算方式</p>
</div>
<div id="powExternalFields" style="display: none;">
<div class="space-y-4">
<div>
<label class="text-sm font-medium mb-2 block">服务器地址</label>
<input id="cfgPowServerUrl" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="http://127.0.0.1:8002">
<p class="text-xs text-muted-foreground mt-1">外部 POW 服务的地址</p>
</div>
<div>
<label class="text-sm font-medium mb-2 block">API 密钥</label>
<input id="cfgPowApiKey" type="password" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="输入 API 密钥">
<p class="text-xs text-muted-foreground mt-1">访问外部 POW 服务的密钥</p>
</div>
</div>
</div>
<div>
<label class="inline-flex items-center gap-2 cursor-pointer">
<input type="checkbox" id="cfgPowProxyEnabled" onchange="togglePowProxyFields()" class="h-4 w-4 rounded border-input">
<span class="text-sm font-medium">启用POW代理</span>
</label>
<p class="text-xs text-muted-foreground mt-1">获取 Sentinel Token 时使用的代理</p>
</div>
<div id="powProxyUrlField" style="display: none;">
<label class="text-sm font-medium mb-2 block">POW代理地址</label>
<input id="cfgPowProxyUrl" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="http://127.0.0.1:7890">
<p class="text-xs text-muted-foreground mt-1">用于获取 POW Token 的代理地址</p>
</div>
<button onclick="savePowConfig()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 w-full">保存配置</button>
</div>
</div>
@@ -357,6 +441,25 @@
<input id="cfgErrorBan" type="number" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="3">
<p class="text-xs text-muted-foreground mt-1">Token 连续错误达到此次数后自动禁用</p>
</div>
<div>
<label class="inline-flex items-center gap-2 cursor-pointer">
<input type="checkbox" id="cfgTaskRetryEnabled" class="h-4 w-4 rounded border-input">
<span class="text-sm font-medium">启用任务失败重试</span>
</label>
<p class="text-xs text-muted-foreground mt-1">生成任务失败时自动重试,直到成功或达到最大重试次数</p>
</div>
<div>
<label class="text-sm font-medium mb-2 block">最大重试次数</label>
<input id="cfgTaskMaxRetries" type="number" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="3" min="1" max="10">
<p class="text-xs text-muted-foreground mt-1">任务失败后最多重试的次数1-10次</p>
</div>
<div>
<label class="inline-flex items-center gap-2 cursor-pointer">
<input type="checkbox" id="cfgAutoDisableOn401" class="h-4 w-4 rounded border-input">
<span class="text-sm font-medium">遇到401错误自动禁用Token</span>
</label>
<p class="text-xs text-muted-foreground mt-1">当Token返回401错误时自动禁用该Token并使用其他Token重试</p>
</div>
<button onclick="saveAdminConfig()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 w-full">保存配置</button>
</div>
</div>
@@ -436,6 +539,15 @@
</select>
</div>
<!-- 回退开关 -->
<div>
<label class="inline-flex items-center gap-2 cursor-pointer">
<input type="checkbox" id="cfgFallbackOnFailure" class="h-4 w-4 rounded border-input" checked>
<span class="text-sm font-medium">去水印失败后自动回退</span>
</label>
<p class="text-xs text-muted-foreground mt-2">开启后,去水印失败时自动回退到带水印视频;关闭后,去水印失败将标记任务为失败状态</p>
</div>
<!-- 自定义解析配置 -->
<div id="customParseOptions" style="display: none;" class="space-y-4">
<div>
@@ -454,6 +566,22 @@
</div>
</div>
<!-- 调用逻辑配置 -->
<div class="rounded-lg border border-border bg-background p-6">
<h3 class="text-lg font-semibold mb-4">账号调用逻辑</h3>
<div class="space-y-4">
<div>
<label class="text-sm font-medium block">调用模式</label>
<select id="cfgCallLogicMode" class="w-full mt-2 px-3 py-2 border border-input rounded-md bg-background text-foreground">
<option value="default">随机轮询</option>
<option value="polling">逐个轮询</option>
</select>
<p class="text-xs text-muted-foreground mt-2">随机轮询:随机选择可用账号;逐个轮询:每个活跃账号只使用一次,全部使用过后再开始下一轮</p>
</div>
<button onclick="saveCallLogicConfig()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 w-full">保存配置</button>
</div>
</div>
<!-- 调试配置 -->
<div class="rounded-lg border border-border bg-background p-6">
<h3 class="text-lg font-semibold mb-4">调试配置</h3>
@@ -774,11 +902,41 @@
</button>
</div>
<div class="p-5 space-y-4">
<div>
<div id="jsonFileSection">
<label class="text-sm font-medium mb-2 block">选择 JSON 文件</label>
<input type="file" id="importFile" accept=".json" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
<p class="text-xs text-muted-foreground mt-1">选择导出的 Token JSON 文件进行导入</p>
</div>
<!-- 纯RT导入输入框区域 -->
<div id="pureRtSection" class="hidden space-y-2.5">
<div>
<label class="text-sm font-medium mb-1.5 block">Refresh Token 列表</label>
<textarea id="pureRtInput" rows="3" placeholder="每行一个 RT" class="flex w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm resize-none"></textarea>
</div>
<div class="grid grid-cols-2 gap-2.5">
<div>
<label class="text-sm font-medium mb-1.5 block">Client ID(可选)</label>
<input type="text" id="pureRtClientId" placeholder="留空使用默认值" class="flex h-8 w-full rounded-md border border-input bg-background px-2.5 py-1.5 text-sm">
</div>
<div>
<label class="text-sm font-medium mb-1.5 block">代理地址(可选)</label>
<input type="text" id="pureRtProxy" placeholder="http://127.0.0.1:7890" class="flex h-8 w-full rounded-md border border-input bg-background px-2.5 py-1.5 text-sm">
</div>
</div>
<div class="grid grid-cols-2 gap-2.5">
<div>
<label class="text-sm font-medium mb-1.5 block">图片并发</label>
<input type="number" id="pureRtImageConcurrency" value="1" min="-1" class="flex h-8 w-full rounded-md border border-input bg-background px-2.5 py-1.5 text-sm">
</div>
<div>
<label class="text-sm font-medium mb-1.5 block">视频并发</label>
<input type="number" id="pureRtVideoConcurrency" value="3" min="-1" class="flex h-8 w-full rounded-md border border-input bg-background px-2.5 py-1.5 text-sm">
</div>
</div>
<p class="text-xs text-muted-foreground">💡 提示:自动刷新并批量导入,并发 -1 表示不限制</p>
</div>
<div>
<label class="text-sm font-medium mb-2 block">选择导入模式</label>
<select id="importMode" onchange="updateImportModeHint()" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
@@ -786,10 +944,11 @@
<option value="offline">离线导入(不更新账号状态)</option>
<option value="st">优先使用ST导入</option>
<option value="rt">优先使用RT导入</option>
<option value="pure_rt">纯RT导入</option>
</select>
<p id="importModeHint" class="text-xs text-muted-foreground mt-1">使用AT更新账号状态订阅信息、Sora2次数等</p>
</div>
<div class="rounded-md bg-gray-50 dark:bg-gray-900/20 p-3 border border-gray-200 dark:border-gray-800">
<div id="importModeHelpSection" class="rounded-md bg-gray-50 dark:bg-gray-900/20 p-3 border border-gray-200 dark:border-gray-800">
<p class="text-xs font-semibold text-gray-900 dark:text-gray-100 mb-2">📋 导入模式说明</p>
<div class="space-y-1.5 text-xs text-gray-700 dark:text-gray-300">
<div class="flex items-start gap-2">
@@ -808,6 +967,10 @@
<span class="font-medium min-w-[100px]">RT导入</span>
<span>适用于只有RT没有AT自动转换为AT</span>
</div>
<div class="flex items-start gap-2">
<span class="font-medium min-w-[100px]">纯RT导入</span>
<span>手动输入RT列表一行一个自动转换并批量导入</span>
</div>
</div>
<p class="text-xs text-gray-600 dark:text-gray-400 mt-2 pt-2 border-t border-gray-200 dark:border-gray-700">
💡 提示:离线导入后可使用"测试"按钮更新账号信息功能不稳定有bug问猫猫
@@ -891,21 +1054,29 @@
</div>
<script>
let allTokens=[],currentPage=1,pageSize=20,selectedTokenIds=new Set();
let allTokens=[],currentPage=1,pageSize=20,selectedTokenIds=new Set(),currentStatusFilter='';
const $=(id)=>document.getElementById(id),
checkAuth=()=>{const t=localStorage.getItem('adminToken');return t||(location.href='/login',null),t},
apiRequest=async(url,opts={})=>{const t=checkAuth();if(!t)return null;const r=await fetch(url,{...opts,headers:{...opts.headers,Authorization:`Bearer ${t}`,'Content-Type':'application/json'}});return r.status===401?(localStorage.removeItem('adminToken'),location.href='/login',null):r},
// 获取token的状态文本和样式
getTokenStatus=(token)=>{if(token.is_active){return{text:'活跃',class:'bg-green-50 text-green-700'}}const reason=token.disabled_reason;if(reason==='token_invalid'||reason==='expired'){return{text:'失效',class:'bg-gray-100 text-gray-700'}}if(reason==='manual'||reason==='error_limit'){return{text:'禁用',class:'bg-gray-100 text-gray-700'}}return{text:'禁用',class:'bg-gray-100 text-gray-700'}},
loadStats=async()=>{try{const r=await apiRequest('/api/stats');if(!r)return;const d=await r.json();$('statTotal').textContent=d.total_tokens||0;$('statActive').textContent=d.active_tokens||0;$('statImages').textContent=(d.today_images||0)+'/'+(d.total_images||0);$('statVideos').textContent=(d.today_videos||0)+'/'+(d.total_videos||0);$('statErrors').textContent=(d.today_errors||0)+'/'+(d.total_errors||0)}catch(e){console.error('加载统计失败:',e)}},
loadTokens=async()=>{try{const r=await apiRequest('/api/tokens');if(!r)return;allTokens=await r.json();renderTokens()}catch(e){console.error('加载Token失败:',e)}},
loadTokens=async()=>{try{const r=await apiRequest('/api/tokens');if(!r)return;allTokens=await r.json();updateStatusFilterOptions();renderTokens()}catch(e){console.error('加载Token失败:',e)}},
updateStatusFilterOptions=()=>{const statusSet=new Set();allTokens.forEach(t=>{const status=getTokenStatus(t);statusSet.add(status.text)});const dropdown=$('statusFilterDropdown');if(!dropdown)return;const statuses=Array.from(statusSet).sort();dropdown.innerHTML='<div class="py-1"><button onclick="selectStatusFilter(\'\')" class="w-full text-left px-4 py-2 text-sm hover:bg-accent transition-colors" data-filter-option="">全部</button>'+statuses.map(s=>`<button onclick="selectStatusFilter('${s}')" class="w-full text-left px-4 py-2 text-sm hover:bg-accent transition-colors" data-filter-option="${s}">${s}</button>`).join('')+'</div>';updateStatusFilterLabel()},
updateStatusFilterLabel=()=>{const label=$('statusFilterLabel');if(label){label.textContent=currentStatusFilter||'全部'}},
toggleStatusFilterDropdown=()=>{const dropdown=$('statusFilterDropdown');if(!dropdown)return;dropdown.classList.toggle('hidden')},
selectStatusFilter=(status)=>{currentStatusFilter=status;currentPage=1;updateStatusFilterLabel();toggleStatusFilterDropdown();renderTokens()},
applyStatusFilter=()=>{currentPage=1;renderTokens()},
getFilteredTokens=()=>{if(!currentStatusFilter)return allTokens;return allTokens.filter(t=>{const status=getTokenStatus(t);return status.text===currentStatusFilter})},
formatExpiry=exp=>{if(!exp)return'-';const d=new Date(exp),now=new Date(),diff=d-now;const dateStr=d.toLocaleDateString('zh-CN',{year:'numeric',month:'2-digit',day:'2-digit'}).replace(/\//g,'-');const timeStr=d.toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit',hour12:false});if(diff<0)return`<span class="text-red-600">${dateStr} ${timeStr}</span>`;const days=Math.floor(diff/864e5);if(days<7)return`<span class="text-orange-600">${dateStr} ${timeStr}</span>`;return`${dateStr} ${timeStr}`},
formatPlanType=type=>{if(!type)return'-';const typeMap={'chatgpt_team':'Team','chatgpt_plus':'Plus','chatgpt_pro':'Pro','chatgpt_free':'Free'};return typeMap[type]||type},
formatPlanTypeWithTooltip=(t)=>{const tooltipText=t.subscription_end?`套餐到期: ${new Date(t.subscription_end).toLocaleDateString('zh-CN',{year:'numeric',month:'2-digit',day:'2-digit'}).replace(/\//g,'-')} ${new Date(t.subscription_end).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit',hour12:false})}`:'';return`<span class="inline-flex items-center rounded px-2 py-0.5 text-xs bg-blue-50 text-blue-700 cursor-pointer" title="${tooltipText||t.plan_title||'-'}">${formatPlanType(t.plan_type)}</span>`},
formatClientId=(clientId)=>{if(!clientId)return'-';const short=clientId.substring(0,8)+'...';return`<span class="text-xs font-mono cursor-pointer hover:text-primary" title="${clientId}" onclick="navigator.clipboard.writeText('${clientId}').then(()=>showToast('已复制','success'))">${short}</span>`},
renderTokens=()=>{const start=(currentPage-1)*pageSize,end=start+pageSize,paginatedTokens=allTokens.slice(start,end);const tb=$('tokenTableBody');tb.innerHTML=paginatedTokens.map(t=>{const imageDisplay=t.image_enabled?`${t.image_count||0}`:'-';const videoDisplay=t.video_enabled?`${t.video_count||0}`:'-';const statusText=t.is_expired?'已过期':(t.is_active?'活跃':'禁用');const statusClass=t.is_expired?'bg-gray-100 text-gray-700':(t.is_active?'bg-green-50 text-green-700':'bg-gray-100 text-gray-700');return`<tr><td class=\"py-2.5 px-3\"><input type=\"checkbox\" class=\"token-checkbox h-4 w-4 rounded border-gray-300\" data-token-id=\"${t.id}\" onchange=\"toggleTokenSelection(${t.id},this.checked)\" ${selectedTokenIds.has(t.id)?'checked':''}></td><td class=\"py-2.5 px-3\">${t.email}</td><td class=\"py-2.5 px-3\"><span class=\"inline-flex items-center rounded px-2 py-0.5 text-xs ${statusClass}\">${statusText}</span></td><td class="py-2.5 px-3">${formatClientId(t.client_id)}</td><td class="py-2.5 px-3 text-xs">${formatExpiry(t.expiry_time)}</td><td class="py-2.5 px-3 text-xs">${formatPlanTypeWithTooltip(t)}</td><td class="py-2.5 px-3">${imageDisplay}</td><td class="py-2.5 px-3">${videoDisplay}</td><td class="py-2.5 px-3">${t.error_count||0}</td><td class="py-2.5 px-3 text-xs text-muted-foreground">${t.remark||'-'}</td><td class="py-2.5 px-3 text-right"><button onclick="testToken(${t.id})" class="inline-flex items-center justify-center rounded-md hover:bg-blue-50 hover:text-blue-700 h-7 px-2 text-xs mr-1">测试</button><button onclick="openEditModal(${t.id})" class="inline-flex items-center justify-center rounded-md hover:bg-green-50 hover:text-green-700 h-7 px-2 text-xs mr-1">编辑</button><button onclick="toggleToken(${t.id},${t.is_active})" class="inline-flex items-center justify-center rounded-md hover:bg-accent h-7 px-2 text-xs mr-1">${t.is_active?'禁用':'启用'}</button><button onclick="deleteToken(${t.id})" class="inline-flex items-center justify-center rounded-md hover:bg-destructive/10 hover:text-destructive h-7 px-2 text-xs">删除</button></td></tr>`}).join('');renderPagination()},
renderTokens=()=>{const filteredTokens=getFilteredTokens();const start=(currentPage-1)*pageSize,end=start+pageSize,paginatedTokens=filteredTokens.slice(start,end);const tb=$('tokenTableBody');tb.innerHTML=paginatedTokens.map(t=>{const imageDisplay=t.image_enabled?`${t.image_count||0}`:'-';const videoDisplay=t.video_enabled?`${t.video_count||0}`:'-';const remainingCount=t.sora2_remaining_count!==undefined&&t.sora2_remaining_count!==null?t.sora2_remaining_count:'-';const status=getTokenStatus(t);const statusText=status.text;const statusClass=status.class;return`<tr><td class=\"py-2.5 px-3\"><input type=\"checkbox\" class=\"token-checkbox h-4 w-4 rounded border-gray-300\" data-token-id=\"${t.id}\" onchange=\"toggleTokenSelection(${t.id},this.checked)\" ${selectedTokenIds.has(t.id)?'checked':''}></td><td class=\"py-2.5 px-3\">${t.email}</td><td class=\"py-2.5 px-3\"><span class=\"inline-flex items-center rounded px-2 py-0.5 text-xs ${statusClass}\">${statusText}</span></td><td class="py-2.5 px-3">${formatClientId(t.client_id)}</td><td class="py-2.5 px-3 text-xs">${formatExpiry(t.expiry_time)}</td><td class="py-2.5 px-3 text-xs">${formatPlanTypeWithTooltip(t)}</td><td class="py-2.5 px-3">${remainingCount}</td><td class="py-2.5 px-3">${imageDisplay}</td><td class="py-2.5 px-3">${videoDisplay}</td><td class="py-2.5 px-3">${t.error_count||0}</td><td class="py-2.5 px-3 text-xs text-muted-foreground">${t.remark||'-'}</td><td class="py-2.5 px-3 text-right"><button onclick="testToken(${t.id})" class="inline-flex items-center justify-center rounded-md hover:bg-blue-50 hover:text-blue-700 h-7 px-2 text-xs mr-1">测试</button><button onclick="openEditModal(${t.id})" class="inline-flex items-center justify-center rounded-md hover:bg-green-50 hover:text-green-700 h-7 px-2 text-xs mr-1">编辑</button><button onclick="toggleToken(${t.id},${t.is_active})" class="inline-flex items-center justify-center rounded-md hover:bg-accent h-7 px-2 text-xs mr-1">${t.is_active?'禁用':'启用'}</button><button onclick="deleteToken(${t.id})" class="inline-flex items-center justify-center rounded-md hover:bg-destructive/10 hover:text-destructive h-7 px-2 text-xs">删除</button></td></tr>`}).join('');renderPagination()},
refreshTokens=async()=>{await loadTokens();await loadStats()},
changePage=(page)=>{currentPage=page;renderTokens()},
changePageSize=(size)=>{pageSize=parseInt(size);currentPage=1;renderTokens()},
renderPagination=()=>{const totalPages=Math.ceil(allTokens.length/pageSize);const container=$('paginationContainer');if(!container)return;let html='<div class="flex items-center justify-between px-4 py-3 border-t border-border"><div class="flex items-center gap-2"><span class="text-sm text-muted-foreground">每页显示</span><select onchange="changePageSize(this.value)" class="h-8 rounded-md border border-input bg-background px-2 text-sm"><option value="20"'+(pageSize===20?' selected':'')+'>20</option><option value="50"'+(pageSize===50?' selected':'')+'>50</option><option value="100"'+(pageSize===100?' selected':'')+'>100</option><option value="200"'+(pageSize===200?' selected':'')+'>200</option><option value="500"'+(pageSize===500?' selected':'')+'>500</option></select><span class="text-sm text-muted-foreground">共 '+allTokens.length+'</span></div><div class="flex items-center gap-2">';if(totalPages>1){html+='<button onclick="changePage(1)" '+(currentPage===1?'disabled':'')+' class="inline-flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent h-8 px-3 text-sm disabled:opacity-50 disabled:cursor-not-allowed">首页</button>';html+='<button onclick="changePage('+(currentPage-1)+')" '+(currentPage===1?'disabled':'')+' class="inline-flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent h-8 px-3 text-sm disabled:opacity-50 disabled:cursor-not-allowed">上一页</button>';html+='<span class="text-sm text-muted-foreground">第 '+currentPage+' / '+totalPages+' 页</span>';html+='<button onclick="changePage('+(currentPage+1)+')" '+(currentPage===totalPages?'disabled':'')+' class="inline-flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent h-8 px-3 text-sm disabled:opacity-50 disabled:cursor-not-allowed">下一页</button>';html+='<button onclick="changePage('+totalPages+')" '+(currentPage===totalPages?'disabled':'')+' class="inline-flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent h-8 px-3 text-sm disabled:opacity-50 disabled:cursor-not-allowed">末页</button>'}html+='</div></div>';container.innerHTML=html},
renderPagination=()=>{const filteredTokens=getFilteredTokens();const totalPages=Math.ceil(filteredTokens.length/pageSize);const container=$('paginationContainer');if(!container)return;let html='<div class="flex items-center justify-between px-4 py-3 border-t border-border"><div class="flex items-center gap-2"><span class="text-sm text-muted-foreground">每页显示</span><select onchange="changePageSize(this.value)" class="h-8 rounded-md border border-input bg-background px-2 text-sm"><option value="20"'+(pageSize===20?' selected':'')+'>20</option><option value="50"'+(pageSize===50?' selected':'')+'>50</option><option value="100"'+(pageSize===100?' selected':'')+'>100</option><option value="200"'+(pageSize===200?' selected':'')+'>200</option><option value="500"'+(pageSize===500?' selected':'')+'>500</option></select><span class="text-sm text-muted-foreground">共 '+filteredTokens.length+' 条'+(currentStatusFilter?' (筛选后)':' (总数: '+allTokens.length+')')+'</span></div><div class="flex items-center gap-2">';if(totalPages>1){html+='<button onclick="changePage(1)" '+(currentPage===1?'disabled':'')+' class="inline-flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent h-8 px-3 text-sm disabled:opacity-50 disabled:cursor-not-allowed">首页</button>';html+='<button onclick="changePage('+(currentPage-1)+')" '+(currentPage===1?'disabled':'')+' class="inline-flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent h-8 px-3 text-sm disabled:opacity-50 disabled:cursor-not-allowed">上一页</button>';html+='<span class="text-sm text-muted-foreground">第 '+currentPage+' / '+totalPages+' 页</span>';html+='<button onclick="changePage('+(currentPage+1)+')" '+(currentPage===totalPages?'disabled':'')+' class="inline-flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent h-8 px-3 text-sm disabled:opacity-50 disabled:cursor-not-allowed">下一页</button>';html+='<button onclick="changePage('+totalPages+')" '+(currentPage===totalPages?'disabled':'')+' class="inline-flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent h-8 px-3 text-sm disabled:opacity-50 disabled:cursor-not-allowed">末页</button>'}html+='</div></div>';container.innerHTML=html},
openAddModal=()=>$('addModal').classList.remove('hidden'),
closeAddModal=()=>{$('addModal').classList.add('hidden');$('addTokenAT').value='';$('addTokenST').value='';$('addTokenRT').value='';$('addTokenClientId').value='';$('addTokenProxyUrl').value='';$('addTokenRemark').value='';$('addTokenImageEnabled').checked=true;$('addTokenVideoEnabled').checked=true;$('addTokenImageConcurrency').value='1';$('addTokenVideoConcurrency').value='3';$('addRTRefreshHint').classList.add('hidden')},
openEditModal=(id)=>{const token=allTokens.find(t=>t.id===id);if(!token)return showToast('Token不存在','error');$('editTokenId').value=token.id;$('editTokenAT').value=token.token||'';$('editTokenST').value=token.st||'';$('editTokenRT').value=token.rt||'';$('editTokenClientId').value=token.client_id||'';$('editTokenProxyUrl').value=token.proxy_url||'';$('editTokenRemark').value=token.remark||'';$('editTokenImageEnabled').checked=token.image_enabled!==false;$('editTokenVideoEnabled').checked=token.video_enabled!==false;$('editTokenImageConcurrency').value=token.image_concurrency||'-1';$('editTokenVideoConcurrency').value=token.video_concurrency||'-1';$('editModal').classList.remove('hidden')},
@@ -927,26 +1098,29 @@
openBatchProxyModal=()=>{if(selectedTokenIds.size===0){showToast('请先选择要修改的Token','info');return}$('batchProxyCount').textContent=selectedTokenIds.size;$('batchProxyUrl').value='';$('batchProxyModal').classList.remove('hidden')},
closeBatchProxyModal=()=>{$('batchProxyModal').classList.add('hidden');$('batchProxyUrl').value=''},
submitBatchProxy=async()=>{const proxyUrl=$('batchProxyUrl').value.trim();const btn=$('batchProxyBtn'),btnText=$('batchProxyBtnText'),btnSpinner=$('batchProxyBtnSpinner');btn.disabled=true;btnText.textContent='修改中...';btnSpinner.classList.remove('hidden');try{const r=await apiRequest('/api/tokens/batch/update-proxy',{method:'POST',body:JSON.stringify({token_ids:Array.from(selectedTokenIds),proxy_url:proxyUrl})});if(!r){btn.disabled=false;btnText.textContent='确认修改';btnSpinner.classList.add('hidden');return}const d=await r.json();if(d.success){closeBatchProxyModal();selectedTokenIds.clear();await refreshTokens();showToast(d.message,'success')}else{showToast('修改失败: '+(d.detail||d.message||'未知错误'),'error')}}catch(e){showToast('修改失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='确认修改';btnSpinner.classList.add('hidden')}},
showImportProgress=(results,added,updated,failed)=>{const summary=$('importProgressSummary'),list=$('importProgressList');summary.innerHTML=`<div class="text-sm"><strong>导入完成</strong><br/>新增: ${added} | 更新: ${updated} | 失败: ${failed}</div>`;list.innerHTML=results.map(r=>{const statusColor=r.success?(r.status==='added'?'text-green-600':'text-blue-600'):'text-red-600';const statusText=r.status==='added'?'新增':r.status==='updated'?'更新':'失败';return`<div class="p-3 rounded-md border ${r.success?'bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800':'bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800'}"><div class="flex items-center justify-between"><span class="font-medium">${r.email}</span><span class="${statusColor} text-sm font-medium">${statusText}</span></div>${r.error?`<div class="text-xs text-red-600 dark:text-red-400 mt-1">${r.error}</div>`:''}</div>`}).join('');openImportProgressModal()},
showImportProgress=(results,added,updated,failed)=>{const summary=$('importProgressSummary'),list=$('importProgressList');summary.innerHTML=`<div class="text-sm"><strong>导入完成</strong><br/>新增: ${added} | 更新: ${updated} | 失败: ${failed}</div>`;list.innerHTML=results.map(r=>{const isFailed=r.status==='failed';const isAdded=r.status==='added';const statusColor=isFailed?'text-red-600':(isAdded?'text-green-600':'text-blue-600');const statusText=isAdded?'新增':(r.status==='updated'?'更新':'失败');const bgColor=isFailed?'bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800':(isAdded?'bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800':'bg-blue-50 border-blue-200 dark:bg-blue-900/20 dark:border-blue-800');const errorMsg=r.message&&isFailed?r.message:(r.error||'');return`<div class="p-3 rounded-md border ${bgColor}"><div class="flex items-center justify-between"><span class="font-medium">${r.email}</span><span class="${statusColor} text-sm font-medium">${statusText}</span></div>${errorMsg?`<div class="text-xs text-red-600 dark:text-red-400 mt-1 whitespace-pre-wrap">${errorMsg}</div>`:''}</div>`}).join('');openImportProgressModal()},
exportTokens=()=>{if(allTokens.length===0){showToast('没有Token可导出','error');return}const exportData=allTokens.map(t=>({email:t.email,access_token:t.token,session_token:t.st||null,refresh_token:t.rt||null,client_id:t.client_id||null,proxy_url:t.proxy_url||null,remark:t.remark||null,is_active:t.is_active,image_enabled:t.image_enabled!==false,video_enabled:t.video_enabled!==false,image_concurrency:t.image_concurrency||(-1),video_concurrency:t.video_concurrency||(-1)}));const dataStr=JSON.stringify(exportData,null,2);const dataBlob=new Blob([dataStr],{type:'application/json'});const url=URL.createObjectURL(dataBlob);const link=document.createElement('a');link.href=url;link.download=`tokens_${new Date().toISOString().split('T')[0]}.json`;document.body.appendChild(link);link.click();document.body.removeChild(link);URL.revokeObjectURL(url);showToast(`已导出 ${allTokens.length} 个Token`,'success')},
batchTestUpdate=async()=>{if(selectedTokenIds.size===0){showToast('请先选择要测试的Token','info');return}if(!confirm(`⚠️ 警告\n\n此操作将请求上游获取选中的 ${selectedTokenIds.size} 个Token的状态信息可能需要较长时间。\n\n确定要继续吗?`)){return}showToast('正在测试更新选中的Token...','info');try{const r=await apiRequest('/api/tokens/batch/test-update',{method:'POST',body:JSON.stringify({token_ids:Array.from(selectedTokenIds)})});if(!r)return;const d=await r.json();if(d.success){selectedTokenIds.clear();await refreshTokens();showToast(d.message,'success')}else{showToast('测试更新失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('测试更新失败: '+e.message,'error')}},
batchEnableAll=async()=>{if(selectedTokenIds.size===0){showToast('请先选择要启用的Token','info');return}if(!confirm(`确定要启用选中的 ${selectedTokenIds.size} 个Token吗\n\n此操作将重置这些Token的错误计数。`)){return}showToast('正在批量启用Token...','info');try{const r=await apiRequest('/api/tokens/batch/enable-all',{method:'POST',body:JSON.stringify({token_ids:Array.from(selectedTokenIds)})});if(!r)return;const d=await r.json();if(d.success){selectedTokenIds.clear();await refreshTokens();showToast(d.message,'success')}else{showToast('批量启用失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('批量启用失败: '+e.message,'error')}},
batchDeleteDisabled=async()=>{if(selectedTokenIds.size===0){showToast('请先选择要删除的Token','info');return}if(!confirm(`⚠️ 第一次确认\n\n即将删除选中的 ${selectedTokenIds.size} 个Token。\n\n此操作不可恢复!确定要继续吗?`)){return}if(!confirm(`⚠️ 第二次确认\n\n你真的确定要删除这 ${selectedTokenIds.size} 个Token吗\n\n删除后无法恢复!`)){return}if(!confirm(`⚠️ 最后确认\n\n这是最后一次确认!\n\n删除 ${selectedTokenIds.size} 个Token后将无法恢复。\n\n确定要执行删除操作吗?`)){return}showToast('正在删除选中的Token...','info');try{const r=await apiRequest('/api/tokens/batch/delete-disabled',{method:'POST',body:JSON.stringify({token_ids:Array.from(selectedTokenIds)})});if(!r)return;const d=await r.json();if(d.success){selectedTokenIds.clear();await refreshTokens();showToast(d.message,'success')}else{showToast('删除失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('删除失败: '+e.message,'error')}},
batchDeleteDisabled=async()=>{if(selectedTokenIds.size===0){showToast('请先选择要删除的Token','info');return}const disabledCount=Array.from(selectedTokenIds).filter(id=>{const token=allTokens.find(t=>t.id===id);return token&&!token.is_active}).length;if(disabledCount===0){showToast('选中的Token中没有禁用的Token','info');return}if(!confirm(`⚠️ 警告\n\n选中了 ${selectedTokenIds.size} 个Token,其中 ${disabledCount} 个是禁用的。\n\n即将删除这 ${disabledCount} 个禁用Token。\n\n此操作不可恢复!确定要继续吗?`)){return}showToast('正在删除选中的禁用Token...','info');try{const r=await apiRequest('/api/tokens/batch/delete-disabled',{method:'POST',body:JSON.stringify({token_ids:Array.from(selectedTokenIds)})});if(!r)return;const d=await r.json();if(d.success){selectedTokenIds.clear();await refreshTokens();showToast(d.message,'success')}else{showToast('删除失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('删除失败: '+e.message,'error')}},
batchDeleteSelected=async()=>{if(selectedTokenIds.size===0){showToast('请先选择要删除的Token','info');return}if(!confirm(`⚠️ 第一次确认\n\n即将删除选中的 ${selectedTokenIds.size} 个Token包括正常和禁用的\n\n此操作不可恢复!确定要继续吗?`)){return}if(!confirm(`⚠️ 第二次确认\n\n你真的确定要删除这 ${selectedTokenIds.size} 个Token吗\n\n删除后无法恢复!`)){return}if(!confirm(`⚠️ 最后确认\n\n这是最后一次确认!\n\n删除 ${selectedTokenIds.size} 个Token后将无法恢复。\n\n确定要执行删除操作吗?`)){return}showToast('正在删除选中的Token...','info');try{const r=await apiRequest('/api/tokens/batch/delete-selected',{method:'POST',body:JSON.stringify({token_ids:Array.from(selectedTokenIds)})});if(!r)return;const d=await r.json();if(d.success){selectedTokenIds.clear();await refreshTokens();showToast(d.message,'success')}else{showToast('删除失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('删除失败: '+e.message,'error')}},
toggleSelectAll=()=>{const checkbox=$('selectAllCheckbox');const checkboxes=document.querySelectorAll('.token-checkbox');if(checkbox.checked){checkboxes.forEach(cb=>{cb.checked=true;const tokenId=parseInt(cb.getAttribute('data-token-id'));selectedTokenIds.add(tokenId)})}else{checkboxes.forEach(cb=>{cb.checked=false});selectedTokenIds.clear()}},
toggleTokenSelection=(tokenId,checked)=>{if(checked){selectedTokenIds.add(tokenId)}else{selectedTokenIds.delete(tokenId)}const allCheckboxes=document.querySelectorAll('.token-checkbox');const allChecked=Array.from(allCheckboxes).every(cb=>cb.checked);$('selectAllCheckbox').checked=allChecked},
batchDisableSelected=async()=>{if(selectedTokenIds.size===0){showToast('请先选择要禁用的Token','info');return}if(!confirm(`确定要禁用选中的 ${selectedTokenIds.size} 个Token吗`)){return}showToast('正在批量禁用Token...','info');try{const r=await apiRequest('/api/tokens/batch/disable-selected',{method:'POST',body:JSON.stringify({token_ids:Array.from(selectedTokenIds)})});if(!r)return;const d=await r.json();if(d.success){selectedTokenIds.clear();await refreshTokens();showToast(d.message,'success')}else{showToast('批量禁用失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('批量禁用失败: '+e.message,'error')}},
updateImportModeHint=()=>{const mode=$('importMode').value,hint=$('importModeHint'),hints={at:'使用AT更新账号状态订阅信息、Sora2次数等',offline:'离线导入,不更新账号状态,动态字段显示为-',st:'自动将ST转换为AT然后更新账号状态',rt:'自动将RT转换为AT并刷新RT然后更新账号状态'};hint.textContent=hints[mode]||''},
submitImportTokens=async()=>{const fileInput=$('importFile');if(!fileInput.files||fileInput.files.length===0){showToast('请选择文件','error');return}const file=fileInput.files[0];if(!file.name.endsWith('.json')){showToast('请选择JSON文件','error');return}const mode=$('importMode').value;try{const fileContent=await file.text();const importData=JSON.parse(fileContent);if(!Array.isArray(importData)){showToast('JSON格式错误应为数组','error');return}if(importData.length===0){showToast('JSON文件为空','error');return}for(let item of importData){if(!item.email){showToast('导入数据缺少必填字段: email','error');return}if(mode==='offline'||mode==='at'){if(!item.access_token){showToast(`${item.email} 缺少必填字段: access_token`,'error');return}}else if(mode==='st'){if(!item.session_token){showToast(`${item.email} 缺少必填字段: session_token`,'error');return}}else if(mode==='rt'){if(!item.refresh_token){showToast(`${item.email} 缺少必填字段: refresh_token`,'error');return}}}const btn=$('importBtn'),btnText=$('importBtnText'),btnSpinner=$('importBtnSpinner');btn.disabled=true;btnText.textContent='导入中...';btnSpinner.classList.remove('hidden');try{const r=await apiRequest('/api/tokens/import',{method:'POST',body:JSON.stringify({tokens:importData,mode:mode})});if(!r){btn.disabled=false;btnText.textContent='导入';btnSpinner.classList.add('hidden');return}const d=await r.json();if(d.success){closeImportModal();await refreshTokens();showImportProgress(d.results||[],d.added||0,d.updated||0,d.failed||0)}else{showToast('导入失败: '+(d.detail||d.message||'未知错误'),'error')}}catch(e){showToast('导入失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='导入';btnSpinner.classList.add('hidden')}}catch(e){showToast('文件解析失败: '+e.message,'error')}},
loadAdminConfig=async()=>{try{const r=await apiRequest('/api/admin/config');if(!r)return;const d=await r.json();$('cfgErrorBan').value=d.error_ban_threshold||3;$('cfgAdminUsername').value=d.admin_username||'admin';$('cfgCurrentAPIKey').value=d.api_key||'';$('cfgDebugEnabled').checked=d.debug_enabled||false}catch(e){console.error('加载配置失败:',e)}},
saveAdminConfig=async()=>{try{const r=await apiRequest('/api/admin/config',{method:'POST',body:JSON.stringify({error_ban_threshold:parseInt($('cfgErrorBan').value)||3})});if(!r)return;const d=await r.json();d.success?showToast('配置保存成功','success'):showToast('保存失败','error')}catch(e){showToast('保存失败: '+e.message,'error')}},
updateImportModeHint=()=>{const mode=$('importMode').value,hint=$('importModeHint'),jsonSection=$('jsonFileSection'),pureRtSection=$('pureRtSection'),helpSection=$('importModeHelpSection'),hints={at:'使用AT更新账号状态订阅信息、Sora2次数等',offline:'离线导入,不更新账号状态,动态字段显示为-',st:'自动将ST转换为AT然后更新账号状态',rt:'自动将RT转换为AT并刷新RT然后更新账号状态',pure_rt:'手动输入RT列表自动刷新并批量导入'};hint.textContent=hints[mode]||'';if(mode==='pure_rt'){jsonSection.classList.add('hidden');pureRtSection.classList.remove('hidden');helpSection.classList.add('hidden')}else{jsonSection.classList.remove('hidden');pureRtSection.classList.add('hidden');helpSection.classList.remove('hidden')}},
submitImportTokens=async()=>{const mode=$('importMode').value;const btn=$('importBtn'),btnText=$('importBtnText'),btnSpinner=$('importBtnSpinner');if(mode==='pure_rt'){const rtInput=$('pureRtInput').value.trim();if(!rtInput){showToast('请输入 Refresh Token','error');return}const clientId=$('pureRtClientId').value.trim()||'app_LlGpXReQgckcGGUo2JrYvtJK';const proxy=$('pureRtProxy').value.trim()||null;const imageConcurrency=parseInt($('pureRtImageConcurrency').value)||1;const videoConcurrency=parseInt($('pureRtVideoConcurrency').value)||3;const rtList=rtInput.split('\n').map(rt=>rt.trim()).filter(rt=>rt.length>0);if(rtList.length===0){showToast('请输入至少一个 Refresh Token','error');return}btn.disabled=true;btnText.textContent='导入中...';btnSpinner.classList.remove('hidden');try{const r=await apiRequest('/api/tokens/import/pure-rt',{method:'POST',body:JSON.stringify({refresh_tokens:rtList,client_id:clientId,proxy_url:proxy,image_concurrency:imageConcurrency,video_concurrency:videoConcurrency})});if(!r){btn.disabled=false;btnText.textContent='导入';btnSpinner.classList.add('hidden');return}const d=await r.json();if(d.success){closeImportModal();await refreshTokens();showImportProgress(d.results||[],d.added||0,d.updated||0,d.failed||0)}else{showToast('导入失败: '+(d.detail||d.message||'未知错误'),'error')}}catch(e){showToast('导入失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='导入';btnSpinner.classList.add('hidden')}return}const fileInput=$('importFile');if(!fileInput.files||fileInput.files.length===0){showToast('请选择文件','error');return}const file=fileInput.files[0];if(!file.name.endsWith('.json')){showToast('请选择JSON文件','error');return}try{const fileContent=await file.text();const importData=JSON.parse(fileContent);if(!Array.isArray(importData)){showToast('JSON格式错误应为数组','error');return}if(importData.length===0){showToast('JSON文件为空','error');return}for(let item of importData){if(!item.email){showToast('导入数据缺少必填字段: email','error');return}if(mode==='offline'||mode==='at'){if(!item.access_token){showToast(`${item.email} 缺少必填字段: access_token`,'error');return}}else if(mode==='st'){if(!item.session_token){showToast(`${item.email} 缺少必填字段: session_token`,'error');return}}else if(mode==='rt'){if(!item.refresh_token){showToast(`${item.email} 缺少必填字段: refresh_token`,'error');return}}}btn.disabled=true;btnText.textContent='导入中...';btnSpinner.classList.remove('hidden');try{const r=await apiRequest('/api/tokens/import',{method:'POST',body:JSON.stringify({tokens:importData,mode:mode})});if(!r){btn.disabled=false;btnText.textContent='导入';btnSpinner.classList.add('hidden');return}const d=await r.json();if(d.success){closeImportModal();await refreshTokens();showImportProgress(d.results||[],d.added||0,d.updated||0,d.failed||0)}else{showToast('导入失败: '+(d.detail||d.message||'未知错误'),'error')}}catch(e){showToast('导入失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='导入';btnSpinner.classList.add('hidden')}}catch(e){showToast('文件解析失败: '+e.message,'error')}},
loadAdminConfig=async()=>{try{const r=await apiRequest('/api/admin/config');if(!r)return;const d=await r.json();$('cfgErrorBan').value=d.error_ban_threshold||3;$('cfgTaskRetryEnabled').checked=d.task_retry_enabled||false;$('cfgTaskMaxRetries').value=d.task_max_retries||3;$('cfgAutoDisableOn401').checked=d.auto_disable_on_401||false;$('cfgAdminUsername').value=d.admin_username||'admin';$('cfgCurrentAPIKey').value=d.api_key||'';$('cfgDebugEnabled').checked=d.debug_enabled||false}catch(e){console.error('加载配置失败:',e)}},
saveAdminConfig=async()=>{try{const r=await apiRequest('/api/admin/config',{method:'POST',body:JSON.stringify({error_ban_threshold:parseInt($('cfgErrorBan').value)||3,task_retry_enabled:$('cfgTaskRetryEnabled').checked,task_max_retries:parseInt($('cfgTaskMaxRetries').value)||3,auto_disable_on_401:$('cfgAutoDisableOn401').checked})});if(!r)return;const d=await r.json();d.success?showToast('配置保存成功','success'):showToast('保存失败','error')}catch(e){showToast('保存失败: '+e.message,'error')}},
updateAdminPassword=async()=>{const username=$('cfgAdminUsername').value.trim(),oldPwd=$('cfgOldPassword').value.trim(),newPwd=$('cfgNewPassword').value.trim();if(!oldPwd||!newPwd)return showToast('请输入旧密码和新密码','error');if(newPwd.length<4)return showToast('新密码至少4个字符','error');try{const r=await apiRequest('/api/admin/password',{method:'POST',body:JSON.stringify({username:username||undefined,old_password:oldPwd,new_password:newPwd})});if(!r)return;const d=await r.json();if(d.success){showToast('密码修改成功,请重新登录','success');setTimeout(()=>{localStorage.removeItem('adminToken');location.href='/login'},2000)}else{showToast('修改失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('修改失败: '+e.message,'error')}},
updateAPIKey=async()=>{const newKey=$('cfgNewAPIKey').value.trim();if(!newKey)return showToast('请输入新的 API Key','error');if(newKey.length<6)return showToast('API Key 至少6个字符','error');if(!confirm('确定要更新 API Key 吗?更新后需要通知所有客户端使用新密钥。'))return;try{const r=await apiRequest('/api/admin/apikey',{method:'POST',body:JSON.stringify({new_api_key:newKey})});if(!r)return;const d=await r.json();if(d.success){showToast('API Key 更新成功','success');$('cfgCurrentAPIKey').value=newKey;$('cfgNewAPIKey').value=''}else{showToast('更新失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('更新失败: '+e.message,'error')}},
toggleDebugMode=async()=>{const enabled=$('cfgDebugEnabled').checked;try{const r=await apiRequest('/api/admin/debug',{method:'POST',body:JSON.stringify({enabled:enabled})});if(!r)return;const d=await r.json();if(d.success){showToast(enabled?'调试模式已开启':'调试模式已关闭','success')}else{showToast('操作失败: '+(d.detail||'未知错误'),'error');$('cfgDebugEnabled').checked=!enabled}}catch(e){showToast('操作失败: '+e.message,'error');$('cfgDebugEnabled').checked=!enabled}},
downloadDebugLogs=async()=>{try{const token=localStorage.getItem('adminToken');if(!token){showToast('未登录','error');return}const r=await fetch('/api/admin/logs/download',{headers:{Authorization:`Bearer ${token}`}});if(!r.ok){if(r.status===404){showToast('日志文件不存在','error')}else{showToast('下载失败','error')}return}const blob=await r.blob();const url=URL.createObjectURL(blob);const link=document.createElement('a');link.href=url;link.download=`logs_${new Date().toISOString().split('T')[0]}.txt`;document.body.appendChild(link);link.click();document.body.removeChild(link);URL.revokeObjectURL(url);showToast('日志文件下载成功','success')}catch(e){showToast('下载失败: '+e.message,'error')}},
loadProxyConfig=async()=>{try{const r=await apiRequest('/api/proxy/config');if(!r)return;const d=await r.json();$('cfgProxyEnabled').checked=d.proxy_enabled||false;$('cfgProxyUrl').value=d.proxy_url||''}catch(e){console.error('加载代理配置失败:',e)}},
setProxyStatus=(msg,type='muted')=>{const el=$('proxyStatusMessage');if(!el)return;if(!msg){el.textContent='';el.classList.add('hidden');return}el.textContent=msg;el.classList.remove('hidden','text-muted-foreground','text-green-600','text-red-600');if(type==='success')el.classList.add('text-green-600');else if(type==='error')el.classList.add('text-red-600');else el.classList.add('text-muted-foreground')},
testProxyConfig=async()=>{const enabled=$('cfgProxyEnabled').checked;const url=$('cfgProxyUrl').value.trim();const testUrl=$('cfgProxyTestUrl').value.trim()||'https://sora.chatgpt.com';if(!enabled||!url){setProxyStatus('代理未启用或地址为空','error');return}try{setProxyStatus('正在测试代理连接...','muted');const r=await apiRequest('/api/proxy/test',{method:'POST',body:JSON.stringify({test_url:testUrl})});if(!r)return;const d=await r.json();if(d.success){setProxyStatus(`${d.message||'代理可用'} - 测试域名: ${d.test_url||testUrl}`,'success')}else{setProxyStatus(`${d.message||'代理不可用'} - 测试域名: ${d.test_url||testUrl}`,'error')}}catch(e){setProxyStatus('代理测试失败: '+e.message,'error')}},
saveProxyConfig=async()=>{try{const r=await apiRequest('/api/proxy/config',{method:'POST',body:JSON.stringify({proxy_enabled:$('cfgProxyEnabled').checked,proxy_url:$('cfgProxyUrl').value.trim()})});if(!r)return;const d=await r.json();d.success?showToast('代理配置保存成功','success'):showToast('保存失败','error')}catch(e){showToast('保存失败: '+e.message,'error')}},
loadWatermarkFreeConfig=async()=>{try{const r=await apiRequest('/api/watermark-free/config');if(!r)return;const d=await r.json();$('cfgWatermarkFreeEnabled').checked=d.watermark_free_enabled||false;$('cfgParseMethod').value=d.parse_method||'third_party';$('cfgCustomParseUrl').value=d.custom_parse_url||'';$('cfgCustomParseToken').value=d.custom_parse_token||'';toggleWatermarkFreeOptions();toggleCustomParseOptions()}catch(e){console.error('加载无水印模式配置失败:',e)}},
saveWatermarkFreeConfig=async()=>{try{const enabled=$('cfgWatermarkFreeEnabled').checked,parseMethod=$('cfgParseMethod').value,customUrl=$('cfgCustomParseUrl').value.trim(),customToken=$('cfgCustomParseToken').value.trim();if(enabled&&parseMethod==='custom'){if(!customUrl)return showToast('请输入解析服务器地址','error');if(!customToken)return showToast('请输入访问密钥','error')}const r=await apiRequest('/api/watermark-free/config',{method:'POST',body:JSON.stringify({watermark_free_enabled:enabled,parse_method:parseMethod,custom_parse_url:customUrl||null,custom_parse_token:customToken||null})});if(!r)return;const d=await r.json();d.success?showToast('无水印模式配置保存成功','success'):showToast('保存失败','error')}catch(e){showToast('保存失败: '+e.message,'error')}},
loadWatermarkFreeConfig=async()=>{try{const r=await apiRequest('/api/watermark-free/config');if(!r)return;const d=await r.json();$('cfgWatermarkFreeEnabled').checked=d.watermark_free_enabled||false;$('cfgParseMethod').value=d.parse_method||'third_party';$('cfgCustomParseUrl').value=d.custom_parse_url||'';$('cfgCustomParseToken').value=d.custom_parse_token||'';$('cfgFallbackOnFailure').checked=d.fallback_on_failure!==false;toggleWatermarkFreeOptions();toggleCustomParseOptions()}catch(e){console.error('加载无水印模式配置失败:',e)}},
saveWatermarkFreeConfig=async()=>{try{const enabled=$('cfgWatermarkFreeEnabled').checked,parseMethod=$('cfgParseMethod').value,customUrl=$('cfgCustomParseUrl').value.trim(),customToken=$('cfgCustomParseToken').value.trim(),fallbackOnFailure=$('cfgFallbackOnFailure').checked;if(enabled&&parseMethod==='custom'){if(!customUrl)return showToast('请输入解析服务器地址','error');if(!customToken)return showToast('请输入访问密钥','error')}const r=await apiRequest('/api/watermark-free/config',{method:'POST',body:JSON.stringify({watermark_free_enabled:enabled,parse_method:parseMethod,custom_parse_url:customUrl||null,custom_parse_token:customToken||null,fallback_on_failure:fallbackOnFailure})});if(!r)return;const d=await r.json();d.success?showToast('无水印模式配置保存成功','success'):showToast('保存失败','error')}catch(e){showToast('保存失败: '+e.message,'error')}},
toggleWatermarkFreeOptions=()=>{const enabled=$('cfgWatermarkFreeEnabled').checked;$('watermarkFreeOptions').style.display=enabled?'block':'none'},
toggleCustomParseOptions=()=>{const method=$('cfgParseMethod').value;$('customParseOptions').style.display=method==='custom'?'block':'none'},
toggleCacheOptions=()=>{const enabled=$('cfgCacheEnabled').checked;$('cacheOptions').style.display=enabled?'block':'none'},
@@ -956,9 +1130,9 @@
saveGenerationTimeout=async()=>{const imageTimeout=parseInt($('cfgImageTimeout').value)||300,videoTimeout=parseInt($('cfgVideoTimeout').value)||1500;console.log('保存生成超时配置:',{imageTimeout,videoTimeout});if(imageTimeout<60||imageTimeout>3600)return showToast('图片超时时间必须在 60-3600 秒之间','error');if(videoTimeout<60||videoTimeout>7200)return showToast('视频超时时间必须在 60-7200 秒之间','error');try{const r=await apiRequest('/api/generation/timeout',{method:'POST',body:JSON.stringify({image_timeout:imageTimeout,video_timeout:videoTimeout})});if(!r){console.error('保存请求失败');return}const d=await r.json();console.log('保存结果:',d);if(d.success){showToast('生成超时配置保存成功','success');await new Promise(r=>setTimeout(r,200));await loadGenerationTimeout()}else{console.error('保存失败:',d);showToast('保存失败','error')}}catch(e){console.error('保存失败:',e);showToast('保存失败: '+e.message,'error')}},
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=>{const isProcessing=l.status_code===-1;const statusText=isProcessing?'处理中':l.status_code;const statusClass=isProcessing?'bg-blue-50 text-blue-700':l.status_code===200?'bg-green-50 text-green-700':'bg-red-50 text-red-700';let progressHtml='<span class="text-xs text-muted-foreground">-</span>';if(isProcessing&&l.task_status){const taskStatusMap={processing:'生成中',completed:'已完成',failed:'失败'};const taskStatusText=taskStatusMap[l.task_status]||l.task_status;const progress=l.progress||0;progressHtml=`<div class="flex flex-col gap-1"><div class="flex items-center gap-2"><div class="flex-1 h-2 bg-gray-200 rounded-full overflow-hidden"><div class="h-full bg-blue-500 transition-all" style="width:${progress}%"></div></div><span class="text-xs text-blue-600">${progress.toFixed(0)}%</span></div><span class="text-xs text-muted-foreground">${taskStatusText}</span></div>`}let actionHtml='<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>';if(isProcessing&&l.task_id){actionHtml='<div class="flex gap-1"><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><button onclick="cancelTask(\''+l.task_id+'\')" class="inline-flex items-center justify-center rounded-md hover:bg-red-50 hover:text-red-700 h-7 px-2 text-xs">终止</button></div>'}return `<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 ${statusClass}">${statusText}</span></td><td class="py-2.5 px-3">${progressHtml}</td><td class="py-2.5 px-3">${l.duration===-1?'处理中':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">${actionHtml}</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;const tb=$('logsTableBody');tb.innerHTML=logs.map(l=>{const isProcessing=l.status_code===-1;const isFailed=l.task_status==='failed';const isCompleted=l.task_status==='completed';const progress=l.progress||0;const statusText=isProcessing?(progress>0?'生成中':'排队中'):l.status_code;const statusClass=isProcessing?'bg-blue-50 text-blue-700':l.status_code===200?'bg-green-50 text-green-700':'bg-red-50 text-red-700';let progressHtml='<span class="text-xs text-muted-foreground">-</span>';if(isProcessing&&l.task_status){const taskStatusMap={processing:'生成中',completed:'已完成',failed:'失败'};const taskStatusText=taskStatusMap[l.task_status]||l.task_status;const progress=l.progress||0;progressHtml=`<div class="flex flex-col gap-1"><div class="flex items-center gap-2"><div class="flex-1 h-2 bg-gray-200 rounded-full overflow-hidden"><div class="h-full bg-blue-500 transition-all" style="width:${progress}%"></div></div><span class="text-xs text-blue-600">${progress.toFixed(0)}%</span></div><span class="text-xs text-muted-foreground">${taskStatusText}</span></div>`}else if(isFailed){progressHtml='<span class="text-xs text-red-600">失败</span>'}else if(isCompleted&&l.status_code===200){progressHtml='<span class="text-xs text-green-600">已完成</span>'}let actionHtml='<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>';if(isProcessing&&l.task_id){actionHtml='<div class="flex gap-1"><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><button onclick="cancelTask(\''+l.task_id+'\')" class="inline-flex items-center justify-center rounded-md hover:bg-red-50 hover:text-red-700 h-7 px-2 text-xs">终止</button></div>'}return `<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 ${statusClass}">${statusText}</span></td><td class="py-2.5 px-3">${progressHtml}</td><td class="py-2.5 px-3">${l.duration===-1?'处理中':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">${actionHtml}</td></tr>`}).join('')}catch(e){console.error('加载日志失败:',e)}},
refreshLogs=async()=>{await loadLogs()},
showLogDetail=(logId)=>{const log=window.allLogs.find(l=>l.id===logId);if(!log){showToast('日志不存在','error');return}const content=$('logDetailContent');let detailHtml='';if(log.status_code===-1){detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm text-blue-600">生成进度</h4><div class="rounded-md border border-blue-200 p-3 bg-blue-50"><p class="text-sm text-blue-700">任务正在生成中...</p>${log.task_status?`<p class="text-xs text-blue-600 mt-1">状态: ${log.task_status}</p>`:''}</div></div>`}else if(log.status_code===200){try{const responseBody=log.response_body?JSON.parse(log.response_body):null;if(responseBody){if(responseBody.data&&responseBody.data.length>0){const item=responseBody.data[0];if(item.url){detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm">生成结果</h4><div class="rounded-md border border-border p-3 bg-muted/30"><p class="text-sm mb-2"><span class="font-medium">文件URL:</span></p><a href="${item.url}" target="_blank" class="text-blue-600 hover:underline text-xs break-all">${item.url}</a></div></div>`}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">${JSON.stringify(responseBody,null,2)}</pre></div>`}}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">${JSON.stringify(responseBody,null,2)}</pre></div>`}}else{detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm">响应信息</h4><p class="text-sm text-muted-foreground">无响应数据</p></div>`}}catch(e){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">${log.response_body||'无'}</pre></div>`}}else{try{const responseBody=log.response_body?JSON.parse(log.response_body):null;if(responseBody&&responseBody.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">${responseBody.error.message||responseBody.error||'未知错误'}</p></div></div>`}else if(log.response_body&&log.response_body!=='{}'){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">${log.response_body}</pre></div>`}}catch(e){if(log.response_body&&log.response_body!=='{}'){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">${log.response_body}</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> ${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===-1?'bg-blue-50 text-blue-700':log.status_code===200?'bg-green-50 text-green-700':'bg-red-50 text-red-700'}">${log.status_code===-1?'生成中':log.status_code}</span></div><div><span class="text-muted-foreground">耗时:</span> ${log.duration===-1?'生成中':log.duration.toFixed(2)+'秒'}</div><div><span class="text-muted-foreground">时间:</span> ${log.created_at?new Date(log.created_at).toLocaleString('zh-CN'):'-'}</div></div></div>`;content.innerHTML=detailHtml;$('logDetailModal').classList.remove('hidden')},
showLogDetail=(logId)=>{const log=window.allLogs.find(l=>l.id===logId);if(!log){showToast('日志不存在','error');return}const content=$('logDetailContent');let detailHtml='';if(log.status_code===-1){detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm text-blue-600">生成进度</h4><div class="rounded-md border border-blue-200 p-3 bg-blue-50"><p class="text-sm text-blue-700">任务正在生成中...</p>${log.task_status?`<p class="text-xs text-blue-600 mt-1">状态: ${log.task_status}</p>`:''}</div></div>`}else if(log.status_code===200){try{const responseBody=log.response_body?JSON.parse(log.response_body):null;if(responseBody){if(responseBody.data&&responseBody.data.length>0){const item=responseBody.data[0];if(item.url){detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm">生成结果</h4><div class="rounded-md border border-border p-3 bg-muted/30"><p class="text-sm mb-2"><span class="font-medium">文件URL:</span></p><a href="${item.url}" target="_blank" class="text-blue-600 hover:underline text-xs break-all">${item.url}</a></div></div>`}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">${JSON.stringify(responseBody,null,2)}</pre></div>`}}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">${JSON.stringify(responseBody,null,2)}</pre></div>`}}else{detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm">响应信息</h4><p class="text-sm text-muted-foreground">无响应数据</p></div>`}}catch(e){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">${log.response_body||'无'}</pre></div>`}}else{try{const responseBody=log.response_body?JSON.parse(log.response_body):null;if(responseBody&&responseBody.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">${responseBody.error.message||responseBody.error||'未知错误'}</p></div></div>`}else if(log.response_body&&log.response_body!=='{}'){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">${log.response_body}</pre></div>`}}catch(e){if(log.response_body&&log.response_body!=='{}'){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">${log.response_body}</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> ${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===-1?'bg-blue-50 text-blue-700':log.status_code===200?'bg-green-50 text-green-700':'bg-red-50 text-red-700'}">${log.status_code===-1?((log.progress||0)>0?'生成中':'排队中'):log.status_code}</span></div><div><span class="text-muted-foreground">耗时:</span> ${log.duration===-1?'生成中':log.duration.toFixed(2)+'秒'}</div><div><span class="text-muted-foreground">时间:</span> ${log.created_at?new Date(log.created_at).toLocaleString('zh-CN'):'-'}</div></div></div>`;content.innerHTML=detailHtml;$('logDetailModal').classList.remove('hidden')},
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){showToast('日志已清空','success');await loadLogs()}else{showToast('清空失败: '+(d.message||'未知错误'),'error')}}catch(e){showToast('清空失败: '+e.message,'error')}},
cancelTask=async(taskId)=>{if(!confirm('确定要终止这个任务吗?'))return;try{const r=await apiRequest(`/api/tasks/${taskId}/cancel`,{method:'POST'});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')}},
@@ -966,7 +1140,15 @@
logout=()=>{if(!confirm('确定要退出登录吗?'))return;localStorage.removeItem('adminToken');location.href='/login'},
loadCharacters=async()=>{try{const r=await apiRequest('/api/characters');if(!r)return;const d=await r.json();const g=$('charactersGrid');if(!d||d.length===0){g.innerHTML='<div class="col-span-full text-center py-8 text-muted-foreground">暂无角色卡</div>';return}g.innerHTML=d.map(c=>`<div class="rounded-lg border border-border bg-background p-4"><div class="flex items-start gap-3"><img src="${c.avatar_path||'/static/favicon.ico'}" class="h-14 w-14 rounded-lg object-cover" onerror="this.src='/static/favicon.ico'"/><div class="flex-1 min-w-0"><div class="font-semibold truncate">${c.display_name||c.username}</div><div class="text-xs text-muted-foreground truncate">@${c.username}</div>${c.description?`<div class="text-xs text-muted-foreground mt-1 line-clamp-2">${c.description}</div>`:''}</div></div><div class="mt-3 flex gap-2"><button onclick="deleteCharacter(${c.id})" class="flex-1 inline-flex items-center justify-center rounded-md border border-destructive text-destructive hover:bg-destructive hover:text-white h-8 px-3 text-sm transition-colors">删除</button></div></div>`).join('')}catch(e){showToast('加载失败: '+e.message,'error')}},
deleteCharacter=async(id)=>{if(!confirm('确定要删除这个角色卡吗?'))return;try{const r=await apiRequest(`/api/characters/${id}`,{method:'DELETE'});if(!r)return;const d=await r.json();if(d.success){showToast('删除成功','success');await loadCharacters()}else{showToast('删除失败','error')}}catch(e){showToast('删除失败: '+e.message,'error')}},
switchTab=t=>{const cap=n=>n.charAt(0).toUpperCase()+n.slice(1);['tokens','settings','logs','generate'].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();loadWatermarkFreeConfig();loadCacheConfig();loadGenerationTimeout();loadATAutoRefreshConfig()}else if(t==='logs'){loadLogs()}};
loadCallLogicConfig=async()=>{try{const r=await apiRequest('/api/call-logic/config');if(!r)return;const d=await r.json();if(d.success&&d.config){const mode=d.config.call_mode||((d.config.polling_mode_enabled||false)?'polling':'default');$('cfgCallLogicMode').value=mode}else{console.error('调用逻辑配置数据格式错误:',d)}}catch(e){console.error('加载调用逻辑配置失败:',e)}},
saveCallLogicConfig=async()=>{try{const mode=$('cfgCallLogicMode').value||'default';const r=await apiRequest('/api/call-logic/config',{method:'POST',body:JSON.stringify({call_mode:mode})});if(!r)return;const d=await r.json();if(d.success){showToast('调用逻辑配置保存成功','success')}else{showToast('保存失败','error')}}catch(e){showToast('保存失败: '+e.message,'error')}},
loadPowConfig=async()=>{try{const r=await apiRequest('/api/pow/config');if(!r)return;const d=await r.json();if(d.success&&d.config){$('cfgPowMode').value=d.config.mode||'local';$('cfgPowServerUrl').value=d.config.server_url||'';$('cfgPowApiKey').value=d.config.api_key||'';$('cfgPowProxyEnabled').checked=d.config.proxy_enabled||false;$('cfgPowProxyUrl').value=d.config.proxy_url||'';togglePowFields();togglePowProxyFields()}else{console.error('POW配置数据格式错误:',d)}}catch(e){console.error('加载POW配置失败:',e)}},
savePowConfig=async()=>{try{const mode=$('cfgPowMode').value;const serverUrl=$('cfgPowServerUrl').value.trim();const apiKey=$('cfgPowApiKey').value.trim();const proxyEnabled=$('cfgPowProxyEnabled').checked;const proxyUrl=$('cfgPowProxyUrl').value.trim();if(mode==='external'){if(!serverUrl)return showToast('请输入服务器地址','error');if(!apiKey)return showToast('请输入API密钥','error')}const r=await apiRequest('/api/pow/config',{method:'POST',body:JSON.stringify({mode:mode,server_url:serverUrl||null,api_key:apiKey||null,proxy_enabled:proxyEnabled,proxy_url:proxyUrl||null})});if(!r)return;const d=await r.json();if(d.success){showToast('POW配置保存成功','success')}else{showToast('保存失败','error')}}catch(e){showToast('保存失败: '+e.message,'error')}},
loadPowProxyConfig=loadPowConfig,savePowProxyConfig=savePowConfig,loadPowServiceConfig=loadPowConfig,savePowServiceConfig=savePowConfig,
togglePowFields=()=>{const mode=$('cfgPowMode').value;const externalFields=$('powExternalFields');if(externalFields){externalFields.style.display=mode==='external'?'block':'none'}},
togglePowProxyFields=()=>{const enabled=$('cfgPowProxyEnabled').checked;const proxyUrlField=$('powProxyUrlField');if(proxyUrlField){proxyUrlField.style.display=enabled?'block':'none'}},
togglePowServiceFields=()=>{togglePowFields()},
switchTab=t=>{const cap=n=>n.charAt(0).toUpperCase()+n.slice(1);['tokens','settings','logs','generate'].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();loadPowProxyConfig();loadPowServiceConfig();loadWatermarkFreeConfig();loadCacheConfig();loadGenerationTimeout();loadATAutoRefreshConfig();loadCallLogicConfig()}else if(t==='logs'){loadLogs()}};
// 自适应生成面板 iframe 高度
window.addEventListener('message', (event) => {
const data = event.data || {};
@@ -978,6 +1160,15 @@
}
}
});
// 点击页面其他地方关闭筛选下拉菜单
document.addEventListener('click', (e) => {
const dropdown = $('statusFilterDropdown');
const btn = $('statusFilterBtn');
if (!dropdown || !btn) return;
if (!dropdown.classList.contains('hidden') && !btn.contains(e.target) && !dropdown.contains(e.target)) {
dropdown.classList.add('hidden');
}
});
window.addEventListener('DOMContentLoaded',()=>{checkAuth();refreshTokens();loadATAutoRefreshConfig()});
</script>
</body>