From dab1f1331013a33111a6613e708f7f81982f51e8 Mon Sep 17 00:00:00 2001 From: TheSmallHanCat Date: Sun, 25 Jan 2026 15:27:05 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E7=BA=AFRT=E5=AF=BC?= =?UTF-8?q?=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/admin.py | 112 +++++++++++++++++++++++++++++++++++++++++++++ static/manage.html | 45 ++++++++++++++++-- 2 files changed, 152 insertions(+), 5 deletions(-) diff --git a/src/api/admin.py b/src/api/admin.py index 21e1c71..c659729 100644 --- a/src/api/admin.py +++ b/src/api/admin.py @@ -115,6 +115,13 @@ class ImportTokensRequest(BaseModel): tokens: List[ImportTokenItem] mode: str = "at" # Import mode: offline/at/st/rt +class PureRtImportRequest(BaseModel): + refresh_tokens: List[str] # List of Refresh Tokens + client_id: str # Client ID (required) + proxy_url: Optional[str] = None # Proxy URL (optional) + image_concurrency: int = 1 # Image concurrency limit (default: 1) + video_concurrency: int = 3 # Video concurrency limit (default: 3) + class UpdateAdminConfigRequest(BaseModel): error_ban_threshold: int task_retry_enabled: Optional[bool] = None @@ -662,6 +669,111 @@ async def import_tokens(request: ImportTokensRequest, token: str = Depends(verif "results": results } +@router.post("/api/tokens/import/pure-rt") +async def import_pure_rt(request: PureRtImportRequest, token: str = Depends(verify_admin_token)): + """Import tokens using pure RT mode (batch RT conversion and import)""" + added_count = 0 + updated_count = 0 + failed_count = 0 + results = [] + + for rt in request.refresh_tokens: + try: + # Step 1: Use RT + client_id + proxy to refresh and get AT + rt_result = await token_manager.rt_to_at( + rt, + client_id=request.client_id, + proxy_url=request.proxy_url + ) + + access_token = rt_result.get("access_token") + new_refresh_token = rt_result.get("refresh_token", rt) # Use new RT if returned, else use original + + if not access_token: + raise ValueError("Failed to get access_token from RT conversion") + + # Step 2: Parse AT to get user info (email) + # The rt_to_at already includes email in the response + email = rt_result.get("email") + + # If email not in rt_result, parse it from access_token + if not email: + import jwt + try: + decoded = jwt.decode(access_token, options={"verify_signature": False}) + email = decoded.get("https://api.openai.com/profile", {}).get("email") + except Exception as e: + raise ValueError(f"Failed to parse email from access_token: {str(e)}") + + if not email: + raise ValueError("Failed to extract email from access_token") + + # Step 3: Check if token with this email already exists + existing_token = await db.get_token_by_email(email) + + if existing_token: + # Update existing token + await token_manager.update_token( + token_id=existing_token.id, + token=access_token, + st=None, # No ST in pure RT mode + rt=new_refresh_token, # Use refreshed RT + client_id=request.client_id, + proxy_url=request.proxy_url, + remark=None, # Keep existing remark + image_enabled=True, + video_enabled=True, + image_concurrency=request.image_concurrency, + video_concurrency=request.video_concurrency, + skip_status_update=False # Update status with new AT + ) + updated_count += 1 + results.append({ + "email": email, + "status": "updated", + "message": "Token updated successfully" + }) + else: + # Add new token + new_token = await token_manager.add_token( + token_value=access_token, + st=None, # No ST in pure RT mode + rt=new_refresh_token, # Use refreshed RT + client_id=request.client_id, + proxy_url=request.proxy_url, + remark=None, + update_if_exists=False, + image_enabled=True, + video_enabled=True, + image_concurrency=request.image_concurrency, + video_concurrency=request.video_concurrency, + skip_status_update=False, # Update status with new AT + email=email # Pass email for new token + ) + added_count += 1 + results.append({ + "email": email, + "status": "added", + "message": "Token added successfully" + }) + + except Exception as e: + failed_count += 1 + results.append({ + "email": "unknown", + "status": "failed", + "message": str(e) + }) + + return { + "success": True, + "message": f"Pure RT import completed: {added_count} added, {updated_count} updated, {failed_count} failed", + "added": added_count, + "updated": updated_count, + "failed": failed_count, + "results": results + } + @router.put("/api/tokens/{token_id}") async def update_token( token_id: int, diff --git a/static/manage.html b/static/manage.html index ba750a2..4c2e6a6 100644 --- a/static/manage.html +++ b/static/manage.html @@ -840,11 +840,41 @@
-
+

选择导出的 Token JSON 文件进行导入

+ + + +

使用AT更新账号状态(订阅信息、Sora2次数等)

-
+

📋 导入模式说明

@@ -874,6 +905,10 @@ RT导入: 适用于只有RT没有AT,自动转换为AT
+
+ 纯RT导入: + 手动输入RT列表(一行一个),自动转换并批量导入 +

💡 提示:离线导入后可使用"测试"按钮更新账号信息,功能不稳定有bug问猫猫 @@ -993,7 +1028,7 @@ openBatchProxyModal=()=>{if(selectedTokenIds.size===0){showToast('请先选择要修改的Token','info');return}$('batchProxyCount').textContent=selectedTokenIds.size;$('batchProxyUrl').value='';$('batchProxyModal').classList.remove('hidden')}, closeBatchProxyModal=()=>{$('batchProxyModal').classList.add('hidden');$('batchProxyUrl').value=''}, submitBatchProxy=async()=>{const proxyUrl=$('batchProxyUrl').value.trim();const btn=$('batchProxyBtn'),btnText=$('batchProxyBtnText'),btnSpinner=$('batchProxyBtnSpinner');btn.disabled=true;btnText.textContent='修改中...';btnSpinner.classList.remove('hidden');try{const r=await apiRequest('/api/tokens/batch/update-proxy',{method:'POST',body:JSON.stringify({token_ids:Array.from(selectedTokenIds),proxy_url:proxyUrl})});if(!r){btn.disabled=false;btnText.textContent='确认修改';btnSpinner.classList.add('hidden');return}const d=await r.json();if(d.success){closeBatchProxyModal();selectedTokenIds.clear();await refreshTokens();showToast(d.message,'success')}else{showToast('修改失败: '+(d.detail||d.message||'未知错误'),'error')}}catch(e){showToast('修改失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='确认修改';btnSpinner.classList.add('hidden')}}, - showImportProgress=(results,added,updated,failed)=>{const summary=$('importProgressSummary'),list=$('importProgressList');summary.innerHTML=`

导入完成
新增: ${added} | 更新: ${updated} | 失败: ${failed}
`;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`
${r.email}${statusText}
${r.error?`
${r.error}
`:''}
`}).join('');openImportProgressModal()}, + showImportProgress=(results,added,updated,failed)=>{const summary=$('importProgressSummary'),list=$('importProgressList');summary.innerHTML=`
导入完成
新增: ${added} | 更新: ${updated} | 失败: ${failed}
`;list.innerHTML=results.map(r=>{const isFailed=r.status==='failed';const isAdded=r.status==='added';const statusColor=isFailed?'text-red-600':(isAdded?'text-green-600':'text-blue-600');const statusText=isAdded?'新增':(r.status==='updated'?'更新':'失败');const bgColor=isFailed?'bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800':(isAdded?'bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800':'bg-blue-50 border-blue-200 dark:bg-blue-900/20 dark:border-blue-800');const errorMsg=r.message&&isFailed?r.message:(r.error||'');return`
${r.email}${statusText}
${errorMsg?`
${errorMsg}
`:''}
`}).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(selectedTokenIds.size===0){showToast('请先选择要测试的Token','info');return}if(!confirm(`⚠️ 警告\n\n此操作将请求上游获取选中的 ${selectedTokenIds.size} 个Token的状态信息,可能需要较长时间。\n\n确定要继续吗?`)){return}showToast('正在测试更新选中的Token...','info');try{const r=await apiRequest('/api/tokens/batch/test-update',{method:'POST',body:JSON.stringify({token_ids:Array.from(selectedTokenIds)})});if(!r)return;const d=await r.json();if(d.success){selectedTokenIds.clear();await refreshTokens();showToast(d.message,'success')}else{showToast('测试更新失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('测试更新失败: '+e.message,'error')}}, batchEnableAll=async()=>{if(selectedTokenIds.size===0){showToast('请先选择要启用的Token','info');return}if(!confirm(`确定要启用选中的 ${selectedTokenIds.size} 个Token吗?\n\n此操作将重置这些Token的错误计数。`)){return}showToast('正在批量启用Token...','info');try{const r=await apiRequest('/api/tokens/batch/enable-all',{method:'POST',body:JSON.stringify({token_ids:Array.from(selectedTokenIds)})});if(!r)return;const d=await r.json();if(d.success){selectedTokenIds.clear();await refreshTokens();showToast(d.message,'success')}else{showToast('批量启用失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('批量启用失败: '+e.message,'error')}}, @@ -1002,8 +1037,8 @@ toggleSelectAll=()=>{const checkbox=$('selectAllCheckbox');const checkboxes=document.querySelectorAll('.token-checkbox');if(checkbox.checked){checkboxes.forEach(cb=>{cb.checked=true;const tokenId=parseInt(cb.getAttribute('data-token-id'));selectedTokenIds.add(tokenId)})}else{checkboxes.forEach(cb=>{cb.checked=false});selectedTokenIds.clear()}}, toggleTokenSelection=(tokenId,checked)=>{if(checked){selectedTokenIds.add(tokenId)}else{selectedTokenIds.delete(tokenId)}const allCheckboxes=document.querySelectorAll('.token-checkbox');const allChecked=Array.from(allCheckboxes).every(cb=>cb.checked);$('selectAllCheckbox').checked=allChecked}, batchDisableSelected=async()=>{if(selectedTokenIds.size===0){showToast('请先选择要禁用的Token','info');return}if(!confirm(`确定要禁用选中的 ${selectedTokenIds.size} 个Token吗?`)){return}showToast('正在批量禁用Token...','info');try{const r=await apiRequest('/api/tokens/batch/disable-selected',{method:'POST',body:JSON.stringify({token_ids:Array.from(selectedTokenIds)})});if(!r)return;const d=await r.json();if(d.success){selectedTokenIds.clear();await refreshTokens();showToast(d.message,'success')}else{showToast('批量禁用失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('批量禁用失败: '+e.message,'error')}}, - updateImportModeHint=()=>{const mode=$('importMode').value,hint=$('importModeHint'),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')}}, + updateImportModeHint=()=>{const mode=$('importMode').value,hint=$('importModeHint'),jsonSection=$('jsonFileSection'),pureRtSection=$('pureRtSection'),helpSection=$('importModeHelpSection'),hints={at:'使用AT更新账号状态(订阅信息、Sora2次数等)',offline:'离线导入,不更新账号状态,动态字段显示为-',st:'自动将ST转换为AT,然后更新账号状态',rt:'自动将RT转换为AT(并刷新RT),然后更新账号状态',pure_rt:'手动输入RT列表,自动刷新并批量导入'};hint.textContent=hints[mode]||'';if(mode==='pure_rt'){jsonSection.classList.add('hidden');pureRtSection.classList.remove('hidden');helpSection.classList.add('hidden')}else{jsonSection.classList.remove('hidden');pureRtSection.classList.add('hidden');helpSection.classList.remove('hidden')}}, + submitImportTokens=async()=>{const mode=$('importMode').value;const btn=$('importBtn'),btnText=$('importBtnText'),btnSpinner=$('importBtnSpinner');if(mode==='pure_rt'){const rtInput=$('pureRtInput').value.trim();if(!rtInput){showToast('请输入 Refresh Token','error');return}const clientId=$('pureRtClientId').value.trim()||'app_LlGpXReQgckcGGUo2JrYvtJK';const proxy=$('pureRtProxy').value.trim()||null;const imageConcurrency=parseInt($('pureRtImageConcurrency').value)||1;const videoConcurrency=parseInt($('pureRtVideoConcurrency').value)||3;const rtList=rtInput.split('\n').map(rt=>rt.trim()).filter(rt=>rt.length>0);if(rtList.length===0){showToast('请输入至少一个 Refresh Token','error');return}btn.disabled=true;btnText.textContent='导入中...';btnSpinner.classList.remove('hidden');try{const r=await apiRequest('/api/tokens/import/pure-rt',{method:'POST',body:JSON.stringify({refresh_tokens:rtList,client_id:clientId,proxy_url:proxy,image_concurrency:imageConcurrency,video_concurrency:videoConcurrency})});if(!r){btn.disabled=false;btnText.textContent='导入';btnSpinner.classList.add('hidden');return}const d=await r.json();if(d.success){closeImportModal();await refreshTokens();showImportProgress(d.results||[],d.added||0,d.updated||0,d.failed||0)}else{showToast('导入失败: '+(d.detail||d.message||'未知错误'),'error')}}catch(e){showToast('导入失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='导入';btnSpinner.classList.add('hidden')}return}const fileInput=$('importFile');if(!fileInput.files||fileInput.files.length===0){showToast('请选择文件','error');return}const file=fileInput.files[0];if(!file.name.endsWith('.json')){showToast('请选择JSON文件','error');return}try{const fileContent=await file.text();const importData=JSON.parse(fileContent);if(!Array.isArray(importData)){showToast('JSON格式错误:应为数组','error');return}if(importData.length===0){showToast('JSON文件为空','error');return}for(let item of importData){if(!item.email){showToast('导入数据缺少必填字段: email','error');return}if(mode==='offline'||mode==='at'){if(!item.access_token){showToast(`${item.email} 缺少必填字段: access_token`,'error');return}}else if(mode==='st'){if(!item.session_token){showToast(`${item.email} 缺少必填字段: session_token`,'error');return}}else if(mode==='rt'){if(!item.refresh_token){showToast(`${item.email} 缺少必填字段: refresh_token`,'error');return}}}btn.disabled=true;btnText.textContent='导入中...';btnSpinner.classList.remove('hidden');try{const r=await apiRequest('/api/tokens/import',{method:'POST',body:JSON.stringify({tokens:importData,mode:mode})});if(!r){btn.disabled=false;btnText.textContent='导入';btnSpinner.classList.add('hidden');return}const d=await r.json();if(d.success){closeImportModal();await refreshTokens();showImportProgress(d.results||[],d.added||0,d.updated||0,d.failed||0)}else{showToast('导入失败: '+(d.detail||d.message||'未知错误'),'error')}}catch(e){showToast('导入失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='导入';btnSpinner.classList.add('hidden')}}catch(e){showToast('文件解析失败: '+e.message,'error')}}, loadAdminConfig=async()=>{try{const r=await apiRequest('/api/admin/config');if(!r)return;const d=await r.json();$('cfgErrorBan').value=d.error_ban_threshold||3;$('cfgTaskRetryEnabled').checked=d.task_retry_enabled||false;$('cfgTaskMaxRetries').value=d.task_max_retries||3;$('cfgAutoDisableOn401').checked=d.auto_disable_on_401||false;$('cfgAdminUsername').value=d.admin_username||'admin';$('cfgCurrentAPIKey').value=d.api_key||'';$('cfgDebugEnabled').checked=d.debug_enabled||false}catch(e){console.error('加载配置失败:',e)}}, saveAdminConfig=async()=>{try{const r=await apiRequest('/api/admin/config',{method:'POST',body:JSON.stringify({error_ban_threshold:parseInt($('cfgErrorBan').value)||3,task_retry_enabled:$('cfgTaskRetryEnabled').checked,task_max_retries:parseInt($('cfgTaskMaxRetries').value)||3,auto_disable_on_401:$('cfgAutoDisableOn401').checked})});if(!r)return;const d=await r.json();d.success?showToast('配置保存成功','success'):showToast('保存失败','error')}catch(e){showToast('保存失败: '+e.message,'error')}}, updateAdminPassword=async()=>{const username=$('cfgAdminUsername').value.trim(),oldPwd=$('cfgOldPassword').value.trim(),newPwd=$('cfgNewPassword').value.trim();if(!oldPwd||!newPwd)return showToast('请输入旧密码和新密码','error');if(newPwd.length<4)return showToast('新密码至少4个字符','error');try{const r=await apiRequest('/api/admin/password',{method:'POST',body:JSON.stringify({username:username||undefined,old_password:oldPwd,new_password:newPwd})});if(!r)return;const d=await r.json();if(d.success){showToast('密码修改成功,请重新登录','success');setTimeout(()=>{localStorage.removeItem('adminToken');location.href='/login'},2000)}else{showToast('修改失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('修改失败: '+e.message,'error')}},