ricebug commited on
Commit
7ce17c1
·
verified ·
1 Parent(s): 448f3df

Upload 26 files

Browse files
Dockerfile ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY . .
9
+
10
+ EXPOSE 8000
11
+
12
+ CMD ["python", "main.py"]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2025 TheSmallHanCat
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
config/setting.toml ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [global]
2
+ api_key = "han1234"
3
+ admin_username = "admin"
4
+ admin_password = "admin"
5
+
6
+ [flow]
7
+ labs_base_url = "https://labs.google/fx/api"
8
+ api_base_url = "https://aisandbox-pa.googleapis.com/v1"
9
+ timeout = 120
10
+ poll_interval = 3.0
11
+ max_poll_attempts = 200
12
+
13
+ [server]
14
+ host = "0.0.0.0"
15
+ port = 8000
16
+
17
+ [debug]
18
+ enabled = false
19
+ log_requests = true
20
+ log_responses = true
21
+ mask_token = true
22
+
23
+ [proxy]
24
+ proxy_enabled = false
25
+ proxy_url = ""
26
+
27
+ [generation]
28
+ image_timeout = 300
29
+ video_timeout = 1500
30
+
31
+ [admin]
32
+ error_ban_threshold = 3
33
+
34
+ [cache]
35
+ enabled = false
36
+ timeout = 7200 # 缓存超时时间(秒), 默认2小时
37
+ base_url = "" # 缓存文件访问的基础URL, 留空则使用服务器地址
config/setting_warp.toml ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [global]
2
+ api_key = "han1234"
3
+ admin_username = "admin"
4
+ admin_password = "admin"
5
+
6
+ [flow]
7
+ labs_base_url = "https://labs.google/fx/api"
8
+ api_base_url = "https://aisandbox-pa.googleapis.com/v1"
9
+ timeout = 120
10
+ poll_interval = 3.0
11
+ max_poll_attempts = 200
12
+
13
+ [server]
14
+ host = "0.0.0.0"
15
+ port = 8000
16
+
17
+ [debug]
18
+ enabled = false
19
+ log_requests = true
20
+ log_responses = true
21
+ mask_token = true
22
+
23
+ [proxy]
24
+ proxy_enabled = true
25
+ proxy_url = "socks5://warp:1080"
26
+
27
+ [generation]
28
+ image_timeout = 300
29
+ video_timeout = 1500
30
+
31
+ [admin]
32
+ error_ban_threshold = 3
33
+
34
+ [cache]
35
+ enabled = false
36
+ timeout = 7200 # 缓存超时时间(秒), 默认2小时
37
+ base_url = "" # 缓存文件访问的基础URL, 留空则使用服务器地址
main.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Flow2API - Main Entry Point"""
2
+ from src.main import app
3
+ import uvicorn
4
+
5
+ if __name__ == "__main__":
6
+ from src.core.config import config
7
+
8
+ uvicorn.run(
9
+ "src.main:app",
10
+ host=config.server_host,
11
+ port=config.server_port,
12
+ reload=False
13
+ )
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.119.0
2
+ uvicorn[standard]==0.32.1
3
+ aiosqlite==0.20.0
4
+ pydantic==2.10.4
5
+ curl-cffi==0.7.3
6
+ tomli==2.2.1
7
+ bcrypt==4.2.1
8
+ python-multipart==0.0.20
9
+ python-dateutil==2.8.2
src/api/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """API modules"""
2
+
3
+ from .routes import router as api_router
4
+ from .admin import router as admin_router
5
+
6
+ __all__ = ["api_router", "admin_router"]
src/api/admin.py ADDED
@@ -0,0 +1,719 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Admin API routes"""
2
+ from fastapi import APIRouter, Depends, HTTPException, Header
3
+ from fastapi.responses import JSONResponse
4
+ from pydantic import BaseModel
5
+ from typing import Optional, List
6
+ import secrets
7
+ from ..core.auth import AuthManager
8
+ from ..core.database import Database
9
+ from ..services.token_manager import TokenManager
10
+ from ..services.proxy_manager import ProxyManager
11
+
12
+ router = APIRouter()
13
+
14
+ # Dependency injection
15
+ token_manager: TokenManager = None
16
+ proxy_manager: ProxyManager = None
17
+ db: Database = None
18
+
19
+ # Store active admin session tokens (in production, use Redis or database)
20
+ active_admin_tokens = set()
21
+
22
+
23
+ def set_dependencies(tm: TokenManager, pm: ProxyManager, database: Database):
24
+ """Set service instances"""
25
+ global token_manager, proxy_manager, db
26
+ token_manager = tm
27
+ proxy_manager = pm
28
+ db = database
29
+
30
+
31
+ # ========== Request Models ==========
32
+
33
+ class LoginRequest(BaseModel):
34
+ username: str
35
+ password: str
36
+
37
+
38
+ class AddTokenRequest(BaseModel):
39
+ st: str
40
+ project_id: Optional[str] = None # 用户可选输入project_id
41
+ project_name: Optional[str] = None
42
+ remark: Optional[str] = None
43
+ image_enabled: bool = True
44
+ video_enabled: bool = True
45
+ image_concurrency: int = -1
46
+ video_concurrency: int = -1
47
+
48
+
49
+ class UpdateTokenRequest(BaseModel):
50
+ st: str # Session Token (必填,用于刷新AT)
51
+ project_id: Optional[str] = None # 用户可选输入project_id
52
+ project_name: Optional[str] = None
53
+ remark: Optional[str] = None
54
+ image_enabled: Optional[bool] = None
55
+ video_enabled: Optional[bool] = None
56
+ image_concurrency: Optional[int] = None
57
+ video_concurrency: Optional[int] = None
58
+
59
+
60
+ class ProxyConfigRequest(BaseModel):
61
+ proxy_enabled: bool
62
+ proxy_url: Optional[str] = None
63
+
64
+
65
+ class GenerationConfigRequest(BaseModel):
66
+ image_timeout: int
67
+ video_timeout: int
68
+
69
+
70
+ class ChangePasswordRequest(BaseModel):
71
+ old_password: str
72
+ new_password: str
73
+
74
+
75
+ class UpdateAPIKeyRequest(BaseModel):
76
+ new_api_key: str
77
+
78
+
79
+ class UpdateDebugConfigRequest(BaseModel):
80
+ enabled: bool
81
+
82
+
83
+ class UpdateAdminConfigRequest(BaseModel):
84
+ error_ban_threshold: int
85
+
86
+
87
+ class ST2ATRequest(BaseModel):
88
+ """ST转AT请求"""
89
+ st: str
90
+
91
+
92
+ # ========== Auth Middleware ==========
93
+
94
+ async def verify_admin_token(authorization: str = Header(None)):
95
+ """Verify admin session token (NOT API key)"""
96
+ if not authorization or not authorization.startswith("Bearer "):
97
+ raise HTTPException(status_code=401, detail="Missing authorization")
98
+
99
+ token = authorization[7:]
100
+
101
+ # Check if token is in active session tokens
102
+ if token not in active_admin_tokens:
103
+ raise HTTPException(status_code=401, detail="Invalid or expired admin token")
104
+
105
+ return token
106
+
107
+
108
+ # ========== Auth Endpoints ==========
109
+
110
+ @router.post("/api/admin/login")
111
+ async def admin_login(request: LoginRequest):
112
+ """Admin login - returns session token (NOT API key)"""
113
+ admin_config = await db.get_admin_config()
114
+
115
+ if not AuthManager.verify_admin(request.username, request.password):
116
+ raise HTTPException(status_code=401, detail="Invalid credentials")
117
+
118
+ # Generate independent session token
119
+ session_token = f"admin-{secrets.token_urlsafe(32)}"
120
+
121
+ # Store in active tokens
122
+ active_admin_tokens.add(session_token)
123
+
124
+ return {
125
+ "success": True,
126
+ "token": session_token, # Session token (NOT API key)
127
+ "username": admin_config.username
128
+ }
129
+
130
+
131
+ @router.post("/api/admin/logout")
132
+ async def admin_logout(token: str = Depends(verify_admin_token)):
133
+ """Admin logout - invalidate session token"""
134
+ active_admin_tokens.discard(token)
135
+ return {"success": True, "message": "退出登录成功"}
136
+
137
+
138
+ @router.post("/api/admin/change-password")
139
+ async def change_password(
140
+ request: ChangePasswordRequest,
141
+ token: str = Depends(verify_admin_token)
142
+ ):
143
+ """Change admin password"""
144
+ admin_config = await db.get_admin_config()
145
+
146
+ # Verify old password
147
+ if not AuthManager.verify_admin(admin_config.username, request.old_password):
148
+ raise HTTPException(status_code=400, detail="旧密码错误")
149
+
150
+ # Update password in database
151
+ await db.update_admin_config(password=request.new_password)
152
+
153
+ # 🔥 Hot reload: sync database config to memory
154
+ await db.reload_config_to_memory()
155
+
156
+ # 🔑 Invalidate all admin session tokens (force re-login for security)
157
+ active_admin_tokens.clear()
158
+
159
+ return {"success": True, "message": "密码修改成功,请重新登录"}
160
+
161
+
162
+ # ========== Token Management ==========
163
+
164
+ @router.get("/api/tokens")
165
+ async def get_tokens(token: str = Depends(verify_admin_token)):
166
+ """Get all tokens with statistics"""
167
+ tokens = await token_manager.get_all_tokens()
168
+ result = []
169
+
170
+ for t in tokens:
171
+ stats = await db.get_token_stats(t.id)
172
+
173
+ result.append({
174
+ "id": t.id,
175
+ "st": t.st, # Session Token for editing
176
+ "at": t.at, # Access Token for editing (从ST转换而来)
177
+ "at_expires": t.at_expires.isoformat() if t.at_expires else None, # 🆕 AT过期时间
178
+ "token": t.at, # 兼容前端 token.token 的访问方式
179
+ "email": t.email,
180
+ "name": t.name,
181
+ "remark": t.remark,
182
+ "is_active": t.is_active,
183
+ "created_at": t.created_at.isoformat() if t.created_at else None,
184
+ "last_used_at": t.last_used_at.isoformat() if t.last_used_at else None,
185
+ "use_count": t.use_count,
186
+ "credits": t.credits, # 🆕 余额
187
+ "user_paygate_tier": t.user_paygate_tier,
188
+ "current_project_id": t.current_project_id, # 🆕 项目ID
189
+ "current_project_name": t.current_project_name, # 🆕 项目名称
190
+ "image_enabled": t.image_enabled,
191
+ "video_enabled": t.video_enabled,
192
+ "image_concurrency": t.image_concurrency,
193
+ "video_concurrency": t.video_concurrency,
194
+ "image_count": stats.image_count if stats else 0,
195
+ "video_count": stats.video_count if stats else 0,
196
+ "error_count": stats.error_count if stats else 0
197
+ })
198
+
199
+ return result # 直接返回数组,兼容前端
200
+
201
+
202
+ @router.post("/api/tokens")
203
+ async def add_token(
204
+ request: AddTokenRequest,
205
+ token: str = Depends(verify_admin_token)
206
+ ):
207
+ """Add a new token"""
208
+ try:
209
+ new_token = await token_manager.add_token(
210
+ st=request.st,
211
+ project_id=request.project_id, # 🆕 支持用户指定project_id
212
+ project_name=request.project_name,
213
+ remark=request.remark,
214
+ image_enabled=request.image_enabled,
215
+ video_enabled=request.video_enabled,
216
+ image_concurrency=request.image_concurrency,
217
+ video_concurrency=request.video_concurrency
218
+ )
219
+
220
+ return {
221
+ "success": True,
222
+ "message": "Token添加成功",
223
+ "token": {
224
+ "id": new_token.id,
225
+ "email": new_token.email,
226
+ "credits": new_token.credits,
227
+ "project_id": new_token.current_project_id,
228
+ "project_name": new_token.current_project_name
229
+ }
230
+ }
231
+ except ValueError as e:
232
+ raise HTTPException(status_code=400, detail=str(e))
233
+ except Exception as e:
234
+ raise HTTPException(status_code=500, detail=f"添加Token失败: {str(e)}")
235
+
236
+
237
+ @router.put("/api/tokens/{token_id}")
238
+ async def update_token(
239
+ token_id: int,
240
+ request: UpdateTokenRequest,
241
+ token: str = Depends(verify_admin_token)
242
+ ):
243
+ """Update token - 使用ST自动刷新AT"""
244
+ try:
245
+ # 先ST转AT
246
+ result = await token_manager.flow_client.st_to_at(request.st)
247
+ at = result["access_token"]
248
+ expires = result.get("expires")
249
+
250
+ # 解析过期时间
251
+ from datetime import datetime
252
+ at_expires = None
253
+ if expires:
254
+ try:
255
+ at_expires = datetime.fromisoformat(expires.replace('Z', '+00:00'))
256
+ except:
257
+ pass
258
+
259
+ # 更新token (包含AT、ST、AT过期时间、project_id和project_name)
260
+ await token_manager.update_token(
261
+ token_id=token_id,
262
+ st=request.st,
263
+ at=at,
264
+ at_expires=at_expires, # 🆕 更新AT过期时间
265
+ project_id=request.project_id,
266
+ project_name=request.project_name,
267
+ remark=request.remark,
268
+ image_enabled=request.image_enabled,
269
+ video_enabled=request.video_enabled,
270
+ image_concurrency=request.image_concurrency,
271
+ video_concurrency=request.video_concurrency
272
+ )
273
+
274
+ return {"success": True, "message": "Token更新成功"}
275
+ except Exception as e:
276
+ raise HTTPException(status_code=500, detail=str(e))
277
+
278
+
279
+ @router.delete("/api/tokens/{token_id}")
280
+ async def delete_token(
281
+ token_id: int,
282
+ token: str = Depends(verify_admin_token)
283
+ ):
284
+ """Delete token"""
285
+ try:
286
+ await token_manager.delete_token(token_id)
287
+ return {"success": True, "message": "Token删除成功"}
288
+ except Exception as e:
289
+ raise HTTPException(status_code=500, detail=str(e))
290
+
291
+
292
+ @router.post("/api/tokens/{token_id}/enable")
293
+ async def enable_token(
294
+ token_id: int,
295
+ token: str = Depends(verify_admin_token)
296
+ ):
297
+ """Enable token"""
298
+ await token_manager.enable_token(token_id)
299
+ return {"success": True, "message": "Token已启用"}
300
+
301
+
302
+ @router.post("/api/tokens/{token_id}/disable")
303
+ async def disable_token(
304
+ token_id: int,
305
+ token: str = Depends(verify_admin_token)
306
+ ):
307
+ """Disable token"""
308
+ await token_manager.disable_token(token_id)
309
+ return {"success": True, "message": "Token已禁用"}
310
+
311
+
312
+ @router.post("/api/tokens/{token_id}/refresh-credits")
313
+ async def refresh_credits(
314
+ token_id: int,
315
+ token: str = Depends(verify_admin_token)
316
+ ):
317
+ """刷新Token余额 🆕"""
318
+ try:
319
+ credits = await token_manager.refresh_credits(token_id)
320
+ return {
321
+ "success": True,
322
+ "message": "余额刷新成功",
323
+ "credits": credits
324
+ }
325
+ except Exception as e:
326
+ raise HTTPException(status_code=500, detail=f"刷新余额失败: {str(e)}")
327
+
328
+
329
+ @router.post("/api/tokens/{token_id}/refresh-at")
330
+ async def refresh_at(
331
+ token_id: int,
332
+ token: str = Depends(verify_admin_token)
333
+ ):
334
+ """手动刷新Token的AT (使用ST转换) 🆕"""
335
+ try:
336
+ # 调用token_manager的内部刷新方法
337
+ success = await token_manager._refresh_at(token_id)
338
+
339
+ if success:
340
+ # 获取更新后的token信息
341
+ updated_token = await token_manager.get_token(token_id)
342
+ return {
343
+ "success": True,
344
+ "message": "AT刷新成功",
345
+ "token": {
346
+ "id": updated_token.id,
347
+ "email": updated_token.email,
348
+ "at_expires": updated_token.at_expires.isoformat() if updated_token.at_expires else None
349
+ }
350
+ }
351
+ else:
352
+ raise HTTPException(status_code=500, detail="AT刷新失败")
353
+ except Exception as e:
354
+ raise HTTPException(status_code=500, detail=f"刷新AT失败: {str(e)}")
355
+
356
+
357
+ @router.post("/api/tokens/st2at")
358
+ async def st_to_at(
359
+ request: ST2ATRequest,
360
+ token: str = Depends(verify_admin_token)
361
+ ):
362
+ """Convert Session Token to Access Token (仅转换,不添加到数据库)"""
363
+ try:
364
+ result = await token_manager.flow_client.st_to_at(request.st)
365
+ return {
366
+ "success": True,
367
+ "message": "ST converted to AT successfully",
368
+ "access_token": result["access_token"],
369
+ "email": result.get("user", {}).get("email"),
370
+ "expires": result.get("expires")
371
+ }
372
+ except Exception as e:
373
+ raise HTTPException(status_code=400, detail=str(e))
374
+
375
+
376
+ # ========== Config Management ==========
377
+
378
+ @router.get("/api/config/proxy")
379
+ async def get_proxy_config(token: str = Depends(verify_admin_token)):
380
+ """Get proxy configuration"""
381
+ config = await proxy_manager.get_proxy_config()
382
+ return {
383
+ "success": True,
384
+ "config": {
385
+ "enabled": config.enabled,
386
+ "proxy_url": config.proxy_url
387
+ }
388
+ }
389
+
390
+
391
+ @router.get("/api/proxy/config")
392
+ async def get_proxy_config_alias(token: str = Depends(verify_admin_token)):
393
+ """Get proxy configuration (alias for frontend compatibility)"""
394
+ config = await proxy_manager.get_proxy_config()
395
+ return {
396
+ "proxy_enabled": config.enabled, # Frontend expects proxy_enabled
397
+ "proxy_url": config.proxy_url
398
+ }
399
+
400
+
401
+ @router.post("/api/proxy/config")
402
+ async def update_proxy_config_alias(
403
+ request: ProxyConfigRequest,
404
+ token: str = Depends(verify_admin_token)
405
+ ):
406
+ """Update proxy configuration (alias for frontend compatibility)"""
407
+ await proxy_manager.update_proxy_config(request.proxy_enabled, request.proxy_url)
408
+ return {"success": True, "message": "代理配置更新成功"}
409
+
410
+
411
+ @router.post("/api/config/proxy")
412
+ async def update_proxy_config(
413
+ request: ProxyConfigRequest,
414
+ token: str = Depends(verify_admin_token)
415
+ ):
416
+ """Update proxy configuration"""
417
+ await proxy_manager.update_proxy_config(request.proxy_enabled, request.proxy_url)
418
+ return {"success": True, "message": "代理配置更新成功"}
419
+
420
+
421
+ @router.get("/api/config/generation")
422
+ async def get_generation_config(token: str = Depends(verify_admin_token)):
423
+ """Get generation timeout configuration"""
424
+ config = await db.get_generation_config()
425
+ return {
426
+ "success": True,
427
+ "config": {
428
+ "image_timeout": config.image_timeout,
429
+ "video_timeout": config.video_timeout
430
+ }
431
+ }
432
+
433
+
434
+ @router.post("/api/config/generation")
435
+ async def update_generation_config(
436
+ request: GenerationConfigRequest,
437
+ token: str = Depends(verify_admin_token)
438
+ ):
439
+ """Update generation timeout configuration"""
440
+ await db.update_generation_config(request.image_timeout, request.video_timeout)
441
+
442
+ # 🔥 Hot reload: sync database config to memory
443
+ await db.reload_config_to_memory()
444
+
445
+ return {"success": True, "message": "生成配置更新成功"}
446
+
447
+
448
+ # ========== System Info ==========
449
+
450
+ @router.get("/api/system/info")
451
+ async def get_system_info(token: str = Depends(verify_admin_token)):
452
+ """Get system information"""
453
+ tokens = await token_manager.get_all_tokens()
454
+ active_tokens = [t for t in tokens if t.is_active]
455
+
456
+ total_credits = sum(t.credits for t in active_tokens)
457
+
458
+ return {
459
+ "success": True,
460
+ "info": {
461
+ "total_tokens": len(tokens),
462
+ "active_tokens": len(active_tokens),
463
+ "total_credits": total_credits,
464
+ "version": "1.0.0"
465
+ }
466
+ }
467
+
468
+
469
+ # ========== Additional Routes for Frontend Compatibility ==========
470
+
471
+ @router.post("/api/login")
472
+ async def login(request: LoginRequest):
473
+ """Login endpoint (alias for /api/admin/login)"""
474
+ return await admin_login(request)
475
+
476
+
477
+ @router.post("/api/logout")
478
+ async def logout(token: str = Depends(verify_admin_token)):
479
+ """Logout endpoint (alias for /api/admin/logout)"""
480
+ return await admin_logout(token)
481
+
482
+
483
+ @router.get("/api/stats")
484
+ async def get_stats(token: str = Depends(verify_admin_token)):
485
+ """Get statistics for dashboard"""
486
+ tokens = await token_manager.get_all_tokens()
487
+ active_tokens = [t for t in tokens if t.is_active]
488
+
489
+ # Calculate totals
490
+ total_images = 0
491
+ total_videos = 0
492
+ total_errors = 0
493
+ today_images = 0
494
+ today_videos = 0
495
+ today_errors = 0
496
+
497
+ for t in tokens:
498
+ stats = await db.get_token_stats(t.id)
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
506
+
507
+ return {
508
+ "total_tokens": len(tokens),
509
+ "active_tokens": len(active_tokens),
510
+ "total_images": total_images,
511
+ "total_videos": total_videos,
512
+ "total_errors": total_errors,
513
+ "today_images": today_images,
514
+ "today_videos": today_videos,
515
+ "today_errors": today_errors
516
+ }
517
+
518
+
519
+ @router.get("/api/logs")
520
+ async def get_logs(
521
+ limit: int = 100,
522
+ token: str = Depends(verify_admin_token)
523
+ ):
524
+ """Get request logs with token email"""
525
+ logs = await db.get_logs(limit=limit)
526
+
527
+ return [{
528
+ "id": log.get("id"),
529
+ "token_id": log.get("token_id"),
530
+ "token_email": log.get("token_email"),
531
+ "token_username": log.get("token_username"),
532
+ "operation": log.get("operation"),
533
+ "status_code": log.get("status_code"),
534
+ "duration": log.get("duration"),
535
+ "created_at": log.get("created_at")
536
+ } for log in logs]
537
+
538
+
539
+ @router.get("/api/admin/config")
540
+ async def get_admin_config(token: str = Depends(verify_admin_token)):
541
+ """Get admin configuration"""
542
+ from ..core.config import config
543
+
544
+ admin_config = await db.get_admin_config()
545
+
546
+ return {
547
+ "admin_username": admin_config.username,
548
+ "api_key": admin_config.api_key,
549
+ "error_ban_threshold": admin_config.error_ban_threshold,
550
+ "debug_enabled": config.debug_enabled # Return actual debug status
551
+ }
552
+
553
+
554
+ @router.post("/api/admin/config")
555
+ async def update_admin_config(
556
+ request: UpdateAdminConfigRequest,
557
+ token: str = Depends(verify_admin_token)
558
+ ):
559
+ """Update admin configuration (error_ban_threshold)"""
560
+ # Update error_ban_threshold in database
561
+ await db.update_admin_config(error_ban_threshold=request.error_ban_threshold)
562
+
563
+ return {"success": True, "message": "配置更新成功"}
564
+
565
+
566
+ @router.post("/api/admin/password")
567
+ async def update_admin_password(
568
+ request: ChangePasswordRequest,
569
+ token: str = Depends(verify_admin_token)
570
+ ):
571
+ """Update admin password"""
572
+ return await change_password(request, token)
573
+
574
+
575
+ @router.post("/api/admin/apikey")
576
+ async def update_api_key(
577
+ request: UpdateAPIKeyRequest,
578
+ token: str = Depends(verify_admin_token)
579
+ ):
580
+ """Update API key (for external API calls, NOT for admin login)"""
581
+ # Update API key in database
582
+ await db.update_admin_config(api_key=request.new_api_key)
583
+
584
+ # 🔥 Hot reload: sync database config to memory
585
+ await db.reload_config_to_memory()
586
+
587
+ return {"success": True, "message": "API Key更新成功"}
588
+
589
+
590
+ @router.post("/api/admin/debug")
591
+ async def update_debug_config(
592
+ request: UpdateDebugConfigRequest,
593
+ token: str = Depends(verify_admin_token)
594
+ ):
595
+ """Update debug configuration"""
596
+ try:
597
+ # Update debug config in database
598
+ await db.update_debug_config(enabled=request.enabled)
599
+
600
+ # 🔥 Hot reload: sync database config to memory
601
+ await db.reload_config_to_memory()
602
+
603
+ status = "enabled" if request.enabled else "disabled"
604
+ return {"success": True, "message": f"Debug mode {status}", "enabled": request.enabled}
605
+ except Exception as e:
606
+ raise HTTPException(status_code=500, detail=f"Failed to update debug config: {str(e)}")
607
+
608
+
609
+ @router.get("/api/generation/timeout")
610
+ async def get_generation_timeout(token: str = Depends(verify_admin_token)):
611
+ """Get generation timeout configuration"""
612
+ return await get_generation_config(token)
613
+
614
+
615
+ @router.post("/api/generation/timeout")
616
+ async def update_generation_timeout(
617
+ request: GenerationConfigRequest,
618
+ token: str = Depends(verify_admin_token)
619
+ ):
620
+ """Update generation timeout configuration"""
621
+ await db.update_generation_config(request.image_timeout, request.video_timeout)
622
+
623
+ # 🔥 Hot reload: sync database config to memory
624
+ await db.reload_config_to_memory()
625
+
626
+ return {"success": True, "message": "生成配置更新成功"}
627
+
628
+
629
+ # ========== AT Auto Refresh Config ==========
630
+
631
+ @router.get("/api/token-refresh/config")
632
+ async def get_token_refresh_config(token: str = Depends(verify_admin_token)):
633
+ """Get AT auto refresh configuration (默认启用)"""
634
+ return {
635
+ "success": True,
636
+ "config": {
637
+ "at_auto_refresh_enabled": True # Flow2API默认启用AT自动刷新
638
+ }
639
+ }
640
+
641
+
642
+ @router.post("/api/token-refresh/enabled")
643
+ async def update_token_refresh_enabled(
644
+ token: str = Depends(verify_admin_token)
645
+ ):
646
+ """Update AT auto refresh enabled (Flow2API固定启用,此接口仅用于前端兼容)"""
647
+ return {
648
+ "success": True,
649
+ "message": "Flow2API的AT自动刷新默认启用且无法关闭"
650
+ }
651
+
652
+
653
+ # ========== Cache Configuration Endpoints ==========
654
+
655
+ @router.get("/api/cache/config")
656
+ async def get_cache_config(token: str = Depends(verify_admin_token)):
657
+ """Get cache configuration"""
658
+ cache_config = await db.get_cache_config()
659
+
660
+ # Calculate effective base URL
661
+ effective_base_url = cache_config.cache_base_url if cache_config.cache_base_url else f"http://127.0.0.1:8000"
662
+
663
+ return {
664
+ "success": True,
665
+ "config": {
666
+ "enabled": cache_config.cache_enabled,
667
+ "timeout": cache_config.cache_timeout,
668
+ "base_url": cache_config.cache_base_url or "",
669
+ "effective_base_url": effective_base_url
670
+ }
671
+ }
672
+
673
+
674
+ @router.post("/api/cache/enabled")
675
+ async def update_cache_enabled(
676
+ request: dict,
677
+ token: str = Depends(verify_admin_token)
678
+ ):
679
+ """Update cache enabled status"""
680
+ enabled = request.get("enabled", False)
681
+ await db.update_cache_config(enabled=enabled)
682
+
683
+ # 🔥 Hot reload: sync database config to memory
684
+ await db.reload_config_to_memory()
685
+
686
+ return {"success": True, "message": f"缓存已{'启用' if enabled else '禁用'}"}
687
+
688
+
689
+ @router.post("/api/cache/config")
690
+ async def update_cache_config_full(
691
+ request: dict,
692
+ token: str = Depends(verify_admin_token)
693
+ ):
694
+ """Update complete cache configuration"""
695
+ enabled = request.get("enabled")
696
+ timeout = request.get("timeout")
697
+ base_url = request.get("base_url")
698
+
699
+ await db.update_cache_config(enabled=enabled, timeout=timeout, base_url=base_url)
700
+
701
+ # 🔥 Hot reload: sync database config to memory
702
+ await db.reload_config_to_memory()
703
+
704
+ return {"success": True, "message": "缓存配置更新成功"}
705
+
706
+
707
+ @router.post("/api/cache/base-url")
708
+ async def update_cache_base_url(
709
+ request: dict,
710
+ token: str = Depends(verify_admin_token)
711
+ ):
712
+ """Update cache base URL"""
713
+ base_url = request.get("base_url", "")
714
+ await db.update_cache_config(base_url=base_url)
715
+
716
+ # 🔥 Hot reload: sync database config to memory
717
+ await db.reload_config_to_memory()
718
+
719
+ return {"success": True, "message": "缓存Base URL更新成功"}
src/api/routes.py ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """API routes - OpenAI compatible endpoints"""
2
+ from fastapi import APIRouter, Depends, HTTPException
3
+ from fastapi.responses import StreamingResponse, JSONResponse
4
+ from typing import List
5
+ import base64
6
+ import re
7
+ import json
8
+ from ..core.auth import verify_api_key_header
9
+ from ..core.models import ChatCompletionRequest
10
+ from ..services.generation_handler import GenerationHandler, MODEL_CONFIG
11
+
12
+ router = APIRouter()
13
+
14
+ # Dependency injection will be set up in main.py
15
+ generation_handler: GenerationHandler = None
16
+
17
+
18
+ def set_generation_handler(handler: GenerationHandler):
19
+ """Set generation handler instance"""
20
+ global generation_handler
21
+ generation_handler = handler
22
+
23
+
24
+ @router.get("/v1/models")
25
+ async def list_models(api_key: str = Depends(verify_api_key_header)):
26
+ """List available models"""
27
+ models = []
28
+
29
+ for model_id, config in MODEL_CONFIG.items():
30
+ description = f"{config['type'].capitalize()} generation"
31
+ if config['type'] == 'image':
32
+ description += f" - {config['model_name']}"
33
+ else:
34
+ description += f" - {config['model_key']}"
35
+
36
+ models.append({
37
+ "id": model_id,
38
+ "object": "model",
39
+ "owned_by": "flow2api",
40
+ "description": description
41
+ })
42
+
43
+ return {
44
+ "object": "list",
45
+ "data": models
46
+ }
47
+
48
+
49
+ @router.post("/v1/chat/completions")
50
+ async def create_chat_completion(
51
+ request: ChatCompletionRequest,
52
+ api_key: str = Depends(verify_api_key_header)
53
+ ):
54
+ """Create chat completion (unified endpoint for image and video generation)"""
55
+ try:
56
+ # Extract prompt from messages
57
+ if not request.messages:
58
+ raise HTTPException(status_code=400, detail="Messages cannot be empty")
59
+
60
+ last_message = request.messages[-1]
61
+ content = last_message.content
62
+
63
+ # Handle both string and array format (OpenAI multimodal)
64
+ prompt = ""
65
+ images: List[bytes] = []
66
+
67
+ if isinstance(content, str):
68
+ # Simple text format
69
+ prompt = content
70
+ elif isinstance(content, list):
71
+ # Multimodal format
72
+ for item in content:
73
+ if item.get("type") == "text":
74
+ prompt = item.get("text", "")
75
+ elif item.get("type") == "image_url":
76
+ # Extract base64 image
77
+ image_url = item.get("image_url", {}).get("url", "")
78
+ if image_url.startswith("data:image"):
79
+ # Parse base64
80
+ match = re.search(r"base64,(.+)", image_url)
81
+ if match:
82
+ image_base64 = match.group(1)
83
+ image_bytes = base64.b64decode(image_base64)
84
+ images.append(image_bytes)
85
+
86
+ # Fallback to deprecated image parameter
87
+ if request.image and not images:
88
+ if request.image.startswith("data:image"):
89
+ match = re.search(r"base64,(.+)", request.image)
90
+ if match:
91
+ image_base64 = match.group(1)
92
+ image_bytes = base64.b64decode(image_base64)
93
+ images.append(image_bytes)
94
+
95
+ if not prompt:
96
+ raise HTTPException(status_code=400, detail="Prompt cannot be empty")
97
+
98
+ # Call generation handler
99
+ if request.stream:
100
+ # Streaming response
101
+ async def generate():
102
+ async for chunk in generation_handler.handle_generation(
103
+ model=request.model,
104
+ prompt=prompt,
105
+ images=images if images else None,
106
+ stream=True
107
+ ):
108
+ yield chunk
109
+
110
+ # Send [DONE] signal
111
+ yield "data: [DONE]\n\n"
112
+
113
+ return StreamingResponse(
114
+ generate(),
115
+ media_type="text/event-stream",
116
+ headers={
117
+ "Cache-Control": "no-cache",
118
+ "Connection": "keep-alive",
119
+ "X-Accel-Buffering": "no"
120
+ }
121
+ )
122
+ else:
123
+ # Non-streaming response
124
+ result = None
125
+ async for chunk in generation_handler.handle_generation(
126
+ model=request.model,
127
+ prompt=prompt,
128
+ images=images if images else None,
129
+ stream=False
130
+ ):
131
+ result = chunk
132
+
133
+ if result:
134
+ # Parse the result JSON string
135
+ try:
136
+ result_json = json.loads(result)
137
+ return JSONResponse(content=result_json)
138
+ except json.JSONDecodeError:
139
+ # If not JSON, return as-is
140
+ return JSONResponse(content={"result": result})
141
+ else:
142
+ raise HTTPException(status_code=500, detail="Generation failed: No response from handler")
143
+
144
+ except HTTPException:
145
+ raise
146
+ except Exception as e:
147
+ raise HTTPException(status_code=500, detail=str(e))
src/core/__init__.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ """Core modules"""
2
+
3
+ from .config import config
4
+ from .auth import AuthManager, verify_api_key_header
5
+ from .logger import debug_logger
6
+
7
+ __all__ = ["config", "AuthManager", "verify_api_key_header", "debug_logger"]
src/core/auth.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Authentication module"""
2
+ import bcrypt
3
+ from typing import Optional
4
+ from fastapi import HTTPException, Security
5
+ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
6
+ from .config import config
7
+
8
+ security = HTTPBearer()
9
+
10
+ class AuthManager:
11
+ """Authentication manager"""
12
+
13
+ @staticmethod
14
+ def verify_api_key(api_key: str) -> bool:
15
+ """Verify API key"""
16
+ return api_key == config.api_key
17
+
18
+ @staticmethod
19
+ def verify_admin(username: str, password: str) -> bool:
20
+ """Verify admin credentials"""
21
+ # Compare with current config (which may be from database or config file)
22
+ return username == config.admin_username and password == config.admin_password
23
+
24
+ @staticmethod
25
+ def hash_password(password: str) -> str:
26
+ """Hash password"""
27
+ return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
28
+
29
+ @staticmethod
30
+ def verify_password(password: str, hashed: str) -> bool:
31
+ """Verify password"""
32
+ return bcrypt.checkpw(password.encode(), hashed.encode())
33
+
34
+ async def verify_api_key_header(credentials: HTTPAuthorizationCredentials = Security(security)) -> str:
35
+ """Verify API key from Authorization header"""
36
+ api_key = credentials.credentials
37
+ if not AuthManager.verify_api_key(api_key):
38
+ raise HTTPException(status_code=401, detail="Invalid API key")
39
+ return api_key
src/core/config.py ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Configuration management for Flow2API"""
2
+ import tomli
3
+ from pathlib import Path
4
+ from typing import Dict, Any, Optional
5
+
6
+ class Config:
7
+ """Application configuration"""
8
+
9
+ def __init__(self):
10
+ self._config = self._load_config()
11
+ self._admin_username: Optional[str] = None
12
+ self._admin_password: Optional[str] = None
13
+
14
+ def _load_config(self) -> Dict[str, Any]:
15
+ """Load configuration from setting.toml"""
16
+ config_path = Path(__file__).parent.parent.parent / "config" / "setting.toml"
17
+ with open(config_path, "rb") as f:
18
+ return tomli.load(f)
19
+
20
+ def reload_config(self):
21
+ """Reload configuration from file"""
22
+ self._config = self._load_config()
23
+
24
+ def get_raw_config(self) -> Dict[str, Any]:
25
+ """Get raw configuration dictionary"""
26
+ return self._config
27
+
28
+ @property
29
+ def admin_username(self) -> str:
30
+ # If admin_username is set from database, use it; otherwise fall back to config file
31
+ if self._admin_username is not None:
32
+ return self._admin_username
33
+ return self._config["global"]["admin_username"]
34
+
35
+ @admin_username.setter
36
+ def admin_username(self, value: str):
37
+ self._admin_username = value
38
+ self._config["global"]["admin_username"] = value
39
+
40
+ def set_admin_username_from_db(self, username: str):
41
+ """Set admin username from database"""
42
+ self._admin_username = username
43
+
44
+ # Flow2API specific properties
45
+ @property
46
+ def flow_labs_base_url(self) -> str:
47
+ """Google Labs base URL for project management"""
48
+ return self._config["flow"]["labs_base_url"]
49
+
50
+ @property
51
+ def flow_api_base_url(self) -> str:
52
+ """Google AI Sandbox API base URL for generation"""
53
+ return self._config["flow"]["api_base_url"]
54
+
55
+ @property
56
+ def flow_timeout(self) -> int:
57
+ return self._config["flow"]["timeout"]
58
+
59
+ @property
60
+ def flow_max_retries(self) -> int:
61
+ return self._config["flow"]["max_retries"]
62
+
63
+ @property
64
+ def poll_interval(self) -> float:
65
+ return self._config["flow"]["poll_interval"]
66
+
67
+ @property
68
+ def max_poll_attempts(self) -> int:
69
+ return self._config["flow"]["max_poll_attempts"]
70
+
71
+ @property
72
+ def server_host(self) -> str:
73
+ return self._config["server"]["host"]
74
+
75
+ @property
76
+ def server_port(self) -> int:
77
+ return self._config["server"]["port"]
78
+
79
+ @property
80
+ def debug_enabled(self) -> bool:
81
+ return self._config.get("debug", {}).get("enabled", False)
82
+
83
+ @property
84
+ def debug_log_requests(self) -> bool:
85
+ return self._config.get("debug", {}).get("log_requests", True)
86
+
87
+ @property
88
+ def debug_log_responses(self) -> bool:
89
+ return self._config.get("debug", {}).get("log_responses", True)
90
+
91
+ @property
92
+ def debug_mask_token(self) -> bool:
93
+ return self._config.get("debug", {}).get("mask_token", True)
94
+
95
+ # Mutable properties for runtime updates
96
+ @property
97
+ def api_key(self) -> str:
98
+ return self._config["global"]["api_key"]
99
+
100
+ @api_key.setter
101
+ def api_key(self, value: str):
102
+ self._config["global"]["api_key"] = value
103
+
104
+ @property
105
+ def admin_password(self) -> str:
106
+ # If admin_password is set from database, use it; otherwise fall back to config file
107
+ if self._admin_password is not None:
108
+ return self._admin_password
109
+ return self._config["global"]["admin_password"]
110
+
111
+ @admin_password.setter
112
+ def admin_password(self, value: str):
113
+ self._admin_password = value
114
+ self._config["global"]["admin_password"] = value
115
+
116
+ def set_admin_password_from_db(self, password: str):
117
+ """Set admin password from database"""
118
+ self._admin_password = password
119
+
120
+ def set_debug_enabled(self, enabled: bool):
121
+ """Set debug mode enabled/disabled"""
122
+ if "debug" not in self._config:
123
+ self._config["debug"] = {}
124
+ self._config["debug"]["enabled"] = enabled
125
+
126
+ @property
127
+ def image_timeout(self) -> int:
128
+ """Get image generation timeout in seconds"""
129
+ return self._config.get("generation", {}).get("image_timeout", 300)
130
+
131
+ def set_image_timeout(self, timeout: int):
132
+ """Set image generation timeout in seconds"""
133
+ if "generation" not in self._config:
134
+ self._config["generation"] = {}
135
+ self._config["generation"]["image_timeout"] = timeout
136
+
137
+ @property
138
+ def video_timeout(self) -> int:
139
+ """Get video generation timeout in seconds"""
140
+ return self._config.get("generation", {}).get("video_timeout", 1500)
141
+
142
+ def set_video_timeout(self, timeout: int):
143
+ """Set video generation timeout in seconds"""
144
+ if "generation" not in self._config:
145
+ self._config["generation"] = {}
146
+ self._config["generation"]["video_timeout"] = timeout
147
+
148
+ # Cache configuration
149
+ @property
150
+ def cache_enabled(self) -> bool:
151
+ """Get cache enabled status"""
152
+ return self._config.get("cache", {}).get("enabled", False)
153
+
154
+ def set_cache_enabled(self, enabled: bool):
155
+ """Set cache enabled status"""
156
+ if "cache" not in self._config:
157
+ self._config["cache"] = {}
158
+ self._config["cache"]["enabled"] = enabled
159
+
160
+ @property
161
+ def cache_timeout(self) -> int:
162
+ """Get cache timeout in seconds"""
163
+ return self._config.get("cache", {}).get("timeout", 7200)
164
+
165
+ def set_cache_timeout(self, timeout: int):
166
+ """Set cache timeout in seconds"""
167
+ if "cache" not in self._config:
168
+ self._config["cache"] = {}
169
+ self._config["cache"]["timeout"] = timeout
170
+
171
+ @property
172
+ def cache_base_url(self) -> str:
173
+ """Get cache base URL"""
174
+ return self._config.get("cache", {}).get("base_url", "")
175
+
176
+ def set_cache_base_url(self, base_url: str):
177
+ """Set cache base URL"""
178
+ if "cache" not in self._config:
179
+ self._config["cache"] = {}
180
+ self._config["cache"]["base_url"] = base_url
181
+
182
+ # Global config instance
183
+ config = Config()
src/core/database.py ADDED
@@ -0,0 +1,1044 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Database storage layer for Flow2API"""
2
+ import aiosqlite
3
+ import json
4
+ from datetime import datetime
5
+ from typing import Optional, List
6
+ from pathlib import Path
7
+ from .models import Token, TokenStats, Task, RequestLog, AdminConfig, ProxyConfig, GenerationConfig, CacheConfig, Project
8
+
9
+
10
+ class Database:
11
+ """SQLite database manager"""
12
+
13
+ def __init__(self, db_path: str = None):
14
+ if db_path is None:
15
+ # Store database in data directory
16
+ data_dir = Path(__file__).parent.parent.parent / "data"
17
+ data_dir.mkdir(exist_ok=True)
18
+ db_path = str(data_dir / "flow.db")
19
+ self.db_path = db_path
20
+
21
+ def db_exists(self) -> bool:
22
+ """Check if database file exists"""
23
+ return Path(self.db_path).exists()
24
+
25
+ async def _table_exists(self, db, table_name: str) -> bool:
26
+ """Check if a table exists in the database"""
27
+ cursor = await db.execute(
28
+ "SELECT name FROM sqlite_master WHERE type='table' AND name=?",
29
+ (table_name,)
30
+ )
31
+ result = await cursor.fetchone()
32
+ return result is not None
33
+
34
+ async def _column_exists(self, db, table_name: str, column_name: str) -> bool:
35
+ """Check if a column exists in a table"""
36
+ try:
37
+ cursor = await db.execute(f"PRAGMA table_info({table_name})")
38
+ columns = await cursor.fetchall()
39
+ return any(col[1] == column_name for col in columns)
40
+ except:
41
+ return False
42
+
43
+ async def _ensure_config_rows(self, db, config_dict: dict = None):
44
+ """Ensure all config tables have their default rows
45
+
46
+ Args:
47
+ db: Database connection
48
+ config_dict: Configuration dictionary from setting.toml (optional)
49
+ If None, use default values instead of reading from TOML.
50
+ """
51
+ # Ensure admin_config has a row
52
+ cursor = await db.execute("SELECT COUNT(*) FROM admin_config")
53
+ count = await cursor.fetchone()
54
+ if count[0] == 0:
55
+ admin_username = "admin"
56
+ admin_password = "admin"
57
+ api_key = "han1234"
58
+ error_ban_threshold = 3
59
+
60
+ if config_dict:
61
+ global_config = config_dict.get("global", {})
62
+ admin_username = global_config.get("admin_username", "admin")
63
+ admin_password = global_config.get("admin_password", "admin")
64
+ api_key = global_config.get("api_key", "han1234")
65
+
66
+ admin_config = config_dict.get("admin", {})
67
+ error_ban_threshold = admin_config.get("error_ban_threshold", 3)
68
+
69
+ await db.execute("""
70
+ INSERT INTO admin_config (id, username, password, api_key, error_ban_threshold)
71
+ VALUES (1, ?, ?, ?, ?)
72
+ """, (admin_username, admin_password, api_key, error_ban_threshold))
73
+
74
+ # Ensure proxy_config has a row
75
+ cursor = await db.execute("SELECT COUNT(*) FROM proxy_config")
76
+ count = await cursor.fetchone()
77
+ if count[0] == 0:
78
+ proxy_enabled = False
79
+ proxy_url = None
80
+
81
+ if config_dict:
82
+ proxy_config = config_dict.get("proxy", {})
83
+ proxy_enabled = proxy_config.get("proxy_enabled", False)
84
+ proxy_url = proxy_config.get("proxy_url", "")
85
+ proxy_url = proxy_url if proxy_url else None
86
+
87
+ await db.execute("""
88
+ INSERT INTO proxy_config (id, enabled, proxy_url)
89
+ VALUES (1, ?, ?)
90
+ """, (proxy_enabled, proxy_url))
91
+
92
+ # Ensure generation_config has a row
93
+ cursor = await db.execute("SELECT COUNT(*) FROM generation_config")
94
+ count = await cursor.fetchone()
95
+ if count[0] == 0:
96
+ image_timeout = 300
97
+ video_timeout = 1500
98
+
99
+ if config_dict:
100
+ generation_config = config_dict.get("generation", {})
101
+ image_timeout = generation_config.get("image_timeout", 300)
102
+ video_timeout = generation_config.get("video_timeout", 1500)
103
+
104
+ await db.execute("""
105
+ INSERT INTO generation_config (id, image_timeout, video_timeout)
106
+ VALUES (1, ?, ?)
107
+ """, (image_timeout, video_timeout))
108
+
109
+ # Ensure cache_config has a row
110
+ cursor = await db.execute("SELECT COUNT(*) FROM cache_config")
111
+ count = await cursor.fetchone()
112
+ if count[0] == 0:
113
+ cache_enabled = False
114
+ cache_timeout = 7200
115
+ cache_base_url = None
116
+
117
+ if config_dict:
118
+ cache_config = config_dict.get("cache", {})
119
+ cache_enabled = cache_config.get("enabled", False)
120
+ cache_timeout = cache_config.get("timeout", 7200)
121
+ cache_base_url = cache_config.get("base_url", "")
122
+ # Convert empty string to None
123
+ cache_base_url = cache_base_url if cache_base_url else None
124
+
125
+ await db.execute("""
126
+ INSERT INTO cache_config (id, cache_enabled, cache_timeout, cache_base_url)
127
+ VALUES (1, ?, ?, ?)
128
+ """, (cache_enabled, cache_timeout, cache_base_url))
129
+
130
+ # Ensure debug_config has a row
131
+ cursor = await db.execute("SELECT COUNT(*) FROM debug_config")
132
+ count = await cursor.fetchone()
133
+ if count[0] == 0:
134
+ debug_enabled = False
135
+ log_requests = True
136
+ log_responses = True
137
+ mask_token = True
138
+
139
+ if config_dict:
140
+ debug_config = config_dict.get("debug", {})
141
+ debug_enabled = debug_config.get("enabled", False)
142
+ log_requests = debug_config.get("log_requests", True)
143
+ log_responses = debug_config.get("log_responses", True)
144
+ mask_token = debug_config.get("mask_token", True)
145
+
146
+ await db.execute("""
147
+ INSERT INTO debug_config (id, enabled, log_requests, log_responses, mask_token)
148
+ VALUES (1, ?, ?, ?, ?)
149
+ """, (debug_enabled, log_requests, log_responses, mask_token))
150
+
151
+ async def check_and_migrate_db(self, config_dict: dict = None):
152
+ """Check database integrity and perform migrations if needed
153
+
154
+ This method is called during upgrade mode to:
155
+ 1. Create missing tables (if they don't exist)
156
+ 2. Add missing columns to existing tables
157
+ 3. Ensure all config tables have default rows
158
+
159
+ Args:
160
+ config_dict: Configuration dictionary from setting.toml (optional)
161
+ Used only to initialize missing config rows with default values.
162
+ Existing config rows will NOT be overwritten.
163
+ """
164
+ async with aiosqlite.connect(self.db_path) as db:
165
+ print("Checking database integrity and performing migrations...")
166
+
167
+ # ========== Step 1: Create missing tables ==========
168
+ # Check and create cache_config table if missing
169
+ if not await self._table_exists(db, "cache_config"):
170
+ print(" ✓ Creating missing table: cache_config")
171
+ await db.execute("""
172
+ CREATE TABLE cache_config (
173
+ id INTEGER PRIMARY KEY DEFAULT 1,
174
+ cache_enabled BOOLEAN DEFAULT 0,
175
+ cache_timeout INTEGER DEFAULT 7200,
176
+ cache_base_url TEXT,
177
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
178
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
179
+ )
180
+ """)
181
+
182
+ # ========== Step 2: Add missing columns to existing tables ==========
183
+ # Check and add missing columns to tokens table
184
+ if await self._table_exists(db, "tokens"):
185
+ columns_to_add = [
186
+ ("at", "TEXT"), # Access Token
187
+ ("at_expires", "TIMESTAMP"), # AT expiration time
188
+ ("credits", "INTEGER DEFAULT 0"), # Balance
189
+ ("user_paygate_tier", "TEXT"), # User tier
190
+ ("current_project_id", "TEXT"), # Current project UUID
191
+ ("current_project_name", "TEXT"), # Project name
192
+ ("image_enabled", "BOOLEAN DEFAULT 1"),
193
+ ("video_enabled", "BOOLEAN DEFAULT 1"),
194
+ ("image_concurrency", "INTEGER DEFAULT -1"),
195
+ ("video_concurrency", "INTEGER DEFAULT -1"),
196
+ ]
197
+
198
+ for col_name, col_type in columns_to_add:
199
+ if not await self._column_exists(db, "tokens", col_name):
200
+ try:
201
+ await db.execute(f"ALTER TABLE tokens ADD COLUMN {col_name} {col_type}")
202
+ print(f" ✓ Added column '{col_name}' to tokens table")
203
+ except Exception as e:
204
+ print(f" ✗ Failed to add column '{col_name}': {e}")
205
+
206
+ # Check and add missing columns to admin_config table
207
+ if await self._table_exists(db, "admin_config"):
208
+ if not await self._column_exists(db, "admin_config", "error_ban_threshold"):
209
+ try:
210
+ await db.execute("ALTER TABLE admin_config ADD COLUMN error_ban_threshold INTEGER DEFAULT 3")
211
+ print(" ✓ Added column 'error_ban_threshold' to admin_config table")
212
+ except Exception as e:
213
+ print(f" ✗ Failed to add column 'error_ban_threshold': {e}")
214
+
215
+ # Check and add missing columns to token_stats table
216
+ if await self._table_exists(db, "token_stats"):
217
+ stats_columns_to_add = [
218
+ ("today_image_count", "INTEGER DEFAULT 0"),
219
+ ("today_video_count", "INTEGER DEFAULT 0"),
220
+ ("today_error_count", "INTEGER DEFAULT 0"),
221
+ ("today_date", "DATE"),
222
+ ("consecutive_error_count", "INTEGER DEFAULT 0"), # 🆕 连续错误计数
223
+ ]
224
+
225
+ for col_name, col_type in stats_columns_to_add:
226
+ if not await self._column_exists(db, "token_stats", col_name):
227
+ try:
228
+ await db.execute(f"ALTER TABLE token_stats ADD COLUMN {col_name} {col_type}")
229
+ print(f" ✓ Added column '{col_name}' to token_stats table")
230
+ except Exception as e:
231
+ print(f" ✗ Failed to add column '{col_name}': {e}")
232
+
233
+ # ========== Step 3: Ensure all config tables have default rows ==========
234
+ # Note: This will NOT overwrite existing config rows
235
+ # It only ensures missing rows are created with default values
236
+ await self._ensure_config_rows(db, config_dict=None)
237
+
238
+ await db.commit()
239
+ print("Database migration check completed.")
240
+
241
+ async def init_db(self):
242
+ """Initialize database tables"""
243
+ async with aiosqlite.connect(self.db_path) as db:
244
+ # Tokens table (Flow2API版本)
245
+ await db.execute("""
246
+ CREATE TABLE IF NOT EXISTS tokens (
247
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
248
+ st TEXT UNIQUE NOT NULL,
249
+ at TEXT,
250
+ at_expires TIMESTAMP,
251
+ email TEXT NOT NULL,
252
+ name TEXT,
253
+ remark TEXT,
254
+ is_active BOOLEAN DEFAULT 1,
255
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
256
+ last_used_at TIMESTAMP,
257
+ use_count INTEGER DEFAULT 0,
258
+ credits INTEGER DEFAULT 0,
259
+ user_paygate_tier TEXT,
260
+ current_project_id TEXT,
261
+ current_project_name TEXT,
262
+ image_enabled BOOLEAN DEFAULT 1,
263
+ video_enabled BOOLEAN DEFAULT 1,
264
+ image_concurrency INTEGER DEFAULT -1,
265
+ video_concurrency INTEGER DEFAULT -1
266
+ )
267
+ """)
268
+
269
+ # Projects table (新增)
270
+ await db.execute("""
271
+ CREATE TABLE IF NOT EXISTS projects (
272
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
273
+ project_id TEXT UNIQUE NOT NULL,
274
+ token_id INTEGER NOT NULL,
275
+ project_name TEXT NOT NULL,
276
+ tool_name TEXT DEFAULT 'PINHOLE',
277
+ is_active BOOLEAN DEFAULT 1,
278
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
279
+ FOREIGN KEY (token_id) REFERENCES tokens(id)
280
+ )
281
+ """)
282
+
283
+ # Token stats table
284
+ await db.execute("""
285
+ CREATE TABLE IF NOT EXISTS token_stats (
286
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
287
+ token_id INTEGER NOT NULL,
288
+ image_count INTEGER DEFAULT 0,
289
+ video_count INTEGER DEFAULT 0,
290
+ success_count INTEGER DEFAULT 0,
291
+ error_count INTEGER DEFAULT 0,
292
+ last_success_at TIMESTAMP,
293
+ last_error_at TIMESTAMP,
294
+ today_image_count INTEGER DEFAULT 0,
295
+ today_video_count INTEGER DEFAULT 0,
296
+ today_error_count INTEGER DEFAULT 0,
297
+ today_date DATE,
298
+ consecutive_error_count INTEGER DEFAULT 0,
299
+ FOREIGN KEY (token_id) REFERENCES tokens(id)
300
+ )
301
+ """)
302
+
303
+ # Tasks table
304
+ await db.execute("""
305
+ CREATE TABLE IF NOT EXISTS tasks (
306
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
307
+ task_id TEXT UNIQUE NOT NULL,
308
+ token_id INTEGER NOT NULL,
309
+ model TEXT NOT NULL,
310
+ prompt TEXT NOT NULL,
311
+ status TEXT NOT NULL DEFAULT 'processing',
312
+ progress INTEGER DEFAULT 0,
313
+ result_urls TEXT,
314
+ error_message TEXT,
315
+ scene_id TEXT,
316
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
317
+ completed_at TIMESTAMP,
318
+ FOREIGN KEY (token_id) REFERENCES tokens(id)
319
+ )
320
+ """)
321
+
322
+ # Request logs table
323
+ await db.execute("""
324
+ CREATE TABLE IF NOT EXISTS request_logs (
325
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
326
+ token_id INTEGER,
327
+ operation TEXT NOT NULL,
328
+ request_body TEXT,
329
+ response_body TEXT,
330
+ status_code INTEGER NOT NULL,
331
+ duration FLOAT NOT NULL,
332
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
333
+ FOREIGN KEY (token_id) REFERENCES tokens(id)
334
+ )
335
+ """)
336
+
337
+ # Admin config table
338
+ await db.execute("""
339
+ CREATE TABLE IF NOT EXISTS admin_config (
340
+ id INTEGER PRIMARY KEY DEFAULT 1,
341
+ username TEXT DEFAULT 'admin',
342
+ password TEXT DEFAULT 'admin',
343
+ api_key TEXT DEFAULT 'han1234',
344
+ error_ban_threshold INTEGER DEFAULT 3,
345
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
346
+ )
347
+ """)
348
+
349
+ # Proxy config table
350
+ await db.execute("""
351
+ CREATE TABLE IF NOT EXISTS proxy_config (
352
+ id INTEGER PRIMARY KEY DEFAULT 1,
353
+ enabled BOOLEAN DEFAULT 0,
354
+ proxy_url TEXT,
355
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
356
+ )
357
+ """)
358
+
359
+ # Generation config table
360
+ await db.execute("""
361
+ CREATE TABLE IF NOT EXISTS generation_config (
362
+ id INTEGER PRIMARY KEY DEFAULT 1,
363
+ image_timeout INTEGER DEFAULT 300,
364
+ video_timeout INTEGER DEFAULT 1500,
365
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
366
+ )
367
+ """)
368
+
369
+ # Cache config table
370
+ await db.execute("""
371
+ CREATE TABLE IF NOT EXISTS cache_config (
372
+ id INTEGER PRIMARY KEY DEFAULT 1,
373
+ cache_enabled BOOLEAN DEFAULT 0,
374
+ cache_timeout INTEGER DEFAULT 7200,
375
+ cache_base_url TEXT,
376
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
377
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
378
+ )
379
+ """)
380
+
381
+ # Debug config table
382
+ await db.execute("""
383
+ CREATE TABLE IF NOT EXISTS debug_config (
384
+ id INTEGER PRIMARY KEY DEFAULT 1,
385
+ enabled BOOLEAN DEFAULT 0,
386
+ log_requests BOOLEAN DEFAULT 1,
387
+ log_responses BOOLEAN DEFAULT 1,
388
+ mask_token BOOLEAN DEFAULT 1,
389
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
390
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
391
+ )
392
+ """)
393
+
394
+ # Create indexes
395
+ await db.execute("CREATE INDEX IF NOT EXISTS idx_task_id ON tasks(task_id)")
396
+ await db.execute("CREATE INDEX IF NOT EXISTS idx_token_st ON tokens(st)")
397
+ await db.execute("CREATE INDEX IF NOT EXISTS idx_project_id ON projects(project_id)")
398
+
399
+ # Migrate request_logs table if needed
400
+ await self._migrate_request_logs(db)
401
+
402
+ await db.commit()
403
+
404
+ async def _migrate_request_logs(self, db):
405
+ """Migrate request_logs table from old schema to new schema"""
406
+ try:
407
+ # Check if old columns exist
408
+ has_model = await self._column_exists(db, "request_logs", "model")
409
+ has_operation = await self._column_exists(db, "request_logs", "operation")
410
+
411
+ if has_model and not has_operation:
412
+ # Old schema detected, need migration
413
+ print("🔄 检测到旧的request_logs表结构,开始迁移...")
414
+
415
+ # Rename old table
416
+ await db.execute("ALTER TABLE request_logs RENAME TO request_logs_old")
417
+
418
+ # Create new table with new schema
419
+ await db.execute("""
420
+ CREATE TABLE request_logs (
421
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
422
+ token_id INTEGER,
423
+ operation TEXT NOT NULL,
424
+ request_body TEXT,
425
+ response_body TEXT,
426
+ status_code INTEGER NOT NULL,
427
+ duration FLOAT NOT NULL,
428
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
429
+ FOREIGN KEY (token_id) REFERENCES tokens(id)
430
+ )
431
+ """)
432
+
433
+ # Migrate data from old table (basic migration)
434
+ await db.execute("""
435
+ INSERT INTO request_logs (token_id, operation, request_body, status_code, duration, created_at)
436
+ SELECT
437
+ token_id,
438
+ model as operation,
439
+ json_object('model', model, 'prompt', substr(prompt, 1, 100)) as request_body,
440
+ CASE
441
+ WHEN status = 'completed' THEN 200
442
+ WHEN status = 'failed' THEN 500
443
+ ELSE 0
444
+ END as status_code,
445
+ response_time as duration,
446
+ created_at
447
+ FROM request_logs_old
448
+ """)
449
+
450
+ # Drop old table
451
+ await db.execute("DROP TABLE request_logs_old")
452
+
453
+ print("✅ request_logs表迁移完成")
454
+ except Exception as e:
455
+ print(f"⚠️ request_logs表迁移失败: {e}")
456
+ # Continue even if migration fails
457
+
458
+ # Token operations
459
+ async def add_token(self, token: Token) -> int:
460
+ """Add a new token"""
461
+ async with aiosqlite.connect(self.db_path) as db:
462
+ cursor = await db.execute("""
463
+ INSERT INTO tokens (st, at, at_expires, email, name, remark, is_active,
464
+ credits, user_paygate_tier, current_project_id, current_project_name,
465
+ image_enabled, video_enabled, image_concurrency, video_concurrency)
466
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
467
+ """, (token.st, token.at, token.at_expires, token.email, token.name, token.remark,
468
+ token.is_active, token.credits, token.user_paygate_tier,
469
+ token.current_project_id, token.current_project_name,
470
+ token.image_enabled, token.video_enabled,
471
+ token.image_concurrency, token.video_concurrency))
472
+ await db.commit()
473
+ token_id = cursor.lastrowid
474
+
475
+ # Create stats entry
476
+ await db.execute("""
477
+ INSERT INTO token_stats (token_id) VALUES (?)
478
+ """, (token_id,))
479
+ await db.commit()
480
+
481
+ return token_id
482
+
483
+ async def get_token(self, token_id: int) -> Optional[Token]:
484
+ """Get token by ID"""
485
+ async with aiosqlite.connect(self.db_path) as db:
486
+ db.row_factory = aiosqlite.Row
487
+ cursor = await db.execute("SELECT * FROM tokens WHERE id = ?", (token_id,))
488
+ row = await cursor.fetchone()
489
+ if row:
490
+ return Token(**dict(row))
491
+ return None
492
+
493
+ async def get_token_by_st(self, st: str) -> Optional[Token]:
494
+ """Get token by ST"""
495
+ async with aiosqlite.connect(self.db_path) as db:
496
+ db.row_factory = aiosqlite.Row
497
+ cursor = await db.execute("SELECT * FROM tokens WHERE st = ?", (st,))
498
+ row = await cursor.fetchone()
499
+ if row:
500
+ return Token(**dict(row))
501
+ return None
502
+
503
+ async def get_all_tokens(self) -> List[Token]:
504
+ """Get all tokens"""
505
+ async with aiosqlite.connect(self.db_path) as db:
506
+ db.row_factory = aiosqlite.Row
507
+ cursor = await db.execute("SELECT * FROM tokens ORDER BY created_at DESC")
508
+ rows = await cursor.fetchall()
509
+ return [Token(**dict(row)) for row in rows]
510
+
511
+ async def get_active_tokens(self) -> List[Token]:
512
+ """Get all active tokens"""
513
+ async with aiosqlite.connect(self.db_path) as db:
514
+ db.row_factory = aiosqlite.Row
515
+ cursor = await db.execute("SELECT * FROM tokens WHERE is_active = 1 ORDER BY last_used_at ASC")
516
+ rows = await cursor.fetchall()
517
+ return [Token(**dict(row)) for row in rows]
518
+
519
+ async def update_token(self, token_id: int, **kwargs):
520
+ """Update token fields"""
521
+ async with aiosqlite.connect(self.db_path) as db:
522
+ updates = []
523
+ params = []
524
+
525
+ for key, value in kwargs.items():
526
+ if value is not None:
527
+ updates.append(f"{key} = ?")
528
+ params.append(value)
529
+
530
+ if updates:
531
+ params.append(token_id)
532
+ query = f"UPDATE tokens SET {', '.join(updates)} WHERE id = ?"
533
+ await db.execute(query, params)
534
+ await db.commit()
535
+
536
+ async def delete_token(self, token_id: int):
537
+ """Delete token and related data"""
538
+ async with aiosqlite.connect(self.db_path) as db:
539
+ await db.execute("DELETE FROM token_stats WHERE token_id = ?", (token_id,))
540
+ await db.execute("DELETE FROM projects WHERE token_id = ?", (token_id,))
541
+ await db.execute("DELETE FROM tokens WHERE id = ?", (token_id,))
542
+ await db.commit()
543
+
544
+ # Project operations
545
+ async def add_project(self, project: Project) -> int:
546
+ """Add a new project"""
547
+ async with aiosqlite.connect(self.db_path) as db:
548
+ cursor = await db.execute("""
549
+ INSERT INTO projects (project_id, token_id, project_name, tool_name, is_active)
550
+ VALUES (?, ?, ?, ?, ?)
551
+ """, (project.project_id, project.token_id, project.project_name,
552
+ project.tool_name, project.is_active))
553
+ await db.commit()
554
+ return cursor.lastrowid
555
+
556
+ async def get_project_by_id(self, project_id: str) -> Optional[Project]:
557
+ """Get project by UUID"""
558
+ async with aiosqlite.connect(self.db_path) as db:
559
+ db.row_factory = aiosqlite.Row
560
+ cursor = await db.execute("SELECT * FROM projects WHERE project_id = ?", (project_id,))
561
+ row = await cursor.fetchone()
562
+ if row:
563
+ return Project(**dict(row))
564
+ return None
565
+
566
+ async def get_projects_by_token(self, token_id: int) -> List[Project]:
567
+ """Get all projects for a token"""
568
+ async with aiosqlite.connect(self.db_path) as db:
569
+ db.row_factory = aiosqlite.Row
570
+ cursor = await db.execute(
571
+ "SELECT * FROM projects WHERE token_id = ? ORDER BY created_at DESC",
572
+ (token_id,)
573
+ )
574
+ rows = await cursor.fetchall()
575
+ return [Project(**dict(row)) for row in rows]
576
+
577
+ async def delete_project(self, project_id: str):
578
+ """Delete project"""
579
+ async with aiosqlite.connect(self.db_path) as db:
580
+ await db.execute("DELETE FROM projects WHERE project_id = ?", (project_id,))
581
+ await db.commit()
582
+
583
+ # Task operations
584
+ async def create_task(self, task: Task) -> int:
585
+ """Create a new task"""
586
+ async with aiosqlite.connect(self.db_path) as db:
587
+ cursor = await db.execute("""
588
+ INSERT INTO tasks (task_id, token_id, model, prompt, status, progress, scene_id)
589
+ VALUES (?, ?, ?, ?, ?, ?, ?)
590
+ """, (task.task_id, task.token_id, task.model, task.prompt,
591
+ task.status, task.progress, task.scene_id))
592
+ await db.commit()
593
+ return cursor.lastrowid
594
+
595
+ async def get_task(self, task_id: str) -> Optional[Task]:
596
+ """Get task by ID"""
597
+ async with aiosqlite.connect(self.db_path) as db:
598
+ db.row_factory = aiosqlite.Row
599
+ cursor = await db.execute("SELECT * FROM tasks WHERE task_id = ?", (task_id,))
600
+ row = await cursor.fetchone()
601
+ if row:
602
+ task_dict = dict(row)
603
+ # Parse result_urls from JSON
604
+ if task_dict.get("result_urls"):
605
+ task_dict["result_urls"] = json.loads(task_dict["result_urls"])
606
+ return Task(**task_dict)
607
+ return None
608
+
609
+ async def update_task(self, task_id: str, **kwargs):
610
+ """Update task"""
611
+ async with aiosqlite.connect(self.db_path) as db:
612
+ updates = []
613
+ params = []
614
+
615
+ for key, value in kwargs.items():
616
+ if value is not None:
617
+ # Convert list to JSON string for result_urls
618
+ if key == "result_urls" and isinstance(value, list):
619
+ value = json.dumps(value)
620
+ updates.append(f"{key} = ?")
621
+ params.append(value)
622
+
623
+ if updates:
624
+ params.append(task_id)
625
+ query = f"UPDATE tasks SET {', '.join(updates)} WHERE task_id = ?"
626
+ await db.execute(query, params)
627
+ await db.commit()
628
+
629
+ # Token stats operations (kept for compatibility, now delegates to specific methods)
630
+ async def increment_token_stats(self, token_id: int, stat_type: str):
631
+ """Increment token statistics (delegates to specific methods)"""
632
+ if stat_type == "image":
633
+ await self.increment_image_count(token_id)
634
+ elif stat_type == "video":
635
+ await self.increment_video_count(token_id)
636
+ elif stat_type == "error":
637
+ await self.increment_error_count(token_id)
638
+
639
+ async def get_token_stats(self, token_id: int) -> Optional[TokenStats]:
640
+ """Get token statistics"""
641
+ async with aiosqlite.connect(self.db_path) as db:
642
+ db.row_factory = aiosqlite.Row
643
+ cursor = await db.execute("SELECT * FROM token_stats WHERE token_id = ?", (token_id,))
644
+ row = await cursor.fetchone()
645
+ if row:
646
+ return TokenStats(**dict(row))
647
+ return None
648
+
649
+ async def increment_image_count(self, token_id: int):
650
+ """Increment image generation count with daily reset"""
651
+ from datetime import date
652
+ async with aiosqlite.connect(self.db_path) as db:
653
+ today = str(date.today())
654
+ # Get current stats
655
+ cursor = await db.execute("SELECT today_date FROM token_stats WHERE token_id = ?", (token_id,))
656
+ row = await cursor.fetchone()
657
+
658
+ # If date changed, reset today's count
659
+ if row and row[0] != today:
660
+ await db.execute("""
661
+ UPDATE token_stats
662
+ SET image_count = image_count + 1,
663
+ today_image_count = 1,
664
+ today_date = ?
665
+ WHERE token_id = ?
666
+ """, (today, token_id))
667
+ else:
668
+ # Same day, just increment both
669
+ await db.execute("""
670
+ UPDATE token_stats
671
+ SET image_count = image_count + 1,
672
+ today_image_count = today_image_count + 1,
673
+ today_date = ?
674
+ WHERE token_id = ?
675
+ """, (today, token_id))
676
+ await db.commit()
677
+
678
+ async def increment_video_count(self, token_id: int):
679
+ """Increment video generation count with daily reset"""
680
+ from datetime import date
681
+ async with aiosqlite.connect(self.db_path) as db:
682
+ today = str(date.today())
683
+ # Get current stats
684
+ cursor = await db.execute("SELECT today_date FROM token_stats WHERE token_id = ?", (token_id,))
685
+ row = await cursor.fetchone()
686
+
687
+ # If date changed, reset today's count
688
+ if row and row[0] != today:
689
+ await db.execute("""
690
+ UPDATE token_stats
691
+ SET video_count = video_count + 1,
692
+ today_video_count = 1,
693
+ today_date = ?
694
+ WHERE token_id = ?
695
+ """, (today, token_id))
696
+ else:
697
+ # Same day, just increment both
698
+ await db.execute("""
699
+ UPDATE token_stats
700
+ SET video_count = video_count + 1,
701
+ today_video_count = today_video_count + 1,
702
+ today_date = ?
703
+ WHERE token_id = ?
704
+ """, (today, token_id))
705
+ await db.commit()
706
+
707
+ async def increment_error_count(self, token_id: int):
708
+ """Increment error count with daily reset
709
+
710
+ Updates two counters:
711
+ - error_count: Historical total errors (never reset)
712
+ - consecutive_error_count: Consecutive errors (reset on success/enable)
713
+ - today_error_count: Today's errors (reset on date change)
714
+ """
715
+ from datetime import date
716
+ async with aiosqlite.connect(self.db_path) as db:
717
+ today = str(date.today())
718
+ # Get current stats
719
+ cursor = await db.execute("SELECT today_date FROM token_stats WHERE token_id = ?", (token_id,))
720
+ row = await cursor.fetchone()
721
+
722
+ # If date changed, reset today's error count
723
+ if row and row[0] != today:
724
+ await db.execute("""
725
+ UPDATE token_stats
726
+ SET error_count = error_count + 1,
727
+ consecutive_error_count = consecutive_error_count + 1,
728
+ today_error_count = 1,
729
+ today_date = ?,
730
+ last_error_at = CURRENT_TIMESTAMP
731
+ WHERE token_id = ?
732
+ """, (today, token_id))
733
+ else:
734
+ # Same day, just increment all counters
735
+ await db.execute("""
736
+ UPDATE token_stats
737
+ SET error_count = error_count + 1,
738
+ consecutive_error_count = consecutive_error_count + 1,
739
+ today_error_count = today_error_count + 1,
740
+ today_date = ?,
741
+ last_error_at = CURRENT_TIMESTAMP
742
+ WHERE token_id = ?
743
+ """, (today, token_id))
744
+ await db.commit()
745
+
746
+ async def reset_error_count(self, token_id: int):
747
+ """Reset consecutive error count (only reset consecutive_error_count, keep error_count and today_error_count)
748
+
749
+ This is called when:
750
+ - Token is manually enabled by admin
751
+ - Request succeeds (resets consecutive error counter)
752
+
753
+ Note: error_count (total historical errors) is NEVER reset
754
+ """
755
+ async with aiosqlite.connect(self.db_path) as db:
756
+ await db.execute("""
757
+ UPDATE token_stats SET consecutive_error_count = 0 WHERE token_id = ?
758
+ """, (token_id,))
759
+ await db.commit()
760
+
761
+ # Config operations
762
+ async def get_admin_config(self) -> Optional[AdminConfig]:
763
+ """Get admin configuration"""
764
+ async with aiosqlite.connect(self.db_path) as db:
765
+ db.row_factory = aiosqlite.Row
766
+ cursor = await db.execute("SELECT * FROM admin_config WHERE id = 1")
767
+ row = await cursor.fetchone()
768
+ if row:
769
+ return AdminConfig(**dict(row))
770
+ return None
771
+
772
+ async def update_admin_config(self, **kwargs):
773
+ """Update admin configuration"""
774
+ async with aiosqlite.connect(self.db_path) as db:
775
+ updates = []
776
+ params = []
777
+
778
+ for key, value in kwargs.items():
779
+ if value is not None:
780
+ updates.append(f"{key} = ?")
781
+ params.append(value)
782
+
783
+ if updates:
784
+ updates.append("updated_at = CURRENT_TIMESTAMP")
785
+ query = f"UPDATE admin_config SET {', '.join(updates)} WHERE id = 1"
786
+ await db.execute(query, params)
787
+ await db.commit()
788
+
789
+ async def get_proxy_config(self) -> Optional[ProxyConfig]:
790
+ """Get proxy configuration"""
791
+ async with aiosqlite.connect(self.db_path) as db:
792
+ db.row_factory = aiosqlite.Row
793
+ cursor = await db.execute("SELECT * FROM proxy_config WHERE id = 1")
794
+ row = await cursor.fetchone()
795
+ if row:
796
+ return ProxyConfig(**dict(row))
797
+ return None
798
+
799
+ async def update_proxy_config(self, enabled: bool, proxy_url: Optional[str] = None):
800
+ """Update proxy configuration"""
801
+ async with aiosqlite.connect(self.db_path) as db:
802
+ await db.execute("""
803
+ UPDATE proxy_config
804
+ SET enabled = ?, proxy_url = ?, updated_at = CURRENT_TIMESTAMP
805
+ WHERE id = 1
806
+ """, (enabled, proxy_url))
807
+ await db.commit()
808
+
809
+ async def get_generation_config(self) -> Optional[GenerationConfig]:
810
+ """Get generation configuration"""
811
+ async with aiosqlite.connect(self.db_path) as db:
812
+ db.row_factory = aiosqlite.Row
813
+ cursor = await db.execute("SELECT * FROM generation_config WHERE id = 1")
814
+ row = await cursor.fetchone()
815
+ if row:
816
+ return GenerationConfig(**dict(row))
817
+ return None
818
+
819
+ async def update_generation_config(self, image_timeout: int, video_timeout: int):
820
+ """Update generation configuration"""
821
+ async with aiosqlite.connect(self.db_path) as db:
822
+ await db.execute("""
823
+ UPDATE generation_config
824
+ SET image_timeout = ?, video_timeout = ?, updated_at = CURRENT_TIMESTAMP
825
+ WHERE id = 1
826
+ """, (image_timeout, video_timeout))
827
+ await db.commit()
828
+
829
+ # Request log operations
830
+ async def add_request_log(self, log: RequestLog):
831
+ """Add request log"""
832
+ async with aiosqlite.connect(self.db_path) as db:
833
+ await db.execute("""
834
+ INSERT INTO request_logs (token_id, operation, request_body, response_body, status_code, duration)
835
+ VALUES (?, ?, ?, ?, ?, ?)
836
+ """, (log.token_id, log.operation, log.request_body, log.response_body,
837
+ log.status_code, log.duration))
838
+ await db.commit()
839
+
840
+ async def get_logs(self, limit: int = 100, token_id: Optional[int] = None):
841
+ """Get request logs with token email"""
842
+ async with aiosqlite.connect(self.db_path) as db:
843
+ db.row_factory = aiosqlite.Row
844
+
845
+ if token_id:
846
+ cursor = await db.execute("""
847
+ SELECT
848
+ rl.id,
849
+ rl.token_id,
850
+ rl.operation,
851
+ rl.request_body,
852
+ rl.response_body,
853
+ rl.status_code,
854
+ rl.duration,
855
+ rl.created_at,
856
+ t.email as token_email,
857
+ t.name as token_username
858
+ FROM request_logs rl
859
+ LEFT JOIN tokens t ON rl.token_id = t.id
860
+ WHERE rl.token_id = ?
861
+ ORDER BY rl.created_at DESC
862
+ LIMIT ?
863
+ """, (token_id, limit))
864
+ else:
865
+ cursor = await db.execute("""
866
+ SELECT
867
+ rl.id,
868
+ rl.token_id,
869
+ rl.operation,
870
+ rl.request_body,
871
+ rl.response_body,
872
+ rl.status_code,
873
+ rl.duration,
874
+ rl.created_at,
875
+ t.email as token_email,
876
+ t.name as token_username
877
+ FROM request_logs rl
878
+ LEFT JOIN tokens t ON rl.token_id = t.id
879
+ ORDER BY rl.created_at DESC
880
+ LIMIT ?
881
+ """, (limit,))
882
+
883
+ rows = await cursor.fetchall()
884
+ return [dict(row) for row in rows]
885
+
886
+ async def init_config_from_toml(self, config_dict: dict, is_first_startup: bool = True):
887
+ """
888
+ Initialize database configuration from setting.toml
889
+
890
+ Args:
891
+ config_dict: Configuration dictionary from setting.toml
892
+ is_first_startup: If True, initialize all config rows from setting.toml.
893
+ If False (upgrade mode), only ensure missing config rows exist with default values.
894
+ """
895
+ async with aiosqlite.connect(self.db_path) as db:
896
+ if is_first_startup:
897
+ # First startup: Initialize all config tables with values from setting.toml
898
+ await self._ensure_config_rows(db, config_dict)
899
+ else:
900
+ # Upgrade mode: Only ensure missing config rows exist (with default values, not from TOML)
901
+ await self._ensure_config_rows(db, config_dict=None)
902
+
903
+ await db.commit()
904
+
905
+ async def reload_config_to_memory(self):
906
+ """
907
+ Reload all configuration from database to in-memory Config instance.
908
+ This should be called after any configuration update to ensure hot-reload.
909
+
910
+ Includes:
911
+ - Admin config (username, password, api_key)
912
+ - Cache config (enabled, timeout, base_url)
913
+ - Generation config (image_timeout, video_timeout)
914
+ - Proxy config will be handled by ProxyManager
915
+ """
916
+ from .config import config
917
+
918
+ # Reload admin config
919
+ admin_config = await self.get_admin_config()
920
+ if admin_config:
921
+ config.set_admin_username_from_db(admin_config.username)
922
+ config.set_admin_password_from_db(admin_config.password)
923
+ config.api_key = admin_config.api_key
924
+
925
+ # Reload cache config
926
+ cache_config = await self.get_cache_config()
927
+ if cache_config:
928
+ config.set_cache_enabled(cache_config.cache_enabled)
929
+ config.set_cache_timeout(cache_config.cache_timeout)
930
+ config.set_cache_base_url(cache_config.cache_base_url or "")
931
+
932
+ # Reload generation config
933
+ generation_config = await self.get_generation_config()
934
+ if generation_config:
935
+ config.set_image_timeout(generation_config.image_timeout)
936
+ config.set_video_timeout(generation_config.video_timeout)
937
+
938
+ # Reload debug config
939
+ debug_config = await self.get_debug_config()
940
+ if debug_config:
941
+ config.set_debug_enabled(debug_config.enabled)
942
+
943
+ # Cache config operations
944
+ async def get_cache_config(self) -> CacheConfig:
945
+ """Get cache configuration"""
946
+ async with aiosqlite.connect(self.db_path) as db:
947
+ db.row_factory = aiosqlite.Row
948
+ cursor = await db.execute("SELECT * FROM cache_config WHERE id = 1")
949
+ row = await cursor.fetchone()
950
+ if row:
951
+ return CacheConfig(**dict(row))
952
+ # Return default if not found
953
+ return CacheConfig(cache_enabled=False, cache_timeout=7200)
954
+
955
+ async def update_cache_config(self, enabled: bool = None, timeout: int = None, base_url: Optional[str] = None):
956
+ """Update cache configuration"""
957
+ async with aiosqlite.connect(self.db_path) as db:
958
+ db.row_factory = aiosqlite.Row
959
+ # Get current values
960
+ cursor = await db.execute("SELECT * FROM cache_config WHERE id = 1")
961
+ row = await cursor.fetchone()
962
+
963
+ if row:
964
+ current = dict(row)
965
+ # Use new values if provided, otherwise keep existing
966
+ new_enabled = enabled if enabled is not None else current.get("cache_enabled", False)
967
+ new_timeout = timeout if timeout is not None else current.get("cache_timeout", 7200)
968
+ new_base_url = base_url if base_url is not None else current.get("cache_base_url")
969
+
970
+ # If base_url is explicitly set to empty string, treat as None
971
+ if base_url == "":
972
+ new_base_url = None
973
+
974
+ await db.execute("""
975
+ UPDATE cache_config
976
+ SET cache_enabled = ?, cache_timeout = ?, cache_base_url = ?, updated_at = CURRENT_TIMESTAMP
977
+ WHERE id = 1
978
+ """, (new_enabled, new_timeout, new_base_url))
979
+ else:
980
+ # Insert default row if not exists
981
+ new_enabled = enabled if enabled is not None else False
982
+ new_timeout = timeout if timeout is not None else 7200
983
+ new_base_url = base_url if base_url is not None else None
984
+
985
+ await db.execute("""
986
+ INSERT INTO cache_config (id, cache_enabled, cache_timeout, cache_base_url)
987
+ VALUES (1, ?, ?, ?)
988
+ """, (new_enabled, new_timeout, new_base_url))
989
+
990
+ await db.commit()
991
+
992
+ # Debug config operations
993
+ async def get_debug_config(self) -> 'DebugConfig':
994
+ """Get debug configuration"""
995
+ from .models import DebugConfig
996
+ async with aiosqlite.connect(self.db_path) as db:
997
+ db.row_factory = aiosqlite.Row
998
+ cursor = await db.execute("SELECT * FROM debug_config WHERE id = 1")
999
+ row = await cursor.fetchone()
1000
+ if row:
1001
+ return DebugConfig(**dict(row))
1002
+ # Return default if not found
1003
+ return DebugConfig(enabled=False, log_requests=True, log_responses=True, mask_token=True)
1004
+
1005
+ async def update_debug_config(
1006
+ self,
1007
+ enabled: bool = None,
1008
+ log_requests: bool = None,
1009
+ log_responses: bool = None,
1010
+ mask_token: bool = None
1011
+ ):
1012
+ """Update debug configuration"""
1013
+ async with aiosqlite.connect(self.db_path) as db:
1014
+ db.row_factory = aiosqlite.Row
1015
+ # Get current values
1016
+ cursor = await db.execute("SELECT * FROM debug_config WHERE id = 1")
1017
+ row = await cursor.fetchone()
1018
+
1019
+ if row:
1020
+ current = dict(row)
1021
+ # Use new values if provided, otherwise keep existing
1022
+ new_enabled = enabled if enabled is not None else current.get("enabled", False)
1023
+ new_log_requests = log_requests if log_requests is not None else current.get("log_requests", True)
1024
+ new_log_responses = log_responses if log_responses is not None else current.get("log_responses", True)
1025
+ new_mask_token = mask_token if mask_token is not None else current.get("mask_token", True)
1026
+
1027
+ await db.execute("""
1028
+ UPDATE debug_config
1029
+ SET enabled = ?, log_requests = ?, log_responses = ?, mask_token = ?, updated_at = CURRENT_TIMESTAMP
1030
+ WHERE id = 1
1031
+ """, (new_enabled, new_log_requests, new_log_responses, new_mask_token))
1032
+ else:
1033
+ # Insert default row if not exists
1034
+ new_enabled = enabled if enabled is not None else False
1035
+ new_log_requests = log_requests if log_requests is not None else True
1036
+ new_log_responses = log_responses if log_responses is not None else True
1037
+ new_mask_token = mask_token if mask_token is not None else True
1038
+
1039
+ await db.execute("""
1040
+ INSERT INTO debug_config (id, enabled, log_requests, log_responses, mask_token)
1041
+ VALUES (1, ?, ?, ?, ?)
1042
+ """, (new_enabled, new_log_requests, new_log_responses, new_mask_token))
1043
+
1044
+ await db.commit()
src/core/logger.py ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Debug logger module for detailed API request/response logging"""
2
+ import json
3
+ import logging
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+ from typing import Dict, Any, Optional
7
+ from .config import config
8
+
9
+ class DebugLogger:
10
+ """Debug logger for API requests and responses"""
11
+
12
+ def __init__(self):
13
+ self.log_file = Path("logs.txt")
14
+ self._setup_logger()
15
+
16
+ def _setup_logger(self):
17
+ """Setup file logger"""
18
+ # Create logger
19
+ self.logger = logging.getLogger("debug_logger")
20
+ self.logger.setLevel(logging.DEBUG)
21
+
22
+ # Remove existing handlers
23
+ self.logger.handlers.clear()
24
+
25
+ # Create file handler
26
+ file_handler = logging.FileHandler(
27
+ self.log_file,
28
+ mode='a',
29
+ encoding='utf-8'
30
+ )
31
+ file_handler.setLevel(logging.DEBUG)
32
+
33
+ # Create formatter
34
+ formatter = logging.Formatter(
35
+ '%(message)s',
36
+ datefmt='%Y-%m-%d %H:%M:%S'
37
+ )
38
+ file_handler.setFormatter(formatter)
39
+
40
+ # Add handler
41
+ self.logger.addHandler(file_handler)
42
+
43
+ # Prevent propagation to root logger
44
+ self.logger.propagate = False
45
+
46
+ def _mask_token(self, token: str) -> str:
47
+ """Mask token for logging (show first 6 and last 6 characters)"""
48
+ if not config.debug_mask_token or len(token) <= 12:
49
+ return token
50
+ return f"{token[:6]}...{token[-6:]}"
51
+
52
+ def _format_timestamp(self) -> str:
53
+ """Format current timestamp"""
54
+ return datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]
55
+
56
+ def _write_separator(self, char: str = "=", length: int = 100):
57
+ """Write separator line"""
58
+ self.logger.info(char * length)
59
+
60
+ def log_request(
61
+ self,
62
+ method: str,
63
+ url: str,
64
+ headers: Dict[str, str],
65
+ body: Optional[Any] = None,
66
+ files: Optional[Dict] = None,
67
+ proxy: Optional[str] = None
68
+ ):
69
+ """Log API request details to log.txt"""
70
+
71
+ if not config.debug_enabled or not config.debug_log_requests:
72
+ return
73
+
74
+ try:
75
+ self._write_separator()
76
+ self.logger.info(f"🔵 [REQUEST] {self._format_timestamp()}")
77
+ self._write_separator("-")
78
+
79
+ # Basic info
80
+ self.logger.info(f"Method: {method}")
81
+ self.logger.info(f"URL: {url}")
82
+
83
+ # Headers
84
+ self.logger.info("\n📋 Headers:")
85
+ masked_headers = dict(headers)
86
+ if "Authorization" in masked_headers or "authorization" in masked_headers:
87
+ auth_key = "Authorization" if "Authorization" in masked_headers else "authorization"
88
+ auth_value = masked_headers[auth_key]
89
+ if auth_value.startswith("Bearer "):
90
+ token = auth_value[7:]
91
+ masked_headers[auth_key] = f"Bearer {self._mask_token(token)}"
92
+
93
+ # Mask Cookie header (ST token)
94
+ if "Cookie" in masked_headers:
95
+ cookie_value = masked_headers["Cookie"]
96
+ if "__Secure-next-auth.session-token=" in cookie_value:
97
+ parts = cookie_value.split("=", 1)
98
+ if len(parts) == 2:
99
+ st_token = parts[1].split(";")[0]
100
+ masked_headers["Cookie"] = f"__Secure-next-auth.session-token={self._mask_token(st_token)}"
101
+
102
+ for key, value in masked_headers.items():
103
+ self.logger.info(f" {key}: {value}")
104
+
105
+ # Body
106
+ if body is not None:
107
+ self.logger.info("\n📦 Request Body:")
108
+ if isinstance(body, (dict, list)):
109
+ body_str = json.dumps(body, indent=2, ensure_ascii=False)
110
+ self.logger.info(body_str)
111
+ else:
112
+ self.logger.info(str(body))
113
+
114
+ # Files
115
+ if files:
116
+ self.logger.info("\n📎 Files:")
117
+ try:
118
+ if hasattr(files, 'keys') and callable(getattr(files, 'keys', None)):
119
+ for key in files.keys():
120
+ self.logger.info(f" {key}: <file data>")
121
+ else:
122
+ self.logger.info(" <multipart form data>")
123
+ except (AttributeError, TypeError):
124
+ self.logger.info(" <binary file data>")
125
+
126
+ # Proxy
127
+ if proxy:
128
+ self.logger.info(f"\n🌐 Proxy: {proxy}")
129
+
130
+ self._write_separator()
131
+ self.logger.info("") # Empty line
132
+
133
+ except Exception as e:
134
+ self.logger.error(f"Error logging request: {e}")
135
+
136
+ def log_response(
137
+ self,
138
+ status_code: int,
139
+ headers: Dict[str, str],
140
+ body: Any,
141
+ duration_ms: Optional[float] = None
142
+ ):
143
+ """Log API response details to log.txt"""
144
+
145
+ if not config.debug_enabled or not config.debug_log_responses:
146
+ return
147
+
148
+ try:
149
+ self._write_separator()
150
+ self.logger.info(f"🟢 [RESPONSE] {self._format_timestamp()}")
151
+ self._write_separator("-")
152
+
153
+ # Status
154
+ status_emoji = "✅" if 200 <= status_code < 300 else "❌"
155
+ self.logger.info(f"Status: {status_code} {status_emoji}")
156
+
157
+ # Duration
158
+ if duration_ms is not None:
159
+ self.logger.info(f"Duration: {duration_ms:.2f}ms")
160
+
161
+ # Headers
162
+ self.logger.info("\n📋 Response Headers:")
163
+ for key, value in headers.items():
164
+ self.logger.info(f" {key}: {value}")
165
+
166
+ # Body
167
+ self.logger.info("\n📦 Response Body:")
168
+ if isinstance(body, (dict, list)):
169
+ body_str = json.dumps(body, indent=2, ensure_ascii=False)
170
+ self.logger.info(body_str)
171
+ elif isinstance(body, str):
172
+ # Try to parse as JSON
173
+ try:
174
+ parsed = json.loads(body)
175
+ body_str = json.dumps(parsed, indent=2, ensure_ascii=False)
176
+ self.logger.info(body_str)
177
+ except:
178
+ # Not JSON, log as text (limit length)
179
+ if len(body) > 2000:
180
+ self.logger.info(f"{body[:2000]}... (truncated)")
181
+ else:
182
+ self.logger.info(body)
183
+ else:
184
+ self.logger.info(str(body))
185
+
186
+ self._write_separator()
187
+ self.logger.info("") # Empty line
188
+
189
+ except Exception as e:
190
+ self.logger.error(f"Error logging response: {e}")
191
+
192
+ def log_error(
193
+ self,
194
+ error_message: str,
195
+ status_code: Optional[int] = None,
196
+ response_text: Optional[str] = None
197
+ ):
198
+ """Log API error details to log.txt"""
199
+
200
+ if not config.debug_enabled:
201
+ return
202
+
203
+ try:
204
+ self._write_separator()
205
+ self.logger.info(f"🔴 [ERROR] {self._format_timestamp()}")
206
+ self._write_separator("-")
207
+
208
+ if status_code:
209
+ self.logger.info(f"Status Code: {status_code}")
210
+
211
+ self.logger.info(f"Error Message: {error_message}")
212
+
213
+ if response_text:
214
+ self.logger.info("\n📦 Error Response:")
215
+ # Try to parse as JSON
216
+ try:
217
+ parsed = json.loads(response_text)
218
+ body_str = json.dumps(parsed, indent=2, ensure_ascii=False)
219
+ self.logger.info(body_str)
220
+ except:
221
+ # Not JSON, log as text
222
+ if len(response_text) > 2000:
223
+ self.logger.info(f"{response_text[:2000]}... (truncated)")
224
+ else:
225
+ self.logger.info(response_text)
226
+
227
+ self._write_separator()
228
+ self.logger.info("") # Empty line
229
+
230
+ except Exception as e:
231
+ self.logger.error(f"Error logging error: {e}")
232
+
233
+ def log_info(self, message: str):
234
+ """Log general info message to log.txt"""
235
+ if not config.debug_enabled:
236
+ return
237
+ try:
238
+ self.logger.info(f"ℹ️ [{self._format_timestamp()}] {message}")
239
+ except Exception as e:
240
+ self.logger.error(f"Error logging info: {e}")
241
+
242
+ def log_warning(self, message: str):
243
+ """Log warning message to log.txt"""
244
+ if not config.debug_enabled:
245
+ return
246
+ try:
247
+ self.logger.warning(f"⚠️ [{self._format_timestamp()}] {message}")
248
+ except Exception as e:
249
+ self.logger.error(f"Error logging warning: {e}")
250
+
251
+ # Global debug logger instance
252
+ debug_logger = DebugLogger()
src/core/models.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Data models for Flow2API"""
2
+ from pydantic import BaseModel
3
+ from typing import Optional, List, Union, Any
4
+ from datetime import datetime
5
+
6
+
7
+ class Token(BaseModel):
8
+ """Token model for Flow2API"""
9
+ id: Optional[int] = None
10
+
11
+ # 认证信息 (核心)
12
+ st: str # Session Token (__Secure-next-auth.session-token)
13
+ at: Optional[str] = None # Access Token (从ST转换而来)
14
+ at_expires: Optional[datetime] = None # AT过期时间
15
+
16
+ # 基础信息
17
+ email: str
18
+ name: Optional[str] = ""
19
+ remark: Optional[str] = None
20
+ is_active: bool = True
21
+ created_at: Optional[datetime] = None
22
+ last_used_at: Optional[datetime] = None
23
+ use_count: int = 0
24
+
25
+ # VideoFX特有字段
26
+ credits: int = 0 # 剩余credits
27
+ user_paygate_tier: Optional[str] = None # PAYGATE_TIER_ONE
28
+
29
+ # 项目管理
30
+ current_project_id: Optional[str] = None # 当前使用的项目UUID
31
+ current_project_name: Optional[str] = None # 项目名称
32
+
33
+ # 功能开关
34
+ image_enabled: bool = True
35
+ video_enabled: bool = True
36
+
37
+ # 并发限制
38
+ image_concurrency: int = -1 # -1表示无限制
39
+ video_concurrency: int = -1 # -1表示无限制
40
+
41
+
42
+ class Project(BaseModel):
43
+ """Project model for VideoFX"""
44
+ id: Optional[int] = None
45
+ project_id: str # VideoFX项目UUID
46
+ token_id: int # 关联的Token ID
47
+ project_name: str # 项目名称
48
+ tool_name: str = "PINHOLE" # 工具名称,固定为PINHOLE
49
+ is_active: bool = True
50
+ created_at: Optional[datetime] = None
51
+
52
+
53
+ class TokenStats(BaseModel):
54
+ """Token statistics"""
55
+ token_id: int
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
+ # 今日统计
63
+ today_image_count: int = 0
64
+ today_video_count: int = 0
65
+ today_error_count: int = 0
66
+ today_date: Optional[str] = None
67
+ # 连续错误计数 (用于自动禁用判断)
68
+ consecutive_error_count: int = 0
69
+
70
+
71
+ class Task(BaseModel):
72
+ """Generation task"""
73
+ id: Optional[int] = None
74
+ task_id: str # Flow API返回的operation name
75
+ token_id: int
76
+ model: str
77
+ prompt: str
78
+ status: str # processing, completed, failed
79
+ progress: int = 0 # 0-100
80
+ result_urls: Optional[List[str]] = None
81
+ error_message: Optional[str] = None
82
+ scene_id: Optional[str] = None # Flow API的sceneId
83
+ created_at: Optional[datetime] = None
84
+ completed_at: Optional[datetime] = None
85
+
86
+
87
+ class RequestLog(BaseModel):
88
+ """API request log"""
89
+ id: Optional[int] = None
90
+ token_id: Optional[int] = None
91
+ operation: str
92
+ request_body: Optional[str] = None
93
+ response_body: Optional[str] = None
94
+ status_code: int
95
+ duration: float
96
+ created_at: Optional[datetime] = None
97
+
98
+
99
+ class AdminConfig(BaseModel):
100
+ """Admin configuration"""
101
+ id: int = 1
102
+ username: str
103
+ password: str
104
+ api_key: str
105
+ error_ban_threshold: int = 3 # Auto-disable token after N consecutive errors
106
+
107
+
108
+ class ProxyConfig(BaseModel):
109
+ """Proxy configuration"""
110
+ id: int = 1
111
+ enabled: bool = False
112
+ proxy_url: Optional[str] = None
113
+
114
+
115
+ class GenerationConfig(BaseModel):
116
+ """Generation timeout configuration"""
117
+ id: int = 1
118
+ image_timeout: int = 300 # seconds
119
+ video_timeout: int = 1500 # seconds
120
+
121
+
122
+ class CacheConfig(BaseModel):
123
+ """Cache configuration"""
124
+ id: int = 1
125
+ cache_enabled: bool = False
126
+ cache_timeout: int = 7200 # seconds (2 hours)
127
+ cache_base_url: Optional[str] = None
128
+ created_at: Optional[datetime] = None
129
+ updated_at: Optional[datetime] = None
130
+
131
+
132
+ class DebugConfig(BaseModel):
133
+ """Debug configuration"""
134
+ id: int = 1
135
+ enabled: bool = False
136
+ log_requests: bool = True
137
+ log_responses: bool = True
138
+ mask_token: bool = True
139
+ created_at: Optional[datetime] = None
140
+ updated_at: Optional[datetime] = None
141
+
142
+
143
+ # OpenAI Compatible Request Models
144
+ class ChatMessage(BaseModel):
145
+ """Chat message"""
146
+ role: str
147
+ content: Union[str, List[dict]] # string or multimodal array
148
+
149
+
150
+ class ChatCompletionRequest(BaseModel):
151
+ """Chat completion request (OpenAI compatible)"""
152
+ model: str
153
+ messages: List[ChatMessage]
154
+ stream: bool = False
155
+ temperature: Optional[float] = None
156
+ max_tokens: Optional[int] = None
157
+ # Flow2API specific parameters
158
+ image: Optional[str] = None # Base64 encoded image (deprecated, use messages)
159
+ video: Optional[str] = None # Base64 encoded video (deprecated)
src/main.py ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI application initialization"""
2
+ from fastapi import FastAPI
3
+ from fastapi.responses import HTMLResponse, FileResponse
4
+ from fastapi.staticfiles import StaticFiles
5
+ from fastapi.middleware.cors import CORSMiddleware
6
+ from contextlib import asynccontextmanager
7
+ from pathlib import Path
8
+
9
+ from .core.config import config
10
+ from .core.database import Database
11
+ from .services.flow_client import FlowClient
12
+ from .services.proxy_manager import ProxyManager
13
+ from .services.token_manager import TokenManager
14
+ from .services.load_balancer import LoadBalancer
15
+ from .services.concurrency_manager import ConcurrencyManager
16
+ from .services.generation_handler import GenerationHandler
17
+ from .api import routes, admin
18
+
19
+
20
+ @asynccontextmanager
21
+ async def lifespan(app: FastAPI):
22
+ """Application lifespan manager"""
23
+ # Startup
24
+ print("=" * 60)
25
+ print("Flow2API Starting...")
26
+ print("=" * 60)
27
+
28
+ # Get config from setting.toml
29
+ config_dict = config.get_raw_config()
30
+
31
+ # Check if database exists (determine if first startup)
32
+ is_first_startup = not db.db_exists()
33
+
34
+ # Initialize database tables structure
35
+ await db.init_db()
36
+
37
+ # Handle database initialization based on startup type
38
+ if is_first_startup:
39
+ print("🎉 First startup detected. Initializing database and configuration from setting.toml...")
40
+ await db.init_config_from_toml(config_dict, is_first_startup=True)
41
+ print("✓ Database and configuration initialized successfully.")
42
+ else:
43
+ print("🔄 Existing database detected. Checking for missing tables and columns...")
44
+ await db.check_and_migrate_db(config_dict)
45
+ print("✓ Database migration check completed.")
46
+
47
+ # Load admin config from database
48
+ admin_config = await db.get_admin_config()
49
+ if admin_config:
50
+ config.set_admin_username_from_db(admin_config.username)
51
+ config.set_admin_password_from_db(admin_config.password)
52
+ config.api_key = admin_config.api_key
53
+
54
+ # Load cache configuration from database
55
+ cache_config = await db.get_cache_config()
56
+ config.set_cache_enabled(cache_config.cache_enabled)
57
+ config.set_cache_timeout(cache_config.cache_timeout)
58
+ config.set_cache_base_url(cache_config.cache_base_url or "")
59
+
60
+ # Load generation configuration from database
61
+ generation_config = await db.get_generation_config()
62
+ config.set_image_timeout(generation_config.image_timeout)
63
+ config.set_video_timeout(generation_config.video_timeout)
64
+
65
+ # Load debug configuration from database
66
+ debug_config = await db.get_debug_config()
67
+ config.set_debug_enabled(debug_config.enabled)
68
+
69
+ # Initialize concurrency manager
70
+ tokens = await token_manager.get_all_tokens()
71
+ await concurrency_manager.initialize(tokens)
72
+
73
+ # Start file cache cleanup task
74
+ await generation_handler.file_cache.start_cleanup_task()
75
+
76
+ print(f"✓ Database initialized")
77
+ print(f"✓ Total tokens: {len(tokens)}")
78
+ print(f"✓ Cache: {'Enabled' if config.cache_enabled else 'Disabled'} (timeout: {config.cache_timeout}s)")
79
+ print(f"✓ File cache cleanup task started")
80
+ print(f"✓ Server running on http://{config.server_host}:{config.server_port}")
81
+ print("=" * 60)
82
+
83
+ yield
84
+
85
+ # Shutdown
86
+ print("Flow2API Shutting down...")
87
+ # Stop file cache cleanup task
88
+ await generation_handler.file_cache.stop_cleanup_task()
89
+ print("✓ File cache cleanup task stopped")
90
+
91
+
92
+ # Initialize components
93
+ db = Database()
94
+ proxy_manager = ProxyManager(db)
95
+ flow_client = FlowClient(proxy_manager)
96
+ token_manager = TokenManager(db, flow_client)
97
+ concurrency_manager = ConcurrencyManager()
98
+ load_balancer = LoadBalancer(token_manager, concurrency_manager)
99
+ generation_handler = GenerationHandler(
100
+ flow_client,
101
+ token_manager,
102
+ load_balancer,
103
+ db,
104
+ concurrency_manager,
105
+ proxy_manager # 添加 proxy_manager 参数
106
+ )
107
+
108
+ # Set dependencies
109
+ routes.set_generation_handler(generation_handler)
110
+ admin.set_dependencies(token_manager, proxy_manager, db)
111
+
112
+ # Create FastAPI app
113
+ app = FastAPI(
114
+ title="Flow2API",
115
+ description="OpenAI-compatible API for Google VideoFX (Veo)",
116
+ version="1.0.0",
117
+ lifespan=lifespan
118
+ )
119
+
120
+ # CORS middleware
121
+ app.add_middleware(
122
+ CORSMiddleware,
123
+ allow_origins=["*"],
124
+ allow_credentials=True,
125
+ allow_methods=["*"],
126
+ allow_headers=["*"],
127
+ )
128
+
129
+ # Include routers
130
+ app.include_router(routes.router)
131
+ app.include_router(admin.router)
132
+
133
+ # Static files - serve tmp directory for cached files
134
+ tmp_dir = Path(__file__).parent.parent / "tmp"
135
+ tmp_dir.mkdir(exist_ok=True)
136
+ app.mount("/tmp", StaticFiles(directory=str(tmp_dir)), name="tmp")
137
+
138
+ # HTML routes for frontend
139
+ static_path = Path(__file__).parent.parent / "static"
140
+
141
+
142
+ @app.get("/", response_class=HTMLResponse)
143
+ async def index():
144
+ """Redirect to login page"""
145
+ login_file = static_path / "login.html"
146
+ if login_file.exists():
147
+ return FileResponse(str(login_file))
148
+ return HTMLResponse(content="<h1>Flow2API</h1><p>Frontend not found</p>", status_code=404)
149
+
150
+
151
+ @app.get("/login", response_class=HTMLResponse)
152
+ async def login_page():
153
+ """Login page"""
154
+ login_file = static_path / "login.html"
155
+ if login_file.exists():
156
+ return FileResponse(str(login_file))
157
+ return HTMLResponse(content="<h1>Login Page Not Found</h1>", status_code=404)
158
+
159
+
160
+ @app.get("/manage", response_class=HTMLResponse)
161
+ async def manage_page():
162
+ """Management console page"""
163
+ manage_file = static_path / "manage.html"
164
+ if manage_file.exists():
165
+ return FileResponse(str(manage_file))
166
+ return HTMLResponse(content="<h1>Management Page Not Found</h1>", status_code=404)
src/services/__init__.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Services modules"""
2
+
3
+ from .flow_client import FlowClient
4
+ from .proxy_manager import ProxyManager
5
+ from .load_balancer import LoadBalancer
6
+ from .concurrency_manager import ConcurrencyManager
7
+ from .token_manager import TokenManager
8
+ from .generation_handler import GenerationHandler
9
+
10
+ __all__ = [
11
+ "FlowClient",
12
+ "ProxyManager",
13
+ "LoadBalancer",
14
+ "ConcurrencyManager",
15
+ "TokenManager",
16
+ "GenerationHandler"
17
+ ]
src/services/concurrency_manager.py ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Concurrency manager for token-based rate limiting"""
2
+ import asyncio
3
+ from typing import Dict, Optional
4
+ from ..core.logger import debug_logger
5
+
6
+
7
+ class ConcurrencyManager:
8
+ """Manages concurrent request limits for each token"""
9
+
10
+ def __init__(self):
11
+ """Initialize concurrency manager"""
12
+ self._image_concurrency: Dict[int, int] = {} # token_id -> remaining image concurrency
13
+ self._video_concurrency: Dict[int, int] = {} # token_id -> remaining video concurrency
14
+ self._lock = asyncio.Lock() # Protect concurrent access
15
+
16
+ async def initialize(self, tokens: list):
17
+ """
18
+ Initialize concurrency counters from token list
19
+
20
+ Args:
21
+ tokens: List of Token objects with image_concurrency and video_concurrency fields
22
+ """
23
+ async with self._lock:
24
+ for token in tokens:
25
+ if token.image_concurrency and token.image_concurrency > 0:
26
+ self._image_concurrency[token.id] = token.image_concurrency
27
+ if token.video_concurrency and token.video_concurrency > 0:
28
+ self._video_concurrency[token.id] = token.video_concurrency
29
+
30
+ debug_logger.log_info(f"Concurrency manager initialized with {len(tokens)} tokens")
31
+
32
+ async def can_use_image(self, token_id: int) -> bool:
33
+ """
34
+ Check if token can be used for image generation
35
+
36
+ Args:
37
+ token_id: Token ID
38
+
39
+ Returns:
40
+ True if token has available image concurrency, False if concurrency is 0
41
+ """
42
+ async with self._lock:
43
+ # If not in dict, it means no limit (-1)
44
+ if token_id not in self._image_concurrency:
45
+ return True
46
+
47
+ remaining = self._image_concurrency[token_id]
48
+ if remaining <= 0:
49
+ debug_logger.log_info(f"Token {token_id} image concurrency exhausted (remaining: {remaining})")
50
+ return False
51
+
52
+ return True
53
+
54
+ async def can_use_video(self, token_id: int) -> bool:
55
+ """
56
+ Check if token can be used for video generation
57
+
58
+ Args:
59
+ token_id: Token ID
60
+
61
+ Returns:
62
+ True if token has available video concurrency, False if concurrency is 0
63
+ """
64
+ async with self._lock:
65
+ # If not in dict, it means no limit (-1)
66
+ if token_id not in self._video_concurrency:
67
+ return True
68
+
69
+ remaining = self._video_concurrency[token_id]
70
+ if remaining <= 0:
71
+ debug_logger.log_info(f"Token {token_id} video concurrency exhausted (remaining: {remaining})")
72
+ return False
73
+
74
+ return True
75
+
76
+ async def acquire_image(self, token_id: int) -> bool:
77
+ """
78
+ Acquire image concurrency slot
79
+
80
+ Args:
81
+ token_id: Token ID
82
+
83
+ Returns:
84
+ True if acquired, False if not available
85
+ """
86
+ async with self._lock:
87
+ if token_id not in self._image_concurrency:
88
+ # No limit
89
+ return True
90
+
91
+ if self._image_concurrency[token_id] <= 0:
92
+ return False
93
+
94
+ self._image_concurrency[token_id] -= 1
95
+ debug_logger.log_info(f"Token {token_id} acquired image slot (remaining: {self._image_concurrency[token_id]})")
96
+ return True
97
+
98
+ async def acquire_video(self, token_id: int) -> bool:
99
+ """
100
+ Acquire video concurrency slot
101
+
102
+ Args:
103
+ token_id: Token ID
104
+
105
+ Returns:
106
+ True if acquired, False if not available
107
+ """
108
+ async with self._lock:
109
+ if token_id not in self._video_concurrency:
110
+ # No limit
111
+ return True
112
+
113
+ if self._video_concurrency[token_id] <= 0:
114
+ return False
115
+
116
+ self._video_concurrency[token_id] -= 1
117
+ debug_logger.log_info(f"Token {token_id} acquired video slot (remaining: {self._video_concurrency[token_id]})")
118
+ return True
119
+
120
+ async def release_image(self, token_id: int):
121
+ """
122
+ Release image concurrency slot
123
+
124
+ Args:
125
+ token_id: Token ID
126
+ """
127
+ async with self._lock:
128
+ if token_id in self._image_concurrency:
129
+ self._image_concurrency[token_id] += 1
130
+ debug_logger.log_info(f"Token {token_id} released image slot (remaining: {self._image_concurrency[token_id]})")
131
+
132
+ async def release_video(self, token_id: int):
133
+ """
134
+ Release video concurrency slot
135
+
136
+ Args:
137
+ token_id: Token ID
138
+ """
139
+ async with self._lock:
140
+ if token_id in self._video_concurrency:
141
+ self._video_concurrency[token_id] += 1
142
+ debug_logger.log_info(f"Token {token_id} released video slot (remaining: {self._video_concurrency[token_id]})")
143
+
144
+ async def get_image_remaining(self, token_id: int) -> Optional[int]:
145
+ """
146
+ Get remaining image concurrency for token
147
+
148
+ Args:
149
+ token_id: Token ID
150
+
151
+ Returns:
152
+ Remaining count or None if no limit
153
+ """
154
+ async with self._lock:
155
+ return self._image_concurrency.get(token_id)
156
+
157
+ async def get_video_remaining(self, token_id: int) -> Optional[int]:
158
+ """
159
+ Get remaining video concurrency for token
160
+
161
+ Args:
162
+ token_id: Token ID
163
+
164
+ Returns:
165
+ Remaining count or None if no limit
166
+ """
167
+ async with self._lock:
168
+ return self._video_concurrency.get(token_id)
169
+
170
+ async def reset_token(self, token_id: int, image_concurrency: int = -1, video_concurrency: int = -1):
171
+ """
172
+ Reset concurrency counters for a token
173
+
174
+ Args:
175
+ token_id: Token ID
176
+ image_concurrency: New image concurrency limit (-1 for no limit)
177
+ video_concurrency: New video concurrency limit (-1 for no limit)
178
+ """
179
+ async with self._lock:
180
+ if image_concurrency > 0:
181
+ self._image_concurrency[token_id] = image_concurrency
182
+ elif token_id in self._image_concurrency:
183
+ del self._image_concurrency[token_id]
184
+
185
+ if video_concurrency > 0:
186
+ self._video_concurrency[token_id] = video_concurrency
187
+ elif token_id in self._video_concurrency:
188
+ del self._video_concurrency[token_id]
189
+
190
+ debug_logger.log_info(f"Token {token_id} concurrency reset (image: {image_concurrency}, video: {video_concurrency})")
src/services/file_cache.py ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """File caching service"""
2
+ import os
3
+ import asyncio
4
+ import hashlib
5
+ import time
6
+ from pathlib import Path
7
+ from typing import Optional
8
+ from datetime import datetime, timedelta
9
+ from curl_cffi.requests import AsyncSession
10
+ from ..core.config import config
11
+ from ..core.logger import debug_logger
12
+
13
+
14
+ class FileCache:
15
+ """File caching service for videos"""
16
+
17
+ def __init__(self, cache_dir: str = "tmp", default_timeout: int = 7200, proxy_manager=None):
18
+ """
19
+ Initialize file cache
20
+
21
+ Args:
22
+ cache_dir: Cache directory path
23
+ default_timeout: Default cache timeout in seconds (default: 2 hours)
24
+ proxy_manager: ProxyManager instance for downloading files
25
+ """
26
+ self.cache_dir = Path(cache_dir)
27
+ self.cache_dir.mkdir(exist_ok=True)
28
+ self.default_timeout = default_timeout
29
+ self.proxy_manager = proxy_manager
30
+ self._cleanup_task = None
31
+
32
+ async def start_cleanup_task(self):
33
+ """Start background cleanup task"""
34
+ if self._cleanup_task is None:
35
+ self._cleanup_task = asyncio.create_task(self._cleanup_loop())
36
+
37
+ async def stop_cleanup_task(self):
38
+ """Stop background cleanup task"""
39
+ if self._cleanup_task:
40
+ self._cleanup_task.cancel()
41
+ try:
42
+ await self._cleanup_task
43
+ except asyncio.CancelledError:
44
+ pass
45
+ self._cleanup_task = None
46
+
47
+ async def _cleanup_loop(self):
48
+ """Background task to clean up expired files"""
49
+ while True:
50
+ try:
51
+ await asyncio.sleep(300) # Check every 5 minutes
52
+ await self._cleanup_expired_files()
53
+ except asyncio.CancelledError:
54
+ break
55
+ except Exception as e:
56
+ debug_logger.log_error(
57
+ error_message=f"Cleanup task error: {str(e)}",
58
+ status_code=0,
59
+ response_text=""
60
+ )
61
+
62
+ async def _cleanup_expired_files(self):
63
+ """Remove expired cache files"""
64
+ try:
65
+ current_time = time.time()
66
+ removed_count = 0
67
+
68
+ for file_path in self.cache_dir.iterdir():
69
+ if file_path.is_file():
70
+ # Check file age
71
+ file_age = current_time - file_path.stat().st_mtime
72
+ if file_age > self.default_timeout:
73
+ try:
74
+ file_path.unlink()
75
+ removed_count += 1
76
+ except Exception:
77
+ pass
78
+
79
+ if removed_count > 0:
80
+ debug_logger.log_info(f"Cleanup: removed {removed_count} expired cache files")
81
+
82
+ except Exception as e:
83
+ debug_logger.log_error(
84
+ error_message=f"Failed to cleanup expired files: {str(e)}",
85
+ status_code=0,
86
+ response_text=""
87
+ )
88
+
89
+ def _generate_cache_filename(self, url: str, media_type: str) -> str:
90
+ """Generate unique filename for cached file"""
91
+ # Use URL hash as filename
92
+ url_hash = hashlib.md5(url.encode()).hexdigest()
93
+
94
+ # Determine file extension
95
+ if media_type == "video":
96
+ ext = ".mp4"
97
+ elif media_type == "image":
98
+ ext = ".jpg"
99
+ else:
100
+ ext = ""
101
+
102
+ return f"{url_hash}{ext}"
103
+
104
+ async def download_and_cache(self, url: str, media_type: str) -> str:
105
+ """
106
+ Download file from URL and cache it locally
107
+
108
+ Args:
109
+ url: File URL to download
110
+ media_type: 'image' or 'video'
111
+
112
+ Returns:
113
+ Local cache filename
114
+ """
115
+ filename = self._generate_cache_filename(url, media_type)
116
+ file_path = self.cache_dir / filename
117
+
118
+ # Check if already cached and not expired
119
+ if file_path.exists():
120
+ file_age = time.time() - file_path.stat().st_mtime
121
+ if file_age < self.default_timeout:
122
+ debug_logger.log_info(f"Cache hit: {filename}")
123
+ return filename
124
+ else:
125
+ # Remove expired file
126
+ try:
127
+ file_path.unlink()
128
+ except Exception:
129
+ pass
130
+
131
+ # Download file
132
+ debug_logger.log_info(f"Downloading file from: {url}")
133
+
134
+ try:
135
+ # Get proxy if available
136
+ proxy_url = None
137
+ if self.proxy_manager:
138
+ proxy_config = await self.proxy_manager.get_proxy_config()
139
+ if proxy_config and proxy_config.enabled and proxy_config.proxy_url:
140
+ proxy_url = proxy_config.proxy_url
141
+
142
+ # Download with proxy support
143
+ async with AsyncSession() as session:
144
+ proxies = {"http": proxy_url, "https": proxy_url} if proxy_url else None
145
+ response = await session.get(url, timeout=60, proxies=proxies)
146
+
147
+ if response.status_code != 200:
148
+ raise Exception(f"Download failed: HTTP {response.status_code}")
149
+
150
+ # Save to cache
151
+ with open(file_path, 'wb') as f:
152
+ f.write(response.content)
153
+
154
+ debug_logger.log_info(f"File cached: {filename} ({len(response.content)} bytes)")
155
+ return filename
156
+
157
+ except Exception as e:
158
+ debug_logger.log_error(
159
+ error_message=f"Failed to download file: {str(e)}",
160
+ status_code=0,
161
+ response_text=str(e)
162
+ )
163
+ raise Exception(f"Failed to cache file: {str(e)}")
164
+
165
+ def get_cache_path(self, filename: str) -> Path:
166
+ """Get full path to cached file"""
167
+ return self.cache_dir / filename
168
+
169
+ def set_timeout(self, timeout: int):
170
+ """Set cache timeout in seconds"""
171
+ self.default_timeout = timeout
172
+ debug_logger.log_info(f"Cache timeout updated to {timeout} seconds")
173
+
174
+ def get_timeout(self) -> int:
175
+ """Get current cache timeout"""
176
+ return self.default_timeout
177
+
178
+ async def clear_all(self):
179
+ """Clear all cached files"""
180
+ try:
181
+ removed_count = 0
182
+ for file_path in self.cache_dir.iterdir():
183
+ if file_path.is_file():
184
+ try:
185
+ file_path.unlink()
186
+ removed_count += 1
187
+ except Exception:
188
+ pass
189
+
190
+ debug_logger.log_info(f"Cache cleared: removed {removed_count} files")
191
+ return removed_count
192
+
193
+ except Exception as e:
194
+ debug_logger.log_error(
195
+ error_message=f"Failed to clear cache: {str(e)}",
196
+ status_code=0,
197
+ response_text=""
198
+ )
199
+ raise
src/services/flow_client.py ADDED
@@ -0,0 +1,657 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Flow API Client for VideoFX (Veo)"""
2
+ import time
3
+ import uuid
4
+ import random
5
+ import base64
6
+ from typing import Dict, Any, Optional, List
7
+ from curl_cffi.requests import AsyncSession
8
+ from ..core.logger import debug_logger
9
+ from ..core.config import config
10
+
11
+
12
+ class FlowClient:
13
+ """VideoFX API客户端"""
14
+
15
+ def __init__(self, proxy_manager):
16
+ self.proxy_manager = proxy_manager
17
+ self.labs_base_url = config.flow_labs_base_url # https://labs.google/fx/api
18
+ self.api_base_url = config.flow_api_base_url # https://aisandbox-pa.googleapis.com/v1
19
+ self.timeout = config.flow_timeout
20
+
21
+ async def _make_request(
22
+ self,
23
+ method: str,
24
+ url: str,
25
+ headers: Optional[Dict] = None,
26
+ json_data: Optional[Dict] = None,
27
+ use_st: bool = False,
28
+ st_token: Optional[str] = None,
29
+ use_at: bool = False,
30
+ at_token: Optional[str] = None
31
+ ) -> Dict[str, Any]:
32
+ """统一HTTP请求处理
33
+
34
+ Args:
35
+ method: HTTP方法 (GET/POST)
36
+ url: 完整URL
37
+ headers: 请求头
38
+ json_data: JSON请求体
39
+ use_st: 是否使用ST认证 (Cookie方式)
40
+ st_token: Session Token
41
+ use_at: 是否使用AT认证 (Bearer方式)
42
+ at_token: Access Token
43
+ """
44
+ proxy_url = await self.proxy_manager.get_proxy_url()
45
+
46
+ if headers is None:
47
+ headers = {}
48
+
49
+ # ST认证 - 使用Cookie
50
+ if use_st and st_token:
51
+ headers["Cookie"] = f"__Secure-next-auth.session-token={st_token}"
52
+
53
+ # AT认证 - 使用Bearer
54
+ if use_at and at_token:
55
+ headers["authorization"] = f"Bearer {at_token}"
56
+
57
+ # 通用请求头
58
+ headers.update({
59
+ "Content-Type": "application/json",
60
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
61
+ })
62
+
63
+ # Log request
64
+ if config.debug_enabled:
65
+ debug_logger.log_request(
66
+ method=method,
67
+ url=url,
68
+ headers=headers,
69
+ body=json_data,
70
+ proxy=proxy_url
71
+ )
72
+
73
+ start_time = time.time()
74
+
75
+ try:
76
+ async with AsyncSession() as session:
77
+ if method.upper() == "GET":
78
+ response = await session.get(
79
+ url,
80
+ headers=headers,
81
+ proxy=proxy_url,
82
+ timeout=self.timeout,
83
+ impersonate="chrome110"
84
+ )
85
+ else: # POST
86
+ response = await session.post(
87
+ url,
88
+ headers=headers,
89
+ json=json_data,
90
+ proxy=proxy_url,
91
+ timeout=self.timeout,
92
+ impersonate="chrome110"
93
+ )
94
+
95
+ duration_ms = (time.time() - start_time) * 1000
96
+
97
+ # Log response
98
+ if config.debug_enabled:
99
+ debug_logger.log_response(
100
+ status_code=response.status_code,
101
+ headers=dict(response.headers),
102
+ body=response.text,
103
+ duration_ms=duration_ms
104
+ )
105
+
106
+ response.raise_for_status()
107
+ return response.json()
108
+
109
+ except Exception as e:
110
+ duration_ms = (time.time() - start_time) * 1000
111
+ error_msg = str(e)
112
+
113
+ if config.debug_enabled:
114
+ debug_logger.log_error(
115
+ error_message=error_msg,
116
+ status_code=getattr(e, 'status_code', None),
117
+ response_text=getattr(e, 'response_text', None)
118
+ )
119
+
120
+ raise Exception(f"Flow API request failed: {error_msg}")
121
+
122
+ # ========== 认证相关 (使用ST) ==========
123
+
124
+ async def st_to_at(self, st: str) -> dict:
125
+ """ST转AT
126
+
127
+ Args:
128
+ st: Session Token
129
+
130
+ Returns:
131
+ {
132
+ "access_token": "AT",
133
+ "expires": "2025-11-15T04:46:04.000Z",
134
+ "user": {...}
135
+ }
136
+ """
137
+ url = f"{self.labs_base_url}/auth/session"
138
+ result = await self._make_request(
139
+ method="GET",
140
+ url=url,
141
+ use_st=True,
142
+ st_token=st
143
+ )
144
+ return result
145
+
146
+ # ========== 项目管理 (使用ST) ==========
147
+
148
+ async def create_project(self, st: str, title: str) -> str:
149
+ """创建项目,返回project_id
150
+
151
+ Args:
152
+ st: Session Token
153
+ title: 项目标题
154
+
155
+ Returns:
156
+ project_id (UUID)
157
+ """
158
+ url = f"{self.labs_base_url}/trpc/project.createProject"
159
+ json_data = {
160
+ "json": {
161
+ "projectTitle": title,
162
+ "toolName": "PINHOLE"
163
+ }
164
+ }
165
+
166
+ result = await self._make_request(
167
+ method="POST",
168
+ url=url,
169
+ json_data=json_data,
170
+ use_st=True,
171
+ st_token=st
172
+ )
173
+
174
+ # 解析返回的project_id
175
+ project_id = result["result"]["data"]["json"]["result"]["projectId"]
176
+ return project_id
177
+
178
+ async def delete_project(self, st: str, project_id: str):
179
+ """删除项目
180
+
181
+ Args:
182
+ st: Session Token
183
+ project_id: 项目ID
184
+ """
185
+ url = f"{self.labs_base_url}/trpc/project.deleteProject"
186
+ json_data = {
187
+ "json": {
188
+ "projectToDeleteId": project_id
189
+ }
190
+ }
191
+
192
+ await self._make_request(
193
+ method="POST",
194
+ url=url,
195
+ json_data=json_data,
196
+ use_st=True,
197
+ st_token=st
198
+ )
199
+
200
+ # ========== 余额查询 (使用AT) ==========
201
+
202
+ async def get_credits(self, at: str) -> dict:
203
+ """查询余额
204
+
205
+ Args:
206
+ at: Access Token
207
+
208
+ Returns:
209
+ {
210
+ "credits": 920,
211
+ "userPaygateTier": "PAYGATE_TIER_ONE"
212
+ }
213
+ """
214
+ url = f"{self.api_base_url}/credits"
215
+ result = await self._make_request(
216
+ method="GET",
217
+ url=url,
218
+ use_at=True,
219
+ at_token=at
220
+ )
221
+ return result
222
+
223
+ # ========== 图片上传 (使用AT) ==========
224
+
225
+ async def upload_image(
226
+ self,
227
+ at: str,
228
+ image_bytes: bytes,
229
+ aspect_ratio: str = "IMAGE_ASPECT_RATIO_LANDSCAPE"
230
+ ) -> str:
231
+ """上传图片,返回mediaGenerationId
232
+
233
+ Args:
234
+ at: Access Token
235
+ image_bytes: 图片字节数据
236
+ aspect_ratio: 图片或视频宽高比(会自动转换为图片格式)
237
+
238
+ Returns:
239
+ mediaGenerationId (CAM...)
240
+ """
241
+ # 转换视频aspect_ratio为图片aspect_ratio
242
+ # VIDEO_ASPECT_RATIO_LANDSCAPE -> IMAGE_ASPECT_RATIO_LANDSCAPE
243
+ # VIDEO_ASPECT_RATIO_PORTRAIT -> IMAGE_ASPECT_RATIO_PORTRAIT
244
+ if aspect_ratio.startswith("VIDEO_"):
245
+ aspect_ratio = aspect_ratio.replace("VIDEO_", "IMAGE_")
246
+
247
+ # 编码为base64 (去掉前缀)
248
+ image_base64 = base64.b64encode(image_bytes).decode('utf-8')
249
+
250
+ url = f"{self.api_base_url}:uploadUserImage"
251
+ json_data = {
252
+ "imageInput": {
253
+ "rawImageBytes": image_base64,
254
+ "mimeType": "image/jpeg",
255
+ "isUserUploaded": True,
256
+ "aspectRatio": aspect_ratio
257
+ },
258
+ "clientContext": {
259
+ "sessionId": self._generate_session_id(),
260
+ "tool": "ASSET_MANAGER"
261
+ }
262
+ }
263
+
264
+ result = await self._make_request(
265
+ method="POST",
266
+ url=url,
267
+ json_data=json_data,
268
+ use_at=True,
269
+ at_token=at
270
+ )
271
+
272
+ # 返回mediaGenerationId
273
+ media_id = result["mediaGenerationId"]["mediaGenerationId"]
274
+ return media_id
275
+
276
+ # ========== 图片生成 (使用AT) - 同步返回 ==========
277
+
278
+ async def generate_image(
279
+ self,
280
+ at: str,
281
+ project_id: str,
282
+ prompt: str,
283
+ model_name: str,
284
+ aspect_ratio: str,
285
+ image_inputs: Optional[List[Dict]] = None
286
+ ) -> dict:
287
+ """生成图片(同步返回)
288
+
289
+ Args:
290
+ at: Access Token
291
+ project_id: 项目ID
292
+ prompt: 提示词
293
+ model_name: GEM_PIX, GEM_PIX_2 或 IMAGEN_3_5
294
+ aspect_ratio: 图片宽高比
295
+ image_inputs: 参考图片列表(图生图时使用)
296
+
297
+ Returns:
298
+ {
299
+ "media": [{
300
+ "image": {
301
+ "generatedImage": {
302
+ "fifeUrl": "图片URL",
303
+ ...
304
+ }
305
+ }
306
+ }]
307
+ }
308
+ """
309
+ url = f"{self.api_base_url}/projects/{project_id}/flowMedia:batchGenerateImages"
310
+
311
+ # 构建请求
312
+ request_data = {
313
+ "clientContext": {
314
+ "sessionId": self._generate_session_id()
315
+ },
316
+ "seed": random.randint(1, 99999),
317
+ "imageModelName": model_name,
318
+ "imageAspectRatio": aspect_ratio,
319
+ "prompt": prompt,
320
+ "imageInputs": image_inputs or []
321
+ }
322
+
323
+ json_data = {
324
+ "requests": [request_data]
325
+ }
326
+
327
+ result = await self._make_request(
328
+ method="POST",
329
+ url=url,
330
+ json_data=json_data,
331
+ use_at=True,
332
+ at_token=at
333
+ )
334
+
335
+ return result
336
+
337
+ # ========== 视频生成 (使用AT) - 异步返回 ==========
338
+
339
+ async def generate_video_text(
340
+ self,
341
+ at: str,
342
+ project_id: str,
343
+ prompt: str,
344
+ model_key: str,
345
+ aspect_ratio: str,
346
+ user_paygate_tier: str = "PAYGATE_TIER_ONE"
347
+ ) -> dict:
348
+ """文生视频,返回task_id
349
+
350
+ Args:
351
+ at: Access Token
352
+ project_id: 项目ID
353
+ prompt: 提示词
354
+ model_key: veo_3_1_t2v_fast 等
355
+ aspect_ratio: 视频宽高比
356
+ user_paygate_tier: 用户等级
357
+
358
+ Returns:
359
+ {
360
+ "operations": [{
361
+ "operation": {"name": "task_id"},
362
+ "sceneId": "uuid",
363
+ "status": "MEDIA_GENERATION_STATUS_PENDING"
364
+ }],
365
+ "remainingCredits": 900
366
+ }
367
+ """
368
+ url = f"{self.api_base_url}/video:batchAsyncGenerateVideoText"
369
+
370
+ scene_id = str(uuid.uuid4())
371
+
372
+ json_data = {
373
+ "clientContext": {
374
+ "sessionId": self._generate_session_id(),
375
+ "projectId": project_id,
376
+ "tool": "PINHOLE",
377
+ "userPaygateTier": user_paygate_tier
378
+ },
379
+ "requests": [{
380
+ "aspectRatio": aspect_ratio,
381
+ "seed": random.randint(1, 99999),
382
+ "textInput": {
383
+ "prompt": prompt
384
+ },
385
+ "videoModelKey": model_key,
386
+ "metadata": {
387
+ "sceneId": scene_id
388
+ }
389
+ }]
390
+ }
391
+
392
+ result = await self._make_request(
393
+ method="POST",
394
+ url=url,
395
+ json_data=json_data,
396
+ use_at=True,
397
+ at_token=at
398
+ )
399
+
400
+ return result
401
+
402
+ async def generate_video_reference_images(
403
+ self,
404
+ at: str,
405
+ project_id: str,
406
+ prompt: str,
407
+ model_key: str,
408
+ aspect_ratio: str,
409
+ reference_images: List[Dict],
410
+ user_paygate_tier: str = "PAYGATE_TIER_ONE"
411
+ ) -> dict:
412
+ """图生视频,返回task_id
413
+
414
+ Args:
415
+ at: Access Token
416
+ project_id: 项目ID
417
+ prompt: 提示词
418
+ model_key: veo_3_0_r2v_fast
419
+ aspect_ratio: 视频宽高比
420
+ reference_images: 参考图片列表 [{"imageUsageType": "IMAGE_USAGE_TYPE_ASSET", "mediaId": "..."}]
421
+ user_paygate_tier: 用户等级
422
+
423
+ Returns:
424
+ 同 generate_video_text
425
+ """
426
+ url = f"{self.api_base_url}/video:batchAsyncGenerateVideoReferenceImages"
427
+
428
+ scene_id = str(uuid.uuid4())
429
+
430
+ json_data = {
431
+ "clientContext": {
432
+ "sessionId": self._generate_session_id(),
433
+ "projectId": project_id,
434
+ "tool": "PINHOLE",
435
+ "userPaygateTier": user_paygate_tier
436
+ },
437
+ "requests": [{
438
+ "aspectRatio": aspect_ratio,
439
+ "seed": random.randint(1, 99999),
440
+ "textInput": {
441
+ "prompt": prompt
442
+ },
443
+ "videoModelKey": model_key,
444
+ "referenceImages": reference_images,
445
+ "metadata": {
446
+ "sceneId": scene_id
447
+ }
448
+ }]
449
+ }
450
+
451
+ result = await self._make_request(
452
+ method="POST",
453
+ url=url,
454
+ json_data=json_data,
455
+ use_at=True,
456
+ at_token=at
457
+ )
458
+
459
+ return result
460
+
461
+ async def generate_video_start_end(
462
+ self,
463
+ at: str,
464
+ project_id: str,
465
+ prompt: str,
466
+ model_key: str,
467
+ aspect_ratio: str,
468
+ start_media_id: str,
469
+ end_media_id: str,
470
+ user_paygate_tier: str = "PAYGATE_TIER_ONE"
471
+ ) -> dict:
472
+ """收尾帧生成视频,返回task_id
473
+
474
+ Args:
475
+ at: Access Token
476
+ project_id: 项目ID
477
+ prompt: 提示词
478
+ model_key: veo_3_1_i2v_s_fast_fl
479
+ aspect_ratio: 视频宽高比
480
+ start_media_id: 起始帧mediaId
481
+ end_media_id: 结束帧mediaId
482
+ user_paygate_tier: 用户等级
483
+
484
+ Returns:
485
+ 同 generate_video_text
486
+ """
487
+ url = f"{self.api_base_url}/video:batchAsyncGenerateVideoStartAndEndImage"
488
+
489
+ scene_id = str(uuid.uuid4())
490
+
491
+ json_data = {
492
+ "clientContext": {
493
+ "sessionId": self._generate_session_id(),
494
+ "projectId": project_id,
495
+ "tool": "PINHOLE",
496
+ "userPaygateTier": user_paygate_tier
497
+ },
498
+ "requests": [{
499
+ "aspectRatio": aspect_ratio,
500
+ "seed": random.randint(1, 99999),
501
+ "textInput": {
502
+ "prompt": prompt
503
+ },
504
+ "videoModelKey": model_key,
505
+ "startImage": {
506
+ "mediaId": start_media_id
507
+ },
508
+ "endImage": {
509
+ "mediaId": end_media_id
510
+ },
511
+ "metadata": {
512
+ "sceneId": scene_id
513
+ }
514
+ }]
515
+ }
516
+
517
+ result = await self._make_request(
518
+ method="POST",
519
+ url=url,
520
+ json_data=json_data,
521
+ use_at=True,
522
+ at_token=at
523
+ )
524
+
525
+ return result
526
+
527
+ async def generate_video_start_image(
528
+ self,
529
+ at: str,
530
+ project_id: str,
531
+ prompt: str,
532
+ model_key: str,
533
+ aspect_ratio: str,
534
+ start_media_id: str,
535
+ user_paygate_tier: str = "PAYGATE_TIER_ONE"
536
+ ) -> dict:
537
+ """仅首帧生成视频,返回task_id
538
+
539
+ Args:
540
+ at: Access Token
541
+ project_id: 项目ID
542
+ prompt: 提示词
543
+ model_key: veo_3_1_i2v_s_fast_fl等
544
+ aspect_ratio: 视频宽高比
545
+ start_media_id: 起始帧mediaId
546
+ user_paygate_tier: 用户等级
547
+
548
+ Returns:
549
+ 同 generate_video_text
550
+ """
551
+ url = f"{self.api_base_url}/video:batchAsyncGenerateVideoStartAndEndImage"
552
+
553
+ scene_id = str(uuid.uuid4())
554
+
555
+ json_data = {
556
+ "clientContext": {
557
+ "sessionId": self._generate_session_id(),
558
+ "projectId": project_id,
559
+ "tool": "PINHOLE",
560
+ "userPaygateTier": user_paygate_tier
561
+ },
562
+ "requests": [{
563
+ "aspectRatio": aspect_ratio,
564
+ "seed": random.randint(1, 99999),
565
+ "textInput": {
566
+ "prompt": prompt
567
+ },
568
+ "videoModelKey": model_key,
569
+ "startImage": {
570
+ "mediaId": start_media_id
571
+ },
572
+ # 注意: 没有endImage字段,只用首帧
573
+ "metadata": {
574
+ "sceneId": scene_id
575
+ }
576
+ }]
577
+ }
578
+
579
+ result = await self._make_request(
580
+ method="POST",
581
+ url=url,
582
+ json_data=json_data,
583
+ use_at=True,
584
+ at_token=at
585
+ )
586
+
587
+ return result
588
+
589
+ # ========== 任务轮询 (使用AT) ==========
590
+
591
+ async def check_video_status(self, at: str, operations: List[Dict]) -> dict:
592
+ """查询视频生成状态
593
+
594
+ Args:
595
+ at: Access Token
596
+ operations: 操作列表 [{"operation": {"name": "task_id"}, "sceneId": "...", "status": "..."}]
597
+
598
+ Returns:
599
+ {
600
+ "operations": [{
601
+ "operation": {
602
+ "name": "task_id",
603
+ "metadata": {...} # 完成时包含视频信息
604
+ },
605
+ "status": "MEDIA_GENERATION_STATUS_SUCCESSFUL"
606
+ }]
607
+ }
608
+ """
609
+ url = f"{self.api_base_url}/video:batchCheckAsyncVideoGenerationStatus"
610
+
611
+ json_data = {
612
+ "operations": operations
613
+ }
614
+
615
+ result = await self._make_request(
616
+ method="POST",
617
+ url=url,
618
+ json_data=json_data,
619
+ use_at=True,
620
+ at_token=at
621
+ )
622
+
623
+ return result
624
+
625
+ # ========== 媒体删除 (使用ST) ==========
626
+
627
+ async def delete_media(self, st: str, media_names: List[str]):
628
+ """删除媒体
629
+
630
+ Args:
631
+ st: Session Token
632
+ media_names: 媒体ID列表
633
+ """
634
+ url = f"{self.labs_base_url}/trpc/media.deleteMedia"
635
+ json_data = {
636
+ "json": {
637
+ "names": media_names
638
+ }
639
+ }
640
+
641
+ await self._make_request(
642
+ method="POST",
643
+ url=url,
644
+ json_data=json_data,
645
+ use_st=True,
646
+ st_token=st
647
+ )
648
+
649
+ # ========== 辅助方法 ==========
650
+
651
+ def _generate_session_id(self) -> str:
652
+ """生成sessionId: ;timestamp"""
653
+ return f";{int(time.time() * 1000)}"
654
+
655
+ def _generate_scene_id(self) -> str:
656
+ """生成sceneId: UUID"""
657
+ return str(uuid.uuid4())
src/services/generation_handler.py ADDED
@@ -0,0 +1,864 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generation handler for Flow2API"""
2
+ import asyncio
3
+ import base64
4
+ import json
5
+ import time
6
+ from typing import Optional, AsyncGenerator, List, Dict, Any
7
+ from ..core.logger import debug_logger
8
+ from ..core.config import config
9
+ from ..core.models import Task, RequestLog
10
+ from .file_cache import FileCache
11
+
12
+
13
+ # Model configuration
14
+ MODEL_CONFIG = {
15
+ # 图片生成 - GEM_PIX (Gemini 2.5 Flash)
16
+ "gemini-2.5-flash-image-landscape": {
17
+ "type": "image",
18
+ "model_name": "GEM_PIX",
19
+ "aspect_ratio": "IMAGE_ASPECT_RATIO_LANDSCAPE"
20
+ },
21
+ "gemini-2.5-flash-image-portrait": {
22
+ "type": "image",
23
+ "model_name": "GEM_PIX",
24
+ "aspect_ratio": "IMAGE_ASPECT_RATIO_PORTRAIT"
25
+ },
26
+
27
+ # 图片生成 - GEM_PIX_2 (Gemini 3.0 Pro)
28
+ "gemini-3.0-pro-image-landscape": {
29
+ "type": "image",
30
+ "model_name": "GEM_PIX_2",
31
+ "aspect_ratio": "IMAGE_ASPECT_RATIO_LANDSCAPE"
32
+ },
33
+ "gemini-3.0-pro-image-portrait": {
34
+ "type": "image",
35
+ "model_name": "GEM_PIX_2",
36
+ "aspect_ratio": "IMAGE_ASPECT_RATIO_PORTRAIT"
37
+ },
38
+
39
+ # 图片生成 - IMAGEN_3_5 (Imagen 4.0)
40
+ "imagen-4.0-generate-preview-landscape": {
41
+ "type": "image",
42
+ "model_name": "IMAGEN_3_5",
43
+ "aspect_ratio": "IMAGE_ASPECT_RATIO_LANDSCAPE"
44
+ },
45
+ "imagen-4.0-generate-preview-portrait": {
46
+ "type": "image",
47
+ "model_name": "IMAGEN_3_5",
48
+ "aspect_ratio": "IMAGE_ASPECT_RATIO_PORTRAIT"
49
+ },
50
+
51
+ # ========== 文生视频 (T2V - Text to Video) ==========
52
+ # 不支持上传图片,只使用文本提示词生成
53
+
54
+ # veo_3_1_t2v_fast_portrait (竖屏)
55
+ # 上游模型名: veo_3_1_t2v_fast_portrait
56
+ "veo_3_1_t2v_fast_portrait": {
57
+ "type": "video",
58
+ "video_type": "t2v",
59
+ "model_key": "veo_3_1_t2v_fast_portrait",
60
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
61
+ "supports_images": False
62
+ },
63
+ # veo_3_1_t2v_fast_landscape (横屏)
64
+ # 上游模型名: veo_3_1_t2v_fast
65
+ "veo_3_1_t2v_fast_landscape": {
66
+ "type": "video",
67
+ "video_type": "t2v",
68
+ "model_key": "veo_3_1_t2v_fast",
69
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
70
+ "supports_images": False
71
+ },
72
+
73
+ # veo_2_1_fast_d_15_t2v (需要新增横竖屏)
74
+ "veo_2_1_fast_d_15_t2v_portrait": {
75
+ "type": "video",
76
+ "video_type": "t2v",
77
+ "model_key": "veo_2_1_fast_d_15_t2v",
78
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
79
+ "supports_images": False
80
+ },
81
+ "veo_2_1_fast_d_15_t2v_landscape": {
82
+ "type": "video",
83
+ "video_type": "t2v",
84
+ "model_key": "veo_2_1_fast_d_15_t2v",
85
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
86
+ "supports_images": False
87
+ },
88
+
89
+ # veo_2_0_t2v (需要新增横竖屏)
90
+ "veo_2_0_t2v_portrait": {
91
+ "type": "video",
92
+ "video_type": "t2v",
93
+ "model_key": "veo_2_0_t2v",
94
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
95
+ "supports_images": False
96
+ },
97
+ "veo_2_0_t2v_landscape": {
98
+ "type": "video",
99
+ "video_type": "t2v",
100
+ "model_key": "veo_2_0_t2v",
101
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
102
+ "supports_images": False
103
+ },
104
+
105
+ # ========== 首尾帧模型 (I2V - Image to Video) ==========
106
+ # 支持1-2张图片:1张作为首帧,2张作为首尾帧
107
+
108
+ # veo_3_1_i2v_s_fast_fl (需要新增横竖屏)
109
+ "veo_3_1_i2v_s_fast_fl_portrait": {
110
+ "type": "video",
111
+ "video_type": "i2v",
112
+ "model_key": "veo_3_1_i2v_s_fast_fl",
113
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
114
+ "supports_images": True,
115
+ "min_images": 1,
116
+ "max_images": 2
117
+ },
118
+ "veo_3_1_i2v_s_fast_fl_landscape": {
119
+ "type": "video",
120
+ "video_type": "i2v",
121
+ "model_key": "veo_3_1_i2v_s_fast_fl",
122
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
123
+ "supports_images": True,
124
+ "min_images": 1,
125
+ "max_images": 2
126
+ },
127
+
128
+ # veo_2_1_fast_d_15_i2v (需要新增横竖屏)
129
+ "veo_2_1_fast_d_15_i2v_portrait": {
130
+ "type": "video",
131
+ "video_type": "i2v",
132
+ "model_key": "veo_2_1_fast_d_15_i2v",
133
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
134
+ "supports_images": True,
135
+ "min_images": 1,
136
+ "max_images": 2
137
+ },
138
+ "veo_2_1_fast_d_15_i2v_landscape": {
139
+ "type": "video",
140
+ "video_type": "i2v",
141
+ "model_key": "veo_2_1_fast_d_15_i2v",
142
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
143
+ "supports_images": True,
144
+ "min_images": 1,
145
+ "max_images": 2
146
+ },
147
+
148
+ # veo_2_0_i2v (需要新增横竖屏)
149
+ "veo_2_0_i2v_portrait": {
150
+ "type": "video",
151
+ "video_type": "i2v",
152
+ "model_key": "veo_2_0_i2v",
153
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
154
+ "supports_images": True,
155
+ "min_images": 1,
156
+ "max_images": 2
157
+ },
158
+ "veo_2_0_i2v_landscape": {
159
+ "type": "video",
160
+ "video_type": "i2v",
161
+ "model_key": "veo_2_0_i2v",
162
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
163
+ "supports_images": True,
164
+ "min_images": 1,
165
+ "max_images": 2
166
+ },
167
+
168
+ # ========== 多图生成 (R2V - Reference Images to Video) ==========
169
+ # 支持多张图片,不限制数量
170
+
171
+ # veo_3_0_r2v_fast (需要新增横竖屏)
172
+ "veo_3_0_r2v_fast_portrait": {
173
+ "type": "video",
174
+ "video_type": "r2v",
175
+ "model_key": "veo_3_0_r2v_fast",
176
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_PORTRAIT",
177
+ "supports_images": True,
178
+ "min_images": 0,
179
+ "max_images": None # 不限制
180
+ },
181
+ "veo_3_0_r2v_fast_landscape": {
182
+ "type": "video",
183
+ "video_type": "r2v",
184
+ "model_key": "veo_3_0_r2v_fast",
185
+ "aspect_ratio": "VIDEO_ASPECT_RATIO_LANDSCAPE",
186
+ "supports_images": True,
187
+ "min_images": 0,
188
+ "max_images": None # 不限制
189
+ }
190
+ }
191
+
192
+
193
+ class GenerationHandler:
194
+ """统一生成处理器"""
195
+
196
+ def __init__(self, flow_client, token_manager, load_balancer, db, concurrency_manager, proxy_manager):
197
+ self.flow_client = flow_client
198
+ self.token_manager = token_manager
199
+ self.load_balancer = load_balancer
200
+ self.db = db
201
+ self.concurrency_manager = concurrency_manager
202
+ self.file_cache = FileCache(
203
+ cache_dir="tmp",
204
+ default_timeout=config.cache_timeout,
205
+ proxy_manager=proxy_manager
206
+ )
207
+
208
+ async def check_token_availability(self, is_image: bool, is_video: bool) -> bool:
209
+ """检查Token可用性
210
+
211
+ Args:
212
+ is_image: 是否检查图片生成Token
213
+ is_video: 是否检查视频生成Token
214
+
215
+ Returns:
216
+ True表示有可用Token, False表示无可用Token
217
+ """
218
+ token_obj = await self.load_balancer.select_token(
219
+ for_image_generation=is_image,
220
+ for_video_generation=is_video
221
+ )
222
+ return token_obj is not None
223
+
224
+ async def handle_generation(
225
+ self,
226
+ model: str,
227
+ prompt: str,
228
+ images: Optional[List[bytes]] = None,
229
+ stream: bool = False
230
+ ) -> AsyncGenerator:
231
+ """统一生成入口
232
+
233
+ Args:
234
+ model: 模型名称
235
+ prompt: 提示词
236
+ images: 图片列表 (bytes格式)
237
+ stream: 是否流式输出
238
+ """
239
+ start_time = time.time()
240
+ token = None
241
+
242
+ # 1. 验证模型
243
+ if model not in MODEL_CONFIG:
244
+ error_msg = f"不支持的模型: {model}"
245
+ debug_logger.log_error(error_msg)
246
+ yield self._create_error_response(error_msg)
247
+ return
248
+
249
+ model_config = MODEL_CONFIG[model]
250
+ generation_type = model_config["type"]
251
+ debug_logger.log_info(f"[GENERATION] 开始生成 - 模型: {model}, 类型: {generation_type}, Prompt: {prompt[:50]}...")
252
+
253
+ # 非流式模式: 只检查可用性
254
+ if not stream:
255
+ is_image = (generation_type == "image")
256
+ is_video = (generation_type == "video")
257
+ available = await self.check_token_availability(is_image, is_video)
258
+
259
+ if available:
260
+ if is_image:
261
+ message = "所有Token可用于图片生成。请启用流式模式使用生成功能。"
262
+ else:
263
+ message = "所有Token可用于视频生成。请启用流式模式使用生成功能。"
264
+ else:
265
+ if is_image:
266
+ message = "没有可用的Token进行图片生成"
267
+ else:
268
+ message = "没有可用的Token进行视频生成"
269
+
270
+ yield self._create_completion_response(message, is_availability_check=True)
271
+ return
272
+
273
+ # 向用户展示开始信息
274
+ if stream:
275
+ yield self._create_stream_chunk(
276
+ f"✨ {'视频' if generation_type == 'video' else '图片'}生成任务已启动\n",
277
+ role="assistant"
278
+ )
279
+
280
+ # 2. 选择Token
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)
290
+ debug_logger.log_error(f"[GENERATION] {error_msg}")
291
+ if stream:
292
+ yield self._create_stream_chunk(f"❌ {error_msg}\n")
293
+ yield self._create_error_response(error_msg)
294
+ return
295
+
296
+ debug_logger.log_info(f"[GENERATION] 已选择Token: {token.id} ({token.email})")
297
+
298
+ try:
299
+ # 3. 确保AT有效
300
+ debug_logger.log_info(f"[GENERATION] 检查Token AT有效性...")
301
+ if stream:
302
+ yield self._create_stream_chunk("初始化生成环境...\n")
303
+
304
+ if not await self.token_manager.is_at_valid(token.id):
305
+ error_msg = "Token AT无效或刷新失败"
306
+ debug_logger.log_error(f"[GENERATION] {error_msg}")
307
+ if stream:
308
+ yield self._create_stream_chunk(f"❌ {error_msg}\n")
309
+ yield self._create_error_response(error_msg)
310
+ return
311
+
312
+ # 重新获取token (AT可能已刷新)
313
+ token = await self.token_manager.get_token(token.id)
314
+
315
+ # 4. 确保Project存在
316
+ debug_logger.log_info(f"[GENERATION] 检查/创建Project...")
317
+
318
+ project_id = await self.token_manager.ensure_project_exists(token.id)
319
+ debug_logger.log_info(f"[GENERATION] Project ID: {project_id}")
320
+
321
+ # 5. 根据类型处理
322
+ if generation_type == "image":
323
+ debug_logger.log_info(f"[GENERATION] 开始图片生成流程...")
324
+ async for chunk in self._handle_image_generation(
325
+ token, project_id, model_config, prompt, images, stream
326
+ ):
327
+ yield chunk
328
+ else: # video
329
+ debug_logger.log_info(f"[GENERATION] 开始视频生成流程...")
330
+ async for chunk in self._handle_video_generation(
331
+ token, project_id, model_config, prompt, images, stream
332
+ ):
333
+ yield chunk
334
+
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. 记录成功日志
341
+ duration = time.time() - start_time
342
+ await self._log_request(
343
+ token.id,
344
+ f"generate_{generation_type}",
345
+ {"model": model, "prompt": prompt[:100], "has_images": images is not None and len(images) > 0},
346
+ {"status": "success"},
347
+ 200,
348
+ duration
349
+ )
350
+
351
+ except Exception as e:
352
+ error_msg = f"生成失败: {str(e)}"
353
+ debug_logger.log_error(f"[GENERATION] ❌ {error_msg}")
354
+ if stream:
355
+ yield self._create_stream_chunk(f"❌ {error_msg}\n")
356
+ if token:
357
+ await self.token_manager.record_error(token.id)
358
+ yield self._create_error_response(error_msg)
359
+
360
+ # 记录失败日志
361
+ duration = time.time() - start_time
362
+ await self._log_request(
363
+ token.id if token else None,
364
+ f"generate_{generation_type if model_config else 'unknown'}",
365
+ {"model": model, "prompt": prompt[:100], "has_images": images is not None and len(images) > 0},
366
+ {"error": error_msg},
367
+ 500,
368
+ duration
369
+ )
370
+
371
+ def _get_no_token_error_message(self, generation_type: str) -> str:
372
+ """获取无可用Token时的详细错误信息"""
373
+ if generation_type == "image":
374
+ return "没有可用的Token进行图片生成。所有Token都处于禁用、冷却、锁定或已过期状态。"
375
+ else:
376
+ return "没有可用的Token进行视频生成。所有Token都处于禁用、冷却、配额耗尽或已过期状态。"
377
+
378
+ async def _handle_image_generation(
379
+ self,
380
+ token,
381
+ project_id: str,
382
+ model_config: dict,
383
+ prompt: str,
384
+ images: Optional[List[bytes]],
385
+ stream: bool
386
+ ) -> AsyncGenerator:
387
+ """处理图片生成 (同步返回)"""
388
+
389
+ # 获取并发槽位
390
+ if self.concurrency_manager:
391
+ if not await self.concurrency_manager.acquire_image(token.id):
392
+ yield self._create_error_response("图片并发限制已达上限")
393
+ return
394
+
395
+ try:
396
+ # 上传图片 (如果有)
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:
416
+ yield self._create_stream_chunk("正在生成图片...\n")
417
+
418
+ result = await self.flow_client.generate_image(
419
+ at=token.at,
420
+ project_id=project_id,
421
+ prompt=prompt,
422
+ model_name=model_config["model_name"],
423
+ aspect_ratio=model_config["aspect_ratio"],
424
+ image_inputs=image_inputs
425
+ )
426
+
427
+ # 提取URL
428
+ media = result.get("media", [])
429
+ if not media:
430
+ yield self._create_error_response("生成结果为空")
431
+ return
432
+
433
+ image_url = media[0]["image"]["generatedImage"]["fifeUrl"]
434
+
435
+ # 缓存图片 (如果启用)
436
+ local_url = image_url
437
+ if config.cache_enabled:
438
+ try:
439
+ if stream:
440
+ yield self._create_stream_chunk("缓存图片中...\n")
441
+ cached_filename = await self.file_cache.download_and_cache(image_url, "image")
442
+ local_url = f"{self._get_base_url()}/tmp/{cached_filename}"
443
+ if stream:
444
+ yield self._create_stream_chunk("✅ 图片缓存成功,准备返回缓存地址...\n")
445
+ except Exception as e:
446
+ debug_logger.log_error(f"Failed to cache image: {str(e)}")
447
+ # 缓存失败不影响结果返回,使用原始URL
448
+ local_url = image_url
449
+ if stream:
450
+ yield self._create_stream_chunk(f"⚠️ 缓存失败: {str(e)}\n正在返回源链接...\n")
451
+ else:
452
+ if stream:
453
+ yield self._create_stream_chunk("缓存已关闭,正在返回源链接...\n")
454
+
455
+ # 返回结果
456
+ if stream:
457
+ yield self._create_stream_chunk(
458
+ f"![Generated Image]({local_url})",
459
+ finish_reason="stop"
460
+ )
461
+ else:
462
+ yield self._create_completion_response(
463
+ local_url, # 直接传URL,让方法内部格式化
464
+ media_type="image"
465
+ )
466
+
467
+ finally:
468
+ # 释放并发槽位
469
+ if self.concurrency_manager:
470
+ await self.concurrency_manager.release_image(token.id)
471
+
472
+ async def _handle_video_generation(
473
+ self,
474
+ token,
475
+ project_id: str,
476
+ model_config: dict,
477
+ prompt: str,
478
+ images: Optional[List[bytes]],
479
+ stream: bool
480
+ ) -> AsyncGenerator:
481
+ """处理视频生成 (异步轮询)"""
482
+
483
+ # 获取并发槽位
484
+ if self.concurrency_manager:
485
+ if not await self.concurrency_manager.acquire_video(token.id):
486
+ yield self._create_error_response("视频并发限制已达上限")
487
+ return
488
+
489
+ try:
490
+ # 获取模型类型和配置
491
+ video_type = model_config.get("video_type")
492
+ supports_images = model_config.get("supports_images", False)
493
+ min_images = model_config.get("min_images", 0)
494
+ max_images = model_config.get("max_images", 0)
495
+
496
+ # 图片数量
497
+ image_count = len(images) if images else 0
498
+
499
+ # ========== 验证和处理图片 ==========
500
+
501
+ # T2V: 文生视频 - 不支持图片
502
+ if video_type == "t2v":
503
+ if image_count > 0:
504
+ if stream:
505
+ yield self._create_stream_chunk("⚠️ 文生视频模型不支持上传图片,将忽略图片仅使用文本提示词生成\n")
506
+ debug_logger.log_warning(f"[T2V] 模型 {model_config['model_key']} 不支持图片,已忽略 {image_count} 张图片")
507
+ images = None # 清空图片
508
+ image_count = 0
509
+
510
+ # I2V: 首尾帧模型 - 需要1-2张图片
511
+ elif video_type == "i2v":
512
+ if image_count < min_images or image_count > max_images:
513
+ error_msg = f"❌ 首尾帧模型需要 {min_images}-{max_images} 张图片,当前提供了 {image_count} 张"
514
+ if stream:
515
+ yield self._create_stream_chunk(f"{error_msg}\n")
516
+ yield self._create_error_response(error_msg)
517
+ return
518
+
519
+ # R2V: 多图生成 - 支持多张图片,不限制数量
520
+ elif video_type == "r2v":
521
+ # 不再限制最大图片数量
522
+ pass
523
+
524
+ # ========== 上传图片 ==========
525
+ start_media_id = None
526
+ end_media_id = None
527
+ reference_images = []
528
+
529
+ # I2V: 首尾帧处理
530
+ if video_type == "i2v" and images:
531
+ if image_count == 1:
532
+ # 只有1张图: 仅作为首帧
533
+ if stream:
534
+ yield self._create_stream_chunk("上传首帧图片...\n")
535
+ start_media_id = await self.flow_client.upload_image(
536
+ token.at, images[0], model_config["aspect_ratio"]
537
+ )
538
+ debug_logger.log_info(f"[I2V] 仅上传首帧: {start_media_id}")
539
+
540
+ elif image_count == 2:
541
+ # 2张图: 首帧+尾帧
542
+ if stream:
543
+ yield self._create_stream_chunk("上传首帧和尾帧图片...\n")
544
+ start_media_id = await self.flow_client.upload_image(
545
+ token.at, images[0], model_config["aspect_ratio"]
546
+ )
547
+ end_media_id = await self.flow_client.upload_image(
548
+ token.at, images[1], model_config["aspect_ratio"]
549
+ )
550
+ debug_logger.log_info(f"[I2V] 上传首尾帧: {start_media_id}, {end_media_id}")
551
+
552
+ # R2V: 多图处理
553
+ elif video_type == "r2v" and images:
554
+ if stream:
555
+ yield self._create_stream_chunk(f"上传 {image_count} 张参考图片...\n")
556
+
557
+ for idx, img in enumerate(images): # 上传所有图片,不限制数量
558
+ media_id = await self.flow_client.upload_image(
559
+ token.at, img, model_config["aspect_ratio"]
560
+ )
561
+ reference_images.append({
562
+ "imageUsageType": "IMAGE_USAGE_TYPE_ASSET",
563
+ "mediaId": media_id
564
+ })
565
+ debug_logger.log_info(f"[R2V] 上传了 {len(reference_images)} 张参考图片")
566
+
567
+ # ========== 调用生成API ==========
568
+ if stream:
569
+ yield self._create_stream_chunk("提交视频生成任务...\n")
570
+
571
+ # I2V: 首尾帧生成
572
+ if video_type == "i2v" and start_media_id:
573
+ if end_media_id:
574
+ # 有首尾帧
575
+ result = await self.flow_client.generate_video_start_end(
576
+ at=token.at,
577
+ project_id=project_id,
578
+ prompt=prompt,
579
+ model_key=model_config["model_key"],
580
+ aspect_ratio=model_config["aspect_ratio"],
581
+ start_media_id=start_media_id,
582
+ end_media_id=end_media_id,
583
+ user_paygate_tier=token.user_paygate_tier or "PAYGATE_TIER_ONE"
584
+ )
585
+ else:
586
+ # 只有首帧
587
+ result = await self.flow_client.generate_video_start_image(
588
+ at=token.at,
589
+ project_id=project_id,
590
+ prompt=prompt,
591
+ model_key=model_config["model_key"],
592
+ aspect_ratio=model_config["aspect_ratio"],
593
+ start_media_id=start_media_id,
594
+ user_paygate_tier=token.user_paygate_tier or "PAYGATE_TIER_ONE"
595
+ )
596
+
597
+ # R2V: 多图生成
598
+ elif video_type == "r2v" and reference_images:
599
+ result = await self.flow_client.generate_video_reference_images(
600
+ at=token.at,
601
+ project_id=project_id,
602
+ prompt=prompt,
603
+ model_key=model_config["model_key"],
604
+ aspect_ratio=model_config["aspect_ratio"],
605
+ reference_images=reference_images,
606
+ user_paygate_tier=token.user_paygate_tier or "PAYGATE_TIER_ONE"
607
+ )
608
+
609
+ # T2V 或 R2V无图: 纯文本生成
610
+ else:
611
+ result = await self.flow_client.generate_video_text(
612
+ at=token.at,
613
+ project_id=project_id,
614
+ prompt=prompt,
615
+ model_key=model_config["model_key"],
616
+ aspect_ratio=model_config["aspect_ratio"],
617
+ user_paygate_tier=token.user_paygate_tier or "PAYGATE_TIER_ONE"
618
+ )
619
+
620
+ # 获取task_id和operations
621
+ operations = result.get("operations", [])
622
+ if not operations:
623
+ yield self._create_error_response("生成任务创建失败")
624
+ return
625
+
626
+ operation = operations[0]
627
+ task_id = operation["operation"]["name"]
628
+ scene_id = operation.get("sceneId")
629
+
630
+ # 保存Task到数据库
631
+ task = Task(
632
+ task_id=task_id,
633
+ token_id=token.id,
634
+ model=model_config["model_key"],
635
+ prompt=prompt,
636
+ status="processing",
637
+ scene_id=scene_id
638
+ )
639
+ await self.db.create_task(task)
640
+
641
+ # 轮询结果
642
+ if stream:
643
+ yield self._create_stream_chunk(f"视频生成中...\n")
644
+
645
+ async for chunk in self._poll_video_result(token, operations, stream):
646
+ yield chunk
647
+
648
+ finally:
649
+ # 释放并发槽位
650
+ if self.concurrency_manager:
651
+ await self.concurrency_manager.release_video(token.id)
652
+
653
+ async def _poll_video_result(
654
+ self,
655
+ token,
656
+ operations: List[Dict],
657
+ stream: bool
658
+ ) -> AsyncGenerator:
659
+ """轮询视频生成结果"""
660
+
661
+ max_attempts = config.max_poll_attempts
662
+ poll_interval = config.poll_interval
663
+
664
+ for attempt in range(max_attempts):
665
+ await asyncio.sleep(poll_interval)
666
+
667
+ try:
668
+ result = await self.flow_client.check_video_status(token.at, operations)
669
+ checked_operations = result.get("operations", [])
670
+
671
+ if not checked_operations:
672
+ continue
673
+
674
+ operation = checked_operations[0]
675
+ status = operation.get("status")
676
+
677
+ # 状态更新 - 每20秒报告一次 (poll_interval=3秒, 20秒约7次轮询)
678
+ progress_update_interval = 7 # 每7次轮询 = 21秒
679
+ if stream and attempt % progress_update_interval == 0: # 每20秒报告一次
680
+ progress = min(int((attempt / max_attempts) * 100), 95)
681
+ yield self._create_stream_chunk(f"生成进度: {progress}%\n")
682
+
683
+ # 检查状态
684
+ if status == "MEDIA_GENERATION_STATUS_SUCCESSFUL":
685
+ # 成功
686
+ metadata = operation["operation"].get("metadata", {})
687
+ video_info = metadata.get("video", {})
688
+ video_url = video_info.get("fifeUrl")
689
+
690
+ if not video_url:
691
+ yield self._create_error_response("视频URL为空")
692
+ return
693
+
694
+ # 缓存视频 (如果启用)
695
+ local_url = video_url
696
+ if config.cache_enabled:
697
+ try:
698
+ if stream:
699
+ yield self._create_stream_chunk("正在缓存视频文件...\n")
700
+ cached_filename = await self.file_cache.download_and_cache(video_url, "video")
701
+ local_url = f"{self._get_base_url()}/tmp/{cached_filename}"
702
+ if stream:
703
+ yield self._create_stream_chunk("✅ 视频缓存成功,准备返回缓存地址...\n")
704
+ except Exception as e:
705
+ debug_logger.log_error(f"Failed to cache video: {str(e)}")
706
+ # 缓存失败不影响结果返回,使用原始URL
707
+ local_url = video_url
708
+ if stream:
709
+ yield self._create_stream_chunk(f"⚠️ 缓存失败: {str(e)}\n正在返回源链接...\n")
710
+ else:
711
+ if stream:
712
+ yield self._create_stream_chunk("缓存已关闭,正在返回源链接...\n")
713
+
714
+ # 更新数据库
715
+ task_id = operation["operation"]["name"]
716
+ await self.db.update_task(
717
+ task_id,
718
+ status="completed",
719
+ progress=100,
720
+ result_urls=[local_url],
721
+ completed_at=time.time()
722
+ )
723
+
724
+ # 返回结果
725
+ if stream:
726
+ yield self._create_stream_chunk(
727
+ f"<video src='{local_url}' controls style='max-width:100%'></video>",
728
+ finish_reason="stop"
729
+ )
730
+ else:
731
+ yield self._create_completion_response(
732
+ local_url, # 直接传URL,让方法内部格式化
733
+ media_type="video"
734
+ )
735
+ return
736
+
737
+ elif status.startswith("MEDIA_GENERATION_STATUS_ERROR"):
738
+ # 失败
739
+ yield self._create_error_response(f"视频生成失败: {status}")
740
+ return
741
+
742
+ except Exception as e:
743
+ debug_logger.log_error(f"Poll error: {str(e)}")
744
+ continue
745
+
746
+ # 超时
747
+ yield self._create_error_response(f"视频生成超时 (已轮询{max_attempts}次)")
748
+
749
+ # ========== 响应格式化 ==========
750
+
751
+ def _create_stream_chunk(self, content: str, role: str = None, finish_reason: str = None) -> str:
752
+ """创建流式响应chunk"""
753
+ import json
754
+ import time
755
+
756
+ chunk = {
757
+ "id": f"chatcmpl-{int(time.time())}",
758
+ "object": "chat.completion.chunk",
759
+ "created": int(time.time()),
760
+ "model": "flow2api",
761
+ "choices": [{
762
+ "index": 0,
763
+ "delta": {},
764
+ "finish_reason": finish_reason
765
+ }]
766
+ }
767
+
768
+ if role:
769
+ chunk["choices"][0]["delta"]["role"] = role
770
+
771
+ if finish_reason:
772
+ chunk["choices"][0]["delta"]["content"] = content
773
+ else:
774
+ chunk["choices"][0]["delta"]["reasoning_content"] = content
775
+
776
+ return f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n"
777
+
778
+ def _create_completion_response(self, content: str, media_type: str = "image", is_availability_check: bool = False) -> str:
779
+ """创建非流式响应
780
+
781
+ Args:
782
+ content: 媒体URL或纯文本消息
783
+ media_type: 媒体类型 ("image" 或 "video")
784
+ is_availability_check: 是否为可用性检查响应 (纯文本消息)
785
+
786
+ Returns:
787
+ JSON格式的响应
788
+ """
789
+ import json
790
+ import time
791
+
792
+ # 可用性检查: 返回纯文本消息
793
+ if is_availability_check:
794
+ formatted_content = content
795
+ else:
796
+ # 媒体生成: 根据媒体类型格式化内容为Markdown
797
+ if media_type == "video":
798
+ formatted_content = f"```html\n<video src='{content}' controls></video>\n```"
799
+ else: # image
800
+ formatted_content = f"![Generated Image]({content})"
801
+
802
+ response = {
803
+ "id": f"chatcmpl-{int(time.time())}",
804
+ "object": "chat.completion",
805
+ "created": int(time.time()),
806
+ "model": "flow2api",
807
+ "choices": [{
808
+ "index": 0,
809
+ "message": {
810
+ "role": "assistant",
811
+ "content": formatted_content
812
+ },
813
+ "finish_reason": "stop"
814
+ }]
815
+ }
816
+
817
+ return json.dumps(response, ensure_ascii=False)
818
+
819
+ def _create_error_response(self, error_message: str) -> str:
820
+ """创建错误响应"""
821
+ import json
822
+
823
+ error = {
824
+ "error": {
825
+ "message": error_message,
826
+ "type": "invalid_request_error",
827
+ "code": "generation_failed"
828
+ }
829
+ }
830
+
831
+ return json.dumps(error, ensure_ascii=False)
832
+
833
+ def _get_base_url(self) -> str:
834
+ """获取基础URL用于缓存文件访问"""
835
+ # 优先使用配置的cache_base_url
836
+ if config.cache_base_url:
837
+ return config.cache_base_url
838
+ # 否则使用服务器地址
839
+ return f"http://{config.server_host}:{config.server_port}"
840
+
841
+ async def _log_request(
842
+ self,
843
+ token_id: Optional[int],
844
+ operation: str,
845
+ request_data: Dict[str, Any],
846
+ response_data: Dict[str, Any],
847
+ status_code: int,
848
+ duration: float
849
+ ):
850
+ """记录请求到数据库"""
851
+ try:
852
+ log = RequestLog(
853
+ token_id=token_id,
854
+ operation=operation,
855
+ request_body=json.dumps(request_data, ensure_ascii=False),
856
+ response_body=json.dumps(response_data, ensure_ascii=False),
857
+ status_code=status_code,
858
+ duration=duration
859
+ )
860
+ await self.db.add_request_log(log)
861
+ except Exception as e:
862
+ # 日志记录失败不影响主流程
863
+ debug_logger.log_error(f"Failed to log request: {e}")
864
+
src/services/load_balancer.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Load balancing module for Flow2API"""
2
+ import random
3
+ from typing import Optional
4
+ from ..core.models import Token
5
+ from .concurrency_manager import ConcurrencyManager
6
+ from ..core.logger import debug_logger
7
+
8
+
9
+ class LoadBalancer:
10
+ """Token load balancer with random selection"""
11
+
12
+ def __init__(self, token_manager, concurrency_manager: Optional[ConcurrencyManager] = None):
13
+ self.token_manager = token_manager
14
+ self.concurrency_manager = concurrency_manager
15
+
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
23
+
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")
35
+
36
+ if not active_tokens:
37
+ debug_logger.log_info(f"[LOAD_BALANCER] ❌ 没有活跃的Token")
38
+ return None
39
+
40
+ # Filter tokens based on generation type
41
+ available_tokens = []
42
+ filtered_reasons = {} # 记录过滤原因
43
+
44
+ for token in active_tokens:
45
+ # Check if token has valid AT (not expired)
46
+ if not await self.token_manager.is_at_valid(token.id):
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:
53
+ filtered_reasons[token.id] = "图片生成已禁用"
54
+ continue
55
+
56
+ # Check concurrency limit
57
+ if self.concurrency_manager and not await self.concurrency_manager.can_use_image(token.id):
58
+ filtered_reasons[token.id] = "图片并发已满"
59
+ continue
60
+
61
+ # Filter for video generation
62
+ if for_video_generation:
63
+ if not token.video_enabled:
64
+ filtered_reasons[token.id] = "视频生成已禁用"
65
+ continue
66
+
67
+ # Check concurrency limit
68
+ if self.concurrency_manager and not await self.concurrency_manager.can_use_video(token.id):
69
+ filtered_reasons[token.id] = "视频并发已满"
70
+ continue
71
+
72
+ available_tokens.append(token)
73
+
74
+ # 输出过滤信息
75
+ if filtered_reasons:
76
+ debug_logger.log_info(f"[LOAD_BALANCER] 已过滤Token:")
77
+ for token_id, reason in filtered_reasons.items():
78
+ debug_logger.log_info(f"[LOAD_BALANCER] - Token {token_id}: {reason}")
79
+
80
+ if not available_tokens:
81
+ debug_logger.log_info(f"[LOAD_BALANCER] ❌ 没有可用的Token (图片生成={for_image_generation}, 视频生成={for_video_generation})")
82
+ return None
83
+
84
+ # Random selection
85
+ selected = random.choice(available_tokens)
86
+ debug_logger.log_info(f"[LOAD_BALANCER] ✅ 已选择Token {selected.id} ({selected.email}) - 余额: {selected.credits}")
87
+ return selected
src/services/proxy_manager.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Proxy management module"""
2
+ from typing import Optional
3
+ from ..core.database import Database
4
+ from ..core.models import ProxyConfig
5
+
6
+ class ProxyManager:
7
+ """Proxy configuration manager"""
8
+
9
+ def __init__(self, db: Database):
10
+ self.db = db
11
+
12
+ async def get_proxy_url(self) -> Optional[str]:
13
+ """Get proxy URL if enabled, otherwise return None"""
14
+ config = await self.db.get_proxy_config()
15
+ if config and config.enabled and config.proxy_url:
16
+ return config.proxy_url
17
+ return None
18
+
19
+ async def update_proxy_config(self, enabled: bool, proxy_url: Optional[str]):
20
+ """Update proxy configuration"""
21
+ await self.db.update_proxy_config(enabled, proxy_url)
22
+
23
+ async def get_proxy_config(self) -> ProxyConfig:
24
+ """Get proxy configuration"""
25
+ return await self.db.get_proxy_config()
src/services/token_manager.py ADDED
@@ -0,0 +1,409 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Token manager for Flow2API with AT auto-refresh"""
2
+ import asyncio
3
+ from datetime import datetime, timedelta, timezone
4
+ from typing import Optional, List
5
+ from ..core.database import Database
6
+ from ..core.models import Token, Project
7
+ from ..core.logger import debug_logger
8
+ from .flow_client import FlowClient
9
+ from .proxy_manager import ProxyManager
10
+
11
+
12
+ class TokenManager:
13
+ """Token lifecycle manager with AT auto-refresh"""
14
+
15
+ def __init__(self, db: Database, flow_client: FlowClient):
16
+ self.db = db
17
+ self.flow_client = flow_client
18
+ self._lock = asyncio.Lock()
19
+
20
+ # ========== Token CRUD ==========
21
+
22
+ async def get_all_tokens(self) -> List[Token]:
23
+ """Get all tokens"""
24
+ return await self.db.get_all_tokens()
25
+
26
+ async def get_active_tokens(self) -> List[Token]:
27
+ """Get all active tokens"""
28
+ return await self.db.get_active_tokens()
29
+
30
+ async def get_token(self, token_id: int) -> Optional[Token]:
31
+ """Get token by ID"""
32
+ return await self.db.get_token(token_id)
33
+
34
+ async def delete_token(self, token_id: int):
35
+ """Delete token"""
36
+ await self.db.delete_token(token_id)
37
+
38
+ async def enable_token(self, token_id: int):
39
+ """Enable a token and reset error count"""
40
+ # Enable the token
41
+ await self.db.update_token(token_id, is_active=True)
42
+ # Reset error count when enabling (only reset total error_count, keep today_error_count)
43
+ await self.db.reset_error_count(token_id)
44
+
45
+ async def disable_token(self, token_id: int):
46
+ """Disable a token"""
47
+ await self.db.update_token(token_id, is_active=False)
48
+
49
+ # ========== Token添加 (支持Project创建) ==========
50
+
51
+ async def add_token(
52
+ self,
53
+ st: str,
54
+ project_id: Optional[str] = None,
55
+ project_name: Optional[str] = None,
56
+ remark: Optional[str] = None,
57
+ image_enabled: bool = True,
58
+ video_enabled: bool = True,
59
+ image_concurrency: int = -1,
60
+ video_concurrency: int = -1
61
+ ) -> Token:
62
+ """Add a new token
63
+
64
+ Args:
65
+ st: Session Token (必需)
66
+ project_id: 项目ID (可选,如果提供则直接使用,不创建新项目)
67
+ project_name: 项目名称 (可选,如果不提供则自动生成)
68
+ remark: 备注
69
+ image_enabled: 是否启用图片生成
70
+ video_enabled: 是否启用视频生成
71
+ image_concurrency: 图片并发限制
72
+ video_concurrency: 视频并发限制
73
+
74
+ Returns:
75
+ Token object
76
+ """
77
+ # Step 1: 检查ST是否已存在
78
+ existing_token = await self.db.get_token_by_st(st)
79
+ if existing_token:
80
+ raise ValueError(f"Token 已存在(邮箱: {existing_token.email})")
81
+
82
+ # Step 2: 使用ST转换AT
83
+ debug_logger.log_info(f"[ADD_TOKEN] Converting ST to AT...")
84
+ try:
85
+ result = await self.flow_client.st_to_at(st)
86
+ at = result["access_token"]
87
+ expires = result.get("expires")
88
+ user_info = result.get("user", {})
89
+ email = user_info.get("email", "")
90
+ name = user_info.get("name", email.split("@")[0] if email else "")
91
+
92
+ # 解析过期时间
93
+ at_expires = None
94
+ if expires:
95
+ try:
96
+ at_expires = datetime.fromisoformat(expires.replace('Z', '+00:00'))
97
+ except:
98
+ pass
99
+
100
+ except Exception as e:
101
+ raise ValueError(f"ST转AT失败: {str(e)}")
102
+
103
+ # Step 3: 查询余额
104
+ try:
105
+ credits_result = await self.flow_client.get_credits(at)
106
+ credits = credits_result.get("credits", 0)
107
+ user_paygate_tier = credits_result.get("userPaygateTier")
108
+ except:
109
+ credits = 0
110
+ user_paygate_tier = None
111
+
112
+ # Step 4: 处理Project ID和名称
113
+ if project_id:
114
+ # 用户提供了project_id,直接使用
115
+ debug_logger.log_info(f"[ADD_TOKEN] Using provided project_id: {project_id}")
116
+ if not project_name:
117
+ # 如果没有提供project_name,生成一个
118
+ now = datetime.now()
119
+ project_name = now.strftime("%b %d - %H:%M")
120
+ else:
121
+ # 用户没有提供project_id,需要创建新项目
122
+ if not project_name:
123
+ # 自动生成项目名称
124
+ now = datetime.now()
125
+ project_name = now.strftime("%b %d - %H:%M")
126
+
127
+ try:
128
+ project_id = await self.flow_client.create_project(st, project_name)
129
+ debug_logger.log_info(f"[ADD_TOKEN] Created new project: {project_name} (ID: {project_id})")
130
+ except Exception as e:
131
+ raise ValueError(f"创建项目失败: {str(e)}")
132
+
133
+ # Step 5: 创建Token对象
134
+ token = Token(
135
+ st=st,
136
+ at=at,
137
+ at_expires=at_expires,
138
+ email=email,
139
+ name=name,
140
+ remark=remark,
141
+ is_active=True,
142
+ credits=credits,
143
+ user_paygate_tier=user_paygate_tier,
144
+ current_project_id=project_id,
145
+ current_project_name=project_name,
146
+ image_enabled=image_enabled,
147
+ video_enabled=video_enabled,
148
+ image_concurrency=image_concurrency,
149
+ video_concurrency=video_concurrency
150
+ )
151
+
152
+ # Step 6: 保存到数据库
153
+ token_id = await self.db.add_token(token)
154
+ token.id = token_id
155
+
156
+ # Step 7: 保存Project到数据库
157
+ project = Project(
158
+ project_id=project_id,
159
+ token_id=token_id,
160
+ project_name=project_name,
161
+ tool_name="PINHOLE"
162
+ )
163
+ await self.db.add_project(project)
164
+
165
+ debug_logger.log_info(f"[ADD_TOKEN] Token added successfully (ID: {token_id}, Email: {email})")
166
+ return token
167
+
168
+ async def update_token(
169
+ self,
170
+ token_id: int,
171
+ st: Optional[str] = None,
172
+ at: Optional[str] = None,
173
+ at_expires: Optional[datetime] = None,
174
+ project_id: Optional[str] = None,
175
+ project_name: Optional[str] = None,
176
+ remark: Optional[str] = None,
177
+ image_enabled: Optional[bool] = None,
178
+ video_enabled: Optional[bool] = None,
179
+ image_concurrency: Optional[int] = None,
180
+ video_concurrency: Optional[int] = None
181
+ ):
182
+ """Update token (支持修改project_id和project_name)"""
183
+ update_fields = {}
184
+
185
+ if st is not None:
186
+ update_fields["st"] = st
187
+ if at is not None:
188
+ update_fields["at"] = at
189
+ if at_expires is not None:
190
+ update_fields["at_expires"] = at_expires
191
+ if project_id is not None:
192
+ update_fields["current_project_id"] = project_id
193
+ if project_name is not None:
194
+ update_fields["current_project_name"] = project_name
195
+ if remark is not None:
196
+ update_fields["remark"] = remark
197
+ if image_enabled is not None:
198
+ update_fields["image_enabled"] = image_enabled
199
+ if video_enabled is not None:
200
+ update_fields["video_enabled"] = video_enabled
201
+ if image_concurrency is not None:
202
+ update_fields["image_concurrency"] = image_concurrency
203
+ if video_concurrency is not None:
204
+ update_fields["video_concurrency"] = video_concurrency
205
+
206
+ if update_fields:
207
+ await self.db.update_token(token_id, **update_fields)
208
+
209
+ # ========== AT自动刷新逻辑 (核心) ==========
210
+
211
+ async def is_at_valid(self, token_id: int) -> bool:
212
+ """检查AT是否有效,如果无效或即将过期则自动刷新
213
+
214
+ Returns:
215
+ True if AT is valid or refreshed successfully
216
+ False if AT cannot be refreshed
217
+ """
218
+ token = await self.db.get_token(token_id)
219
+ if not token:
220
+ return False
221
+
222
+ # 如果AT不存在,需要刷新
223
+ if not token.at:
224
+ debug_logger.log_info(f"[AT_CHECK] Token {token_id}: AT不存在,需要刷新")
225
+ return await self._refresh_at(token_id)
226
+
227
+ # 如果没有过期时间,假设需要刷新
228
+ if not token.at_expires:
229
+ debug_logger.log_info(f"[AT_CHECK] Token {token_id}: AT过期时间未知,尝试刷新")
230
+ return await self._refresh_at(token_id)
231
+
232
+ # 检查是否即将过期 (提前1小时刷新)
233
+ now = datetime.now(timezone.utc)
234
+ # 确保at_expires也是timezone-aware
235
+ if token.at_expires.tzinfo is None:
236
+ at_expires_aware = token.at_expires.replace(tzinfo=timezone.utc)
237
+ else:
238
+ at_expires_aware = token.at_expires
239
+
240
+ time_until_expiry = at_expires_aware - now
241
+
242
+ if time_until_expiry.total_seconds() < 3600: # 1 hour (3600 seconds)
243
+ debug_logger.log_info(f"[AT_CHECK] Token {token_id}: AT即将过期 (剩余 {time_until_expiry.total_seconds():.0f} 秒),需要刷新")
244
+ return await self._refresh_at(token_id)
245
+
246
+ # AT有效
247
+ return True
248
+
249
+ async def _refresh_at(self, token_id: int) -> bool:
250
+ """内部方法: 刷新AT
251
+
252
+ Returns:
253
+ True if refresh successful, False otherwise
254
+ """
255
+ async with self._lock:
256
+ token = await self.db.get_token(token_id)
257
+ if not token:
258
+ return False
259
+
260
+ try:
261
+ debug_logger.log_info(f"[AT_REFRESH] Token {token_id}: 开始刷新AT...")
262
+
263
+ # 使用ST转AT
264
+ result = await self.flow_client.st_to_at(token.st)
265
+ new_at = result["access_token"]
266
+ expires = result.get("expires")
267
+
268
+ # 解析过期时间
269
+ new_at_expires = None
270
+ if expires:
271
+ try:
272
+ new_at_expires = datetime.fromisoformat(expires.replace('Z', '+00:00'))
273
+ except:
274
+ pass
275
+
276
+ # 更新数据库
277
+ await self.db.update_token(
278
+ token_id,
279
+ at=new_at,
280
+ at_expires=new_at_expires
281
+ )
282
+
283
+ debug_logger.log_info(f"[AT_REFRESH] Token {token_id}: AT刷新成功")
284
+ debug_logger.log_info(f" - 新过期时间: {new_at_expires}")
285
+
286
+ # 同时刷新credits
287
+ try:
288
+ credits_result = await self.flow_client.get_credits(new_at)
289
+ await self.db.update_token(
290
+ token_id,
291
+ credits=credits_result.get("credits", 0)
292
+ )
293
+ except:
294
+ pass
295
+
296
+ return True
297
+
298
+ except Exception as e:
299
+ debug_logger.log_error(f"[AT_REFRESH] Token {token_id}: AT刷新失败 - {str(e)}")
300
+ # 刷新失败,禁用Token
301
+ await self.disable_token(token_id)
302
+ return False
303
+
304
+ async def ensure_project_exists(self, token_id: int) -> str:
305
+ """确保Token有可用的Project
306
+
307
+ Returns:
308
+ project_id
309
+ """
310
+ token = await self.db.get_token(token_id)
311
+ if not token:
312
+ raise ValueError("Token not found")
313
+
314
+ # 如果已有project_id,直接返回
315
+ if token.current_project_id:
316
+ return token.current_project_id
317
+
318
+ # 创建新Project
319
+ now = datetime.now()
320
+ project_name = now.strftime("%b %d - %H:%M")
321
+
322
+ try:
323
+ project_id = await self.flow_client.create_project(token.st, project_name)
324
+ debug_logger.log_info(f"[PROJECT] Created project for token {token_id}: {project_name}")
325
+
326
+ # 更新Token
327
+ await self.db.update_token(
328
+ token_id,
329
+ current_project_id=project_id,
330
+ current_project_name=project_name
331
+ )
332
+
333
+ # 保存Project到数据库
334
+ project = Project(
335
+ project_id=project_id,
336
+ token_id=token_id,
337
+ project_name=project_name
338
+ )
339
+ await self.db.add_project(project)
340
+
341
+ return project_id
342
+
343
+ except Exception as e:
344
+ raise ValueError(f"Failed to create project: {str(e)}")
345
+
346
+ # ========== Token使用统计 ==========
347
+
348
+ async def record_usage(self, token_id: int, is_video: bool = False):
349
+ """Record token usage"""
350
+ await self.db.update_token(token_id, use_count=1, last_used_at=datetime.now())
351
+
352
+ if is_video:
353
+ await self.db.increment_token_stats(token_id, "video")
354
+ else:
355
+ await self.db.increment_token_stats(token_id, "image")
356
+
357
+ async def record_error(self, token_id: int):
358
+ """Record token error and auto-disable if threshold reached"""
359
+ await self.db.increment_token_stats(token_id, "error")
360
+
361
+ # Check if should auto-disable token (based on consecutive errors)
362
+ stats = await self.db.get_token_stats(token_id)
363
+ admin_config = await self.db.get_admin_config()
364
+
365
+ if stats and stats.consecutive_error_count >= admin_config.error_ban_threshold:
366
+ debug_logger.log_warning(
367
+ f"[TOKEN_BAN] Token {token_id} consecutive error count ({stats.consecutive_error_count}) "
368
+ f"reached threshold ({admin_config.error_ban_threshold}), auto-disabling"
369
+ )
370
+ await self.disable_token(token_id)
371
+
372
+ async def record_success(self, token_id: int):
373
+ """Record successful request (reset consecutive error count)
374
+
375
+ This method resets error_count to 0, which is used for auto-disable threshold checking.
376
+ Note: today_error_count and historical statistics are NOT reset.
377
+ """
378
+ await self.db.reset_error_count(token_id)
379
+
380
+ # ========== 余额刷新 ==========
381
+
382
+ async def refresh_credits(self, token_id: int) -> int:
383
+ """刷新Token余额
384
+
385
+ Returns:
386
+ credits
387
+ """
388
+ token = await self.db.get_token(token_id)
389
+ if not token:
390
+ return 0
391
+
392
+ # 确保AT有效
393
+ if not await self.is_at_valid(token_id):
394
+ return 0
395
+
396
+ # 重新获取token (AT可能已刷新)
397
+ token = await self.db.get_token(token_id)
398
+
399
+ try:
400
+ result = await self.flow_client.get_credits(token.at)
401
+ credits = result.get("credits", 0)
402
+
403
+ # 更新数据库
404
+ await self.db.update_token(token_id, credits=credits)
405
+
406
+ return credits
407
+ except Exception as e:
408
+ debug_logger.log_error(f"Failed to refresh credits for token {token_id}: {str(e)}")
409
+ return 0
static/login.html ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN" class="h-full">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>登录 - Flow2API</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <style>
9
+ @keyframes slide-up{from{transform:translateY(100%);opacity:0}to{transform:translateY(0);opacity:1}}
10
+ .animate-slide-up{animation:slide-up .3s ease-out}
11
+ </style>
12
+ <script>
13
+ tailwind.config={theme:{extend:{colors:{border:"hsl(0 0% 89%)",input:"hsl(0 0% 89%)",ring:"hsl(0 0% 3.9%)",background:"hsl(0 0% 100%)",foreground:"hsl(0 0% 3.9%)",primary:{DEFAULT:"hsl(0 0% 9%)",foreground:"hsl(0 0% 98%)"},secondary:{DEFAULT:"hsl(0 0% 96.1%)",foreground:"hsl(0 0% 9%)"},muted:{DEFAULT:"hsl(0 0% 96.1%)",foreground:"hsl(0 0% 45.1%)"},destructive:{DEFAULT:"hsl(0 84.2% 60.2%)",foreground:"hsl(0 0% 98%)"}}}}}
14
+ </script>
15
+ </head>
16
+ <body class="h-full bg-background text-foreground antialiased">
17
+ <div class="flex min-h-full flex-col justify-center py-12 px-4 sm:px-6 lg:px-8">
18
+ <div class="sm:mx-auto sm:w-full sm:max-w-md">
19
+ <div class="text-center">
20
+ <h1 class="text-4xl font-bold">Flow2API</h1>
21
+ <p class="mt-2 text-sm text-muted-foreground">管理员控制台</p>
22
+ </div>
23
+ </div>
24
+
25
+ <div class="sm:mx-auto sm:w-full sm:max-w-md">
26
+ <div class="bg-background py-8 px-4 sm:px-10 rounded-lg">
27
+ <form id="loginForm" class="space-y-6">
28
+ <div class="space-y-2">
29
+ <label for="username" class="text-sm font-medium">账户</label>
30
+ <input type="text" id="username" name="username" required class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50" placeholder="请输入账户">
31
+ </div>
32
+ <div class="space-y-2">
33
+ <label for="password" class="text-sm font-medium">密码</label>
34
+ <input type="password" id="password" name="password" required class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50" placeholder="请输入密码">
35
+ </div>
36
+ <button type="submit" id="loginButton" class="inline-flex items-center justify-center rounded-md font-medium transition-colors bg-primary text-primary-foreground hover:bg-primary/90 h-10 w-full disabled:opacity-50">登录</button>
37
+ </form>
38
+
39
+ <div class="mt-6 text-center text-xs text-muted-foreground">
40
+ <p>Flow2API © 2025</p>
41
+ </div>
42
+ </div>
43
+ </div>
44
+ </div>
45
+
46
+ <script>
47
+ const form=document.getElementById('loginForm'),btn=document.getElementById('loginButton');
48
+ form.addEventListener('submit',async(e)=>{e.preventDefault();btn.disabled=true;btn.textContent='登录中...';try{const fd=new FormData(form),r=await fetch('/api/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:fd.get('username'),password:fd.get('password')})});const d=await r.json();d.success?(localStorage.setItem('adminToken',d.token),location.href='/manage'):showToast(d.message||'登录失败','error')}catch(e){showToast('网络错误,请稍后重试','error')}finally{btn.disabled=false;btn.textContent='登录'}});
49
+ function showToast(m,t='error'){const d=document.createElement('div'),bc={success:'bg-green-600',error:'bg-destructive',info:'bg-primary'};d.className=`fixed bottom-4 right-4 ${bc[t]||bc.error} text-white px-4 py-2.5 rounded-lg shadow-lg text-sm font-medium z-50 animate-slide-up`;d.textContent=m;document.body.appendChild(d);setTimeout(()=>{d.style.opacity='0';d.style.transition='opacity .3s';setTimeout(()=>d.parentNode&&document.body.removeChild(d),300)},2000)}
50
+ window.addEventListener('DOMContentLoaded',()=>{const t=localStorage.getItem('adminToken');t&&fetch('/api/stats',{headers:{Authorization:`Bearer ${t}`}}).then(r=>{if(r.ok)location.href='/manage'})});
51
+ </script>
52
+ </body>
53
+ </html>
static/manage.html ADDED
@@ -0,0 +1,586 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN" class="h-full">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>管理控制台 - Flow2API</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <style>
9
+ @keyframes slide-up{from{transform:translateY(100%);opacity:0}to{transform:translateY(0);opacity:1}}
10
+ .animate-slide-up{animation:slide-up .3s ease-out}
11
+ .tab-btn{transition:all .2s ease}
12
+ </style>
13
+ <script>
14
+ tailwind.config={theme:{extend:{colors:{border:"hsl(0 0% 89%)",input:"hsl(0 0% 89%)",ring:"hsl(0 0% 3.9%)",background:"hsl(0 0% 100%)",foreground:"hsl(0 0% 3.9%)",primary:{DEFAULT:"hsl(0 0% 9%)",foreground:"hsl(0 0% 98%)"},secondary:{DEFAULT:"hsl(0 0% 96.1%)",foreground:"hsl(0 0% 9%)"},muted:{DEFAULT:"hsl(0 0% 96.1%)",foreground:"hsl(0 0% 45.1%)"},destructive:{DEFAULT:"hsl(0 84.2% 60.2%)",foreground:"hsl(0 0% 98%)"}}}}}
15
+ </script>
16
+ </head>
17
+ <body class="h-full bg-background text-foreground antialiased">
18
+ <!-- 导航栏 -->
19
+ <header class="sticky top-0 z-50 w-full border-b border-border/40 bg-background/95 backdrop-blur">
20
+ <div class="mx-auto flex h-14 max-w-7xl items-center px-6">
21
+ <div class="mr-4 flex items-baseline gap-3">
22
+ <span class="font-bold text-xl">Flow2API</span>
23
+ </div>
24
+ <div class="flex flex-1 items-center justify-end gap-3">
25
+ <a href="https://github.com/TheSmallHanCat/flow2api" target="_blank" class="inline-flex items-center justify-center text-xs transition-colors hover:bg-accent hover:text-accent-foreground h-7 px-2.5" title="GitHub 仓库">
26
+ <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
27
+ <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v 3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
28
+ </svg>
29
+ </a>
30
+ <button onclick="logout()" class="inline-flex items-center justify-center text-xs transition-colors hover:bg-accent hover:text-accent-foreground h-7 px-2.5 gap-1">
31
+ <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
32
+ <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
33
+ <polyline points="16 17 21 12 16 7"/>
34
+ <line x1="21" y1="12" x2="9" y2="12"/>
35
+ </svg>
36
+ 退出
37
+ </button>
38
+ </div>
39
+ </div>
40
+ </header>
41
+
42
+ <main class="mx-auto max-w-7xl px-6 py-6">
43
+ <!-- Tab 导航 -->
44
+ <div class="border-b border-border mb-6">
45
+ <nav class="flex space-x-8">
46
+ <button onclick="switchTab('tokens')" id="tabTokens" class="tab-btn border-b-2 border-primary text-sm font-medium py-3 px-1">Token 管理</button>
47
+ <button onclick="switchTab('settings')" id="tabSettings" class="tab-btn border-b-2 border-transparent text-sm font-medium py-3 px-1">系统配置</button>
48
+ <button onclick="switchTab('logs')" id="tabLogs" class="tab-btn border-b-2 border-transparent text-sm font-medium py-3 px-1">请求日志</button>
49
+ </nav>
50
+ </div>
51
+
52
+ <!-- Token 管理面板 -->
53
+ <div id="panelTokens">
54
+ <!-- 统计卡片 -->
55
+ <div class="grid gap-4 grid-cols-2 md:grid-cols-5 mb-6">
56
+ <div class="rounded-lg border border-border bg-background p-4">
57
+ <p class="text-sm font-medium text-muted-foreground mb-2">Token 总数</p>
58
+ <h3 class="text-xl font-bold" id="statTotal">-</h3>
59
+ </div>
60
+ <div class="rounded-lg border border-border bg-background p-4">
61
+ <p class="text-sm font-medium text-muted-foreground mb-2">活跃 Token</p>
62
+ <h3 class="text-xl font-bold text-green-600" id="statActive">-</h3>
63
+ </div>
64
+ <div class="rounded-lg border border-border bg-background p-4">
65
+ <p class="text-sm font-medium text-muted-foreground mb-2">今日图片/总图片</p>
66
+ <h3 class="text-xl font-bold text-blue-600" id="statImages">-</h3>
67
+ </div>
68
+ <div class="rounded-lg border border-border bg-background p-4">
69
+ <p class="text-sm font-medium text-muted-foreground mb-2">今日视频/总视频</p>
70
+ <h3 class="text-xl font-bold text-purple-600" id="statVideos">-</h3>
71
+ </div>
72
+ <div class="rounded-lg border border-border bg-background p-4">
73
+ <p class="text-sm font-medium text-muted-foreground mb-2">今日错误/总错误</p>
74
+ <h3 class="text-xl font-bold text-destructive" id="statErrors">-</h3>
75
+ </div>
76
+ </div>
77
+
78
+ <!-- Token 列表 -->
79
+ <div class="rounded-lg border border-border bg-background">
80
+ <div class="flex items-center justify-between gap-4 p-4 border-b border-border">
81
+ <h3 class="text-lg font-semibold">Token 列表</h3>
82
+ <div class="flex items-center gap-3">
83
+ <!-- 自动刷新AT标签和开关 -->
84
+ <div class="flex items-center gap-2">
85
+ <span class="text-xs text-muted-foreground">自动刷新AT</span>
86
+ <div class="relative inline-flex items-center group">
87
+ <label class="inline-flex items-center cursor-pointer">
88
+ <input type="checkbox" id="atAutoRefreshToggle" onchange="toggleATAutoRefresh()" class="sr-only peer">
89
+ <div class="relative w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-primary rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"></div>
90
+ </label>
91
+ <!-- 悬浮提示 -->
92
+ <div class="absolute bottom-full mb-2 left-1/2 transform -translate-x-1/2 bg-gray-900 text-white text-xs rounded px-2 py-1 whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10">
93
+ Token距离过期<1h时自动使用ST刷新AT
94
+ <div class="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-gray-900"></div>
95
+ </div>
96
+ </div>
97
+ </div>
98
+ <button onclick="refreshTokens()" class="inline-flex items-center justify-center rounded-md transition-colors hover:bg-accent h-8 w-8" title="刷新">
99
+ <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
100
+ <polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
101
+ </svg>
102
+ </button>
103
+ <button onclick="exportTokens()" class="inline-flex items-center justify-center rounded-md bg-blue-600 text-white hover:bg-blue-700 h-8 px-3" title="导出所有Token">
104
+ <svg class="h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
105
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
106
+ <polyline points="7 10 12 15 17 10"/>
107
+ <line x1="12" y1="15" x2="12" y2="3"/>
108
+ </svg>
109
+ <span class="text-sm font-medium">导出</span>
110
+ </button>
111
+ <button onclick="openImportModal()" class="inline-flex items-center justify-center rounded-md bg-green-600 text-white hover:bg-green-700 h-8 px-3" title="导入Token">
112
+ <svg class="h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
113
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
114
+ <polyline points="17 8 12 3 7 8"/>
115
+ <line x1="12" y1="3" x2="12" y2="15"/>
116
+ </svg>
117
+ <span class="text-sm font-medium">导入</span>
118
+ </button>
119
+ <button onclick="openAddModal()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-8 px-3">
120
+ <svg class="h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
121
+ <line x1="12" y1="5" x2="12" y2="19"/>
122
+ <line x1="5" y1="12" x2="19" y2="12"/>
123
+ </svg>
124
+ <span class="text-sm font-medium">新增</span>
125
+ </button>
126
+ </div>
127
+ </div>
128
+
129
+ <div class="relative w-full overflow-auto">
130
+ <table class="w-full text-sm">
131
+ <thead>
132
+ <tr class="border-b border-border">
133
+ <th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">邮箱</th>
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>
140
+ <th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">视频</th>
141
+ <th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">错误</th>
142
+ <th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">备注</th>
143
+ <th class="h-10 px-3 text-right align-middle font-medium text-muted-foreground">操作</th>
144
+ </tr>
145
+ </thead>
146
+ <tbody id="tokenTableBody" class="divide-y divide-border">
147
+ <!-- 动态填充 -->
148
+ </tbody>
149
+ </table>
150
+ </div>
151
+ </div>
152
+ </div>
153
+
154
+ <!-- 系统配置面板 -->
155
+ <div id="panelSettings" class="hidden">
156
+ <div class="grid gap-6 lg:grid-cols-2">
157
+ <!-- 安全配置 -->
158
+ <div class="rounded-lg border border-border bg-background p-6">
159
+ <h3 class="text-lg font-semibold mb-4">安全配置</h3>
160
+ <div class="space-y-4">
161
+ <div>
162
+ <label class="text-sm font-medium mb-2 block">管理员用户名</label>
163
+ <input id="cfgAdminUsername" type="text" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
164
+ <p class="text-xs text-muted-foreground mt-1">管理员用户名</p>
165
+ </div>
166
+ <div>
167
+ <label class="text-sm font-medium mb-2 block">旧密码</label>
168
+ <input id="cfgOldPassword" type="password" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="输入旧密码">
169
+ </div>
170
+ <div>
171
+ <label class="text-sm font-medium mb-2 block">新密码</label>
172
+ <input id="cfgNewPassword" type="password" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="输入新密码">
173
+ </div>
174
+ <button onclick="updateAdminPassword()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 w-full">修改密码</button>
175
+ </div>
176
+ </div>
177
+
178
+ <!-- API 密钥配置 -->
179
+ <div class="rounded-lg border border-border bg-background p-6">
180
+ <h3 class="text-lg font-semibold mb-4">API 密钥配置</h3>
181
+ <div class="space-y-4">
182
+ <div>
183
+ <label class="text-sm font-medium mb-2 block">当前 API Key</label>
184
+ <input id="cfgCurrentAPIKey" type="text" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" readonly disabled>
185
+ <p class="text-xs text-muted-foreground mt-1">当前使用的 API Key(只读)</p>
186
+ </div>
187
+ <div>
188
+ <label class="text-sm font-medium mb-2 block">新 API Key</label>
189
+ <input id="cfgNewAPIKey" type="text" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="输入新的 API Key">
190
+ <p class="text-xs text-muted-foreground mt-1">用于客户端调用 API 的密钥</p>
191
+ </div>
192
+ <button onclick="updateAPIKey()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 w-full">更新 API Key</button>
193
+ </div>
194
+ </div>
195
+
196
+ <!-- 代理配置 -->
197
+ <div class="rounded-lg border border-border bg-background p-6">
198
+ <h3 class="text-lg font-semibold mb-4">代理配置</h3>
199
+ <div class="space-y-4">
200
+ <div>
201
+ <label class="inline-flex items-center gap-2 cursor-pointer">
202
+ <input type="checkbox" id="cfgProxyEnabled" class="h-4 w-4 rounded border-input">
203
+ <span class="text-sm font-medium">启用代理</span>
204
+ </label>
205
+ </div>
206
+ <div>
207
+ <label class="text-sm font-medium mb-2 block">代理地址</label>
208
+ <input id="cfgProxyUrl" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="http://127.0.0.1:7890 或 socks5://127.0.0.1:1080">
209
+ <p class="text-xs text-muted-foreground mt-1">支持 HTTP 和 SOCKS5 代理</p>
210
+ </div>
211
+ <button onclick="saveProxyConfig()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 w-full">保存配置</button>
212
+ </div>
213
+ </div>
214
+
215
+ <!-- 错误处理配置 -->
216
+ <div class="rounded-lg border border-border bg-background p-6">
217
+ <h3 class="text-lg font-semibold mb-4">错误处理配置</h3>
218
+ <div class="space-y-4">
219
+ <div>
220
+ <label class="text-sm font-medium mb-2 block">错误封禁阈值</label>
221
+ <input id="cfgErrorBan" type="number" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="3">
222
+ <p class="text-xs text-muted-foreground mt-1">Token 连续错误达到此次数后自动禁用</p>
223
+ </div>
224
+ <button onclick="saveAdminConfig()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 w-full">保存配置</button>
225
+ </div>
226
+ </div>
227
+
228
+ <!-- 缓存配置 -->
229
+ <div class="rounded-lg border border-border bg-background p-6">
230
+ <h3 class="text-lg font-semibold mb-4">缓存配置</h3>
231
+ <div class="space-y-4">
232
+ <div>
233
+ <label class="inline-flex items-center gap-2 cursor-pointer">
234
+ <input type="checkbox" id="cfgCacheEnabled" class="h-4 w-4 rounded border-input" onchange="toggleCacheOptions()">
235
+ <span class="text-sm font-medium">启用缓存</span>
236
+ </label>
237
+ <p class="text-xs text-muted-foreground mt-1">关闭后,生成的图片和视频将直接输出原始链接,不会缓存到本地</p>
238
+ </div>
239
+
240
+ <!-- 缓存配置选项 -->
241
+ <div id="cacheOptions" style="display: none;" class="space-y-4 pt-4 border-t border-border">
242
+ <div>
243
+ <label class="text-sm font-medium mb-2 block">缓存超时时间(秒)</label>
244
+ <input id="cfgCacheTimeout" type="number" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="7200" min="60" max="86400">
245
+ <p class="text-xs text-muted-foreground mt-1">文件缓存超时时间,范围:60-86400 秒(1分钟-24小时)</p>
246
+ </div>
247
+ <div>
248
+ <label class="text-sm font-medium mb-2 block">缓存文件访问域名</label>
249
+ <input id="cfgCacheBaseUrl" type="text" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="https://yourdomain.com">
250
+ <p class="text-xs text-muted-foreground mt-1">留空则使用服务器地址,例如:https://yourdomain.com</p>
251
+ </div>
252
+ <div id="cacheEffectiveUrl" class="rounded-md bg-muted p-3 hidden">
253
+ <p class="text-xs text-muted-foreground">
254
+ <strong>🌐 当前生效的访问地址:</strong><code id="cacheEffectiveUrlValue" class="bg-background px-1 py-0.5 rounded"></code>
255
+ </p>
256
+ </div>
257
+ </div>
258
+
259
+ <button onclick="saveCacheConfig()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 w-full">保存配置</button>
260
+ </div>
261
+ </div>
262
+
263
+ <!-- 生成超时配置 -->
264
+ <div class="rounded-lg border border-border bg-background p-6">
265
+ <h3 class="text-lg font-semibold mb-4">生成超时配置</h3>
266
+ <div class="space-y-4">
267
+ <div>
268
+ <label class="text-sm font-medium mb-2 block">图片生成超时时间(秒)</label>
269
+ <input id="cfgImageTimeout" type="number" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="300" min="60" max="3600">
270
+ <p class="text-xs text-muted-foreground mt-1">图片生成超时时间,范围:60-3600 秒(1分钟-1小时),超时后自动释放Token锁</p>
271
+ </div>
272
+ <div>
273
+ <label class="text-sm font-medium mb-2 block">视频生成超时时间(秒)</label>
274
+ <input id="cfgVideoTimeout" type="number" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="1500" min="60" max="7200">
275
+ <p class="text-xs text-muted-foreground mt-1">视频生成超时时间,范围:60-7200 秒(1分钟-2小时),超时后返回上游API超时错误</p>
276
+ </div>
277
+ <button onclick="saveGenerationTimeout()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 w-full">保存配置</button>
278
+ </div>
279
+ </div>
280
+
281
+ <!-- 调试配置 -->
282
+ <div class="rounded-lg border border-border bg-background p-6">
283
+ <h3 class="text-lg font-semibold mb-4">调试配置</h3>
284
+ <div class="space-y-4">
285
+ <div>
286
+ <label class="inline-flex items-center gap-2 cursor-pointer">
287
+ <input type="checkbox" id="cfgDebugEnabled" class="h-4 w-4 rounded border-input" onchange="toggleDebugMode()">
288
+ <span class="text-sm font-medium">启用调试模式</span>
289
+ </label>
290
+ <p class="text-xs text-muted-foreground mt-2">开启后,详细的上游API请求和响应日志将写入 <code class="bg-muted px-1 py-0.5 rounded">logs.txt</code> 文件</p>
291
+ </div>
292
+ <div class="rounded-md bg-yellow-50 dark:bg-yellow-900/20 p-3 border border-yellow-200 dark:border-yellow-800">
293
+ <p class="text-xs text-yellow-800 dark:text-yellow-200">
294
+ ⚠️ <strong>注意:</strong>调试模式会产生非常非常大量的日志,仅限Debug时候开启,否则磁盘boom
295
+ </p>
296
+ </div>
297
+ </div>
298
+ </div>
299
+ </div>
300
+ </div>
301
+
302
+ <!-- 请求日志面板 -->
303
+ <div id="panelLogs" class="hidden">
304
+ <div class="rounded-lg border border-border bg-background">
305
+ <div class="flex items-center justify-between gap-4 p-4 border-b border-border">
306
+ <h3 class="text-lg font-semibold">请求日志</h3>
307
+ <button onclick="refreshLogs()" class="inline-flex items-center justify-center rounded-md transition-colors hover:bg-accent h-8 w-8" title="刷新">
308
+ <svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
309
+ <polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
310
+ </svg>
311
+ </button>
312
+ </div>
313
+ <div class="relative w-full overflow-auto max-h-[600px]">
314
+ <table class="w-full text-sm">
315
+ <thead class="sticky top-0 bg-background">
316
+ <tr class="border-b border-border">
317
+ <th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">操作</th>
318
+ <th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">Token邮箱</th>
319
+ <th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">状态码</th>
320
+ <th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">耗时(秒)</th>
321
+ <th class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">时间</th>
322
+ </tr>
323
+ </thead>
324
+ <tbody id="logsTableBody" class="divide-y divide-border">
325
+ <!-- 动态填充 -->
326
+ </tbody>
327
+ </table>
328
+ </div>
329
+ </div>
330
+ </div>
331
+
332
+ <!-- 页脚 -->
333
+ <footer class="mt-12 pt-6 border-t border-border text-center text-xs text-muted-foreground">
334
+ <p>© 2025 <a href="https://linux.do/u/thesmallhancat/summary" target="_blank" class="no-underline hover:underline" style="color: inherit;">TheSmallHanCat</a> && <a href="https://linux.do/u/tibbar/summary" target="_blank" class="no-underline hover:underline" style="color: inherit;">Tibbar</a>. All rights reserved.</p>
335
+ </footer>
336
+ </main>
337
+
338
+ <!-- 添加 Token 模态框 -->
339
+ <div id="addModal" class="hidden fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4 overflow-y-auto">
340
+ <div class="bg-background rounded-lg border border-border w-full max-w-2xl shadow-xl my-auto">
341
+ <div class="flex items-center justify-between p-5 border-b border-border sticky top-0 bg-background">
342
+ <h3 class="text-lg font-semibold">添加 Token</h3>
343
+ <button onclick="closeAddModal()" class="text-muted-foreground hover:text-foreground">
344
+ <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
345
+ <line x1="18" y1="6" x2="6" y2="18"/>
346
+ <line x1="6" y1="6" x2="18" y2="18"/>
347
+ </svg>
348
+ </button>
349
+ </div>
350
+ <div class="p-5 space-y-4 max-h-[calc(100vh-200px)] overflow-y-auto">
351
+ <!-- Session Token -->
352
+ <div class="space-y-2">
353
+ <label class="text-sm font-medium">Session Token (ST) <span class="text-red-500">*</span></label>
354
+ <textarea id="addTokenST" rows="3" class="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono resize-none" placeholder="请输入 Session Token" required></textarea>
355
+ <p class="text-xs text-muted-foreground">从浏览器 Cookie 中获取 __Secure-next-auth.session-token,保存时将自动转换为 Access Token</p>
356
+ </div>
357
+
358
+ <!-- Remark -->
359
+ <div class="space-y-2">
360
+ <label class="text-sm font-medium">备注 <span class="text-muted-foreground text-xs">- 可选</span></label>
361
+ <input id="addTokenRemark" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="添加备注信息">
362
+ </div>
363
+
364
+ <!-- Project ID -->
365
+ <div class="space-y-2">
366
+ <label class="text-sm font-medium">Project ID <span class="text-muted-foreground text-xs">- 可选</span></label>
367
+ <input id="addTokenProjectId" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono" placeholder="若不填写则系统自动生成">
368
+ <p class="text-xs text-muted-foreground">如果已有Project ID可直接输入,留空则创建新项目</p>
369
+ </div>
370
+
371
+ <!-- Project Name -->
372
+ <div class="space-y-2">
373
+ <label class="text-sm font-medium">Project Name <span class="text-muted-foreground text-xs">- 可选</span></label>
374
+ <input id="addTokenProjectName" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="若不填写则自动生成 (如: Jan 01 - 12:00)">
375
+ </div>
376
+
377
+ <!-- 功能开关 -->
378
+ <div class="space-y-3 pt-2 border-t border-border">
379
+ <label class="text-sm font-medium">功能开关</label>
380
+ <div class="space-y-2">
381
+ <div class="flex items-center gap-3">
382
+ <label class="inline-flex items-center gap-2 cursor-pointer">
383
+ <input type="checkbox" id="addTokenImageEnabled" checked class="h-4 w-4 rounded border-input">
384
+ <span class="text-sm font-medium">启用图片生成</span>
385
+ </label>
386
+ <input type="number" id="addTokenImageConcurrency" value="-1" class="flex h-8 w-20 rounded-md border border-input bg-background px-2 py-1 text-sm" placeholder="并发数" title="并发数量限制:设置同时处理的最大请求数。-1表示不限制">
387
+ </div>
388
+ </div>
389
+ <div class="space-y-2">
390
+ <div class="flex items-center gap-3">
391
+ <label class="inline-flex items-center gap-2 cursor-pointer">
392
+ <input type="checkbox" id="addTokenVideoEnabled" checked class="h-4 w-4 rounded border-input">
393
+ <span class="text-sm font-medium">启用视频生成</span>
394
+ </label>
395
+ <input type="number" id="addTokenVideoConcurrency" value="-1" class="flex h-8 w-20 rounded-md border border-input bg-background px-2 py-1 text-sm" placeholder="并发数" title="并发数量限制:设置同时处理的最大请求数。-1表示不限制">
396
+ </div>
397
+ </div>
398
+ </div>
399
+ </div>
400
+ <div class="flex items-center justify-end gap-3 p-5 border-t border-border sticky bottom-0 bg-background">
401
+ <button onclick="closeAddModal()" class="inline-flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent h-9 px-5">取消</button>
402
+ <button id="addTokenBtn" onclick="submitAddToken()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-5">
403
+ <span id="addTokenBtnText">添加</span>
404
+ <svg id="addTokenBtnSpinner" class="hidden animate-spin h-4 w-4 ml-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
405
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
406
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
407
+ </svg>
408
+ </button>
409
+ </div>
410
+ </div>
411
+ </div>
412
+
413
+ <!-- 编辑 Token 模态框 -->
414
+ <div id="editModal" class="hidden fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4 overflow-y-auto">
415
+ <div class="bg-background rounded-lg border border-border w-full max-w-2xl shadow-xl my-auto">
416
+ <div class="flex items-center justify-between p-5 border-b border-border sticky top-0 bg-background">
417
+ <h3 class="text-lg font-semibold">编辑 Token</h3>
418
+ <button onclick="closeEditModal()" class="text-muted-foreground hover:text-foreground">
419
+ <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
420
+ <line x1="18" y1="6" x2="6" y2="18"/>
421
+ <line x1="6" y1="6" x2="18" y2="18"/>
422
+ </svg>
423
+ </button>
424
+ </div>
425
+ <div class="p-5 space-y-4 max-h-[calc(100vh-200px)] overflow-y-auto">
426
+ <input type="hidden" id="editTokenId">
427
+
428
+ <!-- Session Token -->
429
+ <div class="space-y-2">
430
+ <label class="text-sm font-medium">Session Token (ST) <span class="text-red-500">*</span></label>
431
+ <textarea id="editTokenST" rows="3" class="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono resize-none" placeholder="请输入 Session Token" required></textarea>
432
+ <p class="text-xs text-muted-foreground">从浏览器 Cookie 中获取 __Secure-next-auth.session-token,保存时将自动转换为 Access Token</p>
433
+ </div>
434
+
435
+ <!-- Remark -->
436
+ <div class="space-y-2">
437
+ <label class="text-sm font-medium">备注 <span class="text-muted-foreground text-xs">- 可选</span></label>
438
+ <input id="editTokenRemark" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="添加备注信息">
439
+ </div>
440
+
441
+ <!-- Project ID -->
442
+ <div class="space-y-2">
443
+ <label class="text-sm font-medium">Project ID <span class="text-muted-foreground text-xs">- 可选</span></label>
444
+ <input id="editTokenProjectId" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono" placeholder="若不填写则保持原有值">
445
+ <p class="text-xs text-muted-foreground">修改Project ID会更新Token使用的项目</p>
446
+ </div>
447
+
448
+ <!-- Project Name -->
449
+ <div class="space-y-2">
450
+ <label class="text-sm font-medium">Project Name <span class="text-muted-foreground text-xs">- 可选</span></label>
451
+ <input id="editTokenProjectName" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="若不填写则保持原有值">
452
+ </div>
453
+
454
+ <!-- 功能开关 -->
455
+ <div class="space-y-3 pt-2 border-t border-border">
456
+ <label class="text-sm font-medium">功能开关</label>
457
+ <div class="space-y-2">
458
+ <div class="flex items-center gap-3">
459
+ <label class="inline-flex items-center gap-2 cursor-pointer">
460
+ <input type="checkbox" id="editTokenImageEnabled" class="h-4 w-4 rounded border-input">
461
+ <span class="text-sm font-medium">启用图片生成</span>
462
+ </label>
463
+ <input type="number" id="editTokenImageConcurrency" class="flex h-8 w-20 rounded-md border border-input bg-background px-2 py-1 text-sm" placeholder="并发数" title="并发数量限制:设置同时处理的最大请求数。-1表示不限制">
464
+ </div>
465
+ </div>
466
+ <div class="space-y-2">
467
+ <div class="flex items-center gap-3">
468
+ <label class="inline-flex items-center gap-2 cursor-pointer">
469
+ <input type="checkbox" id="editTokenVideoEnabled" class="h-4 w-4 rounded border-input">
470
+ <span class="text-sm font-medium">启用视频生成</span>
471
+ </label>
472
+ <input type="number" id="editTokenVideoConcurrency" class="flex h-8 w-20 rounded-md border border-input bg-background px-2 py-1 text-sm" placeholder="并发数" title="并发数量限制:设置同时处理的最大请求数。-1表示不限制">
473
+ </div>
474
+ </div>
475
+ </div>
476
+ </div>
477
+ <div class="flex items-center justify-end gap-3 p-5 border-t border-border sticky bottom-0 bg-background">
478
+ <button onclick="closeEditModal()" class="inline-flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent h-9 px-5">取消</button>
479
+ <button id="editTokenBtn" onclick="submitEditToken()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-5">
480
+ <span id="editTokenBtnText">保存</span>
481
+ <svg id="editTokenBtnSpinner" class="hidden animate-spin h-4 w-4 ml-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
482
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
483
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
484
+ </svg>
485
+ </button>
486
+ </div>
487
+ </div>
488
+ </div>
489
+
490
+ <!-- Token 导入模态框 -->
491
+ <div id="importModal" class="hidden fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4">
492
+ <div class="bg-background rounded-lg border border-border w-full max-w-md shadow-xl">
493
+ <div class="flex items-center justify-between p-5 border-b border-border">
494
+ <h3 class="text-lg font-semibold">导入 Token</h3>
495
+ <button onclick="closeImportModal()" class="text-muted-foreground hover:text-foreground">
496
+ <svg class="h-3.5 w-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
497
+ <line x1="18" y1="6" x2="6" y2="18"/>
498
+ <line x1="6" y1="6" x2="18" y2="18"/>
499
+ </svg>
500
+ </button>
501
+ </div>
502
+ <div class="p-5 space-y-4">
503
+ <div>
504
+ <label class="text-sm font-medium mb-2 block">选择 JSON 文件</label>
505
+ <input type="file" id="importFile" accept=".json" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
506
+ <p class="text-xs text-muted-foreground mt-1">选择导出的 Token JSON 文件进行导入</p>
507
+ </div>
508
+ <div class="rounded-md bg-blue-50 dark:bg-blue-900/20 p-3 border border-blue-200 dark:border-blue-800">
509
+ <p class="text-xs text-blue-800 dark:text-blue-200">
510
+ <strong>说明:</strong>如果邮箱存在则会覆盖更新,不存在则会新增
511
+ </p>
512
+ </div>
513
+ </div>
514
+ <div class="flex items-center justify-end gap-3 p-5 border-t border-border">
515
+ <button onclick="closeImportModal()" class="inline-flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent h-9 px-5">取消</button>
516
+ <button id="importBtn" onclick="submitImportTokens()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-5">
517
+ <span id="importBtnText">导入</span>
518
+ <svg id="importBtnSpinner" class="hidden animate-spin h-4 w-4 ml-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
519
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
520
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
521
+ </svg>
522
+ </button>
523
+ </div>
524
+ </div>
525
+ </div>
526
+
527
+ <script>
528
+ let allTokens=[];
529
+ const $=(id)=>document.getElementById(id),
530
+ checkAuth=()=>{const t=localStorage.getItem('adminToken');return t||(location.href='/login',null),t},
531
+ 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},
532
+ 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)}},
533
+ loadTokens=async()=>{try{const r=await apiRequest('/api/tokens');if(!r)return;allTokens=await r.json();renderTokens()}catch(e){console.error('加载Token失败:',e)}},
534
+ formatExpiry=exp=>{if(!exp)return'<span class="text-muted-foreground">-</span>';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});const hours=Math.floor(diff/36e5);if(diff<0)return`<span class="text-red-600 font-medium" title="已过期">已过期</span>`;if(hours<1)return`<span class="text-red-600 font-medium" title="${dateStr} ${timeStr}">${Math.floor(diff/6e4)}分钟</span>`;if(hours<24)return`<span class="text-orange-600 font-medium" title="${dateStr} ${timeStr}">${hours}小时</span>`;const days=Math.floor(diff/864e5);if(days<7)return`<span class="text-orange-600" title="${dateStr} ${timeStr}">${days}天</span>`;return`<span class="text-muted-foreground" title="${dateStr} ${timeStr}">${days}天</span>`},
535
+ formatPlanType=type=>{if(!type)return'-';const typeMap={'chatgpt_team':'Team','chatgpt_plus':'Plus','chatgpt_pro':'Pro','chatgpt_free':'Free'};return typeMap[type]||type},
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()},
543
+ openAddModal=()=>$('addModal').classList.remove('hidden'),
544
+ closeAddModal=()=>{$('addModal').classList.add('hidden');$('addTokenST').value='';$('addTokenRemark').value='';$('addTokenProjectId').value='';$('addTokenProjectName').value='';$('addTokenImageEnabled').checked=true;$('addTokenVideoEnabled').checked=true;$('addTokenImageConcurrency').value='-1';$('addTokenVideoConcurrency').value='-1'},
545
+ openEditModal=(id)=>{const token=allTokens.find(t=>t.id===id);if(!token)return showToast('Token不存在','error');$('editTokenId').value=token.id;$('editTokenST').value=token.st||'';$('editTokenRemark').value=token.remark||'';$('editTokenProjectId').value=token.current_project_id||'';$('editTokenProjectName').value=token.current_project_name||'';$('editTokenImageEnabled').checked=token.image_enabled!==false;$('editTokenVideoEnabled').checked=token.video_enabled!==false;$('editTokenImageConcurrency').value=token.image_concurrency||'-1';$('editTokenVideoConcurrency').value=token.video_concurrency||'-1';$('editModal').classList.remove('hidden')},
546
+ closeEditModal=()=>{$('editModal').classList.add('hidden');$('editTokenId').value='';$('editTokenST').value='';$('editTokenRemark').value='';$('editTokenProjectId').value='';$('editTokenProjectName').value='';$('editTokenImageEnabled').checked=true;$('editTokenVideoEnabled').checked=true;$('editTokenImageConcurrency').value='';$('editTokenVideoConcurrency').value=''},
547
+ submitEditToken=async()=>{const id=parseInt($('editTokenId').value),st=$('editTokenST').value.trim(),remark=$('editTokenRemark').value.trim(),projectId=$('editTokenProjectId').value.trim(),projectName=$('editTokenProjectName').value.trim(),imageEnabled=$('editTokenImageEnabled').checked,videoEnabled=$('editTokenVideoEnabled').checked,imageConcurrency=$('editTokenImageConcurrency').value?parseInt($('editTokenImageConcurrency').value):null,videoConcurrency=$('editTokenVideoConcurrency').value?parseInt($('editTokenVideoConcurrency').value):null;if(!id)return showToast('Token ID无效','error');if(!st)return showToast('请输入 Session Token','error');const btn=$('editTokenBtn'),btnText=$('editTokenBtnText'),btnSpinner=$('editTokenBtnSpinner');btn.disabled=true;btnText.textContent='保存中...';btnSpinner.classList.remove('hidden');try{const r=await apiRequest(`/api/tokens/${id}`,{method:'PUT',body:JSON.stringify({st:st,remark:remark||null,project_id:projectId||null,project_name:projectName||null,image_enabled:imageEnabled,video_enabled:videoEnabled,image_concurrency:imageConcurrency,video_concurrency:videoConcurrency})});if(!r){btn.disabled=false;btnText.textContent='保存';btnSpinner.classList.add('hidden');return}const d=await r.json();if(d.success){closeEditModal();await refreshTokens();showToast('Token更新成功','success')}else{showToast('更新失败: '+(d.detail||d.message||'未知错误'),'error')}}catch(e){showToast('更新失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='保存';btnSpinner.classList.add('hidden')}},
548
+ convertST2AT=async()=>{const st=$('addTokenST').value.trim();if(!st)return showToast('请先输入 Session Token','error');try{showToast('正在转换 ST→AT...','info');const r=await apiRequest('/api/tokens/st2at',{method:'POST',body:JSON.stringify({st:st})});if(!r)return;const d=await r.json();if(d.success&&d.access_token){$('addTokenAT').value=d.access_token;showToast('转换成功!AT已自动填入','success')}else{showToast('转换失败: '+(d.message||d.detail||'未知错误'),'error')}}catch(e){showToast('转换失败: '+e.message,'error')}},
549
+ convertEditST2AT=async()=>{const st=$('editTokenST').value.trim();if(!st)return showToast('请先输入 Session Token','error');try{showToast('正在转换 ST→AT...','info');const r=await apiRequest('/api/tokens/st2at',{method:'POST',body:JSON.stringify({st:st})});if(!r)return;const d=await r.json();if(d.success&&d.access_token){$('editTokenAT').value=d.access_token;showToast('转换成功!AT已自动填入','success')}else{showToast('转换失败: '+(d.message||d.detail||'未知错误'),'error')}}catch(e){showToast('转换失败: '+e.message,'error')}},
550
+ submitAddToken=async()=>{const st=$('addTokenST').value.trim(),remark=$('addTokenRemark').value.trim(),projectId=$('addTokenProjectId').value.trim(),projectName=$('addTokenProjectName').value.trim(),imageEnabled=$('addTokenImageEnabled').checked,videoEnabled=$('addTokenVideoEnabled').checked,imageConcurrency=parseInt($('addTokenImageConcurrency').value)||(-1),videoConcurrency=parseInt($('addTokenVideoConcurrency').value)||(-1);if(!st)return showToast('请输入 Session Token','error');const btn=$('addTokenBtn'),btnText=$('addTokenBtnText'),btnSpinner=$('addTokenBtnSpinner');btn.disabled=true;btnText.textContent='添加中...';btnSpinner.classList.remove('hidden');try{const r=await apiRequest('/api/tokens',{method:'POST',body:JSON.stringify({st:st,remark:remark||null,project_id:projectId||null,project_name:projectName||null,image_enabled:imageEnabled,video_enabled:videoEnabled,image_concurrency:imageConcurrency,video_concurrency:videoConcurrency})});if(!r){btn.disabled=false;btnText.textContent='添加';btnSpinner.classList.add('hidden');return}const d=await r.json();if(d.success){closeAddModal();await refreshTokens();showToast('Token添加成功','success')}else{showToast('添加失败: '+(d.detail||d.message||'未知错误'),'error')}}catch(e){showToast('添加失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='添加';btnSpinner.classList.add('hidden')}},
551
+ testToken=async(id)=>{try{showToast('正在测试Token...','info');const r=await apiRequest(`/api/tokens/${id}/test`,{method:'POST'});if(!r)return;const d=await r.json();if(d.success&&d.status==='success'){let msg=`Token有效!用户: ${d.email||'未知'}`;if(d.sora2_supported){const remaining=d.sora2_total_count-d.sora2_redeemed_count;msg+=`\nSora2: 支持 (${remaining}/${d.sora2_total_count})`;if(d.sora2_remaining_count!==undefined){msg+=`\n可用次数: ${d.sora2_remaining_count}`}}showToast(msg,'success');await refreshTokens()}else{showToast(`Token无效: ${d.message||'未知错误'}`,'error')}}catch(e){showToast('测试失败: '+e.message,'error')}},
552
+ toggleToken=async(id,isActive)=>{const action=isActive?'disable':'enable';try{const r=await apiRequest(`/api/tokens/${id}/${action}`,{method:'POST'});if(!r)return;const d=await r.json();d.success?(await refreshTokens(),showToast(isActive?'Token已禁用':'Token已启用','success')):showToast('操作失败','error')}catch(e){showToast('操作失败: '+e.message,'error')}},
553
+ toggleTokenStatus=async(id,active)=>{try{const r=await apiRequest(`/api/tokens/${id}/status`,{method:'PUT',body:JSON.stringify({is_active:active})});if(!r)return;const d=await r.json();d.success?(await refreshTokens(),showToast('状态更新成功','success')):showToast('更新失败','error')}catch(e){showToast('更新失败: '+e.message,'error')}},
554
+ deleteToken=async(id,skipConfirm=false)=>{if(!skipConfirm&&!confirm('确定要删除这个Token吗?'))return;try{const r=await apiRequest(`/api/tokens/${id}`,{method:'DELETE'});if(!r)return;const d=await r.json();if(d.success){await refreshTokens();if(!skipConfirm)showToast('删除成功','success');return true}else{if(!skipConfirm)showToast('删除失���','error');return false}}catch(e){if(!skipConfirm)showToast('删除失败: '+e.message,'error');return false}},
555
+ copySora2Code=async(code)=>{if(!code){showToast('没有可复制的邀请码','error');return}try{if(navigator.clipboard&&navigator.clipboard.writeText){await navigator.clipboard.writeText(code);showToast(`邀请码已复制: ${code}`,'success')}else{const textarea=document.createElement('textarea');textarea.value=code;textarea.style.position='fixed';textarea.style.opacity='0';document.body.appendChild(textarea);textarea.select();const success=document.execCommand('copy');document.body.removeChild(textarea);if(success){showToast(`邀请码已复制: ${code}`,'success')}else{showToast('复制失败: 浏览器不支持','error')}}}catch(e){showToast('复制失败: '+e.message,'error')}},
556
+ copyProjectId=async(projectId)=>{if(!projectId){showToast('没有可复制的Project ID','error');return}try{if(navigator.clipboard&&navigator.clipboard.writeText){await navigator.clipboard.writeText(projectId);showToast(`Project ID已复制: ${projectId}`,'success')}else{const textarea=document.createElement('textarea');textarea.value=projectId;textarea.style.position='fixed';textarea.style.opacity='0';document.body.appendChild(textarea);textarea.select();const success=document.execCommand('copy');document.body.removeChild(textarea);if(success){showToast(`Project ID已复制: ${projectId}`,'success')}else{showToast('复制失败: 浏览器不支持','error')}}}catch(e){showToast('复制失败: '+e.message,'error')}},
557
+ openSora2Modal=(id)=>{$('sora2TokenId').value=id;$('sora2InviteCode').value='';$('sora2Modal').classList.remove('hidden')},
558
+ closeSora2Modal=()=>{$('sora2Modal').classList.add('hidden');$('sora2TokenId').value='';$('sora2InviteCode').value=''},
559
+ openImportModal=()=>{$('importModal').classList.remove('hidden');$('importFile').value=''},
560
+ closeImportModal=()=>{$('importModal').classList.add('hidden');$('importFile').value=''},
561
+ exportTokens=()=>{if(allTokens.length===0){showToast('没有Token可导出','error');return}const exportData=allTokens.map(t=>({email:t.email,access_token:t.token,session_token:t.st||null,is_active:t.is_active,image_enabled:t.image_enabled!==false,video_enabled:t.video_enabled!==false,image_concurrency:t.image_concurrency||(-1),video_concurrency:t.video_concurrency||(-1)}));const dataStr=JSON.stringify(exportData,null,2);const dataBlob=new Blob([dataStr],{type:'application/json'});const url=URL.createObjectURL(dataBlob);const link=document.createElement('a');link.href=url;link.download=`tokens_${new Date().toISOString().split('T')[0]}.json`;document.body.appendChild(link);link.click();document.body.removeChild(link);URL.revokeObjectURL(url);showToast(`已导出 ${allTokens.length} 个Token`,'success')},
562
+ submitImportTokens=async()=>{const fileInput=$('importFile');if(!fileInput.files||fileInput.files.length===0){showToast('请选择文件','error');return}const file=fileInput.files[0];if(!file.name.endsWith('.json')){showToast('请选择JSON文件','error');return}try{const fileContent=await file.text();const importData=JSON.parse(fileContent);if(!Array.isArray(importData)){showToast('JSON格式错误:应为数组','error');return}if(importData.length===0){showToast('JSON文件为空','error');return}const btn=$('importBtn'),btnText=$('importBtnText'),btnSpinner=$('importBtnSpinner');btn.disabled=true;btnText.textContent='导入中...';btnSpinner.classList.remove('hidden');try{const r=await apiRequest('/api/tokens/import',{method:'POST',body:JSON.stringify({tokens:importData})});if(!r){btn.disabled=false;btnText.textContent='导入';btnSpinner.classList.add('hidden');return}const d=await r.json();if(d.success){closeImportModal();await refreshTokens();const msg=`导入成功!新增: ${d.added||0}, 更新: ${d.updated||0}`;showToast(msg,'success')}else{showToast('导入失败: '+(d.detail||d.message||'未知错误'),'error')}}catch(e){showToast('导入失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='导入';btnSpinner.classList.add('hidden')}}catch(e){showToast('文件解析失败: '+e.message,'error')}},
563
+ submitSora2Activate=async()=>{const tokenId=parseInt($('sora2TokenId').value),inviteCode=$('sora2InviteCode').value.trim();if(!tokenId)return showToast('Token ID无效','error');if(!inviteCode)return showToast('请输入邀请码','error');if(inviteCode.length!==6)return showToast('邀请码必须是6位','error');const btn=$('sora2ActivateBtn'),btnText=$('sora2ActivateBtnText'),btnSpinner=$('sora2ActivateBtnSpinner');btn.disabled=true;btnText.textContent='激活中...';btnSpinner.classList.remove('hidden');try{showToast('正在激活Sora2...','info');const r=await apiRequest(`/api/tokens/${tokenId}/sora2/activate?invite_code=${inviteCode}`,{method:'POST'});if(!r){btn.disabled=false;btnText.textContent='激活';btnSpinner.classList.add('hidden');return}const d=await r.json();if(d.success){closeSora2Modal();await refreshTokens();if(d.already_accepted){showToast('Sora2已激活(之前已接���)','success')}else{showToast(`Sora2激活成功!邀请码: ${d.invite_code||'无'}`,'success')}}else{showToast('激活失败: '+(d.message||'未知错误'),'error')}}catch(e){showToast('激活失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='激活';btnSpinner.classList.add('hidden')}},
564
+ loadAdminConfig=async()=>{try{const r=await apiRequest('/api/admin/config');if(!r)return;const d=await r.json();$('cfgErrorBan').value=d.error_ban_threshold||3;$('cfgAdminUsername').value=d.admin_username||'admin';$('cfgCurrentAPIKey').value=d.api_key||'';$('cfgDebugEnabled').checked=d.debug_enabled||false}catch(e){console.error('加载配置失败:',e)}},
565
+ saveAdminConfig=async()=>{try{const r=await apiRequest('/api/admin/config',{method:'POST',body:JSON.stringify({error_ban_threshold:parseInt($('cfgErrorBan').value)||3})});if(!r)return;const d=await r.json();d.success?showToast('配置保存成功','success'):showToast('保存失败','error')}catch(e){showToast('保存失败: '+e.message,'error')}},
566
+ updateAdminPassword=async()=>{const username=$('cfgAdminUsername').value.trim(),oldPwd=$('cfgOldPassword').value.trim(),newPwd=$('cfgNewPassword').value.trim();if(!oldPwd||!newPwd)return showToast('请输入旧密码和新密码','error');if(newPwd.length<4)return showToast('新密码至少4个字符','error');try{const r=await apiRequest('/api/admin/password',{method:'POST',body:JSON.stringify({username:username||undefined,old_password:oldPwd,new_password:newPwd})});if(!r)return;const d=await r.json();if(d.success){showToast('密码修改成功,请重新登录','success');setTimeout(()=>{localStorage.removeItem('adminToken');location.href='/login'},2000)}else{showToast('修改失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('修改失败: '+e.message,'error')}},
567
+ updateAPIKey=async()=>{const newKey=$('cfgNewAPIKey').value.trim();if(!newKey)return showToast('请输入新的 API Key','error');if(newKey.length<6)return showToast('API Key 至少6个字符','error');if(!confirm('确定要更新 API Key 吗?更新后需要通知所有客户端使用新密钥。'))return;try{const r=await apiRequest('/api/admin/apikey',{method:'POST',body:JSON.stringify({new_api_key:newKey})});if(!r)return;const d=await r.json();if(d.success){showToast('API Key 更新成功','success');$('cfgCurrentAPIKey').value=newKey;$('cfgNewAPIKey').value=''}else{showToast('更新失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('更新失败: '+e.message,'error')}},
568
+ toggleDebugMode=async()=>{const enabled=$('cfgDebugEnabled').checked;try{const r=await apiRequest('/api/admin/debug',{method:'POST',body:JSON.stringify({enabled:enabled})});if(!r)return;const d=await r.json();if(d.success){showToast(enabled?'调试模式已开启':'调试模式已关闭','success')}else{showToast('操作失败: '+(d.detail||'未知错误'),'error');$('cfgDebugEnabled').checked=!enabled}}catch(e){showToast('操作失败: '+e.message,'error');$('cfgDebugEnabled').checked=!enabled}},
569
+ loadProxyConfig=async()=>{try{const r=await apiRequest('/api/proxy/config');if(!r)return;const d=await r.json();$('cfgProxyEnabled').checked=d.proxy_enabled||false;$('cfgProxyUrl').value=d.proxy_url||''}catch(e){console.error('加载代理配置失败:',e)}},
570
+ saveProxyConfig=async()=>{try{const r=await apiRequest('/api/proxy/config',{method:'POST',body:JSON.stringify({proxy_enabled:$('cfgProxyEnabled').checked,proxy_url:$('cfgProxyUrl').value.trim()})});if(!r)return;const d=await r.json();d.success?showToast('代理配置保存成功','success'):showToast('保存失败','error')}catch(e){showToast('保存失败: '+e.message,'error')}},
571
+ toggleCacheOptions=()=>{const enabled=$('cfgCacheEnabled').checked;$('cacheOptions').style.display=enabled?'block':'none'},
572
+ loadCacheConfig=async()=>{try{console.log('开始加载缓存配置...');const r=await apiRequest('/api/cache/config');if(!r){console.error('API请求失败');return}const d=await r.json();console.log('缓存配置数据:',d);if(d.success&&d.config){const enabled=d.config.enabled!==false;const timeout=d.config.timeout||7200;const baseUrl=d.config.base_url||'';const effectiveUrl=d.config.effective_base_url||'';console.log('设置缓存启用:',enabled);console.log('设置超时时间:',timeout);console.log('设置域名:',baseUrl);console.log('生效URL:',effectiveUrl);$('cfgCacheEnabled').checked=enabled;$('cfgCacheTimeout').value=timeout;$('cfgCacheBaseUrl').value=baseUrl;if(effectiveUrl){$('cacheEffectiveUrlValue').textContent=effectiveUrl;$('cacheEffectiveUrl').classList.remove('hidden')}else{$('cacheEffectiveUrl').classList.add('hidden')}toggleCacheOptions();console.log('缓存配置加载成功')}else{console.error('缓存配置数据格式错误:',d)}}catch(e){console.error('加载缓存配置失败:',e);showToast('加载缓存配置失败: '+e.message,'error')}},
573
+ loadGenerationTimeout=async()=>{try{console.log('开始加载生成超时配置...');const r=await apiRequest('/api/generation/timeout');if(!r){console.error('API请求失败');return}const d=await r.json();console.log('生成超时配置数据:',d);if(d.success&&d.config){const imageTimeout=d.config.image_timeout||300;const videoTimeout=d.config.video_timeout||1500;console.log('设置图片超时:',imageTimeout);console.log('设置视频超时:',videoTimeout);$('cfgImageTimeout').value=imageTimeout;$('cfgVideoTimeout').value=videoTimeout;console.log('生成超时配置加载成功')}else{console.error('生成超时配置数据格式错误:',d)}}catch(e){console.error('加载生成超时配置失败:',e);showToast('加载生成超时配置失败: '+e.message,'error')}},
574
+ saveCacheConfig=async()=>{const enabled=$('cfgCacheEnabled').checked,timeout=parseInt($('cfgCacheTimeout').value)||7200,baseUrl=$('cfgCacheBaseUrl').value.trim();console.log('保存缓存配置:',{enabled,timeout,baseUrl});if(timeout<60||timeout>86400)return showToast('缓存超时时间必须在 60-86400 秒之间','error');if(baseUrl&&!baseUrl.startsWith('http://')&&!baseUrl.startsWith('https://'))return showToast('域名必须以 http:// 或 https:// 开头','error');try{console.log('保存缓存启用状态...');const r0=await apiRequest('/api/cache/enabled',{method:'POST',body:JSON.stringify({enabled:enabled})});if(!r0){console.error('保存缓存启用状态请求失败');return}const d0=await r0.json();console.log('缓存启用状态保存结果:',d0);if(!d0.success){console.error('保存缓存启用状态失败:',d0);return showToast('保存缓存启用状态失败','error')}console.log('保存超时时间...');const r1=await apiRequest('/api/cache/config',{method:'POST',body:JSON.stringify({timeout:timeout})});if(!r1){console.error('保存超时时间请求失败');return}const d1=await r1.json();console.log('超时时间保存结果:',d1);if(!d1.success){console.error('保存超时时间失败:',d1);return showToast('保存超时时间失败','error')}console.log('保存域名...');const r2=await apiRequest('/api/cache/base-url',{method:'POST',body:JSON.stringify({base_url:baseUrl})});if(!r2){console.error('保存域名请求失败');return}const d2=await r2.json();console.log('域名保存结果:',d2);if(d2.success){showToast('缓存配置保存成功','success');console.log('等待配置文件写入完成...');await new Promise(r=>setTimeout(r,200));console.log('重新加载配置...');await loadCacheConfig()}else{console.error('保存域名失败:',d2);showToast('保存域名失败','error')}}catch(e){console.error('保存失败:',e);showToast('保存失败: '+e.message,'error')}},
575
+ saveGenerationTimeout=async()=>{const imageTimeout=parseInt($('cfgImageTimeout').value)||300,videoTimeout=parseInt($('cfgVideoTimeout').value)||1500;console.log('保存生成超时配置:',{imageTimeout,videoTimeout});if(imageTimeout<60||imageTimeout>3600)return showToast('图片超时时间必须在 60-3600 秒之间','error');if(videoTimeout<60||videoTimeout>7200)return showToast('视频超时时间必须在 60-7200 秒之间','error');try{const r=await apiRequest('/api/generation/timeout',{method:'POST',body:JSON.stringify({image_timeout:imageTimeout,video_timeout:videoTimeout})});if(!r){console.error('保存请求失败');return}const d=await r.json();console.log('保存结果:',d);if(d.success){showToast('生成超时配置保存成功','success');await new Promise(r=>setTimeout(r,200));await loadGenerationTimeout()}else{console.error('保存失败:',d);showToast('保存失败','error')}}catch(e){console.error('保存失败:',e);showToast('保存失败: '+e.message,'error')}},
576
+ toggleATAutoRefresh=async()=>{try{const enabled=$('atAutoRefreshToggle').checked;const r=await apiRequest('/api/token-refresh/enabled',{method:'POST',body:JSON.stringify({enabled:enabled})});if(!r){$('atAutoRefreshToggle').checked=!enabled;return}const d=await r.json();if(d.success){showToast(enabled?'AT自动刷新已启用':'AT自动刷新已禁用','success')}else{showToast('操作失败: '+(d.detail||'未知错误'),'error');$('atAutoRefreshToggle').checked=!enabled}}catch(e){showToast('操作失败: '+e.message,'error');$('atAutoRefreshToggle').checked=!enabled}},
577
+ loadATAutoRefreshConfig=async()=>{try{const r=await apiRequest('/api/token-refresh/config');if(!r)return;const d=await r.json();if(d.success&&d.config){$('atAutoRefreshToggle').checked=d.config.at_auto_refresh_enabled||false}else{console.error('AT自动刷新配置数据格式错误:',d)}}catch(e){console.error('加载AT自动刷新配置失败:',e)}},
578
+ loadLogs=async()=>{try{const r=await apiRequest('/api/logs?limit=100');if(!r)return;const logs=await r.json();const tb=$('logsTableBody');tb.innerHTML=logs.map(l=>`<tr><td class="py-2.5 px-3">${l.operation}</td><td class="py-2.5 px-3"><span class="text-xs ${l.token_email?'text-blue-600':'text-muted-foreground'}">${l.token_email||'未知'}</span></td><td class="py-2.5 px-3"><span class="inline-flex items-center rounded px-2 py-0.5 text-xs ${l.status_code===200?'bg-green-50 text-green-700':'bg-red-50 text-red-700'}">${l.status_code}</span></td><td class="py-2.5 px-3">${l.duration.toFixed(2)}</td><td class="py-2.5 px-3 text-xs text-muted-foreground">${l.created_at?new Date(l.created_at).toLocaleString('zh-CN'):'-'}</td></tr>`).join('')}catch(e){console.error('加载日志失败:',e)}},
579
+ refreshLogs=async()=>{await loadLogs()},
580
+ showToast=(m,t='info')=>{const d=document.createElement('div'),bc={success:'bg-green-600',error:'bg-destructive',info:'bg-primary'};d.className=`fixed bottom-4 right-4 ${bc[t]||bc.info} text-white px-4 py-2.5 rounded-lg shadow-lg text-sm font-medium z-50 animate-slide-up`;d.textContent=m;document.body.appendChild(d);setTimeout(()=>{d.style.opacity='0';d.style.transition='opacity .3s';setTimeout(()=>d.parentNode&&document.body.removeChild(d),300)},2000)},
581
+ logout=()=>{if(!confirm('确定要退出登录吗?'))return;localStorage.removeItem('adminToken');location.href='/login'},
582
+ switchTab=t=>{const cap=n=>n.charAt(0).toUpperCase()+n.slice(1);['tokens','settings','logs'].forEach(n=>{const active=n===t;$(`panel${cap(n)}`).classList.toggle('hidden',!active);$(`tab${cap(n)}`).classList.toggle('border-primary',active);$(`tab${cap(n)}`).classList.toggle('text-primary',active);$(`tab${cap(n)}`).classList.toggle('border-transparent',!active);$(`tab${cap(n)}`).classList.toggle('text-muted-foreground',!active)});if(t==='settings'){loadAdminConfig();loadProxyConfig();loadCacheConfig();loadGenerationTimeout();loadATAutoRefreshConfig()}else if(t==='logs'){loadLogs()}};
583
+ window.addEventListener('DOMContentLoaded',()=>{checkAuth();refreshTokens();loadATAutoRefreshConfig()});
584
+ </script>
585
+ </body>
586
+ </html>