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

This commit is contained in:
2026-09-13 15:40:15 +08:00
parent fb8e815771
commit cc5c3f9751
4 changed files with 865 additions and 0 deletions
@@ -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.