Compare commits

..

28 Commits

Author SHA1 Message Date
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
TheSmallHanCat
eadce1a131 feat: 新增Token批量选择及批量禁用、优化请求日志进度输出、新增Token批量修改代理功能
refactor: 移除Sora2激活码相关功能
2026-01-16 12:08:40 +08:00
TheSmallHanCat
27ed2bd9a7 feat: 新增提示词增强模型、Token定时自动刷新、新增分页、新增任务终止及进度显示优化 2026-01-15 21:27:16 +08:00
TheSmallHanCat
c8b218fe9d feat: 优化风格参数提取逻辑 2026-01-13 20:30:36 +08:00
TheSmallHanCat
ac9fb944d6 Merge branch 'main' of https://github.com/TheSmallHanCat/sora2api 2026-01-11 15:40:11 +08:00
TheSmallHanCat
2d3aeff8df Merge pull request #57 from genz27/main
feat:调整剩余次数获取和视频生成请求UA,estimated_num_videos_remaining返回30
2026-01-11 15:39:48 +08:00
TheSmallHanCat
b23f60e66b feat: 新增Token批量管理功能(测试更新/批量启用/清理禁用) 2026-01-11 15:34:02 +08:00
TheSmallHanCat
fb0569c298 feat: 优化CF429错误处理及超时日志记录 2026-01-11 13:04:19 +08:00
genz27
ff25c88d3f feat:调整剩余次数获取和视频生成请求UA,estimated_num_videos_remaining返回30 2026-01-11 02:33:10 +08:00
TheSmallHanCat
ab6aee0dc5 Merge pull request #55 from genz27/feat/sentinel-token-pow
feat: 实现完整的 openai-sentinel-token PoW 验证机制
2026-01-10 16:44:18 +08:00
genz27
a15046a2ff feat: implement openai-sentinel-token PoW verification 2026-01-10 16:06:48 +08:00
19 changed files with 2963 additions and 341 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

@@ -119,6 +119,7 @@ python main.py
| 角色生成视频 | `sora2-*` | 使用 `content` 数组 + `video_url` + 文本 |
| Remix | `sora2-*` | 在 `content` 中包含 Remix ID |
| 视频分镜 | `sora2-*` | 在 `content` 中使用```[时长s]提示词```格式触发 |
| 提示词优化 | `prompt-enhance-*` | 将简单提示词扩展为详细的电影级提示词 |
---
@@ -175,6 +176,28 @@ python main.py
> **注意:** Pro 系列模型需要 ChatGPT Pro 订阅(`plan_type: "chatgpt_pro"`)。如果没有 Pro 账号,请求这些模型会返回错误。
**提示词优化模型**
将简单提示词扩展为详细的电影级提示词,包含场景设置、镜头运动、光影效果、分镜描述等。
| 模型 | 扩展级别 | 时长 | 说明 |
|------|---------|------|------|
| `prompt-enhance-short-10s` | 简短 | 10秒 | 生成简洁的增强提示词 |
| `prompt-enhance-short-15s` | 简短 | 15秒 | 生成简洁的增强提示词 |
| `prompt-enhance-short-20s` | 简短 | 20秒 | 生成简洁的增强提示词 |
| `prompt-enhance-medium-10s` | 中等 | 10秒 | 生成中等长度的增强提示词 |
| `prompt-enhance-medium-15s` | 中等 | 15秒 | 生成中等长度的增强提示词 |
| `prompt-enhance-medium-20s` | 中等 | 20秒 | 生成中等长度的增强提示词 |
| `prompt-enhance-long-10s` | 详细 | 10秒 | 生成详细的增强提示词 |
| `prompt-enhance-long-15s` | 详细 | 15秒 | 生成详细的增强提示词 |
| `prompt-enhance-long-20s` | 详细 | 20秒 | 生成详细的增强提示词 |
**特点:**
- 支持流式和非流式响应
- 自动生成包含PRIMARY、SETTING、LOOK、CAMERA、LIGHT等专业电影术语的提示词
- 包含详细的分镜描述(时间轴、镜头运动、焦点、光影)
- 可直接用于视频生成模型
#### 请求示例
**文生图**
@@ -224,6 +247,42 @@ curl -X POST "http://localhost:8000/v1/chat/completions" \
}'
```
**提示词优化(流式)**
```bash
curl -X POST "http://localhost:8000/v1/chat/completions" \
-H "Authorization: Bearer han1234" \
-H "Content-Type: application/json" \
-d '{
"model": "prompt-enhance-medium-10s",
"messages": [
{
"role": "user",
"content": "猫猫"
}
],
"stream": true
}'
```
**提示词优化(非流式)**
```bash
curl -X POST "http://localhost:8000/v1/chat/completions" \
-H "Authorization: Bearer han1234" \
-H "Content-Type: application/json" \
-d '{
"model": "prompt-enhance-long-15s",
"messages": [
{
"role": "user",
"content": "一只橘猫在窗台玩耍"
}
],
"stream": false
}'
```
**文生视频**
```bash
@@ -523,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,17 @@ 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

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

@@ -11,4 +11,6 @@ pydantic-settings==2.7.0
tomli==2.2.1
toml
faker==24.0.0
python-dateutil==2.8.2
python-dateutil==2.8.2
APScheduler==3.10.4
playwright==1.48.0

View File

@@ -6,6 +6,7 @@ from datetime import datetime
from pathlib import Path
import secrets
from pydantic import BaseModel
from apscheduler.triggers.cron import CronTrigger
from ..core.auth import AuthManager
from ..core.config import config
from ..services.token_manager import TokenManager
@@ -22,18 +23,20 @@ proxy_manager: ProxyManager = None
db: Database = None
generation_handler = None
concurrency_manager: ConcurrencyManager = None
scheduler = None
# Store active admin tokens (in production, use Redis or database)
active_admin_tokens = set()
def set_dependencies(tm: TokenManager, pm: ProxyManager, database: Database, gh=None, cm: ConcurrencyManager = None):
def set_dependencies(tm: TokenManager, pm: ProxyManager, database: Database, gh=None, cm: ConcurrencyManager = None, sched=None):
"""Set dependencies"""
global token_manager, proxy_manager, db, generation_handler, concurrency_manager
global token_manager, proxy_manager, db, generation_handler, concurrency_manager, scheduler
token_manager = tm
proxy_manager = pm
db = database
generation_handler = gh
concurrency_manager = cm
scheduler = sched
def verify_admin_token(authorization: str = Header(None)):
"""Verify admin token from Authorization header"""
@@ -69,8 +72,8 @@ class AddTokenRequest(BaseModel):
remark: Optional[str] = None
image_enabled: bool = True # Enable image generation
video_enabled: bool = True # Enable video generation
image_concurrency: int = -1 # Image concurrency limit (-1 for no limit)
video_concurrency: int = -1 # Video concurrency limit (-1 for no limit)
image_concurrency: int = 1 # Image concurrency limit (default: 1)
video_concurrency: int = 3 # Video concurrency limit (default: 3)
class ST2ATRequest(BaseModel):
st: str # Session Token
@@ -112,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
@@ -145,6 +161,22 @@ class UpdateWatermarkFreeConfigRequest(BaseModel):
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 BatchDisableRequest(BaseModel):
token_ids: List[int]
class BatchUpdateProxyRequest(BaseModel):
token_ids: List[int]
proxy_url: Optional[str] = None
# Auth endpoints
@router.post("/api/login", response_model=LoginResponse)
@@ -314,7 +346,7 @@ async def disable_token(token_id: int, token: str = Depends(verify_admin_token))
@router.post("/api/tokens/{token_id}/test")
async def test_token(token_id: int, token: str = Depends(verify_admin_token)):
"""Test if a token is valid and refresh Sora2 info"""
"""Test if a token is valid"""
try:
result = await token_manager.test_token(token_id)
response = {
@@ -325,16 +357,6 @@ async def test_token(token_id: int, token: str = Depends(verify_admin_token)):
"username": result.get("username")
}
# Include Sora2 info if available
if result.get("valid"):
response.update({
"sora2_supported": result.get("sora2_supported"),
"sora2_invite_code": result.get("sora2_invite_code"),
"sora2_redeemed_count": result.get("sora2_redeemed_count"),
"sora2_total_count": result.get("sora2_total_count"),
"sora2_remaining_count": result.get("sora2_remaining_count")
})
return response
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -348,6 +370,159 @@ async def delete_token(token_id: int, token: str = Depends(verify_admin_token)):
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/api/tokens/batch/test-update")
async def batch_test_update(request: BatchDisableRequest = None, token: str = Depends(verify_admin_token)):
"""Test and update selected tokens or all tokens by fetching their status from upstream"""
try:
if request and request.token_ids:
# Test only selected tokens
tokens = []
for token_id in request.token_ids:
token_obj = await db.get_token(token_id)
if token_obj:
tokens.append(token_obj)
else:
# Test all tokens (backward compatibility)
tokens = await db.get_all_tokens()
success_count = 0
failed_count = 0
results = []
for token_obj in tokens:
try:
# Test token and update account info (same as single test)
result = await token_manager.test_token(token_obj.id)
if result.get("valid"):
success_count += 1
results.append({"id": token_obj.id, "email": token_obj.email, "status": "success"})
else:
failed_count += 1
results.append({"id": token_obj.id, "email": token_obj.email, "status": "failed", "message": result.get("message")})
except Exception as e:
failed_count += 1
results.append({"id": token_obj.id, "email": token_obj.email, "status": "error", "message": str(e)})
return {
"success": True,
"message": f"测试完成:成功 {success_count} 个,失败 {failed_count}",
"success_count": success_count,
"failed_count": failed_count,
"results": results
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/tokens/batch/enable-all")
async def batch_enable_all(request: BatchDisableRequest = None, token: str = Depends(verify_admin_token)):
"""Enable selected tokens or all disabled tokens"""
try:
if request and request.token_ids:
# Enable only selected tokens
enabled_count = 0
for token_id in request.token_ids:
await token_manager.enable_token(token_id)
enabled_count += 1
else:
# Enable all disabled tokens (backward compatibility)
tokens = await db.get_all_tokens()
enabled_count = 0
for token_obj in tokens:
if not token_obj.is_active:
await token_manager.enable_token(token_obj.id)
enabled_count += 1
return {
"success": True,
"message": f"已启用 {enabled_count} 个Token",
"enabled_count": enabled_count
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/tokens/batch/delete-disabled")
async def batch_delete_disabled(request: BatchDisableRequest = None, token: str = Depends(verify_admin_token)):
"""Delete selected disabled tokens or all disabled tokens"""
try:
if request and request.token_ids:
# Delete only selected tokens that are disabled
deleted_count = 0
for token_id in request.token_ids:
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()
deleted_count = 0
for token_obj in tokens:
if not token_obj.is_active:
await token_manager.delete_token(token_obj.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/disable-selected")
async def batch_disable_selected(request: BatchDisableRequest, token: str = Depends(verify_admin_token)):
"""Disable selected tokens"""
try:
disabled_count = 0
for token_id in request.token_ids:
await token_manager.disable_token(token_id)
disabled_count += 1
return {
"success": True,
"message": f"已禁用 {disabled_count} 个Token",
"disabled_count": disabled_count
}
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"""
try:
updated_count = 0
for token_id in request.token_ids:
await token_manager.update_token(
token_id=token_id,
proxy_url=request.proxy_url
)
updated_count += 1
return {
"success": True,
"message": f"已更新 {updated_count} 个Token的代理",
"updated_count": updated_count
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/tokens/import")
async def import_tokens(request: ImportTokensRequest, token: str = Depends(verify_admin_token)):
"""Import tokens with different modes: offline/at/st/rt"""
@@ -498,6 +673,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,
@@ -537,6 +817,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
@@ -552,9 +835,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:
@@ -656,6 +947,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:
@@ -665,7 +1000,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")
@@ -679,7 +1015,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
@@ -725,72 +1062,20 @@ async def get_stats(token: str = Depends(verify_admin_token)):
"today_errors": today_errors
}
# Sora2 endpoints
@router.post("/api/tokens/{token_id}/sora2/activate")
async def activate_sora2(
token_id: int,
invite_code: str,
token: str = Depends(verify_admin_token)
):
"""Activate Sora2 with invite code"""
try:
# Get token
token_obj = await db.get_token(token_id)
if not token_obj:
raise HTTPException(status_code=404, detail="Token not found")
# Activate Sora2
result = await token_manager.activate_sora2_invite(token_obj.token, invite_code)
if result.get("success"):
# Get new invite code after activation
sora2_info = await token_manager.get_sora2_invite_code(token_obj.token, token_id)
# Get remaining count
sora2_remaining_count = 0
try:
remaining_info = await token_manager.get_sora2_remaining_count(token_obj.token, token_id)
if remaining_info.get("success"):
sora2_remaining_count = remaining_info.get("remaining_count", 0)
except Exception as e:
print(f"Failed to get Sora2 remaining count: {e}")
# Update database
await db.update_token_sora2(
token_id,
supported=True,
invite_code=sora2_info.get("invite_code"),
redeemed_count=sora2_info.get("redeemed_count", 0),
total_count=sora2_info.get("total_count", 0),
remaining_count=sora2_remaining_count
)
return {
"success": True,
"message": "Sora2 activated successfully",
"already_accepted": result.get("already_accepted", False),
"invite_code": sora2_info.get("invite_code"),
"redeemed_count": sora2_info.get("redeemed_count", 0),
"total_count": sora2_info.get("total_count", 0),
"sora2_remaining_count": sora2_remaining_count
}
else:
return {
"success": False,
"message": "Failed to activate Sora2"
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to activate Sora2: {str(e)}")
# Logs endpoints
@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"),
@@ -799,14 +1084,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
@@ -1020,6 +1305,24 @@ async def update_at_auto_refresh_enabled(
# Update database
await db.update_token_refresh_config(enabled)
# Dynamically start or stop scheduler
if scheduler:
if enabled:
# Start scheduler if not already running
if not scheduler.running:
scheduler.add_job(
token_manager.batch_refresh_all_tokens,
CronTrigger(hour=0, minute=0),
id='batch_refresh_tokens',
name='Batch refresh all tokens',
replace_existing=True
)
scheduler.start()
else:
# Stop scheduler if running
if scheduler.running:
scheduler.remove_job('batch_refresh_tokens')
return {
"success": True,
"message": f"AT auto refresh {'enabled' if enabled else 'disabled'} successfully",
@@ -1028,6 +1331,134 @@ 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
@router.get("/api/pow-proxy/config")
async def get_pow_proxy_config(token: str = Depends(verify_admin_token)) -> dict:
"""Get POW proxy configuration"""
config_obj = await db.get_pow_proxy_config()
return {
"success": True,
"config": {
"pow_proxy_enabled": config_obj.pow_proxy_enabled,
"pow_proxy_url": config_obj.pow_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"""
try:
await db.update_pow_proxy_config(request.pow_proxy_enabled, request.pow_proxy_url)
config.set_pow_proxy_enabled(request.pow_proxy_enabled)
config.set_pow_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)}")
# Task management endpoints
@router.post("/api/tasks/{task_id}/cancel")
async def cancel_task(task_id: str, token: str = Depends(verify_admin_token)):
"""Cancel a running task"""
try:
# Get task from database
task = await db.get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
# Check if task is still processing
if task.status not in ["processing"]:
return {"success": False, "message": f"任务状态为 {task.status},无法取消"}
# Update task status to failed
await db.update_task(task_id, "failed", 0, error_message="用户手动取消任务")
# Update request log if exists
logs = await db.get_recent_logs(limit=1000)
for log in logs:
if log.get("task_id") == task_id and log.get("status_code") == -1:
import 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": "用户手动取消任务"}',
status_code=499,
duration=duration
)
break
return {"success": True, "message": "任务已取消"}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"取消任务失败: {str(e)}")
# Debug logs download endpoint
@router.get("/api/admin/logs/download")
async def download_debug_logs(token: str = Depends(verify_admin_token)):

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()
@@ -46,21 +48,23 @@ def _extract_remix_id(text: str) -> str:
async def list_models(api_key: str = Depends(verify_api_key_header)):
"""List available models"""
models = []
for model_id, config in MODEL_CONFIG.items():
description = f"{config['type'].capitalize()} generation"
if config['type'] == 'image':
description += f" - {config['width']}x{config['height']}"
else:
elif config['type'] == 'video':
description += f" - {config['orientation']}"
elif config['type'] == 'prompt_enhance':
description += f" - {config['expansion_level']} ({config['duration_s']}s)"
models.append({
"id": model_id,
"object": "model",
"owned_by": "sora2api",
"description": description
})
return {
"object": "list",
"data": models
@@ -69,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")
@@ -140,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,
@@ -151,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,
@@ -216,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,
@@ -227,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,55 @@ 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"""
return self._config.get("pow_proxy", {}).get("pow_proxy_enabled", False)
def set_pow_proxy_enabled(self, enabled: bool):
"""Set POW proxy enabled/disabled"""
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"""
return self._config.get("pow_proxy", {}).get("pow_proxy_url", "")
def set_pow_proxy_url(self, url: str):
"""Set POW proxy URL"""
if "pow_proxy" not in self._config:
self._config["pow_proxy"] = {}
self._config["pow_proxy"]["pow_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,50 @@ 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))
async def check_and_migrate_db(self, config_dict: dict = None):
"""Check database integrity and perform migrations if needed
@@ -245,6 +297,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:
@@ -377,6 +430,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 +456,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 +495,28 @@ 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 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 +532,18 @@ 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")
await db.commit()
async def init_config_from_toml(self, config_dict: dict, is_first_startup: bool = True):
@@ -917,7 +1008,17 @@ class Database:
query = f"UPDATE request_logs SET {', '.join(updates)} WHERE id = ?"
await db.execute(query, params)
await db.commit()
async def update_request_log_task_id(self, log_id: int, task_id: str):
"""Update request log with task_id"""
async with aiosqlite.connect(self.db_path) as db:
await db.execute("""
UPDATE request_logs
SET task_id = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""", (task_id, log_id))
await db.commit()
async def get_recent_logs(self, limit: int = 100) -> List[dict]:
"""Get recent logs with token email"""
async with aiosqlite.connect(self.db_path) as db:
@@ -967,9 +1068,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
@@ -1009,10 +1113,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
@@ -1024,9 +1129,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
@@ -1131,3 +1237,52 @@ 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 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()

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:

View File

@@ -66,6 +66,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 +90,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 +110,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 +138,22 @@ 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
# API Request/Response models
class ChatMessage(BaseModel):
role: str

View File

@@ -5,6 +5,9 @@ from fastapi.responses import FileResponse, HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from pathlib import Path
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from datetime import datetime
# Import modules
from .core.config import config
@@ -18,6 +21,9 @@ from .services.concurrency_manager import ConcurrencyManager
from .api import routes as api_routes
from .api import admin as admin_routes
# Initialize scheduler (uses system local timezone by default)
scheduler = AsyncIOScheduler()
# Initialize FastAPI app
app = FastAPI(
title="Sora2API",
@@ -45,7 +51,7 @@ generation_handler = GenerationHandler(sora_client, token_manager, load_balancer
# Set dependencies for route modules
api_routes.set_generation_handler(generation_handler)
admin_routes.set_dependencies(token_manager, proxy_manager, db, generation_handler, concurrency_manager)
admin_routes.set_dependencies(token_manager, proxy_manager, db, generation_handler, concurrency_manager, scheduler)
# Include routers
app.include_router(api_routes.router)
@@ -133,6 +139,11 @@ 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}")
# Initialize concurrency manager with all tokens
all_tokens = await db.get_all_tokens()
await concurrency_manager.initialize(all_tokens)
@@ -141,10 +152,26 @@ async def startup_event():
# Start file cache cleanup task
await generation_handler.file_cache.start_cleanup_task()
# Start token refresh scheduler if enabled
if token_refresh_config.at_auto_refresh_enabled:
scheduler.add_job(
token_manager.batch_refresh_all_tokens,
CronTrigger(hour=0, minute=0), # Every day at 00:00 (system local timezone)
id='batch_refresh_tokens',
name='Batch refresh all tokens',
replace_existing=True
)
scheduler.start()
print("✓ Token auto-refresh scheduler started (daily at 00:00)")
else:
print("⊘ Token auto-refresh is disabled")
@app.on_event("shutdown")
async def shutdown_event():
"""Cleanup on shutdown"""
await generation_handler.file_cache.stop_cleanup_task()
if scheduler.running:
scheduler.shutdown()
if __name__ == "__main__":
uvicorn.run(

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": {
@@ -154,6 +161,52 @@ MODEL_CONFIG = {
"model": "sy_ore",
"size": "large",
"require_pro": True
},
# Prompt enhancement models
"prompt-enhance-short-10s": {
"type": "prompt_enhance",
"expansion_level": "short",
"duration_s": 10
},
"prompt-enhance-short-15s": {
"type": "prompt_enhance",
"expansion_level": "short",
"duration_s": 15
},
"prompt-enhance-short-20s": {
"type": "prompt_enhance",
"expansion_level": "short",
"duration_s": 20
},
"prompt-enhance-medium-10s": {
"type": "prompt_enhance",
"expansion_level": "medium",
"duration_s": 10
},
"prompt-enhance-medium-15s": {
"type": "prompt_enhance",
"expansion_level": "medium",
"duration_s": 15
},
"prompt-enhance-medium-20s": {
"type": "prompt_enhance",
"expansion_level": "medium",
"duration_s": 20
},
"prompt-enhance-long-10s": {
"type": "prompt_enhance",
"expansion_level": "long",
"duration_s": 10
},
"prompt-enhance-long-15s": {
"type": "prompt_enhance",
"expansion_level": "long",
"duration_s": 15
},
"prompt-enhance-long-20s": {
"type": "prompt_enhance",
"expansion_level": "long",
"duration_s": 20
}
}
@@ -196,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
@@ -264,16 +337,30 @@ class GenerationHandler:
Returns:
Tuple of (cleaned_prompt, style_id)
"""
# Valid style IDs
VALID_STYLES = {
"festive", "kakalaka", "news", "selfie", "handheld",
"golden", "anime", "retro", "nostalgic", "comic"
}
# Extract {style} pattern
match = re.search(r'\{([^}]+)\}', prompt)
if match:
style_id = match.group(1).strip()
# Remove {style} from prompt
cleaned_prompt = re.sub(r'\{[^}]+\}', '', prompt).strip()
# Clean up extra whitespace
cleaned_prompt = ' '.join(cleaned_prompt.split())
debug_logger.log_info(f"Extracted style: '{style_id}' from prompt: '{prompt}'")
return cleaned_prompt, style_id
style_candidate = match.group(1).strip()
# Check if it's a single word (no spaces) and in valid styles list
if ' ' not in style_candidate and style_candidate.lower() in VALID_STYLES:
# Valid style found - remove {style} from prompt
cleaned_prompt = re.sub(r'\{[^}]+\}', '', prompt).strip()
# Clean up extra whitespace
cleaned_prompt = ' '.join(cleaned_prompt.split())
debug_logger.log_info(f"Extracted style: '{style_candidate}' from prompt: '{prompt}'")
return cleaned_prompt, style_candidate.lower()
else:
# Not a valid style - treat as normal prompt
debug_logger.log_info(f"'{style_candidate}' is not a valid style (contains spaces or not in style list), treating as normal prompt")
return prompt, None
return prompt, None
async def _download_file(self, url: str) -> bytes:
@@ -320,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:
@@ -330,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
@@ -342,6 +431,13 @@ class GenerationHandler:
model_config = MODEL_CONFIG[model]
is_video = model_config["type"] == "video"
is_image = model_config["type"] == "image"
is_prompt_enhance = model_config["type"] == "prompt_enhance"
# Handle prompt enhancement
if is_prompt_enhance:
async for chunk in self._handle_prompt_enhance(prompt, model_config, stream):
yield chunk
return
# Non-streaming mode: only check availability
if not stream:
@@ -424,8 +520,22 @@ 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
# This ensures the log is created even if upstream fails
log_id = await self._log_request(
token_obj.id,
f"generate_{model_config['type']}",
{"model": model, "prompt": prompt, "has_image": image is not None},
{}, # Empty response initially
-1, # -1 means in-progress
-1.0, # -1.0 means in-progress
task_id=None # Will be updated after task submission
)
# Upload image if provided
media_id = None
if image:
@@ -445,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",
@@ -506,7 +616,7 @@ class GenerationHandler:
media_id=media_id,
token_id=token_obj.id
)
# Save task to database
task = Task(
task_id=task_id,
@@ -518,22 +628,15 @@ class GenerationHandler:
)
await self.db.create_task(task)
# Create initial log entry (status_code=-1, duration=-1.0 means in-progress)
log_id = await self._log_request(
token_obj.id,
f"generate_{model_config['type']}",
{"model": model, "prompt": prompt, "has_image": image is not None},
{}, # Empty response initially
-1, # -1 means in-progress
-1.0, # -1.0 means in-progress
task_id=task_id
)
# Update log entry with task_id now that we have it
if log_id:
await self.db.update_request_log_task_id(log_id, task_id)
# Record usage
await self.token_manager.record_usage(token_obj.id, is_video=is_video)
# Poll for results with timeout
async for chunk in self._poll_task_result(task_id, token_obj.token, is_video, stream, prompt, token_obj.id):
async for chunk in self._poll_task_result(task_id, token_obj.token, is_video, stream, prompt, token_obj.id, log_id, start_time):
yield chunk
# Record success
@@ -578,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
@@ -591,12 +695,6 @@ class GenerationHandler:
if is_video and token_obj and self.concurrency_manager:
await self.concurrency_manager.release_video(token_obj.id)
# Record error (check if it's an overload error)
if token_obj:
error_str = str(e).lower()
is_overload = "heavy_load" in error_str or "under heavy load" in error_str
await self.token_manager.record_error(token_obj.id, is_overload=is_overload)
# Parse error message to check if it's a structured error (JSON)
error_response = None
try:
@@ -604,17 +702,34 @@ class GenerationHandler:
except:
pass
# Check for CF shield/429 error
is_cf_or_429 = False
if error_response and isinstance(error_response, dict):
error_info = error_response.get("error", {})
if error_info.get("code") == "cf_shield_429":
is_cf_or_429 = True
# Record error (check if it's an overload error or CF/429 error)
if token_obj:
error_str = str(e).lower()
is_overload = "heavy_load" in error_str or "under heavy load" in error_str
# Don't record error for CF shield/429 (not token's fault)
if not is_cf_or_429:
await self.token_manager.record_error(token_obj.id, is_overload=is_overload)
# Update log entry with error data
duration = time.time() - start_time
if log_id:
if error_response:
# Structured error (e.g., unsupported_country_code)
# Structured error (e.g., unsupported_country_code, cf_shield_429)
status_code = 429 if is_cf_or_429 else 400
await self.db.update_request_log(
log_id,
response_body=json.dumps(error_response),
status_code=400,
status_code=status_code,
duration=duration
)
log_updated = True # Mark log as updated
else:
# Generic error
await self.db.update_request_log(
@@ -623,10 +738,127 @@ 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) -> AsyncGenerator[str, None]:
stream: bool, prompt: str, token_id: int = None,
log_id: int = None, start_time: float = None) -> AsyncGenerator[str, None]:
"""Poll for task result with timeout"""
# Get timeout from config
timeout = config.video_timeout if is_video else config.image_timeout
@@ -669,7 +901,19 @@ class GenerationHandler:
await self.concurrency_manager.release_video(token_id)
debug_logger.log_info(f"Released concurrency slot for token {token_id} due to timeout")
# Update task status to failed
await self.db.update_task(task_id, "failed", 0, error_message=f"Generation timeout after {elapsed_time:.1f} seconds")
# Update request log with timeout error
if log_id and start_time:
duration = time.time() - start_time
await self.db.update_request_log(
log_id,
response_body=json.dumps({"error": f"Generation timeout after {elapsed_time:.1f} seconds"}),
status_code=408,
duration=duration
)
raise Exception(f"Upstream API timeout: Generation exceeded {timeout} seconds limit")
@@ -697,6 +941,9 @@ class GenerationHandler:
last_progress = progress_pct
status = task.get("status", "processing")
# Update database with current progress
await self.db.update_task(task_id, "processing", progress_pct)
# Output status every 30 seconds (not just when progress changes)
current_time = time.time()
if stream and (current_time - last_status_output_time >= video_status_interval):
@@ -765,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")
@@ -869,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(
@@ -1057,6 +1328,61 @@ class GenerationHandler:
)
except Exception as e:
# Check for CF shield/429 error - don't retry these
error_str = str(e)
is_cf_or_429 = False
try:
error_response = json.loads(error_str)
if isinstance(error_response, dict):
error_info = error_response.get("error", {})
if error_info.get("code") == "cf_shield_429":
is_cf_or_429 = True
except (json.JSONDecodeError, ValueError):
pass
# CF shield/429 detected - fail immediately
if is_cf_or_429:
debug_logger.log_error(
error_message="CF Shield/429 detected during polling, failing task immediately",
status_code=429,
response_text=error_str
)
# Update task status to failed
await self.db.update_task(task_id, "failed", 0, error_message="Cloudflare challenge or rate limit (429) triggered")
# Update request log with CF/429 error
if log_id and start_time:
duration = time.time() - start_time
await self.db.update_request_log(
log_id,
response_body=json.dumps({"error": "Cloudflare challenge or rate limit (429) triggered"}),
status_code=429,
duration=duration
)
# Release resources
if not is_video and token_id:
await self.load_balancer.token_lock.release_lock(token_id)
if self.concurrency_manager:
await self.concurrency_manager.release_image(token_id)
if is_video and token_id and self.concurrency_manager:
await self.concurrency_manager.release_video(token_id)
# Send error message to client if streaming
if stream:
yield self._format_stream_chunk(
reasoning_content="**CF Shield/429 Error**\\n\\nCloudflare challenge or rate limit (429) triggered\\n"
)
yield self._format_stream_chunk(
content="❌ Generation failed: Cloudflare challenge or rate limit (429) triggered. Please change proxy or reduce request frequency.",
finish_reason="STOP"
)
yield "data: [DONE]\\n\\n"
# Exit polling immediately
return
# For other errors, retry if not last attempt
if attempt >= max_attempts - 1:
raise e
continue
@@ -1183,6 +1509,60 @@ class GenerationHandler:
print(f"Failed to log request: {e}")
return None
# ==================== Prompt Enhancement Handler ====================
async def _handle_prompt_enhance(self, prompt: str, model_config: Dict, stream: bool) -> AsyncGenerator[str, None]:
"""Handle prompt enhancement request
Args:
prompt: Original prompt to enhance
model_config: Model configuration
stream: Whether to stream response
"""
expansion_level = model_config["expansion_level"]
duration_s = model_config["duration_s"]
# Select token
token_obj = await self.load_balancer.select_token(for_video_generation=True)
if not token_obj:
error_msg = "No available tokens for prompt enhancement"
if stream:
yield self._format_stream_chunk(reasoning_content=f"**Error:** {error_msg}", is_first=True)
yield self._format_stream_chunk(finish_reason="STOP")
else:
yield self._format_non_stream_response(error_msg)
return
try:
# Call enhance_prompt API
enhanced_prompt = await self.sora_client.enhance_prompt(
prompt=prompt,
token=token_obj.token,
expansion_level=expansion_level,
duration_s=duration_s,
token_id=token_obj.id
)
if stream:
# Stream response
yield self._format_stream_chunk(
content=enhanced_prompt,
is_first=True
)
yield self._format_stream_chunk(finish_reason="STOP")
else:
# Non-stream response
yield self._format_non_stream_response(enhanced_prompt)
except Exception as e:
error_msg = f"Prompt enhancement failed: {str(e)}"
debug_logger.log_error(error_msg)
if stream:
yield self._format_stream_chunk(content=f"Error: {error_msg}", is_first=True)
yield self._format_stream_chunk(finish_reason="STOP")
else:
yield self._format_non_stream_response(error_msg)
# ==================== Character Creation and Remix Handlers ====================
async def _handle_character_creation_only(self, video_data, model_config: Dict) -> AsyncGenerator[str, None]:
@@ -1315,6 +1695,20 @@ class GenerationHandler:
yield "data: [DONE]\n\n"
except Exception as e:
# Parse error to check for CF shield/429
error_response = None
try:
error_response = json.loads(str(e))
except:
pass
# Check for CF shield/429 error
is_cf_or_429 = False
if error_response and isinstance(error_response, dict):
error_info = error_response.get("error", {})
if error_info.get("code") == "cf_shield_429":
is_cf_or_429 = True
# Log failed character creation
duration = time.time() - start_time
await self._log_request(
@@ -1328,13 +1722,21 @@ class GenerationHandler:
"success": False,
"error": str(e)
},
status_code=500,
status_code=429 if is_cf_or_429 else 500,
duration=duration
)
# Record error (check if it's an overload error or CF/429 error)
if token_obj:
error_str = str(e).lower()
is_overload = "heavy_load" in error_str or "under heavy load" in error_str
# Don't record error for CF shield/429 (not token's fault)
if not is_cf_or_429:
await self.token_manager.record_error(token_obj.id, is_overload=is_overload)
debug_logger.log_error(
error_message=f"Character creation failed: {str(e)}",
status_code=500,
status_code=429 if is_cf_or_429 else 500,
response_text=str(e)
)
raise
@@ -1531,14 +1933,30 @@ class GenerationHandler:
duration=duration
)
# Record error (check if it's an overload error)
# Parse error to check for CF shield/429
error_response = None
try:
error_response = json.loads(str(e))
except:
pass
# Check for CF shield/429 error
is_cf_or_429 = False
if error_response and isinstance(error_response, dict):
error_info = error_response.get("error", {})
if error_info.get("code") == "cf_shield_429":
is_cf_or_429 = True
# Record error (check if it's an overload error or CF/429 error)
if token_obj:
error_str = str(e).lower()
is_overload = "heavy_load" in error_str or "under heavy load" in error_str
await self.token_manager.record_error(token_obj.id, is_overload=is_overload)
# Don't record error for CF shield/429 (not token's fault)
if not is_cf_or_429:
await self.token_manager.record_error(token_obj.id, is_overload=is_overload)
debug_logger.log_error(
error_message=f"Character and video generation failed: {str(e)}",
status_code=500,
status_code=429 if is_cf_or_429 else 500,
response_text=str(e)
)
raise
@@ -1624,14 +2042,30 @@ class GenerationHandler:
await self.token_manager.record_success(token_obj.id, is_video=True)
except Exception as e:
# Record error (check if it's an overload error)
# Parse error to check for CF shield/429
error_response = None
try:
error_response = json.loads(str(e))
except:
pass
# Check for CF shield/429 error
is_cf_or_429 = False
if error_response and isinstance(error_response, dict):
error_info = error_response.get("error", {})
if error_info.get("code") == "cf_shield_429":
is_cf_or_429 = True
# Record error (check if it's an overload error or CF/429 error)
if token_obj:
error_str = str(e).lower()
is_overload = "heavy_load" in error_str or "under heavy load" in error_str
await self.token_manager.record_error(token_obj.id, is_overload=is_overload)
# Don't record error for CF shield/429 (not token's fault)
if not is_cf_or_429:
await self.token_manager.record_error(token_obj.id, is_overload=is_overload)
debug_logger.log_error(
error_message=f"Remix generation failed: {str(e)}",
status_code=500,
status_code=429 if is_cf_or_429 else 500,
response_text=str(e)
)
raise

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]:
"""
@@ -29,29 +56,6 @@ class LoadBalancer:
Returns:
Selected token or None if no available tokens
"""
# Try to auto-refresh tokens expiring within 24 hours if enabled
if config.at_auto_refresh_enabled:
debug_logger.log_info(f"[LOAD_BALANCER] 🔄 自动刷新功能已启用开始检查Token过期时间...")
all_tokens = await self.token_manager.get_all_tokens()
debug_logger.log_info(f"[LOAD_BALANCER] 📊 总Token数: {len(all_tokens)}")
refresh_count = 0
for token in all_tokens:
if token.is_active and token.expiry_time:
from datetime import datetime
time_until_expiry = token.expiry_time - datetime.now()
hours_until_expiry = time_until_expiry.total_seconds() / 3600
# Refresh if expiry is within 24 hours
if hours_until_expiry <= 24:
debug_logger.log_info(f"[LOAD_BALANCER] 🔔 Token {token.id} ({token.email}) 需要刷新,剩余时间: {hours_until_expiry:.2f} 小时")
refresh_count += 1
await self.token_manager.auto_refresh_expiring_token(token.id)
if refresh_count == 0:
debug_logger.log_info(f"[LOAD_BALANCER] ✅ 所有Token都无需刷新")
else:
debug_logger.log_info(f"[LOAD_BALANCER] ✅ 刷新检查完成,共检查 {refresh_count} 个Token")
active_tokens = await self.token_manager.get_active_tokens()
if not active_tokens:
@@ -112,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:
@@ -123,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

@@ -1,35 +1,738 @@
"""Sora API client module"""
import asyncio
import base64
import hashlib
import json
import io
import time
import random
import string
import re
from datetime import datetime, timedelta, timezone
from typing import Optional, Dict, Any, Tuple
from uuid import uuid4
from urllib.request import Request, urlopen, build_opener, ProxyHandler
from urllib.error import HTTPError, URLError
from curl_cffi.requests import AsyncSession
from curl_cffi import CurlMime
from .proxy_manager import ProxyManager
from ..core.config import config
from ..core.logger import debug_logger
try:
from playwright.async_api import async_playwright
PLAYWRIGHT_AVAILABLE = True
except ImportError:
PLAYWRIGHT_AVAILABLE = False
# Global browser instance for reuse (lightweight Playwright approach)
_browser = None
_playwright = None
_current_proxy = None
# Sentinel token cache
_cached_sentinel_token = None
_cached_device_id = None
async def _get_browser(proxy_url: str = None):
"""Get or create browser instance (reuses existing browser)"""
global _browser, _playwright, _current_proxy
# If proxy changed, restart browser
if _browser is not None and _current_proxy != proxy_url:
await _browser.close()
_browser = None
if _browser is None:
_playwright = await async_playwright().start()
launch_args = {
'headless': True,
'args': [
'--no-sandbox',
'--disable-gpu',
'--disable-dev-shm-usage',
'--disable-extensions',
'--disable-plugins',
'--disable-images',
'--disable-default-apps',
'--disable-sync',
'--disable-translate',
'--disable-background-networking',
'--disable-software-rasterizer',
]
}
if proxy_url:
launch_args['proxy'] = {'server': proxy_url}
_browser = await _playwright.chromium.launch(**launch_args)
_current_proxy = proxy_url
return _browser
async def _close_browser():
"""Close browser instance"""
global _browser, _playwright
if _browser:
await _browser.close()
_browser = None
if _playwright:
await _playwright.stop()
_playwright = None
async def _fetch_oai_did(proxy_url: str = None, max_retries: int = 3) -> str:
"""Fetch oai-did using curl_cffi (lightweight approach)
Raises:
Exception: If 403 or 429 response received
"""
debug_logger.log_info(f"[Sentinel] Fetching oai-did...")
for attempt in range(max_retries):
try:
async with AsyncSession(impersonate="chrome120") as session:
response = await session.get(
"https://chatgpt.com/",
proxy=proxy_url,
timeout=30,
allow_redirects=True
)
# Check for 403/429 errors - don't retry, just fail
if response.status_code == 403:
raise Exception("403 Forbidden - Access denied when fetching oai-did")
if response.status_code == 429:
raise Exception("429 Too Many Requests - Rate limited when fetching oai-did")
oai_did = response.cookies.get("oai-did")
if oai_did:
debug_logger.log_info(f"[Sentinel] oai-did: {oai_did}")
return oai_did
set_cookie = response.headers.get("set-cookie", "")
match = re.search(r'oai-did=([a-f0-9-]{36})', set_cookie)
if match:
oai_did = match.group(1)
debug_logger.log_info(f"[Sentinel] oai-did: {oai_did}")
return oai_did
except Exception as e:
error_str = str(e)
# Re-raise 403/429 errors immediately
if "403" in error_str or "429" in error_str:
raise
debug_logger.log_info(f"[Sentinel] oai-did fetch failed: {e}")
if attempt < max_retries - 1:
await asyncio.sleep(2)
return None
async def _generate_sentinel_token_lightweight(proxy_url: str = None, device_id: str = None) -> str:
"""Generate sentinel token using lightweight Playwright approach
Uses route interception and SDK injection for minimal resource usage.
Reuses browser instance across calls.
Args:
proxy_url: Optional proxy URL
device_id: Optional pre-fetched oai-did
Returns:
Sentinel token string or None on failure
Raises:
Exception: If 403/429 when fetching oai-did
"""
global _cached_device_id
if not PLAYWRIGHT_AVAILABLE:
debug_logger.log_info("[Sentinel] Playwright not available")
return None
# Get oai-did
if not device_id:
device_id = await _fetch_oai_did(proxy_url)
if not device_id:
debug_logger.log_info("[Sentinel] Failed to get oai-did")
return None
_cached_device_id = device_id
debug_logger.log_info(f"[Sentinel] Starting browser...")
browser = await _get_browser(proxy_url)
context = await browser.new_context(
viewport={'width': 800, 'height': 600},
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
bypass_csp=True
)
# Set cookie
await context.add_cookies([{
'name': 'oai-did',
'value': device_id,
'domain': 'sora.chatgpt.com',
'path': '/'
}])
page = await context.new_page()
# Route interception - inject SDK
inject_html = '''<!DOCTYPE html><html><head><script src="https://chatgpt.com/backend-api/sentinel/sdk.js"></script></head><body></body></html>'''
async def handle_route(route):
url = route.request.url
if "__sentinel__" in url:
await route.fulfill(status=200, content_type="text/html", body=inject_html)
elif "/sentinel/" in url or "chatgpt.com" in url:
await route.continue_()
else:
await route.abort()
await page.route("**/*", handle_route)
debug_logger.log_info(f"[Sentinel] Loading SDK...")
try:
# Load SDK via hack (must be under sora.chatgpt.com domain)
await page.goto("https://sora.chatgpt.com/__sentinel__", wait_until="load", timeout=30000)
# Wait for SDK to load
await page.wait_for_function("typeof SentinelSDK !== 'undefined' && typeof SentinelSDK.token === 'function'", timeout=15000)
debug_logger.log_info(f"[Sentinel] Getting token...")
# Call SDK
token = await page.evaluate(f'''
async () => {{
try {{
return await SentinelSDK.token('sora_2_create_task', '{device_id}');
}} catch (e) {{
return 'ERROR: ' + e.message;
}}
}}
''')
if token and not token.startswith('ERROR'):
debug_logger.log_info(f"[Sentinel] Token obtained successfully")
return token
else:
debug_logger.log_info(f"[Sentinel] Token error: {token}")
return None
except Exception as e:
debug_logger.log_info(f"[Sentinel] Error: {e}")
return None
finally:
await context.close()
async def _get_cached_sentinel_token(proxy_url: str = None, force_refresh: bool = False) -> str:
"""Get sentinel token with caching support
Args:
proxy_url: Optional proxy URL
force_refresh: Force refresh token (e.g., after 400 error)
Returns:
Sentinel token string or None
Raises:
Exception: If 403/429 when fetching oai-did
"""
global _cached_sentinel_token
# Return cached token if available and not forcing refresh
if _cached_sentinel_token and not force_refresh:
debug_logger.log_info("[Sentinel] Using cached token")
return _cached_sentinel_token
# Generate new token
debug_logger.log_info("[Sentinel] Generating new token...")
token = await _generate_sentinel_token_lightweight(proxy_url)
if token:
_cached_sentinel_token = token
debug_logger.log_info("[Sentinel] Token cached successfully")
return token
def _invalidate_sentinel_cache():
"""Invalidate cached sentinel token (call after 400 error)"""
global _cached_sentinel_token
_cached_sentinel_token = None
debug_logger.log_info("[Sentinel] Cache invalidated")
# PoW related constants
POW_MAX_ITERATION = 500000
POW_CORES = [4, 8, 12, 16, 24, 32]
POW_SCREEN_SIZES = [1266, 1536, 1920, 2560, 3000, 3072, 3120, 3840]
POW_SCRIPTS = [
"https://sora-cdn.oaistatic.com/_next/static/chunks/polyfills-42372ed130431b0a.js",
"https://sora-cdn.oaistatic.com/_next/static/chunks/6974-eaafbe7db9c73c96.js",
"https://sora-cdn.oaistatic.com/_next/static/chunks/main-app-5f0c58611778fb36.js",
"https://chatgpt.com/backend-api/sentinel/sdk.js",
]
POW_NAVIGATOR_KEYS = [
"mimeTypes[object MimeTypeArray]",
"userAgentData[object NavigatorUAData]",
"scheduling[object Scheduling]",
"keyboard[object Keyboard]",
"webkitPersistentStorage[object DeprecatedStorageQuota]",
"registerProtocolHandlerfunction registerProtocolHandler() { [native code] }",
"storage[object StorageManager]",
"locks[object LockManager]",
"appCodeNameMozilla",
"permissions[object Permissions]",
"webdriverfalse",
"vendorGoogle Inc.",
"mediaDevices[object MediaDevices]",
"cookieEnabledtrue",
"productGecko",
"productSub20030107",
"hardwareConcurrency32",
"onLinetrue",
]
POW_DOCUMENT_KEYS = [
"__reactContainer$3k0e9yog4o3",
"__reactContainer$ft149nhgior",
"__reactResources$9nnifsagitb",
"_reactListeningou2wvttp2d9",
"_reactListeningu9qurgpwsme",
"_reactListeningo743lnnpvdg",
"location",
"body",
]
POW_WINDOW_KEYS = [
"getSelection",
"btoa",
"__next_s",
"crossOriginIsolated",
"print",
"0", "window", "self", "document", "name", "location",
"navigator", "screen", "innerWidth", "innerHeight",
"localStorage", "sessionStorage", "crypto", "performance",
]
POW_LANGUAGES = [
("zh-CN", "zh-CN,zh"),
("en-US", "en-US,en"),
("ja-JP", "ja-JP,ja,en"),
("ko-KR", "ko-KR,ko,en"),
]
# User-Agent pools
DESKTOP_USER_AGENTS = [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 11.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
]
MOBILE_USER_AGENTS = [
"Sora/1.2026.007 (Android 15; 24122RKC7C; build 2600700)",
"Sora/1.2026.007 (Android 14; SM-G998B; build 2600700)",
"Sora/1.2026.007 (Android 15; Pixel 8 Pro; build 2600700)",
"Sora/1.2026.007 (Android 14; Pixel 7; build 2600700)",
"Sora/1.2026.007 (Android 15; 2211133C; build 2600700)",
"Sora/1.2026.007 (Android 14; SM-S918B; build 2600700)",
"Sora/1.2026.007 (Android 15; OnePlus 12; build 2600700)",
]
class SoraClient:
"""Sora API client with proxy support"""
CHATGPT_BASE_URL = "https://chatgpt.com"
SENTINEL_FLOW = "sora_2_create_task__auto"
def __init__(self, proxy_manager: ProxyManager):
self.proxy_manager = proxy_manager
self.base_url = config.sora_base_url
self.timeout = config.sora_timeout
@staticmethod
def _generate_sentinel_token() -> str:
def _get_pow_parse_time() -> str:
"""Generate time string for PoW (local timezone)"""
now = datetime.now()
# Get local timezone offset (seconds)
if time.daylight and time.localtime().tm_isdst > 0:
utc_offset_seconds = -time.altzone
else:
utc_offset_seconds = -time.timezone
# Format as +0800 or -0500
offset_hours = utc_offset_seconds // 3600
offset_minutes = abs(utc_offset_seconds % 3600) // 60
offset_sign = '+' if offset_hours >= 0 else '-'
offset_str = f"{offset_sign}{abs(offset_hours):02d}{offset_minutes:02d}"
# Get timezone name
tz_name = time.tzname[1] if time.daylight and time.localtime().tm_isdst > 0 else time.tzname[0]
return now.strftime("%a %b %d %Y %H:%M:%S") + f" GMT{offset_str} ({tz_name})"
@staticmethod
def _get_pow_config(user_agent: str) -> list:
"""Generate PoW config array with browser fingerprint"""
lang = random.choice(POW_LANGUAGES)
perf_time = random.uniform(10000, 100000)
return [
random.choice(POW_SCREEN_SIZES), # [0] screen size
SoraClient._get_pow_parse_time(), # [1] time string (local timezone)
random.choice([4294967296, 4294705152, 2147483648]), # [2] jsHeapSizeLimit
0, # [3] iteration count (dynamic)
user_agent, # [4] UA
random.choice(POW_SCRIPTS) if POW_SCRIPTS else "", # [5] sora cdn script
None, # [6] must be null
lang[0], # [7] language
lang[1], # [8] languages
random.randint(2, 10), # [9] random initial value for dynamic calc
random.choice(POW_NAVIGATOR_KEYS), # [10] navigator key
random.choice(POW_DOCUMENT_KEYS), # [11] document key
random.choice(POW_WINDOW_KEYS), # [12] window key
perf_time, # [13] perf time (random)
str(uuid4()), # [14] UUID
"", # [15] empty
random.choice(POW_CORES), # [16] cores
time.time() * 1000 - perf_time, # [17] time origin
]
@staticmethod
def _solve_pow(seed: str, difficulty: str, config_list: list) -> Tuple[str, bool]:
"""Execute PoW calculation using SHA3-512 hash collision"""
diff_len = len(difficulty) // 2
seed_encoded = seed.encode()
target_diff = bytes.fromhex(difficulty)
static_part1 = (json.dumps(config_list[:3], separators=(',', ':'), ensure_ascii=False)[:-1] + ',').encode()
static_part2 = (',' + json.dumps(config_list[4:9], separators=(',', ':'), ensure_ascii=False)[1:-1] + ',').encode()
static_part3 = (',' + json.dumps(config_list[10:], separators=(',', ':'), ensure_ascii=False)[1:]).encode()
initial_j = config_list[9]
for i in range(POW_MAX_ITERATION):
dynamic_i = str(i).encode()
dynamic_j = str(initial_j + (i + 29) // 30).encode()
final_json = static_part1 + dynamic_i + static_part2 + dynamic_j + static_part3
b64_encoded = base64.b64encode(final_json)
hash_value = hashlib.sha3_512(seed_encoded + b64_encoded).digest()
if hash_value[:diff_len] <= target_diff:
return b64_encoded.decode(), True
error_token = "wQ8Lk5FbGpA2NcR9dShT6gYjU7VxZ4D" + base64.b64encode(f'"{seed}"'.encode()).decode()
return error_token, False
@staticmethod
def _get_pow_token(user_agent: str) -> str:
"""Generate initial PoW token"""
config_list = SoraClient._get_pow_config(user_agent)
seed = format(random.random())
difficulty = "0fffff"
solution, _ = SoraClient._solve_pow(seed, difficulty, config_list)
return "gAAAAAC" + solution
@staticmethod
def _build_sentinel_token(
flow: str,
req_id: str,
pow_token: str,
resp: Dict[str, Any],
user_agent: str,
) -> str:
"""Build openai-sentinel-token from PoW response"""
final_pow_token = pow_token
# Check if PoW is required
proofofwork = resp.get("proofofwork", {})
if proofofwork.get("required"):
seed = proofofwork.get("seed", "")
difficulty = proofofwork.get("difficulty", "")
if seed and difficulty:
config_list = SoraClient._get_pow_config(user_agent)
solution, success = SoraClient._solve_pow(seed, difficulty, config_list)
final_pow_token = "gAAAAAB" + solution
if not success:
debug_logger.log_info("[Warning] PoW calculation failed, using error token")
if not final_pow_token.endswith("~S"):
final_pow_token = final_pow_token + "~S"
token_payload = {
"p": final_pow_token,
"t": resp.get("turnstile", {}).get("dx", ""),
"c": resp.get("token", ""),
"id": req_id,
"flow": flow,
}
return json.dumps(token_payload, ensure_ascii=False, separators=(",", ":"))
@staticmethod
def _post_json_sync(url: str, headers: dict, payload: dict, timeout: int, proxy: Optional[str]) -> Dict[str, Any]:
data = json.dumps(payload).encode("utf-8")
req = Request(url, data=data, headers=headers, method="POST")
try:
if proxy:
opener = build_opener(ProxyHandler({"http": proxy, "https": proxy}))
resp = opener.open(req, timeout=timeout)
else:
resp = urlopen(req, timeout=timeout)
resp_text = resp.read().decode("utf-8")
if resp.status not in (200, 201):
raise Exception(f"Request failed: {resp.status} {resp_text}")
return json.loads(resp_text)
except HTTPError as exc:
body = exc.read().decode("utf-8", errors="ignore")
raise Exception(f"HTTP Error: {exc.code} {body}") from exc
except URLError as exc:
raise Exception(f"URL Error: {exc}") from exc
async def _get_sentinel_token_via_browser(self, proxy_url: Optional[str] = None) -> Optional[str]:
if not PLAYWRIGHT_AVAILABLE:
debug_logger.log_info("[Warning] Playwright not available, cannot use browser fallback")
return None
try:
async with async_playwright() as p:
launch_args = {
"headless": True,
"args": ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"]
}
if proxy_url:
launch_args["proxy"] = {"server": proxy_url}
browser = await p.chromium.launch(**launch_args)
context = await browser.new_context(
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
)
page = await context.new_page()
debug_logger.log_info(f"[Browser] Navigating to sora.chatgpt.com...")
await page.goto("https://sora.chatgpt.com", wait_until="domcontentloaded", timeout=90000)
cookies = await context.cookies()
device_id = None
for cookie in cookies:
if cookie.get("name") == "oai-did":
device_id = cookie.get("value")
break
if not device_id:
device_id = str(uuid4())
debug_logger.log_info(f"[Browser] No oai-did cookie, generated: {device_id}")
else:
debug_logger.log_info(f"[Browser] Got oai-did from cookie: {device_id}")
debug_logger.log_info(f"[Browser] Waiting for SentinelSDK...")
for _ in range(120):
try:
sdk_ready = await page.evaluate("() => typeof window.SentinelSDK !== 'undefined'")
if sdk_ready:
break
except:
pass
await asyncio.sleep(0.5)
else:
debug_logger.log_info("[Browser] SentinelSDK load timeout")
await browser.close()
return None
debug_logger.log_info(f"[Browser] SentinelSDK ready, getting token...")
# 尝试获取 token最多重试 3 次
for attempt in range(3):
debug_logger.log_info(f"[Browser] Getting token, attempt {attempt + 1}/3...")
try:
token = await page.evaluate(
"(deviceId) => window.SentinelSDK.token('sora_2_create_task__auto', deviceId)",
device_id
)
if token:
debug_logger.log_info(f"[Browser] Token obtained successfully")
await browser.close()
if isinstance(token, str):
token_data = json.loads(token)
else:
token_data = token
if "id" not in token_data or not token_data.get("id"):
token_data["id"] = device_id
return json.dumps(token_data, ensure_ascii=False, separators=(",", ":"))
else:
debug_logger.log_info(f"[Browser] Token is empty")
except Exception as e:
debug_logger.log_info(f"[Browser] Token exception: {str(e)}")
if attempt < 2:
await asyncio.sleep(2)
await browser.close()
return None
except Exception as e:
debug_logger.log_error(
error_message=f"Browser sentinel token failed: {str(e)}",
status_code=0,
response_text=str(e),
source="Server"
)
return None
async def _nf_create_urllib(self, token: str, payload: dict, sentinel_token: str,
proxy_url: Optional[str], token_id: Optional[int] = None,
user_agent: Optional[str] = None) -> Dict[str, Any]:
"""Make nf/create request
Returns:
Response dict on success
Raises:
Exception: With error info, including '400' in message for sentinel token errors
"""
生成 openai-sentinel-token
根据测试文件的逻辑,传入任意随机字符即可
生成10-20个字符的随机字符串字母+数字)
"""
length = random.randint(10, 20)
random_str = ''.join(random.choices(string.ascii_letters + string.digits, k=length))
return random_str
url = f"{self.base_url}/nf/create"
if not user_agent:
user_agent = random.choice(DESKTOP_USER_AGENTS)
import json as json_mod
sentinel_data = json_mod.loads(sentinel_token)
device_id = sentinel_data.get("id", str(uuid4()))
headers = {
"Authorization": f"Bearer {token}",
"OpenAI-Sentinel-Token": sentinel_token,
"Content-Type": "application/json",
"User-Agent": user_agent,
"OAI-Language": "en-US",
"OAI-Device-Id": device_id,
}
try:
result = await asyncio.to_thread(
self._post_json_sync, url, headers, payload, 30, proxy_url
)
return result
except Exception as e:
error_str = str(e)
debug_logger.log_error(
error_message=f"nf/create request failed: {error_str}",
status_code=0,
response_text=error_str,
source="Server"
)
raise
@staticmethod
def _post_text_sync(url: str, headers: dict, body: str, timeout: int, proxy: Optional[str]) -> Dict[str, Any]:
data = body.encode("utf-8")
req = Request(url, data=data, headers=headers, method="POST")
try:
if proxy:
opener = build_opener(ProxyHandler({"http": proxy, "https": proxy}))
resp = opener.open(req, timeout=timeout)
else:
resp = urlopen(req, timeout=timeout)
resp_text = resp.read().decode("utf-8")
if resp.status not in (200, 201):
raise Exception(f"Request failed: {resp.status} {resp_text}")
return json.loads(resp_text)
except HTTPError as exc:
body_text = exc.read().decode("utf-8", errors="ignore")
raise Exception(f"HTTP Error: {exc.code} {body_text}") from exc
except URLError as exc:
raise Exception(f"URL Error: {exc}") from exc
async def _generate_sentinel_token(self, token: Optional[str] = None, user_agent: Optional[str] = None) -> Tuple[str, str]:
"""Generate openai-sentinel-token by calling /backend-api/sentinel/req and solving PoW"""
req_id = str(uuid4())
if not user_agent:
user_agent = "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36"
pow_token = self._get_pow_token(user_agent)
init_payload = {
"p": pow_token,
"id": req_id,
"flow": "sora_init"
}
ua_with_pow = f"{user_agent} {json.dumps(init_payload, separators=(',', ':'))}"
proxy_url = await self.proxy_manager.get_proxy_url()
# Request sentinel/req endpoint
url = f"{self.CHATGPT_BASE_URL}/backend-api/sentinel/req"
request_payload = {
"p": pow_token,
"id": req_id,
"flow": "sora_init"
}
request_body = json.dumps(request_payload, separators=(',', ':'))
headers = {
"Accept": "*/*",
"Content-Type": "text/plain;charset=UTF-8",
"Origin": "https://chatgpt.com",
"Referer": "https://chatgpt.com/backend-api/sentinel/frame.html",
"User-Agent": ua_with_pow,
"sec-ch-ua": '"Not(A:Brand";v="8", "Chromium";v="131", "Google Chrome";v="131"',
"sec-ch-ua-mobile": "?1",
"sec-ch-ua-platform": '"Android"',
}
try:
async with AsyncSession(impersonate="chrome131") as session:
response = await session.post(
url,
headers=headers,
data=request_body,
proxy=proxy_url,
timeout=10
)
if response.status_code != 200:
raise Exception(f"Sentinel request failed: {response.status_code} {response.text}")
resp = response.json()
debug_logger.log_info(f"Sentinel response: turnstile.dx={bool(resp.get('turnstile', {}).get('dx'))}, token={bool(resp.get('token'))}, pow_required={resp.get('proofofwork', {}).get('required')}")
except Exception as e:
debug_logger.log_error(
error_message=f"Sentinel request failed: {str(e)}",
status_code=0,
response_text=str(e),
source="Server"
)
raise
# Build final sentinel token
sentinel_token = self._build_sentinel_token(
self.SENTINEL_FLOW, req_id, pow_token, resp, user_agent
)
# Log final token for debugging
parsed = json.loads(sentinel_token)
debug_logger.log_info(f"Final sentinel: p_prefix={parsed['p'][:10]}, p_suffix={parsed['p'][-5:]}, t_len={len(parsed['t'])}, c_len={len(parsed['c'])}, flow={parsed['flow']}")
return sentinel_token, user_agent
@staticmethod
def is_storyboard_prompt(prompt: str) -> bool:
@@ -112,12 +815,15 @@ class SoraClient:
proxy_url = await self.proxy_manager.get_proxy_url(token_id)
headers = {
"Authorization": f"Bearer {token}"
"Authorization": f"Bearer {token}",
"User-Agent" : "Sora/1.2026.007 (Android 15; 24122RKC7C; build 2600700)"
}
# 只在生成请求时添加 sentinel token
if add_sentinel_token:
headers["openai-sentinel-token"] = self._generate_sentinel_token()
sentinel_token, ua = await self._generate_sentinel_token(token)
headers["openai-sentinel-token"] = sentinel_token
headers["User-Agent"] = ua
if not multipart:
headers["Content-Type"] = "application/json"
@@ -147,7 +853,8 @@ class SoraClient:
headers=headers,
body=json_data,
files=multipart,
proxy=proxy_url
proxy=proxy_url,
source="Server"
)
# Record start time
@@ -175,7 +882,8 @@ class SoraClient:
status_code=response.status_code,
headers=dict(response.headers),
body=response_json if response_json else response.text,
duration_ms=duration_ms
duration_ms=duration_ms,
source="Server"
)
# Check status
@@ -197,7 +905,8 @@ class SoraClient:
debug_logger.log_error(
error_message=f"Unsupported country: {error_msg}",
status_code=response.status_code,
response_text=error_msg
response_text=error_msg,
source="Server"
)
# Raise exception with structured error data
raise Exception(error_msg)
@@ -207,7 +916,8 @@ class SoraClient:
debug_logger.log_error(
error_message=error_msg,
status_code=response.status_code,
response_text=response.text
response_text=response.text,
source="Server"
)
raise Exception(error_msg)
@@ -312,9 +1022,70 @@ class SoraClient:
"style_id": style_id
}
# 生成请求需要添加 sentinel token
result = await self._make_request("POST", "/nf/create", token, json_data=json_data, add_sentinel_token=True, token_id=token_id)
return result["id"]
proxy_url = await self.proxy_manager.get_proxy_url(token_id)
# Get POW proxy from configuration
pow_proxy_url = None
if config.pow_proxy_enabled:
pow_proxy_url = config.pow_proxy_url or None
user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
# Try to get cached sentinel token first (using lightweight Playwright approach)
try:
sentinel_token = await _get_cached_sentinel_token(pow_proxy_url, force_refresh=False)
except Exception as e:
# 403/429 errors from oai-did fetch - don't retry, just fail
error_str = str(e)
if "403" in error_str or "429" in error_str:
debug_logger.log_error(
error_message=f"Failed to get sentinel token: {error_str}",
status_code=403 if "403" in error_str else 429,
response_text=error_str,
source="Server"
)
raise
sentinel_token = None
if not sentinel_token:
# Fallback to manual POW if lightweight approach fails
debug_logger.log_info("[Warning] Lightweight sentinel token failed, falling back to manual POW")
sentinel_token, user_agent = await self._generate_sentinel_token(token)
# First attempt with cached/generated token
try:
result = await self._nf_create_urllib(token, json_data, sentinel_token, proxy_url, token_id, user_agent)
return result["id"]
except Exception as e:
error_str = str(e)
# Check if it's a 400 error (sentinel token invalid)
if "400" in error_str or "sentinel" in error_str.lower() or "invalid" in error_str.lower():
debug_logger.log_info("[Sentinel] Got 400 error, refreshing token and retrying...")
# Invalidate cache and get fresh token
_invalidate_sentinel_cache()
try:
sentinel_token = await _get_cached_sentinel_token(pow_proxy_url, force_refresh=True)
except Exception as refresh_e:
# 403/429 errors - don't continue
error_str = str(refresh_e)
if "403" in error_str or "429" in error_str:
raise refresh_e
sentinel_token = None
if not sentinel_token:
# Fallback to manual POW
debug_logger.log_info("[Warning] Refresh failed, falling back to manual POW")
sentinel_token, user_agent = await self._generate_sentinel_token(token)
# Retry with fresh token
result = await self._nf_create_urllib(token, json_data, sentinel_token, proxy_url, token_id, user_agent)
return result["id"]
# For other errors, just re-raise
raise
async def get_image_tasks(self, token: str, limit: int = 20, token_id: Optional[int] = None) -> Dict[str, Any]:
"""Get recent image generation tasks"""
@@ -413,7 +1184,8 @@ class SoraClient:
status_code=response.status_code,
headers=dict(response.headers),
body=response.text if response.text else "No content",
duration_ms=duration_ms
duration_ms=duration_ms,
source="Server"
)
# Check status (DELETE typically returns 204 No Content or 200 OK)
@@ -422,7 +1194,8 @@ class SoraClient:
debug_logger.log_error(
error_message=error_msg,
status_code=response.status_code,
response_text=response.text
response_text=response.text,
source="Server"
)
raise Exception(error_msg)
@@ -478,7 +1251,8 @@ class SoraClient:
status_code=response.status_code,
headers=dict(response.headers),
body=response.text if response.text else "No content",
duration_ms=duration_ms
duration_ms=duration_ms,
source="Server"
)
# Check status
@@ -487,7 +1261,8 @@ class SoraClient:
debug_logger.log_error(
error_message=error_msg,
status_code=response.status_code,
response_text=response.text
response_text=response.text,
source="Server"
)
raise Exception(error_msg)
@@ -500,7 +1275,8 @@ class SoraClient:
debug_logger.log_error(
error_message=error_msg,
status_code=401,
response_text=str(result)
response_text=str(result),
source="Server"
)
raise Exception(error_msg)
@@ -516,7 +1292,8 @@ class SoraClient:
debug_logger.log_error(
error_message=f"Custom parse request failed: {str(e)}",
status_code=500,
response_text=str(e)
response_text=str(e),
source="Server"
)
raise
@@ -714,7 +1491,10 @@ class SoraClient:
"style_id": style_id
}
result = await self._make_request("POST", "/nf/create", token, json_data=json_data, add_sentinel_token=True)
# Generate sentinel token and call /nf/create using urllib
proxy_url = await self.proxy_manager.get_proxy_url()
sentinel_token, user_agent = await self._generate_sentinel_token(token)
result = await self._nf_create_urllib(token, json_data, sentinel_token, proxy_url, user_agent=user_agent)
return result.get("id")
async def generate_storyboard(self, prompt: str, token: str, orientation: str = "landscape",
@@ -761,3 +1541,26 @@ class SoraClient:
result = await self._make_request("POST", "/nf/create/storyboard", token, json_data=json_data, add_sentinel_token=True)
return result.get("id")
async def enhance_prompt(self, prompt: str, token: str, expansion_level: str = "medium",
duration_s: int = 10, token_id: Optional[int] = None) -> str:
"""Enhance prompt using Sora's prompt enhancement API
Args:
prompt: Original prompt to enhance
token: Access token
expansion_level: Expansion level (medium/long)
duration_s: Duration in seconds (10/15/20)
token_id: Token ID for getting token-specific proxy (optional)
Returns:
Enhanced prompt text
"""
json_data = {
"prompt": prompt,
"expansion_level": expansion_level,
"duration_s": duration_s
}
result = await self._make_request("POST", "/editor/enhance_prompt", token, json_data=json_data, token_id=token_id)
return result.get("enhanced_prompt", "")

View File

@@ -289,7 +289,8 @@ class TokenManager:
async with AsyncSession() as session:
headers = {
"Authorization": f"Bearer {access_token}",
"Accept": "application/json"
"Accept": "application/json",
"User-Agent" : "Sora/1.2026.007 (Android 15; 24122RKC7C; build 2600700)"
}
kwargs = {
@@ -1184,7 +1185,7 @@ class TokenManager:
if token_data.st:
try:
debug_logger.log_info(f"[AUTO_REFRESH] 📝 Token {token_id}: 尝试使用 ST 刷新...")
result = await self.st_to_at(token_data.st)
result = await self.st_to_at(token_data.st, proxy_url=token_data.proxy_url)
new_at = result.get("access_token")
new_st = token_data.st # ST refresh doesn't return new ST, so keep the old one
refresh_method = "ST"
@@ -1197,7 +1198,7 @@ class TokenManager:
if not new_at and token_data.rt:
try:
debug_logger.log_info(f"[AUTO_REFRESH] 📝 Token {token_id}: 尝试使用 RT 刷新...")
result = await self.rt_to_at(token_data.rt, client_id=token_data.client_id)
result = await self.rt_to_at(token_data.rt, client_id=token_data.client_id, proxy_url=token_data.proxy_url)
new_at = result.get("access_token")
new_rt = result.get("refresh_token", token_data.rt) # RT might be updated
refresh_method = "RT"
@@ -1224,18 +1225,80 @@ class TokenManager:
# 📍 Step 9: 检查刷新后的过期时间
if new_hours_until_expiry < 0:
# 刷新后仍然过期禁用Token
debug_logger.log_info(f"[AUTO_REFRESH] 🔴 Token {token_id}: 刷新后仍然过期(剩余时间: {new_hours_until_expiry:.2f} 小时),禁用")
await self.disable_token(token_id)
# 刷新后仍然过期,标记为已失效并禁用Token
debug_logger.log_info(f"[AUTO_REFRESH] 🔴 Token {token_id}: 刷新后仍然过期(剩余时间: {new_hours_until_expiry:.2f} 小时),标记为已失效并禁用")
await self.db.mark_token_expired(token_id)
await self.db.update_token_status(token_id, False)
return False
return True
else:
# 刷新失败: 禁用Token
debug_logger.log_info(f"[AUTO_REFRESH] 🚫 Token {token_id}: 无法刷新(无有效的 ST 或 RT禁用")
await self.disable_token(token_id)
# 刷新失败: 标记为已失效并禁用Token
debug_logger.log_info(f"[AUTO_REFRESH] 🚫 Token {token_id}: 无法刷新(无有效的 ST 或 RT标记为已失效并禁用")
await self.db.mark_token_expired(token_id)
await self.db.update_token_status(token_id, False)
return False
except Exception as e:
debug_logger.log_info(f"[AUTO_REFRESH] 🔴 Token {token_id}: 自动刷新异常 - {str(e)}")
return False
async def batch_refresh_all_tokens(self) -> dict:
"""
Batch refresh all tokens (called by scheduled task at midnight)
Returns:
dict with success/failed/skipped counts
"""
debug_logger.log_info("[BATCH_REFRESH] 🔄 开始批量刷新所有Token...")
# Get all tokens
all_tokens = await self.db.get_all_tokens()
success_count = 0
failed_count = 0
skipped_count = 0
for token in all_tokens:
# Skip tokens without ST or RT
if not token.st and not token.rt:
debug_logger.log_info(f"[BATCH_REFRESH] ⏭️ Token {token.id} ({token.email}): 无ST或RT跳过")
skipped_count += 1
continue
# Skip tokens without expiry time
if not token.expiry_time:
debug_logger.log_info(f"[BATCH_REFRESH] ⏭️ Token {token.id} ({token.email}): 无过期时间,跳过")
skipped_count += 1
continue
# Check if token needs refresh (expiry within 24 hours)
time_until_expiry = token.expiry_time - datetime.now()
hours_until_expiry = time_until_expiry.total_seconds() / 3600
if hours_until_expiry > 24:
debug_logger.log_info(f"[BATCH_REFRESH] ⏭️ Token {token.id} ({token.email}): 剩余时间 {hours_until_expiry:.2f}h > 24h跳过")
skipped_count += 1
continue
# Try to refresh
try:
result = await self.auto_refresh_expiring_token(token.id)
if result:
success_count += 1
debug_logger.log_info(f"[BATCH_REFRESH] ✅ Token {token.id} ({token.email}): 刷新成功")
else:
failed_count += 1
debug_logger.log_info(f"[BATCH_REFRESH] ❌ Token {token.id} ({token.email}): 刷新失败")
except Exception as e:
failed_count += 1
debug_logger.log_info(f"[BATCH_REFRESH] ❌ Token {token.id} ({token.email}): 刷新异常 - {str(e)}")
debug_logger.log_info(f"[BATCH_REFRESH] ✅ 批量刷新完成: 成功 {success_count}, 失败 {failed_count}, 跳过 {skipped_count}")
return {
"success": success_count,
"failed": failed_count,
"skipped": skipped_count,
"total": len(all_tokens)
}

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

@@ -1529,7 +1529,7 @@
</div>
<div>
<label class="section-title" for="baseUrl" style="margin-bottom:4px;">服务器地址</label>
<input id="baseUrl" class="input" type="text" value="http://127.0.0.1:8080" placeholder="后端地址,默认本机">
<input id="baseUrl" class="input" type="text" value="http://127.0.0.1:8000" placeholder="后端地址,默认本机">
</div>
</div>

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">
@@ -101,6 +121,146 @@
<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
</svg>
</button>
<!-- 批量操作下拉菜单 -->
<style>
.batch-dropdown-container {
position: relative;
display: inline-block;
}
.batch-dropdown-btn {
display: inline-flex;
align-items: center;
justify-content: center;
height: 32px;
padding: 0 12px;
background: #6366f1;
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.batch-dropdown-btn:hover {
background: #4f46e5;
}
.batch-dropdown-arrow {
margin-left: 4px;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.batch-dropdown-container:hover .batch-dropdown-arrow {
transform: rotate(180deg);
}
.batch-dropdown-menu {
position: absolute;
top: calc(100% + 4px);
left: 0;
min-width: 160px;
background: white;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 50;
opacity: 0;
visibility: hidden;
transform: translateY(-8px);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
}
.batch-dropdown-container:hover .batch-dropdown-menu {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.batch-dropdown-item {
display: flex;
align-items: center;
width: 100%;
padding: 8px 12px;
border: none;
background: none;
color: #374151;
font-size: 13px;
text-align: left;
cursor: pointer;
transition: background 0.15s ease;
}
.batch-dropdown-item:hover {
background: #f3f4f6;
}
.batch-dropdown-item.purple:hover { background: #faf5ff; color: #9333ea; }
.batch-dropdown-item.teal:hover { background: #f0fdfa; color: #0d9488; }
.batch-dropdown-item.orange:hover { background: #fff7ed; color: #ea580c; }
.batch-dropdown-item.red:hover { background: #fef2f2; color: #dc2626; }
.batch-dropdown-item.blue:hover { background: #eff6ff; color: #2563eb; }
.batch-dropdown-item + .batch-dropdown-item {
border-top: 1px solid #f3f4f6;
}
.batch-dropdown-icon {
width: 14px;
height: 14px;
margin-right: 8px;
}
</style>
<div class="batch-dropdown-container">
<button class="batch-dropdown-btn" title="批量操作">
<svg class="h-4 w-4 mr-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 11l3 3L22 4"/>
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>
</svg>
<span class="text-sm font-medium">批量操作</span>
<svg class="batch-dropdown-arrow 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 class="batch-dropdown-menu">
<button onclick="batchTestUpdate()" class="batch-dropdown-item purple" 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">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
<span>测试更新</span>
</button>
<button onclick="batchEnableAll()" class="batch-dropdown-item teal" 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">
<path d="M9 11l3 3L22 4"/>
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>
</svg>
<span>批量启用</span>
</button>
<button onclick="batchDisableSelected()" class="batch-dropdown-item orange" 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="10"/>
<line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/>
</svg>
<span>批量禁用</span>
</button>
<button onclick="batchDeleteDisabled()" 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"/>
</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"/>
<path d="M12 1v6m0 6v6m-5.2-9.8l4.2 4.2m4.2 0l4.2-4.2m-12.6 0l4.2 4.2m4.2 0l4.2 4.2"/>
</svg>
<span>修改代理</span>
</button>
</div>
</div>
<button onclick="exportTokens()" class="inline-flex items-center justify-center rounded-md bg-blue-600 text-white hover:bg-blue-700 h-8 px-3" title="导出所有Token">
<svg class="h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
@@ -131,12 +291,14 @@
<table class="w-full text-sm">
<thead>
<tr class="border-b border-border">
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">
<input type="checkbox" id="selectAllCheckbox" onchange="toggleSelectAll()" class="h-4 w-4 rounded border-gray-300">
</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">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">Sora2</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>
@@ -150,6 +312,8 @@
</tbody>
</table>
</div>
<!-- 分页控件 -->
<div id="paginationContainer"></div>
</div>
</div>
@@ -210,7 +374,39 @@
<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="inline-flex items-center gap-2 cursor-pointer">
<input type="checkbox" id="cfgPowProxyEnabled" 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>
<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="savePowProxyConfig()" 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>
@@ -223,6 +419,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>
@@ -302,6 +517,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>
@@ -320,6 +544,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>
@@ -378,9 +618,10 @@
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground w-32">操作</th>
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground w-40">Token邮箱</th>
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground w-20">状态码</th>
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground w-32">进度</th>
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground w-24">耗时(秒)</th>
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground w-44">时间</th>
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground w-20">详情</th>
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground w-32">操作</th>
</tr>
</thead>
<tbody id="logsTableBody" class="divide-y divide-border">
@@ -496,7 +737,7 @@
<input type="checkbox" id="addTokenImageEnabled" checked class="h-4 w-4 rounded border-input">
<span class="text-sm font-medium">启用图片生成</span>
</label>
<input type="number" id="addTokenImageConcurrency" value="-1" class="flex h-8 w-20 rounded-md border border-input bg-background px-2 py-1 text-sm" placeholder="并发数" title="并发数量限制:设置同时处理的最大请求数。-1表示不限制">
<input type="number" id="addTokenImageConcurrency" value="1" class="flex h-8 w-20 rounded-md border border-input bg-background px-2 py-1 text-sm" placeholder="并发数" title="并发数量限制:设置同时处理的最大请求数。-1表示不限制">
</div>
</div>
<div class="space-y-2">
@@ -505,7 +746,7 @@
<input type="checkbox" id="addTokenVideoEnabled" checked class="h-4 w-4 rounded border-input">
<span class="text-sm font-medium">启用视频生成</span>
</label>
<input type="number" id="addTokenVideoConcurrency" value="-1" class="flex h-8 w-20 rounded-md border border-input bg-background px-2 py-1 text-sm" placeholder="并发数" title="并发数量限制:设置同时处理的最大请求数。-1表示不限制">
<input type="number" id="addTokenVideoConcurrency" value="3" class="flex h-8 w-20 rounded-md border border-input bg-background px-2 py-1 text-sm" placeholder="并发数" title="并发数量限制:设置同时处理的最大请求数。-1表示不限制">
</div>
</div>
</div>
@@ -626,39 +867,6 @@
</div>
</div>
<!-- Sora2 激活模态框 -->
<div id="sora2Modal" class="hidden fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4">
<div class="bg-background rounded-lg border border-border w-full max-w-md shadow-xl">
<div class="flex items-center justify-between p-5 border-b border-border">
<h3 class="text-lg font-semibold">激活 Sora2</h3>
<button onclick="closeSora2Modal()" class="text-muted-foreground hover:text-foreground">
<svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="p-5 space-y-4">
<input type="hidden" id="sora2TokenId">
<div>
<label class="text-sm font-medium mb-2 block">Sora2 邀请码</label>
<input id="sora2InviteCode" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="输入6位邀请码例如0ZSKEG">
<p class="text-xs text-muted-foreground mt-1">输入Sora2邀请码以激活该Token的Sora2功能</p>
</div>
</div>
<div class="flex items-center justify-end gap-3 p-5 border-t border-border">
<button onclick="closeSora2Modal()" class="inline-flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent h-9 px-5">取消</button>
<button id="sora2ActivateBtn" onclick="submitSora2Activate()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-5">
<span id="sora2ActivateBtnText">激活</span>
<svg id="sora2ActivateBtnSpinner" class="hidden animate-spin h-4 w-4 ml-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</button>
</div>
</div>
</div>
<!-- Token 导入模态框 -->
<div id="importModal" class="hidden fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4">
<div class="bg-background rounded-lg border border-border w-full max-w-md shadow-xl">
@@ -672,11 +880,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">
@@ -684,10 +922,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">
@@ -706,6 +945,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问猫猫
@@ -751,23 +994,67 @@
</div>
</div>
<!-- 批量修改代理模态框 -->
<div id="batchProxyModal" class="hidden fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4">
<div class="bg-background rounded-lg border border-border w-full max-w-md shadow-xl">
<div class="flex items-center justify-between p-5 border-b border-border">
<h3 class="text-lg font-semibold">批量修改代理</h3>
<button onclick="closeBatchProxyModal()" class="text-muted-foreground hover:text-foreground">
<svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="p-5 space-y-4">
<div>
<label class="text-sm font-medium mb-2 block">代理地址</label>
<input type="text" id="batchProxyUrl" 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>
<div class="rounded-md bg-blue-50 dark:bg-blue-900/20 p-3 border border-blue-200 dark:border-blue-800">
<p class="text-xs text-blue-900 dark:text-blue-100">
<span class="font-semibold">提示:</span>将为 <span id="batchProxyCount" class="font-semibold">0</span> 个选中的Token修改代理地址
</p>
</div>
</div>
<div class="flex items-center justify-end gap-3 p-5 border-t border-border">
<button onclick="closeBatchProxyModal()" class="inline-flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent h-9 px-5">取消</button>
<button id="batchProxyBtn" onclick="submitBatchProxy()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-5">
<svg id="batchProxyBtnSpinner" class="hidden animate-spin -ml-1 mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span id="batchProxyBtnText">确认修改</span>
</button>
</div>
</div>
</div>
<script>
let allTokens=[];
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},
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 statusText=t.is_expired?'已过期':(t.is_active?'活跃':'禁用');statusSet.add(statusText)});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 statusText=t.is_expired?'已过期':(t.is_active?'活跃':'禁用');return statusText===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},
formatSora2=(t)=>{if(t.sora2_supported===true){const remaining=t.sora2_total_count-t.sora2_redeemed_count;const tooltipText=`邀请码: ${t.sora2_invite_code||'无'}\n可用次数: ${remaining}/${t.sora2_total_count}\n已用次数: ${t.sora2_redeemed_count}`;return`<div class="inline-flex items-center gap-1"><span class="inline-flex items-center rounded px-2 py-0.5 text-xs bg-green-50 text-green-700 cursor-pointer" title="${tooltipText}" onclick="copySora2Code('${t.sora2_invite_code||''}')">支持</span><span class="text-xs text-muted-foreground" title="${tooltipText}">${remaining}/${t.sora2_total_count}</span></div>`}else if(t.sora2_supported===false){return`<span class="inline-flex items-center rounded px-2 py-0.5 text-xs bg-gray-100 text-gray-700 cursor-pointer" title="点击使用邀请码激活" onclick="openSora2Modal(${t.id})">不支持</span>`}else{return'-'}},
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>`},
formatSora2Remaining=(t)=>{if(t.sora2_supported===true){const remaining=t.sora2_remaining_count||0;return`<span class="text-xs">${remaining}</span>`}else{return'-'}},
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 tb=$('tokenTableBody');tb.innerHTML=allTokens.map(t=>{const imageDisplay=t.image_enabled?`${t.image_count||0}`:'-';const videoDisplay=(t.video_enabled&&t.sora2_supported)?`${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\">${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 text-xs">${formatSora2(t)}</td><td class="py-2.5 px-3">${formatSora2Remaining(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('')},
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 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">${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 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='-1';$('addRTRefreshHint').classList.add('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')},
closeEditModal=()=>{$('editModal').classList.add('hidden');$('editTokenId').value='';$('editTokenAT').value='';$('editTokenST').value='';$('editTokenRT').value='';$('editTokenClientId').value='';$('editTokenProxyUrl').value='';$('editTokenRemark').value='';$('editTokenImageEnabled').checked=true;$('editTokenVideoEnabled').checked=true;$('editTokenImageConcurrency').value='';$('editTokenVideoConcurrency').value='';$('editRTRefreshHint').classList.add('hidden')},
submitEditToken=async()=>{const id=parseInt($('editTokenId').value),at=$('editTokenAT').value.trim(),st=$('editTokenST').value.trim(),rt=$('editTokenRT').value.trim(),clientId=$('editTokenClientId').value.trim(),proxyUrl=$('editTokenProxyUrl').value.trim(),remark=$('editTokenRemark').value.trim(),imageEnabled=$('editTokenImageEnabled').checked,videoEnabled=$('editTokenVideoEnabled').checked,imageConcurrency=$('editTokenImageConcurrency').value?parseInt($('editTokenImageConcurrency').value):null,videoConcurrency=$('editTokenVideoConcurrency').value?parseInt($('editTokenVideoConcurrency').value):null;if(!id)return showToast('Token ID无效','error');if(!at)return showToast('请输入 Access Token','error');const btn=$('editTokenBtn'),btnText=$('editTokenBtnText'),btnSpinner=$('editTokenBtnSpinner');btn.disabled=true;btnText.textContent='保存中...';btnSpinner.classList.remove('hidden');try{const r=await apiRequest(`/api/tokens/${id}`,{method:'PUT',body:JSON.stringify({token:at,st:st||null,rt:rt||null,client_id:clientId||null,proxy_url:proxyUrl||'',remark:remark||null,image_enabled:imageEnabled,video_enabled:videoEnabled,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){closeEditModal();await refreshTokens();showToast('Token更新成功','success')}else{showToast('更新失败: '+(d.detail||d.message||'未知错误'),'error')}}catch(e){showToast('更新失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='保存';btnSpinner.classList.add('hidden')}},
@@ -780,28 +1067,36 @@
toggleToken=async(id,isActive)=>{const action=isActive?'disable':'enable';try{const r=await apiRequest(`/api/tokens/${id}/${action}`,{method:'POST'});if(!r)return;const d=await r.json();d.success?(await refreshTokens(),showToast(isActive?'Token已禁用':'Token已启用','success')):showToast('操作失败','error')}catch(e){showToast('操作失败: '+e.message,'error')}},
toggleTokenStatus=async(id,active)=>{try{const r=await apiRequest(`/api/tokens/${id}/status`,{method:'PUT',body:JSON.stringify({is_active:active})});if(!r)return;const d=await r.json();d.success?(await refreshTokens(),showToast('状态更新成功','success')):showToast('更新失败','error')}catch(e){showToast('更新失败: '+e.message,'error')}},
deleteToken=async(id,skipConfirm=false)=>{if(!skipConfirm&&!confirm('确定要删除这个Token吗?'))return;try{const r=await apiRequest(`/api/tokens/${id}`,{method:'DELETE'});if(!r)return;const d=await r.json();if(d.success){await refreshTokens();if(!skipConfirm)showToast('删除成功','success');return true}else{if(!skipConfirm)showToast('删除失败','error');return false}}catch(e){if(!skipConfirm)showToast('删除失败: '+e.message,'error');return false}},
copySora2Code=async(code)=>{if(!code){showToast('没有可复制的邀请码','error');return}try{if(navigator.clipboard&&navigator.clipboard.writeText){await navigator.clipboard.writeText(code);showToast(`邀请码已复制: ${code}`,'success')}else{const textarea=document.createElement('textarea');textarea.value=code;textarea.style.position='fixed';textarea.style.opacity='0';document.body.appendChild(textarea);textarea.select();const success=document.execCommand('copy');document.body.removeChild(textarea);if(success){showToast(`邀请码已复制: ${code}`,'success')}else{showToast('复制失败: 浏览器不支持','error')}}}catch(e){showToast('复制失败: '+e.message,'error')}},
openSora2Modal=(id)=>{$('sora2TokenId').value=id;$('sora2InviteCode').value='';$('sora2Modal').classList.remove('hidden')},
closeSora2Modal=()=>{$('sora2Modal').classList.add('hidden');$('sora2TokenId').value='';$('sora2InviteCode').value=''},
openImportModal=()=>{$('importModal').classList.remove('hidden');$('importFile').value=''},
closeImportModal=()=>{$('importModal').classList.add('hidden');$('importFile').value=''},
openImportProgressModal=()=>{$('importProgressModal').classList.remove('hidden')},
closeImportProgressModal=()=>{$('importProgressModal').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()},
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 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')},
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')}},
submitSora2Activate=async()=>{const tokenId=parseInt($('sora2TokenId').value),inviteCode=$('sora2InviteCode').value.trim();if(!tokenId)return showToast('Token ID无效','error');if(!inviteCode)return showToast('请输入邀请码','error');if(inviteCode.length!==6)return showToast('邀请码必须是6位','error');const btn=$('sora2ActivateBtn'),btnText=$('sora2ActivateBtnText'),btnSpinner=$('sora2ActivateBtnSpinner');btn.disabled=true;btnText.textContent='激活中...';btnSpinner.classList.remove('hidden');try{showToast('正在激活Sora2...','info');const r=await apiRequest(`/api/tokens/${tokenId}/sora2/activate?invite_code=${inviteCode}`,{method:'POST'});if(!r){btn.disabled=false;btnText.textContent='激活';btnSpinner.classList.add('hidden');return}const d=await r.json();if(d.success){closeSora2Modal();await refreshTokens();if(d.already_accepted){showToast('Sora2已激活之前已接受','success')}else{showToast(`Sora2激活成功邀请码: ${d.invite_code||'无'}`,'success')}}else{showToast('激活失败: '+(d.message||'未知错误'),'error')}}catch(e){showToast('激活失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='激活';btnSpinner.classList.add('hidden')}},
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')}},
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}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'),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'},
@@ -811,16 +1106,21 @@
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=>`<tr><td class="py-2.5 px-3">${l.operation}</td><td class="py-2.5 px-3"><span class="text-xs ${l.token_email?'text-blue-600':'text-muted-foreground'}">${l.token_email||'未知'}</span></td><td class="py-2.5 px-3"><span class="inline-flex items-center rounded px-2 py-0.5 text-xs ${l.status_code===-1?'bg-blue-50 text-blue-700':l.status_code===200?'bg-green-50 text-green-700':'bg-red-50 text-red-700'}">${l.status_code===-1?'生成中':l.status_code}</span></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"><button onclick="showLogDetail(${l.id})" class="inline-flex items-center justify-center rounded-md hover:bg-blue-50 hover:text-blue-700 h-7 px-2 text-xs">查看</button></td></tr>`).join('')}catch(e){console.error('加载日志失败:',e)}},
loadLogs=async()=>{try{const r=await apiRequest('/api/logs?limit=100');if(!r)return;const logs=await r.json();window.allLogs=logs;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')}},
showToast=(m,t='info')=>{const d=document.createElement('div'),bc={success:'bg-green-600',error:'bg-destructive',info:'bg-primary'};d.className=`fixed bottom-4 right-4 ${bc[t]||bc.info} text-white px-4 py-2.5 rounded-lg shadow-lg text-sm font-medium z-50 animate-slide-up`;d.textContent=m;document.body.appendChild(d);setTimeout(()=>{d.style.opacity='0';d.style.transition='opacity .3s';setTimeout(()=>d.parentNode&&document.body.removeChild(d),300)},2000)},
logout=()=>{if(!confirm('确定要退出登录吗?'))return;localStorage.removeItem('adminToken');location.href='/login'},
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')}},
loadPowProxyConfig=async()=>{try{const r=await apiRequest('/api/pow-proxy/config');if(!r)return;const d=await r.json();if(d.success&&d.config){$('cfgPowProxyEnabled').checked=d.config.pow_proxy_enabled||false;$('cfgPowProxyUrl').value=d.config.pow_proxy_url||''}else{console.error('POW代理配置数据格式错误:',d)}}catch(e){console.error('加载POW代理配置失败:',e)}},
savePowProxyConfig=async()=>{try{const r=await apiRequest('/api/pow-proxy/config',{method:'POST',body:JSON.stringify({pow_proxy_enabled:$('cfgPowProxyEnabled').checked,pow_proxy_url:$('cfgPowProxyUrl').value.trim()})});if(!r)return;const d=await r.json();if(d.success){showToast('POW代理配置保存成功','success')}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();loadPowProxyConfig();loadWatermarkFreeConfig();loadCacheConfig();loadGenerationTimeout();loadATAutoRefreshConfig();loadCallLogicConfig()}else if(t==='logs'){loadLogs()}};
// 自适应生成面板 iframe 高度
window.addEventListener('message', (event) => {
const data = event.data || {};
@@ -832,6 +1132,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>