From 29fddfa85bd3f919305bf5e5aa47a8e169f873f3 Mon Sep 17 00:00:00 2001 From: TheSmallHanCat Date: Sat, 7 Feb 2026 20:36:41 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BAToken=E7=A6=81?= =?UTF-8?q?=E7=94=A8=E7=8A=B6=E6=80=81=E7=AE=A1=E7=90=86=EF=BC=8C=E5=8C=BA?= =?UTF-8?q?=E5=88=86=E5=A4=B1=E6=95=88=E4=B8=8E=E7=A6=81=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/setting.toml | 12 ++++++++++ src/api/admin.py | 5 +++- src/core/database.py | 45 +++++++++++++++++++++++++++-------- src/core/models.py | 2 ++ src/services/token_manager.py | 21 ++++++++++++---- static/manage.html | 8 ++++--- 6 files changed, 74 insertions(+), 19 deletions(-) diff --git a/config/setting.toml b/config/setting.toml index daef6fb..3a8de72 100644 --- a/config/setting.toml +++ b/config/setting.toml @@ -60,3 +60,15 @@ call_mode = "default" # 常用时区:中国/新加坡 +8, 日本/韩国 +9, 印度 +5.5, 伦敦 0, 纽约 -5, 洛杉矶 -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 = "" + diff --git a/src/api/admin.py b/src/api/admin.py index 65973af..eaae03e 100644 --- a/src/api/admin.py +++ b/src/api/admin.py @@ -249,7 +249,10 @@ async def get_tokens(token: str = Depends(verify_admin_token)) -> List[dict]: "video_enabled": token.video_enabled, # 并发限制 "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 diff --git a/src/core/database.py b/src/core/database.py index 79752e4..af3ed19 100644 --- a/src/core/database.py +++ b/src/core/database.py @@ -393,7 +393,8 @@ class Database: video_enabled BOOLEAN DEFAULT 1, image_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"): 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() async def init_config_from_toml(self, config_dict: dict, is_first_startup: bool = True): @@ -698,27 +715,35 @@ class Database: """, (token_id,)) await db.commit() - async def update_token_status(self, token_id: int, is_active: bool): - """Update token status""" + async def update_token_status(self, token_id: int, is_active: bool, disabled_reason: Optional[str] = None): + """Update token status and disabled reason""" async with aiosqlite.connect(self.db_path) as db: await db.execute(""" - UPDATE tokens SET is_active = ? WHERE id = ? - """, (is_active, token_id)) + UPDATE tokens SET is_active = ?, disabled_reason = ? WHERE id = ? + """, (is_active, disabled_reason, token_id)) await db.commit() 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: await db.execute(""" - UPDATE tokens SET is_expired = 1, is_active = 0 WHERE id = ? - """, (token_id,)) + UPDATE tokens SET is_expired = 1, is_active = 0, disabled_reason = ? WHERE 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() 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: await db.execute(""" - UPDATE tokens SET is_expired = 0 WHERE id = ? + UPDATE tokens SET is_expired = 0, disabled_reason = NULL WHERE id = ? """, (token_id,)) await db.commit() diff --git a/src/core/models.py b/src/core/models.py index c30b079..49f6445 100644 --- a/src/core/models.py +++ b/src/core/models.py @@ -40,6 +40,8 @@ class Token(BaseModel): video_concurrency: int = -1 # 视频并发数限制,-1表示不限制 # 过期标记 is_expired: bool = False # Token是否已过期(401 token_invalidated) + # 禁用原因: manual=手动禁用, error_limit=错误次数超限, token_invalid=Token失效, expired=过期失效 + disabled_reason: Optional[str] = None class TokenStats(BaseModel): """Token statistics""" diff --git a/src/services/token_manager.py b/src/services/token_manager.py index c47f814..745a4f4 100644 --- a/src/services/token_manager.py +++ b/src/services/token_manager.py @@ -946,19 +946,21 @@ class TokenManager: async def update_token_status(self, token_id: int, is_active: bool): """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): """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) await self.db.reset_error_count(token_id) # Clear expired flag when enabling await self.db.clear_token_expired(token_id) async def disable_token(self, token_id: int): - """Disable a token""" - await self.db.update_token_status(token_id, False) + """Disable a token (manual disable)""" + await self.db.update_token_status(token_id, False, "manual") 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)""" @@ -1048,6 +1050,14 @@ class TokenManager: "valid": False, "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 { "valid": False, "message": f"Token is invalid: {error_msg}" @@ -1077,7 +1087,8 @@ class TokenManager: admin_config = await self.db.get_admin_config() 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): """Record successful request (reset error count)""" diff --git a/static/manage.html b/static/manage.html index cc326b9..94be007 100644 --- a/static/manage.html +++ b/static/manage.html @@ -1058,19 +1058,21 @@ const $=(id)=>document.getElementById(id), 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}, + // 获取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)}}, 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='
'+statuses.map(s=>``).join('')+'
';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='
'+statuses.map(s=>``).join('')+'
';updateStatusFilterLabel()}, updateStatusFilterLabel=()=>{const label=$('statusFilterLabel');if(label){label.textContent=currentStatusFilter||'全部'}}, toggleStatusFilterDropdown=()=>{const dropdown=$('statusFilterDropdown');if(!dropdown)return;dropdown.classList.toggle('hidden')}, selectStatusFilter=(status)=>{currentStatusFilter=status;currentPage=1;updateStatusFilterLabel();toggleStatusFilterDropdown();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`${dateStr} ${timeStr}`;const days=Math.floor(diff/864e5);if(days<7)return`${dateStr} ${timeStr}`;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}, 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`${formatPlanType(t.plan_type)}`}, formatClientId=(clientId)=>{if(!clientId)return'-';const short=clientId.substring(0,8)+'...';return`${short}`}, - 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`${t.email}${statusText}${formatClientId(t.client_id)}${formatExpiry(t.expiry_time)}${formatPlanTypeWithTooltip(t)}${remainingCount}${imageDisplay}${videoDisplay}${t.error_count||0}${t.remark||'-'}`}).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`${t.email}${statusText}${formatClientId(t.client_id)}${formatExpiry(t.expiry_time)}${formatPlanTypeWithTooltip(t)}${remainingCount}${imageDisplay}${videoDisplay}${t.error_count||0}${t.remark||'-'}`}).join('');renderPagination()}, refreshTokens=async()=>{await loadTokens();await loadStats()}, changePage=(page)=>{currentPage=page;renderTokens()}, changePageSize=(size)=>{pageSize=parseInt(size);currentPage=1;renderTokens()},