Appearance
不显影的证件照
背景:缩略图路径审计已判"安全",但模型卡片
background-image仍为空、回退盒子图标。 过程:顺藤摸瓜,揪出三层封印——zip 路径错配、冻结快照不重绘、resource_root 迁移孤儿。
联邦的模型库里,每张卡片左上角本该有一张小小的证件照。
可用户打开库,看到的却是一排空框——background-image: ; 干干净净,什么都没写,底下孤零零躺着一个 lucide:box 的盒子图标。
"我之前不是审过路径了吗?"外交官有点懵,"音乐库那颗雷是 resource_root 跟着 config 错,缩略图系统我明明查过——它走的是 %LOCALAPPDATA%/MikuMikuAR/thumbnails/,跟 resource_root 半点关系都没有。CacheKey 也安全,相对路径生成失败就回退绝对路径,永远不会空 key。"
"审计说安全,"桌面壳把一张空框的 HTML 推到他面前,"可它还是空的。"
外交官盯着那行 background-image: ;,沉默了三秒。
"那说明——雷不在你审的那一段。"
第一层封印:两张脸的模型
外交官先去磁盘上看了眼。缩略图目录里躺着 56 个 png,时间戳清清楚楚,最近的两个是今天 15:53、15:54 刚生成的。
"生成侧是好的,"他说,"captureThumbnail 在模型加载后咔嚓一声,canvas.toDataURL('image/png', 0.8) 落盘,png 实实在在写进去了。问题在'查'的那一侧——key 对不上。"
他翻开 app.go 和 zipextract.go,两行注释像两根针:
go
// app.go:272 — for zip entries, the zip path
ModelEntry.file_path = zip包路径
// zipextract.go:33 — 解压后的临时 pmx 路径
ExtractZip().file_path = 解压路径"看到了吗?"外交官指着屏幕,"库里扫描出来的 m.file_path,对 zip 模型来说是zip 包路径。可 captureThumbnail 拿到的 filePath,是 ExtractZip 返回的解压临时路径。两个路径不一样,CacheKey 算出来的哈希就不一样——落盘用解压路径,查询用 zip 路径,永远 miss。"
"那修这个不就完了?"
"没那么简单,"外交官摇头,"你贴的那个模型,路径里没有 .zip,是个直接的 .pmx。它的 m.file_path 和解压路径是同一个绝对路径,按理说不该 miss。可它也是空的。"
空框依旧。第一层封印只困住了 zip 模型,困不住眼前这个 .pmx。
第二层封印:冻结的相册
外交官换了个思路:不去猜 key,直接读渲染链路。
renderGridMode 里,异步取到缩略图后,有这么一段:
typescript
GetThumbnailBatch(pmxPaths)
.then((batch) => {
const merged = new Map(thumbnailCache);
for (const [path, data] of Object.entries(batch)) {
merged.set(path, data);
}
setThumbnailCache(merged); // 更新了全局缓存
})
.catch(() => {});"全局缓存确实更新了,"外交官说,"但面板拿到的是什么?"
他往上看了一行——面板创建时传进去的 thumbnailCache:
typescript
createResourcePanel(card, {
items: allResourceItems,
thumbnailCache: new Map(Object.entries(thumbnailCache)), // ← 冻结快照"new Map(Object.entries(thumbnailCache)),"外交官念出声,"这是拷贝一份当时的快照。异步返回后,全局缓存变了,可面板手里那本相册是创建那一刻的复印件——永远停在空白页。"
"还有更绝的,"他翻到 ui-resource-panel.ts,"面板里有个 IntersectionObserver,本该在缓存更新时重新触发渲染。可它创建了之后从来没调用过 observe()——死代码。缓存变了,它不会自己 fire。所以已可见的卡片,永远拿不到新照片。"
时序是这样的:
1. GetThumbnailBatch 异步发起(还没回来)
2. 同步创建面板 → 读冻结快照(空)→ 渲染空框
3. 稍后 GetThumbnailBatch 回来 → 更新全局缓存
4. 但面板不重绘 → 空框永远空"所以所有模型都不显示,"外交官总结,"跟 key 无关,跟 zip 无关。是渲染链路漏了'重绘'这一脚。"
他改了三处:setThumbnailCache 改成原地 mutate(不再替换 Map 对象),面板传 live 引用而非冻结快照,异步返回后补一句 resourcePanel.updateItems(...) 重绘。
改完跑 grep,确认全仓再无 new Map(Object.entries(thumbnailCache)) 残留。
结果还有两处。
第三层封印:被漏掉的两行
replace_all 没覆盖到的两行,藏在 grep 的二次复查里:
typescript
// library-core.ts:1098 — renderGridMode 嵌入视图
thumbnailCache: new Map(Object.entries(thumbnailCache)), // ← 还是冻结快照
// library-core.ts:844 — renderFullscreenFolder
thumbnailCache: new Map(Object.entries(thumbnailCache)), // ← 还是冻结快照"这就是为什么你'还是没看到',"外交官苦笑,"我上一轮以为修完了,其实 replace_all 漏了这两处。即使 updateItems 触发了重绘,面板读的还是那个永远空的复印件。"
两行改掉,传 live 引用。类型检查 0 错,单测 75/75 过。
第四层真相:迁移的孤儿
还有最后一块拼图。用户之前修过 resource_root——从临时目录改回项目根。
CacheKey 用 filepath.Rel(resource_root, modelPath) 算相对 key。修复前,resource_root 是 temp 目录,Rel 算出的路径以 .. 开头被拒,回退绝对路径;修复后,Rel 成功,改用相对路径。
"所以修复前生成的那些 png,"外交官说,"key 是绝对路径;修复后查询用相对路径——它们全成了孤儿。即使渲染修好了,这些旧 png 也查不到。"
他在 Go 端 Get 里加了一道回退:先试相对路径 key,miss 再试绝对路径 key。零风险,把修复前落盘的旧照片重新认回来。
go
func Get(thumbDir, modelPath, rootPath string) (string, error) {
if data, err := os.ReadFile(relKey); err == nil {
return data, nil
}
return os.ReadFile(absKey) // 孤儿回退
}三层封印的教训
桌面壳在新地图的"模型管理"一栏,画了三个套在一起的封印。
最里层是 zip 路径错配——只困住 zip 模型,.pmx 自由出入。中间层是 冻结快照不重绘——困住所有模型,是真正的主谋。最外层是 resource_root 迁移孤儿——修复一个旧 bug 时,顺手制造的一批失忆照片。
"你之前的路径审计没错,"外交官说,"CacheKey 确实安全,永远不会算错 key。但你审的是'钥匙会不会配错',没审'门会不会开'。钥匙对了,门后的人却拿着复印件,永远看不到墙上的照片。"
桌面壳把这张图钉上墙,旁边写:
审计一个系统,不要只审计它"算得对不对"。要审计它"算完之后,结果有没有真的流到用户眼前"。
缓存更新了,面板不重绘——等于没更新。快照拷贝了,原稿改了——等于没拷贝。Observer 建了不 observe——等于没建。
三层封印,任意一层单独存在,照片都不显影。它们叠在一起,把整个模型库变成了一排空框。
"有时候,"外交官在离开前说,"最难的 bug 不是算错,是算对了却没人看。"
桌面壳关掉编辑器。模型还在库里。卡片还是空的——但这一次,他知道空框底下压着三层封印,而封印的钥匙,已经在他手里。
教训:缓存更新了不等于用户看到了。算对 key 只是第一步,结果还得真的流到渲染那最后一帧。快照会骗人,Observer 会装死,但用户眼前的空框不会撒谎。