docs: add product archive spec and plan
This commit is contained in:
@@ -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 1–7 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.
|
||||
Reference in New Issue
Block a user