Merge remote-tracking branch 'origin/master'

# Conflicts:
#	docker-compose.yml
This commit is contained in:
2026-09-13 15:37:38 +08:00
11 changed files with 676 additions and 3 deletions
+68
View File
@@ -16,11 +16,14 @@ from contextlib import asynccontextmanager
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from urllib.error import HTTPError, URLError
from urllib.request import Request as UrlRequest, urlopen
from fastapi import FastAPI, File, Form, HTTPException, Query, Request, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, StreamingResponse
from PIL import Image, ImageDraw, ImageFont
from pydantic import BaseModel
from core import config as wc_config
from core.fonts import get_cached_font
@@ -82,6 +85,12 @@ METADATA_DIR.mkdir(parents=True, exist_ok=True)
ORDERS_DIR.mkdir(parents=True, exist_ok=True)
PRODUCT_ARCHIVES_DIR.mkdir(parents=True, exist_ok=True)
# The converter intentionally runs as a separate, unprivileged container. Do
# not expose it publicly: the application validates payload size and forwards
# only generated SVG documents to this private service.
AI_CONVERTER_URL = os.environ.get("AI_CONVERTER_URL", "").rstrip("/")
AI_EXPORT_MAX_BYTES = 25 * 1024 * 1024
metadata_store = MetadataStore(METADATA_DIR / "app.db")
manager = JobManager(metadata_store)
storage = Storage(WORKSPACE_DIR)
@@ -1019,6 +1028,65 @@ def get_file(job_id: str, kind: str):
return FileResponse(path, media_type=media, filename=path.name)
class AiExportRequest(BaseModel):
"""The SVG is produced by this application, then converted in a private service."""
svg: str
filename: str = "wordcloud.ai"
def _ai_download_name(value: str) -> str:
stem = Path(value).stem
stem = re.sub(r"[^A-Za-z0-9._-]+", "-", stem).strip(".-")[:80]
return f"{stem or 'wordcloud'}.ai"
@app.post("/api/exports/ai")
def export_ai(payload: AiExportRequest):
"""Convert an application-generated SVG to an Illustrator-compatible AI file.
The conversion service has no public port. Keeping this proxy in the main
API lets the frontend retain its same-origin API and gives us one place to
enforce file-size limits and translate converter errors.
"""
if not AI_CONVERTER_URL:
raise HTTPException(status_code=503, detail="AI 转换服务尚未配置")
svg_bytes = payload.svg.encode("utf-8")
if not svg_bytes:
raise HTTPException(status_code=400, detail="SVG 内容不能为空")
if len(svg_bytes) > AI_EXPORT_MAX_BYTES:
raise HTTPException(status_code=413, detail="SVG 文件超过 25 MB 限制")
request = UrlRequest(
f"{AI_CONVERTER_URL}/convert",
data=svg_bytes,
headers={"Content-Type": "image/svg+xml; charset=utf-8"},
method="POST",
)
try:
with urlopen(request, timeout=60) as response:
ai_bytes = response.read()
except HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")[:1000]
try:
detail = json.loads(detail).get("detail", detail)
except (json.JSONDecodeError, AttributeError):
pass
raise HTTPException(status_code=422 if exc.code == 422 else 502, detail=f"AI 转换失败: {detail}") from exc
except URLError as exc:
raise HTTPException(status_code=503, detail="AI 转换服务不可用") from exc
if not ai_bytes:
raise HTTPException(status_code=502, detail="AI 转换服务未返回文件")
filename = _ai_download_name(payload.filename)
return StreamingResponse(
io.BytesIO(ai_bytes),
media_type="application/postscript",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
# ═══════════════════════════════════════════════════════════
# 3.9 生产订单列表(下单派单投递的 WCD 生产任务,需登录)
# ═══════════════════════════════════════════════════════════