Commit ·
fcd61c6
1
Parent(s): 29f247e
fix: 图片模型多图支持、重置错误计数
Browse filesfeat: 账号类型分配模型配额
Fixes #5,#7
- README.md +1 -1
- src/api/admin.py +1 -1
- src/core/models.py +1 -1
- src/services/generation_handler.py +20 -14
- src/services/load_balancer.py +10 -2
- static/manage.html +3 -1
README.md
CHANGED
|
@@ -36,7 +36,7 @@
|
|
| 36 |
```bash
|
| 37 |
# 克隆项目
|
| 38 |
git clone https://github.com/TheSmallHanCat/flow2api.git
|
| 39 |
-
cd
|
| 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 |
-
|
| 410 |
-
|
| 411 |
-
|
| 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 |
-
|
|
|
|
| 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()},
|