Appearance
路径的陷阱
背景:预设加载时用路径匹配模型注册表——斜杠方向/大小写/相对绝对/zip 内路径四种方式都会导致匹配失败,静默失效。 过程:loadPMXFile 返回值从 void 改为 string|null,调用方直接拿 ID 操作,不再需要路径匹配。改 5 个调用点,核心是 applyPresetFromLib。
XSS 攻坚战的第二天,外交官的桌子上多了第二颗红石子。
石子上刻着一行小字:预设加载时用路径匹配模型注册表。
"这是什么意思?"AI 同行者拿起石子,翻来覆去地看。
"意思是,"外交官打开 model-preset.ts,"用户从模型库应用一个预设,系统先加载 PMX 模型,然后用模型的文件路径去注册表里面找,找到后再把预设应用上去。"
"听起来……挺正常的?"
"正常是正常,"外交官指着代码,"但它的问题不在逻辑,在脆弱性。"
他用手指点了点那一行路径匹配的代码——像是在指一根快要断的绳子。
为什么路径匹配是脆弱的
"路径匹配有多少种死法?"外交官自问自答,"我给你数几种。"
第一种:斜杠的方向。
"Windows 是反斜杠 \\,Unix 是正斜杠 /。Go 后端返回的路径可能是反斜杠,前端 JS 处理时可能变成正斜杠。两个看起来一样的路径,字符串比较不一样。"
"那做一次归一化不就行了?"
"是有归一化。但归一化的方式呢?"外交官摊开手,"A 模块用 .replace(/\\/g, '/'),B 模块用 path.posix.normalize(),C 模块什么都不做——你归一化你的,我归一化我的,最后还是可能对不上。"
第二种:大小写。
"Windows 文件系统不区分大小写。Model.pmx 和 model.pmx 是同一个文件。但 JavaScript 的字符串比较是区分大小写的。注册表的 key 是大写开头,匹配的路径是小写开头——匹配不上。"
"那用 toLowerCase() 啊。"
"又是归一化的老问题,"外交官摇头,"谁来做 toLowerCase?加载的时候做?匹配的时候做?还是两边都做?只要有一边忘了,就对不上。"
第三种:相对路径 vs 绝对路径。
"模型库传进来的可能是相对路径——hatsune_miku/model.pmx。注册表里面存的可能是绝对路径——C:/Library/hatsune_miku/model.pmx。你用相对路径去查绝对路径的表,怎么查得到?"
"那……resolve 一下?"
"resolve 需要基准路径。基准路径从哪来?模型库根目录?那如果模型是从别的地方拖进来的呢?基准路径就不对了。"
第四种:zip 内路径。
"从 zip 包里加载的模型,路径可能是 zip://archive.zip/model.pmx,也可能是解压后的缓存路径 cache/abc123/model.pmx。同一个模型,两次加载路径不一样。"
AI 同行者沉默了。
"所以……有多少种方式会匹配不上?"
"至少四种,"外交官竖起四根手指,"而这四种还可能叠加。斜杠不对 + 大小写不对 + 相对绝对不对——三重不匹配,你就是找不到。"
"那怎么办?"
外交官把第二颗红石子放在桌上,推到 AI 面前。
"不用路径匹配。用 ID。"
ID 的想法
"loadPMXFile 现在返回什么?"外交官问。
"void。加载完就完了。"
"对。加载完成了,但调用方不知道加载的是哪个模型——它只能自己去猜,用路径去猜。"
"如果 loadPMXFile 返回模型的 ID 呢?"
"就是这个意思,"外交官点头,"加载完成 → 返回模型 ID → 调用方拿着 ID 直接去注册表取。不需要匹配,不需要猜测,不需要归一化。是什么就是什么。"
他在纸上画了一张对比图:
旧流程:
applyPresetFromLib(path)
→ loadPMXFile(path) → void
→ 用 path 去 modelRegistry 找
→ 找到了?应用预设
→ 没找到?静默失败
新流程:
applyPresetFromLib(path)
→ loadPMXFile(path) → modelId
→ 用 modelId 直接取模型
→ 一定找到(除非加载失败,返回 null)
→ 应用预设"看起来更简单了,"AI 说。
"本来就应该这么简单,"外交官说,"调用一个函数,它告诉你结果。这是函数的基本素养——做了什么,做成了没有,结果是什么,都应该通过返回值说清楚。而不是让调用方自己去猜。"
影响面:五个调用点
"但是,"AI 翻了翻代码,"loadPMXFile 不止一个调用方。改返回值,所有调用方都要改。"
"所以要先清点,"外交官打开 grep 的结果,"五个调用点。"
他一个一个列出来:
- onModelRowClick(模型库点击加载)——现在不关心返回值,改了不影响
- applyPresetFromLib(从模型库应用预设)——最核心的调用方,改造的目标
- 拖拽导入(drag-drop 事件处理)——和模型库点击类似,不关心返回值
- replaceModel(替换模型)——需要确认是否用了路径匹配
- 调试命令(键盘快捷键加载测试模型)——可以忽略
"五个调用点,"AI 数了数,"不算多。"
"不多,但要一个一个看,"外交官说,"改返回值类型从 void 到 string | null,TypeScript 会帮我们找。但逻辑上的适配——哪些调用方需要处理 null,哪些不需要——得人来判断。"
他顿了顿:
"而且还有一个问题。"
"什么问题?"
"如果模型已经加载过了呢?"外交官指着 scene-loader.ts 里的重复加载检查,"loadPMXFile 现在有一个检查:如果同一路径的模型已经在注册表中,就直接返回,不重复加载。这种情况,返回什么 ID?"
"返回已存在模型的 ID 啊,"AI 理所当然地说。
"对。但这意味着——loadPMXFile 的返回值语义变了。它不再只是'加载一个新模型',它变成了'确保模型已加载,并返回它的 ID'。"
"幂等?"
"对,幂等,"外交官点头,"调用一次和调用十次,结果一样。这是好事。但这个语义变化要让所有调用方都知道。"
改造开始
外交官打开 scene-loader.ts,开始改 loadPMXFile。
第一步:函数签名。
typescript
// 旧
export async function loadPMXFile(...): Promise<void>
// 新
export async function loadPMXFile(...): Promise<string | null>第二步:已加载模型的情况,直接返回现有 ID。
typescript
const existing = _modelManager.findByPath(path);
if (existing) {
return existing.id;
}第三步:新加载成功的情况,返回新注册的 ID。
typescript
const registeredId = _modelManager.register({...});
return registeredId;第四步:失败的情况,返回 null。
typescript
catch (err) {
setStatus(`✗ 加载失败: ${err.message}`, false);
return null;
}"四步改完了,"AI 说,"倒是挺快。"
"函数本身的改动不大,"外交官说,"真正的工作在调用方。"
核心调用方:applyPresetFromLib
外交官打开 model-preset.ts,找到 applyPresetFromLib。
旧代码是这样的:
typescript
await loadPMXFile(preset.modelPath);
const m = modelRegistry.list().find(m => m.path === preset.modelPath);
if (m) applyModelPreset(m.id, preset);用路径找。找得到就应用,找不到就……什么也不做。静默失败。
新代码变成了这样:
typescript
const modelId = await loadPMXFile(preset.modelPath);
if (!modelId) {
setStatus('✗ 模型加载失败,预设未应用', false);
return;
}
applyModelPreset(modelId, preset);直接用返回的 ID。加载失败有明确的反馈。
"简洁多了,"AI 说,"而且失败了用户知道为什么。"
"这就是返回值的意义,"外交官说,"函数不应该沉默。成功了有结果,失败了有原因。调用方不需要猜。"
其他调用方的适配
剩下的四个调用方,外交官一个一个过:
onModelRowClick —— 不关心返回值,只是加载模型。TypeScript 不会报错,因为 Promise<string | null> 的返回值可以被忽略。不需要改。
拖拽导入 —— 同上。不需要改。
replaceModel —— 外交官翻了翻它的实现。它是先 removeModel 再 loadPMXFile。同样不关心返回值。不需要改。
调试命令 —— 同上。不需要改。
"五个调用方,只需要改一个?"AI 有点意外。
"核心调用方只有一个,"外交官说,"其他的本来就不关心返回值,改不改都行。但函数签名变了,它们的代码不需要动——TypeScript 允许忽略返回值。这是好事,向后兼容。"
"但是……void 到 string | null,不是 breaking change 吗?"
"对 TypeScript 的类型系统来说是,但对运行时来说不是,"外交官解释,"旧代码不接收返回值,新函数返回值——运行时完全没问题。类型层面的报错会提醒你'这里有返回值了你要不要看看',但不会崩。"
他合上文件:
"这是最好的那种改造——语义提升了,旧代码不受影响,新代码可以用新特性。"
预设系统的地基
改造完的时候,天已经黑了。
外交官在测试环境里试了试——从模型库应用一个预设,加载模型,应用参数,一气呵成。没有路径匹配,没有归一化,没有大小写问题。
"感觉……更稳了,"AI 说。
"是更稳了,"外交官点头,"预设系统之前是建在沙滩上的——路径匹配就是那片沙滩。现在换成了桩子——ID 就是桩子。"
他把第二颗红石子放在桌上,和第一颗摆在一起。
两颗石子,两种风险。一个是安全,一个是健壮性。
"下一颗?"AI 问。
外交官拿起第三颗红石子——材质启用状态序列化。
"这个,"他说,"也是预设系统的问题。用户关了头发的材质,保存预设,再加载,头发又出来了。用户会觉得预设是坏的。"
"也是地基?"
"也是地基,"外交官把第三颗石子放在第二颗旁边,"预设系统有三根桩子——模型加载要对、材质状态要全、动作数据要准。现在第一根桩子打好了。"
窗外的灯光映在三颗红石子上,颜色在空气中慢慢散开。
XSS 的红是锐利的——像一把刀。
路径匹配的红是暗的——像地基深处的裂缝。
材质启用的红是温和的——像用户的失望。
三种红,三种问题,但解决它们的思路是一样的:
不要让调用方猜。函数要对自己的结果负责。
附录:函数返回值设计原则
| 原则 | 说明 |
|---|---|
| 返回结果,不返回 void | 做了什么,做成了没有,结果是什么,通过返回值说清楚 |
| 幂等优先 | 同一个函数调用一次和调用十次,结果应该一样 |
| 失败有信号 | null / undefined / 抛出异常——选一种,但不能沉默 |
| 调用方不猜 | 调用方不应该通过"去别的地方查"来知道函数的结果 |
| 向后兼容 | 从 void 改为有返回值是安全的——旧代码可以忽略新返回值 |
教训:最脆弱的代码不是逻辑复杂的代码,是"看起来能跑但全靠巧合"的代码。路径匹配就是巧合——巧合地斜杠一致,巧合地大小写一致,巧合地路径格式一致。巧合多了,总有一天对不上。