Compare commits

...
24 Commits
Author SHA1 Message Date
broccoli 31e7053bb0 Add product wordcloud board links
Build, Push and Deploy / build (push) Successful in 9s
Build, Push and Deploy / deploy (push) Successful in 24s
2026-09-14 15:32:31 +08:00
lai_hong 1f9eb2853c fix(export): outline SVG text for AI export
Build, Push and Deploy / build (push) Successful in 12s
Build, Push and Deploy / deploy (push) Successful in 25s
2026-09-13 15:55:46 +08:00
broccoli cc5c3f9751 docs: add product archive spec and plan
Build, Push and Deploy / build (push) Successful in 11s
Build, Push and Deploy / deploy (push) Successful in 26s
2026-09-13 15:40:15 +08:00
broccoli fb8e815771 chore: ignore generated design tests 2026-09-13 15:37:49 +08:00
broccoli 4dc86b0d8e Merge remote-tracking branch 'origin/master'
# Conflicts:
#	docker-compose.yml
2026-09-13 15:37:38 +08:00
broccoli 7f4ac45639 fix: trace job imported wordcloud stickers 2026-09-13 01:01:18 +08:00
broccoli 7e275798e7 fix: load authenticated product archive images 2026-09-12 18:37:56 +08:00
broccoli 4fd6d66c0e feat: merge accepted frontend design updates 2026-09-12 18:12:25 +08:00
broccoli cde8f377e2 fix: expose failed products and archive row covers 2026-09-12 17:49:09 +08:00
broccoli fabc6d69f2 docs: add product archive operations runbook 2026-09-12 17:45:35 +08:00
broccoli cd9308ae8f feat: add product archive management page 2026-09-12 17:34:03 +08:00
broccoli e13ddd5562 feat: add canvas product archive flow 2026-09-12 16:58:24 +08:00
broccoli f900be30ba fix: add authenticated product image contract 2026-09-12 16:19:41 +08:00
broccoli fe62a7f721 feat: return full product detail with images and versions 2026-09-12 16:10:19 +08:00
broccoli dd3003609b feat: add product archive frontend client 2026-09-12 15:48:19 +08:00
broccoli cb5a46631b test: use store timestamps for cleanup compatibility 2026-09-12 15:33:16 +08:00
broccoli 21ad6fbf8d fix: reconcile and expose failed product cleanup 2026-09-12 15:24:41 +08:00
broccoli bd78eef161 fix: quarantine failed product cleanups in failed_cleanup state 2026-09-12 15:00:33 +08:00
broccoli b3ab1bfc52 fix: harden retention cleanup safety 2026-09-12 14:12:17 +08:00
broccoli da5c2b4503 feat: add product archive retention cleanup 2026-09-12 13:55:31 +08:00
broccoli 87a3d44359 fix: persist product archive cover and provenance 2026-09-12 13:28:11 +08:00
broccoli 1c51a9bc2b feat: archive visible wordclouds into products 2026-09-12 13:17:13 +08:00
broccoli c2ca770cd7 feat: detect visible wordcloud archive sources 2026-09-12 13:08:02 +08:00
broccoli bf24d20be9 feat: add product archive metadata store 2026-09-12 13:04:32 +08:00
39 changed files with 6179 additions and 137 deletions
+3
View File
@@ -73,5 +73,8 @@ GIT_PUSH_指南.md
# ── Local-only / third-party copies ───────────
ref/
.design-tests/
.superpowers/
.worktrees/
backend/service_orders/
docs/storage-metrics.json
+140
View File
@@ -0,0 +1,140 @@
# 产品档案与清理运行手册
## 1. 服务启动
产品档案要求三类持久卷全部存在:
```bash
docker compose up -d --build
docker compose ps
docker compose logs --tail=80 backend
```
`docker-compose.yml` 中必须挂载:
- `wordcloud_products:/app/service_products`
- `wordcloud_metadata:/app/service_metadata`
- `wordcloud_orders:/app/service_orders`
首次上线或更新后,先检查健康接口:
```bash
docker compose exec backend curl -fsS http://localhost:8000/api/health
```
## 2. 外部产品同步
首期外部系统可以先使用创建/更新产品接口做幂等同步:
```http
POST /api/products
Authorization: Bearer <>
Content-Type: application/json
{
"source": "external",
"external_product_id": "PROD-123",
"name": "",
"sku": "SKU-123",
"specification": ""
}
```
同步以 `(source, external_product_id)` 合并,不按名称合并。后续产品接口字段稳定后,可以在现有模型上补适配层,不必改档案结构。
产品列表和详情默认展示产品名称、SKU、规格、状态与归档数量;完整 `product_id` 只在产品详情页的“系统信息”折叠区暴露。
## 3. 清理 dry-run
物理清理默认关闭。上线后先只看候选,不删除数据:
```bash
docker compose exec backend python -m service.storage_metrics --max-age-days 30
```
输出 JSON 中重点检查:
- `archive_protected_job_ids`: 已归档进产品档案的源任务,不会被临时清理。
- `asset_referenced_job_ids`: 普通画布素材引用,只反映当前引用,不是永久保护。
- `temporary_jobs`: 成功生成、30 天以上、未被产品档案保护的任务。
- `pending_product_ids`: 已进入 30 天待清理期的产品。
- `failed_cleanup` 产品不会出现在 `pending_product_ids`,需要人工检查后恢复或重新排队。
- `reclaimable_bytes`: 预计可回收空间。
服务启动和每 24 小时也会运行一次 `CleanupService.preview()`。没有设置清理开关时,它只计算候选,不删除。
## 4. 启用物理清理
确认 dry-run 报告没有误删对象后,再在部署环境中显式开启:
```bash
CLEANUP_APPLY_ENABLED=true docker compose up -d --build
```
或在既有运维体系中通过 secret/env manager 提供同名变量。开启后仍必须传入 `--apply` 才会真正删除:
```bash
docker compose exec backend python -m service.storage_metrics --max-age-days 30 --apply
```
同一 API 也可以触发,但必须同时满足:
1. `CLEANUP_APPLY_ENABLED=true`
2. `POST /api/maintenance/cleanup-run` body 为 `{"confirm": true}`
3. 使用生产订单管理口令鉴权
清理规则:
- 只清理成功生成、超过 30 天、带位置库、且当前仍不在 `product_wordcloud_archives` 中的任务。
- 每次执行前会重新检查归档引用;在预览和删除之间新归档的任务会被跳过。
- 归档保护来自产品词云档案表,不来自普通素材引用。
- 产品物理清理只在 `purge_after` 到期后发生;到期前可以恢复。
## 5. 恢复待清理产品
产品进入待清理状态后,30 天内可恢复:
```http
POST /api/products/{product_id}/restore
Authorization: Bearer <>
```
前端产品档案页会显示:
```text
待清理 · 将于 YYYY/MM/DD 删除
```
点击“恢复产品”后,产品回到 `active`,原清理记录标记为 `restored`
删除按钮只是软删除,进入下一个 30 天可恢复窗口;真正的物理删除由清理服务执行。
## 6. `failed_cleanup` 处置
物理删除失败时,服务不会假装文件仍完整,也不会自动重试。产品进入:
```text
failed_cleanup
```
处置步骤:
1. 打开产品档案详情,确认产品状态。
2. 检查服务器上的产品目录:
```bash
docker compose exec backend sh -lc 'find /app/service_products/<product_id> -maxdepth 2 -type f -printf "%p %s\n"'
```
3. 如果目录完整且可继续使用,调用恢复接口或点击“恢复产品”。
4. 如果目录不完整,先人工恢复或确认业务上放弃该产品,再恢复后重新软删除。
5. 不要直接修改 `product_archive.db`,也不要直接把失败状态改成 `pending_cleanup`。
已放弃且确认可安全删除的失败记录,应由管理员按上述恢复/重排队流程处理;系统不会自动把它当作完整产品重试。
## 7. 回滚与部署注意
- 新版本上线前保留现有 `service_products`、`service_metadata`、`service_orders` 卷。
- 若当前环境曾把产品档案写入旧的未挂载目录,先停写并复制目录到挂载卷,再启动新版本。
- `CLEANUP_APPLY_ENABLED` 回到 `false` 后,调度器恢复为只 dry-run。
- 回滚不会自动恢复已物理删除的数据;因此首次启用物理清理必须先审查 dry-run。
+466 -15
View File
@@ -12,11 +12,13 @@ import threading
import time
import uuid
import zipfile
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from urllib.error import HTTPError, URLError
from urllib.request import Request as UrlRequest, urlopen
from xml.etree import ElementTree as ET
from fastapi import FastAPI, File, Form, HTTPException, Query, Request, UploadFile
from fastapi.middleware.cors import CORSMiddleware
@@ -26,10 +28,13 @@ from pydantic import BaseModel
from core import config as wc_config
from core.fonts import get_cached_font
from .cleanup_service import CleanupReport, CleanupService
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, ProductPurgeInProgressError
from .runner import JobRunner
from .schemas import (
Asset,
@@ -44,6 +49,13 @@ from .schemas import (
LineSpacingAnalysisSummary,
Project,
ProjectSummary,
ProductDetailResponse,
ProductImageResponse,
ProductInput,
ProductRecord,
ProductVersionArchiveResponse,
ProductVersionDetailResponse,
ProductWordcloudArchiveResponse,
Template,
WordLocation,
)
@@ -65,25 +77,41 @@ 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_products"
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)
# The converter intentionally runs as a separate, unprivileged container. Do
# not expose it publicly: the application validates payload size and forwards
# only generated SVG documents to this private service.
AI_CONVERTER_URL = os.environ.get("AI_CONVERTER_URL", "").rstrip("/")
AI_EXPORT_MAX_BYTES = 25 * 1024 * 1024
SVG_NAMESPACE = "http://www.w3.org/2000/svg"
_AI_TEXT_FONT_CACHE: tuple[object, object, dict[int, str], int] | None = None
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")
cleanup_service = CleanupService(storage, metadata_store, product_archive_store)
app = FastAPI(title="WordCloud Test Service", version="0.1.0")
@asynccontextmanager
async def _app_lifespan(_app: FastAPI):
_start_cleanup_scheduler()
try:
yield
finally:
_stop_cleanup_scheduler()
app = FastAPI(title="WordCloud Test Service", version="0.1.0", lifespan=_app_lifespan)
app.add_middleware(
CORSMiddleware,
@@ -163,26 +191,27 @@ def health() -> dict:
@app.get("/api/maintenance/storage-summary")
def storage_summary() -> dict:
referenced_jobs: set[str] = set()
asset_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,
)
asset_referenced_jobs.add(job_id)
report = cleanup_service.preview(datetime.now(timezone.utc))
archive_protected_jobs = cleanup_service.archive_protected_job_ids()
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),
"referenced_job_ids": len(asset_referenced_jobs),
"asset_referenced_job_ids": sorted(asset_referenced_jobs),
"archive_protected_job_ids": sorted(archive_protected_jobs),
"archive_protected_job_count": len(archive_protected_jobs),
"temporary_job_ids": [item.job_id for item in report.temporary_jobs],
"stale_job_count": len(report.temporary_jobs),
"reclaimable_bytes": report.reclaimable_bytes,
"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,
"dry_run_only": not _cleanup_apply_enabled(),
}
@@ -1009,6 +1038,109 @@ class AiExportRequest(BaseModel):
filename: str = "wordcloud.ai"
def _ai_text_font() -> tuple[object, object, dict[int, str], int]:
"""Load the bundled CJK font once for SVG text-to-outline conversion."""
global _AI_TEXT_FONT_CACHE
if _AI_TEXT_FONT_CACHE is not None:
return _AI_TEXT_FONT_CACHE
from fontTools.ttLib import TTFont
font_path = PROJECT_ROOT / "assets" / "fonts" / "STHeiti Medium.ttc"
font = TTFont(font_path, fontNumber=0)
_AI_TEXT_FONT_CACHE = (
font,
font.getGlyphSet(),
font.getBestCmap() or {},
int(font["head"].unitsPerEm),
)
return _AI_TEXT_FONT_CACHE
def _svg_number(value: str | None, default: float = 0) -> float:
if value is None:
return default
match = re.match(r"\s*([-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?)", value)
if not match:
raise ValueError(f"无效数值: {value}")
number = float(match.group(1))
if not number == number or number in {float("inf"), float("-inf")}:
raise ValueError(f"无效数值: {value}")
return number
def _svg_tag_name(element: ET.Element) -> str:
return element.tag.rsplit("}", 1)[-1]
def _outline_svg_text(svg_bytes: bytes) -> bytes:
"""Replace application SVG ``text`` nodes with CJK-safe glyph outlines.
AI5/PostScript has no portable Unicode text encoding. Outlining here keeps
Chinese canvas text visually stable and leaves the private converter to
process only paths and basic geometry.
"""
try:
root = ET.fromstring(svg_bytes)
except ET.ParseError as exc:
raise ValueError(f"SVG 解析失败: {exc}") from exc
font, glyph_set, cmap, units_per_em = _ai_text_font()
from fontTools.pens.svgPathPen import SVGPathPen
text_only_attributes = {
"x", "y", "dx", "dy", "rotate", "textLength", "lengthAdjust",
"font-family", "font-size", "font-weight", "font-style", "text-anchor",
"dominant-baseline", "alignment-baseline",
}
def outline_children(parent: ET.Element) -> None:
for index, element in enumerate(list(parent)):
if _svg_tag_name(element) != "text":
outline_children(element)
continue
if list(element):
raise ValueError("AI 导出暂不支持包含 tspan 等子节点的文字")
try:
font_size = _svg_number(element.get("font-size"), 16)
x = _svg_number(element.get("x"), 0)
y = _svg_number(element.get("y"), 0)
except ValueError as exc:
raise ValueError(f"AI 文字轮廓转换失败: {exc}") from exc
if font_size <= 0:
raise ValueError("AI 文字轮廓转换失败: font-size 必须大于 0")
outer = ET.Element(
f"{{{SVG_NAMESPACE}}}g",
{key: value for key, value in element.attrib.items() if key not in text_only_attributes},
)
scale = font_size / units_per_em
glyph_group = ET.SubElement(
outer,
f"{{{SVG_NAMESPACE}}}g",
{"transform": f"translate({x:g} {y:g}) scale({scale:.12g} {-scale:.12g})"},
)
cursor = 0.0
for character in element.text or "":
glyph_name = cmap.get(ord(character), ".notdef")
glyph = glyph_set.get(glyph_name) or glyph_set[".notdef"]
pen = SVGPathPen(glyph_set)
glyph.draw(pen)
path_data = pen.getCommands()
if path_data:
ET.SubElement(
glyph_group,
f"{{{SVG_NAMESPACE}}}path",
{"d": path_data, "transform": f"translate({cursor:g} 0)"},
)
cursor += float(glyph.width)
parent[index] = outer
outline_children(root)
return ET.tostring(root, encoding="utf-8")
def _ai_download_name(value: str) -> str:
stem = Path(value).stem
stem = re.sub(r"[^A-Za-z0-9._-]+", "-", stem).strip(".-")[:80]
@@ -1026,11 +1158,17 @@ def export_ai(payload: AiExportRequest):
if not AI_CONVERTER_URL:
raise HTTPException(status_code=503, detail="AI 转换服务尚未配置")
svg_bytes = payload.svg.encode("utf-8")
if not svg_bytes:
raw_svg_bytes = payload.svg.encode("utf-8")
if not raw_svg_bytes:
raise HTTPException(status_code=400, detail="SVG 内容不能为空")
if len(svg_bytes) > AI_EXPORT_MAX_BYTES:
if len(raw_svg_bytes) > AI_EXPORT_MAX_BYTES:
raise HTTPException(status_code=413, detail="SVG 文件超过 25 MB 限制")
try:
svg_bytes = _outline_svg_text(raw_svg_bytes)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
if len(svg_bytes) > AI_EXPORT_MAX_BYTES:
raise HTTPException(status_code=413, detail="文字转轮廓后的 SVG 超过 25 MB 限制")
request = UrlRequest(
f"{AI_CONVERTER_URL}/convert",
@@ -1083,6 +1221,82 @@ def _require_orders_auth(request: Request) -> None:
raise HTTPException(status_code=403, detail="需要登录") # noqa: S105
def _cleanup_apply_enabled() -> bool:
return os.environ.get("CLEANUP_APPLY_ENABLED", "false").strip().lower() == "true"
def _run_scheduled_cleanup_once(now: datetime | None = None) -> CleanupReport:
run_at = now or datetime.now(timezone.utc)
apply_enabled = _cleanup_apply_enabled()
report = cleanup_service.apply(run_at) if apply_enabled else cleanup_service.preview(run_at)
log.info(
"cleanup cycle apply=%s temporary_jobs=%d pending_products=%d reclaimable_bytes=%d "
"reminder_job_ids=%s deleted_job_ids=%s deleted_product_ids=%s",
apply_enabled,
len(report.temporary_jobs),
len(report.pending_products),
report.reclaimable_bytes,
report.reminder_job_ids,
report.deleted_job_ids,
report.deleted_product_ids,
)
return report
_cleanup_scheduler_stop = threading.Event()
_cleanup_scheduler_thread: threading.Thread | None = None
def _cleanup_scheduler_loop() -> None:
while not _cleanup_scheduler_stop.wait(24 * 60 * 60):
try:
_run_scheduled_cleanup_once()
except Exception:
log.exception("scheduled cleanup cycle failed")
def _start_cleanup_scheduler() -> None:
global _cleanup_scheduler_thread
if _cleanup_scheduler_thread is not None:
if _cleanup_scheduler_thread.is_alive():
return
_cleanup_scheduler_thread = None
_cleanup_scheduler_stop.clear()
_run_scheduled_cleanup_once()
_cleanup_scheduler_thread = threading.Thread(
target=_cleanup_scheduler_loop,
name="retention-cleanup",
daemon=True,
)
_cleanup_scheduler_thread.start()
def _stop_cleanup_scheduler() -> None:
global _cleanup_scheduler_thread
_cleanup_scheduler_stop.set()
worker = _cleanup_scheduler_thread
if worker is not None:
worker.join(timeout=5)
if worker is not None and not worker.is_alive() and _cleanup_scheduler_thread is worker:
_cleanup_scheduler_thread = None
@app.get("/api/maintenance/cleanup-candidates", response_model=CleanupReport)
def cleanup_candidates(request: Request) -> CleanupReport:
_require_orders_auth(request)
return cleanup_service.preview(datetime.now(timezone.utc))
@app.post("/api/maintenance/cleanup-run", response_model=CleanupReport)
def cleanup_run(request: Request, payload: dict) -> CleanupReport:
_require_orders_auth(request)
if payload.get("confirm") is not True:
raise HTTPException(status_code=400, detail="confirm must be true")
if not _cleanup_apply_enabled():
raise HTTPException(status_code=409, detail="cleanup apply is disabled")
return cleanup_service.apply(datetime.now(timezone.utc))
@app.post("/api/login")
async def login(request: Request) -> dict:
try:
@@ -1094,6 +1308,243 @@ 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,
)
def _product_image_response(row: sqlite3.Row) -> ProductImageResponse:
return ProductImageResponse(
image_id=row["image_id"],
product_id=row["product_id"],
version_id=row["version_id"],
image_path=row["image_path"],
image_type=row["image_type"],
is_cover=bool(row["is_cover"]),
created_at=datetime.fromisoformat(row["created_at"]),
image_url=f"/api/products/{row['product_id']}/images/{row['image_id']}",
)
def _version_archive_response(version_id: str) -> ProductVersionArchiveResponse:
row = product_archive_store._fetchone(
"SELECT * FROM product_versions WHERE version_id = ?", (version_id,)
)
if row is None:
raise HTTPException(status_code=404, detail="version not found")
archives = [
ProductWordcloudArchiveResponse(
archive_id=item["archive_id"],
product_id=item["product_id"],
version_id=item["version_id"],
archive_path=item["archive_path"],
source_job_id=item["source_job_id"],
source_asset_id=item["source_asset_id"],
db_checksum=item["db_checksum"],
created_at=datetime.fromisoformat(item["created_at"]),
db_path=item["archive_path"],
)
for item in product_archive_store._fetchall(
"""SELECT * FROM product_wordcloud_archives
WHERE version_id = ? ORDER BY created_at, archive_id""",
(version_id,),
)
]
preview_image = product_archive_store._fetchone(
"""SELECT * FROM product_images
WHERE product_id = ? AND version_id = ? AND image_type = 'design_preview'
ORDER BY created_at DESC, image_id DESC LIMIT 1""",
(row["product_id"], version_id),
)
design_preview_path = (
str(preview_image["image_path"])
if preview_image and preview_image["image_path"]
else str(PRODUCT_ARCHIVES_DIR / row["product_id"] / version_id / "design-preview.png")
)
return ProductVersionArchiveResponse(
version_id=row["version_id"],
product_id=row["product_id"],
version=row["version"],
metadata=json.loads(row["metadata"] or "{}"),
created_at=datetime.fromisoformat(row["created_at"]),
design_preview_path=design_preview_path,
wordcloud_count=len(archives),
wordcloud_archives=archives,
)
def _version_detail_response(version_id: str) -> ProductVersionDetailResponse:
response = _version_archive_response(version_id)
row = product_archive_store._fetchone(
"""SELECT * FROM product_images
WHERE product_id = ? AND version_id = ? AND image_type = 'design_preview'
ORDER BY created_at DESC, image_id DESC LIMIT 1""",
(response.product_id, version_id),
)
return ProductVersionDetailResponse(
**response.model_dump(),
design_preview_image_id=row["image_id"] if row else None,
design_preview_url=(
f"/api/products/{response.product_id}/images/{row['image_id']}" if row else ""
),
)
def _product_detail_response(product_id: str) -> ProductDetailResponse:
product = _get_product_or_404(product_id)
images = [
_product_image_response(row)
for row in product_archive_store._fetchall(
"""SELECT * FROM product_images
WHERE product_id = ? ORDER BY created_at, image_id""",
(product_id,),
)
]
versions = [
_version_detail_response(row["version_id"])
for row in product_archive_store._fetchall(
"""SELECT version_id FROM product_versions
WHERE product_id = ? ORDER BY created_at, version_id""",
(product_id,),
)
]
return ProductDetailResponse(**product.model_dump(), images=images, versions=versions)
@app.get(
"/api/products/{product_id}/images/{image_id}",
response_class=FileResponse,
)
def get_product_image(
request: Request,
product_id: str,
image_id: str,
) -> FileResponse:
_require_orders_auth(request)
_get_product_or_404(product_id)
row = product_archive_store._fetchone(
"SELECT * FROM product_images WHERE product_id = ? AND image_id = ?",
(product_id, image_id),
)
if row is None or not row["image_path"]:
raise HTTPException(status_code=404, detail="product image not found")
image_path = Path(row["image_path"]).resolve()
archive_root = PRODUCT_ARCHIVES_DIR.resolve()
if not image_path.is_relative_to(archive_root) or not image_path.is_file():
raise HTTPException(status_code=404, detail="product image not found")
media_types = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".svg": "image/svg+xml",
}
media_type = media_types.get(image_path.suffix.lower(), "application/octet-stream")
return FileResponse(image_path, media_type=media_type, filename=image_path.name)
@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=ProductDetailResponse)
def get_product(request: Request, product_id: str) -> ProductDetailResponse:
_require_orders_auth(request)
return _product_detail_response(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
return _version_archive_response(version.version_id)
@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")
if product.status == "failed_cleanup":
raise HTTPException(status_code=409, detail="product cleanup failed; restore it before deleting")
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 ProductPurgeInProgressError 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
@app.get("/api/orders")
def list_orders(request: Request) -> list[dict]:
"""生产订单列表:来自小程序下单派单投递到 wordcloud 的 WCD 生产任务(需登录)。"""
+148
View File
@@ -0,0 +1,148 @@
"""Deterministic retention preview and physical cleanup for jobs and products."""
from __future__ import annotations
import shutil
from datetime import datetime, timedelta, timezone
from pathlib import Path
from pydantic import BaseModel, Field
from .metadata_store import MetadataStore
from .product_archive_store import ProductArchiveStore
from .schemas import ProductRecord
from .storage import Storage
TEMPORARY_RETENTION = timedelta(days=30)
REMINDER_AGE = timedelta(days=23)
class TemporaryJob(BaseModel):
job_id: str
created_at: datetime
size_bytes: int
class CleanupReport(BaseModel):
temporary_jobs: list[TemporaryJob] = Field(default_factory=list)
pending_products: list[ProductRecord] = Field(default_factory=list)
failed_products: list[ProductRecord] = Field(default_factory=list)
reclaimable_bytes: int = 0
reminder_job_ids: list[str] = Field(default_factory=list)
deleted_job_ids: list[str] = Field(default_factory=list)
deleted_product_ids: list[str] = Field(default_factory=list)
def _as_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
class CleanupService:
"""Calculate candidates first and apply only those still eligible."""
def __init__(
self,
storage: Storage,
metadata_store: MetadataStore,
product_store: ProductArchiveStore,
) -> None:
self.storage = storage
self.metadata_store = metadata_store
self.product_store = product_store
def archive_protected_job_ids(self) -> set[str]:
rows = self.product_store._fetchall(
"""SELECT DISTINCT source_job_id
FROM product_wordcloud_archives
WHERE source_job_id != ''"""
)
return {str(row["source_job_id"]) for row in rows}
def _is_archive_protected(self, job_id: str) -> bool:
row = self.product_store._fetchone(
"""SELECT 1 FROM product_wordcloud_archives
WHERE source_job_id = ? LIMIT 1""",
(job_id,),
)
return row is not None
def preview(self, now: datetime) -> CleanupReport:
now_utc = _as_utc(now)
self.product_store.reconcile_stalled_purges(now_utc)
protected = self.archive_protected_job_ids()
temporary_jobs: list[TemporaryJob] = []
reminder_job_ids: list[str] = []
for job in self.metadata_store.load_jobs():
if job.status != "success" or not job.artifacts.get("db") or job.job_id in protected:
continue
age = now_utc - _as_utc(job.created_at)
if age >= TEMPORARY_RETENTION:
temporary_jobs.append(
TemporaryJob(
job_id=job.job_id,
created_at=job.created_at,
size_bytes=self.storage.job_dir_size(job.job_id),
)
)
elif age >= REMINDER_AGE:
reminder_job_ids.append(job.job_id)
temporary_jobs.sort(key=lambda item: (item.created_at, item.job_id))
reminder_job_ids.sort()
pending_products = self.product_store.due_product_cleanups(now_utc)
product_bytes = sum(
self._directory_size(self.product_store.root.parent / product.product_id)
for product in pending_products
)
return CleanupReport(
temporary_jobs=temporary_jobs,
pending_products=pending_products,
failed_products=self.product_store.failed_product_cleanups(),
reclaimable_bytes=sum(item.size_bytes for item in temporary_jobs) + product_bytes,
reminder_job_ids=reminder_job_ids,
)
def apply(self, now: datetime) -> CleanupReport:
now_utc = _as_utc(now)
report = self.preview(now_utc)
for item in report.temporary_jobs:
# An archive can be committed after preview. Protect it at the last
# possible point before removing the original workspace.
if self._is_archive_protected(item.job_id):
continue
self.storage.remove_job_dir(item.job_id)
self.metadata_store.delete_job(item.job_id)
report.deleted_job_ids.append(item.job_id)
for candidate in report.pending_products:
product = self.product_store.claim_product_purge(candidate.product_id, now_utc)
if product is None:
continue
product_dir = self.product_store.root.parent / product.product_id
try:
self._before_product_files_delete(product.product_id)
if product_dir.exists():
shutil.rmtree(product_dir)
except Exception:
# rmtree may have removed only part of the archive. Quarantine
# the record for manual inspection instead of retrying it.
self.product_store.fail_product_purge(product.product_id)
raise
self.product_store.finalize_product_purge(product.product_id, now=now_utc)
report.deleted_product_ids.append(product.product_id)
return report
def _before_product_files_delete(self, product_id: str) -> None:
"""Interleaving seam between the durable claim and physical deletion."""
@staticmethod
def _directory_size(root: Path) -> int:
if not root.exists():
return 0
return sum(path.stat().st_size for path in root.rglob("*") if path.is_file())
+75
View File
@@ -0,0 +1,75 @@
"""Helpers for locating and safely archiving word-cloud layout databases."""
from __future__ import annotations
import hashlib
import sqlite3
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable
@dataclass(frozen=True)
class WordcloudSource:
asset_id: str
source_job_id: str
def find_visible_wordcloud_sources(
document: dict[str, Any], load_asset_meta: Callable[[str], dict[str, Any]]
) -> list[WordcloudSource]:
"""Return unique word-cloud sources referenced by visible sticker elements."""
visible = {
str(layer.get("id")): layer.get("visible") is not False
for layer in document.get("layers") or []
if isinstance(layer, dict)
}
seen: set[str] = set()
result: list[WordcloudSource] = []
for element in document.get("elements") or []:
if not isinstance(element, dict) or element.get("type") != "sticker":
continue
if visible and visible.get(str(element.get("layerId")), True) is False:
continue
asset_id = str(element.get("assetId") or "")
meta = load_asset_meta(asset_id)
job_id = str(meta.get("job_id") or "")
asset_type = str(meta.get("type") or "")
if asset_type in {"wordcloud", "sticker"} and job_id and job_id not in seen:
seen.add(job_id)
result.append(WordcloudSource(asset_id, job_id))
return result
def validate_word_locations_db(db_path: Path) -> None:
"""Ensure a read-only SQLite database contains the expected layout table."""
if not db_path.is_file():
raise ValueError(f"word locations database does not exist: {db_path}")
try:
with sqlite3.connect(f"file:{db_path.resolve()}?mode=ro", uri=True) as connection:
exists = connection.execute(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'word_locations'"
).fetchone()
except sqlite3.Error as exc:
raise ValueError(f"invalid word locations database: {db_path}") from exc
if exists is None:
raise ValueError(f"word locations database is missing word_locations: {db_path}")
def copy_word_locations_snapshot(source: Path, destination: Path) -> str:
"""Copy a validated layout database atomically and return its SHA-256 checksum."""
validate_word_locations_db(source)
destination.parent.mkdir(parents=True, exist_ok=True)
temporary = destination.with_name(f"{destination.name}.tmp")
digest = hashlib.sha256()
try:
with source.open("rb") as source_file, temporary.open("wb") as temporary_file:
while chunk := source_file.read(1024 * 1024):
temporary_file.write(chunk)
digest.update(chunk)
validate_word_locations_db(temporary)
temporary.replace(destination)
except Exception:
temporary.unlink(missing_ok=True)
raise
return digest.hexdigest()
+146
View File
@@ -0,0 +1,146 @@
"""Transactional product-version archives for generated word clouds."""
from __future__ import annotations
import io
import shutil
import uuid
from datetime import datetime
from pathlib import Path
from typing import Any, Callable
from PIL import Image, UnidentifiedImageError
from .product_archive import (
WordcloudSource,
copy_word_locations_snapshot,
find_visible_wordcloud_sources,
)
from .product_archive_store import ProductArchiveStore
from .schemas import ProductVersionRecord
class ProductPendingCleanupError(ValueError):
"""Raised when a pending or failed-cleanup product is changed before recovery."""
class ProductArchiveService:
"""Materialize a self-contained design preview and word-location snapshots."""
def __init__(
self,
store: ProductArchiveStore,
storage: Any,
load_asset_meta: Callable[[str], dict[str, Any]],
resolve_job_status: Callable[[str], Any],
) -> None:
self.store = store
self.storage = storage
self.load_asset_meta = load_asset_meta
self.resolve_job_status = resolve_job_status
self.archive_root = store.root.parent
@staticmethod
def _validate_preview(preview_bytes: bytes) -> None:
if not preview_bytes.startswith(b"\x89PNG\r\n\x1a\n"):
raise ValueError("preview must be a PNG image")
try:
with Image.open(io.BytesIO(preview_bytes)) as image:
image.verify()
if image.format != "PNG":
raise ValueError("preview must be a PNG image")
except (UnidentifiedImageError, OSError, ValueError) as exc:
raise ValueError("preview must be a valid PNG image") from exc
def _source_db_paths(self, sources: list[WordcloudSource]) -> list[tuple[WordcloudSource, Path]]:
resolved: list[tuple[WordcloudSource, Path]] = []
for source in sources:
status = self.resolve_job_status(source.source_job_id)
if status is None or getattr(status, "status", "") != "success":
raise ValueError(f"wordcloud source job is not successful: {source.source_job_id}")
artifacts = getattr(status, "artifacts", {}) or {}
raw_path = artifacts.get("db", "")
if not raw_path:
raise ValueError(f"wordcloud source database is unavailable: {source.source_job_id}")
db_path = Path(raw_path)
if not db_path.is_file():
raise ValueError(f"wordcloud source database is unavailable: {source.source_job_id}")
resolved.append((source, db_path))
return resolved
@staticmethod
def _move_staging(staging_dir: Path, final_dir: Path) -> None:
staging_dir.replace(final_dir)
def archive_version(
self,
product_id: str,
document: dict[str, Any],
preview_bytes: bytes,
now: datetime | None = None,
) -> ProductVersionRecord:
"""Archive visible, successful word clouds without trusting client job IDs."""
if not isinstance(document, dict):
raise ValueError("document must be a JSON object")
product = self.store.get_product(product_id)
if product.status in ("pending_cleanup", "failed_cleanup"):
raise ProductPendingCleanupError("product is pending cleanup")
if product.status != "active":
raise ValueError("product is not active")
self._validate_preview(preview_bytes)
sources = find_visible_wordcloud_sources(document, self.load_asset_meta)
source_dbs = self._source_db_paths(sources)
product_dir = self.archive_root / product_id
staging_dir = product_dir / f".{uuid.uuid4().hex}.staging"
final_dir: Path | None = None
try:
staging_dir.mkdir(parents=True, exist_ok=False)
preview_path = staging_dir / "design-preview.png"
preview_path.write_bytes(preview_bytes)
snapshots: list[tuple[WordcloudSource, Path, str]] = []
for index, (source, source_db) in enumerate(source_dbs, start=1):
snapshot_path = (
staging_dir
/ "wordclouds"
/ f"{index:02d}-{source.source_job_id}"
/ "word_locations.sqlite"
)
checksum = copy_word_locations_snapshot(source_db, snapshot_path)
snapshots.append((source, snapshot_path, checksum))
version_id = f"ver_{uuid.uuid4().hex}"
target_dir = product_dir / version_id
self._move_staging(staging_dir, target_dir)
final_dir = target_dir
preview = final_dir / "design-preview.png"
archive_rows = [
{
"archive_path": str(final_dir / snapshot_path.relative_to(staging_dir)),
"source_job_id": source.source_job_id,
"source_asset_id": source.asset_id,
"db_checksum": checksum,
}
for source, snapshot_path, checksum in snapshots
]
version, _, _ = self.store.write_archive_version(
product_id,
version_id=version_id,
metadata={
"document": document,
"wordcloud_count": len(snapshots),
"wordcloud_sources": [
{"asset_id": source.asset_id, "source_job_id": source.source_job_id}
for source, _, _ in snapshots
],
},
preview_path=str(preview),
archives=archive_rows,
now=now,
)
return version
except Exception:
shutil.rmtree(final_dir or staging_dir, ignore_errors=True)
raise
+513
View File
@@ -0,0 +1,513 @@
"""Durable SQLite metadata store for archived product word clouds."""
from __future__ import annotations
import json
import sqlite3
import threading
import uuid
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
from .schemas import (
ProductImageRecord,
ProductInput,
ProductRecord,
ProductVersionRecord,
ProductWordcloudArchiveRecord,
)
def _now() -> datetime:
return datetime.now(timezone.utc)
PURGE_CLAIM_TIMEOUT = timedelta(hours=1)
class ProductPurgeInProgressError(ValueError):
"""Raised when restore loses the atomic race to physical purge."""
class ProductArchiveStore:
"""Owns durable product metadata and logical cleanup state."""
def __init__(self, root: Path) -> None:
self.root = root
self.root.mkdir(parents=True, exist_ok=True)
self.db_path = self.root / "product_archive.db"
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 products (
product_id TEXT PRIMARY KEY,
source TEXT NOT NULL,
external_product_id TEXT,
name TEXT NOT NULL,
sku TEXT NOT NULL DEFAULT '',
specification TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
purge_after TEXT,
cover_image_id TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_products_source_external_id
ON products(source, external_product_id);
CREATE INDEX IF NOT EXISTS idx_products_name ON products(name);
CREATE INDEX IF NOT EXISTS idx_products_purge_after ON products(purge_after);
CREATE TABLE IF NOT EXISTS product_versions (
version_id TEXT PRIMARY KEY,
product_id TEXT NOT NULL,
version TEXT NOT NULL DEFAULT '',
metadata TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_product_versions_product_id
ON product_versions(product_id);
CREATE TABLE IF NOT EXISTS product_images (
image_id TEXT PRIMARY KEY,
product_id TEXT NOT NULL,
version_id TEXT NOT NULL,
image_path TEXT NOT NULL DEFAULT '',
image_type TEXT NOT NULL DEFAULT '',
is_cover INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_product_images_product_id
ON product_images(product_id);
CREATE INDEX IF NOT EXISTS idx_product_images_version_id
ON product_images(version_id);
CREATE TABLE IF NOT EXISTS product_wordcloud_archives (
archive_id TEXT PRIMARY KEY,
product_id TEXT NOT NULL,
version_id TEXT NOT NULL,
archive_path TEXT NOT NULL DEFAULT '',
source_job_id TEXT NOT NULL DEFAULT '',
source_asset_id TEXT NOT NULL DEFAULT '',
db_checksum TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_product_wordcloud_archives_product_id
ON product_wordcloud_archives(product_id);
CREATE INDEX IF NOT EXISTS idx_product_wordcloud_archives_version_id
ON product_wordcloud_archives(version_id);
CREATE TABLE IF NOT EXISTS cleanup_records (
cleanup_id TEXT PRIMARY KEY,
product_id TEXT NOT NULL,
status TEXT NOT NULL,
purge_after TEXT,
created_at TEXT NOT NULL,
completed_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_cleanup_records_product_id
ON cleanup_records(product_id);
CREATE INDEX IF NOT EXISTS idx_cleanup_records_purge_after
ON cleanup_records(purge_after);
"""
)
self._ensure_column(conn, "products", "cover_image_id", "TEXT")
self._ensure_column(conn, "product_images", "is_cover", "INTEGER NOT NULL DEFAULT 0")
self._ensure_column(conn, "product_wordcloud_archives", "source_job_id", "TEXT NOT NULL DEFAULT ''")
self._ensure_column(conn, "product_wordcloud_archives", "source_asset_id", "TEXT NOT NULL DEFAULT ''")
self._ensure_column(conn, "product_wordcloud_archives", "db_checksum", "TEXT NOT NULL DEFAULT ''")
self._ensure_column(conn, "cleanup_records", "claimed_at", "TEXT")
@staticmethod
def _ensure_column(conn: sqlite3.Connection, table: str, column: str, declaration: str) -> None:
columns = {row["name"] for row in conn.execute(f"PRAGMA table_info({table})")}
if column not in columns:
conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {declaration}")
def _execute(self, sql: str, parameters: tuple[Any, ...] = ()) -> None:
with self._lock, self._connect() as conn:
conn.execute(sql, parameters)
def _fetchone(self, sql: str, parameters: tuple[Any, ...] = ()) -> sqlite3.Row | None:
with self._lock, self._connect() as conn:
return conn.execute(sql, parameters).fetchone()
def _fetchall(self, sql: str, parameters: tuple[Any, ...] = ()) -> list[sqlite3.Row]:
with self._lock, self._connect() as conn:
return conn.execute(sql, parameters).fetchall()
@staticmethod
def _product_from_row(row: sqlite3.Row) -> ProductRecord:
return ProductRecord(
product_id=row["product_id"], source=row["source"],
external_product_id=row["external_product_id"], name=row["name"],
sku=row["sku"], specification=row["specification"], status=row["status"],
created_at=datetime.fromisoformat(row["created_at"]),
updated_at=datetime.fromisoformat(row["updated_at"]),
purge_after=datetime.fromisoformat(row["purge_after"]) if row["purge_after"] else None,
cover_image_id=row["cover_image_id"],
)
def upsert_product(self, product: ProductInput) -> ProductRecord:
if not product.name.strip():
raise ValueError("product name must not be blank")
if product.source == "external" and not (product.external_product_id or "").strip():
raise ValueError("external products require external_product_id")
now = _now()
existing = None
if product.source == "external":
existing = self._fetchone(
"SELECT * FROM products WHERE source = ? AND external_product_id = ?",
(product.source, product.external_product_id),
)
if existing:
self._execute(
"""UPDATE products SET name = ?, sku = ?, specification = ?, updated_at = ?
WHERE product_id = ?""",
(product.name, product.sku, product.specification, now.isoformat(), existing["product_id"]),
)
return self.get_product(existing["product_id"])
product_id = f"prod_{uuid.uuid4().hex}"
self._execute(
"""INSERT INTO products (
product_id, source, external_product_id, name, sku, specification,
status, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?)""",
(product_id, product.source, product.external_product_id, product.name,
product.sku, product.specification, now.isoformat(), now.isoformat()),
)
return self.get_product(product_id)
def get_product(self, product_id: str) -> ProductRecord:
row = self._fetchone("SELECT * FROM products WHERE product_id = ?", (product_id,))
if row is None:
raise ValueError(f"unknown product_id: {product_id}")
return self._product_from_row(row)
def list_products(self, query: str = "") -> list[ProductRecord]:
if query:
rows = self._fetchall(
"SELECT * FROM products WHERE name LIKE ? ORDER BY updated_at DESC, product_id",
(f"%{query}%",),
)
else:
rows = self._fetchall("SELECT * FROM products ORDER BY updated_at DESC, product_id")
return [self._product_from_row(row) for row in rows]
def create_version(
self, product_id: str, version: str = "", metadata: dict[str, Any] | None = None,
now: datetime | None = None,
) -> ProductVersionRecord:
self.get_product(product_id)
created_at = now or _now()
record = ProductVersionRecord(
version_id=f"ver_{uuid.uuid4().hex}", product_id=product_id, version=version,
metadata=metadata or {}, created_at=created_at,
)
self._execute(
"INSERT INTO product_versions (version_id, product_id, version, metadata, created_at) VALUES (?, ?, ?, ?, ?)",
(record.version_id, record.product_id, record.version, json.dumps(record.metadata, ensure_ascii=False), record.created_at.isoformat()),
)
return record
def _before_archive_commit(self) -> None:
"""Test seam: runs inside the archive metadata transaction before commit."""
def write_archive_version(
self,
product_id: str,
version_id: str,
metadata: dict[str, Any],
preview_path: str,
archives: list[dict[str, str]],
now: datetime | None = None,
) -> tuple[ProductVersionRecord, ProductImageRecord, list[ProductWordcloudArchiveRecord]]:
"""Persist one complete archive version and make its preview the sole cover."""
created_at = now or _now()
version = ProductVersionRecord(
version_id=version_id, product_id=product_id, metadata=metadata, created_at=created_at
)
image = ProductImageRecord(
image_id=f"img_{uuid.uuid4().hex}", product_id=product_id, version_id=version_id,
image_path=preview_path, image_type="design_preview", is_cover=True, created_at=created_at,
)
records = [
ProductWordcloudArchiveRecord(
archive_id=f"wca_{uuid.uuid4().hex}", product_id=product_id, version_id=version_id,
archive_path=item["archive_path"], source_job_id=item["source_job_id"],
source_asset_id=item["source_asset_id"], db_checksum=item["db_checksum"], created_at=created_at,
)
for item in archives
]
with self._lock, self._connect() as conn:
product = conn.execute("SELECT product_id FROM products WHERE product_id = ?", (product_id,)).fetchone()
if product is None:
raise ValueError(f"unknown product_id: {product_id}")
conn.execute("BEGIN")
conn.execute(
"INSERT INTO product_versions (version_id, product_id, version, metadata, created_at) VALUES (?, ?, ?, ?, ?)",
(version.version_id, version.product_id, version.version, json.dumps(version.metadata, ensure_ascii=False), version.created_at.isoformat()),
)
conn.execute(
"UPDATE product_images SET is_cover = 0 WHERE product_id = ? AND is_cover = 1", (product_id,)
)
conn.execute(
"""INSERT INTO product_images (image_id, product_id, version_id, image_path, image_type, is_cover, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(image.image_id, image.product_id, image.version_id, image.image_path, image.image_type, 1, image.created_at.isoformat()),
)
for record in records:
conn.execute(
"""INSERT INTO product_wordcloud_archives
(archive_id, product_id, version_id, archive_path, source_job_id, source_asset_id, db_checksum, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(record.archive_id, record.product_id, record.version_id, record.archive_path,
record.source_job_id, record.source_asset_id, record.db_checksum, record.created_at.isoformat()),
)
conn.execute("UPDATE products SET cover_image_id = ?, updated_at = ? WHERE product_id = ?", (image.image_id, created_at.isoformat(), product_id))
self._before_archive_commit()
conn.commit()
return version, image, records
def add_image(
self, product_id: str, version_id: str, image_path: str = "", image_type: str = "",
now: datetime | None = None,
) -> ProductImageRecord:
self._require_version(product_id, version_id)
record = ProductImageRecord(
image_id=f"img_{uuid.uuid4().hex}", product_id=product_id, version_id=version_id,
image_path=image_path, image_type=image_type, created_at=now or _now(),
)
self._execute(
"""INSERT INTO product_images (image_id, product_id, version_id, image_path, image_type, created_at)
VALUES (?, ?, ?, ?, ?, ?)""",
(record.image_id, record.product_id, record.version_id, record.image_path, record.image_type, record.created_at.isoformat()),
)
return record
def add_wordcloud_archive(
self, product_id: str, version_id: str, archive_path: str = "", now: datetime | None = None,
) -> ProductWordcloudArchiveRecord:
self._require_version(product_id, version_id)
record = ProductWordcloudArchiveRecord(
archive_id=f"wca_{uuid.uuid4().hex}", product_id=product_id, version_id=version_id,
archive_path=archive_path, created_at=now or _now(),
)
self._execute(
"""INSERT INTO product_wordcloud_archives (archive_id, product_id, version_id, archive_path, created_at)
VALUES (?, ?, ?, ?, ?)""",
(record.archive_id, record.product_id, record.version_id, record.archive_path, record.created_at.isoformat()),
)
return record
def _require_version(self, product_id: str, version_id: str) -> None:
row = self._fetchone(
"SELECT version_id FROM product_versions WHERE product_id = ? AND version_id = ?",
(product_id, version_id),
)
if row is None:
raise ValueError(f"unknown version_id for product: {version_id}")
def mark_pending_cleanup(self, product_id: str, now: datetime) -> ProductRecord:
product = self.get_product(product_id)
if product.status not in ("active", "failed_cleanup"):
raise ValueError("product cannot be marked pending cleanup")
purge_after = now + timedelta(days=30)
self._execute(
"UPDATE products SET status = ?, purge_after = ?, updated_at = ? WHERE product_id = ?",
("pending_cleanup", purge_after.isoformat(), now.isoformat(), product_id),
)
self._execute(
"""INSERT INTO cleanup_records (cleanup_id, product_id, status, purge_after, created_at)
VALUES (?, ?, ?, ?, ?)""",
(f"cln_{uuid.uuid4().hex}", product_id, "pending_cleanup", purge_after.isoformat(), now.isoformat()),
)
return self.get_product(product_id)
def restore_product(self, product_id: str, now: datetime | None = None) -> ProductRecord:
restored_at = now or _now()
with self._lock, self._connect() as conn:
conn.execute("BEGIN IMMEDIATE")
product = conn.execute(
"SELECT product_id, status FROM products WHERE product_id = ?", (product_id,)
).fetchone()
if product is None:
raise ValueError(f"unknown product_id: {product_id}")
if product["status"] == "purged":
raise ValueError("purged product cannot be restored")
purging = conn.execute(
"""SELECT 1 FROM cleanup_records
WHERE product_id = ? AND status = 'purging' LIMIT 1""",
(product_id,),
).fetchone()
if purging is not None:
raise ProductPurgeInProgressError("product is purging")
conn.execute(
"UPDATE products SET status = ?, purge_after = NULL, updated_at = ? WHERE product_id = ?",
("active", restored_at.isoformat(), product_id),
)
conn.execute(
"""UPDATE cleanup_records SET status = ?, completed_at = ?
WHERE product_id = ? AND status IN ('pending_cleanup', 'failed_cleanup')""",
("restored", restored_at.isoformat(), product_id),
)
return self.get_product(product_id)
def due_product_cleanups(self, now: datetime) -> list[ProductRecord]:
rows = self._fetchall(
"""SELECT products.* FROM products
WHERE products.status = ?
AND products.purge_after IS NOT NULL
AND products.purge_after <= ?
AND EXISTS (
SELECT 1 FROM cleanup_records
WHERE cleanup_records.product_id = products.product_id
AND cleanup_records.status = 'pending_cleanup'
)
ORDER BY products.purge_after, products.product_id""",
("pending_cleanup", now.isoformat()),
)
return [self._product_from_row(row) for row in rows]
def claim_product_purge(self, product_id: str, now: datetime) -> ProductRecord | None:
"""Atomically win the right to delete a due product's files."""
with self._lock, self._connect() as conn:
conn.execute("BEGIN IMMEDIATE")
row = conn.execute(
"SELECT * FROM products WHERE product_id = ?", (product_id,)
).fetchone()
if row is None:
return None
purge_after = datetime.fromisoformat(row["purge_after"]) if row["purge_after"] else None
if row["status"] != "pending_cleanup" or purge_after is None or purge_after > now:
return None
cleanup = conn.execute(
"""SELECT cleanup_id FROM cleanup_records
WHERE product_id = ? AND status = 'pending_cleanup'
ORDER BY created_at DESC, cleanup_id DESC LIMIT 1""",
(product_id,),
).fetchone()
if cleanup is None:
return None
updated = conn.execute(
"""UPDATE cleanup_records SET status = 'purging', claimed_at = ?
WHERE cleanup_id = ? AND status = 'pending_cleanup'""",
(now.isoformat(), cleanup["cleanup_id"]),
)
if updated.rowcount != 1:
return None
return self._product_from_row(row)
def fail_product_purge(self, product_id: str, now: datetime | None = None) -> ProductRecord:
"""Quarantine a purge whose physical deletion failed mid-way."""
failed_at = now or _now()
with self._lock, self._connect() as conn:
conn.execute("BEGIN IMMEDIATE")
claim = conn.execute(
"""SELECT 1 FROM cleanup_records
WHERE product_id = ? AND status = 'purging' LIMIT 1""",
(product_id,),
).fetchone()
if claim is None:
raise ValueError("product purge is not claimed")
updated = conn.execute(
"""UPDATE cleanup_records SET status = 'failed_cleanup'
WHERE product_id = ? AND status = 'purging'""",
(product_id,),
)
if updated.rowcount != 1:
raise ValueError("product purge is not claimed")
conn.execute(
"UPDATE products SET status = 'failed_cleanup', updated_at = ? WHERE product_id = ?",
(failed_at.isoformat(), product_id),
)
return self.get_product(product_id)
def reconcile_stalled_purges(
self,
now: datetime,
timeout: timedelta = PURGE_CLAIM_TIMEOUT,
) -> list[ProductRecord]:
"""Move abandoned purge claims to failed_cleanup for manual inspection."""
cutoff = now - timeout
with self._lock, self._connect() as conn:
conn.execute("BEGIN IMMEDIATE")
rows = conn.execute(
"""SELECT product_id FROM cleanup_records
WHERE status = 'purging'
AND claimed_at IS NOT NULL
AND claimed_at <= ?""",
(cutoff.isoformat(),),
).fetchall()
affected: list[str] = []
for row in rows:
product_id = row["product_id"]
product_row = conn.execute(
"""SELECT 1 FROM products WHERE product_id = ? AND status = 'pending_cleanup'""",
(product_id,),
).fetchone()
if product_row is None:
continue
updated = conn.execute(
"""UPDATE cleanup_records SET status = 'failed_cleanup'
WHERE product_id = ? AND status = 'purging' AND claimed_at <= ?""",
(product_id, cutoff.isoformat()),
)
if updated.rowcount != 1:
continue
conn.execute(
"""UPDATE products SET status = 'failed_cleanup', updated_at = ?
WHERE product_id = ? AND status = 'pending_cleanup'""",
(now.isoformat(), product_id),
)
affected.append(product_id)
return [self.get_product(product_id) for product_id in affected]
def failed_product_cleanups(self) -> list[ProductRecord]:
rows = self._fetchall(
"""SELECT * FROM products WHERE status = ?
ORDER BY purge_after, product_id""",
("failed_cleanup",),
)
return [self._product_from_row(row) for row in rows]
def finalize_product_purge(self, product_id: str, now: datetime) -> ProductRecord:
"""Atomically mark a claimed product purged after file removal."""
with self._lock, self._connect() as conn:
conn.execute("BEGIN IMMEDIATE")
claim = conn.execute(
"""SELECT 1 FROM cleanup_records
WHERE product_id = ? AND status = 'purging' LIMIT 1""",
(product_id,),
).fetchone()
if claim is None:
raise ValueError("product purge is not claimed")
conn.execute(
"UPDATE products SET status = ?, updated_at = ? WHERE product_id = ?",
("purged", now.isoformat(), product_id),
)
conn.execute(
"""UPDATE cleanup_records SET status = ?, completed_at = ?
WHERE product_id = ? AND status = 'purging'""",
("purged", now.isoformat(), product_id),
)
return self.get_product(product_id)
def purge_product(self, product_id: str, now: datetime | None = None) -> ProductRecord:
purged_at = now or _now()
if self.claim_product_purge(product_id, purged_at) is None:
raise ValueError("product is not due for purge")
return self.finalize_product_purge(product_id, purged_at)
+100 -1
View File
@@ -2,11 +2,110 @@ from __future__ import annotations
from datetime import datetime
from pathlib import Path
from typing import Literal
from typing import Any, Literal
from pydantic import BaseModel, Field
class ProductInput(BaseModel):
source: Literal["manual", "external"]
external_product_id: str | None = None
name: str
sku: str = ""
specification: str = ""
def __init__(
self,
source: Literal["manual", "external"] | None = None,
external_product_id: str | None = None,
name: str | None = None,
sku: str = "",
specification: str = "",
**data: Any,
) -> None:
if source is not None:
data["source"] = source
if external_product_id is not None or "external_product_id" not in data:
data["external_product_id"] = external_product_id
if name is not None:
data["name"] = name
if sku or "sku" not in data:
data["sku"] = sku
if specification or "specification" not in data:
data["specification"] = specification
super().__init__(**data)
class ProductRecord(BaseModel):
product_id: str
source: Literal["manual", "external"]
external_product_id: str | None = None
name: str
sku: str = ""
specification: str = ""
status: Literal["active", "pending_cleanup", "failed_cleanup", "purged"] = "active"
created_at: datetime
updated_at: datetime
purge_after: datetime | None = None
cover_image_id: str | None = None
class ProductVersionRecord(BaseModel):
version_id: str
product_id: str
version: str = ""
metadata: dict[str, Any] = Field(default_factory=dict)
created_at: datetime
class ProductImageRecord(BaseModel):
image_id: str
product_id: str
version_id: str
image_path: str = ""
image_type: str = ""
is_cover: bool = False
created_at: datetime
class ProductWordcloudArchiveRecord(BaseModel):
archive_id: str
product_id: str
version_id: str
archive_path: str = ""
source_job_id: str = ""
source_asset_id: str = ""
db_checksum: str = ""
created_at: datetime
class ProductWordcloudArchiveResponse(ProductWordcloudArchiveRecord):
db_path: str
class ProductVersionArchiveResponse(ProductVersionRecord):
design_preview_path: str
wordcloud_count: int
wordcloud_archives: list[ProductWordcloudArchiveResponse] = Field(default_factory=list)
class ProductImageResponse(ProductImageRecord):
image_url: str = ""
class ProductVersionDetailResponse(ProductVersionRecord):
design_preview_image_id: str | None = None
design_preview_url: str = ""
design_preview_path: str = ""
wordcloud_count: int = 0
wordcloud_archives: list[ProductWordcloudArchiveResponse] = Field(default_factory=list)
class ProductDetailResponse(ProductRecord):
images: list[ProductImageResponse] = Field(default_factory=list)
versions: list[ProductVersionDetailResponse] = Field(default_factory=list)
class JobCreateResponse(BaseModel):
job_id: str
+43 -18
View File
@@ -1,18 +1,21 @@
#!/usr/bin/env python3
"""Inspect and optionally clean stale job directories.
"""Inspect retention candidates and optionally clean verified managed jobs.
Default mode is a safe dry-run that reports reclaimable bytes. Pass --apply to
actually remove unreferenced job directories.
Default mode is a safe dry-run. Metadata-less workspaces are reported for
manual investigation but are never removed by ``--apply``.
"""
from __future__ import annotations
import argparse
import json
import os
from datetime import datetime, timezone
from pathlib import Path
from .cleanup_service import CleanupService
from .metadata_store import MetadataStore
from .product_archive_store import ProductArchiveStore
from .storage import Storage
@@ -21,6 +24,7 @@ PROJECT_ROOT = BACKEND_ROOT
WORKSPACE_DIR = PROJECT_ROOT / "service_workspace"
ASSETS_DIR = PROJECT_ROOT / "service_assets"
METADATA_DIR = PROJECT_ROOT / "service_metadata"
PRODUCTS_DIR = PROJECT_ROOT / "service_products"
def read_asset_meta(path: Path) -> dict:
@@ -41,32 +45,45 @@ def referenced_job_ids() -> set[str]:
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("--max-age-days", type=float, default=30)
parser.add_argument(
"--apply",
action="store_true",
help="Delete only CleanupService-verified candidates; orphan workspaces remain report-only",
)
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)
product_store = ProductArchiveStore(PRODUCTS_DIR / "metadata")
cleanup = CleanupService(storage, store, product_store)
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,
asset_referenced = referenced_job_ids()
archive_protected = cleanup.archive_protected_job_ids()
orphaned = storage.stale_job_dirs(
referenced_job_ids=archive_protected,
exclude_job_ids=known_job_ids,
max_age_days=args.max_age_days,
)
reclaimable = sum(item["size_bytes"] for item in stale)
cleanup_report = cleanup.preview(datetime.now(timezone.utc))
reclaimable = cleanup_report.reclaimable_bytes + sum(item["size_bytes"] for item in orphaned)
report = {
"scanned_at": datetime.now(timezone.utc).isoformat(),
"job_dir_count": len(storage.list_job_ids()),
"referenced_job_ids": len(referenced),
"referenced_job_ids": len(asset_referenced),
"asset_referenced_job_ids": sorted(asset_referenced),
"archive_protected_job_ids": sorted(archive_protected),
"known_job_ids": len(known_job_ids),
"stale_job_count": len(stale),
"temporary_jobs": [item.model_dump(mode="json") for item in cleanup_report.temporary_jobs],
"pending_product_ids": [item.product_id for item in cleanup_report.pending_products],
"failed_product_ids": [item.product_id for item in cleanup_report.failed_products],
"reminder_job_ids": cleanup_report.reminder_job_ids,
"stale_job_count": len(orphaned),
"reclaimable_bytes": reclaimable,
"max_age_days": args.max_age_days,
"apply": args.apply,
"stale_jobs": stale[:200],
"stale_jobs": orphaned[:200],
}
print(json.dumps(report, ensure_ascii=False, indent=2))
@@ -74,11 +91,19 @@ def main() -> None:
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 os.environ.get("CLEANUP_APPLY_ENABLED", "false").strip().lower() != "true":
parser.error("--apply requires CLEANUP_APPLY_ENABLED=true")
applied = cleanup.apply(datetime.now(timezone.utc))
freed = sum(
item.size_bytes
for item in applied.temporary_jobs
if item.job_id in applied.deleted_job_ids
)
print(
"freed_bytes="
f"{freed} deleted_job_ids={applied.deleted_job_ids} "
f"deleted_product_ids={applied.deleted_product_ids}"
)
if __name__ == "__main__":
+27
View File
@@ -0,0 +1,27 @@
from __future__ import annotations
import sys
from pathlib import Path
from xml.etree import ElementTree as ET
BACKEND_DIR = Path(__file__).resolve().parents[1]
if str(BACKEND_DIR) not in sys.path:
sys.path.insert(0, str(BACKEND_DIR))
from service import app as service_app # noqa: E402
def test_ai_export_outlines_chinese_svg_text() -> None:
source = b'''<svg xmlns="http://www.w3.org/2000/svg" width="300" height="100">
<text x="12" y="56" font-size="42" fill="#112233" transform="rotate(2 0 0)">你好 AI</text>
</svg>'''
outlined = service_app._outline_svg_text(source)
root = ET.fromstring(outlined)
names = [element.tag.rsplit("}", 1)[-1] for element in root.iter()]
assert "text" not in names
assert names.count("path") >= 3
assert b'rotate(2 0 0)' in outlined
assert b'fill="#112233"' in outlined
+519
View File
@@ -0,0 +1,519 @@
from __future__ import annotations
import json
import os
import sqlite3
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
BACKEND_DIR = Path(__file__).resolve().parents[1]
if str(BACKEND_DIR) not in sys.path:
sys.path.insert(0, str(BACKEND_DIR))
from service import app as service_app # noqa: E402
from service.cleanup_service import CleanupService # noqa: E402
from service.metadata_store import MetadataStore # noqa: E402
from service.product_archive_store import ProductArchiveStore # noqa: E402
from service.schemas import JobStatus, ProductInput # noqa: E402
from service.storage import Storage # noqa: E402
from service import storage_metrics # noqa: E402
NOW = datetime(2026, 9, 12, 12, 0, tzinfo=timezone.utc)
def make_cleanup_service(tmp_path: Path) -> CleanupService:
return CleanupService(
Storage(tmp_path / "workspace"),
MetadataStore(tmp_path / "metadata" / "app.db"),
ProductArchiveStore(tmp_path / "products" / "metadata"),
)
def create_success_job(
service: CleanupService,
*,
age_days: float,
archived: bool = False,
create_folder: bool = True,
) -> str:
job_id = f"{len(service.metadata_store.job_ids()) + 1:032x}"
created_at = NOW - timedelta(days=age_days)
db_path = service.storage.job_root(job_id) / "output" / "word_locations.sqlite"
if create_folder:
db_path.parent.mkdir(parents=True)
db_path.write_bytes(b"db-content")
service.metadata_store.upsert_job(
JobStatus(
job_id=job_id,
status="success",
stage="done",
progress_percent=100,
message="done",
artifacts={"db": str(db_path)},
created_at=created_at,
updated_at=created_at,
)
)
if archived:
product = service.product_store.upsert_product(ProductInput(name="Archived", source="manual"))
service.product_store.write_archive_version(
product.product_id,
version_id=f"ver_{job_id}",
metadata={},
preview_path="",
archives=[
{
"archive_path": "snapshot.sqlite",
"source_job_id": job_id,
"source_asset_id": "asset-1",
"db_checksum": "checksum",
}
],
now=NOW,
)
return job_id
def create_pending_product(
service: CleanupService,
*,
purge_after: datetime,
create_folder: bool = True,
):
product = service.product_store.upsert_product(ProductInput(name="Pending", source="manual"))
marked_at = purge_after - timedelta(days=30)
product = service.product_store.mark_pending_cleanup(product.product_id, now=marked_at)
if create_folder:
product_dir = service.product_store.root.parent / product.product_id
product_dir.mkdir(parents=True)
(product_dir / "preview.png").write_bytes(b"product-bytes")
return product
def archive_job(service: CleanupService, job_id: str) -> None:
product = service.product_store.upsert_product(ProductInput(name="Late archive", source="manual"))
service.product_store.write_archive_version(
product.product_id,
version_id=f"ver_late_{job_id}",
metadata={},
preview_path="",
archives=[
{
"archive_path": "snapshot.sqlite",
"source_job_id": job_id,
"source_asset_id": "asset-late",
"db_checksum": "checksum",
}
],
now=NOW,
)
def auth_header() -> dict[str, str]:
return {"Authorization": f"Bearer {service_app._orders_token()}"}
def test_cleanup_skips_archived_job_and_marks_job_at_23_day_threshold_for_reminder(tmp_path):
service = make_cleanup_service(tmp_path)
archived_job = create_success_job(service, age_days=31, archived=True)
remind_job = create_success_job(service, age_days=23)
almost_remind_job = create_success_job(service, age_days=23 - (1 / 86400))
report = service.preview(now=NOW)
assert archived_job not in {item.job_id for item in report.temporary_jobs}
assert remind_job in report.reminder_job_ids
assert almost_remind_job not in report.reminder_job_ids
def test_apply_removes_only_due_unarchived_job_and_due_product(tmp_path):
service = make_cleanup_service(tmp_path)
due_job = create_success_job(service, age_days=30)
young_job = create_success_job(service, age_days=29)
product = create_pending_product(service, purge_after=NOW)
report = service.apply(now=NOW)
assert report.deleted_job_ids == [due_job]
assert not service.storage.job_root(due_job).exists()
assert service.storage.job_root(young_job).exists()
assert due_job not in service.metadata_store.job_ids()
assert product.product_id in report.deleted_product_ids
assert not (service.product_store.root.parent / product.product_id).exists()
assert service.product_store.get_product(product.product_id).status == "purged"
def test_apply_treats_missing_job_and_product_folders_as_idempotent_success(tmp_path):
service = make_cleanup_service(tmp_path)
due_job = create_success_job(service, age_days=30, create_folder=False)
product = create_pending_product(service, purge_after=NOW, create_folder=False)
report = service.apply(now=NOW)
assert report.deleted_job_ids == [due_job]
assert report.deleted_product_ids == [product.product_id]
assert due_job not in service.metadata_store.job_ids()
assert service.product_store.get_product(product.product_id).status == "purged"
def test_restored_product_is_not_deleted_when_original_purge_time_arrives(tmp_path):
service = make_cleanup_service(tmp_path)
product = create_pending_product(service, purge_after=NOW)
product_dir = service.product_store.root.parent / product.product_id
service.product_store.restore_product(product.product_id, now=NOW - timedelta(days=1))
report = service.apply(now=NOW)
assert product.product_id not in report.deleted_product_ids
assert product_dir.exists()
assert service.product_store.get_product(product.product_id).status == "active"
def test_atomic_purge_claim_blocks_restore_before_product_files_are_deleted(
tmp_path, monkeypatch
):
service = make_cleanup_service(tmp_path)
product = create_pending_product(service, purge_after=NOW)
restore_conflicts: list[str] = []
def attempt_restore(product_id: str) -> None:
with pytest.raises(ValueError, match="purging"):
service.product_store.restore_product(product_id, now=NOW)
restore_conflicts.append(product_id)
monkeypatch.setattr(
service, "_before_product_files_delete", attempt_restore, raising=False
)
report = service.apply(now=NOW)
assert restore_conflicts == [product.product_id]
assert report.deleted_product_ids == [product.product_id]
assert service.product_store.get_product(product.product_id).status == "purged"
def test_failed_product_file_deletion_enters_failed_cleanup_and_is_not_retried(
tmp_path, monkeypatch
):
service = make_cleanup_service(tmp_path)
product = create_pending_product(service, purge_after=NOW)
monkeypatch.setattr(
"service.cleanup_service.shutil.rmtree",
lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("disk failure")),
)
with pytest.raises(OSError, match="disk failure"):
service.apply(now=NOW)
record = service.product_store.get_product(product.product_id)
cleanup = service.product_store._fetchone(
"SELECT status FROM cleanup_records WHERE product_id = ?", (product.product_id,)
)["status"]
assert record.status == "failed_cleanup"
assert record.purge_after == NOW
assert cleanup == "failed_cleanup"
assert service.product_store.due_product_cleanups(NOW) == []
assert (service.product_store.root.parent / product.product_id).exists()
def test_failed_cleanup_product_can_be_restored_or_requeued(tmp_path, monkeypatch):
service = make_cleanup_service(tmp_path)
def _fail_purge(purge_after: datetime) -> str:
product = create_pending_product(service, purge_after=purge_after)
monkeypatch.setattr(
"service.cleanup_service.shutil.rmtree",
lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("disk failure")),
)
with pytest.raises(OSError):
service.apply(now=purge_after)
assert service.product_store.get_product(product.product_id).status == "failed_cleanup"
return product.product_id
failed = _fail_purge(NOW)
restored = service.product_store.restore_product(failed, now=NOW + timedelta(days=1))
assert restored.status == "active"
assert restored.purge_after is None
failed_requeue = _fail_purge(NOW)
requeued = service.product_store.mark_pending_cleanup(
failed_requeue, now=NOW + timedelta(days=1)
)
assert requeued.status == "pending_cleanup"
assert requeued.purge_after == NOW + timedelta(days=31)
assert service.product_store.due_product_cleanups(NOW + timedelta(days=5)) == []
assert service.product_store.due_product_cleanups(NOW + timedelta(days=31)) == [
requeued
]
def test_stale_purging_claim_is_reconciled_to_failed_cleanup(tmp_path, monkeypatch):
service = make_cleanup_service(tmp_path)
product = create_pending_product(service, purge_after=NOW)
store = service.product_store
original_fail = store.fail_product_purge
calls = {"n": 0}
def flaky_fail(product_id, now=None):
calls["n"] += 1
if calls["n"] == 1:
raise sqlite3.OperationalError("simulated busy")
return original_fail(product_id, now=now)
monkeypatch.setattr(store, "fail_product_purge", flaky_fail)
monkeypatch.setattr(
"service.cleanup_service.shutil.rmtree",
lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("disk failure")),
)
with pytest.raises(sqlite3.OperationalError, match="simulated busy"):
service.apply(now=NOW)
stranded = store._fetchone(
"SELECT status, claimed_at FROM cleanup_records WHERE product_id = ?",
(product.product_id,),
)
assert stranded["status"] == "purging"
assert datetime.fromisoformat(stranded["claimed_at"]) == NOW
report = service.preview(now=NOW + timedelta(hours=2))
assert [item.product_id for item in report.failed_products] == [product.product_id]
assert store.get_product(product.product_id).status == "failed_cleanup"
assert store.due_product_cleanups(NOW + timedelta(days=1)) == []
def test_cleanup_candidates_reports_failed_products(tmp_path, monkeypatch):
service = make_cleanup_service(tmp_path)
product = create_pending_product(service, purge_after=NOW)
store = service.product_store
store.claim_product_purge(product.product_id, NOW)
store.fail_product_purge(product.product_id, now=NOW)
monkeypatch.setattr(service_app, "cleanup_service", service, raising=False)
client = TestClient(service_app.app)
response = client.get("/api/maintenance/cleanup-candidates", headers=auth_header())
assert response.status_code == 200
data = response.json()
assert [item["product_id"] for item in data["failed_products"]] == [
product.product_id
]
assert data["pending_products"] == []
def test_apply_rechecks_archive_reference_immediately_before_job_deletion(tmp_path):
service = make_cleanup_service(tmp_path)
due_job = create_success_job(service, age_days=31)
original_preview = service.preview
def preview_then_archive(now: datetime):
report = original_preview(now)
archive_job(service, due_job)
return report
service.preview = preview_then_archive # type: ignore[method-assign]
report = service.apply(now=NOW)
assert due_job not in report.deleted_job_ids
assert service.storage.job_root(due_job).exists()
assert due_job in service.metadata_store.job_ids()
def test_cleanup_routes_require_admin_auth_and_confirmed_enabled_apply(tmp_path, monkeypatch):
service = make_cleanup_service(tmp_path)
due_job = create_success_job(service, age_days=3650)
monkeypatch.setattr(service_app, "cleanup_service", service)
monkeypatch.setenv("CLEANUP_APPLY_ENABLED", "false")
client = TestClient(service_app.app)
assert client.get("/api/maintenance/cleanup-candidates").status_code == 403
assert client.post("/api/maintenance/cleanup-run", json={"confirm": True}).status_code == 403
assert client.post(
"/api/maintenance/cleanup-run", json={"confirm": False}, headers=auth_header()
).status_code == 400
disabled = client.post(
"/api/maintenance/cleanup-run", json={"confirm": True}, headers=auth_header()
)
assert disabled.status_code == 409
assert service.storage.job_root(due_job).exists()
monkeypatch.setenv("CLEANUP_APPLY_ENABLED", "true")
preview = client.get("/api/maintenance/cleanup-candidates", headers=auth_header())
applied = client.post(
"/api/maintenance/cleanup-run", json={"confirm": True}, headers=auth_header()
)
assert preview.status_code == 200
assert preview.json()["temporary_jobs"][0]["job_id"] == due_job
assert applied.status_code == 200
assert applied.json()["deleted_job_ids"] == [due_job]
assert not service.storage.job_root(due_job).exists()
def test_scheduled_cleanup_is_preview_only_until_apply_flag_is_enabled(tmp_path, monkeypatch):
service = make_cleanup_service(tmp_path)
first_job = create_success_job(service, age_days=31)
monkeypatch.setattr(service_app, "cleanup_service", service)
monkeypatch.setenv("CLEANUP_APPLY_ENABLED", "false")
service_app._run_scheduled_cleanup_once(now=NOW)
assert service.storage.job_root(first_job).exists()
second_job = create_success_job(service, age_days=31)
monkeypatch.setenv("CLEANUP_APPLY_ENABLED", "true")
service_app._run_scheduled_cleanup_once(now=NOW)
assert not service.storage.job_root(first_job).exists()
assert not service.storage.job_root(second_job).exists()
def test_scheduler_stop_retains_live_worker_and_restart_does_not_duplicate_cycle(monkeypatch):
class LiveThread:
def __init__(self):
self.join_calls: list[float | None] = []
def is_alive(self) -> bool:
return True
def join(self, timeout=None) -> None:
self.join_calls.append(timeout)
worker = LiveThread()
cycles: list[str] = []
monkeypatch.setattr(service_app, "_cleanup_scheduler_thread", worker)
monkeypatch.setattr(service_app, "_run_scheduled_cleanup_once", lambda: cycles.append("run"))
service_app._stop_cleanup_scheduler()
service_app._start_cleanup_scheduler()
assert service_app._cleanup_scheduler_stop.is_set()
assert service_app._cleanup_scheduler_thread is worker
assert worker.join_calls
assert cycles == []
def test_storage_summary_separates_archive_protection_from_asset_references(tmp_path, monkeypatch):
service = make_cleanup_service(tmp_path)
archived_job = create_success_job(service, age_days=3650, archived=True)
asset_job = create_success_job(service, age_days=3650)
assets_dir = tmp_path / "assets"
asset_dir = assets_dir / "as" / "asset-old"
asset_dir.mkdir(parents=True)
(asset_dir / "meta.json").write_text(
'{"asset_id":"asset-old","job_id":"' + asset_job + '"}', encoding="utf-8"
)
monkeypatch.setattr(service_app, "storage", service.storage)
monkeypatch.setattr(service_app, "metadata_store", service.metadata_store)
monkeypatch.setattr(service_app, "product_archive_store", service.product_store)
monkeypatch.setattr(service_app, "cleanup_service", service)
monkeypatch.setattr(service_app, "ASSETS_DIR", assets_dir)
summary = service_app.storage_summary()
assert summary["archive_protected_job_ids"] == [archived_job]
assert summary["asset_referenced_job_ids"] == [asset_job]
assert summary["temporary_job_ids"] == [asset_job]
def test_storage_metrics_protects_archives_but_not_old_asset_references(
tmp_path, monkeypatch, capsys
):
workspace = tmp_path / "workspace"
assets = tmp_path / "assets"
metadata = tmp_path / "metadata"
products = tmp_path / "products"
storage = Storage(workspace)
archived_job = "a" * 32
asset_job = "b" * 32
for job_id in (archived_job, asset_job):
root = storage.job_root(job_id)
root.mkdir(parents=True)
(root / "payload.bin").write_bytes(b"payload")
old_timestamp = (NOW - timedelta(days=31)).timestamp()
os.utime(root, (old_timestamp, old_timestamp))
asset_dir = assets / "as" / "asset-old"
asset_dir.mkdir(parents=True)
(asset_dir / "meta.json").write_text(
json.dumps({"asset_id": "asset-old", "job_id": asset_job}), encoding="utf-8"
)
product_store = ProductArchiveStore(products / "metadata")
product = product_store.upsert_product(ProductInput(name="Archive", source="manual"))
product_store.write_archive_version(
product.product_id,
version_id="ver_archive",
metadata={},
preview_path="",
archives=[{
"archive_path": "snapshot.sqlite",
"source_job_id": archived_job,
"source_asset_id": "asset-archive",
"db_checksum": "checksum",
}],
now=NOW,
)
monkeypatch.setattr(storage_metrics, "WORKSPACE_DIR", workspace)
monkeypatch.setattr(storage_metrics, "ASSETS_DIR", assets)
monkeypatch.setattr(storage_metrics, "METADATA_DIR", metadata)
monkeypatch.setattr(storage_metrics, "PRODUCTS_DIR", products, raising=False)
monkeypatch.setattr(sys, "argv", ["storage_metrics", "--max-age-days", "30"])
storage_metrics.main()
report = json.loads(capsys.readouterr().out)
assert report["archive_protected_job_ids"] == [archived_job]
assert report["asset_referenced_job_ids"] == [asset_job]
assert [item["job_id"] for item in report["stale_jobs"]] == [asset_job]
def test_storage_metrics_apply_never_deletes_unproven_or_failed_workspaces(
tmp_path, monkeypatch
):
workspace = tmp_path / "workspace"
metadata = tmp_path / "metadata"
storage = Storage(workspace)
metadata_store = MetadataStore(metadata / "app.db")
orphan_job = "c" * 32
failed_job = "d" * 32
for job_id in (orphan_job, failed_job):
root = storage.job_root(job_id)
root.mkdir(parents=True)
(root / "payload.bin").write_bytes(b"payload")
old_timestamp = (datetime.now(timezone.utc) - timedelta(days=31)).timestamp()
os.utime(root, (old_timestamp, old_timestamp))
metadata_store.upsert_job(
JobStatus(
job_id=failed_job,
status="failed",
stage="failed",
progress_percent=100,
message="failed",
artifacts={"db": str(storage.job_root(failed_job) / "output" / "db.sqlite")},
error="generation failed",
created_at=datetime.now(timezone.utc) - timedelta(days=31),
updated_at=datetime.now(timezone.utc) - timedelta(days=31),
)
)
monkeypatch.setattr(storage_metrics, "WORKSPACE_DIR", workspace)
monkeypatch.setattr(storage_metrics, "ASSETS_DIR", tmp_path / "assets")
monkeypatch.setattr(storage_metrics, "METADATA_DIR", metadata)
monkeypatch.setattr(storage_metrics, "PRODUCTS_DIR", tmp_path / "products")
monkeypatch.setattr(sys, "argv", ["storage_metrics", "--apply"])
monkeypatch.setenv("CLEANUP_APPLY_ENABLED", "true")
storage_metrics.main()
assert storage.job_root(orphan_job).exists()
assert storage.job_root(failed_job).exists()
+582
View File
@@ -0,0 +1,582 @@
import hashlib
import json
import sqlite3
import sys
import shutil
from io import BytesIO
from pathlib import Path
from types import SimpleNamespace
from datetime import datetime, timedelta, timezone
import pytest
from fastapi.testclient import TestClient
from PIL import Image
BACKEND_DIR = Path(__file__).resolve().parents[1]
if str(BACKEND_DIR) not in sys.path:
sys.path.insert(0, str(BACKEND_DIR))
from service.product_archive import ( # noqa: E402
WordcloudSource,
copy_word_locations_snapshot,
find_visible_wordcloud_sources,
validate_word_locations_db,
)
from service.product_archive_store import ProductArchiveStore # noqa: E402
from service.cleanup_service import CleanupService # noqa: E402
from service.metadata_store import MetadataStore # noqa: E402
from service.storage import Storage # noqa: E402
from service.schemas import JobStatus # noqa: E402
from service import app as service_app # noqa: E402
def _png_bytes() -> bytes:
image = BytesIO()
Image.new("RGB", (1, 1), "white").save(image, format="PNG")
return image.getvalue()
PNG_BYTES = _png_bytes()
def orders_auth_header() -> dict[str, str]:
return {"Authorization": f"Bearer {service_app._orders_token()}"}
def create_product(client):
response = client.post(
"/api/products", json={"name": "笔盒", "source": "manual"}, headers=orders_auth_header()
)
assert response.status_code == 201
return response.json()
@pytest.fixture
def product_archive_client(tmp_path, monkeypatch):
archive_root = tmp_path / "product_archives"
store = ProductArchiveStore(archive_root / "metadata")
monkeypatch.setattr(service_app, "PRODUCT_ARCHIVES_DIR", archive_root, raising=False)
monkeypatch.setattr(service_app, "product_archive_store", store, raising=False)
client = TestClient(service_app.app)
client.archive_root = archive_root
client.product_store = store
return client
@pytest.fixture
def prepared_wordcloud_job(tmp_path, monkeypatch):
workspace = tmp_path / "source-workspace"
db_path = workspace / "output" / "word_locations.sqlite"
db_path.parent.mkdir(parents=True)
with sqlite3.connect(db_path) as connection:
connection.execute("CREATE TABLE word_locations (id INTEGER PRIMARY KEY, name TEXT)")
connection.execute("INSERT INTO word_locations (name) VALUES ('hello')")
asset_id = "asset_wordcloud"
assets_dir = tmp_path / "assets"
asset_dir = assets_dir / asset_id[:2] / asset_id
asset_dir.mkdir(parents=True)
(asset_dir / "meta.json").write_text(
json.dumps({"asset_id": asset_id, "type": "wordcloud", "job_id": "job-success"}),
encoding="utf-8",
)
monkeypatch.setattr(service_app, "ASSETS_DIR", assets_dir)
monkeypatch.setattr(
service_app,
"_resolve_job_status",
lambda job_id: SimpleNamespace(status="success", artifacts={"db": str(db_path)}),
)
return SimpleNamespace(
workspace=workspace,
asset_id=asset_id,
document={"elements": [{"type": "sticker", "assetId": asset_id}]},
db_path=db_path,
)
def archive_product_version(client, product_id, document, preview=PNG_BYTES):
return client.post(
f"/api/products/{product_id}/versions",
data={"document_json": json.dumps(document)},
files={"preview": ("design-preview.png", preview, "image/png")},
headers=orders_auth_header(),
)
def test_scanner_keeps_only_visible_wordcloud_assets_and_deduplicates():
document = {
"layers": [{"id": "shown", "visible": True}, {"id": "hidden", "visible": False}],
"elements": [
{"type": "sticker", "assetId": "wc-a", "layerId": "shown"},
{"type": "sticker", "assetId": "wc-a", "layerId": "shown"},
{"type": "sticker", "assetId": "wc-b", "layerId": "hidden"},
{"type": "sticker", "assetId": "photo", "layerId": "shown"},
],
}
assets = {
"wc-a": {"type": "wordcloud", "job_id": "a" * 32},
"wc-b": {"type": "wordcloud", "job_id": "b" * 32},
"photo": {"type": "upload", "job_id": ""},
}
assert find_visible_wordcloud_sources(document, assets.__getitem__) == [
WordcloudSource("wc-a", "a" * 32)
]
def test_scanner_treats_elements_as_visible_without_layers():
document = {"elements": [{"type": "sticker", "assetId": "wc-a"}]}
assets = {"wc-a": {"type": "wordcloud", "job_id": "a" * 32}}
assert find_visible_wordcloud_sources(document, assets.__getitem__) == [
WordcloudSource("wc-a", "a" * 32)
]
def test_scanner_returns_empty_list_without_elements():
assert find_visible_wordcloud_sources({"layers": []}, lambda _: {}) == []
def test_scanner_skips_wordcloud_without_job_id():
document = {"elements": [{"type": "sticker", "assetId": "wc-a"}]}
assets = {"wc-a": {"type": "wordcloud"}}
assert find_visible_wordcloud_sources(document, assets.__getitem__) == []
def test_scanner_accepts_job_imported_wordcloud_stickers():
document = {"elements": [{"type": "sticker", "assetId": "wc-a"}]}
assets = {"wc-a": {"type": "sticker", "job_id": "a" * 32}}
assert find_visible_wordcloud_sources(document, assets.__getitem__) == [
WordcloudSource("wc-a", "a" * 32)
]
def test_snapshot_rejects_non_sqlite_file_without_creating_destination(tmp_path):
source = tmp_path / "not-a-database.sqlite"
destination = tmp_path / "archive" / "word_locations.sqlite"
source.write_text("not a SQLite database", encoding="utf-8")
with pytest.raises(ValueError):
copy_word_locations_snapshot(source, destination)
assert not destination.exists()
assert not destination.with_name(f"{destination.name}.tmp").exists()
def test_snapshot_validates_table_copies_atomically_and_returns_checksum(tmp_path):
source = tmp_path / "word_locations.sqlite"
destination = tmp_path / "archive" / "word_locations.sqlite"
with sqlite3.connect(source) as connection:
connection.execute("CREATE TABLE word_locations (id INTEGER PRIMARY KEY, name TEXT)")
connection.execute("INSERT INTO word_locations (name) VALUES ('hello')")
validate_word_locations_db(source)
checksum = copy_word_locations_snapshot(source, destination)
assert checksum == hashlib.sha256(destination.read_bytes()).hexdigest()
with sqlite3.connect(destination) as connection:
assert connection.execute("SELECT name FROM word_locations").fetchone() == ("hello",)
def test_archive_version_copies_db_after_source_workspace_is_removed(
product_archive_client, prepared_wordcloud_job
):
product = create_product(product_archive_client)
response = product_archive_client.post(
f"/api/products/{product['product_id']}/versions",
data={"document_json": json.dumps(prepared_wordcloud_job.document)},
files={"preview": ("design-preview.png", PNG_BYTES, "image/png")},
headers=orders_auth_header(),
)
assert response.status_code == 201
archive = response.json()["wordcloud_archives"][0]
shutil.rmtree(prepared_wordcloud_job.workspace)
assert Path(archive["db_path"]).exists()
def test_archive_version_rejects_preview_with_non_png_mime_type(
product_archive_client, prepared_wordcloud_job
):
product = create_product(product_archive_client)
response = product_archive_client.post(
f"/api/products/{product['product_id']}/versions",
data={"document_json": json.dumps(prepared_wordcloud_job.document)},
files={"preview": ("design-preview.jpg", PNG_BYTES, "image/jpeg")},
headers=orders_auth_header(),
)
assert response.status_code == 400
def test_archive_version_rejects_unavailable_source_db(product_archive_client, prepared_wordcloud_job):
prepared_wordcloud_job.db_path.unlink()
product = create_product(product_archive_client)
response = product_archive_client.post(
f"/api/products/{product['product_id']}/versions",
data={"document_json": json.dumps(prepared_wordcloud_job.document)},
files={"preview": ("design-preview.png", PNG_BYTES, "image/png")},
headers=orders_auth_header(),
)
assert response.status_code == 400
assert not list(product_archive_client.archive_root.glob("prod_*"))
def test_archive_version_rejects_non_success_source_job(product_archive_client, prepared_wordcloud_job, monkeypatch):
monkeypatch.setattr(
service_app,
"_resolve_job_status",
lambda job_id: SimpleNamespace(status="running", artifacts={"db": str(prepared_wordcloud_job.db_path)}),
)
product = create_product(product_archive_client)
response = product_archive_client.post(
f"/api/products/{product['product_id']}/versions",
data={"document_json": json.dumps(prepared_wordcloud_job.document)},
files={"preview": ("design-preview.png", PNG_BYTES, "image/png")},
headers=orders_auth_header(),
)
assert response.status_code == 400
def test_archive_version_ignores_hidden_wordclouds(product_archive_client, prepared_wordcloud_job):
document = {
"layers": [{"id": "hidden", "visible": False}],
"elements": [{"type": "sticker", "assetId": prepared_wordcloud_job.asset_id, "layerId": "hidden"}],
}
product = create_product(product_archive_client)
response = product_archive_client.post(
f"/api/products/{product['product_id']}/versions",
data={"document_json": json.dumps(document)},
files={"preview": ("design-preview.png", PNG_BYTES, "image/png")},
headers=orders_auth_header(),
)
assert response.status_code == 201
assert response.json()["wordcloud_count"] == 0
def test_archive_version_handles_document_without_wordcloud_sources(product_archive_client):
product = create_product(product_archive_client)
response = product_archive_client.post(
f"/api/products/{product['product_id']}/versions",
data={"document_json": json.dumps({"elements": []})},
files={"preview": ("design-preview.png", PNG_BYTES, "image/png")},
headers=orders_auth_header(),
)
assert response.status_code == 201
assert response.json()["wordcloud_count"] == 0
def test_archive_version_rejects_invalid_png_bytes_with_png_mime(product_archive_client):
product = create_product(product_archive_client)
response = archive_product_version(product_archive_client, product["product_id"], {"elements": []}, b"\x89PNG\r\n\x1a\nnot-an-image")
assert response.status_code == 400
def test_archive_version_persists_each_multi_source_provenance(product_archive_client, prepared_wordcloud_job, monkeypatch):
second_db = prepared_wordcloud_job.workspace / "output" / "second.sqlite"
with sqlite3.connect(second_db) as connection:
connection.execute("CREATE TABLE word_locations (id INTEGER PRIMARY KEY, name TEXT)")
second_asset = "asset_second"
second_dir = service_app._asset_dir(second_asset)
second_dir.mkdir(parents=True)
(second_dir / "meta.json").write_text(json.dumps({"type": "wordcloud", "job_id": "job-second"}), encoding="utf-8")
monkeypatch.setattr(
service_app, "_resolve_job_status",
lambda job_id: SimpleNamespace(status="success", artifacts={"db": str(prepared_wordcloud_job.db_path if job_id == "job-success" else second_db)}),
)
product = create_product(product_archive_client)
response = archive_product_version(product_archive_client, product["product_id"], {
"elements": [
{"type": "sticker", "assetId": prepared_wordcloud_job.asset_id},
{"type": "sticker", "assetId": second_asset},
]
})
assert response.status_code == 201
archives = {item["source_job_id"]: item for item in response.json()["wordcloud_archives"]}
assert archives["job-success"]["source_asset_id"] == prepared_wordcloud_job.asset_id
assert archives["job-second"]["source_asset_id"] == second_asset
assert archives["job-success"]["db_checksum"] == hashlib.sha256(Path(archives["job-success"]["db_path"]).read_bytes()).hexdigest()
assert archives["job-second"]["db_checksum"] == hashlib.sha256(Path(archives["job-second"]["db_path"]).read_bytes()).hexdigest()
def test_later_archive_version_becomes_current_cover_in_product_contract(product_archive_client):
product = create_product(product_archive_client)
first = archive_product_version(product_archive_client, product["product_id"], {"elements": []})
first_cover = product_archive_client.get(f"/api/products/{product['product_id']}", headers=orders_auth_header()).json()["cover_image_id"]
second = archive_product_version(product_archive_client, product["product_id"], {"elements": []})
detail = product_archive_client.get(f"/api/products/{product['product_id']}", headers=orders_auth_header()).json()
listed = product_archive_client.get("/api/products", headers=orders_auth_header()).json()[0]
assert first.status_code == second.status_code == 201
assert detail["cover_image_id"] == listed["cover_image_id"]
assert detail["cover_image_id"] != first_cover
def test_product_detail_contract_includes_images_and_archive_versions(
product_archive_client, prepared_wordcloud_job
):
product = create_product(product_archive_client)
archived = archive_product_version(
product_archive_client, product["product_id"], prepared_wordcloud_job.document
)
detail = product_archive_client.get(
f"/api/products/{product['product_id']}", headers=orders_auth_header()
).json()
assert archived.status_code == 201
assert detail["product_id"] == product["product_id"]
assert len(detail["images"]) == 1
assert detail["images"][0]["image_type"] == "design_preview"
assert detail["images"][0]["is_cover"] is True
assert len(detail["versions"]) == 1
version = detail["versions"][0]
preview_image = detail["images"][0]
expected_image_url = f"/api/products/{product['product_id']}/images/{preview_image['image_id']}"
assert version["version_id"] == archived.json()["version_id"]
assert version["design_preview_image_id"] == preview_image["image_id"]
assert version["design_preview_url"] == expected_image_url
assert version["wordcloud_count"] == 1
assert version["wordcloud_archives"][0]["source_job_id"] == "job-success"
assert Path(version["design_preview_path"]).name == "design-preview.png"
def test_product_detail_image_contract_is_authenticated_and_path_safe(
product_archive_client, prepared_wordcloud_job, tmp_path
):
product = create_product(product_archive_client)
archive_product_version(
product_archive_client, product["product_id"], prepared_wordcloud_job.document
)
detail = product_archive_client.get(
f"/api/products/{product['product_id']}", headers=orders_auth_header()
).json()
image = detail["images"][0]
assert image["image_url"] == f"/api/products/{product['product_id']}/images/{image['image_id']}"
served = product_archive_client.get(
f"/api/products/{product['product_id']}/images/{image['image_id']}",
headers=orders_auth_header(),
)
assert served.status_code == 200
assert served.headers["content-type"] == "image/png"
assert served.content == product_archive_client.archive_root.joinpath(
product["product_id"], image["version_id"], "design-preview.png"
).read_bytes()
outside_path = tmp_path / "outside.png"
outside_path.write_bytes(PNG_BYTES)
product_archive_client.product_store._execute(
"UPDATE product_images SET image_path = ? WHERE image_id = ?",
(str(outside_path), image["image_id"]),
)
unsafe = product_archive_client.get(
f"/api/products/{product['product_id']}/images/{image['image_id']}",
headers=orders_auth_header(),
)
assert unsafe.status_code == 404
unknown = product_archive_client.get(
f"/api/products/{product['product_id']}/images/img_unknown",
headers=orders_auth_header(),
)
assert unknown.status_code == 404
unauthenticated_detail = product_archive_client.get(
f"/api/products/{product['product_id']}"
)
unauthenticated_image = product_archive_client.get(
f"/api/products/{product['product_id']}/images/{image['image_id']}"
)
assert unauthenticated_detail.status_code == unauthenticated_image.status_code == 403
def test_product_routes_require_orders_auth(product_archive_client):
product = create_product(product_archive_client)
requests = [
product_archive_client.post("/api/products", json={"name": "未授权", "source": "manual"}),
product_archive_client.get("/api/products"),
product_archive_client.get(f"/api/products/{product['product_id']}"),
product_archive_client.post(f"/api/products/{product['product_id']}/versions"),
product_archive_client.delete(f"/api/products/{product['product_id']}"),
product_archive_client.post(f"/api/products/{product['product_id']}/restore"),
]
assert [response.status_code for response in requests] == [403] * 6
def test_archive_version_rejects_failed_cleanup_product_and_status_is_visible(
product_archive_client,
):
product = create_product(product_archive_client)
deleted = product_archive_client.delete(
f"/api/products/{product['product_id']}", headers=orders_auth_header()
).json()
store = product_archive_client.product_store
purge_after = store.get_product(deleted["product_id"]).purge_after
store.claim_product_purge(deleted["product_id"], purge_after)
store.fail_product_purge(deleted["product_id"])
listed = product_archive_client.get(
f"/api/products/{deleted['product_id']}", headers=orders_auth_header()
).json()
assert listed["status"] == "failed_cleanup"
response = archive_product_version(
product_archive_client, deleted["product_id"], {"elements": []}
)
assert response.status_code == 409
restored = product_archive_client.post(
f"/api/products/{deleted['product_id']}/restore", headers=orders_auth_header()
).json()
assert restored["status"] == "active"
def test_delete_rejects_failed_cleanup_product_until_restore(product_archive_client):
product = create_product(product_archive_client)
deleted = product_archive_client.delete(
f"/api/products/{product['product_id']}", headers=orders_auth_header()
).json()
store = product_archive_client.product_store
purge_after = store.get_product(deleted["product_id"]).purge_after
store.claim_product_purge(deleted["product_id"], purge_after)
store.fail_product_purge(deleted["product_id"], now=purge_after)
rejected = product_archive_client.delete(
f"/api/products/{deleted['product_id']}", headers=orders_auth_header()
)
assert rejected.status_code == 409
assert store.get_product(deleted["product_id"]).status == "failed_cleanup"
restored = product_archive_client.post(
f"/api/products/{deleted['product_id']}/restore", headers=orders_auth_header()
)
assert restored.status_code == 200
assert restored.json()["status"] == "active"
soft_deleted = product_archive_client.delete(
f"/api/products/{deleted['product_id']}", headers=orders_auth_header()
)
assert soft_deleted.status_code == 200
assert soft_deleted.json()["status"] == "pending_cleanup"
def test_only_visible_inserted_wordcloud_is_retained_after_30_day_cleanup(
product_archive_client, prepared_wordcloud_job, tmp_path, monkeypatch
):
archived_at = datetime.now(timezone.utc)
created_at = archived_at - timedelta(days=31)
assets_dir = tmp_path / "e2e-assets"
assets_dir.mkdir(parents=True)
monkeypatch.setattr(service_app, "ASSETS_DIR", assets_dir)
source_specs = {
"job-visible": "visible",
"job-hidden": "hidden",
"job-detached": None,
}
source_assets: dict[str, dict[str, str]] = {}
source_paths: dict[str, Path] = {}
metadata_store = MetadataStore(tmp_path / "e2e-metadata" / "app.db")
storage = Storage(tmp_path / "e2e-workspace")
for job_id, placement in source_specs.items():
asset_id = f"asset_{job_id.replace('-', '_')}"
asset_dir = assets_dir / asset_id[:2] / asset_id
asset_dir.mkdir(parents=True)
(asset_dir / "meta.json").write_text(
json.dumps({"asset_id": asset_id, "type": "wordcloud", "job_id": job_id}),
encoding="utf-8",
)
source_assets[job_id] = asset_id
source_paths[job_id] = storage.job_root(job_id) / "output" / "word_locations.sqlite"
source_paths[job_id].parent.mkdir(parents=True, exist_ok=True)
with sqlite3.connect(source_paths[job_id]) as connection:
connection.execute("CREATE TABLE word_locations (id INTEGER PRIMARY KEY, name TEXT)")
connection.execute("INSERT INTO word_locations (name) VALUES (?)", (job_id,))
metadata_store.upsert_job(
JobStatus(
job_id=job_id,
status="success",
stage="done",
progress_percent=100,
message="done",
artifacts={"db": str(source_paths[job_id])},
created_at=created_at,
updated_at=created_at,
)
)
def resolve_job(job_id: str):
return SimpleNamespace(
status="success",
artifacts={"db": str(source_paths[job_id])},
)
monkeypatch.setattr(service_app, "_resolve_job_status", resolve_job)
product = create_product(product_archive_client)
response = archive_product_version(product_archive_client, product["product_id"], {
"layers": [
{"id": "shown", "visible": True},
{"id": "hidden", "visible": False},
],
"elements": [
{"type": "sticker", "assetId": source_assets["job-visible"], "layerId": "shown"},
{"type": "sticker", "assetId": source_assets["job-hidden"], "layerId": "hidden"},
],
})
assert response.status_code == 201
archived = response.json()["wordcloud_archives"]
assert [item["source_job_id"] for item in archived] == ["job-visible"]
visible_snapshot = Path(archived[0]["db_path"])
assert visible_snapshot.exists()
cleanup = CleanupService(storage, metadata_store, product_archive_client.product_store)
report = cleanup.apply(now=archived_at + timedelta(days=31))
assert "job-visible" not in report.deleted_job_ids
assert {"job-hidden", "job-detached"} <= set(report.deleted_job_ids)
assert not storage.job_root("job-hidden").exists()
assert not storage.job_root("job-detached").exists()
assert storage.job_root("job-visible").exists()
assert visible_snapshot.exists()
def test_archive_failure_during_final_move_removes_staging_files(product_archive_client, prepared_wordcloud_job, monkeypatch):
product = create_product(product_archive_client)
service = service_app._product_archive_service()
monkeypatch.setattr(service, "_move_staging", lambda *_: (_ for _ in ()).throw(RuntimeError("move failed")))
with pytest.raises(RuntimeError, match="move failed"):
service.archive_version(product["product_id"], prepared_wordcloud_job.document, PNG_BYTES)
assert not list((product_archive_client.archive_root / product["product_id"]).iterdir())
def test_archive_failure_during_metadata_transaction_removes_final_files(product_archive_client, prepared_wordcloud_job, monkeypatch):
product = create_product(product_archive_client)
monkeypatch.setattr(product_archive_client.product_store, "_before_archive_commit", lambda: (_ for _ in ()).throw(RuntimeError("metadata failed")))
with pytest.raises(RuntimeError, match="metadata failed"):
service_app._product_archive_service().archive_version(product["product_id"], prepared_wordcloud_job.document, PNG_BYTES)
assert not list((product_archive_client.archive_root / product["product_id"]).iterdir())
assert product_archive_client.product_store._fetchall("SELECT * FROM product_versions") == []
+252
View File
@@ -0,0 +1,252 @@
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
BACKEND_DIR = Path(__file__).resolve().parents[1]
if str(BACKEND_DIR) not in sys.path:
sys.path.insert(0, str(BACKEND_DIR))
from service.product_archive_store import ( # noqa: E402
ProductArchiveStore,
ProductPurgeInProgressError,
)
from service.schemas import ProductInput # noqa: E402
NOW = datetime(2026, 9, 12, 8, 0, tzinfo=timezone.utc)
def test_external_product_id_updates_one_product(tmp_path):
store = ProductArchiveStore(tmp_path / "service_products")
first = store.upsert_product(
ProductInput("external", "sku-42", "笔盒", "B-42", "黄色")
)
second = store.upsert_product(
ProductInput("external", "sku-42", "笔盒新版", "B-42", "黄色")
)
assert first.product_id == second.product_id
assert store.list_products(query="新版")[0].name == "笔盒新版"
def test_manual_product_has_internal_id_and_soft_delete_window(tmp_path):
store = ProductArchiveStore(tmp_path / "service_products")
product = store.upsert_product(
ProductInput("manual", None, "校长笔盒", "", "166 × 47 mm")
)
store.mark_pending_cleanup(product.product_id, now=NOW)
assert product.product_id.startswith("prod_")
assert store.get_product(product.product_id).status == "pending_cleanup"
assert store.get_product(product.product_id).purge_after == NOW + timedelta(days=30)
@pytest.mark.parametrize(
"product_input",
[
ProductInput("manual", None, "", "B-42", "黄色"),
ProductInput("external", None, "笔盒", "B-42", "黄色"),
ProductInput("external", "", "笔盒", "B-42", "黄色"),
],
)
def test_upsert_rejects_invalid_product_inputs(tmp_path, product_input):
store = ProductArchiveStore(tmp_path / "service_products")
with pytest.raises(ValueError):
store.upsert_product(product_input)
def test_unknown_product_operations_are_rejected(tmp_path):
store = ProductArchiveStore(tmp_path / "service_products")
with pytest.raises(ValueError):
store.get_product("prod_missing")
with pytest.raises(ValueError):
store.mark_pending_cleanup("prod_missing", now=NOW)
def test_purge_requires_due_pending_cleanup_and_lists_due_products(tmp_path):
store = ProductArchiveStore(tmp_path / "service_products")
product = store.upsert_product(ProductInput("manual", None, "待归档笔盒"))
with pytest.raises(ValueError):
store.purge_product(product.product_id, now=NOW)
store.mark_pending_cleanup(product.product_id, now=NOW)
future = NOW + timedelta(days=29)
with pytest.raises(ValueError):
store.purge_product(product.product_id, now=future)
assert store.due_product_cleanups(now=future) == []
due = NOW + timedelta(days=30)
assert [record.product_id for record in store.due_product_cleanups(now=due)] == [
product.product_id
]
assert store.purge_product(product.product_id, now=due).status == "purged"
def test_restore_removes_product_from_due_cleanup_selection(tmp_path):
store = ProductArchiveStore(tmp_path / "service_products")
product = store.upsert_product(ProductInput("manual", None, "恢复笔盒"))
store.mark_pending_cleanup(product.product_id, now=NOW)
restored = store.restore_product(product.product_id, now=NOW + timedelta(days=1))
assert restored.status == "active"
assert restored.purge_after is None
assert store.due_product_cleanups(now=NOW + timedelta(days=31)) == []
def test_restore_rejects_product_after_purge_has_deleted_its_files(tmp_path):
store = ProductArchiveStore(tmp_path / "service_products")
product = store.upsert_product(ProductInput("manual", None, "已清理笔盒"))
store.mark_pending_cleanup(product.product_id, now=NOW)
store.purge_product(product.product_id, now=NOW + timedelta(days=30))
with pytest.raises(ValueError, match="purged"):
store.restore_product(product.product_id, now=NOW + timedelta(days=31))
assert store.get_product(product.product_id).status == "purged"
def test_fail_product_purge_requires_an_active_claim_and_preserves_purge_after(tmp_path):
store = ProductArchiveStore(tmp_path / "service_products")
product = store.upsert_product(ProductInput("manual", None, "失败清理笔盒"))
store.mark_pending_cleanup(product.product_id, now=NOW)
with pytest.raises(ValueError, match="claim"):
store.fail_product_purge(product.product_id)
due = NOW + timedelta(days=30)
store.claim_product_purge(product.product_id, due)
failed = store.fail_product_purge(product.product_id, now=due + timedelta(hours=1))
assert failed.status == "failed_cleanup"
assert failed.purge_after == due
assert failed.updated_at == due + timedelta(hours=1)
cleanup = store._fetchone(
"SELECT status FROM cleanup_records WHERE product_id = ?", (product.product_id,)
)["status"]
assert cleanup == "failed_cleanup"
assert store.due_product_cleanups(due) == []
with pytest.raises(ValueError):
store.purge_product(product.product_id, now=due + timedelta(days=1))
def test_reconcile_stalled_purges_quarantines_only_claims_older_than_timeout(tmp_path):
store = ProductArchiveStore(tmp_path / "service_products")
stale_product = store.upsert_product(ProductInput("manual", None, "卡住清理笔盒"))
fresh_product = store.upsert_product(ProductInput("manual", None, "正常清理笔盒"))
for product in (stale_product, fresh_product):
store.mark_pending_cleanup(product.product_id, now=NOW)
store.claim_product_purge(product.product_id, NOW + timedelta(days=30))
old_claim = (NOW - timedelta(hours=2)).isoformat()
store._execute(
"UPDATE cleanup_records SET claimed_at = ? WHERE product_id = ? AND status = 'purging'",
(old_claim, stale_product.product_id),
)
failed = store.reconcile_stalled_purges(now=NOW, timeout=timedelta(hours=1))
assert [item.product_id for item in failed] == [stale_product.product_id]
assert store.get_product(stale_product.product_id).status == "failed_cleanup"
assert store.get_product(fresh_product.product_id).status == "pending_cleanup"
with pytest.raises(ProductPurgeInProgressError):
store.restore_product(fresh_product.product_id, now=NOW + timedelta(seconds=1))
def test_mark_pending_cleanup_accepts_failed_product_and_rejects_purged(tmp_path):
store = ProductArchiveStore(tmp_path / "service_products")
product = store.upsert_product(ProductInput("manual", None, "重新排队笔盒"))
store.mark_pending_cleanup(product.product_id, now=NOW)
store.claim_product_purge(product.product_id, NOW + timedelta(days=30))
store.fail_product_purge(product.product_id)
requeued = store.mark_pending_cleanup(product.product_id, now=NOW + timedelta(days=1))
assert requeued.status == "pending_cleanup"
assert requeued.purge_after == NOW + timedelta(days=31)
purged_product = store.upsert_product(ProductInput("manual", None, "已删除笔盒"))
store.mark_pending_cleanup(purged_product.product_id, now=NOW)
store.purge_product(purged_product.product_id, now=NOW + timedelta(days=30))
with pytest.raises(ValueError, match="cannot"):
store.mark_pending_cleanup(
purged_product.product_id, now=NOW + timedelta(days=31)
)
def test_version_bound_operations_reject_unknown_or_mismatched_versions(tmp_path):
store = ProductArchiveStore(tmp_path / "service_products")
first = store.upsert_product(ProductInput("manual", None, "第一个笔盒"))
second = store.upsert_product(ProductInput("manual", None, "第二个笔盒"))
version = store.create_version(first.product_id)
with pytest.raises(ValueError):
store.add_image(first.product_id, "ver_missing")
with pytest.raises(ValueError):
store.add_wordcloud_archive(first.product_id, "ver_missing")
with pytest.raises(ValueError):
store.add_image(second.product_id, version.version_id)
with pytest.raises(ValueError):
store.add_wordcloud_archive(second.product_id, version.version_id)
def test_archive_write_is_atomic_and_replaces_the_current_cover(tmp_path):
store = ProductArchiveStore(tmp_path / "service_products")
product = store.upsert_product(ProductInput("manual", None, "笔盒"))
first, first_cover, first_archives = store.write_archive_version(
product_id=product.product_id,
version_id="ver_first",
metadata={},
preview_path="/archive/first/design-preview.png",
archives=[{
"archive_path": "/archive/first/word_locations.sqlite",
"source_job_id": "job-first",
"source_asset_id": "asset-first",
"db_checksum": "first-checksum",
}],
now=NOW,
)
second, second_cover, second_archives = store.write_archive_version(
product_id=product.product_id,
version_id="ver_second",
metadata={},
preview_path="/archive/second/design-preview.png",
archives=[],
now=NOW + timedelta(seconds=1),
)
assert first_cover.is_cover is True
assert first_archives[0].source_job_id == "job-first"
assert first_archives[0].source_asset_id == "asset-first"
assert first_archives[0].db_checksum == "first-checksum"
assert store.get_product(product.product_id).cover_image_id == second_cover.image_id
assert store._fetchone("SELECT is_cover FROM product_images WHERE image_id = ?", (first_cover.image_id,))["is_cover"] == 0
assert store._fetchone("SELECT is_cover FROM product_images WHERE image_id = ?", (second_cover.image_id,))["is_cover"] == 1
assert first.version_id != second.version_id
def test_archive_write_rolls_back_all_rows_when_the_transaction_fails(tmp_path, monkeypatch):
store = ProductArchiveStore(tmp_path / "service_products")
product = store.upsert_product(ProductInput("manual", None, "笔盒"))
monkeypatch.setattr(store, "_before_archive_commit", lambda: (_ for _ in ()).throw(RuntimeError("injected")))
with pytest.raises(RuntimeError, match="injected"):
store.write_archive_version(
product_id=product.product_id,
version_id="ver_failure",
metadata={},
preview_path="/archive/failure/design-preview.png",
archives=[],
now=NOW,
)
assert store._fetchall("SELECT * FROM product_versions") == []
assert store._fetchall("SELECT * FROM product_images") == []
assert store.get_product(product.product_id).cover_image_id is None
+9
View File
@@ -8,6 +8,7 @@ services:
ports:
- "8000:8000"
environment:
CLEANUP_APPLY_ENABLED: "${CLEANUP_APPLY_ENABLED:-false}"
AI_CONVERTER_URL: http://ai-converter:8090
volumes:
- wordcloud_workspace:/app/service_workspace
@@ -15,6 +16,9 @@ services:
- wordcloud_projects:/app/service_projects
- wordcloud_design_templates:/app/service_design_templates
- wordcloud_fonts:/app/service_fonts
- wordcloud_products:/app/service_products
- wordcloud_metadata:/app/service_metadata
- wordcloud_orders:/app/service_orders
restart: unless-stopped
depends_on:
ai-converter:
@@ -52,6 +56,8 @@ services:
build:
context: ./frontend
dockerfile: Dockerfile
args:
VITE_WORDCLOUD_BOARD_BASE_URL: ${VITE_WORDCLOUD_BOARD_BASE_URL:-http://114.55.99.6:47880}
container_name: wordcloud-frontend
ports:
- "3000:80"
@@ -66,3 +72,6 @@ volumes:
wordcloud_projects:
wordcloud_design_templates:
wordcloud_fonts:
wordcloud_products:
wordcloud_metadata:
wordcloud_orders:
+2 -1
View File
@@ -226,7 +226,8 @@ SSE 事件流。事件数据模型:
- 最大 SVG 大小:25 MB。
- 当前支持路径、`rect``circle``ellipse``line`、纯色填充/描边和仿射变换。
- 不支持图片、普通文字、SVG 图案/裁剪/透明效果时会返回 `422`,不会产生可能失真的 AI 文件
- 普通 SVG `text` 会先使用内置中文字体转换为轮廓路径,以保证中文在 AI 中可见;文本将不再是可编辑文字
- 不支持图片、SVG 图案/裁剪/透明效果时会返回 `422`,不会产生可能失真的 AI 文件。
## Templates
+2 -1
View File
@@ -78,7 +78,8 @@ TemplateHome(首页)
"导出总图 AI"先按同一画布模型生成 SVG,再提交到后端 `/api/exports/ai`,由 Docker Compose 内部的 `ai-converter` 转换为 Illustrator 5 兼容 `.ai` 文件。
- 支持路径、矩形、椭圆、圆、线条、纯色填充/描边和仿射变换。
- 图片贴纸、普通文字、透明度、SVG pattern/clipPath 等不保证保真的特性会明确失败,不会静默生成错误文件
- 普通文字会在服务端使用内置中文字体转换为轮廓路径,避免 Illustrator 的字体缺失或中文编码问题;导出后文字不再是可编辑文本
- 图片贴纸、透明度、SVG pattern/clipPath 等不保证保真的特性会明确失败,不会静默生成错误文件。
- 词云本体由后端输出为路径,适合作为 AI 导出主场景。
## ZIP 导出
@@ -0,0 +1,634 @@
# 产品档案与词云归档 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 让画布中可见且实际插入的词云在“加入产品列表”时形成独立、持久的产品档案;未归档词云任务则在 30 天后安全清理。
**Architecture:** 后端新增持久化的产品主档 SQLite 和每个产品版本的词云位置库快照。归档 API 在服务端依据 CanvasDocument 与素材来源重新识别可见词云、复制并校验位置库;前端只提交画布和完整设计预览 PNG,不决定归档范围。临时任务和软删除产品由可测试的清理服务计算候选,生产启用前先以 dry-run 验证。
**Tech Stack:** FastAPI、Pydantic、SQLite、Python 标准库文件系统、React 18、TypeScript、Vite、Docker Compose。
**Spec:** `specs/product-archive/requirements.md` and `specs/product-archive/design.md`
## Global Constraints
- 仅当前**可见图层**中、以 `type=wordcloud` 且带 `job_id` 素材元数据可追溯的贴纸,才是可归档词云来源。
-`source_job_id` 去重;同一词云出现多次仍只创建一份数据库快照。
- `job_id` 仅用于追溯,产品列表默认不得显示完整 ID;外部同步以 `(source, external_product_id)` 幂等合并。
- 原始任务位置库是临时工作区产物;产品档案必须复制出独立快照,不能依赖原始任务目录。
- 临时任务保留 30 天,清理前第 23 天在管理界面提示;产品删除/解除关联进入 30 天可恢复期;已归档产品不自动删除。
- 首期封面只保存完整设计预览 PNG,但数据模型必须支持 `design_preview``reality_photo``external_product_image` 三种图片来源。
- 产品档案、应用元数据和订单数据必须使用 Docker 命名卷持久化;第一次启用物理清理前必须先运行 dry-run。
- 新增产品、归档、查找和清理接口复用生产订单管理身份;任何归档范围由后端重新计算。
- 保留既有用户的未提交改动。每次提交只 `git add` 本任务列出的路径。
---
## File Structure
| 路径 | 职责 |
|---|---|
| `backend/service/schemas.py` | 产品、版本、图片、归档和清理 API 的 Pydantic 契约 |
| `backend/service/product_archive_store.py` | 产品元数据 SQLite、软删除状态和档案文件目录的低层持久化 |
| `backend/service/product_archive.py` | 从 CanvasDocument 识别可归档词云来源、验证位置库、原子快照拷贝 |
| `backend/service/product_archive_service.py` | 组合主档、扫描器、素材和任务状态,提供一次归档事务 |
| `backend/service/cleanup_service.py` | 临时任务和已删除产品的 dry-run、提醒和物理清理策略 |
| `backend/service/app.py` | 初始化服务、产品 API、权限和维护路由 |
| `backend/tests/test_product_archive_store.py` | 主档、版本、外部产品幂等和软删除测试 |
| `backend/tests/test_product_archive.py` | 图层/素材识别、数据库快照、归档 API 测试 |
| `backend/tests/test_cleanup_service.py` | 30 天规则、档案保护、恢复和 dry-run 测试 |
| `frontend/src/lib/productArchive.ts` | 产品 API 类型与 fetch 封装 |
| `frontend/src/lib/designPreview.ts` | 由完整可见 CanvasDocument 生成设计预览 PNG Blob |
| `frontend/src/components/ProductArchiveDialog.tsx` | 画布中的产品选择/新建/归档确认交互 |
| `frontend/src/pages/ProductArchivePage.tsx` | 产品列表、详情、封面与版本摘要 |
| `frontend/src/pages/CanvasStudio.tsx` | “加入产品列表”入口、对话框和成功状态 |
| `frontend/src/App.tsx``frontend/src/pages/TemplateHome.tsx` | 产品档案页面路由与入口 |
| `frontend/src/styles.css` | 产品归档弹窗、列表和状态标签;沿用现有设计 token |
| `frontend/tests/product-archive-ui.test.mjs` | 前端入口、可见提示和预览调用回归测试 |
| `docker-compose.yml` | 产品、元数据、订单持久化卷 |
| `backend/docs/product-archive-runbook.md` | 同步、dry-run、启用物理清理与恢复操作说明 |
---
### Task 1: 建立产品档案领域契约与持久化主档
**Files:**
- Create: `backend/service/product_archive_store.py`
- Modify: `backend/service/schemas.py`
- Test: `backend/tests/test_product_archive_store.py`
**Interfaces:**
- Produces `ProductInput(source, external_product_id, name, sku, specification)``ProductRecord``ProductVersionRecord``ProductImageRecord``ProductWordcloudArchiveRecord`
- Produces `ProductArchiveStore(root: Path)` with `upsert_product``create_version``add_image``add_wordcloud_archive``get_product``list_products``mark_pending_cleanup``restore_product``due_product_cleanups``purge_product`
- `upsert_product` creates `prod_<uuidhex>` for `manual`; `external` requires non-empty `external_product_id` and conflicts on `(source, external_product_id)`.
- [ ] **Step 1: Write the failing persistence and idempotency tests**
```python
def test_external_product_id_updates_one_product(tmp_path):
store = ProductArchiveStore(tmp_path / "service_products")
first = store.upsert_product(ProductInput("external", "sku-42", "笔盒", "B-42", "黄色"))
second = store.upsert_product(ProductInput("external", "sku-42", "笔盒新版", "B-42", "黄色"))
assert first.product_id == second.product_id
assert store.list_products(query="新版")[0].name == "笔盒新版"
def test_manual_product_has_internal_id_and_soft_delete_window(tmp_path):
store = ProductArchiveStore(tmp_path / "service_products")
product = store.upsert_product(ProductInput("manual", None, "校长笔盒", "", "166 × 47 mm"))
store.mark_pending_cleanup(product.product_id, now=NOW)
assert product.product_id.startswith("prod_")
assert store.get_product(product.product_id).status == "pending_cleanup"
assert store.get_product(product.product_id).purge_after == NOW + timedelta(days=30)
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd backend && pytest tests/test_product_archive_store.py -q`
Expected: FAIL because `product_archive_store` and its contracts do not exist.
- [ ] **Step 3: Write the minimal store and schema**
Add Pydantic response/request models in `schemas.py`. Create SQLite tables `products`, `product_versions`, `product_images`, `product_wordcloud_archives`, and `cleanup_records`, with indexes on `name`, `(source, external_product_id)`, `product_id`, and `purge_after`. Configure connections with WAL, 5-second busy timeout and `sqlite3.Row`, matching `MetadataStore`.
```python
def mark_pending_cleanup(self, product_id: str, now: datetime) -> ProductRecord:
purge_after = now + timedelta(days=30)
self._execute(
"UPDATE products SET status = ?, purge_after = ?, updated_at = ? WHERE product_id = ?",
("pending_cleanup", purge_after.isoformat(), now.isoformat(), product_id),
)
return self.get_product(product_id)
```
Reject blank names, external inputs without ID, unknown IDs, and archive rows whose version does not exist. Do not physically delete a product in this task.
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd backend && pytest tests/test_product_archive_store.py -q`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add backend/service/schemas.py backend/service/product_archive_store.py backend/tests/test_product_archive_store.py
git commit -m "feat: add product archive metadata store"
```
### Task 2: 识别可见画布中的词云来源
**Files:**
- Create: `backend/service/product_archive.py`
- Test: `backend/tests/test_product_archive.py`
**Interfaces:**
- Produces immutable `WordcloudSource(asset_id: str, source_job_id: str)`.
- Produces `find_visible_wordcloud_sources(document, load_asset_meta) -> list[WordcloudSource]`.
- Produces `validate_word_locations_db(db_path: Path) -> None` and `copy_word_locations_snapshot(source: Path, destination: Path) -> str`; the return is a SHA-256 checksum.
- [ ] **Step 1: Write failing visibility, provenance and deduplication tests**
```python
def test_scanner_keeps_only_visible_wordcloud_assets_and_deduplicates():
document = {
"layers": [{"id": "shown", "visible": True}, {"id": "hidden", "visible": False}],
"elements": [
{"type": "sticker", "assetId": "wc-a", "layerId": "shown"},
{"type": "sticker", "assetId": "wc-a", "layerId": "shown"},
{"type": "sticker", "assetId": "wc-b", "layerId": "hidden"},
{"type": "sticker", "assetId": "photo", "layerId": "shown"},
],
}
assets = {
"wc-a": {"type": "wordcloud", "job_id": "a" * 32},
"wc-b": {"type": "wordcloud", "job_id": "b" * 32},
"photo": {"type": "upload", "job_id": ""},
}
assert find_visible_wordcloud_sources(document, assets.__getitem__) == [
WordcloudSource("wc-a", "a" * 32)
]
```
Add tests for no layer list (visible by default), absent elements (not returned), missing `job_id` (not returned), and a non-SQLite source file (raises `ValueError` and leaves no destination file).
- [ ] **Step 2: Run test to verify it fails**
Run: `cd backend && pytest tests/test_product_archive.py -q`
Expected: FAIL because the scanner and snapshot helpers do not exist.
- [ ] **Step 3: Write the minimal scanner and atomic copy**
```python
def find_visible_wordcloud_sources(document, load_asset_meta):
visible = {
str(layer.get("id")): layer.get("visible") is not False
for layer in document.get("layers") or []
if isinstance(layer, dict)
}
seen, result = set(), []
for element in document.get("elements") or []:
if not isinstance(element, dict) or element.get("type") != "sticker":
continue
if visible and visible.get(str(element.get("layerId")), True) is False:
continue
asset_id = str(element.get("assetId") or "")
meta = load_asset_meta(asset_id)
job_id = str(meta.get("job_id") or "")
if meta.get("type") == "wordcloud" and job_id and job_id not in seen:
seen.add(job_id)
result.append(WordcloudSource(asset_id, job_id))
return result
```
Validate that `word_locations` exists with `sqlite3`. Copy through `<destination>.tmp`, calculate SHA-256 in chunks, reopen the temporary copy, then atomically `replace()` it.
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd backend && pytest tests/test_product_archive.py -q`
Expected: PASS, including failed-copy cleanup.
- [ ] **Step 5: Commit**
```bash
git add backend/service/product_archive.py backend/tests/test_product_archive.py
git commit -m "feat: detect visible wordcloud archive sources"
```
### Task 3: 实现产品版本归档事务和 FastAPI 路由
**Files:**
- Create: `backend/service/product_archive_service.py`
- Modify: `backend/service/app.py`
- Modify: `backend/service/schemas.py`
- Modify: `backend/tests/test_product_archive.py`
**Interfaces:**
- Produces `ProductArchiveService(store, storage, load_asset_meta, resolve_job_status)`.
- Produces `archive_version(product_id, document, preview_bytes, now) -> ProductVersionRecord`.
- Adds `POST /api/products`, `GET /api/products`, `GET /api/products/{product_id}`, `POST /api/products/{product_id}/versions`, `DELETE /api/products/{product_id}`, `POST /api/products/{product_id}/restore`.
- Version creation accepts multipart `document_json` and `preview` (`image/png`).
- [ ] **Step 1: Write failing service and API tests**
```python
def test_archive_version_copies_db_after_source_workspace_is_removed(client, prepared_wordcloud_job):
product = client.post("/api/products", json={"name": "笔盒", "source": "manual"}).json()
response = client.post(
f"/api/products/{product['product_id']}/versions",
data={"document_json": json.dumps(prepared_wordcloud_job.document)},
files={"preview": ("design-preview.png", PNG_BYTES, "image/png")},
headers=orders_auth_header(),
)
assert response.status_code == 201
archive = response.json()["wordcloud_archives"][0]
shutil.rmtree(prepared_wordcloud_job.workspace)
assert Path(archive["db_path"]).exists()
```
Add failures for preview MIME mismatch, unavailable source DB, non-success source job, hidden-only wordcloud, and zero-source document returning `wordcloud_count == 0`.
- [ ] **Step 2: Run test to verify it fails**
Run: `cd backend && pytest tests/test_product_archive.py -q`
Expected: FAIL because product service and routes do not exist.
- [ ] **Step 3: Write archive transaction and authenticated routes**
Save the preview as `design-preview.png` only after checking PNG magic bytes and decoding it with Pillow. Require source job status `success` and a current DB artifact before copying. Create the version, image row (`kind="design_preview"`, `is_cover=1`) and every archive row only after its file exists. On any error, remove the incomplete version directory and transaction rows.
Parse `document_json` with `json.loads`; call `_require_orders_auth(request)` for every product read/write route. Resolve asset metadata with `_read_asset_meta(_asset_dir(asset_id))`; never accept a client job ID. Return 201 for version creation, 400 for malformed JSON/file, 404 for absent product, and 409 for a pending-cleanup product.
- [ ] **Step 4: Run API and existing regression tests**
Run: `cd backend && pytest tests/test_product_archive.py tests/test_wcd_import.py -q`
Expected: PASS; WCD production remains independent from product archive creation.
- [ ] **Step 5: Commit**
```bash
git add backend/service/app.py backend/service/schemas.py backend/service/product_archive_service.py backend/tests/test_product_archive.py
git commit -m "feat: archive visible wordclouds into products"
```
### Task 4: 实现清理服务、dry-run 管理接口和持久化卷
**Files:**
- Create: `backend/service/cleanup_service.py`
- Modify: `backend/service/app.py`
- Modify: `backend/service/storage_metrics.py`
- Modify: `docker-compose.yml`
- Test: `backend/tests/test_cleanup_service.py`
**Interfaces:**
- Produces `CleanupService(storage, metadata_store, product_store)` with `preview(now)` and `apply(now)`.
- Produces `CleanupReport(temporary_jobs, pending_products, reclaimable_bytes, reminder_job_ids, deleted_job_ids, deleted_product_ids)`.
- Adds authenticated `GET /api/maintenance/cleanup-candidates` and `POST /api/maintenance/cleanup-run`; POST requires JSON `{"confirm": true}`.
- Uses `CLEANUP_APPLY_ENABLED`; false is dry-run-only, true allows physical deletion after rollout approval.
- [ ] **Step 1: Write failing retention and recovery tests**
```python
def test_cleanup_skips_archived_job_and_marks_23_day_job_for_reminder(tmp_path):
service = make_cleanup_service(tmp_path)
archived_job = create_success_job(service, age_days=31, archived=True)
remind_job = create_success_job(service, age_days=23, archived=False)
report = service.preview(now=NOW)
assert archived_job not in {item.job_id for item in report.temporary_jobs}
assert remind_job in report.reminder_job_ids
def test_apply_removes_only_due_unarchived_job_and_due_product(tmp_path):
service = make_cleanup_service(tmp_path)
due_job = create_success_job(service, age_days=30, archived=False)
product = create_pending_product(service, purge_after=NOW)
report = service.apply(now=NOW)
assert due_job in report.deleted_job_ids
assert not service.storage.job_root(due_job).exists()
assert product.product_id in report.deleted_product_ids
```
Add tests for `confirm=false`, product restore, missing folders, and rechecking product archive references before deletion.
- [ ] **Step 2: Run test to verify it fails**
Run: `cd backend && pytest tests/test_cleanup_service.py -q`
Expected: FAIL because `CleanupService` does not exist.
- [ ] **Step 3: Write deterministic preview/apply behavior**
A temporary candidate is a successful job containing a DB artifact whose metadata `created_at` is at least 30 days old and whose job ID is not present in `product_wordcloud_archives`. At 23 days add it to `reminder_job_ids`. `apply()` must rerun `preview()`, delete workspace through `storage.remove_job_dir`, then delete metadata. It must delete product files only after `purge_after <= now`; missing directories are idempotent success.
Update `storage_summary()` and `storage_metrics.py` to report archive-protected job IDs separately. Ordinary asset references only extend the temporary window; they are not permanent protection.
- [ ] **Step 4: Add durable volumes and guarded periodic execution**
Add these mounts and named volumes:
```yaml
- wordcloud_products:/app/service_products
- wordcloud_metadata:/app/service_metadata
- wordcloud_orders:/app/service_orders
```
Start a daemon scheduler that runs `preview()` once on application startup and every 24 hours. It calls `apply()` only when `CLEANUP_APPLY_ENABLED=true`. Logs may contain counts, job IDs and byte totals but no person names from location data.
- [ ] **Step 5: Run focused tests and Compose validation**
Run: `cd backend && pytest tests/test_cleanup_service.py tests/test_product_archive.py -q`
Run: `docker compose config`
Expected: PASS; Compose lists all three named mounts.
- [ ] **Step 6: Commit**
```bash
git add backend/service/cleanup_service.py backend/service/app.py backend/service/storage_metrics.py backend/tests/test_cleanup_service.py docker-compose.yml
git commit -m "feat: add product archive retention cleanup"
```
### Task 5: 添加前端产品 API 和完整设计预览生成器
**Files:**
- Create: `frontend/src/lib/productArchive.ts`
- Create: `frontend/src/lib/designPreview.ts`
- Modify: `frontend/src/types.ts`
- Test: `frontend/tests/product-archive-ui.test.mjs`
**Interfaces:**
- Produces `ProductSummary`, `ProductDetail`, `ProductVersion`, `ProductArchiveResult`, `createManualProduct`, `listProducts`, `archiveProductVersion`, `restoreProduct`, `deleteProduct`.
- Produces `createDesignPreviewBlob(document: CanvasDocument, stickers: Map<string, StickerAsset>): Promise<Blob>`.
- `createDesignPreviewBlob` uses `serializeDocument(document, stickers, { includeBackground: true })`, draws the resulting SVG into a canvas at document dimensions, and returns a PNG Blob.
- [ ] **Step 1: Write failing frontend source-level tests**
```javascript
test('design preview serializes the complete visible document before PNG upload', async () => {
const source = await readFile('frontend/src/lib/designPreview.ts', 'utf8');
assert.match(source, /serializeDocument\(document, stickers, \{ includeBackground: true \}\)/);
assert.match(source, /canvas\.toBlob/);
});
test('archive client submits document JSON and a PNG preview as multipart data', async () => {
const source = await readFile('frontend/src/lib/productArchive.ts', 'utf8');
assert.match(source, /form\.append\('document_json'/);
assert.match(source, /form\.append\('preview'/);
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `node --test frontend/tests/product-archive-ui.test.mjs`
Expected: FAIL because the new modules do not exist.
- [ ] **Step 3: Write the typed client and preview converter**
Use `apiUrl` and `ensureOk` from `frontend/src/lib/api.ts`. `archiveProductVersion` appends `JSON.stringify(document)` as `document_json` and a `File` named `design-preview.png` as `preview`; it never appends job IDs. Pass the current order-admin token via the existing Bearer-header convention.
Create the preview from the existing `serializeDocument` exporter, which already includes visible layers, text, shapes, rotations and embedded sticker assets. Load its SVG Blob into `Image`, draw once to `HTMLCanvasElement`, reject empty/non-PNG output, and revoke the object URL in success and failure paths.
- [ ] **Step 4: Run test and type-check build**
Run: `node --test frontend/tests/product-archive-ui.test.mjs`
Run: `cd frontend && npm run build`
Expected: PASS with no TypeScript Blob/File errors.
- [ ] **Step 5: Commit**
```bash
git add frontend/src/lib/productArchive.ts frontend/src/lib/designPreview.ts frontend/src/types.ts frontend/tests/product-archive-ui.test.mjs
git commit -m "feat: add product archive frontend client"
```
### Task 6: 在画布中加入产品归档确认流程
**Files:**
- Create: `frontend/src/components/ProductArchiveDialog.tsx`
- Modify: `frontend/src/pages/CanvasStudio.tsx`
- Modify: `frontend/src/components/Icons.tsx`
- Modify: `frontend/src/styles.css`
- Modify: `frontend/tests/product-archive-ui.test.mjs`
**Interfaces:**
- Produces `<ProductArchiveDialog documentModel stickers onArchived onClose />`.
- Consumes `listProducts`, `createManualProduct`, `archiveProductVersion`, and `createDesignPreviewBlob`.
- Adds `IconArchive` to the existing local icon set.
- Calls `onArchived(result)` only after the backend archive response succeeds.
- [ ] **Step 1: Extend the failing UI source-level tests**
```javascript
test('canvas offers a product archive action and reports detected source count', async () => {
const canvas = await readFile('frontend/src/pages/CanvasStudio.tsx', 'utf8');
const dialog = await readFile('frontend/src/components/ProductArchiveDialog.tsx', 'utf8');
assert.match(canvas, /加入产品列表/);
assert.match(dialog, /已检测到.*份画布词云/);
assert.match(dialog, /将作为产品封面/);
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `node --test frontend/tests/product-archive-ui.test.mjs`
Expected: FAIL because the dialog and action text do not exist.
- [ ] **Step 3: Write dialog states and exact user copy**
Use four explicit local states: `loadingProducts`, `creatingProduct`, `archiving`, and `error`. Let users search existing products by visible name/SKU or choose “新建产品”; manual creation requires name and accepts optional SKU/规格. The displayed source count is a preview only; server scanning remains authoritative.
Use this confirmation copy:
```text
已检测到 N 份画布词云,将全部归档。
当前完整设计将保存为产品预览图,后续可替换为实景图。
```
When zero sources are detected, use:
```text
当前画布未检测到可归档词云。可以建立产品,但该版本会标记为“无词云归档数据”。
```
- [ ] **Step 4: Wire CanvasStudio action and success state**
Place `加入产品列表` next to existing `添加词云` in the CanvasStudio navbar. On success close the dialog and show:
```text
已归档至产品《{name}》· {count} 份词云位置数据已长期保存
```
Do not show a raw product ID or job ID. Pass `normalizedDocument` to keep hidden-layer treatment identical to the exported preview.
- [ ] **Step 5: Add style using existing tokens**
Create a wide modal with left 4:3 contain preview and right product selector/metadata. Reuse `--bg-panel`, `--border`, `--accent`, `--success`, `--warn`, `--font-main`, and `--font-mono`. Add classes `product-status-archived`, `product-status-empty`, and `product-status-pending-cleanup`.
- [ ] **Step 6: Run test and build**
Run: `node --test frontend/tests/product-archive-ui.test.mjs`
Run: `cd frontend && npm run build`
Expected: PASS.
- [ ] **Step 7: Commit**
```bash
git add frontend/src/components/ProductArchiveDialog.tsx frontend/src/pages/CanvasStudio.tsx frontend/src/components/Icons.tsx frontend/src/styles.css frontend/tests/product-archive-ui.test.mjs
git commit -m "feat: add canvas product archive flow"
```
### Task 7: 提供产品档案列表、详情与路由入口
**Files:**
- Create: `frontend/src/pages/ProductArchivePage.tsx`
- Modify: `frontend/src/App.tsx`
- Modify: `frontend/src/pages/TemplateHome.tsx`
- Modify: `frontend/src/styles.css`
- Modify: `frontend/tests/product-archive-ui.test.mjs`
**Interfaces:**
- Produces `<ProductArchivePage themeMode systemTheme onThemeModeChange onOpenHome />`.
- Consumes `listProducts`, `getProduct`, `restoreProduct`, and `deleteProduct`.
- Adds `products` to `AppPage`, and a homepage action named `产品档案`.
- [ ] **Step 1: Write failing page and route tests**
```javascript
test('app routes to product archives and list keeps IDs out of primary cells', async () => {
const app = await readFile('frontend/src/App.tsx', 'utf8');
const page = await readFile('frontend/src/pages/ProductArchivePage.tsx', 'utf8');
assert.match(app, /'products'/);
assert.match(page, /产品档案/);
assert.match(page, /产品名称/);
assert.doesNotMatch(page, /<td>\{product\.product_id\}<\/td>/);
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `node --test frontend/tests/product-archive-ui.test.mjs`
Expected: FAIL because the page and route do not exist.
- [ ] **Step 3: Write product list and detail flow**
Each list row has a left `design_preview` thumbnail, center name plus optional SKU/规格, and right human-readable archive status/count. With no cover, render a neutral `暂无预览图` frame. Detail shows preview, version timestamp, wordcloud count, and a collapsed “系统信息” section with copyable product ID.
For `pending_cleanup`, render `待清理 · 将于 YYYY/MM/DD 删除` plus “恢复产品” and “立即删除”. The latter only sends soft delete; physical deletion remains CleanupService responsibility.
- [ ] **Step 4: Wire navigation and authorization**
Add `产品档案` to the home actions alongside `生产订单` and `查找`. Do not replace OrdersPage: WCD job status remains a separate production-task view. Product page uses the same stored order-admin token and existing login presentation when no token is available.
- [ ] **Step 5: Run test and build**
Run: `node --test frontend/tests/product-archive-ui.test.mjs`
Run: `cd frontend && npm run build`
Expected: PASS; primary row content is name/SKU/status, not raw ID.
- [ ] **Step 6: Commit**
```bash
git add frontend/src/pages/ProductArchivePage.tsx frontend/src/App.tsx frontend/src/pages/TemplateHome.tsx frontend/src/styles.css frontend/tests/product-archive-ui.test.mjs
git commit -m "feat: add product archive management page"
```
### Task 8: 完成运行手册、端到端验证与首次清理演练
**Files:**
- Create: `backend/docs/product-archive-runbook.md`
- Modify: `backend/tests/test_product_archive.py`
- Modify: `backend/tests/test_cleanup_service.py`
- Modify: `frontend/tests/product-archive-ui.test.mjs`
**Interfaces:**
- Documents product synchronization, candidate inspection, `CLEANUP_APPLY_ENABLED` rollout, soft-delete recovery and rollback.
- Produces no new runtime interface; verifies Tasks 17 integration.
- [ ] **Step 1: Add an end-to-end business-boundary test**
```python
def test_only_visible_inserted_wordcloud_is_retained_after_30_day_cleanup(client, archive_fixture):
product = create_manual_product(client, "最终产品")
archive_visible_wordcloud_and_hidden_wordcloud(client, product, archive_fixture)
report = run_cleanup_at(client, NOW + timedelta(days=31), confirm=True)
detail = client.get(f"/api/products/{product['product_id']}", headers=orders_auth_header()).json()
assert detail["versions"][0]["wordcloud_count"] == 1
assert archive_fixture.visible_snapshot.exists()
assert archive_fixture.hidden_source_job_id in report["deleted_job_ids"]
```
- [ ] **Step 2: Run end-to-end test**
Run: `cd backend && pytest tests/test_product_archive.py::test_only_visible_inserted_wordcloud_is_retained_after_30_day_cleanup -q`
Expected: PASS; fix the responsible task implementation if it exposes an integration gap.
- [ ] **Step 3: Write dry-run-first operator runbook**
Include these commands and their intent:
```bash
docker compose up -d --build
docker compose exec backend python -m service.storage_metrics --max-age-days 30
docker compose exec backend python -m service.storage_metrics --max-age-days 30 --apply
```
Document that `--apply` is permitted only after `CLEANUP_APPLY_ENABLED=true` and a dry-run candidate list has been reviewed. Document product recovery before `purge_after`.
- [ ] **Step 4: Run complete local verification**
Run: `cd backend && pytest -q`
Run: `node --test frontend/tests/*.test.mjs`
Run: `cd frontend && npm run build`
Run: `docker compose config`
Run: `docker compose up -d --build`
Run: `docker compose ps`
Expected: tests and frontend build PASS; Compose reports healthy services. Manually create a wordcloud, insert it into a visible layer, add it to a manual product, confirm preview/card rendering, hide a second wordcloud layer, and verify only the visible source is archived.
- [ ] **Step 5: Inspect a dry-run without deleting data**
Run: `docker compose exec backend python -m service.storage_metrics --max-age-days 30`
Expected: JSON shows `apply: false`; no workspace or product archive files are removed.
- [ ] **Step 6: Commit**
```bash
git add backend/docs/product-archive-runbook.md backend/tests/test_product_archive.py backend/tests/test_cleanup_service.py frontend/tests/product-archive-ui.test.mjs
git commit -m "docs: add product archive operations runbook"
```
## Self-Review
### Spec coverage
- Visible canvas-only scanning, hidden-layer exclusion, deleted/non-inserted exclusion and deduplication: Tasks 2 and 8.
- Product main record, stable external ID, manual products and name-first presentation: Tasks 1, 3 and 7.
- Independent word-location snapshots and source-job traceability: Task 3.
- Default full-design preview and future multi-source image model: Tasks 1, 3, 5, 6 and 7.
- 30-day temporary cleanup, day-23 reminder, product soft-delete grace period and dry-run: Tasks 4 and 8.
- Existing Docker persistence gap: Task 4.
- Admin authorization and server-side archive decisions: Tasks 3 and 7.
- Local Docker validation required by repository instructions: Task 8.
### Placeholder scan
The plan names every module, public interface, test file, command, persistent directory and cleanup state. It contains no deferred implementation markers or unspecified error-handling steps.
### Type consistency
- Task 1 defines ProductArchiveStore, ProductInput and response records used by Tasks 3 and 4.
- Task 2 defines WordcloudSource and snapshot helpers consumed by Task 3.
- Task 3 defines routes consumed by Tasks 5 and 7.
- Task 4 depends only on ProductArchiveStore archive-source lookup and existing Storage/MetadataStore.
- Task 5 defines the frontend client and preview creator consumed by Tasks 6 and 7.
+2
View File
@@ -9,6 +9,8 @@ COPY package.json package-lock.json* ./
RUN npm ci
COPY . .
ARG VITE_WORDCLOUD_BOARD_BASE_URL=http://114.55.99.6:47880
ENV VITE_WORDCLOUD_BOARD_BASE_URL=$VITE_WORDCLOUD_BOARD_BASE_URL
RUN npm run build
# ── Stage 2: Serve with Nginx ──────────────────────────────────────────────
+15 -1
View File
@@ -4,12 +4,13 @@ import CanvasStudio from './pages/CanvasStudio';
import FindPage from './pages/FindPage';
import HelpPage from './pages/HelpPage';
import OrdersPage from './pages/OrdersPage';
import ProductArchivePage from './pages/ProductArchivePage';
import TemplateHome from './pages/TemplateHome';
import TestWorkbench from './pages/TestWorkbench';
import { CanvasDocument, WordcloudReplaceSession, WordcloudStickerPayload } from './types';
import { createDefaultDocument } from './lib/canvasDocument';
type AppPage = 'home' | 'canvas' | 'wordcloud' | 'orders' | 'find' | 'help';
type AppPage = 'home' | 'canvas' | 'wordcloud' | 'orders' | 'products' | 'find' | 'help';
const getStoredTheme = (): ThemeMode => {
const stored = window.localStorage.getItem('wordcloud-theme');
@@ -129,6 +130,18 @@ export default function App() {
);
}
if (page === 'products') {
return (
<ProductArchivePage
themeMode={themeMode}
systemTheme={systemTheme}
onThemeModeChange={setThemeMode}
onOpenHome={() => setPage('home')}
onOpenHelp={openHelp}
/>
);
}
return (
<TemplateHome
themeMode={themeMode}
@@ -143,6 +156,7 @@ export default function App() {
setPage('canvas');
}}
onOpenOrders={() => setPage('orders')}
onOpenProducts={() => setPage('products')}
onOpenFind={() => setPage('find')}
onOpenHelp={openHelp}
/>
+35 -22
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { MutableRefObject, PointerEvent as ReactPointerEvent, ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { DockSide, FloatingPanelFrame, FloatingPanelLayout } from '../hooks/useFloatingPanels';
import { computeEdgeHighlight, SnapPreview, WorkspaceSize } from '../hooks/usePanelDocking';
import { IconClose } from './Icons';
@@ -205,6 +206,39 @@ export default function FloatingPanel({
// Exclude panels with zero geometry (e.g. a closed-but-persisted leftover)
&& (frames[otherId]?.width || 0) > 0 && (frames[otherId]?.height || 0) > 0));
// Snap geometry is relative to the workspace. Render guides at that same
// level rather than inside the moving panel, whose own position and overflow
// would otherwise offset and clip the preview.
const dockGuide = (highlight || (snapPreview && snapPreview.side)) && workspaceNodeRef?.current
? createPortal(
<>
{highlight && (
<div
className="floating-panel-edge-highlight"
style={{
left: highlight.x,
top: highlight.y,
width: highlight.width,
height: highlight.height,
}}
/>
)}
{snapPreview && snapPreview.side && (
<div
className="floating-panel-snap-guide"
style={{
left: snapPreview.x,
top: snapPreview.y,
width: snapPreview.width,
height: snapPreview.height,
}}
/>
)}
</>,
workspaceNodeRef.current,
)
: null;
return (
<section
className="floating-panel"
@@ -258,28 +292,7 @@ export default function FloatingPanel({
/>
);
})}
{highlight && (
<div
className="floating-panel-edge-highlight"
style={{
left: highlight.x,
top: highlight.y,
width: highlight.width,
height: highlight.height,
}}
/>
)}
{snapPreview && snapPreview.side && (
<div
className="floating-panel-snap-guide"
style={{
left: snapPreview.x,
top: snapPreview.y,
width: snapPreview.width,
height: snapPreview.height,
}}
/>
)}
{dockGuide}
</section>
);
}
+10
View File
@@ -277,3 +277,13 @@ export function IconCopy() {
</Icon>
);
}
export function IconArchive() {
return (
<Icon>
<rect x="3" y="3" width="10" height="10" rx="1" />
<path d="M5 3l3 3 1 0h2v3M10 10v3h-3" />
<path d="M5 13h6M3 13v-1M10 13v-1" />
</Icon>
);
}
@@ -0,0 +1,305 @@
import { useEffect, useMemo, useState } from 'react';
import type { MouseEvent as ReactMouseEvent } from 'react';
import {
CanvasDocument,
ProductArchiveResult,
ProductStatus,
ProductSummary,
StickerAsset,
} from '../types';
import { normalizeDocument } from '../lib/canvasDocument';
import {
archiveProductVersion,
createManualProduct,
listProducts,
} from '../lib/productArchive';
import { createDesignPreviewBlob } from '../lib/designPreview';
import { IconArchive, IconClose } from './Icons';
interface ProductArchiveDialogProps {
documentModel: CanvasDocument;
stickers: Map<string, StickerAsset>;
onArchived: (result: ProductArchiveResult, productName: string) => void;
onClose: () => void;
}
type ArchiveDialogState = 'loadingProducts' | 'creatingProduct' | 'archiving' | 'error';
const ORDERS_TOKEN_KEY = 'wordcloud-orders-token';
const ACTIVE_STATUS_CLASS: Record<ProductStatus, string> = {
active: 'product-status-archived',
pending_cleanup: 'product-status-pending-cleanup',
failed_cleanup: 'product-status-pending-cleanup',
purged: 'product-status-empty',
};
export default function ProductArchiveDialog({
documentModel,
stickers,
onArchived,
onClose,
}: ProductArchiveDialogProps) {
const document = normalizeDocument(documentModel);
const token = window.localStorage.getItem(ORDERS_TOKEN_KEY) || '';
const [state, setState] = useState<ArchiveDialogState>('loadingProducts');
const [products, setProducts] = useState<ProductSummary[]>([]);
const [query, setQuery] = useState('');
const [creating, setCreating] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [name, setName] = useState('');
const [sku, setSku] = useState('');
const [specification, setSpecification] = useState('');
const [errorText, setErrorText] = useState('');
const [previewUrl, setPreviewUrl] = useState('');
const [previewBlob, setPreviewBlob] = useState<Blob | null>(null);
const [visibleWordcloudCount, setVisibleWordcloudCount] = useState(0);
useEffect(() => {
let cancelled = false;
const objectUrls: string[] = [];
const loadPreview = async () => {
try {
const blob = await createDesignPreviewBlob(document, stickers);
const url = URL.createObjectURL(blob);
objectUrls.push(url);
if (!cancelled) {
setPreviewBlob(blob);
setPreviewUrl(url);
}
} catch {
// 预览是辅助信息;生成失败时显示占位帧,不阻塞归档。
}
};
const load = async () => {
try {
const items = await listProducts(token);
if (cancelled) return;
setProducts(items);
setVisibleWordcloudCount(countVisibleWordclouds(document, stickers));
setState('creatingProduct');
} catch (error) {
if (cancelled) return;
setErrorText(error instanceof Error ? error.message : '产品列表加载失败');
setState('error');
}
};
void loadPreview();
void load();
return () => {
cancelled = true;
objectUrls.forEach(url => URL.revokeObjectURL(url));
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const filteredProducts = useMemo(
() => products.filter(product => !query.trim() ||
product.name.includes(query.trim()) ||
product.sku.includes(query.trim())),
[products, query],
);
const selectedProduct = selectedId
? products.find(product => product.product_id === selectedId)
: null;
const confirmTarget = creating
? { name: name.trim(), sku: sku.trim(), specification: specification.trim() }
: selectedProduct;
const canConfirm = creating
? Boolean(name.trim())
: Boolean(confirmTarget);
const startNewProduct = () => {
setCreating(true);
setSelectedId(null);
setErrorText('');
setState('creatingProduct');
};
const chooseProduct = (product: ProductSummary) => {
setCreating(false);
setSelectedId(product.product_id);
setErrorText('');
setState('creatingProduct');
};
const submitArchive = async () => {
if (!token || !canConfirm || !confirmTarget) return;
if (!previewBlob) {
setErrorText('设计预览尚未生成,请稍候再试');
setState('error');
return;
}
setErrorText('');
setState('archiving');
try {
const product = creating
? await createManualProduct(token, { name, sku, specification })
: selectedProduct;
if (!product) throw new Error('归档产品未选定');
const result = await archiveProductVersion(token, product.product_id, document, previewBlob);
onArchived(result, product.name);
onClose();
} catch (error) {
setErrorText(error instanceof Error ? error.message : '归档请求失败');
setState('error');
}
};
return (
<div className="product-archive-backdrop" role="dialog" aria-modal="true" aria-label="加入产品列表" onClick={onClose}>
<div className="product-archive-dialog" onClick={(event: ReactMouseEvent) => event.stopPropagation()}>
<header className="product-archive-head">
<span className="product-archive-eyebrow"><IconArchive /> </span>
<h2 className="product-archive-title"></h2>
<button className="icon-btn product-archive-close" title="关闭" onClick={onClose}><IconClose /></button>
</header>
<main className="product-archive-grid">
<section className="product-archive-preview-panel">
<div className="product-archive-preview-frame">
{previewUrl
? <img className="product-archive-preview" src={previewUrl} alt="当前完整设计预览(将作为产品封面)" />
: <div className="product-archive-preview-placeholder"></div>}
</div>
<p className="product-archive-preview-note"></p>
<p className="product-archive-source-note"> {visibleWordcloudCount} </p>
</section>
<section className="product-archive-form-panel">
{state === 'loadingProducts' && (
<p className="product-archive-state product-archive-loading"></p>
)}
{state === 'error' && (
<div className="product-archive-error">
<p>{errorText}</p>
</div>
)}
{(state === 'creatingProduct' || state === 'archiving') && (
<div className="product-archive-form">
{visibleWordcloudCount === 0 && (
<p className="product-archive-empty-copy"></p>
)}
<label className="product-archive-search-row">
<span></span>
<input
className="product-archive-search"
type="search"
value={query}
onChange={event => setQuery(event.target.value)}
placeholder="按名称或 SKU 搜索"
/>
</label>
<div className="product-archive-list">
{filteredProducts.map(product => (
<button
key={product.product_id}
type="button"
className={`product-archive-option${selectedId === product.product_id && !creating ? ' selected' : ''}`}
onClick={() => chooseProduct(product)}
>
<span className="product-archive-option-name">{product.name}</span>
<span className="product-archive-option-sku">{product.sku}</span>
<span className={`product-archive-option-status ${statusClass(product.status)}`}>
{productStatusLabel(product.status)}
</span>
</button>
))}
{filteredProducts.length === 0 && (
<p className="product-archive-empty"></p>
)}
</div>
<div className="product-archive-create">
<span className="product-archive-create-title"></span>
<label>
<span></span>
<input
className="product-archive-input"
value={name}
onFocus={() => startNewProduct()}
onChange={event => setName(event.target.value)}
placeholder="产品名称(必填)"
/>
</label>
<div className="product-archive-meta-row">
<label>
<span>SKU</span>
<input
className="product-archive-input"
value={sku}
onChange={event => setSku(event.target.value)}
placeholder="可选"
/>
</label>
<label>
<span></span>
<input
className="product-archive-input"
value={specification}
onChange={event => setSpecification(event.target.value)}
placeholder="可选"
/>
</label>
</div>
<div className="product-archive-create-actions">
<button className="btn btn-secondary btn-sm" type="button" onClick={startNewProduct}></button>
{creating && <span className="product-archive-create-note"></span>}
</div>
</div>
<footer className="product-archive-actions">
<button className="btn btn-primary btn-sm btn-block" disabled={!canConfirm || !previewBlob || state === 'archiving'} onClick={submitArchive}>
{state === 'archiving' ? '正在归档…' : previewBlob ? '确认归档' : '正在生成预览…'}
</button>
<button className="btn btn-secondary btn-sm" type="button" onClick={onClose}></button>
</footer>
</div>
)}
</section>
</main>
</div>
</div>
);
}
function countVisibleWordclouds(document: CanvasDocument, stickers: Map<string, StickerAsset>): number {
const visibleLayers = new Set(
(document.layers || []).filter(layer => layer.visible !== false).map(layer => layer.id),
);
const seen = new Set<string>();
for (const element of document.elements) {
if (element.type !== 'sticker') continue;
if (!element.layerId || !visibleLayers.has(element.layerId)) continue;
const asset = stickers.get(element.assetId);
const sourceJobId = wordcloudSourceJobId(asset);
if (!sourceJobId) continue;
seen.add(sourceJobId);
}
return seen.size;
}
/**
* 画布贴纸元数据中可追溯的词云来源任务 ID。类型上仅定义基础字段,
* 运行时来自生成接口的素材携带 type=wordcloud 与 job_id。
*/
function wordcloudSourceJobId(asset: StickerAsset | undefined): string {
return asset?.jobId || '';
}
function statusClass(status: ProductStatus): string {
return ACTIVE_STATUS_CLASS[status] || 'product-status-empty';
}
function productStatusLabel(status: ProductStatus): string {
if (status === 'pending_cleanup' || status === 'failed_cleanup') return '待清理';
if (status === 'purged') return '已清理';
return '已归档词云数据';
}
+47
View File
@@ -115,6 +115,53 @@ export function normalizeDocument(input: CanvasDocument): CanvasDocument {
};
}
/**
* Return elements in their final paint order: the document background is
* always underneath, followed by canvas layers from bottom to top. Elements
* inside one layer retain their own relative order.
*/
export function orderCanvasElementsByLayer(documentModel: CanvasDocument): CanvasElement[] {
const document = normalizeDocument(documentModel);
const layerOrder = new Map((document.layers || []).map((layer, index) => [layer.id, index]));
return document.elements
.map((element, elementIndex) => ({
element,
elementIndex,
layerIndex: layerOrder.get(element.layerId || '') ?? -1,
}))
.sort((a, b) => a.layerIndex - b.layerIndex || a.elementIndex - b.elementIndex)
.map(({ element }) => element);
}
/**
* Changes the stacking order of an element without allowing it to cross a
* canvas-layer boundary. Canvas-layer ordering is managed separately.
*/
export function moveCanvasElementWithinLayer(
documentModel: CanvasDocument,
elementId: string,
direction: -1 | 1,
): CanvasDocument {
const document = normalizeDocument(documentModel);
const elementIndex = document.elements.findIndex(element => element.id === elementId);
if (elementIndex < 0) return document;
const layerId = document.elements[elementIndex].layerId;
const siblingIndexes = document.elements
.map((element, index) => ({ element, index }))
.filter(({ element }) => element.layerId === layerId)
.map(({ index }) => index);
const siblingIndex = siblingIndexes.indexOf(elementIndex);
const nextSiblingIndex = siblingIndex + direction;
if (nextSiblingIndex < 0 || nextSiblingIndex >= siblingIndexes.length) return document;
const nextElements = [...document.elements];
const swapIndex = siblingIndexes[nextSiblingIndex];
[nextElements[elementIndex], nextElements[swapIndex]] = [nextElements[swapIndex], nextElements[elementIndex]];
return { ...document, elements: nextElements };
}
function normalizeFolders(folders: CanvasLayerFolder[], layers: CanvasLayer[]) {
const layerIds = new Set(layers.map(layer => layer.id));
return folders
+64
View File
@@ -0,0 +1,64 @@
import type { CanvasDocument, StickerAsset } from '../types';
import { serializeDocument } from './svgExport';
const PNG_TYPE = 'image/png';
/**
* Render the full visible canvas document to a PNG Blob. WebKit returns the
* Blob directly from toBlob; the standard-track callback form and the
* data-URL fallback keep other browsers working.
*/
export async function createDesignPreviewBlob(
document: CanvasDocument,
stickers: Map<string, StickerAsset>,
): Promise<Blob> {
const width = Math.max(1, Math.floor(document.width || 0));
const height = Math.max(1, Math.floor(document.height || 0));
const svgMarkup = await serializeDocument(document, stickers, { includeBackground: true });
const svgBlob = new Blob([svgMarkup], { type: 'image/svg+xml;charset=utf-8' });
const objectUrl = URL.createObjectURL(svgBlob);
try {
const image: HTMLImageElement =
typeof Image === 'function' ? new Image() : globalThis.document.createElement('img');
image.src = objectUrl;
await image.decode();
const canvas = globalThis.document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('设计预览画布上下文不可用');
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
const pngBlob = await canvasToPngBlob(canvas);
if (pngBlob.type !== PNG_TYPE || pngBlob.size === 0) {
throw new Error('设计预览未生成有效的 PNG 输出');
}
return pngBlob;
} finally {
URL.revokeObjectURL(objectUrl);
}
}
async function canvasToPngBlob(canvas: HTMLCanvasElement): Promise<Blob> {
if (typeof canvas.toBlob === 'function') {
try {
const direct = (canvas.toBlob as unknown as (type: string) => Blob | undefined)('image/png');
if (direct) return direct;
} catch {
// Non-WebKit browsers wait for the callback form below.
}
return new Promise<Blob>((resolve, reject) => {
canvas.toBlob(blob => {
if (blob) resolve(blob);
else reject(new Error('设计预览画布未生成 PNG Blob'));
}, 'image/png');
});
}
if (typeof canvas.toDataURL === 'function') {
const res = await fetch(canvas.toDataURL('image/png'));
if (!res.ok) throw new Error('设计预览 PNG 输出读取失败');
return res.blob();
}
throw new Error('当前浏览器不支持设计预览 PNG 输出');
}
+64
View File
@@ -0,0 +1,64 @@
import type { CanvasDocument, ProductArchiveResult, ProductDetail, ProductSummary } from '../types';
import { apiUrl, ensureOk } from './api';
async function requestJson<T>(token: string, path: string, init: RequestInit = {}): Promise<T> {
const headers = new Headers(init.headers);
headers.set('Accept', 'application/json');
if (token) headers.set('Authorization', `Bearer ${token}`);
const res = await fetch(apiUrl(path), { ...init, headers });
await ensureOk(res, '产品档案接口请求失败');
return res.json() as Promise<T>;
}
export async function listProducts(token: string, query = ''): Promise<ProductSummary[]> {
const suffix = query.trim() ? `?query=${encodeURIComponent(query.trim())}` : '';
return requestJson(token, `/api/products${suffix}`);
}
export async function getProduct(token: string, productId: string): Promise<ProductDetail> {
return requestJson(token, `/api/products/${encodeURIComponent(productId)}`);
}
export async function createManualProduct(
token: string,
input: { name: string; sku?: string; specification?: string },
): Promise<ProductSummary> {
return requestJson(token, '/api/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source: 'manual',
external_product_id: null,
name: input.name.trim(),
sku: input.sku?.trim() || '',
specification: input.specification?.trim() || '',
}),
});
}
export async function archiveProductVersion(
token: string,
productId: string,
document: CanvasDocument,
preview: Blob,
): Promise<ProductArchiveResult> {
const form = new FormData();
form.append('document_json', JSON.stringify(document));
form.append('preview', new File([preview], 'design-preview.png', { type: 'image/png' }));
return requestJson(token, `/api/products/${encodeURIComponent(productId)}/versions`, {
method: 'POST',
body: form,
});
}
export async function restoreProduct(token: string, productId: string): Promise<ProductSummary> {
return requestJson(token, `/api/products/${encodeURIComponent(productId)}/restore`, {
method: 'POST',
});
}
export async function deleteProduct(token: string, productId: string): Promise<ProductSummary> {
return requestJson(token, `/api/products/${encodeURIComponent(productId)}`, {
method: 'DELETE',
});
}
+8 -3
View File
@@ -73,12 +73,14 @@ interface BackendAsset {
type: string;
mime_type: string;
file_url: string;
job_id?: string;
created_at: string;
}
async function apiListAssets(): Promise<BackendAsset[]> {
const res = await ensureOk(await fetch(apiUrl('/api/assets?type=sticker')), '读取贴纸库失败');
return res.json();
const res = await ensureOk(await fetch(apiUrl('/api/assets')), '读取贴纸库失败');
const assets = await res.json() as BackendAsset[];
return assets.filter(asset => asset.type === 'sticker' || asset.type === 'wordcloud');
}
async function apiUploadAsset(
@@ -114,6 +116,7 @@ export async function loadStickerLibrary(): Promise<StickerAsset[]> {
type: a.mime_type === 'image/svg+xml' ? 'svg' : 'image',
source: a.file_url,
createdAt: a.created_at,
jobId: a.job_id || undefined,
tint: tints[a.asset_id] as StickerAsset['tint'],
mimeType: a.mime_type,
}));
@@ -136,6 +139,7 @@ export async function addStickerAsset(
type: input.type,
source: asset.file_url,
createdAt: asset.created_at,
jobId: asset.job_id || undefined,
tint: input.tint,
mimeType: asset.mime_type,
};
@@ -150,7 +154,7 @@ export async function addStickerAssetFromJob(
): Promise<StickerAsset> {
const form = new FormData();
form.append('name', name || `词云 ${new Date().toLocaleString('zh-CN')}`);
form.append('type', 'sticker');
form.append('type', 'wordcloud');
const res = await ensureOk(
await fetch(apiUrl(`/api/assets/from-job/${jobId}`), { method: 'POST', body: form }),
'从任务导入贴纸失败',
@@ -162,6 +166,7 @@ export async function addStickerAssetFromJob(
type: asset.mime_type === 'image/svg+xml' ? 'svg' : 'image',
source: asset.file_url,
createdAt: asset.created_at,
jobId: asset.job_id || undefined,
mimeType: asset.mime_type,
};
window.dispatchEvent(new CustomEvent(STICKER_LIBRARY_EVENT));
+2 -2
View File
@@ -1,6 +1,6 @@
import { CanvasDocument, StickerAsset } from '../types';
import { apiUrl } from './api';
import { hasCanvasBackground, normalizeDocument, pxToMm } from './canvasDocument';
import { hasCanvasBackground, normalizeDocument, orderCanvasElementsByLayer, pxToMm } from './canvasDocument';
import { createZip } from './zip';
export interface SerializeOptions {
@@ -66,7 +66,7 @@ export async function serializeDocument(
parts.push(`<rect width="100%" height="100%" fill="${escapeXml(doc.background)}"/>`);
}
for (const element of doc.elements) {
for (const element of orderCanvasElementsByLayer(doc)) {
const layerId = element.layerId || doc.layers?.[0]?.id;
if (layerFilter && (!layerId || !layerFilter.has(layerId))) continue;
if (!layerFilter && layerId && !visibleLayers.has(layerId)) continue;
+19
View File
@@ -0,0 +1,19 @@
const DEFAULT_WORDCLOUD_BOARD_BASE_URL = 'http://114.55.99.6:47880';
export type WordCloudBoardRoute = 'cloud' | 'screen' | 'control';
function trimTrailingSlashes(value: string): string {
return value.replace(/\/+$/, '');
}
export const WORDCLOUD_BOARD_BASE_URL = trimTrailingSlashes(
import.meta.env.VITE_WORDCLOUD_BOARD_BASE_URL?.trim()
|| DEFAULT_WORDCLOUD_BOARD_BASE_URL,
);
export function wordCloudBoardUrl(
route: WordCloudBoardRoute,
productId: string,
): string {
return `${WORDCLOUD_BOARD_BASE_URL}/${route}/${encodeURIComponent(productId)}`;
}
+58 -22
View File
@@ -8,6 +8,7 @@ import {
CanvasLayer,
CanvasLayerFolder,
LineSpacingAnalysisSummary,
ProductArchiveResult,
ShapeCanvasElement,
StickerAsset,
WordcloudReplaceSession,
@@ -27,7 +28,9 @@ import {
layerIsLocked,
makeId,
mmToPx,
moveCanvasElementWithinLayer,
normalizeDocument,
orderCanvasElementsByLayer,
pxToMm,
TRANSPARENT_BACKGROUND,
} from '../lib/canvasDocument';
@@ -52,7 +55,9 @@ import {
IconPlus,
IconSettings,
IconHelp,
IconArchive,
} from '../components/Icons';
import ProductArchiveDialog from '../components/ProductArchiveDialog';
import FloatingPanel from '../components/FloatingPanel';
import DockTabBar, { DockTabItem } from '../components/DockTabBar';
import { DOCK_WIDTH, DOCK_TOP_RESERVED, FloatingPanelLayout, TAB_BAR_HEIGHT, useFloatingPanels } from '../hooks/useFloatingPanels';
@@ -127,6 +132,8 @@ export default function CanvasStudio({
const [zoom, setZoom] = useState(0.55);
const [dragState, setDragState] = useState<DragState | null>(null);
const [openPanels, setOpenPanels] = useState<CanvasPanelId[]>(['layers', 'properties']);
const [archiveDialogOpen, setArchiveDialogOpen] = useState(false);
const [archiveSuccessText, setArchiveSuccessText] = useState('');
const stageRef = useRef<HTMLDivElement>(null);
const workspaceRef = useRef<HTMLDivElement>(null);
const workspaceSizeRef = useRef<WorkspaceSize>({ width: 1200, height: 700 });
@@ -201,6 +208,12 @@ export default function CanvasStudio({
return map;
}, [stickers]);
const visibleCanvasElements = useMemo(
() => orderCanvasElementsByLayer(normalizedDocument)
.filter(element => layers.find(layer => layer.id === element.layerId)?.visible !== false),
[layers, normalizedDocument],
);
useEffect(() => {
const normalized = normalizeDocument(documentModel);
if (JSON.stringify(normalized) !== JSON.stringify(documentModel)) {
@@ -386,26 +399,12 @@ export default function CanvasStudio({
const bringForward = () => {
if (!selectedId) return;
setDocumentModel(prev => {
const index = prev.elements.findIndex(item => item.id === selectedId);
if (index < 0 || index === prev.elements.length - 1) return prev;
const next = [...prev.elements];
const [item] = next.splice(index, 1);
next.splice(index + 1, 0, item);
return { ...prev, elements: next };
});
setDocumentModel(prev => moveCanvasElementWithinLayer(prev, selectedId, 1));
};
const sendBackward = () => {
if (!selectedId) return;
setDocumentModel(prev => {
const index = prev.elements.findIndex(item => item.id === selectedId);
if (index <= 0) return prev;
const next = [...prev.elements];
const [item] = next.splice(index, 1);
next.splice(index - 1, 0, item);
return { ...prev, elements: next };
});
setDocumentModel(prev => moveCanvasElementWithinLayer(prev, selectedId, -1));
};
const handleSvgImport = async (file: File | null) => {
@@ -486,6 +485,23 @@ export default function CanvasStudio({
downloadBlob(blob, 'canvas-layers.zip');
};
const openProductArchive = () => {
setArchiveSuccessText('');
setArchiveDialogOpen(true);
};
const handleProductArchived = (result: ProductArchiveResult, productName: string) => {
const displayName = productName.trim() || `《未命名产品》`;
setArchiveSuccessText(`已归档至产品《${displayName}》· ${result.wordcloud_count || 0} 份词云位置数据已长期保存`);
setArchiveDialogOpen(false);
};
useEffect(() => {
if (!archiveSuccessText) return;
const timer = window.setTimeout(() => setArchiveSuccessText(''), 6000);
return () => window.clearTimeout(timer);
}, [archiveSuccessText]);
const applyWordcloudAssetReplace = (
replaceTarget: NonNullable<WordcloudStickerPayload['replaceTarget']>,
nextAssetId: string,
@@ -1029,6 +1045,7 @@ export default function CanvasStudio({
</div>
<div className="navbar-end">
<button className="btn btn-secondary btn-sm" onClick={onOpenWordcloud}></button>
<button className="btn btn-secondary btn-sm" onClick={openProductArchive}><IconArchive /> </button>
{onOpenHelp && <button className="btn btn-secondary btn-sm" onClick={onOpenHelp}><IconHelp /> </button>}
<AppSettingsWindow
themeMode={themeMode}
@@ -1048,16 +1065,16 @@ export default function CanvasStudio({
style={{
width: normalizedDocument.width,
height: normalizedDocument.height,
background: normalizedDocument.background,
'--canvas-background': normalizedDocument.background,
transform: `scale(${zoom})`,
}}
} as CSSProperties}
>
{normalizedDocument.elements
.filter(element => layers.find(layer => layer.id === element.layerId)?.visible !== false)
.map(element => (
{visibleCanvasElements
.map((element, stackOrder) => (
<CanvasElementView
key={element.id}
element={element}
stackOrder={stackOrder}
asset={element.type === 'sticker' ? stickerById.get(element.assetId) : undefined}
selected={element.id === selectedId}
locked={layerIsLocked(normalizedDocument, element.layerId)}
@@ -1105,6 +1122,22 @@ export default function CanvasStudio({
})}
{openPanels.map(renderPanel)}
</div>
{archiveDialogOpen && (
<ProductArchiveDialog
documentModel={normalizedDocument}
stickers={stickerById}
onArchived={handleProductArchived}
onClose={() => setArchiveDialogOpen(false)}
/>
)}
{archiveSuccessText && (
<div className="product-archive-toast" role="status">
<span className="product-archive-toast-icon"><IconArchive /></span>
<span>{archiveSuccessText}</span>
</div>
)}
</div>
);
}
@@ -1587,7 +1620,7 @@ function CanvasExportPanel({
>
{exportingAi ? '正在生成 AI...' : '导出总图 AI'}
</button>
<div className="note-text">AI </div>
<div className="note-text">AI SVG </div>
<div className="section-divider" />
<div className="section-title"></div>
@@ -2286,6 +2319,7 @@ async function stickerAssetToMaskSource(asset: StickerAsset): Promise<import('..
function CanvasElementView({
element,
stackOrder,
asset,
selected,
locked,
@@ -2294,6 +2328,7 @@ function CanvasElementView({
onTextChange,
}: {
element: CanvasElement;
stackOrder: number;
asset?: StickerAsset;
selected: boolean;
locked: boolean;
@@ -2307,6 +2342,7 @@ function CanvasElementView({
width: element.width,
height: element.height,
opacity: element.opacity,
zIndex: stackOrder + 1,
transform: `rotate(${element.rotation}deg)`,
};
+501
View File
@@ -0,0 +1,501 @@
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
import AppSettingsWindow, { type ThemeMode } from '../components/AppSettingsWindow';
import { apiUrl, ensureOk } from '../lib/api';
import { IconArchive, IconGrid, IconHelp, IconLock, IconRefresh, IconTrash } from '../components/Icons';
import { wordCloudBoardUrl, type WordCloudBoardRoute } from '../lib/wordcloudBoard';
import {
deleteProduct,
getProduct,
listProducts,
restoreProduct,
} from '../lib/productArchive';
import type { ProductDetail, ProductStatus, ProductSummary } from '../types';
interface ProductArchivePageProps {
themeMode: ThemeMode;
systemTheme: 'light' | 'dark';
onThemeModeChange: (mode: ThemeMode) => void;
onOpenHome: () => void;
onOpenHelp?: () => void;
}
const TOKEN_KEY = 'wordcloud-orders-token';
const wordCloudBoardActions: Array<{ label: string; route: WordCloudBoardRoute }> = [
{ label: '个人查找', route: 'cloud' },
{ label: '现场大屏', route: 'screen' },
{ label: '手机控制', route: 'control' },
];
function statusClass(status: ProductStatus): string {
if (status === 'active') return 'product-status-archived';
if (status === 'pending_cleanup' || status === 'failed_cleanup') return 'product-status-pending-cleanup';
return 'product-status-empty';
}
function statusLabel(product: ProductSummary): string {
if (product.status === 'active') return '已归档词云数据';
if (product.status === 'pending_cleanup') return '待清理';
if (product.status === 'failed_cleanup') return '清理失败';
return '已清理';
}
function formatDate(value: string | null): string {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '-';
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
}
function formatDateTime(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '-';
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function hasWordCloudArchive(detail: ProductDetail): boolean {
return detail.versions.some(version =>
version.wordcloud_archives.some(archive => archive.source_job_id.trim().length > 0),
);
}
function AuthenticatedImage({
token,
imageUrl,
alt,
emptyLabel,
}: {
token: string;
imageUrl: string;
alt: string;
emptyLabel: string;
}) {
const [objectUrl, setObjectUrl] = useState('');
useEffect(() => {
let cancelled = false;
let createdUrl = '';
const load = async () => {
try {
const res = await fetch(apiUrl(imageUrl), {
headers: { Authorization: `Bearer ${token}` },
});
await ensureOk(res, '预览图读取失败');
const blob = await res.blob();
createdUrl = URL.createObjectURL(blob);
if (!cancelled) setObjectUrl(createdUrl);
} catch {
if (!cancelled) setObjectUrl('');
}
};
void load();
return () => {
cancelled = true;
if (createdUrl) URL.revokeObjectURL(createdUrl);
};
}, [imageUrl, token]);
if (!objectUrl) {
return <div className="product-archive-image-empty">{emptyLabel}</div>;
}
return <img src={objectUrl} alt={alt} loading="lazy" decoding="async" />;
}
export default function ProductArchivePage({
themeMode,
systemTheme,
onThemeModeChange,
onOpenHome,
onOpenHelp,
}: ProductArchivePageProps) {
const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY) || '');
const [password, setPassword] = useState('');
const [loginError, setLoginError] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [products, setProducts] = useState<ProductSummary[]>([]);
const [query, setQuery] = useState('');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [detail, setDetail] = useState<ProductDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [actionBusy, setActionBusy] = useState(false);
const selected = products.find(product => product.product_id === selectedId) || null;
const loadProducts = async (authToken: string, search = query) => {
if (!authToken) return;
setLoading(true);
setError('');
try {
const items = await listProducts(authToken, search);
setProducts(items);
setSelectedId(current => items.some(item => item.product_id === current) ? current : null);
} catch (err) {
setError(err instanceof Error ? err.message : '产品档案读取失败');
if (err instanceof Error && (err.message.includes('401') || err.message.includes('403'))) {
localStorage.removeItem(TOKEN_KEY);
setToken('');
}
} finally {
setLoading(false);
}
};
useEffect(() => {
void loadProducts(token);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
useEffect(() => {
let cancelled = false;
if (!token || !selectedId) {
setDetail(null);
return;
}
setDetailLoading(true);
setError('');
getProduct(token, selectedId)
.then(next => {
if (!cancelled) setDetail(next);
})
.catch(err => {
if (cancelled) return;
setError(err instanceof Error ? err.message : '产品详情读取失败');
setDetail(null);
})
.finally(() => {
if (!cancelled) setDetailLoading(false);
});
return () => {
cancelled = true;
};
}, [selectedId, token]);
const handleLogin = async (event: FormEvent) => {
event.preventDefault();
setLoginError('');
try {
const res = await ensureOk(await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
}), '登录失败');
const data = await res.json() as { token: string };
localStorage.setItem(TOKEN_KEY, data.token);
setPassword('');
setToken(data.token);
} catch (err) {
setLoginError(err instanceof Error ? err.message : '登录失败');
}
};
const runAction = async (action: () => Promise<ProductSummary>) => {
if (!token || !selectedId || actionBusy) return;
setActionBusy(true);
setError('');
try {
await action();
await loadProducts(token);
const nextDetail = await getProduct(token, selectedId);
setDetail(nextDetail);
} catch (err) {
setError(err instanceof Error ? err.message : '操作失败');
} finally {
setActionBusy(false);
}
};
const copyProductId = async () => {
if (!detail) return;
try {
await navigator.clipboard.writeText(detail.product_id);
} catch {
setError('复制产品 ID 失败');
}
};
if (!token) {
return (
<div className="orders-page product-archive-page">
<nav className="navbar">
<div className="navbar-brand">
<div className="navbar-brand-icon"><IconLock /></div>
<span className="navbar-brand-name"> · </span>
</div>
<div className="navbar-actions" />
<div className="navbar-end">
<button className="nav-btn" onClick={onOpenHome}>
<span className="nav-btn-icon"><IconGrid /></span>
<span className="nav-btn-label"></span>
</button>
<AppSettingsWindow themeMode={themeMode} systemTheme={systemTheme} onThemeModeChange={onThemeModeChange} />
</div>
</nav>
<main className="orders-main">
<form className="login-card" onSubmit={handleLogin}>
<h2 className="login-title"></h2>
<p className="orders-hint"></p>
<input
type="password"
className="orders-input"
placeholder="管理口令"
value={password}
onChange={event => setPassword(event.target.value)}
/>
{loginError && <div className="orders-error">{loginError}</div>}
<button type="submit" className="btn btn-primary btn-block"></button>
</form>
</main>
</div>
);
}
const detailImages = detail?.images || [];
const previewImage = detail
? detailImages.find(image => image.image_id === detail.cover_image_id) || detailImages[0]
: null;
const previewUrl = previewImage?.image_url || '';
const latestVersion = detail?.versions[detail.versions.length - 1] || null;
const canOpenWordCloudBoard = detail !== null
&& selected?.status === 'active'
&& hasWordCloudArchive(detail);
return (
<div className="orders-page product-archive-page">
<nav className="navbar">
<div className="navbar-brand">
<div className="navbar-brand-icon"><IconArchive /></div>
<span className="navbar-brand-name"></span>
</div>
<div className="navbar-actions" />
<div className="navbar-end">
<button className="nav-btn" onClick={onOpenHome}>
<span className="nav-btn-icon"><IconGrid /></span>
<span className="nav-btn-label"></span>
</button>
<button className="nav-btn" onClick={() => loadProducts(token)}>
<span className="nav-btn-icon"><IconRefresh /></span>
<span className="nav-btn-label"></span>
</button>
{onOpenHelp && (
<button className="nav-btn" onClick={onOpenHelp}>
<span className="nav-btn-icon"><IconHelp /></span>
<span className="nav-btn-label"></span>
</button>
)}
<button
className="nav-btn"
onClick={() => {
localStorage.removeItem(TOKEN_KEY);
setToken('');
setProducts([]);
setDetail(null);
setSelectedId(null);
}}
>
<span className="nav-btn-icon"><IconTrash /></span>
<span className="nav-btn-label">退</span>
</button>
<AppSettingsWindow themeMode={themeMode} systemTheme={systemTheme} onThemeModeChange={onThemeModeChange} />
</div>
</nav>
<main className="orders-main orders-layout">
<aside className="orders-queue">
<div className="orders-queue-head">
<span></span>
<span className="orders-count">{products.length} </span>
</div>
<div className="product-archive-search-block">
<input
className="orders-input"
placeholder="产品名称"
value={query}
onChange={event => setQuery(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') void loadProducts(token);
}}
/>
<button className="btn btn-secondary btn-sm" onClick={() => loadProducts(token)}></button>
</div>
<div className="orders-queue-list">
{loading && <div className="table-empty"></div>}
{!loading && products.length === 0 && <div className="table-empty"></div>}
{products.map(product => {
const coverUrl = product.cover_image_id
? `/api/products/${product.product_id}/images/${product.cover_image_id}`
: "";
return (
<button
key={product.product_id}
className={`product-archive-row${selectedId === product.product_id ? ' active' : ''}`}
onClick={() => setSelectedId(product.product_id)}
>
<span className="product-archive-thumb">
{coverUrl
? <AuthenticatedImage
token={token}
imageUrl={coverUrl}
alt={product.name}
emptyLabel="暂无预览图"
/>
: <span className="product-archive-thumb-empty"></span>}
</span>
<span className="product-archive-row-body">
<span className="product-archive-row-name">{product.name}</span>
<span className="product-archive-row-meta">
{[product.sku, product.specification].filter(Boolean).join(' · ') || '未填写规格'}
</span>
</span>
<span className={`product-status ${statusClass(product.status)}`}>{statusLabel(product)}</span>
</button>
);
})}
</div>
</aside>
<section className="orders-detail">
{!selected ? (
<div className="table-empty"></div>
) : (
<>
<div className="orders-detail-head">
<div>
<h2 className="orders-detail-title">{selected.name}</h2>
<span className={`orders-status ${statusClass(selected.status)}`}>{statusLabel(selected)}</span>
<span className="orders-detail-meta">
{[selected.sku, selected.specification].filter(Boolean).join(' · ') || '未填写规格'}
</span>
</div>
<div className="orders-detail-actions">
{(selected.status === 'pending_cleanup' || selected.status === 'failed_cleanup') && (
<button
className="btn btn-secondary btn-sm"
disabled={actionBusy}
onClick={() => runAction(() => restoreProduct(token, selected.product_id))}
>
</button>
)}
{selected.status === 'active' && (
<button
className="btn btn-danger btn-sm"
disabled={actionBusy}
onClick={() => runAction(() => deleteProduct(token, selected.product_id))}
>
</button>
)}
</div>
</div>
{selected.status === 'pending_cleanup' && (
<div className="orders-error product-archive-warning">
· {formatDate(selected.purge_after)}
</div>
)}
{selected.status === 'failed_cleanup' && (
<div className="orders-error product-archive-warning"></div>
)}
{error && <div className="orders-error">{error}</div>}
{detailLoading && <div className="table-empty"></div>}
{detail && (
<div className="product-archive-detail">
<div className="product-archive-cover">
{previewUrl
? <AuthenticatedImage
token={token}
imageUrl={previewUrl}
alt={`${selected.name} 预览图`}
emptyLabel="暂无预览图"
/>
: <div className="product-archive-cover-empty"></div>}
</div>
<div className="product-archive-detail-body">
<div className="product-archive-metrics">
<div>
<span className="product-archive-metric-label"></span>
<strong>{latestVersion?.wordcloud_count ?? 0} </strong>
</div>
<div>
<span className="product-archive-metric-label"></span>
<strong>{detail.versions.length} </strong>
</div>
<div>
<span className="product-archive-metric-label"></span>
<strong>{detail.images.length} </strong>
</div>
</div>
<section className="product-archive-board-links" aria-labelledby="wordcloud-board-title">
<h3 id="wordcloud-board-title"></h3>
<div className="product-archive-board-actions">
{wordCloudBoardActions.map(action => (
canOpenWordCloudBoard ? (
<a
className="btn btn-secondary btn-sm"
href={wordCloudBoardUrl(action.route, detail.product_id)}
key={action.route}
target="_blank"
rel="noopener noreferrer"
>
{action.label}
</a>
) : (
<button className="btn btn-secondary btn-sm" disabled key={action.route} type="button">
{action.label}
</button>
)
))}
</div>
{!canOpenWordCloudBoard && (
<p className="product-archive-board-note"></p>
)}
</section>
<div className="product-archive-timeline">
<h3>线</h3>
{detail.versions.length === 0 && <p></p>}
{detail.versions.map(version => (
<div className="product-archive-version" key={version.version_id}>
<div>
<span>{formatDateTime(version.created_at)}</span>
<span>{version.wordcloud_count} </span>
</div>
</div>
))}
</div>
<details className="product-archive-system">
<summary></summary>
<div className="product-archive-system-row">
<span> ID</span>
<code>{detail.product_id}</code>
</div>
<button className="btn btn-secondary btn-sm" onClick={copyProductId}> ID</button>
</details>
</div>
</div>
)}
</>
)}
</section>
</main>
</div>
);
}
+87 -25
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
import AppSettingsWindow, { type ThemeMode } from '../components/AppSettingsWindow';
import Breadcrumb from '../components/Breadcrumb';
import { BackendAsset, CanvasTemplate } from '../types';
@@ -17,6 +17,7 @@ import {
import { serializeDocument } from '../lib/svgExport';
import { loadStickerLibrary } from '../lib/stickerLibrary';
import {
IconArchive,
IconGrid,
IconCloud,
IconFind,
@@ -32,6 +33,7 @@ interface TemplateHomeProps {
onCreateBlank: () => void;
onUseTemplate: (template: CanvasTemplate) => void;
onOpenOrders: () => void;
onOpenProducts: () => void;
onOpenFind: () => void;
onOpenHelp: () => void;
}
@@ -43,6 +45,7 @@ export default function TemplateHome({
onCreateBlank,
onUseTemplate,
onOpenOrders,
onOpenProducts,
onOpenFind,
onOpenHelp,
}: TemplateHomeProps) {
@@ -143,6 +146,10 @@ export default function TemplateHome({
<span className="nav-btn-icon"><IconCloud /></span>
<span className="nav-btn-label"></span>
</button>
<button className="nav-btn" onClick={onOpenProducts}>
<span className="nav-btn-icon"><IconArchive /></span>
<span className="nav-btn-label"></span>
</button>
<button className="nav-btn" onClick={onOpenFind}>
<span className="nav-btn-icon"><IconFind /></span>
<span className="nav-btn-label"></span>
@@ -229,6 +236,9 @@ function TemplateModal({
const next = () => setIdx(i => Math.min(slides.length - 1, i + 1));
const [largeUrl, setLargeUrl] = useState<string>(slides.length > 0 ? assetUrl(slides[safeIdx]) : '');
const [closing, setClosing] = useState(false);
const closeTimerRef = useRef(0);
const pendingActionRef = useRef(() => onClose());
useEffect(() => {
if (slides.length > 0) {
setLargeUrl(assetUrl(slides[safeIdx]));
@@ -241,11 +251,23 @@ function TemplateModal({
return () => { cancelled = true; };
}, [slides, safeIdx, template, stickerById]);
useEffect(() => () => window.clearTimeout(closeTimerRef.current), []);
const requestClose = (action: () => void = onClose) => {
if (closing) return;
pendingActionRef.current = action;
setClosing(true);
closeTimerRef.current = window.setTimeout(() => {
setClosing(false);
pendingActionRef.current();
}, 160);
};
return (
<div className="template-modal-backdrop" onClick={onClose}>
<div className="template-modal" onClick={e => e.stopPropagation()}>
<div className={`template-modal-backdrop${closing ? ' template-modal-exiting' : ''}`} onClick={() => requestClose()}>
<div className={`template-modal${closing ? ' template-modal-exiting' : ''}`} onClick={e => e.stopPropagation()}>
{/* Large preview with prev/next arrows */}
<div className="template-preview large" style={{ position: 'relative' }}>
<div className="template-preview large modal-layer" style={{ position: 'relative', '--modal-layer': 0 } as React.CSSProperties}>
<img src={largeUrl} alt={template.name} />
{slides.length > 1 && (
<>
@@ -263,39 +285,79 @@ function TemplateModal({
)}
</div>
<div className="template-modal-body">
<div className="template-title large">{template.name}</div>
{template.description && <p className="template-description">{template.description}</p>}
<div className="template-meta modal-meta">
<span>{formatMm(template.document.width)} x {formatMm(template.document.height)} mm</span>
<span>{template.document.elements.length} </span>
<span>{formatDate(templateUpdatedAt(template))}</span>
</div>
{/* Thumbnail strip */}
{slides.length > 1 && (
<div className="reference-strip">
{slides.map((asset, i) => (
<TemplateDetailPanel
template={template}
onUse={() => requestClose(onUse)}
onClose={() => requestClose()}
onDelete={onDelete}
referenceImages={slides.length > 1 ? slides.map((asset, i) => (
<img
key={asset.asset_id}
src={assetUrl(asset)}
alt={asset.name}
className={i === safeIdx ? 'active' : ''}
onClick={() => setIdx(i)}
style={{ cursor: 'pointer', outline: i === safeIdx ? '2px solid var(--color-primary, #6c63ff)' : 'none', borderRadius: 4 }}
/>
))}
)) : undefined}
/>
</div>
</div>
);
}
function TemplateDetailPanel({
template,
referenceImages,
onUse,
onClose,
onDelete,
}: {
template: CanvasTemplate;
referenceImages?: ReactNode;
onUse: () => void;
onClose: () => void;
onDelete: () => void;
}) {
return (
<aside className="template-modal-body template-detail-panel" aria-label="模板详情">
<header className="template-detail-summary modal-layer" style={{ '--modal-layer': 1 } as React.CSSProperties}>
<span className="template-detail-eyebrow"></span>
<h2 className="template-detail-title">{template.name}</h2>
</header>
<section className="template-detail-description modal-layer" style={{ '--modal-layer': 2 } as React.CSSProperties}>
<span className="template-detail-section-label"></span>
<p>{template.description || '未填写简介'}</p>
</section>
<dl className="template-detail-specs modal-layer" style={{ '--modal-layer': 3 } as React.CSSProperties}>
<div className="template-detail-spec">
<dt></dt>
<dd>{formatMm(template.document.width)} × {formatMm(template.document.height)} mm</dd>
</div>
<div className="template-detail-spec">
<dt></dt>
<dd>{template.document.elements.length} </dd>
</div>
<div className="template-detail-spec">
<dt></dt>
<dd>{formatDate(templateUpdatedAt(template))}</dd>
</div>
</dl>
{referenceImages && (
<section className="template-detail-references modal-layer" style={{ '--modal-layer': 4 } as React.CSSProperties}>
<span className="template-detail-section-label"></span>
<div className="reference-strip">{referenceImages}</div>
</section>
)}
<div className="btn-group">
<footer className="template-detail-actions modal-layer" style={{ '--modal-layer': 5 } as React.CSSProperties}>
<button className="btn btn-primary" onClick={onUse}>使</button>
<button className="btn btn-secondary" onClick={onClose}></button>
<button className="btn btn-danger" onClick={onDelete}></button>
</div>
</div>
</div>
</div>
<button className="btn btn-danger template-detail-delete" onClick={onDelete}></button>
</footer>
</aside>
);
}
+791 -16
View File
@@ -36,6 +36,17 @@
--nav-height: 56px;
--panel-width: 240px;
--transition: 200ms cubic-bezier(0.4, 0, 0.2, 1);
--modal-backdrop-duration: 260ms;
--modal-backdrop-opacity: 0.05;
--modal-backdrop-blur: 8px;
--modal-open-duration: 320ms;
--modal-open-delay: 60ms;
--modal-open-easing: cubic-bezier(0.22, 1, 0.36, 1);
--modal-content-duration: 200ms;
--modal-content-delay: 120ms;
--modal-content-stagger: 55ms;
--modal-exit-duration: 160ms;
--modal-exit-easing: cubic-bezier(0.4, 0, 0.2, 1);
--font-main: 'DM Sans', sans-serif;
--font-mono: 'DM Mono', monospace;
/* 液态玻璃 */
@@ -1577,7 +1588,19 @@ body.resizing {
flex-shrink: 0;
box-shadow: 0 16px 50px rgba(0,0,0,0.18);
transform-origin: center center;
overflow: hidden;
/* 画布背景仅限于本身,设计元素允许越出画布继续编辑。 */
overflow: visible;
isolation: isolate;
background: transparent;
}
.studio-stage::before {
content: '';
position: absolute;
inset: 0;
z-index: 0;
background: var(--canvas-background, #ffffff);
pointer-events: none;
}
.studio-bottombar {
@@ -2285,12 +2308,12 @@ body.resizing {
padding: 28px;
/* 只模糊、不压暗:去掉黑遮罩,保留毛玻璃磨砂。
模糊从 0 缓慢增强到目标值(700ms ease-out),进展不突兀 */
background: rgba(255, 255, 255, 0.04);
backdrop-filter: blur(10px);
animation: modal-backdrop-in 700ms ease-out;
background: rgba(255, 255, 255, var(--modal-backdrop-opacity));
backdrop-filter: blur(var(--modal-backdrop-blur));
animation: modal-backdrop-in var(--modal-backdrop-duration) cubic-bezier(0.22, 1, 0.36, 1);
}
:root[data-theme="dark"] .template-modal-backdrop {
background: rgba(10, 12, 18, 0.14);
background: rgba(10, 12, 18, calc(var(--modal-backdrop-opacity) * 2.4));
}
.template-modal {
width: min(960px, 100%);
@@ -2302,11 +2325,139 @@ body.resizing {
border-radius: var(--radius-lg);
background: var(--bg-panel);
box-shadow: var(--shadow-lg);
animation: template-pop 160ms ease-out;
animation: template-pop var(--modal-open-duration) var(--modal-open-easing) var(--modal-open-delay) backwards;
}
.modal-meta {
margin-top: 10px;
.template-modal-backdrop.template-modal-exiting {
animation: modal-backdrop-out var(--modal-exit-duration) var(--modal-exit-easing) forwards;
}
.template-modal.template-modal-exiting {
animation: template-exit var(--modal-exit-duration) var(--modal-exit-easing);
}
.modal-layer {
animation: modal-layer-in var(--modal-content-duration) var(--modal-open-easing)
calc(var(--modal-content-delay) + var(--modal-content-stagger) * var(--modal-layer, 0)) backwards;
}
.template-modal-exiting .modal-layer {
animation: modal-layer-out var(--modal-exit-duration) var(--modal-exit-easing);
}
.template-detail-panel {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
gap: 16px;
padding: 22px;
overflow-y: auto;
}
.template-detail-summary {
display: grid;
gap: 5px;
}
.template-detail-eyebrow,
.template-detail-section-label {
color: var(--lf-text-faint, var(--text-muted));
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
line-height: 1.2;
text-transform: uppercase;
}
.template-detail-title {
margin: 0;
color: var(--lf-text, var(--text-primary));
font-size: 22px;
line-height: 1.25;
overflow-wrap: anywhere;
}
.template-detail-description {
display: grid;
gap: 8px;
padding: 13px 14px;
border: 1px solid var(--lf-glass-soft, var(--border));
border-radius: var(--radius-md);
background: var(--lf-input-bg, var(--bg-panel-alt));
}
.template-detail-description p {
display: -webkit-box;
margin: 0;
overflow: hidden;
color: var(--lf-text-dim, var(--text-secondary));
font-size: 13px;
line-height: 1.55;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
}
.template-detail-specs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin: 0;
}
.template-detail-spec {
min-width: 0;
padding: 11px 10px;
border: 1px solid var(--lf-glass-soft, var(--border));
border-radius: var(--radius-sm);
background: var(--lf-glass-bg, var(--bg-panel-alt));
}
.template-detail-spec dt {
margin-bottom: 6px;
color: var(--lf-text-faint, var(--text-muted));
font-size: 11px;
}
.template-detail-spec dd {
margin: 0;
overflow: hidden;
color: var(--lf-text, var(--text-primary));
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
}
.template-detail-spec:first-child {
grid-column: 1 / -1;
}
.template-detail-references {
display: grid;
gap: 8px;
}
.template-detail-references .reference-strip {
margin: 0;
}
.template-detail-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-top: auto;
padding-top: 16px;
border-top: 1px solid var(--lf-glass-soft, var(--border));
}
.template-detail-actions .btn {
min-width: 0;
}
.template-detail-delete {
grid-column: 1 / -1;
}
.reference-strip {
@@ -2324,11 +2475,12 @@ body.resizing {
border-radius: var(--radius-md);
background: var(--bg-panel-alt);
flex-shrink: 0;
transition: outline 0.1s;
cursor: pointer;
transition: border-color 0.1s, outline 0.1s;
}
.reference-strip img.active {
outline: 2px solid var(--color-primary, #6c63ff);
outline: 2px solid var(--accent);
outline-offset: 1px;
}
@@ -2367,11 +2519,57 @@ body.resizing {
@keyframes template-pop {
from {
opacity: 0;
transform: scale(0.96);
transform: translateY(0) scale(0.98);
}
to {
opacity: 1;
transform: scale(1);
transform: translateY(0) scale(1);
}
}
@keyframes modal-layer-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes template-exit {
from {
opacity: 1;
transform: translateY(0) scale(1);
}
to {
opacity: 0;
transform: translateY(0) scale(0.98);
}
}
@keyframes modal-layer-out {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(5px);
}
}
@keyframes modal-backdrop-out {
from {
opacity: 1;
-webkit-backdrop-filter: blur(var(--modal-backdrop-blur));
backdrop-filter: blur(var(--modal-backdrop-blur));
}
to {
opacity: 0;
-webkit-backdrop-filter: blur(0px);
backdrop-filter: blur(0px);
}
}
/* 遮罩只模糊不压暗 → 让模糊从 0 慢慢增强到目标值,而非瞬间全糊(进展缓一点) */
@@ -2383,8 +2581,8 @@ body.resizing {
}
to {
opacity: 1;
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(var(--modal-backdrop-blur));
backdrop-filter: blur(var(--modal-backdrop-blur));
}
}
@@ -2471,6 +2669,27 @@ body.resizing {
.template-masonry {
column-count: 1;
}
.template-modal-backdrop {
padding: 14px;
}
.template-detail-panel {
gap: 14px;
padding: 18px;
}
.template-detail-specs {
grid-template-columns: 1fr;
}
.template-detail-actions {
grid-template-columns: 1fr;
}
.template-detail-delete {
grid-column: auto;
}
}
/* ===== RESIZABLE PANEL HANDLE ===== */
@@ -2574,6 +2793,24 @@ body.resizing .studio-panel {
box-shadow: inset 0 0 0 1px var(--lf-glass-soft), 0 12px 34px var(--lf-shadow-strong);
}
/* 横向 SVG 使用满高白色画布承托,完整图形居中显示且不裁切。 */
.template-home .template-modal .template-preview.large {
align-self: stretch;
aspect-ratio: 4 / 3;
min-height: 300px;
background: #fff;
}
.template-home .template-modal .template-preview.large img {
height: 100%;
max-height: none;
}
/* SVG 常带透明背景,缩略图统一以白底承托,保证预览可见。 */
.template-home .template-detail-references .reference-strip img {
background: #fff;
}
/* 空态与模态玻璃化 */
.template-home .template-empty {
background: var(--lf-glass-bg);
@@ -3092,5 +3329,543 @@ body.resizing .studio-panel {
.orders-page .orders-input:focus,
.find-page .orders-input:focus { border-color: var(--lf-accent-border); }
/* 未选任务时的居中占位 */
.find-page .find-stage .table-empty { height: 100%; display: flex; align-items: center; justify-content: center; color: var(--lf-text-faint); }
.find-page .orders-input:focus { border-color: var(--lf-accent-border); }
/* ===== 产品档案归档弹窗 ===== */
.product-archive-backdrop {
position: fixed;
inset: 0;
z-index: 110;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(255,255,255,var(--modal-backdrop-opacity));
backdrop-filter: blur(var(--modal-backdrop-blur));
}
:root[data-theme="dark"] .product-archive-backdrop {
background: rgba(10,12,18,calc(var(--modal-backdrop-opacity) * 2.4));
}
.product-archive-dialog {
width: min(920px, 100%);
max-height: calc(100vh - 48px);
display: grid;
grid-template-rows: auto minmax(0, 1fr);
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--bg-panel);
box-shadow: var(--shadow-lg);
animation: template-pop var(--modal-open-duration) var(--modal-open-easing) var(--modal-open-delay) backwards;
}
.product-archive-head {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 18px;
border-bottom: 1px solid var(--border);
}
.product-archive-eyebrow {
color: var(--text-muted);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
display: inline-flex;
align-items: center;
gap: 5px;
}
.product-archive-title {
margin: 0;
color: var(--text-primary);
font-size: 15px;
font-weight: 600;
}
.product-archive-close {
margin-left: auto;
}
.product-archive-grid {
display: grid;
grid-template-columns: minmax(0, 0.9fr) minmax(300px, 1.1fr);
gap: 14px;
padding: 16px;
overflow-y: auto;
}
.product-archive-preview-panel,
.product-archive-form-panel {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.product-archive-preview-frame {
width: 100%;
aspect-ratio: 4 / 3;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-panel-alt);
overflow: hidden;
}
.product-archive-preview {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
.product-archive-preview-placeholder {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
font-family: var(--font-main);
font-size: 13px;
}
.product-archive-preview-note,
.product-archive-source-note {
margin: 0;
color: var(--text-secondary);
font-size: 12px;
line-height: 1.5;
overflow-wrap: anywhere;
}
.product-archive-source-note {
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-panel-alt);
}
.product-archive-form {
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
overflow-y: auto;
}
.product-archive-loading,
.product-archive-empty,
.product-archive-state {
color: var(--text-muted);
font-size: 13px;
}
.product-archive-error {
padding: 10px 12px;
border: 1px solid var(--danger);
border-radius: var(--radius-sm);
background: var(--danger-light);
color: var(--danger);
font-size: 13px;
overflow-wrap: anywhere;
}
.product-archive-empty-copy {
margin: 0;
padding: 9px 11px;
border: 1px solid var(--warn);
border-radius: var(--radius-sm);
background: var(--bg-panel-alt);
color: var(--warn);
font-size: 12px;
line-height: 1.5;
overflow-wrap: anywhere;
}
.product-archive-search-row {
display: grid;
gap: 5px;
color: var(--text-secondary);
font-size: 12px;
}
.product-archive-search,
.product-archive-input {
width: 100%;
padding: 7px 9px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-panel-alt);
color: var(--text-primary);
font-family: var(--font-main);
font-size: 13px;
}
.product-archive-search:focus,
.product-archive-input:focus {
outline: none;
border-color: var(--border-focus);
}
.product-archive-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 150px;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-panel);
}
.product-archive-option {
width: 100%;
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 8px;
padding: 7px 9px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-primary);
text-align: left;
cursor: pointer;
}
.product-archive-option.selected {
background: var(--accent-light);
box-shadow: inset 0 0 0 1px var(--border-focus);
}
.product-archive-option-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-main);
font-size: 13px;
font-weight: 600;
}
.product-archive-option-sku {
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 11px;
}
.product-archive-option-status {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
line-height: 1.4;
white-space: nowrap;
}
.product-archive-create {
display: grid;
gap: 9px;
padding: 11px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-panel-alt);
}
.product-archive-create-title {
color: var(--text-secondary);
font-size: 12px;
font-weight: 700;
}
.product-archive-create label {
display: grid;
gap: 4px;
color: var(--text-muted);
font-size: 11px;
}
.product-archive-meta-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.product-archive-create-actions {
display: flex;
align-items: center;
gap: 8px;
}
.product-archive-create-note {
color: var(--text-muted);
font-size: 11px;
}
.product-archive-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 2px;
}
.product-archive-actions .btn-primary:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.product-status-archived {
color: var(--success);
background: var(--success-light);
}
.product-status-empty {
color: var(--text-muted);
background: var(--bg-panel-alt);
}
.product-status-pending-cleanup {
color: var(--warn);
background: var(--accent-light);
}
.product-archive-toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translateX(-50%);
z-index: 120;
display: flex;
align-items: center;
gap: 8px;
max-width: min(720px, calc(100vw - 28px));
padding: 9px 14px;
border: 1px solid var(--success);
border-radius: var(--radius-sm);
background: var(--success-light);
color: var(--success);
font-size: 13px;
box-shadow: var(--shadow-md);
}
.product-archive-toast-icon {
display: inline-flex;
flex: 0 0 auto;
}
.product-archive-toast span:last-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 720px) {
.product-archive-grid {
grid-template-columns: minmax(0, 1fr);
}
.product-archive-preview-panel {
max-height: 300px;
}
.product-archive-option {
grid-template-columns: minmax(0, 1fr) auto;
}
.product-archive-option-sku {
display: none;
}
.product-archive-meta-row {
grid-template-columns: minmax(0, 1fr);
}
}
/* ===== 产品档案管理页 ===== */
.product-archive-search-block {
display: flex;
gap: 6px;
padding: 10px 12px;
border-bottom: 1px solid var(--border);
}
.product-archive-search-block .orders-input {
margin-bottom: 0;
}
.product-archive-row {
width: 100%;
display: grid;
grid-template-columns: 64px minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
padding: 9px 10px;
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-primary);
text-align: left;
cursor: pointer;
}
.product-archive-row:hover,
.product-archive-row.active {
background: var(--accent-light);
border-color: var(--accent);
}
.product-archive-thumb {
width: 64px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-panel-alt);
}
.product-archive-thumb img {
width: 100%;
height: 100%;
object-fit: contain;
}
.product-archive-image-empty {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
font-size: 11px;
text-align: center;
}
.product-archive-thumb-empty,
.product-archive-cover-empty {
padding: 4px;
color: var(--text-muted);
font-size: 11px;
text-align: center;
}
.product-archive-row-body {
min-width: 0;
display: grid;
gap: 4px;
}
.product-archive-row-name,
.product-archive-row-meta {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.product-archive-row-name {
font-family: var(--font-main);
font-size: 13px;
font-weight: 600;
}
.product-archive-row-meta {
color: var(--text-secondary);
font-size: 11px;
}
.product-archive-warning {
margin-bottom: 12px;
}
.product-archive-detail {
display: grid;
grid-template-columns: minmax(260px, 0.75fr) minmax(0, 1fr);
gap: 16px;
}
.product-archive-cover {
min-height: 220px;
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg-panel-alt);
}
.product-archive-cover img {
width: 100%;
height: 100%;
object-fit: contain;
}
.product-archive-cover-empty {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.product-archive-detail-body {
min-width: 0;
display: grid;
gap: 14px;
}
.product-archive-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.product-archive-metrics > div {
padding: 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-panel-alt);
}
.product-archive-metric-label {
display: block;
margin-bottom: 4px;
color: var(--text-secondary);
font-size: 11px;
}
.product-archive-metrics strong {
color: var(--text-primary);
font-size: 15px;
}
.product-archive-board-links {
padding: 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-panel-alt);
}
.product-archive-board-links h3 {
margin: 0 0 10px;
color: var(--text-primary);
font-size: 13px;
}
.product-archive-board-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.product-archive-board-actions .btn {
text-decoration: none;
}
.product-archive-board-note {
margin: 8px 0 0;
color: var(--text-muted);
font-size: 11px;
}
.product-archive-timeline {
padding: 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-panel-alt);
}
.product-archive-timeline h3 {
margin: 0 0 10px;
color: var(--text-primary);
font-size: 13px;
}
.product-archive-timeline p {
margin: 0;
color: var(--text-muted);
font-size: 12px;
}
.product-archive-version {
padding: 8px 0;
border-bottom: 1px solid var(--border);
}
.product-archive-version:last-child {
border-bottom: 0;
}
.product-archive-version > div {
display: flex;
justify-content: space-between;
gap: 10px;
color: var(--text-secondary);
font-size: 12px;
}
.product-archive-system {
padding: 10px;
border: 1px dashed var(--border);
border-radius: var(--radius-sm);
}
.product-archive-system summary {
color: var(--text-secondary);
cursor: pointer;
font-size: 12px;
}
.product-archive-system-row {
display: flex;
align-items: center;
gap: 8px;
margin: 10px 0;
color: var(--text-secondary);
font-size: 12px;
}
.product-archive-system-row code {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-primary);
font-family: var(--font-mono);
}
@media (max-width: 900px) {
.product-archive-detail {
grid-template-columns: minmax(0, 1fr);
}
.product-archive-row {
grid-template-columns: 48px minmax(0, 1fr);
}
.product-archive-row .product-status {
grid-column: 2;
justify-self: start;
}
}
+62
View File
@@ -109,6 +109,7 @@ export interface StickerAsset {
type: StickerAssetType;
source: string;
createdAt: string;
jobId?: string;
tint?: string;
mimeType?: string;
}
@@ -261,3 +262,64 @@ export interface WordcloudStickerPayload {
/** 存在时表示替换画布上已有词云,而不是新增贴纸 */
replaceTarget?: WordcloudReplaceTarget;
}
// 产品档案与词云归档(与 backend/service/schemas.py 对齐)
export type ProductSource = 'manual' | 'external';
export type ProductStatus = 'active' | 'pending_cleanup' | 'failed_cleanup' | 'purged';
export interface ProductSummary {
product_id: string;
source: ProductSource;
external_product_id: string | null;
name: string;
sku: string;
specification: string;
status: ProductStatus;
created_at: string;
updated_at: string;
purge_after: string | null;
cover_image_id: string | null;
}
export interface ProductImage {
image_id: string;
product_id: string;
version_id: string;
image_path: string;
image_type: string;
is_cover: boolean;
created_at: string;
image_url?: string;
}
export interface ProductWordcloudArchive {
archive_id: string;
product_id: string;
version_id: string;
archive_path: string;
source_job_id: string;
source_asset_id: string;
db_checksum: string;
db_path: string;
created_at: string;
}
export interface ProductVersion {
version_id: string;
product_id: string;
version: string;
metadata: Record<string, unknown>;
created_at: string;
design_preview_path: string;
design_preview_image_id?: string | null;
design_preview_url?: string;
wordcloud_count: number;
wordcloud_archives: ProductWordcloudArchive[];
}
export type ProductArchiveResult = ProductVersion;
export interface ProductDetail extends ProductSummary {
images: ProductImage[];
versions: ProductVersion[];
}
@@ -0,0 +1,60 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import ts from 'typescript';
async function loadCanvasDocumentModule() {
const source = await readFile(new URL('../src/lib/canvasDocument.ts', import.meta.url), 'utf8');
const compiled = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2020,
},
}).outputText;
const module = { exports: {} };
new Function('exports', 'module', compiled)(module.exports, module);
return module.exports;
}
const documentWithTwoLayers = {
width: 400,
height: 240,
background: '#ffffff',
layers: [
{ id: 'layer-bottom', name: '底层', visible: true, locked: false },
{ id: 'layer-top', name: '顶层', visible: true, locked: false },
],
layerFolders: [],
elements: [
{ id: 'top-first', type: 'rect', layerId: 'layer-top', x: 0, y: 0, width: 10, height: 10, rotation: 0, opacity: 1, fill: '#f00', stroke: '#f00', strokeWidth: 0 },
{ id: 'bottom-first', type: 'rect', layerId: 'layer-bottom', x: 0, y: 0, width: 10, height: 10, rotation: 0, opacity: 1, fill: '#0f0', stroke: '#0f0', strokeWidth: 0 },
{ id: 'top-second', type: 'rect', layerId: 'layer-top', x: 0, y: 0, width: 10, height: 10, rotation: 0, opacity: 1, fill: '#00f', stroke: '#00f', strokeWidth: 0 },
],
};
test('canvas paint order keeps every element above the background and honors layer order', async () => {
const { orderCanvasElementsByLayer } = await loadCanvasDocumentModule();
assert.deepEqual(
orderCanvasElementsByLayer(documentWithTwoLayers).map(element => element.id),
['bottom-first', 'top-first', 'top-second'],
);
});
test('moving an element only changes its order inside its own canvas layer', async () => {
const { moveCanvasElementWithinLayer } = await loadCanvasDocumentModule();
const moved = moveCanvasElementWithinLayer(documentWithTwoLayers, 'top-first', 1);
assert.deepEqual(
moved.elements.map(element => element.id),
['top-second', 'bottom-first', 'top-first'],
);
});
test('canvas elements remain visible when they extend beyond the document surface', async () => {
const styles = await readFile(new URL('../src/styles.css', import.meta.url), 'utf8');
const stageRule = /\.studio-stage\s*\{([\s\S]*?)\n\}/.exec(styles)?.[1] ?? '';
assert.match(stageRule, /overflow:\s*visible/);
});
+10
View File
@@ -0,0 +1,10 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
test('dock preview is mounted in the workspace rather than inside the moving panel', async () => {
const source = await readFile(new URL('../src/components/FloatingPanel.tsx', import.meta.url), 'utf8');
assert.match(source, /createPortal\(/);
assert.match(source, /workspaceNodeRef\.current,\s*\n\s*\)/);
});
@@ -0,0 +1,92 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
test('design preview serializes the complete visible document before PNG upload', async () => {
const source = await readFile(new URL('../src/lib/designPreview.ts', import.meta.url), 'utf8');
assert.match(source, /serializeDocument\(document, stickers, \{ includeBackground: true \}\)/);
assert.match(source, /canvas\.toBlob/);
});
test('archive client submits document JSON and a PNG preview as multipart data', async () => {
const source = await readFile(new URL('../src/lib/productArchive.ts', import.meta.url), 'utf8');
assert.match(source, /form\.append\('document_json'/);
assert.match(source, /form\.append\('preview'/);
});
test('canvas offers a product archive action and reports detected source count', async () => {
const canvas = await readFile(new URL('../src/pages/CanvasStudio.tsx', import.meta.url), 'utf8');
const dialog = await readFile(new URL('../src/components/ProductArchiveDialog.tsx', import.meta.url), 'utf8');
assert.match(canvas, /加入产品列表/);
assert.match(dialog, /已检测到.*份画布词云/);
assert.match(dialog, /将作为产品封面/);
});
test('archive dialog states the server scan is authoritative and covers zero-source drafts', async () => {
const dialog = await readFile(new URL('../src/components/ProductArchiveDialog.tsx', import.meta.url), 'utf8');
assert.match(dialog, /(以服务端扫描为准|服务端扫描为最终依据|最终归档数量以服务端为准)/);
assert.match(dialog, /无词云归档数据/);
});
test('archive success hides raw identifiers and reports the archived count', async () => {
const canvas = await readFile(new URL('../src/pages/CanvasStudio.tsx', import.meta.url), 'utf8');
assert.match(canvas, /已归档至产品/);
assert.match(canvas, /份词云位置数据已长期保存/);
assert.doesNotMatch(canvas, /已归档至产品.*\{product\.product_id\}/);
});
test('archive status styles cover archived, empty and pending cleanup states', async () => {
const styles = await readFile(new URL('../src/styles.css', import.meta.url), 'utf8');
assert.match(styles, /\.product-status-archived/);
assert.match(styles, /\.product-status-empty/);
assert.match(styles, /\.product-status-pending-cleanup/);
});
test('new product creation archives with the created product id instead of a draft id', async () => {
const dialog = await readFile(new URL('../src/components/ProductArchiveDialog.tsx', import.meta.url), 'utf8');
assert.match(dialog, /await createManualProduct\(token, \{ name, sku, specification \}\)/);
assert.match(dialog, /archiveProductVersion\(token, product\.product_id, document, previewBlob\)/);
});
test('app routes to product archives and list keeps IDs out of primary cells', async () => {
const app = await readFile(new URL('../src/App.tsx', import.meta.url), 'utf8');
const page = await readFile(new URL('../src/pages/ProductArchivePage.tsx', import.meta.url), 'utf8');
assert.match(app, /'products'/);
assert.match(page, /产品档案/);
assert.match(page, /产品名称/);
assert.doesNotMatch(page, /<td>\{product\.product_id\}<\/td>/);
});
test('product archive page exposes restore and soft delete actions', async () => {
const page = await readFile(new URL('../src/pages/ProductArchivePage.tsx', import.meta.url), 'utf8');
assert.match(page, /恢复产品/);
assert.match(page, /软删除/);
assert.match(page, /系统信息/);
assert.match(page, /待清理 · 将于/);
});
test('product archive links use Product IDs and a configurable board origin', async () => {
const links = await readFile(new URL('../src/lib/wordcloudBoard.ts', import.meta.url), 'utf8');
const page = await readFile(new URL('../src/pages/ProductArchivePage.tsx', import.meta.url), 'utf8');
assert.match(links, /VITE_WORDCLOUD_BOARD_BASE_URL/);
assert.match(links, /http:\/\/114\.55\.99\.6:47880/);
assert.match(links, /encodeURIComponent\(productId\)/);
assert.match(links, /'cloud' \| 'screen' \| 'control'/);
assert.match(page, /product-archive-board-links/);
assert.match(page, /个人查找/);
assert.match(page, /现场大屏/);
assert.match(page, /手机控制/);
assert.match(page, /canOpenWordCloudBoard/);
assert.match(page, /disabled/);
assert.match(page, /target="_blank"/);
assert.match(page, /rel="noopener noreferrer"/);
assert.match(page, /detail\.product_id/);
});
@@ -0,0 +1,47 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const sourcePath = new URL('../src/pages/TemplateHome.tsx', import.meta.url);
const stylesPath = new URL('../src/styles.css', import.meta.url);
test('template details are rendered through one reusable right-panel component', async () => {
const source = await readFile(sourcePath, 'utf8');
assert.match(source, /function TemplateDetailPanel\(/);
assert.match(source, /<TemplateDetailPanel/);
assert.match(source, /className="[^"]*template-detail-panel/);
});
test('template detail styles separate summary, specifications, and actions', async () => {
const styles = await readFile(stylesPath, 'utf8');
assert.match(styles, /\.template-detail-summary/);
assert.match(styles, /\.template-detail-specs/);
assert.match(styles, /\.template-detail-actions/);
});
test('template detail specifications and actions stack on narrow screens', async () => {
const styles = await readFile(stylesPath, 'utf8');
assert.match(styles, /@media \(max-width: 560px\) \{[\s\S]*?\.template-detail-specs\s*\{\s*grid-template-columns: 1fr;/);
assert.match(styles, /@media \(max-width: 560px\) \{[\s\S]*?\.template-detail-actions\s*\{\s*grid-template-columns: 1fr;/);
});
test('narrow-screen modal keeps the action row reachable under the viewport cap', async () => {
const styles = await readFile(stylesPath, 'utf8');
// The modal caps height with overflow:hidden. On single-column mobile the
// detail panel must keep its own scroll so actions cannot be clipped away.
assert.match(styles, /\.template-modal\s*\{[^}]*max-height:\s*calc\(100vh\s*-\s*56px\);/);
assert.match(styles, /\.template-modal\s*\{[^}]*overflow:\s*hidden;/);
assert.match(styles, /\.template-detail-panel\s*\{[^}]*overflow-y:\s*auto;/);
});
test('modal previews fill their column with a white SVG backdrop and thumbnails stay opaque', async () => {
const styles = await readFile(stylesPath, 'utf8');
assert.match(styles, /\.template-home \.template-modal \.template-preview\.large\s*\{[^}]*align-self: stretch;[^}]*aspect-ratio: 4 \/ 3;[^}]*background: #fff;/);
assert.match(styles, /\.template-home \.template-modal \.template-preview\.large img\s*\{[^}]*height: 100%;[^}]*max-height: none;/);
assert.match(styles, /\.template-home \.template-detail-references \.reference-strip img\s*\{[^}]*background: #fff;/);
});
+167
View File
@@ -0,0 +1,167 @@
# 产品档案与词云归档:技术设计
> 前置需求:[requirements.md](requirements.md)
## 1. 设计结论
词云生成任务仍可在设计期间生成位置库,但该文件只是一份**临时工作区产物**;它在成功生成后默认保留 30 天。用户从画布执行“加入产品列表”时,系统才把当前可见画布实际使用的词云位置库复制为产品档案中的不可变快照。产品档案不依赖原始任务目录,因此原始任务后续清理不会影响已加工产品的查询能力。
“加入产品列表”是设计系统内可执行的归档授权;实体加工和上市不由当前系统自动推断。后续可从生产/MES/电商接口写入产品状态,但不改变“已归档产品数据不自动删除”的原则。
## 2. 生命周期
```mermaid
stateDiagram-v2
[*] --> 临时设计数据: 词云生成成功
临时设计数据 --> 临时设计数据: 设计、插入或移除画布
临时设计数据 --> 待清理: 30 天未归档
临时设计数据 --> 产品档案: 加入产品列表
产品档案 --> 待清理: 解除关联或删除产品
待清理 --> [*]: 30 天宽限期结束
产品档案 --> 产品档案: 更新产品资料/添加图片
```
清理前 7 天,系统在“产品档案/数据清理”管理界面显示提醒。首期不依赖邮件或外部通知服务。待清理状态允许恢复或延长;到达物理清理时间才删除文件。
## 3. 归档识别规则
归档由后端根据提交的画布文档重新计算,不能仅相信前端传来的词云列表。
1. 读取当前 `CanvasDocument``layers``elements`
2. 排除不可见图层中的元素。
3. 在剩余元素中收集插入式素材的 `assetId`
4. 从素材元数据读取其词云来源;首期兼容现有 `job_id`,新字段统一为 `source_job_id``source_kind=wordcloud`
5. 仅接受存在成功任务且包含 `word_locations` SQLite 的来源;普通图片、无来源 SVG、WCD 合成任务等不计入词云档案。
6.`source_job_id` 去重;同一词云多次出现只归档一份位置库。
7. 若零份词云可归档,允许创建产品但明确标记“无词云归档数据”;不得虚报归档成功。
词云从画布删除后不会出现在本次文档扫描中,因此不会随本次产品版本归档。已经归档的历史产品版本不因后来编辑画布而被改写。
## 4. 数据与文件边界
### 4.1 产品元数据 SQLite
新增独立且持久化的 `service_products/products.db`,而不是把产品数据放在当前未挂载的 `service_metadata/app.db` 中。
| 表 | 核心字段 | 用途 |
|---|---|---|
| `products` | `product_id`, `source`, `external_product_id`, `name`, `sku`, `specification`, `status`, `cover_image_id`, timestamps | 产品主档;`(source, external_product_id)` 唯一 |
| `product_versions` | `version_id`, `product_id`, `design_snapshot_path`, `design_digest`, `wordcloud_count`, timestamps | 一次“加入产品列表”产生一个不可变设计版本 |
| `product_wordcloud_archives` | `archive_id`, `version_id`, `source_job_id`, `source_asset_id`, `db_path`, `db_checksum`, timestamps | 产品版本与一份位置库快照的关系 |
| `product_images` | `image_id`, `product_id`, `version_id?`, `kind`, `path`, `remote_source?`, `is_cover`, timestamps | 图片集合;首期使用 `design_preview` |
| `cleanup_records` | `subject_type`, `subject_id`, `state`, `purge_after`, `reminded_at`, `reason` | 临时任务和已删除档案的可恢复清理状态 |
`external_product_id` 是接口幂等键,不作为默认显示字段。网页创建产品的 `product_id` 由服务生成;可选 SKU/规格用于人类识别。
### 4.2 档案文件
```text
service_products/
products.db
<product_id>/
<version_id>/
design-preview.png
wordclouds/
<archive_id>.db
```
- `design-preview.png` 是加入产品时的完整设计预览快照,首期默认作为封面。
- 每份 `.db` 为原始任务 `wordcloud_hd.db` 的验证后拷贝,保留原有 `word_locations` 表结构。
- 词云查找使用产品档案快照,不再要求原始 `service_workspace/<job_id>` 存在。
- 图片模型支持未来的 `reality_photo``external_product_image`,并始终由 `cover_image_id` 指向当前封面。远端图片应下载/复制入档案,而非只保存易失效 URL。
### 4.3 原始任务与档案的关系
`job_id` 是来源追溯键,不是产品档案的身份。每次归档记录来源任务、素材和数据库校验和;当相同外部产品重新加入新的设计时,增加新的 `product_version`,旧版本继续可追溯。
## 5. 服务边界与接口
### 5.1 ProductArchiveStore
新增产品档案存储层,负责:产品幂等创建/更新、版本创建、数据库文件原子复制、封面文件保存、软删除、恢复和清理候选查询。文件复制采用临时文件、SQLite 可读性检查、校验和计算和原子重命名;任一失败都回滚元数据和临时文件,不能出现“列表显示已归档但数据库缺失”。
### 5.2 ProductProvider
定义产品来源适配层:
- `manual`:网页创建。
- `external`:外部产品接口同步。首期只约定稳定 ID 和名称;SKU、规格、封面 URL 为可选字段。
外部接口尚未在当前仓库中实现,因此首期不绑定具体 URL、认证方式或字段名。适配层以 `source + external_product_id` 进行幂等 upsert,避免按名称错误合并。
### 5.3 建议 API
| 接口 | 责任 |
|---|---|
| `GET /api/products` | 搜索产品列表;按名称/SKU/状态筛选 |
| `POST /api/products` | 网页创建产品 |
| `POST /api/products/sync` | 由产品接口适配器批量 upsert;不在首期绑定外部协议 |
| `GET /api/products/{product_id}` | 产品、图片、版本及词云归档摘要 |
| `POST /api/products/{product_id}/versions` | 提交当前画布;服务端识别可见词云、保存预览并创建归档版本 |
| `GET /api/products/{product_id}/archives/{archive_id}/locations` | 查询该产品词云名字位置 |
| `DELETE /api/products/{product_id}` | 软删除,进入 30 天待清理期 |
| `POST /api/products/{product_id}/restore` | 恢复待清理产品 |
| `GET /api/maintenance/cleanup-candidates` | 管理员查看清理预览 |
| `POST /api/maintenance/cleanup-run` | 仅清理已到期且未被档案引用的数据 |
产品、档案查询和清理接口沿用生产订单管理权限;加入产品动作也必须带同一管理身份,避免把包含人员名字的位置库暴露给匿名调用。
## 6. 前端体验
### 6.1 画布中的入口
在画布顶栏或文件操作区提供「加入产品列表」。点击后:
1. 扫描当前画布并显示“已检测到 N 份画布词云,将全部归档”。
2. 用户搜索接口同步产品,或选择「新建产品」。
3. 对新建产品填写名称,规格/SKU 为可选。
4. 显示完整设计预览缩略图,说明“将作为产品封面;以后可替换为实景图”。
5. 确认后显示“已归档至产品《名称》· N 份词云位置数据已长期保存”。
没有可归档词云时,仍可建立产品,但确认界面和产品状态必须显示“无词云归档数据”。
### 6.2 产品列表与产品详情
- 列表的主识别信息为封面、产品名称和规格/SKU,不显示完整 ID。
- 列表状态为「已归档词云数据」「无词云数据」「待清理」等人类可读标签。
- 详情页展示当前封面、大号设计预览、归档版本时间线、每版本词云数量及后续图片集合入口。
- 完整 ID 仅在“系统信息”折叠区提供复制,供排错和接口对接。
视觉沿用当前深色工作台的工业化风格:现有 `DM Sans` / `DM Mono`、背景 `#101114`、面板 `#1c1f24`、操作强调 `#6ea8ff`、归档成功 `#58d69c`、待处理 `#e4b95a`。这是对通用 UI 规范字体/颜色限制的窄范围品牌继承。
## 7. 清理策略
| 对象 | 初始状态 | 提醒 | 物理清理 |
|---|---|---|---|
| 成功生成但未归档的词云任务 | 临时 | 第 23 天在管理界面提示 | 第 30 天 |
| 已归档产品/词云版本 | 已归档 | 无自动删除 | 仅在显式解除关联/删除后 |
| 已解除关联或删除的产品档案 | 待清理 | 可恢复期内显示 | 30 天后 |
清理执行时应删除完整的原始任务目录或档案版本目录,而不是仅删数据库的一部分;这避免留下指向失效数据库的元数据。清理器必须重新检查是否仍存在产品归档关联,避免并发“加入产品”和清理导致误删。
现有按素材引用跳过清理的逻辑需要改为按产品档案引用保护:普通画布素材引用只延长临时设计保留期,不构成永久保留理由。
## 8. 部署、迁移与兼容
当前 Docker Compose 挂载了工作区/素材目录,但未挂载 `service_metadata``service_orders`。产品档案必须新增持久化卷,例如 `wordcloud_products -> /app/service_products`;同时建议为元数据与订单目录补充独立持久化卷,避免容器重建后丢失关联记录。
上线时:
1. 创建产品存储与 schema,不修改既有 `word_locations` 结构。
2. 既有任务默认视为临时数据,按其完成时间纳入清理候选;不自动把旧任务提升为产品档案。
3. 只有用户通过“加入产品列表”确认的设计才创建新档案快照。
4. 在第一次实际清理前,以只读 dry-run 展示候选清单,确认后才开启物理删除。
## 9. 验证策略
- 单元测试:可见图层筛选、来源识别、去重、隐藏图层排除、外部 ID 幂等、宽限期计算。
- 服务测试:数据库快照原子复制和回滚、归档后原始任务删除仍可查询、软删除与恢复、到期清理二次引用检查。
- 前端测试:归档扫描摘要、零词云提示、产品名称展示、设计预览默认封面、待清理状态提示。
- 人工验证:将多份词云插入/删除/隐藏后加入产品;确认实际归档数量与画布可见内容一致;清理 dry-run 不包含已归档位置库。
## 10. 未纳入本阶段的开放项
- 外部产品接口的真实协议、认证与字段映射。
- 实景图上传、图片多选与外部图片下载任务。
- 实体加工、上市、退市状态如何从外部系统回传。
- 多用户角色与精细权限;首期复用订单后台管理身份。
+62
View File
@@ -0,0 +1,62 @@
# 产品档案与词云归档:需求确认稿
## 问题与目标
当前词云位置库按生成任务保存,设计试验、废弃样例与实际产品没有明确边界,既会累积无用数据,也无法把已加工产品的名字位置稳定地归档。系统需要在设计环节将“画布实际使用的词云”归入产品档案,并让未归档的临时数据可被安全清理。
## 已确认的业务规则
- **长期保留依据**:产品已加入产品列表;这是设计端可执行的保留动作。实体产品已加工是其业务背景,但不依赖系统自动推断加工结果。
- **归档对象**:加入产品时,扫描当前画布中实际插入、且可追溯至词云生成任务的素材;默认归档全部命中的词云。
- **不归档对象**:仅生成但从未插入画布的词云、中间样例/废品、已从当前画布移除的词云、普通图片与不可追溯来源的 SVG。
- **隐藏图层**:不视为当前成品内容,不归档其中的词云。
- **去重**:同一来源词云在画布中出现多次时,只保存一份词云位置库快照。
- **产品身份**:接口导入产品使用稳定的外部产品 ID;网页创建产品使用内部产品 ID。ID 是系统关联键,不是默认展示内容。
- **人类识别**:默认展示产品名称;可辅以规格、SKU/款号与封面,不显示完整产品 ID。
- **产品图片**:产品从一开始支持图片集合和当前封面;首期仅保存当前完整设计的预览快照,后续可添加实景图和产品接口图。
- **归档介质**:产品档案保存词云位置库的独立快照,同时保留来源任务 ID 供追溯;不能只依赖可能被清理的原始任务目录。
## 范围
1. 在画布中提供“加入产品列表”动作。
2. 支持选择接口同步的产品或在网页创建产品。
3. 创建/更新产品档案、设计预览图和关联词云位置库快照。
4. 在产品列表与设计界面显示归档状态及可读提示。
5. 为临时设计数据提供自动清理候选、延期和人工清理能力。
6. 为产品档案提供后续多图、外部产品接口与实体加工状态接入的扩展位。
## 非目标(首期)
- 不自动根据 WCD 合成任务成功推断“实体已加工”或“已上市”。
- 不在首期接入实景图上传或外部产品图片;只保留可扩展的数据结构与封面选择入口。
- 不以产品名称作为接口同步和去重依据。
- 不删除已归档产品的词云位置库,除非用户显式删除产品档案或解除关联。
## 用户故事
1. 作为设计人员,我希望把当前设计加入产品列表,使成品使用的词云数据被长期保存。
2. 作为设计人员,我希望系统自动识别当前画布中使用的词云,而不必逐项查找生成任务。
3. 作为生产/运营人员,我希望通过产品名称、规格与预览图识别档案,而不是记住内部 ID。
4. 作为管理人员,我希望未用于产品的临时设计数据能被定期清理,同时可在清理前人工保留或延后。
5. 作为未来接口维护者,我希望外部产品能按稳定 ID 合并进同一个产品档案。
## 验收标准(EARS
1. 当用户在画布中选择“加入产品列表”时,系统应扫描当前可见画布元素并识别可追溯的词云来源。
2. 当扫描到多个可追溯词云时,系统应默认选择全部唯一来源并在确认界面显示数量和来源摘要。
3. 当同一词云来源被多个画布元素引用时,系统应只创建一份位置库归档快照。
4. 当词云仅存在于隐藏图层、未插入画布或已从当前画布删除时,系统不应将其归档为该产品的词云数据。
5. 当用户确认加入已有产品或创建新产品时,系统应创建产品与词云归档版本的关联,并保存当前完整设计预览图。
6. 当产品来自外部接口时,系统应使用外部产品 ID 作为幂等关联键;页面应默认显示产品名称而非该 ID。
7. 当产品在网页内创建时,系统应生成内部产品 ID,并允许用户填写用于展示的产品名称及可选规格/SKU。
8. 当归档成功时,系统应提示“已归档至产品”,并显示产品名称和已归档词云数量。
9. 当设计数据尚未归档至产品时,系统应明确标记其为临时数据并显示清理策略或预计清理时间。
10. 当产品档案仍关联某词云位置库时,自动清理流程不得删除该归档位置库。
11. 当用户解除产品关联或删除产品档案时,系统应先进入可恢复的待清理状态,而非立即不可逆删除词云数据。
12. 当后续加入实景图或外部产品图时,系统应能把它们作为产品图片候选,并允许选择当前封面而不丢失设计预览快照。
## 尚待确认的策略参数
- 临时设计数据的默认保留天数及清理前提醒时点。
- 产品被删除、外部接口下架或解除关联后的宽限期。
- 产品接口可提供的最小字段(稳定 ID、名称;建议另含 SKU/规格/封面 URL)。