生成结果回收站
面向:在「美术工具」里批量试过提示词、结果列表太长想收拾一下的人。 姊妹篇:美术资源清理清单.md(那份讲磁盘过程残留,本份讲生成结果条目)。
1. 一句话
每一条生成结果(第 N 号)都能单独删掉,删掉不是销毁 —— 它进回收站,随时能原样捞回来。
- 2D 概念图结果(
design_jobs行) - 3D 模型结果(
model3d_jobs行)
两种都是「一行 = 一次生成」,删一行 = 从列表里拿掉一项,同批次其它结果不受影响。
2. 在哪操作
| 动作 | 位置 |
|---|---|
| 删一条结果 | 美术工具 → 点开某个资产 → 右侧详情面板 → 每条结果行最右侧的红色「删除」 |
| 找回 / 彻底删 | 左侧导航「清理与回收站」→「回收区」卡片 → 生成结果项 页签 |
「清理与回收站」下的回收区有两个页签,是两套粒度,别搞混:
| 页签 | 粒度 | 装着什么 | 来源 |
|---|---|---|---|
| 过程文件批次 | 文件 | A/B/C 档扫描出来的磁盘残留 | 「清理与回收站」的清理流程 |
| 生成结果项 | 结果 | 被删掉的第 N 号结果(整条) | 详情面板上的删除按钮 |
3. 删一条时,到底发生了什么
设计上选的是快照删除,不是软删除偷偷打个 deleted_at 标记。原因很实在: 软删除要求每一条查询路径都记得过滤(hub 的计数、资产详情、任务列表……), 漏一处就是「删了还在」的幽灵数据,且这种 bug 静默、难查。快照法把查询路径全部保持原样。
删除时的动作序列:
- 收集代价提示(不拦,只讲明白,见第 4 节)
- 算出仍被别的记录引用的文件 → 这些留在原地不动
- 产物文件
shutil.move搬进快照目录_trash/asset_forge/recycle/<时间戳>/files/(是搬,不是删) - 整行原值 —— 含原主键 —— 写进
_item.json - 指向它的下游外键置空(3D 行引用已删的 2D 设计时,
design_job_id→NULL) - 最后才真删台账行
第 3 步在第 6 步之前,是刻意的:万一中途崩了,最坏情况是「行还在、文件已搬走」, 属于可恢复态;反过来则是「文件没了、行还在」,那才真叫丢。
恢复时反向走一遍:文件搬回原路径,行按原主键插回,被置空的外键回填原值 (所以 3D 结果恢复后,仍然挂在原来那条 2D 设计下面,关系不断)。
快照目录名是 {kind}_{原编号}_{年月日-时分秒},一个 stamp 对应一份独立快照。 同一行若在同一秒里被删两次(典型是「删掉 → 恢复 → 又删」),会撞名 —— 这时自动缀序号 (…-1)另开一份,绝不复用目录。复用会让 shutil.move 把上一份快照里的文件 静默覆盖掉(Windows 下 rename 失败会退化成 copy2 覆盖),属于看不出痕迹的丢数据。
4. 有代价的删除,会先告诉你
不是硬拦 —— 老大明确说过「每一项我都可以选择把它删除掉」。所以能删就删, 但该说清楚的说清楚,确认框里会列出来:
| 情形 | 提示内容 |
|---|---|
| 这条模型作品正在用 | 「这个模型入库过(作品库里的文件名:xxx.glb),删掉作品里会缺件」 |
| 这条 3D 被下游引用 | 会顺带把下游那行的外键置空(恢复时自动回填) |
| 任务还在 RUNNING | 提醒它可能还在跑,删了结果会落空 |
| 文件仍被别的记录引用 | 这些文件留在原地,回收区里会标 kept,告诉你删了但没搬 |
恢复之后这些提示自然失效 —— 因为一切回到了删除前的样子。
5. 三道安全闸
- 边界:只搬数据根之内(
asset_forge_data/)的文件,根外的路径一律不碰 - 共用:仍被其它台账行引用的文件不搬走(复用
cleanup.scan_references的引用扫描),
回收区条目标 kept,恢复时不重复搬
- 路径注入:
stamp参数校验,含/\..直接拒;不存在的快照 404;
已恢复过的快照再恢复 400(防重复插行)
6. 唯一不可逆的一步
回收区里「彻底删除」按钮 —— 这是全流程唯一真删磁盘文件的操作(shutil.rmtree), 有二次确认。除此之外,包括详情面板的删除、清理流程的清理,全都是可逆的。
7. 接口一览
前缀 /api(子路径部署时自动带 /forge 前缀)。
| Method | 路径 | 说明 | |
|---|---|---|---|
| GET | /recycle/items | 回收站结果项目列表(含已恢复的,前端把其恢复按钮置灰) | |
| POST | /recycle/delete | `{kind: "design"\ | "model3d", job_id: int}` → 移入回收站 |
| POST | /recycle/restore | {stamp: "..."} → 按快照原样恢复 | |
| POST | /recycle/purge | {stamp: "..."} → 彻底删除,不可逆 |
实现位置:
asset-forge/backend/app/services/recycle.py # 核心逻辑(快照/搬运/恢复/清除)
asset-forge/backend/app/routers/recycle.py # 路由
asset-forge/backend/app/schemas.py # RecycleDeleteRequest / RecycleStampRequest
asset-forge/frontend/src/api/recycle.ts # 前端调用封装
asset-forge/frontend/src/components/ResourceDetailPanel.vue # 每条结果的删除按钮
asset-forge/frontend/src/views/CleanupView.vue # 回收区「生成结果项」页签
8. 与文件级清理的分工
_trash/asset_forge/
├── 20260917-205234/ ← 文件级批次(cleanup 流程产出,整批恢复)
├── 20260917-222446/
├── ...
└── recycle/ ← 结果级快照(详情面板删除产出,逐条恢复)
└── 20260918-001234_design_35/
├── _item.json ← 整行原值(含原主键)
└── files/ ← 搬走的产物文件
同一个回收区根,两套独立快照,各自恢复互不牵连。前端合成一张列表展示。
9. 自检
cd D:\3d-game\asset-forge\backend
.venv\Scripts\python.exe ..\_tools\_smoke_recycle.py # 39 项断言
界面级(真点按钮,走单入口 :8080/forge):
cd D:\3d-game\asset-forge
node _shot_recycle.js # 27 项检查
回归(改这块必跑):
cd D:\3d-game\asset-forge\backend
.venv\Scripts\python.exe ..\_tools\_smoke_cleanup.py # 37 项
.venv\Scripts\python.exe ..\_tools\_smoke_prompt_gate.py # 113 项
cd .. && node _shot_cleanup.js # 44 项
三条注意:
- 必须用生成器的 venv 解释器(
asset-forge/backend/.venv/)。用系统 python 会
ModuleNotFoundError: No module named 'sqlmodel'。
- 界面级脚本跑之前确认三服务在线(
python tools/services.py status),且前端产物是带
/forge/ 前缀的那份(npm run build 时带 VITE_APP_BASE=/forge/),否则白屏、测试全红。
- 界面级脚本会真删一条真实记录再恢复 —— 选的是「松树」里编号最大的设计稿,
跑完自动把造出的快照「彻底删」掉,回收站回到跑之前的样子。
测试与生产必须隔离(血泪教训)
_smoke_recycle.py 把 rc.RECYCLE_ROOT 改指向自己的 _trash/asset_forge/_smoke_recycle/, 跑完连根拔掉。绝不能让它写生产回收区 —— 早先图省事直接写生产的,结果脚本被 | head -6 这类管道引发 SIGPIPE 掐死时 finally: cleanup() 跑不完, 在真实回收站里留下 30+ 条 design_39_20260918-… 垃圾快照;它们带完整 _item.json、 在界面上正常显示,看起来就像有人删了 39 号结果。
同一个坑还有第二层:cleanup() 原先按「字段里含 __smoke_rc__」认自建行, 但 FK 测试那条 Model3DJob 的 glb_path=""、meta 取默认 "{}",两个字段都不含 TAG, 于是每次跑漏 1 行,跑到第 9 次就在真实 ledger.db 里积出 9 条悬空 FK (#51,53,…,67 全指向已删的 design #40),还因为 SQLite 主键复用让下一轮断言假红。
⇒ 认行认主键,别认内容。现在脚本显式登记 (kind, pk),并在收尾自审里 核对「台账 0 悬空 FK + 生产回收区目录集合一字不差 + 测试回收区已清空」,不符就大声报。