Initial project baseline
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,119 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
import threading
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from .schemas import JobDetail, JobEvent, JobStatus
|
||||
|
||||
|
||||
@dataclass
|
||||
class JobState:
|
||||
status: JobStatus
|
||||
events: list[JobEvent] = field(default_factory=list)
|
||||
subscribers: list[queue.Queue] = field(default_factory=list)
|
||||
|
||||
|
||||
class JobManager:
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._jobs: dict[str, JobState] = {}
|
||||
|
||||
def create_job(self) -> str:
|
||||
job_id = uuid.uuid4().hex
|
||||
now = datetime.now(timezone.utc)
|
||||
status = JobStatus(
|
||||
job_id=job_id,
|
||||
status="queued",
|
||||
stage="queued",
|
||||
progress_percent=0,
|
||||
message="任务已创建",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
artifacts={},
|
||||
error="",
|
||||
)
|
||||
with self._lock:
|
||||
self._jobs[job_id] = JobState(status=status)
|
||||
return job_id
|
||||
|
||||
def exists(self, job_id: str) -> bool:
|
||||
with self._lock:
|
||||
return job_id in self._jobs
|
||||
|
||||
def get_status(self, job_id: str) -> JobStatus:
|
||||
with self._lock:
|
||||
return self._jobs[job_id].status
|
||||
|
||||
def get_detail(self, job_id: str) -> JobDetail:
|
||||
with self._lock:
|
||||
state = self._jobs[job_id]
|
||||
return JobDetail(status=state.status, recent_events=state.events[-100:])
|
||||
|
||||
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)
|
||||
|
||||
def set_status(
|
||||
self,
|
||||
job_id: str,
|
||||
*,
|
||||
status: str,
|
||||
stage: str,
|
||||
progress_percent: int,
|
||||
message: str,
|
||||
error: str | None = None,
|
||||
) -> None:
|
||||
with self._lock:
|
||||
s = self._jobs[job_id].status
|
||||
s.status = status
|
||||
s.stage = stage
|
||||
s.progress_percent = progress_percent
|
||||
s.message = message
|
||||
s.updated_at = datetime.now(timezone.utc)
|
||||
if error is not None:
|
||||
s.error = error
|
||||
|
||||
def add_event(self, job_id: str, *, kind: str, stage: str, progress_percent: int, message: str) -> None:
|
||||
event = JobEvent(
|
||||
type=kind,
|
||||
stage=stage,
|
||||
progress_percent=progress_percent,
|
||||
message=message,
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
with self._lock:
|
||||
state = self._jobs[job_id]
|
||||
state.events.append(event)
|
||||
state.status.stage = stage
|
||||
state.status.progress_percent = progress_percent
|
||||
state.status.message = message
|
||||
state.status.updated_at = event.timestamp
|
||||
for sub in state.subscribers:
|
||||
sub.put(event)
|
||||
|
||||
def subscribe(self, job_id: str) -> queue.Queue:
|
||||
q: queue.Queue = queue.Queue()
|
||||
with self._lock:
|
||||
self._jobs[job_id].subscribers.append(q)
|
||||
return q
|
||||
|
||||
def unsubscribe(self, job_id: str, q: queue.Queue) -> None:
|
||||
with self._lock:
|
||||
subs = self._jobs[job_id].subscribers
|
||||
if q in subs:
|
||||
subs.remove(q)
|
||||
|
||||
@staticmethod
|
||||
def resolve_artifact_path(status: JobStatus, kind: str) -> Path:
|
||||
if kind not in status.artifacts:
|
||||
raise KeyError(kind)
|
||||
p = status.artifacts[kind]
|
||||
if not p:
|
||||
raise FileNotFoundError(kind)
|
||||
return Path(p)
|
||||
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
后端日志配置模块。
|
||||
|
||||
提供统一的日志格式和两个 handler:
|
||||
- console: 输出到控制台(uvicorn 可见)
|
||||
- file: 输出到 backend/.runtime/service.log
|
||||
"""
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_LOG_DIR = Path(__file__).resolve().parent.parent / ".runtime"
|
||||
_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
_LOG_FILE = _LOG_DIR / "service.log"
|
||||
|
||||
_FORMAT = "%(asctime)s | %(levelname)-7s | %(name)s | %(message)s"
|
||||
_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
|
||||
def setup_logging(level: int = logging.DEBUG) -> None:
|
||||
"""配置全局日志,可重复调用(幂等)。"""
|
||||
root = logging.getLogger()
|
||||
if root.handlers:
|
||||
return # 已配置过
|
||||
|
||||
root.setLevel(level)
|
||||
|
||||
console = logging.StreamHandler(sys.stdout)
|
||||
console.setLevel(logging.INFO)
|
||||
console.setFormatter(logging.Formatter(_FORMAT, datefmt=_DATE_FORMAT))
|
||||
|
||||
file_handler = logging.FileHandler(str(_LOG_FILE), mode="a", encoding="utf-8")
|
||||
file_handler.setLevel(logging.DEBUG)
|
||||
file_handler.setFormatter(logging.Formatter(_FORMAT, datefmt=_DATE_FORMAT))
|
||||
|
||||
root.addHandler(console)
|
||||
root.addHandler(file_handler)
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
"""获取命名 logger,自动触发 setup。"""
|
||||
setup_logging()
|
||||
return logging.getLogger(name)
|
||||
@@ -0,0 +1,165 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from .job_manager import JobManager
|
||||
from .log_config import get_logger
|
||||
from .schemas import JobPaths
|
||||
|
||||
log = get_logger("service.runner")
|
||||
|
||||
|
||||
STAGE_RULES: list[tuple[str, str, int]] = [
|
||||
("--- 1. 读取数据 ---", "reading_data", 10),
|
||||
("--- 2. 智能画幅计算 ---", "sizing_canvas", 20),
|
||||
("--- 3. 生成掩膜", "building_mask", 35),
|
||||
("--- 4. 计算权重 ---", "computing_weights", 45),
|
||||
("--- 5. 启动生成", "placing_words", 65),
|
||||
("--- 6. 高清渲染 ---", "rendering", 85),
|
||||
("已保存:", "writing_outputs", 92),
|
||||
("✅ 完成", "writing_outputs", 99),
|
||||
]
|
||||
|
||||
|
||||
class JobRunner:
|
||||
def __init__(self, project_root: Path, manager: JobManager) -> None:
|
||||
self.project_root = project_root
|
||||
self.manager = manager
|
||||
self.script_path = self.project_root / "wordcloud_generate_hybrid.py"
|
||||
|
||||
def _parse_stage(self, line: str, current_stage: str, current_progress: int) -> tuple[str, int]:
|
||||
for token, stage, progress in STAGE_RULES:
|
||||
if token in line:
|
||||
return stage, progress
|
||||
|
||||
if "尝试 #" in line or "尺度" in line or "二分重试" in line:
|
||||
progress = max(current_progress, 70)
|
||||
return "placing_words", min(progress + 1, 84)
|
||||
|
||||
return current_stage, current_progress
|
||||
|
||||
def run(self, job_id: str, paths: JobPaths, config: dict) -> None:
|
||||
t_start = time.time()
|
||||
log.info("=" * 50)
|
||||
log.info("[Runner] 任务启动 job_id=%s", job_id)
|
||||
log.info(" config_path = %s", paths.config_path)
|
||||
log.info(" output_dir = %s", paths.output_dir)
|
||||
log.info(" 子进程 python = %s", sys.executable)
|
||||
self.manager.set_status(job_id, status="running", stage="starting", progress_percent=1, message="任务启动")
|
||||
|
||||
with paths.config_path.open("w", encoding="utf-8") as f:
|
||||
json.dump(config, f, ensure_ascii=False, indent=2)
|
||||
log.info(" 配置文件已写入")
|
||||
|
||||
cmd = [
|
||||
sys.executable,
|
||||
str(self.script_path),
|
||||
"--config",
|
||||
str(paths.config_path),
|
||||
]
|
||||
|
||||
env = os.environ.copy()
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
cwd=str(self.project_root),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
env=env,
|
||||
)
|
||||
|
||||
stage = "starting"
|
||||
progress = 1
|
||||
|
||||
assert process.stdout is not None
|
||||
for raw in process.stdout:
|
||||
line = raw.rstrip("\n")
|
||||
log.info("[Pipeline] %s", line)
|
||||
stage, progress = self._parse_stage(line, stage, progress)
|
||||
self.manager.add_event(
|
||||
job_id,
|
||||
kind="log",
|
||||
stage=stage,
|
||||
progress_percent=progress,
|
||||
message=line,
|
||||
)
|
||||
|
||||
ret = process.wait()
|
||||
elapsed = time.time() - t_start
|
||||
log.info("[Runner] 子进程退出 code=%d 耗时=%.2fs", ret, elapsed)
|
||||
|
||||
png = next(paths.output_dir.glob("*.png"), None)
|
||||
svg = next(paths.output_dir.glob("*[!_stroke].svg"), None)
|
||||
svg_stroke = next(paths.output_dir.glob("*_stroke.svg"), None)
|
||||
db = next(paths.output_dir.glob("*.db"), None)
|
||||
metrics = next(paths.output_dir.glob("*metrics*.json"), None)
|
||||
|
||||
log.info("[Runner] 产物扫描:")
|
||||
log.info(" png = %s", png)
|
||||
log.info(" svg = %s", svg)
|
||||
log.info(" svg_stroke = %s", svg_stroke)
|
||||
log.info(" db = %s", db)
|
||||
log.info(" metrics = %s", metrics)
|
||||
|
||||
artifacts = {
|
||||
"png": str(png) if png else "",
|
||||
"svg": str(svg) if svg else "",
|
||||
"svg_stroke": str(svg_stroke) if svg_stroke else "",
|
||||
"db": str(db) if db else "",
|
||||
"metrics": str(metrics) if metrics else "",
|
||||
}
|
||||
self.manager.set_artifacts(job_id, artifacts)
|
||||
|
||||
if ret == 0 and not png:
|
||||
log.error("[Runner] 任务失败:退出码=0 但未找到输出图片")
|
||||
self.manager.add_event(
|
||||
job_id,
|
||||
kind="status",
|
||||
stage="failed",
|
||||
progress_percent=100,
|
||||
message="任务失败:未找到输出图片",
|
||||
)
|
||||
self.manager.set_status(
|
||||
job_id,
|
||||
status="failed",
|
||||
stage="failed",
|
||||
progress_percent=100,
|
||||
message="任务失败",
|
||||
error="missing png artifact",
|
||||
)
|
||||
return
|
||||
|
||||
if ret == 0:
|
||||
log.info("[Runner] ✅ 任务完成 job_id=%s 总耗时=%.2fs", job_id, elapsed)
|
||||
self.manager.add_event(
|
||||
job_id,
|
||||
kind="status",
|
||||
stage="completed",
|
||||
progress_percent=100,
|
||||
message="任务完成",
|
||||
)
|
||||
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(
|
||||
job_id,
|
||||
kind="status",
|
||||
stage="failed",
|
||||
progress_percent=100,
|
||||
message=f"任务失败,退出码: {ret}",
|
||||
)
|
||||
self.manager.set_status(
|
||||
job_id,
|
||||
status="failed",
|
||||
stage="failed",
|
||||
progress_percent=100,
|
||||
message="任务失败",
|
||||
error=f"script exited with code {ret}",
|
||||
)
|
||||
@@ -0,0 +1,139 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class JobCreateResponse(BaseModel):
|
||||
job_id: str
|
||||
|
||||
|
||||
class JobEvent(BaseModel):
|
||||
type: Literal["log", "status"]
|
||||
stage: str
|
||||
progress_percent: int = Field(ge=0, le=100)
|
||||
message: str
|
||||
timestamp: datetime
|
||||
|
||||
|
||||
class JobStatus(BaseModel):
|
||||
job_id: str
|
||||
status: Literal["queued", "running", "success", "failed"]
|
||||
stage: str
|
||||
progress_percent: int = Field(ge=0, le=100)
|
||||
message: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
artifacts: dict[str, str]
|
||||
error: str = ""
|
||||
|
||||
|
||||
class JobDetail(BaseModel):
|
||||
status: JobStatus
|
||||
recent_events: list[JobEvent]
|
||||
|
||||
|
||||
class JobResult(BaseModel):
|
||||
job_id: str
|
||||
status: Literal["queued", "running", "success", "failed"]
|
||||
image_url: str = ""
|
||||
svg_url: str = ""
|
||||
svg_stroke_url: str = ""
|
||||
db_url: str = ""
|
||||
metrics_url: str = ""
|
||||
|
||||
|
||||
class WordLocation(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
x: int
|
||||
y: int
|
||||
font_size: int
|
||||
color: str = ""
|
||||
orientation: Literal["horizontal", "vertical"] = "horizontal"
|
||||
box_x: int
|
||||
box_y: int
|
||||
box_width: int
|
||||
box_height: int
|
||||
|
||||
|
||||
class JobLocationSearchResult(BaseModel):
|
||||
job_id: str
|
||||
query: str = ""
|
||||
total: int = 0
|
||||
canvas_width: int = 0
|
||||
canvas_height: int = 0
|
||||
matches: list[WordLocation] = Field(default_factory=list)
|
||||
|
||||
|
||||
class JobPaths(BaseModel):
|
||||
root: Path
|
||||
input_dir: Path
|
||||
output_dir: Path
|
||||
mask_path: Path
|
||||
excel_path: Path
|
||||
config_path: Path
|
||||
|
||||
|
||||
class Template(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
width: int
|
||||
height: int
|
||||
aspect_ratio: str
|
||||
description: str = ""
|
||||
|
||||
|
||||
class Asset(BaseModel):
|
||||
asset_id: str
|
||||
name: str
|
||||
type: str
|
||||
mime_type: str
|
||||
width: int
|
||||
height: int
|
||||
file_size: int
|
||||
file_url: str
|
||||
job_id: str = ""
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class DesignTemplate(BaseModel):
|
||||
template_id: str
|
||||
name: str
|
||||
description: str = ""
|
||||
document: dict
|
||||
reference_asset_ids: list[str] = Field(default_factory=list)
|
||||
cover_asset_id: str = ""
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class Project(BaseModel):
|
||||
project_id: str
|
||||
name: str
|
||||
template_id: str
|
||||
background_color: str
|
||||
stickers: list[dict] = Field(default_factory=list)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ProjectSummary(BaseModel):
|
||||
project_id: str
|
||||
name: str
|
||||
template_id: str
|
||||
background_color: str
|
||||
sticker_count: int = 0
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class Font(BaseModel):
|
||||
font_id: str
|
||||
name: str
|
||||
filename: str
|
||||
file_size: int
|
||||
created_at: datetime
|
||||
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .schemas import JobPaths
|
||||
|
||||
|
||||
class Storage:
|
||||
def __init__(self, base_dir: Path) -> None:
|
||||
self.base_dir = base_dir
|
||||
self.base_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def prepare_job_dirs(self, job_id: str) -> JobPaths:
|
||||
root = self.base_dir / job_id
|
||||
input_dir = root / "input"
|
||||
output_dir = root / "output"
|
||||
input_dir.mkdir(parents=True, exist_ok=True)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
return JobPaths(
|
||||
root=root,
|
||||
input_dir=input_dir,
|
||||
output_dir=output_dir,
|
||||
mask_path=input_dir / "mask.png",
|
||||
excel_path=input_dir / "names.xlsx",
|
||||
config_path=root / "config.json",
|
||||
)
|
||||
Reference in New Issue
Block a user