Compare commits

...

5 Commits

Author SHA1 Message Date
TheSmallHanCat
ac9fb944d6 Merge branch 'main' of https://github.com/TheSmallHanCat/sora2api 2026-01-11 15:40:11 +08:00
TheSmallHanCat
2d3aeff8df Merge pull request #57 from genz27/main
feat:调整剩余次数获取和视频生成请求UA,estimated_num_videos_remaining返回30
2026-01-11 15:39:48 +08:00
TheSmallHanCat
b23f60e66b feat: 新增Token批量管理功能(测试更新/批量启用/清理禁用) 2026-01-11 15:34:02 +08:00
TheSmallHanCat
fb0569c298 feat: 优化CF429错误处理及超时日志记录 2026-01-11 13:04:19 +08:00
genz27
ff25c88d3f feat:调整剩余次数获取和视频生成请求UA,estimated_num_videos_remaining返回30 2026-01-11 02:33:10 +08:00
6 changed files with 252 additions and 21 deletions

View File

@@ -348,6 +348,79 @@ async def delete_token(token_id: int, token: str = Depends(verify_admin_token)):
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/api/tokens/batch/test-update")
async def batch_test_update(token: str = Depends(verify_admin_token)):
"""Test and update all tokens by fetching their status from upstream"""
try:
tokens = await db.get_all_tokens()
success_count = 0
failed_count = 0
results = []
for token_obj in tokens:
try:
# Test token and update account info (same as single test)
result = await token_manager.test_token(token_obj.id)
if result.get("valid"):
success_count += 1
results.append({"id": token_obj.id, "email": token_obj.email, "status": "success"})
else:
failed_count += 1
results.append({"id": token_obj.id, "email": token_obj.email, "status": "failed", "message": result.get("message")})
except Exception as e:
failed_count += 1
results.append({"id": token_obj.id, "email": token_obj.email, "status": "error", "message": str(e)})
return {
"success": True,
"message": f"测试完成:成功 {success_count} 个,失败 {failed_count}",
"success_count": success_count,
"failed_count": failed_count,
"results": results
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/tokens/batch/enable-all")
async def batch_enable_all(token: str = Depends(verify_admin_token)):
"""Enable all disabled tokens"""
try:
tokens = await db.get_all_tokens()
enabled_count = 0
for token_obj in tokens:
if not token_obj.is_active:
await token_manager.enable_token(token_obj.id)
enabled_count += 1
return {
"success": True,
"message": f"已启用 {enabled_count} 个禁用的Token",
"enabled_count": enabled_count
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/tokens/batch/delete-disabled")
async def batch_delete_disabled(token: str = Depends(verify_admin_token)):
"""Delete all disabled tokens"""
try:
tokens = await db.get_all_tokens()
deleted_count = 0
for token_obj in tokens:
if not token_obj.is_active:
await token_manager.delete_token(token_obj.id)
deleted_count += 1
return {
"success": True,
"message": f"已删除 {deleted_count} 个禁用的Token",
"deleted_count": deleted_count
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/tokens/import")
async def import_tokens(request: ImportTokensRequest, token: str = Depends(verify_admin_token)):
"""Import tokens with different modes: offline/at/st/rt"""

View File

@@ -533,7 +533,7 @@ class GenerationHandler:
await self.token_manager.record_usage(token_obj.id, is_video=is_video)
# Poll for results with timeout
async for chunk in self._poll_task_result(task_id, token_obj.token, is_video, stream, prompt, token_obj.id):
async for chunk in self._poll_task_result(task_id, token_obj.token, is_video, stream, prompt, token_obj.id, log_id, start_time):
yield chunk
# Record success
@@ -591,12 +591,6 @@ class GenerationHandler:
if is_video and token_obj and self.concurrency_manager:
await self.concurrency_manager.release_video(token_obj.id)
# Record error (check if it's an overload error)
if token_obj:
error_str = str(e).lower()
is_overload = "heavy_load" in error_str or "under heavy load" in error_str
await self.token_manager.record_error(token_obj.id, is_overload=is_overload)
# Parse error message to check if it's a structured error (JSON)
error_response = None
try:
@@ -604,15 +598,31 @@ class GenerationHandler:
except:
pass
# Check for CF shield/429 error
is_cf_or_429 = False
if error_response and isinstance(error_response, dict):
error_info = error_response.get("error", {})
if error_info.get("code") == "cf_shield_429":
is_cf_or_429 = True
# Record error (check if it's an overload error or CF/429 error)
if token_obj:
error_str = str(e).lower()
is_overload = "heavy_load" in error_str or "under heavy load" in error_str
# Don't record error for CF shield/429 (not token's fault)
if not is_cf_or_429:
await self.token_manager.record_error(token_obj.id, is_overload=is_overload)
# Update log entry with error data
duration = time.time() - start_time
if log_id:
if error_response:
# Structured error (e.g., unsupported_country_code)
# Structured error (e.g., unsupported_country_code, cf_shield_429)
status_code = 429 if is_cf_or_429 else 400
await self.db.update_request_log(
log_id,
response_body=json.dumps(error_response),
status_code=400,
status_code=status_code,
duration=duration
)
else:
@@ -626,7 +636,8 @@ class GenerationHandler:
raise e
async def _poll_task_result(self, task_id: str, token: str, is_video: bool,
stream: bool, prompt: str, token_id: int = None) -> AsyncGenerator[str, None]:
stream: bool, prompt: str, token_id: int = None,
log_id: int = None, start_time: float = None) -> AsyncGenerator[str, None]:
"""Poll for task result with timeout"""
# Get timeout from config
timeout = config.video_timeout if is_video else config.image_timeout
@@ -669,7 +680,19 @@ class GenerationHandler:
await self.concurrency_manager.release_video(token_id)
debug_logger.log_info(f"Released concurrency slot for token {token_id} due to timeout")
# Update task status to failed
await self.db.update_task(task_id, "failed", 0, error_message=f"Generation timeout after {elapsed_time:.1f} seconds")
# Update request log with timeout error
if log_id and start_time:
duration = time.time() - start_time
await self.db.update_request_log(
log_id,
response_body=json.dumps({"error": f"Generation timeout after {elapsed_time:.1f} seconds"}),
status_code=408,
duration=duration
)
raise Exception(f"Upstream API timeout: Generation exceeded {timeout} seconds limit")
@@ -1057,6 +1080,61 @@ class GenerationHandler:
)
except Exception as e:
# Check for CF shield/429 error - don't retry these
error_str = str(e)
is_cf_or_429 = False
try:
error_response = json.loads(error_str)
if isinstance(error_response, dict):
error_info = error_response.get("error", {})
if error_info.get("code") == "cf_shield_429":
is_cf_or_429 = True
except (json.JSONDecodeError, ValueError):
pass
# CF shield/429 detected - fail immediately
if is_cf_or_429:
debug_logger.log_error(
error_message="CF Shield/429 detected during polling, failing task immediately",
status_code=429,
response_text=error_str
)
# Update task status to failed
await self.db.update_task(task_id, "failed", 0, error_message="Cloudflare challenge or rate limit (429) triggered")
# Update request log with CF/429 error
if log_id and start_time:
duration = time.time() - start_time
await self.db.update_request_log(
log_id,
response_body=json.dumps({"error": "Cloudflare challenge or rate limit (429) triggered"}),
status_code=429,
duration=duration
)
# Release resources
if not is_video and token_id:
await self.load_balancer.token_lock.release_lock(token_id)
if self.concurrency_manager:
await self.concurrency_manager.release_image(token_id)
if is_video and token_id and self.concurrency_manager:
await self.concurrency_manager.release_video(token_id)
# Send error message to client if streaming
if stream:
yield self._format_stream_chunk(
reasoning_content="**CF Shield/429 Error**\\n\\nCloudflare challenge or rate limit (429) triggered\\n"
)
yield self._format_stream_chunk(
content="❌ Generation failed: Cloudflare challenge or rate limit (429) triggered. Please change proxy or reduce request frequency.",
finish_reason="STOP"
)
yield "data: [DONE]\\n\\n"
# Exit polling immediately
return
# For other errors, retry if not last attempt
if attempt >= max_attempts - 1:
raise e
continue
@@ -1315,6 +1393,20 @@ class GenerationHandler:
yield "data: [DONE]\n\n"
except Exception as e:
# Parse error to check for CF shield/429
error_response = None
try:
error_response = json.loads(str(e))
except:
pass
# Check for CF shield/429 error
is_cf_or_429 = False
if error_response and isinstance(error_response, dict):
error_info = error_response.get("error", {})
if error_info.get("code") == "cf_shield_429":
is_cf_or_429 = True
# Log failed character creation
duration = time.time() - start_time
await self._log_request(
@@ -1328,13 +1420,21 @@ class GenerationHandler:
"success": False,
"error": str(e)
},
status_code=500,
status_code=429 if is_cf_or_429 else 500,
duration=duration
)
# Record error (check if it's an overload error or CF/429 error)
if token_obj:
error_str = str(e).lower()
is_overload = "heavy_load" in error_str or "under heavy load" in error_str
# Don't record error for CF shield/429 (not token's fault)
if not is_cf_or_429:
await self.token_manager.record_error(token_obj.id, is_overload=is_overload)
debug_logger.log_error(
error_message=f"Character creation failed: {str(e)}",
status_code=500,
status_code=429 if is_cf_or_429 else 500,
response_text=str(e)
)
raise
@@ -1531,14 +1631,30 @@ class GenerationHandler:
duration=duration
)
# Record error (check if it's an overload error)
# Parse error to check for CF shield/429
error_response = None
try:
error_response = json.loads(str(e))
except:
pass
# Check for CF shield/429 error
is_cf_or_429 = False
if error_response and isinstance(error_response, dict):
error_info = error_response.get("error", {})
if error_info.get("code") == "cf_shield_429":
is_cf_or_429 = True
# Record error (check if it's an overload error or CF/429 error)
if token_obj:
error_str = str(e).lower()
is_overload = "heavy_load" in error_str or "under heavy load" in error_str
await self.token_manager.record_error(token_obj.id, is_overload=is_overload)
# Don't record error for CF shield/429 (not token's fault)
if not is_cf_or_429:
await self.token_manager.record_error(token_obj.id, is_overload=is_overload)
debug_logger.log_error(
error_message=f"Character and video generation failed: {str(e)}",
status_code=500,
status_code=429 if is_cf_or_429 else 500,
response_text=str(e)
)
raise
@@ -1624,14 +1740,30 @@ class GenerationHandler:
await self.token_manager.record_success(token_obj.id, is_video=True)
except Exception as e:
# Record error (check if it's an overload error)
# Parse error to check for CF shield/429
error_response = None
try:
error_response = json.loads(str(e))
except:
pass
# Check for CF shield/429 error
is_cf_or_429 = False
if error_response and isinstance(error_response, dict):
error_info = error_response.get("error", {})
if error_info.get("code") == "cf_shield_429":
is_cf_or_429 = True
# Record error (check if it's an overload error or CF/429 error)
if token_obj:
error_str = str(e).lower()
is_overload = "heavy_load" in error_str or "under heavy load" in error_str
await self.token_manager.record_error(token_obj.id, is_overload=is_overload)
# Don't record error for CF shield/429 (not token's fault)
if not is_cf_or_429:
await self.token_manager.record_error(token_obj.id, is_overload=is_overload)
debug_logger.log_error(
error_message=f"Remix generation failed: {str(e)}",
status_code=500,
status_code=429 if is_cf_or_429 else 500,
response_text=str(e)
)
raise

View File

@@ -284,7 +284,8 @@ class SoraClient:
proxy_url = await self.proxy_manager.get_proxy_url(token_id)
headers = {
"Authorization": f"Bearer {token}"
"Authorization": f"Bearer {token}",
"User-Agent" : "Sora/1.2026.007 (Android 15; 24122RKC7C; build 2600700)"
}
# 只在生成请求时添加 sentinel token

View File

@@ -289,7 +289,8 @@ class TokenManager:
async with AsyncSession() as session:
headers = {
"Authorization": f"Bearer {access_token}",
"Accept": "application/json"
"Accept": "application/json",
"User-Agent" : "Sora/1.2026.007 (Android 15; 24122RKC7C; build 2600700)"
}
kwargs = {

View File

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

View File

@@ -101,6 +101,27 @@
<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
</svg>
</button>
<button onclick="batchTestUpdate()" class="inline-flex items-center justify-center rounded-md bg-purple-600 text-white hover:bg-purple-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="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
<span class="text-sm font-medium">测试更新</span>
</button>
<button onclick="batchEnableAll()" class="inline-flex items-center justify-center rounded-md bg-teal-600 text-white hover:bg-teal-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="M9 11l3 3L22 4"/>
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>
</svg>
<span class="text-sm font-medium">批量启用</span>
</button>
<button onclick="batchDeleteDisabled()" class="inline-flex items-center justify-center rounded-md bg-red-600 text-white hover:bg-red-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">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
<span class="text-sm font-medium">清理禁用</span>
</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"/>
@@ -789,6 +810,9 @@
closeImportProgressModal=()=>{$('importProgressModal').classList.add('hidden')},
showImportProgress=(results,added,updated,failed)=>{const summary=$('importProgressSummary'),list=$('importProgressList');summary.innerHTML=`<div class="text-sm"><strong>导入完成</strong><br/>新增: ${added} | 更新: ${updated} | 失败: ${failed}</div>`;list.innerHTML=results.map(r=>{const statusColor=r.success?(r.status==='added'?'text-green-600':'text-blue-600'):'text-red-600';const statusText=r.status==='added'?'新增':r.status==='updated'?'更新':'失败';return`<div class="p-3 rounded-md border ${r.success?'bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800':'bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800'}"><div class="flex items-center justify-between"><span class="font-medium">${r.email}</span><span class="${statusColor} text-sm font-medium">${statusText}</span></div>${r.error?`<div class="text-xs text-red-600 dark:text-red-400 mt-1">${r.error}</div>`:''}</div>`}).join('');openImportProgressModal()},
exportTokens=()=>{if(allTokens.length===0){showToast('没有Token可导出','error');return}const exportData=allTokens.map(t=>({email:t.email,access_token:t.token,session_token:t.st||null,refresh_token:t.rt||null,client_id:t.client_id||null,proxy_url:t.proxy_url||null,remark:t.remark||null,is_active:t.is_active,image_enabled:t.image_enabled!==false,video_enabled:t.video_enabled!==false,image_concurrency:t.image_concurrency||(-1),video_concurrency:t.video_concurrency||(-1)}));const dataStr=JSON.stringify(exportData,null,2);const dataBlob=new Blob([dataStr],{type:'application/json'});const url=URL.createObjectURL(dataBlob);const link=document.createElement('a');link.href=url;link.download=`tokens_${new Date().toISOString().split('T')[0]}.json`;document.body.appendChild(link);link.click();document.body.removeChild(link);URL.revokeObjectURL(url);showToast(`已导出 ${allTokens.length} 个Token`,'success')},
batchTestUpdate=async()=>{if(!confirm('⚠️ 警告\n\n此操作将请求上游获取所有Token的状态信息可能需要较长时间。\n\n确定要继续吗')){return}showToast('正在测试更新所有Token...','info');try{const r=await apiRequest('/api/tokens/batch/test-update',{method:'POST'});if(!r)return;const d=await r.json();if(d.success){await refreshTokens();showToast(d.message,'success')}else{showToast('测试更新失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('测试更新失败: '+e.message,'error')}},
batchEnableAll=async()=>{const disabledCount=allTokens.filter(t=>!t.is_active).length;if(disabledCount===0){showToast('没有禁用的Token','info');return}if(!confirm(`确定要启用所有 ${disabledCount} 个禁用的Token吗\n\n此操作将重置这些Token的错误计数。`)){return}showToast('正在批量启用Token...','info');try{const r=await apiRequest('/api/tokens/batch/enable-all',{method:'POST'});if(!r)return;const d=await r.json();if(d.success){await refreshTokens();showToast(d.message,'success')}else{showToast('批量启用失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('批量启用失败: '+e.message,'error')}},
batchDeleteDisabled=async()=>{const disabledCount=allTokens.filter(t=>!t.is_active).length;if(disabledCount===0){showToast('没有禁用的Token','info');return}if(!confirm(`⚠️ 第一次确认\n\n即将删除所有 ${disabledCount} 个禁用的Token。\n\n此操作不可恢复!确定要继续吗?`)){return}if(!confirm(`⚠️ 第二次确认\n\n你真的确定要删除这 ${disabledCount} 个禁用的Token吗\n\n删除后无法恢复!`)){return}if(!confirm(`⚠️ 最后确认\n\n这是最后一次确认!\n\n删除 ${disabledCount} 个禁用的Token后将无法恢复。\n\n确定要执行删除操作吗?`)){return}showToast('正在清理禁用Token...','info');try{const r=await apiRequest('/api/tokens/batch/delete-disabled',{method:'POST'});if(!r)return;const d=await r.json();if(d.success){await refreshTokens();showToast(d.message,'success')}else{showToast('清理失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('清理失败: '+e.message,'error')}},
updateImportModeHint=()=>{const mode=$('importMode').value,hint=$('importModeHint'),hints={at:'使用AT更新账号状态订阅信息、Sora2次数等',offline:'离线导入,不更新账号状态,动态字段显示为-',st:'自动将ST转换为AT然后更新账号状态',rt:'自动将RT转换为AT并刷新RT然后更新账号状态'};hint.textContent=hints[mode]||''},
submitImportTokens=async()=>{const fileInput=$('importFile');if(!fileInput.files||fileInput.files.length===0){showToast('请选择文件','error');return}const file=fileInput.files[0];if(!file.name.endsWith('.json')){showToast('请选择JSON文件','error');return}const mode=$('importMode').value;try{const fileContent=await file.text();const importData=JSON.parse(fileContent);if(!Array.isArray(importData)){showToast('JSON格式错误应为数组','error');return}if(importData.length===0){showToast('JSON文件为空','error');return}for(let item of importData){if(!item.email){showToast('导入数据缺少必填字段: email','error');return}if(mode==='offline'||mode==='at'){if(!item.access_token){showToast(`${item.email} 缺少必填字段: access_token`,'error');return}}else if(mode==='st'){if(!item.session_token){showToast(`${item.email} 缺少必填字段: session_token`,'error');return}}else if(mode==='rt'){if(!item.refresh_token){showToast(`${item.email} 缺少必填字段: refresh_token`,'error');return}}}const btn=$('importBtn'),btnText=$('importBtnText'),btnSpinner=$('importBtnSpinner');btn.disabled=true;btnText.textContent='导入中...';btnSpinner.classList.remove('hidden');try{const r=await apiRequest('/api/tokens/import',{method:'POST',body:JSON.stringify({tokens:importData,mode:mode})});if(!r){btn.disabled=false;btnText.textContent='导入';btnSpinner.classList.add('hidden');return}const d=await r.json();if(d.success){closeImportModal();await refreshTokens();showImportProgress(d.results||[],d.added||0,d.updated||0,d.failed||0)}else{showToast('导入失败: '+(d.detail||d.message||'未知错误'),'error')}}catch(e){showToast('导入失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='导入';btnSpinner.classList.add('hidden')}}catch(e){showToast('文件解析失败: '+e.message,'error')}},
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')}},