feat:重构UI

This commit is contained in:
2025-12-26 16:03:12 +08:00
parent 1429e0e66a
commit abcbe3cddc
67 changed files with 12170 additions and 515 deletions

View File

@@ -5,7 +5,7 @@ API_PORT=8000
DEBUG=true
# Database
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/translator
DATABASE_URL=mysql+aiomysql://root:root@localhost:3306/translator
# Redis
REDIS_URL=redis://localhost:6379/0

View File

@@ -1,6 +1,7 @@
from fastapi import APIRouter, HTTPException, Depends, Header
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
from ..core import get_db
from ..models import Admin
@@ -11,7 +12,6 @@ router = APIRouter(prefix="/api/v1/admin", tags=["admin"])
async def get_current_admin(
authorization: str = Header(None),
db: AsyncSession = Depends(get_db),
):
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="未授权")
@@ -22,15 +22,52 @@ async def get_current_admin(
return payload
class AdminLoginRequest(BaseModel):
username: str
password: str
class AdminChangePasswordRequest(BaseModel):
old_password: str
new_password: str
@router.post("/login")
async def login(
username: str,
password: str,
data: AdminLoginRequest,
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Admin).where(Admin.username == username))
result = await db.execute(select(Admin).where(Admin.username == data.username))
admin = result.scalar_one_or_none()
if not admin or not verify_password(password, admin.password_hash):
if not admin or not verify_password(data.password, admin.password_hash):
raise HTTPException(status_code=401, detail="用户名或密码错误")
token = create_token({"sub": admin.username, "id": admin.id})
return {"token": token}
return {"token": token, "username": admin.username}
@router.get("/me")
async def me(payload: dict = Depends(get_current_admin)):
return {"id": payload.get("id"), "username": payload.get("sub"), "exp": payload.get("exp")}
@router.post("/change-password")
async def change_password(
data: AdminChangePasswordRequest,
payload: dict = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
admin_id = payload.get("id")
if not admin_id:
raise HTTPException(status_code=401, detail="未授权")
result = await db.execute(select(Admin).where(Admin.id == admin_id))
admin = result.scalar_one_or_none()
if not admin or not verify_password(data.old_password, admin.password_hash):
raise HTTPException(status_code=400, detail="原密码错误")
if len(data.new_password) < 8:
raise HTTPException(status_code=400, detail="新密码至少 8 位")
admin.password_hash = hash_password(data.new_password)
await db.commit()
return {"success": True}

View File

@@ -1,7 +1,9 @@
from fastapi import APIRouter, HTTPException, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict
import time
import httpx
from ..core import get_db
from ..models import AIProvider
@@ -11,14 +13,19 @@ router = APIRouter(prefix="/api/v1/admin/providers", tags=["ai-providers"])
class ProviderCreate(BaseModel):
model_config = ConfigDict(protected_namespaces=())
name: str
model_id: str
base_url: str | None = None
api_key: str
is_active: bool = True
is_default: bool = False
class ProviderUpdate(BaseModel):
model_config = ConfigDict(protected_namespaces=())
name: str | None = None
model_id: str | None = None
base_url: str | None = None
@@ -106,3 +113,43 @@ async def delete_provider(
await db.delete(provider)
await db.commit()
return {"success": True}
@router.post("/{provider_id}/test")
async def test_provider(
provider_id: int,
db: AsyncSession = Depends(get_db),
_: dict = Depends(get_current_admin),
):
result = await db.execute(select(AIProvider).where(AIProvider.id == provider_id))
provider = result.scalar_one_or_none()
if not provider:
raise HTTPException(status_code=404, detail="Provider 不存在")
base_url = (provider.base_url or "https://api.openai.com/v1").rstrip("/")
url = f"{base_url}/chat/completions"
start = time.perf_counter()
try:
async with httpx.AsyncClient(timeout=10.0) as client:
res = await client.post(
url,
headers={"Authorization": f"Bearer {provider.api_key}"},
json={
"model": provider.model_id,
"messages": [{"role": "user", "content": "ping"}],
"temperature": 0,
"max_tokens": 1,
},
)
res.raise_for_status()
except httpx.HTTPStatusError as e:
detail = (e.response.text or "").strip()
if len(detail) > 200:
detail = detail[:200] + "..."
raise HTTPException(status_code=400, detail=f"测试失败:{detail or e.response.reason_phrase}")
except Exception as e:
raise HTTPException(status_code=400, detail=f"测试失败:{str(e)}")
latency_ms = int((time.perf_counter() - start) * 1000)
return {"ok": True, "latency_ms": latency_ms}

View File

@@ -1,3 +1,4 @@
import asyncio
import json
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
@@ -34,10 +35,13 @@ async def translate(request: Request, payload: TranslateRequest):
cached=True,
)
source_lang_display = payload.source_lang_name or payload.source_lang
target_lang_display = payload.target_lang_name or payload.target_lang
translation = await llm_service.translate(
payload.source_text,
payload.source_lang,
payload.target_lang,
source_lang_display,
target_lang_display,
payload.style.value,
)
@@ -71,34 +75,59 @@ async def translate_stream(request: Request, payload: TranslateRequest):
cached = await cache_service.get(cache_key)
source_lang_display = payload.source_lang_name or payload.source_lang
target_lang_display = payload.target_lang_name or payload.target_lang
async def _safe_cache_set(translation: str):
try:
await cache_service.set(cache_key, {
"source_lang": payload.source_lang,
"translation": translation,
})
except Exception:
# 缓存写入失败不影响响应
pass
def _is_cancelled(exc: BaseException) -> bool:
# Python 3.11: CancelledError & BaseExceptionGroup are BaseException, not Exception.
if isinstance(exc, asyncio.CancelledError):
return True
if isinstance(exc, BaseExceptionGroup):
return all(_is_cancelled(e) for e in exc.exceptions)
return False
async def generate():
if cached:
meta = {"source_lang": payload.source_lang, "target_lang": payload.target_lang, "cached": True}
yield f"event: meta\ndata: {json.dumps(meta)}\n\n"
yield f"event: chunk\ndata: {json.dumps({'delta': cached['translation']})}\n\n"
done = {"translation": cached["translation"]}
yield f"event: done\ndata: {json.dumps(done)}\n\n"
return
meta = {"source_lang": payload.source_lang, "target_lang": payload.target_lang, "cached": False}
yield f"event: meta\ndata: {json.dumps(meta)}\n\n"
full_translation = ""
async for chunk in llm_service.translate_stream(
payload.source_text,
payload.source_lang,
payload.target_lang,
payload.style.value,
):
full_translation += chunk
yield f"event: chunk\ndata: {json.dumps({'delta': chunk})}\n\n"
try:
if cached:
meta = {"source_lang": payload.source_lang, "target_lang": payload.target_lang, "cached": True}
yield f"event: meta\ndata: {json.dumps(meta)}\n\n"
yield f"event: chunk\ndata: {json.dumps({'delta': cached['translation']})}\n\n"
yield f"event: done\ndata: {json.dumps({'translation': cached['translation']})}\n\n"
return
await cache_service.set(cache_key, {
"source_lang": payload.source_lang,
"translation": full_translation,
})
meta = {"source_lang": payload.source_lang, "target_lang": payload.target_lang, "cached": False}
yield f"event: meta\ndata: {json.dumps(meta)}\n\n"
done = {"translation": full_translation}
yield f"event: done\ndata: {json.dumps(done)}\n\n"
async for chunk in llm_service.translate_stream(
payload.source_text,
source_lang_display,
target_lang_display,
payload.style.value,
):
full_translation += chunk
yield f"event: chunk\ndata: {json.dumps({'delta': chunk})}\n\n"
# 缓存写入放到后台,避免影响流式响应完成
asyncio.create_task(_safe_cache_set(full_translation))
yield f"event: done\ndata: {json.dumps({'translation': full_translation})}\n\n"
except BaseException as e:
if _is_cancelled(e):
# 客户端断开或任务被取消时,直接结束生成器,避免打断 chunked 响应收尾。
task = asyncio.current_task()
if task:
task.uncancel()
return
yield f"event: error\ndata: {json.dumps({'error': str(e)})}\n\n"
return StreamingResponse(generate(), media_type="text/event-stream")

View File

@@ -10,7 +10,7 @@ class Settings(BaseSettings):
debug: bool = True
# Database
database_url: str = "postgresql+asyncpg://user:pass@db:5432/app"
database_url: str = "mysql+aiomysql://user:pass@db:3306/app"
# Redis
redis_url: str = "redis://redis:6379/0"

View File

@@ -2,7 +2,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .core import get_settings, logger
from .core import get_settings, logger, engine
from .api import translate_router, admin_router, provider_router, stats_router
from .services import cache_service, rate_limit_service, llm_service, stats_service
@@ -22,6 +22,10 @@ async def lifespan(app: FastAPI):
await rate_limit_service.disconnect()
await llm_service.disconnect()
await stats_service.disconnect()
try:
await engine.dispose()
except Exception:
pass
app = FastAPI(

View File

@@ -1,3 +1,6 @@
from pydantic import BaseModel
class TranslateResponse(BaseModel):
source_lang: str
target_lang: str

View File

@@ -11,7 +11,9 @@ class TranslationStyle(str, Enum):
class TranslateRequest(BaseModel):
source_text: str = Field(..., min_length=1, max_length=10000)
source_lang: str = Field(default="auto", max_length=10)
source_lang_name: str | None = Field(default=None, max_length=50)
target_lang: str = Field(..., max_length=10)
target_lang_name: str | None = Field(default=None, max_length=50)
style: TranslationStyle = TranslationStyle.literal
glossary_id: str | None = None
format: str = "text"

View File

@@ -1,3 +1,4 @@
import asyncio
import hashlib
import json
import redis.asyncio as redis
@@ -11,7 +12,11 @@ class CacheService:
self.redis: redis.Redis | None = None
async def connect(self):
self.redis = redis.from_url(settings.redis_url)
try:
self.redis = redis.from_url(settings.redis_url)
await self.redis.ping()
except Exception:
self.redis = None
async def disconnect(self):
if self.redis:
@@ -38,7 +43,12 @@ class CacheService:
async def get(self, key: str) -> dict | None:
if not self.redis:
return None
data = await self.redis.get(key)
try:
data = await self.redis.get(key)
except asyncio.CancelledError:
return None
except Exception:
return None
if data:
return json.loads(data)
return None
@@ -47,7 +57,12 @@ class CacheService:
if not self.redis:
return
ttl = ttl or settings.cache_ttl_seconds
await self.redis.set(key, json.dumps(value), ex=ttl)
try:
await self.redis.set(key, json.dumps(value), ex=ttl)
except asyncio.CancelledError:
return
except Exception:
return
cache_service = CacheService()

View File

@@ -1,6 +1,11 @@
import httpx
import json
from typing import AsyncGenerator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..core import get_settings
from ..core.database import AsyncSessionLocal
from ..models import AIProvider
settings = get_settings()
@@ -16,6 +21,29 @@ class LLMService:
if self.client:
await self.client.aclose()
async def _get_provider(self) -> dict:
"""从数据库获取默认的 AI Provider 配置"""
async with AsyncSessionLocal() as session:
result = await session.execute(
select(AIProvider).where(
AIProvider.is_active == True,
AIProvider.is_default == True
)
)
provider = result.scalar_one_or_none()
if provider:
return {
"api_key": provider.api_key,
"base_url": provider.base_url or "https://api.openai.com/v1",
"model": provider.model_id,
}
# 回退到环境变量配置
return {
"api_key": settings.llm_api_key,
"base_url": settings.llm_base_url or "https://api.openai.com/v1",
"model": settings.llm_model,
}
def _build_prompt(
self,
source_text: str,
@@ -44,14 +72,14 @@ class LLMService:
if not self.client:
raise RuntimeError("LLM client not initialized")
provider = await self._get_provider()
messages = self._build_prompt(source_text, source_lang, target_lang, style)
base_url = settings.llm_base_url or "https://api.openai.com/v1"
response = await self.client.post(
f"{base_url}/chat/completions",
headers={"Authorization": f"Bearer {settings.llm_api_key}"},
f"{provider['base_url']}/chat/completions",
headers={"Authorization": f"Bearer {provider['api_key']}"},
json={
"model": settings.llm_model,
"model": provider["model"],
"messages": messages,
"temperature": settings.default_temperature,
},
@@ -70,15 +98,15 @@ class LLMService:
if not self.client:
raise RuntimeError("LLM client not initialized")
provider = await self._get_provider()
messages = self._build_prompt(source_text, source_lang, target_lang, style)
base_url = settings.llm_base_url or "https://api.openai.com/v1"
async with self.client.stream(
"POST",
f"{base_url}/chat/completions",
headers={"Authorization": f"Bearer {settings.llm_api_key}"},
f"{provider['base_url']}/chat/completions",
headers={"Authorization": f"Bearer {provider['api_key']}"},
json={
"model": settings.llm_model,
"model": provider["model"],
"messages": messages,
"temperature": settings.default_temperature,
"stream": True,
@@ -89,7 +117,6 @@ class LLMService:
data = line[6:]
if data == "[DONE]":
break
import json
chunk = json.loads(data)
delta = chunk["choices"][0].get("delta", {})
if "content" in delta:

View File

@@ -1,3 +1,4 @@
import asyncio
import time
import redis.asyncio as redis
from ..core import get_settings
@@ -10,7 +11,11 @@ class RateLimitService:
self.redis: redis.Redis | None = None
async def connect(self):
self.redis = redis.from_url(settings.redis_url)
try:
self.redis = redis.from_url(settings.redis_url)
await self.redis.ping()
except Exception:
self.redis = None
async def disconnect(self):
if self.redis:
@@ -23,9 +28,14 @@ class RateLimitService:
now = int(time.time())
window_key = f"rl:{key}:{now // 60}"
count = await self.redis.incr(window_key)
if count == 1:
await self.redis.expire(window_key, 60)
try:
count = await self.redis.incr(window_key)
if count == 1:
await self.redis.expire(window_key, 60)
except asyncio.CancelledError:
return True
except Exception:
return True
return count <= limit

View File

@@ -1,3 +1,4 @@
import asyncio
import json
from datetime import datetime
import redis.asyncio as redis
@@ -30,21 +31,26 @@ class StatsService:
):
if not self.redis:
return
now = datetime.utcnow()
date = now.strftime("%Y-%m-%d")
hour = now.hour
key = self._get_key(provider_id, date, hour)
try:
now = datetime.utcnow()
date = now.strftime("%Y-%m-%d")
hour = now.hour
key = self._get_key(provider_id, date, hour)
pipe = self.redis.pipeline()
pipe.hincrby(key, "request_count", 1)
pipe.hincrby(key, "input_tokens", input_tokens)
pipe.hincrby(key, "output_tokens", output_tokens)
if cached:
pipe.hincrby(key, "cached_count", 1)
if error:
pipe.hincrby(key, "error_count", 1)
pipe.expire(key, 86400 * 30)
await pipe.execute()
pipe = self.redis.pipeline()
pipe.hincrby(key, "request_count", 1)
pipe.hincrby(key, "input_tokens", input_tokens)
pipe.hincrby(key, "output_tokens", output_tokens)
if cached:
pipe.hincrby(key, "cached_count", 1)
if error:
pipe.hincrby(key, "error_count", 1)
pipe.expire(key, 86400 * 30)
await pipe.execute()
except asyncio.CancelledError:
return
except Exception:
return
async def get_stats(self, provider_id: int, date: str) -> dict:
if not self.redis:
@@ -61,7 +67,12 @@ class StatsService:
for hour in range(24):
key = self._get_key(provider_id, date, hour)
data = await self.redis.hgetall(key)
try:
data = await self.redis.hgetall(key)
except asyncio.CancelledError:
return result
except Exception:
data = {}
hourly = {
"hour": hour,
"request_count": int(data.get(b"request_count", 0)),
@@ -79,27 +90,37 @@ class StatsService:
async def get_rpm_tpm(self, provider_id: int) -> dict:
if not self.redis:
return {"rpm": 0, "tpm": 0}
now = datetime.utcnow()
minute_key = f"rpm:{provider_id}:{now.strftime('%Y-%m-%d-%H-%M')}"
rpm = int(await self.redis.get(minute_key) or 0)
try:
now = datetime.utcnow()
minute_key = f"rpm:{provider_id}:{now.strftime('%Y-%m-%d-%H-%M')}"
rpm = int(await self.redis.get(minute_key) or 0)
tpm_key = f"tpm:{provider_id}:{now.strftime('%Y-%m-%d-%H-%M')}"
tpm = int(await self.redis.get(tpm_key) or 0)
return {"rpm": rpm, "tpm": tpm}
tpm_key = f"tpm:{provider_id}:{now.strftime('%Y-%m-%d-%H-%M')}"
tpm = int(await self.redis.get(tpm_key) or 0)
return {"rpm": rpm, "tpm": tpm}
except asyncio.CancelledError:
return {"rpm": 0, "tpm": 0}
except Exception:
return {"rpm": 0, "tpm": 0}
async def incr_rpm_tpm(self, provider_id: int, tokens: int):
if not self.redis:
return
now = datetime.utcnow()
minute_key = f"rpm:{provider_id}:{now.strftime('%Y-%m-%d-%H-%M')}"
tpm_key = f"tpm:{provider_id}:{now.strftime('%Y-%m-%d-%H-%M')}"
try:
now = datetime.utcnow()
minute_key = f"rpm:{provider_id}:{now.strftime('%Y-%m-%d-%H-%M')}"
tpm_key = f"tpm:{provider_id}:{now.strftime('%Y-%m-%d-%H-%M')}"
pipe = self.redis.pipeline()
pipe.incr(minute_key)
pipe.expire(minute_key, 120)
pipe.incrby(tpm_key, tokens)
pipe.expire(tpm_key, 120)
await pipe.execute()
pipe = self.redis.pipeline()
pipe.incr(minute_key)
pipe.expire(minute_key, 120)
pipe.incrby(tpm_key, tokens)
pipe.expire(tpm_key, 120)
await pipe.execute()
except asyncio.CancelledError:
return
except Exception:
return
stats_service = StatsService()

View File

@@ -9,7 +9,7 @@ dependencies = [
"pydantic>=2.5.0",
"pydantic-settings>=2.1.0",
"redis>=5.0.0",
"asyncpg>=0.29.0",
"aiomysql>=0.2.0",
"sqlalchemy[asyncio]>=2.0.0",
"httpx>=0.26.0",
"python-jose[cryptography]>=3.3.0",
@@ -28,6 +28,9 @@ dev = [
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["app"]
[tool.ruff]
line-length = 100
target-version = "py311"