Skip to content

路径的陷阱

背景:预设加载时用路径匹配模型注册表——斜杠方向/大小写/相对绝对/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.pmxmodel.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 的结果,"五个调用点。"

他一个一个列出来:

  1. onModelRowClick(模型库点击加载)——现在不关心返回值,改了不影响
  2. applyPresetFromLib(从模型库应用预设)——最核心的调用方,改造的目标
  3. 拖拽导入(drag-drop 事件处理)——和模型库点击类似,不关心返回值
  4. replaceModel(替换模型)——需要确认是否用了路径匹配
  5. 调试命令(键盘快捷键加载测试模型)——可以忽略

"五个调用点,"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 改为有返回值是安全的——旧代码可以忽略新返回值

教训:最脆弱的代码不是逻辑复杂的代码,是"看起来能跑但全靠巧合"的代码。路径匹配就是巧合——巧合地斜杠一致,巧合地大小写一致,巧合地路径格式一致。巧合多了,总有一天对不上。