Files
wordcloud/docs/superpowers/plans/2026-09-12-product-archive.md
T
broccoli cc5c3f9751
Build, Push and Deploy / build (push) Successful in 11s
Build, Push and Deploy / deploy (push) Successful in 26s
docs: add product archive spec and plan
2026-09-13 15:40:15 +08:00

30 KiB
Raw Blame History

产品档案与词云归档 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_previewreality_photoexternal_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.tsxfrontend/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)ProductRecordProductVersionRecordProductImageRecordProductWordcloudArchiveRecord

  • Produces ProductArchiveStore(root: Path) with upsert_productcreate_versionadd_imageadd_wordcloud_archiveget_productlist_productsmark_pending_cleanuprestore_productdue_product_cleanupspurge_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

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) -> 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

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_json and preview (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) 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

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>.

  • 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

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, 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

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, and deleteProduct.

  • Adds products to AppPage, 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_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

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.