fix: 图片模型多图支持、重置错误计数

feat: 账号类型分配模型配额
Fixes #5,#7
This commit is contained in:
TheSmallHanCat
2025-12-01 19:12:46 +08:00
parent 29f247e108
commit fcd61c692a
6 changed files with 36 additions and 20 deletions

View File

@@ -36,7 +36,7 @@
```bash
# 克隆项目
git clone https://github.com/TheSmallHanCat/flow2api.git
cd sora2api
cd flow2api
# 启动服务
docker-compose up -d

View File

@@ -499,7 +499,7 @@ async def get_stats(token: str = Depends(verify_admin_token)):
if stats:
total_images += stats.image_count
total_videos += stats.video_count
total_errors += stats.error_count
total_errors += stats.error_count # Historical total errors
today_images += stats.today_image_count
today_videos += stats.today_video_count
today_errors += stats.today_error_count

View File

@@ -56,7 +56,7 @@ class TokenStats(BaseModel):
image_count: int = 0
video_count: int = 0
success_count: int = 0
error_count: int = 0
error_count: int = 0 # Historical total errors (never reset)
last_success_at: Optional[datetime] = None
last_error_at: Optional[datetime] = None
# 今日统计

View File

@@ -281,9 +281,9 @@ class GenerationHandler:
debug_logger.log_info(f"[GENERATION] 正在选择可用Token...")
if generation_type == "image":
token = await self.load_balancer.select_token(for_image_generation=True)
token = await self.load_balancer.select_token(for_image_generation=True, model=model)
else:
token = await self.load_balancer.select_token(for_video_generation=True)
token = await self.load_balancer.select_token(for_video_generation=True, model=model)
if not token:
error_msg = self._get_no_token_error_message(generation_type)
@@ -335,6 +335,10 @@ class GenerationHandler:
# 6. 记录使用
is_video = (generation_type == "video")
await self.token_manager.record_usage(token.id, is_video=is_video)
# 重置错误计数 (请求成功时清空连续错误计数)
await self.token_manager.record_success(token.id)
debug_logger.log_info(f"[GENERATION] ✅ 生成成功完成")
# 7. 记录成功日志
@@ -397,19 +401,21 @@ class GenerationHandler:
image_inputs = []
if images and len(images) > 0:
if stream:
yield self._create_stream_chunk("上传参考图片...\n")
yield self._create_stream_chunk(f"上传 {len(images)}参考图片...\n")
image_bytes = images[0] # 图生图只需要一张
media_id = await self.flow_client.upload_image(
token.at,
image_bytes,
model_config["aspect_ratio"]
)
image_inputs = [{
"name": media_id,
"imageInputType": "IMAGE_INPUT_TYPE_REFERENCE"
}]
# 支持多图输入
for idx, image_bytes in enumerate(images):
media_id = await self.flow_client.upload_image(
token.at,
image_bytes,
model_config["aspect_ratio"]
)
image_inputs.append({
"name": media_id,
"imageInputType": "IMAGE_INPUT_TYPE_REFERENCE"
})
if stream:
yield self._create_stream_chunk(f"已上传第 {idx + 1}/{len(images)} 张图片\n")
# 调用生成API
if stream:

View File

@@ -16,7 +16,8 @@ class LoadBalancer:
async def select_token(
self,
for_image_generation: bool = False,
for_video_generation: bool = False
for_video_generation: bool = False,
model: Optional[str] = None
) -> Optional[Token]:
"""
Select a token using random load balancing
@@ -24,11 +25,12 @@ class LoadBalancer:
Args:
for_image_generation: If True, only select tokens with image_enabled=True
for_video_generation: If True, only select tokens with video_enabled=True
model: Model name (used to filter tokens for specific models)
Returns:
Selected token or None if no available tokens
"""
debug_logger.log_info(f"[LOAD_BALANCER] 开始选择Token (图片生成={for_image_generation}, 视频生成={for_video_generation})")
debug_logger.log_info(f"[LOAD_BALANCER] 开始选择Token (图片生成={for_image_generation}, 视频生成={for_video_generation}, 模型={model})")
active_tokens = await self.token_manager.get_active_tokens()
debug_logger.log_info(f"[LOAD_BALANCER] 获取到 {len(active_tokens)} 个活跃Token")
@@ -47,6 +49,12 @@ class LoadBalancer:
filtered_reasons[token.id] = "AT无效或已过期"
continue
# Filter for gemini-3.0 models (skip free tier tokens)
if model and model in ["gemini-3.0-pro-image-landscape", "gemini-3.0-pro-image-portrait"]:
if token.user_paygate_tier == "PAYGATE_TIER_NOT_PAID":
filtered_reasons[token.id] = "gemini-3.0模型不支持普通账号"
continue
# Filter for image generation
if for_image_generation:
if not token.image_enabled:

View File

@@ -134,6 +134,7 @@
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">状态</th>
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">过期时间</th>
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">余额</th>
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">类型</th>
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">项目名称</th>
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">项目ID</th>
<th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">图片</th>
@@ -536,7 +537,8 @@
formatSora2=(t)=>{if(t.sora2_supported===true){const remaining=t.sora2_total_count-t.sora2_redeemed_count;const tooltipText=`邀请码: ${t.sora2_invite_code||'无'}\n可用次数: ${remaining}/${t.sora2_total_count}\n已用次数: ${t.sora2_redeemed_count}`;return`<div class="inline-flex items-center gap-1"><span class="inline-flex items-center rounded px-2 py-0.5 text-xs bg-green-50 text-green-700 cursor-pointer" title="${tooltipText}" onclick="copySora2Code('${t.sora2_invite_code||''}')">支持</span><span class="text-xs text-muted-foreground" title="${tooltipText}">${remaining}/${t.sora2_total_count}</span></div>`}else if(t.sora2_supported===false){return`<span class="inline-flex items-center rounded px-2 py-0.5 text-xs bg-gray-100 text-gray-700 cursor-pointer" title="点击使用邀请码激活" onclick="openSora2Modal(${t.id})">不支持</span>`}else{return'-'}},
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>`},
formatSora2Remaining=(t)=>{if(t.sora2_supported===true){const remaining=t.sora2_remaining_count||0;return`<span class="text-xs">${remaining}</span>`}else{return'-'}},
renderTokens=()=>{const tb=$('tokenTableBody');tb.innerHTML=allTokens.map(t=>{const imageDisplay=t.image_enabled?`${t.image_count||0}`:'-';const videoDisplay=t.video_enabled?`${t.video_count||0}`:'-';const creditsDisplay=t.credits!==undefined?`${t.credits}`:'-';const projectDisplay=t.current_project_name||'-';const projectIdDisplay=t.current_project_id?(t.current_project_id.length>5?`<span class="cursor-pointer text-blue-600 hover:text-blue-700" onclick="copyProjectId('${t.current_project_id}')" title="${t.current_project_id}">${t.current_project_id.substring(0,5)}...</span>`:`<span class="cursor-pointer text-blue-600 hover:text-blue-700" onclick="copyProjectId('${t.current_project_id}')" title="${t.current_project_id}">${t.current_project_id}</span>`):'-';const expiryDisplay=formatExpiry(t.at_expires);return`<tr><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 ${t.is_active?'bg-green-50 text-green-700':'bg-gray-100 text-gray-700'}">${t.is_active?'活跃':'禁用'}</span></td><td class="py-2.5 px-3 text-xs">${expiryDisplay}</td><td class="py-2.5 px-3"><button onclick="refreshTokenCredits(${t.id})" class="inline-flex items-center gap-1 text-blue-600 hover:text-blue-700 text-sm" title="点击刷新余额"><span>${creditsDisplay}</span><svg class="h-3 w-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 11-6.219-8.56"/><path d="M15 4.5l3.5 3.5L22 4.5"/></svg></button></td><td class="py-2.5 px-3 text-xs">${projectDisplay}</td><td class="py-2.5 px-3 text-xs">${projectIdDisplay}</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="refreshTokenAT(${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" title="刷新AT">更新</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('')},
formatAccountType=(tier)=>{if(tier==='PAYGATE_TIER_NOT_PAID'){return`<span class="inline-flex items-center rounded px-2 py-0.5 text-xs bg-gray-100 text-gray-700">普通</span>`}else{return`<span class="inline-flex items-center rounded px-2 py-0.5 text-xs bg-purple-50 text-purple-700">会员</span>`}},
renderTokens=()=>{const tb=$('tokenTableBody');tb.innerHTML=allTokens.map(t=>{const imageDisplay=t.image_enabled?`${t.image_count||0}`:'-';const videoDisplay=t.video_enabled?`${t.video_count||0}`:'-';const creditsDisplay=t.credits!==undefined?`${t.credits}`:'-';const accountTypeDisplay=formatAccountType(t.user_paygate_tier);const projectDisplay=t.current_project_name||'-';const projectIdDisplay=t.current_project_id?(t.current_project_id.length>5?`<span class="cursor-pointer text-blue-600 hover:text-blue-700" onclick="copyProjectId('${t.current_project_id}')" title="${t.current_project_id}">${t.current_project_id.substring(0,5)}...</span>`:`<span class="cursor-pointer text-blue-600 hover:text-blue-700" onclick="copyProjectId('${t.current_project_id}')" title="${t.current_project_id}">${t.current_project_id}</span>`):'-';const expiryDisplay=formatExpiry(t.at_expires);return`<tr><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 ${t.is_active?'bg-green-50 text-green-700':'bg-gray-100 text-gray-700'}">${t.is_active?'活跃':'禁用'}</span></td><td class="py-2.5 px-3 text-xs">${expiryDisplay}</td><td class="py-2.5 px-3"><button onclick="refreshTokenCredits(${t.id})" class="inline-flex items-center gap-1 text-blue-600 hover:text-blue-700 text-sm" title="点击刷新余额"><span>${creditsDisplay}</span><svg class="h-3 w-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 11-6.219-8.56"/><path d="M15 4.5l3.5 3.5L22 4.5"/></svg></button></td><td class="py-2.5 px-3">${accountTypeDisplay}</td><td class="py-2.5 px-3 text-xs">${projectDisplay}</td><td class="py-2.5 px-3 text-xs">${projectIdDisplay}</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="refreshTokenAT(${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" title="刷新AT">更新</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('')},
refreshTokenCredits=async(id)=>{try{showToast('正在刷新余额...','info');const r=await apiRequest(`/api/tokens/${id}/refresh-credits`,{method:'POST'});if(!r)return;const d=await r.json();if(d.success){showToast(`余额刷新成功: ${d.credits}`,'success');await refreshTokens()}else{showToast('刷新失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('刷新失败: '+e.message,'error')}},
refreshTokenAT=async(id)=>{try{showToast('正在更新AT...','info');const r=await apiRequest(`/api/tokens/${id}/refresh-at`,{method:'POST'});if(!r)return;const d=await r.json();if(d.success){const expiresDate=d.token.at_expires?new Date(d.token.at_expires):null;const expiresStr=expiresDate?expiresDate.toLocaleString('zh-CN',{year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',hour12:false}).replace(/\//g,'-'):'未知';showToast(`AT更新成功! 新过期时间: ${expiresStr}`,'success');await refreshTokens()}else{showToast('更新失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('更新失败: '+e.message,'error')}},
refreshTokens=async()=>{await loadTokens();await loadStats()},