BirkhoffLee commited on
Commit
d3a7520
·
unverified ·
1 Parent(s): a49e322

refactor: 资源拆分

Browse files
src/auth.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """认证与 Session 管理模块。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import secrets
8
+ import uuid
9
+ from typing import Optional
10
+
11
+ import bcrypt
12
+ from fastapi import HTTPException, Request
13
+ from itsdangerous import BadSignature, SignatureExpired, TimestampSigner
14
+
15
+ # 保持与原实现一致的常量和日志名称
16
+ logger = logging.getLogger("gateway")
17
+
18
+
19
+ # ── Session 配置 ───────────────────────────────────────────────────────────────
20
+ SESSION_COOKIE = "gw_session"
21
+ SESSION_MAX_AGE = 86400 # 24 hours
22
+
23
+ SECRET_KEY = os.environ.get("SESSION_SECRET") or secrets.token_hex(32)
24
+ signer = TimestampSigner(SECRET_KEY)
25
+
26
+ INTERNAL_KEY_SALT = (os.environ.get("INTERNAL_KEY_SALT") or SECRET_KEY).strip()
27
+
28
+
29
+ # ── 用户加载与认证 ────────────────────────────────────────────────────────────
30
+ def _load_users() -> dict[str, str]:
31
+ """从 BASIC_AUTH_USERS 加载用户名密码。"""
32
+ raw = os.environ.get("BASIC_AUTH_USERS", "").replace("\\n", "\n")
33
+ users: dict[str, str] = {}
34
+ for line in raw.splitlines():
35
+ line = line.strip()
36
+ if not line or line.startswith("#"):
37
+ continue
38
+ if ":" not in line:
39
+ logger.warning("Skipping invalid BASIC_AUTH_USERS line (no colon)")
40
+ continue
41
+ username, password = line.split(":", 1)
42
+ username = username.strip()
43
+ password = password.strip()
44
+ if username and password:
45
+ users[username] = password
46
+ if not users:
47
+ logger.error("No valid users found — authentication will always fail")
48
+ return users
49
+
50
+
51
+ USERS = _load_users()
52
+ INTERNAL_KEY_NAMESPACE = uuid.uuid5(uuid.NAMESPACE_DNS, INTERNAL_KEY_SALT)
53
+
54
+
55
+ def _make_internal_api_key(username: str) -> str:
56
+ """基于用户名生成稳定内部 Key(仅服务端使用)。"""
57
+ value = uuid.uuid5(INTERNAL_KEY_NAMESPACE, username)
58
+ return f"sk-{value}"
59
+
60
+
61
+ INTERNAL_KEY_TO_USER = {
62
+ _make_internal_api_key(username): username for username in USERS.keys()
63
+ }
64
+
65
+
66
+ def _verify_credentials(username: str, password: str) -> bool:
67
+ """验证用户名密码,支持明文与 bcrypt。"""
68
+ stored = USERS.get(username)
69
+ if stored is None:
70
+ return False
71
+ if stored.startswith("$2"):
72
+ return bcrypt.checkpw(password.encode(), stored.encode())
73
+ return secrets.compare_digest(stored, password)
74
+
75
+
76
+ # ── Session ──────────────────────────────────────────────────────────────────
77
+ def _make_session(username: str) -> str:
78
+ """生成签名的 Session token。"""
79
+ return signer.sign(username).decode()
80
+
81
+
82
+ def _verify_session(token: str) -> Optional[str]:
83
+ """验证 Session token,返回用户名或 None。"""
84
+ try:
85
+ username = signer.unsign(token, max_age=SESSION_MAX_AGE).decode()
86
+ except (BadSignature, SignatureExpired):
87
+ return None
88
+ if username not in USERS:
89
+ return None
90
+ return username
91
+
92
+
93
+ def _get_session_user(request: Request) -> Optional[str]:
94
+ """从请求 Cookie 中解析当前用户。"""
95
+ token = request.cookies.get(SESSION_COOKIE)
96
+ return _verify_session(token) if token else None
97
+
98
+
99
+ def _require_user(request: Request) -> str:
100
+ """FastAPI 依赖:要求用户已登录,否则抛出 401。"""
101
+ username = _get_session_user(request)
102
+ if not username:
103
+ raise HTTPException(status_code=401, detail="Unauthorized")
104
+ return username
105
+
src/billing.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """计费逻辑与 usage_records 持久化。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Any
7
+
8
+ import storage
9
+
10
+
11
+ # 价格单位:USD / 1M tokens
12
+ DEFAULT_INPUT_PRICE_PER_1M = float(
13
+ os.environ.get("OPENAI_DEFAULT_INPUT_PRICE_PER_1M", "0.15")
14
+ )
15
+ DEFAULT_OUTPUT_PRICE_PER_1M = float(
16
+ os.environ.get("OPENAI_DEFAULT_OUTPUT_PRICE_PER_1M", "0.60")
17
+ )
18
+ MODEL_PRICES_PER_1M: dict[str, tuple[float, float]] = {
19
+ "gpt-4o-mini": (0.15, 0.60),
20
+ "gpt-4.1-mini": (0.40, 1.60),
21
+ "gpt-4.1": (2.00, 8.00),
22
+ "gpt-4o": (2.50, 10.00),
23
+ }
24
+
25
+
26
+ def calc_cost_usd(model: str, prompt_tokens: int, completion_tokens: int) -> float:
27
+ """计算一次请求的美元成本。"""
28
+ model_rates = MODEL_PRICES_PER_1M.get(model, None)
29
+ if model_rates is None:
30
+ in_rate = DEFAULT_INPUT_PRICE_PER_1M
31
+ out_rate = DEFAULT_OUTPUT_PRICE_PER_1M
32
+ else:
33
+ in_rate, out_rate = model_rates
34
+
35
+ cost = (prompt_tokens * in_rate + completion_tokens * out_rate) / 1_000_000.0
36
+ return round(cost, 8)
37
+
38
+
39
+ def record_usage(
40
+ *,
41
+ username: str,
42
+ job_id: str | None,
43
+ model: str,
44
+ prompt_tokens: int,
45
+ completion_tokens: int,
46
+ total_tokens: int,
47
+ ) -> None:
48
+ """记录一次模型调用的 token 使用情况与成本。"""
49
+ cost_usd = calc_cost_usd(model, prompt_tokens, completion_tokens)
50
+ storage.db_execute(
51
+ """
52
+ INSERT INTO usage_records(
53
+ username, job_id, model,
54
+ prompt_tokens, completion_tokens, total_tokens,
55
+ cost_usd, created_at
56
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
57
+ """,
58
+ (
59
+ username,
60
+ job_id,
61
+ model,
62
+ prompt_tokens,
63
+ completion_tokens,
64
+ total_tokens,
65
+ cost_usd,
66
+ storage.now_iso(),
67
+ ),
68
+ )
69
+
70
+
71
+ def get_billing_summary(username: str) -> dict[str, Any]:
72
+ """汇总某个用户的累计账单信息。"""
73
+ row = storage.db_fetchone(
74
+ """
75
+ SELECT
76
+ COALESCE(SUM(prompt_tokens), 0) AS prompt_tokens,
77
+ COALESCE(SUM(completion_tokens), 0) AS completion_tokens,
78
+ COALESCE(SUM(total_tokens), 0) AS total_tokens,
79
+ COALESCE(SUM(cost_usd), 0) AS total_cost_usd
80
+ FROM usage_records
81
+ WHERE username = ?
82
+ """,
83
+ (username,),
84
+ )
85
+ if row is None:
86
+ return {
87
+ "username": username,
88
+ "prompt_tokens": 0,
89
+ "completion_tokens": 0,
90
+ "total_tokens": 0,
91
+ "total_cost_usd": 0.0,
92
+ }
93
+ return {
94
+ "username": username,
95
+ "prompt_tokens": row["prompt_tokens"],
96
+ "completion_tokens": row["completion_tokens"],
97
+ "total_tokens": row["total_tokens"],
98
+ "total_cost_usd": round(float(row["total_cost_usd"]), 8),
99
+ }
100
+
101
+
102
+ def get_billing_records(username: str, limit: int) -> list[dict[str, Any]]:
103
+ """获取用户近期账单记录。"""
104
+ rows = storage.db_fetchall(
105
+ """
106
+ SELECT
107
+ id, username, job_id, model,
108
+ prompt_tokens, completion_tokens, total_tokens,
109
+ cost_usd, created_at
110
+ FROM usage_records
111
+ WHERE username = ?
112
+ ORDER BY created_at DESC
113
+ LIMIT ?
114
+ """,
115
+ (username, limit),
116
+ )
117
+ return [dict(row) for row in rows]
118
+
src/gateway.py CHANGED
@@ -6,29 +6,16 @@ from __future__ import annotations
6
  import asyncio
7
  import contextlib
8
  import html
9
- import json
10
  import logging
11
  import os
12
- import secrets
13
  import shutil
14
- import sqlite3
15
- import threading
16
  import uuid
17
- from datetime import datetime, timezone
18
  from pathlib import Path
19
- from typing import Any, Optional
20
 
21
- import bcrypt
22
  import httpx
23
  from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile
24
- from fastapi.responses import (
25
- FileResponse,
26
- HTMLResponse,
27
- JSONResponse,
28
- RedirectResponse,
29
- Response,
30
- )
31
- from itsdangerous import BadSignature, SignatureExpired, TimestampSigner
32
  from pdf2zh_next import BasicSettings
33
  from pdf2zh_next import OpenAISettings
34
  from pdf2zh_next import PDFSettings
@@ -36,61 +23,24 @@ from pdf2zh_next import SettingsModel
36
  from pdf2zh_next import TranslationSettings
37
  from pdf2zh_next.high_level import do_translate_async_stream
38
 
39
- # ── 配置 ──────────────────────────────────────────────────────────────────────
40
- SESSION_COOKIE = "gw_session"
41
- SESSION_MAX_AGE = 86400 # 24 hours
42
-
43
- SECRET_KEY = os.environ.get("SESSION_SECRET") or secrets.token_hex(32)
44
- signer = TimestampSigner(SECRET_KEY)
45
 
46
- DATA_DIR = Path(os.environ.get("DATA_DIR", "/data"))
47
- UPLOAD_DIR = DATA_DIR / "uploads"
48
- JOB_DIR = DATA_DIR / "jobs"
49
- DB_PATH = DATA_DIR / "gateway.db"
50
 
51
  INTERNAL_OPENAI_BASE_URL = os.environ.get(
52
  "INTERNAL_OPENAI_BASE_URL", "http://127.0.0.1:7860/internal/openai/v1"
53
  )
54
- OPENAI_UPSTREAM_CHAT_URL = os.environ.get(
55
- "OPENAI_UPSTREAM_CHAT_URL", "https://api.openai.com/v1/chat/completions"
56
- )
57
- OPENAI_REAL_API_KEY = os.environ.get("OPENAI_API_KEY", "").strip()
58
 
59
  FIXED_TRANSLATION_MODEL = "SiliconFlowFree"
60
  DEFAULT_LANG_IN = os.environ.get("DEFAULT_LANG_IN", "en").strip()
61
  DEFAULT_LANG_OUT = os.environ.get("DEFAULT_LANG_OUT", "zh").strip()
62
  TRANSLATION_QPS = int(os.environ.get("TRANSLATION_QPS", "4"))
63
 
64
- INTERNAL_KEY_SALT = (os.environ.get("INTERNAL_KEY_SALT") or SECRET_KEY).strip()
65
-
66
- # 模型路由表:模型名 -> 上游配置
67
- MODEL_ROUTE_TABLE: dict[str, dict[str, Any]] = {
68
- "SiliconFlowFree": {
69
- "route_type": "chatproxy",
70
- "base_urls": [
71
- "https://api1.pdf2zh-next.com/chatproxy",
72
- "https://api2.pdf2zh-next.com/chatproxy",
73
- ],
74
- "api_key": "",
75
- }
76
- }
77
-
78
- # 价格单位:USD / 1M tokens
79
- DEFAULT_INPUT_PRICE_PER_1M = float(
80
- os.environ.get("OPENAI_DEFAULT_INPUT_PRICE_PER_1M", "0.15")
81
- )
82
- DEFAULT_OUTPUT_PRICE_PER_1M = float(
83
- os.environ.get("OPENAI_DEFAULT_OUTPUT_PRICE_PER_1M", "0.60")
84
- )
85
- MODEL_PRICES_PER_1M: dict[str, tuple[float, float]] = {
86
- "gpt-4o-mini": (0.15, 0.60),
87
- "gpt-4.1-mini": (0.40, 1.60),
88
- "gpt-4.1": (2.00, 8.00),
89
- "gpt-4o": (2.50, 10.00),
90
- }
91
-
92
- LOCALHOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
93
-
94
  logging.basicConfig(
95
  level=logging.INFO,
96
  format="%(asctime)s %(levelname)s %(name)s - %(message)s",
@@ -98,202 +48,6 @@ logging.basicConfig(
98
  logger = logging.getLogger("gateway")
99
 
100
 
101
- # ── 用户加载与认证 ────────────────────────────────────────────────────────────
102
- def _load_users() -> dict[str, str]:
103
- """从 BASIC_AUTH_USERS 加载用户名密码。"""
104
- raw = os.environ.get("BASIC_AUTH_USERS", "").replace("\\n", "\n")
105
- users: dict[str, str] = {}
106
- for line in raw.splitlines():
107
- line = line.strip()
108
- if not line or line.startswith("#"):
109
- continue
110
- if ":" not in line:
111
- logger.warning("Skipping invalid BASIC_AUTH_USERS line (no colon)")
112
- continue
113
- username, password = line.split(":", 1)
114
- username = username.strip()
115
- password = password.strip()
116
- if username and password:
117
- users[username] = password
118
- if not users:
119
- logger.error("No valid users found — authentication will always fail")
120
- return users
121
-
122
-
123
- USERS = _load_users()
124
- INTERNAL_KEY_NAMESPACE = uuid.uuid5(uuid.NAMESPACE_DNS, INTERNAL_KEY_SALT)
125
-
126
-
127
- def _make_internal_api_key(username: str) -> str:
128
- """基于用户名生成稳定内部 Key(仅服务端使用)。"""
129
- value = uuid.uuid5(INTERNAL_KEY_NAMESPACE, username)
130
- return f"sk-{value}"
131
-
132
-
133
- INTERNAL_KEY_TO_USER = {
134
- _make_internal_api_key(username): username for username in USERS.keys()
135
- }
136
-
137
-
138
- def _verify_credentials(username: str, password: str) -> bool:
139
- """验证用户名密码,支持明文与 bcrypt。"""
140
- stored = USERS.get(username)
141
- if stored is None:
142
- return False
143
- if stored.startswith("$2"):
144
- return bcrypt.checkpw(password.encode(), stored.encode())
145
- return secrets.compare_digest(stored, password)
146
-
147
-
148
- # ── Session ──────────────────────────────────────────────────────────────────
149
- def _make_session(username: str) -> str:
150
- return signer.sign(username).decode()
151
-
152
-
153
- def _verify_session(token: str) -> Optional[str]:
154
- try:
155
- username = signer.unsign(token, max_age=SESSION_MAX_AGE).decode()
156
- except (BadSignature, SignatureExpired):
157
- return None
158
- if username not in USERS:
159
- return None
160
- return username
161
-
162
-
163
- def _get_session_user(request: Request) -> Optional[str]:
164
- token = request.cookies.get(SESSION_COOKIE)
165
- return _verify_session(token) if token else None
166
-
167
-
168
- def _require_user(request: Request) -> str:
169
- username = _get_session_user(request)
170
- if not username:
171
- raise HTTPException(status_code=401, detail="Unauthorized")
172
- return username
173
-
174
-
175
- # ── 存储层 ───────────────────────────────────────────────────────────────────
176
- _db_lock = threading.Lock()
177
- _db_conn: sqlite3.Connection | None = None
178
-
179
-
180
- def _now_iso() -> str:
181
- return datetime.now(timezone.utc).isoformat()
182
-
183
-
184
- def _ensure_data_dirs() -> None:
185
- UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
186
- JOB_DIR.mkdir(parents=True, exist_ok=True)
187
-
188
-
189
- def _init_db() -> None:
190
- global _db_conn
191
- _ensure_data_dirs()
192
- conn = sqlite3.connect(DB_PATH, check_same_thread=False)
193
- conn.row_factory = sqlite3.Row
194
- with conn:
195
- conn.execute(
196
- """
197
- CREATE TABLE IF NOT EXISTS jobs (
198
- id TEXT PRIMARY KEY,
199
- username TEXT NOT NULL,
200
- filename TEXT NOT NULL,
201
- input_path TEXT NOT NULL,
202
- output_dir TEXT NOT NULL,
203
- status TEXT NOT NULL,
204
- progress REAL NOT NULL DEFAULT 0,
205
- message TEXT,
206
- error TEXT,
207
- model TEXT NOT NULL,
208
- lang_in TEXT NOT NULL,
209
- lang_out TEXT NOT NULL,
210
- cancel_requested INTEGER NOT NULL DEFAULT 0,
211
- mono_pdf_path TEXT,
212
- dual_pdf_path TEXT,
213
- glossary_path TEXT,
214
- created_at TEXT NOT NULL,
215
- updated_at TEXT NOT NULL,
216
- started_at TEXT,
217
- finished_at TEXT
218
- )
219
- """
220
- )
221
- conn.execute(
222
- """
223
- CREATE TABLE IF NOT EXISTS usage_records (
224
- id INTEGER PRIMARY KEY AUTOINCREMENT,
225
- username TEXT NOT NULL,
226
- job_id TEXT,
227
- model TEXT NOT NULL,
228
- prompt_tokens INTEGER NOT NULL,
229
- completion_tokens INTEGER NOT NULL,
230
- total_tokens INTEGER NOT NULL,
231
- cost_usd REAL NOT NULL,
232
- created_at TEXT NOT NULL
233
- )
234
- """
235
- )
236
- conn.execute(
237
- """
238
- CREATE INDEX IF NOT EXISTS idx_jobs_user_time
239
- ON jobs(username, created_at DESC)
240
- """
241
- )
242
- conn.execute(
243
- """
244
- CREATE INDEX IF NOT EXISTS idx_usage_user_time
245
- ON usage_records(username, created_at DESC)
246
- """
247
- )
248
- _db_conn = conn
249
-
250
-
251
- def _db_execute(sql: str, params: tuple[Any, ...] = ()) -> None:
252
- if _db_conn is None:
253
- raise RuntimeError("DB is not initialized")
254
- with _db_lock, _db_conn:
255
- _db_conn.execute(sql, params)
256
-
257
-
258
- def _db_fetchone(sql: str, params: tuple[Any, ...] = ()) -> sqlite3.Row | None:
259
- if _db_conn is None:
260
- raise RuntimeError("DB is not initialized")
261
- with _db_lock:
262
- return _db_conn.execute(sql, params).fetchone()
263
-
264
-
265
- def _db_fetchall(sql: str, params: tuple[Any, ...] = ()) -> list[sqlite3.Row]:
266
- if _db_conn is None:
267
- raise RuntimeError("DB is not initialized")
268
- with _db_lock:
269
- return _db_conn.execute(sql, params).fetchall()
270
-
271
-
272
- def _update_job(job_id: str, **fields: Any) -> None:
273
- if not fields:
274
- return
275
- fields["updated_at"] = _now_iso()
276
- set_clause = ", ".join(f"{k} = ?" for k in fields.keys())
277
- params = tuple(fields.values()) + (job_id,)
278
- _db_execute(f"UPDATE jobs SET {set_clause} WHERE id = ?", params)
279
-
280
-
281
- def _row_to_job_dict(row: sqlite3.Row) -> dict[str, Any]:
282
- job = dict(row)
283
- job["artifact_urls"] = {
284
- "mono": f"/api/jobs/{job['id']}/artifacts/mono"
285
- if job.get("mono_pdf_path")
286
- else None,
287
- "dual": f"/api/jobs/{job['id']}/artifacts/dual"
288
- if job.get("dual_pdf_path")
289
- else None,
290
- "glossary": f"/api/jobs/{job['id']}/artifacts/glossary"
291
- if job.get("glossary_path")
292
- else None,
293
- }
294
- return job
295
-
296
-
297
  # ── 任务执行 ───────────────────────────────────────────────────────────────────
298
  _job_queue: asyncio.Queue[str] = asyncio.Queue()
299
  _worker_task: asyncio.Task[None] | None = None
@@ -301,52 +55,9 @@ _running_tasks: dict[str, asyncio.Task[None]] = {}
301
  _active_job_by_user: dict[str, str] = {}
302
 
303
 
304
- def _calc_cost_usd(model: str, prompt_tokens: int, completion_tokens: int) -> float:
305
- model_rates = MODEL_PRICES_PER_1M.get(model, None)
306
- if model_rates is None:
307
- in_rate = DEFAULT_INPUT_PRICE_PER_1M
308
- out_rate = DEFAULT_OUTPUT_PRICE_PER_1M
309
- else:
310
- in_rate, out_rate = model_rates
311
-
312
- cost = (prompt_tokens * in_rate + completion_tokens * out_rate) / 1_000_000.0
313
- return round(cost, 8)
314
-
315
-
316
- def _record_usage(
317
- *,
318
- username: str,
319
- job_id: str | None,
320
- model: str,
321
- prompt_tokens: int,
322
- completion_tokens: int,
323
- total_tokens: int,
324
- ) -> None:
325
- cost_usd = _calc_cost_usd(model, prompt_tokens, completion_tokens)
326
- _db_execute(
327
- """
328
- INSERT INTO usage_records(
329
- username, job_id, model,
330
- prompt_tokens, completion_tokens, total_tokens,
331
- cost_usd, created_at
332
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
333
- """,
334
- (
335
- username,
336
- job_id,
337
- model,
338
- prompt_tokens,
339
- completion_tokens,
340
- total_tokens,
341
- cost_usd,
342
- _now_iso(),
343
- ),
344
- )
345
-
346
-
347
  def _build_settings_for_job(row: sqlite3.Row) -> SettingsModel:
348
  username = row["username"]
349
- internal_key = _make_internal_api_key(username)
350
 
351
  settings = SettingsModel(
352
  basic=BasicSettings(debug=False, gui=False),
@@ -368,7 +79,7 @@ def _build_settings_for_job(row: sqlite3.Row) -> SettingsModel:
368
 
369
 
370
  async def _run_single_job(job_id: str) -> None:
371
- row = _db_fetchone("SELECT * FROM jobs WHERE id = ?", (job_id,))
372
  if row is None:
373
  return
374
  if row["status"] != "queued":
@@ -378,10 +89,10 @@ async def _run_single_job(job_id: str) -> None:
378
  return
379
 
380
  username = row["username"]
381
- _update_job(
382
  job_id,
383
  status="running",
384
- started_at=_now_iso(),
385
  message="Translation started",
386
  progress=0.0,
387
  )
@@ -397,19 +108,19 @@ async def _run_single_job(job_id: str) -> None:
397
  if event_type in {"progress_start", "progress_update", "progress_end"}:
398
  progress = float(event.get("overall_progress", 0.0))
399
  stage = event.get("stage", "")
400
- _update_job(
401
  job_id,
402
  progress=max(0.0, min(100.0, progress)),
403
  message=f"{stage}" if stage else "Running",
404
  )
405
  elif event_type == "error":
406
  error_msg = str(event.get("error", "Unknown translation error"))
407
- _update_job(
408
  job_id,
409
  status="failed",
410
  error=error_msg,
411
  message="Translation failed",
412
- finished_at=_now_iso(),
413
  )
414
  return
415
  elif event_type == "finish":
@@ -430,41 +141,41 @@ async def _run_single_job(job_id: str) -> None:
430
  elif ".dual.pdf" in name and not dual_path:
431
  dual_path = str(file)
432
 
433
- _update_job(
434
  job_id,
435
  status="succeeded",
436
  progress=100.0,
437
  message="Translation finished",
438
- finished_at=_now_iso(),
439
  mono_pdf_path=mono_path or None,
440
  dual_pdf_path=dual_path or None,
441
  glossary_path=glossary_path or None,
442
  )
443
  return
444
 
445
- _update_job(
446
  job_id,
447
  status="failed",
448
  error="Translation stream ended unexpectedly",
449
  message="Translation failed",
450
- finished_at=_now_iso(),
451
- )
452
  except asyncio.CancelledError:
453
- _update_job(
454
  job_id,
455
  status="cancelled",
456
  message="Cancelled by user",
457
- finished_at=_now_iso(),
458
  )
459
  raise
460
  except Exception as exc: # noqa: BLE001
461
  logger.exception("Translation job failed: %s", job_id)
462
- _update_job(
463
  job_id,
464
  status="failed",
465
  error=str(exc),
466
  message="Translation failed",
467
- finished_at=_now_iso(),
468
  )
469
  finally:
470
  if _active_job_by_user.get(username) == job_id:
@@ -490,8 +201,8 @@ async def _job_worker() -> None:
490
 
491
  def _enqueue_pending_jobs() -> None:
492
  # 服务重启后,正在运行中的任务标记失败。
493
- restart_time = _now_iso()
494
- _db_execute(
495
  """
496
  UPDATE jobs
497
  SET status='failed',
@@ -504,99 +215,18 @@ def _enqueue_pending_jobs() -> None:
504
  (restart_time, restart_time),
505
  )
506
 
507
- rows = _db_fetchall(
508
  "SELECT id FROM jobs WHERE status='queued' ORDER BY created_at ASC"
509
  )
510
  for row in rows:
511
  _job_queue.put_nowait(row["id"])
512
 
513
 
514
- # ── 页面模板 ───────────────────────────────────────────────────────────────────
515
- _LOGIN_HTML = """\
516
- <!DOCTYPE html>
517
- <html lang="zh-CN">
518
- <head>
519
- <meta charset="UTF-8">
520
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
521
- <title>登录</title>
522
- <style>
523
- *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
524
- body {
525
- min-height: 100vh;
526
- display: flex;
527
- align-items: center;
528
- justify-content: center;
529
- background: linear-gradient(135deg, #f0f2f5 0%, #e4e8f0 100%);
530
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
531
- }
532
- .card {
533
- background: #fff;
534
- border-radius: 14px;
535
- box-shadow: 0 6px 32px rgba(0, 0, 0, 0.10);
536
- padding: 44px 40px;
537
- width: 100%;
538
- max-width: 400px;
539
- }
540
- h1 { font-size: 1.5rem; font-weight: 700; color: #111827; margin-bottom: 6px; }
541
- p.sub { font-size: 0.875rem; color: #6b7280; margin-bottom: 30px; }
542
- label { display: block; font-size: 0.8rem; font-weight: 600; color: #374151; margin-bottom: 6px; }
543
- input[type=text], input[type=password] {
544
- width: 100%;
545
- padding: 11px 14px;
546
- border: 1.5px solid #e5e7eb;
547
- border-radius: 8px;
548
- font-size: 0.95rem;
549
- outline: none;
550
- transition: border-color 0.15s;
551
- margin-bottom: 20px;
552
- color: #111827;
553
- }
554
- input:focus { border-color: #4f6ef7; box-shadow: 0 0 0 3px rgba(79,110,247,0.12); }
555
- button {
556
- width: 100%;
557
- padding: 12px;
558
- background: linear-gradient(135deg, #4f6ef7 0%, #3b5bdb 100%);
559
- color: #fff;
560
- border: none;
561
- border-radius: 8px;
562
- font-size: 1rem;
563
- font-weight: 600;
564
- cursor: pointer;
565
- transition: opacity 0.15s;
566
- }
567
- button:hover { opacity: 0.88; }
568
- .error {
569
- background: #fef2f2;
570
- border: 1.5px solid #fecaca;
571
- border-radius: 8px;
572
- padding: 10px 14px;
573
- font-size: 0.875rem;
574
- color: #dc2626;
575
- margin-bottom: 20px;
576
- }
577
- </style>
578
- </head>
579
- <body>
580
- <div class="card">
581
- <h1>欢迎回来</h1>
582
- <p class="sub">请先登录后继续</p>
583
- __ERROR_BLOCK__
584
- <form method="post" action="/login">
585
- <label for="u">用户名</label>
586
- <input id="u" type="text" name="username" autocomplete="username" required autofocus>
587
- <label for="p">密码</label>
588
- <input id="p" type="password" name="password" autocomplete="current-password" required>
589
- <button type="submit">登录</button>
590
- </form>
591
- </div>
592
- </body>
593
- </html>
594
- """
595
-
596
-
597
  def _login_page(error: str = "") -> str:
 
 
598
  error_block = f'<div class="error">{html.escape(error)}</div>' if error else ""
599
- return _LOGIN_HTML.replace("__ERROR_BLOCK__", error_block)
600
 
601
 
602
  def _dashboard_page(username: str) -> str:
@@ -604,278 +234,12 @@ def _dashboard_page(username: str) -> str:
604
  safe_lang_in = html.escape(DEFAULT_LANG_IN)
605
  safe_lang_out = html.escape(DEFAULT_LANG_OUT)
606
 
607
- return f"""<!DOCTYPE html>
608
- <html lang="zh-CN">
609
- <head>
610
- <meta charset="UTF-8" />
611
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
612
- <title>PDF 翻译控制台</title>
613
- <style>
614
- :root {{
615
- --bg: #f4f7fb;
616
- --card: #ffffff;
617
- --ink: #0f172a;
618
- --sub: #475569;
619
- --line: #dbe3ee;
620
- --brand: #0f766e;
621
- --brand-dark: #115e59;
622
- --danger: #b91c1c;
623
- }}
624
- * {{ box-sizing: border-box; }}
625
- body {{
626
- margin: 0;
627
- color: var(--ink);
628
- background: radial-gradient(circle at 15% -20%, #d5f3ef 0, #f4f7fb 52%);
629
- font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
630
- }}
631
- .wrap {{ max-width: 1100px; margin: 24px auto; padding: 0 16px 40px; }}
632
- .top {{
633
- display: flex;
634
- align-items: center;
635
- justify-content: space-between;
636
- margin-bottom: 16px;
637
- }}
638
- h1 {{ margin: 0; font-size: 1.5rem; }}
639
- .user {{ color: var(--sub); font-size: 0.95rem; }}
640
- .grid {{ display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }}
641
- .card {{
642
- background: var(--card);
643
- border: 1px solid var(--line);
644
- border-radius: 14px;
645
- box-shadow: 0 10px 28px rgba(17, 24, 39, 0.06);
646
- padding: 16px;
647
- }}
648
- .card h2 {{ margin: 0 0 10px; font-size: 1.03rem; }}
649
- .row {{ display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }}
650
- label {{ display: block; margin: 10px 0 6px; font-size: 0.86rem; color: var(--sub); }}
651
- input[type=text], select, input[type=file] {{
652
- width: 100%; padding: 10px 12px; border-radius: 8px;
653
- border: 1px solid var(--line); background: #fff; color: var(--ink);
654
- }}
655
- button {{
656
- border: none; border-radius: 9px; padding: 10px 14px;
657
- font-weight: 600; cursor: pointer;
658
- }}
659
- .primary {{ background: var(--brand); color: #fff; }}
660
- .primary:hover {{ background: var(--brand-dark); }}
661
- .muted {{ background: #e2e8f0; color: #0f172a; }}
662
- .danger {{ background: #fee2e2; color: var(--danger); }}
663
- .hint {{ margin-top: 8px; color: var(--sub); font-size: 0.84rem; }}
664
- .status {{ margin-top: 10px; min-height: 22px; font-size: 0.9rem; }}
665
- table {{ width: 100%; border-collapse: collapse; margin-top: 8px; font-size: 0.88rem; }}
666
- th, td {{ border-bottom: 1px solid var(--line); text-align: left; padding: 8px 6px; }}
667
- th {{ color: var(--sub); font-weight: 600; }}
668
- .mono {{ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.8rem; }}
669
- .actions button {{ margin-right: 6px; margin-bottom: 4px; }}
670
- .foot {{ margin-top: 20px; color: var(--sub); font-size: 0.82rem; }}
671
- @media (max-width: 900px) {{
672
- .grid {{ grid-template-columns: 1fr; }}
673
- .row {{ grid-template-columns: 1fr; }}
674
- }}
675
- </style>
676
- </head>
677
- <body>
678
- <div class="wrap">
679
- <div class="top">
680
- <div>
681
- <h1>PDF 翻译控制台</h1>
682
- <div class="user">当前用户:<strong>{safe_user}</strong></div>
683
- </div>
684
- <div><a href="/logout"><button class="muted">退出登录</button></a></div>
685
- </div>
686
-
687
- <div class="grid">
688
- <section class="card">
689
- <h2>新建任务</h2>
690
- <form id="jobForm">
691
- <label>PDF 文件</label>
692
- <input name="file" type="file" accept=".pdf" required />
693
-
694
- <div class="row">
695
- <div>
696
- <label>源语言</label>
697
- <input name="lang_in" type="text" value="{safe_lang_in}" required />
698
- </div>
699
- <div>
700
- <label>目标语言</label>
701
- <input name="lang_out" type="text" value="{safe_lang_out}" required />
702
- </div>
703
- </div>
704
-
705
- <div style="margin-top: 12px;">
706
- <button class="primary" type="submit">提交任务</button>
707
- </div>
708
- </form>
709
- <div class="hint">模型由后台固定为 SiliconFlowFree,用户无需选择。</div>
710
- <div id="jobStatus" class="status"></div>
711
- </section>
712
-
713
- <section class="card">
714
- <h2>我的账单</h2>
715
- <div id="billingSummary" class="mono">加载中...</div>
716
- <table>
717
- <thead>
718
- <tr>
719
- <th>时间 (UTC)</th>
720
- <th>模型</th>
721
- <th>输入</th>
722
- <th>输出</th>
723
- <th>总计</th>
724
- <th>费用 (USD)</th>
725
- </tr>
726
- </thead>
727
- <tbody id="billingBody"></tbody>
728
- </table>
729
- </section>
730
- </div>
731
-
732
- <section class="card" style="margin-top: 14px;">
733
- <h2>我的任务</h2>
734
- <table>
735
- <thead>
736
- <tr>
737
- <th>ID</th>
738
- <th>文件</th>
739
- <th>状态</th>
740
- <th>进度</th>
741
- <th>模型</th>
742
- <th>更新时间 (UTC)</th>
743
- <th>操作</th>
744
- </tr>
745
- </thead>
746
- <tbody id="jobsBody"></tbody>
747
- </table>
748
- </section>
749
-
750
- <div class="foot">内部 OpenAI 接口仅允许 localhost 访问,不会直接暴露给终端用户。</div>
751
- </div>
752
-
753
- <script>
754
- async function apiJson(url, options = undefined) {{
755
- const resp = await fetch(url, options);
756
- if (!resp.ok) {{
757
- const data = await resp.text();
758
- throw new Error(data || `HTTP ${{resp.status}}`);
759
- }}
760
- return resp.json();
761
- }}
762
-
763
- function esc(s) {{
764
- return String(s || "").replace(/[&<>"']/g, (c) => ({{
765
- '&': '&amp;',
766
- '<': '&lt;',
767
- '>': '&gt;',
768
- '"': '&quot;',
769
- "'": '&#39;'
770
- }})[c]);
771
- }}
772
-
773
- async function refreshBilling() {{
774
- const summary = await apiJson('/api/billing/me');
775
- const rows = await apiJson('/api/billing/me/records?limit=20');
776
-
777
- document.getElementById('billingSummary').textContent =
778
- `总 tokens=${{summary.total_tokens}} | 总费用(USD)=${{Number(summary.total_cost_usd).toFixed(6)}}`;
779
-
780
- const body = document.getElementById('billingBody');
781
- body.innerHTML = '';
782
- for (const r of rows.records) {{
783
- const tr = document.createElement('tr');
784
- tr.innerHTML = `
785
- <td>${{esc(r.created_at)}}</td>
786
- <td class="mono">${{esc(r.model)}}</td>
787
- <td>${{r.prompt_tokens}}</td>
788
- <td>${{r.completion_tokens}}</td>
789
- <td>${{r.total_tokens}}</td>
790
- <td>${{Number(r.cost_usd).toFixed(6)}}</td>
791
- `;
792
- body.appendChild(tr);
793
- }}
794
- }}
795
-
796
- function actionButtons(job) {{
797
- const actions = [];
798
- if (job.status === 'queued' || job.status === 'running') {{
799
- actions.push(`<button class="danger" onclick="cancelJob('${{job.id}}')">取消</button>`);
800
- }}
801
- if (job.artifact_urls?.mono) {{
802
- actions.push(`<a href="${{job.artifact_urls.mono}}"><button class="muted">单语版</button></a>`);
803
- }}
804
- if (job.artifact_urls?.dual) {{
805
- actions.push(`<a href="${{job.artifact_urls.dual}}"><button class="muted">双语版</button></a>`);
806
- }}
807
- if (job.artifact_urls?.glossary) {{
808
- actions.push(`<a href="${{job.artifact_urls.glossary}}"><button class="muted">术语表</button></a>`);
809
- }}
810
- return actions.join(' ');
811
- }}
812
-
813
- function statusText(status) {{
814
- const statusMap = {{
815
- queued: '排队中',
816
- running: '进行中',
817
- succeeded: '成功',
818
- failed: '失败',
819
- cancelled: '已取消'
820
- }};
821
- return statusMap[status] || status;
822
- }}
823
-
824
- async function refreshJobs() {{
825
- const data = await apiJson('/api/jobs?limit=50');
826
- const body = document.getElementById('jobsBody');
827
- body.innerHTML = '';
828
-
829
- for (const job of data.jobs) {{
830
- const tr = document.createElement('tr');
831
- tr.innerHTML = `
832
- <td class="mono">${{esc(job.id)}}</td>
833
- <td>${{esc(job.filename)}}</td>
834
- <td>${{esc(statusText(job.status))}}${{job.error ? ' / ' + esc(job.error) : ''}}</td>
835
- <td>${{Number(job.progress).toFixed(1)}}%</td>
836
- <td class="mono">${{esc(job.model)}}</td>
837
- <td class="mono">${{esc(job.updated_at)}}</td>
838
- <td class="actions">${{actionButtons(job)}}</td>
839
- `;
840
- body.appendChild(tr);
841
- }}
842
- }}
843
-
844
- async function cancelJob(jobId) {{
845
- try {{
846
- await apiJson(`/api/jobs/${{jobId}}/cancel`, {{ method: 'POST' }});
847
- await refreshJobs();
848
- }} catch (err) {{
849
- alert(`取消失败: ${{err.message}}`);
850
- }}
851
- }}
852
-
853
- document.getElementById('jobForm').addEventListener('submit', async (event) => {{
854
- event.preventDefault();
855
- const status = document.getElementById('jobStatus');
856
- status.textContent = '提交中...';
857
-
858
- const formData = new FormData(event.target);
859
- try {{
860
- const created = await apiJson('/api/jobs', {{ method: 'POST', body: formData }});
861
- status.textContent = `任务已入队: ${{created.job.id}}`;
862
- event.target.reset();
863
- await refreshJobs();
864
- }} catch (err) {{
865
- status.textContent = `提交失败: ${{err.message}}`;
866
- }}
867
- }});
868
-
869
- async function refreshAll() {{
870
- await Promise.all([refreshJobs(), refreshBilling()]);
871
- }}
872
-
873
- refreshAll();
874
- setInterval(refreshAll, 3000);
875
- </script>
876
- </body>
877
- </html>
878
- """
879
 
880
 
881
  # ── FastAPI App ───────────────────────────────────────────────────────────────
@@ -887,18 +251,18 @@ _http_client: httpx.AsyncClient | None = None
887
  async def _startup() -> None:
888
  global _http_client, _worker_task
889
 
890
- _init_db()
891
  _enqueue_pending_jobs()
892
 
893
  _http_client = httpx.AsyncClient(timeout=httpx.Timeout(180.0))
894
  _worker_task = asyncio.create_task(_job_worker(), name="job-worker")
895
 
896
- if not OPENAI_REAL_API_KEY:
897
  logger.info(
898
  "OPENAI_API_KEY is empty, non-routed OpenAI models will fail"
899
  )
900
 
901
- logger.info("Gateway started. Data dir: %s", DATA_DIR)
902
 
903
 
904
  @app.on_event("shutdown")
@@ -918,11 +282,10 @@ async def _shutdown() -> None:
918
  await _http_client.aclose()
919
  _http_client = None
920
 
921
- if _db_conn:
922
- _db_conn.close()
923
 
924
 
925
- # ── 路由:基础与认证 ───────────────────────────────────────────────────────────
926
  @app.get("/healthz")
927
  async def healthz() -> Response:
928
  return Response("ok", media_type="text/plain")
@@ -930,7 +293,7 @@ async def healthz() -> Response:
930
 
931
  @app.get("/login", response_class=HTMLResponse)
932
  async def login_page(request: Request) -> HTMLResponse:
933
- if _get_session_user(request):
934
  return RedirectResponse("/", status_code=302)
935
  return HTMLResponse(_login_page())
936
 
@@ -942,13 +305,13 @@ async def login(
942
  password: str = Form(...),
943
  ) -> Response:
944
  next_url = request.query_params.get("next", "/")
945
- if _verify_credentials(username, password):
946
- token = _make_session(username)
947
  resp = RedirectResponse(next_url, status_code=303)
948
  resp.set_cookie(
949
- SESSION_COOKIE,
950
  token,
951
- max_age=SESSION_MAX_AGE,
952
  httponly=True,
953
  samesite="lax",
954
  )
@@ -962,13 +325,14 @@ async def login(
962
  @app.get("/logout")
963
  async def logout() -> Response:
964
  resp = RedirectResponse("/login", status_code=302)
965
- resp.delete_cookie(SESSION_COOKIE)
966
  return resp
967
 
968
 
 
969
  @app.get("/", response_class=HTMLResponse)
970
  async def index(request: Request) -> Response:
971
- username = _get_session_user(request)
972
  if not username:
973
  return RedirectResponse("/login", status_code=302)
974
  return HTMLResponse(_dashboard_page(username))
@@ -976,37 +340,29 @@ async def index(request: Request) -> Response:
976
 
977
  # ── 路由:任务 API ─────────────────────────────────────────────────────────────
978
  @app.get("/api/me")
979
- async def api_me(username: str = Depends(_require_user)) -> dict[str, str]:
980
  return {"username": username}
981
 
982
 
983
  @app.get("/api/jobs")
984
  async def api_list_jobs(
985
  limit: int = 50,
986
- username: str = Depends(_require_user),
987
  ) -> dict[str, Any]:
988
  limit = max(1, min(limit, 200))
989
- rows = _db_fetchall(
990
- """
991
- SELECT * FROM jobs
992
- WHERE username = ?
993
- ORDER BY created_at DESC
994
- LIMIT ?
995
- """,
996
- (username, limit),
997
- )
998
- return {"jobs": [_row_to_job_dict(row) for row in rows]}
999
 
1000
 
1001
  @app.get("/api/jobs/{job_id}")
1002
- async def api_get_job(job_id: str, username: str = Depends(_require_user)) -> dict[str, Any]:
1003
- row = _db_fetchone(
1004
- "SELECT * FROM jobs WHERE id = ? AND username = ?",
1005
- (job_id, username),
1006
- )
1007
- if row is None:
1008
  raise HTTPException(status_code=404, detail="Job not found")
1009
- return {"job": _row_to_job_dict(row)}
1010
 
1011
 
1012
  @app.post("/api/jobs")
@@ -1014,7 +370,7 @@ async def api_create_job(
1014
  file: UploadFile = File(...),
1015
  lang_in: str = Form(DEFAULT_LANG_IN),
1016
  lang_out: str = Form(DEFAULT_LANG_OUT),
1017
- username: str = Depends(_require_user),
1018
  ) -> dict[str, Any]:
1019
  filename = file.filename or "input.pdf"
1020
  if not filename.lower().endswith(".pdf"):
@@ -1022,8 +378,8 @@ async def api_create_job(
1022
 
1023
  job_id = uuid.uuid4().hex
1024
  safe_filename = Path(filename).name
1025
- input_path = (UPLOAD_DIR / f"{job_id}.pdf").resolve()
1026
- output_dir = (JOB_DIR / job_id).resolve()
1027
  output_dir.mkdir(parents=True, exist_ok=True)
1028
 
1029
  try:
@@ -1032,60 +388,42 @@ async def api_create_job(
1032
  finally:
1033
  await file.close()
1034
 
1035
- now = _now_iso()
1036
- _db_execute(
1037
- """
1038
- INSERT INTO jobs(
1039
- id, username, filename, input_path, output_dir,
1040
- status, progress, message, error,
1041
- model, lang_in, lang_out,
1042
- cancel_requested,
1043
- created_at, updated_at
1044
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1045
- """,
1046
- (
1047
- job_id,
1048
- username,
1049
- safe_filename,
1050
- str(input_path),
1051
- str(output_dir),
1052
- "queued",
1053
- 0.0,
1054
- "Queued",
1055
- None,
1056
- FIXED_TRANSLATION_MODEL,
1057
- lang_in.strip() or DEFAULT_LANG_IN,
1058
- lang_out.strip() or DEFAULT_LANG_OUT,
1059
- 0,
1060
- now,
1061
- now,
1062
- ),
1063
  )
1064
 
1065
  await _job_queue.put(job_id)
1066
- row = _db_fetchone("SELECT * FROM jobs WHERE id = ?", (job_id,))
1067
- return {"job": _row_to_job_dict(row)}
1068
 
1069
 
1070
  @app.post("/api/jobs/{job_id}/cancel")
1071
  async def api_cancel_job(
1072
  job_id: str,
1073
- username: str = Depends(_require_user),
1074
  ) -> dict[str, Any]:
1075
- row = _db_fetchone(
1076
- "SELECT * FROM jobs WHERE id = ? AND username = ?",
1077
- (job_id, username),
1078
- )
1079
- if row is None:
1080
  raise HTTPException(status_code=404, detail="Job not found")
1081
 
1082
  status = row["status"]
1083
  if status in {"succeeded", "failed", "cancelled"}:
1084
  return {"status": status, "message": "Job already finished"}
1085
 
1086
- _update_job(job_id, cancel_requested=1, message="Cancel requested")
1087
  if status == "queued":
1088
- _update_job(job_id, status="cancelled", finished_at=_now_iso(), progress=0.0)
 
 
 
 
 
1089
  return {"status": "cancelled", "message": "Job cancelled"}
1090
 
1091
  task = _running_tasks.get(job_id)
@@ -1095,35 +433,15 @@ async def api_cancel_job(
1095
  return {"status": "cancelling", "message": "Cancellation requested"}
1096
 
1097
 
1098
- def _resolve_artifact_path(raw_path: str | None, output_dir: Path) -> Path | None:
1099
- if not raw_path:
1100
- return None
1101
- path = Path(raw_path)
1102
- if not path.is_absolute():
1103
- path = (output_dir / path).resolve()
1104
- else:
1105
- path = path.resolve()
1106
-
1107
- if not path.exists():
1108
- return None
1109
-
1110
- try:
1111
- path.relative_to(output_dir)
1112
- except ValueError:
1113
- return None
1114
- return path
1115
-
1116
-
1117
  @app.get("/api/jobs/{job_id}/artifacts/{artifact_type}")
1118
  async def api_download_artifact(
1119
  job_id: str,
1120
  artifact_type: str,
1121
- username: str = Depends(_require_user),
1122
  ) -> Response:
1123
- row = _db_fetchone(
1124
- "SELECT * FROM jobs WHERE id = ? AND username = ?",
1125
- (job_id, username),
1126
- )
1127
  if row is None:
1128
  raise HTTPException(status_code=404, detail="Job not found")
1129
 
@@ -1137,7 +455,7 @@ async def api_download_artifact(
1137
  raise HTTPException(status_code=404, detail="Unknown artifact")
1138
 
1139
  output_dir = Path(row["output_dir"]).resolve()
1140
- path = _resolve_artifact_path(row[column], output_dir)
1141
  if path is None:
1142
  raise HTTPException(status_code=404, detail="Artifact not found")
1143
 
@@ -1146,303 +464,30 @@ async def api_download_artifact(
1146
 
1147
  # ── 路由:计费 API ─────────────────────────────────────────────────────────────
1148
  @app.get("/api/billing/me")
1149
- async def api_billing_summary(username: str = Depends(_require_user)) -> dict[str, Any]:
1150
- row = _db_fetchone(
1151
- """
1152
- SELECT
1153
- COALESCE(SUM(prompt_tokens), 0) AS prompt_tokens,
1154
- COALESCE(SUM(completion_tokens), 0) AS completion_tokens,
1155
- COALESCE(SUM(total_tokens), 0) AS total_tokens,
1156
- COALESCE(SUM(cost_usd), 0) AS total_cost_usd
1157
- FROM usage_records
1158
- WHERE username = ?
1159
- """,
1160
- (username,),
1161
- )
1162
- return {
1163
- "username": username,
1164
- "prompt_tokens": row["prompt_tokens"],
1165
- "completion_tokens": row["completion_tokens"],
1166
- "total_tokens": row["total_tokens"],
1167
- "total_cost_usd": round(float(row["total_cost_usd"]), 8),
1168
- }
1169
 
1170
 
1171
  @app.get("/api/billing/me/records")
1172
  async def api_billing_records(
1173
  limit: int = 50,
1174
- username: str = Depends(_require_user),
1175
  ) -> dict[str, Any]:
1176
  limit = max(1, min(limit, 200))
1177
- rows = _db_fetchall(
1178
- """
1179
- SELECT
1180
- id, username, job_id, model,
1181
- prompt_tokens, completion_tokens, total_tokens,
1182
- cost_usd, created_at
1183
- FROM usage_records
1184
- WHERE username = ?
1185
- ORDER BY created_at DESC
1186
- LIMIT ?
1187
- """,
1188
- (username, limit),
1189
- )
1190
- return {
1191
- "records": [dict(row) for row in rows],
1192
- }
1193
-
1194
-
1195
- # ── 路由:内部 OpenAI 兼容接口 ────────────────────────────────────────────────
1196
- def _extract_bearer_token(request: Request) -> str:
1197
- header = request.headers.get("authorization", "")
1198
- if not header.lower().startswith("bearer "):
1199
- raise HTTPException(status_code=401, detail="Missing bearer token")
1200
- token = header[7:].strip()
1201
- if not token:
1202
- raise HTTPException(status_code=401, detail="Missing bearer token")
1203
- return token
1204
-
1205
-
1206
- def _require_localhost(request: Request) -> None:
1207
- client = request.client
1208
- host = client.host if client else ""
1209
- if host not in LOCALHOSTS:
1210
- raise HTTPException(status_code=403, detail="Internal endpoint only")
1211
-
1212
-
1213
- def _extract_text_from_message_content(content: Any) -> str:
1214
- if isinstance(content, str):
1215
- return content
1216
- if not isinstance(content, list):
1217
- return ""
1218
-
1219
- parts: list[str] = []
1220
- for item in content:
1221
- if not isinstance(item, dict):
1222
- continue
1223
- if item.get("type") != "text":
1224
- continue
1225
- text = item.get("text")
1226
- if isinstance(text, str):
1227
- parts.append(text)
1228
- return "".join(parts)
1229
-
1230
-
1231
- def _extract_text_from_messages(messages: Any) -> str:
1232
- if not isinstance(messages, list):
1233
- raise HTTPException(status_code=400, detail="messages must be a list")
1234
-
1235
- for message in reversed(messages):
1236
- if not isinstance(message, dict):
1237
- continue
1238
- if message.get("role") != "user":
1239
- continue
1240
- text = _extract_text_from_message_content(message.get("content"))
1241
- if text:
1242
- return text
1243
-
1244
- for message in reversed(messages):
1245
- if not isinstance(message, dict):
1246
- continue
1247
- text = _extract_text_from_message_content(message.get("content"))
1248
- if text:
1249
- return text
1250
-
1251
- raise HTTPException(status_code=400, detail="messages does not contain text content")
1252
-
1253
-
1254
- def _should_request_json_mode(payload: dict[str, Any]) -> bool:
1255
- response_format = payload.get("response_format")
1256
- if not isinstance(response_format, dict):
1257
- return False
1258
- return response_format.get("type") == "json_object"
1259
-
1260
-
1261
- def _build_openai_compatible_response(model: str, content: str) -> dict[str, Any]:
1262
- return {
1263
- "id": f"chatcmpl-{uuid.uuid4().hex}",
1264
- "object": "chat.completion",
1265
- "created": int(datetime.now(timezone.utc).timestamp()),
1266
- "model": model,
1267
- "choices": [
1268
- {
1269
- "index": 0,
1270
- "message": {
1271
- "role": "assistant",
1272
- "content": content,
1273
- },
1274
- "finish_reason": "stop",
1275
- }
1276
- ],
1277
- "usage": {
1278
- "prompt_tokens": 0,
1279
- "completion_tokens": 0,
1280
- "total_tokens": 0,
1281
- },
1282
- }
1283
-
1284
-
1285
- async def _forward_to_chatproxy(
1286
- payload: dict[str, Any],
1287
- model: str,
1288
- route: dict[str, Any],
1289
- ) -> dict[str, Any]:
1290
- if _http_client is None:
1291
- raise HTTPException(status_code=500, detail="HTTP client is not ready")
1292
-
1293
- base_urls = route.get("base_urls", [])
1294
- if not isinstance(base_urls, list) or not base_urls:
1295
- raise HTTPException(status_code=500, detail=f"No upstream configured for model {model}")
1296
-
1297
- request_json = {
1298
- "text": _extract_text_from_messages(payload.get("messages")),
1299
- }
1300
- if _should_request_json_mode(payload):
1301
- request_json["requestJsonMode"] = True
1302
-
1303
- api_key = str(route.get("api_key") or "").strip()
1304
- headers = {"Content-Type": "application/json"}
1305
- if api_key:
1306
- headers["Authorization"] = f"Bearer {api_key}"
1307
-
1308
- last_error = "No available upstream"
1309
- for base_url in base_urls:
1310
- try:
1311
- upstream = await _http_client.post(
1312
- str(base_url),
1313
- headers=headers,
1314
- json=request_json,
1315
- )
1316
- except httpx.HTTPError as exc:
1317
- last_error = str(exc)
1318
- logger.warning("chatproxy call failed: model=%s url=%s error=%s", model, base_url, exc)
1319
- continue
1320
-
1321
- if upstream.status_code >= 400:
1322
- last_error = f"status={upstream.status_code}"
1323
- logger.warning(
1324
- "chatproxy upstream returned error: model=%s url=%s status=%s",
1325
- model,
1326
- base_url,
1327
- upstream.status_code,
1328
- )
1329
- continue
1330
-
1331
- try:
1332
- body = upstream.json()
1333
- except Exception as exc: # noqa: BLE001
1334
- last_error = f"invalid json response: {exc}"
1335
- logger.warning(
1336
- "chatproxy upstream returned invalid json: model=%s url=%s",
1337
- model,
1338
- base_url,
1339
- )
1340
- continue
1341
-
1342
- content = body.get("content")
1343
- if not isinstance(content, str):
1344
- last_error = "missing content field"
1345
- logger.warning(
1346
- "chatproxy upstream missing content: model=%s url=%s body=%s",
1347
- model,
1348
- base_url,
1349
- body,
1350
- )
1351
- continue
1352
-
1353
- return _build_openai_compatible_response(model=model, content=content)
1354
-
1355
- raise HTTPException(
1356
- status_code=502,
1357
- detail=f"All chatproxy upstreams failed for model {model}: {last_error}",
1358
- )
1359
-
1360
-
1361
  @app.post("/internal/openai/v1/chat/completions")
1362
  async def internal_openai_chat_completions(request: Request) -> Response:
1363
- _require_localhost(request)
1364
- token = _extract_bearer_token(request)
1365
-
1366
- username = INTERNAL_KEY_TO_USER.get(token)
1367
- if not username:
1368
- raise HTTPException(status_code=401, detail="Invalid internal API key")
1369
-
1370
- try:
1371
- payload = await request.json()
1372
- except json.JSONDecodeError as exc:
1373
- raise HTTPException(status_code=400, detail=f"Invalid JSON body: {exc}") from exc
1374
-
1375
- if payload.get("stream"):
1376
- raise HTTPException(status_code=400, detail="stream=true is not supported")
1377
-
1378
- if _http_client is None:
1379
- raise HTTPException(status_code=500, detail="HTTP client is not ready")
1380
-
1381
- model = str(payload.get("model") or "").strip()
1382
- if not model:
1383
- raise HTTPException(status_code=400, detail="model is required")
1384
-
1385
- route = MODEL_ROUTE_TABLE.get(model)
1386
- if route and route.get("route_type") == "chatproxy":
1387
- response_json = await _forward_to_chatproxy(payload=payload, model=model, route=route)
1388
- _record_usage(
1389
- username=username,
1390
- job_id=_active_job_by_user.get(username),
1391
- model=model,
1392
- prompt_tokens=0,
1393
- completion_tokens=0,
1394
- total_tokens=0,
1395
- )
1396
- return JSONResponse(response_json, status_code=200)
1397
-
1398
- if not OPENAI_REAL_API_KEY:
1399
- raise HTTPException(status_code=500, detail="OPENAI_API_KEY is not configured")
1400
-
1401
- headers = {
1402
- "Authorization": f"Bearer {OPENAI_REAL_API_KEY}",
1403
- "Content-Type": "application/json",
1404
- }
1405
-
1406
- try:
1407
- upstream = await _http_client.post(
1408
- OPENAI_UPSTREAM_CHAT_URL,
1409
- headers=headers,
1410
- json=payload,
1411
- )
1412
- except httpx.HTTPError as exc:
1413
- logger.error("Upstream OpenAI call failed: %s", exc)
1414
- raise HTTPException(status_code=502, detail="Upstream OpenAI request failed") from exc
1415
-
1416
- response_json: dict[str, Any] | None = None
1417
- content_type = upstream.headers.get("content-type", "")
1418
- if "application/json" in content_type.lower():
1419
- try:
1420
- response_json = upstream.json()
1421
- except Exception: # noqa: BLE001
1422
- response_json = None
1423
-
1424
- if upstream.status_code < 400 and response_json is not None:
1425
- usage = response_json.get("usage") or {}
1426
- prompt_tokens = int(usage.get("prompt_tokens") or 0)
1427
- completion_tokens = int(usage.get("completion_tokens") or 0)
1428
- total_tokens = int(usage.get("total_tokens") or (prompt_tokens + completion_tokens))
1429
-
1430
- job_id = _active_job_by_user.get(username)
1431
-
1432
- _record_usage(
1433
- username=username,
1434
- job_id=job_id,
1435
- model=model,
1436
- prompt_tokens=prompt_tokens,
1437
- completion_tokens=completion_tokens,
1438
- total_tokens=total_tokens,
1439
- )
1440
 
1441
- if response_json is not None:
1442
- return JSONResponse(response_json, status_code=upstream.status_code)
1443
 
1444
- return Response(
1445
- content=upstream.content,
1446
- status_code=upstream.status_code,
1447
- media_type=content_type or None,
1448
- )
 
 
6
  import asyncio
7
  import contextlib
8
  import html
 
9
  import logging
10
  import os
 
11
  import shutil
 
 
12
  import uuid
 
13
  from pathlib import Path
14
+ from typing import Any
15
 
 
16
  import httpx
17
  from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile
18
+ from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, Response
 
 
 
 
 
 
 
19
  from pdf2zh_next import BasicSettings
20
  from pdf2zh_next import OpenAISettings
21
  from pdf2zh_next import PDFSettings
 
23
  from pdf2zh_next import TranslationSettings
24
  from pdf2zh_next.high_level import do_translate_async_stream
25
 
26
+ import auth
27
+ import billing
28
+ import jobs
29
+ import proxy
30
+ import storage
31
+ from web.template_loader import get_static_path, load_template
32
 
33
+ # ── 配置 ──────────────────────────────────────────────────────────────────────
 
 
 
34
 
35
  INTERNAL_OPENAI_BASE_URL = os.environ.get(
36
  "INTERNAL_OPENAI_BASE_URL", "http://127.0.0.1:7860/internal/openai/v1"
37
  )
 
 
 
 
38
 
39
  FIXED_TRANSLATION_MODEL = "SiliconFlowFree"
40
  DEFAULT_LANG_IN = os.environ.get("DEFAULT_LANG_IN", "en").strip()
41
  DEFAULT_LANG_OUT = os.environ.get("DEFAULT_LANG_OUT", "zh").strip()
42
  TRANSLATION_QPS = int(os.environ.get("TRANSLATION_QPS", "4"))
43
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  logging.basicConfig(
45
  level=logging.INFO,
46
  format="%(asctime)s %(levelname)s %(name)s - %(message)s",
 
48
  logger = logging.getLogger("gateway")
49
 
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  # ── 任务执行 ───────────────────────────────────────────────────────────────────
52
  _job_queue: asyncio.Queue[str] = asyncio.Queue()
53
  _worker_task: asyncio.Task[None] | None = None
 
55
  _active_job_by_user: dict[str, str] = {}
56
 
57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  def _build_settings_for_job(row: sqlite3.Row) -> SettingsModel:
59
  username = row["username"]
60
+ internal_key = auth._make_internal_api_key(username)
61
 
62
  settings = SettingsModel(
63
  basic=BasicSettings(debug=False, gui=False),
 
79
 
80
 
81
  async def _run_single_job(job_id: str) -> None:
82
+ row = jobs.get_job_row(job_id)
83
  if row is None:
84
  return
85
  if row["status"] != "queued":
 
89
  return
90
 
91
  username = row["username"]
92
+ jobs.update_job(
93
  job_id,
94
  status="running",
95
+ started_at=storage.now_iso(),
96
  message="Translation started",
97
  progress=0.0,
98
  )
 
108
  if event_type in {"progress_start", "progress_update", "progress_end"}:
109
  progress = float(event.get("overall_progress", 0.0))
110
  stage = event.get("stage", "")
111
+ jobs.update_job(
112
  job_id,
113
  progress=max(0.0, min(100.0, progress)),
114
  message=f"{stage}" if stage else "Running",
115
  )
116
  elif event_type == "error":
117
  error_msg = str(event.get("error", "Unknown translation error"))
118
+ jobs.update_job(
119
  job_id,
120
  status="failed",
121
  error=error_msg,
122
  message="Translation failed",
123
+ finished_at=storage.now_iso(),
124
  )
125
  return
126
  elif event_type == "finish":
 
141
  elif ".dual.pdf" in name and not dual_path:
142
  dual_path = str(file)
143
 
144
+ jobs.update_job(
145
  job_id,
146
  status="succeeded",
147
  progress=100.0,
148
  message="Translation finished",
149
+ finished_at=storage.now_iso(),
150
  mono_pdf_path=mono_path or None,
151
  dual_pdf_path=dual_path or None,
152
  glossary_path=glossary_path or None,
153
  )
154
  return
155
 
156
+ jobs.update_job(
157
  job_id,
158
  status="failed",
159
  error="Translation stream ended unexpectedly",
160
  message="Translation failed",
161
+ finished_at=storage.now_iso(),
162
+ )
163
  except asyncio.CancelledError:
164
+ jobs.update_job(
165
  job_id,
166
  status="cancelled",
167
  message="Cancelled by user",
168
+ finished_at=storage.now_iso(),
169
  )
170
  raise
171
  except Exception as exc: # noqa: BLE001
172
  logger.exception("Translation job failed: %s", job_id)
173
+ jobs.update_job(
174
  job_id,
175
  status="failed",
176
  error=str(exc),
177
  message="Translation failed",
178
+ finished_at=storage.now_iso(),
179
  )
180
  finally:
181
  if _active_job_by_user.get(username) == job_id:
 
201
 
202
  def _enqueue_pending_jobs() -> None:
203
  # 服务重启后,正在运行中的任务标记失败。
204
+ restart_time = storage.now_iso()
205
+ storage.db_execute(
206
  """
207
  UPDATE jobs
208
  SET status='failed',
 
215
  (restart_time, restart_time),
216
  )
217
 
218
+ rows = storage.db_fetchall(
219
  "SELECT id FROM jobs WHERE status='queued' ORDER BY created_at ASC"
220
  )
221
  for row in rows:
222
  _job_queue.put_nowait(row["id"])
223
 
224
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  def _login_page(error: str = "") -> str:
226
+ """渲染登录页 HTML。"""
227
+ tpl = load_template("login.html")
228
  error_block = f'<div class="error">{html.escape(error)}</div>' if error else ""
229
+ return tpl.replace("__ERROR_BLOCK__", error_block)
230
 
231
 
232
  def _dashboard_page(username: str) -> str:
 
234
  safe_lang_in = html.escape(DEFAULT_LANG_IN)
235
  safe_lang_out = html.escape(DEFAULT_LANG_OUT)
236
 
237
+ tpl = load_template("dashboard.html")
238
+ return (
239
+ tpl.replace("__USERNAME__", safe_user)
240
+ .replace("__LANG_IN__", safe_lang_in)
241
+ .replace("__LANG_OUT__", safe_lang_out)
242
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
 
244
 
245
  # ── FastAPI App ───────────────────────────────────────────────────────────────
 
251
  async def _startup() -> None:
252
  global _http_client, _worker_task
253
 
254
+ storage.init_db()
255
  _enqueue_pending_jobs()
256
 
257
  _http_client = httpx.AsyncClient(timeout=httpx.Timeout(180.0))
258
  _worker_task = asyncio.create_task(_job_worker(), name="job-worker")
259
 
260
+ if not proxy.OPENAI_REAL_API_KEY:
261
  logger.info(
262
  "OPENAI_API_KEY is empty, non-routed OpenAI models will fail"
263
  )
264
 
265
+ logger.info("Gateway started. Data dir: %s", storage.DATA_DIR)
266
 
267
 
268
  @app.on_event("shutdown")
 
282
  await _http_client.aclose()
283
  _http_client = None
284
 
285
+ storage.close_db()
 
286
 
287
 
288
+ # ── 路由:基础与认证(当前 Space 原型,不保证向后兼容) ─────────────────────
289
  @app.get("/healthz")
290
  async def healthz() -> Response:
291
  return Response("ok", media_type="text/plain")
 
293
 
294
  @app.get("/login", response_class=HTMLResponse)
295
  async def login_page(request: Request) -> HTMLResponse:
296
+ if auth._get_session_user(request):
297
  return RedirectResponse("/", status_code=302)
298
  return HTMLResponse(_login_page())
299
 
 
305
  password: str = Form(...),
306
  ) -> Response:
307
  next_url = request.query_params.get("next", "/")
308
+ if auth._verify_credentials(username, password):
309
+ token = auth._make_session(username)
310
  resp = RedirectResponse(next_url, status_code=303)
311
  resp.set_cookie(
312
+ auth.SESSION_COOKIE,
313
  token,
314
+ max_age=auth.SESSION_MAX_AGE,
315
  httponly=True,
316
  samesite="lax",
317
  )
 
325
  @app.get("/logout")
326
  async def logout() -> Response:
327
  resp = RedirectResponse("/login", status_code=302)
328
+ resp.delete_cookie(auth.SESSION_COOKIE)
329
  return resp
330
 
331
 
332
+ # ── 路由:页面渲染(HTML) ────────────────────────────────────────────────────
333
  @app.get("/", response_class=HTMLResponse)
334
  async def index(request: Request) -> Response:
335
+ username = auth._get_session_user(request)
336
  if not username:
337
  return RedirectResponse("/login", status_code=302)
338
  return HTMLResponse(_dashboard_page(username))
 
340
 
341
  # ── 路由:任务 API ─────────────────────────────────────────────────────────────
342
  @app.get("/api/me")
343
+ async def api_me(username: str = Depends(auth._require_user)) -> dict[str, str]:
344
  return {"username": username}
345
 
346
 
347
  @app.get("/api/jobs")
348
  async def api_list_jobs(
349
  limit: int = 50,
350
+ username: str = Depends(auth._require_user),
351
  ) -> dict[str, Any]:
352
  limit = max(1, min(limit, 200))
353
+ jobs_list = jobs.get_jobs_for_user(username=username, limit=limit)
354
+ return {"jobs": jobs_list}
 
 
 
 
 
 
 
 
355
 
356
 
357
  @app.get("/api/jobs/{job_id}")
358
+ async def api_get_job(
359
+ job_id: str,
360
+ username: str = Depends(auth._require_user),
361
+ ) -> dict[str, Any]:
362
+ job = jobs.get_job_for_user(job_id=job_id, username=username)
363
+ if job is None:
364
  raise HTTPException(status_code=404, detail="Job not found")
365
+ return {"job": job}
366
 
367
 
368
  @app.post("/api/jobs")
 
370
  file: UploadFile = File(...),
371
  lang_in: str = Form(DEFAULT_LANG_IN),
372
  lang_out: str = Form(DEFAULT_LANG_OUT),
373
+ username: str = Depends(auth._require_user),
374
  ) -> dict[str, Any]:
375
  filename = file.filename or "input.pdf"
376
  if not filename.lower().endswith(".pdf"):
 
378
 
379
  job_id = uuid.uuid4().hex
380
  safe_filename = Path(filename).name
381
+ input_path = (storage.UPLOAD_DIR / f"{job_id}.pdf").resolve()
382
+ output_dir = (storage.JOB_DIR / job_id).resolve()
383
  output_dir.mkdir(parents=True, exist_ok=True)
384
 
385
  try:
 
388
  finally:
389
  await file.close()
390
 
391
+ job_dict = jobs.create_job_record(
392
+ job_id=job_id,
393
+ username=username,
394
+ filename=safe_filename,
395
+ input_path=input_path,
396
+ output_dir=output_dir,
397
+ model=FIXED_TRANSLATION_MODEL,
398
+ lang_in=lang_in.strip() or DEFAULT_LANG_IN,
399
+ lang_out=lang_out.strip() or DEFAULT_LANG_OUT,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
400
  )
401
 
402
  await _job_queue.put(job_id)
403
+ return {"job": job_dict}
 
404
 
405
 
406
  @app.post("/api/jobs/{job_id}/cancel")
407
  async def api_cancel_job(
408
  job_id: str,
409
+ username: str = Depends(auth._require_user),
410
  ) -> dict[str, Any]:
411
+ row = jobs.get_job_row(job_id)
412
+ if row is None or row["username"] != username:
 
 
 
413
  raise HTTPException(status_code=404, detail="Job not found")
414
 
415
  status = row["status"]
416
  if status in {"succeeded", "failed", "cancelled"}:
417
  return {"status": status, "message": "Job already finished"}
418
 
419
+ jobs.update_job(job_id, cancel_requested=1, message="Cancel requested")
420
  if status == "queued":
421
+ jobs.update_job(
422
+ job_id,
423
+ status="cancelled",
424
+ finished_at=storage.now_iso(),
425
+ progress=0.0,
426
+ )
427
  return {"status": "cancelled", "message": "Job cancelled"}
428
 
429
  task = _running_tasks.get(job_id)
 
433
  return {"status": "cancelling", "message": "Cancellation requested"}
434
 
435
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
436
  @app.get("/api/jobs/{job_id}/artifacts/{artifact_type}")
437
  async def api_download_artifact(
438
  job_id: str,
439
  artifact_type: str,
440
+ username: str = Depends(auth._require_user),
441
  ) -> Response:
442
+ row = jobs.get_job_row(job_id)
443
+ if row is not None and row["username"] != username:
444
+ row = None
 
445
  if row is None:
446
  raise HTTPException(status_code=404, detail="Job not found")
447
 
 
455
  raise HTTPException(status_code=404, detail="Unknown artifact")
456
 
457
  output_dir = Path(row["output_dir"]).resolve()
458
+ path = jobs.resolve_artifact_path(row[column], output_dir)
459
  if path is None:
460
  raise HTTPException(status_code=404, detail="Artifact not found")
461
 
 
464
 
465
  # ── 路由:计费 API ─────────────────────────────────────────────────────────────
466
  @app.get("/api/billing/me")
467
+ async def api_billing_summary(
468
+ username: str = Depends(auth._require_user),
469
+ ) -> dict[str, Any]:
470
+ return billing.get_billing_summary(username)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
471
 
472
 
473
  @app.get("/api/billing/me/records")
474
  async def api_billing_records(
475
  limit: int = 50,
476
+ username: str = Depends(auth._require_user),
477
  ) -> dict[str, Any]:
478
  limit = max(1, min(limit, 200))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
479
  @app.post("/internal/openai/v1/chat/completions")
480
  async def internal_openai_chat_completions(request: Request) -> Response:
481
+ return await proxy.handle_internal_chat_completions(
482
+ request=request,
483
+ http_client=_http_client,
484
+ active_job_by_user=_active_job_by_user,
485
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
486
 
 
 
487
 
488
+ # ── 路由:静态资源 ─────────────────────────────────────────────────────────────
489
+ @app.get("/static/dashboard.js")
490
+ async def dashboard_js() -> FileResponse:
491
+ """提供控制台前端脚本。"""
492
+ path = get_static_path("dashboard.js")
493
+ return FileResponse(path, media_type="application/javascript")
src/jobs.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """任务相关的持久化操作与辅助函数。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import sqlite3
9
+
10
+ import storage
11
+
12
+
13
+ def row_to_job_dict(row: sqlite3.Row) -> dict[str, Any]:
14
+ """将任务行转换为对外暴露的字典结构。"""
15
+ job = dict(row)
16
+ job["artifact_urls"] = {
17
+ "mono": f"/api/jobs/{job['id']}/artifacts/mono"
18
+ if job.get("mono_pdf_path")
19
+ else None,
20
+ "dual": f"/api/jobs/{job['id']}/artifacts/dual"
21
+ if job.get("dual_pdf_path")
22
+ else None,
23
+ "glossary": f"/api/jobs/{job['id']}/artifacts/glossary"
24
+ if job.get("glossary_path")
25
+ else None,
26
+ }
27
+ return job
28
+
29
+
30
+ def update_job(job_id: str, **fields: Any) -> None:
31
+ """更新任务记录指定字段。"""
32
+ if not fields:
33
+ return
34
+ fields["updated_at"] = storage.now_iso()
35
+ set_clause = ", ".join(f"{k} = ?" for k in fields.keys())
36
+ params = tuple(fields.values()) + (job_id,)
37
+ storage.db_execute(f"UPDATE jobs SET {set_clause} WHERE id = ?", params)
38
+
39
+
40
+ def get_job_row(job_id: str) -> sqlite3.Row | None:
41
+ """按 ID 获取任务原始行。"""
42
+ return storage.db_fetchone("SELECT * FROM jobs WHERE id = ?", (job_id,))
43
+
44
+
45
+ def get_job_for_user(job_id: str, username: str) -> dict[str, Any] | None:
46
+ """获取用户可见的任务,如果不存在或不属于该用户返回 None。"""
47
+ row = storage.db_fetchone(
48
+ "SELECT * FROM jobs WHERE id = ? AND username = ?",
49
+ (job_id, username),
50
+ )
51
+ if row is None:
52
+ return None
53
+ return row_to_job_dict(row)
54
+
55
+
56
+ def get_jobs_for_user(username: str, limit: int) -> list[dict[str, Any]]:
57
+ """列出用户的任务列表,按创建时间倒序。"""
58
+ rows = storage.db_fetchall(
59
+ """
60
+ SELECT * FROM jobs
61
+ WHERE username = ?
62
+ ORDER BY created_at DESC
63
+ LIMIT ?
64
+ """,
65
+ (username, limit),
66
+ )
67
+ return [row_to_job_dict(row) for row in rows]
68
+
69
+
70
+ def create_job_record(
71
+ *,
72
+ job_id: str,
73
+ username: str,
74
+ filename: str,
75
+ input_path: Path,
76
+ output_dir: Path,
77
+ model: str,
78
+ lang_in: str,
79
+ lang_out: str,
80
+ ) -> dict[str, Any]:
81
+ """插入一条新任务并返回任务字典。"""
82
+ now = storage.now_iso()
83
+ storage.db_execute(
84
+ """
85
+ INSERT INTO jobs(
86
+ id, username, filename, input_path, output_dir,
87
+ status, progress, message, error,
88
+ model, lang_in, lang_out,
89
+ cancel_requested,
90
+ created_at, updated_at
91
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
92
+ """,
93
+ (
94
+ job_id,
95
+ username,
96
+ filename,
97
+ str(input_path),
98
+ str(output_dir),
99
+ "queued",
100
+ 0.0,
101
+ "Queued",
102
+ None,
103
+ model,
104
+ lang_in,
105
+ lang_out,
106
+ 0,
107
+ now,
108
+ now,
109
+ ),
110
+ )
111
+ row = storage.db_fetchone("SELECT * FROM jobs WHERE id = ?", (job_id,))
112
+ if row is None:
113
+ raise RuntimeError("Failed to fetch job after insert")
114
+ return row_to_job_dict(row)
115
+
116
+
117
+ def resolve_artifact_path(raw_path: str | None, output_dir: Path) -> Path | None:
118
+ """解析并校验任务产物路径,限制在 output_dir 内部。"""
119
+ if not raw_path:
120
+ return None
121
+ path = Path(raw_path)
122
+ if not path.is_absolute():
123
+ path = (output_dir / path).resolve()
124
+ else:
125
+ path = path.resolve()
126
+
127
+ if not path.exists():
128
+ return None
129
+
130
+ try:
131
+ path.relative_to(output_dir)
132
+ except ValueError:
133
+ return None
134
+ return path
135
+
src/proxy.py ADDED
@@ -0,0 +1,305 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """内部 OpenAI 兼容代理逻辑。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ import uuid
9
+ from datetime import datetime, timezone
10
+ from typing import Any
11
+
12
+ import httpx
13
+ from fastapi import HTTPException, Request
14
+ from fastapi.responses import JSONResponse, Response
15
+
16
+ import auth
17
+ import billing
18
+
19
+ logger = logging.getLogger("gateway")
20
+
21
+
22
+ OPENAI_UPSTREAM_CHAT_URL = os.environ.get(
23
+ "OPENAI_UPSTREAM_CHAT_URL", "https://api.openai.com/v1/chat/completions"
24
+ )
25
+ OPENAI_REAL_API_KEY = os.environ.get("OPENAI_API_KEY", "").strip()
26
+
27
+ LOCALHOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
28
+
29
+
30
+ # 模型路由表:模型名 -> 上游配置
31
+ MODEL_ROUTE_TABLE: dict[str, dict[str, Any]] = {
32
+ "SiliconFlowFree": {
33
+ "route_type": "chatproxy",
34
+ "base_urls": [
35
+ "https://api1.pdf2zh-next.com/chatproxy",
36
+ "https://api2.pdf2zh-next.com/chatproxy",
37
+ ],
38
+ "api_key": "",
39
+ }
40
+ }
41
+
42
+
43
+ def _extract_bearer_token(request: Request) -> str:
44
+ """从 Authorization 头中提取 Bearer token。"""
45
+ header = request.headers.get("authorization", "")
46
+ if not header.lower().startswith("bearer "):
47
+ raise HTTPException(status_code=401, detail="Missing bearer token")
48
+ token = header[7:].strip()
49
+ if not token:
50
+ raise HTTPException(status_code=401, detail="Missing bearer token")
51
+ return token
52
+
53
+
54
+ def _require_localhost(request: Request) -> None:
55
+ """限制仅允许本地回环地址访问。"""
56
+ client = request.client
57
+ host = client.host if client else ""
58
+ if host not in LOCALHOSTS:
59
+ raise HTTPException(status_code=403, detail="Internal endpoint only")
60
+
61
+
62
+ def _extract_text_from_message_content(content: Any) -> str:
63
+ if isinstance(content, str):
64
+ return content
65
+ if not isinstance(content, list):
66
+ return ""
67
+
68
+ parts: list[str] = []
69
+ for item in content:
70
+ if not isinstance(item, dict):
71
+ continue
72
+ if item.get("type") != "text":
73
+ continue
74
+ text = item.get("text")
75
+ if isinstance(text, str):
76
+ parts.append(text)
77
+ return "".join(parts)
78
+
79
+
80
+ def _extract_text_from_messages(messages: Any) -> str:
81
+ if not isinstance(messages, list):
82
+ raise HTTPException(status_code=400, detail="messages must be a list")
83
+
84
+ for message in reversed(messages):
85
+ if not isinstance(message, dict):
86
+ continue
87
+ if message.get("role") != "user":
88
+ continue
89
+ text = _extract_text_from_message_content(message.get("content"))
90
+ if text:
91
+ return text
92
+
93
+ for message in reversed(messages):
94
+ if not isinstance(message, dict):
95
+ continue
96
+ text = _extract_text_from_message_content(message.get("content"))
97
+ if text:
98
+ return text
99
+
100
+ raise HTTPException(status_code=400, detail="messages does not contain text content")
101
+
102
+
103
+ def _should_request_json_mode(payload: dict[str, Any]) -> bool:
104
+ response_format = payload.get("response_format")
105
+ if not isinstance(response_format, dict):
106
+ return False
107
+ return response_format.get("type") == "json_object"
108
+
109
+
110
+ def _build_openai_compatible_response(model: str, content: str) -> dict[str, Any]:
111
+ return {
112
+ "id": f"chatcmpl-{uuid.uuid4().hex}",
113
+ "object": "chat.completion",
114
+ "created": int(datetime.now(timezone.utc).timestamp()),
115
+ "model": model,
116
+ "choices": [
117
+ {
118
+ "index": 0,
119
+ "message": {
120
+ "role": "assistant",
121
+ "content": content,
122
+ },
123
+ "finish_reason": "stop",
124
+ }
125
+ ],
126
+ "usage": {
127
+ "prompt_tokens": 0,
128
+ "completion_tokens": 0,
129
+ "total_tokens": 0,
130
+ },
131
+ }
132
+
133
+
134
+ async def _forward_to_chatproxy(
135
+ http_client: httpx.AsyncClient,
136
+ payload: dict[str, Any],
137
+ model: str,
138
+ route: dict[str, Any],
139
+ ) -> dict[str, Any]:
140
+ base_urls = route.get("base_urls", [])
141
+ if not isinstance(base_urls, list) or not base_urls:
142
+ raise HTTPException(status_code=500, detail=f"No upstream configured for model {model}")
143
+
144
+ request_json = {
145
+ "text": _extract_text_from_messages(payload.get("messages")),
146
+ }
147
+ if _should_request_json_mode(payload):
148
+ request_json["requestJsonMode"] = True
149
+
150
+ api_key = str(route.get("api_key") or "").strip()
151
+ headers = {"Content-Type": "application/json"}
152
+ if api_key:
153
+ headers["Authorization"] = f"Bearer {api_key}"
154
+
155
+ last_error = "No available upstream"
156
+ for base_url in base_urls:
157
+ try:
158
+ upstream = await http_client.post(
159
+ str(base_url),
160
+ headers=headers,
161
+ json=request_json,
162
+ )
163
+ except httpx.HTTPError as exc:
164
+ last_error = str(exc)
165
+ logger.warning("chatproxy call failed: model=%s url=%s error=%s", model, base_url, exc)
166
+ continue
167
+
168
+ if upstream.status_code >= 400:
169
+ last_error = f"status={upstream.status_code}"
170
+ logger.warning(
171
+ "chatproxy upstream returned error: model=%s url=%s status=%s",
172
+ model,
173
+ base_url,
174
+ upstream.status_code,
175
+ )
176
+ continue
177
+
178
+ try:
179
+ body = upstream.json()
180
+ except Exception as exc: # noqa: BLE001
181
+ last_error = f"invalid json response: {exc}"
182
+ logger.warning(
183
+ "chatproxy upstream returned invalid json: model=%s url=%s",
184
+ model,
185
+ base_url,
186
+ )
187
+ continue
188
+
189
+ content = body.get("content")
190
+ if not isinstance(content, str):
191
+ last_error = "missing content field"
192
+ logger.warning(
193
+ "chatproxy upstream missing content: model=%s url=%s body=%s",
194
+ model,
195
+ base_url,
196
+ body,
197
+ )
198
+ continue
199
+
200
+ return _build_openai_compatible_response(model=model, content=content)
201
+
202
+ raise HTTPException(
203
+ status_code=502,
204
+ detail=f"All chatproxy upstreams failed for model {model}: {last_error}",
205
+ )
206
+
207
+
208
+ async def handle_internal_chat_completions(
209
+ request: Request,
210
+ http_client: httpx.AsyncClient | None,
211
+ active_job_by_user: dict[str, str],
212
+ ) -> Response:
213
+ """处理内部 OpenAI 兼容聊天接口请求。"""
214
+ _require_localhost(request)
215
+ token = _extract_bearer_token(request)
216
+
217
+ username = auth.INTERNAL_KEY_TO_USER.get(token)
218
+ if not username:
219
+ raise HTTPException(status_code=401, detail="Invalid internal API key")
220
+
221
+ try:
222
+ payload = await request.json()
223
+ except json.JSONDecodeError as exc:
224
+ raise HTTPException(status_code=400, detail=f"Invalid JSON body: {exc}") from exc
225
+
226
+ if payload.get("stream"):
227
+ raise HTTPException(status_code=400, detail="stream=true is not supported")
228
+
229
+ if http_client is None:
230
+ raise HTTPException(status_code=500, detail="HTTP client is not ready")
231
+
232
+ model = str(payload.get("model") or "").strip()
233
+ if not model:
234
+ raise HTTPException(status_code=400, detail="model is required")
235
+
236
+ route = MODEL_ROUTE_TABLE.get(model)
237
+ if route and route.get("route_type") == "chatproxy":
238
+ response_json = await _forward_to_chatproxy(
239
+ http_client=http_client,
240
+ payload=payload,
241
+ model=model,
242
+ route=route,
243
+ )
244
+ billing.record_usage(
245
+ username=username,
246
+ job_id=active_job_by_user.get(username),
247
+ model=model,
248
+ prompt_tokens=0,
249
+ completion_tokens=0,
250
+ total_tokens=0,
251
+ )
252
+ return JSONResponse(response_json, status_code=200)
253
+
254
+ if not OPENAI_REAL_API_KEY:
255
+ raise HTTPException(status_code=500, detail="OPENAI_API_KEY is not configured")
256
+
257
+ headers = {
258
+ "Authorization": f"Bearer {OPENAI_REAL_API_KEY}",
259
+ "Content-Type": "application/json",
260
+ }
261
+
262
+ try:
263
+ upstream = await http_client.post(
264
+ OPENAI_UPSTREAM_CHAT_URL,
265
+ headers=headers,
266
+ json=payload,
267
+ )
268
+ except httpx.HTTPError as exc:
269
+ logger.error("Upstream OpenAI call failed: %s", exc)
270
+ raise HTTPException(status_code=502, detail="Upstream OpenAI request failed") from exc
271
+
272
+ response_json: dict[str, Any] | None = None
273
+ content_type = upstream.headers.get("content-type", "")
274
+ if "application/json" in content_type.lower():
275
+ try:
276
+ response_json = upstream.json()
277
+ except Exception: # noqa: BLE001
278
+ response_json = None
279
+
280
+ if upstream.status_code < 400 and response_json is not None:
281
+ usage = response_json.get("usage") or {}
282
+ prompt_tokens = int(usage.get("prompt_tokens") or 0)
283
+ completion_tokens = int(usage.get("completion_tokens") or 0)
284
+ total_tokens = int(usage.get("total_tokens") or (prompt_tokens + completion_tokens))
285
+
286
+ job_id = active_job_by_user.get(username)
287
+
288
+ billing.record_usage(
289
+ username=username,
290
+ job_id=job_id,
291
+ model=model,
292
+ prompt_tokens=prompt_tokens,
293
+ completion_tokens=completion_tokens,
294
+ total_tokens=total_tokens,
295
+ )
296
+
297
+ if response_json is not None:
298
+ return JSONResponse(response_json, status_code=upstream.status_code)
299
+
300
+ return Response(
301
+ content=upstream.content,
302
+ status_code=upstream.status_code,
303
+ media_type=content_type or None,
304
+ )
305
+
src/storage.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """存储层:数据目录与 SQLite 封装。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sqlite3
7
+ import threading
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ # 注意:日志仍然复用主网关 logger 名称,方便统一过滤
13
+ import logging
14
+
15
+ logger = logging.getLogger("gateway")
16
+
17
+
18
+ # ── 路径与基本配置 ─────────────────────────────────────────────────────────────
19
+ DATA_DIR = Path(os.environ.get("DATA_DIR", "/data"))
20
+ UPLOAD_DIR = DATA_DIR / "uploads"
21
+ JOB_DIR = DATA_DIR / "jobs"
22
+ DB_PATH = DATA_DIR / "gateway.db"
23
+
24
+
25
+ _db_lock = threading.Lock()
26
+ _db_conn: sqlite3.Connection | None = None
27
+
28
+
29
+ def now_iso() -> str:
30
+ """返回当前 UTC 时间的 ISO 字符串。"""
31
+ return datetime.now(timezone.utc).isoformat()
32
+
33
+
34
+ def ensure_data_dirs() -> None:
35
+ """确保数据目录存在。"""
36
+ UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
37
+ JOB_DIR.mkdir(parents=True, exist_ok=True)
38
+
39
+
40
+ def init_db() -> None:
41
+ """初始化 SQLite 数据库与基础表结构。"""
42
+ global _db_conn
43
+
44
+ ensure_data_dirs()
45
+ conn = sqlite3.connect(DB_PATH, check_same_thread=False)
46
+ conn.row_factory = sqlite3.Row
47
+ with conn:
48
+ conn.execute(
49
+ """
50
+ CREATE TABLE IF NOT EXISTS jobs (
51
+ id TEXT PRIMARY KEY,
52
+ username TEXT NOT NULL,
53
+ filename TEXT NOT NULL,
54
+ input_path TEXT NOT NULL,
55
+ output_dir TEXT NOT NULL,
56
+ status TEXT NOT NULL,
57
+ progress REAL NOT NULL DEFAULT 0,
58
+ message TEXT,
59
+ error TEXT,
60
+ model TEXT NOT NULL,
61
+ lang_in TEXT NOT NULL,
62
+ lang_out TEXT NOT NULL,
63
+ cancel_requested INTEGER NOT NULL DEFAULT 0,
64
+ mono_pdf_path TEXT,
65
+ dual_pdf_path TEXT,
66
+ glossary_path TEXT,
67
+ created_at TEXT NOT NULL,
68
+ updated_at TEXT NOT NULL,
69
+ started_at TEXT,
70
+ finished_at TEXT
71
+ )
72
+ """
73
+ )
74
+ conn.execute(
75
+ """
76
+ CREATE TABLE IF NOT EXISTS usage_records (
77
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
78
+ username TEXT NOT NULL,
79
+ job_id TEXT,
80
+ model TEXT NOT NULL,
81
+ prompt_tokens INTEGER NOT NULL,
82
+ completion_tokens INTEGER NOT NULL,
83
+ total_tokens INTEGER NOT NULL,
84
+ cost_usd REAL NOT NULL,
85
+ created_at TEXT NOT NULL
86
+ )
87
+ """
88
+ )
89
+ conn.execute(
90
+ """
91
+ CREATE INDEX IF NOT EXISTS idx_jobs_user_time
92
+ ON jobs(username, created_at DESC)
93
+ """
94
+ )
95
+ conn.execute(
96
+ """
97
+ CREATE INDEX IF NOT EXISTS idx_usage_user_time
98
+ ON usage_records(username, created_at DESC)
99
+ """
100
+ )
101
+
102
+ _db_conn = conn
103
+ logger.info("Database initialized at %s", DB_PATH)
104
+
105
+
106
+ def close_db() -> None:
107
+ """关闭数据库连接,用于应用关闭阶段。"""
108
+ global _db_conn
109
+
110
+ if _db_conn is not None:
111
+ _db_conn.close()
112
+ _db_conn = None
113
+
114
+
115
+ def db_execute(sql: str, params: tuple[Any, ...] = ()) -> None:
116
+ """执行写操作 SQL。"""
117
+ if _db_conn is None:
118
+ raise RuntimeError("DB is not initialized")
119
+ with _db_lock, _db_conn:
120
+ _db_conn.execute(sql, params)
121
+
122
+
123
+ def db_fetchone(sql: str, params: tuple[Any, ...] = ()) -> sqlite3.Row | None:
124
+ """执行查询并返回单行。"""
125
+ if _db_conn is None:
126
+ raise RuntimeError("DB is not initialized")
127
+ with _db_lock:
128
+ return _db_conn.execute(sql, params).fetchone()
129
+
130
+
131
+ def db_fetchall(sql: str, params: tuple[Any, ...] = ()) -> list[sqlite3.Row]:
132
+ """执行查询并返回多行。"""
133
+ if _db_conn is None:
134
+ raise RuntimeError("DB is not initialized")
135
+ with _db_lock:
136
+ return _db_conn.execute(sql, params).fetchall()
137
+
src/web/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ """Web 相关模板与静态资源包。"""
2
+
src/web/static/dashboard.js ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ async function apiJson(url, options = undefined) {
2
+ const resp = await fetch(url, options);
3
+ if (!resp.ok) {
4
+ const data = await resp.text();
5
+ throw new Error(data || `HTTP ${resp.status}`);
6
+ }
7
+ return resp.json();
8
+ }
9
+
10
+ function esc(s) {
11
+ return String(s || "").replace(/[&<>"']/g, (c) => ({
12
+ "&": "&amp;",
13
+ "<": "&lt;",
14
+ ">": "&gt;",
15
+ '"': "&quot;",
16
+ "'": "&#39;",
17
+ })[c]);
18
+ }
19
+
20
+ async function refreshBilling() {
21
+ const summary = await apiJson("/api/billing/me");
22
+ const rows = await apiJson("/api/billing/me/records?limit=20");
23
+
24
+ document.getElementById("billingSummary").textContent =
25
+ `总 tokens=${summary.total_tokens} | 总费用(USD)=${Number(
26
+ summary.total_cost_usd,
27
+ ).toFixed(6)}`;
28
+
29
+ const body = document.getElementById("billingBody");
30
+ body.innerHTML = "";
31
+ for (const r of rows.records) {
32
+ const tr = document.createElement("tr");
33
+ tr.innerHTML = `
34
+ <td>${esc(r.created_at)}</td>
35
+ <td class="mono">${esc(r.model)}</td>
36
+ <td>${r.prompt_tokens}</td>
37
+ <td>${r.completion_tokens}</td>
38
+ <td>${r.total_tokens}</td>
39
+ <td>${Number(r.cost_usd).toFixed(6)}</td>
40
+ `;
41
+ body.appendChild(tr);
42
+ }
43
+ }
44
+
45
+ function actionButtons(job) {
46
+ const actions = [];
47
+ if (job.status === "queued" || job.status === "running") {
48
+ actions.push(
49
+ `<button class="danger" onclick="cancelJob('${job.id}')">取消</button>`,
50
+ );
51
+ }
52
+ if (job.artifact_urls?.mono) {
53
+ actions.push(
54
+ `<a href="${job.artifact_urls.mono}"><button class="muted">单语版</button></a>`,
55
+ );
56
+ }
57
+ if (job.artifact_urls?.dual) {
58
+ actions.push(
59
+ `<a href="${job.artifact_urls.dual}"><button class="muted">双语版</button></a>`,
60
+ );
61
+ }
62
+ if (job.artifact_urls?.glossary) {
63
+ actions.push(
64
+ `<a href="${job.artifact_urls.glossary}"><button class="muted">术语表</button></a>`,
65
+ );
66
+ }
67
+ return actions.join(" ");
68
+ }
69
+
70
+ function statusText(status) {
71
+ const statusMap = {
72
+ queued: "排队中",
73
+ running: "进行中",
74
+ succeeded: "成功",
75
+ failed: "失败",
76
+ cancelled: "已取消",
77
+ };
78
+ return statusMap[status] || status;
79
+ }
80
+
81
+ async function refreshJobs() {
82
+ const data = await apiJson("/api/jobs?limit=50");
83
+ const body = document.getElementById("jobsBody");
84
+ body.innerHTML = "";
85
+
86
+ for (const job of data.jobs) {
87
+ const tr = document.createElement("tr");
88
+ tr.innerHTML = `
89
+ <td class="mono">${esc(job.id)}</td>
90
+ <td>${esc(job.filename)}</td>
91
+ <td>${esc(statusText(job.status))}${job.error ? " / " + esc(job.error) : ""}</td>
92
+ <td>${Number(job.progress).toFixed(1)}%</td>
93
+ <td class="mono">${esc(job.model)}</td>
94
+ <td class="mono">${esc(job.updated_at)}</td>
95
+ <td class="actions">${actionButtons(job)}</td>
96
+ `;
97
+ body.appendChild(tr);
98
+ }
99
+ }
100
+
101
+ async function cancelJob(jobId) {
102
+ try {
103
+ await apiJson(`/api/jobs/${jobId}/cancel`, { method: "POST" });
104
+ await refreshJobs();
105
+ } catch (err) {
106
+ alert(`取消失败: ${err.message}`);
107
+ }
108
+ }
109
+
110
+ document.getElementById("jobForm").addEventListener("submit", async (event) => {
111
+ event.preventDefault();
112
+ const status = document.getElementById("jobStatus");
113
+ status.textContent = "提交中...";
114
+
115
+ const formData = new FormData(event.target);
116
+ try {
117
+ const created = await apiJson("/api/jobs", { method: "POST", body: formData });
118
+ status.textContent = `任务已入队: ${created.job.id}`;
119
+ event.target.reset();
120
+ await refreshJobs();
121
+ } catch (err) {
122
+ status.textContent = `提交失败: ${err.message}`;
123
+ }
124
+ });
125
+
126
+ async function refreshAll() {
127
+ await Promise.all([refreshJobs(), refreshBilling()]);
128
+ }
129
+
130
+ refreshAll();
131
+ setInterval(refreshAll, 3000);
132
+
src/web/template_loader.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """简单的模板与静态资源加载工具。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+
8
+ BASE_DIR = Path(__file__).resolve().parent
9
+ TEMPLATE_DIR = BASE_DIR / "templates"
10
+ STATIC_DIR = BASE_DIR / "static"
11
+
12
+
13
+ def load_template(name: str) -> str:
14
+ """读取模板文件内容。"""
15
+ path = TEMPLATE_DIR / name
16
+ return path.read_text(encoding="utf-8")
17
+
18
+
19
+ def get_static_path(name: str) -> Path:
20
+ """返回静态资源的绝对路径路径。"""
21
+ return (STATIC_DIR / name).resolve()
22
+
src/web/templates/dashboard.html ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>PDF 翻译控制台</title>
7
+ <style>
8
+ :root {
9
+ --bg: #f4f7fb;
10
+ --card: #ffffff;
11
+ --ink: #0f172a;
12
+ --sub: #475569;
13
+ --line: #dbe3ee;
14
+ --brand: #0f766e;
15
+ --brand-dark: #115e59;
16
+ --danger: #b91c1c;
17
+ }
18
+ * { box-sizing: border-box; }
19
+ body {
20
+ margin: 0;
21
+ color: var(--ink);
22
+ background: radial-gradient(circle at 15% -20%, #d5f3ef 0, #f4f7fb 52%);
23
+ font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
24
+ }
25
+ .wrap { max-width: 1100px; margin: 24px auto; padding: 0 16px 40px; }
26
+ .top {
27
+ display: flex;
28
+ align-items: center;
29
+ justify-content: space-between;
30
+ margin-bottom: 16px;
31
+ }
32
+ h1 { margin: 0; font-size: 1.5rem; }
33
+ .user { color: var(--sub); font-size: 0.95rem; }
34
+ .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
35
+ .card {
36
+ background: var(--card);
37
+ border: 1px solid var(--line);
38
+ border-radius: 14px;
39
+ box-shadow: 0 10px 28px rgba(17, 24, 39, 0.06);
40
+ padding: 16px;
41
+ }
42
+ .card h2 { margin: 0 0 10px; font-size: 1.03rem; }
43
+ .row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
44
+ label { display: block; margin: 10px 0 6px; font-size: 0.86rem; color: var(--sub); }
45
+ input[type=text], select, input[type=file] {
46
+ width: 100%; padding: 10px 12px; border-radius: 8px;
47
+ border: 1px solid var(--line); background: #fff; color: var(--ink);
48
+ }
49
+ button {
50
+ border: none; border-radius: 9px; padding: 10px 14px;
51
+ font-weight: 600; cursor: pointer;
52
+ }
53
+ .primary { background: var(--brand); color: #fff; }
54
+ .primary:hover { background: var(--brand-dark); }
55
+ .muted { background: #e2e8f0; color: #0f172a; }
56
+ .danger { background: #fee2e2; color: var(--danger); }
57
+ .hint { margin-top: 8px; color: var(--sub); font-size: 0.84rem; }
58
+ .status { margin-top: 10px; min-height: 22px; font-size: 0.9rem; }
59
+ table { width: 100%; border-collapse: collapse; margin-top: 8px; font-size: 0.88rem; }
60
+ th, td { border-bottom: 1px solid var(--line); text-align: left; padding: 8px 6px; }
61
+ th { color: var(--sub); font-weight: 600; }
62
+ .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.8rem; }
63
+ .actions button { margin-right: 6px; margin-bottom: 4px; }
64
+ .foot { margin-top: 20px; color: var(--sub); font-size: 0.82rem; }
65
+ @media (max-width: 900px) {
66
+ .grid { grid-template-columns: 1fr; }
67
+ .row { grid-template-columns: 1fr; }
68
+ }
69
+ </style>
70
+ </head>
71
+ <body>
72
+ <div class="wrap">
73
+ <div class="top">
74
+ <div>
75
+ <h1>PDF 翻译控制台</h1>
76
+ <div class="user">当前用户:<strong>__USERNAME__</strong></div>
77
+ </div>
78
+ <div><a href="/logout"><button class="muted">退出登录</button></a></div>
79
+ </div>
80
+
81
+ <div class="grid">
82
+ <section class="card">
83
+ <h2>新建任务</h2>
84
+ <form id="jobForm">
85
+ <label>PDF 文件</label>
86
+ <input name="file" type="file" accept=".pdf" required />
87
+
88
+ <div class="row">
89
+ <div>
90
+ <label>源语言</label>
91
+ <input name="lang_in" type="text" value="__LANG_IN__" required />
92
+ </div>
93
+ <div>
94
+ <label>目标语言</label>
95
+ <input name="lang_out" type="text" value="__LANG_OUT__" required />
96
+ </div>
97
+ </div>
98
+
99
+ <div style="margin-top: 12px;">
100
+ <button class="primary" type="submit">提交任务</button>
101
+ </div>
102
+ </form>
103
+ <div class="hint">模型由后台固定为 SiliconFlowFree,用户无需选择。</div>
104
+ <div id="jobStatus" class="status"></div>
105
+ </section>
106
+
107
+ <section class="card">
108
+ <h2>我的账单</h2>
109
+ <div id="billingSummary" class="mono">加载中...</div>
110
+ <table>
111
+ <thead>
112
+ <tr>
113
+ <th>时间 (UTC)</th>
114
+ <th>模型</th>
115
+ <th>输入</th>
116
+ <th>输出</th>
117
+ <th>总计</th>
118
+ <th>费用 (USD)</th>
119
+ </tr>
120
+ </thead>
121
+ <tbody id="billingBody"></tbody>
122
+ </table>
123
+ </section>
124
+ </div>
125
+
126
+ <section class="card" style="margin-top: 14px;">
127
+ <h2>我的任务</h2>
128
+ <table>
129
+ <thead>
130
+ <tr>
131
+ <th>ID</th>
132
+ <th>文件</th>
133
+ <th>状态</th>
134
+ <th>进度</th>
135
+ <th>模型</th>
136
+ <th>更新时间 (UTC)</th>
137
+ <th>操作</th>
138
+ </tr>
139
+ </thead>
140
+ <tbody id="jobsBody"></tbody>
141
+ </table>
142
+ </section>
143
+
144
+ <div class="foot">内部 OpenAI 接口仅允许 localhost 访问,不会直接暴露给终端用户。</div>
145
+ </div>
146
+
147
+ <script src="/static/dashboard.js"></script>
148
+ </body>
149
+ </html>
150
+
src/web/templates/login.html ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>登录</title>
7
+ <style>
8
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
9
+ body {
10
+ min-height: 100vh;
11
+ display: flex;
12
+ align-items: center;
13
+ justify-content: center;
14
+ background: linear-gradient(135deg, #f0f2f5 0%, #e4e8f0 100%);
15
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
16
+ }
17
+ .card {
18
+ background: #fff;
19
+ border-radius: 14px;
20
+ box-shadow: 0 6px 32px rgba(0, 0, 0, 0.10);
21
+ padding: 44px 40px;
22
+ width: 100%;
23
+ max-width: 400px;
24
+ }
25
+ h1 { font-size: 1.5rem; font-weight: 700; color: #111827; margin-bottom: 6px; }
26
+ p.sub { font-size: 0.875rem; color: #6b7280; margin-bottom: 30px; }
27
+ label { display: block; font-size: 0.8rem; font-weight: 600; color: #374151; margin-bottom: 6px; }
28
+ input[type=text], input[type=password] {
29
+ width: 100%;
30
+ padding: 11px 14px;
31
+ border: 1.5px solid #e5e7eb;
32
+ border-radius: 8px;
33
+ font-size: 0.95rem;
34
+ outline: none;
35
+ transition: border-color 0.15s;
36
+ margin-bottom: 20px;
37
+ color: #111827;
38
+ }
39
+ input:focus { border-color: #4f6ef7; box-shadow: 0 0 0 3px rgba(79,110,247,0.12); }
40
+ button {
41
+ width: 100%;
42
+ padding: 12px;
43
+ background: linear-gradient(135deg, #4f6ef7 0%, #3b5bdb 100%);
44
+ color: #fff;
45
+ border: none;
46
+ border-radius: 8px;
47
+ font-size: 1rem;
48
+ font-weight: 600;
49
+ cursor: pointer;
50
+ transition: opacity 0.15s;
51
+ }
52
+ button:hover { opacity: 0.88; }
53
+ .error {
54
+ background: #fef2f2;
55
+ border: 1.5px solid #fecaca;
56
+ border-radius: 8px;
57
+ padding: 10px 14px;
58
+ font-size: 0.875rem;
59
+ color: #dc2626;
60
+ margin-bottom: 20px;
61
+ }
62
+ </style>
63
+ </head>
64
+ <body>
65
+ <div class="card">
66
+ <h1>欢迎回来</h1>
67
+ <p class="sub">请先登录后继续</p>
68
+ __ERROR_BLOCK__
69
+ <form method="post" action="/login">
70
+ <label for="u">用户名</label>
71
+ <input id="u" type="text" name="username" autocomplete="username" required autofocus>
72
+ <label for="p">密码</label>
73
+ <input id="p" type="password" name="password" autocomplete="current-password" required>
74
+ <button type="submit">登录</button>
75
+ </form>
76
+ </div>
77
+ </body>
78
+ </html>
79
+