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))
+54 -3
View File
@@ -6,7 +6,9 @@ import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from .metadata_store import MetadataStore
from .schemas import JobDetail, JobEvent, JobStatus
@@ -18,9 +20,20 @@ class JobState:
class JobManager:
def __init__(self) -> None:
def __init__(self, store: Optional[MetadataStore] = None) -> None:
self._lock = threading.Lock()
self.store = store
self._jobs: dict[str, JobState] = {}
self._restore_from_store()
def _restore_from_store(self) -> None:
if not self.store:
return
for status in self.store.load_jobs():
self._jobs[status.job_id] = JobState(
status=status,
events=self.store.load_events(status.job_id, limit=100),
)
def create_job(self) -> str:
job_id = uuid.uuid4().hex
@@ -38,6 +51,8 @@ class JobManager:
)
with self._lock:
self._jobs[job_id] = JobState(status=status)
if self.store:
self.store.upsert_job(status)
return job_id
def exists(self, job_id: str) -> bool:
@@ -53,11 +68,19 @@ class JobManager:
state = self._jobs[job_id]
return JobDetail(status=state.status, recent_events=state.events[-100:])
def delete_job(self, job_id: str) -> None:
with self._lock:
self._jobs.pop(job_id, None)
if self.store:
self.store.delete_job(job_id)
def set_artifacts(self, job_id: str, artifacts: dict[str, str]) -> None:
with self._lock:
status = self._jobs[job_id].status
status.artifacts = artifacts
status.updated_at = datetime.now(timezone.utc)
if self.store:
self.store.upsert_job(status)
def set_status(
self,
@@ -68,6 +91,7 @@ class JobManager:
progress_percent: int,
message: str,
error: str | None = None,
elapsed_seconds: float | None = None,
) -> None:
with self._lock:
s = self._jobs[job_id].status
@@ -78,14 +102,28 @@ class JobManager:
s.updated_at = datetime.now(timezone.utc)
if error is not None:
s.error = error
if elapsed_seconds is not None:
s.elapsed_seconds = elapsed_seconds
if self.store:
self.store.upsert_job(s)
def add_event(self, job_id: str, *, kind: str, stage: str, progress_percent: int, message: str) -> None:
def add_event(
self,
job_id: str,
*,
kind: str,
stage: str,
progress_percent: int,
message: str,
elapsed_seconds: float | None = None,
) -> None:
event = JobEvent(
type=kind,
stage=stage,
progress_percent=progress_percent,
message=message,
timestamp=datetime.now(timezone.utc),
elapsed_seconds=elapsed_seconds,
)
with self._lock:
state = self._jobs[job_id]
@@ -94,13 +132,26 @@ class JobManager:
state.status.progress_percent = progress_percent
state.status.message = message
state.status.updated_at = event.timestamp
if elapsed_seconds is not None:
state.status.elapsed_seconds = elapsed_seconds
for sub in state.subscribers:
sub.put(event)
if self.store:
self.store.add_event(job_id, event)
self.store.upsert_job(state.status)
def subscribe(self, job_id: str) -> queue.Queue:
q: queue.Queue = queue.Queue()
with self._lock:
self._jobs[job_id].subscribers.append(q)
state = self._jobs[job_id]
# A fast job can emit preview_ready/completed before the browser
# finishes opening the SSE connection. Replay the existing event
# history into this subscriber so timing and preview updates are
# never lost; the lock also makes the snapshot/registration
# atomic with respect to new events.
for event in state.events:
q.put(event)
state.subscribers.append(q)
return q
def unsubscribe(self, job_id: str, q: queue.Queue) -> None:
+192
View File
@@ -0,0 +1,192 @@
"""Small SQLite-backed metadata store for jobs and events.
This is intentionally dependency-free and acts as the first durable layer for
business metadata. The schema is shaped so it can be moved to PostgreSQL later
without changing callers.
"""
from __future__ import annotations
import json
import sqlite3
import threading
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterable, Optional
from .schemas import JobEvent, JobStatus
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
class MetadataStore:
def __init__(self, db_path: Path) -> None:
self.db_path = db_path
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._lock = threading.Lock()
self._init_db()
def _connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(str(self.db_path), check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
return conn
def _init_db(self) -> None:
with self._lock, self._connect() as conn:
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
status TEXT NOT NULL,
stage TEXT NOT NULL,
progress_percent INTEGER NOT NULL DEFAULT 0,
message TEXT NOT NULL DEFAULT '',
artifacts TEXT NOT NULL DEFAULT '{}',
error TEXT NOT NULL DEFAULT '',
elapsed_seconds REAL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS job_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id TEXT NOT NULL,
type TEXT NOT NULL,
stage TEXT NOT NULL,
progress_percent INTEGER NOT NULL DEFAULT 0,
message TEXT NOT NULL DEFAULT '',
timestamp TEXT NOT NULL,
elapsed_seconds REAL
);
CREATE INDEX IF NOT EXISTS idx_job_events_job_timestamp
ON job_events(job_id, id);
"""
)
def upsert_job(self, status: JobStatus) -> None:
with self._lock, self._connect() as conn:
conn.execute(
"""
INSERT INTO jobs (
id, status, stage, progress_percent, message,
artifacts, error, elapsed_seconds, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
status = excluded.status,
stage = excluded.stage,
progress_percent = excluded.progress_percent,
message = excluded.message,
artifacts = excluded.artifacts,
error = excluded.error,
elapsed_seconds = excluded.elapsed_seconds,
updated_at = excluded.updated_at
""",
(
status.job_id,
status.status,
status.stage,
status.progress_percent,
status.message,
json.dumps(status.artifacts, ensure_ascii=False),
status.error,
status.elapsed_seconds,
status.created_at.isoformat(),
status.updated_at.isoformat(),
),
)
def add_event(self, job_id: str, event: JobEvent) -> None:
with self._lock, self._connect() as conn:
conn.execute(
"""
INSERT INTO job_events (
job_id, type, stage, progress_percent, message, timestamp, elapsed_seconds
) VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
job_id,
event.type,
event.stage,
event.progress_percent,
event.message,
event.timestamp.isoformat(),
event.elapsed_seconds,
),
)
def load_jobs(self) -> list[JobStatus]:
with self._lock, self._connect() as conn:
rows = conn.execute(
"""
SELECT * FROM jobs
"""
).fetchall()
result: list[JobStatus] = []
for row in rows:
try:
result.append(
JobStatus(
job_id=row["id"],
status=row["status"],
stage=row["stage"],
progress_percent=row["progress_percent"],
message=row["message"],
artifacts=json.loads(row["artifacts"] or "{}"),
error=row["error"],
created_at=datetime.fromisoformat(row["created_at"]),
updated_at=datetime.fromisoformat(row["updated_at"]),
elapsed_seconds=row["elapsed_seconds"],
)
)
except Exception:
continue
return result
def load_events(self, job_id: str, limit: int = 100) -> list[JobEvent]:
with self._lock, self._connect() as conn:
rows = conn.execute(
"""
SELECT type, stage, progress_percent, message, timestamp, elapsed_seconds
FROM job_events
WHERE job_id = ?
ORDER BY id DESC
LIMIT ?
""",
(job_id, limit),
).fetchall()
return [
JobEvent(
type=row["type"],
stage=row["stage"],
progress_percent=row["progress_percent"],
message=row["message"],
timestamp=datetime.fromisoformat(row["timestamp"]),
elapsed_seconds=row["elapsed_seconds"],
)
for row in reversed(rows)
]
def delete_job(self, job_id: str) -> None:
with self._lock, self._connect() as conn:
conn.execute("DELETE FROM jobs WHERE id = ?", (job_id,))
conn.execute("DELETE FROM job_events WHERE job_id = ?", (job_id,))
def job_ids(self) -> set[str]:
with self._lock, self._connect() as conn:
rows = conn.execute("SELECT id FROM jobs").fetchall()
return {row["id"] for row in rows}
def summarize(self) -> dict:
with self._lock, self._connect() as conn:
jobs = conn.execute("SELECT COUNT(*) AS n FROM jobs").fetchone()
events = conn.execute("SELECT COUNT(*) AS n FROM job_events").fetchone()
return {
"db_size_bytes": self.db_path.stat().st_size if self.db_path.exists() else 0,
"jobs": jobs["n"] if jobs else 0,
"events": events["n"] if events else 0,
}
+61 -6
View File
@@ -45,7 +45,7 @@ class JobRunner:
return current_stage, current_progress
def run(self, job_id: str, paths: JobPaths, config: dict) -> None:
t_start = time.time()
t_start = time.perf_counter()
log.info("=" * 50)
log.info("[Runner] 任务启动 job_id=%s", job_id)
log.info(" config_path = %s", paths.config_path)
@@ -65,6 +65,11 @@ class JobRunner:
]
env = os.environ.copy()
# Force unbuffered stdout so every print() flushes immediately and the
# frontend SSE log view shows each step in real time. Without this,
# Python block-buffers stdout when it is a pipe, so lines pile up and
# only arrive in bursts after the buffer fills or the process exits.
env["PYTHONUNBUFFERED"] = "1"
process = subprocess.Popen(
cmd,
cwd=str(self.project_root),
@@ -77,24 +82,63 @@ class JobRunner:
stage = "starting"
progress = 1
output_lines: list[str] = [] # collect all output lines for error reporting
preview_sent = False
assert process.stdout is not None
for raw in process.stdout:
line = raw.rstrip("\n")
output_lines.append(line)
log.info("[Pipeline] %s", line)
stage, progress = self._parse_stage(line, stage, progress)
elapsed = time.perf_counter() - t_start
self.manager.add_event(
job_id,
kind="log",
stage=stage,
progress_percent=progress,
message=line,
elapsed_seconds=round(elapsed, 3),
)
# The PNG is complete before SVG/DB export starts. Publish it as a
# preview so the frontend does not wait for the slower artifacts.
if not preview_sent and line.startswith("已保存:"):
candidate = Path(line.split(":", 1)[1].strip())
if candidate.suffix.lower() == ".png" and candidate.exists():
self.manager.set_artifacts(job_id, {"png": str(candidate)})
self.manager.add_event(
job_id,
kind="status",
stage="preview_ready",
progress_percent=max(progress, 94),
message="预览已生成,后台继续导出其余文件",
elapsed_seconds=round(elapsed, 3),
)
preview_sent = True
ret = process.wait()
elapsed = time.time() - t_start
elapsed = time.perf_counter() - t_start
log.info("[Runner] 子进程退出 code=%d 耗时=%.2fs", ret, elapsed)
# ── 错误时:截取最后 30 行输出作为详细错误信息 ─────────────
error_detail = None
if ret != 0:
# 找到 "生成失败" 或 "错误" 或 traceback 之后的内容
error_lines = []
captured = False
for line in reversed(output_lines):
if not captured:
error_lines.append(line)
if any(kw in line for kw in ("生成失败", "error", "Error", "Traceback", "traceback", "未满足", "放置")):
captured = True
elif len(error_lines) < 30:
error_lines.append(line)
else:
break
error_lines.reverse()
error_detail = "\n".join(error_lines) if error_lines else f"script exited with code {ret}"
log.info("[Runner] 错误详情:\n%s", error_detail)
png = next(paths.output_dir.glob("*.png"), None)
# NOTE: do NOT use "*[!_stroke].svg" — in glob, [!...] is a character class,
# so filenames ending with "e.svg" (e.g. AutoResize.svg) are incorrectly skipped.
@@ -142,15 +186,24 @@ class JobRunner:
return
if ret == 0:
done_message = f"任务完成,用时 {elapsed:.2f}"
log.info("[Runner] ✅ 任务完成 job_id=%s 总耗时=%.2fs", job_id, elapsed)
self.manager.add_event(
job_id,
kind="status",
stage="completed",
progress_percent=100,
message="任务完成",
message=done_message,
elapsed_seconds=round(elapsed, 3),
)
self.manager.set_status(
job_id,
status="success",
stage="completed",
progress_percent=100,
message=done_message,
elapsed_seconds=round(elapsed, 3),
)
self.manager.set_status(job_id, status="success", stage="completed", progress_percent=100, message="任务完成")
else:
log.error("[Runner] ❌ 任务失败 job_id=%s exit_code=%d 耗时=%.2fs", job_id, ret, elapsed)
self.manager.add_event(
@@ -158,7 +211,8 @@ class JobRunner:
kind="status",
stage="failed",
progress_percent=100,
message=f"任务失败,退出码: {ret}",
message=f"任务失败,退出码: {ret},用时 {elapsed:.2f}",
elapsed_seconds=round(elapsed, 3),
)
self.manager.set_status(
job_id,
@@ -166,5 +220,6 @@ class JobRunner:
stage="failed",
progress_percent=100,
message="任务失败",
error=f"script exited with code {ret}",
error=error_detail or f"script exited with code {ret}",
elapsed_seconds=round(elapsed, 3),
)
+3
View File
@@ -17,6 +17,7 @@ class JobEvent(BaseModel):
progress_percent: int = Field(ge=0, le=100)
message: str
timestamp: datetime
elapsed_seconds: float | None = None
class JobStatus(BaseModel):
@@ -29,6 +30,7 @@ class JobStatus(BaseModel):
updated_at: datetime
artifacts: dict[str, str]
error: str = ""
elapsed_seconds: float | None = None
class JobDetail(BaseModel):
@@ -44,6 +46,7 @@ class JobResult(BaseModel):
svg_stroke_url: str = ""
db_url: str = ""
metrics_url: str = ""
elapsed_seconds: float | None = None
class WordLocation(BaseModel):
+74
View File
@@ -1,10 +1,17 @@
from __future__ import annotations
import os
import re
import shutil
import time
from pathlib import Path
from .schemas import JobPaths
_JOB_ID_RE = re.compile(r"^[0-9a-f]{32,}$")
class Storage:
def __init__(self, base_dir: Path) -> None:
self.base_dir = base_dir
@@ -25,3 +32,70 @@ class Storage:
excel_path=input_dir / "names.xlsx",
config_path=root / "config.json",
)
def job_root(self, job_id: str) -> Path:
return self.base_dir / job_id
def job_dir_size(self, job_id: str) -> int:
root = self.job_root(job_id)
if not root.exists():
return 0
total = 0
for dirpath, _, filenames in os.walk(root):
for filename in filenames:
try:
total += os.path.getsize(os.path.join(dirpath, filename))
except OSError:
continue
return total
def job_dir_info(self, job_id: str) -> dict | None:
root = self.job_root(job_id)
if not root.is_dir():
return None
try:
mtime = root.stat().st_mtime
except OSError:
return None
return {
"job_id": job_id,
"path": str(root),
"size_bytes": self.job_dir_size(job_id),
"age_days": round(max(0.0, time.time() - mtime) / 86400.0, 2),
}
def list_job_ids(self) -> list[str]:
if not self.base_dir.exists():
return []
return [
item.name
for item in self.base_dir.iterdir()
if item.is_dir() and _JOB_ID_RE.match(item.name)
]
def stale_job_dirs(
self,
referenced_job_ids: set[str],
exclude_job_ids: set[str] | None = None,
max_age_days: float | None = None,
) -> list[dict]:
exclude = exclude_job_ids or set()
result: list[dict] = []
for job_id in self.list_job_ids():
if job_id in referenced_job_ids or job_id in exclude:
continue
info = self.job_dir_info(job_id)
if not info:
continue
if max_age_days is not None and info["age_days"] < max_age_days:
continue
result.append(info)
return sorted(result, key=lambda item: item["size_bytes"], reverse=True)
def remove_job_dir(self, job_id: str) -> int:
root = self.job_root(job_id)
if not root.exists():
return 0
size = self.job_dir_size(job_id)
shutil.rmtree(root, ignore_errors=True)
return size
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Inspect and optionally clean stale job directories.
Default mode is a safe dry-run that reports reclaimable bytes. Pass --apply to
actually remove unreferenced job directories.
"""
from __future__ import annotations
import argparse
import json
from datetime import datetime, timezone
from pathlib import Path
from .metadata_store import MetadataStore
from .storage import Storage
BACKEND_ROOT = Path(__file__).resolve().parents[1]
PROJECT_ROOT = BACKEND_ROOT
WORKSPACE_DIR = PROJECT_ROOT / "service_workspace"
ASSETS_DIR = PROJECT_ROOT / "service_assets"
METADATA_DIR = PROJECT_ROOT / "service_metadata"
def read_asset_meta(path: Path) -> dict:
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return {}
def referenced_job_ids() -> set[str]:
refs: set[str] = set()
for meta_path in ASSETS_DIR.glob("*/*/meta.json"):
job_id = read_asset_meta(meta_path).get("job_id") or ""
if job_id:
refs.add(job_id)
return refs
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--max-age-days", type=float, default=0)
parser.add_argument("--apply", action="store_true", help="Actually delete stale job directories")
parser.add_argument("--json", type=Path, default=None, help="Write JSON report")
args = parser.parse_args()
store = MetadataStore(METADATA_DIR / "app.db")
storage = Storage(WORKSPACE_DIR)
known_job_ids = store.job_ids() if store.db_path.exists() else set()
referenced = referenced_job_ids()
stale = storage.stale_job_dirs(
referenced_job_ids=referenced,
exclude_job_ids=known_job_ids,
max_age_days=args.max_age_days,
)
reclaimable = sum(item["size_bytes"] for item in stale)
report = {
"scanned_at": datetime.now(timezone.utc).isoformat(),
"job_dir_count": len(storage.list_job_ids()),
"referenced_job_ids": len(referenced),
"known_job_ids": len(known_job_ids),
"stale_job_count": len(stale),
"reclaimable_bytes": reclaimable,
"max_age_days": args.max_age_days,
"apply": args.apply,
"stale_jobs": stale[:200],
}
print(json.dumps(report, ensure_ascii=False, indent=2))
if args.json:
args.json.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
if args.apply:
freed = 0
for item in stale:
freed += storage.remove_job_dir(item["job_id"])
store.delete_job(item["job_id"])
print(f"freed_bytes={freed}")
if __name__ == "__main__":
main()