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

635 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 产品档案与词云归档 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.