# TE 产品文档检索 · 代码审计报告 v0.5.8
## 「全部下载（3份）拆成 3 个文件夹」bug 根因定位（只读审计，未改任何代码）

- 审计日期：2026-10-11
- 审计范围：popup.js 全文（2460 行）+ background.js 全文（71 行），着重审计「下载后保存文件夹」决策链路
- 触发现象：检索 `1897009-2`（3 份文档：CAD文件(STP) / 产品规格 / 产品图纸）→ 点击卡片上的「全部下载（3 份）」→ 下载目录出现 **3 个文件夹**：`1897009-2`、`1897009-2 (1)`、`1897009-2 (2)`（创建时间同一分钟 20:45），而不是全部落进一个 `1897009-2/`
- 结论先行：**历史潜伏竞态，非 v0.5.8 新引入**。病灶在卡片级「全部下载」按钮的实现方式（模拟逐个单击 + 每次单击独立决策文件夹），与 v0.4.3 引入的「探测收敛等待 ≥1.8s」叠加后必然触发。v0.5.8 的解压功能与本 bug **无关**。

---

## 一、当前版本「保存文件夹」决策机制全链路审计

### 1.1 决策唯一入口：`getFolderName(partNo)`（popup.js L787-812）

每次下载前调用，按「重名文件夹」模式（默认）执行以下四步：

```
① 查内存缓存 FOLDER_CACHE[mode|pn]      → 命中直接返回（不再探测）
② 磁盘探测 detectExistingFolders(base, pn)
   · chrome.downloads.search 全量扫描，只采信 exists===true 且 state==='complete'
   · ★ 收敛等待：MIN_ELAPSED_MS=1800 + 连续两轮一致（上限 14 轮 × 700ms）
   · 即：一次探测至少耗时 1.8~2.5 秒才返回
③ 决策：
   · 磁盘无 <编号> 且 FOLDER_RESERVED 无 <编号> → 用纯编号（新建文件夹）
   · 否则 → 取「<编号> (n)」中第一个未被磁盘占用、未被会话预留的 n（加后缀）
④ 写入 FOLDER_RESERVED.add(folder) + FOLDER_CACHE[cacheKey]=folder（决策完成后才写！）
```

### 1.2 什么时候新建纯编号文件夹 / 什么时候加 (n)

| 场景 | 结果 |
|---|---|
| 磁盘上**没有** `<编号>` 文件夹（探测收敛确认），且本次会话没预留过 | **新建纯编号** `1897009-2/` |
| 磁盘上**已有** `<编号>`（之前下载过、文件还在），或本会话已有下载预留了它 | **加后缀** `1897009-2 (1)`，再占用则 `(2)`、`(3)`… |
| 设置切到「重名文件」模式 | 永远纯编号文件夹，重名只加在文件名上（浏览器 uniquify，铁律 2：uniquify 只管文件名不管文件夹） |
| 用户删过文件夹但浏览器历史未收敛（幻影） | 探测必须等 1.8s+ 收敛，防误判 —— 这正是竞态的时间来源 |

### 1.3 五个下载入口如何调用 getFolderName（审计结果）

| 入口 | 调用方式 | 是否有并发保护 |
|---|---|---|
| 单文档「下载」按钮 | `downloadDoc` → 每次独立调用 | 单次点击无并发，安全 |
| **卡片级「全部下载（N份）」** | **模拟 N 次独立单击，间隔 600ms（L2373-2378）** | ❌ **无任何保护（bug 病灶）** |
| 顶部「下载全部文档和产品图」 | `downloadAllDocs` 先构建 `folderByPn`（每个编号只决策一次，L1740-1743）再逐个下载 | ✅ 安全 |
| 产品图（ZIP / 首张 / 灯箱） | 各自调用一次 | 单次点击安全 |
| 下载失败回退原 zip | 复用已解析的 folder 参数 | ✅ 安全 |

---

## 二、bug 根因：逐帧时序推演

卡片级按钮代码（L2369-2381）：

```js
all.addEventListener("click", () => {
  const items = card.querySelectorAll(".doc-item");
  items.forEach((el, i) => {
    const btn = el.querySelector("button.mini");
    if (btn && btn.textContent === "下载") setTimeout(() => btn.click(), i * 600);
  });
});
```

即：**3 份文档 = 3 次互不相干的 `downloadDoc`，各自走完整的 `getFolderName` 四步**。推演（t=0 为第一次单击）：

| 时刻 | 事件 | FOLDER_RESERVED / FOLDER_CACHE 状态 |
|---|---|---|
| t=0 | 单击① → `getFolderName` 启动探测（需 ≥1.8s） | 空 / 空 |
| t=600ms | 单击② → 查缓存 **miss（①还没决策完，没东西可查）** → 也启动自己的探测 | 空 / 空 |
| t=1200ms | 单击③ → 同样缓存 miss → 第三条独立探测链 | 空 / 空 |
| t≈2000ms | ①探测收敛完成 → 磁盘无同名 → 决策 `1897009-2`，**此刻才**写 RESERVED+CACHE | {纯编号} / 有 |
| t≈2600ms | ②探测收敛完成 → 磁盘无同名，**但 RESERVED 里纯编号已被①占** → 决策 `1897009-2 (1)` | +(1) |
| t≈3200ms | ③探测收敛完成 → (1) 也被占 → 决策 `1897009-2 (2)` | +(2) |

三个文件夹、每个落一份文件 —— **与用户截图完全吻合**（`1897009-2`、`(1)`、`(2)` 同一分钟创建）。

### 根因归纳（三要素叠加，缺一不触发）

1. **卡片按钮 = 逐个模拟单击**：N 份文档发起 N 条独立决策链（顶部按钮的 `folderByPn` 语义没有覆盖到这里）；
2. **决策太慢**：v0.4.3 为治「幻影文件夹」引入的收敛等待（≥1.8s）远大于 600ms 的点击间隔；
3. **缓存/预留写入太晚**：`FOLDER_RESERVED` 与 `FOLDER_CACHE` 都在探测完成后才写，后续点击在 t=600ms 查缓存时必然 miss，无法复用第一条链的结果。

### 与 v0.5.8 的关系

v0.5.8 的解压链路（fetch → 内存解压 → downloadBlob）**发生在文件夹决策之后**，且复用传入的 folder 参数，与本次 bug 无因果。该竞态自 v0.4.3 加入收敛等待起即潜伏；之所以现在暴露，是因为本例 3 份文档恰好都在同一张卡片里用卡片级按钮批量下载（此前用户多走顶部按钮或单文档下载）。

---

## 三、影响面与风险评级

| 项 | 评估 |
|---|---|
| 触发条件 | 同一卡片 ≥2 份文档 + 使用卡片级「全部下载（N份）」按钮 → **必现**（非偶发） |
| 数据安全 | ⚠️ 无覆盖风险：uniquify 安全阀保证只多建文件夹，绝不覆盖已有文件 |
| 数据正确性 | 归档割裂：同一编号的文档分散在多个 (n) 文件夹，违背「按编号归档」核心语义 |
| 波及范围 | 仅卡片级按钮；顶部按钮、单文档、产品图链路审计均无此问题 |
| 次要发现 | 门户「代码审计报告」板块链接的 `审计报告_v0.4.3.md` 在云端 `download/latest/extension/` 中不存在（404），仅 `审计报告_v0.5.2.md` 实际在位 |

---

## 四、修复方案（待用户通知后执行，本报告未改任何代码）

| 方案 | 做法 | 评价 |
|---|---|---|
| **① 复用 folderByPn 语义（推荐）** | 卡片按钮点击时先 `await getFolderName(pn)` **一次**，把同一个 folder 传给 N 次 `downloadDoc`（后者增加可选 folder 参数） | 与顶部按钮行为对齐，改动小、语义一致 |
| **② 并发决策合并** | `getFolderName` 内对同一 pn 的并发调用共享同一条 in-flight Promise | 治本（任何入口都不再可能拆文件夹），可与①叠加作为双保险 |
| ③ 缩短点击间隔 | 600ms → 2500ms | ❌ 否决：治标，且拖慢批量、仍依赖时序运气 |

修复后防回归：verify_v054 新增章节静态断言「卡片按钮必须单次决策文件夹」+ vm 沙箱跑真实源码模拟 3 连击断言三次 `getFolderName` 返回值一致；实机 CDP 复测 1897009-2 批量下载落盘单一文件夹。版本迭代 v0.5.9，按固定 6 步交付发布。

---

## 五、审计结论

当前版本「文件夹何时新建 / 何时加 (n)」的规则本身**符合设计意图且被 5 个入口中的 4 个正确执行**；唯一失守点是卡片级「全部下载」按钮的「模拟逐个单击」实现，在 v0.4.3 收敛等待机制下产生三条并发决策链，导致同一编号被拆成纯编号 + (1) + (2) 三个文件夹。属**必现级 P1 归档正确性 bug**（无数据覆盖风险），建议按方案①+②修复。

---

## 六、修复落地（v0.5.9）

- 用户批准「方案①+② 都做」，已于 v0.5.9 实现：
  - **① 卡片按钮先决策一次再复用**：`全部下载（N 份）`按钮点击时先 `await getFolderName(pn)` 一次得到 folder，再带同一 folder 循环调用 N 次 `downloadDoc`（后者新增可选 folder 参数，仅当未传入时才重算）；彻底移除原 `setTimeout(()=>btn.click(), i*600)` 模拟单击。
  - **② getFolderName 并发合并**：新增 `FOLDER_PENDING` Map，同 `cacheKey` 的并发调用合并到同一次 in-flight 探测 Promise，探测结束清理；同编号并发只触发 1 次磁盘探测、共享同一 folder。模式切换 / 清空会话时一并清理 `FOLDER_PENDING`。
- 验证：
  - `verify_v054.js` 扩至 **169 项**（新增第 18 章）：常量 / 合并分支 / 卡片按钮改"先决策一次" / 旧 setTimeout 已移除 / **同编号并发 3 次只触发 1 次磁盘探测** / 不同编号独立 / 还原 bug（3 次并发全部落入同一文件夹）。
  - **实机端到端（真实 Edge + 真实 MV3 插件）4/0**：注入含 3 份文档的卡片 → 点「全部下载」→ 捕获实际入队落盘路径 → 3 份文件全部归入 `TE Web Download/1897009-2/`（不再拆成 3 个文件夹）。
- 结论：根因已根治，双保险方案生效，无回归。
