feat: archive visible wordclouds into products

This commit is contained in:
2026-09-12 13:17:13 +08:00
parent c2ca770cd7
commit 1c51a9bc2b
4 changed files with 462 additions and 0 deletions
+135
View File
@@ -27,6 +27,8 @@ 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 .product_archive_service import ProductArchiveService, ProductPendingCleanupError
from .product_archive_store import ProductArchiveStore
from .runner import JobRunner
from .schemas import (
Asset,
@@ -41,6 +43,9 @@ from .schemas import (
LineSpacingAnalysisSummary,
Project,
ProjectSummary,
ProductInput,
ProductRecord,
ProductVersionArchiveResponse,
Template,
WordLocation,
)
@@ -62,17 +67,20 @@ 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"
PRODUCT_ARCHIVES_DIR = PROJECT_ROOT / "service_product_archives"
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)
PRODUCT_ARCHIVES_DIR.mkdir(parents=True, exist_ok=True)
metadata_store = MetadataStore(METADATA_DIR / "app.db")
manager = JobManager(metadata_store)
storage = Storage(WORKSPACE_DIR)
runner = JobRunner(PROJECT_ROOT, manager)
product_archive_store = ProductArchiveStore(PRODUCT_ARCHIVES_DIR / "metadata")
app = FastAPI(title="WordCloud Test Service", version="0.1.0")
@@ -1026,6 +1034,133 @@ async def login(request: Request) -> dict:
return {"token": _orders_token()}
# ═══════════════════════════════════════════════════════════
# 3.10 产品及设计版本归档(需登录)
# ═══════════════════════════════════════════════════════════
def _get_product_or_404(product_id: str) -> ProductRecord:
try:
return product_archive_store.get_product(product_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail="product not found") from exc
def _product_archive_service() -> ProductArchiveService:
"""Build against current globals so isolated tests can substitute local storage."""
return ProductArchiveService(
store=product_archive_store,
storage=storage,
load_asset_meta=lambda asset_id: _read_asset_meta(_asset_dir(asset_id)),
resolve_job_status=_resolve_job_status,
)
@app.post("/api/products", response_model=ProductRecord, status_code=201)
def create_product(request: Request, product: ProductInput) -> ProductRecord:
_require_orders_auth(request)
try:
return product_archive_store.upsert_product(product)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get("/api/products", response_model=list[ProductRecord])
def list_products(request: Request, query: str = Query("")) -> list[ProductRecord]:
_require_orders_auth(request)
return product_archive_store.list_products(query=query)
@app.get("/api/products/{product_id}", response_model=ProductRecord)
def get_product(request: Request, product_id: str) -> ProductRecord:
_require_orders_auth(request)
return _get_product_or_404(product_id)
@app.post(
"/api/products/{product_id}/versions",
response_model=ProductVersionArchiveResponse,
status_code=201,
)
async def create_product_version(
request: Request,
product_id: str,
document_json: str = Form(""),
preview: UploadFile | None = File(None),
) -> ProductVersionArchiveResponse:
_require_orders_auth(request)
_get_product_or_404(product_id)
if preview is None or preview.content_type != "image/png":
raise HTTPException(status_code=400, detail="preview must be image/png")
try:
document = json.loads(document_json)
except json.JSONDecodeError as exc:
raise HTTPException(status_code=400, detail="document_json must be valid JSON") from exc
if not isinstance(document, dict):
raise HTTPException(status_code=400, detail="document_json must be a JSON object")
try:
version = _product_archive_service().archive_version(
product_id=product_id,
document=document,
preview_bytes=await preview.read(),
now=datetime.now(timezone.utc),
)
except ProductPendingCleanupError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
source_job_ids = [
str(source.get("source_job_id") or "")
for source in version.metadata.get("wordcloud_sources", [])
if isinstance(source, dict)
]
rows = product_archive_store._fetchall(
"SELECT * FROM product_wordcloud_archives WHERE version_id = ? ORDER BY created_at, archive_id",
(version.version_id,),
)
archives = [
{
"archive_id": row["archive_id"],
"product_id": row["product_id"],
"version_id": row["version_id"],
"archive_path": row["archive_path"],
"created_at": datetime.fromisoformat(row["created_at"]),
"source_job_id": source_job_id,
"db_path": row["archive_path"],
}
for source_job_id, row in zip(source_job_ids, rows, strict=True)
]
return ProductVersionArchiveResponse(
**version.model_dump(),
design_preview_path=str(PRODUCT_ARCHIVES_DIR / product_id / version.version_id / "design-preview.png"),
wordcloud_count=len(archives),
wordcloud_archives=archives,
)
@app.delete("/api/products/{product_id}", response_model=ProductRecord)
def delete_product(request: Request, product_id: str) -> ProductRecord:
_require_orders_auth(request)
product = _get_product_or_404(product_id)
if product.status == "pending_cleanup":
raise HTTPException(status_code=409, detail="product is pending cleanup")
try:
return product_archive_store.mark_pending_cleanup(product_id, now=datetime.now(timezone.utc))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.post("/api/products/{product_id}/restore", response_model=ProductRecord)
def restore_product(request: Request, product_id: str) -> ProductRecord:
_require_orders_auth(request)
_get_product_or_404(product_id)
try:
return product_archive_store.restore_product(product_id, now=datetime.now(timezone.utc))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get("/api/orders")
def list_orders(request: Request) -> list[dict]:
"""生产订单列表:来自小程序下单派单投递到 wordcloud 的 WCD 生产任务(需登录)。"""