feat: 增强Token禁用状态管理,区分失效与禁用

This commit is contained in:
TheSmallHanCat
2026-02-07 20:36:41 +08:00
parent 5a0ccbe2de
commit 29fddfa85b
6 changed files with 74 additions and 19 deletions

View File

@@ -60,3 +60,15 @@ call_mode = "default"
# 常用时区:中国/新加坡 +8, 日本/韩国 +9, 印度 +5.5, 伦敦 0, 纽约 -5, 洛杉矶 -8 # 常用时区:中国/新加坡 +8, 日本/韩国 +9, 印度 +5.5, 伦敦 0, 纽约 -5, 洛杉矶 -8
timezone_offset = 8 timezone_offset = 8
[pow_service]
# beta测试目前仍处于测试阶段
# POW 计算模式local本地计算或 external外部服务
mode = "external"
# 外部 POW 服务地址(仅在 external 模式下使用)
server_url = "http://localhost:8002"
# 外部 POW 服务访问密钥(仅在 external 模式下使用)
api_key = "your-secure-api-key-here"
# POW 代理配置
proxy_enabled = false
proxy_url = ""

View File

@@ -249,7 +249,10 @@ async def get_tokens(token: str = Depends(verify_admin_token)) -> List[dict]:
"video_enabled": token.video_enabled, "video_enabled": token.video_enabled,
# 并发限制 # 并发限制
"image_concurrency": token.image_concurrency, "image_concurrency": token.image_concurrency,
"video_concurrency": token.video_concurrency "video_concurrency": token.video_concurrency,
# 过期和禁用信息
"is_expired": token.is_expired,
"disabled_reason": token.disabled_reason
}) })
return result return result

View File

@@ -393,7 +393,8 @@ class Database:
video_enabled BOOLEAN DEFAULT 1, video_enabled BOOLEAN DEFAULT 1,
image_concurrency INTEGER DEFAULT -1, image_concurrency INTEGER DEFAULT -1,
video_concurrency INTEGER DEFAULT -1, video_concurrency INTEGER DEFAULT -1,
is_expired BOOLEAN DEFAULT 0 is_expired BOOLEAN DEFAULT 0,
disabled_reason TEXT
) )
""") """)
@@ -586,6 +587,22 @@ class Database:
if not await self._column_exists(db, "admin_config", "auto_disable_on_401"): 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.execute("ALTER TABLE admin_config ADD COLUMN auto_disable_on_401 BOOLEAN DEFAULT 1")
# Migration: Add disabled_reason column to tokens table if it doesn't exist
if not await self._column_exists(db, "tokens", "disabled_reason"):
await db.execute("ALTER TABLE tokens ADD COLUMN disabled_reason TEXT")
# For existing disabled tokens without a reason, set to 'manual'
await db.execute("""
UPDATE tokens
SET disabled_reason = 'manual'
WHERE is_active = 0 AND disabled_reason IS NULL
""")
# For existing expired tokens, set to 'expired'
await db.execute("""
UPDATE tokens
SET disabled_reason = 'expired'
WHERE is_expired = 1 AND disabled_reason IS NULL
""")
await db.commit() await db.commit()
async def init_config_from_toml(self, config_dict: dict, is_first_startup: bool = True): async def init_config_from_toml(self, config_dict: dict, is_first_startup: bool = True):
@@ -698,27 +715,35 @@ class Database:
""", (token_id,)) """, (token_id,))
await db.commit() await db.commit()
async def update_token_status(self, token_id: int, is_active: bool): async def update_token_status(self, token_id: int, is_active: bool, disabled_reason: Optional[str] = None):
"""Update token status""" """Update token status and disabled reason"""
async with aiosqlite.connect(self.db_path) as db: async with aiosqlite.connect(self.db_path) as db:
await db.execute(""" await db.execute("""
UPDATE tokens SET is_active = ? WHERE id = ? UPDATE tokens SET is_active = ?, disabled_reason = ? WHERE id = ?
""", (is_active, token_id)) """, (is_active, disabled_reason, token_id))
await db.commit() await db.commit()
async def mark_token_expired(self, token_id: int): async def mark_token_expired(self, token_id: int):
"""Mark token as expired and disable it""" """Mark token as expired and disable it with reason"""
async with aiosqlite.connect(self.db_path) as db: async with aiosqlite.connect(self.db_path) as db:
await db.execute(""" await db.execute("""
UPDATE tokens SET is_expired = 1, is_active = 0 WHERE id = ? UPDATE tokens SET is_expired = 1, is_active = 0, disabled_reason = ? WHERE id = ?
""", (token_id,)) """, ("expired", token_id))
await db.commit()
async def mark_token_invalid(self, token_id: int):
"""Mark token as invalid (401 error) and disable it"""
async with aiosqlite.connect(self.db_path) as db:
await db.execute("""
UPDATE tokens SET is_expired = 1, is_active = 0, disabled_reason = ? WHERE id = ?
""", ("token_invalid", token_id))
await db.commit() await db.commit()
async def clear_token_expired(self, token_id: int): async def clear_token_expired(self, token_id: int):
"""Clear token expired flag""" """Clear token expired flag and disabled reason"""
async with aiosqlite.connect(self.db_path) as db: async with aiosqlite.connect(self.db_path) as db:
await db.execute(""" await db.execute("""
UPDATE tokens SET is_expired = 0 WHERE id = ? UPDATE tokens SET is_expired = 0, disabled_reason = NULL WHERE id = ?
""", (token_id,)) """, (token_id,))
await db.commit() await db.commit()

View File

@@ -40,6 +40,8 @@ class Token(BaseModel):
video_concurrency: int = -1 # 视频并发数限制,-1表示不限制 video_concurrency: int = -1 # 视频并发数限制,-1表示不限制
# 过期标记 # 过期标记
is_expired: bool = False # Token是否已过期401 token_invalidated is_expired: bool = False # Token是否已过期401 token_invalidated
# 禁用原因: manual=手动禁用, error_limit=错误次数超限, token_invalid=Token失效, expired=过期失效
disabled_reason: Optional[str] = None
class TokenStats(BaseModel): class TokenStats(BaseModel):
"""Token statistics""" """Token statistics"""

View File

@@ -946,19 +946,21 @@ class TokenManager:
async def update_token_status(self, token_id: int, is_active: bool): async def update_token_status(self, token_id: int, is_active: bool):
"""Update token active status""" """Update token active status"""
await self.db.update_token_status(token_id, is_active) # When manually changing status, set appropriate disabled_reason
disabled_reason = None if is_active else "manual"
await self.db.update_token_status(token_id, is_active, disabled_reason)
async def enable_token(self, token_id: int): async def enable_token(self, token_id: int):
"""Enable a token and reset error count""" """Enable a token and reset error count"""
await self.db.update_token_status(token_id, True) await self.db.update_token_status(token_id, True, None) # Clear disabled_reason
# Reset error count when enabling (in token_stats table) # Reset error count when enabling (in token_stats table)
await self.db.reset_error_count(token_id) await self.db.reset_error_count(token_id)
# Clear expired flag when enabling # Clear expired flag when enabling
await self.db.clear_token_expired(token_id) await self.db.clear_token_expired(token_id)
async def disable_token(self, token_id: int): async def disable_token(self, token_id: int):
"""Disable a token""" """Disable a token (manual disable)"""
await self.db.update_token_status(token_id, False) await self.db.update_token_status(token_id, False, "manual")
async def test_token(self, token_id: int) -> dict: async def test_token(self, token_id: int) -> dict:
"""Test if a token is valid by calling Sora API and refresh account info (subscription + Sora2)""" """Test if a token is valid by calling Sora API and refresh account info (subscription + Sora2)"""
@@ -1048,6 +1050,14 @@ class TokenManager:
"valid": False, "valid": False,
"message": "Token已过期token_invalidated" "message": "Token已过期token_invalidated"
} }
# Check if error is "Failed to get user info:401"
if "Failed to get user info:401" in error_msg or "Failed to get user info: 401" in error_msg:
# Mark token as invalid and disable it
await self.db.mark_token_invalid(token_id)
return {
"valid": False,
"message": "Token无效: Token is invalid: Failed to get user info:401"
}
return { return {
"valid": False, "valid": False,
"message": f"Token is invalid: {error_msg}" "message": f"Token is invalid: {error_msg}"
@@ -1077,7 +1087,8 @@ class TokenManager:
admin_config = await self.db.get_admin_config() admin_config = await self.db.get_admin_config()
if stats and stats.consecutive_error_count >= admin_config.error_ban_threshold: if stats and stats.consecutive_error_count >= admin_config.error_ban_threshold:
await self.db.update_token_status(token_id, False) # Disable token with error_limit reason
await self.db.update_token_status(token_id, False, "error_limit")
async def record_success(self, token_id: int, is_video: bool = False): async def record_success(self, token_id: int, is_video: bool = False):
"""Record successful request (reset error count)""" """Record successful request (reset error count)"""

View File

@@ -1058,19 +1058,21 @@
const $=(id)=>document.getElementById(id), const $=(id)=>document.getElementById(id),
checkAuth=()=>{const t=localStorage.getItem('adminToken');return t||(location.href='/login',null),t}, checkAuth=()=>{const t=localStorage.getItem('adminToken');return t||(location.href='/login',null),t},
apiRequest=async(url,opts={})=>{const t=checkAuth();if(!t)return null;const r=await fetch(url,{...opts,headers:{...opts.headers,Authorization:`Bearer ${t}`,'Content-Type':'application/json'}});return r.status===401?(localStorage.removeItem('adminToken'),location.href='/login',null):r}, apiRequest=async(url,opts={})=>{const t=checkAuth();if(!t)return null;const r=await fetch(url,{...opts,headers:{...opts.headers,Authorization:`Bearer ${t}`,'Content-Type':'application/json'}});return r.status===401?(localStorage.removeItem('adminToken'),location.href='/login',null):r},
// 获取token的状态文本和样式
getTokenStatus=(token)=>{if(token.is_active){return{text:'活跃',class:'bg-green-50 text-green-700'}}const reason=token.disabled_reason;if(reason==='token_invalid'||reason==='expired'){return{text:'失效',class:'bg-gray-100 text-gray-700'}}if(reason==='manual'||reason==='error_limit'){return{text:'禁用',class:'bg-gray-100 text-gray-700'}}return{text:'禁用',class:'bg-gray-100 text-gray-700'}},
loadStats=async()=>{try{const r=await apiRequest('/api/stats');if(!r)return;const d=await r.json();$('statTotal').textContent=d.total_tokens||0;$('statActive').textContent=d.active_tokens||0;$('statImages').textContent=(d.today_images||0)+'/'+(d.total_images||0);$('statVideos').textContent=(d.today_videos||0)+'/'+(d.total_videos||0);$('statErrors').textContent=(d.today_errors||0)+'/'+(d.total_errors||0)}catch(e){console.error('加载统计失败:',e)}}, loadStats=async()=>{try{const r=await apiRequest('/api/stats');if(!r)return;const d=await r.json();$('statTotal').textContent=d.total_tokens||0;$('statActive').textContent=d.active_tokens||0;$('statImages').textContent=(d.today_images||0)+'/'+(d.total_images||0);$('statVideos').textContent=(d.today_videos||0)+'/'+(d.total_videos||0);$('statErrors').textContent=(d.today_errors||0)+'/'+(d.total_errors||0)}catch(e){console.error('加载统计失败:',e)}},
loadTokens=async()=>{try{const r=await apiRequest('/api/tokens');if(!r)return;allTokens=await r.json();updateStatusFilterOptions();renderTokens()}catch(e){console.error('加载Token失败:',e)}}, loadTokens=async()=>{try{const r=await apiRequest('/api/tokens');if(!r)return;allTokens=await r.json();updateStatusFilterOptions();renderTokens()}catch(e){console.error('加载Token失败:',e)}},
updateStatusFilterOptions=()=>{const statusSet=new Set();allTokens.forEach(t=>{const statusText=t.is_expired?'已过期':(t.is_active?'活跃':'禁用');statusSet.add(statusText)});const dropdown=$('statusFilterDropdown');if(!dropdown)return;const statuses=Array.from(statusSet).sort();dropdown.innerHTML='<div class="py-1"><button onclick="selectStatusFilter(\'\')" class="w-full text-left px-4 py-2 text-sm hover:bg-accent transition-colors" data-filter-option="">全部</button>'+statuses.map(s=>`<button onclick="selectStatusFilter('${s}')" class="w-full text-left px-4 py-2 text-sm hover:bg-accent transition-colors" data-filter-option="${s}">${s}</button>`).join('')+'</div>';updateStatusFilterLabel()}, updateStatusFilterOptions=()=>{const statusSet=new Set();allTokens.forEach(t=>{const status=getTokenStatus(t);statusSet.add(status.text)});const dropdown=$('statusFilterDropdown');if(!dropdown)return;const statuses=Array.from(statusSet).sort();dropdown.innerHTML='<div class="py-1"><button onclick="selectStatusFilter(\'\')" class="w-full text-left px-4 py-2 text-sm hover:bg-accent transition-colors" data-filter-option="">全部</button>'+statuses.map(s=>`<button onclick="selectStatusFilter('${s}')" class="w-full text-left px-4 py-2 text-sm hover:bg-accent transition-colors" data-filter-option="${s}">${s}</button>`).join('')+'</div>';updateStatusFilterLabel()},
updateStatusFilterLabel=()=>{const label=$('statusFilterLabel');if(label){label.textContent=currentStatusFilter||'全部'}}, updateStatusFilterLabel=()=>{const label=$('statusFilterLabel');if(label){label.textContent=currentStatusFilter||'全部'}},
toggleStatusFilterDropdown=()=>{const dropdown=$('statusFilterDropdown');if(!dropdown)return;dropdown.classList.toggle('hidden')}, toggleStatusFilterDropdown=()=>{const dropdown=$('statusFilterDropdown');if(!dropdown)return;dropdown.classList.toggle('hidden')},
selectStatusFilter=(status)=>{currentStatusFilter=status;currentPage=1;updateStatusFilterLabel();toggleStatusFilterDropdown();renderTokens()}, selectStatusFilter=(status)=>{currentStatusFilter=status;currentPage=1;updateStatusFilterLabel();toggleStatusFilterDropdown();renderTokens()},
applyStatusFilter=()=>{currentPage=1;renderTokens()}, applyStatusFilter=()=>{currentPage=1;renderTokens()},
getFilteredTokens=()=>{if(!currentStatusFilter)return allTokens;return allTokens.filter(t=>{const statusText=t.is_expired?'已过期':(t.is_active?'活跃':'禁用');return statusText===currentStatusFilter})}, getFilteredTokens=()=>{if(!currentStatusFilter)return allTokens;return allTokens.filter(t=>{const status=getTokenStatus(t);return status.text===currentStatusFilter})},
formatExpiry=exp=>{if(!exp)return'-';const d=new Date(exp),now=new Date(),diff=d-now;const dateStr=d.toLocaleDateString('zh-CN',{year:'numeric',month:'2-digit',day:'2-digit'}).replace(/\//g,'-');const timeStr=d.toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit',hour12:false});if(diff<0)return`<span class="text-red-600">${dateStr} ${timeStr}</span>`;const days=Math.floor(diff/864e5);if(days<7)return`<span class="text-orange-600">${dateStr} ${timeStr}</span>`;return`${dateStr} ${timeStr}`}, formatExpiry=exp=>{if(!exp)return'-';const d=new Date(exp),now=new Date(),diff=d-now;const dateStr=d.toLocaleDateString('zh-CN',{year:'numeric',month:'2-digit',day:'2-digit'}).replace(/\//g,'-');const timeStr=d.toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit',hour12:false});if(diff<0)return`<span class="text-red-600">${dateStr} ${timeStr}</span>`;const days=Math.floor(diff/864e5);if(days<7)return`<span class="text-orange-600">${dateStr} ${timeStr}</span>`;return`${dateStr} ${timeStr}`},
formatPlanType=type=>{if(!type)return'-';const typeMap={'chatgpt_team':'Team','chatgpt_plus':'Plus','chatgpt_pro':'Pro','chatgpt_free':'Free'};return typeMap[type]||type}, formatPlanType=type=>{if(!type)return'-';const typeMap={'chatgpt_team':'Team','chatgpt_plus':'Plus','chatgpt_pro':'Pro','chatgpt_free':'Free'};return typeMap[type]||type},
formatPlanTypeWithTooltip=(t)=>{const tooltipText=t.subscription_end?`套餐到期: ${new Date(t.subscription_end).toLocaleDateString('zh-CN',{year:'numeric',month:'2-digit',day:'2-digit'}).replace(/\//g,'-')} ${new Date(t.subscription_end).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit',hour12:false})}`:'';return`<span class="inline-flex items-center rounded px-2 py-0.5 text-xs bg-blue-50 text-blue-700 cursor-pointer" title="${tooltipText||t.plan_title||'-'}">${formatPlanType(t.plan_type)}</span>`}, formatPlanTypeWithTooltip=(t)=>{const tooltipText=t.subscription_end?`套餐到期: ${new Date(t.subscription_end).toLocaleDateString('zh-CN',{year:'numeric',month:'2-digit',day:'2-digit'}).replace(/\//g,'-')} ${new Date(t.subscription_end).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit',hour12:false})}`:'';return`<span class="inline-flex items-center rounded px-2 py-0.5 text-xs bg-blue-50 text-blue-700 cursor-pointer" title="${tooltipText||t.plan_title||'-'}">${formatPlanType(t.plan_type)}</span>`},
formatClientId=(clientId)=>{if(!clientId)return'-';const short=clientId.substring(0,8)+'...';return`<span class="text-xs font-mono cursor-pointer hover:text-primary" title="${clientId}" onclick="navigator.clipboard.writeText('${clientId}').then(()=>showToast('已复制','success'))">${short}</span>`}, formatClientId=(clientId)=>{if(!clientId)return'-';const short=clientId.substring(0,8)+'...';return`<span class="text-xs font-mono cursor-pointer hover:text-primary" title="${clientId}" onclick="navigator.clipboard.writeText('${clientId}').then(()=>showToast('已复制','success'))">${short}</span>`},
renderTokens=()=>{const filteredTokens=getFilteredTokens();const start=(currentPage-1)*pageSize,end=start+pageSize,paginatedTokens=filteredTokens.slice(start,end);const tb=$('tokenTableBody');tb.innerHTML=paginatedTokens.map(t=>{const imageDisplay=t.image_enabled?`${t.image_count||0}`:'-';const videoDisplay=t.video_enabled?`${t.video_count||0}`:'-';const remainingCount=t.sora2_remaining_count!==undefined&&t.sora2_remaining_count!==null?t.sora2_remaining_count:'-';const statusText=t.is_expired?'已过期':(t.is_active?'活跃':'禁用');const statusClass=t.is_expired?'bg-gray-100 text-gray-700':(t.is_active?'bg-green-50 text-green-700':'bg-gray-100 text-gray-700');return`<tr><td class=\"py-2.5 px-3\"><input type=\"checkbox\" class=\"token-checkbox h-4 w-4 rounded border-gray-300\" data-token-id=\"${t.id}\" onchange=\"toggleTokenSelection(${t.id},this.checked)\" ${selectedTokenIds.has(t.id)?'checked':''}></td><td class=\"py-2.5 px-3\">${t.email}</td><td class=\"py-2.5 px-3\"><span class=\"inline-flex items-center rounded px-2 py-0.5 text-xs ${statusClass}\">${statusText}</span></td><td class="py-2.5 px-3">${formatClientId(t.client_id)}</td><td class="py-2.5 px-3 text-xs">${formatExpiry(t.expiry_time)}</td><td class="py-2.5 px-3 text-xs">${formatPlanTypeWithTooltip(t)}</td><td class="py-2.5 px-3">${remainingCount}</td><td class="py-2.5 px-3">${imageDisplay}</td><td class="py-2.5 px-3">${videoDisplay}</td><td class="py-2.5 px-3">${t.error_count||0}</td><td class="py-2.5 px-3 text-xs text-muted-foreground">${t.remark||'-'}</td><td class="py-2.5 px-3 text-right"><button onclick="testToken(${t.id})" class="inline-flex items-center justify-center rounded-md hover:bg-blue-50 hover:text-blue-700 h-7 px-2 text-xs mr-1">测试</button><button onclick="openEditModal(${t.id})" class="inline-flex items-center justify-center rounded-md hover:bg-green-50 hover:text-green-700 h-7 px-2 text-xs mr-1">编辑</button><button onclick="toggleToken(${t.id},${t.is_active})" class="inline-flex items-center justify-center rounded-md hover:bg-accent h-7 px-2 text-xs mr-1">${t.is_active?'禁用':'启用'}</button><button onclick="deleteToken(${t.id})" class="inline-flex items-center justify-center rounded-md hover:bg-destructive/10 hover:text-destructive h-7 px-2 text-xs">删除</button></td></tr>`}).join('');renderPagination()}, renderTokens=()=>{const filteredTokens=getFilteredTokens();const start=(currentPage-1)*pageSize,end=start+pageSize,paginatedTokens=filteredTokens.slice(start,end);const tb=$('tokenTableBody');tb.innerHTML=paginatedTokens.map(t=>{const imageDisplay=t.image_enabled?`${t.image_count||0}`:'-';const videoDisplay=t.video_enabled?`${t.video_count||0}`:'-';const remainingCount=t.sora2_remaining_count!==undefined&&t.sora2_remaining_count!==null?t.sora2_remaining_count:'-';const status=getTokenStatus(t);const statusText=status.text;const statusClass=status.class;return`<tr><td class=\"py-2.5 px-3\"><input type=\"checkbox\" class=\"token-checkbox h-4 w-4 rounded border-gray-300\" data-token-id=\"${t.id}\" onchange=\"toggleTokenSelection(${t.id},this.checked)\" ${selectedTokenIds.has(t.id)?'checked':''}></td><td class=\"py-2.5 px-3\">${t.email}</td><td class=\"py-2.5 px-3\"><span class=\"inline-flex items-center rounded px-2 py-0.5 text-xs ${statusClass}\">${statusText}</span></td><td class="py-2.5 px-3">${formatClientId(t.client_id)}</td><td class="py-2.5 px-3 text-xs">${formatExpiry(t.expiry_time)}</td><td class="py-2.5 px-3 text-xs">${formatPlanTypeWithTooltip(t)}</td><td class="py-2.5 px-3">${remainingCount}</td><td class="py-2.5 px-3">${imageDisplay}</td><td class="py-2.5 px-3">${videoDisplay}</td><td class="py-2.5 px-3">${t.error_count||0}</td><td class="py-2.5 px-3 text-xs text-muted-foreground">${t.remark||'-'}</td><td class="py-2.5 px-3 text-right"><button onclick="testToken(${t.id})" class="inline-flex items-center justify-center rounded-md hover:bg-blue-50 hover:text-blue-700 h-7 px-2 text-xs mr-1">测试</button><button onclick="openEditModal(${t.id})" class="inline-flex items-center justify-center rounded-md hover:bg-green-50 hover:text-green-700 h-7 px-2 text-xs mr-1">编辑</button><button onclick="toggleToken(${t.id},${t.is_active})" class="inline-flex items-center justify-center rounded-md hover:bg-accent h-7 px-2 text-xs mr-1">${t.is_active?'禁用':'启用'}</button><button onclick="deleteToken(${t.id})" class="inline-flex items-center justify-center rounded-md hover:bg-destructive/10 hover:text-destructive h-7 px-2 text-xs">删除</button></td></tr>`}).join('');renderPagination()},
refreshTokens=async()=>{await loadTokens();await loadStats()}, refreshTokens=async()=>{await loadTokens();await loadStats()},
changePage=(page)=>{currentPage=page;renderTokens()}, changePage=(page)=>{currentPage=page;renderTokens()},
changePageSize=(size)=>{pageSize=parseInt(size);currentPage=1;renderTokens()}, changePageSize=(size)=>{pageSize=parseInt(size);currentPage=1;renderTokens()},