refactor: 资源拆分
Browse files- src/auth.py +105 -0
- src/billing.py +118 -0
- src/gateway.py +105 -1060
- src/jobs.py +135 -0
- src/proxy.py +305 -0
- src/storage.py +137 -0
- src/web/__init__.py +2 -0
- src/web/static/dashboard.js +132 -0
- src/web/template_loader.py +22 -0
- src/web/templates/dashboard.html +150 -0
- src/web/templates/login.html +79 -0
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
|
| 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 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
|
| 46 |
-
|
| 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 =
|
| 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 |
-
|
| 382 |
job_id,
|
| 383 |
status="running",
|
| 384 |
-
started_at=
|
| 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 |
-
|
| 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 |
-
|
| 408 |
job_id,
|
| 409 |
status="failed",
|
| 410 |
error=error_msg,
|
| 411 |
message="Translation failed",
|
| 412 |
-
finished_at=
|
| 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 |
-
|
| 434 |
job_id,
|
| 435 |
status="succeeded",
|
| 436 |
progress=100.0,
|
| 437 |
message="Translation finished",
|
| 438 |
-
finished_at=
|
| 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 |
-
|
| 446 |
job_id,
|
| 447 |
status="failed",
|
| 448 |
error="Translation stream ended unexpectedly",
|
| 449 |
message="Translation failed",
|
| 450 |
-
finished_at=
|
| 451 |
-
|
| 452 |
except asyncio.CancelledError:
|
| 453 |
-
|
| 454 |
job_id,
|
| 455 |
status="cancelled",
|
| 456 |
message="Cancelled by user",
|
| 457 |
-
finished_at=
|
| 458 |
)
|
| 459 |
raise
|
| 460 |
except Exception as exc: # noqa: BLE001
|
| 461 |
logger.exception("Translation job failed: %s", job_id)
|
| 462 |
-
|
| 463 |
job_id,
|
| 464 |
status="failed",
|
| 465 |
error=str(exc),
|
| 466 |
message="Translation failed",
|
| 467 |
-
finished_at=
|
| 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 =
|
| 494 |
-
|
| 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 =
|
| 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
|
| 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 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 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 |
-
'&': '&',
|
| 766 |
-
'<': '<',
|
| 767 |
-
'>': '>',
|
| 768 |
-
'"': '"',
|
| 769 |
-
"'": '''
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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(
|
| 1003 |
-
|
| 1004 |
-
|
| 1005 |
-
|
| 1006 |
-
)
|
| 1007 |
-
if
|
| 1008 |
raise HTTPException(status_code=404, detail="Job not found")
|
| 1009 |
-
return {"job":
|
| 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 |
-
|
| 1036 |
-
|
| 1037 |
-
|
| 1038 |
-
|
| 1039 |
-
|
| 1040 |
-
|
| 1041 |
-
|
| 1042 |
-
|
| 1043 |
-
|
| 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 |
-
|
| 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 =
|
| 1076 |
-
|
| 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 |
-
|
| 1087 |
if status == "queued":
|
| 1088 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 1124 |
-
|
| 1125 |
-
|
| 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 =
|
| 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(
|
| 1150 |
-
|
| 1151 |
-
|
| 1152 |
-
|
| 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 |
-
|
| 1364 |
-
|
| 1365 |
-
|
| 1366 |
-
|
| 1367 |
-
|
| 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 |
-
|
| 1445 |
-
|
| 1446 |
-
|
| 1447 |
-
|
| 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 |
+
"&": "&",
|
| 13 |
+
"<": "<",
|
| 14 |
+
">": ">",
|
| 15 |
+
'"': """,
|
| 16 |
+
"'": "'",
|
| 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 |
+
|