TheSmallHanCat commited on
Commit
fcd61c6
·
1 Parent(s): 29f247e

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

Browse files

feat: 账号类型分配模型配额
Fixes #5,#7

README.md CHANGED
@@ -36,7 +36,7 @@
36
  ```bash
37
  # 克隆项目
38
  git clone https://github.com/TheSmallHanCat/flow2api.git
39
- cd sora2api
40
 
41
  # 启动服务
42
  docker-compose up -d
 
36
  ```bash
37
  # 克隆项目
38
  git clone https://github.com/TheSmallHanCat/flow2api.git
39
+ cd flow2api
40
 
41
  # 启动服务
42
  docker-compose up -d
src/api/admin.py CHANGED
@@ -499,7 +499,7 @@ async def get_stats(token: str = Depends(verify_admin_token)):
499
  if stats:
500
  total_images += stats.image_count
501
  total_videos += stats.video_count
502
- total_errors += stats.error_count
503
  today_images += stats.today_image_count
504
  today_videos += stats.today_video_count
505
  today_errors += stats.today_error_count
 
499
  if stats:
500
  total_images += stats.image_count
501
  total_videos += stats.video_count
502
+ total_errors += stats.error_count # Historical total errors
503
  today_images += stats.today_image_count
504
  today_videos += stats.today_video_count
505
  today_errors += stats.today_error_count
src/core/models.py CHANGED
@@ -56,7 +56,7 @@ class TokenStats(BaseModel):
56
  image_count: int = 0
57
  video_count: int = 0
58
  success_count: int = 0
59
- error_count: int = 0
60
  last_success_at: Optional[datetime] = None
61
  last_error_at: Optional[datetime] = None
62
  # 今日统计
 
56
  image_count: int = 0
57
  video_count: int = 0
58
  success_count: int = 0
59
+ error_count: int = 0 # Historical total errors (never reset)
60
  last_success_at: Optional[datetime] = None
61
  last_error_at: Optional[datetime] = None
62
  # 今日统计
src/services/generation_handler.py CHANGED
@@ -281,9 +281,9 @@ class GenerationHandler:
281
  debug_logger.log_info(f"[GENERATION] 正在选择可用Token...")
282
 
283
  if generation_type == "image":
284
- token = await self.load_balancer.select_token(for_image_generation=True)
285
  else:
286
- token = await self.load_balancer.select_token(for_video_generation=True)
287
 
288
  if not token:
289
  error_msg = self._get_no_token_error_message(generation_type)
@@ -335,6 +335,10 @@ class GenerationHandler:
335
  # 6. 记录使用
336
  is_video = (generation_type == "video")
337
  await self.token_manager.record_usage(token.id, is_video=is_video)
 
 
 
 
338
  debug_logger.log_info(f"[GENERATION] ✅ 生成成功完成")
339
 
340
  # 7. 记录成功日志
@@ -397,19 +401,21 @@ class GenerationHandler:
397
  image_inputs = []
398
  if images and len(images) > 0:
399
  if stream:
400
- yield self._create_stream_chunk("上传参考图片...\n")
401
-
402
- image_bytes = images[0] # 图生图只需要一张
403
- media_id = await self.flow_client.upload_image(
404
- token.at,
405
- image_bytes,
406
- model_config["aspect_ratio"]
407
- )
408
 
409
- image_inputs = [{
410
- "name": media_id,
411
- "imageInputType": "IMAGE_INPUT_TYPE_REFERENCE"
412
- }]
 
 
 
 
 
 
 
 
 
413
 
414
  # 调用生成API
415
  if stream:
 
281
  debug_logger.log_info(f"[GENERATION] 正在选择可用Token...")
282
 
283
  if generation_type == "image":
284
+ token = await self.load_balancer.select_token(for_image_generation=True, model=model)
285
  else:
286
+ token = await self.load_balancer.select_token(for_video_generation=True, model=model)
287
 
288
  if not token:
289
  error_msg = self._get_no_token_error_message(generation_type)
 
335
  # 6. 记录使用
336
  is_video = (generation_type == "video")
337
  await self.token_manager.record_usage(token.id, is_video=is_video)
338
+
339
+ # 重置错误计数 (请求成功时清空连续错误计数)
340
+ await self.token_manager.record_success(token.id)
341
+
342
  debug_logger.log_info(f"[GENERATION] ✅ 生成成功完成")
343
 
344
  # 7. 记录成功日志
 
401
  image_inputs = []
402
  if images and len(images) > 0:
403
  if stream:
404
+ yield self._create_stream_chunk(f"上传 {len(images)} 张参考图片...\n")
 
 
 
 
 
 
 
405
 
406
+ # 支持多图输入
407
+ for idx, image_bytes in enumerate(images):
408
+ media_id = await self.flow_client.upload_image(
409
+ token.at,
410
+ image_bytes,
411
+ model_config["aspect_ratio"]
412
+ )
413
+ image_inputs.append({
414
+ "name": media_id,
415
+ "imageInputType": "IMAGE_INPUT_TYPE_REFERENCE"
416
+ })
417
+ if stream:
418
+ yield self._create_stream_chunk(f"已上传第 {idx + 1}/{len(images)} 张图片\n")
419
 
420
  # 调用生成API
421
  if stream:
src/services/load_balancer.py CHANGED
@@ -16,7 +16,8 @@ class LoadBalancer:
16
  async def select_token(
17
  self,
18
  for_image_generation: bool = False,
19
- for_video_generation: bool = False
 
20
  ) -> Optional[Token]:
21
  """
22
  Select a token using random load balancing
@@ -24,11 +25,12 @@ class LoadBalancer:
24
  Args:
25
  for_image_generation: If True, only select tokens with image_enabled=True
26
  for_video_generation: If True, only select tokens with video_enabled=True
 
27
 
28
  Returns:
29
  Selected token or None if no available tokens
30
  """
31
- debug_logger.log_info(f"[LOAD_BALANCER] 开始选择Token (图片生成={for_image_generation}, 视频生成={for_video_generation})")
32
 
33
  active_tokens = await self.token_manager.get_active_tokens()
34
  debug_logger.log_info(f"[LOAD_BALANCER] 获取到 {len(active_tokens)} 个活跃Token")
@@ -47,6 +49,12 @@ class LoadBalancer:
47
  filtered_reasons[token.id] = "AT无效或已过期"
48
  continue
49
 
 
 
 
 
 
 
50
  # Filter for image generation
51
  if for_image_generation:
52
  if not token.image_enabled:
 
16
  async def select_token(
17
  self,
18
  for_image_generation: bool = False,
19
+ for_video_generation: bool = False,
20
+ model: Optional[str] = None
21
  ) -> Optional[Token]:
22
  """
23
  Select a token using random load balancing
 
25
  Args:
26
  for_image_generation: If True, only select tokens with image_enabled=True
27
  for_video_generation: If True, only select tokens with video_enabled=True
28
+ model: Model name (used to filter tokens for specific models)
29
 
30
  Returns:
31
  Selected token or None if no available tokens
32
  """
33
+ debug_logger.log_info(f"[LOAD_BALANCER] 开始选择Token (图片生成={for_image_generation}, 视频生成={for_video_generation}, 模型={model})")
34
 
35
  active_tokens = await self.token_manager.get_active_tokens()
36
  debug_logger.log_info(f"[LOAD_BALANCER] 获取到 {len(active_tokens)} 个活跃Token")
 
49
  filtered_reasons[token.id] = "AT无效或已过期"
50
  continue
51
 
52
+ # Filter for gemini-3.0 models (skip free tier tokens)
53
+ if model and model in ["gemini-3.0-pro-image-landscape", "gemini-3.0-pro-image-portrait"]:
54
+ if token.user_paygate_tier == "PAYGATE_TIER_NOT_PAID":
55
+ filtered_reasons[token.id] = "gemini-3.0模型不支持普通账号"
56
+ continue
57
+
58
  # Filter for image generation
59
  if for_image_generation:
60
  if not token.image_enabled:
static/manage.html CHANGED
@@ -134,6 +134,7 @@
134
  <th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">状态</th>
135
  <th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">过期时间</th>
136
  <th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">余额</th>
 
137
  <th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">项目名称</th>
138
  <th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">项目ID</th>
139
  <th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">图片</th>
@@ -536,7 +537,8 @@
536
  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'-'}},
537
  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>`},
538
  formatSora2Remaining=(t)=>{if(t.sora2_supported===true){const remaining=t.sora2_remaining_count||0;return`<span class="text-xs">${remaining}</span>`}else{return'-'}},
539
- 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('')},
 
540
  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')}},
541
  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')}},
542
  refreshTokens=async()=>{await loadTokens();await loadStats()},
 
134
  <th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">状态</th>
135
  <th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">过期时间</th>
136
  <th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">余额</th>
137
+ <th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">类型</th>
138
  <th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">项目名称</th>
139
  <th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">项目ID</th>
140
  <th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">图片</th>
 
537
  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'-'}},
538
  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>`},
539
  formatSora2Remaining=(t)=>{if(t.sora2_supported===true){const remaining=t.sora2_remaining_count||0;return`<span class="text-xs">${remaining}</span>`}else{return'-'}},
540
+ 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>`}},
541
+ 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('')},
542
  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')}},
543
  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')}},
544
  refreshTokens=async()=>{await loadTokens();await loadStats()},