feat(deploy): fix docker deployment and add backend i18n

- Docker Deployment Fixes:
  - Switch base images to docker.m.daocloud.io to resolve registry 401 errors
  - Add Postgres and Redis services to docker-compose.traefik.yml
  - Fix frontend build: replace missing icons (Globe->Location, Chart->TrendCharts)
  - Fix frontend build: resolve pnpm CI/TTY issues and frozen lockfile errors
  - Add missing backend dependencies (sqlalchemy, psycopg2, redis-py, celery, docker-py) in pixi.toml
  - Ensure database tables are created on startup (lifespan event)

- Backend Internationalization (i18n):
  - Add backend/app/core/i18n.py for locale handling
  - Update API endpoints (jobs, tasks, uploads, results) to return localized messages
  - Support 'Accept-Language' header (en/zh)

- Documentation:
  - Update DOCKER_DEPLOYMENT.md with new architecture and troubleshooting
  - Update AGENTS.md with latest stack details and deployment steps
  - Update @fix_plan.md status

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
zly
2026-01-14 12:38:54 +08:00
parent c0f2de02ca
commit 9835b6e341
32 changed files with 1924 additions and 231 deletions

View File

@@ -11,6 +11,7 @@ from ...models.job import Job, JobStatus
from ...schemas.job import JobResponse
from ...workers.tasks import run_bttoxin_analysis, update_queue_positions
from ...config import settings
from ...core.i18n import I18n, get_i18n
router = APIRouter()
@@ -30,7 +31,8 @@ async def create_job(
min_coverage: float = Form(0.6),
allow_unknown_families: bool = Form(False),
require_index_hit: bool = Form(True),
db: Session = Depends(get_db)
db: Session = Depends(get_db),
i18n: I18n = Depends(get_i18n)
):
"""
创建新分析任务
@@ -52,14 +54,14 @@ async def create_job(
if ext not in allowed_extensions:
raise HTTPException(
status_code=400,
detail=f"Invalid file extension: {ext}. Allowed: {', '.join(allowed_extensions)}"
detail=i18n.t("invalid_extension", ext=ext, allowed=', '.join(allowed_extensions))
)
# 限制单文件上传
if len(files) != 1:
raise HTTPException(
status_code=400,
detail="Only one file allowed per task"
detail=i18n.t("single_file_only")
)
job_id = str(uuid.uuid4())
@@ -114,20 +116,20 @@ async def create_job(
@router.get("/{job_id}", response_model=JobResponse)
async def get_job(job_id: str, db: Session = Depends(get_db)):
async def get_job(job_id: str, db: Session = Depends(get_db), i18n: I18n = Depends(get_i18n)):
"""获取任务详情"""
job = db.query(Job).filter(Job.id == job_id).first()
if not job:
raise HTTPException(status_code=404, detail="Job not found")
raise HTTPException(status_code=404, detail=i18n.t("job_not_found"))
return job
@router.get("/{job_id}/progress")
async def get_job_progress(job_id: str, db: Session = Depends(get_db)):
async def get_job_progress(job_id: str, db: Session = Depends(get_db), i18n: I18n = Depends(get_i18n)):
"""获取任务进度"""
job = db.query(Job).filter(Job.id == job_id).first()
if not job:
raise HTTPException(status_code=404, detail="Job not found")
raise HTTPException(status_code=404, detail=i18n.t("job_not_found"))
result = {
'job_id': job_id,
@@ -148,7 +150,7 @@ async def get_job_progress(job_id: str, db: Session = Depends(get_db)):
@router.post("/update-queue-positions")
async def trigger_queue_update(db: Session = Depends(get_db)):
async def trigger_queue_update(db: Session = Depends(get_db), i18n: I18n = Depends(get_i18n)):
"""手动触发队列位置更新"""
task = update_queue_positions.delay()
return {"message": "Queue update triggered", "task_id": task.id}
return {"message": i18n.t("queue_update_triggered"), "task_id": task.id}

View File

@@ -1,5 +1,5 @@
"""结果下载 API"""
from fastapi import APIRouter, HTTPException, Response
from fastapi import APIRouter, HTTPException, Response, Depends
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from pathlib import Path
@@ -10,6 +10,7 @@ import shutil
from ...database import get_db
from ...models.job import Job, JobStatus
from ...config import settings
from ...core.i18n import I18n, get_i18n
router = APIRouter()
@@ -17,21 +18,21 @@ RESULTS_DIR = Path(settings.RESULTS_DIR)
@router.get("/{job_id}/download")
async def download_results(job_id: str, db: Session = Depends(get_db)):
async def download_results(job_id: str, db: Session = Depends(get_db), i18n: I18n = Depends(get_i18n)):
"""下载任务结果(打包为 .tar.gz"""
job = db.query(Job).filter(Job.id == job_id).first()
if not job:
raise HTTPException(status_code=404, detail="Job not found")
raise HTTPException(status_code=404, detail=i18n.t("job_not_found"))
if job.status != JobStatus.COMPLETED:
raise HTTPException(
status_code=400,
detail=f"Job not completed. Current status: {job.status}"
detail=i18n.t("job_not_completed", status=job.status)
)
job_output_dir = RESULTS_DIR / job_id
if not job_output_dir.exists():
raise HTTPException(status_code=404, detail="Results not found on disk")
raise HTTPException(status_code=404, detail=i18n.t("results_not_found"))
# 创建 tar.gz 文件到内存
tar_buffer = io.BytesIO()
@@ -53,11 +54,11 @@ async def download_results(job_id: str, db: Session = Depends(get_db)):
@router.delete("/{job_id}")
async def delete_job(job_id: str, db: Session = Depends(get_db)):
async def delete_job(job_id: str, db: Session = Depends(get_db), i18n: I18n = Depends(get_i18n)):
"""删除任务及其结果"""
job = db.query(Job).filter(Job.id == job_id).first()
if not job:
raise HTTPException(status_code=404, detail="Job not found")
raise HTTPException(status_code=404, detail=i18n.t("job_not_found"))
# 删除磁盘上的文件
job_input_dir = Path(settings.UPLOAD_DIR) / job_id
@@ -73,4 +74,4 @@ async def delete_job(job_id: str, db: Session = Depends(get_db)):
db.delete(job)
db.commit()
return {"message": f"Job {job_id} deleted successfully"}
return {"message": i18n.t("job_deleted", job_id=job_id)}

View File

@@ -9,6 +9,7 @@ from ...database import get_db
from ...models.job import Job, JobStatus
from ...schemas.job import JobResponse
from ...config import settings
from ...core.i18n import I18n, get_i18n
router = APIRouter()
@@ -30,31 +31,31 @@ class QueuePosition(BaseModel):
@router.post("/", response_model=JobResponse)
async def create_task(request: TaskCreateRequest, db: Session = Depends(get_db)):
async def create_task(request: TaskCreateRequest, db: Session = Depends(get_db), i18n: I18n = Depends(get_i18n)):
"""创建新任务(兼容前端)"""
# 暂时复用 jobs 逻辑
# TODO: 实现完整的文件上传和处理
raise HTTPException(status_code=501, detail="Use POST /api/v1/jobs/create for now")
raise HTTPException(status_code=501, detail=i18n.t("use_create_endpoint"))
@router.get("/{task_id}", response_model=JobResponse)
async def get_task(task_id: str, db: Session = Depends(get_db)):
async def get_task(task_id: str, db: Session = Depends(get_db), i18n: I18n = Depends(get_i18n)):
"""获取任务状态"""
job = db.query(Job).filter(Job.id == task_id).first()
if not job:
raise HTTPException(status_code=404, detail="Task not found")
raise HTTPException(status_code=404, detail=i18n.t("task_not_found"))
return job
@router.get("/{task_id}/queue")
async def get_queue_position(task_id: str, db: Session = Depends(get_db)):
async def get_queue_position(task_id: str, db: Session = Depends(get_db), i18n: I18n = Depends(get_i18n)):
"""获取排队位置"""
job = db.query(Job).filter(Job.id == task_id).first()
if not job:
raise HTTPException(status_code=404, detail="Task not found")
raise HTTPException(status_code=404, detail=i18n.t("task_not_found"))
if job.status not in [JobStatus.PENDING, JobStatus.QUEUED]:
return {"position": 0, "message": "Task is not in queue"}
return {"position": 0, "message": i18n.t("task_not_in_queue")}
# 计算排队位置
ahead_jobs = db.query(Job).filter(

View File

@@ -1,8 +1,9 @@
"""文件上传 API"""
from fastapi import APIRouter
from fastapi import APIRouter, Depends
from ...core.i18n import I18n, get_i18n
router = APIRouter()
@router.get("/")
async def upload_info():
return {"message": "Upload endpoint"}
async def upload_info(i18n: I18n = Depends(get_i18n)):
return {"message": i18n.t("upload_endpoint")}