JXJBing commited on
Commit
9652ab4
·
verified ·
1 Parent(s): 3e9d0d5

Upload 36 files

Browse files
.gitignore ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 确保文本文件在仓库中使用 LF
2
+ * text=auto eol=lf
3
+ # API密钥配置
4
+ api_keys.json
5
+ admin_config.json
6
+ *.key
7
+
8
+ # 环境变量和配置
9
+ .env
10
+ *.env
11
+ config.local.json
12
+
13
+ # 生成的图像文件
14
+ src/static/images/*
15
+ !src/static/images/.gitkeep
16
+
17
+ # Python
18
+ __pycache__/
19
+ *.py[cod]
20
+ *$py.class
21
+ *.so
22
+ .Python
23
+ env/
24
+ build/
25
+ develop-eggs/
26
+ dist/
27
+ downloads/
28
+ eggs/
29
+ .eggs/
30
+ lib/
31
+ lib64/
32
+ parts/
33
+ sdist/
34
+ var/
35
+ *.egg-info/
36
+ .installed.cfg
37
+ *.egg
38
+ venv/
39
+ .venv/
40
+
41
+ # 日志文件
42
+ *.log
43
+ logs/
44
+
45
+ # IDE配置
46
+ .idea/
47
+ .vscode/
48
+ *.swp
49
+ *.swo
50
+ .DS_Store
51
+
52
+ # Docker
53
+ .dockerignore
Dockerfile ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # 设置Python环境
6
+ ENV PYTHONDONTWRITEBYTECODE=1
7
+ ENV PYTHONUNBUFFERED=1
8
+ ENV PYTHONIOENCODING=utf-8
9
+
10
+ # 安装依赖
11
+ COPY requirements.txt .
12
+ RUN pip install --no-cache-dir -r requirements.txt
13
+
14
+ # 复制应用代码
15
+ COPY . .
16
+
17
+ # 创建必要的目录
18
+ RUN mkdir -p /app/src/static/images && \
19
+ chmod -R 777 /app/src/static
20
+
21
+ # 暴露端口
22
+ EXPOSE 8890
23
+
24
+ # 设置健康检查
25
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
26
+ CMD curl -f http://localhost:8890/health || exit 1
27
+
28
+ # 启动应用
29
+ CMD ["python", "run.py"]
README.md CHANGED
@@ -1,11 +1,485 @@
1
- ---
2
- title: Wulingling
3
- emoji: 🐠
4
- colorFrom: pink
5
- colorTo: blue
6
- sdk: docker
7
- pinned: false
8
- license: mit
9
- ---
10
-
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # OpenAI兼容的Sora API服务
2
+
3
+ 这是一个为Sora提供OpenAI兼容接口的API服务。该服务使用cloudscraper绕过Cloudflare验证,支持多key轮询、并发处理和标准的OpenAI接口格式。
4
+
5
+ ## 功能特点
6
+
7
+ - **OpenAI兼容接口**:完全兼容OpenAI的`/v1/chat/completions`接口
8
+ - **CF验证绕过**:使用cloudscraper库成功绕过Cloudflare验证
9
+ - **多key轮询**:支持多个Sora认证token,根据权重和速率限制智能选择
10
+ - **并发处理**:支持多个并发请求
11
+ - **流式响应**:支持SSE格式的流式响应
12
+ - **图像处理**:支持文本到图像生成和图像到图像生成(Remix)
13
+ - **异步处理**:支持异步生成图像,返回立即响应,防止请求超时
14
+ - **状态查询**:提供API端点查询异步任务的状态和结果
15
+ - **优化性能**:经过代码优化,提高请求处理速度和资源利用率
16
+ - **健康检查**:支持容器健康检查功能
17
+
18
+ ## 环境要求
19
+
20
+ - Python 3.8+
21
+ - FastAPI 0.95.0+
22
+ - cloudscraper 1.2.71+
23
+ - 其他依赖见requirements.txt
24
+
25
+ ## 快速部署指南
26
+
27
+
28
+ 你可以直接运行不指定任何环境变量,所有环境变量都可以在面板里面配置
29
+
30
+
31
+ 管理员登录密钥默认:sk-123456
32
+
33
+
34
+ api请求密钥不设置默认和管理员登录密钥相同
35
+
36
+
37
+ docker 一键运行
38
+ ```bash
39
+ docker run -d -p 8890:8890 --name sora-api 1hei1/sora-api:latest
40
+ ```
41
+
42
+
43
+
44
+ ### 方法一:直接运行
45
+
46
+ 1. 克隆仓库
47
+ ```bash
48
+ git clone https://github.com/1hei1/sora-api.git
49
+ cd sora-api
50
+ ```
51
+
52
+ 2. 安装依赖
53
+ ```bash
54
+ pip install -r requirements.txt
55
+ ```
56
+
57
+ 3. 配置API Keys(两种方式)
58
+ - **方式1**: 创建api_keys.json文件
59
+ ```json
60
+ [
61
+ {"key": "Bearer your-sora-token-1", "weight": 1, "max_rpm": 60},
62
+ {"key": "Bearer your-sora-token-2", "weight": 2, "max_rpm": 60}
63
+ ]
64
+ ```
65
+ - **方式2**: 设置环境变量
66
+ ```bash
67
+ # Linux/macOS
68
+ export API_KEYS='[{"key": "Bearer your-sora-token-1", "weight": 1, "max_rpm": 60}, {"key": "Bearer your-sora-token-2", "weight": 2, "max_rpm": 60}]' 
69
+
70
+ # Windows (PowerShell)
71
+ $env:API_KEYS='[{"key": "Bearer your-sora-token-1", "weight": 1, "max_rpm": 60}, {"key": "Bearer your-sora-token-2", "weight": 2, "max_rpm": 60}]'
72
+
73
+ # Windows (CMD)
74
+ set API_KEYS=[{"key": "Bearer your-sora-token-1", "weight": 1, "max_rpm": 60}, {"key": "Bearer your-sora-token-2", "weight": 2, "max_rpm": 60}] 
75
+ ```
76
+
77
+ 4. 配置代理(可选,如果需要)
78
+ ```bash
79
+ # Linux/macOS - 基本代理
80
+ export PROXY_HOST=127.0.0.1
81
+ export PROXY_PORT=7890
82
+
83
+ # Linux/macOS - 带认证的代理
84
+ export PROXY_HOST=127.0.0.1
85
+ export PROXY_PORT=7890
86
+ export PROXY_USER=username
87
+ export PROXY_PASS=password
88
+
89
+ # Windows (PowerShell) - 基本代理
90
+ $env:PROXY_HOST="127.0.0.1"
91
+ $env:PROXY_PORT="7890"
92
+
93
+ # Windows (PowerShell) - 带认证的代理
94
+ $env:PROXY_HOST="127.0.0.1"
95
+ $env:PROXY_PORT="7890"
96
+ $env:PROXY_USER="username"
97
+ $env:PROXY_PASS="password"
98
+
99
+ # Windows (CMD) - 基本代理
100
+ set PROXY_HOST=127.0.0.1
101
+ set PROXY_PORT=7890
102
+
103
+ # Windows (CMD) - 带认证的代理
104
+ set PROXY_HOST=127.0.0.1
105
+ set PROXY_PORT=7890
106
+ set PROXY_USER=username
107
+ set PROXY_PASS=password
108
+ ```
109
+
110
+ 5. 启动服务
111
+ ```bash
112
+ python run.py
113
+ ```
114
+
115
+ 6. 访问服务
116
+ - API服务地址: http://localhost:8890
117
+ - 后台管理面板: http://localhost:8890/admin
118
+
119
+ ### 方法二:Docker部署
120
+
121
+ 1. 构建Docker镜像
122
+ ```bash
123
+ docker build -t sora-api .
124
+ ```
125
+
126
+ 2. 运行Docker容器(不同配置选项)
127
+
128
+ **基本运行方式**:
129
+ ```bash
130
+ docker run -d -p 8890:8890 --name sora-api sora-api
131
+ ```
132
+
133
+ **使用预打包镜像**:
134
+ ```bash
135
+ docker run -d -p 8890:8890 --name sora-api 1hei1/sora-api:latest
136
+ ```
137
+
138
+ **使用预打包镜像并配置API密钥**:
139
+ ```bash
140
+ docker run -d -p 8890:8890 \
141
+ -e API_KEYS='[{"key": "Bearer your-sora-token-1", "weight": 1, "max_rpm": 60}, {"key": "Bearer your-sora-token-2", "weight": 2, "max_rpm": 60}]' \
142
+ --name sora-api \
143
+ 1hei1/sora-api:v0.1
144
+ ```
145
+
146
+ **使用预打包镜像并配置代理**:
147
+ ```bash
148
+ docker run -d -p 8890:8890 \
149
+ -e API_KEYS='[{"key": "Bearer your-sora-token-1", "weight": 1, "max_rpm": 60}]' \
150
+ -e PROXY_HOST=host.docker.internal \
151
+ -e PROXY_PORT=7890 \
152
+ --name sora-api \
153
+ 1hei1/sora-api:v0.1
154
+ ```
155
+
156
+ **带API密钥配置**:
157
+ ```bash
158
+ docker run -d -p 8890:8890 \
159
+ -e API_KEYS='[{"key": "Bearer your-sora-token-1", "weight": 1, "max_rpm": 60}, {"key": "Bearer your-sora-token-2", "weight": 2, "max_rpm": 60}]' \
160
+ --name sora-api \
161
+ sora-api
162
+ ```
163
+
164
+ **带基本代理配置**:
165
+ ```bash
166
+ docker run -d -p 8890:8890 \
167
+ -e API_KEYS='[{"key": "Bearer your-sora-token-1", "weight": 1, "max_rpm": 60}]' \
168
+ -e PROXY_HOST=host.docker.internal \
169
+ -e PROXY_PORT=7890 \
170
+ --name sora-api \
171
+ sora-api
172
+ ```
173
+
174
+ **带认证代理配置**:
175
+ ```bash
176
+ docker run -d -p 8890:8890 \
177
+ -e API_KEYS='[{"key": "Bearer your-sora-token-1", "weight": 1, "max_rpm": 60}]' \
178
+ -e PROXY_HOST=host.docker.internal \
179
+ -e PROXY_PORT=7890 \
180
+ -e PROXY_USER=username \
181
+ -e PROXY_PASS=password \
182
+ --name sora-api \
183
+ sora-api
184
+ ```
185
+
186
+ **使用外部配置文件**:
187
+ ```bash
188
+ # 首先确保api_keys.json文件已正确配置
189
+ docker run -d -p 8890:8890 \
190
+ -v $(pwd)/api_keys.json:/app/api_keys.json \
191
+ -e PROXY_HOST=host.docker.internal \
192
+ -e PROXY_PORT=7890 \
193
+ --name sora-api \
194
+ sora-api
195
+ ```
196
+
197
+ **挂载本地目录保存图片**:
198
+ ```bash
199
+ docker run -d -p 8890:8890 \
200
+ -v /your/local/path:/app/src/static/images \
201
+ --name sora-api \
202
+ sora-api
203
+ ```
204
+
205
+ **启用详细日志**:
206
+ ```bash
207
+ docker run -d -p 8890:8890 \
208
+ -e API_KEYS='[{"key": "Bearer your-sora-token-1", "weight": 1, "max_rpm": 60}]' \
209
+ -e VERBOSE_LOGGING=true \
210
+ --name sora-api \
211
+ sora-api
212
+ ```
213
+
214
+ **注意**: 在Docker中使用宿主机代理时,请使用`host.docker.internal`而不是`127.0.0.1`作为代理主机地址。
215
+
216
+ 3. 检查容器状态
217
+ ```bash
218
+ docker ps
219
+ docker logs sora-api
220
+ ```
221
+
222
+ 4. 停止和移除容器
223
+ ```bash
224
+ docker stop sora-api
225
+ docker rm sora-api
226
+ ```
227
+
228
+ ## 环境变量说明
229
+
230
+ | 环境变量 | 描述 | 默认值 | 示例 |
231
+ |---------|------|--------|------|
232
+ | `API_HOST` | API服务监听地址 | `0.0.0.0` | `127.0.0.1` |
233
+ | `API_PORT` | API服务端口 | `8890` | `9000` |
234
+ | `BASE_URL` | API基础URL(生成图片的时候需要用到) | `http://0.0.0.0:8890` | `https://api.example.com` | 
235
+ | `PROXY_HOST` | HTTP代理主机 | 空(不使用代理) | `127.0.0.1` |
236
+ | `PROXY_PORT` | HTTP代理端口 | 空(不使用代理) | `7890` |
237
+ | `PROXY_USER` | HTTP代理用户名 | 空(不使用认证) | `username` |
238
+ | `PROXY_PASS` | HTTP代理密码 | 空(不使用认证) | `password` |
239
+ | `IMAGE_SAVE_DIR` | 图片保存目录 | `src/static/images` | `/data/images` |
240
+ | `IMAGE_LOCALIZATION` | 是否启用图片本地化 | `False` | `True` |
241
+ | `ADMIN_KEY` | 管理员API密钥(登录界面输入的密码) | `sk-123456` | `sk-youradminkey` |
242
+ | `API_AUTH_TOKEN` | API认证令牌(使用api服务传入的key) | 空 | `your-auth-token` |
243
+ | `VERBOSE_LOGGING` | 是否启用详细日志 | `False` | `True` |
244
+
245
+ ## API密钥配置说明
246
+
247
+ API密钥配置采用JSON格式,每个密钥包含以下属性:
248
+
249
+ - `key`: Sora认证令牌(必须包含Bearer前缀)
250
+ - `weight`: 轮询权重,数字越大被选中概率越高
251
+ - `max_rpm`: 每分钟最大请求数(速率限制)
252
+
253
+ 示例:
254
+ ```json
255
+ [
256
+ {
257
+ "key": "Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IjE5MzQ0ZTY1LWJiYzktNDRkMS1hOWQwLWY5NTdiMDc5YmQwZSIsInR5cCI6IkpXVCJ9...",
258
+ "weight": 1,
259
+ "max_rpm": 60
260
+ },
261
+ {
262
+ "key": "Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IjE5MzQ0ZTY1LWJiYzktNDRkMS1hOWQwLWY5NTdiMDc5YmQwZSIsInR5cCI6IkpXVCJ9...",
263
+ "weight": 2,
264
+ "max_rpm": 60
265
+ }
266
+ ]
267
+ ```
268
+
269
+ ## 使用示例
270
+
271
+ ### 使用curl发送请求
272
+
273
+ ```bash
274
+ # 文本到图像请求(非流式)
275
+ curl -X POST http://localhost:8890/v1/chat/completions \
276
+ -H "Content-Type: application/json" \
277
+ -H "Authorization: Bearer your-api-key" \
278
+ -d '{
279
+ "model": "sora-1.0",
280
+ "messages": [
281
+ {"role": "user", "content": "生成一只在草地上奔跑的金毛犬"}
282
+ ],
283
+ "n": 1,
284
+ "stream": false
285
+ }'
286
+
287
+ # 文本到图像请求(流式)
288
+ curl -X POST http://localhost:8890/v1/chat/completions \
289
+ -H "Content-Type: application/json" \
290
+ -H "Authorization: Bearer your-api-key" \
291
+ -d '{
292
+ "model": "sora-1.0",
293
+ "messages": [
294
+ {"role": "user", "content": "生成一只在草地上奔跑的金毛犬"}
295
+ ],
296
+ "n": 1,
297
+ "stream": true
298
+ }'
299
+
300
+ # 查询异步任务状态
301
+ curl -X GET http://localhost:8890/v1/generation/chatcmpl-123456789abcdef \
302
+ -H "Authorization: Bearer your-api-key"
303
+ ```
304
+
305
+ ## 常见问题排查
306
+
307
+ 1. **连接超时或无法连接**
308
+ - 检查代理配置是否正确
309
+ - 如使用代理认证,确认用户名密码正确
310
+ - 确认Sora服务器是否可用
311
+ - 检查本地网络连接
312
+
313
+ 2. **API密钥加载失败**
314
+ - 确认api_keys.json格式正确
315
+ - 检查环境变量API_KEYS是否正确设置
316
+ - 查看日志中的错误信息
317
+
318
+ 3. **图片生成失败**
319
+ - 确认Sora令牌有效性
320
+ - 查看日志中的错误信息
321
+ - 检查是否超出账户额度限制
322
+
323
+ 4. **Docker容器启动失败**
324
+ - 检查端口是否被占用
325
+ - 确认环境变量设置正确
326
+ - 查看Docker日志中的错误信息
327
+
328
+ 5. **环境变量API_AUTH_TOKEN和ADMIN_KEY的区别**
329
+ - 环境变量 API_AUTH_TOKEN代表你在cheery studio 或者newapi里调用填写的令牌
330
+ - 环境变量 ADMIN_KEY 代表你管理面板的登录密码
331
+ - 当不设置API_AUTH_TOKE时 其值默认等于ADMIN_KEY的值
332
+
333
+ 6. **环境变量BASE_URL的作用**
334
+ - 这个是图片本地化时设置的,比如生成了一张图片,获取到图片原始url,由于有的客户端不能访问sora,所以要本地化,这个base_url就是指定本地化图片的url前缀。
335
+
336
+ 6. **token无效**
337
+ - 首次使用的token需要设置用户名,可以使用下面的脚本批量设置用户名:
338
+ ```python
339
+ import random
340
+ import string
341
+ import logging
342
+ import cloudscraper
343
+
344
+ # Configure logging
345
+ tlogging = logging.getLogger(__name__)
346
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
347
+
348
+ # --- Configuration ---
349
+ PROXY = {
350
+ "http": "http://127.0.0.1:7890",
351
+ "https": "http://127.0.0.1:7890"
352
+ }
353
+ PROFILE_API = "https://sora.chatgpt.com/backend/me"
354
+ TOKENS_FILE = "tokens.txt" # 每行一个 Bearer token
355
+ RESULTS_FILE = "update_results.txt" # 保存更新结果
356
+ USERNAME_LENGTH = 8 # 随机用户名长度
357
+
358
+ # --- Utilities ---
359
+ def random_username(length: int = USERNAME_LENGTH) -> str:
360
+ """生成全小写随机用户名"""
361
+ return ''.join(random.choices(string.ascii_lowercase, k=length))
362
+
363
+
364
+ def sanitize_headers(headers: dict) -> dict:
365
+ """
366
+ 移除所有非 Latin-1 字符,确保 headers 可以被底层 HTTP 库正确编码。
367
+ """
368
+ new = {}
369
+ for k, v in headers.items():
370
+ if isinstance(v, str):
371
+ new[k] = v.encode('latin-1', 'ignore').decode('latin-1')
372
+ else:
373
+ new[k] = v
374
+ return new
375
+
376
+
377
+ class SoraBatchUpdater:
378
+ def __init__(self, proxy: dict = None):
379
+ self.proxy = proxy or {}
380
+
381
+ def update_username_for_token(self, token: str) -> tuple[bool, str]:
382
+ """
383
+ 针对单个 Bearer token,生成随机用户名并发送更新请求。
384
+ 返回 (success, message)。
385
+ """
386
+ scraper = cloudscraper.create_scraper()
387
+ if self.proxy:
388
+ scraper.proxies = self.proxy
389
+
390
+ headers = {
391
+ "Accept": "*/*",
392
+ "Accept-Language": "zh-CN,zh;q=0.9",
393
+ "Content-Type": "application/json",
394
+ "Authorization": f"Bearer {token}",
395
+ "sec-ch-ua": '"Chromium";v="136", "Google Chrome";v="136", "Not.A/Brand";v="99"',
396
+ "sec-ch-ua-mobile": "?0",
397
+ "sec-ch-ua-platform": '"Windows"',
398
+ "sec-fetch-dest": "empty",
399
+ "sec-fetch-mode": "cors",
400
+ "sec-fetch-site": "same-origin",
401
+ }
402
+ headers = sanitize_headers(headers)
403
+
404
+ new_username = random_username()
405
+ payload = {"username": new_username}
406
+
407
+ try:
408
+ resp = scraper.post(
409
+ PROFILE_API,
410
+ headers=headers,
411
+ json=payload,
412
+ allow_redirects=False,
413
+ timeout=15
414
+ )
415
+ status = resp.status_code
416
+ if resp.ok:
417
+ msg = f"OK ({new_username})"
418
+ logging.info("Token %s: updated to %s", token[:6], new_username)
419
+ return True, msg
420
+ else:
421
+ text = resp.text.replace('\n', '')
422
+ msg = f"Failed {status}: {text}" # 简要错误信息
423
+ logging.warning("Token %s: %s", token[:6], msg)
424
+ return False, msg
425
+ except Exception as e:
426
+ msg = str(e)
427
+ logging.error("Token %s exception: %s", token[:6], msg)
428
+ return False, msg
429
+
430
+ def batch_update(self, tokens: list[str]) -> None:
431
+ """
432
+ 对一组 Bearer token 批量更新用户名,并将结果写入 RESULTS_FILE。
433
+ """
434
+ results = []
435
+ for token in tokens:
436
+ success, message = self.update_username_for_token(token)
437
+ results.append((token, success, message))
438
+
439
+ # 写入结果文件
440
+ with open(RESULTS_FILE, 'w', encoding='utf-8') as f:
441
+ for token, success, msg in results:
442
+ status = 'SUCCESS' if success else 'ERROR'
443
+ f.write(f"{token} ---- {status} ---- {msg}\n")
444
+ logging.info("Batch update complete. Results saved to %s", RESULTS_FILE)
445
+
446
+
447
+ def load_tokens(filepath: str) -> list[str]:
448
+ """从文件加载每行一个 token 的列表"""
449
+ try:
450
+ with open(filepath, 'r', encoding='utf-8') as f:
451
+ return [line.strip() for line in f if line.strip()]
452
+ except FileNotFoundError:
453
+ logging.error("Tokens file not found: %s", filepath)
454
+ return []
455
+
456
+
457
+ if __name__ == '__main__':
458
+ tokens = load_tokens(TOKENS_FILE)
459
+ if not tokens:
460
+ logging.error("No tokens to update. Exiting.")
461
+ else:
462
+ updater = SoraBatchUpdater(proxy=PROXY)
463
+ updater.batch_update(tokens)
464
+
465
+ ```
466
+
467
+
468
+ ## 性能优化
469
+
470
+ 最新版本包含以下性能优化:
471
+
472
+ 1. **代码重构**:简化了代码结构,提高可读性和可维护性
473
+ 2. **内存优化**:减少不必要的内��使用,优化大型图像处理
474
+ 3. **异步处理**:全面使用异步处理提高并发性能
475
+ 4. **错误处理**:改进了错误处理和日志记录
476
+ 5. **密钥管理**:优化了密钥轮询算法,提高了可靠性
477
+ 6. **容器优化**:增强了Docker容器配置,支持健康检查
478
+
479
+ ## 贡献
480
+
481
+ 欢迎提交问题报告和改进建议!
482
+
483
+ ## 许可证
484
+
485
+ MIT
api_keys.json.example ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "key": "Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IjE5MzQ0ZTY1LWJiYzktNDRkMS1hOWQwLWY5NTdiMDc5YmQwZSIsInR5cCI6IkpXVCJ9...",
4
+ "weight": 1,
5
+ "max_rpm": 60
6
+ },
7
+ {
8
+ "key": "Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IjE5MzQ0ZTY1LWJiYzktNDRkMS1hOWQwLWY5NTdiMDc5YmQwZSIsInR5cCI6IkpXVCJ9...",
9
+ "weight": 2,
10
+ "max_rpm": 60
11
+ }
12
+ ]
docs/image_localization.md ADDED
@@ -0,0 +1 @@
 
 
1
+
requirements.txt ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Web框架
2
+ fastapi>=0.101.1
3
+ uvicorn>=0.23.2
4
+ pydantic>=2.3.0
5
+ python-multipart==0.0.6
6
+
7
+ # HTTP请求
8
+ cloudscraper==1.2.71
9
+ aiohttp>=3.8.5
10
+ aiofiles==0.8.0
11
+
12
+ # 工具
13
+ python-dotenv>=1.0.0
14
+ requests==2.30.0
15
+ pillow==10.0.0
16
+ cryptography==41.0.3
17
+ psutil==5.9.5
18
+ tzdata>=2023.3
19
+ pytz>=2023.3
20
+ retry>=0.9.2
21
+ fuzzywuzzy>=0.18.0
22
+ python-Levenshtein>=0.21.1
23
+ tzlocal>=5.0.1
24
+ humanize>=4.7.0
25
+ prettytable>=3.8.0
26
+ natsort>=8.4.0
27
+ tenacity>=8.2.3
28
+ PyJWT>=2.6.0
run.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ import sys
3
+ import os
4
+ import logging
5
+
6
+ # 设置日志
7
+ logging.basicConfig(
8
+ level=logging.INFO,
9
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
10
+ )
11
+ logger = logging.getLogger("sora-api")
12
+
13
+ # 设置控制台输出编码为UTF-8
14
+ if sys.platform.startswith('win'):
15
+ os.system("chcp 65001")
16
+ sys.stdout.reconfigure(encoding='utf-8')
17
+ elif sys.stdout.encoding != 'utf-8':
18
+ sys.stdout.reconfigure(encoding='utf-8')
19
+
20
+ # 确保src目录在路径中
21
+ sys.path.insert(0, os.path.abspath(os.path.dirname(__file__)))
22
+
23
+ # 提前加载app和key_manager,确保API密钥在启动前已加载
24
+ from src.app import app, key_manager
25
+ from src.main import init_app
26
+ from src.config import Config
27
+ import uvicorn
28
+
29
+ if __name__ == "__main__":
30
+ # 设置环境变量确保正确处理UTF-8
31
+ os.environ["PYTHONIOENCODING"] = "utf-8"
32
+
33
+ # 初始化应用
34
+ init_app()
35
+
36
+ # 启动服务
37
+ logger.info(f"启动OpenAI兼容的Sora API服务: {Config.HOST}:{Config.PORT}")
38
+ logger.info(f"已加载 {len(key_manager.keys)} 个API密钥")
39
+ uvicorn.run(
40
+ "src.app:app",
41
+ host=Config.HOST,
42
+ port=Config.PORT,
43
+ reload=False # 生产环境关闭自动重载
44
+ )
src/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # Sora API服务包
2
+ # 包含对Sora的API调用封装,提供OpenAI兼容接口
src/api/__init__.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from fastapi import APIRouter
3
+ from .admin import router as admin_router
4
+ from .generation import router as generation_router
5
+ from .chat import router as chat_router
6
+ from .health import router as health_router
7
+ # 添加auth路由
8
+ from .auth import router as auth_router
9
+
10
+ # 设置日志
11
+ logger = logging.getLogger("sora-api.api")
12
+
13
+ # 创建v1版本路由(兼容OpenAI API)
14
+ v1_router = APIRouter(prefix="/v1")
15
+
16
+ # 创建主路由
17
+ main_router = APIRouter()
18
+
19
+ # 向v1路由注册相关功能路由
20
+ v1_router.include_router(generation_router)
21
+ v1_router.include_router(chat_router)
22
+
23
+ # 注册所有路由
24
+ main_router.include_router(v1_router) # 保持与OpenAI API兼容的v1前缀路由
25
+ main_router.include_router(admin_router, tags=["admin"])
26
+ main_router.include_router(health_router, tags=["health"])
27
+ main_router.include_router(auth_router, tags=["auth"])
28
+
29
+ logger.info("API路由已初始化")
src/api/admin.py ADDED
@@ -0,0 +1,463 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import logging
3
+ import dotenv
4
+ from typing import Dict, Any, List
5
+ from fastapi import APIRouter, Depends, HTTPException
6
+ from fastapi.responses import FileResponse
7
+
8
+ from ..models.schemas import ApiKeyCreate, ApiKeyUpdate, ConfigUpdate, LogLevelUpdate, BatchOperation, BatchImportOperation
9
+ from ..api.dependencies import verify_admin, verify_admin_jwt
10
+ from ..config import Config
11
+ from ..key_manager import key_manager
12
+ from ..sora_integration import SoraClient
13
+
14
+ # 设置日志
15
+ logger = logging.getLogger("sora-api.admin")
16
+
17
+ # 日志系统配置
18
+ class LogConfig:
19
+ LEVEL = os.getenv("LOG_LEVEL", "WARNING").upper()
20
+ FORMAT = "%(asctime)s [%(levelname)s] %(message)s"
21
+
22
+ # 创建路由
23
+ router = APIRouter(prefix="/api")
24
+
25
+ # 密钥管理API
26
+ @router.get("/keys")
27
+ async def get_all_keys(admin_token = Depends(verify_admin_jwt)):
28
+ """获取所有API密钥"""
29
+ return key_manager.get_all_keys()
30
+
31
+ @router.get("/keys/{key_id}")
32
+ async def get_key(key_id: str, admin_token = Depends(verify_admin_jwt)):
33
+ """获取单个API密钥详情"""
34
+ key = key_manager.get_key_by_id(key_id)
35
+ if not key:
36
+ raise HTTPException(status_code=404, detail="密钥不存在")
37
+ return key
38
+
39
+ @router.post("/keys")
40
+ async def create_key(key_data: ApiKeyCreate, admin_token = Depends(verify_admin_jwt)):
41
+ """创建新API密钥"""
42
+ try:
43
+ # 确保密钥值包含 Bearer 前缀
44
+ key_value = key_data.key_value
45
+ if not key_value.startswith("Bearer "):
46
+ key_value = f"Bearer {key_value}"
47
+
48
+ new_key = key_manager.add_key(
49
+ key_value,
50
+ name=key_data.name,
51
+ weight=key_data.weight,
52
+ rate_limit=key_data.rate_limit,
53
+ is_enabled=key_data.is_enabled,
54
+ notes=key_data.notes
55
+ )
56
+
57
+ # 通过Config永久保存所有密钥
58
+ Config.save_api_keys(key_manager.keys)
59
+
60
+ return new_key
61
+ except Exception as e:
62
+ logger.error(f"创建密钥失败: {str(e)}", exc_info=True)
63
+ raise HTTPException(status_code=400, detail=str(e))
64
+
65
+ @router.put("/keys/{key_id}")
66
+ async def update_key(key_id: str, key_data: ApiKeyUpdate, admin_token = Depends(verify_admin_jwt)):
67
+ """更新API密钥信息"""
68
+ try:
69
+ # 如果提供了新的密钥值,确保包含Bearer前缀
70
+ key_value = key_data.key_value
71
+ if key_value and not key_value.startswith("Bearer "):
72
+ key_value = f"Bearer {key_value}"
73
+ key_data.key_value = key_value
74
+
75
+ updated_key = key_manager.update_key(
76
+ key_id,
77
+ key_value=key_data.key_value,
78
+ name=key_data.name,
79
+ weight=key_data.weight,
80
+ rate_limit=key_data.rate_limit,
81
+ is_enabled=key_data.is_enabled,
82
+ notes=key_data.notes
83
+ )
84
+ if not updated_key:
85
+ raise HTTPException(status_code=404, detail="密钥不存在")
86
+
87
+ # 通过Config永久保存所有密钥
88
+ Config.save_api_keys(key_manager.keys)
89
+
90
+ return updated_key
91
+ except HTTPException:
92
+ raise
93
+ except Exception as e:
94
+ logger.error(f"更新密钥失败: {str(e)}", exc_info=True)
95
+ raise HTTPException(status_code=400, detail=str(e))
96
+
97
+ @router.delete("/keys/{key_id}")
98
+ async def delete_key(key_id: str, admin_token = Depends(verify_admin_jwt)):
99
+ """删除API密钥"""
100
+ success = key_manager.delete_key(key_id)
101
+ if not success:
102
+ raise HTTPException(status_code=404, detail="密钥不存在")
103
+
104
+ # 通过Config永久保存所有密钥
105
+ Config.save_api_keys(key_manager.keys)
106
+
107
+ return {"status": "success", "message": "密钥已删除"}
108
+
109
+ @router.get("/stats")
110
+ async def get_usage_stats(admin_token = Depends(verify_admin_jwt)):
111
+ """获取API使用统计"""
112
+ stats = key_manager.get_usage_stats()
113
+
114
+ # 处理daily_usage数据,确保前端能够正确显示
115
+ daily_usage = {}
116
+ keys_usage = {}
117
+
118
+ # 从past_7_days数据转换为daily_usage格式
119
+ for date, counts in stats.get("past_7_days", {}).items():
120
+ daily_usage[date] = counts.get("successful", 0) + counts.get("failed", 0)
121
+
122
+ # 获取每个密钥的使用情况
123
+ for key in key_manager.keys:
124
+ key_id = key.get("id")
125
+ key_name = key.get("name") or f"密钥_{key_id[:6]}"
126
+
127
+ # 获取该密钥的使用统计
128
+ if key_id in key_manager.usage_stats:
129
+ key_stats = key_manager.usage_stats[key_id]
130
+ total_requests = key_stats.get("total_requests", 0)
131
+
132
+ if total_requests > 0:
133
+ keys_usage[key_name] = total_requests
134
+
135
+ # 添加到返回数据中
136
+ stats["daily_usage"] = daily_usage
137
+ stats["keys_usage"] = keys_usage
138
+
139
+ return stats
140
+
141
+ @router.post("/keys/test")
142
+ async def test_key(key_data: ApiKeyCreate, admin_token = Depends(verify_admin_jwt)):
143
+ """测试API密钥是否有效"""
144
+ try:
145
+ # 获取密钥值
146
+ key_value = key_data.key_value.strip()
147
+
148
+ # 确保密钥格式正确
149
+ if not key_value.startswith("Bearer "):
150
+ key_value = f"Bearer {key_value}"
151
+
152
+ # 获取代理配置
153
+ proxy_host = Config.PROXY_HOST if Config.PROXY_HOST and Config.PROXY_HOST.strip() else None
154
+ proxy_port = Config.PROXY_PORT if Config.PROXY_PORT and Config.PROXY_PORT.strip() else None
155
+ proxy_user = Config.PROXY_USER if Config.PROXY_USER and Config.PROXY_USER.strip() else None
156
+ proxy_pass = Config.PROXY_PASS if Config.PROXY_PASS and Config.PROXY_PASS.strip() else None
157
+
158
+ test_client = SoraClient(
159
+ proxy_host=proxy_host,
160
+ proxy_port=proxy_port,
161
+ proxy_user=proxy_user,
162
+ proxy_pass=proxy_pass,
163
+ auth_token=key_value
164
+ )
165
+
166
+ # 执行简单API调用测试连接
167
+ test_result = await test_client.test_connection()
168
+ logger.info(f"API密钥测试结果: {test_result}")
169
+
170
+ # 检查底层测试结果的状态
171
+ if test_result.get("status") == "success":
172
+ # API连接测试成功
173
+ return {
174
+ "status": "success",
175
+ "message": "API密钥测试成功",
176
+ "details": test_result,
177
+ "success": True
178
+ }
179
+ else:
180
+ # API连接测试失败
181
+ return {
182
+ "status": "error",
183
+ "message": f"API密钥测试失败: {test_result.get('message', '连接失败')}",
184
+ "details": test_result,
185
+ "success": False
186
+ }
187
+ except Exception as e:
188
+ logger.error(f"测试密钥失败: {str(e)}", exc_info=True)
189
+ return {
190
+ "status": "error",
191
+ "message": f"API密钥测试失败: {str(e)}",
192
+ "success": False
193
+ }
194
+
195
+ @router.post("/keys/batch")
196
+ async def batch_operation(operation: Dict[str, Any], admin_token = Depends(verify_admin_jwt)):
197
+ """批量操作API密钥"""
198
+ try:
199
+ action = operation.get("action")
200
+ logger.info(f"接收到批量操作请求: {action}")
201
+
202
+ if not action:
203
+ logger.warning("批量操作缺少action参数")
204
+ raise HTTPException(status_code=400, detail="缺少必要参数: action")
205
+
206
+ logger.info(f"批量操作类型: {action}")
207
+
208
+ if action == "import":
209
+ # 批量导入API密钥
210
+ keys_data = operation.get("keys", [])
211
+ if not keys_data:
212
+ logger.warning("批量导入缺少keys数据")
213
+ raise HTTPException(status_code=400, detail="未提供密钥数据")
214
+
215
+ logger.info(f"准备导入 {len(keys_data)} 个密钥")
216
+
217
+ # 对每个密钥处理Bearer前缀
218
+ for key_data in keys_data:
219
+ if isinstance(key_data, dict):
220
+ key_value = key_data.get("key", "").strip()
221
+ if key_value and not key_value.startswith("Bearer "):
222
+ key_data["key"] = f"Bearer {key_value}"
223
+
224
+ # 执行批量导入
225
+ try:
226
+ result = key_manager.batch_import_keys(keys_data)
227
+ logger.info(f"导入结果: 成功={result['imported']}, 跳过={result['skipped']}")
228
+
229
+ # 通过Config永久保存所有密钥
230
+ Config.save_api_keys(key_manager.keys)
231
+
232
+ return {
233
+ "success": True,
234
+ "message": f"成功导入 {result['imported']} 个密钥,跳过 {result['skipped']} 个重复密钥",
235
+ "imported": result["imported"],
236
+ "skipped": result["skipped"]
237
+ }
238
+ except Exception as e:
239
+ logger.error(f"批量导入密钥错误: {str(e)}", exc_info=True)
240
+ raise HTTPException(status_code=500, detail=f"导入密钥失败: {str(e)}")
241
+
242
+ elif action not in ["enable", "disable", "delete"]:
243
+ logger.warning(f"不支持的批量操作: {action}")
244
+ raise HTTPException(status_code=400, detail=f"不支持的操作: {action}")
245
+
246
+ # 对于非导入操作,需要提供key_ids
247
+ key_ids = operation.get("key_ids", [])
248
+ if not key_ids:
249
+ logger.warning(f"{action}操作缺少key_ids参数")
250
+ raise HTTPException(status_code=400, detail="缺少必要参数: key_ids")
251
+
252
+ # 确保key_ids是一个列表
253
+ if isinstance(key_ids, str):
254
+ key_ids = [key_ids]
255
+
256
+ logger.info(f"批量{action}操作 {len(key_ids)} 个密钥")
257
+
258
+ if action == "enable":
259
+ # 批量启用
260
+ success_count = 0
261
+ for key_id in key_ids:
262
+ updated = key_manager.update_key(key_id, is_enabled=True)
263
+ if updated:
264
+ success_count += 1
265
+
266
+ # 通过Config永久保存所有密钥
267
+ Config.save_api_keys(key_manager.keys)
268
+
269
+ logger.info(f"成功启用 {success_count} 个密钥")
270
+ return {
271
+ "success": True,
272
+ "message": f"已成功启用 {success_count} 个密钥",
273
+ "affected": success_count
274
+ }
275
+ elif action == "disable":
276
+ # 批量禁用
277
+ success_count = 0
278
+ for key_id in key_ids:
279
+ updated = key_manager.update_key(key_id, is_enabled=False)
280
+ if updated:
281
+ success_count += 1
282
+
283
+ # 通过Config永久保存所有密钥
284
+ Config.save_api_keys(key_manager.keys)
285
+
286
+ logger.info(f"成功禁用 {success_count} 个密钥")
287
+ return {
288
+ "success": True,
289
+ "message": f"已成功禁用 {success_count} 个密钥",
290
+ "affected": success_count
291
+ }
292
+ elif action == "delete":
293
+ # 批量删除
294
+ success_count = 0
295
+ for key_id in key_ids:
296
+ if key_manager.delete_key(key_id):
297
+ success_count += 1
298
+
299
+ # 通过Config永久保存所有密钥
300
+ Config.save_api_keys(key_manager.keys)
301
+
302
+ logger.info(f"成功删除 {success_count} 个密钥")
303
+ return {
304
+ "success": True,
305
+ "message": f"已成功删除 {success_count} 个密钥",
306
+ "affected": success_count
307
+ }
308
+ except HTTPException:
309
+ raise
310
+ except Exception as e:
311
+ logger.error(f"批量操作失败: {str(e)}", exc_info=True)
312
+ raise HTTPException(status_code=500, detail=str(e))
313
+
314
+ # 配置管理API
315
+ @router.get("/config")
316
+ async def get_config(admin_token = Depends(verify_admin_jwt)):
317
+ """获取当前系统配置"""
318
+ return {
319
+ "HOST": Config.HOST,
320
+ "PORT": Config.PORT,
321
+ "BASE_URL": Config.BASE_URL,
322
+ "PROXY_HOST": Config.PROXY_HOST,
323
+ "PROXY_PORT": Config.PROXY_PORT,
324
+ "PROXY_USER": Config.PROXY_USER,
325
+ "PROXY_PASS": "******" if Config.PROXY_PASS else "",
326
+ "IMAGE_LOCALIZATION": Config.IMAGE_LOCALIZATION,
327
+ "IMAGE_SAVE_DIR": Config.IMAGE_SAVE_DIR,
328
+ "API_AUTH_TOKEN": bool(Config.API_AUTH_TOKEN) # 只返回是否设置,不返回实际值
329
+ }
330
+
331
+ @router.post("/config")
332
+ async def update_config(config_data: ConfigUpdate, admin_token = Depends(verify_admin_jwt)):
333
+ """更新系统配置"""
334
+ try:
335
+ changes = []
336
+
337
+ # 更新代理设置
338
+ if config_data.PROXY_HOST is not None:
339
+ Config.PROXY_HOST = config_data.PROXY_HOST
340
+ changes.append("PROXY_HOST")
341
+ # 更新环境变量
342
+ os.environ["PROXY_HOST"] = config_data.PROXY_HOST
343
+
344
+ if config_data.PROXY_PORT is not None:
345
+ Config.PROXY_PORT = config_data.PROXY_PORT
346
+ changes.append("PROXY_PORT")
347
+ # 更新环境变量
348
+ os.environ["PROXY_PORT"] = config_data.PROXY_PORT
349
+
350
+ # 更新代理认证设置
351
+ if config_data.PROXY_USER is not None:
352
+ Config.PROXY_USER = config_data.PROXY_USER
353
+ changes.append("PROXY_USER")
354
+ # 更新环境变量
355
+ os.environ["PROXY_USER"] = config_data.PROXY_USER
356
+
357
+ if config_data.PROXY_PASS is not None:
358
+ Config.PROXY_PASS = config_data.PROXY_PASS
359
+ changes.append("PROXY_PASS")
360
+ # 更新环境变量
361
+ os.environ["PROXY_PASS"] = config_data.PROXY_PASS
362
+
363
+ # 更新基础URL设置
364
+ if config_data.BASE_URL is not None:
365
+ Config.BASE_URL = config_data.BASE_URL
366
+ changes.append("BASE_URL")
367
+ # 更新环境变量
368
+ os.environ["BASE_URL"] = config_data.BASE_URL
369
+
370
+ # 更新图片本地化设置
371
+ if config_data.IMAGE_LOCALIZATION is not None:
372
+ Config.IMAGE_LOCALIZATION = config_data.IMAGE_LOCALIZATION
373
+ changes.append("IMAGE_LOCALIZATION")
374
+ # 更新环境变量
375
+ os.environ["IMAGE_LOCALIZATION"] = str(config_data.IMAGE_LOCALIZATION)
376
+
377
+ if config_data.IMAGE_SAVE_DIR is not None:
378
+ Config.IMAGE_SAVE_DIR = config_data.IMAGE_SAVE_DIR
379
+ changes.append("IMAGE_SAVE_DIR")
380
+ # 更新环境变量
381
+ os.environ["IMAGE_SAVE_DIR"] = config_data.IMAGE_SAVE_DIR
382
+ # 确保目录存在
383
+ os.makedirs(Config.IMAGE_SAVE_DIR, exist_ok=True)
384
+
385
+ # 保存到.env文件
386
+ if config_data.save_to_env and changes:
387
+ env_file = os.path.join(Config.BASE_DIR, '.env')
388
+ env_data = {}
389
+
390
+ # 先读取现有的.env文件
391
+ if os.path.exists(env_file):
392
+ env_data = dotenv.dotenv_values(env_file)
393
+
394
+ # 更新环境变量
395
+ for field in changes:
396
+ value = getattr(Config, field)
397
+ env_data[field] = str(value)
398
+
399
+ # 写入.env文件
400
+ with open(env_file, 'w') as f:
401
+ for key, value in env_data.items():
402
+ f.write(f"{key}={value}\n")
403
+
404
+ logger.info(f"已将配置保存到.env文件: {changes}")
405
+
406
+ return {
407
+ "status": "success",
408
+ "message": f"配置已更新: {', '.join(changes) if changes else '无变更'}"
409
+ }
410
+ except Exception as e:
411
+ logger.error(f"更新配置失败: {str(e)}", exc_info=True)
412
+ raise HTTPException(status_code=400, detail=f"更新配置失败: {str(e)}")
413
+
414
+ @router.post("/logs/level")
415
+ async def update_log_level(data: LogLevelUpdate, admin_token = Depends(verify_admin_jwt)):
416
+ """更新日志级别"""
417
+ try:
418
+ # 验证日志级别
419
+ level = data.level.upper()
420
+ valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
421
+
422
+ if level not in valid_levels:
423
+ raise HTTPException(status_code=400, detail=f"无效的日志级别: {level}")
424
+
425
+ # 更新根日志记录器的级别
426
+ root_logger = logging.getLogger()
427
+ root_logger.setLevel(getattr(logging, level))
428
+
429
+ # 同时更新sora-api模块的日志级别
430
+ sora_logger = logging.getLogger("sora-api")
431
+ sora_logger.setLevel(getattr(logging, level))
432
+
433
+ # 记录日志级别变更
434
+ logger.info(f"日志级别已更新为: {level}")
435
+
436
+ # 如果需要,保存到环境变量
437
+ if data.save_to_env:
438
+ env_file = os.path.join(Config.BASE_DIR, '.env')
439
+ env_data = {}
440
+
441
+ # 先读取现有的.env文件
442
+ if os.path.exists(env_file):
443
+ env_data = dotenv.dotenv_values(env_file)
444
+
445
+ # 更新LOG_LEVEL环境变量
446
+ env_data["LOG_LEVEL"] = level
447
+
448
+ # 写入.env文件
449
+ with open(env_file, 'w') as f:
450
+ for key, value in env_data.items():
451
+ f.write(f"{key}={value}\n")
452
+
453
+ # 记录配置保存
454
+ logger.info(f"已将日志级别保存到.env文件: LOG_LEVEL={level}")
455
+
456
+ return {"status": "success", "message": f"日志级别已更新为: {level}"}
457
+ except HTTPException:
458
+ raise
459
+ except Exception as e:
460
+ logger.error(f"更新日志级别失败: {str(e)}", exc_info=True)
461
+ raise HTTPException(status_code=400, detail=f"更新日志级别失败: {str(e)}")
462
+
463
+ # 管理员密钥API - 已移至app.py中
src/api/auth.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import jwt
2
+ import time
3
+ import uuid
4
+ import logging
5
+ from typing import Dict, Any
6
+ from fastapi import APIRouter, HTTPException, Depends
7
+ from pydantic import BaseModel
8
+
9
+ from ..config import Config
10
+
11
+ # 设置日志
12
+ logger = logging.getLogger("sora-api.auth")
13
+
14
+ # 创建路由
15
+ router = APIRouter(prefix="/api/auth")
16
+
17
+ # JWT配置
18
+ JWT_SECRET = Config.ADMIN_KEY # 使用管理员密钥作为JWT秘钥
19
+ JWT_ALGORITHM = "HS256"
20
+ JWT_EXPIRATION = 3600 # 令牌有效期1小时
21
+
22
+ # 认证请求模型
23
+ class LoginRequest(BaseModel):
24
+ admin_key: str
25
+
26
+ # 令牌响应模型
27
+ class TokenResponse(BaseModel):
28
+ token: str
29
+ expires_in: int
30
+ token_type: str = "bearer"
31
+
32
+ # 创建JWT令牌
33
+ def create_jwt_token(data: Dict[str, Any], expires_delta: int = JWT_EXPIRATION) -> str:
34
+ payload = data.copy()
35
+ issued_at = int(time.time())
36
+ expiration = issued_at + expires_delta
37
+ payload.update({
38
+ "iat": issued_at,
39
+ "exp": expiration,
40
+ "jti": str(uuid.uuid4())
41
+ })
42
+
43
+ token = jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
44
+ return token
45
+
46
+ # 验证JWT令牌
47
+ def verify_jwt_token(token: str) -> Dict[str, Any]:
48
+ try:
49
+ payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
50
+ # 检查token是否已过期
51
+ if payload.get("exp", 0) < time.time():
52
+ raise HTTPException(status_code=401, detail="令牌已过期")
53
+ return payload
54
+ except jwt.PyJWTError as e:
55
+ logger.warning(f"JWT验证失败: {str(e)}")
56
+ raise HTTPException(status_code=401, detail="无效的令牌")
57
+
58
+ # 登录API
59
+ @router.post("/login", response_model=TokenResponse)
60
+ async def login(request: LoginRequest):
61
+ """管理员登录,返回JWT令牌"""
62
+ # 验证管理员密钥
63
+ if request.admin_key != Config.ADMIN_KEY:
64
+ logger.warning(f"尝试使用无效的管理员密钥登录")
65
+ # 使用固定延迟以防止计时攻击
66
+ time.sleep(1)
67
+ raise HTTPException(status_code=401, detail="管理员密钥错误")
68
+
69
+ # 创建令牌
70
+ token_data = {
71
+ "sub": "admin",
72
+ "role": "admin",
73
+ "name": "管理员"
74
+ }
75
+
76
+ token = create_jwt_token(token_data)
77
+ logger.info(f"管理员登录成功,生成新令牌")
78
+
79
+ return TokenResponse(token=token, expires_in=JWT_EXPIRATION)
80
+
81
+ # 刷新令牌API
82
+ @router.post("/refresh", response_model=TokenResponse)
83
+ async def refresh_token(token: str = Depends(verify_jwt_token)):
84
+ """刷新JWT令牌"""
85
+ # 创建新令牌,保持相同的sub和role
86
+ token_data = {
87
+ "sub": token.get("sub"),
88
+ "role": token.get("role", "admin"),
89
+ "name": token.get("name", "管理员"),
90
+ "refresh_count": token.get("refresh_count", 0) + 1
91
+ }
92
+
93
+ new_token = create_jwt_token(token_data)
94
+ logger.info(f"令牌刷新成功,生成新令牌")
95
+
96
+ return TokenResponse(token=new_token, expires_in=JWT_EXPIRATION)
src/api/chat.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uuid
2
+ import time
3
+ import re
4
+ import logging
5
+ from typing import Dict, Any, List, Optional
6
+ from fastapi import APIRouter, Depends, BackgroundTasks, HTTPException
7
+ from fastapi.responses import StreamingResponse, JSONResponse
8
+
9
+ from ..models.schemas import ChatCompletionRequest
10
+ from ..api.dependencies import verify_api_key, get_sora_client_dep
11
+ from ..services.image_service import process_image_task, format_think_block
12
+ from ..services.streaming import generate_streaming_response, generate_streaming_remix_response
13
+ from ..key_manager import key_manager
14
+
15
+ # 设置日志
16
+ logger = logging.getLogger("sora-api.chat")
17
+
18
+ # 创建路由
19
+ router = APIRouter()
20
+
21
+ @router.post("/chat/completions")
22
+ async def chat_completions(
23
+ request: ChatCompletionRequest,
24
+ background_tasks: BackgroundTasks,
25
+ client_info = Depends(get_sora_client_dep()),
26
+ api_key: str = Depends(verify_api_key)
27
+ ):
28
+ """
29
+ 聊天完成端点 - 处理文本到图像和图像到图像的请求
30
+ 兼容OpenAI API格式
31
+ """
32
+ # 解析客户端信息
33
+ sora_client, sora_auth_token = client_info
34
+
35
+ # 记录开始时间
36
+ start_time = time.time()
37
+ success = False
38
+
39
+ try:
40
+ # 分析用户消息
41
+ user_messages = [m for m in request.messages if m.role == "user"]
42
+ if not user_messages:
43
+ raise HTTPException(status_code=400, detail="至少需要一条用户消息")
44
+
45
+ last_user_message = user_messages[-1]
46
+ prompt = ""
47
+ image_data = None
48
+
49
+ # 提取提示词和图片数据
50
+ if isinstance(last_user_message.content, str):
51
+ # 简单的字符串内容
52
+ prompt = last_user_message.content
53
+
54
+ # 检查是否包含内嵌的base64图片
55
+ pattern = r'data:image\/[^;]+;base64,([^"]+)'
56
+ match = re.search(pattern, prompt)
57
+ if match:
58
+ image_data = match.group(1)
59
+ # 从提示词中删除base64数据
60
+ prompt = re.sub(pattern, "[已上传图片]", prompt)
61
+ else:
62
+ # 多模态内容,提取文本和图片
63
+ content_items = last_user_message.content
64
+ text_parts = []
65
+
66
+ for item in content_items:
67
+ if item.type == "text" and item.text:
68
+ text_parts.append(item.text)
69
+ elif item.type == "image_url" and item.image_url:
70
+ # 如果有图片URL包含base64数据
71
+ url = item.image_url.get("url", "")
72
+ if url.startswith("data:image/"):
73
+ pattern = r'data:image\/[^;]+;base64,([^"]+)'
74
+ match = re.search(pattern, url)
75
+ if match:
76
+ image_data = match.group(1)
77
+ text_parts.append("[已上传图片]")
78
+
79
+ prompt = " ".join(text_parts)
80
+
81
+ # 检查是否为流式响应
82
+ if request.stream:
83
+ # 流式响应处理
84
+ if image_data:
85
+ response = StreamingResponse(
86
+ generate_streaming_remix_response(sora_client, prompt, image_data, request.n),
87
+ media_type="text/event-stream"
88
+ )
89
+ else:
90
+ response = StreamingResponse(
91
+ generate_streaming_response(sora_client, prompt, request.n),
92
+ media_type="text/event-stream"
93
+ )
94
+ success = True
95
+
96
+ # 记录请求结果
97
+ response_time = time.time() - start_time
98
+ key_manager.record_request_result(sora_auth_token, success, response_time)
99
+
100
+ return response
101
+ else:
102
+ # 非流式响应 - 返回一个即时响应,表示任务已接收
103
+ request_id = f"chatcmpl-{uuid.uuid4().hex}"
104
+
105
+ # 创建后台任务
106
+ if image_data:
107
+ background_tasks.add_task(
108
+ process_image_task,
109
+ request_id,
110
+ sora_client,
111
+ "remix",
112
+ prompt,
113
+ image_data=image_data,
114
+ num_images=request.n
115
+ )
116
+ else:
117
+ background_tasks.add_task(
118
+ process_image_task,
119
+ request_id,
120
+ sora_client,
121
+ "generation",
122
+ prompt,
123
+ num_images=request.n,
124
+ width=720,
125
+ height=480
126
+ )
127
+
128
+ # 返回正在处理的响应
129
+ processing_message = "正在准备生成任务,请稍候..."
130
+ response = {
131
+ "id": request_id,
132
+ "object": "chat.completion",
133
+ "created": int(time.time()),
134
+ "model": "sora-1.0",
135
+ "choices": [
136
+ {
137
+ "index": 0,
138
+ "message": {
139
+ "role": "assistant",
140
+ "content": format_think_block(processing_message)
141
+ },
142
+ "finish_reason": "processing"
143
+ }
144
+ ],
145
+ "usage": {
146
+ "prompt_tokens": len(prompt) // 4,
147
+ "completion_tokens": 10,
148
+ "total_tokens": len(prompt) // 4 + 10
149
+ }
150
+ }
151
+
152
+ success = True
153
+
154
+ # 记录请求结果
155
+ response_time = time.time() - start_time
156
+ key_manager.record_request_result(sora_auth_token, success, response_time)
157
+
158
+ return JSONResponse(content=response)
159
+
160
+ except Exception as e:
161
+ success = False
162
+ logger.error(f"处理聊天完成请求失败: {str(e)}", exc_info=True)
163
+
164
+ # 记录请求结果
165
+ response_time = time.time() - start_time
166
+ key_manager.record_request_result(sora_auth_token, success, response_time)
167
+
168
+ raise HTTPException(status_code=500, detail=f"图像生成失败: {str(e)}")
src/api/dependencies.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Optional, Tuple, Dict, Any
2
+ from fastapi import Request, HTTPException, Depends, Header
3
+ import aiohttp
4
+ import logging
5
+ from ..config import Config
6
+ from .auth import verify_jwt_token
7
+
8
+ logger = logging.getLogger("sora-api.dependencies")
9
+
10
+ # 全局会话池
11
+ session_pool: Optional[aiohttp.ClientSession] = None
12
+
13
+ # 获取Sora客户端
14
+ def get_sora_client(auth_token: str):
15
+ from ..sora_integration import SoraClient
16
+
17
+ # 使用字典缓存客户端实例
18
+ if not hasattr(get_sora_client, "clients"):
19
+ get_sora_client.clients = {}
20
+
21
+ if auth_token not in get_sora_client.clients:
22
+ proxy_host = Config.PROXY_HOST if Config.PROXY_HOST and Config.PROXY_HOST.strip() else None
23
+ proxy_port = Config.PROXY_PORT if Config.PROXY_PORT and Config.PROXY_PORT.strip() else None
24
+ proxy_user = Config.PROXY_USER if Config.PROXY_USER and Config.PROXY_USER.strip() else None
25
+ proxy_pass = Config.PROXY_PASS if Config.PROXY_PASS and Config.PROXY_PASS.strip() else None
26
+
27
+ get_sora_client.clients[auth_token] = SoraClient(
28
+ proxy_host=proxy_host,
29
+ proxy_port=proxy_port,
30
+ proxy_user=proxy_user,
31
+ proxy_pass=proxy_pass,
32
+ auth_token=auth_token
33
+ )
34
+
35
+ return get_sora_client.clients[auth_token]
36
+
37
+ # 从请求头中获取并验证认证令牌
38
+ async def get_token_from_header(authorization: Optional[str] = Header(None)) -> str:
39
+ """从请求头中获取认证令牌"""
40
+ if not authorization:
41
+ raise HTTPException(status_code=401, detail="缺少认证头")
42
+
43
+ if not authorization.startswith("Bearer "):
44
+ raise HTTPException(status_code=401, detail="无效的认证头格式")
45
+
46
+ return authorization.replace("Bearer ", "")
47
+
48
+ # 验证API key
49
+ async def verify_api_key(request: Request):
50
+ """检查请求头中的API密钥"""
51
+ auth_header = request.headers.get("Authorization")
52
+ if not auth_header or not auth_header.startswith("Bearer "):
53
+ raise HTTPException(status_code=401, detail="缺少或无效的API key")
54
+
55
+ api_key = auth_header.replace("Bearer ", "")
56
+
57
+ # 验证API认证令牌
58
+ if Config.API_AUTH_TOKEN:
59
+ # 如果设置了API_AUTH_TOKEN环境变量,则进行验证
60
+ if api_key != Config.API_AUTH_TOKEN:
61
+ logger.warning(f"API认证失败: 提供的令牌不匹配")
62
+ raise HTTPException(status_code=401, detail="API认证失败,令牌无效")
63
+ else:
64
+ # 如果未设置API_AUTH_TOKEN,则验证是否为管理面板的key
65
+ from ..key_manager import key_manager
66
+ valid_keys = [k.get("key") for k in key_manager.get_all_keys() if k.get("is_enabled", False)]
67
+ if api_key not in valid_keys and api_key != Config.ADMIN_KEY:
68
+ logger.warning(f"API认证失败: 提供的key不在有效列表中")
69
+ raise HTTPException(status_code=401, detail="API认证失败,key无效")
70
+
71
+ return api_key
72
+
73
+ # 获取Sora客户端依赖
74
+ def get_sora_client_dep(specific_key=None):
75
+ """返回一个依赖函数,用于获取Sora客户端
76
+
77
+ Args:
78
+ specific_key: 指定使用的API密钥,如果不为None,则优先使用此密钥
79
+ """
80
+ async def _get_client(auth_token: str = Depends(verify_api_key)):
81
+ from ..key_manager import key_manager
82
+
83
+ # 如果提供了特定密钥,则使用该密钥
84
+ if specific_key:
85
+ sora_auth_token = specific_key
86
+ else:
87
+ # 使用密钥管理器获取可用的API密钥
88
+ sora_auth_token = key_manager.get_key()
89
+ if not sora_auth_token:
90
+ raise HTTPException(status_code=429, detail="所有API key都已达到速率限制")
91
+
92
+ # 获取Sora客户端
93
+ return get_sora_client(sora_auth_token), sora_auth_token
94
+
95
+ return _get_client
96
+
97
+ # 验证JWT令牌并验证管理员权限
98
+ async def verify_admin_jwt(token: str = Depends(get_token_from_header)) -> Dict[str, Any]:
99
+ """验证JWT令牌并确认管理员权限"""
100
+ # 验证JWT令牌
101
+ payload = verify_jwt_token(token)
102
+
103
+ # 验证是否为管理员角色
104
+ if payload.get("role") != "admin":
105
+ raise HTTPException(status_code=403, detail="没有管理员权限")
106
+
107
+ return payload
108
+
109
+ # 验证管理员权限(传统方法,保留向后兼容性)
110
+ async def verify_admin(request: Request):
111
+ """验证管理员权限"""
112
+ auth_header = request.headers.get("Authorization")
113
+ if not auth_header or not auth_header.startswith("Bearer "):
114
+ raise HTTPException(status_code=401, detail="未授权")
115
+
116
+ token = auth_header.replace("Bearer ", "")
117
+
118
+ # 尝试JWT验证
119
+ try:
120
+ payload = verify_jwt_token(token)
121
+ if payload.get("role") == "admin":
122
+ return token
123
+ except HTTPException:
124
+ # JWT验证失败,尝试传统验证
125
+ pass
126
+
127
+ # 传统验证(直接验证管理员密钥)
128
+ if token != Config.ADMIN_KEY:
129
+ raise HTTPException(status_code=403, detail="没有管理员权限")
130
+
131
+ return token
src/api/generation.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ import logging
3
+ from typing import Dict, Any
4
+ from fastapi import APIRouter, Depends, HTTPException
5
+ from fastapi.responses import JSONResponse
6
+
7
+ from ..api.dependencies import verify_api_key, get_sora_client_dep
8
+ from ..services.image_service import get_generation_result, get_task_api_key
9
+ from ..key_manager import key_manager
10
+
11
+ # 设置日志
12
+ logger = logging.getLogger("sora-api.generation")
13
+
14
+ # 创建路由
15
+ router = APIRouter()
16
+
17
+ @router.get("/generation/{request_id}")
18
+ async def check_generation_status(
19
+ request_id: str,
20
+ client_info = Depends(get_sora_client_dep()),
21
+ api_key: str = Depends(verify_api_key)
22
+ ):
23
+ """
24
+ 检查图像生成任务的状态
25
+
26
+ Args:
27
+ request_id: 要查询的请求ID
28
+ client_info: Sora客户端信息(由依赖提供)
29
+ api_key: API密钥(由依赖提供)
30
+
31
+ Returns:
32
+ 包含任务状态和结果的JSON响应
33
+ """
34
+ # 获取任务对应的原始API密钥
35
+ task_api_key = get_task_api_key(request_id)
36
+
37
+ # 如果找到任务对应的API密钥,则使用该密钥获取客户端
38
+ if task_api_key:
39
+ # 获取使用特定API密钥的客户端
40
+ specific_client_dep = get_sora_client_dep(specific_key=task_api_key)
41
+ client_info = await specific_client_dep(api_key)
42
+
43
+ # 解析客户端信息
44
+ _, sora_auth_token = client_info
45
+
46
+ # 记录开始时间
47
+ start_time = time.time()
48
+ success = False
49
+
50
+ try:
51
+ # 获取任务结果
52
+ result = get_generation_result(request_id)
53
+
54
+ if result.get("status") == "not_found":
55
+ raise HTTPException(status_code=404, detail=f"找不到生成任务: {request_id}")
56
+
57
+ if result.get("status") == "completed":
58
+ # 任务已完成,返回结果
59
+ image_urls = result.get("image_urls", [])
60
+
61
+ # 构建OpenAI兼容的响应
62
+ response = {
63
+ "id": request_id,
64
+ "object": "chat.completion",
65
+ "created": result.get("timestamp", int(time.time())),
66
+ "model": "sora-1.0",
67
+ "choices": [
68
+ {
69
+ "index": i,
70
+ "message": {
71
+ "role": "assistant",
72
+ "content": f"![Generated Image]({url})"
73
+ },
74
+ "finish_reason": "stop"
75
+ }
76
+ for i, url in enumerate(image_urls)
77
+ ],
78
+ "usage": {
79
+ "prompt_tokens": 0,
80
+ "completion_tokens": 20,
81
+ "total_tokens": 20
82
+ }
83
+ }
84
+ success = True
85
+
86
+ elif result.get("status") == "failed":
87
+ # 任务失败
88
+ message = result.get("message", f"```think\n生成失败: {result.get('error', '未知错误')}\n```")
89
+
90
+ response = {
91
+ "id": request_id,
92
+ "object": "chat.completion",
93
+ "created": result.get("timestamp", int(time.time())),
94
+ "model": "sora-1.0",
95
+ "choices": [
96
+ {
97
+ "index": 0,
98
+ "message": {
99
+ "role": "assistant",
100
+ "content": message
101
+ },
102
+ "finish_reason": "error"
103
+ }
104
+ ],
105
+ "usage": {
106
+ "prompt_tokens": 0,
107
+ "completion_tokens": 10,
108
+ "total_tokens": 10
109
+ }
110
+ }
111
+ success = False
112
+
113
+ else: # 处理中
114
+ # 任务仍在处理中
115
+ message = result.get("message", "```think\n正在生成图像,请稍候...\n```")
116
+
117
+ response = {
118
+ "id": request_id,
119
+ "object": "chat.completion",
120
+ "created": result.get("timestamp", int(time.time())),
121
+ "model": "sora-1.0",
122
+ "choices": [
123
+ {
124
+ "index": 0,
125
+ "message": {
126
+ "role": "assistant",
127
+ "content": message
128
+ },
129
+ "finish_reason": "processing"
130
+ }
131
+ ],
132
+ "usage": {
133
+ "prompt_tokens": 0,
134
+ "completion_tokens": 10,
135
+ "total_tokens": 10
136
+ }
137
+ }
138
+ success = True
139
+
140
+ # 记录请求结果
141
+ response_time = time.time() - start_time
142
+ key_manager.record_request_result(sora_auth_token, success, response_time)
143
+
144
+ # 返回响应
145
+ return JSONResponse(content=response)
146
+
147
+ except HTTPException:
148
+ # 直接重新抛出HTTP异常
149
+ raise
150
+ except Exception as e:
151
+ # 处理其他异常
152
+ success = False
153
+ logger.error(f"检查任务状态失败: {str(e)}", exc_info=True)
154
+
155
+ # 记录请求结果
156
+ response_time = time.time() - start_time
157
+ key_manager.record_request_result(sora_auth_token, success, response_time)
158
+
159
+ raise HTTPException(status_code=500, detail=f"检查任务状态失败: {str(e)}")
src/api/health.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ import platform
3
+ import os
4
+ import sys
5
+ import psutil
6
+ from fastapi import APIRouter, Depends
7
+ from ..key_manager import key_manager
8
+
9
+ # 创建路由
10
+ router = APIRouter()
11
+
12
+ @router.get("/health")
13
+ async def health_check():
14
+ """
15
+ API健康检查端点
16
+
17
+ 返回:
18
+ 基本的健康状态信息
19
+ """
20
+ return {
21
+ "status": "ok",
22
+ "timestamp": time.time(),
23
+ "version": "2.0.0"
24
+ }
25
+
26
+ @router.get("/health/extended")
27
+ async def extended_health_check():
28
+ """
29
+ 扩展的健康检查端点
30
+
31
+ 返回:
32
+ 详细的系统和服务状态信息
33
+ """
34
+ # 获取系统信息
35
+ system_info = {
36
+ "platform": platform.platform(),
37
+ "python_version": sys.version,
38
+ "cpu_count": psutil.cpu_count(),
39
+ "memory_total": psutil.virtual_memory().total,
40
+ "memory_available": psutil.virtual_memory().available,
41
+ "disk_usage": psutil.disk_usage('/').percent
42
+ }
43
+
44
+ # 获取服务信息
45
+ service_info = {
46
+ "uptime": time.time() - psutil.Process(os.getpid()).create_time(),
47
+ "active_keys": sum(1 for k in key_manager.keys if k.get("is_enabled", False)),
48
+ "total_keys": len(key_manager.keys),
49
+ }
50
+
51
+ return {
52
+ "status": "ok",
53
+ "timestamp": time.time(),
54
+ "version": "2.0.0",
55
+ "system": system_info,
56
+ "service": service_info,
57
+ }
src/app.py ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import aiohttp
3
+ import logging
4
+ from fastapi import FastAPI, Request, HTTPException
5
+ from fastapi.responses import JSONResponse, FileResponse
6
+ from fastapi.middleware.cors import CORSMiddleware
7
+ from fastapi.staticfiles import StaticFiles
8
+ from fastapi.exceptions import RequestValidationError
9
+
10
+ from .config import Config
11
+ from .key_manager import key_manager
12
+ from .api import main_router
13
+ from .api.dependencies import session_pool
14
+
15
+ # 配置日志
16
+ logging.basicConfig(
17
+ level=getattr(logging, os.getenv("LOG_LEVEL", "INFO").upper()),
18
+ format="%(asctime)s [%(levelname)s] %(message)s",
19
+ datefmt="%Y-%m-%d %H:%M:%S"
20
+ )
21
+
22
+ logger = logging.getLogger("sora-api")
23
+
24
+ # 创建FastAPI应用
25
+ app = FastAPI(
26
+ title="OpenAI Compatible Sora API",
27
+ description="为Sora提供OpenAI兼容接口的API服务",
28
+ version="2.0.0",
29
+ docs_url="/docs",
30
+ redoc_url="/redoc",
31
+ )
32
+
33
+ # 配置CORS中间件
34
+ origins = [
35
+ "http://localhost",
36
+ "http://localhost:8890",
37
+ f"http://{Config.HOST}:{Config.PORT}",
38
+ Config.BASE_URL,
39
+ ]
40
+
41
+ app.add_middleware(
42
+ CORSMiddleware,
43
+ allow_origins=origins,
44
+ allow_credentials=True,
45
+ allow_methods=["GET", "POST", "PUT", "DELETE"],
46
+ allow_headers=["Authorization", "Content-Type"],
47
+ )
48
+
49
+ # 异常处理
50
+ @app.exception_handler(RequestValidationError)
51
+ async def validation_exception_handler(request: Request, exc: RequestValidationError):
52
+ """处理请求验证错误"""
53
+ return JSONResponse(
54
+ status_code=422,
55
+ content={"detail": f"请求验证错误: {str(exc)}"}
56
+ )
57
+
58
+ @app.exception_handler(Exception)
59
+ async def global_exception_handler(request: Request, exc: Exception):
60
+ """全局异常处理器"""
61
+ # 记录错误
62
+ logger.error(f"全局异常: {str(exc)}", exc_info=True)
63
+
64
+ # 如果是已知的HTTPException,保持原始状态码和详情
65
+ if isinstance(exc, HTTPException):
66
+ return JSONResponse(
67
+ status_code=exc.status_code,
68
+ content={"detail": exc.detail}
69
+ )
70
+
71
+ # 其他异常返回500状态码
72
+ return JSONResponse(
73
+ status_code=500,
74
+ content={"detail": f"服务器内部错误: {str(exc)}"}
75
+ )
76
+
77
+ # 应用启动和关闭事件
78
+ @app.on_event("startup")
79
+ async def startup_event():
80
+ """应用启动时执行的操作"""
81
+ global session_pool
82
+ # 创建共享会话池
83
+ session_pool = aiohttp.ClientSession()
84
+ logger.info("应用已启动,创建了全局会话池")
85
+
86
+ # 初始化时保存管理员密钥
87
+ Config.save_admin_key()
88
+
89
+ # 确保静态文件目录存在
90
+ os.makedirs(os.path.join(Config.STATIC_DIR, "admin"), exist_ok=True)
91
+ os.makedirs(os.path.join(Config.STATIC_DIR, "admin/js"), exist_ok=True)
92
+ os.makedirs(os.path.join(Config.STATIC_DIR, "admin/css"), exist_ok=True)
93
+ os.makedirs(Config.IMAGE_SAVE_DIR, exist_ok=True)
94
+
95
+ # 输出图片保存目录的信息
96
+ logger.info(f"图片保存目录: {Config.IMAGE_SAVE_DIR}")
97
+
98
+ # 图片访问URL
99
+ base_url = Config.BASE_URL.rstrip('/')
100
+ if Config.STATIC_PATH_PREFIX:
101
+ logger.info(f"图片将通过 {base_url}{Config.STATIC_PATH_PREFIX}/images/<filename> 访问")
102
+ else:
103
+ logger.info(f"图片将通过 {base_url}/images/<filename> 访问")
104
+
105
+ # 打印配置信息
106
+ Config.print_config()
107
+
108
+ @app.on_event("shutdown")
109
+ async def shutdown_event():
110
+ """应用关闭时执行的操作"""
111
+ # 关闭会话池
112
+ if session_pool:
113
+ await session_pool.close()
114
+ logger.info("应用已关闭,清理了全局会话池")
115
+
116
+ # 添加根路径路由
117
+ @app.get("/")
118
+ async def root():
119
+ """根路径,返回系统状态信息"""
120
+ return {
121
+ "status": "OK",
122
+ "message": "系统运行正常",
123
+ "version": app.version,
124
+ "name": app.title
125
+ }
126
+
127
+ # 挂载静态文件目录
128
+ app.mount("/static", StaticFiles(directory=Config.STATIC_DIR), name="static")
129
+
130
+ # 通用图片访问路由 - 支持多种路径格式
131
+ @app.get("/images/{filename}")
132
+ @app.get("/static/images/{filename}")
133
+ async def get_image(filename: str):
134
+ """处理图片请求 - 无论保存在哪里"""
135
+ # 直接从IMAGE_SAVE_DIR获取图片
136
+ file_path = os.path.join(Config.IMAGE_SAVE_DIR, filename)
137
+ if os.path.exists(file_path):
138
+ return FileResponse(file_path)
139
+ else:
140
+ logger.warning(f"请求的图片不存在: {file_path}")
141
+ raise HTTPException(status_code=404, detail="图片不存在")
142
+
143
+ # 添加静态文件路径前缀的兼容路由
144
+ if Config.STATIC_PATH_PREFIX:
145
+ prefix_path = Config.STATIC_PATH_PREFIX.lstrip("/")
146
+
147
+ @app.get(f"/{prefix_path}/images/{{filename}}")
148
+ async def get_prefixed_image(filename: str):
149
+ """处理带前缀的图片请求"""
150
+ return await get_image(filename)
151
+
152
+ # 管理界面路由
153
+ @app.get("/admin")
154
+ async def admin_panel():
155
+ """返回管理面板HTML页面"""
156
+ return FileResponse(os.path.join(Config.STATIC_DIR, "admin/index.html"))
157
+
158
+ # 现在使用JWT��证,不再需要直接暴露管理员密钥
159
+
160
+ # 注册所有API路由
161
+ app.include_router(main_router)
162
+
163
+ # 应用入口点(供uvicorn直接调用)
164
+ if __name__ == "__main__":
165
+ import uvicorn
166
+ uvicorn.run(
167
+ "app:app",
168
+ host=Config.HOST,
169
+ port=Config.PORT,
170
+ reload=Config.VERBOSE_LOGGING
171
+ )
src/app.py.backup ADDED
@@ -0,0 +1,1159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import json
3
+ import time
4
+ import uuid
5
+ import base64
6
+ import os
7
+ import tempfile
8
+ import threading
9
+ import dotenv
10
+ import logging
11
+ from typing import List, Dict, Any, Optional, Union
12
+ from fastapi import FastAPI, HTTPException, Depends, Request, BackgroundTasks, File, UploadFile, Form
13
+ from fastapi.responses import StreamingResponse, JSONResponse, FileResponse, HTMLResponse
14
+ from fastapi.middleware.cors import CORSMiddleware
15
+ from fastapi.staticfiles import StaticFiles
16
+ from pydantic import BaseModel, Field
17
+ import uvicorn
18
+ import re
19
+
20
+ from .key_manager import KeyManager
21
+ from .sora_integration import SoraClient
22
+ from .config import Config
23
+ from .utils import localize_image_urls # 导入新增的图片本地化功能
24
+
25
+ # 日志系统配置
26
+ class LogConfig:
27
+ LEVEL = os.getenv("LOG_LEVEL", "WARNING").upper()
28
+ FORMAT = "%(asctime)s [%(levelname)s] %(message)s"
29
+
30
+ # 初始化日志
31
+ logging.basicConfig(
32
+ level=getattr(logging, LogConfig.LEVEL),
33
+ format=LogConfig.FORMAT,
34
+ datefmt="%Y-%m-%d %H:%M:%S"
35
+ )
36
+ logger = logging.getLogger("sora-api")
37
+
38
+ # 打印日志级别信息
39
+ logger.info(f"日志级别设置为: {LogConfig.LEVEL}")
40
+ logger.info(f"要调整日志级别,请设置环境变量 LOG_LEVEL=DEBUG|INFO|WARNING|ERROR")
41
+
42
+ # 创建FastAPI应用
43
+ app = FastAPI(title="OpenAI Compatible Sora API")
44
+
45
+ # 添加CORS支持
46
+ app.add_middleware(
47
+ CORSMiddleware,
48
+ allow_origins=["*"],
49
+ allow_credentials=True,
50
+ allow_methods=["*"],
51
+ allow_headers=["*"],
52
+ )
53
+
54
+ # 确保静态文件目录存在
55
+ os.makedirs(os.path.join(Config.STATIC_DIR, "admin"), exist_ok=True)
56
+ os.makedirs(os.path.join(Config.STATIC_DIR, "admin/js"), exist_ok=True)
57
+ os.makedirs(os.path.join(Config.STATIC_DIR, "admin/css"), exist_ok=True)
58
+ os.makedirs(os.path.join(Config.STATIC_DIR, "images"), exist_ok=True) # 确保图片目录存在
59
+
60
+ # 打印配置信息
61
+ Config.print_config()
62
+
63
+ # 挂载静态文件目录
64
+ app.mount("/static", StaticFiles(directory=Config.STATIC_DIR), name="static")
65
+
66
+ # 初始化Key管理器
67
+ key_manager = KeyManager(storage_file=Config.KEYS_STORAGE_FILE)
68
+
69
+ # 初始化时保存管理员密钥
70
+ Config.save_admin_key()
71
+
72
+ # 创建Sora客户端池
73
+ sora_clients = {}
74
+
75
+ # 存储生成结果的全局字典
76
+ generation_results = {}
77
+
78
+ # 请求模型
79
+ class ContentItem(BaseModel):
80
+ type: str
81
+ text: Optional[str] = None
82
+ image_url: Optional[Dict[str, str]] = None
83
+
84
+ class ChatMessage(BaseModel):
85
+ role: str
86
+ content: Union[str, List[ContentItem]]
87
+
88
+ class ChatCompletionRequest(BaseModel):
89
+ model: str
90
+ messages: List[ChatMessage]
91
+ temperature: Optional[float] = 1.0
92
+ top_p: Optional[float] = 1.0
93
+ n: Optional[int] = 1
94
+ stream: Optional[bool] = False
95
+ max_tokens: Optional[int] = None
96
+ presence_penalty: Optional[float] = 0
97
+ frequency_penalty: Optional[float] = 0
98
+
99
+ # API密钥管理模型
100
+ class ApiKeyCreate(BaseModel):
101
+ name: str = Field(..., description="密钥名称")
102
+ key_value: str = Field(..., description="Bearer Token值")
103
+ weight: int = Field(default=1, ge=1, le=10, description="权重值")
104
+ rate_limit: int = Field(default=60, description="每分钟最大请求数")
105
+ is_enabled: bool = Field(default=True, description="是否启用")
106
+ notes: Optional[str] = Field(default=None, description="备注信息")
107
+
108
+ class ApiKeyUpdate(BaseModel):
109
+ name: Optional[str] = None
110
+ key_value: Optional[str] = None
111
+ weight: Optional[int] = None
112
+ rate_limit: Optional[int] = None
113
+ is_enabled: Optional[bool] = None
114
+ notes: Optional[str] = None
115
+
116
+ # 获取Sora客户端
117
+ def get_sora_client(auth_token: str) -> SoraClient:
118
+ if auth_token not in sora_clients:
119
+ proxy_host = Config.PROXY_HOST if Config.PROXY_HOST and Config.PROXY_HOST.strip() else None
120
+ proxy_port = Config.PROXY_PORT if Config.PROXY_PORT and Config.PROXY_PORT.strip() else None
121
+
122
+ sora_clients[auth_token] = SoraClient(
123
+ proxy_host=proxy_host,
124
+ proxy_port=proxy_port,
125
+ auth_token=auth_token
126
+ )
127
+ return sora_clients[auth_token]
128
+
129
+ # 验证API key
130
+ async def verify_api_key(request: Request):
131
+ auth_header = request.headers.get("Authorization")
132
+ if not auth_header or not auth_header.startswith("Bearer "):
133
+ raise HTTPException(status_code=401, detail="缺少或无效的API key")
134
+
135
+ api_key = auth_header.replace("Bearer ", "")
136
+ # 在实际应用中,这里应该验证key的有效性
137
+ # 这里简化处理,假设所有key都有效
138
+ return api_key
139
+
140
+ # 验证管理员权限
141
+ async def verify_admin(request: Request):
142
+ auth_header = request.headers.get("Authorization")
143
+ if not auth_header or not auth_header.startswith("Bearer "):
144
+ raise HTTPException(status_code=401, detail="未授权")
145
+
146
+ admin_key = auth_header.replace("Bearer ", "")
147
+ # 这里应该检查是否为管理员密钥
148
+ # 简化处理,假设admin_key是预设的管理员密钥
149
+ if admin_key != Config.ADMIN_KEY:
150
+ raise HTTPException(status_code=403, detail="没有管理员权限")
151
+ return admin_key
152
+
153
+ # 将处理中状态消息格式化为think代码块
154
+ def format_think_block(message):
155
+ """将消息放入```think代码块中"""
156
+ return f"```think\n{message}\n```"
157
+
158
+ # 后台任务处理函数 - 文本生成图像
159
+ async def process_image_generation(
160
+ request_id: str,
161
+ sora_client: SoraClient,
162
+ prompt: str,
163
+ num_images: int = 1,
164
+ width: int = 720,
165
+ height: int = 480
166
+ ):
167
+ try:
168
+ # 更新状态为生成中
169
+ generation_results[request_id] = {
170
+ "status": "processing",
171
+ "message": format_think_block("正在生成图像中,请耐心等待..."),
172
+ "timestamp": int(time.time())
173
+ }
174
+
175
+ # 生成图像
176
+ logger.info(f"[{request_id}] 开始生成图像, 提示词: {prompt}")
177
+ image_urls = await sora_client.generate_image(
178
+ prompt=prompt,
179
+ num_images=num_images,
180
+ width=width,
181
+ height=height
182
+ )
183
+
184
+ # 验证生成结果
185
+ if isinstance(image_urls, str):
186
+ logger.warning(f"[{request_id}] 图像生成失败或返回了错误信息: {image_urls}")
187
+ generation_results[request_id] = {
188
+ "status": "failed",
189
+ "error": image_urls,
190
+ "message": format_think_block(f"图像生成失败: {image_urls}"),
191
+ "timestamp": int(time.time())
192
+ }
193
+ return
194
+
195
+ if not image_urls:
196
+ logger.warning(f"[{request_id}] 图像生成返回了空列表")
197
+ generation_results[request_id] = {
198
+ "status": "failed",
199
+ "error": "图像生成返回了空结果",
200
+ "message": format_think_block("图像生成失败: 服务器返回了空结果"),
201
+ "timestamp": int(time.time())
202
+ }
203
+ return
204
+
205
+ logger.info(f"[{request_id}] 成功生成 {len(image_urls)} 张图片")
206
+ if logger.isEnabledFor(logging.DEBUG):
207
+ for i, url in enumerate(image_urls):
208
+ logger.debug(f"[{request_id}] 图片 {i+1}: {url}")
209
+
210
+ # 本地化图片URL
211
+ if Config.IMAGE_LOCALIZATION:
212
+ logger.info(f"[{request_id}] 准备进行图片本地化处理")
213
+ logger.debug(f"[{request_id}] 图片本地化配置: 启用={Config.IMAGE_LOCALIZATION}, 保存目录={Config.IMAGE_SAVE_DIR}")
214
+ try:
215
+ localized_urls = await localize_image_urls(image_urls)
216
+ logger.info(f"[{request_id}] 图片本地化处理完成")
217
+
218
+ # 检查本地化结果
219
+ if not localized_urls:
220
+ logger.warning(f"[{request_id}] 本地化处理返回了空列表,将使用原始URL")
221
+ localized_urls = image_urls
222
+
223
+ # 检查是否所有URL都被正确本地化
224
+ local_count = sum(1 for url in localized_urls if url.startswith("/static/") or "/static/" in url)
225
+ logger.info(f"[{request_id}] 本地化结果: 总计 {len(localized_urls)} 张图片,成功本地化 {local_count} 张")
226
+
227
+ if local_count == 0:
228
+ logger.warning(f"[{request_id}] 警告:没有一个URL被成功本地化,将使用原始URL")
229
+ localized_urls = image_urls
230
+
231
+ # 打印结果对比
232
+ if logger.isEnabledFor(logging.DEBUG):
233
+ for i, (orig, local) in enumerate(zip(image_urls, localized_urls)):
234
+ logger.debug(f"[{request_id}] 图片 {i+1} 本地化结果: {orig} -> {local}")
235
+
236
+ image_urls = localized_urls
237
+ except Exception as e:
238
+ logger.error(f"[{request_id}] 图片本地化过程中发生错误: {str(e)}")
239
+ if logger.isEnabledFor(logging.DEBUG):
240
+ import traceback
241
+ logger.debug(traceback.format_exc())
242
+ logger.info(f"[{request_id}] 由于错误,将使用原始URL")
243
+ else:
244
+ logger.info(f"[{request_id}] 图片本地化功能未启用,使用原始URL")
245
+
246
+ # 存储结果
247
+ generation_results[request_id] = {
248
+ "status": "completed",
249
+ "image_urls": image_urls,
250
+ "timestamp": int(time.time())
251
+ }
252
+
253
+ # 30分钟后自动清理结果
254
+ threading.Timer(1800, lambda: generation_results.pop(request_id, None)).start()
255
+
256
+ except Exception as e:
257
+ error_message = f"图像生成失败: {str(e)}"
258
+ generation_results[request_id] = {
259
+ "status": "failed",
260
+ "error": error_message,
261
+ "message": format_think_block(error_message),
262
+ "timestamp": int(time.time())
263
+ }
264
+ logger.error(f"图像生成失败 (ID: {request_id}): {str(e)}")
265
+ if logger.isEnabledFor(logging.DEBUG):
266
+ import traceback
267
+ logger.debug(traceback.format_exc())
268
+
269
+ # 后台任务处理函数 - 带图片的remix
270
+ async def process_image_remix(
271
+ request_id: str,
272
+ sora_client: SoraClient,
273
+ prompt: str,
274
+ image_data: str,
275
+ num_images: int = 1
276
+ ):
277
+ try:
278
+ # 更新状态为处理中
279
+ generation_results[request_id] = {
280
+ "status": "processing",
281
+ "message": format_think_block("正在处理上传的图片..."),
282
+ "timestamp": int(time.time())
283
+ }
284
+
285
+ # 保存base64图片到临时文件
286
+ temp_dir = tempfile.mkdtemp()
287
+ temp_image_path = os.path.join(temp_dir, f"upload_{uuid.uuid4()}.png")
288
+
289
+ try:
290
+ # 解码并保存图片
291
+ with open(temp_image_path, "wb") as f:
292
+ f.write(base64.b64decode(image_data))
293
+
294
+ # 更新状态为上传中
295
+ generation_results[request_id] = {
296
+ "status": "processing",
297
+ "message": format_think_block("正在上传图片到Sora服务..."),
298
+ "timestamp": int(time.time())
299
+ }
300
+
301
+ # 上传图片
302
+ upload_result = await sora_client.upload_image(temp_image_path)
303
+ media_id = upload_result['id']
304
+
305
+ # 更新状态为生成中
306
+ generation_results[request_id] = {
307
+ "status": "processing",
308
+ "message": format_think_block("正在基于图片生成新图像..."),
309
+ "timestamp": int(time.time())
310
+ }
311
+
312
+ # 执行remix生成
313
+ logger.info(f"[{request_id}] 开始生成Remix图像, 提示词: {prompt}")
314
+ image_urls = await sora_client.generate_image_remix(
315
+ prompt=prompt,
316
+ media_id=media_id,
317
+ num_images=num_images
318
+ )
319
+
320
+ # 本地化图片URL
321
+ if Config.IMAGE_LOCALIZATION:
322
+ logger.info(f"[{request_id}] 准备进行图片本地化处理")
323
+ localized_urls = await localize_image_urls(image_urls)
324
+ image_urls = localized_urls
325
+ logger.info(f"[{request_id}] Remix图片本地化处理完成")
326
+
327
+ # 存储结果
328
+ generation_results[request_id] = {
329
+ "status": "completed",
330
+ "image_urls": image_urls,
331
+ "timestamp": int(time.time())
332
+ }
333
+
334
+ # 30分钟后自动清理结果
335
+ threading.Timer(1800, lambda: generation_results.pop(request_id, None)).start()
336
+
337
+ finally:
338
+ # 清理临时文件
339
+ if os.path.exists(temp_image_path):
340
+ os.remove(temp_image_path)
341
+ if os.path.exists(temp_dir):
342
+ os.rmdir(temp_dir)
343
+
344
+ except Exception as e:
345
+ error_message = f"图像Remix失败: {str(e)}"
346
+ generation_results[request_id] = {
347
+ "status": "failed",
348
+ "error": error_message,
349
+ "message": format_think_block(error_message),
350
+ "timestamp": int(time.time())
351
+ }
352
+ logger.error(f"图像Remix失败 (ID: {request_id}): {str(e)}")
353
+
354
+ # 添加一个新端点用于检查生成状态
355
+ @app.get("/v1/generation/{request_id}")
356
+ async def check_generation_status(request_id: str, api_key: str = Depends(verify_api_key)):
357
+ """
358
+ 检查图像生成任务的状态
359
+ """
360
+ # 获取一个可用的key并记录开始时间
361
+ sora_auth_token = key_manager.get_key()
362
+ if not sora_auth_token:
363
+ raise HTTPException(status_code=429, detail="所有API key都已达到速率限制")
364
+
365
+ start_time = time.time()
366
+ success = False
367
+
368
+ try:
369
+ if request_id not in generation_results:
370
+ raise HTTPException(status_code=404, detail=f"找不到生成任务: {request_id}")
371
+
372
+ result = generation_results[request_id]
373
+
374
+ if result["status"] == "completed":
375
+ image_urls = result["image_urls"]
376
+
377
+ # 构建OpenAI兼容的响应
378
+ response = {
379
+ "id": request_id,
380
+ "object": "chat.completion",
381
+ "created": result["timestamp"],
382
+ "model": "sora-1.0",
383
+ "choices": [
384
+ {
385
+ "index": i,
386
+ "message": {
387
+ "role": "assistant",
388
+ "content": f"![Generated Image]({url})"
389
+ },
390
+ "finish_reason": "stop"
391
+ }
392
+ for i, url in enumerate(image_urls)
393
+ ],
394
+ "usage": {
395
+ "prompt_tokens": 0, # 简化的令牌计算
396
+ "completion_tokens": 20,
397
+ "total_tokens": 20
398
+ }
399
+ }
400
+ success = True
401
+ return JSONResponse(content=response)
402
+
403
+ elif result["status"] == "failed":
404
+ if "message" in result:
405
+ # 返回带有格式化错误消息的响应
406
+ response = {
407
+ "id": request_id,
408
+ "object": "chat.completion",
409
+ "created": result["timestamp"],
410
+ "model": "sora-1.0",
411
+ "choices": [
412
+ {
413
+ "index": 0,
414
+ "message": {
415
+ "role": "assistant",
416
+ "content": result["message"]
417
+ },
418
+ "finish_reason": "error"
419
+ }
420
+ ],
421
+ "usage": {
422
+ "prompt_tokens": 0,
423
+ "completion_tokens": 10,
424
+ "total_tokens": 10
425
+ }
426
+ }
427
+ success = False
428
+ return JSONResponse(content=response)
429
+ else:
430
+ # 向后兼容,使用老的方式
431
+ raise HTTPException(status_code=500, detail=f"生成失败: {result['error']}")
432
+
433
+ else: # 处理中
434
+ message = result.get("message", "```think\n正在生成图像,请稍候...\n```")
435
+ response = {
436
+ "id": request_id,
437
+ "object": "chat.completion",
438
+ "created": result["timestamp"],
439
+ "model": "sora-1.0",
440
+ "choices": [
441
+ {
442
+ "index": 0,
443
+ "message": {
444
+ "role": "assistant",
445
+ "content": message
446
+ },
447
+ "finish_reason": "processing"
448
+ }
449
+ ],
450
+ "usage": {
451
+ "prompt_tokens": 0,
452
+ "completion_tokens": 10,
453
+ "total_tokens": 10
454
+ }
455
+ }
456
+ success = True
457
+ return JSONResponse(content=response)
458
+ except Exception as e:
459
+ success = False
460
+ raise HTTPException(status_code=500, detail=f"检查任务状态失败: {str(e)}")
461
+ finally:
462
+ # 记录请求结果
463
+ response_time = time.time() - start_time
464
+ key_manager.record_request_result(sora_auth_token, success, response_time)
465
+
466
+ # 聊天完成端点
467
+ @app.post("/v1/chat/completions")
468
+ async def chat_completions(
469
+ request: ChatCompletionRequest,
470
+ api_key: str = Depends(verify_api_key),
471
+ background_tasks: BackgroundTasks = None
472
+ ):
473
+ # 获取一个可用的key
474
+ sora_auth_token = key_manager.get_key()
475
+ if not sora_auth_token:
476
+ raise HTTPException(status_code=429, detail="所有API key都已达到速率限制")
477
+
478
+ # 获取Sora客户端
479
+ sora_client = get_sora_client(sora_auth_token)
480
+
481
+ # 分析最后一条用户消息以提取内容
482
+ user_messages = [m for m in request.messages if m.role == "user"]
483
+ if not user_messages:
484
+ raise HTTPException(status_code=400, detail="至少需要一条用户消息")
485
+
486
+ last_user_message = user_messages[-1]
487
+ prompt = ""
488
+ image_data = None
489
+
490
+ # 提取提示词和图片数据
491
+ if isinstance(last_user_message.content, str):
492
+ # 简单的字符串内容
493
+ prompt = last_user_message.content
494
+
495
+ # 检查是否包含内嵌的base64图片
496
+ pattern = r'data:image\/[^;]+;base64,([^"]+)'
497
+ match = re.search(pattern, prompt)
498
+ if match:
499
+ image_data = match.group(1)
500
+ # 从提示词中删除base64数据,以保持提示词的可读性
501
+ prompt = re.sub(pattern, "[已上传图片]", prompt)
502
+ else:
503
+ # 多模态内容,提取文本和图片
504
+ content_items = last_user_message.content
505
+ text_parts = []
506
+
507
+ for item in content_items:
508
+ if item.type == "text" and item.text:
509
+ text_parts.append(item.text)
510
+ elif item.type == "image_url" and item.image_url:
511
+ # 如果有图片URL包含base64数据
512
+ url = item.image_url.get("url", "")
513
+ if url.startswith("data:image/"):
514
+ pattern = r'data:image\/[^;]+;base64,([^"]+)'
515
+ match = re.search(pattern, url)
516
+ if match:
517
+ image_data = match.group(1)
518
+ text_parts.append("[已上传图片]")
519
+
520
+ prompt = " ".join(text_parts)
521
+
522
+ # 记录开始时间
523
+ start_time = time.time()
524
+ success = False
525
+
526
+ # 处理图片生成
527
+ try:
528
+ # 检查是否为流式响应
529
+ if request.stream:
530
+ # 流式响应特殊处理文本+图片的情况
531
+ if image_data:
532
+ response = StreamingResponse(
533
+ generate_streaming_remix_response(sora_client, prompt, image_data, request.n),
534
+ media_type="text/event-stream"
535
+ )
536
+ else:
537
+ response = StreamingResponse(
538
+ generate_streaming_response(sora_client, prompt, request.n),
539
+ media_type="text/event-stream"
540
+ )
541
+ success = True
542
+
543
+ # 记录请求结果(流式响应立即记录)
544
+ response_time = time.time() - start_time
545
+ key_manager.record_request_result(sora_auth_token, success, response_time)
546
+
547
+ return response
548
+ else:
549
+ # 对于非流式响应,返回一个即时响应,表示任务已接收
550
+ # 创建一个唯一ID
551
+ request_id = f"chatcmpl-{uuid.uuid4().hex}"
552
+
553
+ # 在结果字典中创建初始状态
554
+ processing_message = "正在准备生成任务,请稍候..."
555
+ generation_results[request_id] = {
556
+ "status": "processing",
557
+ "message": format_think_block(processing_message),
558
+ "timestamp": int(time.time())
559
+ }
560
+
561
+ # 添加后台任务
562
+ if image_data:
563
+ background_tasks.add_task(
564
+ process_image_remix,
565
+ request_id,
566
+ sora_client,
567
+ prompt,
568
+ image_data,
569
+ request.n
570
+ )
571
+ else:
572
+ background_tasks.add_task(
573
+ process_image_generation,
574
+ request_id,
575
+ sora_client,
576
+ prompt,
577
+ request.n,
578
+ 720, # width
579
+ 480 # height
580
+ )
581
+
582
+ # 立即返回一个"正在处理中"的响应
583
+ response = {
584
+ "id": request_id,
585
+ "object": "chat.completion",
586
+ "created": int(time.time()),
587
+ "model": "sora-1.0",
588
+ "choices": [
589
+ {
590
+ "index": 0,
591
+ "message": {
592
+ "role": "assistant",
593
+ "content": format_think_block(processing_message)
594
+ },
595
+ "finish_reason": "processing"
596
+ }
597
+ ],
598
+ "usage": {
599
+ "prompt_tokens": len(prompt) // 4,
600
+ "completion_tokens": 10,
601
+ "total_tokens": len(prompt) // 4 + 10
602
+ }
603
+ }
604
+
605
+ success = True
606
+
607
+ # 记录请求结果(非流式响应立即记录)
608
+ response_time = time.time() - start_time
609
+ key_manager.record_request_result(sora_auth_token, success, response_time)
610
+
611
+ return JSONResponse(content=response)
612
+ except Exception as e:
613
+ success = False
614
+
615
+ # 记录请求结果(异常情况也记录)
616
+ response_time = time.time() - start_time
617
+ key_manager.record_request_result(sora_auth_token, success, response_time)
618
+
619
+ raise HTTPException(status_code=500, detail=f"图像生成失败: {str(e)}")
620
+
621
+ # 流式响应生成器 - 普通文本到图像
622
+ async def generate_streaming_response(
623
+ sora_client: SoraClient,
624
+ prompt: str,
625
+ n_images: int = 1
626
+ ):
627
+ request_id = f"chatcmpl-{uuid.uuid4().hex}"
628
+
629
+ # 发送开始事件
630
+ yield f"data: {json.dumps({'id': request_id, 'object': 'chat.completion.chunk', 'created': int(time.time()), 'model': 'sora-1.0', 'choices': [{'index': 0, 'delta': {'role': 'assistant'}, 'finish_reason': None}]})}\n\n"
631
+
632
+ # 发送处理中的消息(放在代码块中)
633
+ start_msg = "```think\n正在生成图像,请稍候...\n"
634
+ yield f"data: {json.dumps({'id': request_id, 'object': 'chat.completion.chunk', 'created': int(time.time()), 'model': 'sora-1.0', 'choices': [{'index': 0, 'delta': {'content': start_msg}, 'finish_reason': None}]})}\n\n"
635
+
636
+ # 创建一个后台任务来生成图像
637
+ logger.info(f"[流式响应 {request_id}] 开始生成图像, 提示词: {prompt}")
638
+ generation_task = asyncio.create_task(sora_client.generate_image(
639
+ prompt=prompt,
640
+ num_images=n_images,
641
+ width=720,
642
+ height=480
643
+ ))
644
+
645
+ # 每5秒发送一条"仍在生成中"的消息,防止连接超时
646
+ progress_messages = [
647
+ "正在处理您的请求...",
648
+ "仍在生成图像中,请继续等待...",
649
+ "Sora正在创作您的图像作品...",
650
+ "图像生成需要一点时间,感谢您的耐心等待...",
651
+ "我们正在努力为您创作高质量图像..."
652
+ ]
653
+
654
+ i = 0
655
+ while not generation_task.done():
656
+ # 每5秒发送一次进度消息
657
+ await asyncio.sleep(5)
658
+ progress_msg = progress_messages[i % len(progress_messages)]
659
+ i += 1
660
+ content = "\n" + progress_msg + "\n"
661
+ yield f"data: {json.dumps({'id': request_id, 'object': 'chat.completion.chunk', 'created': int(time.time()), 'model': 'sora-1.0', 'choices': [{'index': 0, 'delta': {'content': content}, 'finish_reason': None}]})}\n\n"
662
+
663
+ try:
664
+ # 获取生成结果
665
+ image_urls = await generation_task
666
+ logger.info(f"[流式响应 {request_id}] 图像生成完成,获取到 {len(image_urls) if isinstance(image_urls, list) else '非列表'} 个URL")
667
+
668
+ # 本地化图片URL
669
+ if Config.IMAGE_LOCALIZATION and isinstance(image_urls, list) and image_urls:
670
+ logger.info(f"[流式响应 {request_id}] 准备进行图片本地化处理")
671
+ try:
672
+ localized_urls = await localize_image_urls(image_urls)
673
+ logger.info(f"[流式响应 {request_id}] 图片本地化处理完成")
674
+
675
+ # 检查本地化结果
676
+ if not localized_urls:
677
+ logger.warning(f"[流式响应 {request_id}] 本地化处理返回了空列表,将使用原始URL")
678
+ localized_urls = image_urls
679
+
680
+ # 检查是否所有URL都被正确本地化
681
+ local_count = sum(1 for url in localized_urls if url.startswith("/static/") or "/static/" in url)
682
+ if local_count == 0:
683
+ logger.warning(f"[流式响应 {request_id}] 警告:没有一个URL被成功本地化,将使用原始URL")
684
+ localized_urls = image_urls
685
+ else:
686
+ logger.info(f"[流式响应 {request_id}] 成功本地化 {local_count}/{len(localized_urls)} 张图片")
687
+
688
+ # 打印本地化对比结果
689
+ if logger.isEnabledFor(logging.DEBUG):
690
+ for i, (orig, local) in enumerate(zip(image_urls, localized_urls)):
691
+ logger.debug(f"[流式响应 {request_id}] 图片 {i+1}: {orig} -> {local}")
692
+
693
+ image_urls = localized_urls
694
+ except Exception as e:
695
+ logger.error(f"[流式响应 {request_id}] 图片本地化过程中发生错误: {str(e)}")
696
+ if logger.isEnabledFor(logging.DEBUG):
697
+ import traceback
698
+ logger.debug(traceback.format_exc())
699
+ logger.info(f"[流式响应 {request_id}] 由于错误,将使用原始URL")
700
+ elif not Config.IMAGE_LOCALIZATION:
701
+ logger.info(f"[流式响应 {request_id}] 图片本地化功能未启用")
702
+ elif not isinstance(image_urls, list) or not image_urls:
703
+ logger.warning(f"[流式响应 {request_id}] 无法进行本地化: 图像结果不是有效的URL列表")
704
+
705
+ # 结束代码块
706
+ content_str = "\n```\n\n"
707
+ yield f"data: {json.dumps({'id': request_id, 'object': 'chat.completion.chunk', 'created': int(time.time()), 'model': 'sora-1.0', 'choices': [{'index': 0, 'delta': {'content': content_str}, 'finish_reason': None}]})}\n\n"
708
+
709
+ # 添加生成的图片URLs
710
+ for i, url in enumerate(image_urls):
711
+ if i > 0:
712
+ content_str = "\n\n"
713
+ yield f"data: {json.dumps({'id': request_id, 'object': 'chat.completion.chunk', 'created': int(time.time()), 'model': 'sora-1.0', 'choices': [{'index': 0, 'delta': {'content': content_str}, 'finish_reason': None}]})}\n\n"
714
+
715
+ image_markdown = f"![Generated Image]({url})"
716
+ yield f"data: {json.dumps({'id': request_id, 'object': 'chat.completion.chunk', 'created': int(time.time()), 'model': 'sora-1.0', 'choices': [{'index': 0, 'delta': {'content': image_markdown}, 'finish_reason': None}]})}\n\n"
717
+
718
+ # 发送完成事件
719
+ yield f"data: {json.dumps({'id': request_id, 'object': 'chat.completion.chunk', 'created': int(time.time()), 'model': 'sora-1.0', 'choices': [{'index': 0, 'delta': {}, 'finish_reason': 'stop'}]})}\n\n"
720
+
721
+ # 发送结束标志
722
+ yield "data: [DONE]\n\n"
723
+ except Exception as e:
724
+ error_msg = f"图像生成失败: {str(e)}"
725
+ logger.error(f"[流式响应 {request_id}] 错误: {error_msg}")
726
+ if logger.isEnabledFor(logging.DEBUG):
727
+ import traceback
728
+ logger.debug(traceback.format_exc())
729
+ error_content = f"\n{error_msg}\n```"
730
+ yield f"data: {json.dumps({'id': request_id, 'object': 'chat.completion.chunk', 'created': int(time.time()), 'model': 'sora-1.0', 'choices': [{'index': 0, 'delta': {'content': error_content}, 'finish_reason': 'error'}]})}\n\n"
731
+ yield "data: [DONE]\n\n"
732
+
733
+ # 流式响应生成器 - 带图片的remix
734
+ async def generate_streaming_remix_response(
735
+ sora_client: SoraClient,
736
+ prompt: str,
737
+ image_data: str,
738
+ n_images: int = 1
739
+ ):
740
+ request_id = f"chatcmpl-{uuid.uuid4().hex}"
741
+
742
+ # 发送开始事件
743
+ yield f"data: {json.dumps({'id': request_id, 'object': 'chat.completion.chunk', 'created': int(time.time()), 'model': 'sora-1.0', 'choices': [{'index': 0, 'delta': {'role': 'assistant'}, 'finish_reason': None}]})}\n\n"
744
+
745
+ try:
746
+ # 保存base64图片到临时文件
747
+ temp_dir = tempfile.mkdtemp()
748
+ temp_image_path = os.path.join(temp_dir, f"upload_{uuid.uuid4()}.png")
749
+
750
+ try:
751
+ # 解码并保存图片
752
+ with open(temp_image_path, "wb") as f:
753
+ f.write(base64.b64decode(image_data))
754
+
755
+ # 上传图片
756
+ upload_msg = "```think\n上传图片中...\n"
757
+ yield f"data: {json.dumps({'id': request_id, 'object': 'chat.completion.chunk', 'created': int(time.time()), 'model': 'sora-1.0', 'choices': [{'index': 0, 'delta': {'content': upload_msg}, 'finish_reason': None}]})}\n\n"
758
+
759
+ logger.info(f"[流式响应Remix {request_id}] 上传图片中")
760
+ upload_result = await sora_client.upload_image(temp_image_path)
761
+ media_id = upload_result['id']
762
+
763
+ # 发送生成中消息
764
+ generate_msg = "\n基于图片生成新图像中...\n"
765
+ yield f"data: {json.dumps({'id': request_id, 'object': 'chat.completion.chunk', 'created': int(time.time()), 'model': 'sora-1.0', 'choices': [{'index': 0, 'delta': {'content': generate_msg}, 'finish_reason': None}]})}\n\n"
766
+
767
+ # 创建一个后台任务来生成图像
768
+ logger.info(f"[流式响应Remix {request_id}] 开始生成图像,提示词: {prompt}")
769
+ generation_task = asyncio.create_task(sora_client.generate_image_remix(
770
+ prompt=prompt,
771
+ media_id=media_id,
772
+ num_images=n_images
773
+ ))
774
+
775
+ # 每5秒发送一条"仍在生成中"的消息,防止连接超时
776
+ progress_messages = [
777
+ "正在处理您的请求...",
778
+ "仍在生成图像中,请继续等待...",
779
+ "Sora正在基于您的图片创作新作品...",
780
+ "图像生成需要一点时间,感谢您的耐心等待...",
781
+ "正在努力融合您的风格和提示词,打造专属图像..."
782
+ ]
783
+
784
+ i = 0
785
+ while not generation_task.done():
786
+ # 每5秒发送一次进度消息
787
+ await asyncio.sleep(5)
788
+ progress_msg = progress_messages[i % len(progress_messages)]
789
+ i += 1
790
+ content = "\n" + progress_msg + "\n"
791
+ yield f"data: {json.dumps({'id': request_id, 'object': 'chat.completion.chunk', 'created': int(time.time()), 'model': 'sora-1.0', 'choices': [{'index': 0, 'delta': {'content': content}, 'finish_reason': None}]})}\n\n"
792
+
793
+ # 获取生成结果
794
+ image_urls = await generation_task
795
+ logger.info(f"[流式响应Remix {request_id}] 图像生成完成")
796
+
797
+ # 本地化图片URL
798
+ if Config.IMAGE_LOCALIZATION:
799
+ logger.info(f"[流式响应Remix {request_id}] 进行图片本地化处理")
800
+ localized_urls = await localize_image_urls(image_urls)
801
+ image_urls = localized_urls
802
+ logger.info(f"[流式响应Remix {request_id}] 图片本地化处理完成")
803
+ else:
804
+ logger.info(f"[流式响应Remix {request_id}] 图片本地化功能未启用")
805
+
806
+ # 结束代码块
807
+ content_str = "\n```\n\n"
808
+ yield f"data: {json.dumps({'id': request_id, 'object': 'chat.completion.chunk', 'created': int(time.time()), 'model': 'sora-1.0', 'choices': [{'index': 0, 'delta': {'content': content_str}, 'finish_reason': None}]})}\n\n"
809
+
810
+ # 发送图片URL作为Markdown
811
+ for i, url in enumerate(image_urls):
812
+ if i > 0:
813
+ newline_str = "\n\n"
814
+ yield f"data: {json.dumps({'id': request_id, 'object': 'chat.completion.chunk', 'created': int(time.time()), 'model': 'sora-1.0', 'choices': [{'index': 0, 'delta': {'content': newline_str}, 'finish_reason': None}]})}\n\n"
815
+
816
+ image_markdown = f"![Generated Image]({url})"
817
+ yield f"data: {json.dumps({'id': request_id, 'object': 'chat.completion.chunk', 'created': int(time.time()), 'model': 'sora-1.0', 'choices': [{'index': 0, 'delta': {'content': image_markdown}, 'finish_reason': None}]})}\n\n"
818
+
819
+ # 发送完成事件
820
+ yield f"data: {json.dumps({'id': request_id, 'object': 'chat.completion.chunk', 'created': int(time.time()), 'model': 'sora-1.0', 'choices': [{'index': 0, 'delta': {}, 'finish_reason': 'stop'}]})}\n\n"
821
+
822
+ # 发送结束标志
823
+ yield "data: [DONE]\n\n"
824
+
825
+ finally:
826
+ # 清理临时文件
827
+ if os.path.exists(temp_image_path):
828
+ os.remove(temp_image_path)
829
+ if os.path.exists(temp_dir):
830
+ os.rmdir(temp_dir)
831
+
832
+ except Exception as e:
833
+ error_msg = f"图像Remix失败: {str(e)}"
834
+ logger.error(f"[流式响应Remix {request_id}] 错误: {error_msg}")
835
+ if logger.isEnabledFor(logging.DEBUG):
836
+ import traceback
837
+ logger.debug(traceback.format_exc())
838
+ error_content = f"\n{error_msg}\n```"
839
+ yield f"data: {json.dumps({'id': request_id, 'object': 'chat.completion.chunk', 'created': int(time.time()), 'model': 'sora-1.0', 'choices': [{'index': 0, 'delta': {'content': error_content}, 'finish_reason': 'error'}]})}\n\n"
840
+
841
+ # 结束流
842
+ yield "data: [DONE]\n\n"
843
+
844
+ # API密钥管理端点
845
+ @app.get("/api/keys")
846
+ async def get_all_keys(admin_key: str = Depends(verify_admin)):
847
+ """获取所有API密钥"""
848
+ return key_manager.get_all_keys()
849
+
850
+ @app.get("/api/keys/{key_id}")
851
+ async def get_key(key_id: str, admin_key: str = Depends(verify_admin)):
852
+ """获取单个API密钥详情"""
853
+ key = key_manager.get_key_by_id(key_id)
854
+ if not key:
855
+ raise HTTPException(status_code=404, detail="密钥不存在")
856
+ return key
857
+
858
+ @app.post("/api/keys")
859
+ async def create_key(key_data: ApiKeyCreate, admin_key: str = Depends(verify_admin)):
860
+ """创建新API密钥"""
861
+ try:
862
+ # 确保密钥值包含 Bearer 前缀
863
+ key_value = key_data.key_value
864
+ if not key_value.startswith("Bearer "):
865
+ key_value = f"Bearer {key_value}"
866
+
867
+ new_key = key_manager.add_key(
868
+ key_value,
869
+ name=key_data.name,
870
+ weight=key_data.weight,
871
+ rate_limit=key_data.rate_limit,
872
+ is_enabled=key_data.is_enabled,
873
+ notes=key_data.notes
874
+ )
875
+
876
+ # 通过Config永久保存所有密钥
877
+ Config.save_api_keys(key_manager.keys)
878
+
879
+ return new_key
880
+ except Exception as e:
881
+ raise HTTPException(status_code=400, detail=str(e))
882
+
883
+ @app.put("/api/keys/{key_id}")
884
+ async def update_key(key_id: str, key_data: ApiKeyUpdate, admin_key: str = Depends(verify_admin)):
885
+ """更新API密钥信息"""
886
+ try:
887
+ # 如果提供了新的密钥值,确保包含Bearer前缀
888
+ key_value = key_data.key_value
889
+ if key_value and not key_value.startswith("Bearer "):
890
+ key_value = f"Bearer {key_value}"
891
+ key_data.key_value = key_value
892
+
893
+ updated_key = key_manager.update_key(
894
+ key_id,
895
+ key_value=key_data.key_value,
896
+ name=key_data.name,
897
+ weight=key_data.weight,
898
+ rate_limit=key_data.rate_limit,
899
+ is_enabled=key_data.is_enabled,
900
+ notes=key_data.notes
901
+ )
902
+ if not updated_key:
903
+ raise HTTPException(status_code=404, detail="密钥不存在")
904
+
905
+ # 通过Config永久保存所有密钥
906
+ Config.save_api_keys(key_manager.keys)
907
+
908
+ return updated_key
909
+ except Exception as e:
910
+ raise HTTPException(status_code=400, detail=str(e))
911
+
912
+ @app.delete("/api/keys/{key_id}")
913
+ async def delete_key(key_id: str, admin_key: str = Depends(verify_admin)):
914
+ """删除API密钥"""
915
+ success = key_manager.delete_key(key_id)
916
+ if not success:
917
+ raise HTTPException(status_code=404, detail="密钥不存在")
918
+
919
+ # 通过Config永久保存所有密钥
920
+ Config.save_api_keys(key_manager.keys)
921
+
922
+ return {"status": "success", "message": "密钥已删除"}
923
+
924
+ @app.get("/api/stats")
925
+ async def get_usage_stats(admin_key: str = Depends(verify_admin)):
926
+ """获取API使用统计"""
927
+ return key_manager.get_usage_stats()
928
+
929
+ @app.post("/api/keys/test")
930
+ async def test_key(key_data: ApiKeyCreate, admin_key: str = Depends(verify_admin)):
931
+ """测试API密钥是否有效"""
932
+ try:
933
+ # 确保密钥值包含 Bearer 前缀
934
+ key_value = key_data.key_value
935
+ if not key_value.startswith("Bearer "):
936
+ key_value = f"Bearer {key_value}"
937
+
938
+ # 获取代理配置
939
+ proxy_host = Config.PROXY_HOST if Config.PROXY_HOST and Config.PROXY_HOST.strip() else None
940
+ proxy_port = Config.PROXY_PORT if Config.PROXY_PORT and Config.PROXY_PORT.strip() else None
941
+
942
+ # 创建临时客户端测试连接
943
+ test_client = SoraClient(
944
+ proxy_host=proxy_host,
945
+ proxy_port=proxy_port,
946
+ auth_token=key_value
947
+ )
948
+ # 执行简单API调用测试连接
949
+ test_result = await test_client.test_connection()
950
+ return {"status": "success", "message": "API密钥测试成功", "details": test_result}
951
+ except Exception as e:
952
+ return {"status": "error", "message": f"API密钥测试失败: {str(e)}"}
953
+
954
+ @app.post("/api/keys/batch")
955
+ async def batch_operation(operation: Dict[str, Any], admin_key: str = Depends(verify_admin)):
956
+ """批量操作API密钥"""
957
+ action = operation.get("action")
958
+ key_ids = operation.get("key_ids", [])
959
+
960
+ if not action or not key_ids:
961
+ raise HTTPException(status_code=400, detail="无效的请求参数")
962
+
963
+ # 确保key_ids是一个列表
964
+ if isinstance(key_ids, str):
965
+ key_ids = [key_ids]
966
+
967
+ results = {}
968
+
969
+ if action == "enable":
970
+ for key_id in key_ids:
971
+ success = key_manager.update_key(key_id, is_enabled=True)
972
+ results[key_id] = "success" if success else "failed"
973
+ elif action == "disable":
974
+ for key_id in key_ids:
975
+ success = key_manager.update_key(key_id, is_enabled=False)
976
+ results[key_id] = "success" if success else "failed"
977
+ elif action == "delete":
978
+ for key_id in key_ids:
979
+ success = key_manager.delete_key(key_id)
980
+ results[key_id] = "success" if success else "failed"
981
+ else:
982
+ raise HTTPException(status_code=400, detail="不支持的操作类型")
983
+
984
+ # 通过Config永久保存所有密钥
985
+ Config.save_api_keys(key_manager.keys)
986
+
987
+ return {"status": "success", "results": results}
988
+
989
+ # 健康检查端点
990
+ @app.get("/health")
991
+ async def health_check():
992
+ return {"status": "ok", "timestamp": time.time()}
993
+
994
+ # 管理界面路由
995
+ @app.get("/admin")
996
+ async def admin_panel():
997
+ return FileResponse(os.path.join(Config.STATIC_DIR, "admin/index.html"))
998
+
999
+ # 管理员密钥API
1000
+ @app.get("/admin/key")
1001
+ async def admin_key():
1002
+ return {"admin_key": Config.ADMIN_KEY}
1003
+
1004
+ # 挂载静态文件
1005
+ app.mount("/admin", StaticFiles(directory=os.path.join(Config.STATIC_DIR, "admin"), html=True), name="admin")
1006
+
1007
+ # 配置管理模型
1008
+ class ConfigUpdate(BaseModel):
1009
+ IMAGE_LOCALIZATION: Optional[bool] = None
1010
+ IMAGE_SAVE_DIR: Optional[str] = None
1011
+ LOG_LEVEL: Optional[str] = None
1012
+
1013
+ # 配置管理页面
1014
+ @app.get("/admin/config")
1015
+ async def config_panel():
1016
+ return FileResponse("src/static/admin/config.html")
1017
+
1018
+ # 获取当前配置
1019
+ @app.get("/api/config")
1020
+ async def get_config(admin_key: str = Depends(verify_admin)):
1021
+ """获取当前系统配置"""
1022
+ return {
1023
+ "IMAGE_LOCALIZATION": Config.IMAGE_LOCALIZATION,
1024
+ "IMAGE_SAVE_DIR": Config.IMAGE_SAVE_DIR,
1025
+ "LOG_LEVEL": LogConfig.LEVEL
1026
+ }
1027
+
1028
+ # 更新配置
1029
+ @app.post("/api/config")
1030
+ async def update_config(config_data: ConfigUpdate, admin_key: str = Depends(verify_admin)):
1031
+ """更新系统配置"""
1032
+ changes = {}
1033
+
1034
+ if config_data.IMAGE_LOCALIZATION is not None:
1035
+ old_value = Config.IMAGE_LOCALIZATION
1036
+ Config.IMAGE_LOCALIZATION = config_data.IMAGE_LOCALIZATION
1037
+ os.environ["IMAGE_LOCALIZATION"] = str(config_data.IMAGE_LOCALIZATION)
1038
+ changes["IMAGE_LOCALIZATION"] = {
1039
+ "old": old_value,
1040
+ "new": Config.IMAGE_LOCALIZATION
1041
+ }
1042
+
1043
+ if config_data.IMAGE_SAVE_DIR is not None:
1044
+ old_value = Config.IMAGE_SAVE_DIR
1045
+ Config.IMAGE_SAVE_DIR = config_data.IMAGE_SAVE_DIR
1046
+ os.environ["IMAGE_SAVE_DIR"] = config_data.IMAGE_SAVE_DIR
1047
+
1048
+ # 确保目录存在
1049
+ os.makedirs(Config.IMAGE_SAVE_DIR, exist_ok=True)
1050
+
1051
+ changes["IMAGE_SAVE_DIR"] = {
1052
+ "old": old_value,
1053
+ "new": Config.IMAGE_SAVE_DIR
1054
+ }
1055
+
1056
+ if config_data.LOG_LEVEL is not None:
1057
+ old_value = LogConfig.LEVEL
1058
+ level = config_data.LOG_LEVEL.upper()
1059
+ # 验证日志级别是否有效
1060
+ valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
1061
+ if level not in valid_levels:
1062
+ raise HTTPException(status_code=400, detail=f"无效的日志级别,有效值:{', '.join(valid_levels)}")
1063
+
1064
+ # 更新日志级别
1065
+ LogConfig.LEVEL = level
1066
+ logging.getLogger("sora-api").setLevel(getattr(logging, level))
1067
+ os.environ["LOG_LEVEL"] = level
1068
+
1069
+ changes["LOG_LEVEL"] = {
1070
+ "old": old_value,
1071
+ "new": level
1072
+ }
1073
+
1074
+ logger.info(f"日志级别已更改为: {level}")
1075
+
1076
+ # 保存到.env文件以持久化配置
1077
+ try:
1078
+ dotenv_file = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".env")
1079
+
1080
+ # 读取现有.env文件
1081
+ env_vars = {}
1082
+ if os.path.exists(dotenv_file):
1083
+ with open(dotenv_file, "r") as f:
1084
+ for line in f:
1085
+ if line.strip() and not line.startswith("#"):
1086
+ key, value = line.strip().split("=", 1)
1087
+ env_vars[key] = value
1088
+
1089
+ # 更新值
1090
+ if config_data.IMAGE_LOCALIZATION is not None:
1091
+ env_vars["IMAGE_LOCALIZATION"] = str(config_data.IMAGE_LOCALIZATION)
1092
+ if config_data.IMAGE_SAVE_DIR is not None:
1093
+ env_vars["IMAGE_SAVE_DIR"] = config_data.IMAGE_SAVE_DIR
1094
+ if config_data.LOG_LEVEL is not None:
1095
+ env_vars["LOG_LEVEL"] = config_data.LOG_LEVEL.upper()
1096
+
1097
+ # 写回文件
1098
+ with open(dotenv_file, "w") as f:
1099
+ for key, value in env_vars.items():
1100
+ f.write(f"{key}={value}\n")
1101
+ except Exception as e:
1102
+ logger.error(f"保存配置到.env文件失败: {str(e)}")
1103
+
1104
+ return {
1105
+ "success": True,
1106
+ "message": "配置已更新",
1107
+ "changes": changes
1108
+ }
1109
+
1110
+ # 日志级别控制
1111
+ class LogLevelUpdate(BaseModel):
1112
+ level: str = Field(..., description="日志级别")
1113
+
1114
+ @app.post("/api/logs/level")
1115
+ async def update_log_level(data: LogLevelUpdate, admin_key: str = Depends(verify_admin)):
1116
+ """更新系统日志级别"""
1117
+ level = data.level.upper()
1118
+
1119
+ # 验证日志级别是否有效
1120
+ valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
1121
+ if level not in valid_levels:
1122
+ raise HTTPException(status_code=400, detail=f"无效的日志级别,有效值:{', '.join(valid_levels)}")
1123
+
1124
+ # 更新日志级别
1125
+ old_level = LogConfig.LEVEL
1126
+ LogConfig.LEVEL = level
1127
+ logging.getLogger("sora-api").setLevel(getattr(logging, level))
1128
+ os.environ["LOG_LEVEL"] = level
1129
+
1130
+ # 记录变更
1131
+ logger.info(f"日志级别已更改为: {level}")
1132
+
1133
+ # 更新.env文件
1134
+ try:
1135
+ dotenv_file = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".env")
1136
+
1137
+ # 读取现有.env文件
1138
+ env_vars = {}
1139
+ if os.path.exists(dotenv_file):
1140
+ with open(dotenv_file, "r") as f:
1141
+ for line in f:
1142
+ if line.strip() and not line.startswith("#"):
1143
+ key, value = line.strip().split("=", 1)
1144
+ env_vars[key] = value
1145
+
1146
+ # 更新日志级别
1147
+ env_vars["LOG_LEVEL"] = level
1148
+
1149
+ # 写回文件
1150
+ with open(dotenv_file, "w") as f:
1151
+ for key, value in env_vars.items():
1152
+ f.write(f"{key}={value}\n")
1153
+ except Exception as e:
1154
+ logger.warning(f"保存日志级别到.env文件失败: {str(e)}")
1155
+
1156
+ return {
1157
+ "success": True,
1158
+ "message": f"日志级别已更改: {old_level} -> {level}"
1159
+ }
src/app.py.optimized ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import aiohttp
3
+ import logging
4
+ from fastapi import FastAPI, Request, HTTPException
5
+ from fastapi.responses import JSONResponse, FileResponse
6
+ from fastapi.middleware.cors import CORSMiddleware
7
+ from fastapi.staticfiles import StaticFiles
8
+ from fastapi.exceptions import RequestValidationError
9
+
10
+ from .config import Config
11
+ from .key_manager import KeyManager
12
+ from .api import main_router
13
+ from .api.dependencies import session_pool
14
+
15
+ # 配置日志
16
+ logging.basicConfig(
17
+ level=getattr(logging, os.getenv("LOG_LEVEL", "INFO").upper()),
18
+ format="%(asctime)s [%(levelname)s] %(message)s",
19
+ datefmt="%Y-%m-%d %H:%M:%S"
20
+ )
21
+
22
+ logger = logging.getLogger("sora-api")
23
+
24
+ # 全局键管理器
25
+ key_manager = KeyManager(storage_file=Config.KEYS_STORAGE_FILE)
26
+
27
+ # 创建FastAPI应用
28
+ app = FastAPI(
29
+ title="OpenAI Compatible Sora API",
30
+ description="为Sora提供OpenAI兼容接口的API服务",
31
+ version="2.0.0",
32
+ docs_url="/docs",
33
+ redoc_url="/redoc",
34
+ )
35
+
36
+ # 配置CORS中间件
37
+ origins = [
38
+ "http://localhost",
39
+ "http://localhost:8890",
40
+ f"http://{Config.HOST}:{Config.PORT}",
41
+ Config.BASE_URL,
42
+ ]
43
+
44
+ app.add_middleware(
45
+ CORSMiddleware,
46
+ allow_origins=origins,
47
+ allow_credentials=True,
48
+ allow_methods=["GET", "POST", "PUT", "DELETE"],
49
+ allow_headers=["Authorization", "Content-Type"],
50
+ )
51
+
52
+ # 异常处理
53
+ @app.exception_handler(RequestValidationError)
54
+ async def validation_exception_handler(request: Request, exc: RequestValidationError):
55
+ """处理请求验证错误"""
56
+ return JSONResponse(
57
+ status_code=422,
58
+ content={"detail": f"请求验证错误: {str(exc)}"}
59
+ )
60
+
61
+ @app.exception_handler(Exception)
62
+ async def global_exception_handler(request: Request, exc: Exception):
63
+ """全局异常处理器"""
64
+ # 记录错误
65
+ logger.error(f"全局异常: {str(exc)}", exc_info=True)
66
+
67
+ # 如果是已知的HTTPException,保持原始状态码和详情
68
+ if isinstance(exc, HTTPException):
69
+ return JSONResponse(
70
+ status_code=exc.status_code,
71
+ content={"detail": exc.detail}
72
+ )
73
+
74
+ # 其他异常返回500状态码
75
+ return JSONResponse(
76
+ status_code=500,
77
+ content={"detail": f"服务器内部错误: {str(exc)}"}
78
+ )
79
+
80
+ # 应用启动和关闭事件
81
+ @app.on_event("startup")
82
+ async def startup_event():
83
+ """应用启动时执行的操作"""
84
+ global session_pool
85
+ # 创建共享会话池
86
+ session_pool = aiohttp.ClientSession()
87
+ logger.info("应用已启动,创建了全局会话池")
88
+
89
+ # 初始化时保存管理员密钥
90
+ Config.save_admin_key()
91
+
92
+ # 确保静态文件目录存在
93
+ os.makedirs(os.path.join(Config.STATIC_DIR, "admin"), exist_ok=True)
94
+ os.makedirs(os.path.join(Config.STATIC_DIR, "admin/js"), exist_ok=True)
95
+ os.makedirs(os.path.join(Config.STATIC_DIR, "admin/css"), exist_ok=True)
96
+ os.makedirs(os.path.join(Config.STATIC_DIR, "images"), exist_ok=True)
97
+
98
+ # 打印配置信息
99
+ Config.print_config()
100
+
101
+ @app.on_event("shutdown")
102
+ async def shutdown_event():
103
+ """应用关闭时执行的操作"""
104
+ # 关闭会话池
105
+ if session_pool:
106
+ await session_pool.close()
107
+ logger.info("应用已关闭,清理了全局会话池")
108
+
109
+ # 挂载静态文件目录
110
+ app.mount("/static", StaticFiles(directory=Config.STATIC_DIR), name="static")
111
+
112
+ # 管理界面路由
113
+ @app.get("/admin")
114
+ async def admin_panel():
115
+ """返回管理面板HTML页面"""
116
+ return FileResponse(os.path.join(Config.STATIC_DIR, "admin/index.html"))
117
+
118
+ # 注册所有API路由
119
+ app.include_router(main_router)
120
+
121
+ # 应用入口点(供uvicorn直接调用)
122
+ if __name__ == "__main__":
123
+ import uvicorn
124
+ uvicorn.run(
125
+ "app:app",
126
+ host=Config.HOST,
127
+ port=Config.PORT,
128
+ reload=Config.VERBOSE_LOGGING
129
+ )
src/config.py ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import uuid
4
+ from typing import List, Dict
5
+ import logging
6
+
7
+ logger = logging.getLogger("sora-api.config")
8
+
9
+ class Config:
10
+ # API服务配置
11
+ HOST = os.getenv("API_HOST", "0.0.0.0")
12
+ PORT = int(os.getenv("API_PORT", "8890"))
13
+
14
+ # 基础URL配置
15
+ BASE_URL = os.getenv("BASE_URL", f"http://0.0.0.0:{PORT}")
16
+
17
+ # 静态文件路径前缀,用于处理应用部署在子路径的情况
18
+ # 例如: /sora-api 表示应用部署在 /sora-api 下
19
+ STATIC_PATH_PREFIX = os.getenv("STATIC_PATH_PREFIX", "")
20
+
21
+ # 代理配置
22
+ PROXY_HOST = os.getenv("PROXY_HOST", "")
23
+ PROXY_PORT = os.getenv("PROXY_PORT", "")
24
+ PROXY_USER = os.getenv("PROXY_USER", "")
25
+ PROXY_PASS = os.getenv("PROXY_PASS", "")
26
+
27
+ # 目录配置
28
+ ROOT_DIR = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
29
+ BASE_DIR = ROOT_DIR
30
+ STATIC_DIR = os.getenv("STATIC_DIR", os.path.join(BASE_DIR, "src/static"))
31
+
32
+ # 图片保存目录 - 用户只需设置这一项
33
+ IMAGE_SAVE_DIR = os.getenv("IMAGE_SAVE_DIR", os.path.join(STATIC_DIR, "images"))
34
+
35
+ # 图片本地化配置
36
+ IMAGE_LOCALIZATION = os.getenv("IMAGE_LOCALIZATION", "False").lower() in ("true", "1", "yes")
37
+
38
+ # 当外部访问地址与服务器地址不同时,可通过BASE_URL覆盖图片访问地址
39
+ # 例如:当服务器在内网,但通过反向代理从外网访问时
40
+
41
+ # API Keys配置
42
+ API_KEYS = []
43
+
44
+ # 管理员配置
45
+ ADMIN_KEY = os.getenv("ADMIN_KEY", "sk-123456")
46
+ KEYS_STORAGE_FILE = os.getenv("KEYS_STORAGE_FILE", "api_keys.json")
47
+
48
+ # API认证令牌
49
+ API_AUTH_TOKEN = os.getenv("API_AUTH_TOKEN", "")
50
+
51
+ # 日志配置
52
+ VERBOSE_LOGGING = os.getenv("VERBOSE_LOGGING", "False").lower() in ("true", "1", "yes")
53
+
54
+ @classmethod
55
+ def print_config(cls):
56
+ """打印当前配置信息"""
57
+ print("\n==== Sora API 配置信息 ====")
58
+ print(f"基础目录: {cls.BASE_DIR}")
59
+ print(f"API服务地址: {cls.HOST}:{cls.PORT}")
60
+ print(f"基础URL: {cls.BASE_URL}")
61
+
62
+ # API认证信息
63
+ if cls.API_AUTH_TOKEN:
64
+ print(f"API认证令牌: 已设置 (长度: {len(cls.API_AUTH_TOKEN)})")
65
+ else:
66
+ print(f"API认证令牌: 未设置 (将使用管理面板的key)")
67
+
68
+ # 详细日志
69
+ if cls.VERBOSE_LOGGING:
70
+ print(f"静态文件目录: {cls.STATIC_DIR}")
71
+ print(f"图片保存目录: {cls.IMAGE_SAVE_DIR}")
72
+ print(f"图片本地化: {'启用' if cls.IMAGE_LOCALIZATION else '禁用'}")
73
+
74
+ # 代理配置信息
75
+ if cls.PROXY_HOST:
76
+ proxy_info = f"{cls.PROXY_HOST}:{cls.PROXY_PORT}"
77
+ if cls.PROXY_USER:
78
+ proxy_info = f"{cls.PROXY_USER}:****@{proxy_info}"
79
+ print(f"代理配置: {proxy_info}")
80
+ else:
81
+ print(f"代理配置: (未配置)")
82
+
83
+ # 确保必要目录存在
84
+ cls._ensure_directories()
85
+
86
+ @classmethod
87
+ def _ensure_directories(cls):
88
+ """确保必要的目录存在"""
89
+ # 检查静态文件目录
90
+ if not os.path.exists(cls.STATIC_DIR):
91
+ print(f"⚠️ 警告: 静态文件目录不存在: {cls.STATIC_DIR}")
92
+ elif cls.VERBOSE_LOGGING:
93
+ print(f"✅ 静态文件目录存在")
94
+
95
+ # 检查并创建图片保存目录
96
+ if not os.path.exists(cls.IMAGE_SAVE_DIR):
97
+ try:
98
+ os.makedirs(cls.IMAGE_SAVE_DIR, exist_ok=True)
99
+ if cls.VERBOSE_LOGGING:
100
+ print(f"✅ 已创建图片保存目录: {cls.IMAGE_SAVE_DIR}")
101
+ except Exception as e:
102
+ print(f"❌ 创建图片保存目录失败: {str(e)}")
103
+ elif cls.VERBOSE_LOGGING:
104
+ print(f"✅ 图片保存目录存在")
105
+
106
+ # 测试写入权限
107
+ try:
108
+ test_file = os.path.join(cls.IMAGE_SAVE_DIR, '.test_write')
109
+ with open(test_file, 'w') as f:
110
+ f.write('test')
111
+ os.remove(test_file)
112
+ print(f"✅ 图片保存目录有写入权限")
113
+ except Exception as e:
114
+ print(f"❌ 图片保存目录没有写入权限: {str(e)}")
115
+
116
+ @classmethod
117
+ def load_api_keys(cls):
118
+ """加载API密钥"""
119
+ # 先从环境变量加载
120
+ api_keys_str = os.getenv("API_KEYS", "")
121
+ if api_keys_str:
122
+ try:
123
+ cls.API_KEYS = json.loads(api_keys_str)
124
+ if cls.VERBOSE_LOGGING:
125
+ logger.info(f"已从环境变量加载 {len(cls.API_KEYS)} 个API keys")
126
+ return
127
+ except json.JSONDecodeError as e:
128
+ logger.error(f"解析环境变量API keys失败: {e}")
129
+
130
+ # 再从文件加载
131
+ try:
132
+ if os.path.exists(cls.KEYS_STORAGE_FILE):
133
+ with open(cls.KEYS_STORAGE_FILE, "r", encoding="utf-8") as f:
134
+ keys_data = json.load(f)
135
+ if isinstance(keys_data, dict) and "keys" in keys_data:
136
+ cls.API_KEYS = [k for k in keys_data["keys"] if k.get("key")]
137
+ else:
138
+ cls.API_KEYS = keys_data
139
+
140
+ if cls.VERBOSE_LOGGING:
141
+ logger.info(f"已从文件加载 {len(cls.API_KEYS)} 个API keys")
142
+ except Exception as e:
143
+ logger.error(f"从文件加载API keys失败: {e}")
144
+
145
+ @classmethod
146
+ def save_api_keys(cls, keys_data):
147
+ """保存API密钥到文件"""
148
+ try:
149
+ keys_storage_file = os.path.join(cls.BASE_DIR, cls.KEYS_STORAGE_FILE)
150
+
151
+ with open(keys_storage_file, "w", encoding="utf-8") as f:
152
+ if isinstance(keys_data, list):
153
+ json.dump({"keys": keys_data}, f, ensure_ascii=False, indent=2)
154
+ else:
155
+ json.dump(keys_data, f, ensure_ascii=False, indent=2)
156
+
157
+ # 更新内存中的keys
158
+ if isinstance(keys_data, dict) and "keys" in keys_data:
159
+ cls.API_KEYS = [k for k in keys_data["keys"] if k.get("key")]
160
+ else:
161
+ cls.API_KEYS = keys_data
162
+
163
+ if cls.VERBOSE_LOGGING:
164
+ logger.info(f"API keys已保存至 {keys_storage_file}")
165
+ except Exception as e:
166
+ logger.error(f"保存API keys失败: {e}")
167
+
168
+ @classmethod
169
+ def save_admin_key(cls):
170
+ """保存管理员密钥到文件"""
171
+ try:
172
+ admin_config_file = os.path.join(cls.BASE_DIR, "admin_config.json")
173
+ with open(admin_config_file, "w", encoding="utf-8") as f:
174
+ json.dump({"admin_key": cls.ADMIN_KEY}, f, indent=2)
175
+
176
+ if cls.VERBOSE_LOGGING:
177
+ logger.info(f"管理员密钥已保存至 {admin_config_file}")
178
+ except Exception as e:
179
+ logger.error(f"保存管理员密钥失败: {e}")
src/key_manager.py ADDED
@@ -0,0 +1,777 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ import random
3
+ import uuid
4
+ import json
5
+ import os
6
+ import logging
7
+ import threading
8
+ from typing import Dict, List, Optional, Any, Union, Tuple, Callable
9
+
10
+ # 初始化日志
11
+ logger = logging.getLogger("sora-api.key_manager")
12
+
13
+ class KeyManager:
14
+ def __init__(self, storage_file: str = "api_keys.json"):
15
+ """
16
+ 初始化密钥管理器
17
+
18
+ Args:
19
+ storage_file: 密钥存储文件路径
20
+ """
21
+ self.keys = [] # 密钥列表
22
+ self.storage_file = storage_file
23
+ self.usage_stats = {} # 使用统计
24
+ self._lock = threading.RLock() # 添加可重入锁以支持并发访问
25
+ self._working_keys = {} # 新增:记录正在工作中的密钥 {key_value: task_id}
26
+ self._load_keys()
27
+
28
+ def _load_keys(self) -> None:
29
+ """从环境变量或文件加载密钥"""
30
+ keys_loaded = False
31
+
32
+ # 先尝试从环境变量加载
33
+ api_keys_str = os.getenv("API_KEYS", "")
34
+ if api_keys_str:
35
+ try:
36
+ env_data = json.loads(api_keys_str)
37
+ self._process_keys_data(env_data)
38
+ if len(self.keys) > 0:
39
+ logger.info(f"已从环境变量加载 {len(self.keys)} 个密钥")
40
+ keys_loaded = True
41
+ else:
42
+ logger.warning("环境变量API_KEYS存在但未包含有效密钥")
43
+ except json.JSONDecodeError as e:
44
+ logger.error(f"解析环境变量API keys失败: {str(e)}")
45
+
46
+ # 如果环境变量未设置、解析失败或未加载到密钥,从文件加载
47
+ if not keys_loaded:
48
+ try:
49
+ if os.path.exists(self.storage_file):
50
+ logger.info(f"尝试从文件加载密钥: {self.storage_file}")
51
+ with open(self.storage_file, 'r', encoding='utf-8') as f:
52
+ data = json.load(f)
53
+ keys_before = len(self.keys)
54
+ self._process_keys_data(data)
55
+ keys_loaded = len(self.keys) > keys_before
56
+ logger.info(f"已从文件加载 {len(self.keys) - keys_before} 个密钥")
57
+ else:
58
+ logger.warning(f"密钥文件不存在: {self.storage_file}")
59
+ except Exception as e:
60
+ logger.error(f"加载密钥失败: {str(e)}")
61
+
62
+ if len(self.keys) == 0:
63
+ logger.warning("未能从环境变量或文件加载任何密钥")
64
+
65
+
66
+ def _process_keys_data(self, data):
67
+ """处理不同格式的密钥数据"""
68
+ # 处理不同的数据格式
69
+ if isinstance(data, list):
70
+ # 旧版格式:直接是密钥列表
71
+ raw_keys = data
72
+ self.keys = []
73
+ self.usage_stats = {}
74
+
75
+ # 为每个密钥创建完整的记录
76
+ for key_info in raw_keys:
77
+ if isinstance(key_info, dict):
78
+ key_value = key_info.get("key")
79
+ if not key_value:
80
+ logger.warning(f"忽略无效密钥配置: {key_info}")
81
+ continue
82
+
83
+ # 确保有ID
84
+ key_id = key_info.get("id") or str(uuid.uuid4())
85
+
86
+ # 构建完整的密钥记录
87
+ key_record = {
88
+ "id": key_id,
89
+ "name": key_info.get("name", ""),
90
+ "key": key_value,
91
+ "weight": key_info.get("weight", 1),
92
+ "max_rpm": key_info.get("max_rpm", 60),
93
+ "requests": 0,
94
+ "last_reset": time.time(),
95
+ "available": key_info.get("is_enabled", True),
96
+ "is_enabled": key_info.get("is_enabled", True),
97
+ "created_at": key_info.get("created_at", time.time()),
98
+ "last_used": key_info.get("last_used"),
99
+ "notes": key_info.get("notes")
100
+ }
101
+
102
+ self.keys.append(key_record)
103
+
104
+ # 初始化使用统计
105
+ self.usage_stats[key_id] = {
106
+ "total_requests": 0,
107
+ "successful_requests": 0,
108
+ "failed_requests": 0,
109
+ "daily_usage": {},
110
+ "average_response_time": 0
111
+ }
112
+ elif isinstance(key_info, str):
113
+ # 如果是字符串,直接作为密钥值
114
+ key_id = str(uuid.uuid4())
115
+ self.keys.append({
116
+ "id": key_id,
117
+ "name": "",
118
+ "key": key_info,
119
+ "weight": 1,
120
+ "max_rpm": 60,
121
+ "requests": 0,
122
+ "last_reset": time.time(),
123
+ "available": True,
124
+ "is_enabled": True,
125
+ "created_at": time.time(),
126
+ "last_used": None,
127
+ "notes": None
128
+ })
129
+
130
+ # 初始化使用统计
131
+ self.usage_stats[key_id] = {
132
+ "total_requests": 0,
133
+ "successful_requests": 0,
134
+ "failed_requests": 0,
135
+ "daily_usage": {},
136
+ "average_response_time": 0
137
+ }
138
+ else:
139
+ # 新版格式:包含keys和usage_stats的字典
140
+ self.keys = data.get('keys', [])
141
+ self.usage_stats = data.get('usage_stats', {})
142
+
143
+ def _save_keys(self) -> None:
144
+ """保存密钥到文件"""
145
+ try:
146
+ with open(self.storage_file, 'w', encoding='utf-8') as f:
147
+ json.dump({
148
+ 'keys': self.keys,
149
+ 'usage_stats': self.usage_stats
150
+ }, f, ensure_ascii=False, indent=2)
151
+
152
+ # 同时更新Config中的API_KEYS
153
+ try:
154
+ from .config import Config
155
+ Config.API_KEYS = self.keys
156
+ except (ImportError, AttributeError):
157
+ logger.debug("无法更新Config中的API_KEYS")
158
+ except Exception as e:
159
+ logger.error(f"保存密钥失败: {str(e)}")
160
+
161
+ def add_key(self, key_value: str, name: str = "", weight: int = 1,
162
+ rate_limit: int = 60, is_enabled: bool = True, notes: str = None) -> Dict[str, Any]:
163
+ """
164
+ 添加密钥
165
+
166
+ Args:
167
+ key_value: 密钥值
168
+ name: 密钥名称
169
+ weight: 权重
170
+ rate_limit: 速率限制(每分钟请求数)
171
+ is_enabled: 是否启用
172
+ notes: 备注
173
+
174
+ Returns:
175
+ 添加的密钥信息
176
+ """
177
+ with self._lock: # 使用锁保护添加过程
178
+ # 检查密钥是否已存在
179
+ for key in self.keys:
180
+ if key.get("key") == key_value:
181
+ return key
182
+
183
+ key_id = str(uuid.uuid4())
184
+ new_key = {
185
+ "id": key_id,
186
+ "name": name,
187
+ "key": key_value,
188
+ "weight": weight,
189
+ "max_rpm": rate_limit,
190
+ "requests": 0,
191
+ "last_reset": time.time(),
192
+ "available": is_enabled,
193
+ "is_enabled": is_enabled,
194
+ "created_at": time.time(),
195
+ "last_used": None,
196
+ "notes": notes
197
+ }
198
+ self.keys.append(new_key)
199
+
200
+ # 初始化使用统计
201
+ self.usage_stats[key_id] = {
202
+ "total_requests": 0,
203
+ "successful_requests": 0,
204
+ "failed_requests": 0,
205
+ "daily_usage": {},
206
+ "average_response_time": 0
207
+ }
208
+
209
+ self._save_keys()
210
+ logger.info(f"已添加密钥: {name or key_id}")
211
+ return new_key
212
+
213
+ def get_all_keys(self) -> List[Dict[str, Any]]:
214
+ """获取所有密钥信息(已隐藏完整密钥值)"""
215
+ with self._lock: # 使用锁保护读取过程
216
+ result = []
217
+ for key in self.keys:
218
+ key_copy = key.copy()
219
+ if "key" in key_copy:
220
+ # 只显示密钥前6位和后4位
221
+ full_key = key_copy["key"]
222
+ if len(full_key) > 10:
223
+ key_copy["key"] = full_key[:6] + "..." + full_key[-4:]
224
+
225
+ # 增加临时禁用信息的处理
226
+ if key_copy.get("temp_disabled_until"):
227
+ temp_disabled_until = key_copy["temp_disabled_until"]
228
+ # 确保temp_disabled_until是时间戳格式
229
+ if isinstance(temp_disabled_until, (int, float)):
230
+ # 转换为可读格式,但保留原始时间戳,让前端可以自行处理
231
+ disabled_until_date = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(temp_disabled_until))
232
+ key_copy["temp_disabled_until_formatted"] = disabled_until_date
233
+ key_copy["temp_disabled_remaining"] = int(temp_disabled_until - time.time())
234
+
235
+ result.append(key_copy)
236
+ return result
237
+
238
+ def get_key_by_id(self, key_id: str) -> Optional[Dict[str, Any]]:
239
+ """根据ID获取密钥信息"""
240
+ with self._lock: # 使用锁保护读取过程
241
+ for key in self.keys:
242
+ if key.get("id") == key_id:
243
+ return key
244
+ return None
245
+
246
+ def update_key(self, key_id: str, **kwargs) -> Optional[Dict[str, Any]]:
247
+ """
248
+ 更新密钥信息
249
+
250
+ Args:
251
+ key_id: 密钥ID
252
+ **kwargs: 要更新的字段
253
+
254
+ Returns:
255
+ 更新后的密钥信息,未找到则返回None
256
+ """
257
+ with self._lock: # 使用锁保护更新过程
258
+ for key in self.keys:
259
+ if key.get("id") == key_id:
260
+ # 更新提供的字段
261
+ for field, value in kwargs.items():
262
+ if value is not None:
263
+ if field == "is_enabled":
264
+ key["available"] = value # 同步更新available字段
265
+ key[field] = value
266
+
267
+ self._save_keys()
268
+ logger.info(f"已更新密钥: {key.get('name') or key_id}")
269
+ return key
270
+
271
+ logger.warning(f"未找到密钥: {key_id}")
272
+ return None
273
+
274
+ def delete_key(self, key_id: str) -> bool:
275
+ """
276
+ 删除密钥
277
+
278
+ Args:
279
+ key_id: 密钥ID
280
+
281
+ Returns:
282
+ 是否成功删除
283
+ """
284
+ with self._lock: # 使用锁保护删除过程
285
+ original_length = len(self.keys)
286
+ self.keys = [key for key in self.keys if key.get("id") != key_id]
287
+
288
+ # 如果成功删除,保存密钥
289
+ if len(self.keys) < original_length:
290
+ self._save_keys()
291
+ return True
292
+
293
+ return False
294
+
295
+ def batch_import_keys(self, keys_data: List[Dict[str, Any]]) -> Dict[str, int]:
296
+ """
297
+ 批量导入密钥
298
+
299
+ Args:
300
+ keys_data: 密钥数据列表,每个元素为包含密钥信息的字典
301
+
302
+ Returns:
303
+ 导入结果统计
304
+ """
305
+ with self._lock: # 使用锁保护导入过程
306
+ imported_count = 0
307
+ skipped_count = 0
308
+
309
+ # 获取现有密钥值
310
+ existing_keys = {key.get("key") for key in self.keys}
311
+
312
+ for key_data in keys_data:
313
+ key_value = key_data.get("key")
314
+ if not key_value:
315
+ continue
316
+
317
+ # 检查密钥是否已存在
318
+ if key_value in existing_keys:
319
+ skipped_count += 1
320
+ continue
321
+
322
+ # 添加新密钥
323
+ key_id = str(uuid.uuid4())
324
+ new_key = {
325
+ "id": key_id,
326
+ "name": key_data.get("name", ""),
327
+ "key": key_value,
328
+ "weight": key_data.get("weight", 1),
329
+ "max_rpm": key_data.get("rate_limit", 60),
330
+ "requests": 0,
331
+ "last_reset": time.time(),
332
+ "available": key_data.get("enabled", True),
333
+ "is_enabled": key_data.get("enabled", True),
334
+ "created_at": time.time(),
335
+ "last_used": None,
336
+ "notes": key_data.get("notes")
337
+ }
338
+ self.keys.append(new_key)
339
+ existing_keys.add(key_value) # 添加到已存在集合中
340
+
341
+ # 初始化使用统计
342
+ self.usage_stats[key_id] = {
343
+ "total_requests": 0,
344
+ "successful_requests": 0,
345
+ "failed_requests": 0,
346
+ "daily_usage": {},
347
+ "average_response_time": 0
348
+ }
349
+
350
+ imported_count += 1
351
+
352
+ # 保存密钥
353
+ if imported_count > 0:
354
+ self._save_keys()
355
+
356
+ return {
357
+ "imported": imported_count,
358
+ "skipped": skipped_count
359
+ }
360
+
361
+ def get_key(self) -> Optional[str]:
362
+ """获取下一个可用的密钥"""
363
+ with self._lock: # 使用锁保护整个获取密钥过程
364
+ if not self.keys:
365
+ logger.warning("没有可用的密钥")
366
+ return None
367
+
368
+ # 重置计数器(如果需要)
369
+ current_time = time.time()
370
+ temporary_disabled_updated = False
371
+
372
+ for key in self.keys:
373
+ # 检查是否有被临时禁用的密钥需要重新启用
374
+ if key.get("temp_disabled_until") and current_time > key.get("temp_disabled_until"):
375
+ key["is_enabled"] = True
376
+ key["available"] = True
377
+ key["temp_disabled_until"] = None
378
+ temporary_disabled_updated = True
379
+ logger.info(f"密钥 {key.get('name') or key.get('id')} 的临时禁用已��除")
380
+
381
+ if current_time - key["last_reset"] >= 60:
382
+ key["requests"] = 0
383
+ key["last_reset"] = current_time
384
+ if not key.get("temp_disabled_until"): # 只有未被临时禁用的密钥才会被重新激活
385
+ key["available"] = key.get("is_enabled", True)
386
+
387
+ # 如果有任何临时禁用的密钥被更新,保存变更
388
+ if temporary_disabled_updated:
389
+ self._save_keys()
390
+
391
+ # 筛选可用的密钥,排除工作中的密钥
392
+ available_keys = []
393
+ for k in self.keys:
394
+ key_value = k.get("key", "")
395
+ clean_key = key_value.replace("Bearer ", "") if key_value.startswith("Bearer ") else key_value
396
+
397
+ # 检查此密钥是否在工作中
398
+ is_working = clean_key in self._working_keys
399
+
400
+ if k.get("available", False) and not is_working:
401
+ available_keys.append(k)
402
+
403
+ if not available_keys:
404
+ logger.warning("没有可用的密钥(所有密钥都达到速率限制、被禁用或正在工作中)")
405
+ return None
406
+
407
+ # 根据权重选择密钥
408
+ weights = [k.get("weight", 1) for k in available_keys]
409
+ selected_idx = random.choices(range(len(available_keys)), weights=weights, k=1)[0]
410
+ selected_key = available_keys[selected_idx]
411
+
412
+ # 更新使用统计
413
+ selected_key["requests"] += 1
414
+ selected_key["last_used"] = current_time
415
+
416
+ # 检查是否达到速率限制
417
+ if selected_key["requests"] >= selected_key.get("max_rpm", 60):
418
+ selected_key["available"] = False
419
+
420
+ # 保存数据 - 并发环境下调整为每次都保存,避免状态不一致
421
+ # 原来是随机保存(10%的概率)
422
+ self._save_keys()
423
+
424
+ # 确保返回的密钥包含"Bearer "前缀
425
+ key_value = selected_key["key"]
426
+ if not key_value.startswith("Bearer "):
427
+ key_value = f"Bearer {key_value}"
428
+
429
+ return key_value
430
+
431
+ def record_request_result(self, key: str, success: bool, response_time: float = 0) -> None:
432
+ """
433
+ 记录请求结果
434
+
435
+ Args:
436
+ key: 密钥值
437
+ success: 请求是否成功
438
+ response_time: 响应时间(秒)
439
+ """
440
+ if not key:
441
+ logger.warning("记录请求结果失败:密钥为空")
442
+ return
443
+
444
+ with self._lock: # 使用锁保护记录过程
445
+ # 去掉可能的Bearer前缀
446
+ key_for_search = key.replace("Bearer ", "") if key.startswith("Bearer ") else key
447
+
448
+ # 查找对应的密钥ID
449
+ key_id = None
450
+ key_info = None
451
+ for k in self.keys:
452
+ stored_key = k.get("key", "").replace("Bearer ", "") if k.get("key", "").startswith("Bearer ") else k.get("key", "")
453
+ if stored_key == key_for_search:
454
+ key_id = k.get("id")
455
+ key_info = k
456
+ break
457
+
458
+ if not key_id:
459
+ logger.warning(f"记录请求结果失败:未找到密钥 {key_for_search[:6]}...")
460
+ return
461
+
462
+ # 初始化usage_stats如果该密钥还没有统计数据
463
+ if key_id not in self.usage_stats:
464
+ self.usage_stats[key_id] = {
465
+ "total_requests": 0,
466
+ "successful_requests": 0,
467
+ "failed_requests": 0,
468
+ "daily_usage": {},
469
+ "average_response_time": 0
470
+ }
471
+
472
+ # 记录请求结果
473
+ stats = self.usage_stats[key_id]
474
+ stats["total_requests"] += 1
475
+
476
+ if success:
477
+ stats["successful_requests"] += 1
478
+ else:
479
+ stats["failed_requests"] += 1
480
+
481
+ # 记录响应时间
482
+ if response_time > 0:
483
+ if stats["average_response_time"] == 0:
484
+ stats["average_response_time"] = response_time
485
+ else:
486
+ # 使用加权平均
487
+ old_avg = stats["average_response_time"]
488
+ total = stats["total_requests"]
489
+ # 避免 total 为0或1时产生问题,尽管前面 total_requests 已经增加了
490
+ if total > 0:
491
+ stats["average_response_time"] = ((old_avg * (total - 1)) + response_time) / total
492
+ else: # 理论上不应该发生,因为 total_requests 已经增加了
493
+ stats["average_response_time"] = response_time
494
+
495
+ # 记录每日使用情况
496
+ today = time.strftime("%Y-%m-%d")
497
+ if today not in stats["daily_usage"]:
498
+ stats["daily_usage"][today] = {"successful": 0, "failed": 0} # 初始化每日统计
499
+
500
+ # 根据成功与否更新每日统计
501
+ if success:
502
+ stats["daily_usage"][today]["successful"] += 1
503
+ else:
504
+ stats["daily_usage"][today]["failed"] += 1
505
+
506
+ # 保留最近30天的数据
507
+ if len(stats["daily_usage"]) > 30:
508
+ # 获取所有日期并排序,然后删除最早的
509
+ sorted_dates = sorted(stats["daily_usage"].keys())
510
+ if sorted_dates: # 确保列表不为空
511
+ oldest_date = sorted_dates[0]
512
+ del stats["daily_usage"][oldest_date]
513
+
514
+ # 更新密钥的最后使用时间
515
+ if key_info and "last_used" in key_info:
516
+ key_info["last_used"] = time.time()
517
+
518
+ # 并发环境下每次都保存,确保统计准确性
519
+ self._save_keys()
520
+
521
+ def get_usage_stats(self) -> Dict[str, Any]:
522
+ """获取使用统计信息"""
523
+ with self._lock: # 使用锁保护读取过程
524
+ total_keys = len(self.keys)
525
+ active_keys = sum(1 for k in self.keys if k.get("is_enabled", False))
526
+ available_keys = sum(1 for k in self.keys if k.get("available", False))
527
+
528
+ total_requests = sum(stats.get("total_requests", 0) for stats in self.usage_stats.values())
529
+ successful_requests = sum(stats.get("successful_requests", 0) for stats in self.usage_stats.values())
530
+
531
+ # 计算成功率
532
+ success_rate = (successful_requests / total_requests * 100) if total_requests > 0 else 0
533
+
534
+ # 计算每个密钥的平均响应时间
535
+ avg_response_times = [stats.get("average_response_time", 0) for stats in self.usage_stats.values() if stats.get("average_response_time", 0) > 0]
536
+ overall_avg_response_time = sum(avg_response_times) / len(avg_response_times) if avg_response_times else 0
537
+
538
+ # 获取过去7天的使用情况
539
+ past_7_days = {}
540
+ for key_id, stats in self.usage_stats.items():
541
+ daily_usage = stats.get("daily_usage", {})
542
+ for date, count_data in daily_usage.items():
543
+ if date not in past_7_days:
544
+ past_7_days[date] = {"successful": 0, "failed": 0}
545
+ # 正确处理字典类型的count_data
546
+ past_7_days[date]["successful"] += count_data.get("successful", 0)
547
+ past_7_days[date]["failed"] += count_data.get("failed", 0)
548
+
549
+ # 只保留最近7天
550
+ dates = sorted(past_7_days.keys(), reverse=True)[:7]
551
+ past_7_days = {date: past_7_days[date] for date in dates}
552
+
553
+ return {
554
+ "total_keys": total_keys,
555
+ "active_keys": active_keys,
556
+ "available_keys": available_keys,
557
+ "total_requests": total_requests,
558
+ "successful_requests": successful_requests,
559
+ "failed_requests": total_requests - successful_requests,
560
+ "success_rate": success_rate,
561
+ "average_response_time": overall_avg_response_time,
562
+ "past_7_days": past_7_days
563
+ }
564
+
565
+ def mark_key_as_working(self, key: str, task_id: str) -> None:
566
+ """
567
+ 将密钥标记为工作中状态
568
+
569
+ Args:
570
+ key: API密钥值(可能包含Bearer前缀)
571
+ task_id: 关联的任务ID
572
+ """
573
+ with self._lock:
574
+ clean_key = key.replace("Bearer ", "") if key.startswith("Bearer ") else key
575
+ self._working_keys[clean_key] = task_id
576
+ logger.debug(f"密钥已标记为工作中,关联任务ID: {task_id}")
577
+
578
+ def release_key(self, key: str) -> None:
579
+ """
580
+ 释放工作中的密钥
581
+
582
+ Args:
583
+ key: API密钥值(可能包含Bearer前缀)
584
+ """
585
+ with self._lock:
586
+ clean_key = key.replace("Bearer ", "") if key.startswith("Bearer ") else key
587
+ if clean_key in self._working_keys:
588
+ del self._working_keys[clean_key]
589
+ logger.debug(f"密钥已释放")
590
+
591
+ def is_key_working(self, key: str) -> bool:
592
+ """
593
+ 检查密钥是否正在工作中
594
+
595
+ Args:
596
+ key: API密钥值(可能包含Bearer前缀)
597
+
598
+ Returns:
599
+ bool: 是否在工作中
600
+ """
601
+ with self._lock:
602
+ clean_key = key.replace("Bearer ", "") if key.startswith("Bearer ") else key
603
+ return clean_key in self._working_keys
604
+
605
+ def mark_key_invalid(self, key: str) -> Optional[str]:
606
+ """
607
+ 将指定的密钥标记为无效(临时禁用而不是永久禁用),并返回一个新的可用密钥
608
+
609
+ Args:
610
+ key: API密钥值(可能包含Bearer前缀)
611
+
612
+ Returns:
613
+ Optional[str]: 新的可用密钥,如果没有可用密钥则返回None
614
+ """
615
+ # 调用临时禁用方法,设置24小时禁用时间
616
+ return self.mark_key_temp_disabled(key, hours=24.0)
617
+
618
+ def mark_key_temp_disabled(self, key: str, hours: float = 12.0) -> Optional[str]:
619
+ """
620
+ 将指定的密钥临时禁用指定小时数,并返回一个新的可用密钥
621
+
622
+ Args:
623
+ key: API密钥值(可能包含Bearer前缀)
624
+ hours: 禁用小时数
625
+
626
+ Returns:
627
+ Optional[str]: 新的可用密钥,如果没有可用密钥则返回None
628
+ """
629
+ with self._lock: # 使用锁保护临时禁用过程
630
+ # 去掉可能的Bearer前缀
631
+ key_for_search = key.replace("Bearer ", "") if key.startswith("Bearer ") else key
632
+
633
+ # 检查是否是因为密钥在工作中导致的错误
634
+ if key_for_search in self._working_keys:
635
+ logger.warning(f"尝试禁用正在工作中的密钥(任务ID: {self._working_keys[key_for_search]}),跳过禁用操作")
636
+ # 获取一个新密钥返回,但不禁用当前密钥
637
+ new_key = self.get_key()
638
+ if new_key:
639
+ logger.info(f"已返回新密钥,但未禁用工作中的密钥")
640
+ return new_key
641
+ else:
642
+ logger.warning("没有可用的备用密钥")
643
+ return None
644
+
645
+ # 查找对应的密钥
646
+ key_found = False
647
+ disabled_key_id = None
648
+ for key_info in self.keys:
649
+ stored_key = key_info.get("key", "").replace("Bearer ", "") if key_info.get("key", "").startswith("Bearer ") else key_info.get("key", "")
650
+ if stored_key == key_for_search:
651
+ # 标记密钥为临时禁用
652
+ disabled_until = time.time() + (hours * 3600) # 当前时间加上禁用小时数
653
+ key_info["available"] = False
654
+ key_info["temp_disabled_until"] = disabled_until
655
+ key_info["notes"] = (key_info.get("notes") or "") + f"\n[自动] 在 {time.strftime('%Y-%m-%d %H:%M:%S')} 被临时禁用{hours}小时"
656
+ key_found = True
657
+ disabled_key_id = key_info.get("id")
658
+ logger.warning(f"密钥 {key_info.get('name') or key_info.get('id')} 被临时禁用{hours}小时")
659
+ break
660
+
661
+ if key_found:
662
+ # 保存更改
663
+ self._save_keys()
664
+
665
+ # 获取新的密钥,排除已禁用的
666
+ new_key = self.get_key()
667
+ if new_key:
668
+ logger.info(f"已自动切换到新的密钥")
669
+ return new_key
670
+ else:
671
+ logger.warning("没有可用的备用密钥")
672
+ return None
673
+ else:
674
+ logger.warning(f"未找到要临时禁用的密钥")
675
+ return None
676
+
677
+ def retry_request(self, original_key: str, request_func: Callable, max_retries: int = 1,
678
+ max_key_switches: int = 3) -> Tuple[bool, Any, str]:
679
+ """
680
+ 出错时自动重试请求,并在需要时切换密钥
681
+
682
+ Args:
683
+ original_key: 原始API密钥(可能包含Bearer前缀)
684
+ request_func: 执行请求的函数,接受一个参数(密钥)并返回(成功标志, 结果)
685
+ max_retries: 使用同一密钥的最大重试次数
686
+ max_key_switches: 最大密钥切换次数
687
+
688
+ Returns:
689
+ Tuple[bool, Any, str]: (是否成功, 请求结果, 使用的密钥)
690
+ """
691
+ current_key = original_key
692
+ current_key_switches = 0
693
+
694
+ # 首先用原始密钥尝试
695
+ for attempt in range(max_retries + 1): # +1是因为第一次不算重试
696
+ try:
697
+ success, result = request_func(current_key)
698
+ # 成功的请求不应该导致密钥被禁用
699
+ if success:
700
+ # 记录请求成功,避免不必要的密钥禁用
701
+ with self._lock:
702
+ self.record_request_result(current_key, True)
703
+ return True, result, current_key
704
+ logger.warning(f"请求失败(尝试 {attempt+1}/{max_retries+1}): {result}")
705
+ except Exception as e:
706
+ logger.error(f"请求异常(尝试 {attempt+1}/{max_retries+1}): {str(e)}")
707
+
708
+ # 如果这不是最后一次尝试,等���一秒后重试
709
+ if attempt < max_retries:
710
+ time.sleep(1)
711
+
712
+ # 如果原始密钥的所有重试都失败,尝试切换密钥
713
+ tried_keys = set([current_key.replace("Bearer ", "") if current_key.startswith("Bearer ") else current_key])
714
+
715
+ while current_key_switches < max_key_switches:
716
+ # 获取新的密钥
717
+ with self._lock:
718
+ new_key = self.get_key()
719
+ if not new_key:
720
+ logger.warning("没有更多可用的密钥")
721
+ break
722
+
723
+ # 确保不使用已经尝试过的密钥
724
+ clean_new_key = new_key.replace("Bearer ", "") if new_key.startswith("Bearer ") else new_key
725
+ if clean_new_key in tried_keys:
726
+ continue
727
+
728
+ tried_keys.add(clean_new_key)
729
+ current_key = new_key
730
+ current_key_switches += 1
731
+
732
+ logger.info(f"切换到新密钥 (切换 {current_key_switches}/{max_key_switches})")
733
+
734
+ # 用新密钥尝试
735
+ for attempt in range(max_retries + 1):
736
+ try:
737
+ success, result = request_func(current_key)
738
+ if success:
739
+ # 记录请求成功
740
+ with self._lock:
741
+ self.record_request_result(current_key, True)
742
+ return True, result, current_key
743
+ logger.warning(f"使用新密钥请求失败(尝试 {attempt+1}/{max_retries+1}): {result}")
744
+ except Exception as e:
745
+ logger.error(f"使用新密钥请求异常(尝试 {attempt+1}/{max_retries+1}): {str(e)}")
746
+
747
+ # 如果这不是最后一次尝试,等待一秒后重试
748
+ if attempt < max_retries:
749
+ time.sleep(1)
750
+
751
+ # 所有尝试都失败,临时禁用原始密钥
752
+ # 但是在并发环境下,这可能是因为网络或服务问题,而非密钥问题
753
+ # 增加额外检查以减少不必要的密钥禁用
754
+ should_disable = True
755
+
756
+ # 在临时禁用前,确认是否是密钥问题而非服务或网络问题
757
+ # 此处可以添加额外逻辑来判断是否应该禁用密钥
758
+
759
+ if should_disable:
760
+ logger.error(f"所有重试和密钥切换尝试都失败,临时禁用原始密钥")
761
+ with self._lock:
762
+ self.mark_key_temp_disabled(original_key, hours=6.0) # 减少禁用时间,避免资源浪费
763
+ else:
764
+ logger.warning(f"所有重试和密钥切换尝试都失败,但可能是服务问题而非密钥问题,不禁用密钥")
765
+
766
+ # 返回最后一次尝试的结果
767
+ return False, result, current_key
768
+
769
+ # 创建全局密钥管理器实例
770
+ storage_file = os.getenv("KEYS_STORAGE_FILE", "api_keys.json")
771
+ # 如果提供了绝对路径则直接使用,否则使用相对路径
772
+ if not os.path.isabs(storage_file):
773
+ base_dir = os.getenv("BASE_DIR", os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
774
+ storage_file = os.path.join(base_dir, storage_file)
775
+
776
+ key_manager = KeyManager(storage_file=storage_file)
777
+ logger.info(f"初始化全局密钥管理器,存储文件: {storage_file}")
src/main.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uvicorn
2
+ import logging
3
+ from .app import app, key_manager
4
+ from .config import Config
5
+
6
+ # 获取日志记录器
7
+ logger = logging.getLogger("sora-api.main")
8
+
9
+ def init_app():
10
+ """初始化应用程序"""
11
+ try:
12
+ # 密钥管理器已在app.py中初始化并加载完成
13
+ # 检查是否有可用的密钥
14
+ if not key_manager.keys:
15
+ logger.warning("未配置API key,将使用测试密钥")
16
+ key_manager.add_key(
17
+ key_value="Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IjE5MzQ0ZTY1LWJiYzktNDRkMS1hOWQwLWY5NTdiMDc5YmQwZSIsInR5cCI6IkpXVCJ9...",
18
+ name="默认测试密钥"
19
+ )
20
+
21
+ logger.info(f"API服务初始化完成,已加载 {len(key_manager.keys)} 个API key")
22
+ except Exception as e:
23
+ logger.error(f"API服务初始化失败: {str(e)}")
24
+ raise
25
+
26
+ def start():
27
+ """启动API服务"""
28
+ # 初始化应用
29
+ init_app()
30
+
31
+ # 打印配置信息
32
+ Config.print_config()
33
+
34
+ # 启动服务
35
+ logger.info(f"启动服务: {Config.HOST}:{Config.PORT}")
36
+ uvicorn.run(
37
+ "src.app:app",
38
+ host=Config.HOST,
39
+ port=Config.PORT,
40
+ reload=Config.VERBOSE_LOGGING # 仅在调试模式下开启自动重载
41
+ )
42
+
43
+ if __name__ == "__main__":
44
+ start()
src/models/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+
src/models/schemas.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List, Dict, Any, Optional, Union
2
+ from pydantic import BaseModel, Field
3
+
4
+ # 聊天完成请求模型
5
+ class ContentItem(BaseModel):
6
+ type: str
7
+ text: Optional[str] = None
8
+ image_url: Optional[Dict[str, str]] = None
9
+
10
+ class ChatMessage(BaseModel):
11
+ role: str
12
+ content: Union[str, List[ContentItem]]
13
+
14
+ class ChatCompletionRequest(BaseModel):
15
+ model: str
16
+ messages: List[ChatMessage]
17
+ temperature: Optional[float] = 1.0
18
+ top_p: Optional[float] = 1.0
19
+ n: Optional[int] = 1
20
+ stream: Optional[bool] = False
21
+ max_tokens: Optional[int] = None
22
+ presence_penalty: Optional[float] = 0
23
+ frequency_penalty: Optional[float] = 0
24
+
25
+ # API密钥创建模型
26
+ class ApiKeyCreate(BaseModel):
27
+ name: str = Field(..., description="密钥名称")
28
+ key_value: str = Field(..., description="密钥值")
29
+ weight: int = Field(1, description="权重")
30
+ rate_limit: int = Field(60, description="速率限制(每分钟请求数)")
31
+ is_enabled: bool = Field(True, description="是否启用")
32
+ notes: Optional[str] = Field(None, description="备注")
33
+
34
+ # API密钥更新模型
35
+ class ApiKeyUpdate(BaseModel):
36
+ name: Optional[str] = Field(None, description="密钥名称")
37
+ key_value: Optional[str] = Field(None, description="密钥值")
38
+ weight: Optional[int] = Field(None, description="权重")
39
+ rate_limit: Optional[int] = Field(None, description="速率限制(每分钟请求数)")
40
+ is_enabled: Optional[bool] = Field(None, description="是否启用")
41
+ notes: Optional[str] = Field(None, description="备注")
42
+
43
+ # 批量操作基础模型
44
+ class BatchOperation(BaseModel):
45
+ action: str = Field(..., description="操作类型:import, enable, disable, delete")
46
+ key_ids: Optional[List[str]] = Field(None, description="要操作的密钥ID列表")
47
+
48
+ # 批量导入的密钥项模型
49
+ class ImportKeyItem(BaseModel):
50
+ name: str = Field(..., description="密钥名称")
51
+ key: str = Field(..., description="密钥值")
52
+ weight: int = Field(1, description="权重")
53
+ rate_limit: int = Field(60, description="速率限制(每分钟请求数)")
54
+ enabled: bool = Field(True, description="是否启用")
55
+ notes: Optional[str] = Field(None, description="备注")
56
+
57
+ # 批量导入模型
58
+ class BatchImportOperation(BatchOperation):
59
+ keys: List[ImportKeyItem] = Field(..., description="要导入的密钥列表")
60
+ key_ids: Optional[List[str]] = None
61
+
62
+ # 系统配置更新模型
63
+ class ConfigUpdate(BaseModel):
64
+ PROXY_HOST: Optional[str] = Field(None, description="代理服务器主机")
65
+ PROXY_PORT: Optional[str] = Field(None, description="代理服务器端口")
66
+ PROXY_USER: Optional[str] = Field(None, description="代理服务器用户名")
67
+ PROXY_PASS: Optional[str] = Field(None, description="代理服务器密码")
68
+ BASE_URL: Optional[str] = Field(None, description="基础URL,用于图片访问地址")
69
+ IMAGE_LOCALIZATION: Optional[bool] = Field(None, description="是否启用图片本地化存储")
70
+ IMAGE_SAVE_DIR: Optional[str] = Field(None, description="图片保存目录")
71
+ save_to_env: bool = Field(True, description="是否保存到环境变量文件")
72
+
73
+ # 日志级别更新模型
74
+ class LogLevelUpdate(BaseModel):
75
+ level: str = Field(..., description="日志级别: DEBUG, INFO, WARNING, ERROR, CRITICAL")
76
+ save_to_env: bool = Field(True, description="是否保存到环境变量文件")
77
+
78
+ # JWT认证请求模型
79
+ class LoginRequest(BaseModel):
80
+ admin_key: str = Field(..., description="管理员密钥")
81
+
82
+ # JWT令牌响应模型
83
+ class TokenResponse(BaseModel):
84
+ token: str = Field(..., description="JWT令牌")
85
+ expires_in: int = Field(..., description="有效期(秒)")
86
+ token_type: str = Field("bearer", description="令牌类型")
src/services/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+
src/services/image_service.py ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import base64
3
+ import os
4
+ import tempfile
5
+ import time
6
+ import uuid
7
+ import logging
8
+ import threading
9
+ from typing import List, Dict, Any, Optional, Union, Tuple
10
+
11
+ from ..sora_integration import SoraClient
12
+ from ..config import Config
13
+ from ..utils import localize_image_urls
14
+
15
+ logger = logging.getLogger("sora-api.image_service")
16
+
17
+ # 存储生成结果的全局字典
18
+ generation_results = {}
19
+
20
+ # 存储任务与API密钥的映射关系
21
+ task_to_api_key = {}
22
+
23
+ # 将处理中状态消息格式化为think代码块
24
+ def format_think_block(message: str) -> str:
25
+ """将消息放入```think代码块中"""
26
+ return f"```think\n{message}\n```"
27
+
28
+ async def process_image_task(
29
+ request_id: str,
30
+ sora_client: SoraClient,
31
+ task_type: str,
32
+ prompt: str,
33
+ **kwargs
34
+ ) -> None:
35
+ """
36
+ 统一的图像处理任务函数
37
+
38
+ Args:
39
+ request_id: 请求ID
40
+ sora_client: Sora客户端实例
41
+ task_type: 任务类型 ("generation" 或 "remix")
42
+ prompt: 提示词
43
+ **kwargs: 其他参数,取决于任务类型
44
+ """
45
+ try:
46
+ # 保存当前任务使用的API密钥,以便后续使用同一密钥进行操作
47
+ current_api_key = sora_client.auth_token
48
+ task_to_api_key[request_id] = current_api_key
49
+
50
+ # 更新状态为处理中
51
+ generation_results[request_id] = {
52
+ "status": "processing",
53
+ "message": format_think_block("正在准备生成任务,请稍候..."),
54
+ "timestamp": int(time.time()),
55
+ "api_key": current_api_key # 记录使用的API密钥
56
+ }
57
+
58
+ # 根据任务类型执行不同操作
59
+ if task_type == "generation":
60
+ # 文本到图像生成
61
+ num_images = kwargs.get("num_images", 1)
62
+ width = kwargs.get("width", 720)
63
+ height = kwargs.get("height", 480)
64
+
65
+ # 更新状态
66
+ generation_results[request_id] = {
67
+ "status": "processing",
68
+ "message": format_think_block("正在生成图像,请耐心等待..."),
69
+ "timestamp": int(time.time()),
70
+ "api_key": current_api_key
71
+ }
72
+
73
+ # 生成图像
74
+ logger.info(f"[{request_id}] 开始生成图像, 提示词: {prompt}")
75
+ image_urls = await sora_client.generate_image(
76
+ prompt=prompt,
77
+ num_images=num_images,
78
+ width=width,
79
+ height=height
80
+ )
81
+
82
+ elif task_type == "remix":
83
+ # 图像到图像生成(Remix)
84
+ image_data = kwargs.get("image_data")
85
+ num_images = kwargs.get("num_images", 1)
86
+
87
+ if not image_data:
88
+ raise ValueError("缺少图像数据")
89
+
90
+ # 更新状态
91
+ generation_results[request_id] = {
92
+ "status": "processing",
93
+ "message": format_think_block("正在处理上传的图片..."),
94
+ "timestamp": int(time.time()),
95
+ "api_key": current_api_key
96
+ }
97
+
98
+ # 保存base64图片到临时文件
99
+ temp_dir = tempfile.mkdtemp()
100
+ temp_image_path = os.path.join(temp_dir, f"upload_{uuid.uuid4()}.png")
101
+
102
+ try:
103
+ # 解码并保存图片
104
+ with open(temp_image_path, "wb") as f:
105
+ f.write(base64.b64decode(image_data))
106
+
107
+ # 更新状态
108
+ generation_results[request_id] = {
109
+ "status": "processing",
110
+ "message": format_think_block("正在上传图片到Sora服务..."),
111
+ "timestamp": int(time.time()),
112
+ "api_key": current_api_key
113
+ }
114
+
115
+ # 上传图片 - 确保使用与初始请求相同的API密钥
116
+ upload_result = await sora_client.upload_image(temp_image_path)
117
+ media_id = upload_result['id']
118
+
119
+ # 更新状态
120
+ generation_results[request_id] = {
121
+ "status": "processing",
122
+ "message": format_think_block("正在基于图片生成新图像..."),
123
+ "timestamp": int(time.time()),
124
+ "api_key": current_api_key
125
+ }
126
+
127
+ # 执行remix生成
128
+ logger.info(f"[{request_id}] 开始生成Remix图像, 提示词: {prompt}")
129
+ image_urls = await sora_client.generate_image_remix(
130
+ prompt=prompt,
131
+ media_id=media_id,
132
+ num_images=num_images
133
+ )
134
+
135
+ finally:
136
+ # 清理临时文件
137
+ if os.path.exists(temp_image_path):
138
+ os.remove(temp_image_path)
139
+ if os.path.exists(temp_dir):
140
+ os.rmdir(temp_dir)
141
+ else:
142
+ raise ValueError(f"未知的任务类型: {task_type}")
143
+
144
+ # 验证生成结果
145
+ if isinstance(image_urls, str):
146
+ logger.warning(f"[{request_id}] 图像生成失败或返回了错误信息: {image_urls}")
147
+ generation_results[request_id] = {
148
+ "status": "failed",
149
+ "error": image_urls,
150
+ "message": format_think_block(f"图像生成失败: {image_urls}"),
151
+ "timestamp": int(time.time()),
152
+ "api_key": current_api_key
153
+ }
154
+ return
155
+
156
+ if not image_urls:
157
+ logger.warning(f"[{request_id}] 图像生成返回了空列表")
158
+ generation_results[request_id] = {
159
+ "status": "failed",
160
+ "error": "图像生成返回了空结果",
161
+ "message": format_think_block("图像生成失败: 服务器返回了空结果"),
162
+ "timestamp": int(time.time()),
163
+ "api_key": current_api_key
164
+ }
165
+ return
166
+
167
+ logger.info(f"[{request_id}] 成功生成 {len(image_urls)} 张图片")
168
+
169
+ # 本地化图片URL
170
+ if Config.IMAGE_LOCALIZATION:
171
+ logger.info(f"[{request_id}] 准备进行图片本地化处理")
172
+ try:
173
+ localized_urls = await localize_image_urls(image_urls)
174
+ logger.info(f"[{request_id}] 图片本地化处理完成")
175
+
176
+ # 检查本地化结果
177
+ if not localized_urls:
178
+ logger.warning(f"[{request_id}] 本地化处理返回了空列表,将使用原始URL")
179
+ localized_urls = image_urls
180
+
181
+ # 检查是否所有URL都被正确本地化
182
+ local_count = sum(1 for url in localized_urls if url.startswith("/static/") or "/static/" in url)
183
+ logger.info(f"[{request_id}] 本地化结果: 总计 {len(localized_urls)} 张图片,成功本地化 {local_count} 张")
184
+
185
+ if local_count == 0:
186
+ logger.warning(f"[{request_id}] 警告:没有一个URL被成功本地化,将使用原始URL")
187
+ localized_urls = image_urls
188
+
189
+ image_urls = localized_urls
190
+ except Exception as e:
191
+ logger.error(f"[{request_id}] 图片本地化过程中发生错误: {str(e)}", exc_info=True)
192
+ logger.info(f"[{request_id}] 由于错误,将使用原始URL")
193
+ else:
194
+ logger.info(f"[{request_id}] 图片本地化功能未启用,使用原始URL")
195
+
196
+ # 存储结果
197
+ generation_results[request_id] = {
198
+ "status": "completed",
199
+ "image_urls": image_urls,
200
+ "timestamp": int(time.time()),
201
+ "api_key": current_api_key
202
+ }
203
+
204
+ # 30分钟后自动清理结果
205
+ def cleanup_task():
206
+ generation_results.pop(request_id, None)
207
+ task_to_api_key.pop(request_id, None)
208
+
209
+ threading.Timer(1800, cleanup_task).start()
210
+
211
+ except Exception as e:
212
+ error_message = f"图像生成失败: {str(e)}"
213
+ generation_results[request_id] = {
214
+ "status": "failed",
215
+ "error": error_message,
216
+ "message": format_think_block(error_message),
217
+ "timestamp": int(time.time()),
218
+ "api_key": sora_client.auth_token # 记录当前API密钥
219
+ }
220
+ logger.error(f"图像生成失败 (ID: {request_id}): {str(e)}", exc_info=True)
221
+
222
+ def get_generation_result(request_id: str) -> Dict[str, Any]:
223
+ """获取生成结果"""
224
+ if request_id not in generation_results:
225
+ return {
226
+ "status": "not_found",
227
+ "error": f"找不到生成任务: {request_id}",
228
+ "timestamp": int(time.time())
229
+ }
230
+
231
+ return generation_results[request_id]
232
+
233
+ def get_task_api_key(request_id: str) -> Optional[str]:
234
+ """获取任务对应的API密钥"""
235
+ return task_to_api_key.get(request_id)
src/services/streaming.py ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import time
3
+ import asyncio
4
+ import logging
5
+ from typing import AsyncGenerator, List, Dict, Any
6
+
7
+ from ..sora_integration import SoraClient
8
+ from ..config import Config
9
+ from ..utils import localize_image_urls
10
+ from .image_service import format_think_block
11
+
12
+ logger = logging.getLogger("sora-api.streaming")
13
+
14
+ async def generate_streaming_response(
15
+ sora_client: SoraClient,
16
+ prompt: str,
17
+ n_images: int = 1
18
+ ) -> AsyncGenerator[str, None]:
19
+ """
20
+ 文本到图像的流式响应生成器
21
+
22
+ Args:
23
+ sora_client: Sora客户端
24
+ prompt: 提示词
25
+ n_images: 生成图像数量
26
+
27
+ Yields:
28
+ SSE格式的响应数据
29
+ """
30
+ request_id = f"chatcmpl-stream-{time.time()}-{hash(prompt) % 10000}"
31
+
32
+ # 发送开始事件
33
+ yield f"data: {json.dumps({'id': request_id, 'object': 'chat.completion.chunk', 'created': int(time.time()), 'model': 'sora-1.0', 'choices': [{'index': 0, 'delta': {'role': 'assistant'}, 'finish_reason': None}]})}\n\n"
34
+
35
+ # 发送处理中的消息(放在代码块中)
36
+ start_msg = "```think\n正在生成图像,请稍候...\n"
37
+ yield f"data: {json.dumps({'id': request_id, 'object': 'chat.completion.chunk', 'created': int(time.time()), 'model': 'sora-1.0', 'choices': [{'index': 0, 'delta': {'content': start_msg}, 'finish_reason': None}]})}\n\n"
38
+
39
+ # 创建一个后台任务来生成图像
40
+ logger.info(f"[流式响应 {request_id}] 开始生成图像, 提示词: {prompt}")
41
+ generation_task = asyncio.create_task(sora_client.generate_image(
42
+ prompt=prompt,
43
+ num_images=n_images,
44
+ width=720,
45
+ height=480
46
+ ))
47
+
48
+ # 每5秒发送一条"仍在生成中"的消息,防止连接超时
49
+ progress_messages = [
50
+ "正在处理您的请求...",
51
+ "仍在生成图像中,请继续等待...",
52
+ "Sora正在创作您的图像作品...",
53
+ "图像生成需要一点时间,感谢您的耐心等待...",
54
+ "我们正在努力为您创作高质量图像..."
55
+ ]
56
+
57
+ i = 0
58
+ while not generation_task.done():
59
+ # 每5秒发送一次进度消息
60
+ await asyncio.sleep(5)
61
+ progress_msg = progress_messages[i % len(progress_messages)]
62
+ i += 1
63
+ content = "\n" + progress_msg + "\n"
64
+ yield f"data: {json.dumps({'id': request_id, 'object': 'chat.completion.chunk', 'created': int(time.time()), 'model': 'sora-1.0', 'choices': [{'index': 0, 'delta': {'content': content}, 'finish_reason': None}]})}\n\n"
65
+
66
+ try:
67
+ # 获取生成结果
68
+ image_urls = await generation_task
69
+ logger.info(f"[流式响应 {request_id}] 图像生成完成,获取到 {len(image_urls) if isinstance(image_urls, list) else '非列表'} 个URL")
70
+
71
+ # 本地化图片URL
72
+ if Config.IMAGE_LOCALIZATION and isinstance(image_urls, list) and image_urls:
73
+ logger.info(f"[流式响应 {request_id}] 准备进行图片本地化处理")
74
+ try:
75
+ localized_urls = await localize_image_urls(image_urls)
76
+ image_urls = localized_urls
77
+ logger.info(f"[流式响应 {request_id}] 图片本地化处理完成")
78
+ except Exception as e:
79
+ logger.error(f"[流式响应 {request_id}] 图片本地化过程中发生错误: {str(e)}", exc_info=True)
80
+ logger.info(f"[流式响应 {request_id}] 由于错误,将使用原始URL")
81
+ elif not Config.IMAGE_LOCALIZATION:
82
+ logger.info(f"[流式响应 {request_id}] 图片本地化功能未启用")
83
+
84
+ # 结束代码块
85
+ content_str = "\n```\n\n"
86
+ yield f"data: {json.dumps({'id': request_id, 'object': 'chat.completion.chunk', 'created': int(time.time()), 'model': 'sora-1.0', 'choices': [{'index': 0, 'delta': {'content': content_str}, 'finish_reason': None}]})}\n\n"
87
+
88
+ # 添加生成的图片URLs
89
+ for i, url in enumerate(image_urls):
90
+ if i > 0:
91
+ content_str = "\n\n"
92
+ yield f"data: {json.dumps({'id': request_id, 'object': 'chat.completion.chunk', 'created': int(time.time()), 'model': 'sora-1.0', 'choices': [{'index': 0, 'delta': {'content': content_str}, 'finish_reason': None}]})}\n\n"
93
+
94
+ image_markdown = f"![Generated Image]({url})"
95
+ yield f"data: {json.dumps({'id': request_id, 'object': 'chat.completion.chunk', 'created': int(time.time()), 'model': 'sora-1.0', 'choices': [{'index': 0, 'delta': {'content': image_markdown}, 'finish_reason': None}]})}\n\n"
96
+
97
+ # 发送完成事件
98
+ yield f"data: {json.dumps({'id': request_id, 'object': 'chat.completion.chunk', 'created': int(time.time()), 'model': 'sora-1.0', 'choices': [{'index': 0, 'delta': {}, 'finish_reason': 'stop'}]})}\n\n"
99
+
100
+ # 发送结束标志
101
+ yield "data: [DONE]\n\n"
102
+
103
+ except Exception as e:
104
+ error_msg = f"图像生成失败: {str(e)}"
105
+ logger.error(f"[流式响应 {request_id}] 错误: {error_msg}", exc_info=True)
106
+ error_content = f"\n{error_msg}\n```"
107
+ yield f"data: {json.dumps({'id': request_id, 'object': 'chat.completion.chunk', 'created': int(time.time()), 'model': 'sora-1.0', 'choices': [{'index': 0, 'delta': {'content': error_content}, 'finish_reason': 'error'}]})}\n\n"
108
+ yield "data: [DONE]\n\n"
109
+
110
+ async def generate_streaming_remix_response(
111
+ sora_client: SoraClient,
112
+ prompt: str,
113
+ image_data: str,
114
+ n_images: int = 1
115
+ ) -> AsyncGenerator[str, None]:
116
+ """
117
+ 图像到图像的流式响应生成器
118
+
119
+ Args:
120
+ sora_client: Sora客户端
121
+ prompt: 提示词
122
+ image_data: Base64编码的图像数据
123
+ n_images: 生成图像数量
124
+
125
+ Yields:
126
+ SSE格式的响应数据
127
+ """
128
+ import os
129
+ import tempfile
130
+ import uuid
131
+ import base64
132
+
133
+ request_id = f"chatcmpl-stream-remix-{time.time()}-{hash(prompt) % 10000}"
134
+
135
+ # 发送开始事件
136
+ yield f"data: {json.dumps({'id': request_id, 'object': 'chat.completion.chunk', 'created': int(time.time()), 'model': 'sora-1.0', 'choices': [{'index': 0, 'delta': {'role': 'assistant'}, 'finish_reason': None}]})}\n\n"
137
+
138
+ try:
139
+ # 保存base64图片到临时文件
140
+ temp_dir = tempfile.mkdtemp()
141
+ temp_image_path = os.path.join(temp_dir, f"upload_{uuid.uuid4()}.png")
142
+
143
+ try:
144
+ # 解码并保存图片
145
+ with open(temp_image_path, "wb") as f:
146
+ f.write(base64.b64decode(image_data))
147
+
148
+ # 上传图片
149
+ upload_msg = "```think\n上传图片中...\n"
150
+ yield f"data: {json.dumps({'id': request_id, 'object': 'chat.completion.chunk', 'created': int(time.time()), 'model': 'sora-1.0', 'choices': [{'index': 0, 'delta': {'content': upload_msg}, 'finish_reason': None}]})}\n\n"
151
+
152
+ logger.info(f"[流式响应Remix {request_id}] 上传图片中")
153
+ upload_result = await sora_client.upload_image(temp_image_path)
154
+ media_id = upload_result['id']
155
+
156
+ # 发送生成中消息
157
+ generate_msg = "\n基于图片生成新图像中...\n"
158
+ yield f"data: {json.dumps({'id': request_id, 'object': 'chat.completion.chunk', 'created': int(time.time()), 'model': 'sora-1.0', 'choices': [{'index': 0, 'delta': {'content': generate_msg}, 'finish_reason': None}]})}\n\n"
159
+
160
+ # 创建后台任务生成图像
161
+ logger.info(f"[流式响应Remix {request_id}] 开始生成图像,提示词: {prompt}")
162
+ generation_task = asyncio.create_task(sora_client.generate_image_remix(
163
+ prompt=prompt,
164
+ media_id=media_id,
165
+ num_images=n_images
166
+ ))
167
+
168
+ # 每5秒发送一条"仍在生成中"的消息
169
+ progress_messages = [
170
+ "正在处理您的请求...",
171
+ "仍在生成图像中,请继续等待...",
172
+ "Sora正在基于您的图片创作新作品...",
173
+ "图像生成需要一点时间,感谢您的耐心等待...",
174
+ "正在融合您的风格和提示词,打造专属图像..."
175
+ ]
176
+
177
+ i = 0
178
+ while not generation_task.done():
179
+ await asyncio.sleep(5)
180
+ progress_msg = progress_messages[i % len(progress_messages)]
181
+ i += 1
182
+ content = "\n" + progress_msg + "\n"
183
+ yield f"data: {json.dumps({'id': request_id, 'object': 'chat.completion.chunk', 'created': int(time.time()), 'model': 'sora-1.0', 'choices': [{'index': 0, 'delta': {'content': content}, 'finish_reason': None}]})}\n\n"
184
+
185
+ # 获取生成结果
186
+ image_urls = await generation_task
187
+ logger.info(f"[流式响应Remix {request_id}] 图像生成完成")
188
+
189
+ # 本地化图片URL
190
+ if Config.IMAGE_LOCALIZATION:
191
+ logger.info(f"[流式响应Remix {request_id}] 进行图片本地化处理")
192
+ localized_urls = await localize_image_urls(image_urls)
193
+ image_urls = localized_urls
194
+ logger.info(f"[流式响应Remix {request_id}] 图片本地化处理完成")
195
+ else:
196
+ logger.info(f"[流式响应Remix {request_id}] 图片本地化功能未启用")
197
+
198
+ # 结束代码块
199
+ content_str = "\n```\n\n"
200
+ yield f"data: {json.dumps({'id': request_id, 'object': 'chat.completion.chunk', 'created': int(time.time()), 'model': 'sora-1.0', 'choices': [{'index': 0, 'delta': {'content': content_str}, 'finish_reason': None}]})}\n\n"
201
+
202
+ # 发送图片URL作为Markdown
203
+ for i, url in enumerate(image_urls):
204
+ if i > 0:
205
+ newline_str = "\n\n"
206
+ yield f"data: {json.dumps({'id': request_id, 'object': 'chat.completion.chunk', 'created': int(time.time()), 'model': 'sora-1.0', 'choices': [{'index': 0, 'delta': {'content': newline_str}, 'finish_reason': None}]})}\n\n"
207
+
208
+ image_markdown = f"![Generated Image]({url})"
209
+ yield f"data: {json.dumps({'id': request_id, 'object': 'chat.completion.chunk', 'created': int(time.time()), 'model': 'sora-1.0', 'choices': [{'index': 0, 'delta': {'content': image_markdown}, 'finish_reason': None}]})}\n\n"
210
+
211
+ # 发送完成事件
212
+ yield f"data: {json.dumps({'id': request_id, 'object': 'chat.completion.chunk', 'created': int(time.time()), 'model': 'sora-1.0', 'choices': [{'index': 0, 'delta': {}, 'finish_reason': 'stop'}]})}\n\n"
213
+
214
+ # 发送结束标志
215
+ yield "data: [DONE]\n\n"
216
+
217
+ finally:
218
+ # 清理临时文件
219
+ if os.path.exists(temp_image_path):
220
+ os.remove(temp_image_path)
221
+ if os.path.exists(temp_dir):
222
+ os.rmdir(temp_dir)
223
+
224
+ except Exception as e:
225
+ error_msg = f"图像Remix失败: {str(e)}"
226
+ logger.error(f"[流式响应Remix {request_id}] 错误: {error_msg}", exc_info=True)
227
+ error_content = f"\n{error_msg}\n```"
228
+ yield f"data: {json.dumps({'id': request_id, 'object': 'chat.completion.chunk', 'created': int(time.time()), 'model': 'sora-1.0', 'choices': [{'index': 0, 'delta': {'content': error_content}, 'finish_reason': 'error'}]})}\n\n"
229
+
230
+ # 结束流
231
+ yield "data: [DONE]\n\n"
src/sora_generator.py ADDED
@@ -0,0 +1,1145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import cloudscraper
2
+ import time
3
+ import json
4
+ import random
5
+ import string
6
+ import os
7
+ import mimetypes # To guess file mime type
8
+ import argparse # For better command-line arguments
9
+ import asyncio
10
+ from .utils import localize_image_urls
11
+ from .config import Config
12
+
13
+ class SoraImageGenerator:
14
+ def __init__(self, proxy_host=None, proxy_port=None, proxy_user=None, proxy_pass=None, auth_token=None):
15
+ # 使用Config.VERBOSE_LOGGING替代直接从环境变量读取SORA_DEBUG
16
+ self.DEBUG = Config.VERBOSE_LOGGING
17
+
18
+ # 设置代理
19
+ if proxy_host and proxy_port:
20
+ # 如果有认证信息,添加到代理URL中
21
+ if proxy_user and proxy_pass:
22
+ proxy_auth = f"{proxy_user}:{proxy_pass}@"
23
+ self.proxies = {
24
+ "http": f"http://{proxy_auth}{proxy_host}:{proxy_port}",
25
+ "https": f"http://{proxy_auth}{proxy_host}:{proxy_port}"
26
+ }
27
+ if self.DEBUG:
28
+ print(f"已配置带认证的代理: {proxy_user}:****@{proxy_host}:{proxy_port}")
29
+ else:
30
+ self.proxies = {
31
+ "http": f"http://{proxy_host}:{proxy_port}",
32
+ "https": f"http://{proxy_host}:{proxy_port}"
33
+ }
34
+ if self.DEBUG:
35
+ print(f"已配置代理: {proxy_host}:{proxy_port}")
36
+ else:
37
+ self.proxies = None
38
+ if self.DEBUG:
39
+ print("代理未配置。请求将直接发送。")
40
+ # 创建 cloudscraper 实例
41
+ self.scraper = cloudscraper.create_scraper(
42
+ browser={
43
+ 'browser': 'chrome',
44
+ 'platform': 'windows',
45
+ 'mobile': False
46
+ }
47
+ )
48
+ # 设置通用请求头 - 移除 Content-Type 和 openai-sentinel-token (会动态添加)
49
+ self.base_headers = {
50
+ "accept": "*/*",
51
+ "accept-language": "zh-CN,zh;q=0.9",
52
+ "cache-control": "no-cache",
53
+ "pragma": "no-cache",
54
+ "priority": "u=1, i",
55
+ "sec-ch-ua": "\"Chromium\";v=\"136\", \"Google Chrome\";v=\"136\", \"Not.A/Brand\";v=\"99\"",
56
+ "sec-ch-ua-mobile": "?0",
57
+ "sec-ch-ua-platform": "\"Windows\"",
58
+ "sec-fetch-dest": "empty",
59
+ "sec-fetch-mode": "cors",
60
+ "sec-fetch-site": "same-origin",
61
+ # Referer 会根据操作不同设置
62
+ }
63
+ # 设置认证Token (从外部传入或硬编码)
64
+ self.auth_token = auth_token or "Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IjE5MzQ0ZTY1LWJiYzktNDRkMS1hOWQwLWY5NTdiMDc5YmQwZSIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsiaHR0cHM6Ly9hcGkub3BlbmFpLmNvbS92MSJdLCJjbGllbnRfaWQiOiJhcHBfWDh6WTZ2VzJwUTl0UjNkRTduSzFqTDVnSCIsImV4cCI6MTc0Nzk3MDExMSwiaHR0cHM6Ly9hcGkub3BlbmFpLmNvbS9hdXRoIjp7InVzZXJfaWQiOiJ1c2VyLWdNeGM0QmVoVXhmTW1iTDdpeUtqengxYiJ9LCJodHRwczovL2FwaS5vcGVuYWkuY29tL3Byb2ZpbGUiOnsiZW1haWwiOiIzajVtOTFud3VtckBmcmVlLnViby5lZHUuZ24iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZX0sImlhdCI6MTc0NzEwNjExMSwiaXNzIjoiaHR0cHM6Ly9hdXRoLm9wZW5haS5jb20iLCJqdGkiOiIzMGM4ZDJhOS0yNzkxLTRhNjQtODI2OS0yMzU3OGFhMmI0MTEiLCJuYmYiOjE3NDcxMDYxMTEsInB3ZF9hdXRoX3RpbWUiOjE3NDcxMDYxMDkxMDksInNjcCI6WyJvcGVuaWQiLCJlbWFpbCIsInByb2ZpbGUiLCJvZmZsaW5lX2FjY2VzcyIsIm1vZGVsLnJlcXVlc3QiLCJtb2RlbC5yZWFkIiwib3JnYW5pemF0aW9uLnJlYWQiLCJvcmdhbml6YXRpb24ud3JpdGUiXSwic2Vzc2lvbl9pZCI6ImF1dGhzZXNzX21yWFRwZlVENU51TDFsV05xNUhSOW9lYiIsInN1YiI6ImF1dGgwfDY4MjFkYWYyNjhiYjgxMzFkMDRkYTAwNCJ9.V4ZqYJuf_f7F_DrMMRrt-ymul5HUrqENVkiFyEwfYmzMFWthEGS6Ryia100QRlprw8jjGscHZXlUFaOcRNIarcBig8fBY6n_AB3J34MlcBv6peS-3_EJlIiH_N7j_mu-8lNpJbxk9lSlFaGpKU1IOO7kBuaAmLH-iErM-wqBfSlnnAq8h4iqBDxi4CMTcAhVm2-qG7u7f0Ho1TCGa7wrdchWtZxyfHIqNWkC88qBlUwTH5g2vRL419_zIKEWKyAtV2WNI68vpyBLrRVhtnpDh0jcrm2WqCj2X2LQqNFkFKoui3wCdG9Vskpl39l9sV54HuV7w6stQIausR1F4Y9NbjsBAyLIimZOllCwYAefTC2BOpIHfOA3_D58G3SEiRADVK7pK7ip6QsEI__GteoeCuRvZA9b5jLmhVS0SUlDYSOoNwlJ_ejfEpPJcmHUchFa7bUkS-XVrEUgr1yP5FxPwWUyn7UWrW_dZ3lVW1EU4Bp6Kp6JuwyOFf2Mj-V3_9tc8qJRClI8WHUf6In0hiO_pGbFCI2opkF3XusAQKmTB12nPBsmSlwewigTPhAj3nf-8Ze3O-etnBrV5pz_woIwQsQ54T-wgEdrLWDE6dSqNDulfpldF6Cok62212kW8w3SY3V7VSq5Tr1KRyWXJEH-haVb6qmAE2ldDjeHvJossWg" # 替换成你的有效Token或从环境变量读取
65
+ if not self.auth_token or not self.auth_token.startswith("Bearer "):
66
+ raise ValueError("无效或缺失的认证Token (应以 'Bearer ' 开头)")
67
+ self.gen_url = "https://sora.chatgpt.com/backend/video_gen"
68
+ self.check_url = "https://sora.chatgpt.com/backend/video_gen"
69
+ self.upload_url = "https://sora.chatgpt.com/backend/uploads"
70
+
71
+ # 显示是否启用了图片本地化功能
72
+ if Config.IMAGE_LOCALIZATION:
73
+ if self.DEBUG:
74
+ print(f"图片本地化功能已启用,图片将保存到:{Config.IMAGE_SAVE_DIR}")
75
+ os.makedirs(Config.IMAGE_SAVE_DIR, exist_ok=True)
76
+
77
+ def _get_dynamic_headers(self, content_type="application/json", referer="https://sora.chatgpt.com/explore"):
78
+ """为每个请求生成包含动态sentinel token的headers"""
79
+ headers = self.base_headers.copy()
80
+ headers["authorization"] = self.auth_token
81
+ headers["openai-sentinel-token"] = self._generate_sentinel_token()
82
+ headers["referer"] = referer
83
+ if content_type: # multipart/form-data 时 Content-Type 由 requests 自动设置
84
+ headers["content-type"] = content_type
85
+ return headers
86
+
87
+ def _generate_sentinel_token(self):
88
+ """生成一个随机修改的sentinel-token"""
89
+ base_token = {
90
+ "p": "gAAAAABWzIxMjksIk1vbiBNYXkgMTIgMjAyNSAxODo1ODowOSBHTVQrMDgwMCAo5Lit5Zu95qCH5YeG5pe26Ze0KSIsMjI0ODE0Njk0NCw5LCJNb3ppbGxhLzUuMCAoV2luZG93cyBOVCAxMC4wOyBXaW42NDsgeDY0KSBBcHBsZVdlYktpdC81MzcuMzYgKEtIVE1MLCBsaWtlIEdlY2tvKSBDaHJvbWUvMTM2LjAuMC4wIFNhZmFyaS81MzcuMzYiLCJodHRwczovL3NvcmEtY2RuLm9haXN0YXRpYy5jb20vX25leHQvc3RhdGljL2NodW5rcy93ZWJwYWNrLWU1MDllMDk0N2RlZWM0Y2YuanMiLG51bGwsInpoLUNOIiwiemgtQ04semgiLDEwLCJwcm9kdWN0U3Vi4oiSMjAwMzAxMDciLCJfcmVhY3RMaXN0ZW5pbmd2cXAzdWtpNmh6IiwiX19zZW50aW5lbF9pbml0X3BlbmRpbmciLDEyMDczMzEuNSwiN2M4OTMxM2EtZTY0Mi00MjI4LTk5ZDQtNTRlZDRjYzQ3MGZiIiwiIiwxNl0=",
91
+ "t": "SBYdBBoGFhQRdVtbdxEYEQEaHQAAFhQRYwN2T2IEUH5pRHJyYx51YnJybFBhWHZkYgRyUHgDclZzH2l5a1gXUWVlcRMRGBEBHR0HBBYUEXpnfVt9YFAJDB8WAAIAAgYRDgxpYltjSl55SWJEZV9mXx4KFh8WGAAaBxYUEXVydW9ydXJ1b3J1cnVvcnVydW9ydXJ1b3J1b3J1b3J1b3J1DgkMHxYBAgALAxEOHh0BBwIXCwwEBx8FAwADGwAAHxYcBxoFDQwJFnFDEw4WHxYXBxoCBwwJFmB2dGRxYgBqdHVLf2hUX3VySWpVcVNNa3ZiYW13dgtyb3J5cHxvam12YWB7YgN2THVcYnxsSwR7fH9mfHJlZ1F3VARtcVwLcGpiV2pwaEdmZFhgdGZLbWRxXAdQbHJ5dHNvanlwQ29Xd1QEZnFmXFJoWFBRbEZ2e3JTWVF8YnFvd2ZUd2xiR3Vzb1BxcWV3UXxLbmxrYV9Wf3FxfHNJclVydU1qdXJtaHJmUH9sYnF1fElcdXtMdH5sdnZmZAR+ZmpUcXZzVgN2cUN3UHZyZWhyXAd8bHJ5cnVGdW1lWGRScWIAamBlDgkMHxYKBgADDREODHZjcWx/AnIBZkJLV2FTRWZWZX5Pa2JHVWsDbmRie1xgYXFoUWxfbmlhBQJ4f3FmUGFJBnNkWEphZ3VET2VYcntpA25kYntDVWRYf2Z3ZXZ9Vl9hXXZ1RGZseENzZnJ3Z3YCdn1WX2FddnVEZmx4Q3NmcndkFh8WFwAaBAUMCRZrcnh7fF92T2Z1eXx2dW5pZ1YKXGIFRnFhX3ZAZmJLUXwAfmtgeGlxYQVoeFdYdU1iYgJjfFsBAVJ7YUxrB1ZDVUR+fGthcXN4ZkRrbH9lWmtsG0FiAkdIZHFXZnxffnRiVgZsa3JeUmVlQEhhQ31ybFsNZ2VCS2NmWEZFZlhMWWQEcWJ2ZWZdZx5XdWRYXnllcVxJanMKWnQARElXdgoDUHx4WlZ0UEBWB0tNTQBuQW4dAlNXWXRUV11cXWkHQwx0XW5BUW9QbWVyRnFhZWJIVwR1UHtfRGdSeGl1YQUfcmJ2V0h3Zgd4bFR9cWdCUHFmZU1qd199amVDeQx5X3JRYB51ZWRYXnlSZHJcdAVHdXtmcWlWaXFwV1hGXlB0Q2hlX1dQf2V+fWB7cndqcl5YUF92TXRxV3V3AkBXY0JpbWRfSlJ8AgVLZGFHdnwCRHBnHFdzZlheV2wCYmZmbAZjfF9iUWxoBmJrYkp/UAJiZmVleXB4ZWZhdR9HYmJ2Qn5iX3JKdGFbV3xfdmF1HnVmZWF0f2V1THlmdXlTd3t+VFJ2Q05RfEZbV3tiAVIHAlpLAXICVXZpRmpzbEVXa1wcUgZXBwxO",
92
+ "c": "gAAAAABoIdRAZEk6qAnDRqdimKxPhXtA_xBkiDXhKF4LUmlNY6CZmfNxZiabjPHk_DCEUnnyq-y6JPj-D46YPk-6r7zR6qS64hEwGYt9Hh_8vUIod-7PLh9qPKqdYl4TBCVUgtrbhTWfse7s6NHCSy1T0Nzj2C6vAUPhzAx4LAMIrl2YbElkUVPgwELyYF_inh3zliwZL-zp4zR3LOABcrGqlrLoP7_kNrwcIZwlVD1RNlnHy9TEFsRzYOMQo_DbagZAK1h87arrMonZHBi9ukfiGuvCQP-y76j61b4qaQPMA19EoURLwnotVBWUIBpHEEoH9vmPb817sGwQ2R8XHoAVR4dwYs_7EoS8H8kAlUVZDjAKGq5x48nvZrarLBjYJXXsfJLuxhibNYXG1hKNbOdi1w-Xl1NgqSPAb-MuwnyDLPGE5MeLkwM2Dl3jD7G6B2Z2F993cvW7mOOs0OebZ6NMgIrZnTG4mMI7PirPY95JWmztDfeuFLJ_V_kyaSP--BZCIIAB4074RBVitIrJEwceWVW3zXOOHWJoDax7E0nfa5abvLGCjdEeJfNx4Fcp7iYFN_E8iR0f797DLlFh4uFLv1DPhipYtQFpPPUlbxKu9H9W4IDr7Hv_LgfvFo0VwLzV6ANZPGmdza67dAsKXrWtXlCrVNfqFoVO4wI4n-zrE3lcUPzI_ZJebF2HGlzerTvqgU5R0i4fzUGY4-UqpWlVurP-rCJY2ARcSMSyPXnPGetl5Z2m-f9k7K3n7txrfv2293jyyRTVAZLC9aLrBOFWHD4cf0aiwisgJ9IKnhhnoJ-WF8NuvFS7l1Z0d2zTnndjuryb0M36Og4b_Ku3aJKS0_Eqbns8_bUXdEPh5_15T_92_1yf5jy-amrgolgcO_7yJqX5aU9-PUUiaP3WzeyidMSH4Vtls63tQ5evUlDkEHfNKoyCYaSxpzA_FsNmPbMmcv3g1wNKg_W8V0Yh7ZrtW1L8229SpZFWc96Sg3CRPplk-dnVzV93lP7cN5o5ubGWZMkkz5UASjE5XLn8h5dx6neuOemKVHAj29QxOWmdGEehNvmeMec0k8uL9X-7yYBJSTnI066OR38JUUTFqTH3RSXI9M4ggals32P56bwUmawvZ-bu02qc3kCVI3oK9bnP8oTk0xTK5_bMrlevGYKG0qdPXamgdDfVg7hlNA2OTCnw6iRP6DJiPm_zKVdQa4z6SPJsdt_Q",
93
+ "id": self._generate_random_id(),
94
+ "flow": "sora_create_task" # This might need changing for uploads? Let's try keeping it.
95
+ }
96
+ # Simple randomization (can be improved if needed)
97
+ p_chars = list(base_token["p"])
98
+ for _ in range(random.randint(3, 7)):
99
+ pos = random.randint(10, len(p_chars) - 10)
100
+ p_chars[pos] = random.choice(string.ascii_letters + string.digits + "+/=")
101
+ base_token["p"] = "".join(p_chars)
102
+ c_chars = list(base_token["c"])
103
+ for _ in range(random.randint(3, 7)):
104
+ pos = random.randint(10, len(c_chars) - 10)
105
+ c_chars[pos] = random.choice(string.ascii_letters + string.digits + "+/=_-")
106
+ base_token["c"] = "".join(c_chars)
107
+ return json.dumps(base_token)
108
+
109
+ def _generate_random_id(self):
110
+ """生成一个随机ID,格式类似于UUID"""
111
+ return f"{self._random_hex(8)}-{self._random_hex(4)}-{self._random_hex(4)}-{self._random_hex(4)}-{self._random_hex(12)}"
112
+
113
+ def _random_hex(self, length):
114
+ """生成指定长度的随机十六进制字符串"""
115
+ return ''.join(random.choice(string.hexdigits.lower()) for _ in range(length))
116
+
117
+ def generate_image(self, prompt, num_images=1, width=720, height=480):
118
+ """
119
+ 生成一张或多张图片并返回图片URL列表
120
+ 参数:
121
+ prompt (str): 图片生成提示词
122
+ num_images (int): 要生成的图片数量 (对应 n_variants)
123
+ width (int): 图片宽度
124
+ height (int): 图片高度
125
+ 返回:
126
+ list[str] or str: 成功时返回包含图片URL的列表,失败时返回错误信息字符串
127
+ """
128
+ # 确保提示词是正确的UTF-8格式
129
+ if isinstance(prompt, bytes):
130
+ prompt = prompt.decode('utf-8')
131
+
132
+ # 打印提示词,并处理可能的编码问题
133
+ try:
134
+ if self.DEBUG:
135
+ print(f"开始生成 {num_images} 张图片,提示词: '{prompt}'")
136
+ except UnicodeEncodeError:
137
+ if self.DEBUG:
138
+ print(f"开始生成 {num_images} 张图片,提示词: [编码显示问题,但数据正确]")
139
+
140
+ payload = {
141
+ "type": "image_gen",
142
+ "operation": "simple_compose",
143
+ "prompt": prompt,
144
+ "n_variants": num_images, # 使用传入的数量
145
+ "width": width,
146
+ "height": height,
147
+ "n_frames": 1,
148
+ "inpaint_items": []
149
+ }
150
+ try:
151
+ task_id = self._submit_task(payload, referer="https://sora.chatgpt.com/explore")
152
+ if not task_id:
153
+ # 任务提交失败,尝试切换密钥后重试
154
+ if self.DEBUG:
155
+ print(f"任务提交失败,尝试切换API密钥后重试")
156
+
157
+ try:
158
+ # 导入在这里进行以避免循环导入
159
+ from .key_manager import key_manager
160
+ # 标记当前密钥为无效并获取新密钥
161
+ new_key = key_manager.mark_key_invalid(self.auth_token)
162
+ if new_key:
163
+ self.auth_token = new_key
164
+ if self.DEBUG:
165
+ print(f"已切换到新的API密钥,重试任务")
166
+ # 使用新密钥重试提交任务
167
+ task_id = self._submit_task(payload, referer="https://sora.chatgpt.com/explore")
168
+ if not task_id:
169
+ return "任务提交失败(已尝试切换密钥)"
170
+ else:
171
+ return "任务提交失败(无可用的备用密钥)"
172
+ except ImportError:
173
+ if self.DEBUG:
174
+ print(f"无法导入key_manager,无法自动切换密钥")
175
+ return "任务提交失败"
176
+ except Exception as e:
177
+ if self.DEBUG:
178
+ print(f"切换API密钥时发生错误: {str(e)}")
179
+ return f"任务提交失败: {str(e)}"
180
+
181
+ if self.DEBUG:
182
+ print(f"任务已提交,ID: {task_id}")
183
+ # 轮询检查任务状态,直到完成
184
+ image_urls = self._poll_task_status(task_id)
185
+
186
+ # 如果轮询返回错误信息,也尝试切换密钥重试
187
+ if isinstance(image_urls, str) and ("失败" in image_urls or "错误" in image_urls or "error" in image_urls.lower()):
188
+ if self.DEBUG:
189
+ print(f"任务执行失败,尝试切换API密钥后重试")
190
+
191
+ try:
192
+ from .key_manager import key_manager
193
+ new_key = key_manager.mark_key_invalid(self.auth_token)
194
+ if new_key:
195
+ self.auth_token = new_key
196
+ if self.DEBUG:
197
+ print(f"已切换到新的API密钥,重试整个生成过程")
198
+ # 使用新密钥重试整个生成过程
199
+ return self.generate_image(prompt, num_images, width, height)
200
+ except (ImportError, Exception) as e:
201
+ if self.DEBUG:
202
+ print(f"切换API密钥失败或重试失败: {str(e)}")
203
+
204
+ # 图片本地化处理
205
+ if Config.IMAGE_LOCALIZATION and isinstance(image_urls, list) and image_urls:
206
+ if self.DEBUG:
207
+ print(f"\n================================")
208
+ print(f"开始图片本地化处理")
209
+ print(f"图片本地化配置: 启用={Config.IMAGE_LOCALIZATION}, 保存目录={Config.IMAGE_SAVE_DIR}")
210
+ print(f"需要本地化的图片数量: {len(image_urls)}")
211
+ print(f"原始图片URLs: {image_urls}")
212
+ print(f"================================\n")
213
+
214
+ try:
215
+ # 创建事件循环并运行图片本地化
216
+ if self.DEBUG:
217
+ print(f"创建异步事件循环处理图片下载...")
218
+ loop = asyncio.new_event_loop()
219
+ asyncio.set_event_loop(loop)
220
+
221
+ if self.DEBUG:
222
+ print(f"调用localize_image_urls函数...")
223
+ localized_urls = loop.run_until_complete(localize_image_urls(image_urls))
224
+ loop.close()
225
+ if self.DEBUG:
226
+ print(f"异步事件循环已关闭")
227
+
228
+ if self.DEBUG:
229
+ print(f"\n================================")
230
+ print(f"图片本地化完成")
231
+ print(f"原始URLs: {image_urls}")
232
+ print(f"本地化后的URLs: {localized_urls}")
233
+ print(f"================================\n")
234
+
235
+ # 检查结果是否有效
236
+ if not localized_urls:
237
+ if self.DEBUG:
238
+ print(f"❌ 警告:本地化后的URL列表为空,将使用原始URL")
239
+ return image_urls
240
+
241
+ # 检查是否所有URL都被正确本地化
242
+ local_count = sum(1 for url in localized_urls if url.startswith("/static/") or "/static/" in url)
243
+ if local_count == 0:
244
+ if self.DEBUG:
245
+ print(f"❌ 警告:没有一个URL被成功本地化,将使用原始URL")
246
+ return image_urls
247
+ elif local_count < len(localized_urls):
248
+ if self.DEBUG:
249
+ print(f"⚠️ 注意:部分URL未成功本地化 ({local_count}/{len(localized_urls)})")
250
+
251
+ return localized_urls
252
+ except Exception as e:
253
+ if self.DEBUG:
254
+ print(f"❌ 图片本地化过程中发生错误: {str(e)}")
255
+ import traceback
256
+ traceback.print_exc()
257
+ if self.DEBUG:
258
+ print(f"由于错误,将返回原始URL")
259
+ return image_urls
260
+ elif not Config.IMAGE_LOCALIZATION:
261
+ if self.DEBUG:
262
+ print(f"图片本地化功能未启用,返回原始URLs")
263
+ elif not isinstance(image_urls, list):
264
+ if self.DEBUG:
265
+ print(f"图片生成返回了非列表结果: {image_urls},无法进行本地化")
266
+ elif not image_urls:
267
+ if self.DEBUG:
268
+ print(f"图片生成返回了空列表,没有可本地化的内容")
269
+
270
+ return image_urls
271
+ except Exception as e:
272
+ if self.DEBUG:
273
+ print(f"❌ 生成图片时出错: {str(e)}")
274
+ import traceback
275
+ traceback.print_exc()
276
+ return f"生成图片时出错: {str(e)}"
277
+
278
+ def upload_image(self, file_path):
279
+ """
280
+ 上传本地图片文件到Sora后端
281
+ 参数:
282
+ file_path (str): 本地图片文件的路径
283
+ 返回:
284
+ dict or str: 成功时返回包含上传信息的字典,失败时返回错误信息字符串
285
+ """
286
+ if not os.path.exists(file_path):
287
+ return f"错误:文件未找到 '{file_path}'"
288
+ file_name = os.path.basename(file_path)
289
+ mime_type, _ = mimetypes.guess_type(file_path)
290
+ if not mime_type or not mime_type.startswith('image/'):
291
+ return f"错误:无法确定文件类型或文件不是图片 '{file_path}' (Mime: {mime_type})"
292
+ if self.DEBUG:
293
+ print(f"开始上传图片: {file_name} (Type: {mime_type})")
294
+ # multipart/form-data 请求不需要手动设置 Content-Type header
295
+ # requests 会根据 files 参数自动处理 boundary 和 Content-Type
296
+ headers = self._get_dynamic_headers(content_type=None, referer="https://sora.chatgpt.com/library") # Referer from example
297
+
298
+ # 尝试上传
299
+ return self._try_upload_with_retry(file_path, file_name, mime_type, headers)
300
+
301
+ def _try_upload_with_retry(self, file_path, file_name, mime_type, headers, is_retry=False):
302
+ """尝试上传图片,失败时可能尝试切换密钥重试"""
303
+ # 保存当前的API密钥,确保整个上传过程使用相同的密钥
304
+ current_auth_token = self.auth_token
305
+
306
+ files = {
307
+ 'file': (file_name, open(file_path, 'rb'), mime_type),
308
+ 'file_name': (None, file_name) # 第二个字段是文件名
309
+ }
310
+ try:
311
+ response = self.scraper.post(
312
+ self.upload_url,
313
+ headers=headers,
314
+ files=files, # 使用 files 参数上传文件
315
+ proxies=self.proxies,
316
+ timeout=60 # 上传可能需要更长时间
317
+ )
318
+ if response.status_code == 200:
319
+ result = response.json()
320
+ if self.DEBUG:
321
+ print(f"图片上传成功! Media ID: {result.get('id')}")
322
+ # 确保返回的结果中包含上传时使用的API密钥信息
323
+ result['used_auth_token'] = current_auth_token
324
+ # print(f"上传响应: {json.dumps(result, indent=2)}") # 可选:打印完整响应
325
+ return result # 返回包含id, url等信息的字典
326
+ else:
327
+ error_msg = f"上传图片失败,状态码: {response.status_code}, 响应: {response.text}"
328
+ if self.DEBUG:
329
+ print(error_msg)
330
+
331
+ # 如果不是已经在重试,且响应表明可能是API密钥问题,尝试切换密钥
332
+ if not is_retry and (response.status_code in [401, 403] or "auth" in response.text.lower() or "token" in response.text.lower()):
333
+ if self.DEBUG:
334
+ print(f"上传失败可能与API密钥有关,尝试切换密钥后重试")
335
+
336
+ try:
337
+ from .key_manager import key_manager
338
+ new_key = key_manager.mark_key_invalid(self.auth_token)
339
+ if new_key:
340
+ self.auth_token = new_key
341
+ if self.DEBUG:
342
+ print(f"已切换到新的API密钥,重试上传")
343
+ # 更新头部信息并重试
344
+ new_headers = self._get_dynamic_headers(content_type=None, referer="https://sora.chatgpt.com/library")
345
+ return self._try_upload_with_retry(file_path, file_name, mime_type, new_headers, is_retry=True)
346
+ except (ImportError, Exception) as e:
347
+ if self.DEBUG:
348
+ print(f"切换API密钥失败: {str(e)}")
349
+
350
+ return error_msg
351
+ except Exception as e:
352
+ error_msg = f"上传图片时出错: {str(e)}"
353
+ if self.DEBUG:
354
+ print(error_msg)
355
+
356
+ # 如果不是已经在重试,尝试切换密钥后重试
357
+ if not is_retry:
358
+ if self.DEBUG:
359
+ print(f"上传过程中发生异常,尝试切换API密钥后重试")
360
+
361
+ try:
362
+ from .key_manager import key_manager
363
+ new_key = key_manager.mark_key_invalid(self.auth_token)
364
+ if new_key:
365
+ self.auth_token = new_key
366
+ if self.DEBUG:
367
+ print(f"已切换到新的API密钥,重试上传")
368
+ # 更新头部信息并重试
369
+ new_headers = self._get_dynamic_headers(content_type=None, referer="https://sora.chatgpt.com/library")
370
+ return self._try_upload_with_retry(file_path, file_name, mime_type, new_headers, is_retry=True)
371
+ except (ImportError, Exception) as err:
372
+ if self.DEBUG:
373
+ print(f"切换API密钥失败: {str(err)}")
374
+
375
+ return error_msg
376
+ finally:
377
+ # 确保文件句柄被关闭
378
+ if 'file' in files and files['file'][1]:
379
+ files['file'][1].close()
380
+
381
+ def generate_image_remix(self, prompt, uploaded_media_id, num_images=1, width=None, height=None):
382
+ """
383
+ 基于已上传的图片进行重混(Remix)生成新图片
384
+ 参数:
385
+ prompt (str): 图片生成提示词
386
+ uploaded_media_id (str): 通过 upload_image 获取的媒体ID (例如 "media_...")
387
+ num_images (int): 要生成的图片数量
388
+ width (int, optional): 输出图片宽度。如果为None,可能由API决定。
389
+ height (int, optional): 输出图片高度。如果为None,可能由API决定。
390
+ 返回:
391
+ list[str] or str: 成功时返回包含图片URL的列表,失败时返回错误信息字符串
392
+ """
393
+ if self.DEBUG:
394
+ print(f"开始 Remix 图片 (ID: {uploaded_media_id}),提示词: '{prompt}'")
395
+
396
+ # 如果上传图片时使用了特定API密钥,则确保使用同一个密钥进行remix
397
+ # 在upload_image的返回结果中可能包含used_auth_token
398
+ if isinstance(uploaded_media_id, dict) and 'id' in uploaded_media_id:
399
+ if 'used_auth_token' in uploaded_media_id and uploaded_media_id['used_auth_token'] != self.auth_token:
400
+ if self.DEBUG:
401
+ print(f"检测到上传图片时使用了不同的API密钥,切换到匹配的密钥进行Remix操作")
402
+ self.auth_token = uploaded_media_id['used_auth_token']
403
+ uploaded_media_id = uploaded_media_id['id']
404
+
405
+ # 获取上传图片的信息,特别是原始尺寸,如果未指定输出尺寸则可能需要
406
+ # (这里简化,假设API能处理尺寸或���们强制指定)
407
+ # 实际应用中可能需要先查询 media_id 的详情
408
+ payload = {
409
+ "prompt": prompt,
410
+ "n_variants": num_images,
411
+ "inpaint_items": [
412
+ {
413
+ "type": "image",
414
+ "frame_index": 0,
415
+ "preset_id": None,
416
+ "generation_id": None,
417
+ "upload_media_id": uploaded_media_id, # 关键:引用上传的图片
418
+ "source_start_frame": 0,
419
+ "source_end_frame": 0,
420
+ "crop_bounds": None
421
+ }
422
+ ],
423
+ "operation": "remix", # 关键:操作类型
424
+ "type": "image_gen",
425
+ "n_frames": 1,
426
+ "width": 720, # 可选,如果为None,API可能会基于输入调整
427
+ "height": 480 # 可选
428
+ }
429
+ # 只有当width和height都提供了值时才添加到payload中
430
+ if width is not None:
431
+ payload["width"] = width
432
+ if height is not None:
433
+ payload["height"] = height
434
+ try:
435
+ # 提交任务时使用 library 作为 referer,与示例一致
436
+ task_id = self._submit_task(payload, referer="https://sora.chatgpt.com/library")
437
+ if not task_id:
438
+ # 任务提交失败,尝试切换密钥后重试
439
+ if self.DEBUG:
440
+ print(f"Remix任务提交失败,尝试切换API密钥后重试")
441
+
442
+ try:
443
+ # 导入在这里进行以避免循环导入
444
+ from .key_manager import key_manager
445
+ # 标记当前密钥为无效并获取新密钥
446
+ new_key = key_manager.mark_key_invalid(self.auth_token)
447
+ if new_key:
448
+ self.auth_token = new_key
449
+ if self.DEBUG:
450
+ print(f"已切换到新的API密钥,重试Remix任务")
451
+ # 使用新密钥重试提交任务
452
+ task_id = self._submit_task(payload, referer="https://sora.chatgpt.com/library")
453
+ if not task_id:
454
+ return "Remix任务提交失败(已尝试切换密钥)"
455
+ else:
456
+ return "Remix任务提交失败(无可用的备用密钥)"
457
+ except ImportError:
458
+ if self.DEBUG:
459
+ print(f"无法导入key_manager,无法自动切换密钥")
460
+ return "Remix任务提交失败"
461
+ except Exception as e:
462
+ if self.DEBUG:
463
+ print(f"切换API密钥时发生错误: {str(e)}")
464
+ return f"Remix任务提交失败: {str(e)}"
465
+
466
+ if self.DEBUG:
467
+ print(f"Remix 任务已提交,ID: {task_id}")
468
+ # 轮询检查任务状态
469
+ image_urls = self._poll_task_status(task_id)
470
+
471
+ # 如果轮询返回错误信息,也尝试切换密钥重试
472
+ if isinstance(image_urls, str) and ("失败" in image_urls or "错误" in image_urls or "error" in image_urls.lower()):
473
+ if self.DEBUG:
474
+ print(f"Remix任务执行失败,尝试切换API密钥后重试")
475
+
476
+ try:
477
+ from .key_manager import key_manager
478
+ new_key = key_manager.mark_key_invalid(self.auth_token)
479
+ if new_key:
480
+ self.auth_token = new_key
481
+ if self.DEBUG:
482
+ print(f"已切换到新的API密钥,重试整个Remix过程")
483
+ # 使用新密钥重试整个生成过程
484
+ return self.generate_image_remix(prompt, uploaded_media_id, num_images, width, height)
485
+ except (ImportError, Exception) as e:
486
+ if self.DEBUG:
487
+ print(f"切换API密钥失败或重试失败: {str(e)}")
488
+
489
+ # 增加图片本地化支持
490
+ if image_urls and isinstance(image_urls, list):
491
+ if Config.IMAGE_LOCALIZATION:
492
+ if self.DEBUG:
493
+ print(f"正在本地化 Remix 生成的 {len(image_urls)} 张图片...")
494
+ # 创建事件循环并运行图片本地化
495
+ loop = asyncio.new_event_loop()
496
+ asyncio.set_event_loop(loop)
497
+ localized_urls = loop.run_until_complete(localize_image_urls(image_urls))
498
+ loop.close()
499
+
500
+ if self.DEBUG:
501
+ print(f"Remix 图片本地化完成")
502
+ return localized_urls
503
+
504
+ return image_urls
505
+ except Exception as e:
506
+ # 如果发生异常,也尝试切换密钥重试
507
+ if self.DEBUG:
508
+ print(f"Remix生成过程中发生异常: {str(e)},尝试切换API密钥")
509
+
510
+ try:
511
+ from .key_manager import key_manager
512
+ new_key = key_manager.mark_key_invalid(self.auth_token)
513
+ if new_key:
514
+ self.auth_token = new_key
515
+ if self.DEBUG:
516
+ print(f"已切换到新的API密钥,重试整个Remix过程")
517
+ # 使用新密钥重试整个生成过程
518
+ return self.generate_image_remix(prompt, uploaded_media_id, num_images, width, height)
519
+ except (ImportError, Exception) as err:
520
+ if self.DEBUG:
521
+ print(f"切换API密钥失败或重试失败: {str(err)}")
522
+
523
+ return f"Remix 生成图片时出错: {str(e)}"
524
+
525
+ def _submit_task(self, payload, referer="https://sora.chatgpt.com/explore"):
526
+ """提交生成任务 (通用,接受payload字典)"""
527
+ headers = self._get_dynamic_headers(content_type="application/json", referer=referer)
528
+
529
+ # 获取当前的auth_token用于最后可能的释放
530
+ current_auth_token = self.auth_token
531
+
532
+ try:
533
+ # 检查是否可以导入key_manager并标记密钥为工作中
534
+ try:
535
+ from .key_manager import key_manager
536
+ # 生成一个临时任务ID,用于标记密钥工作状态
537
+ temp_task_id = f"pending_task_{self._generate_random_id()}"
538
+ key_manager.mark_key_as_working(self.auth_token, temp_task_id)
539
+ except ImportError:
540
+ if self.DEBUG:
541
+ print(f"无法导入key_manager,无法标记密钥工作状态")
542
+ except Exception as e:
543
+ if self.DEBUG:
544
+ print(f"标记密钥工作状态时发生错误: {str(e)}")
545
+
546
+ response = self.scraper.post(
547
+ self.gen_url,
548
+ headers=headers,
549
+ json=payload,
550
+ proxies=self.proxies,
551
+ timeout=20 # 增加超时时间
552
+ )
553
+ if response.status_code == 200:
554
+ try:
555
+ result = response.json()
556
+ task_id = result.get("id")
557
+ if task_id:
558
+ # 更新任务ID为实际分配的ID
559
+ try:
560
+ from .key_manager import key_manager
561
+ # 更新工作中状态的任务ID
562
+ key_manager.release_key(self.auth_token) # 先释放临时ID
563
+ key_manager.mark_key_as_working(self.auth_token, task_id) # 用真实ID重新标记
564
+ if self.DEBUG:
565
+ print(f"已将密钥标记为工作中,任务ID: {task_id}")
566
+ except (ImportError, Exception) as e:
567
+ if self.DEBUG:
568
+ print(f"更新密钥工作状态时发生错误: {str(e)}")
569
+
570
+ return task_id
571
+ else:
572
+ # 任务提交成功但未返回ID,释放密钥
573
+ try:
574
+ from .key_manager import key_manager
575
+ key_manager.release_key(self.auth_token)
576
+ if self.DEBUG:
577
+ print(f"任务提交未返回ID,已释放密钥")
578
+ except (ImportError, Exception) as e:
579
+ if self.DEBUG:
580
+ print(f"释放密钥时发生错误: {str(e)}")
581
+
582
+ # 检查响应中是否有可能表明API密钥问题的信息
583
+ response_text = response.text.lower()
584
+ is_auth_issue = False
585
+ auth_keywords = ["authorization", "auth", "token", "permission", "unauthorized", "credentials", "login"]
586
+
587
+ for keyword in auth_keywords:
588
+ if keyword in response_text:
589
+ is_auth_issue = True
590
+ break
591
+
592
+ if is_auth_issue:
593
+ if self.DEBUG:
594
+ print(f"API响应内容表明可能存在认证问题,尝试切换密钥")
595
+
596
+ try:
597
+ from .key_manager import key_manager
598
+ new_key = key_manager.mark_key_invalid(self.auth_token)
599
+ if new_key:
600
+ self.auth_token = new_key
601
+ if self.DEBUG:
602
+ print(f"已切换到新的API密钥,重试请求")
603
+ # 使用新密钥重试请求
604
+ return self._submit_task(payload, referer)
605
+ except ImportError:
606
+ if self.DEBUG:
607
+ print(f"无法导入key_manager,无法自动切换密钥")
608
+ except Exception as e:
609
+ if self.DEBUG:
610
+ print(f"切换API密钥时发生错误: {str(e)}")
611
+
612
+ if self.DEBUG:
613
+ print(f"提交任务成功,但响应中未找到任务ID。响应: {response.text}")
614
+ return None
615
+ except json.JSONDecodeError:
616
+ # 释放密钥
617
+ try:
618
+ from .key_manager import key_manager
619
+ key_manager.release_key(self.auth_token)
620
+ if self.DEBUG:
621
+ print(f"JSON解析失败,已释放密钥")
622
+ except (ImportError, Exception) as e:
623
+ if self.DEBUG:
624
+ print(f"释放密钥时发生错误: {str(e)}")
625
+
626
+ if self.DEBUG:
627
+ print(f"提交任务成功,但无法解析响应JSON。状态码: {response.status_code}, 响应: {response.text}")
628
+ return None
629
+ else:
630
+ # 释放密钥
631
+ try:
632
+ from .key_manager import key_manager
633
+ key_manager.release_key(self.auth_token)
634
+ if self.DEBUG:
635
+ print(f"请求失败,已释放密钥")
636
+ except (ImportError, Exception) as e:
637
+ if self.DEBUG:
638
+ print(f"释放密钥时发生错误: {str(e)}")
639
+
640
+ if self.DEBUG:
641
+ print(f"提交任务失败,状态码: {response.status_code}")
642
+ if self.DEBUG:
643
+ print(f"请求Payload: {json.dumps(payload)}")
644
+ if self.DEBUG:
645
+ print(f"响应内容: {response.text}")
646
+
647
+ # 特殊处理429错误(太多并发任务)
648
+ if response.status_code == 429:
649
+ response_text = response.text.lower()
650
+ is_concurrent_issue = (
651
+ "concurrent" in response_text or
652
+ "too many" in response_text or
653
+ "wait" in response_text or
654
+ "progress" in response_text
655
+ )
656
+
657
+ if is_concurrent_issue:
658
+ if self.DEBUG:
659
+ print(f"检测到并发限制错误,当前密钥正在处理其他任务,获取新密钥但不禁用当前密钥")
660
+ try:
661
+ from .key_manager import key_manager
662
+ # 不标记为无效,但获取一个新的可用密钥
663
+ new_key = key_manager.get_key()
664
+ if new_key:
665
+ self.auth_token = new_key
666
+ if self.DEBUG:
667
+ print(f"已获取新的API密钥,重试请求")
668
+ # 使用新密钥重试请求
669
+ return self._submit_task(payload, referer)
670
+ else:
671
+ if self.DEBUG:
672
+ print(f"没有可用的备用密钥")
673
+ except (ImportError, Exception) as e:
674
+ if self.DEBUG:
675
+ print(f"获取新密钥时发生错误: {str(e)}")
676
+ return None
677
+
678
+ # 检查是否是认证失败(401/403)或其他可能表明API密钥失效的情况
679
+ response_text = response.text.lower()
680
+ is_auth_issue = (
681
+ response.status_code in [401, 403] or
682
+ "authorization" in response_text or
683
+ "auth" in response_text or
684
+ "token" in response_text or
685
+ "permission" in response_text or
686
+ "unauthorized" in response_text or
687
+ "credentials" in response_text or
688
+ "login" in response_text or
689
+ "invalid" in response_text
690
+ ) and not (
691
+ # 排除并发限制导致的错误
692
+ "concurrent" in response_text or
693
+ "too many" in response_text or
694
+ "wait" in response_text or
695
+ "progress" in response_text
696
+ )
697
+
698
+ if is_auth_issue:
699
+ if self.DEBUG:
700
+ print(f"API密钥可能已失效,尝试切换密钥")
701
+
702
+ try:
703
+ # 导入在这里进行以避免循环导入
704
+ from .key_manager import key_manager
705
+ # 标记当前密钥为无效并获取新密钥
706
+ new_key = key_manager.mark_key_invalid(self.auth_token)
707
+ if new_key:
708
+ self.auth_token = new_key
709
+ if self.DEBUG:
710
+ print(f"已切换到新的API密钥,重试请求")
711
+ # 使用新密钥重试请求
712
+ return self._submit_task(payload, referer)
713
+ except ImportError:
714
+ if self.DEBUG:
715
+ print(f"无法导入key_manager,无法自动切换密钥")
716
+ except Exception as e:
717
+ if self.DEBUG:
718
+ print(f"切换API密钥时发生错误: {str(e)}")
719
+
720
+ return None
721
+ except Exception as e:
722
+ # 确保释放密钥
723
+ try:
724
+ from .key_manager import key_manager
725
+ key_manager.release_key(current_auth_token)
726
+ if self.DEBUG:
727
+ print(f"发生异常,已释放密钥")
728
+ except (ImportError, Exception) as release_err:
729
+ if self.DEBUG:
730
+ print(f"释放密钥时发生错误: {str(release_err)}")
731
+
732
+ if self.DEBUG:
733
+ print(f"提交任务时出错: {str(e)}")
734
+
735
+ # 检查异常信息中是否包含可能的认证问题
736
+ error_str = str(e).lower()
737
+ is_auth_issue = (
738
+ "authorization" in error_str or
739
+ "auth" in error_str or
740
+ "token" in error_str or
741
+ "permission" in error_str or
742
+ "unauthorized" in error_str or
743
+ "credentials" in error_str or
744
+ "login" in error_str
745
+ )
746
+
747
+ if is_auth_issue:
748
+ if self.DEBUG:
749
+ print(f"异常信息表明可能存在API密钥问题,尝试切换密钥")
750
+
751
+ try:
752
+ from .key_manager import key_manager
753
+ new_key = key_manager.mark_key_invalid(self.auth_token)
754
+ if new_key:
755
+ self.auth_token = new_key
756
+ if self.DEBUG:
757
+ print(f"已切换到新的API密钥,重试请求")
758
+ # 使用新密钥重试请求
759
+ return self._submit_task(payload, referer)
760
+ except (ImportError, Exception) as err:
761
+ if self.DEBUG:
762
+ print(f"切换API密钥失败: {str(err)}")
763
+
764
+ return None
765
+
766
+ def _poll_task_status(self, task_id, max_attempts=40, interval=5):
767
+ """
768
+ 轮询检查任务状态,直到完成,返回所有生成的图片URL列表
769
+ """
770
+ # 保存当前使用的密钥,确保最后可以正确释放
771
+ current_auth_token = self.auth_token
772
+
773
+ if self.DEBUG:
774
+ print(f"开始轮询任务 {task_id} 的状态...")
775
+ try:
776
+ for attempt in range(max_attempts):
777
+ try:
778
+ headers = self._get_dynamic_headers(referer="https://sora.chatgpt.com/library") # Polling often happens from library view
779
+ query_url = f"{self.check_url}?limit=10" # 获取最近的任务,减少数据量
780
+ response = self.scraper.get(
781
+ query_url,
782
+ headers=headers,
783
+ proxies=self.proxies,
784
+ timeout=15
785
+ )
786
+ if response.status_code == 200:
787
+ try:
788
+ result = response.json()
789
+ task_responses = result.get("task_responses", [])
790
+ # 查找对应的任务
791
+ for task in task_responses:
792
+ if task.get("id") == task_id:
793
+ status = task.get("status")
794
+ if self.DEBUG:
795
+ print(f" 任务 {task_id} 状态: {status} (尝试 {attempt+1}/{max_attempts})")
796
+ if status == "succeeded":
797
+ # 任务成功完成,释放密钥
798
+ try:
799
+ from .key_manager import key_manager
800
+ key_manager.release_key(current_auth_token)
801
+ if self.DEBUG:
802
+ print(f"任务成功完成,已释放密钥")
803
+ except (ImportError, Exception) as e:
804
+ if self.DEBUG:
805
+ print(f"释放密钥时发生错误: {str(e)}")
806
+
807
+ generations = task.get("generations", [])
808
+ image_urls = []
809
+ if generations:
810
+ for gen in generations:
811
+ url = gen.get("url")
812
+ if url:
813
+ image_urls.append(url)
814
+ if image_urls:
815
+ if self.DEBUG:
816
+ print(f"任务 {task_id} 成功完成!找到 {len(image_urls)} 张图片。")
817
+ return image_urls
818
+ else:
819
+ if self.DEBUG:
820
+ print(f"任务 {task_id} 状态为 succeeded,但在响应中未找到有效的图片URL。")
821
+ if self.DEBUG:
822
+ print(f"任务详情: {json.dumps(task, indent=2)}")
823
+ return "任务成功但未找到图片URL"
824
+ elif status == "failed":
825
+ # 任务失败,释放密钥
826
+ try:
827
+ from .key_manager import key_manager
828
+ key_manager.release_key(current_auth_token)
829
+ if self.DEBUG:
830
+ print(f"任务失败,已释放密钥")
831
+ except (ImportError, Exception) as e:
832
+ if self.DEBUG:
833
+ print(f"释放密钥时发生错误: {str(e)}")
834
+
835
+ failure_reason = task.get("failure_reason", "未知原因")
836
+ if self.DEBUG:
837
+ print(f"任务 {task_id} 失败: {failure_reason}")
838
+ # 检查是否是因为API密钥问题导致的失败
839
+ failure_reason_lower = failure_reason.lower()
840
+ auth_keywords = [
841
+ "authorization", "auth", "token", "permission",
842
+ "unauthorized", "credentials", "login", "invalid"
843
+ ]
844
+ is_auth_issue = any(keyword in failure_reason_lower for keyword in auth_keywords)
845
+
846
+ if is_auth_issue:
847
+ if self.DEBUG:
848
+ print(f"检测到API密钥可能失效,尝试切换密钥")
849
+ try:
850
+ # 导入在这里进行以避免循环导入
851
+ from .key_manager import key_manager
852
+ # 标记当前密钥为无效并获取新密钥
853
+ new_key = key_manager.mark_key_invalid(self.auth_token)
854
+ if new_key:
855
+ self.auth_token = new_key
856
+ if self.DEBUG:
857
+ print(f"已切换到新的API密钥")
858
+ except ImportError:
859
+ if self.DEBUG:
860
+ print(f"无法导入key_manager,无法自动切换密钥")
861
+ except Exception as e:
862
+ if self.DEBUG:
863
+ print(f"切换API密钥时发生错误: {str(e)}")
864
+ return f"任务失败: {failure_reason}"
865
+ elif status in ["rejected", "needs_user_review"]:
866
+ # 任务被拒绝,释放密钥
867
+ try:
868
+ from .key_manager import key_manager
869
+ key_manager.release_key(current_auth_token)
870
+ if self.DEBUG:
871
+ print(f"任务被拒绝,已释放密钥")
872
+ except (ImportError, Exception) as e:
873
+ if self.DEBUG:
874
+ print(f"释放密钥时发生错误: {str(e)}")
875
+
876
+ if self.DEBUG:
877
+ print(f"任务 {task_id} 被拒绝或需要审查: {status}")
878
+ return f"任务被拒绝或需审查: {status}"
879
+ # else status is pending, processing, etc. - continue polling
880
+ break # Found the task, no need to check others in this response
881
+ else:
882
+ # Task ID not found in the recent list, maybe it's older or just submitted
883
+ if self.DEBUG:
884
+ print(f" 未在最近任务列表中找到 {task_id},继续等待... (尝试 {attempt+1}/{max_attempts})")
885
+ except json.JSONDecodeError:
886
+ if self.DEBUG:
887
+ print(f"检查任务状态时无法解析响应JSON。状态码: {response.status_code}, 响应: {response.text}")
888
+ else:
889
+ if self.DEBUG:
890
+ print(f"检查任务状态失败,状态码: {response.status_code}, 响应: {response.text}")
891
+
892
+ # 检查是否是认证失败或其他可能表明API密钥失效的情况
893
+ response_text = response.text.lower()
894
+ is_auth_issue = (
895
+ response.status_code in [401, 403, 429] or
896
+ "authorization" in response_text or
897
+ "auth" in response_text or
898
+ "token" in response_text or
899
+ "permission" in response_text or
900
+ "unauthorized" in response_text or
901
+ "credentials" in response_text or
902
+ "login" in response_text or
903
+ "invalid" in response_text
904
+ )
905
+
906
+ if is_auth_issue:
907
+ if self.DEBUG:
908
+ print(f"API密钥可能已失效,尝试切换密钥")
909
+
910
+ try:
911
+ # 导入在这里进行以避免循环导入
912
+ from .key_manager import key_manager
913
+ # 标记当前密钥为无效并获取新密钥
914
+ new_key = key_manager.mark_key_invalid(self.auth_token)
915
+ if new_key:
916
+ self.auth_token = new_key
917
+ if self.DEBUG:
918
+ print(f"已切换到新的API密钥,重试请求")
919
+ # 使用新密钥继续轮询
920
+ continue
921
+ except ImportError:
922
+ if self.DEBUG:
923
+ print(f"无法导入key_manager,无法自动切换密钥")
924
+ except Exception as e:
925
+ if self.DEBUG:
926
+ print(f"切换API密钥时发生错误: {str(e)}")
927
+
928
+ time.sleep(interval) # 等待一段时间后再次检查
929
+ except Exception as e:
930
+ if self.DEBUG:
931
+ print(f"检查任务状态时出错: {str(e)}")
932
+
933
+ # 检查异常信息中是否包含可能的认证问题
934
+ error_str = str(e).lower()
935
+ is_auth_issue = (
936
+ "authorization" in error_str or
937
+ "auth" in error_str or
938
+ "token" in error_str or
939
+ "permission" in error_str or
940
+ "unauthorized" in error_str or
941
+ "credentials" in error_str or
942
+ "login" in error_str
943
+ )
944
+
945
+ if is_auth_issue:
946
+ if self.DEBUG:
947
+ print(f"异常信息表明可能存在API密钥问题,尝试切换密钥")
948
+
949
+ try:
950
+ from .key_manager import key_manager
951
+ new_key = key_manager.mark_key_invalid(self.auth_token)
952
+ if new_key:
953
+ self.auth_token = new_key
954
+ if self.DEBUG:
955
+ print(f"已切换到新的API密钥,重试请求")
956
+ # 重置当前尝试次数,继续轮询
957
+ continue
958
+ except (ImportError, Exception) as err:
959
+ if self.DEBUG:
960
+ print(f"切换API密钥失败: {str(err)}")
961
+
962
+ # Add a slightly longer delay on error to avoid hammering the server
963
+ time.sleep(interval * 1.5)
964
+
965
+ # 如果达到最大尝试次数,释放密钥
966
+ try:
967
+ from .key_manager import key_manager
968
+ key_manager.release_key(current_auth_token)
969
+ if self.DEBUG:
970
+ print(f"轮询超时,已释放密钥")
971
+ except (ImportError, Exception) as e:
972
+ if self.DEBUG:
973
+ print(f"释放密钥时发生错误: {str(e)}")
974
+
975
+ return f"任务 {task_id} 超时 ({max_attempts * interval}秒),未能获取最终状态"
976
+ except Exception as e:
977
+ # 确保在异常情况下也释放密钥
978
+ try:
979
+ from .key_manager import key_manager
980
+ key_manager.release_key(current_auth_token)
981
+ if self.DEBUG:
982
+ print(f"轮询过程发生异常,已释放密钥")
983
+ except (ImportError, Exception) as release_err:
984
+ if self.DEBUG:
985
+ print(f"释放密钥时发生错误: {str(release_err)}")
986
+
987
+ if self.DEBUG:
988
+ print(f"轮询任务状态时发生未处理的异常: {str(e)}")
989
+ import traceback
990
+ traceback.print_exc()
991
+ return f"轮询任务状态时出错: {str(e)}"
992
+
993
+ def test_connection(self):
994
+ """
995
+ 测试API连接是否有效,仅发送一个轻量级请求
996
+ 返回:
997
+ dict: 包含连接状态信息的字典
998
+ """
999
+ start_time = time.time() # 记录开始时间,用于计算响应时间
1000
+ success = False # 初始化请求结果标识
1001
+
1002
+ try:
1003
+ # 使用简单的GET请求来验证连接和认证
1004
+ headers = self._get_dynamic_headers(referer="https://sora.chatgpt.com/explore")
1005
+ response = self.scraper.get(
1006
+ "https://sora.chatgpt.com/backend/parameters",
1007
+ headers=headers,
1008
+ proxies=self.proxies,
1009
+ timeout=10
1010
+ )
1011
+
1012
+ if response.status_code == 200:
1013
+ result = response.json()
1014
+ # 检查返回的数据中是否含有关键字段,确认API确实有效
1015
+ api_valid = result.get("can_create_images") is not None or "limits_for_images" in result
1016
+
1017
+ if api_valid:
1018
+ success = True # 标记请求成功
1019
+ # 记录请求结果(成功)
1020
+ try:
1021
+ from .key_manager import key_manager
1022
+ response_time = time.time() - start_time
1023
+ key_manager.record_request_result(self.auth_token, success, response_time)
1024
+ except (ImportError, Exception) as e:
1025
+ if self.DEBUG:
1026
+ print(f"记录请求结果失败: {str(e)}")
1027
+
1028
+ return {
1029
+ "status": "success",
1030
+ "message": "API连接测试成功",
1031
+ "data": result
1032
+ }
1033
+ else:
1034
+ # API返回200但数据不符合预期
1035
+ success = False # 标记请求失败
1036
+
1037
+ # 记录请求结果(失败)
1038
+ try:
1039
+ from .key_manager import key_manager
1040
+ response_time = time.time() - start_time
1041
+ key_manager.record_request_result(self.auth_token, success, response_time)
1042
+ except (ImportError, Exception) as e:
1043
+ if self.DEBUG:
1044
+ print(f"记录请求结果失败: {str(e)}")
1045
+
1046
+ return {
1047
+ "status": "error",
1048
+ "message": "API连接测试失败:返回数据格式不符合预期",
1049
+ "response": result
1050
+ }
1051
+ else:
1052
+ # 检查是否是认证失败或其他可能表明API密钥失效的情况
1053
+ response_text = response.text.lower()
1054
+ is_auth_issue = (
1055
+ response.status_code in [401, 403, 429] or
1056
+ "authorization" in response_text or
1057
+ "auth" in response_text or
1058
+ "token" in response_text or
1059
+ "permission" in response_text or
1060
+ "unauthorized" in response_text or
1061
+ "credentials" in response_text or
1062
+ "login" in response_text or
1063
+ "invalid" in response_text
1064
+ )
1065
+
1066
+ success = False # 标记请求失败
1067
+
1068
+ # 记录请求结果(失败)
1069
+ try:
1070
+ from .key_manager import key_manager
1071
+ response_time = time.time() - start_time
1072
+ key_manager.record_request_result(self.auth_token, success, response_time)
1073
+ except (ImportError, Exception) as e:
1074
+ if self.DEBUG:
1075
+ print(f"记录请求结果失败: {str(e)}")
1076
+
1077
+ if is_auth_issue:
1078
+ if self.DEBUG:
1079
+ print(f"API密钥可能已失效,尝试切换密钥")
1080
+
1081
+ try:
1082
+ # 导入在这里进行以避免循环导入
1083
+ from .key_manager import key_manager
1084
+ # 标记当前密钥为无效并获取新密钥
1085
+ new_key = key_manager.mark_key_invalid(self.auth_token)
1086
+ if new_key:
1087
+ self.auth_token = new_key
1088
+ if self.DEBUG:
1089
+ print(f"已切换到新的API密钥,重试连接测试")
1090
+ # 使用新密钥重试
1091
+ return self.test_connection()
1092
+ except ImportError:
1093
+ if self.DEBUG:
1094
+ print(f"无法导入key_manager,无法自动切换密钥")
1095
+ except Exception as e:
1096
+ if self.DEBUG:
1097
+ print(f"切换API密钥时发生错误: {str(e)}")
1098
+
1099
+ return {
1100
+ "status": "error",
1101
+ "message": f"API连接测试失败,状态码: {response.status_code}",
1102
+ "response": response.text
1103
+ }
1104
+ except Exception as e:
1105
+ success = False # 标记请求失败
1106
+
1107
+ # 记录请求结果(异常失败)
1108
+ try:
1109
+ from .key_manager import key_manager
1110
+ response_time = time.time() - start_time
1111
+ key_manager.record_request_result(self.auth_token, success, response_time)
1112
+ except (ImportError, Exception) as err:
1113
+ if self.DEBUG:
1114
+ print(f"记录请求结果失败: {str(err)}")
1115
+
1116
+ # 检查异常信息中是否包含可能的认证问题
1117
+ error_str = str(e).lower()
1118
+ is_auth_issue = (
1119
+ "authorization" in error_str or
1120
+ "auth" in error_str or
1121
+ "token" in error_str or
1122
+ "permission" in error_str or
1123
+ "unauthorized" in error_str or
1124
+ "credentials" in error_str or
1125
+ "login" in error_str
1126
+ )
1127
+
1128
+ if is_auth_issue:
1129
+ if self.DEBUG:
1130
+ print(f"异常信息表明可能存在API密钥问题,尝试切换密钥")
1131
+
1132
+ try:
1133
+ from .key_manager import key_manager
1134
+ new_key = key_manager.mark_key_invalid(self.auth_token)
1135
+ if new_key:
1136
+ self.auth_token = new_key
1137
+ if self.DEBUG:
1138
+ print(f"已切换到新的API密钥,重试连接测试")
1139
+ # 使用新密钥重试
1140
+ return self.test_connection()
1141
+ except (ImportError, Exception) as err:
1142
+ if self.DEBUG:
1143
+ print(f"切换API密钥失败: {str(err)}")
1144
+
1145
+ raise Exception(f"API连接测试失败: {str(e)}")
src/sora_integration.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import concurrent.futures
3
+ from typing import List, Dict, Any, Optional, Union
4
+ import json
5
+ import os
6
+ import base64
7
+ import tempfile
8
+ import uuid
9
+
10
+ # 导入原有的SoraImageGenerator类
11
+ from .sora_generator import SoraImageGenerator
12
+
13
+ class SoraClient:
14
+ def __init__(self, proxy_host=None, proxy_port=None, proxy_user=None, proxy_pass=None, auth_token=None):
15
+ """初始化Sora客户端,使用cloudscraper绕过CF验证"""
16
+ self.generator = SoraImageGenerator(
17
+ proxy_host=proxy_host,
18
+ proxy_port=proxy_port,
19
+ proxy_user=proxy_user,
20
+ proxy_pass=proxy_pass,
21
+ auth_token=auth_token
22
+ )
23
+ # 保存原始的auth_token,用于检测是否已更新
24
+ self.auth_token = auth_token
25
+ # 创建线程池执行器
26
+ self.executor = concurrent.futures.ThreadPoolExecutor(max_workers=10)
27
+
28
+ async def generate_image(self, prompt: str, num_images: int = 1,
29
+ width: int = 720, height: int = 480) -> List[str]:
30
+ """异步包装SoraImageGenerator.generate_image方法"""
31
+ loop = asyncio.get_running_loop()
32
+ # 使用线程池执行同步方法(因为cloudscraper不是异步的)
33
+ result = await loop.run_in_executor(
34
+ self.executor,
35
+ lambda: self.generator.generate_image(prompt, num_images, width, height)
36
+ )
37
+
38
+ # 检查generator中的auth_token是否已经被更新(由自动切换密钥机制)
39
+ if self.generator.auth_token != self.auth_token:
40
+ self.auth_token = self.generator.auth_token
41
+
42
+ if isinstance(result, list):
43
+ return result
44
+ else:
45
+ raise Exception(f"图像生成失败: {result}")
46
+
47
+ async def upload_image(self, image_path: str) -> Dict:
48
+ """异步包装上传图片方法"""
49
+ loop = asyncio.get_running_loop()
50
+ result = await loop.run_in_executor(
51
+ self.executor,
52
+ lambda: self.generator.upload_image(image_path)
53
+ )
54
+
55
+ # 检查generator中的auth_token是否已经被更新
56
+ if self.generator.auth_token != self.auth_token:
57
+ self.auth_token = self.generator.auth_token
58
+
59
+ if isinstance(result, dict) and 'id' in result:
60
+ return result
61
+ else:
62
+ raise Exception(f"图片上传失败: {result}")
63
+
64
+ async def generate_image_remix(self, prompt: str, media_id: str,
65
+ num_images: int = 1) -> List[str]:
66
+ """异步包装remix方法"""
67
+ loop = asyncio.get_running_loop()
68
+
69
+ # 处理可能包含API密钥信息的media_id对象
70
+ if isinstance(media_id, dict) and 'id' in media_id:
71
+ # 如果上传时使用的密钥与当前不同,则先切换密钥
72
+ if 'used_auth_token' in media_id and media_id['used_auth_token'] != self.auth_token:
73
+ self.auth_token = media_id['used_auth_token']
74
+ # 同步更新generator的auth_token
75
+ self.generator.auth_token = self.auth_token
76
+ # 提取实际的media_id
77
+ media_id = media_id['id']
78
+
79
+ result = await loop.run_in_executor(
80
+ self.executor,
81
+ lambda: self.generator.generate_image_remix(prompt, media_id, num_images)
82
+ )
83
+
84
+ # 检查generator中的auth_token是否已经被更新
85
+ if self.generator.auth_token != self.auth_token:
86
+ self.auth_token = self.generator.auth_token
87
+
88
+ if isinstance(result, list):
89
+ return result
90
+ else:
91
+ raise Exception(f"Remix生成失败: {result}")
92
+
93
+ async def test_connection(self) -> Dict:
94
+ """测试API连接是否有效"""
95
+ try:
96
+ # 简单测试上传功能,这个方法会调用API但不会真正上传文件
97
+ loop = asyncio.get_running_loop()
98
+ result = await loop.run_in_executor(
99
+ self.executor,
100
+ lambda: self.generator.test_connection()
101
+ )
102
+
103
+ # 检查generator中的auth_token是否已经被更新
104
+ if self.generator.auth_token != self.auth_token:
105
+ self.auth_token = self.generator.auth_token
106
+
107
+ # 直接返回generator.test_connection的结果,保留所有信息
108
+ return result
109
+ except Exception as e:
110
+ return {"status": "error", "message": f"API连接测试失败: {str(e)}"}
111
+
112
+ def close(self):
113
+ """关闭线程池"""
114
+ self.executor.shutdown(wait=False)
src/static/admin/config.html ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Sora API - 配置管理</title>
7
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
8
+ <link href="css/admin.css" rel="stylesheet">
9
+ </head>
10
+ <body>
11
+ <div class="container">
12
+ <h1 class="my-4">Sora API - 配置管理</h1>
13
+
14
+ <div id="messages" class="mb-4"></div>
15
+
16
+ <div class="card mb-4">
17
+ <div class="card-header">
18
+ <h5 class="mb-0">系统配置</h5>
19
+ </div>
20
+ <div class="card-body">
21
+ <form id="configForm">
22
+ <div class="mb-3">
23
+ <label class="form-label fw-bold">HTTP代理设置</label>
24
+ <div class="row g-3">
25
+ <div class="col-md-6">
26
+ <label for="proxy_host" class="form-label">代理主机</label>
27
+ <input type="text" class="form-control" id="proxy_host" placeholder="例如: 127.0.0.1">
28
+ </div>
29
+ <div class="col-md-6">
30
+ <label for="proxy_port" class="form-label">代理端口</label>
31
+ <input type="text" class="form-control" id="proxy_port" placeholder="例如: 7890">
32
+ </div>
33
+ </div>
34
+ <div class="mt-3">
35
+ <label class="form-label">代理认证 (可选)</label>
36
+ <div class="row g-3">
37
+ <div class="col-md-6">
38
+ <label for="proxy_user" class="form-label">用户名</label>
39
+ <input type="text" class="form-control" id="proxy_user" placeholder="代理用户名">
40
+ </div>
41
+ <div class="col-md-6">
42
+ <label for="proxy_pass" class="form-label">密码</label>
43
+ <input type="password" class="form-control" id="proxy_pass" placeholder="代理密码">
44
+ </div>
45
+ </div>
46
+ </div>
47
+ <div class="form-text text-muted mt-2">如需使用代理访问Sora API,请填写以上信息,留空表示不使用代理</div>
48
+ </div>
49
+
50
+ <hr>
51
+
52
+ <div class="mb-3">
53
+ <label class="form-label fw-bold">图片本地化设置</label>
54
+ <div class="form-check form-switch">
55
+ <input class="form-check-input" type="checkbox" id="image_localization">
56
+ <label class="form-check-label" for="image_localization">启用图片本地化</label>
57
+ </div>
58
+ <div class="form-text text-muted">启用后,Sora生成的图片将被下载并保存到本地服务器,避免客户端无法访问外部链接的问题</div>
59
+ </div>
60
+
61
+ <div class="mb-3">
62
+ <label for="image_save_dir" class="form-label">图片保存目录</label>
63
+ <input type="text" class="form-control" id="image_save_dir" placeholder="src/static/images">
64
+ <div class="form-text text-muted">相对于工作目录的路径,必须确保目录存在且有写入权限</div>
65
+ </div>
66
+
67
+ <button type="button" id="saveConfig" class="btn btn-primary">保存配置</button>
68
+ </form>
69
+ </div>
70
+ </div>
71
+
72
+ <div class="card mb-4">
73
+ <div class="card-header">
74
+ <h5 class="mb-0">帮助说明</h5>
75
+ </div>
76
+ <div class="card-body">
77
+ <p><strong>HTTP代理设置:</strong></p>
78
+ <ul>
79
+ <li>如果您的服务器无法直接访问Sora API,可以配置HTTP代理</li>
80
+ <li>代理认证是可选的,如果您的代理服务器不需要认证,请留空</li>
81
+ <li>在Docker环境中,代理主机通常设置为<code>host.docker.internal</code>而不是<code>127.0.0.1</code></li>
82
+ </ul>
83
+
84
+ <p><strong>图片本地化功能说明:</strong></p>
85
+ <ul>
86
+ <li>本功能解决Sora图片URL无法访问的问题,将生成的图片保存到本地服务器</li>
87
+ <li>启用后,系统会自动下载Sora返回的图片并存储到指定目录</li>
88
+ <li>API返回的图片链接将替换为本地URL地址</li>
89
+ <li>确保���置的保存目录有足够的磁盘空间和访问权限</li>
90
+ </ul>
91
+ <p class="text-warning"><strong>注意:</strong> 配置变更会立即生效,但不会影响已经生成的图片</p>
92
+ </div>
93
+ </div>
94
+ </div>
95
+
96
+ <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
97
+ <script src="js/config.js"></script>
98
+ </body>
99
+ </html>
src/static/admin/css/style.css ADDED
@@ -0,0 +1,424 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* 全局样式 */
2
+ :root {
3
+ --sidebar-width: 250px;
4
+ --sidebar-bg: #343a40;
5
+ --sidebar-color: #fff;
6
+ --primary-color: #0d6efd;
7
+ --success-color: #198754;
8
+ --warning-color: #ffc107;
9
+ --danger-color: #dc3545;
10
+ --info-color: #0dcaf0;
11
+ --transition-speed: 0.3s;
12
+ }
13
+
14
+ body {
15
+ font-family: 'Segoe UI', 'Microsoft YaHei', 'Arial', sans-serif;
16
+ background-color: #f8f9fa;
17
+ overflow-x: hidden;
18
+ margin: 0;
19
+ padding: 0;
20
+ min-height: 100vh;
21
+ }
22
+
23
+ /* 登录页面样式 */
24
+ #login-container, .login-container {
25
+ background-color: #f8f9fa;
26
+ position: fixed;
27
+ top: 0;
28
+ left: 0;
29
+ width: 100%;
30
+ height: 100vh;
31
+ z-index: 1050;
32
+ align-items: center;
33
+ justify-content: center;
34
+ }
35
+
36
+ #login-container .card {
37
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
38
+ border-radius: 15px;
39
+ overflow: hidden;
40
+ width: 400px;
41
+ max-width: 90%;
42
+ }
43
+
44
+ #login-container .card-header {
45
+ background-color: var(--primary-color);
46
+ padding: 1.5rem 1rem;
47
+ }
48
+
49
+ #login-container .bi-shield-lock {
50
+ color: var(--primary-color);
51
+ }
52
+
53
+ #login-form .btn-primary {
54
+ background-color: var(--primary-color);
55
+ border-color: var(--primary-color);
56
+ padding: 0.6rem 1.2rem;
57
+ font-weight: 500;
58
+ }
59
+
60
+ #login-form .btn-primary:hover {
61
+ background-color: #0b5ed7;
62
+ border-color: #0a58ca;
63
+ }
64
+
65
+ #login-error {
66
+ border-radius: 8px;
67
+ }
68
+
69
+ /* 页面布局 */
70
+ .wrapper {
71
+ display: flex;
72
+ width: 100%;
73
+ min-height: 100vh;
74
+ align-items: stretch;
75
+ position: relative;
76
+ }
77
+
78
+ /* 侧边栏样式 */
79
+ #sidebar {
80
+ min-width: 250px;
81
+ max-width: 250px;
82
+ background: var(--sidebar-bg);
83
+ color: var(--sidebar-color);
84
+ transition: all var(--transition-speed);
85
+ height: 100vh;
86
+ position: fixed;
87
+ z-index: 999;
88
+ box-shadow: 0 0 15px rgba(0, 0, 0, 0.1);
89
+ top: 0;
90
+ left: 0;
91
+ }
92
+
93
+ #sidebar.active {
94
+ margin-left: -250px;
95
+ }
96
+
97
+ #sidebar .sidebar-header {
98
+ padding: 20px;
99
+ background: #212529;
100
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
101
+ }
102
+
103
+ #sidebar ul.components {
104
+ padding: 20px 0;
105
+ height: calc(100vh - 140px);
106
+ overflow-y: auto;
107
+ }
108
+
109
+ #sidebar ul li a {
110
+ padding: 12px 20px;
111
+ font-size: 1.1em;
112
+ display: block;
113
+ color: #fff;
114
+ text-decoration: none;
115
+ transition: all 0.3s;
116
+ border-left: 3px solid transparent;
117
+ }
118
+
119
+ #sidebar ul li a:hover {
120
+ background: #495057;
121
+ border-left: 3px solid var(--primary-color);
122
+ }
123
+
124
+ #sidebar ul li.active > a {
125
+ background: var(--primary-color);
126
+ color: white;
127
+ border-left: 3px solid #fff;
128
+ }
129
+
130
+ #sidebar ul li.sidebar-footer {
131
+ margin-top: auto;
132
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
133
+ position: absolute;
134
+ bottom: 0;
135
+ width: 100%;
136
+ }
137
+
138
+ #sidebar ul li.sidebar-footer a {
139
+ color: rgba(255, 255, 255, 0.8);
140
+ }
141
+
142
+ #sidebar ul li.sidebar-footer a:hover {
143
+ background: rgba(220, 53, 69, 0.6);
144
+ color: white;
145
+ }
146
+
147
+ /* 主内容区样式 */
148
+ #content {
149
+ width: calc(100% - 250px);
150
+ padding: 0;
151
+ min-height: 100vh;
152
+ transition: all 0.3s;
153
+ position: absolute;
154
+ right: 0;
155
+ overflow-x: hidden;
156
+ }
157
+
158
+ #content.active {
159
+ width: 100%;
160
+ }
161
+
162
+ /* 内容页面样式 */
163
+ .content-page {
164
+ display: none;
165
+ padding: 0 20px 30px;
166
+ }
167
+
168
+ .content-page.active {
169
+ display: block;
170
+ animation: fadeIn 0.3s;
171
+ }
172
+
173
+ /* 表格样式 */
174
+ .table th {
175
+ font-weight: 600;
176
+ border-top: none;
177
+ white-space: nowrap;
178
+ }
179
+
180
+ .table td {
181
+ vertical-align: middle;
182
+ }
183
+
184
+ /* 状态标签样式 */
185
+ .status-badge {
186
+ display: inline-block;
187
+ padding: 0.25em 0.6em;
188
+ font-size: 85%;
189
+ font-weight: 600;
190
+ line-height: 1;
191
+ text-align: center;
192
+ white-space: nowrap;
193
+ vertical-align: baseline;
194
+ border-radius: 0.25rem;
195
+ cursor: help;
196
+ }
197
+
198
+ .status-enabled {
199
+ background-color: var(--success-color);
200
+ color: white;
201
+ }
202
+
203
+ .status-disabled {
204
+ background-color: var(--danger-color);
205
+ color: white;
206
+ }
207
+
208
+ .status-temp-disabled {
209
+ background-color: var(--warning-color);
210
+ color: black;
211
+ position: relative;
212
+ }
213
+
214
+ /* 为临时禁用状态的提示信息添加样式 */
215
+ .small.text-muted {
216
+ font-size: 0.75rem;
217
+ display: block;
218
+ margin-top: 3px;
219
+ }
220
+
221
+ /* 让状态标签支持鼠标悬停提示 */
222
+ .status-badge:hover {
223
+ background-color: #ffd700;
224
+ color: black;
225
+ }
226
+
227
+ /* 响应式调整 */
228
+ @media (max-width: 768px) {
229
+ #sidebar {
230
+ margin-left: -250px;
231
+ }
232
+ #sidebar.active {
233
+ margin-left: 0;
234
+ }
235
+ #content {
236
+ width: 100%;
237
+ }
238
+ #content.active {
239
+ width: calc(100% - 250px);
240
+ }
241
+ #sidebarCollapse {
242
+ display: block;
243
+ }
244
+ .navbar .container-fluid {
245
+ padding-left: 10px;
246
+ padding-right: 10px;
247
+ }
248
+ }
249
+
250
+ /* 卡片样式美化 */
251
+ .card {
252
+ border: none;
253
+ border-radius: 10px;
254
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
255
+ margin-bottom: 20px;
256
+ overflow: hidden;
257
+ }
258
+
259
+ .card-header {
260
+ background-color: #fff;
261
+ border-bottom: 1px solid rgba(0, 0, 0, 0.05);
262
+ padding: 15px 20px;
263
+ font-weight: 500;
264
+ }
265
+
266
+ .card-body {
267
+ padding: 20px;
268
+ }
269
+
270
+ /* 按钮样式 */
271
+ .btn-primary {
272
+ background-color: var(--primary-color);
273
+ border-color: var(--primary-color);
274
+ }
275
+
276
+ .btn-primary:hover {
277
+ background-color: #0b5ed7;
278
+ border-color: #0a58ca;
279
+ }
280
+
281
+ .btn-success {
282
+ background-color: var(--success-color);
283
+ border-color: var(--success-color);
284
+ }
285
+
286
+ .btn-danger {
287
+ background-color: var(--danger-color);
288
+ border-color: var(--danger-color);
289
+ }
290
+
291
+ /* 表单元素样式 */
292
+ .form-control:focus {
293
+ border-color: var(--primary-color);
294
+ box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25);
295
+ }
296
+
297
+ /* 操作按钮样式 */
298
+ .action-btn {
299
+ margin-right: 5px;
300
+ display: inline-flex;
301
+ align-items: center;
302
+ justify-content: center;
303
+ width: 2.2rem;
304
+ height: 2.2rem;
305
+ padding: 0;
306
+ border-radius: 50%;
307
+ }
308
+
309
+ .action-btn i {
310
+ font-size: 0.9rem;
311
+ }
312
+
313
+ /* 密钥值样式 */
314
+ .key-value {
315
+ max-width: 180px;
316
+ overflow: hidden;
317
+ text-overflow: ellipsis;
318
+ white-space: nowrap;
319
+ font-family: monospace;
320
+ font-size: 0.9rem;
321
+ }
322
+
323
+ /* 统计卡片样式 */
324
+ .card.bg-primary, .card.bg-success, .card.bg-danger, .card.bg-info {
325
+ box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
326
+ transition: transform 0.3s ease;
327
+ }
328
+
329
+ .card.bg-primary:hover, .card.bg-success:hover, .card.bg-danger:hover, .card.bg-info:hover {
330
+ transform: translateY(-5px);
331
+ }
332
+
333
+ .card.bg-primary .card-title, .card.bg-success .card-title,
334
+ .card.bg-danger .card-title, .card.bg-info .card-title {
335
+ font-size: 1rem;
336
+ opacity: 0.9;
337
+ }
338
+
339
+ .card.bg-primary .card-text, .card.bg-success .card-text,
340
+ .card.bg-danger .card-text, .card.bg-info .card-text {
341
+ font-size: 1.8rem;
342
+ font-weight: 600;
343
+ }
344
+
345
+ /* 图表容器 */
346
+ canvas {
347
+ width: 100% !important;
348
+ height: 300px !important;
349
+ }
350
+
351
+ /* 导航栏样式 */
352
+ .navbar {
353
+ box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
354
+ background-color: #fff !important;
355
+ }
356
+
357
+ /* 管理员密钥显示 */
358
+ #admin-key-display {
359
+ font-size: 0.8rem;
360
+ font-family: monospace;
361
+ }
362
+
363
+ /* Toast通知样式 */
364
+ .toast {
365
+ border-radius: 8px;
366
+ box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
367
+ }
368
+
369
+ /* 动画效果 */
370
+ @keyframes fadeIn {
371
+ from { opacity: 0; }
372
+ to { opacity: 1; }
373
+ }
374
+
375
+ /* 最近的API密钥列表样式 */
376
+ #recent-keys-list .list-group-item {
377
+ padding: 0.75rem 1rem;
378
+ border-left: none;
379
+ border-right: none;
380
+ }
381
+
382
+ #recent-keys-list .list-group-item:hover {
383
+ background-color: rgba(13, 110, 253, 0.05);
384
+ }
385
+
386
+ /* 模态框样式 */
387
+ .modal-content {
388
+ border-radius: 12px;
389
+ border: none;
390
+ box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
391
+ overflow: hidden;
392
+ }
393
+
394
+ .modal-header {
395
+ border-bottom: 1px solid rgba(0, 0, 0, 0.05);
396
+ }
397
+
398
+ .modal-footer {
399
+ border-top: 1px solid rgba(0, 0, 0, 0.05);
400
+ }
401
+
402
+ /* 分页样式 */
403
+ .pagination {
404
+ margin-bottom: 0;
405
+ }
406
+
407
+ .page-link {
408
+ color: var(--primary-color);
409
+ border-radius: 4px;
410
+ margin: 0 2px;
411
+ }
412
+
413
+ .page-item.active .page-link {
414
+ background-color: var(--primary-color);
415
+ border-color: var(--primary-color);
416
+ }
417
+
418
+ /* 搜索框样式 */
419
+ #search-keys {
420
+ min-width: 250px;
421
+ border-radius: 20px;
422
+ padding-left: 15px;
423
+ padding-right: 15px;
424
+ }
src/static/admin/index.html ADDED
@@ -0,0 +1,512 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Sora API - 管理控制台</title>
7
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
8
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css">
9
+ <link rel="stylesheet" href="/static/admin/css/style.css">
10
+ </head>
11
+ <body>
12
+ <!-- 登录页面 -->
13
+ <div id="login-container" style="display: flex;" class="login-container">
14
+ <div class="card shadow-lg">
15
+ <div class="card-header bg-primary text-white text-center py-3">
16
+ <h4>Sora API 管理控制台</h4>
17
+ </div>
18
+ <div class="card-body p-4">
19
+ <div class="text-center mb-4">
20
+ <i class="bi bi-shield-lock" style="font-size: 3rem;"></i>
21
+ <h5 class="mt-2">管理员登录</h5>
22
+ </div>
23
+ <form id="login-form">
24
+ <div class="mb-3">
25
+ <label for="admin-key-input" class="form-label">管理员密钥</label>
26
+ <input type="password" class="form-control" id="admin-key-input" required>
27
+ <div class="form-text">请输入管理员密钥以访问管理控制台</div>
28
+ </div>
29
+ <div class="d-grid gap-2">
30
+ <button type="submit" class="btn btn-primary">登录</button>
31
+ </div>
32
+ </form>
33
+ <div id="login-error" class="alert alert-danger mt-3" style="display: none;"></div>
34
+ </div>
35
+ </div>
36
+ </div>
37
+
38
+ <!-- 管理控制台 -->
39
+ <div id="admin-panel" style="display: none;" class="wrapper">
40
+ <!-- 侧边栏导航 -->
41
+ <nav id="sidebar">
42
+ <div class="sidebar-header d-flex align-items-center">
43
+ <h3 class="mb-0">Sora API</h3>
44
+ </div>
45
+
46
+ <ul class="list-unstyled components">
47
+ <li class="active">
48
+ <a href="#" data-page="dashboard"><i class="bi bi-speedometer2 me-2"></i>仪表盘</a>
49
+ </li>
50
+ <li>
51
+ <a href="#" data-page="keys"><i class="bi bi-key-fill me-2"></i>API密钥管理</a>
52
+ </li>
53
+ <li>
54
+ <a href="#" data-page="stats"><i class="bi bi-graph-up me-2"></i>使用统计</a>
55
+ </li>
56
+ <li>
57
+ <a href="#" data-page="settings"><i class="bi bi-gear-fill me-2"></i>系统设置</a>
58
+ </li>
59
+ <li class="sidebar-footer">
60
+ <a href="#" id="logout-btn"><i class="bi bi-box-arrow-right me-2"></i>退出登录</a>
61
+ </li>
62
+ </ul>
63
+ </nav>
64
+
65
+ <!-- 页面内容区 -->
66
+ <div id="content">
67
+ <!-- 顶部导航栏 -->
68
+ <nav class="navbar navbar-expand-lg navbar-light bg-light shadow-sm mb-4">
69
+ <div class="container-fluid">
70
+ <button type="button" id="sidebarCollapse" class="btn btn-primary">
71
+ <i class="bi bi-list"></i>
72
+ </button>
73
+ <div class="ms-3 d-none d-md-block">
74
+ <h5 class="mb-0" id="current-page-title">仪表盘</h5>
75
+ </div>
76
+ <div class="ms-auto d-flex align-items-center">
77
+ <span class="badge bg-primary me-3" id="admin-key-display"></span>
78
+ <span class="text-muted d-none d-md-block">欢迎回来,管理员</span>
79
+ </div>
80
+ </div>
81
+ </nav>
82
+
83
+ <!-- 仪表盘页面 -->
84
+ <div id="dashboard-page" class="content-page active container-fluid">
85
+ <div class="row mb-4">
86
+ <div class="col-12">
87
+ <h4 class="mb-4">系统概览</h4>
88
+ </div>
89
+ <div class="col-md-6 col-lg-3 mb-4">
90
+ <div class="card bg-primary text-white h-100">
91
+ <div class="card-body">
92
+ <h5 class="card-title">总API密钥</h5>
93
+ <h3 id="total-keys" class="card-text">--</h3>
94
+ </div>
95
+ </div>
96
+ </div>
97
+ <div class="col-md-6 col-lg-3 mb-4">
98
+ <div class="card bg-success text-white h-100">
99
+ <div class="card-body">
100
+ <h5 class="card-title">激活密钥</h5>
101
+ <h3 id="active-keys" class="card-text">--</h3>
102
+ </div>
103
+ </div>
104
+ </div>
105
+ <div class="col-md-6 col-lg-3 mb-4">
106
+ <div class="card bg-danger text-white h-100">
107
+ <div class="card-body">
108
+ <h5 class="card-title">禁用密钥</h5>
109
+ <h3 id="disabled-keys" class="card-text">--</h3>
110
+ </div>
111
+ </div>
112
+ </div>
113
+ <div class="col-md-6 col-lg-3 mb-4">
114
+ <div class="card bg-info text-white h-100">
115
+ <div class="card-body">
116
+ <h5 class="card-title">今日请求</h5>
117
+ <h3 id="today-requests" class="card-text">--</h3>
118
+ </div>
119
+ </div>
120
+ </div>
121
+ </div>
122
+
123
+ <div class="row">
124
+ <div class="col-lg-8 mb-4">
125
+ <div class="card h-100">
126
+ <div class="card-header">
127
+ <h5 class="card-title mb-0">近期请求趋势</h5>
128
+ </div>
129
+ <div class="card-body">
130
+ <canvas id="dashboard-requests-chart"></canvas>
131
+ </div>
132
+ </div>
133
+ </div>
134
+ <div class="col-lg-4 mb-4">
135
+ <div class="card h-100">
136
+ <div class="card-header">
137
+ <h5 class="card-title mb-0">最新API密钥</h5>
138
+ </div>
139
+ <div class="card-body p-0">
140
+ <div class="list-group list-group-flush" id="recent-keys-list">
141
+ <!-- JS动态加载 -->
142
+ </div>
143
+ </div>
144
+ <div class="card-footer text-end">
145
+ <a href="#" class="btn btn-sm btn-primary" data-page="keys">查看全部</a>
146
+ </div>
147
+ </div>
148
+ </div>
149
+ </div>
150
+ </div>
151
+
152
+ <!-- API密钥管理页面 -->
153
+ <div id="keys-page" class="content-page container-fluid">
154
+ <div class="row mb-4">
155
+ <div class="col-12">
156
+ <div class="card">
157
+ <div class="card-body">
158
+ <div class="d-flex justify-content-between flex-wrap mb-3">
159
+ <div class="mb-2">
160
+ <button id="add-key-btn" class="btn btn-primary me-2">
161
+ <i class="bi bi-plus-circle me-1"></i> 添加密钥
162
+ </button>
163
+ <div class="btn-group">
164
+ <button class="btn btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">
165
+ 批量操作
166
+ </button>
167
+ <ul class="dropdown-menu">
168
+ <li><a class="dropdown-item batch-action" data-action="enable" href="#">启用选中</a></li>
169
+ <li><a class="dropdown-item batch-action" data-action="disable" href="#">禁用选中</a></li>
170
+ <li><hr class="dropdown-divider"></li>
171
+ <li><a class="dropdown-item batch-action text-danger" data-action="delete" href="#">删除选中</a></li>
172
+ </ul>
173
+ </div>
174
+ <button id="import-keys-btn" class="btn btn-outline-primary ms-2">
175
+ <i class="bi bi-upload me-1"></i> 批量导入
176
+ </button>
177
+ </div>
178
+ <div class="mb-2">
179
+ <input type="text" id="search-keys" class="form-control" placeholder="搜索密钥...">
180
+ </div>
181
+ </div>
182
+
183
+ <div class="table-responsive">
184
+ <table class="table table-hover">
185
+ <thead>
186
+ <tr>
187
+ <th><input type="checkbox" id="select-all"></th>
188
+ <th>名称</th>
189
+ <th>密钥值</th>
190
+ <th>状��</th>
191
+ <th>权重</th>
192
+ <th>速率限制</th>
193
+ <th>创建时间</th>
194
+ <th>最后使用</th>
195
+ <th>操作</th>
196
+ </tr>
197
+ </thead>
198
+ <tbody id="keys-table-body">
199
+ <!-- JS动态加载 -->
200
+ </tbody>
201
+ </table>
202
+ </div>
203
+
204
+ <div id="pagination" class="d-flex justify-content-center mt-4">
205
+ <!-- JS动态加载 -->
206
+ </div>
207
+ </div>
208
+ </div>
209
+ </div>
210
+ </div>
211
+ </div>
212
+
213
+ <!-- 使用统计页面 -->
214
+ <div id="stats-page" class="content-page container-fluid">
215
+ <div class="row">
216
+ <div class="col-12">
217
+ <h4 class="mb-4">使用统计</h4>
218
+ </div>
219
+ <div class="col-md-6 col-lg-3 mb-4">
220
+ <div class="card bg-primary text-white">
221
+ <div class="card-body">
222
+ <h5 class="card-title">总请求次数</h5>
223
+ <h3 id="total-requests" class="card-text">--</h3>
224
+ </div>
225
+ </div>
226
+ </div>
227
+ <div class="col-md-6 col-lg-3 mb-4">
228
+ <div class="card bg-success text-white">
229
+ <div class="card-body">
230
+ <h5 class="card-title">成功请求</h5>
231
+ <h3 id="successful-requests" class="card-text">--</h3>
232
+ </div>
233
+ </div>
234
+ </div>
235
+ <div class="col-md-6 col-lg-3 mb-4">
236
+ <div class="card bg-danger text-white">
237
+ <div class="card-body">
238
+ <h5 class="card-title">失败请求</h5>
239
+ <h3 id="failed-requests" class="card-text">--</h3>
240
+ </div>
241
+ </div>
242
+ </div>
243
+ <div class="col-md-6 col-lg-3 mb-4">
244
+ <div class="card bg-info text-white">
245
+ <div class="card-body">
246
+ <h5 class="card-title">成功率</h5>
247
+ <h3 id="success-rate" class="card-text">--</h3>
248
+ </div>
249
+ </div>
250
+ </div>
251
+ </div>
252
+
253
+ <div class="row">
254
+ <!-- 请求趋势图 -->
255
+ <div class="col-lg-8 mb-4">
256
+ <div class="card">
257
+ <div class="card-header">
258
+ <h5 class="card-title mb-0">请求趋势</h5>
259
+ </div>
260
+ <div class="card-body">
261
+ <canvas id="requests-chart"></canvas>
262
+ </div>
263
+ </div>
264
+ </div>
265
+
266
+ <!-- 密钥使用分布 -->
267
+ <div class="col-lg-4 mb-4">
268
+ <div class="card">
269
+ <div class="card-header">
270
+ <h5 class="card-title mb-0">密钥使用分布</h5>
271
+ </div>
272
+ <div class="card-body">
273
+ <canvas id="keys-usage-chart"></canvas>
274
+ </div>
275
+ </div>
276
+ </div>
277
+ </div>
278
+ </div>
279
+
280
+ <!-- 设置页面 -->
281
+ <div id="settings-page" class="content-page container-fluid">
282
+ <div class="row">
283
+ <div class="col-12">
284
+ <h4 class="mb-4">系统设置</h4>
285
+ </div>
286
+ <div class="col-md-6 mb-4">
287
+ <div class="card">
288
+ <div class="card-header">
289
+ <h5 class="card-title mb-0">管理员密钥</h5>
290
+ </div>
291
+ <div class="card-body">
292
+ <div class="mb-3">
293
+ <label for="admin-key" class="form-label">管理员密钥</label>
294
+ <div class="input-group">
295
+ <input type="password" class="form-control" id="admin-key" readonly>
296
+ <button class="btn btn-outline-secondary" type="button" id="show-admin-key">
297
+ <i class="bi bi-eye"></i>
298
+ </button>
299
+ <button class="btn btn-outline-secondary" type="button" id="copy-admin-key">
300
+ <i class="bi bi-clipboard"></i>
301
+ </button>
302
+ </div>
303
+ <div class="form-text">此密钥用于访问管理界面,请妥善保管</div>
304
+ </div>
305
+ </div>
306
+ </div>
307
+ </div>
308
+
309
+ <div class="col-md-6 mb-4">
310
+ <div class="card">
311
+ <div class="card-header">
312
+ <h5 class="card-title mb-0">系统配置</h5>
313
+ </div>
314
+ <div class="card-body">
315
+ <form id="settings-form">
316
+ <div class="mb-3">
317
+ <label for="proxy-host" class="form-label">代理主机</label>
318
+ <input type="text" class="form-control" id="proxy-host">
319
+ </div>
320
+ <div class="mb-3">
321
+ <label for="proxy-port" class="form-label">代理端口</label>
322
+ <input type="text" class="form-control" id="proxy-port">
323
+ </div>
324
+
325
+ <div class="mb-3">
326
+ <label for="proxy-user" class="form-label">代理用户名</label>
327
+ <input type="text" class="form-control" id="proxy-user">
328
+ </div>
329
+
330
+ <div class="mb-3">
331
+ <label for="proxy-pass" class="form-label">代理密码</label>
332
+ <input type="password" class="form-control" id="proxy-pass">
333
+ <div class="form-text">如果代理服务器需要认证,请填写用户名和密码</div>
334
+ </div>
335
+
336
+ <hr>
337
+ <h6 class="mb-3">图片本地化设置</h6>
338
+
339
+ <div class="mb-3 form-check">
340
+ <input type="checkbox" class="form-check-input" id="image-localization">
341
+ <label class="form-check-label" for="image-localization">启用图片本地化</label>
342
+ <div class="form-text">启用后,Sora生成的图片将被下载并保存到本地服务器,避免客户端无法访问外部链接</div>
343
+ </div>
344
+
345
+ <div class="mb-3">
346
+ <label for="base-url" class="form-label">Base URL</label>
347
+ <input type="text" class="form-control" id="base-url" placeholder="http://example.com">
348
+ <div class="form-text">设置访问本地化图片的基础URL,例如:http://example.com 或 https://your-domain.com</div>
349
+ </div>
350
+
351
+ <div class="mb-3">
352
+ <label for="image-save-dir" class="form-label">图片保存目录</label>
353
+ <input type="text" class="form-control" id="image-save-dir" placeholder="src/static/images">
354
+ <div class="form-text">相对于工作目录的路径,必须确保目录存在且有写入权限</div>
355
+ </div>
356
+
357
+ <button type="submit" class="btn btn-primary">保存设置</button>
358
+ </form>
359
+ </div>
360
+ </div>
361
+ </div>
362
+ </div>
363
+ </div>
364
+ </div>
365
+ </div>
366
+
367
+ <!-- 添加/编辑密钥对话框 -->
368
+ <div class="modal fade" id="keyModal" tabindex="-1">
369
+ <div class="modal-dialog">
370
+ <div class="modal-content">
371
+ <div class="modal-header">
372
+ <h5 class="modal-title" id="keyModalLabel">添加新密钥</h5>
373
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
374
+ </div>
375
+ <div class="modal-body">
376
+ <form id="key-form">
377
+ <input type="hidden" id="key-id">
378
+ <div class="mb-3">
379
+ <label for="key-name" class="form-label">密钥名称</label>
380
+ <input type="text" class="form-control" id="key-name" required>
381
+ </div>
382
+ <div class="mb-3">
383
+ <label for="key-value" class="form-label">密钥值 (Bearer Token)</label>
384
+ <input type="text" class="form-control" id="key-value" required>
385
+ <div class="form-text">输入OpenAI格式的API密钥,无需添加"Bearer "前缀,系统会自动处理</div>
386
+ </div>
387
+ <div class="mb-3">
388
+ <label for="key-weight" class="form-label">权重 (1-10)</label>
389
+ <input type="number" class="form-control" id="key-weight" min="1" max="10" value="1">
390
+ <div class="form-text">权重越高,被选中的概率越大</div>
391
+ </div>
392
+ <div class="mb-3">
393
+ <label for="key-rate-limit" class="form-label">速率限制 (每分钟请求数)</label>
394
+ <input type="number" class="form-control" id="key-rate-limit" min="1" value="60">
395
+ </div>
396
+ <div class="mb-3 form-check">
397
+ <input type="checkbox" class="form-check-input" id="key-enabled" checked>
398
+ <label class="form-check-label" for="key-enabled">启用</label>
399
+ </div>
400
+ <div class="mb-3">
401
+ <label for="key-notes" class="form-label">备注</label>
402
+ <textarea class="form-control" id="key-notes" rows="2"></textarea>
403
+ </div>
404
+ </form>
405
+ </div>
406
+ <div class="modal-footer">
407
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
408
+ <button type="button" class="btn btn-primary" id="test-key-btn">测试连接</button>
409
+ <button type="button" class="btn btn-success" id="save-key-btn">保存</button>
410
+ </div>
411
+ </div>
412
+ </div>
413
+ </div>
414
+
415
+ <!-- 确认对话框 -->
416
+ <div class="modal fade" id="confirmModal" tabindex="-1">
417
+ <div class="modal-dialog">
418
+ <div class="modal-content">
419
+ <div class="modal-header">
420
+ <h5 class="modal-title" id="confirmModalLabel">确认操作</h5>
421
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
422
+ </div>
423
+ <div class="modal-body" id="confirm-message">
424
+ 确定要执行此操作吗?
425
+ </div>
426
+ <div class="modal-footer">
427
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
428
+ <button type="button" class="btn btn-danger" id="confirm-btn">确认</button>
429
+ </div>
430
+ </div>
431
+ </div>
432
+ </div>
433
+
434
+ <!-- 批量导入密钥对话框 -->
435
+ <div class="modal fade" id="importKeysModal" tabindex="-1">
436
+ <div class="modal-dialog modal-lg">
437
+ <div class="modal-content">
438
+ <div class="modal-header">
439
+ <h5 class="modal-title">批量导入API密钥</h5>
440
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
441
+ </div>
442
+ <div class="modal-body">
443
+ <ul class="nav nav-tabs" id="importTabs" role="tablist">
444
+ <li class="nav-item" role="presentation">
445
+ <button class="nav-link active" id="paste-tab" data-bs-toggle="tab" data-bs-target="#paste-content" type="button" role="tab">粘贴内容</button>
446
+ </li>
447
+ <li class="nav-item" role="presentation">
448
+ <button class="nav-link" id="file-tab" data-bs-toggle="tab" data-bs-target="#file-upload" type="button" role="tab">上传文件</button>
449
+ </li>
450
+ </ul>
451
+ <div class="tab-content mt-3" id="importTabsContent">
452
+ <div class="tab-pane fade show active" id="paste-content" role="tabpanel">
453
+ <div class="mb-3">
454
+ <label for="keys-text" class="form-label">API密钥列表</label>
455
+ <textarea class="form-control" id="keys-text" rows="10" placeholder="支持两种格式:&#10;1. 每行一个密钥&#10;2. 每行格式:[名称],[API密钥],[权重(可选)],[速率限制(可选)]&#10;&#10;例如:&#10;sk-abcdefg123456&#10;我的密钥2,sk-hijklmn789012,5,100"></textarea>
456
+ </div>
457
+ </div>
458
+ <div class="tab-pane fade" id="file-upload" role="tabpanel">
459
+ <div class="mb-3">
460
+ <label for="keys-file" class="form-label">选择文件</label>
461
+ <input class="form-control" type="file" id="keys-file" accept=".txt,.csv">
462
+ <div class="form-text">支持TXT或CSV文件,每行格式同上</div>
463
+ </div>
464
+ </div>
465
+ </div>
466
+ <div class="form-check mt-3">
467
+ <input class="form-check-input" type="checkbox" id="auto-enable-keys" checked>
468
+ <label class="form-check-label" for="auto-enable-keys">
469
+ 自动启用导入的密钥
470
+ </label>
471
+ </div>
472
+ <div class="alert alert-info mt-3">
473
+ <i class="bi bi-info-circle me-2"></i> 批量导入将跳过重复的密钥值。如果名称重复但密钥值不同,将作为新密钥添加。
474
+ </div>
475
+ <div id="import-preview" class="mt-3" style="display: none;">
476
+ <h6>导入预览 (<span id="preview-count">0</span>个密钥)</h6>
477
+ <div class="table-responsive">
478
+ <table class="table table-sm">
479
+ <thead>
480
+ <tr>
481
+ <th>名称</th>
482
+ <th>密钥</th>
483
+ <th>权重</th>
484
+ <th>速率限制</th>
485
+ </tr>
486
+ </thead>
487
+ <tbody id="preview-table-body">
488
+ </tbody>
489
+ </table>
490
+ </div>
491
+ </div>
492
+ </div>
493
+ <div class="modal-footer">
494
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
495
+ <button type="button" class="btn btn-primary" id="preview-import-btn">预览</button>
496
+ <button type="button" class="btn btn-success" id="confirm-import-btn" disabled>导入</button>
497
+ </div>
498
+ </div>
499
+ </div>
500
+ </div>
501
+
502
+ <!-- Toast通知 -->
503
+ <div class="position-fixed bottom-0 end-0 p-3" style="z-index: 5">
504
+ <div id="toast-container"></div>
505
+ </div>
506
+
507
+ <!-- JavaScript库 -->
508
+ <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
509
+ <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
510
+ <script src="/static/admin/js/main.js"></script>
511
+ </body>
512
+ </html>
src/static/admin/js/config.js ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ document.addEventListener('DOMContentLoaded', function() {
2
+ // 获取当前配置
3
+ fetchConfig();
4
+
5
+ // 保存配置按钮事件
6
+ document.getElementById('saveConfig').addEventListener('click', saveConfig);
7
+ });
8
+
9
+ // 获取当前配置
10
+ function fetchConfig() {
11
+ fetch('/api/config', {
12
+ method: 'GET',
13
+ headers: {
14
+ 'Authorization': 'Bearer ' + getAdminKey()
15
+ }
16
+ })
17
+ .then(response => {
18
+ if (!response.ok) {
19
+ throw new Error('获取配置失败,请检查管理员密钥是否正确');
20
+ }
21
+ return response.json();
22
+ })
23
+ .then(data => {
24
+ // 代理设置
25
+ document.getElementById('proxy_host').value = data.PROXY_HOST || '';
26
+ document.getElementById('proxy_port').value = data.PROXY_PORT || '';
27
+ document.getElementById('proxy_user').value = data.PROXY_USER || '';
28
+
29
+ // 代理密码字段
30
+ const proxyPassField = document.getElementById('proxy_pass');
31
+ if (data.PROXY_PASS && data.PROXY_PASS !== '') {
32
+ // 设置代理密码占位符,防止用户修改
33
+ proxyPassField.setAttribute('placeholder', '已设置代理密码 (请保留空)');
34
+ } else {
35
+ proxyPassField.setAttribute('placeholder', '代理密码');
36
+ }
37
+
38
+ // 图像本地化设置
39
+ document.getElementById('image_localization').checked = data.IMAGE_LOCALIZATION || false;
40
+ document.getElementById('image_save_dir').value = data.IMAGE_SAVE_DIR || 'src/static/images';
41
+ })
42
+ .catch(error => {
43
+ showMessage('错误', error.message, 'danger');
44
+ });
45
+ }
46
+
47
+ // 保存配置
48
+ function saveConfig() {
49
+ // 获取代理设置
50
+ const proxyHost = document.getElementById('proxy_host').value.trim();
51
+ const proxyPort = document.getElementById('proxy_port').value.trim();
52
+ const proxyUser = document.getElementById('proxy_user').value.trim();
53
+ const proxyPass = document.getElementById('proxy_pass').value.trim();
54
+
55
+ // 获取图像本地化设置
56
+ const imageLocalization = document.getElementById('image_localization').checked;
57
+ const imageSaveDir = document.getElementById('image_save_dir').value.trim();
58
+
59
+ // 创建配置对象
60
+ const config = {
61
+ PROXY_HOST: proxyHost,
62
+ PROXY_PORT: proxyPort,
63
+ PROXY_USER: proxyUser,
64
+ IMAGE_LOCALIZATION: imageLocalization,
65
+ IMAGE_SAVE_DIR: imageSaveDir,
66
+ save_to_env: true
67
+ };
68
+
69
+ // 如果代理密码存在,则添加到配置对象中
70
+ if (proxyPass) {
71
+ config.PROXY_PASS = proxyPass;
72
+ }
73
+
74
+ fetch('/api/config', {
75
+ method: 'POST',
76
+ headers: {
77
+ 'Content-Type': 'application/json',
78
+ 'Authorization': 'Bearer ' + getAdminKey()
79
+ },
80
+ body: JSON.stringify(config)
81
+ })
82
+ .then(response => {
83
+ if (!response.ok) {
84
+ throw new Error('保存配置失败,请检查管理员密钥是否正确');
85
+ }
86
+ return response.json();
87
+ })
88
+ .then(data => {
89
+ showMessage('成功', '配置已保存', 'success');
90
+
91
+ // 成功后,立即重新获取配置以更新显示
92
+ setTimeout(fetchConfig, 1000);
93
+ })
94
+ .catch(error => {
95
+ showMessage('错误', error.message, 'danger');
96
+ });
97
+ }
98
+
99
+ // 获取管理员密钥
100
+ function getAdminKey() {
101
+ return localStorage.getItem('adminKey') || '';
102
+ }
103
+
104
+ // 显示消息
105
+ function showMessage(title, message, type) {
106
+ const alertDiv = document.createElement('div');
107
+ alertDiv.className = `alert alert-${type} alert-dismissible fade show`;
108
+ alertDiv.innerHTML = `
109
+ <strong>${title}:</strong> ${message}
110
+ <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
111
+ `;
112
+
113
+ const messagesContainer = document.getElementById('messages');
114
+ messagesContainer.appendChild(alertDiv);
115
+
116
+ // 5秒后自动关闭
117
+ setTimeout(() => {
118
+ alertDiv.classList.remove('show');
119
+ setTimeout(() => alertDiv.remove(), 150);
120
+ }, 5000);
121
+ }
src/static/admin/js/main.js ADDED
@@ -0,0 +1,1631 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 全局变量
2
+ let adminKey = '';
3
+ let keysData = [];
4
+ let currentPage = 1;
5
+ const keysPerPage = 10;
6
+ let statsData = null;
7
+ let dashboardRequestsChart = null;
8
+ let requestsChart = null;
9
+ let keysUsageChart = null;
10
+ let systemSettings = {
11
+ proxyHost: '',
12
+ proxyPort: '',
13
+ proxyUser: '',
14
+ proxyPass: ''
15
+ };
16
+ // token过期时间
17
+ let tokenExpiry = null;
18
+ // JWT配置
19
+ const JWT_EXPIRATION = 3600; // 令牌有效期1小时(秒)
20
+ // 导入预览数据
21
+ let importPreviewData = [];
22
+
23
+ // DOM 加载完成后执行
24
+ document.addEventListener('DOMContentLoaded', () => {
25
+ // 检查是否已登录
26
+ checkAuth();
27
+
28
+ // 绑定登录表单提交事件
29
+ document.getElementById('login-form').addEventListener('submit', async (e) => {
30
+ e.preventDefault();
31
+ await handleLogin();
32
+ });
33
+
34
+ // 绑定退出登录事件
35
+ document.getElementById('logout-btn').addEventListener('click', (e) => {
36
+ e.preventDefault();
37
+ handleLogout();
38
+ });
39
+
40
+ // 侧边栏切换
41
+ document.getElementById('sidebarCollapse').addEventListener('click', () => {
42
+ const sidebar = document.getElementById('sidebar');
43
+ const content = document.getElementById('content');
44
+
45
+ sidebar.classList.toggle('active');
46
+ content.classList.toggle('active');
47
+ });
48
+
49
+ // 页面切换
50
+ document.querySelectorAll('[data-page]').forEach(link => {
51
+ link.addEventListener('click', (e) => {
52
+ e.preventDefault();
53
+ const targetPage = link.getAttribute('data-page');
54
+
55
+ // 验证token是否有效
56
+ if (!isTokenValid()) {
57
+ handleLogout();
58
+ showToast('登录已过期,请重新登录', 'warning');
59
+ return;
60
+ }
61
+
62
+ // 更新导航链接激活状态
63
+ document.querySelectorAll('#sidebar li').forEach(li => li.classList.remove('active'));
64
+ link.closest('li').classList.add('active');
65
+
66
+ // 更新页面标题
67
+ document.getElementById('current-page-title').textContent = link.textContent.trim();
68
+
69
+ // 隐藏所有页面,只显示目标页面
70
+ document.querySelectorAll('.content-page').forEach(page => {
71
+ page.classList.remove('active');
72
+ page.style.display = 'none';
73
+ });
74
+ document.getElementById(`${targetPage}-page`).classList.add('active');
75
+ document.getElementById(`${targetPage}-page`).style.display = 'block';
76
+
77
+ // 加载相应页面的数据
78
+ switch (targetPage) {
79
+ case 'dashboard':
80
+ loadDashboard();
81
+ break;
82
+ case 'keys':
83
+ loadKeys();
84
+ break;
85
+ case 'stats':
86
+ loadStats();
87
+ break;
88
+ case 'settings':
89
+ loadSettings();
90
+ break;
91
+ }
92
+ });
93
+ });
94
+
95
+ // 仪表盘页面的"查看全部"按钮
96
+ document.querySelectorAll('#dashboard-page [data-page]').forEach(link => {
97
+ link.addEventListener('click', (e) => {
98
+ e.preventDefault();
99
+ const targetPage = link.getAttribute('data-page');
100
+ // 触发相应导航的点击事件
101
+ document.querySelector(`#sidebar a[data-page="${targetPage}"]`).click();
102
+ });
103
+ });
104
+
105
+ // 添加密钥按钮事件
106
+ document.getElementById('add-key-btn').addEventListener('click', () => {
107
+ // 重置表单
108
+ document.getElementById('key-form').reset();
109
+ document.getElementById('key-id').value = '';
110
+ document.getElementById('keyModalLabel').textContent = '添加新密钥';
111
+
112
+ // 显示模态框
113
+ const keyModal = new bootstrap.Modal(document.getElementById('keyModal'));
114
+ keyModal.show();
115
+ });
116
+
117
+ // 保存密钥按钮事件
118
+ document.getElementById('save-key-btn').addEventListener('click', saveKey);
119
+
120
+ // 测试密钥按钮事件
121
+ document.getElementById('test-key-btn').addEventListener('click', testKey);
122
+
123
+ // 全选/取消全选
124
+ document.getElementById('select-all').addEventListener('change', (e) => {
125
+ const checkboxes = document.querySelectorAll('#keys-table-body input[type="checkbox"]');
126
+ checkboxes.forEach(checkbox => checkbox.checked = e.target.checked);
127
+ });
128
+
129
+ // 批量操作事件
130
+ document.querySelectorAll('.batch-action').forEach(action => {
131
+ action.addEventListener('click', (e) => {
132
+ e.preventDefault();
133
+ const actionType = action.getAttribute('data-action');
134
+ const selectedIds = getSelectedKeyIds();
135
+
136
+ if (selectedIds.length === 0) {
137
+ showToast('请至少选择一个密钥', 'warning');
138
+ return;
139
+ }
140
+
141
+ // 显示确认对话框
142
+ let message = '';
143
+ if (actionType === 'enable') {
144
+ message = `确定要启用选中的 ${selectedIds.length} 个密钥吗?`;
145
+ } else if (actionType === 'disable') {
146
+ message = `确定要禁用选中的 ${selectedIds.length} 个密钥吗?`;
147
+ } else if (actionType === 'delete') {
148
+ message = `确定要删除选中的 ${selectedIds.length} 个密钥吗?此操作不可恢复!`;
149
+ }
150
+
151
+ showConfirmDialog(message, () => {
152
+ batchOperation(actionType, selectedIds);
153
+ });
154
+ });
155
+ });
156
+
157
+ // 搜索框事件
158
+ document.getElementById('search-keys').addEventListener('input', (e) => {
159
+ const searchTerm = e.target.value.toLowerCase();
160
+ if (searchTerm) {
161
+ const filteredKeys = keysData.filter(key =>
162
+ key.name.toLowerCase().includes(searchTerm) ||
163
+ key.key.toLowerCase().includes(searchTerm)
164
+ );
165
+ renderKeysTable(filteredKeys);
166
+ } else {
167
+ renderKeysTable(keysData);
168
+ }
169
+ });
170
+
171
+ // 管理员密钥显示/隐藏
172
+ document.getElementById('show-admin-key').addEventListener('click', () => {
173
+ const adminKeyInput = document.getElementById('admin-key');
174
+ if (adminKeyInput.type === 'password') {
175
+ adminKeyInput.type = 'text';
176
+ document.getElementById('show-admin-key').innerHTML = '<i class="bi bi-eye-slash"></i>';
177
+ } else {
178
+ adminKeyInput.type = 'password';
179
+ document.getElementById('show-admin-key').innerHTML = '<i class="bi bi-eye"></i>';
180
+ }
181
+ });
182
+
183
+ // 复制管理员密钥
184
+ document.getElementById('copy-admin-key').addEventListener('click', () => {
185
+ const adminKeyInput = document.getElementById('admin-key');
186
+ adminKeyInput.type = 'text';
187
+ adminKeyInput.select();
188
+ document.execCommand('copy');
189
+ adminKeyInput.type = 'password';
190
+ showToast('管理员密钥已复制到剪贴板', 'success');
191
+ });
192
+
193
+ // 设置表单提交
194
+ document.getElementById('settings-form').addEventListener('submit', async (e) => {
195
+ e.preventDefault();
196
+ await saveSettings();
197
+ });
198
+
199
+ // 批量导入密钥按钮事件
200
+ document.getElementById('import-keys-btn').addEventListener('click', () => {
201
+ // 重置导入表单和预览
202
+ document.getElementById('keys-text').value = '';
203
+ document.getElementById('keys-file').value = '';
204
+ document.getElementById('auto-enable-keys').checked = true;
205
+ document.getElementById('import-preview').style.display = 'none';
206
+ document.getElementById('confirm-import-btn').disabled = true;
207
+ importPreviewData = [];
208
+
209
+ // 显示导入模态框
210
+ const importModal = new bootstrap.Modal(document.getElementById('importKeysModal'));
211
+ importModal.show();
212
+ });
213
+
214
+ // 预览导入按钮事件
215
+ document.getElementById('preview-import-btn').addEventListener('click', previewImport);
216
+
217
+ // 确认导入按钮事件
218
+ document.getElementById('confirm-import-btn').addEventListener('click', confirmImport);
219
+
220
+ // 文件选择事件
221
+ document.getElementById('keys-file').addEventListener('change', async (e) => {
222
+ if (e.target.files.length > 0) {
223
+ const file = e.target.files[0];
224
+ const reader = new FileReader();
225
+
226
+ reader.onload = function(event) {
227
+ document.getElementById('keys-text').value = event.target.result;
228
+ };
229
+
230
+ reader.readAsText(file);
231
+ }
232
+ });
233
+ });
234
+
235
+ // 检查是否已登录
236
+ function checkAuth() {
237
+ const savedAdminKey = localStorage.getItem('adminKey');
238
+ const savedTokenExpiry = localStorage.getItem('tokenExpiry');
239
+
240
+ if (savedAdminKey && savedTokenExpiry) {
241
+ adminKey = savedAdminKey;
242
+ tokenExpiry = parseInt(savedTokenExpiry);
243
+
244
+ // 检查token是否已过期
245
+ if (isTokenValid()) {
246
+ showAdminPanel();
247
+ // 初始化其他数据
248
+ displayAdminKey();
249
+ loadDashboard(); // 初始加载仪表盘页面
250
+
251
+ // 如果token即将过期(小于5分钟),则刷新
252
+ const fiveMinutesInMs = 5 * 60 * 1000;
253
+ if (tokenExpiry - new Date().getTime() < fiveMinutesInMs) {
254
+ refreshToken();
255
+ }
256
+ } else {
257
+ // token已过期,清除存储并显示登录面板
258
+ handleLogout();
259
+ showToast('登录已过期,请重新登录', 'warning');
260
+ }
261
+ } else {
262
+ showLoginPanel();
263
+ }
264
+ }
265
+
266
+ // 处理登录
267
+ async function handleLogin() {
268
+ const inputKey = document.getElementById('admin-key-input').value.trim();
269
+ if (!inputKey) {
270
+ showLoginError('请输入管理员密钥');
271
+ return;
272
+ }
273
+
274
+ try {
275
+ // 显示加载状态
276
+ document.querySelector('#login-form button').disabled = true;
277
+ document.querySelector('#login-form button').innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 登录中...';
278
+
279
+ // 使用fetch API进行登录请求
280
+ const response = await fetch('/api/auth/login', {
281
+ method: 'POST',
282
+ headers: {
283
+ 'Content-Type': 'application/json',
284
+ },
285
+ body: JSON.stringify({ admin_key: inputKey })
286
+ });
287
+
288
+ // 恢复按钮状态
289
+ document.querySelector('#login-form button').disabled = false;
290
+ document.querySelector('#login-form button').innerHTML = '登录';
291
+
292
+ if (response.ok) {
293
+ const data = await response.json();
294
+ adminKey = data.token;
295
+ tokenExpiry = new Date().getTime() + (data.expires_in * 1000);
296
+
297
+ // 保存至本地存储
298
+ localStorage.setItem('adminKey', adminKey);
299
+ localStorage.setItem('tokenExpiry', tokenExpiry.toString());
300
+
301
+ // 隐藏错误信息
302
+ document.getElementById('login-error').style.display = 'none';
303
+
304
+ // 显示管理面板
305
+ showAdminPanel();
306
+ displayAdminKey();
307
+ loadDashboard();
308
+ } else {
309
+ // 登录失败
310
+ try {
311
+ const error = await response.json();
312
+ showLoginError(error.detail || '登录失败,请检查管理员密钥');
313
+ } catch {
314
+ showLoginError('登录失败,请检查管理员密钥');
315
+ }
316
+ }
317
+ } catch (error) {
318
+ // 处理网络错误等异常
319
+ document.querySelector('#login-form button').disabled = false;
320
+ document.querySelector('#login-form button').innerHTML = '登录';
321
+ console.error('验证管理员密钥失败:', error);
322
+ showLoginError('验证失败,请稍后再试');
323
+ }
324
+ }
325
+
326
+ // 显示登录错误
327
+ function showLoginError(message) {
328
+ const errorElement = document.getElementById('login-error');
329
+ errorElement.textContent = message;
330
+ errorElement.style.display = 'block';
331
+
332
+ // 3秒后自动隐藏
333
+ setTimeout(() => {
334
+ errorElement.style.display = 'none';
335
+ }, 3000);
336
+ }
337
+
338
+ // 处理登出
339
+ function handleLogout() {
340
+ localStorage.removeItem('adminKey');
341
+ localStorage.removeItem('tokenExpiry');
342
+ adminKey = '';
343
+ tokenExpiry = null;
344
+ showLoginPanel();
345
+ document.getElementById('admin-key-input').value = '';
346
+ }
347
+
348
+ // 检查token是否有效
349
+ function isTokenValid() {
350
+ if (!tokenExpiry) return false;
351
+ // 检查token是否过期(留10秒缓冲)
352
+ return new Date().getTime() < tokenExpiry - 10000;
353
+ }
354
+
355
+ // 刷新token
356
+ async function refreshToken() {
357
+ try {
358
+ const response = await fetch('/api/auth/refresh', {
359
+ method: 'POST',
360
+ headers: {
361
+ 'Authorization': `Bearer ${adminKey}`,
362
+ 'Content-Type': 'application/json'
363
+ }
364
+ });
365
+
366
+ if (response.ok) {
367
+ const data = await response.json();
368
+ adminKey = data.token;
369
+ tokenExpiry = new Date().getTime() + (data.expires_in * 1000);
370
+ localStorage.setItem('adminKey', adminKey);
371
+ localStorage.setItem('tokenExpiry', tokenExpiry.toString());
372
+ return true;
373
+ } else {
374
+ return false;
375
+ }
376
+ } catch (error) {
377
+ console.error('刷新token失败:', error);
378
+ return false;
379
+ }
380
+ }
381
+
382
+ // 显示登录面板
383
+ function showLoginPanel() {
384
+ document.getElementById('login-container').style.display = 'flex';
385
+ document.getElementById('admin-panel').style.display = 'none';
386
+ // 清除可能存在的错误信息
387
+ document.getElementById('login-error').style.display = 'none';
388
+ }
389
+
390
+ // 显示管理面板
391
+ function showAdminPanel() {
392
+ document.getElementById('login-container').style.display = 'none';
393
+ document.getElementById('admin-panel').style.display = 'flex';
394
+
395
+ // 确保侧边栏正常显示
396
+ document.getElementById('sidebar').classList.remove('active');
397
+ document.getElementById('content').classList.remove('active');
398
+
399
+ // 初始化显示仪表盘页面,隐藏其他页面
400
+ document.querySelectorAll('.content-page').forEach(page => {
401
+ page.classList.remove('active');
402
+ page.style.display = 'none';
403
+ });
404
+ document.getElementById('dashboard-page').classList.add('active');
405
+ document.getElementById('dashboard-page').style.display = 'block';
406
+
407
+ // 更新导航栏状态
408
+ document.querySelectorAll('#sidebar li').forEach(li => li.classList.remove('active'));
409
+ document.querySelector('#sidebar li:first-child').classList.add('active');
410
+
411
+ // 更新页面标题
412
+ document.getElementById('current-page-title').textContent = '仪���盘';
413
+ }
414
+
415
+ // 显示管理员密钥信息
416
+ function displayAdminKey() {
417
+ // 设置到隐藏输入框
418
+ document.getElementById('admin-key').value = adminKey;
419
+
420
+ // 显示密钥部分信息
421
+ const keyDisplay = adminKey.substring(0, 6) + '...' + adminKey.substring(adminKey.length - 4);
422
+ document.getElementById('admin-key-display').textContent = '管理员: ' + keyDisplay;
423
+ }
424
+
425
+ // API请求包装函数,处理token刷新逻辑
426
+ async function apiRequest(url, options = {}) {
427
+ // 检查token是否即将到期(剩余5分钟以内)
428
+ const fiveMinutesInMs = 5 * 60 * 1000;
429
+ if (tokenExpiry && (tokenExpiry - new Date().getTime() < fiveMinutesInMs)) {
430
+ // 刷新token
431
+ const refreshSuccess = await refreshToken();
432
+ if (!refreshSuccess) {
433
+ // token刷新失败,需要重新登录
434
+ handleLogout();
435
+ showToast('登录已过期,请重新登录', 'warning');
436
+ return null;
437
+ }
438
+ }
439
+
440
+ // 确保options中包含正确的headers
441
+ options.headers = options.headers || {};
442
+ options.headers['Authorization'] = `Bearer ${adminKey}`;
443
+
444
+ // 如果是POST/PUT请求且有body,确保设置正确的Content-Type
445
+ if ((options.method === 'POST' || options.method === 'PUT') && options.body) {
446
+ // 确保Content-Type正确设置
447
+ if (!options.headers['Content-Type']) {
448
+ options.headers['Content-Type'] = 'application/json';
449
+ }
450
+ }
451
+
452
+ // 发送请求
453
+ try {
454
+ const response = await fetch(url, options);
455
+
456
+ // 处理401/403错误(未授权)
457
+ if (response.status === 401 || response.status === 403) {
458
+ // 尝试刷新token
459
+ if (await refreshToken()) {
460
+ // 刷新成功,使用新token重试请求
461
+ options.headers['Authorization'] = `Bearer ${adminKey}`;
462
+ const retryResponse = await fetch(url, options);
463
+ if (retryResponse.ok) {
464
+ return await retryResponse.json();
465
+ }
466
+ }
467
+
468
+ // 刷新失败或重试失败,需要重新登录
469
+ handleLogout();
470
+ showToast('登录已过期,请重新登录', 'warning');
471
+ return null;
472
+ }
473
+
474
+ // 其他错误
475
+ if (!response.ok) {
476
+ // 尝试解析错误消息
477
+ try {
478
+ const errorData = await response.json();
479
+ throw new Error(`请求失败: ${response.status} - ${errorData.detail || errorData.message || response.statusText}`);
480
+ } catch (e) {
481
+ throw new Error(`请求失败: ${response.status} ${response.statusText}`);
482
+ }
483
+ }
484
+
485
+ return await response.json();
486
+ } catch (error) {
487
+ console.error('API请求错误:', error);
488
+ showToast(`请求失败: ${error.message}`, 'error');
489
+ return null;
490
+ }
491
+ }
492
+
493
+ // 加载密钥列表
494
+ async function loadKeys() {
495
+ try {
496
+ // 显示加载中
497
+ document.getElementById('keys-table-body').innerHTML = '<tr><td colspan="9" class="text-center">加载中...</td></tr>';
498
+
499
+ const data = await apiRequest('/api/keys');
500
+ if (data) {
501
+ keysData = data;
502
+ renderKeysTable(keysData);
503
+ }
504
+ } catch (error) {
505
+ console.error('加载密钥失败:', error);
506
+ document.getElementById('keys-table-body').innerHTML =
507
+ `<tr><td colspan="9" class="text-center text-danger">加载失败: ${error.message}</td></tr>`;
508
+ }
509
+ }
510
+
511
+ // 渲染密钥表格
512
+ function renderKeysTable(keys) {
513
+ const tableBody = document.getElementById('keys-table-body');
514
+ tableBody.innerHTML = '';
515
+
516
+ if (keys.length === 0) {
517
+ tableBody.innerHTML = '<tr><td colspan="9" class="text-center">暂无密钥数据</td></tr>';
518
+ return;
519
+ }
520
+
521
+ // 分页处理
522
+ const startIndex = (currentPage - 1) * keysPerPage;
523
+ const endIndex = startIndex + keysPerPage;
524
+ const keysToShow = keys.slice(startIndex, endIndex);
525
+
526
+ // 渲染表格行
527
+ keysToShow.forEach(key => {
528
+ const row = document.createElement('tr');
529
+
530
+ // 创建时间和最后使用时间格式化
531
+ const createDate = key.created_at ? new Date(key.created_at * 1000).toLocaleString() : '未知';
532
+ const lastUsedDate = key.last_used ? new Date(key.last_used * 1000).toLocaleString() : '从未使用';
533
+
534
+ // 确定密钥状态
535
+ let statusText = '';
536
+ let statusClass = '';
537
+ let statusTitle = '';
538
+ let remainingTimeText = '';
539
+
540
+ if (key.temp_disabled_until) {
541
+ // 临时禁用 - 使用格式化后的时间
542
+ statusText = '临时禁用';
543
+ statusClass = 'status-temp-disabled';
544
+
545
+ // 优先使用服务器返回的格式化时间
546
+ const enableTimeText = key.temp_disabled_until_formatted ||
547
+ new Date(key.temp_disabled_until * 1000).toLocaleString();
548
+
549
+ statusTitle = `将于 ${enableTimeText} 恢复`;
550
+
551
+ // 如果有剩余时间信息,添加可读性更强的显示
552
+ if (key.temp_disabled_remaining !== undefined) {
553
+ const remainingSecs = key.temp_disabled_remaining;
554
+ if (remainingSecs > 0) {
555
+ // 转换为 小时:分钟:秒 格式
556
+ const hours = Math.floor(remainingSecs / 3600);
557
+ const minutes = Math.floor((remainingSecs % 3600) / 60);
558
+ const seconds = remainingSecs % 60;
559
+
560
+ remainingTimeText = `剩余 ${hours}小时${minutes}分钟`;
561
+ } else {
562
+ remainingTimeText = '即将恢复';
563
+ }
564
+ }
565
+ } else if (key.is_enabled) {
566
+ // 启用
567
+ statusText = '启用';
568
+ statusClass = 'status-enabled';
569
+ } else {
570
+ // 永久禁用
571
+ statusText = '禁用';
572
+ statusClass = 'status-disabled';
573
+ }
574
+
575
+ row.innerHTML = `
576
+ <td><input type="checkbox" name="key-checkbox" class="key-checkbox" value="${key.id}" data-id="${key.id}"></td>
577
+ <td>${key.name || '未命名'}</td>
578
+ <td class="key-value">${key.key}</td>
579
+ <td>
580
+ <span class="status-badge ${statusClass}" title="${statusTitle}">
581
+ ${statusText}
582
+ </span>
583
+ ${key.temp_disabled_until ?
584
+ `<div class="small text-muted">
585
+ 启用于: ${key.temp_disabled_until_formatted || new Date(key.temp_disabled_until * 1000).toLocaleString()}
586
+ ${remainingTimeText ? `<br>${remainingTimeText}` : ''}
587
+ </div>` : ''}
588
+ </td>
589
+ <td>${key.weight || 1}</td>
590
+ <td>${key.max_rpm || 60}/分钟</td>
591
+ <td>${createDate}</td>
592
+ <td>${lastUsedDate}</td>
593
+ <td>
594
+ <button class="btn btn-sm btn-primary action-btn edit-key" data-id="${key.id}" title="编辑">
595
+ <i class="bi bi-pencil"></i>
596
+ </button>
597
+ <button class="btn btn-sm btn-danger action-btn delete-key" data-id="${key.id}" title="删除">
598
+ <i class="bi bi-trash"></i>
599
+ </button>
600
+ <button class="btn btn-sm btn-secondary action-btn copy-key" data-key="${key.key}" title="复制密钥">
601
+ <i class="bi bi-clipboard"></i>
602
+ </button>
603
+ </td>
604
+ `;
605
+
606
+ tableBody.appendChild(row);
607
+ });
608
+
609
+ // 绑定操作按钮事件
610
+ bindTableEvents();
611
+
612
+ // 更新分页
613
+ renderPagination(keys.length);
614
+ }
615
+
616
+ // 绑定表格操作事件
617
+ function bindTableEvents() {
618
+ // 编辑按钮
619
+ document.querySelectorAll('.edit-key').forEach(btn => {
620
+ btn.addEventListener('click', async () => {
621
+ const keyId = btn.getAttribute('data-id');
622
+ await loadKeyDetails(keyId);
623
+
624
+ // 显示模态框
625
+ document.getElementById('keyModalLabel').textContent = '编辑密钥';
626
+ const keyModal = new bootstrap.Modal(document.getElementById('keyModal'));
627
+ keyModal.show();
628
+ });
629
+ });
630
+
631
+ // 删除按钮
632
+ document.querySelectorAll('.delete-key').forEach(btn => {
633
+ btn.addEventListener('click', () => {
634
+ const keyId = btn.getAttribute('data-id');
635
+ const keyName = btn.closest('tr').children[1].textContent;
636
+
637
+ showConfirmDialog(`确定要删除密钥 "${keyName}" 吗?此操作不可恢复!`, () => {
638
+ deleteKey(keyId);
639
+ });
640
+ });
641
+ });
642
+
643
+ // 复制按钮
644
+ document.querySelectorAll('.copy-key').forEach(btn => {
645
+ btn.addEventListener('click', () => {
646
+ const keyValue = btn.getAttribute('data-key');
647
+ navigator.clipboard.writeText(keyValue)
648
+ .then(() => showToast('密钥已复制到剪贴板', 'success'))
649
+ .catch(err => showToast('复制失败: ' + err, 'error'));
650
+ });
651
+ });
652
+ }
653
+
654
+ // 渲染分页
655
+ function renderPagination(totalKeys) {
656
+ const pagination = document.getElementById('pagination');
657
+ pagination.innerHTML = '';
658
+
659
+ if (totalKeys <= keysPerPage) {
660
+ return;
661
+ }
662
+
663
+ const totalPages = Math.ceil(totalKeys / keysPerPage);
664
+ const ul = document.createElement('ul');
665
+ ul.className = 'pagination';
666
+
667
+ // 上一页
668
+ const prevLi = document.createElement('li');
669
+ prevLi.className = `page-item ${currentPage === 1 ? 'disabled' : ''}`;
670
+ prevLi.innerHTML = `<a class="page-link" href="#" data-page="${currentPage - 1}">上一页</a>`;
671
+ ul.appendChild(prevLi);
672
+
673
+ // 页码
674
+ for (let i = 1; i <= totalPages; i++) {
675
+ const li = document.createElement('li');
676
+ li.className = `page-item ${currentPage === i ? 'active' : ''}`;
677
+ li.innerHTML = `<a class="page-link" href="#" data-page="${i}">${i}</a>`;
678
+ ul.appendChild(li);
679
+ }
680
+
681
+ // 下一页
682
+ const nextLi = document.createElement('li');
683
+ nextLi.className = `page-item ${currentPage === totalPages ? 'disabled' : ''}`;
684
+ nextLi.innerHTML = `<a class="page-link" href="#" data-page="${currentPage + 1}">下一页</a>`;
685
+ ul.appendChild(nextLi);
686
+
687
+ pagination.appendChild(ul);
688
+
689
+ // 绑定页码点击事件
690
+ document.querySelectorAll('.page-link').forEach(link => {
691
+ link.addEventListener('click', (e) => {
692
+ e.preventDefault();
693
+ if (link.parentElement.classList.contains('disabled')) {
694
+ return;
695
+ }
696
+ currentPage = parseInt(link.getAttribute('data-page'));
697
+ renderKeysTable(keysData);
698
+ });
699
+ });
700
+ }
701
+
702
+ // 加载单个密钥详情
703
+ async function loadKeyDetails(keyId) {
704
+ try {
705
+ const keyData = await apiRequest(`/api/keys/${keyId}`);
706
+ if (!keyData) return;
707
+
708
+ // 填充表单
709
+ document.getElementById('key-id').value = keyData.id;
710
+ document.getElementById('key-name').value = keyData.name || '';
711
+ document.getElementById('key-value').value = keyData.key || '';
712
+ document.getElementById('key-weight').value = keyData.weight || 1;
713
+ document.getElementById('key-rate-limit').value = keyData.max_rpm || 60;
714
+ document.getElementById('key-enabled').checked = keyData.is_enabled;
715
+ document.getElementById('key-notes').value = keyData.notes || '';
716
+ } catch (error) {
717
+ console.error('加载密钥详情失败:', error);
718
+ showToast('加载密钥详情失败: ' + error.message, 'error');
719
+ }
720
+ }
721
+
722
+ // 保存密钥
723
+ async function saveKey() {
724
+ try {
725
+ const keyId = document.getElementById('key-id').value;
726
+ const keyData = {
727
+ name: document.getElementById('key-name').value,
728
+ key_value: document.getElementById('key-value').value,
729
+ weight: parseInt(document.getElementById('key-weight').value),
730
+ rate_limit: parseInt(document.getElementById('key-rate-limit').value),
731
+ is_enabled: document.getElementById('key-enabled').checked,
732
+ notes: document.getElementById('key-notes').value
733
+ };
734
+
735
+ if (!keyData.name || !keyData.key_value) {
736
+ showToast('密钥名称和值不能为空', 'warning');
737
+ return;
738
+ }
739
+
740
+ let url, method;
741
+
742
+ if (keyId) {
743
+ // 更新现有密钥
744
+ url = `/api/keys/${keyId}`;
745
+ method = 'PUT';
746
+ } else {
747
+ // 创建新密钥
748
+ url = '/api/keys';
749
+ method = 'POST';
750
+ }
751
+
752
+ const options = {
753
+ method,
754
+ headers: {
755
+ 'Content-Type': 'application/json'
756
+ },
757
+ body: JSON.stringify(keyData)
758
+ };
759
+
760
+ const result = await apiRequest(url, options);
761
+ if (!result) return;
762
+
763
+ // 关闭模态框
764
+ const keyModal = bootstrap.Modal.getInstance(document.getElementById('keyModal'));
765
+ keyModal.hide();
766
+
767
+ // 重新加载密钥列表
768
+ await loadKeys();
769
+
770
+ // 如果是仪表盘页面,也更新仪表盘数据
771
+ if (document.getElementById('dashboard-page').classList.contains('active')) {
772
+ await loadDashboard();
773
+ }
774
+
775
+ // 显示成功消息
776
+ showToast(keyId ? '密钥更新成功' : '密钥添加成功', 'success');
777
+ } catch (error) {
778
+ console.error('保存密钥失败:', error);
779
+ showToast('保存密钥失败: ' + error.message, 'error');
780
+ }
781
+ }
782
+
783
+ // 测试密钥连接
784
+ async function testKey() {
785
+ try {
786
+ const keyValue = document.getElementById('key-value').value;
787
+
788
+ if (!keyValue) {
789
+ showToast('请输入有效的密钥值', 'warning');
790
+ return;
791
+ }
792
+
793
+ const keyName = document.getElementById('key-name').value || '新密钥';
794
+
795
+ // 显示测试中状态
796
+ const testButton = document.getElementById('test-key-btn');
797
+ const originalText = testButton.innerHTML;
798
+ testButton.innerHTML = '<i class="bi bi-hourglass-split me-1"></i> 测试中...';
799
+ testButton.disabled = true;
800
+
801
+ const testData = {
802
+ name: keyName,
803
+ key_value: keyValue
804
+ };
805
+
806
+ const result = await apiRequest('/api/keys/test', {
807
+ method: 'POST',
808
+ headers: {
809
+ 'Content-Type': 'application/json'
810
+ },
811
+ body: JSON.stringify(testData)
812
+ });
813
+
814
+ // 恢复按钮状态
815
+ testButton.innerHTML = originalText;
816
+ testButton.disabled = false;
817
+
818
+ if (!result) return;
819
+
820
+ if (result.status === "success") {
821
+ showToast(`测试成功: ${result.message || '密钥可用'}`, 'success');
822
+ } else {
823
+ showToast(`测试失败: ${result.message || '密钥无法连接'}`, 'warning');
824
+ }
825
+ } catch (error) {
826
+ console.error('测试密钥失败:', error);
827
+ showToast('测试密钥失败: ' + error.message, 'error');
828
+
829
+ // 恢复按钮状态
830
+ const testButton = document.getElementById('test-key-btn');
831
+ testButton.innerHTML = '<i class="bi bi-shield-check me-1"></i> 测试连接';
832
+ testButton.disabled = false;
833
+ }
834
+ }
835
+
836
+ // 删除密钥
837
+ async function deleteKey(keyId) {
838
+ try {
839
+ const result = await apiRequest(`/api/keys/${keyId}`, {
840
+ method: 'DELETE'
841
+ });
842
+
843
+ if (!result) return;
844
+
845
+ // 重新加载密钥列表
846
+ await loadKeys();
847
+
848
+ // 如果是仪表盘页面,也更新仪表盘数据
849
+ if (document.getElementById('dashboard-page').classList.contains('active')) {
850
+ await loadDashboard();
851
+ }
852
+
853
+ // 显示成功消息
854
+ showToast('密钥删除成功', 'success');
855
+ } catch (error) {
856
+ console.error('删除密钥失败:', error);
857
+ showToast('删除密钥失败: ' + error.message, 'error');
858
+ }
859
+ }
860
+
861
+ // 批量操作
862
+ async function batchOperation(action, keyIds) {
863
+ try {
864
+ const operationData = {
865
+ action: action,
866
+ key_ids: keyIds
867
+ };
868
+
869
+ const result = await apiRequest('/api/keys/batch', {
870
+ method: 'POST',
871
+ headers: {
872
+ 'Content-Type': 'application/json'
873
+ },
874
+ body: JSON.stringify(operationData)
875
+ });
876
+
877
+ if (!result) return;
878
+
879
+ // 重新加载密钥列表
880
+ await loadKeys();
881
+
882
+ // 如果是仪表盘页面,也更新仪表盘数据
883
+ if (document.getElementById('dashboard-page').classList.contains('active')) {
884
+ await loadDashboard();
885
+ }
886
+
887
+ // 显示成功消息
888
+ let message = '';
889
+ if (action === 'enable') {
890
+ message = '批量启用成功';
891
+ } else if (action === 'disable') {
892
+ message = '批量禁用成功';
893
+ } else if (action === 'delete') {
894
+ message = '批量删除成功';
895
+ }
896
+
897
+ showToast(message, 'success');
898
+ } catch (error) {
899
+ console.error('批量操作失败:', error);
900
+ showToast('批量操作失败: ' + error.message, 'error');
901
+ }
902
+ }
903
+
904
+ // 获取所有选中的密钥ID
905
+ function getSelectedKeyIds() {
906
+ const checkboxes = document.querySelectorAll('#keys-table-body input[type="checkbox"]:checked');
907
+ return Array.from(checkboxes).map(checkbox => checkbox.getAttribute('data-id'));
908
+ }
909
+
910
+ // 加载统计数据
911
+ async function loadStats() {
912
+ try {
913
+ // 显示加载中状态
914
+ document.getElementById('total-requests').textContent = '加载中...';
915
+ document.getElementById('successful-requests').textContent = '加载中...';
916
+ document.getElementById('failed-requests').textContent = '加载中...';
917
+ document.getElementById('success-rate').textContent = '加载中...';
918
+
919
+ const data = await apiRequest('/api/stats');
920
+ if (data) {
921
+ statsData = data;
922
+
923
+ // 渲染统计卡片
924
+ renderStats();
925
+
926
+ // 渲染图表
927
+ renderRequestsChart(statsData.daily_usage);
928
+ renderKeysUsageChart(statsData.keys_usage);
929
+ }
930
+ } catch (error) {
931
+ console.error('加载统计数据失败:', error);
932
+
933
+ // 创建模拟数据
934
+ statsData = {
935
+ total_requests: Math.floor(Math.random() * 5000) + 1000,
936
+ successful_requests: Math.floor(Math.random() * 4000) + 800,
937
+ daily_usage: generateMockDailyUsage(),
938
+ keys_usage: generateMockKeysUsage()
939
+ };
940
+
941
+ // 渲染模拟数据
942
+ renderStats();
943
+ renderRequestsChart(statsData.daily_usage);
944
+ renderKeysUsageChart(statsData.keys_usage);
945
+ }
946
+ }
947
+
948
+ // 渲染统计卡片
949
+ function renderStats() {
950
+ if (!statsData) {
951
+ // 如果没有统计数据,使用默认值
952
+ statsData = {
953
+ total_requests: 0,
954
+ successful_requests: 0
955
+ };
956
+ }
957
+
958
+ const totalRequests = statsData.total_requests || 0;
959
+ const successfulRequests = statsData.successful_requests || 0;
960
+ const failedRequests = totalRequests - successfulRequests;
961
+ const successRate = totalRequests > 0 ? ((successfulRequests / totalRequests) * 100).toFixed(1) + '%' : '0%';
962
+
963
+ // 更新统计卡片
964
+ document.getElementById('total-requests').textContent = totalRequests;
965
+ document.getElementById('successful-requests').textContent = successfulRequests;
966
+ document.getElementById('failed-requests').textContent = failedRequests;
967
+ document.getElementById('success-rate').textContent = successRate;
968
+ }
969
+
970
+ // 渲染请求趋势图
971
+ function renderRequestsChart(dailyUsage) {
972
+ // 准备数据
973
+ const dates = Object.keys(dailyUsage || {}).sort();
974
+ const lastDates = dates.slice(-30); // 最近30天
975
+
976
+ const chartData = {
977
+ labels: lastDates.map(date => date.substring(5)), // 只显示月-日
978
+ datasets: [{
979
+ label: '请求数',
980
+ data: lastDates.map(date => dailyUsage[date] || 0),
981
+ backgroundColor: 'rgba(13, 110, 253, 0.4)',
982
+ borderColor: 'rgba(13, 110, 253, 1)',
983
+ borderWidth: 2
984
+ }]
985
+ };
986
+
987
+ // 销毁现有图表
988
+ if (requestsChart) {
989
+ requestsChart.destroy();
990
+ }
991
+
992
+ // 创建新图表
993
+ const ctx = document.getElementById('requests-chart').getContext('2d');
994
+ requestsChart = new Chart(ctx, {
995
+ type: 'bar',
996
+ data: chartData,
997
+ options: {
998
+ responsive: true,
999
+ maintainAspectRatio: false,
1000
+ plugins: {
1001
+ legend: {
1002
+ display: false
1003
+ },
1004
+ tooltip: {
1005
+ mode: 'index',
1006
+ intersect: false
1007
+ }
1008
+ },
1009
+ scales: {
1010
+ x: {
1011
+ grid: {
1012
+ display: false
1013
+ }
1014
+ },
1015
+ y: {
1016
+ beginAtZero: true,
1017
+ ticks: {
1018
+ precision: 0
1019
+ }
1020
+ }
1021
+ }
1022
+ }
1023
+ });
1024
+ }
1025
+
1026
+ // 渲染密钥使用分布图
1027
+ function renderKeysUsageChart(keysUsage) {
1028
+ // 准备数据
1029
+ const keys = Object.keys(keysUsage || {});
1030
+ const values = keys.map(key => keysUsage[key]);
1031
+
1032
+ // 只显示前8个密钥,其余归为"其他"
1033
+ let displayKeys = keys;
1034
+ let displayValues = values;
1035
+
1036
+ if (keys.length > 8) {
1037
+ displayKeys = keys.slice(0, 7);
1038
+ displayValues = values.slice(0, 7);
1039
+
1040
+ // 计算"其他"的总和
1041
+ const othersSum = values.slice(7).reduce((sum, value) => sum + value, 0);
1042
+ displayKeys.push('其他');
1043
+ displayValues.push(othersSum);
1044
+ }
1045
+
1046
+ // 生成颜色
1047
+ const backgroundColors = [
1048
+ 'rgba(13, 110, 253, 0.7)', // 主蓝色
1049
+ 'rgba(220, 53, 69, 0.7)', // 红色
1050
+ 'rgba(25, 135, 84, 0.7)', // 绿色
1051
+ 'rgba(255, 193, 7, 0.7)', // 黄色
1052
+ 'rgba(111, 66, 193, 0.7)', // 紫色
1053
+ 'rgba(23, 162, 184, 0.7)', // 青色
1054
+ 'rgba(102, 16, 242, 0.7)', // 靛蓝色
1055
+ 'rgba(108, 117, 125, 0.7)' // 灰色
1056
+ ];
1057
+
1058
+ const chartData = {
1059
+ labels: displayKeys,
1060
+ datasets: [{
1061
+ data: displayValues,
1062
+ backgroundColor: backgroundColors,
1063
+ borderColor: backgroundColors.map(color => color.replace('0.7', '1')),
1064
+ borderWidth: 1
1065
+ }]
1066
+ };
1067
+
1068
+ // 销毁现有图表
1069
+ if (keysUsageChart) {
1070
+ keysUsageChart.destroy();
1071
+ }
1072
+
1073
+ // 创建新图表
1074
+ const ctx = document.getElementById('keys-usage-chart').getContext('2d');
1075
+ keysUsageChart = new Chart(ctx, {
1076
+ type: 'pie',
1077
+ data: chartData,
1078
+ options: {
1079
+ responsive: true,
1080
+ maintainAspectRatio: false,
1081
+ plugins: {
1082
+ legend: {
1083
+ position: 'right'
1084
+ }
1085
+ }
1086
+ }
1087
+ });
1088
+ }
1089
+
1090
+ // 加载系统设置
1091
+ async function loadSettings() {
1092
+ try {
1093
+ const data = await apiRequest('/api/config');
1094
+ if (!data) return;
1095
+
1096
+ // 设置现有的代理设置
1097
+ document.getElementById('proxy-host').value = data.PROXY_HOST || '';
1098
+ document.getElementById('proxy-port').value = data.PROXY_PORT || '';
1099
+ document.getElementById('proxy-user').value = data.PROXY_USER || '';
1100
+ document.getElementById('proxy-pass').value = '';
1101
+
1102
+ // 设置基础URL
1103
+ document.getElementById('base-url').value = data.BASE_URL || '';
1104
+
1105
+ // 设置图片本地化配置
1106
+ document.getElementById('image-localization').checked = data.IMAGE_LOCALIZATION || false;
1107
+ document.getElementById('image-save-dir').value = data.IMAGE_SAVE_DIR || 'src/static/images';
1108
+ } catch (error) {
1109
+ console.error('加载设置失败:', error);
1110
+ showToast('获取设置失败: ' + error.message, 'error');
1111
+ }
1112
+ }
1113
+
1114
+ // 保存系统设置
1115
+ async function saveSettings() {
1116
+ try {
1117
+ // 获取表单数据
1118
+ const config = {
1119
+ PROXY_HOST: document.getElementById('proxy-host').value,
1120
+ PROXY_PORT: document.getElementById('proxy-port').value,
1121
+ PROXY_USER: document.getElementById('proxy-user').value,
1122
+ PROXY_PASS: document.getElementById('proxy-pass').value,
1123
+ BASE_URL: document.getElementById('base-url').value,
1124
+ IMAGE_LOCALIZATION: document.getElementById('image-localization').checked,
1125
+ IMAGE_SAVE_DIR: document.getElementById('image-save-dir').value || 'src/static/images',
1126
+ save_to_env: true
1127
+ };
1128
+
1129
+ // 调用API保存到服务器
1130
+ const result = await apiRequest('/api/config', {
1131
+ method: 'POST',
1132
+ headers: {
1133
+ 'Content-Type': 'application/json'
1134
+ },
1135
+ body: JSON.stringify(config)
1136
+ });
1137
+
1138
+ if (!result) return;
1139
+
1140
+ showToast('设置已保存', 'success');
1141
+ } catch (error) {
1142
+ console.error('保存设置失败:', error);
1143
+ showToast('保存设置失败: ' + error.message, 'error');
1144
+ }
1145
+ }
1146
+
1147
+ // 显示确认对话框
1148
+ function showConfirmDialog(message, callback) {
1149
+ document.getElementById('confirm-message').textContent = message;
1150
+ const confirmModal = new bootstrap.Modal(document.getElementById('confirmModal'));
1151
+
1152
+ // 确认按钮事件
1153
+ document.getElementById('confirm-btn').onclick = () => {
1154
+ confirmModal.hide();
1155
+ if (typeof callback === 'function') {
1156
+ callback();
1157
+ }
1158
+ };
1159
+
1160
+ confirmModal.show();
1161
+ }
1162
+
1163
+ // 显示提示消息
1164
+ function showToast(message, type = 'info') {
1165
+ // 创建Toast元素
1166
+ const toastId = 'toast-' + Date.now();
1167
+ const toastEl = document.createElement('div');
1168
+ toastEl.className = `toast align-items-center text-white bg-${type}`;
1169
+ toastEl.id = toastId;
1170
+ toastEl.setAttribute('role', 'alert');
1171
+ toastEl.setAttribute('aria-live', 'assertive');
1172
+ toastEl.setAttribute('aria-atomic', 'true');
1173
+
1174
+ toastEl.innerHTML = `
1175
+ <div class="d-flex">
1176
+ <div class="toast-body">
1177
+ ${message}
1178
+ </div>
1179
+ <button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
1180
+ </div>
1181
+ `;
1182
+
1183
+ // 添加到容器
1184
+ document.getElementById('toast-container').appendChild(toastEl);
1185
+
1186
+ // 显示Toast
1187
+ const toast = new bootstrap.Toast(toastEl, {
1188
+ autohide: true,
1189
+ delay: 3000
1190
+ });
1191
+ toast.show();
1192
+
1193
+ // 监听隐藏事件,删除元素
1194
+ toastEl.addEventListener('hidden.bs.toast', () => {
1195
+ toastEl.remove();
1196
+ });
1197
+ }
1198
+
1199
+ // 加载仪表盘数据
1200
+ async function loadDashboard() {
1201
+ try {
1202
+ // 先加载密钥统计
1203
+ await loadKeyStats();
1204
+
1205
+ // 再加载使用统计
1206
+ await loadUsageStats();
1207
+
1208
+ // 加载最近的密钥
1209
+ await loadRecentKeys();
1210
+ } catch (error) {
1211
+ console.error('加载仪表盘数据失败:', error);
1212
+ showToast('仪表盘数据加载失败', 'error');
1213
+ }
1214
+ }
1215
+
1216
+ // 加载密钥统计
1217
+ async function loadKeyStats() {
1218
+ try {
1219
+ const data = await apiRequest('/api/keys');
1220
+ if (!data) return;
1221
+
1222
+ keysData = data;
1223
+
1224
+ // 计算统计数据
1225
+ const totalKeys = keysData.length;
1226
+ const activeKeys = keysData.filter(key => key.is_enabled).length;
1227
+ const disabledKeys = totalKeys - activeKeys;
1228
+
1229
+ // 更新统计卡片
1230
+ document.getElementById('total-keys').textContent = totalKeys;
1231
+ document.getElementById('active-keys').textContent = activeKeys;
1232
+ document.getElementById('disabled-keys').textContent = disabledKeys;
1233
+ } catch (error) {
1234
+ console.error('加载密钥统计失败:', error);
1235
+ }
1236
+ }
1237
+
1238
+ // 加载使用统计
1239
+ async function loadUsageStats() {
1240
+ try {
1241
+ const data = await apiRequest('/api/stats');
1242
+ if (!data) return;
1243
+
1244
+ // 更新仪表盘统计数据
1245
+ const totalRequests = data.total_requests || 0;
1246
+ const successfulRequests = data.successful_requests || 0;
1247
+ const failedRequests = data.failed_requests || 0;
1248
+
1249
+ // 更新统计卡片
1250
+ document.getElementById('total-requests').textContent = totalRequests;
1251
+ document.getElementById('successful-requests').textContent = successfulRequests;
1252
+ document.getElementById('failed-requests').textContent = failedRequests;
1253
+
1254
+ // 计算成功率
1255
+ const successRate = totalRequests > 0 ? Math.round((successfulRequests / totalRequests) * 100) : 0;
1256
+ document.getElementById('success-rate').textContent = `${successRate}%`;
1257
+
1258
+ // 获取今日使用量
1259
+ const today = new Date().toISOString().split('T')[0];
1260
+ const todayRequests = (data.daily_usage && data.daily_usage[today]) || 0;
1261
+ document.getElementById('today-requests').textContent = todayRequests;
1262
+
1263
+ // 设置图表数据
1264
+ statsData = data;
1265
+
1266
+ // 更新图表
1267
+ renderDashboardRequestsChart(data.daily_usage || {});
1268
+
1269
+ // 渲染请求趋势图和密钥使用分布图
1270
+ renderRequestsChart(data.daily_usage || {});
1271
+ renderKeysUsageChart(data.keys_usage || {});
1272
+
1273
+ console.log("图表数据已更新:", {
1274
+ daily_usage: data.daily_usage,
1275
+ keys_usage: data.keys_usage
1276
+ });
1277
+ } catch (error) {
1278
+ console.error('加载使用统计失败:', error);
1279
+ }
1280
+ }
1281
+
1282
+ // 加载最近的密钥
1283
+ async function loadRecentKeys() {
1284
+ try {
1285
+ if (!keysData || keysData.length === 0) {
1286
+ const data = await apiRequest('/api/keys');
1287
+ if (!data) return;
1288
+ keysData = data;
1289
+ }
1290
+
1291
+ // 按创建时间排序,获取最近的5个
1292
+ const recentKeys = [...keysData]
1293
+ .sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
1294
+ .slice(0, 5);
1295
+
1296
+ const recentKeysList = document.getElementById('recent-keys-list');
1297
+ recentKeysList.innerHTML = '';
1298
+
1299
+ if (recentKeys.length === 0) {
1300
+ recentKeysList.innerHTML = '<div class="list-group-item text-center text-muted">暂无密钥数据</div>';
1301
+ return;
1302
+ }
1303
+
1304
+ recentKeys.forEach(key => {
1305
+ const createDate = key.created_at ? new Date(key.created_at * 1000).toLocaleDateString() : '未知';
1306
+ const li = document.createElement('a');
1307
+ li.className = 'list-group-item list-group-item-action d-flex justify-content-between align-items-center';
1308
+ li.href = '#';
1309
+ li.innerHTML = `
1310
+ <div>
1311
+ <strong>${key.name || '未命名'}</strong>
1312
+ <div class="text-muted small">${key.key.substring(0, 10)}...</div>
1313
+ </div>
1314
+ <div>
1315
+ <span class="badge ${key.is_enabled ? 'bg-success' : 'bg-danger'} rounded-pill">
1316
+ ${key.is_enabled ? '启用' : '禁用'}
1317
+ </span>
1318
+ <small class="text-muted ms-2">${createDate}</small>
1319
+ </div>
1320
+ `;
1321
+ recentKeysList.appendChild(li);
1322
+
1323
+ // 点击跳转到密钥管理页面
1324
+ li.addEventListener('click', (e) => {
1325
+ e.preventDefault();
1326
+ document.querySelector('#sidebar a[data-page="keys"]').click();
1327
+ });
1328
+ });
1329
+ } catch (error) {
1330
+ console.error('加载最近密钥失败:', error);
1331
+ }
1332
+ }
1333
+
1334
+ // 渲染仪表盘请求趋势图
1335
+ function renderDashboardRequestsChart(dailyUsage) {
1336
+ // 获取最近7天的日期
1337
+ const dates = [];
1338
+ const now = new Date();
1339
+ for (let i = 6; i >= 0; i--) {
1340
+ const date = new Date(now);
1341
+ date.setDate(now.getDate() - i);
1342
+ dates.push(date.toISOString().split('T')[0]);
1343
+ }
1344
+
1345
+ // 准备图表数据
1346
+ const chartData = {
1347
+ labels: dates.map(date => date.substring(5)), // 只显示月-日
1348
+ datasets: [{
1349
+ label: '每日请求数',
1350
+ data: dates.map(date => (dailyUsage && dailyUsage[date]) || 0),
1351
+ borderColor: '#0d6efd',
1352
+ backgroundColor: 'rgba(13, 110, 253, 0.1)',
1353
+ borderWidth: 2,
1354
+ fill: true,
1355
+ tension: 0.3
1356
+ }]
1357
+ };
1358
+
1359
+ // 销毁现有图表
1360
+ if (dashboardRequestsChart) {
1361
+ dashboardRequestsChart.destroy();
1362
+ }
1363
+
1364
+ // 创建新图表
1365
+ const ctx = document.getElementById('dashboard-requests-chart').getContext('2d');
1366
+ dashboardRequestsChart = new Chart(ctx, {
1367
+ type: 'line',
1368
+ data: chartData,
1369
+ options: {
1370
+ responsive: true,
1371
+ maintainAspectRatio: false,
1372
+ plugins: {
1373
+ legend: {
1374
+ display: false
1375
+ },
1376
+ tooltip: {
1377
+ mode: 'index',
1378
+ intersect: false
1379
+ }
1380
+ },
1381
+ scales: {
1382
+ x: {
1383
+ grid: {
1384
+ display: false
1385
+ }
1386
+ },
1387
+ y: {
1388
+ beginAtZero: true,
1389
+ ticks: {
1390
+ precision: 0
1391
+ }
1392
+ }
1393
+ }
1394
+ }
1395
+ });
1396
+ }
1397
+
1398
+ // 批量导入密钥预览
1399
+ async function previewImport() {
1400
+ const keysText = document.getElementById('keys-text').value.trim();
1401
+
1402
+ if (!keysText) {
1403
+ showToast('请输入或上传密钥数据', 'warning');
1404
+ return;
1405
+ }
1406
+
1407
+ try {
1408
+ // 解析密钥数据
1409
+ const lines = keysText.split('\n').filter(line => line.trim());
1410
+ importPreviewData = [];
1411
+
1412
+ console.log(`开始处理 ${lines.length} 行数据`);
1413
+
1414
+ for (let i = 0; i < lines.length; i++) {
1415
+ try {
1416
+ const line = lines[i];
1417
+ // 安全获取子字符串
1418
+ const safeSubstring = (str, start, end) => {
1419
+ if (!str) return '';
1420
+ return str.substring(start, Math.min(end, str.length));
1421
+ };
1422
+
1423
+ console.log(`处理第 ${i+1} 行: ${safeSubstring(line, 0, 10)}...`);
1424
+
1425
+ // 检查是否是单行密钥格式(不包含逗号)
1426
+ if (!line.includes(',')) {
1427
+ const keyValue = line.trim();
1428
+ console.log(` 单行格式,密钥值: ${safeSubstring(keyValue, 0, 5)}...`);
1429
+
1430
+ // 几乎不做验证 - 只要不是空字符串或太短就接受
1431
+ if (!keyValue || keyValue.length < 5) {
1432
+ console.log(` 密钥太短,跳过`);
1433
+ continue; // 跳过太短的密钥
1434
+ }
1435
+
1436
+ // 为密钥自动生成名称(使用前5位)
1437
+ const keyName = `密钥_${safeSubstring(keyValue, 0, 5)}`;
1438
+
1439
+ importPreviewData.push({
1440
+ name: keyName,
1441
+ key: keyValue,
1442
+ weight: 1,
1443
+ rate_limit: 60,
1444
+ enabled: document.getElementById('auto-enable-keys').checked
1445
+ });
1446
+ console.log(` 添加到预览数据,当前共 ${importPreviewData.length} 个`);
1447
+ continue;
1448
+ }
1449
+
1450
+ // 处理标准格式(带逗号分隔)
1451
+ const parts = line.split(',').map(part => part.trim());
1452
+ console.log(` 标准格式,分割后有 ${parts.length} 部分`);
1453
+
1454
+ if (parts.length < 2) {
1455
+ console.log(` 部分数量不足2,跳过`);
1456
+ continue; // 跳过格式不正确的行
1457
+ }
1458
+
1459
+ const keyName = parts[0] || `密钥_未命名_${i+1}`;
1460
+ const keyValue = parts[1] || '';
1461
+ const weight = parts.length > 2 ? parseInt(parts[2]) || 1 : 1;
1462
+ const rateLimit = parts.length > 3 ? parseInt(parts[3]) || 60 : 60;
1463
+
1464
+ console.log(` 名称: ${keyName}, 密钥: ${safeSubstring(keyValue, 0, 5)}..., 权重: ${weight}, 速率: ${rateLimit}`);
1465
+
1466
+ // 几乎不做验证 - 只要不是空字符串或太短就接受
1467
+ if (!keyValue || keyValue.length < 5) {
1468
+ console.log(` 密钥太短,跳过`);
1469
+ continue; // 跳过太短的密钥
1470
+ }
1471
+
1472
+ importPreviewData.push({
1473
+ name: keyName,
1474
+ key: keyValue,
1475
+ weight: weight,
1476
+ rate_limit: rateLimit,
1477
+ enabled: document.getElementById('auto-enable-keys').checked
1478
+ });
1479
+ console.log(` 添加到预览数据,当前共 ${importPreviewData.length} 个`);
1480
+ } catch (lineError) {
1481
+ console.error(`处理第 ${i+1} 行时出错:`, lineError);
1482
+ // 继续处理下一行
1483
+ continue;
1484
+ }
1485
+ }
1486
+
1487
+ console.log(`处理完成,共有 ${importPreviewData.length} 个有效密钥`);
1488
+
1489
+ // 显示预览
1490
+ if (importPreviewData.length > 0) {
1491
+ renderImportPreview();
1492
+ document.getElementById('preview-count').textContent = importPreviewData.length;
1493
+ document.getElementById('import-preview').style.display = 'block';
1494
+ document.getElementById('confirm-import-btn').disabled = false;
1495
+ console.log('预览渲染完成,启用导入按钮');
1496
+ } else {
1497
+ showToast('未找到有效的密钥数据', 'warning');
1498
+ document.getElementById('import-preview').style.display = 'none';
1499
+ document.getElementById('confirm-import-btn').disabled = true;
1500
+ console.log('未找到有效密钥,禁用导入按钮');
1501
+ }
1502
+ } catch (error) {
1503
+ console.error('预览导入失败:', error);
1504
+ showToast('预览失败: ' + error.message, 'danger');
1505
+ }
1506
+ }
1507
+
1508
+ // 渲染导入预览表格
1509
+ function renderImportPreview() {
1510
+ const tableBody = document.getElementById('preview-table-body');
1511
+ tableBody.innerHTML = '';
1512
+
1513
+ // 限制预览最多显示10行
1514
+ const displayData = importPreviewData.slice(0, 10);
1515
+
1516
+ displayData.forEach(key => {
1517
+ const row = document.createElement('tr');
1518
+
1519
+ // 名称
1520
+ const nameCell = document.createElement('td');
1521
+ nameCell.textContent = key.name;
1522
+ row.appendChild(nameCell);
1523
+
1524
+ // 密钥(部分隐藏)
1525
+ const keyCell = document.createElement('td');
1526
+ const maskedKey = key.key.substring(0, 5) + '...' + key.key.substring(key.key.length - 4);
1527
+ keyCell.textContent = maskedKey;
1528
+ row.appendChild(keyCell);
1529
+
1530
+ // 权重
1531
+ const weightCell = document.createElement('td');
1532
+ weightCell.textContent = key.weight;
1533
+ row.appendChild(weightCell);
1534
+
1535
+ // 速率限制
1536
+ const rateLimitCell = document.createElement('td');
1537
+ rateLimitCell.textContent = key.rate_limit;
1538
+ row.appendChild(rateLimitCell);
1539
+
1540
+ tableBody.appendChild(row);
1541
+ });
1542
+
1543
+ // 如果有更多未显示的行
1544
+ if (importPreviewData.length > 10) {
1545
+ const moreRow = document.createElement('tr');
1546
+ const moreCell = document.createElement('td');
1547
+ moreCell.colSpan = 4;
1548
+ moreCell.textContent = `... 另外 ${importPreviewData.length - 10} 个密钥未在预览中显示`;
1549
+ moreCell.className = 'text-center text-muted';
1550
+ moreRow.appendChild(moreCell);
1551
+ tableBody.appendChild(moreRow);
1552
+ }
1553
+ }
1554
+
1555
+ // 确认导入密钥
1556
+ async function confirmImport() {
1557
+ if (importPreviewData.length === 0) {
1558
+ showToast('没有要导入的密钥', 'warning');
1559
+ return;
1560
+ }
1561
+
1562
+ try {
1563
+ // 显示加载状态
1564
+ const importBtn = document.getElementById('confirm-import-btn');
1565
+ const originalText = importBtn.textContent;
1566
+ importBtn.disabled = true;
1567
+ importBtn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 导入中...';
1568
+
1569
+ console.log('准备发送批量导入请求,数据预览:', importPreviewData.slice(0, 2));
1570
+
1571
+ // 简化数据处理
1572
+ const requestData = {
1573
+ action: "import",
1574
+ keys: []
1575
+ };
1576
+
1577
+ // 手动将每个importPreviewData项转换为普通对象
1578
+ for (const item of importPreviewData) {
1579
+ requestData.keys.push({
1580
+ name: item.name || "",
1581
+ key: item.key || "",
1582
+ weight: item.weight || 1,
1583
+ rate_limit: item.rate_limit || 60,
1584
+ enabled: item.enabled !== undefined ? item.enabled : true
1585
+ });
1586
+ }
1587
+
1588
+ console.log('发送请求到 /api/keys/batch,请求数据:', requestData);
1589
+
1590
+ // 发送请求
1591
+ const response = await apiRequest('/api/keys/batch', {
1592
+ method: 'POST',
1593
+ headers: {
1594
+ 'Content-Type': 'application/json'
1595
+ },
1596
+ body: JSON.stringify(requestData)
1597
+ });
1598
+
1599
+ console.log('收到批量导入响应:', response);
1600
+
1601
+ if (response && response.success) {
1602
+ // 隐藏模态框
1603
+ bootstrap.Modal.getInstance(document.getElementById('importKeysModal')).hide();
1604
+
1605
+ // 刷新密钥列表
1606
+ await loadKeys();
1607
+
1608
+ // 显示成功消息
1609
+ const successCount = response.imported || importPreviewData.length;
1610
+ const skippedCount = response.skipped || 0;
1611
+ let message = `成功导入 ${successCount} 个密钥`;
1612
+ if (skippedCount > 0) {
1613
+ message += `,${skippedCount} 个重复密钥已跳过`;
1614
+ }
1615
+ showToast(message, 'success');
1616
+ console.log('导入成功完成');
1617
+ } else {
1618
+ const errorMsg = (response && response.message) ? response.message : '未知错误';
1619
+ showToast('导入失败: ' + errorMsg, 'danger');
1620
+ console.error('导入失败,服务器响应:', response);
1621
+ }
1622
+ } catch (error) {
1623
+ console.error('导入过程中发生异常:', error);
1624
+ showToast('导入失败: ' + error.message, 'danger');
1625
+ } finally {
1626
+ // 恢复按钮状态
1627
+ const importBtn = document.getElementById('confirm-import-btn');
1628
+ importBtn.disabled = false;
1629
+ importBtn.textContent = '导入';
1630
+ }
1631
+ }
src/utils.py ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import uuid
3
+ import aiohttp
4
+ import aiofiles
5
+ import logging
6
+ import ssl
7
+ from urllib.parse import urlparse
8
+ from .config import Config
9
+
10
+ # 初始化日志
11
+ logger = logging.getLogger("sora-api.utils")
12
+
13
+ # 图片本地化调试开关
14
+ IMAGE_DEBUG = os.getenv("IMAGE_DEBUG", "").lower() in ("true", "1", "yes")
15
+
16
+ # 修复Python 3.11之前版本中HTTPS代理处理HTTPS请求的问题
17
+ # 参考: https://docs.aiohttp.org/en/stable/client_advanced.html#proxy-support
18
+ try:
19
+ import aiohttp.connector
20
+ orig_create_connection = aiohttp.connector.TCPConnector._create_connection
21
+
22
+ async def patched_create_connection(self, req, traces, timeout):
23
+ if req.ssl and req.proxy and req.proxy.scheme == 'https':
24
+ # 为代理连接创建SSL上下文
25
+ proxy_ssl = ssl.create_default_context()
26
+ req.proxy_ssl = proxy_ssl
27
+
28
+ if IMAGE_DEBUG:
29
+ logger.debug("已应用HTTPS代理补丁")
30
+
31
+ return await orig_create_connection(self, req, traces, timeout)
32
+
33
+ # 应用猴子补丁
34
+ aiohttp.connector.TCPConnector._create_connection = patched_create_connection
35
+
36
+ if IMAGE_DEBUG:
37
+ logger.debug("已启用aiohttp HTTPS代理支持补丁")
38
+ except Exception as e:
39
+ logger.warning(f"应用HTTPS代理补丁失败: {e}")
40
+
41
+ async def download_and_save_image(image_url: str) -> str:
42
+ """
43
+ 下载图片并保存到本地
44
+
45
+ Args:
46
+ image_url: 图片URL
47
+
48
+ Returns:
49
+ 本地化后的图片URL
50
+ """
51
+ # 如果未启用本地化或URL已经是本地路径,直接返回
52
+ if not Config.IMAGE_LOCALIZATION:
53
+ if IMAGE_DEBUG:
54
+ logger.debug(f"图片本地化未启用,返回原始URL: {image_url}")
55
+ return image_url
56
+
57
+ # 检查是否已经是本地URL
58
+ # 准备URL检测所需的信息
59
+ parsed_base_url = urlparse(Config.BASE_URL)
60
+ base_path = parsed_base_url.path.rstrip('/')
61
+
62
+ # 直接检查常见的图片URL模式
63
+ local_url_patterns = [
64
+ "/images/",
65
+ "/static/images/",
66
+ f"{base_path}/images/",
67
+ f"{base_path}/static/images/"
68
+ ]
69
+
70
+ # 检查是否有自定义前缀
71
+ if Config.STATIC_PATH_PREFIX:
72
+ prefix = Config.STATIC_PATH_PREFIX
73
+ if not prefix.startswith('/'):
74
+ prefix = f"/{prefix}"
75
+ local_url_patterns.append(f"{prefix}/images/")
76
+ local_url_patterns.append(f"{base_path}{prefix}/images/")
77
+
78
+ # 检查是否符合任何本地URL模式
79
+ is_local_url = any(pattern in image_url for pattern in local_url_patterns)
80
+
81
+ if is_local_url:
82
+ if IMAGE_DEBUG:
83
+ logger.debug(f"URL已是本地图片路径: {image_url}")
84
+
85
+ # 如果是相对路径,补充完整的URL
86
+ if image_url.startswith("/"):
87
+ return f"{parsed_base_url.scheme}://{parsed_base_url.netloc}{image_url}"
88
+ return image_url
89
+
90
+ try:
91
+ # 生成文件名和保存路径
92
+ parsed_url = urlparse(image_url)
93
+ file_extension = os.path.splitext(parsed_url.path)[1] or ".png"
94
+ filename = f"{uuid.uuid4()}{file_extension}"
95
+ save_path = os.path.join(Config.IMAGE_SAVE_DIR, filename)
96
+
97
+ # 确保保存目录存在
98
+ os.makedirs(Config.IMAGE_SAVE_DIR, exist_ok=True)
99
+
100
+ if IMAGE_DEBUG:
101
+ logger.debug(f"下载图片: {image_url} -> {save_path}")
102
+
103
+ # 配置代理
104
+ proxy = None
105
+ if Config.PROXY_HOST and Config.PROXY_PORT:
106
+ proxy_auth = None
107
+ if Config.PROXY_USER and Config.PROXY_PASS:
108
+ proxy_auth = aiohttp.BasicAuth(Config.PROXY_USER, Config.PROXY_PASS)
109
+
110
+ proxy_url = f"http://{Config.PROXY_HOST}:{Config.PROXY_PORT}"
111
+ if IMAGE_DEBUG:
112
+ auth_info = f" (使用认证)" if proxy_auth else ""
113
+ logger.debug(f"使用代理: {proxy_url}{auth_info}")
114
+ proxy = proxy_url
115
+
116
+ # 下载图片
117
+ timeout = aiohttp.ClientTimeout(total=60)
118
+ async with aiohttp.ClientSession(timeout=timeout) as session:
119
+ # 创建请求参数
120
+ request_kwargs = {"timeout": 30}
121
+ if proxy:
122
+ request_kwargs["proxy"] = proxy
123
+ if Config.PROXY_USER and Config.PROXY_PASS:
124
+ request_kwargs["proxy_auth"] = aiohttp.BasicAuth(Config.PROXY_USER, Config.PROXY_PASS)
125
+
126
+ async with session.get(image_url, **request_kwargs) as response:
127
+ if response.status != 200:
128
+ logger.warning(f"下载失败,状态码: {response.status}, URL: {image_url}")
129
+ return image_url
130
+
131
+ content = await response.read()
132
+ if not content:
133
+ logger.warning("下载内容为空")
134
+ return image_url
135
+
136
+ # 保存图片
137
+ async with aiofiles.open(save_path, "wb") as f:
138
+ await f.write(content)
139
+
140
+ # 检查文件是否成功保存
141
+ if not os.path.exists(save_path) or os.path.getsize(save_path) == 0:
142
+ logger.warning(f"图片保存失败: {save_path}")
143
+ if os.path.exists(save_path):
144
+ os.remove(save_path)
145
+ return image_url
146
+
147
+ # 返回本地URL
148
+ # 获取文件名
149
+ filename = os.path.basename(save_path)
150
+
151
+ # 解析基础URL
152
+ parsed_base_url = urlparse(Config.BASE_URL)
153
+ base_path = parsed_base_url.path.rstrip('/')
154
+
155
+ # 使用固定的图片URL格式
156
+ relative_url = f"/images/{filename}"
157
+
158
+ # 如果设置了STATIC_PATH_PREFIX,优先使用该前缀
159
+ if Config.STATIC_PATH_PREFIX:
160
+ prefix = Config.STATIC_PATH_PREFIX
161
+ if not prefix.startswith('/'):
162
+ prefix = f"/{prefix}"
163
+ relative_url = f"{prefix}/images/{filename}"
164
+
165
+ # 如果BASE_URL有子路径,添加到相对路径前
166
+ if base_path:
167
+ relative_url = f"{base_path}{relative_url}"
168
+
169
+ # 处理重复斜杠
170
+ while "//" in relative_url:
171
+ relative_url = relative_url.replace("//", "/")
172
+
173
+ # 生成完整URL
174
+ full_url = f"{parsed_base_url.scheme}://{parsed_base_url.netloc}{relative_url}"
175
+
176
+ if IMAGE_DEBUG:
177
+ logger.debug(f"图片保存成功: {full_url}")
178
+ logger.debug(f"图片保存路径: {save_path}")
179
+
180
+ return full_url
181
+ except Exception as e:
182
+ logger.error(f"图片下载失败: {str(e)}", exc_info=IMAGE_DEBUG)
183
+ return image_url
184
+
185
+ async def localize_image_urls(image_urls: list) -> list:
186
+ """
187
+ 批量将图片URL本地化
188
+
189
+ Args:
190
+ image_urls: 图片URL列表
191
+
192
+ Returns:
193
+ 本地化后的URL列表
194
+ """
195
+ if not Config.IMAGE_LOCALIZATION or not image_urls:
196
+ return image_urls
197
+
198
+ if IMAGE_DEBUG:
199
+ logger.debug(f"本地化 {len(image_urls)} 个URL: {image_urls}")
200
+ else:
201
+ logger.info(f"本地化 {len(image_urls)} 个图片")
202
+
203
+ localized_urls = []
204
+ for url in image_urls:
205
+ localized_url = await download_and_save_image(url)
206
+ localized_urls.append(localized_url)
207
+
208
+ return localized_urls
test_client.py ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ import requests
4
+ import json
5
+ import time
6
+ import sys
7
+ import base64
8
+ import os
9
+
10
+ # 设置UTF-8编码
11
+ if sys.platform.startswith('win'):
12
+ os.system("chcp 65001")
13
+ if hasattr(sys.stdout, 'reconfigure'):
14
+ sys.stdout.reconfigure(encoding='utf-8')
15
+
16
+ API_URL = "http://127.0.0.1:8890/v1/chat/completions"
17
+ API_KEY = "sk-123456" # 替换为实际的API key
18
+
19
+ def test_text_to_image(prompt="生成一只可爱的猫咪", stream=False):
20
+ """测试文本到图像生成"""
21
+ print(f"\n===== 测试文本到图像生成 =====")
22
+ try:
23
+ print(f"提示词: '{prompt}'")
24
+ except UnicodeEncodeError:
25
+ print(f"提示词: [包含非ASCII字符]")
26
+ print(f"流式响应: {stream}")
27
+
28
+ headers = {
29
+ "Content-Type": "application/json",
30
+ "Authorization": f"Bearer {API_KEY}"
31
+ }
32
+
33
+ payload = {
34
+ "model": "sora-1.0",
35
+ "messages": [
36
+ {"role": "user", "content": prompt}
37
+ ],
38
+ "n": 1,
39
+ "stream": stream
40
+ }
41
+
42
+ start_time = time.time()
43
+ response = requests.post(
44
+ API_URL,
45
+ headers=headers,
46
+ json=payload,
47
+ stream=stream
48
+ )
49
+
50
+ if response.status_code != 200:
51
+ print(f"错误: 状态码 {response.status_code}")
52
+ print(response.text)
53
+ return
54
+
55
+ if stream:
56
+ # 处理流式响应
57
+ print("流式响应内容:")
58
+ for line in response.iter_lines():
59
+ if line:
60
+ line = line.decode('utf-8')
61
+ if line.startswith("data: "):
62
+ data = line[6:]
63
+ if data == "[DONE]":
64
+ print("[完成]")
65
+ else:
66
+ try:
67
+ json_data = json.loads(data)
68
+ if 'choices' in json_data and json_data['choices'] and 'delta' in json_data['choices'][0]:
69
+ delta = json_data['choices'][0]['delta']
70
+ if 'content' in delta:
71
+ print(f"接收内容: {delta['content']}")
72
+ except Exception as e:
73
+ print(f"解析响应时出错: {e}")
74
+ else:
75
+ # 处理普通响应
76
+ try:
77
+ data = response.json()
78
+ print(f"响应内容:")
79
+ print(json.dumps(data, indent=2, ensure_ascii=False))
80
+
81
+ if 'choices' in data and data['choices']:
82
+ image_url = None
83
+ content = data['choices'][0]['message']['content']
84
+ if "![Generated Image](" in content:
85
+ image_url = content.split("![Generated Image](")[1].split(")")[0]
86
+ print(f"\n生成的图片URL: {image_url}")
87
+ except Exception as e:
88
+ print(f"解析响应时出错: {e}")
89
+
90
+ elapsed = time.time() - start_time
91
+ print(f"请求耗时: {elapsed:.2f}秒")
92
+
93
+ def test_image_to_image(image_path, prompt="将这张图片变成动漫风格"):
94
+ """测试图像到图像生成(Remix)"""
95
+ print(f"\n===== 测试图像到图像生成 =====")
96
+ print(f"图片路径: '{image_path}'")
97
+ print(f"提示词: '{prompt}'")
98
+
99
+ # 读取并转换图片为base64
100
+ try:
101
+ with open(image_path, "rb") as image_file:
102
+ base64_image = base64.b64encode(image_file.read()).decode('utf-8')
103
+ except Exception as e:
104
+ print(f"读取图片失败: {e}")
105
+ return
106
+
107
+ # 构建请求
108
+ headers = {
109
+ "Content-Type": "application/json",
110
+ "Authorization": f"Bearer {API_KEY}"
111
+ }
112
+
113
+ payload = {
114
+ "model": "sora-1.0",
115
+ "messages": [
116
+ {"role": "user", "content": f"data:image/jpeg;base64,{base64_image}\n{prompt}"}
117
+ ],
118
+ "n": 1,
119
+ "stream": False
120
+ }
121
+
122
+ start_time = time.time()
123
+ response = requests.post(
124
+ API_URL,
125
+ headers=headers,
126
+ json=payload
127
+ )
128
+
129
+ if response.status_code != 200:
130
+ print(f"错误: 状态码 {response.status_code}")
131
+ print(response.text)
132
+ return
133
+
134
+ # 处理响应
135
+ try:
136
+ data = response.json()
137
+ print(f"响应内容:")
138
+ print(json.dumps(data, indent=2, ensure_ascii=False))
139
+
140
+ if 'choices' in data and data['choices']:
141
+ image_url = None
142
+ content = data['choices'][0]['message']['content']
143
+ if "![Generated Image](" in content:
144
+ image_url = content.split("![Generated Image](")[1].split(")")[0]
145
+ print(f"\n生成的图片URL: {image_url}")
146
+ except Exception as e:
147
+ print(f"解析响应时出错: {e}")
148
+
149
+ elapsed = time.time() - start_time
150
+ print(f"请求耗时: {elapsed:.2f}秒")
151
+
152
+ def main():
153
+ """主函数"""
154
+ if len(sys.argv) < 2:
155
+ print("用法: python test_client.py <测试类型> [参数...]")
156
+ print("测试类型:")
157
+ print(" text2img <提示词> [stream=true/false]")
158
+ print(" img2img <图片路径> <提示词>")
159
+ return
160
+
161
+ test_type = sys.argv[1].lower()
162
+
163
+ if test_type == "text2img":
164
+ prompt = sys.argv[2] if len(sys.argv) > 2 else "生成一只可爱的猫咪"
165
+ stream = False
166
+ if len(sys.argv) > 3 and sys.argv[3].lower() == "stream=true":
167
+ stream = True
168
+ test_text_to_image(prompt, stream)
169
+ elif test_type == "img2img":
170
+ if len(sys.argv) < 3:
171
+ print("错误: 需要图片路径")
172
+ return
173
+ image_path = sys.argv[2]
174
+ prompt = sys.argv[3] if len(sys.argv) > 3 else "将这张图片变成动漫风格"
175
+ test_image_to_image(image_path, prompt)
176
+ else:
177
+ print(f"错误: 未知的测试类型 '{test_type}'")
178
+
179
+ if __name__ == "__main__":
180
+ main()
test_image_download.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import os
3
+ import sys
4
+ import logging
5
+
6
+ # 配置日志
7
+ logging.basicConfig(
8
+ level=logging.DEBUG,
9
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
10
+ )
11
+
12
+ # 添加src目录到Python路径
13
+ sys.path.append(os.path.dirname(os.path.abspath(__file__)))
14
+
15
+ from src.utils import download_and_save_image, localize_image_urls
16
+ from src.config import Config
17
+
18
+ # 设置为True启用本地化
19
+ Config.IMAGE_LOCALIZATION = True
20
+ # 打开调试日志
21
+ os.environ["IMAGE_DEBUG"] = "true"
22
+ # 确保目录存在
23
+ os.makedirs(Config.IMAGE_SAVE_DIR, exist_ok=True)
24
+
25
+ async def test_single_download():
26
+ """测试单个图片下载"""
27
+ # 使用一个可靠的图片URL进行测试
28
+ test_url = "https://pic.baidu.com/feed/b90e7bec54e736d1f9ddbe94c2691f254d4ade13.jpeg"
29
+ print(f"测试单个图片下载: {test_url}")
30
+
31
+ local_url = await download_and_save_image(test_url)
32
+ print(f"下载结果: {local_url}")
33
+
34
+ # 检查文件是否真的下载了
35
+ if local_url.startswith("http"):
36
+ # 处理完整URL的情况
37
+ url_path = local_url.split(Config.BASE_URL, 1)[-1]
38
+ if url_path.startswith("/static/"):
39
+ relative_path = url_path[len("/static/"):]
40
+ file_path = os.path.join(Config.STATIC_DIR, relative_path)
41
+ print(f"完整URL转换为文件路径: {file_path}")
42
+
43
+ if os.path.exists(file_path):
44
+ print(f"文件存在: {file_path}, 大小: {os.path.getsize(file_path)} 字节")
45
+ else:
46
+ print(f"文件不存在: {file_path}")
47
+ # 尝试查找可能的文件
48
+ dir_path = os.path.dirname(file_path)
49
+ if os.path.exists(dir_path):
50
+ print(f"目录存在: {dir_path}")
51
+ print(f"目录内容: {os.listdir(dir_path)}")
52
+ else:
53
+ print(f"目录不存在: {dir_path}")
54
+ else:
55
+ print(f"URL格式异常: {local_url}")
56
+ elif local_url.startswith("/static/"):
57
+ # 从URL恢复实际的文件路径
58
+ relative_path = local_url[len("/static/"):]
59
+ file_path = os.path.join(Config.STATIC_DIR, relative_path)
60
+ print(f"相对URL转换为文件路径: {file_path}")
61
+
62
+ if os.path.exists(file_path):
63
+ print(f"文件存在: {file_path}, 大小: {os.path.getsize(file_path)} 字节")
64
+ else:
65
+ print(f"文件不存在: {file_path}")
66
+ else:
67
+ print("下载失败,返回了原始URL")
68
+
69
+ async def test_multiple_downloads():
70
+ """测试多个图片下载"""
71
+ test_urls = [
72
+ "https://pic.baidu.com/feed/b90e7bec54e736d1f9ddbe94c2691f254d4ade13.jpeg",
73
+ "https://pic1.zhimg.com/v2-b78b719d8782ad5146851b87bbd3a9fb_r.jpg"
74
+ ]
75
+ print(f"\n测试多个图片下载: {test_urls}")
76
+
77
+ local_urls = await localize_image_urls(test_urls)
78
+
79
+ print(f"本地化结果: {local_urls}")
80
+
81
+ # 验证所有文件是否下载成功
82
+ for i, url in enumerate(local_urls):
83
+ print(f"\n检查文件 {i+1}:")
84
+ if url.startswith("http"):
85
+ # 处理完整URL的情况
86
+ if url.startswith(Config.BASE_URL):
87
+ url_path = url.split(Config.BASE_URL, 1)[-1]
88
+ if url_path.startswith("/static/"):
89
+ relative_path = url_path[len("/static/"):]
90
+ file_path = os.path.join(Config.STATIC_DIR, relative_path)
91
+ print(f"完整URL转换为文件路径: {file_path}")
92
+
93
+ if os.path.exists(file_path):
94
+ print(f"文件 {i+1} 存在: {file_path}, 大小: {os.path.getsize(file_path)} 字节")
95
+ else:
96
+ print(f"文件 {i+1} 不存在: {file_path}")
97
+ # 尝试查找可能的文件
98
+ dir_path = os.path.dirname(file_path)
99
+ if os.path.exists(dir_path):
100
+ print(f"目录存在: {dir_path}")
101
+ print(f"目录内容: {os.listdir(dir_path)}")
102
+ else:
103
+ print(f"目录不存在: {dir_path}")
104
+ else:
105
+ print(f"URL格式异常: {url}")
106
+ else:
107
+ print(f"文件 {i+1} 下载失败,返回了原始URL: {url}")
108
+ elif url.startswith("/static/"):
109
+ # 从URL恢复实际的文件路径
110
+ relative_path = url[len("/static/"):]
111
+ file_path = os.path.join(Config.STATIC_DIR, relative_path)
112
+ print(f"相对URL转换为文件路径: {file_path}")
113
+
114
+ if os.path.exists(file_path):
115
+ print(f"文件 {i+1} 存在: {file_path}, 大小: {os.path.getsize(file_path)} 字节")
116
+ else:
117
+ print(f"文件 {i+1} 不存在: {file_path}")
118
+ else:
119
+ print(f"文件 {i+1} 下载失败,返回了原始URL: {url}")
120
+
121
+ async def main():
122
+ print(f"配置信息:")
123
+ print(f"IMAGE_LOCALIZATION: {Config.IMAGE_LOCALIZATION}")
124
+ print(f"STATIC_DIR: {Config.STATIC_DIR}")
125
+ print(f"IMAGE_SAVE_DIR: {Config.IMAGE_SAVE_DIR}")
126
+
127
+ await test_single_download()
128
+ await test_multiple_downloads()
129
+
130
+ if __name__ == "__main__":
131
+ asyncio.run(main())