mirror of
https://github.com/TheSmallHanCat/sora2api.git
synced 2026-03-13 15:37:32 +08:00
feat: 新增图片视频并发设置
新增token导入导出为json chore: 完善token刷新日志输出 fix: 修复自动更新时无法根据AT有效期禁用token问题
This commit is contained in:
120
src/api/admin.py
120
src/api/admin.py
@@ -8,6 +8,7 @@ from ..core.auth import AuthManager
|
|||||||
from ..core.config import config
|
from ..core.config import config
|
||||||
from ..services.token_manager import TokenManager
|
from ..services.token_manager import TokenManager
|
||||||
from ..services.proxy_manager import ProxyManager
|
from ..services.proxy_manager import ProxyManager
|
||||||
|
from ..services.concurrency_manager import ConcurrencyManager
|
||||||
from ..core.database import Database
|
from ..core.database import Database
|
||||||
from ..core.models import Token, AdminConfig, ProxyConfig
|
from ..core.models import Token, AdminConfig, ProxyConfig
|
||||||
|
|
||||||
@@ -18,17 +19,19 @@ token_manager: TokenManager = None
|
|||||||
proxy_manager: ProxyManager = None
|
proxy_manager: ProxyManager = None
|
||||||
db: Database = None
|
db: Database = None
|
||||||
generation_handler = None
|
generation_handler = None
|
||||||
|
concurrency_manager: ConcurrencyManager = None
|
||||||
|
|
||||||
# Store active admin tokens (in production, use Redis or database)
|
# Store active admin tokens (in production, use Redis or database)
|
||||||
active_admin_tokens = set()
|
active_admin_tokens = set()
|
||||||
|
|
||||||
def set_dependencies(tm: TokenManager, pm: ProxyManager, database: Database, gh=None):
|
def set_dependencies(tm: TokenManager, pm: ProxyManager, database: Database, gh=None, cm: ConcurrencyManager = None):
|
||||||
"""Set dependencies"""
|
"""Set dependencies"""
|
||||||
global token_manager, proxy_manager, db, generation_handler
|
global token_manager, proxy_manager, db, generation_handler, concurrency_manager
|
||||||
token_manager = tm
|
token_manager = tm
|
||||||
proxy_manager = pm
|
proxy_manager = pm
|
||||||
db = database
|
db = database
|
||||||
generation_handler = gh
|
generation_handler = gh
|
||||||
|
concurrency_manager = cm
|
||||||
|
|
||||||
def verify_admin_token(authorization: str = Header(None)):
|
def verify_admin_token(authorization: str = Header(None)):
|
||||||
"""Verify admin token from Authorization header"""
|
"""Verify admin token from Authorization header"""
|
||||||
@@ -62,6 +65,8 @@ class AddTokenRequest(BaseModel):
|
|||||||
remark: Optional[str] = None
|
remark: Optional[str] = None
|
||||||
image_enabled: bool = True # Enable image generation
|
image_enabled: bool = True # Enable image generation
|
||||||
video_enabled: bool = True # Enable video 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)
|
||||||
|
|
||||||
class ST2ATRequest(BaseModel):
|
class ST2ATRequest(BaseModel):
|
||||||
st: str # Session Token
|
st: str # Session Token
|
||||||
@@ -79,6 +84,22 @@ class UpdateTokenRequest(BaseModel):
|
|||||||
remark: Optional[str] = None
|
remark: Optional[str] = None
|
||||||
image_enabled: Optional[bool] = None # Enable image generation
|
image_enabled: Optional[bool] = None # Enable image generation
|
||||||
video_enabled: Optional[bool] = None # Enable video generation
|
video_enabled: Optional[bool] = None # Enable video generation
|
||||||
|
image_concurrency: Optional[int] = None # Image concurrency limit
|
||||||
|
video_concurrency: Optional[int] = None # Video concurrency limit
|
||||||
|
|
||||||
|
class ImportTokenItem(BaseModel):
|
||||||
|
email: str # Email (primary key)
|
||||||
|
access_token: str # Access Token (AT)
|
||||||
|
session_token: Optional[str] = None # Session Token (ST)
|
||||||
|
refresh_token: Optional[str] = None # Refresh Token (RT)
|
||||||
|
is_active: bool = True # Active status
|
||||||
|
image_enabled: bool = True # Enable image generation
|
||||||
|
video_enabled: bool = True # Enable video generation
|
||||||
|
image_concurrency: int = -1 # Image concurrency limit
|
||||||
|
video_concurrency: int = -1 # Video concurrency limit
|
||||||
|
|
||||||
|
class ImportTokensRequest(BaseModel):
|
||||||
|
tokens: List[ImportTokenItem]
|
||||||
|
|
||||||
class UpdateAdminConfigRequest(BaseModel):
|
class UpdateAdminConfigRequest(BaseModel):
|
||||||
error_ban_threshold: int
|
error_ban_threshold: int
|
||||||
@@ -173,7 +194,10 @@ async def get_tokens(token: str = Depends(verify_admin_token)) -> List[dict]:
|
|||||||
"sora2_cooldown_until": token.sora2_cooldown_until.isoformat() if token.sora2_cooldown_until else None,
|
"sora2_cooldown_until": token.sora2_cooldown_until.isoformat() if token.sora2_cooldown_until else None,
|
||||||
# 功能开关
|
# 功能开关
|
||||||
"image_enabled": token.image_enabled,
|
"image_enabled": token.image_enabled,
|
||||||
"video_enabled": token.video_enabled
|
"video_enabled": token.video_enabled,
|
||||||
|
# 并发限制
|
||||||
|
"image_concurrency": token.image_concurrency,
|
||||||
|
"video_concurrency": token.video_concurrency
|
||||||
})
|
})
|
||||||
|
|
||||||
return result
|
return result
|
||||||
@@ -189,8 +213,17 @@ async def add_token(request: AddTokenRequest, token: str = Depends(verify_admin_
|
|||||||
remark=request.remark,
|
remark=request.remark,
|
||||||
update_if_exists=False,
|
update_if_exists=False,
|
||||||
image_enabled=request.image_enabled,
|
image_enabled=request.image_enabled,
|
||||||
video_enabled=request.video_enabled
|
video_enabled=request.video_enabled,
|
||||||
|
image_concurrency=request.image_concurrency,
|
||||||
|
video_concurrency=request.video_concurrency
|
||||||
)
|
)
|
||||||
|
# Initialize concurrency counters for the new token
|
||||||
|
if concurrency_manager:
|
||||||
|
await concurrency_manager.reset_token(
|
||||||
|
new_token.id,
|
||||||
|
image_concurrency=request.image_concurrency,
|
||||||
|
video_concurrency=request.video_concurrency
|
||||||
|
)
|
||||||
return {"success": True, "message": "Token 添加成功", "token_id": new_token.id}
|
return {"success": True, "message": "Token 添加成功", "token_id": new_token.id}
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
# Token already exists
|
# Token already exists
|
||||||
@@ -300,13 +333,79 @@ async def delete_token(token_id: int, token: str = Depends(verify_admin_token)):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
@router.post("/api/tokens/import")
|
||||||
|
async def import_tokens(request: ImportTokensRequest, token: str = Depends(verify_admin_token)):
|
||||||
|
"""Import tokens in append mode (update if exists, add if not)"""
|
||||||
|
try:
|
||||||
|
added_count = 0
|
||||||
|
updated_count = 0
|
||||||
|
|
||||||
|
for import_item in request.tokens:
|
||||||
|
# Check if token with this email already exists
|
||||||
|
existing_token = await db.get_token_by_email(import_item.email)
|
||||||
|
|
||||||
|
if existing_token:
|
||||||
|
# Update existing token
|
||||||
|
await token_manager.update_token(
|
||||||
|
token_id=existing_token.id,
|
||||||
|
token=import_item.access_token,
|
||||||
|
st=import_item.session_token,
|
||||||
|
rt=import_item.refresh_token,
|
||||||
|
image_enabled=import_item.image_enabled,
|
||||||
|
video_enabled=import_item.video_enabled,
|
||||||
|
image_concurrency=import_item.image_concurrency,
|
||||||
|
video_concurrency=import_item.video_concurrency
|
||||||
|
)
|
||||||
|
# Update active status
|
||||||
|
await token_manager.update_token_status(existing_token.id, import_item.is_active)
|
||||||
|
# Reset concurrency counters
|
||||||
|
if concurrency_manager:
|
||||||
|
await concurrency_manager.reset_token(
|
||||||
|
existing_token.id,
|
||||||
|
image_concurrency=import_item.image_concurrency,
|
||||||
|
video_concurrency=import_item.video_concurrency
|
||||||
|
)
|
||||||
|
updated_count += 1
|
||||||
|
else:
|
||||||
|
# Add new token
|
||||||
|
new_token = await token_manager.add_token(
|
||||||
|
token_value=import_item.access_token,
|
||||||
|
st=import_item.session_token,
|
||||||
|
rt=import_item.refresh_token,
|
||||||
|
update_if_exists=False,
|
||||||
|
image_enabled=import_item.image_enabled,
|
||||||
|
video_enabled=import_item.video_enabled,
|
||||||
|
image_concurrency=import_item.image_concurrency,
|
||||||
|
video_concurrency=import_item.video_concurrency
|
||||||
|
)
|
||||||
|
# Set active status
|
||||||
|
if not import_item.is_active:
|
||||||
|
await token_manager.disable_token(new_token.id)
|
||||||
|
# Initialize concurrency counters
|
||||||
|
if concurrency_manager:
|
||||||
|
await concurrency_manager.reset_token(
|
||||||
|
new_token.id,
|
||||||
|
image_concurrency=import_item.image_concurrency,
|
||||||
|
video_concurrency=import_item.video_concurrency
|
||||||
|
)
|
||||||
|
added_count += 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Import completed: {added_count} added, {updated_count} updated",
|
||||||
|
"added": added_count,
|
||||||
|
"updated": updated_count
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Import failed: {str(e)}")
|
||||||
|
|
||||||
@router.put("/api/tokens/{token_id}")
|
@router.put("/api/tokens/{token_id}")
|
||||||
async def update_token(
|
async def update_token(
|
||||||
token_id: int,
|
token_id: int,
|
||||||
request: UpdateTokenRequest,
|
request: UpdateTokenRequest,
|
||||||
token: str = Depends(verify_admin_token)
|
token: str = Depends(verify_admin_token)
|
||||||
):
|
):
|
||||||
"""Update token (AT, ST, RT, remark, image_enabled, video_enabled)"""
|
"""Update token (AT, ST, RT, remark, image_enabled, video_enabled, concurrency limits)"""
|
||||||
try:
|
try:
|
||||||
await token_manager.update_token(
|
await token_manager.update_token(
|
||||||
token_id=token_id,
|
token_id=token_id,
|
||||||
@@ -315,8 +414,17 @@ async def update_token(
|
|||||||
rt=request.rt,
|
rt=request.rt,
|
||||||
remark=request.remark,
|
remark=request.remark,
|
||||||
image_enabled=request.image_enabled,
|
image_enabled=request.image_enabled,
|
||||||
video_enabled=request.video_enabled
|
video_enabled=request.video_enabled,
|
||||||
|
image_concurrency=request.image_concurrency,
|
||||||
|
video_concurrency=request.video_concurrency
|
||||||
)
|
)
|
||||||
|
# Reset concurrency counters if they were updated
|
||||||
|
if concurrency_manager and (request.image_concurrency is not None or request.video_concurrency is not None):
|
||||||
|
await concurrency_manager.reset_token(
|
||||||
|
token_id,
|
||||||
|
image_concurrency=request.image_concurrency if request.image_concurrency is not None else -1,
|
||||||
|
video_concurrency=request.video_concurrency if request.video_concurrency is not None else -1
|
||||||
|
)
|
||||||
return {"success": True, "message": "Token updated"}
|
return {"success": True, "message": "Token updated"}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|||||||
@@ -192,6 +192,8 @@ class Database:
|
|||||||
("sora2_cooldown_until", "TIMESTAMP"),
|
("sora2_cooldown_until", "TIMESTAMP"),
|
||||||
("image_enabled", "BOOLEAN DEFAULT 1"),
|
("image_enabled", "BOOLEAN DEFAULT 1"),
|
||||||
("video_enabled", "BOOLEAN DEFAULT 1"),
|
("video_enabled", "BOOLEAN DEFAULT 1"),
|
||||||
|
("image_concurrency", "INTEGER DEFAULT -1"),
|
||||||
|
("video_concurrency", "INTEGER DEFAULT -1"),
|
||||||
]
|
]
|
||||||
|
|
||||||
for col_name, col_type in columns_to_add:
|
for col_name, col_type in columns_to_add:
|
||||||
@@ -270,7 +272,9 @@ class Database:
|
|||||||
sora2_remaining_count INTEGER DEFAULT 0,
|
sora2_remaining_count INTEGER DEFAULT 0,
|
||||||
sora2_cooldown_until TIMESTAMP,
|
sora2_cooldown_until TIMESTAMP,
|
||||||
image_enabled BOOLEAN DEFAULT 1,
|
image_enabled BOOLEAN DEFAULT 1,
|
||||||
video_enabled BOOLEAN DEFAULT 1
|
video_enabled BOOLEAN DEFAULT 1,
|
||||||
|
image_concurrency INTEGER DEFAULT -1,
|
||||||
|
video_concurrency INTEGER DEFAULT -1
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
@@ -545,15 +549,16 @@ class Database:
|
|||||||
INSERT INTO tokens (token, email, username, name, st, rt, remark, expiry_time, is_active,
|
INSERT INTO tokens (token, email, username, name, st, rt, remark, expiry_time, is_active,
|
||||||
plan_type, plan_title, subscription_end, sora2_supported, sora2_invite_code,
|
plan_type, plan_title, subscription_end, sora2_supported, sora2_invite_code,
|
||||||
sora2_redeemed_count, sora2_total_count, sora2_remaining_count, sora2_cooldown_until,
|
sora2_redeemed_count, sora2_total_count, sora2_remaining_count, sora2_cooldown_until,
|
||||||
image_enabled, video_enabled)
|
image_enabled, video_enabled, image_concurrency, video_concurrency)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""", (token.token, token.email, "", token.name, token.st, token.rt,
|
""", (token.token, token.email, "", token.name, token.st, token.rt,
|
||||||
token.remark, token.expiry_time, token.is_active,
|
token.remark, token.expiry_time, token.is_active,
|
||||||
token.plan_type, token.plan_title, token.subscription_end,
|
token.plan_type, token.plan_title, token.subscription_end,
|
||||||
token.sora2_supported, token.sora2_invite_code,
|
token.sora2_supported, token.sora2_invite_code,
|
||||||
token.sora2_redeemed_count, token.sora2_total_count,
|
token.sora2_redeemed_count, token.sora2_total_count,
|
||||||
token.sora2_remaining_count, token.sora2_cooldown_until,
|
token.sora2_remaining_count, token.sora2_cooldown_until,
|
||||||
token.image_enabled, token.video_enabled))
|
token.image_enabled, token.video_enabled,
|
||||||
|
token.image_concurrency, token.video_concurrency))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
token_id = cursor.lastrowid
|
token_id = cursor.lastrowid
|
||||||
|
|
||||||
@@ -584,6 +589,16 @@ class Database:
|
|||||||
if row:
|
if row:
|
||||||
return Token(**dict(row))
|
return Token(**dict(row))
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
async def get_token_by_email(self, email: str) -> Optional[Token]:
|
||||||
|
"""Get token by email"""
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
db.row_factory = aiosqlite.Row
|
||||||
|
cursor = await db.execute("SELECT * FROM tokens WHERE email = ?", (email,))
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return Token(**dict(row))
|
||||||
|
return None
|
||||||
|
|
||||||
async def get_active_tokens(self) -> List[Token]:
|
async def get_active_tokens(self) -> List[Token]:
|
||||||
"""Get all active tokens (enabled, not cooled down, not expired)"""
|
"""Get all active tokens (enabled, not cooled down, not expired)"""
|
||||||
@@ -677,7 +692,9 @@ class Database:
|
|||||||
plan_title: Optional[str] = None,
|
plan_title: Optional[str] = None,
|
||||||
subscription_end: Optional[datetime] = None,
|
subscription_end: Optional[datetime] = None,
|
||||||
image_enabled: Optional[bool] = None,
|
image_enabled: Optional[bool] = None,
|
||||||
video_enabled: Optional[bool] = None):
|
video_enabled: Optional[bool] = None,
|
||||||
|
image_concurrency: Optional[int] = None,
|
||||||
|
video_concurrency: Optional[int] = None):
|
||||||
"""Update token (AT, ST, RT, remark, expiry_time, subscription info, image_enabled, video_enabled)"""
|
"""Update token (AT, ST, RT, remark, expiry_time, subscription info, image_enabled, video_enabled)"""
|
||||||
async with aiosqlite.connect(self.db_path) as db:
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
# Build dynamic update query
|
# Build dynamic update query
|
||||||
@@ -724,6 +741,14 @@ class Database:
|
|||||||
updates.append("video_enabled = ?")
|
updates.append("video_enabled = ?")
|
||||||
params.append(video_enabled)
|
params.append(video_enabled)
|
||||||
|
|
||||||
|
if image_concurrency is not None:
|
||||||
|
updates.append("image_concurrency = ?")
|
||||||
|
params.append(image_concurrency)
|
||||||
|
|
||||||
|
if video_concurrency is not None:
|
||||||
|
updates.append("video_concurrency = ?")
|
||||||
|
params.append(video_concurrency)
|
||||||
|
|
||||||
if updates:
|
if updates:
|
||||||
params.append(token_id)
|
params.append(token_id)
|
||||||
query = f"UPDATE tokens SET {', '.join(updates)} WHERE id = ?"
|
query = f"UPDATE tokens SET {', '.join(updates)} WHERE id = ?"
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ class Token(BaseModel):
|
|||||||
# 功能开关
|
# 功能开关
|
||||||
image_enabled: bool = True # 是否启用图片生成
|
image_enabled: bool = True # 是否启用图片生成
|
||||||
video_enabled: bool = True # 是否启用视频生成
|
video_enabled: bool = True # 是否启用视频生成
|
||||||
|
# 并发限制
|
||||||
|
image_concurrency: int = -1 # 图片并发数限制,-1表示不限制
|
||||||
|
video_concurrency: int = -1 # 视频并发数限制,-1表示不限制
|
||||||
|
|
||||||
class TokenStats(BaseModel):
|
class TokenStats(BaseModel):
|
||||||
"""Token statistics"""
|
"""Token statistics"""
|
||||||
|
|||||||
13
src/main.py
13
src/main.py
@@ -14,6 +14,7 @@ from .services.proxy_manager import ProxyManager
|
|||||||
from .services.load_balancer import LoadBalancer
|
from .services.load_balancer import LoadBalancer
|
||||||
from .services.sora_client import SoraClient
|
from .services.sora_client import SoraClient
|
||||||
from .services.generation_handler import GenerationHandler
|
from .services.generation_handler import GenerationHandler
|
||||||
|
from .services.concurrency_manager import ConcurrencyManager
|
||||||
from .api import routes as api_routes
|
from .api import routes as api_routes
|
||||||
from .api import admin as admin_routes
|
from .api import admin as admin_routes
|
||||||
|
|
||||||
@@ -37,13 +38,14 @@ app.add_middleware(
|
|||||||
db = Database()
|
db = Database()
|
||||||
token_manager = TokenManager(db)
|
token_manager = TokenManager(db)
|
||||||
proxy_manager = ProxyManager(db)
|
proxy_manager = ProxyManager(db)
|
||||||
load_balancer = LoadBalancer(token_manager)
|
concurrency_manager = ConcurrencyManager()
|
||||||
|
load_balancer = LoadBalancer(token_manager, concurrency_manager)
|
||||||
sora_client = SoraClient(proxy_manager)
|
sora_client = SoraClient(proxy_manager)
|
||||||
generation_handler = GenerationHandler(sora_client, token_manager, load_balancer, db, proxy_manager)
|
generation_handler = GenerationHandler(sora_client, token_manager, load_balancer, db, proxy_manager, concurrency_manager)
|
||||||
|
|
||||||
# Set dependencies for route modules
|
# Set dependencies for route modules
|
||||||
api_routes.set_generation_handler(generation_handler)
|
api_routes.set_generation_handler(generation_handler)
|
||||||
admin_routes.set_dependencies(token_manager, proxy_manager, db, generation_handler)
|
admin_routes.set_dependencies(token_manager, proxy_manager, db, generation_handler, concurrency_manager)
|
||||||
|
|
||||||
# Include routers
|
# Include routers
|
||||||
app.include_router(api_routes.router)
|
app.include_router(api_routes.router)
|
||||||
@@ -127,6 +129,11 @@ async def startup_event():
|
|||||||
token_refresh_config = await db.get_token_refresh_config()
|
token_refresh_config = await db.get_token_refresh_config()
|
||||||
config.set_at_auto_refresh_enabled(token_refresh_config.at_auto_refresh_enabled)
|
config.set_at_auto_refresh_enabled(token_refresh_config.at_auto_refresh_enabled)
|
||||||
|
|
||||||
|
# Initialize concurrency manager with all tokens
|
||||||
|
all_tokens = await db.get_all_tokens()
|
||||||
|
await concurrency_manager.initialize(all_tokens)
|
||||||
|
print(f"✓ Concurrency manager initialized with {len(all_tokens)} tokens")
|
||||||
|
|
||||||
# Start file cache cleanup task
|
# Start file cache cleanup task
|
||||||
await generation_handler.file_cache.start_cleanup_task()
|
await generation_handler.file_cache.start_cleanup_task()
|
||||||
|
|
||||||
|
|||||||
191
src/services/concurrency_manager.py
Normal file
191
src/services/concurrency_manager.py
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
"""Concurrency manager for token-based rate limiting"""
|
||||||
|
import asyncio
|
||||||
|
from typing import Dict, Optional
|
||||||
|
from ..core.logger import debug_logger
|
||||||
|
|
||||||
|
|
||||||
|
class ConcurrencyManager:
|
||||||
|
"""Manages concurrent request limits for each token"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize concurrency manager"""
|
||||||
|
self._image_concurrency: Dict[int, int] = {} # token_id -> remaining image concurrency
|
||||||
|
self._video_concurrency: Dict[int, int] = {} # token_id -> remaining video concurrency
|
||||||
|
self._lock = asyncio.Lock() # Protect concurrent access
|
||||||
|
|
||||||
|
async def initialize(self, tokens: list):
|
||||||
|
"""
|
||||||
|
Initialize concurrency counters from token list
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tokens: List of Token objects with image_concurrency and video_concurrency fields
|
||||||
|
"""
|
||||||
|
async with self._lock:
|
||||||
|
for token in tokens:
|
||||||
|
if token.image_concurrency and token.image_concurrency > 0:
|
||||||
|
self._image_concurrency[token.id] = token.image_concurrency
|
||||||
|
if token.video_concurrency and token.video_concurrency > 0:
|
||||||
|
self._video_concurrency[token.id] = token.video_concurrency
|
||||||
|
|
||||||
|
debug_logger.log_info(f"Concurrency manager initialized with {len(tokens)} tokens")
|
||||||
|
|
||||||
|
async def can_use_image(self, token_id: int) -> bool:
|
||||||
|
"""
|
||||||
|
Check if token can be used for image generation
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token_id: Token ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if token has available image concurrency, False if concurrency is 0
|
||||||
|
"""
|
||||||
|
async with self._lock:
|
||||||
|
# If not in dict, it means no limit (-1)
|
||||||
|
if token_id not in self._image_concurrency:
|
||||||
|
return True
|
||||||
|
|
||||||
|
remaining = self._image_concurrency[token_id]
|
||||||
|
if remaining <= 0:
|
||||||
|
debug_logger.log_info(f"Token {token_id} image concurrency exhausted (remaining: {remaining})")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def can_use_video(self, token_id: int) -> bool:
|
||||||
|
"""
|
||||||
|
Check if token can be used for video generation
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token_id: Token ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if token has available video concurrency, False if concurrency is 0
|
||||||
|
"""
|
||||||
|
async with self._lock:
|
||||||
|
# If not in dict, it means no limit (-1)
|
||||||
|
if token_id not in self._video_concurrency:
|
||||||
|
return True
|
||||||
|
|
||||||
|
remaining = self._video_concurrency[token_id]
|
||||||
|
if remaining <= 0:
|
||||||
|
debug_logger.log_info(f"Token {token_id} video concurrency exhausted (remaining: {remaining})")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def acquire_image(self, token_id: int) -> bool:
|
||||||
|
"""
|
||||||
|
Acquire image concurrency slot
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token_id: Token ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if acquired, False if not available
|
||||||
|
"""
|
||||||
|
async with self._lock:
|
||||||
|
if token_id not in self._image_concurrency:
|
||||||
|
# No limit
|
||||||
|
return True
|
||||||
|
|
||||||
|
if self._image_concurrency[token_id] <= 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._image_concurrency[token_id] -= 1
|
||||||
|
debug_logger.log_info(f"Token {token_id} acquired image slot (remaining: {self._image_concurrency[token_id]})")
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def acquire_video(self, token_id: int) -> bool:
|
||||||
|
"""
|
||||||
|
Acquire video concurrency slot
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token_id: Token ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if acquired, False if not available
|
||||||
|
"""
|
||||||
|
async with self._lock:
|
||||||
|
if token_id not in self._video_concurrency:
|
||||||
|
# No limit
|
||||||
|
return True
|
||||||
|
|
||||||
|
if self._video_concurrency[token_id] <= 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._video_concurrency[token_id] -= 1
|
||||||
|
debug_logger.log_info(f"Token {token_id} acquired video slot (remaining: {self._video_concurrency[token_id]})")
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def release_image(self, token_id: int):
|
||||||
|
"""
|
||||||
|
Release image concurrency slot
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token_id: Token ID
|
||||||
|
"""
|
||||||
|
async with self._lock:
|
||||||
|
if token_id in self._image_concurrency:
|
||||||
|
self._image_concurrency[token_id] += 1
|
||||||
|
debug_logger.log_info(f"Token {token_id} released image slot (remaining: {self._image_concurrency[token_id]})")
|
||||||
|
|
||||||
|
async def release_video(self, token_id: int):
|
||||||
|
"""
|
||||||
|
Release video concurrency slot
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token_id: Token ID
|
||||||
|
"""
|
||||||
|
async with self._lock:
|
||||||
|
if token_id in self._video_concurrency:
|
||||||
|
self._video_concurrency[token_id] += 1
|
||||||
|
debug_logger.log_info(f"Token {token_id} released video slot (remaining: {self._video_concurrency[token_id]})")
|
||||||
|
|
||||||
|
async def get_image_remaining(self, token_id: int) -> Optional[int]:
|
||||||
|
"""
|
||||||
|
Get remaining image concurrency for token
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token_id: Token ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Remaining count or None if no limit
|
||||||
|
"""
|
||||||
|
async with self._lock:
|
||||||
|
return self._image_concurrency.get(token_id)
|
||||||
|
|
||||||
|
async def get_video_remaining(self, token_id: int) -> Optional[int]:
|
||||||
|
"""
|
||||||
|
Get remaining video concurrency for token
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token_id: Token ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Remaining count or None if no limit
|
||||||
|
"""
|
||||||
|
async with self._lock:
|
||||||
|
return self._video_concurrency.get(token_id)
|
||||||
|
|
||||||
|
async def reset_token(self, token_id: int, image_concurrency: int = -1, video_concurrency: int = -1):
|
||||||
|
"""
|
||||||
|
Reset concurrency counters for a token
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token_id: Token ID
|
||||||
|
image_concurrency: New image concurrency limit (-1 for no limit)
|
||||||
|
video_concurrency: New video concurrency limit (-1 for no limit)
|
||||||
|
"""
|
||||||
|
async with self._lock:
|
||||||
|
if image_concurrency > 0:
|
||||||
|
self._image_concurrency[token_id] = image_concurrency
|
||||||
|
elif token_id in self._image_concurrency:
|
||||||
|
del self._image_concurrency[token_id]
|
||||||
|
|
||||||
|
if video_concurrency > 0:
|
||||||
|
self._video_concurrency[token_id] = video_concurrency
|
||||||
|
elif token_id in self._video_concurrency:
|
||||||
|
del self._video_concurrency[token_id]
|
||||||
|
|
||||||
|
debug_logger.log_info(f"Token {token_id} concurrency reset (image: {image_concurrency}, video: {video_concurrency})")
|
||||||
|
|
||||||
@@ -11,6 +11,7 @@ from .sora_client import SoraClient
|
|||||||
from .token_manager import TokenManager
|
from .token_manager import TokenManager
|
||||||
from .load_balancer import LoadBalancer
|
from .load_balancer import LoadBalancer
|
||||||
from .file_cache import FileCache
|
from .file_cache import FileCache
|
||||||
|
from .concurrency_manager import ConcurrencyManager
|
||||||
from ..core.database import Database
|
from ..core.database import Database
|
||||||
from ..core.models import Task, RequestLog
|
from ..core.models import Task, RequestLog
|
||||||
from ..core.config import config
|
from ..core.config import config
|
||||||
@@ -71,11 +72,13 @@ class GenerationHandler:
|
|||||||
"""Handle generation requests"""
|
"""Handle generation requests"""
|
||||||
|
|
||||||
def __init__(self, sora_client: SoraClient, token_manager: TokenManager,
|
def __init__(self, sora_client: SoraClient, token_manager: TokenManager,
|
||||||
load_balancer: LoadBalancer, db: Database, proxy_manager=None):
|
load_balancer: LoadBalancer, db: Database, proxy_manager=None,
|
||||||
|
concurrency_manager: Optional[ConcurrencyManager] = None):
|
||||||
self.sora_client = sora_client
|
self.sora_client = sora_client
|
||||||
self.token_manager = token_manager
|
self.token_manager = token_manager
|
||||||
self.load_balancer = load_balancer
|
self.load_balancer = load_balancer
|
||||||
self.db = db
|
self.db = db
|
||||||
|
self.concurrency_manager = concurrency_manager
|
||||||
self.file_cache = FileCache(
|
self.file_cache = FileCache(
|
||||||
cache_dir="tmp",
|
cache_dir="tmp",
|
||||||
default_timeout=config.cache_timeout,
|
default_timeout=config.cache_timeout,
|
||||||
@@ -287,6 +290,19 @@ class GenerationHandler:
|
|||||||
if not lock_acquired:
|
if not lock_acquired:
|
||||||
raise Exception(f"Failed to acquire lock for token {token_obj.id}")
|
raise Exception(f"Failed to acquire lock for token {token_obj.id}")
|
||||||
|
|
||||||
|
# Acquire concurrency slot for image generation
|
||||||
|
if self.concurrency_manager:
|
||||||
|
concurrency_acquired = await self.concurrency_manager.acquire_image(token_obj.id)
|
||||||
|
if not concurrency_acquired:
|
||||||
|
await self.load_balancer.token_lock.release_lock(token_obj.id)
|
||||||
|
raise Exception(f"Failed to acquire concurrency slot for token {token_obj.id}")
|
||||||
|
|
||||||
|
# Acquire concurrency slot for video generation
|
||||||
|
if is_video and self.concurrency_manager:
|
||||||
|
concurrency_acquired = await self.concurrency_manager.acquire_video(token_obj.id)
|
||||||
|
if not concurrency_acquired:
|
||||||
|
raise Exception(f"Failed to acquire concurrency slot for token {token_obj.id}")
|
||||||
|
|
||||||
task_id = None
|
task_id = None
|
||||||
is_first_chunk = True # Track if this is the first chunk
|
is_first_chunk = True # Track if this is the first chunk
|
||||||
|
|
||||||
@@ -364,6 +380,13 @@ class GenerationHandler:
|
|||||||
# Release lock for image generation
|
# Release lock for image generation
|
||||||
if is_image:
|
if is_image:
|
||||||
await self.load_balancer.token_lock.release_lock(token_obj.id)
|
await self.load_balancer.token_lock.release_lock(token_obj.id)
|
||||||
|
# Release concurrency slot for image generation
|
||||||
|
if self.concurrency_manager:
|
||||||
|
await self.concurrency_manager.release_image(token_obj.id)
|
||||||
|
|
||||||
|
# Release concurrency slot for video generation
|
||||||
|
if is_video and self.concurrency_manager:
|
||||||
|
await self.concurrency_manager.release_video(token_obj.id)
|
||||||
|
|
||||||
# Log successful request
|
# Log successful request
|
||||||
duration = time.time() - start_time
|
duration = time.time() - start_time
|
||||||
@@ -380,6 +403,13 @@ class GenerationHandler:
|
|||||||
# Release lock for image generation on error
|
# Release lock for image generation on error
|
||||||
if is_image and token_obj:
|
if is_image and token_obj:
|
||||||
await self.load_balancer.token_lock.release_lock(token_obj.id)
|
await self.load_balancer.token_lock.release_lock(token_obj.id)
|
||||||
|
# Release concurrency slot for image generation
|
||||||
|
if self.concurrency_manager:
|
||||||
|
await self.concurrency_manager.release_image(token_obj.id)
|
||||||
|
|
||||||
|
# Release concurrency slot for video generation on error
|
||||||
|
if is_video and token_obj and self.concurrency_manager:
|
||||||
|
await self.concurrency_manager.release_video(token_obj.id)
|
||||||
|
|
||||||
# Record error
|
# Record error
|
||||||
if token_obj:
|
if token_obj:
|
||||||
@@ -431,6 +461,15 @@ class GenerationHandler:
|
|||||||
if not is_video and token_id:
|
if not is_video and token_id:
|
||||||
await self.load_balancer.token_lock.release_lock(token_id)
|
await self.load_balancer.token_lock.release_lock(token_id)
|
||||||
debug_logger.log_info(f"Released lock for token {token_id} due to timeout")
|
debug_logger.log_info(f"Released lock for token {token_id} due to timeout")
|
||||||
|
# Release concurrency slot for image generation
|
||||||
|
if self.concurrency_manager:
|
||||||
|
await self.concurrency_manager.release_image(token_id)
|
||||||
|
debug_logger.log_info(f"Released concurrency slot for token {token_id} due to timeout")
|
||||||
|
|
||||||
|
# Release concurrency slot for video generation
|
||||||
|
if is_video and token_id and self.concurrency_manager:
|
||||||
|
await self.concurrency_manager.release_video(token_id)
|
||||||
|
debug_logger.log_info(f"Released concurrency slot for token {token_id} due to timeout")
|
||||||
|
|
||||||
await self.db.update_task(task_id, "failed", 0, error_message=f"Generation timeout after {elapsed_time:.1f} seconds")
|
await self.db.update_task(task_id, "failed", 0, error_message=f"Generation timeout after {elapsed_time:.1f} seconds")
|
||||||
raise Exception(f"Upstream API timeout: Generation exceeded {timeout} seconds limit")
|
raise Exception(f"Upstream API timeout: Generation exceeded {timeout} seconds limit")
|
||||||
@@ -783,6 +822,15 @@ class GenerationHandler:
|
|||||||
if not is_video and token_id:
|
if not is_video and token_id:
|
||||||
await self.load_balancer.token_lock.release_lock(token_id)
|
await self.load_balancer.token_lock.release_lock(token_id)
|
||||||
debug_logger.log_info(f"Released lock for token {token_id} due to max attempts reached")
|
debug_logger.log_info(f"Released lock for token {token_id} due to max attempts reached")
|
||||||
|
# Release concurrency slot for image generation
|
||||||
|
if self.concurrency_manager:
|
||||||
|
await self.concurrency_manager.release_image(token_id)
|
||||||
|
debug_logger.log_info(f"Released concurrency slot for token {token_id} due to max attempts reached")
|
||||||
|
|
||||||
|
# Release concurrency slot for video generation
|
||||||
|
if is_video and token_id and self.concurrency_manager:
|
||||||
|
await self.concurrency_manager.release_video(token_id)
|
||||||
|
debug_logger.log_info(f"Released concurrency slot for token {token_id} due to max attempts reached")
|
||||||
|
|
||||||
await self.db.update_task(task_id, "failed", 0, error_message=f"Generation timeout after {timeout} seconds")
|
await self.db.update_task(task_id, "failed", 0, error_message=f"Generation timeout after {timeout} seconds")
|
||||||
raise Exception(f"Upstream API timeout: Generation exceeded {timeout} seconds limit")
|
raise Exception(f"Upstream API timeout: Generation exceeded {timeout} seconds limit")
|
||||||
|
|||||||
@@ -5,12 +5,15 @@ from ..core.models import Token
|
|||||||
from ..core.config import config
|
from ..core.config import config
|
||||||
from .token_manager import TokenManager
|
from .token_manager import TokenManager
|
||||||
from .token_lock import TokenLock
|
from .token_lock import TokenLock
|
||||||
|
from .concurrency_manager import ConcurrencyManager
|
||||||
|
from ..core.logger import debug_logger
|
||||||
|
|
||||||
class LoadBalancer:
|
class LoadBalancer:
|
||||||
"""Token load balancer with random selection and image generation lock"""
|
"""Token load balancer with random selection and image generation lock"""
|
||||||
|
|
||||||
def __init__(self, token_manager: TokenManager):
|
def __init__(self, token_manager: TokenManager, concurrency_manager: Optional[ConcurrencyManager] = None):
|
||||||
self.token_manager = token_manager
|
self.token_manager = token_manager
|
||||||
|
self.concurrency_manager = concurrency_manager
|
||||||
# Use image timeout from config as lock timeout
|
# Use image timeout from config as lock timeout
|
||||||
self.token_lock = TokenLock(lock_timeout=config.image_timeout)
|
self.token_lock = TokenLock(lock_timeout=config.image_timeout)
|
||||||
|
|
||||||
@@ -27,7 +30,11 @@ class LoadBalancer:
|
|||||||
"""
|
"""
|
||||||
# Try to auto-refresh tokens expiring within 24 hours if enabled
|
# Try to auto-refresh tokens expiring within 24 hours if enabled
|
||||||
if config.at_auto_refresh_enabled:
|
if config.at_auto_refresh_enabled:
|
||||||
|
debug_logger.log_info(f"[LOAD_BALANCER] 🔄 自动刷新功能已启用,开始检查Token过期时间...")
|
||||||
all_tokens = await self.token_manager.get_all_tokens()
|
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:
|
for token in all_tokens:
|
||||||
if token.is_active and token.expiry_time:
|
if token.is_active and token.expiry_time:
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -35,8 +42,15 @@ class LoadBalancer:
|
|||||||
hours_until_expiry = time_until_expiry.total_seconds() / 3600
|
hours_until_expiry = time_until_expiry.total_seconds() / 3600
|
||||||
# Refresh if expiry is within 24 hours
|
# Refresh if expiry is within 24 hours
|
||||||
if hours_until_expiry <= 24:
|
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)
|
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()
|
active_tokens = await self.token_manager.get_active_tokens()
|
||||||
|
|
||||||
if not active_tokens:
|
if not active_tokens:
|
||||||
@@ -82,6 +96,9 @@ class LoadBalancer:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if not await self.token_lock.is_locked(token.id):
|
if not await self.token_lock.is_locked(token.id):
|
||||||
|
# Check concurrency limit if concurrency manager is available
|
||||||
|
if self.concurrency_manager and not await self.concurrency_manager.can_use_image(token.id):
|
||||||
|
continue
|
||||||
available_tokens.append(token)
|
available_tokens.append(token)
|
||||||
|
|
||||||
if not available_tokens:
|
if not available_tokens:
|
||||||
@@ -90,5 +107,15 @@ class LoadBalancer:
|
|||||||
# Random selection from available tokens
|
# Random selection from available tokens
|
||||||
return random.choice(available_tokens)
|
return random.choice(available_tokens)
|
||||||
else:
|
else:
|
||||||
# For video generation, no lock needed
|
# For video generation, check concurrency limit
|
||||||
return random.choice(active_tokens)
|
if for_video_generation and self.concurrency_manager:
|
||||||
|
available_tokens = []
|
||||||
|
for token in active_tokens:
|
||||||
|
if await self.concurrency_manager.can_use_video(token.id):
|
||||||
|
available_tokens.append(token)
|
||||||
|
if not available_tokens:
|
||||||
|
return None
|
||||||
|
return random.choice(available_tokens)
|
||||||
|
else:
|
||||||
|
# For video generation without concurrency manager, no additional filtering
|
||||||
|
return random.choice(active_tokens)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from ..core.database import Database
|
|||||||
from ..core.models import Token, TokenStats
|
from ..core.models import Token, TokenStats
|
||||||
from ..core.config import config
|
from ..core.config import config
|
||||||
from .proxy_manager import ProxyManager
|
from .proxy_manager import ProxyManager
|
||||||
|
from ..core.logger import debug_logger
|
||||||
|
|
||||||
class TokenManager:
|
class TokenManager:
|
||||||
"""Token lifecycle manager"""
|
"""Token lifecycle manager"""
|
||||||
@@ -416,6 +417,7 @@ class TokenManager:
|
|||||||
|
|
||||||
async def st_to_at(self, session_token: str) -> dict:
|
async def st_to_at(self, session_token: str) -> dict:
|
||||||
"""Convert Session Token to Access Token"""
|
"""Convert Session Token to Access Token"""
|
||||||
|
debug_logger.log_info(f"[ST_TO_AT] 开始转换 Session Token 为 Access Token...")
|
||||||
proxy_url = await self.proxy_manager.get_proxy_url()
|
proxy_url = await self.proxy_manager.get_proxy_url()
|
||||||
|
|
||||||
async with AsyncSession() as session:
|
async with AsyncSession() as session:
|
||||||
@@ -434,24 +436,68 @@ class TokenManager:
|
|||||||
|
|
||||||
if proxy_url:
|
if proxy_url:
|
||||||
kwargs["proxy"] = proxy_url
|
kwargs["proxy"] = proxy_url
|
||||||
|
debug_logger.log_info(f"[ST_TO_AT] 使用代理: {proxy_url}")
|
||||||
|
|
||||||
response = await session.get(
|
url = "https://sora.chatgpt.com/api/auth/session"
|
||||||
"https://sora.chatgpt.com/api/auth/session",
|
debug_logger.log_info(f"[ST_TO_AT] 📡 请求 URL: {url}")
|
||||||
**kwargs
|
|
||||||
)
|
|
||||||
|
|
||||||
if response.status_code != 200:
|
try:
|
||||||
raise ValueError(f"Failed to convert ST to AT: {response.status_code}")
|
response = await session.get(url, **kwargs)
|
||||||
|
debug_logger.log_info(f"[ST_TO_AT] 📥 响应状态码: {response.status_code}")
|
||||||
|
|
||||||
data = response.json()
|
if response.status_code != 200:
|
||||||
return {
|
error_msg = f"Failed to convert ST to AT: {response.status_code}"
|
||||||
"access_token": data.get("accessToken"),
|
debug_logger.log_info(f"[ST_TO_AT] ❌ {error_msg}")
|
||||||
"email": data.get("user", {}).get("email"),
|
debug_logger.log_info(f"[ST_TO_AT] 响应内容: {response.text[:500]}")
|
||||||
"expires": data.get("expires")
|
raise ValueError(error_msg)
|
||||||
}
|
|
||||||
|
# 获取响应文本用于调试
|
||||||
|
response_text = response.text
|
||||||
|
debug_logger.log_info(f"[ST_TO_AT] 📄 响应内容: {response_text[:500]}")
|
||||||
|
|
||||||
|
# 检查响应是否为空
|
||||||
|
if not response_text or response_text.strip() == "":
|
||||||
|
debug_logger.log_info(f"[ST_TO_AT] ❌ 响应体为空")
|
||||||
|
raise ValueError("Response body is empty")
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
except Exception as json_err:
|
||||||
|
debug_logger.log_info(f"[ST_TO_AT] ❌ JSON解析失败: {str(json_err)}")
|
||||||
|
debug_logger.log_info(f"[ST_TO_AT] 原始响应: {response_text[:1000]}")
|
||||||
|
raise ValueError(f"Failed to parse JSON response: {str(json_err)}")
|
||||||
|
|
||||||
|
# 检查data是否为None
|
||||||
|
if data is None:
|
||||||
|
debug_logger.log_info(f"[ST_TO_AT] ❌ 响应JSON为空")
|
||||||
|
raise ValueError("Response JSON is empty")
|
||||||
|
|
||||||
|
access_token = data.get("accessToken")
|
||||||
|
email = data.get("user", {}).get("email") if data.get("user") else None
|
||||||
|
expires = data.get("expires")
|
||||||
|
|
||||||
|
# 检查必要字段
|
||||||
|
if not access_token:
|
||||||
|
debug_logger.log_info(f"[ST_TO_AT] ❌ 响应中缺少 accessToken 字段")
|
||||||
|
debug_logger.log_info(f"[ST_TO_AT] 响应数据: {data}")
|
||||||
|
raise ValueError("Missing accessToken in response")
|
||||||
|
|
||||||
|
debug_logger.log_info(f"[ST_TO_AT] ✅ ST 转换成功")
|
||||||
|
debug_logger.log_info(f" - Email: {email}")
|
||||||
|
debug_logger.log_info(f" - 过期时间: {expires}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"access_token": access_token,
|
||||||
|
"email": email,
|
||||||
|
"expires": expires
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
debug_logger.log_info(f"[ST_TO_AT] 🔴 异常: {str(e)}")
|
||||||
|
raise
|
||||||
|
|
||||||
async def rt_to_at(self, refresh_token: str) -> dict:
|
async def rt_to_at(self, refresh_token: str) -> dict:
|
||||||
"""Convert Refresh Token to Access Token"""
|
"""Convert Refresh Token to Access Token"""
|
||||||
|
debug_logger.log_info(f"[RT_TO_AT] 开始转换 Refresh Token 为 Access Token...")
|
||||||
proxy_url = await self.proxy_manager.get_proxy_url()
|
proxy_url = await self.proxy_manager.get_proxy_url()
|
||||||
|
|
||||||
async with AsyncSession() as session:
|
async with AsyncSession() as session:
|
||||||
@@ -474,21 +520,64 @@ class TokenManager:
|
|||||||
|
|
||||||
if proxy_url:
|
if proxy_url:
|
||||||
kwargs["proxy"] = proxy_url
|
kwargs["proxy"] = proxy_url
|
||||||
|
debug_logger.log_info(f"[RT_TO_AT] 使用代理: {proxy_url}")
|
||||||
|
|
||||||
response = await session.post(
|
url = "https://auth.openai.com/oauth/token"
|
||||||
"https://auth.openai.com/oauth/token",
|
debug_logger.log_info(f"[RT_TO_AT] 📡 请求 URL: {url}")
|
||||||
**kwargs
|
|
||||||
)
|
|
||||||
|
|
||||||
if response.status_code != 200:
|
try:
|
||||||
raise ValueError(f"Failed to convert RT to AT: {response.status_code} - {response.text}")
|
response = await session.post(url, **kwargs)
|
||||||
|
debug_logger.log_info(f"[RT_TO_AT] 📥 响应状态码: {response.status_code}")
|
||||||
|
|
||||||
data = response.json()
|
if response.status_code != 200:
|
||||||
return {
|
error_msg = f"Failed to convert RT to AT: {response.status_code}"
|
||||||
"access_token": data.get("access_token"),
|
debug_logger.log_info(f"[RT_TO_AT] ❌ {error_msg}")
|
||||||
"refresh_token": data.get("refresh_token"),
|
debug_logger.log_info(f"[RT_TO_AT] 响应内容: {response.text[:500]}")
|
||||||
"expires_in": data.get("expires_in")
|
raise ValueError(f"{error_msg} - {response.text}")
|
||||||
}
|
|
||||||
|
# 获取响应文本用于调试
|
||||||
|
response_text = response.text
|
||||||
|
debug_logger.log_info(f"[RT_TO_AT] 📄 响应内容: {response_text[:500]}")
|
||||||
|
|
||||||
|
# 检查响应是否为空
|
||||||
|
if not response_text or response_text.strip() == "":
|
||||||
|
debug_logger.log_info(f"[RT_TO_AT] ❌ 响应体为空")
|
||||||
|
raise ValueError("Response body is empty")
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
except Exception as json_err:
|
||||||
|
debug_logger.log_info(f"[RT_TO_AT] ❌ JSON解析失败: {str(json_err)}")
|
||||||
|
debug_logger.log_info(f"[RT_TO_AT] 原始响应: {response_text[:1000]}")
|
||||||
|
raise ValueError(f"Failed to parse JSON response: {str(json_err)}")
|
||||||
|
|
||||||
|
# 检查data是否为None
|
||||||
|
if data is None:
|
||||||
|
debug_logger.log_info(f"[RT_TO_AT] ❌ 响应JSON为空")
|
||||||
|
raise ValueError("Response JSON is empty")
|
||||||
|
|
||||||
|
access_token = data.get("access_token")
|
||||||
|
new_refresh_token = data.get("refresh_token")
|
||||||
|
expires_in = data.get("expires_in")
|
||||||
|
|
||||||
|
# 检查必要字段
|
||||||
|
if not access_token:
|
||||||
|
debug_logger.log_info(f"[RT_TO_AT] ❌ 响应中缺少 access_token 字段")
|
||||||
|
debug_logger.log_info(f"[RT_TO_AT] 响应数据: {data}")
|
||||||
|
raise ValueError("Missing access_token in response")
|
||||||
|
|
||||||
|
debug_logger.log_info(f"[RT_TO_AT] ✅ RT 转换成功")
|
||||||
|
debug_logger.log_info(f" - 新 Access Token 有效期: {expires_in} 秒")
|
||||||
|
debug_logger.log_info(f" - Refresh Token 已更新: {'是' if new_refresh_token else '否'}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"access_token": access_token,
|
||||||
|
"refresh_token": new_refresh_token,
|
||||||
|
"expires_in": expires_in
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
debug_logger.log_info(f"[RT_TO_AT] 🔴 异常: {str(e)}")
|
||||||
|
raise
|
||||||
|
|
||||||
async def add_token(self, token_value: str,
|
async def add_token(self, token_value: str,
|
||||||
st: Optional[str] = None,
|
st: Optional[str] = None,
|
||||||
@@ -496,7 +585,9 @@ class TokenManager:
|
|||||||
remark: Optional[str] = None,
|
remark: Optional[str] = None,
|
||||||
update_if_exists: bool = False,
|
update_if_exists: bool = False,
|
||||||
image_enabled: bool = True,
|
image_enabled: bool = True,
|
||||||
video_enabled: bool = True) -> Token:
|
video_enabled: bool = True,
|
||||||
|
image_concurrency: int = -1,
|
||||||
|
video_concurrency: int = -1) -> Token:
|
||||||
"""Add a new Access Token to database
|
"""Add a new Access Token to database
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -507,6 +598,8 @@ class TokenManager:
|
|||||||
update_if_exists: If True, update existing token instead of raising error
|
update_if_exists: If True, update existing token instead of raising error
|
||||||
image_enabled: Enable image generation (default: True)
|
image_enabled: Enable image generation (default: True)
|
||||||
video_enabled: Enable video generation (default: True)
|
video_enabled: Enable video generation (default: True)
|
||||||
|
image_concurrency: Image concurrency limit (-1 for no limit)
|
||||||
|
video_concurrency: Video concurrency limit (-1 for no limit)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Token object
|
Token object
|
||||||
@@ -640,7 +733,9 @@ class TokenManager:
|
|||||||
sora2_total_count=sora2_total_count,
|
sora2_total_count=sora2_total_count,
|
||||||
sora2_remaining_count=sora2_remaining_count,
|
sora2_remaining_count=sora2_remaining_count,
|
||||||
image_enabled=image_enabled,
|
image_enabled=image_enabled,
|
||||||
video_enabled=video_enabled
|
video_enabled=video_enabled,
|
||||||
|
image_concurrency=image_concurrency,
|
||||||
|
video_concurrency=video_concurrency
|
||||||
)
|
)
|
||||||
|
|
||||||
# Save to database
|
# Save to database
|
||||||
@@ -712,8 +807,10 @@ class TokenManager:
|
|||||||
rt: Optional[str] = None,
|
rt: Optional[str] = None,
|
||||||
remark: Optional[str] = None,
|
remark: Optional[str] = None,
|
||||||
image_enabled: Optional[bool] = None,
|
image_enabled: Optional[bool] = None,
|
||||||
video_enabled: Optional[bool] = None):
|
video_enabled: Optional[bool] = None,
|
||||||
"""Update token (AT, ST, RT, remark, image_enabled, video_enabled)"""
|
image_concurrency: Optional[int] = None,
|
||||||
|
video_concurrency: Optional[int] = None):
|
||||||
|
"""Update token (AT, ST, RT, remark, image_enabled, video_enabled, concurrency limits)"""
|
||||||
# If token (AT) is updated, decode JWT to get new expiry time
|
# If token (AT) is updated, decode JWT to get new expiry time
|
||||||
expiry_time = None
|
expiry_time = None
|
||||||
if token:
|
if token:
|
||||||
@@ -724,7 +821,8 @@ class TokenManager:
|
|||||||
pass # If JWT decode fails, keep expiry_time as None
|
pass # If JWT decode fails, keep expiry_time as None
|
||||||
|
|
||||||
await self.db.update_token(token_id, token=token, st=st, rt=rt, remark=remark, expiry_time=expiry_time,
|
await self.db.update_token(token_id, token=token, st=st, rt=rt, remark=remark, expiry_time=expiry_time,
|
||||||
image_enabled=image_enabled, video_enabled=video_enabled)
|
image_enabled=image_enabled, video_enabled=video_enabled,
|
||||||
|
image_concurrency=image_concurrency, video_concurrency=video_concurrency)
|
||||||
|
|
||||||
async def get_active_tokens(self) -> List[Token]:
|
async def get_active_tokens(self) -> List[Token]:
|
||||||
"""Get all active tokens (not cooled down)"""
|
"""Get all active tokens (not cooled down)"""
|
||||||
@@ -880,68 +978,104 @@ class TokenManager:
|
|||||||
True if refresh successful, False otherwise
|
True if refresh successful, False otherwise
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
# 📍 Step 1: 获取Token数据
|
||||||
|
debug_logger.log_info(f"[AUTO_REFRESH] 开始检查Token {token_id}...")
|
||||||
token_data = await self.db.get_token(token_id)
|
token_data = await self.db.get_token(token_id)
|
||||||
|
|
||||||
if not token_data:
|
if not token_data:
|
||||||
|
debug_logger.log_info(f"[AUTO_REFRESH] ❌ Token {token_id} 不存在")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Check if token is expiring within 24 hours
|
# 📍 Step 2: 检查是否有过期时间
|
||||||
if not token_data.expiry_time:
|
if not token_data.expiry_time:
|
||||||
|
debug_logger.log_info(f"[AUTO_REFRESH] ⏭️ Token {token_id} 无过期时间,跳过刷新")
|
||||||
return False # No expiry time set
|
return False # No expiry time set
|
||||||
|
|
||||||
|
# 📍 Step 3: 计算剩余时间
|
||||||
time_until_expiry = token_data.expiry_time - datetime.now()
|
time_until_expiry = token_data.expiry_time - datetime.now()
|
||||||
hours_until_expiry = time_until_expiry.total_seconds() / 3600
|
hours_until_expiry = time_until_expiry.total_seconds() / 3600
|
||||||
|
|
||||||
# Only refresh if expiry is within 24 hours (1440 minutes)
|
debug_logger.log_info(f"[AUTO_REFRESH] ⏰ Token {token_id} 信息:")
|
||||||
|
debug_logger.log_info(f" - Email: {token_data.email}")
|
||||||
|
debug_logger.log_info(f" - 过期时间: {token_data.expiry_time.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||||
|
debug_logger.log_info(f" - 剩余时间: {hours_until_expiry:.2f} 小时")
|
||||||
|
debug_logger.log_info(f" - 是否激活: {token_data.is_active}")
|
||||||
|
debug_logger.log_info(f" - 有ST: {'是' if token_data.st else '否'}")
|
||||||
|
debug_logger.log_info(f" - 有RT: {'是' if token_data.rt else '否'}")
|
||||||
|
|
||||||
|
# 📍 Step 4: 检查是否需要刷新
|
||||||
if hours_until_expiry > 24:
|
if hours_until_expiry > 24:
|
||||||
|
debug_logger.log_info(f"[AUTO_REFRESH] ⏭️ Token {token_id} 剩余时间 > 24小时,无需刷新")
|
||||||
return False # Token not expiring soon
|
return False # Token not expiring soon
|
||||||
|
|
||||||
|
# 📍 Step 5: 触发刷新
|
||||||
if hours_until_expiry < 0:
|
if hours_until_expiry < 0:
|
||||||
# Token already expired, still try to refresh
|
debug_logger.log_info(f"[AUTO_REFRESH] 🔴 Token {token_id} 已过期,尝试自动刷新...")
|
||||||
print(f"🔄 Token {token_id} 已过期,尝试自动刷新...")
|
|
||||||
else:
|
else:
|
||||||
print(f"🔄 Token {token_id} 将在 {hours_until_expiry:.1f} 小时后过期,尝试自动刷新...")
|
debug_logger.log_info(f"[AUTO_REFRESH] 🟡 Token {token_id} 将在 {hours_until_expiry:.2f} 小时后过期,尝试自动刷新...")
|
||||||
|
|
||||||
# Priority: ST > RT
|
# Priority: ST > RT
|
||||||
new_at = None
|
new_at = None
|
||||||
new_st = None
|
new_st = None
|
||||||
new_rt = None
|
new_rt = None
|
||||||
|
refresh_method = None
|
||||||
|
|
||||||
|
# 📍 Step 6: 尝试使用ST刷新
|
||||||
if token_data.st:
|
if token_data.st:
|
||||||
# Try to refresh using ST
|
|
||||||
try:
|
try:
|
||||||
print(f"📝 使用 ST 刷新 Token {token_id}...")
|
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)
|
||||||
new_at = result.get("access_token")
|
new_at = result.get("access_token")
|
||||||
# ST refresh doesn't return new ST, so keep the old one
|
new_st = token_data.st # ST refresh doesn't return new ST, so keep the old one
|
||||||
new_st = token_data.st
|
refresh_method = "ST"
|
||||||
print(f"✅ 使用 ST 刷新成功")
|
debug_logger.log_info(f"[AUTO_REFRESH] ✅ Token {token_id}: 使用 ST 刷新成功")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ 使用 ST 刷新失败: {e}")
|
debug_logger.log_info(f"[AUTO_REFRESH] ❌ Token {token_id}: 使用 ST 刷新失败 - {str(e)}")
|
||||||
new_at = None
|
new_at = None
|
||||||
|
|
||||||
|
# 📍 Step 7: 如果ST失败,尝试使用RT
|
||||||
if not new_at and token_data.rt:
|
if not new_at and token_data.rt:
|
||||||
# Try to refresh using RT
|
|
||||||
try:
|
try:
|
||||||
print(f"📝 使用 RT 刷新 Token {token_id}...")
|
debug_logger.log_info(f"[AUTO_REFRESH] 📝 Token {token_id}: 尝试使用 RT 刷新...")
|
||||||
result = await self.rt_to_at(token_data.rt)
|
result = await self.rt_to_at(token_data.rt)
|
||||||
new_at = result.get("access_token")
|
new_at = result.get("access_token")
|
||||||
new_rt = result.get("refresh_token", token_data.rt) # RT might be updated
|
new_rt = result.get("refresh_token", token_data.rt) # RT might be updated
|
||||||
print(f"✅ 使用 RT 刷新成功")
|
refresh_method = "RT"
|
||||||
|
debug_logger.log_info(f"[AUTO_REFRESH] ✅ Token {token_id}: 使用 RT 刷新成功")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ 使用 RT 刷新失败: {e}")
|
debug_logger.log_info(f"[AUTO_REFRESH] ❌ Token {token_id}: 使用 RT 刷新失败 - {str(e)}")
|
||||||
new_at = None
|
new_at = None
|
||||||
|
|
||||||
|
# 📍 Step 8: 处理刷新结果
|
||||||
if new_at:
|
if new_at:
|
||||||
# Update token with new AT
|
# 刷新成功: 更新Token
|
||||||
|
debug_logger.log_info(f"[AUTO_REFRESH] 💾 Token {token_id}: 保存新的 Access Token...")
|
||||||
await self.update_token(token_id, token=new_at, st=new_st, rt=new_rt)
|
await self.update_token(token_id, token=new_at, st=new_st, rt=new_rt)
|
||||||
print(f"✅ Token {token_id} 已自动刷新")
|
|
||||||
|
# 获取更新后的Token信息
|
||||||
|
updated_token = await self.db.get_token(token_id)
|
||||||
|
new_expiry_time = updated_token.expiry_time
|
||||||
|
new_hours_until_expiry = ((new_expiry_time - datetime.now()).total_seconds() / 3600) if new_expiry_time else -1
|
||||||
|
|
||||||
|
debug_logger.log_info(f"[AUTO_REFRESH] ✅ Token {token_id} 已自动刷新成功")
|
||||||
|
debug_logger.log_info(f" - 刷新方式: {refresh_method}")
|
||||||
|
debug_logger.log_info(f" - 新过期时间: {new_expiry_time.strftime('%Y-%m-%d %H:%M:%S') if new_expiry_time else 'N/A'}")
|
||||||
|
debug_logger.log_info(f" - 新剩余时间: {new_hours_until_expiry:.2f} 小时")
|
||||||
|
|
||||||
|
# 📍 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)
|
||||||
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
# No ST or RT, disable token
|
# 刷新失败: 禁用Token
|
||||||
print(f"⚠️ Token {token_id} 无法刷新(无 ST 或 RT),已禁用")
|
debug_logger.log_info(f"[AUTO_REFRESH] 🚫 Token {token_id}: 无法刷新(无有效的 ST 或 RT),已禁用")
|
||||||
await self.disable_token(token_id)
|
await self.disable_token(token_id)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ 自动刷新 Token {token_id} 失败: {e}")
|
debug_logger.log_info(f"[AUTO_REFRESH] 🔴 Token {token_id}: 自动刷新异常 - {str(e)}")
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -100,6 +100,22 @@
|
|||||||
<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"/>
|
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<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"/>
|
||||||
|
<polyline points="7 10 12 15 17 10"/>
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm font-medium">导出</span>
|
||||||
|
</button>
|
||||||
|
<button onclick="openImportModal()" class="inline-flex items-center justify-center rounded-md bg-green-600 text-white hover:bg-green-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"/>
|
||||||
|
<polyline points="17 8 12 3 7 8"/>
|
||||||
|
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm font-medium">导入</span>
|
||||||
|
</button>
|
||||||
<button onclick="openAddModal()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-8 px-3">
|
<button onclick="openAddModal()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-8 px-3">
|
||||||
<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">
|
<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">
|
||||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||||
@@ -414,16 +430,22 @@
|
|||||||
<div class="space-y-3 pt-2 border-t border-border">
|
<div class="space-y-3 pt-2 border-t border-border">
|
||||||
<label class="text-sm font-medium">功能开关</label>
|
<label class="text-sm font-medium">功能开关</label>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<label class="inline-flex items-center gap-2 cursor-pointer">
|
<div class="flex items-center gap-3">
|
||||||
<input type="checkbox" id="addTokenImageEnabled" checked class="h-4 w-4 rounded border-input">
|
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||||||
<span class="text-sm font-medium">启用图片生成</span>
|
<input type="checkbox" id="addTokenImageEnabled" checked class="h-4 w-4 rounded border-input">
|
||||||
</label>
|
<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表示不限制">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<label class="inline-flex items-center gap-2 cursor-pointer">
|
<div class="flex items-center gap-3">
|
||||||
<input type="checkbox" id="addTokenVideoEnabled" checked class="h-4 w-4 rounded border-input">
|
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||||||
<span class="text-sm font-medium">启用视频生成</span>
|
<input type="checkbox" id="addTokenVideoEnabled" checked class="h-4 w-4 rounded border-input">
|
||||||
</label>
|
<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表示不限制">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -497,16 +519,22 @@
|
|||||||
<div class="space-y-3 pt-2 border-t border-border">
|
<div class="space-y-3 pt-2 border-t border-border">
|
||||||
<label class="text-sm font-medium">功能开关</label>
|
<label class="text-sm font-medium">功能开关</label>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<label class="inline-flex items-center gap-2 cursor-pointer">
|
<div class="flex items-center gap-3">
|
||||||
<input type="checkbox" id="editTokenImageEnabled" class="h-4 w-4 rounded border-input">
|
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||||||
<span class="text-sm font-medium">启用图片生成</span>
|
<input type="checkbox" id="editTokenImageEnabled" class="h-4 w-4 rounded border-input">
|
||||||
</label>
|
<span class="text-sm font-medium">启用图片生成</span>
|
||||||
|
</label>
|
||||||
|
<input type="number" id="editTokenImageConcurrency" 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>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<label class="inline-flex items-center gap-2 cursor-pointer">
|
<div class="flex items-center gap-3">
|
||||||
<input type="checkbox" id="editTokenVideoEnabled" class="h-4 w-4 rounded border-input">
|
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||||||
<span class="text-sm font-medium">启用视频生成</span>
|
<input type="checkbox" id="editTokenVideoEnabled" class="h-4 w-4 rounded border-input">
|
||||||
</label>
|
<span class="text-sm font-medium">启用视频生成</span>
|
||||||
|
</label>
|
||||||
|
<input type="number" id="editTokenVideoConcurrency" 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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -556,6 +584,43 @@
|
|||||||
</div>
|
</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">
|
||||||
|
<div class="flex items-center justify-between p-5 border-b border-border">
|
||||||
|
<h3 class="text-lg font-semibold">导入 Token</h3>
|
||||||
|
<button onclick="closeImportModal()" 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">选择 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>
|
||||||
|
<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-800 dark:text-blue-200">
|
||||||
|
<strong>说明:</strong>如果邮箱存在则会覆盖更新,不存在则会新增
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-end gap-3 p-5 border-t border-border">
|
||||||
|
<button onclick="closeImportModal()" class="inline-flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent h-9 px-5">取消</button>
|
||||||
|
<button id="importBtn" onclick="submitImportTokens()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-5">
|
||||||
|
<span id="importBtnText">导入</span>
|
||||||
|
<svg id="importBtnSpinner" 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>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
let allTokens=[];
|
let allTokens=[];
|
||||||
const $=(id)=>document.getElementById(id),
|
const $=(id)=>document.getElementById(id),
|
||||||
@@ -571,15 +636,15 @@
|
|||||||
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}`:'-';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 ${t.is_active?'bg-green-50 text-green-700':'bg-gray-100 text-gray-700'}">${t.is_active?'活跃':'禁用'}</span></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 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}`:'-';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 ${t.is_active?'bg-green-50 text-green-700':'bg-gray-100 text-gray-700'}">${t.is_active?'活跃':'禁用'}</span></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('')},
|
||||||
refreshTokens=async()=>{await loadTokens();await loadStats()},
|
refreshTokens=async()=>{await loadTokens();await loadStats()},
|
||||||
openAddModal=()=>$('addModal').classList.remove('hidden'),
|
openAddModal=()=>$('addModal').classList.remove('hidden'),
|
||||||
closeAddModal=()=>{$('addModal').classList.add('hidden');$('addTokenAT').value='';$('addTokenST').value='';$('addTokenRT').value='';$('addTokenRemark').value='';$('addTokenImageEnabled').checked=true;$('addTokenVideoEnabled').checked=true;$('addRTRefreshHint').classList.add('hidden')},
|
closeAddModal=()=>{$('addModal').classList.add('hidden');$('addTokenAT').value='';$('addTokenST').value='';$('addTokenRT').value='';$('addTokenRemark').value='';$('addTokenImageEnabled').checked=true;$('addTokenVideoEnabled').checked=true;$('addTokenImageConcurrency').value='-1';$('addTokenVideoConcurrency').value='-1';$('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||'';$('editTokenRemark').value=token.remark||'';$('editTokenImageEnabled').checked=token.image_enabled!==false;$('editTokenVideoEnabled').checked=token.video_enabled!==false;$('editModal').classList.remove('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||'';$('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='';$('editTokenRemark').value='';$('editTokenImageEnabled').checked=true;$('editTokenVideoEnabled').checked=true;$('editRTRefreshHint').classList.add('hidden')},
|
closeEditModal=()=>{$('editModal').classList.add('hidden');$('editTokenId').value='';$('editTokenAT').value='';$('editTokenST').value='';$('editTokenRT').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(),remark=$('editTokenRemark').value.trim(),imageEnabled=$('editTokenImageEnabled').checked,videoEnabled=$('editTokenVideoEnabled').checked;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,remark:remark||null,image_enabled:imageEnabled,video_enabled:videoEnabled})});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')}},
|
submitEditToken=async()=>{const id=parseInt($('editTokenId').value),at=$('editTokenAT').value.trim(),st=$('editTokenST').value.trim(),rt=$('editTokenRT').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,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')}},
|
||||||
convertST2AT=async()=>{const st=$('addTokenST').value.trim();if(!st)return showToast('请先输入 Session Token','error');try{showToast('正在转换 ST→AT...','info');const r=await apiRequest('/api/tokens/st2at',{method:'POST',body:JSON.stringify({st:st})});if(!r)return;const d=await r.json();if(d.success&&d.access_token){$('addTokenAT').value=d.access_token;showToast('转换成功!AT已自动填入','success')}else{showToast('转换失败: '+(d.message||d.detail||'未知错误'),'error')}}catch(e){showToast('转换失败: '+e.message,'error')}},
|
convertST2AT=async()=>{const st=$('addTokenST').value.trim();if(!st)return showToast('请先输入 Session Token','error');try{showToast('正在转换 ST→AT...','info');const r=await apiRequest('/api/tokens/st2at',{method:'POST',body:JSON.stringify({st:st})});if(!r)return;const d=await r.json();if(d.success&&d.access_token){$('addTokenAT').value=d.access_token;showToast('转换成功!AT已自动填入','success')}else{showToast('转换失败: '+(d.message||d.detail||'未知错误'),'error')}}catch(e){showToast('转换失败: '+e.message,'error')}},
|
||||||
convertRT2AT=async()=>{const rt=$('addTokenRT').value.trim();if(!rt)return showToast('请先输入 Refresh Token','error');const hint=$('addRTRefreshHint');hint.classList.add('hidden');try{showToast('正在转换 RT→AT...','info');const r=await apiRequest('/api/tokens/rt2at',{method:'POST',body:JSON.stringify({rt:rt})});if(!r)return;const d=await r.json();if(d.success&&d.access_token){$('addTokenAT').value=d.access_token;if(d.refresh_token){$('addTokenRT').value=d.refresh_token;hint.classList.remove('hidden');showToast('转换成功!AT已自动填入,RT已被刷新并更新','success')}else{showToast('转换成功!AT已自动填入','success')}}else{showToast('转换失败: '+(d.message||d.detail||'未知错误'),'error')}}catch(e){showToast('转换失败: '+e.message,'error')}},
|
convertRT2AT=async()=>{const rt=$('addTokenRT').value.trim();if(!rt)return showToast('请先输入 Refresh Token','error');const hint=$('addRTRefreshHint');hint.classList.add('hidden');try{showToast('正在转换 RT→AT...','info');const r=await apiRequest('/api/tokens/rt2at',{method:'POST',body:JSON.stringify({rt:rt})});if(!r)return;const d=await r.json();if(d.success&&d.access_token){$('addTokenAT').value=d.access_token;if(d.refresh_token){$('addTokenRT').value=d.refresh_token;hint.classList.remove('hidden');showToast('转换成功!AT已自动填入,RT已被刷新并更新','success')}else{showToast('转换成功!AT已自动填入','success')}}else{showToast('转换失败: '+(d.message||d.detail||'未知错误'),'error')}}catch(e){showToast('转换失败: '+e.message,'error')}},
|
||||||
convertEditST2AT=async()=>{const st=$('editTokenST').value.trim();if(!st)return showToast('请先输入 Session Token','error');try{showToast('正在转换 ST→AT...','info');const r=await apiRequest('/api/tokens/st2at',{method:'POST',body:JSON.stringify({st:st})});if(!r)return;const d=await r.json();if(d.success&&d.access_token){$('editTokenAT').value=d.access_token;showToast('转换成功!AT已自动填入','success')}else{showToast('转换失败: '+(d.message||d.detail||'未知错误'),'error')}}catch(e){showToast('转换失败: '+e.message,'error')}},
|
convertEditST2AT=async()=>{const st=$('editTokenST').value.trim();if(!st)return showToast('请先输入 Session Token','error');try{showToast('正在转换 ST→AT...','info');const r=await apiRequest('/api/tokens/st2at',{method:'POST',body:JSON.stringify({st:st})});if(!r)return;const d=await r.json();if(d.success&&d.access_token){$('editTokenAT').value=d.access_token;showToast('转换成功!AT已自动填入','success')}else{showToast('转换失败: '+(d.message||d.detail||'未知错误'),'error')}}catch(e){showToast('转换失败: '+e.message,'error')}},
|
||||||
convertEditRT2AT=async()=>{const rt=$('editTokenRT').value.trim();if(!rt)return showToast('请先输入 Refresh Token','error');const hint=$('editRTRefreshHint');hint.classList.add('hidden');try{showToast('正在转换 RT→AT...','info');const r=await apiRequest('/api/tokens/rt2at',{method:'POST',body:JSON.stringify({rt:rt})});if(!r)return;const d=await r.json();if(d.success&&d.access_token){$('editTokenAT').value=d.access_token;if(d.refresh_token){$('editTokenRT').value=d.refresh_token;hint.classList.remove('hidden');showToast('转换成功!AT已自动填入,RT已被刷新并更新','success')}else{showToast('转换成功!AT已自动填入','success')}}else{showToast('转换失败: '+(d.message||d.detail||'未知错误'),'error')}}catch(e){showToast('转换失败: '+e.message,'error')}},
|
convertEditRT2AT=async()=>{const rt=$('editTokenRT').value.trim();if(!rt)return showToast('请先输入 Refresh Token','error');const hint=$('editRTRefreshHint');hint.classList.add('hidden');try{showToast('正在转换 RT→AT...','info');const r=await apiRequest('/api/tokens/rt2at',{method:'POST',body:JSON.stringify({rt:rt})});if(!r)return;const d=await r.json();if(d.success&&d.access_token){$('editTokenAT').value=d.access_token;if(d.refresh_token){$('editTokenRT').value=d.refresh_token;hint.classList.remove('hidden');showToast('转换成功!AT已自动填入,RT已被刷新并更新','success')}else{showToast('转换成功!AT已自动填入','success')}}else{showToast('转换失败: '+(d.message||d.detail||'未知错误'),'error')}}catch(e){showToast('转换失败: '+e.message,'error')}},
|
||||||
submitAddToken=async()=>{const at=$('addTokenAT').value.trim(),st=$('addTokenST').value.trim(),rt=$('addTokenRT').value.trim(),remark=$('addTokenRemark').value.trim(),imageEnabled=$('addTokenImageEnabled').checked,videoEnabled=$('addTokenVideoEnabled').checked;if(!at)return showToast('请输入 Access Token 或使用 ST/RT 转换','error');const btn=$('addTokenBtn'),btnText=$('addTokenBtnText'),btnSpinner=$('addTokenBtnSpinner');btn.disabled=true;btnText.textContent='添加中...';btnSpinner.classList.remove('hidden');try{const r=await apiRequest('/api/tokens',{method:'POST',body:JSON.stringify({token:at,st:st||null,rt:rt||null,remark:remark||null,image_enabled:imageEnabled,video_enabled:videoEnabled})});if(!r){btn.disabled=false;btnText.textContent='添加';btnSpinner.classList.add('hidden');return}if(r.status===409){const d=await r.json();const msg=d.detail||'Token 已存在';btn.disabled=false;btnText.textContent='添加';btnSpinner.classList.add('hidden');if(confirm(msg+'\n\n是否删除旧 Token 后重新添加?')){const existingToken=allTokens.find(t=>t.token===at);if(existingToken){const deleted=await deleteToken(existingToken.id,true);if(deleted){showToast('正在重新添加...','info');setTimeout(()=>submitAddToken(),500)}else{showToast('删除旧 Token 失败','error')}}}return}const d=await r.json();if(d.success){closeAddModal();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')}},
|
submitAddToken=async()=>{const at=$('addTokenAT').value.trim(),st=$('addTokenST').value.trim(),rt=$('addTokenRT').value.trim(),remark=$('addTokenRemark').value.trim(),imageEnabled=$('addTokenImageEnabled').checked,videoEnabled=$('addTokenVideoEnabled').checked,imageConcurrency=parseInt($('addTokenImageConcurrency').value)||(-1),videoConcurrency=parseInt($('addTokenVideoConcurrency').value)||(-1);if(!at)return showToast('请输入 Access Token 或使用 ST/RT 转换','error');const btn=$('addTokenBtn'),btnText=$('addTokenBtnText'),btnSpinner=$('addTokenBtnSpinner');btn.disabled=true;btnText.textContent='添加中...';btnSpinner.classList.remove('hidden');try{const r=await apiRequest('/api/tokens',{method:'POST',body:JSON.stringify({token:at,st:st||null,rt:rt||null,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}if(r.status===409){const d=await r.json();const msg=d.detail||'Token 已存在';btn.disabled=false;btnText.textContent='添加';btnSpinner.classList.add('hidden');if(confirm(msg+'\n\n是否删除旧 Token 后重新添加?')){const existingToken=allTokens.find(t=>t.token===at);if(existingToken){const deleted=await deleteToken(existingToken.id,true);if(deleted){showToast('正在重新添加...','info');setTimeout(()=>submitAddToken(),500)}else{showToast('删除旧 Token 失败','error')}}}return}const d=await r.json();if(d.success){closeAddModal();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')}},
|
||||||
testToken=async(id)=>{try{showToast('正在测试Token...','info');const r=await apiRequest(`/api/tokens/${id}/test`,{method:'POST'});if(!r)return;const d=await r.json();if(d.success&&d.status==='success'){let msg=`Token有效!用户: ${d.email||'未知'}`;if(d.sora2_supported){const remaining=d.sora2_total_count-d.sora2_redeemed_count;msg+=`\nSora2: 支持 (${remaining}/${d.sora2_total_count})`;if(d.sora2_remaining_count!==undefined){msg+=`\n可用次数: ${d.sora2_remaining_count}`}}showToast(msg,'success');await refreshTokens()}else{showToast(`Token无效: ${d.message||'未知错误'}`,'error')}}catch(e){showToast('测试失败: '+e.message,'error')}},
|
testToken=async(id)=>{try{showToast('正在测试Token...','info');const r=await apiRequest(`/api/tokens/${id}/test`,{method:'POST'});if(!r)return;const d=await r.json();if(d.success&&d.status==='success'){let msg=`Token有效!用户: ${d.email||'未知'}`;if(d.sora2_supported){const remaining=d.sora2_total_count-d.sora2_redeemed_count;msg+=`\nSora2: 支持 (${remaining}/${d.sora2_total_count})`;if(d.sora2_remaining_count!==undefined){msg+=`\n可用次数: ${d.sora2_remaining_count}`}}showToast(msg,'success');await refreshTokens()}else{showToast(`Token无效: ${d.message||'未知错误'}`,'error')}}catch(e){showToast('测试失败: '+e.message,'error')}},
|
||||||
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')}},
|
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')}},
|
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')}},
|
||||||
@@ -587,6 +652,10 @@
|
|||||||
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')}},
|
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')},
|
openSora2Modal=(id)=>{$('sora2TokenId').value=id;$('sora2InviteCode').value='';$('sora2Modal').classList.remove('hidden')},
|
||||||
closeSora2Modal=()=>{$('sora2Modal').classList.add('hidden');$('sora2TokenId').value='';$('sora2InviteCode').value=''},
|
closeSora2Modal=()=>{$('sora2Modal').classList.add('hidden');$('sora2TokenId').value='';$('sora2InviteCode').value=''},
|
||||||
|
openImportModal=()=>{$('importModal').classList.remove('hidden');$('importFile').value=''},
|
||||||
|
closeImportModal=()=>{$('importModal').classList.add('hidden');$('importFile').value=''},
|
||||||
|
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,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')},
|
||||||
|
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}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}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})});if(!r){btn.disabled=false;btnText.textContent='导入';btnSpinner.classList.add('hidden');return}const d=await r.json();if(d.success){closeImportModal();await refreshTokens();const msg=`导入成功!新增: ${d.added||0}, 更新: ${d.updated||0}`;showToast(msg,'success')}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')}},
|
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)}},
|
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')}},
|
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')}},
|
||||||
|
|||||||
Reference in New Issue
Block a user