diff --git a/config/setting.toml b/config/setting.toml index 2ec16ae..6250d6b 100644 --- a/config/setting.toml +++ b/config/setting.toml @@ -34,6 +34,8 @@ error_ban_threshold = 3 # 任务失败重试配置 task_retry_enabled = true task_max_retries = 3 +# 401错误自动禁用token +auto_disable_on_401 = true [proxy] proxy_enabled = false diff --git a/src/api/admin.py b/src/api/admin.py index 67fe24d..641fd37 100644 --- a/src/api/admin.py +++ b/src/api/admin.py @@ -119,6 +119,7 @@ class UpdateAdminConfigRequest(BaseModel): error_ban_threshold: int task_retry_enabled: Optional[bool] = None task_max_retries: Optional[int] = None + auto_disable_on_401: Optional[bool] = None class UpdateProxyConfigRequest(BaseModel): proxy_enabled: bool @@ -682,6 +683,7 @@ async def get_admin_config(token: str = Depends(verify_admin_token)) -> dict: "error_ban_threshold": admin_config.error_ban_threshold, "task_retry_enabled": admin_config.task_retry_enabled, "task_max_retries": admin_config.task_max_retries, + "auto_disable_on_401": admin_config.auto_disable_on_401, "api_key": config.api_key, "admin_username": config.admin_username, "debug_enabled": config.debug_enabled @@ -705,6 +707,8 @@ async def update_admin_config( current_config.task_retry_enabled = request.task_retry_enabled if request.task_max_retries is not None: current_config.task_max_retries = request.task_max_retries + if request.auto_disable_on_401 is not None: + current_config.auto_disable_on_401 = request.auto_disable_on_401 await db.update_admin_config(current_config) return {"success": True, "message": "Configuration updated"} @@ -941,8 +945,8 @@ async def get_logs(limit: int = 100, token: str = Depends(verify_admin_token)): "task_id": log.get("task_id") } - # If task_id exists and status is in-progress, get task progress - if log.get("task_id") and log.get("status_code") == -1: + # If task_id exists, get task progress and status + if log.get("task_id"): task = await db.get_task(log.get("task_id")) if task: log_data["progress"] = task.progress diff --git a/src/core/database.py b/src/core/database.py index 12682b9..e57d145 100644 --- a/src/core/database.py +++ b/src/core/database.py @@ -57,6 +57,7 @@ class Database: error_ban_threshold = 3 task_retry_enabled = True task_max_retries = 3 + auto_disable_on_401 = True if config_dict: global_config = config_dict.get("global", {}) @@ -68,11 +69,12 @@ class Database: error_ban_threshold = admin_config.get("error_ban_threshold", 3) task_retry_enabled = admin_config.get("task_retry_enabled", True) task_max_retries = admin_config.get("task_max_retries", 3) + auto_disable_on_401 = admin_config.get("auto_disable_on_401", True) await db.execute(""" - INSERT INTO admin_config (id, admin_username, admin_password, api_key, error_ban_threshold, task_retry_enabled, task_max_retries) - VALUES (1, ?, ?, ?, ?, ?, ?) - """, (admin_username, admin_password, api_key, error_ban_threshold, task_retry_enabled, task_max_retries)) + INSERT INTO admin_config (id, admin_username, admin_password, api_key, error_ban_threshold, task_retry_enabled, task_max_retries, auto_disable_on_401) + VALUES (1, ?, ?, ?, ?, ?, ?, ?) + """, (admin_username, admin_password, api_key, error_ban_threshold, task_retry_enabled, task_max_retries, auto_disable_on_401)) # Ensure proxy_config has a row cursor = await db.execute("SELECT COUNT(*) FROM proxy_config") @@ -477,6 +479,8 @@ class Database: await db.execute("ALTER TABLE admin_config ADD COLUMN task_retry_enabled BOOLEAN DEFAULT 1") if not await self._column_exists(db, "admin_config", "task_max_retries"): await db.execute("ALTER TABLE admin_config ADD COLUMN task_max_retries INTEGER DEFAULT 3") + if not await self._column_exists(db, "admin_config", "auto_disable_on_401"): + await db.execute("ALTER TABLE admin_config ADD COLUMN auto_disable_on_401 BOOLEAN DEFAULT 1") await db.commit() diff --git a/src/core/models.py b/src/core/models.py index c79a85f..9442e33 100644 --- a/src/core/models.py +++ b/src/core/models.py @@ -92,6 +92,7 @@ class AdminConfig(BaseModel): error_ban_threshold: int = 3 task_retry_enabled: bool = True # 是否启用任务失败重试 task_max_retries: int = 3 # 任务最大重试次数 + auto_disable_on_401: bool = True # 遇到401错误自动禁用token updated_at: Optional[datetime] = None class ProxyConfig(BaseModel): diff --git a/src/services/generation_handler.py b/src/services/generation_handler.py index 69fe567..d32a047 100644 --- a/src/services/generation_handler.py +++ b/src/services/generation_handler.py @@ -17,6 +17,13 @@ from ..core.models import Task, RequestLog from ..core.config import config from ..core.logger import debug_logger +# Custom exception to carry token_id information +class GenerationError(Exception): + """Custom exception for generation errors that includes token_id""" + def __init__(self, message: str, token_id: Optional[int] = None): + super().__init__(message) + self.token_id = token_id + # Model configuration MODEL_CONFIG = { "gpt-image": { @@ -726,7 +733,11 @@ class GenerationHandler: status_code=500, duration=duration ) - raise e + # Wrap exception with token_id information + if token_obj: + raise GenerationError(str(e), token_id=token_obj.id) + else: + raise e async def handle_generation_with_retry(self, model: str, prompt: str, image: Optional[str] = None, @@ -747,9 +758,11 @@ class GenerationHandler: admin_config = await self.db.get_admin_config() retry_enabled = admin_config.task_retry_enabled max_retries = admin_config.task_max_retries if retry_enabled else 0 + auto_disable_on_401 = admin_config.auto_disable_on_401 retry_count = 0 last_error = None + last_token_id = None # Track the token that caused the error while retry_count <= max_retries: try: @@ -761,6 +774,30 @@ class GenerationHandler: except Exception as e: last_error = e + error_str = str(e) + + # Extract token_id from GenerationError if available + if isinstance(e, GenerationError) and e.token_id: + last_token_id = e.token_id + + # Check if this is a 401 error + is_401_error = "401" in error_str or "unauthorized" in error_str.lower() or "token_invalidated" in error_str.lower() + + # If 401 error and auto-disable is enabled, disable the token + if is_401_error and auto_disable_on_401 and last_token_id: + debug_logger.log_info(f"Detected 401 error, auto-disabling token {last_token_id}") + try: + await self.db.update_token_status(last_token_id, False) + if stream: + yield self._format_stream_chunk( + reasoning_content=f"**检测到401错误,已自动禁用Token {last_token_id}**\\n\\n正在使用其他Token重试...\\n\\n" + ) + except Exception as disable_error: + debug_logger.log_error( + error_message=f"Failed to disable token {last_token_id}: {str(disable_error)}", + status_code=500, + response_text=str(disable_error) + ) # Check if we should retry should_retry = ( diff --git a/static/manage.html b/static/manage.html index 82ed5a9..9996bd4 100644 --- a/static/manage.html +++ b/static/manage.html @@ -382,6 +382,13 @@

任务失败后最多重试的次数(1-10次)

+
+ +

当Token返回401错误时,自动禁用该Token并使用其他Token重试

+
@@ -489,7 +496,7 @@ -

随机轮询:随机选择可用账号;逐个轮询:每个活跃账号只调用一次,全部轮询后再开始下一轮

+

随机轮询:随机选择可用账号;逐个轮询:每个活跃账号只使用一次,全部使用过后再开始下一轮

@@ -978,8 +985,8 @@ batchDisableSelected=async()=>{if(selectedTokenIds.size===0){showToast('请先选择要禁用的Token','info');return}if(!confirm(`确定要禁用选中的 ${selectedTokenIds.size} 个Token吗?`)){return}showToast('正在批量禁用Token...','info');try{const r=await apiRequest('/api/tokens/batch/disable-selected',{method:'POST',body:JSON.stringify({token_ids:Array.from(selectedTokenIds)})});if(!r)return;const d=await r.json();if(d.success){selectedTokenIds.clear();await refreshTokens();showToast(d.message,'success')}else{showToast('批量禁用失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('批量禁用失败: '+e.message,'error')}}, updateImportModeHint=()=>{const mode=$('importMode').value,hint=$('importModeHint'),hints={at:'使用AT更新账号状态(订阅信息、Sora2次数等)',offline:'离线导入,不更新账号状态,动态字段显示为-',st:'自动将ST转换为AT,然后更新账号状态',rt:'自动将RT转换为AT(并刷新RT),然后更新账号状态'};hint.textContent=hints[mode]||''}, submitImportTokens=async()=>{const fileInput=$('importFile');if(!fileInput.files||fileInput.files.length===0){showToast('请选择文件','error');return}const file=fileInput.files[0];if(!file.name.endsWith('.json')){showToast('请选择JSON文件','error');return}const mode=$('importMode').value;try{const fileContent=await file.text();const importData=JSON.parse(fileContent);if(!Array.isArray(importData)){showToast('JSON格式错误:应为数组','error');return}if(importData.length===0){showToast('JSON文件为空','error');return}for(let item of importData){if(!item.email){showToast('导入数据缺少必填字段: email','error');return}if(mode==='offline'||mode==='at'){if(!item.access_token){showToast(`${item.email} 缺少必填字段: access_token`,'error');return}}else if(mode==='st'){if(!item.session_token){showToast(`${item.email} 缺少必填字段: session_token`,'error');return}}else if(mode==='rt'){if(!item.refresh_token){showToast(`${item.email} 缺少必填字段: refresh_token`,'error');return}}}const btn=$('importBtn'),btnText=$('importBtnText'),btnSpinner=$('importBtnSpinner');btn.disabled=true;btnText.textContent='导入中...';btnSpinner.classList.remove('hidden');try{const r=await apiRequest('/api/tokens/import',{method:'POST',body:JSON.stringify({tokens:importData,mode:mode})});if(!r){btn.disabled=false;btnText.textContent='导入';btnSpinner.classList.add('hidden');return}const d=await r.json();if(d.success){closeImportModal();await refreshTokens();showImportProgress(d.results||[],d.added||0,d.updated||0,d.failed||0)}else{showToast('导入失败: '+(d.detail||d.message||'未知错误'),'error')}}catch(e){showToast('导入失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='导入';btnSpinner.classList.add('hidden')}}catch(e){showToast('文件解析失败: '+e.message,'error')}}, - loadAdminConfig=async()=>{try{const r=await apiRequest('/api/admin/config');if(!r)return;const d=await r.json();$('cfgErrorBan').value=d.error_ban_threshold||3;$('cfgTaskRetryEnabled').checked=d.task_retry_enabled||false;$('cfgTaskMaxRetries').value=d.task_max_retries||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,task_retry_enabled:$('cfgTaskRetryEnabled').checked,task_max_retries:parseInt($('cfgTaskMaxRetries').value)||3})});if(!r)return;const d=await r.json();d.success?showToast('配置保存成功','success'):showToast('保存失败','error')}catch(e){showToast('保存失败: '+e.message,'error')}}, + loadAdminConfig=async()=>{try{const r=await apiRequest('/api/admin/config');if(!r)return;const d=await r.json();$('cfgErrorBan').value=d.error_ban_threshold||3;$('cfgTaskRetryEnabled').checked=d.task_retry_enabled||false;$('cfgTaskMaxRetries').value=d.task_max_retries||3;$('cfgAutoDisableOn401').checked=d.auto_disable_on_401||false;$('cfgAdminUsername').value=d.admin_username||'admin';$('cfgCurrentAPIKey').value=d.api_key||'';$('cfgDebugEnabled').checked=d.debug_enabled||false}catch(e){console.error('加载配置失败:',e)}}, + saveAdminConfig=async()=>{try{const r=await apiRequest('/api/admin/config',{method:'POST',body:JSON.stringify({error_ban_threshold:parseInt($('cfgErrorBan').value)||3,task_retry_enabled:$('cfgTaskRetryEnabled').checked,task_max_retries:parseInt($('cfgTaskMaxRetries').value)||3,auto_disable_on_401:$('cfgAutoDisableOn401').checked})});if(!r)return;const d=await r.json();d.success?showToast('配置保存成功','success'):showToast('保存失败','error')}catch(e){showToast('保存失败: '+e.message,'error')}}, updateAdminPassword=async()=>{const username=$('cfgAdminUsername').value.trim(),oldPwd=$('cfgOldPassword').value.trim(),newPwd=$('cfgNewPassword').value.trim();if(!oldPwd||!newPwd)return showToast('请输入旧密码和新密码','error');if(newPwd.length<4)return showToast('新密码至少4个字符','error');try{const r=await apiRequest('/api/admin/password',{method:'POST',body:JSON.stringify({username:username||undefined,old_password:oldPwd,new_password:newPwd})});if(!r)return;const d=await r.json();if(d.success){showToast('密码修改成功,请重新登录','success');setTimeout(()=>{localStorage.removeItem('adminToken');location.href='/login'},2000)}else{showToast('修改失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('修改失败: '+e.message,'error')}}, updateAPIKey=async()=>{const newKey=$('cfgNewAPIKey').value.trim();if(!newKey)return showToast('请输入新的 API Key','error');if(newKey.length<6)return showToast('API Key 至少6个字符','error');if(!confirm('确定要更新 API Key 吗?更新后需要通知所有客户端使用新密钥。'))return;try{const r=await apiRequest('/api/admin/apikey',{method:'POST',body:JSON.stringify({new_api_key:newKey})});if(!r)return;const d=await r.json();if(d.success){showToast('API Key 更新成功','success');$('cfgCurrentAPIKey').value=newKey;$('cfgNewAPIKey').value=''}else{showToast('更新失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('更新失败: '+e.message,'error')}}, toggleDebugMode=async()=>{const enabled=$('cfgDebugEnabled').checked;try{const r=await apiRequest('/api/admin/debug',{method:'POST',body:JSON.stringify({enabled:enabled})});if(!r)return;const d=await r.json();if(d.success){showToast(enabled?'调试模式已开启':'调试模式已关闭','success')}else{showToast('操作失败: '+(d.detail||'未知错误'),'error');$('cfgDebugEnabled').checked=!enabled}}catch(e){showToast('操作失败: '+e.message,'error');$('cfgDebugEnabled').checked=!enabled}}, @@ -999,7 +1006,7 @@ saveGenerationTimeout=async()=>{const imageTimeout=parseInt($('cfgImageTimeout').value)||300,videoTimeout=parseInt($('cfgVideoTimeout').value)||1500;console.log('保存生成超时配置:',{imageTimeout,videoTimeout});if(imageTimeout<60||imageTimeout>3600)return showToast('图片超时时间必须在 60-3600 秒之间','error');if(videoTimeout<60||videoTimeout>7200)return showToast('视频超时时间必须在 60-7200 秒之间','error');try{const r=await apiRequest('/api/generation/timeout',{method:'POST',body:JSON.stringify({image_timeout:imageTimeout,video_timeout:videoTimeout})});if(!r){console.error('保存请求失败');return}const d=await r.json();console.log('保存结果:',d);if(d.success){showToast('生成超时配置保存成功','success');await new Promise(r=>setTimeout(r,200));await loadGenerationTimeout()}else{console.error('保存失败:',d);showToast('保存失败','error')}}catch(e){console.error('保存失败:',e);showToast('保存失败: '+e.message,'error')}}, toggleATAutoRefresh=async()=>{try{const enabled=$('atAutoRefreshToggle').checked;const r=await apiRequest('/api/token-refresh/enabled',{method:'POST',body:JSON.stringify({enabled:enabled})});if(!r){$('atAutoRefreshToggle').checked=!enabled;return}const d=await r.json();if(d.success){showToast(enabled?'AT自动刷新已启用':'AT自动刷新已禁用','success')}else{showToast('操作失败: '+(d.detail||'未知错误'),'error');$('atAutoRefreshToggle').checked=!enabled}}catch(e){showToast('操作失败: '+e.message,'error');$('atAutoRefreshToggle').checked=!enabled}}, loadATAutoRefreshConfig=async()=>{try{const r=await apiRequest('/api/token-refresh/config');if(!r)return;const d=await r.json();if(d.success&&d.config){$('atAutoRefreshToggle').checked=d.config.at_auto_refresh_enabled||false}else{console.error('AT自动刷新配置数据格式错误:',d)}}catch(e){console.error('加载AT自动刷新配置失败:',e)}}, - loadLogs=async()=>{try{const r=await apiRequest('/api/logs?limit=100');if(!r)return;const logs=await r.json();window.allLogs=logs;const tb=$('logsTableBody');tb.innerHTML=logs.map(l=>{const isProcessing=l.status_code===-1;const statusText=isProcessing?'处理中':l.status_code;const statusClass=isProcessing?'bg-blue-50 text-blue-700':l.status_code===200?'bg-green-50 text-green-700':'bg-red-50 text-red-700';let progressHtml='-';if(isProcessing&&l.task_status){const taskStatusMap={processing:'生成中',completed:'已完成',failed:'失败'};const taskStatusText=taskStatusMap[l.task_status]||l.task_status;const progress=l.progress||0;progressHtml=`
${progress.toFixed(0)}%
${taskStatusText}
`}let actionHtml='';if(isProcessing&&l.task_id){actionHtml='
'}return `${l.operation}${l.token_email||'未知'}${statusText}${progressHtml}${l.duration===-1?'处理中':l.duration.toFixed(2)+'秒'}${l.created_at?new Date(l.created_at).toLocaleString('zh-CN'):'-'}${actionHtml}`}).join('')}catch(e){console.error('加载日志失败:',e)}}, + loadLogs=async()=>{try{const r=await apiRequest('/api/logs?limit=100');if(!r)return;const logs=await r.json();window.allLogs=logs;const tb=$('logsTableBody');tb.innerHTML=logs.map(l=>{const isProcessing=l.status_code===-1&&l.task_status==='processing';const isFailed=l.task_status==='failed';const isCompleted=l.task_status==='completed';const statusText=isProcessing?'处理中':l.status_code;const statusClass=isProcessing?'bg-blue-50 text-blue-700':l.status_code===200?'bg-green-50 text-green-700':'bg-red-50 text-red-700';let progressHtml='-';if(isProcessing&&l.task_status){const taskStatusMap={processing:'生成中',completed:'已完成',failed:'失败'};const taskStatusText=taskStatusMap[l.task_status]||l.task_status;const progress=l.progress||0;progressHtml=`
${progress.toFixed(0)}%
${taskStatusText}
`}else if(isFailed){progressHtml='失败'}else if(isCompleted&&l.status_code===200){progressHtml='已完成'}let actionHtml='';if(isProcessing&&l.task_id){actionHtml='
'}return `${l.operation}${l.token_email||'未知'}${statusText}${progressHtml}${l.duration===-1?'处理中':l.duration.toFixed(2)+'秒'}${l.created_at?new Date(l.created_at).toLocaleString('zh-CN'):'-'}${actionHtml}`}).join('')}catch(e){console.error('加载日志失败:',e)}}, refreshLogs=async()=>{await loadLogs()}, showLogDetail=(logId)=>{const log=window.allLogs.find(l=>l.id===logId);if(!log){showToast('日志不存在','error');return}const content=$('logDetailContent');let detailHtml='';if(log.status_code===-1){detailHtml+=`

生成进度

任务正在生成中...

${log.task_status?`

状态: ${log.task_status}

`:''}
`}else if(log.status_code===200){try{const responseBody=log.response_body?JSON.parse(log.response_body):null;if(responseBody){if(responseBody.data&&responseBody.data.length>0){const item=responseBody.data[0];if(item.url){detailHtml+=`

生成结果

文件URL:

${item.url}
`}else{detailHtml+=`

响应数据

${JSON.stringify(responseBody,null,2)}
`}}else{detailHtml+=`

响应数据

${JSON.stringify(responseBody,null,2)}
`}}else{detailHtml+=`

响应信息

无响应数据

`}}catch(e){detailHtml+=`

响应数据

${log.response_body||'无'}
`}}else{try{const responseBody=log.response_body?JSON.parse(log.response_body):null;if(responseBody&&responseBody.error){detailHtml+=`

错误原因

${responseBody.error.message||responseBody.error||'未知错误'}

`}else if(log.response_body&&log.response_body!=='{}'){detailHtml+=`

错误信息

${log.response_body}
`}}catch(e){if(log.response_body&&log.response_body!=='{}'){detailHtml+=`

错误信息

${log.response_body}
`}}}detailHtml+=`

基本信息

操作: ${log.operation}
状态码: ${log.status_code===-1?'生成中':log.status_code}
耗时: ${log.duration===-1?'生成中':log.duration.toFixed(2)+'秒'}
时间: ${log.created_at?new Date(log.created_at).toLocaleString('zh-CN'):'-'}
`;content.innerHTML=detailHtml;$('logDetailModal').classList.remove('hidden')}, closeLogDetailModal=()=>{$('logDetailModal').classList.add('hidden')},