feat(wordcloud): 收口在途开发(布局/存储/前端)+ R4 WCD 生产任务(jobs wcd_file)与生产订单列表
This commit is contained in:
+483
-11
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
@@ -10,6 +11,7 @@ import sqlite3
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
@@ -24,6 +26,7 @@ from core.fonts import get_cached_font
|
||||
from .job_manager import JobManager
|
||||
from .line_spacing import analyze_svg_line_spacing_file
|
||||
from .log_config import get_logger
|
||||
from .metadata_store import MetadataStore
|
||||
from .runner import JobRunner
|
||||
from .schemas import (
|
||||
Asset,
|
||||
@@ -57,12 +60,17 @@ ASSETS_DIR = PROJECT_ROOT / "service_assets"
|
||||
PROJECTS_DIR = PROJECT_ROOT / "service_projects"
|
||||
FONTS_DIR = PROJECT_ROOT / "service_fonts"
|
||||
DESIGN_TEMPLATES_DIR = PROJECT_ROOT / "service_design_templates"
|
||||
METADATA_DIR = PROJECT_ROOT / "service_metadata"
|
||||
ORDERS_DIR = PROJECT_ROOT / "service_orders"
|
||||
ASSETS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
PROJECTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
FONTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
DESIGN_TEMPLATES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
METADATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
ORDERS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
manager = JobManager()
|
||||
metadata_store = MetadataStore(METADATA_DIR / "app.db")
|
||||
manager = JobManager(metadata_store)
|
||||
storage = Storage(WORKSPACE_DIR)
|
||||
runner = JobRunner(PROJECT_ROOT, manager)
|
||||
|
||||
@@ -144,6 +152,31 @@ def health() -> dict:
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/api/maintenance/storage-summary")
|
||||
def storage_summary() -> dict:
|
||||
referenced_jobs: set[str] = set()
|
||||
for d in _list_dirs(ASSETS_DIR):
|
||||
meta = _read_asset_meta(d)
|
||||
job_id = meta.get("job_id") or ""
|
||||
if job_id:
|
||||
referenced_jobs.add(job_id)
|
||||
stale = storage.stale_job_dirs(
|
||||
referenced_job_ids=referenced_jobs,
|
||||
exclude_job_ids=metadata_store.job_ids(),
|
||||
max_age_days=0,
|
||||
)
|
||||
return {
|
||||
"job_dir_count": len(storage.list_job_ids()),
|
||||
"referenced_job_ids": len(referenced_jobs),
|
||||
"stale_job_count": len(stale),
|
||||
"reclaimable_bytes": sum(item["size_bytes"] for item in stale),
|
||||
"metadata_db_bytes": metadata_store.summarize()["db_size_bytes"],
|
||||
"jobs_in_db": metadata_store.summarize()["jobs"],
|
||||
"events_in_db": metadata_store.summarize()["events"],
|
||||
"dry_run_only": True,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/jobs", response_model=list[JobStatus])
|
||||
def list_jobs() -> list[JobStatus]:
|
||||
with manager._lock:
|
||||
@@ -153,26 +186,21 @@ def list_jobs() -> list[JobStatus]:
|
||||
@app.post("/api/jobs", response_model=JobCreateResponse)
|
||||
async def create_job(
|
||||
mask_image: Optional[UploadFile] = File(None),
|
||||
name_list: UploadFile = File(...),
|
||||
name_list: Optional[UploadFile] = File(None),
|
||||
wcd_file: Optional[UploadFile] = File(None),
|
||||
font_file: Optional[UploadFile] = File(None),
|
||||
font_id: str = Form(""),
|
||||
params: str = Form("{}"),
|
||||
) -> JobCreateResponse:
|
||||
log.info("─" * 50)
|
||||
log.info("[API] POST /api/jobs 收到新任务请求")
|
||||
log.info(" name_list.filename = %s", name_list.filename)
|
||||
log.info(" name_list.filename = %s", name_list.filename if name_list else "无")
|
||||
log.info(" wcd_file.filename = %s", wcd_file.filename if wcd_file else "无")
|
||||
log.info(" mask_image.filename = %s", mask_image.filename if mask_image else "无")
|
||||
log.info(" font_file.filename = %s", font_file.filename if font_file else "无")
|
||||
log.info(" font_id = %s", font_id or "(未指定)")
|
||||
log.info(" params (raw) = %s", params)
|
||||
|
||||
if not name_list.filename:
|
||||
raise HTTPException(status_code=400, detail="name_list is required")
|
||||
|
||||
ext_xlsx = Path(name_list.filename).suffix.lower()
|
||||
if ext_xlsx not in {".xlsx"}:
|
||||
raise HTTPException(status_code=400, detail="name_list must be xlsx")
|
||||
|
||||
try:
|
||||
user_params = json.loads(params)
|
||||
except json.JSONDecodeError:
|
||||
@@ -181,6 +209,23 @@ async def create_job(
|
||||
if not isinstance(user_params, dict):
|
||||
raise HTTPException(status_code=400, detail="params must be JSON object")
|
||||
|
||||
mode = str(user_params.get("MODE", "IMAGE")).upper()
|
||||
|
||||
# WCD 生产任务:传入 .wcd 画布导入导出包(还原设计 -> 生产,见 docs/wordcloud-contract.md v1.1)
|
||||
is_wcd = mode == "WCD" or bool(wcd_file and wcd_file.filename)
|
||||
if is_wcd:
|
||||
if not (wcd_file and wcd_file.filename):
|
||||
raise HTTPException(status_code=400, detail="wcd_file is required when MODE=WCD")
|
||||
return await _create_wcd_job(wcd_file, user_params)
|
||||
|
||||
# 名单/xlsx 模式(既有逻辑;必填校验移到 mode/WCD 判断之后)
|
||||
if not name_list or not name_list.filename:
|
||||
raise HTTPException(status_code=400, detail="name_list is required")
|
||||
|
||||
ext_xlsx = Path(name_list.filename).suffix.lower()
|
||||
if ext_xlsx not in {".xlsx"}:
|
||||
raise HTTPException(status_code=400, detail="name_list must be xlsx")
|
||||
|
||||
log.info(" 解析后 params = %s", json.dumps(user_params, ensure_ascii=False))
|
||||
|
||||
mode = str(user_params.get("MODE", "IMAGE")).upper()
|
||||
@@ -234,8 +279,12 @@ async def create_job(
|
||||
"MODE": mode,
|
||||
"EXCEL_PATH": str(paths.excel_path),
|
||||
"OUTPUT_DIR": str(paths.output_dir),
|
||||
"SAVE_DEBUG_IMAGES": True,
|
||||
# Debug masks are useful during local diagnosis but add extra image
|
||||
# writes to every request. Keep the fast service path disk-light; the
|
||||
# explicit CLI/config option remains available when debugging.
|
||||
"SAVE_DEBUG_IMAGES": False,
|
||||
"DEBUG_OUTPUT_DIR": str(paths.output_dir / "debug"),
|
||||
"FAST_MODE": True,
|
||||
}
|
||||
if mask_image and mask_image.filename:
|
||||
config["MASK_IMAGE_PATH"] = str(paths.mask_path)
|
||||
@@ -273,6 +322,174 @@ async def create_job(
|
||||
return JobCreateResponse(job_id=job_id)
|
||||
|
||||
|
||||
def _parse_hex(color: object):
|
||||
"""把 '#RRGGBB' / '#RGB' / 空值 解析为 RGBA tuple。"""
|
||||
raw = str(color or "#ffffff").strip().lstrip("#")
|
||||
if len(raw) == 3:
|
||||
raw = "".join(c + c for c in raw)
|
||||
try:
|
||||
return tuple(int(raw[i : i + 2], 16) for i in (0, 2, 4)) + (255,)
|
||||
except ValueError:
|
||||
return (255, 255, 255, 255)
|
||||
|
||||
|
||||
def _compose_design_png(document: dict, file_map: dict, output_path: Path) -> None:
|
||||
"""把 CanvasDocument 合成一张扁平 PNG(生产任务产物):背景 + 按 zIndex 叠贴纸。
|
||||
|
||||
file_map: {pkg_asset_id: {"path": str}},即 WCD 内临时 assetId -> 落盘素材文件。
|
||||
"""
|
||||
try:
|
||||
width = int(document.get("width") or 1200)
|
||||
height = int(document.get("height") or 1200)
|
||||
except (TypeError, ValueError):
|
||||
width, height = 1200, 1200
|
||||
canvas = Image.new("RGBA", (max(width, 1), max(height, 1)), _parse_hex(document.get("background")))
|
||||
elements = [
|
||||
e
|
||||
for e in document.get("elements", [])
|
||||
if isinstance(e, dict) and e.get("type") == "sticker"
|
||||
]
|
||||
elements.sort(key=lambda e: e.get("zIndex", 0))
|
||||
for e in elements:
|
||||
info = file_map.get(str(e.get("assetId") or ""))
|
||||
if not info:
|
||||
continue
|
||||
path = info.get("path")
|
||||
if not path or not Path(path).exists():
|
||||
continue
|
||||
try:
|
||||
img = Image.open(path).convert("RGBA")
|
||||
except Exception:
|
||||
continue
|
||||
ew = e.get("width")
|
||||
eh = e.get("height")
|
||||
ew = int(ew) if isinstance(ew, (int, float)) and ew > 0 else img.width
|
||||
eh = int(eh) if isinstance(eh, (int, float)) and eh > 0 else img.height
|
||||
if (ew, eh) != (img.width, img.height):
|
||||
img = img.resize((int(ew), int(eh)))
|
||||
opacity = e.get("opacity", 1)
|
||||
if isinstance(opacity, (int, float)) and opacity >= 0 and opacity != 1:
|
||||
img = img.copy()
|
||||
img.putalpha(img.getchannel("A").point(lambda v: round(v * float(opacity))))
|
||||
canvas.alpha_composite(img, (int(e.get("x") or 0), int(e.get("y") or 0)))
|
||||
canvas.convert("RGB").save(output_path, "PNG")
|
||||
|
||||
|
||||
async def _create_wcd_job(wcd_file: UploadFile, user_params: dict) -> JobCreateResponse:
|
||||
"""WCD 生产任务:校验并还原 .wcd,落库设计,合成生产 PNG 作为任务产物。
|
||||
|
||||
与 POST /api/design-templates/import 共用素材去重/注册/重映射逻辑;
|
||||
状态机与产物管线复用 jobs(queued/running/success/failed)。
|
||||
"""
|
||||
if Path(wcd_file.filename).suffix.lower() != ".wcd":
|
||||
raise HTTPException(status_code=400, detail="wcd_file must be .wcd")
|
||||
content = await wcd_file.read()
|
||||
if not content:
|
||||
raise HTTPException(status_code=400, detail="wcd_file is empty")
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(content))
|
||||
except zipfile.BadZipFile as exc:
|
||||
raise HTTPException(status_code=400, detail="file is not a valid zip/wcd package") from exc
|
||||
|
||||
with zf:
|
||||
manifest = _read_import_json(zf, "manifest.json")
|
||||
order_meta = manifest.get("meta") or {}
|
||||
order_no = str(order_meta.get("orderNo") or "") if isinstance(order_meta, dict) else ""
|
||||
# 兼容旧命名 order-{orderNo}
|
||||
if not order_no and str(manifest.get("name") or "").startswith("order-"):
|
||||
order_no = str(manifest.get("name"))[6:]
|
||||
if manifest.get("format") != "wordcloud-canvas":
|
||||
raise HTTPException(status_code=400, detail="不是 wordcloud-canvas 格式")
|
||||
try:
|
||||
version = int(manifest.get("version", 1))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail="manifest version 不是合法数字") from exc
|
||||
if version != 1:
|
||||
raise HTTPException(status_code=400, detail=f"不支持的包版本: {version}")
|
||||
|
||||
document = _read_import_json(zf, "document.json")
|
||||
asset_items = manifest.get("assets")
|
||||
if not isinstance(asset_items, list):
|
||||
asset_items = []
|
||||
|
||||
remap: dict[str, str] = {}
|
||||
file_map: dict[str, dict] = {}
|
||||
reference_ids: list[str] = []
|
||||
for item in asset_items:
|
||||
pkg_id = str(item.get("id") or "")
|
||||
if not pkg_id:
|
||||
continue
|
||||
entry_name = _zip_asset_entry(zf, pkg_id)
|
||||
mime = _imported_asset_mime(item)
|
||||
asset_bytes = zf.read(entry_name)
|
||||
meta = _register_import_asset(str(item.get("name") or pkg_id), asset_bytes, mime)
|
||||
real_id = str(meta["asset_id"])
|
||||
remap[pkg_id] = real_id
|
||||
file_map[pkg_id] = {
|
||||
"path": str(_asset_dir(real_id) / f"asset{_asset_extension_for_mime(mime)}"),
|
||||
"mime": mime,
|
||||
}
|
||||
reference_ids.append(real_id)
|
||||
_remap_import_asset_ids(document, remap)
|
||||
|
||||
# 落库生产设计(与模板导入一致,便于追溯/复用/在画布中继续编辑)
|
||||
display_name = str(manifest.get("name") or "导入生产设计").strip()[:120] or "导入生产设计"
|
||||
template_id = f"tmpl_{uuid.uuid4().hex}"
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
_write_design_template(_design_template_dir(template_id), {
|
||||
"template_id": template_id,
|
||||
"name": display_name,
|
||||
"description": str(manifest.get("description") or "下单派单生产设计"),
|
||||
"document": document,
|
||||
"reference_asset_ids": reference_ids,
|
||||
"cover_asset_id": reference_ids[0] if reference_ids else "",
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
})
|
||||
|
||||
job_id = manager.create_job()
|
||||
paths = storage.prepare_job_dirs(job_id)
|
||||
(paths.input_dir / f"{template_id}.wcd").write_bytes(content)
|
||||
log.info("[WCD] 生产任务 job_id=%s template=%s assets=%d", job_id, template_id, len(reference_ids))
|
||||
|
||||
# 登记生产订单(供 wordcloud 侧订单列表查看)
|
||||
if order_no:
|
||||
_write_order(_order_dir(order_no), {
|
||||
"order_id": order_no,
|
||||
"order_no": order_no,
|
||||
"job_id": job_id,
|
||||
"template_id": template_id,
|
||||
"status": "running",
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
log.info("[Order] 登记生产订单 %s → job %s", order_no, job_id)
|
||||
manager.set_status(
|
||||
job_id, status="running", stage="composing", progress_percent=10, message="WCD 生产任务"
|
||||
)
|
||||
|
||||
def _run_wcd_safe() -> None:
|
||||
try:
|
||||
png_path = paths.output_dir / "result.png"
|
||||
_compose_design_png(document, file_map, png_path)
|
||||
manager.set_artifacts(job_id, {"png": str(png_path)})
|
||||
manager.set_status(
|
||||
job_id, status="success", stage="done", progress_percent=100, message="生产完成"
|
||||
)
|
||||
except Exception as exc:
|
||||
logging.exception("wcd job crashed", extra={"job_id": job_id})
|
||||
manager.set_status(
|
||||
job_id,
|
||||
status="failed",
|
||||
stage="failed",
|
||||
progress_percent=100,
|
||||
message="任务失败",
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
threading.Thread(target=_run_wcd_safe, daemon=True).start()
|
||||
return JobCreateResponse(job_id=job_id)
|
||||
|
||||
|
||||
@app.get("/api/jobs/{job_id}", response_model=JobStatus)
|
||||
def get_job(job_id: str) -> JobStatus:
|
||||
if not manager.exists(job_id):
|
||||
@@ -329,6 +546,7 @@ def get_result(job_id: str) -> JobResult:
|
||||
svg_stroke_url=u("svg_stroke"),
|
||||
db_url=u("db"),
|
||||
metrics_url=u("metrics"),
|
||||
elapsed_seconds=status.elapsed_seconds,
|
||||
)
|
||||
|
||||
|
||||
@@ -676,6 +894,45 @@ def get_file(job_id: str, kind: str):
|
||||
return FileResponse(path, media_type=media, filename=path.name)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 3.9 生产订单列表(下单派单投递的 WCD 生产任务)
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
@app.get("/api/orders")
|
||||
def list_orders() -> list[dict]:
|
||||
"""生产订单列表:来自小程序下单派单投递到 wordcloud 的 WCD 生产任务。"""
|
||||
orders = []
|
||||
if ORDERS_DIR.exists():
|
||||
for item in list(ORDERS_DIR.iterdir()):
|
||||
if not item.is_dir() or not (item / "order.json").exists():
|
||||
continue
|
||||
o = _read_order(item)
|
||||
if not o:
|
||||
continue
|
||||
# 用 job 的最新状态回填
|
||||
try:
|
||||
st = manager.get_status(str(o.get("job_id") or ""))
|
||||
o["status"] = st.status
|
||||
except Exception:
|
||||
pass
|
||||
orders.append(o)
|
||||
return list(reversed(orders))
|
||||
|
||||
|
||||
@app.get("/api/orders/{order_no}")
|
||||
def get_order(order_no: str) -> dict:
|
||||
path = Path(_order_dir(order_no)) / "order.json"
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=404, detail="order not found")
|
||||
o = json.loads(path.read_text(encoding="utf-8"))
|
||||
try:
|
||||
st = manager.get_status(str(o.get("job_id") or ""))
|
||||
o["status"] = st.status
|
||||
except Exception:
|
||||
pass
|
||||
return o
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 4. 模板接口
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
@@ -700,6 +957,26 @@ def _write_design_template(template_dir: Path, data: dict) -> None:
|
||||
)
|
||||
|
||||
|
||||
# ── 生产订单存储(下单派单投递的 WCD 生产任务)────────────
|
||||
def _order_dir(order_no: str) -> Path:
|
||||
return ORDERS_DIR / order_no
|
||||
|
||||
|
||||
def _read_order(order_dir: Path) -> dict:
|
||||
path = order_dir / "order.json"
|
||||
if not path.exists():
|
||||
return {}
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _write_order(order_dir: Path, data: dict) -> None:
|
||||
order_dir.mkdir(parents=True, exist_ok=True)
|
||||
(order_dir / "order.json").write_text(
|
||||
json.dumps(data, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _parse_json_array(value: str, field_name: str) -> list:
|
||||
try:
|
||||
parsed = json.loads(value or "[]")
|
||||
@@ -758,6 +1035,201 @@ async def create_design_template(
|
||||
return DesignTemplate(**data)
|
||||
|
||||
|
||||
def _read_import_json(zf: zipfile.ZipFile, name: str) -> dict:
|
||||
try:
|
||||
with zf.open(name) as fh:
|
||||
raw = fh.read().decode("utf-8")
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=400, detail=f"{name} 缺失") from exc
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise HTTPException(status_code=400, detail=f"{name} 不是合法 JSON") from exc
|
||||
if not isinstance(data, dict):
|
||||
raise HTTPException(status_code=400, detail=f"{name} 必须是 JSON 对象")
|
||||
return data
|
||||
|
||||
|
||||
def _imported_asset_mime(item: dict) -> str:
|
||||
mime = str(item.get("mimeType") or item.get("mime_type") or "").strip().lower()
|
||||
if mime in {"image/svg+xml", "image/png", "image/jpeg"}:
|
||||
return mime
|
||||
asset_type = str(item.get("type") or "").strip().lower()
|
||||
if asset_type in {"svg", "image/svg+xml"}:
|
||||
return "image/svg+xml"
|
||||
if asset_type in {"image", "png"}:
|
||||
return "image/png"
|
||||
return "image/svg+xml" if str(item.get("id", "")).endswith(".svg") else "image/png"
|
||||
|
||||
|
||||
def _asset_meta_items() -> list[dict]:
|
||||
result: list[dict] = []
|
||||
for d in _list_dirs(ASSETS_DIR):
|
||||
meta = _read_asset_meta(d)
|
||||
if meta:
|
||||
result.append(meta)
|
||||
return result
|
||||
|
||||
|
||||
def _find_asset_by_sha256(digest: str) -> dict | None:
|
||||
for meta in _asset_meta_items():
|
||||
if meta.get("sha256") == digest:
|
||||
return meta
|
||||
for meta in _asset_meta_items():
|
||||
try:
|
||||
ext = _asset_extension_for_mime(str(meta.get("mime_type") or ""))
|
||||
path = _asset_dir(str(meta["asset_id"])) / f"asset{ext}"
|
||||
if path.exists() and hashlib.sha256(path.read_bytes()).hexdigest() == digest:
|
||||
meta["sha256"] = digest
|
||||
_write_asset_meta(_asset_dir(str(meta["asset_id"])), meta)
|
||||
return meta
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _register_import_asset(name: str, content: bytes, mime: str) -> dict:
|
||||
digest = hashlib.sha256(content).hexdigest()
|
||||
existing = _find_asset_by_sha256(digest)
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
asset_id = f"asset_{uuid.uuid4().hex}"
|
||||
asset_path = _asset_dir(asset_id)
|
||||
asset_path.mkdir(parents=True, exist_ok=True)
|
||||
ext = _asset_extension_for_mime(mime)
|
||||
dest = asset_path / f"asset{ext}"
|
||||
dest.write_bytes(content)
|
||||
|
||||
width, height = 0, 0
|
||||
if mime == "image/svg+xml":
|
||||
width, height = _parse_svg_viewbox(dest)
|
||||
else:
|
||||
try:
|
||||
with Image.open(dest) as img:
|
||||
width, height = img.size
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
meta = {
|
||||
"asset_id": asset_id,
|
||||
"name": name[:120] or "导入素材",
|
||||
"type": "sticker",
|
||||
"mime_type": mime,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"file_size": len(content),
|
||||
"sha256": digest,
|
||||
"file_url": f"/api/assets/{asset_id}/download",
|
||||
"job_id": "",
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
_write_asset_meta(asset_path, meta)
|
||||
return meta
|
||||
|
||||
|
||||
def _zip_asset_entry(zf: zipfile.ZipFile, pkg_id: str) -> str:
|
||||
candidates: list[str] = []
|
||||
for entry in zf.namelist():
|
||||
parts = entry.split("/")
|
||||
if (
|
||||
len(parts) >= 2
|
||||
and parts[0] == "assets"
|
||||
and parts[1].startswith(pkg_id)
|
||||
and ".." not in parts
|
||||
and not entry.endswith("/")
|
||||
):
|
||||
candidates.append(entry)
|
||||
if not candidates:
|
||||
raise HTTPException(status_code=400, detail=f"包内缺少素材: {pkg_id}")
|
||||
for entry in candidates:
|
||||
if Path(entry).stem == pkg_id:
|
||||
return entry
|
||||
return sorted(candidates)[0]
|
||||
|
||||
|
||||
def _remap_import_asset_ids(document: dict, asset_map: dict[str, str]) -> None:
|
||||
elements = document.get("elements")
|
||||
if not isinstance(elements, list):
|
||||
return
|
||||
for element in elements:
|
||||
if isinstance(element, dict) and element.get("type") == "sticker":
|
||||
old_id = element.get("assetId")
|
||||
if isinstance(old_id, str) and old_id in asset_map:
|
||||
element["assetId"] = asset_map[old_id]
|
||||
|
||||
|
||||
@app.post("/api/design-templates/import", response_model=DesignTemplate)
|
||||
async def import_design_template(
|
||||
file: UploadFile = File(...),
|
||||
name: str = Form(""),
|
||||
description: str = Form(""),
|
||||
) -> DesignTemplate:
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail="file is required")
|
||||
if Path(file.filename).suffix.lower() != ".wcd":
|
||||
raise HTTPException(status_code=400, detail="file must be .wcd")
|
||||
|
||||
content = await file.read()
|
||||
if not content:
|
||||
raise HTTPException(status_code=400, detail="file is empty")
|
||||
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(content))
|
||||
except zipfile.BadZipFile as exc:
|
||||
raise HTTPException(status_code=400, detail="file is not a valid zip/wcd package") from exc
|
||||
|
||||
with zf:
|
||||
manifest = _read_import_json(zf, "manifest.json")
|
||||
if manifest.get("format") != "wordcloud-canvas":
|
||||
raise HTTPException(status_code=400, detail="不是 wordcloud-canvas 格式")
|
||||
try:
|
||||
version = int(manifest.get("version", 1))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail="manifest version 不是合法数字") from exc
|
||||
if version != 1:
|
||||
raise HTTPException(status_code=400, detail=f"不支持的包版本: {version}")
|
||||
|
||||
document = _read_import_json(zf, "document.json")
|
||||
asset_items = manifest.get("assets")
|
||||
if not isinstance(asset_items, list):
|
||||
asset_items = []
|
||||
|
||||
asset_map: dict[str, str] = {}
|
||||
reference_ids: list[str] = []
|
||||
for item in asset_items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
pkg_id = str(item.get("id") or "")
|
||||
if not pkg_id:
|
||||
continue
|
||||
entry_name = _zip_asset_entry(zf, pkg_id)
|
||||
mime = _imported_asset_mime(item)
|
||||
asset_bytes = zf.read(entry_name)
|
||||
meta = _register_import_asset(str(item.get("name") or pkg_id), asset_bytes, mime)
|
||||
asset_map[pkg_id] = str(meta["asset_id"])
|
||||
reference_ids.append(str(meta["asset_id"]))
|
||||
|
||||
_remap_import_asset_ids(document, asset_map)
|
||||
|
||||
display_name = name.strip() or str(manifest.get("name") or "导入设计").strip() or "导入设计"
|
||||
display_description = description.strip() or str(manifest.get("description") or "").strip()
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
template_id = f"tmpl_{uuid.uuid4().hex}"
|
||||
data = {
|
||||
"template_id": template_id,
|
||||
"name": display_name[:120],
|
||||
"description": display_description,
|
||||
"document": document,
|
||||
"reference_asset_ids": reference_ids,
|
||||
"cover_asset_id": reference_ids[0] if reference_ids else "",
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
_write_design_template(_design_template_dir(template_id), data)
|
||||
return DesignTemplate(**data)
|
||||
|
||||
|
||||
@app.get("/api/design-templates/{template_id}", response_model=DesignTemplate)
|
||||
def get_design_template(template_id: str) -> DesignTemplate:
|
||||
data = _read_design_template(_design_template_dir(template_id))
|
||||
|
||||
Reference in New Issue
Block a user