feat(wordcloud): 收口在途开发(布局/存储/前端)+ R4 WCD 生产任务(jobs wcd_file)与生产订单列表
This commit is contained in:
+92
-15
@@ -12,7 +12,7 @@
|
||||
5. 估算字号范围 → weights.calculate_font_by_area_model()
|
||||
6. 小画布布局 → layout.OptimizedEfficientWordCloud.generate_from_frequencies()
|
||||
7. C++ 按真实字形找位置并原子写入 → IntegralGrid.place_glyph_exact()
|
||||
8. 整批未完整放入 → 统一缩放字号或扩大画布后重排
|
||||
8. 二分搜索能容纳全部姓名的最大字号缩放;仍放不下则扩大画布后重排
|
||||
9. 密度优化 → 探测更大字号并保留完整率不下降的候选
|
||||
10. 高清精修 → render.refine_layout_with_hd_clearance() 加入隔离带局部微调
|
||||
11. 输出 PNG / SVG / DB / metrics
|
||||
@@ -93,10 +93,15 @@ total_target = len(names) * N_REPETITIONS
|
||||
|
||||
## 放置策略
|
||||
|
||||
放置顺序按字号分档:大字号先随机撒开,其余再从中心螺旋填充。
|
||||
大字号需要整片空白才放得下,等螺旋填满画布就没有空间了,因此必须先放。
|
||||
档内按索引顺序放置,保证同一 `layout_seed` 完全可复现。
|
||||
|
||||
每个词的放置流程:
|
||||
|
||||
1. 根据目标分数得到目标字号(线性插值于 `min_font` 到 `max_font`)
|
||||
2. 随机决定横排或竖排(`prefer_horizontal` 控制概率)
|
||||
2. 逐词独立随机决定横排或竖排,竖排概率为 `VERTICAL_RATIO`(流水线以此换算 `prefer_horizontal = 1 - VERTICAL_RATIO`);
|
||||
当前竖排为整词旋转 90°(字符侧倒),不是字符直立的传统竖排
|
||||
3. 用 PIL 渲染真实字形 bitmap,施加单侧安全边距(margin)生成碰撞 mask
|
||||
4. 调用 C++ `place_glyph_exact()` 搜索合法位置并原子写入
|
||||
5. 当前方向找不到时,只尝试同字号的另一方向
|
||||
@@ -113,14 +118,33 @@ total_target = len(names) * N_REPETITIONS
|
||||
- `canvas`:真实占用像素(`1` 表示已占用或掩膜阻挡)
|
||||
- `data`:兼容旧矩形查询的积分图;当前主路径不依赖逐词重建
|
||||
|
||||
`place_glyph_exact()` 行为:
|
||||
`place_glyph_exact()` 有三种放置模式,由 `layout.py` 按字号分配:
|
||||
|
||||
1. 等字号批次前 `70%` 姓名优先选择靠近可填区域质心的合法位置,避免少量姓名接受第一个随机空位而形成大块空洞
|
||||
2. 多字号批次只对前 `25%` 以及高权重(`score ≥ 0.80`)姓名启用中心偏好
|
||||
3. 少于 `100` 人的等字号批次中心候选数提高到 `768`;`100–300` 人提高到 `512`;其余保持 `256`
|
||||
4. 随机探测失败后,从种子决定的偏移开始完整扫描
|
||||
5. 对每个候选位置逐像素比较碰撞 mask 与 C++ `canvas`
|
||||
6. 命中后只写入真实字形,占位查询和写入在同一次 C++ 调用中完成
|
||||
**大字号(`placement_mode=2`)**:字号达到 `min_font + (max_font - min_font) * 0.80` 的姓名先放,随机探测取第一个合法位置。
|
||||
探测受一个软半径约束:前 `75%` 次探测限制在质心周围 `0.55 → 1.0` 倍掩膜半径内,之后放开。
|
||||
约束只影响落点偏好,不会排除任何合法位置,因此不影响完整率。
|
||||
等字号批次中 `max_font == min_font`,不存在大字号档,全部走螺旋。
|
||||
|
||||
**小字号(`placement_mode=1`)**:先试可填区域质心,再沿费马螺旋(黄金角 `2.39996`,`radius = 1.25 * sqrt(step)`)向外搜索。
|
||||
`spiral_cursor` 在整批姓名间持续推进。每个词以随机相位 `theta_offset ∈ [0, 2π)` 起扫,
|
||||
使相邻两词的角距不再恒为黄金角;随机量取自按 `layout_seed` 派生的逐词种子,同一种子完全可复现,
|
||||
不同种子给出真正不同的排布,而不只是同一图形换名字。
|
||||
|
||||
相位必须取满整圈。曾尝试限制在 `±60°` 的窄扇区内,结果明显更差:
|
||||
扇区会让词跳过边界上已经合法的位置而退到更差的位置,实测 800 词的最终墨水密度下降 31%(`0.255 → 0.176`)。
|
||||
取满整圈则不损失密度,因为半径增长时扫描本来就会覆盖所有角度。
|
||||
|
||||
注意这并不会让排布显得无序:半径仍与放置顺序高度相关(Pearson `r ≈ 0.98`)。
|
||||
中心向外且保持紧密的填充必然按半径递增推进——「有序」与「紧密」是同一件事。
|
||||
要在不牺牲密度的前提下打散这种观感,需要多个螺旋原点,而不是在单一螺旋上加抖动。
|
||||
|
||||
**兼容模式(`placement_mode=0`)**:纯随机探测,取第一个合法位置。
|
||||
|
||||
三种模式共用后续步骤:
|
||||
|
||||
1. 随机探测失败后,从种子决定的偏移开始环形完整扫描,保证存在合法位置时一定能找到
|
||||
2. 对每个候选位置逐像素比较碰撞 mask 与 C++ `canvas`
|
||||
3. 命中后只写入真实字形,占位查询和写入在同一次 C++ 调用中完成
|
||||
|
||||
真实字形搜索允许透明角落和笔画间空隙安全交错,比外接矩形碰撞更密。安全边距只参与候选检查,不会被双侧累计放大。
|
||||
|
||||
@@ -132,18 +156,50 @@ total_target = len(names) * N_REPETITIONS
|
||||
|
||||
1. 小画布完整布局放大到高清
|
||||
2. 逐词在高清画布上验证,加入 `1px` 隔离带
|
||||
3. 碰撞时只允许最大 `max_shift=24px` 的局部微位移
|
||||
4. 精修失败时,最多进行 `1` 次确定性整批优先级回溯(把失败词移到队列最前)
|
||||
5. 若轮廓过窄无法容纳额外隔离带,降为精确零间隙碰撞(`clearance=0`)
|
||||
6. 全部候选无解时,由外层扩大画布后整批重新布局,不进行远距离单词搬移
|
||||
3. 碰撞时先在 `max_shift=24px` 内做局部微位移
|
||||
4. 邻域内无解时,`_find_free_placement()` 以逐步放大的窗口(`256 → 1024 → 全画布`)在整张画布上找空位,
|
||||
取离原位置最近的一个。窗口内用积分图筛选:足迹范围内完全空白的位置必定可放,
|
||||
无需逐像素比较,因此绝大多数候选位置只花两次加法就被排除。
|
||||
这一步把「一个词放不下就整条流水线换更大画布重来」变成一次局部搬移——
|
||||
后者是全流程中最贵的失败路径,实测会让端到端耗时翻倍且仍可能最终失败
|
||||
5. 精修失败时,最多进行 `1` 次确定性整批优先级回溯(把失败词移到队列最前)
|
||||
6. 若轮廓过窄无法容纳额外隔离带,降为精确零间隙碰撞(`clearance=0`)
|
||||
7. 全部候选无解时,才由外层扩大画布后整批重新布局
|
||||
|
||||
密度搜索会保留各档完整整批候选。最高密度候选若无法在有限位置修正范围内通过高清隔离验收,则改用上一档完整整批候选;不会对碰撞姓名单独缩字号,也不会接受带重叠的高密度结果。
|
||||
|
||||
## 字号缩放二分搜索
|
||||
|
||||
目标是找到**能放下全部姓名且轮廓覆盖最好的字号缩放**。单纯追求最大字号会让费马螺旋把词压在质心圆盘内、走不到掩膜远端,于是非圆形掩膜(心形尖端、人物四肢)填成圆形。
|
||||
|
||||
流水线用「探测」来判定某个缩放是否可行。探测在遇到第一个放不下的姓名时立刻停止:
|
||||
一个姓名只有在螺旋、随机探测和全画布穷举扫描都失败后才算放不下,所以单个失败即可证明该缩放不可行,
|
||||
不必把整批跑完。这一点对速度至关重要——放不下的姓名要付出完整搜索的代价,
|
||||
实测约为可放下姓名的 `10` 倍,把注定失败的整批跑到底是流水线中最昂贵的操作。
|
||||
`OptimizedEfficientWordCloud.max_failures` 控制这一行为,为 `None` 时跑满整批并尽量多放。
|
||||
|
||||
搜索过程:
|
||||
|
||||
1. 从 `scale=1.0` 开始探测;失败则按 `sqrt(已放置比例)` 收缩再试,最多 `4` 次
|
||||
2. 得到一个可行值后,在最大失败值与最小可行值之间二分最多 `3` 次,把之前收缩让掉的字号找回来
|
||||
3. 相邻两个缩放取整后字号相同时停止——工作网格上字号是小整数,再细分没有意义
|
||||
4. 多个可行结果中取**形状覆盖度最高**的那个(`compute_coverage_score`),覆盖度并列时才取缩放更大者
|
||||
|
||||
### 形状覆盖度
|
||||
|
||||
`compute_coverage_score()` 把可填区域切成 `8×8` 像素的块,只统计可填像素占比 ≥ 30% 的「区域块」,计算其中被墨迹触达的比例:
|
||||
|
||||
```
|
||||
coverage = 被触达的区域块数 / 区域块总数
|
||||
```
|
||||
|
||||
这是「轮廓是否被填出来」的直接度量:质心圆盘只触达中心几块,覆盖度低;铺进掩膜每个臂/尖端的布局触达各块,覆盖度高。块粒度(而非逐像素加权)让它对掩膜几何稳健——一个尖端无论宽 3px 还是 30px 都是一个块,触达它都被同等奖励。
|
||||
|
||||
## 填充率重试
|
||||
|
||||
一次布局完成后,`compute_fill_ratio_fast()` 重新渲染 layout 并计算填充率。
|
||||
一次布局完成后,`compute_fill_ratio_fast()` 重新渲染 layout 并计算填充率,`compute_coverage_score()` 计算形状覆盖度。
|
||||
|
||||
- 面积模型首次完整放入但明显低于 `TARGET_FILL_RATIO` 时,只允许一次整批等比例增字号尝试。新布局必须仍然完整且真实填充率更高才会采用
|
||||
- 面积模型首次完整放入但明显低于 `TARGET_FILL_RATIO` 时,进入**双向密度优化**:每轮同时探测「加大字号」和「减小字号」两个方向,取覆盖度更高的候选。减小字号让费马螺旋走更远、触达掩膜远端,即使填充率略降也能提升覆盖度——这正是纠正「填成圆形」的关键方向。覆盖度与填充率都无提升时停止
|
||||
- 整批未完整放入时,流水线不会输出半成品:先整批等比例调整字号;触及最小字号仍失败时按 `CANVAS_RETRY_GROWTH` 扩大画布并重新生成
|
||||
|
||||
## 字号硬约束
|
||||
@@ -176,3 +232,24 @@ new_w = clamp(base_w × scale_factor, max_edge=6000)
|
||||
- 扩展后宽高向上取整到最近的 `100`
|
||||
- 最大边长限制为 `6000px`,避免 SVG/PNG 过度膨胀
|
||||
- 掩膜只生成一次,扩展后复用
|
||||
|
||||
## 性能:按字符缓存
|
||||
|
||||
一份中文名单里不同**字符**的数量远小于不同**姓名**的数量——750 个三字姓名通常只含约 `34` 个不同字符。
|
||||
两处最重的工作因此按字符而不是按姓名缓存:
|
||||
|
||||
- **SVG 轮廓**(`layout._char_shape`):每个 `(字符, 字号, 方向)` 只取一次字形轮廓并格式化一次路径字符串。
|
||||
一个姓名由若干字符路径拼成,字符在词内的位置放进元素的 `transform` 平移量,
|
||||
所以缓存的路径字符串被逐字节复用,不需要重新解析或平移坐标。
|
||||
导出时每个字形输出一个 `<path>`,几何结果与整词单路径完全一致(已逐点验证)。
|
||||
- **高清字形位图**(`render._word_ink`):隔离精修与独立的重叠审计会在相同字号下光栅化相同姓名,
|
||||
两者共用一份缓存。
|
||||
|
||||
`_path_bbox()` 只在每个字符首次构建时调用一次,词的包围盒由各字符包围盒平移后取并集算出,
|
||||
不再对生成好的路径字符串做正则重解析。
|
||||
|
||||
## 零重叠保证
|
||||
|
||||
输出前 `count_layout_overlap_pixels()` 会独立重渲染整个 layout 并统计被两个及以上词占用的像素。
|
||||
该值必须为 `0`,否则 `placement_ok` 为假,流水线拒绝输出而不是交付带重叠的结果。
|
||||
这项校验独立于隔离精修,即使精修逻辑有误也能兜住。
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
# 画布模板导入导出包(`.wcd`)方案(规划)
|
||||
|
||||
> 状态:第一版已实现(画布导出 `.wcd`、首页导入 `.wcd`)。
|
||||
> 目的:实现画布/设计的完整导入导出,要求包内不仅包含画布尺寸、背景、图层、元素摆放信息,还要把元素用到的素材一起带出去,使得包可以在另一台机器或另一个实例中导入复用。
|
||||
|
||||
## 1. 包格式
|
||||
|
||||
建议采用 Zip 包,文件后缀为 `.wcd`。没有必要自定义二进制格式。
|
||||
|
||||
预期目录结构:
|
||||
|
||||
```
|
||||
example.wcd
|
||||
├── manifest.json // 包元数据与 schema version
|
||||
├── document.json // CanvasDocument:画布结构
|
||||
├── preview.png // 可选封面图
|
||||
├── fonts/ // 可选字体文件
|
||||
│ └── ...
|
||||
└── assets/
|
||||
├── asset-001.svg
|
||||
├── asset-002.png
|
||||
└── asset-003.svg
|
||||
```
|
||||
|
||||
## 2. manifest.json
|
||||
|
||||
```json
|
||||
{
|
||||
"format": "wordcloud-canvas",
|
||||
"version": 1,
|
||||
"name": "海报模板",
|
||||
"description": "示例模板",
|
||||
"createdAt": "2026-08-06T00:00:00Z",
|
||||
"canvas": {
|
||||
"width": 1600,
|
||||
"height": 1000,
|
||||
"background": "#ffffff"
|
||||
},
|
||||
"assets": [
|
||||
{
|
||||
"id": "asset-001",
|
||||
"originalAssetId": "asset_xxx",
|
||||
"name": "词云 A",
|
||||
"type": "svg",
|
||||
"mimeType": "image/svg+xml",
|
||||
"sha256": "abc...",
|
||||
"size": 1024
|
||||
}
|
||||
],
|
||||
"fonts": []
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
- `format`:固定标识,防止其他 Zip 被误导入。
|
||||
- `version`:包格式版本,后续升级时便于兼容。
|
||||
- `assets[].id`:包内临时 ID,只在这个包内有效。
|
||||
- `originalAssetId`:导出时的来源素材 ID,仅记录,不要求导入后保留。
|
||||
- `sha256`:可选,导入时用于去重。
|
||||
|
||||
## 3. document.json
|
||||
|
||||
`document.json` 就是当前前端的 `CanvasDocument` 模型:
|
||||
|
||||
- `width`:画布宽度。
|
||||
- `height`:画布高度。
|
||||
- `background`:画布背景色。
|
||||
- `layers`:图层列表。
|
||||
- `layerFolders`:图层文件夹列表。
|
||||
- `elements`:元素列表。
|
||||
|
||||
对 Sticker 元素有一个关键规则:**导出时把 `assetId` 替换成包内临时 ID**。
|
||||
|
||||
示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"width": 1600,
|
||||
"height": 1000,
|
||||
"background": "#ffffff",
|
||||
"layers": [
|
||||
{ "id": "layer-1", "name": "词云", "visible": true, "locked": false }
|
||||
],
|
||||
"layerFolders": [],
|
||||
"elements": [
|
||||
{
|
||||
"id": "element-1",
|
||||
"type": "sticker",
|
||||
"assetId": "asset-001",
|
||||
"x": 100,
|
||||
"y": 80,
|
||||
"width": 800,
|
||||
"height": 500,
|
||||
"rotation": 0,
|
||||
"opacity": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 4. 导出流程
|
||||
|
||||
建议由后端提供导出接口,例如:
|
||||
|
||||
```text
|
||||
GET /api/designs/{id}/export
|
||||
```
|
||||
|
||||
或当前模板库扩展为:
|
||||
|
||||
```text
|
||||
GET /api/design-templates/{id}/export
|
||||
```
|
||||
|
||||
导出步骤:
|
||||
|
||||
1. 从数据库读取画布文档 `design_documents`。
|
||||
2. 序列化 `CanvasDocument`。
|
||||
3. 遍历 Sticker 元素,收集所有真实素材 ID。
|
||||
4. 读取每个素材文件字节。
|
||||
5. 为每个素材生成包内 ID,例如 `asset-001`。
|
||||
6. 用包内 ID 替换 `document.json` 中的 `assetId`。
|
||||
7. 把素材写入 `assets/` 目录。
|
||||
8. 可选:生成 `preview.png` 作为导入时的缩略图。
|
||||
9. 可选:如果模板使用了后端字体,把字体文件写入 `fonts/`。
|
||||
10. 生成 `manifest.json`。
|
||||
11. 打包为 `.wcd` 并返回。
|
||||
|
||||
## 5. 导入流程
|
||||
|
||||
建议后端提供导入接口,例如:
|
||||
|
||||
```text
|
||||
POST /api/designs/import
|
||||
multipart/form-data: file=.wcd
|
||||
```
|
||||
|
||||
导入步骤:
|
||||
|
||||
1. 把 `.wcd` 解压到临时目录。
|
||||
2. 校验 `manifest.json`:
|
||||
- 是否是 `wordcloud-canvas` 格式。
|
||||
- `version` 是否兼容。
|
||||
- `document.json` 是否结构合法。
|
||||
3. 读取 `document.json` 并通过现有 `normalizeDocument` 逻辑归一化。
|
||||
4. 逐个处理 `assets/` 下素材:
|
||||
- 计算 SHA-256。
|
||||
- 如果素材表中已有相同 SHA-256,复用已有素材 ID。
|
||||
- 否则调用素材导入逻辑写入 `assets` 表 + 文件系统。
|
||||
5. 把 `document.json` 中的包内 `assetId` 重新映射为真实素材 ID。
|
||||
6. 保存为新的 `design_documents`。
|
||||
7. 可选:把 `preview.png` 作为模板封面。
|
||||
8. 返回新设计/模板 ID。
|
||||
|
||||
## 6. 与现有模板系统的关系
|
||||
|
||||
当前模板保存是把 `CanvasDocument` 和 `reference_asset_ids` 写到目录 JSON 里:
|
||||
|
||||
- 在线模板:保持后端素材引用,适合当前实例内复用。
|
||||
- `.wcd`:把素材一起打包,适合跨机器/离线/换实例导入导出。
|
||||
|
||||
两者最终统一到:
|
||||
|
||||
- `design_documents`:存画布文档。
|
||||
- `assets`:存素材元数据。
|
||||
- `design_templates`:存模板元数据并引用素材。
|
||||
|
||||
`.wcd` 只是外部交换容器。
|
||||
|
||||
## 7. 边界与设计决策
|
||||
|
||||
### 先不做自定义二进制格式
|
||||
|
||||
Zip + JSON 足够,方便调试、校验和后续扩展。
|
||||
|
||||
### 不把素材 base64 塞进 document.json
|
||||
|
||||
素材单独放文件,避免 JSON 膨胀;`document.json` 只保存引用 ID。
|
||||
|
||||
### 素材缺失处理
|
||||
|
||||
导出时如果某个素材文件缺失,可以选择:
|
||||
|
||||
- 导出失败并提示哪个素材缺失。
|
||||
- 或在 `manifest` 中标记为 `missing`,导入时提示并跳过。
|
||||
|
||||
建议第一版采用“导出失败并提示”,保证导入包完整。
|
||||
|
||||
### 字体处理
|
||||
|
||||
第一版建议只保留 `fontFamily` 字符串,不打包字体。
|
||||
|
||||
后续如果确实需要跨机器还原,再把字体文件放进 `fonts/`,导入时注册到字体库。
|
||||
|
||||
### 去重
|
||||
|
||||
`assets.sha256` 是导入去重的关键字段:
|
||||
|
||||
- 包内相同素材只存一次。
|
||||
- 多次导入相同素材时直接复用数据库中的现有素材。
|
||||
|
||||
## 8. 分阶段实施
|
||||
|
||||
### 阶段一:最小可用包(已完成)
|
||||
|
||||
- 定义 `.wcd`,包含 `manifest.json`、`document.json`、`assets/`。
|
||||
- 支持导出当前画布或模板。
|
||||
- 支持解压导入,只处理贴纸素材和画布布局。
|
||||
- 不处理字体,不生成 preview。
|
||||
|
||||
### 阶段二:导入体验完善
|
||||
|
||||
- 生成 `preview.png`。
|
||||
- 导入时检查素材缺失。
|
||||
- 支持同一设计重复导入去重。
|
||||
|
||||
### 阶段三:与 PostgreSQL 打通
|
||||
|
||||
- `POST /api/designs/import` 最终写入 `design_documents`。
|
||||
- 素材导入自动写入 `assets` 表。
|
||||
- 导出接口直接读取 `design_documents` 和 `assets`,不再依赖前端状态。
|
||||
|
||||
## 9. 相关文件参考
|
||||
|
||||
- `frontend/src/lib/svgExport.ts`:当前图层 ZIP 导出。
|
||||
- `frontend/src/lib/templateLibrary.ts`:当前模板保存/读取。
|
||||
- `frontend/src/lib/canvasDocument.ts`:`CanvasDocument` 模型与归一化。
|
||||
- `frontend/src/types.ts`:`CanvasDocument`、`StickerAsset` 等类型。
|
||||
- `backend/service/app.py`:当前 `/api/assets`、`/api/design-templates` 接口。
|
||||
- `docs/DESIGN_DATA_STORAGE_PLAN.md`:存储层重构后文档落库设计。
|
||||
@@ -34,6 +34,7 @@ backend/service_workspace/{job_id}/config.json
|
||||
| `strokeWeights` | `ENABLE_STROKE_WEIGHTS` |
|
||||
| `sizeRatio` | `SIZE_RATIO` |
|
||||
| `packingEfficiency` | `PACKING_EFFICIENCY` |
|
||||
| `verticalRatio` | `VERTICAL_RATIO` |
|
||||
| `targetFillRatio` | `TARGET_FILL_RATIO` |
|
||||
| `userMinFontSize` | `USER_MIN_FONT_SIZE` |
|
||||
| `userMaxFontSize` | `USER_MAX_FONT_SIZE` |
|
||||
@@ -92,6 +93,7 @@ backend/service_workspace/{job_id}/config.json
|
||||
| `TARGET_FILL_RATIO` | `0.45` | 面积模型目标笔画填充率 |
|
||||
| `SIZE_RATIO` | `2.0` | `max_font` 相对 `min_font` 的比例;`1.0` 为等字号模式 |
|
||||
| `PACKING_EFFICIENCY` | `0.9` | 面积模型中的打包效率 |
|
||||
| `VERTICAL_RATIO` | `0.18` | 竖排概率,逐词独立抽取;`0.0` 全部横排,`1.0` 全部竖排 |
|
||||
|
||||
### 字号硬约束
|
||||
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
# 业务数据存储优化与指标报告
|
||||
|
||||
> 状态:已完成第一阶段可审计代码改造;未执行破坏性清理。
|
||||
> 目标:减少无效任务文件、把任务元数据从“内存 + 散落目录 JSON”提升为“可恢复元数据存储”,并为后续 PostgreSQL 迁移留出接口。
|
||||
|
||||
## 1. 优化前现状
|
||||
|
||||
### 任务存储
|
||||
|
||||
- 任务状态 / 事件只存在于 `JobManager._jobs` 内存中,服务重启即丢失。
|
||||
- `POST /api/jobs` 每次提交都会立刻创建 `service_workspace/{job_id}/input`、`output` 和配置文件。
|
||||
- 任务完成后没有 TTL / 引用检查清理逻辑;历史任务目录会一直留在磁盘。
|
||||
- `service_workspace` 中大量任务目录只是“曾经跑过一次”的产物,没有被任何素材或模板引用。
|
||||
|
||||
### 素材存储
|
||||
|
||||
- 素材文件保留在 `service_assets/{asset_id}/asset.*`,元数据写在目录内 `meta.json`。
|
||||
- 普通的 `POST /api/assets` 没有写入 `sha256`,只有 `.wcd` 导入路径开始做内容去重。
|
||||
- 前端贴纸 `tint` 仍放在 localStorage,没有回到服务端统一维护。
|
||||
|
||||
### 当前实际磁盘基线(2026-08-06 扫描)
|
||||
|
||||
| 项 | 数量 / 大小 |
|
||||
|---|---|
|
||||
| `service_workspace` 任务目录 | 193 个 |
|
||||
| `service_workspace` 总大小 | 2.44 GiB / 2,618,726,669 bytes |
|
||||
| 被素材 `job_id` 引用的任务目录 | 6 个 |
|
||||
| 未被任何素材引用的任务目录 | 187 个 |
|
||||
| 未引用任务目录总大小 | 2.38 GiB / 2,556,506,642 bytes |
|
||||
| 素材文件 | 40 个,合计约 95.6 MiB |
|
||||
| 素材中重复内容多占空间 | 6 个额外文件,约 2.34 MiB |
|
||||
| 设计模板 JSON | 4 个 |
|
||||
|
||||
## 2. 优化方案与已落地改动
|
||||
|
||||
### 1) 新增业务元数据存储层
|
||||
|
||||
新增 `backend/service/metadata_store.py`:
|
||||
|
||||
- SQLite 单文件 `backend/service_metadata/app.db`。
|
||||
- 建 `jobs` 和 `job_events` 两张表。
|
||||
- 任务创建、状态更新、产物路径、SSE 事件都会落库。
|
||||
- `JobManager` 启动时可以从数据库恢复任务,不再完全依赖内存。
|
||||
- 表结构有意保持“一行元数据 + JSONB/JSON 字段”风格,后续迁移到 PostgreSQL 时主体字段不变。
|
||||
|
||||
### 2) 任务目录可审计与可清理
|
||||
|
||||
扩展 `backend/service/storage.py`:
|
||||
|
||||
- `job_dir_size()` / `job_dir_info()`:按任务统计占用。
|
||||
- `stale_job_dirs()`:按“未被素材引用、不在元数据库、可选按年龄”筛选遗留目录。
|
||||
- `remove_job_dir()`:提供精确清理,只清理 `service_workspace` 下的任务目录。
|
||||
|
||||
新增 `backend/service/storage_metrics.py`:
|
||||
|
||||
- 默认 `dry-run`,只扫描并输出可回收空间。
|
||||
- 只有显式 `--apply` 才会删除遗留任务目录。
|
||||
- 示例:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
.venv/bin/python -m service.storage_metrics --max-age-days 0
|
||||
.venv/bin/python -m service.storage_metrics --max-age-days 0 --json ../docs/storage-metrics.json
|
||||
# 确认后执行
|
||||
.venv/bin/python -m service.storage_metrics --max-age-days 0 --apply
|
||||
```
|
||||
|
||||
### 3) 素材去重基础
|
||||
|
||||
- `.wcd` 导入路径已按 `sha256` 去重素材。
|
||||
- `service_assets/{id}/meta.json` 中新增 `sha256` 字段。
|
||||
- 后续 `POST /api/assets` 也可以统一补充哈希,形成服务级去重。
|
||||
|
||||
### 4) 可实时查询指标
|
||||
|
||||
新增只读接口:
|
||||
|
||||
```text
|
||||
GET /api/maintenance/storage-summary
|
||||
```
|
||||
|
||||
返回指标包括:
|
||||
|
||||
- `job_dir_count`:当前任务目录数。
|
||||
- `referenced_job_ids`:被素材引用的任务数。
|
||||
- `stale_job_count`:可回收任务数。
|
||||
- `reclaimable_bytes`:可回收字节数。
|
||||
- `jobs_in_db` / `events_in_db`:当前元数据分录数。
|
||||
- `dry_run_only`: `true`,明确该接口不做删除。
|
||||
|
||||
## 3. 优化后指标
|
||||
|
||||
### 空间收益(只做审计,未执行删除)
|
||||
|
||||
| 指标 | 优化前 | 优化后可清理 | 优化后保留 |
|
||||
|---|---:|---:|---:|
|
||||
| 任务目录 | 193 | 187 | 6(被素材引用) |
|
||||
| 任务文件占用 | 2.44 GiB | 2.38 GiB | ~59.3 MiB |
|
||||
| 任务空间占用 | 100% | 可回收 97.6% | 首个保护区约 2.4% |
|
||||
|
||||
换算:
|
||||
|
||||
- 2,556,506,642 bytes ≈ 2.38 GiB。
|
||||
- 若执行清理,仅任务目录可释放约 **2.38 GiB**。
|
||||
- 清理后任务目录可降到约 **59.3 MiB**,即保留的部分仍是当前贴纸真正引用的任务产物。
|
||||
|
||||
### 素材去重收益
|
||||
|
||||
当前 40 个素材文件中有 6 个属于重复内容,去重后:
|
||||
|
||||
- 少存 6 个文件。
|
||||
- 可节省 2,455,630 bytes,约 **2.34 MiB**。
|
||||
- 素材文件数量从 40 → 34 个唯一内容。
|
||||
|
||||
这个数字目前不大,因为很多重复不到 100KB;真正大头仍是任务目录。
|
||||
|
||||
### 效能与可维护性收益
|
||||
|
||||
1. **任务可恢复**
|
||||
- 原来重启服务后任务状态、进度、事件全部丢失。
|
||||
- 现在 `jobs` / `job_events` 落库,启动时可恢复。
|
||||
|
||||
2. **查询由全盘扫描变为索引查询**
|
||||
- 原来 `list_jobs` 只读内存;任务详情依赖内存里的事件列表。
|
||||
- 现在有持久化事件表和 `job_id` 索引,可追溯历史。
|
||||
|
||||
3. **清理依据可计算**
|
||||
- 原来“哪个目录能删”靠人工判断。
|
||||
- 现在可统计“是否被素材引用、是否在元数据库、目录多老”,避免误删正在使用的任务。
|
||||
|
||||
4. **存储成本上限可控**
|
||||
- 配合 TTL 清理,后续每新增任务产生的产物会在保留期后被回收。
|
||||
- 不会继续无限制累积。
|
||||
|
||||
## 4. 建议后续执行步骤
|
||||
|
||||
1. 确认当前项目不再需要 187 个旧任务产物后,执行:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
.venv/bin/python -m service.storage_metrics --max-age-days 0 --apply
|
||||
```
|
||||
|
||||
2. 把 `metadata_store.py` 从 SQLite 迁移到 PostgreSQL:
|
||||
|
||||
- 安装 SQLAlchemy / asyncpg。
|
||||
- `docker-compose.yml` 增加 PostgreSQL 服务。
|
||||
- 将 `jobs` / `job_events` / `assets` / `design_documents` 迁到 PG。
|
||||
|
||||
3. 把贴纸 `tint` 从 localStorage 迁到 `assets` 元数据,并由后端 `PATCH /api/assets/{id}` 维护。
|
||||
|
||||
4. `POST /api/assets` 统一补 `sha256`,实现服务级素材去重。
|
||||
|
||||
5. 增加后台定时清理任务,例如保留 7 天、30 天两档。
|
||||
|
||||
## 5. 相关文件
|
||||
|
||||
- `backend/service/metadata_store.py`
|
||||
- `backend/service/job_manager.py`
|
||||
- `backend/service/storage.py`
|
||||
- `backend/service/storage_metrics.py`
|
||||
- `backend/service/app.py`
|
||||
- `docs/DESIGN_DATA_STORAGE_PLAN.md`
|
||||
@@ -0,0 +1,173 @@
|
||||
# 存储与数据库重构方案(规划)
|
||||
|
||||
> 状态:第一阶段部分已落地(任务元数据落 SQLite、任务清理审计、素材 sha256)。
|
||||
> 目的:后端当前仍以“内存 + 文件 + 任务级 SQLite + meta.json”为主要存储方式。本文档规划后续迁移到 PostgreSQL 元数据库,并优化任务生命周期和贴纸持久化。
|
||||
|
||||
## 1. 当前现状
|
||||
|
||||
| 数据 | 当前存储 | 问题 |
|
||||
|---|---|---|
|
||||
| 任务状态 / 事件 | `JobManager._jobs` 仅存内存 | 重启服务后任务记录丢失 |
|
||||
| 任务输入 / 输出 | `backend/service_workspace/{job_id}` | 提交任务即建目录,未完成或被放弃的任务会遗留文件 |
|
||||
| 词云坐标结果 | 每个任务生成一个 `word_locations.db`(SQLite) | 每个任务自带一份 SQLite 文件,查询分散 |
|
||||
| 贴纸素材 | `backend/service_assets/asset_xxx/asset.svg` + `meta.json` | 素材元数据不是数据库,tint 等前端信息还依赖 localStorage |
|
||||
| 设计模板 / 工程 | `service_design_templates` / `service_projects` 目录 + JSON | 模板和工程之间缺少数据库关联 |
|
||||
| 画布文档 | 前端 localStorage | 无法跨设备,也无法作为后端权威数据 |
|
||||
|
||||
注意:当前不能认为系统已经在使用 PostgreSQL。代码中出现的 `*.db` 是词云算法自己写的 SQLite 结果文件,例如 `backend/core/pipeline.py` 的 `word_locations` 表。
|
||||
|
||||
## 2. 目标架构
|
||||
|
||||
整体原则:
|
||||
|
||||
- **文件继续存文件系统或对象存储**(SVG / PNG / 遮罩 / Excel / 字体)。
|
||||
- **业务元数据和引用关系存 PostgreSQL**。
|
||||
- 数据库保存路径引用,不保存大文件内容。
|
||||
|
||||
目标模型:
|
||||
|
||||
| 表 | 用途 | 说明 |
|
||||
|---|---|---|
|
||||
| `jobs` | 任务主表 | job_id、状态、参数 JSONB、产物引用、创建时间 |
|
||||
| `job_events` | 任务进度事件 | SSE 进度事件落库,服务重启后可恢复 |
|
||||
| `assets` | 贴纸 / 素材表 | 素材元数据、文件路径、来源 job、sha256、tint |
|
||||
| `design_documents` | 画布文档 | CanvasDocument JSONB,绑定模板/工程 |
|
||||
| `design_templates` | 模板 | 模板元数据 + 画布文档引用 |
|
||||
| `projects` | 工程 | 模板 + 画布文档 + 素材引用 |
|
||||
|
||||
### jobs 表字段建议
|
||||
|
||||
```text
|
||||
id uuid pk
|
||||
status text -- submitted/running/success/failed/cancelled
|
||||
stage text
|
||||
progress int
|
||||
message text
|
||||
params jsonb -- 用户提交的词云参数
|
||||
input_files jsonb -- mask/excel/font 引用
|
||||
artifacts jsonb -- png/svg/svg_stroke/db/metrics 路径或文件 id
|
||||
error text
|
||||
created_at timestamptz
|
||||
updated_at timestamptz
|
||||
retention_until timestamptz -- 清理时间
|
||||
```
|
||||
|
||||
### assets 表字段建议
|
||||
|
||||
```text
|
||||
id uuid pk
|
||||
name text
|
||||
type text -- wordcloud / upload / shape / reference
|
||||
mime_type text
|
||||
storage_key text -- 文件系统路径或对象存储 key
|
||||
width int
|
||||
height int
|
||||
file_size bigint
|
||||
sha256 text -- 用于导入去重
|
||||
source_job_id uuid nullable
|
||||
tint text nullable
|
||||
created_at timestamptz
|
||||
deleted_at timestamptz nullable
|
||||
```
|
||||
|
||||
## 3. 任务存储链路
|
||||
|
||||
现状是 `POST /api/jobs` 提交时直接创建 job 目录和保存上传文件。
|
||||
|
||||
目标改动:
|
||||
|
||||
1. `POST /api/jobs` 只写 `jobs` 表,状态为 `submitted` 或 `queued`。
|
||||
2. 上传文件先落到临时上传区,或延迟到进入 runner 前再落盘。
|
||||
3. runner 真正开始时才创建任务的 `input/` 和 `output/` 目录。
|
||||
4. 任务完成后把产物路径/文件 id 写入 `jobs.artifacts`。
|
||||
5. 增加后台清理任务:
|
||||
- 清理 `completed` 且未被贴纸/工程引用的任务文件。
|
||||
- 支持按 `retention_until` 保留最近结果。
|
||||
- 被用户导入为贴纸的任务文件可延长保留时间。
|
||||
|
||||
这样不会每次申请都攒下一堆用不上的目录和文件。
|
||||
|
||||
## 4. 贴纸持久化
|
||||
|
||||
贴纸在当前 `frontend/src/lib/stickerLibrary.ts` 中已经走后端 `POST /api/assets`,但元数据仍写在 `meta.json`,tint 还保存在 localStorage。
|
||||
|
||||
目标改动:
|
||||
|
||||
- `assets` 表作为贴纸唯一权威来源。
|
||||
- `POST /api/assets`:写文件系统 + 写 `assets` 表,返回 `asset_id`。
|
||||
- `GET /api/assets`:从数据库读取列表。
|
||||
- `PATCH /api/assets/{id}`:更新 tint、name 等元数据。
|
||||
- `DELETE /api/assets/{id}`:物理删除文件 + 记录,或软删除防止破坏设计文档引用。
|
||||
- `POST /api/assets/from-job/{job_id}`:沿用同一逻辑,写入 `source_job_id`。
|
||||
- 前端不再依赖 localStorage 保存贴纸 tint,加载和更新都走 API。
|
||||
|
||||
## 5. 画布文档与模板
|
||||
|
||||
当前画布保存在 localStorage,模板保存成目录 JSON。
|
||||
|
||||
目标改动:
|
||||
|
||||
- `design_documents` 保存 `CanvasDocument` JSONB。
|
||||
- 画布每次保存调用 `PUT /api/documents/{id}`。
|
||||
- `design_templates` 引用 `design_documents`,同时记录 `reference_asset_ids` 和封面图。
|
||||
- 后续实现画布导出导入时,导入包可直接写入 `design_documents`,并把包内素材批量写入 `assets` 表。
|
||||
|
||||
## 6. PostgreSQL 接入方式
|
||||
|
||||
建议:
|
||||
|
||||
- 引入 SQLAlchemy(或 asyncpg)作为数据库访问层。
|
||||
- 使用 Alembic 管理 migration。
|
||||
- 在 `docker-compose.yml` 增加 PostgreSQL 服务。
|
||||
- 通过环境变量注入 `DATABASE_URL`,本地开发和 Docker 使用不同配置。
|
||||
- 暂不把词云算法的 `word_locations` 表强制迁移到 PostgreSQL,可以保留 SQLite 作为任务内部产物,再通过导出接口把需要的布局结果写入 `jobs` 或独立布局表中。
|
||||
|
||||
## 7. 分阶段实施
|
||||
|
||||
### 阶段一:接入 PostgreSQL,先做贴纸和任务元数据(任务元数据已用 SQLite 先行落地)
|
||||
|
||||
- 建 `assets` / `jobs` / `job_events` 表。
|
||||
- `assets` 接口从文件 meta 迁移到 DB。
|
||||
- 提交任务仍可使用现有 runner,但把任务状态写入 DB。
|
||||
- 不改动词云算法核心。
|
||||
|
||||
### 阶段二:任务生命周期优化(清理审计已落地)
|
||||
|
||||
- `POST /api/jobs` 只记账,不提前建目录。
|
||||
- runner 开始前再落 input/output。
|
||||
- 增加 TTL 清理任务。
|
||||
- 任务列表、任务详情改为从 DB 查询。
|
||||
|
||||
### 阶段三:画布文档和导入包
|
||||
|
||||
- 建 `design_documents` / `design_templates` / `projects` 表。
|
||||
- 画布保存从 localStorage 改为后端文档接口。
|
||||
- `wcd` 导入导出包直接对接这些表。
|
||||
|
||||
## 8. 风险与注意点
|
||||
|
||||
- 现有任务接口依赖内存中的 `JobManager`,迁到 DB 后需要兼容 SSE 进度事件。
|
||||
- 文件迁移只能做增量:老素材目录可先保留,新写入走 DB。
|
||||
- 删除素材要检查 `design_documents` 引用,避免出现缺失贴纸。
|
||||
- tint 从前端 localStorage 迁移到 DB 时,需要兼容旧浏览器状态。
|
||||
|
||||
## 10. 已落地实现
|
||||
|
||||
- `backend/service/metadata_store.py`:SQLite 元数据 `jobs` / `job_events`。
|
||||
- `backend/service/job_manager.py`:任务状态和事件落库,服务重启可恢复。
|
||||
- `backend/service/storage.py`:任务目录占用、过期审计、可清理能力。
|
||||
- `backend/service/storage_metrics.py`:dry-run 指标和显式 `--apply` 清理。
|
||||
- `backend/service/app.py`:`GET /api/maintenance/storage-summary`。
|
||||
- `docs/DATA_STORAGE_OPTIMIZATION.md`:完整空间/效能指标。
|
||||
|
||||
> 注:当前项目没有接入 PostgreSQL。代码里的 `*.db` 是词云算法自己的 SQLite 结果文件;新加的 `service_metadata/app.db` 是业务元数据先行层。`jobs` / `job_events` 表结构设计上可平滑迁移到 PostgreSQL。
|
||||
|
||||
## 9. 相关文件参考
|
||||
|
||||
- `backend/service/app.py`:目前的任务、素材、模板 API。
|
||||
- `backend/service/job_manager.py`:内存中的任务状态。
|
||||
- `backend/service/storage.py`:任务目录创建。
|
||||
- `backend/service/runner.py`:任务运行与产物扫描。
|
||||
- `backend/core/pipeline.py`:词云结果 SQLite 写入。
|
||||
- `frontend/src/lib/stickerLibrary.ts`:前端贴纸库。
|
||||
- `docker-compose.yml`:服务编排,后续加 PostgreSQL。
|
||||
Reference in New Issue
Block a user