feat(wordcloud): 收口在途开发(布局/存储/前端)+ R4 WCD 生产任务(jobs wcd_file)与生产订单列表

This commit is contained in:
2026-08-13 14:22:48 +08:00
parent 1d17b5e20d
commit e518540235
32 changed files with 3525 additions and 592 deletions
+483 -11
View File
@@ -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 共用素材去重/注册/重映射逻辑;
状态机与产物管线复用 jobsqueued/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))