mirror of
https://github.com/TheSmallHanCat/sora2api.git
synced 2026-02-04 10:14:41 +08:00
Compare commits
28 Commits
795e0b2670
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc95de0f28 | ||
|
|
92015882cc | ||
|
|
5570fa35a6 | ||
|
|
06c2bea806 | ||
|
|
576310c50c | ||
|
|
dab1f13310 | ||
|
|
50e004d722 | ||
|
|
ef49e3e670 | ||
|
|
d300f94683 | ||
|
|
ef1d1f90de | ||
|
|
5342435512 | ||
|
|
4b471ccb2b | ||
|
|
1703876ffa | ||
|
|
447079f863 | ||
|
|
a1ba92e8f6 | ||
|
|
a93d81bfc0 | ||
|
|
0cc1c2e32d | ||
|
|
881366175f | ||
|
|
eadce1a131 | ||
|
|
27ed2bd9a7 | ||
|
|
c8b218fe9d | ||
|
|
ac9fb944d6 | ||
|
|
2d3aeff8df | ||
|
|
b23f60e66b | ||
|
|
fb0569c298 | ||
|
|
ff25c88d3f | ||
|
|
ab6aee0dc5 | ||
|
|
a15046a2ff |
39
Dockerfile
39
Dockerfile
@@ -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
|
||||
|
||||
62
README.md
62
README.md
@@ -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验证解决方案
|
||||
感谢所有贡献者和使用者的支持!
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -11,4 +11,6 @@ services:
|
||||
- ./config/setting.toml:/app/config/setting.toml
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
- TZ=Asia/Shanghai
|
||||
- TIMEZONE_OFFSET=8
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -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
|
||||
591
src/api/admin.py
591
src/api/admin.py
@@ -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)):
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
29
src/main.py
29
src/main.py
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]",
|
||||
"registerProtocolHandler−function registerProtocolHandler() { [native code] }",
|
||||
"storage−[object StorageManager]",
|
||||
"locks−[object LockManager]",
|
||||
"appCodeName−Mozilla",
|
||||
"permissions−[object Permissions]",
|
||||
"webdriver−false",
|
||||
"vendor−Google Inc.",
|
||||
"mediaDevices−[object MediaDevices]",
|
||||
"cookieEnabled−true",
|
||||
"product−Gecko",
|
||||
"productSub−20030107",
|
||||
"hardwareConcurrency−32",
|
||||
"onLine−true",
|
||||
]
|
||||
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", "")
|
||||
|
||||
@@ -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
95
src/utils/timezone.py
Normal 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)
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user