feat: archive visible wordclouds into products
This commit is contained in:
@@ -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 生产任务(需登录)。"""
|
||||
|
||||
Reference in New Issue
Block a user