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
+2
View File
@@ -74,5 +74,7 @@ GIT_PUSH_指南.md
# ── Local-only / third-party copies ─────────── # ── Local-only / third-party copies ───────────
ref/ ref/
.design-tests/ .design-tests/
.superpowers/
.worktrees/
backend/service_orders/ backend/service_orders/
docs/storage-metrics.json docs/storage-metrics.json
@@ -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.
+167
View File
@@ -0,0 +1,167 @@
# 产品档案与词云归档:技术设计
> 前置需求:[requirements.md](requirements.md)
## 1. 设计结论
词云生成任务仍可在设计期间生成位置库,但该文件只是一份**临时工作区产物**;它在成功生成后默认保留 30 天。用户从画布执行“加入产品列表”时,系统才把当前可见画布实际使用的词云位置库复制为产品档案中的不可变快照。产品档案不依赖原始任务目录,因此原始任务后续清理不会影响已加工产品的查询能力。
“加入产品列表”是设计系统内可执行的归档授权;实体加工和上市不由当前系统自动推断。后续可从生产/MES/电商接口写入产品状态,但不改变“已归档产品数据不自动删除”的原则。
## 2. 生命周期
```mermaid
stateDiagram-v2
[*] --> 临时设计数据: 词云生成成功
临时设计数据 --> 临时设计数据: 设计、插入或移除画布
临时设计数据 --> 待清理: 30 天未归档
临时设计数据 --> 产品档案: 加入产品列表
产品档案 --> 待清理: 解除关联或删除产品
待清理 --> [*]: 30 天宽限期结束
产品档案 --> 产品档案: 更新产品资料/添加图片
```
清理前 7 天,系统在“产品档案/数据清理”管理界面显示提醒。首期不依赖邮件或外部通知服务。待清理状态允许恢复或延长;到达物理清理时间才删除文件。
## 3. 归档识别规则
归档由后端根据提交的画布文档重新计算,不能仅相信前端传来的词云列表。
1. 读取当前 `CanvasDocument``layers``elements`
2. 排除不可见图层中的元素。
3. 在剩余元素中收集插入式素材的 `assetId`
4. 从素材元数据读取其词云来源;首期兼容现有 `job_id`,新字段统一为 `source_job_id``source_kind=wordcloud`
5. 仅接受存在成功任务且包含 `word_locations` SQLite 的来源;普通图片、无来源 SVG、WCD 合成任务等不计入词云档案。
6.`source_job_id` 去重;同一词云多次出现只归档一份位置库。
7. 若零份词云可归档,允许创建产品但明确标记“无词云归档数据”;不得虚报归档成功。
词云从画布删除后不会出现在本次文档扫描中,因此不会随本次产品版本归档。已经归档的历史产品版本不因后来编辑画布而被改写。
## 4. 数据与文件边界
### 4.1 产品元数据 SQLite
新增独立且持久化的 `service_products/products.db`,而不是把产品数据放在当前未挂载的 `service_metadata/app.db` 中。
| 表 | 核心字段 | 用途 |
|---|---|---|
| `products` | `product_id`, `source`, `external_product_id`, `name`, `sku`, `specification`, `status`, `cover_image_id`, timestamps | 产品主档;`(source, external_product_id)` 唯一 |
| `product_versions` | `version_id`, `product_id`, `design_snapshot_path`, `design_digest`, `wordcloud_count`, timestamps | 一次“加入产品列表”产生一个不可变设计版本 |
| `product_wordcloud_archives` | `archive_id`, `version_id`, `source_job_id`, `source_asset_id`, `db_path`, `db_checksum`, timestamps | 产品版本与一份位置库快照的关系 |
| `product_images` | `image_id`, `product_id`, `version_id?`, `kind`, `path`, `remote_source?`, `is_cover`, timestamps | 图片集合;首期使用 `design_preview` |
| `cleanup_records` | `subject_type`, `subject_id`, `state`, `purge_after`, `reminded_at`, `reason` | 临时任务和已删除档案的可恢复清理状态 |
`external_product_id` 是接口幂等键,不作为默认显示字段。网页创建产品的 `product_id` 由服务生成;可选 SKU/规格用于人类识别。
### 4.2 档案文件
```text
service_products/
products.db
<product_id>/
<version_id>/
design-preview.png
wordclouds/
<archive_id>.db
```
- `design-preview.png` 是加入产品时的完整设计预览快照,首期默认作为封面。
- 每份 `.db` 为原始任务 `wordcloud_hd.db` 的验证后拷贝,保留原有 `word_locations` 表结构。
- 词云查找使用产品档案快照,不再要求原始 `service_workspace/<job_id>` 存在。
- 图片模型支持未来的 `reality_photo``external_product_image`,并始终由 `cover_image_id` 指向当前封面。远端图片应下载/复制入档案,而非只保存易失效 URL。
### 4.3 原始任务与档案的关系
`job_id` 是来源追溯键,不是产品档案的身份。每次归档记录来源任务、素材和数据库校验和;当相同外部产品重新加入新的设计时,增加新的 `product_version`,旧版本继续可追溯。
## 5. 服务边界与接口
### 5.1 ProductArchiveStore
新增产品档案存储层,负责:产品幂等创建/更新、版本创建、数据库文件原子复制、封面文件保存、软删除、恢复和清理候选查询。文件复制采用临时文件、SQLite 可读性检查、校验和计算和原子重命名;任一失败都回滚元数据和临时文件,不能出现“列表显示已归档但数据库缺失”。
### 5.2 ProductProvider
定义产品来源适配层:
- `manual`:网页创建。
- `external`:外部产品接口同步。首期只约定稳定 ID 和名称;SKU、规格、封面 URL 为可选字段。
外部接口尚未在当前仓库中实现,因此首期不绑定具体 URL、认证方式或字段名。适配层以 `source + external_product_id` 进行幂等 upsert,避免按名称错误合并。
### 5.3 建议 API
| 接口 | 责任 |
|---|---|
| `GET /api/products` | 搜索产品列表;按名称/SKU/状态筛选 |
| `POST /api/products` | 网页创建产品 |
| `POST /api/products/sync` | 由产品接口适配器批量 upsert;不在首期绑定外部协议 |
| `GET /api/products/{product_id}` | 产品、图片、版本及词云归档摘要 |
| `POST /api/products/{product_id}/versions` | 提交当前画布;服务端识别可见词云、保存预览并创建归档版本 |
| `GET /api/products/{product_id}/archives/{archive_id}/locations` | 查询该产品词云名字位置 |
| `DELETE /api/products/{product_id}` | 软删除,进入 30 天待清理期 |
| `POST /api/products/{product_id}/restore` | 恢复待清理产品 |
| `GET /api/maintenance/cleanup-candidates` | 管理员查看清理预览 |
| `POST /api/maintenance/cleanup-run` | 仅清理已到期且未被档案引用的数据 |
产品、档案查询和清理接口沿用生产订单管理权限;加入产品动作也必须带同一管理身份,避免把包含人员名字的位置库暴露给匿名调用。
## 6. 前端体验
### 6.1 画布中的入口
在画布顶栏或文件操作区提供「加入产品列表」。点击后:
1. 扫描当前画布并显示“已检测到 N 份画布词云,将全部归档”。
2. 用户搜索接口同步产品,或选择「新建产品」。
3. 对新建产品填写名称,规格/SKU 为可选。
4. 显示完整设计预览缩略图,说明“将作为产品封面;以后可替换为实景图”。
5. 确认后显示“已归档至产品《名称》· N 份词云位置数据已长期保存”。
没有可归档词云时,仍可建立产品,但确认界面和产品状态必须显示“无词云归档数据”。
### 6.2 产品列表与产品详情
- 列表的主识别信息为封面、产品名称和规格/SKU,不显示完整 ID。
- 列表状态为「已归档词云数据」「无词云数据」「待清理」等人类可读标签。
- 详情页展示当前封面、大号设计预览、归档版本时间线、每版本词云数量及后续图片集合入口。
- 完整 ID 仅在“系统信息”折叠区提供复制,供排错和接口对接。
视觉沿用当前深色工作台的工业化风格:现有 `DM Sans` / `DM Mono`、背景 `#101114`、面板 `#1c1f24`、操作强调 `#6ea8ff`、归档成功 `#58d69c`、待处理 `#e4b95a`。这是对通用 UI 规范字体/颜色限制的窄范围品牌继承。
## 7. 清理策略
| 对象 | 初始状态 | 提醒 | 物理清理 |
|---|---|---|---|
| 成功生成但未归档的词云任务 | 临时 | 第 23 天在管理界面提示 | 第 30 天 |
| 已归档产品/词云版本 | 已归档 | 无自动删除 | 仅在显式解除关联/删除后 |
| 已解除关联或删除的产品档案 | 待清理 | 可恢复期内显示 | 30 天后 |
清理执行时应删除完整的原始任务目录或档案版本目录,而不是仅删数据库的一部分;这避免留下指向失效数据库的元数据。清理器必须重新检查是否仍存在产品归档关联,避免并发“加入产品”和清理导致误删。
现有按素材引用跳过清理的逻辑需要改为按产品档案引用保护:普通画布素材引用只延长临时设计保留期,不构成永久保留理由。
## 8. 部署、迁移与兼容
当前 Docker Compose 挂载了工作区/素材目录,但未挂载 `service_metadata``service_orders`。产品档案必须新增持久化卷,例如 `wordcloud_products -> /app/service_products`;同时建议为元数据与订单目录补充独立持久化卷,避免容器重建后丢失关联记录。
上线时:
1. 创建产品存储与 schema,不修改既有 `word_locations` 结构。
2. 既有任务默认视为临时数据,按其完成时间纳入清理候选;不自动把旧任务提升为产品档案。
3. 只有用户通过“加入产品列表”确认的设计才创建新档案快照。
4. 在第一次实际清理前,以只读 dry-run 展示候选清单,确认后才开启物理删除。
## 9. 验证策略
- 单元测试:可见图层筛选、来源识别、去重、隐藏图层排除、外部 ID 幂等、宽限期计算。
- 服务测试:数据库快照原子复制和回滚、归档后原始任务删除仍可查询、软删除与恢复、到期清理二次引用检查。
- 前端测试:归档扫描摘要、零词云提示、产品名称展示、设计预览默认封面、待清理状态提示。
- 人工验证:将多份词云插入/删除/隐藏后加入产品;确认实际归档数量与画布可见内容一致;清理 dry-run 不包含已归档位置库。
## 10. 未纳入本阶段的开放项
- 外部产品接口的真实协议、认证与字段映射。
- 实景图上传、图片多选与外部图片下载任务。
- 实体加工、上市、退市状态如何从外部系统回传。
- 多用户角色与精细权限;首期复用订单后台管理身份。
+62
View File
@@ -0,0 +1,62 @@
# 产品档案与词云归档:需求确认稿
## 问题与目标
当前词云位置库按生成任务保存,设计试验、废弃样例与实际产品没有明确边界,既会累积无用数据,也无法把已加工产品的名字位置稳定地归档。系统需要在设计环节将“画布实际使用的词云”归入产品档案,并让未归档的临时数据可被安全清理。
## 已确认的业务规则
- **长期保留依据**:产品已加入产品列表;这是设计端可执行的保留动作。实体产品已加工是其业务背景,但不依赖系统自动推断加工结果。
- **归档对象**:加入产品时,扫描当前画布中实际插入、且可追溯至词云生成任务的素材;默认归档全部命中的词云。
- **不归档对象**:仅生成但从未插入画布的词云、中间样例/废品、已从当前画布移除的词云、普通图片与不可追溯来源的 SVG。
- **隐藏图层**:不视为当前成品内容,不归档其中的词云。
- **去重**:同一来源词云在画布中出现多次时,只保存一份词云位置库快照。
- **产品身份**:接口导入产品使用稳定的外部产品 ID;网页创建产品使用内部产品 ID。ID 是系统关联键,不是默认展示内容。
- **人类识别**:默认展示产品名称;可辅以规格、SKU/款号与封面,不显示完整产品 ID。
- **产品图片**:产品从一开始支持图片集合和当前封面;首期仅保存当前完整设计的预览快照,后续可添加实景图和产品接口图。
- **归档介质**:产品档案保存词云位置库的独立快照,同时保留来源任务 ID 供追溯;不能只依赖可能被清理的原始任务目录。
## 范围
1. 在画布中提供“加入产品列表”动作。
2. 支持选择接口同步的产品或在网页创建产品。
3. 创建/更新产品档案、设计预览图和关联词云位置库快照。
4. 在产品列表与设计界面显示归档状态及可读提示。
5. 为临时设计数据提供自动清理候选、延期和人工清理能力。
6. 为产品档案提供后续多图、外部产品接口与实体加工状态接入的扩展位。
## 非目标(首期)
- 不自动根据 WCD 合成任务成功推断“实体已加工”或“已上市”。
- 不在首期接入实景图上传或外部产品图片;只保留可扩展的数据结构与封面选择入口。
- 不以产品名称作为接口同步和去重依据。
- 不删除已归档产品的词云位置库,除非用户显式删除产品档案或解除关联。
## 用户故事
1. 作为设计人员,我希望把当前设计加入产品列表,使成品使用的词云数据被长期保存。
2. 作为设计人员,我希望系统自动识别当前画布中使用的词云,而不必逐项查找生成任务。
3. 作为生产/运营人员,我希望通过产品名称、规格与预览图识别档案,而不是记住内部 ID。
4. 作为管理人员,我希望未用于产品的临时设计数据能被定期清理,同时可在清理前人工保留或延后。
5. 作为未来接口维护者,我希望外部产品能按稳定 ID 合并进同一个产品档案。
## 验收标准(EARS
1. 当用户在画布中选择“加入产品列表”时,系统应扫描当前可见画布元素并识别可追溯的词云来源。
2. 当扫描到多个可追溯词云时,系统应默认选择全部唯一来源并在确认界面显示数量和来源摘要。
3. 当同一词云来源被多个画布元素引用时,系统应只创建一份位置库归档快照。
4. 当词云仅存在于隐藏图层、未插入画布或已从当前画布删除时,系统不应将其归档为该产品的词云数据。
5. 当用户确认加入已有产品或创建新产品时,系统应创建产品与词云归档版本的关联,并保存当前完整设计预览图。
6. 当产品来自外部接口时,系统应使用外部产品 ID 作为幂等关联键;页面应默认显示产品名称而非该 ID。
7. 当产品在网页内创建时,系统应生成内部产品 ID,并允许用户填写用于展示的产品名称及可选规格/SKU。
8. 当归档成功时,系统应提示“已归档至产品”,并显示产品名称和已归档词云数量。
9. 当设计数据尚未归档至产品时,系统应明确标记其为临时数据并显示清理策略或预计清理时间。
10. 当产品档案仍关联某词云位置库时,自动清理流程不得删除该归档位置库。
11. 当用户解除产品关联或删除产品档案时,系统应先进入可恢复的待清理状态,而非立即不可逆删除词云数据。
12. 当后续加入实景图或外部产品图时,系统应能把它们作为产品图片候选,并允许选择当前封面而不丢失设计预览快照。
## 尚待确认的策略参数
- 临时设计数据的默认保留天数及清理前提醒时点。
- 产品被删除、外部接口下架或解除关联后的宽限期。
- 产品接口可提供的最小字段(稳定 ID、名称;建议另含 SKU/规格/封面 URL)。