30 KiB
产品档案与词云归档 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)withupsert_product、create_version、add_image、add_wordcloud_archive、get_product、list_products、mark_pending_cleanup、restore_product、due_product_cleanups、purge_product。 -
upsert_productcreatesprod_<uuidhex>formanual;externalrequires non-emptyexternal_product_idand conflicts on(source, external_product_id). -
Step 1: Write the failing persistence and idempotency tests
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.
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
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) -> Noneandcopy_word_locations_snapshot(source: Path, destination: Path) -> str; the return is a SHA-256 checksum. -
Step 1: Write failing visibility, provenance and deduplication tests
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
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
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_jsonandpreview(image/png). -
Step 1: Write failing service and API tests
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
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)withpreview(now)andapply(now). -
Produces
CleanupReport(temporary_jobs, pending_products, reclaimable_bytes, reminder_job_ids, deleted_job_ids, deleted_product_ids). -
Adds authenticated
GET /api/maintenance/cleanup-candidatesandPOST /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
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:
- 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
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>. -
createDesignPreviewBlobusesserializeDocument(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
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
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, andcreateDesignPreviewBlob. -
Adds
IconArchiveto the existing local icon set. -
Calls
onArchived(result)only after the backend archive response succeeds. -
Step 1: Extend the failing UI source-level tests
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:
已检测到 N 份画布词云,将全部归档。
当前完整设计将保存为产品预览图,后续可替换为实景图。
When zero sources are detected, use:
当前画布未检测到可归档词云。可以建立产品,但该版本会标记为“无词云归档数据”。
- Step 4: Wire CanvasStudio action and success state
Place 加入产品列表 next to existing 添加词云 in the CanvasStudio navbar. On success close the dialog and show:
已归档至产品《{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
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, anddeleteProduct. -
Adds
productstoAppPage, and a homepage action named产品档案. -
Step 1: Write failing page and route tests
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
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_ENABLEDrollout, soft-delete recovery and rollback. -
Produces no new runtime interface; verifies Tasks 1–7 integration.
-
Step 1: Add an end-to-end business-boundary test
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:
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
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.