# TE 产品文档检索插件 · V0.6.11 全量审计报告

- **审计日期**：2026-10-12
- **审计版本**：v0.6.11（manifest version = 0.6.11）
- **审计范围**：全量源码，共 **5667 行 / 6 个文件**
  - `popup.js` 4205 行 · `popup.css` 687 行 · `popup.html` 195 行 · `background.js` 394 行 · `zipcore.js` 150 行 · `manifest.json` 36 行
- **审计方式**：逐段精读 + 模式 grep 取证（所有"有没有做"类结论均附行号证据）
- **审计性质**：只读审计，未改动任何业务代码

---

## 一、审计概要

### 总体健康度：**中等偏上（B+），但存在 3 个阻断级缺陷**

**做得好的地方（工程素养确实高）**：
- 注释密度极高，关键决策处都写了「为什么这么做」，不是"注释代码"而是"注释意图"
- v0.6.11 把**下载编排从弹窗迁入后台 SW**是架构上正确的一步（任务清单一次性移交、进度写 `storage.session`、abort 通道、心跳保活）
- **overwrite 铁律**执行到位：全仓 `conflictAction:"overwrite"` 字面量**仅 1 处**，且前后台双重路径白名单纵深防御
- 设置项统一「内存态优先 + 用户交互守卫」模式，11 项设置与 UI 零错配

**三个必须处理的硬伤**：

| # | 问题 | 位置 | 后果 |
|---|------|------|------|
| **1** | **后台产品图 ZIP 打包产出 0 字节空文件** | `zipcore.js:82` × `background.js:231` | 设「产品图数量=全部」时所有 `PIC_*.ZIP` 是空文件，而 UI 报"全部下载完成" |
| **2** | **更新重试后轮询定时器未重启** | `popup.js:2986-2993` | 重试的下载失去兜底 → Promise 永不 settle → 「更新」按钮永久失效 |
| **3** | **SW 无状态持久化与启动对账** | `background.js:120-144` | 长批次下载中 SW 被回收 → UI 永久卡在"正在下载"，与 v0.6.11 宣称根治的现象**同构复发** |

**问题统计**：共 **46 条**（阻断 3 / 严重 16 / 一般 22 / 轻微 5，已合并两份审计的重复项）

---

## 二、二次检索专项（用户重点关切）

### 2.1 完整调用链（含行号）

```
runSearchLoop(1361)
├─ isDoc = DOC_NUMBER_PREFIX_RE.test(q)        // 108|114|501|411-     (1366)
├─ if (isDoc)                                                        (1372)
│    searchByDocNumber(q)                                           (1374 → 2029 → 2008)
│      └─ searchDocRows → chrome.tabs.create(2010) 【开标签页】
│    renderPartCard                                                 (1377)
│      ★ 注意：此通道完全跳过 postProcessDocs
│        → 无图纸语言筛选、无图框规格识别、无三色态
└─ else
     searchPart(q)                                                  (1379 → 1795)
     filtered = docs.filter(选中类型)                                (1381)
     ── 二次检索唯一正常触发点 ─────────────────────                (1385-1387)
     drawingWanted = wanted.includes("产品图纸")                    (1385)
     hasDrawing   = filtered.some(doc_type === "产品图纸")           (1386)
     if (drawingWanted && !hasDrawing)                              (1387)
         docNo = partNoToDocNo(q)                                   (1388 → 1962 掐头去尾)
         alt = await searchDrawingByDocNo(docNo)                    (1394 → 1977)
             └─ searchDocRows(2008)
                  ├─ chrome.tabs.create({url: DOC_SEARCH_URL, active:false})(2010)
                  ├─ chrome.scripting.executeScript(...)                 (2013)
                  ├─ docSearchInPage(2035)
                  └─ finally → chrome.tabs.remove(tab.id)               (2025)
     postProcessDocs(...)                                           (1405)  ★ 串行
     CURRENT_RESULTS.push + renderPartCard                         (1416-1422)
  catch (e)                                                        (1424)
     if (!isDoc)                                                   (1431)
        alt = await searchDrawingByDocNo(partNoToDocNo(q))         (1438)  ★ 重复触发
     postProcessDocs(...)                                          (1449)
```

**触发条件确证**（`popup.js:1385-1387`）：勾了「产品图纸」**且**主通道过滤后一份图纸都没有 → 触发。与是否找到规格/CAD 无关。

### 2.2 耗时拆解表（每个编号）

| 阶段 | 代码行 | 正常耗时 | 最坏耗时 | 可否优化 |
|---|---|---|---|---|
| ① `tabs.create` + Angular 页面加载 | 2010 | 1–3s | 10–15s（**无超时**） | ✅ 标签页池化 |
| ② `executeScript` 注入 | 2013 | 0.1s | 1s | ✅ 池化后免注入 |
| ③ 等搜索框 `for(i<40) + sleep(500)` | 2051-2055 | 1–3s（2–6 拍） | **20s** | ✅ MutationObserver |
| ④ 赋值 + 找按钮 + click | 2059-2077 | <0.1s | <0.1s | — |
| ⑤ 等结果行 `for(i<50) + sleep(500)` | 2082-2114 | 1–3s（2–6 拍） | **25s** | ✅ 观察器 + 硬超时 |
| ⑥ `tabs.remove` | 2025 | <0.1s | <0.1s | — |
| **单编号合计** | | **3–10s** | **45s** | |
| **× 20 编号（全部无图纸）** | | **1–3.5 min** | **15–30 min** | |

**关键放大因素（这是"肉眼可见开标签页"的直接原因）**：

1. **零复用**：`chrome.tabs.create`(2010) 在每个 `searchDocRows` 调用时都新建一个官网标签页，`finally`(2025) 立即销毁。**N 个编号 = N 次完整加载 te.com.cn 的 Angular 单页应用**（HTML + JS + CSS + 图片全部重来）。你看到标签页一个个冒出来，就是这个循环。

2. **隐藏页定时器节流（隐藏的 2 倍放大器）**：`active: false`(2010) 让标签页处于后台，Chrome 对后台页的 `setTimeout` 执行 **≥1s 对齐**，于是 `sleep(500)` 实际 ≈1s。两段轮询的理论上限从 45s 直接抬到 **≈90s**。

3. **无任何超时/取消机制**：`executeScript` 无 timeout，`docSearchInPage` 无 AbortController。页面一旦卡在中间态，该编号**永久挂起**，整条队列停滞。

4. **零缓存**：同一会话内同一 `docNo` 重复查询（catch 分支重触发、规格补全 `appendSpecDocsByHits` 的 `searchDocRows(no)` 3699）不做任何缓存，全部重走"建页→加载→轮询→销毁"。

### 2.3 必然白等场景清单

| # | 场景 | 证据行 | 白等时长 |
|---|------|--------|----------|
| 1 | **受限/停产/只有规格无图纸的产品**（如 `7-2322905-4`）：产品页解析出规格但零图纸 → 必触发；换算出的 `docNo`（料号中段如 `2378904`）在文档编号通道通常查不到 | 1387 + 1962-1969 | 满 25s 结果轮询 |
| 2 | 文档编号通道命中但全是非 PDF（只有 3D/STEP/xls）→ `rows.length===0` 不 break(2107)，而 notFound 文案也未出现(2109) | 2093 / 2107 / 2109 | 满 25s |
| 3 | 页面加载慢 / Angular 未引导 → 40 拍找不到搜索框 → 返回 error | 2056 | 满 20s，且 1979-1983 **静默吞错**，用户零提示 |
| 4 | 搜索框选择器过宽（2040-2047 取"第一个可见 text input"）→ 可能填进页面其它输入框 → 搜非所想 | 2040-2047 | 满 25s |
| 5 | 用户只想要规格/CAD，却默认勾着产品图纸 → 每个无图纸编号照样开标签页 | 1385 | 每编号 3–45s |
| 6 | 主通道二次检索成功、随后 `postProcessDocs`(1405) 抛错 → 进 catch → **对同一编号再开一个标签页重查一次** | 1405 → 1424 → 1438 | 重复 45s |
| 7 | 规格补全叠加：命中 2 个图框编号 → `appendSpecDocsByHits` 为每个编号各开一个标签页(3699)，串行执行 | 3694-3699 | 命中数 × 45s |
| 8 | WAF / 未登录导致产品页返回 200 但无文档区 → 0 图纸 → 二次检索必触发 | 1797-1807 + 1387 | 满 45s |

---

## 三、问题清单

### 阻断级（3）

#### 🔴 B1 · 后台产品图 ZIP 打包产出 0 字节空文件

- **位置**：`zipcore.js:82` 返回 Blob × `background.js:231` 消费
- **证据**：
  ```js
  // zipcore.js:82
  return new Blob([...chunks, ...central, eocd], { type: "application/zip" });

  // background.js:231
  return await bgEnqueueDownload(bytesToDataUrl(zip, "application/zip"), fname);
  ```
  而 `bytesToDataUrl`（`background.js:182-189`）只吃 `Uint8Array`：
  ```js
  for (let i = 0; i < u8.length; i += CHUNK) { ... }   // Blob 无 .length → undefined
  ```
  `for(i=0; i<undefined; i++)` **一次不执行** → `bin=""` → 产出 `data:application/zip;base64,`（**0 字节**）
- **影响**：设置「下载产品图数量 = 全部」时，所有 `PIC_<编号>.ZIP` 是**空文件**。而 `bgEnqueueDownload` 受理成功即 `resolve(true)` → UI 报"全部下载完成"。**这是 v0.6.11 把 zip 工具抽到 zipcore 时的迁移遗留** —— 弹窗侧旧路径（`popup.js:2677` 用 `URL.createObjectURL(zipBlob)`）反而正常，所以极易漏测。
- **修法**：`makeZip` 返回 `Uint8Array`（拼接 chunks，去掉 Blob），后台直接消费；弹窗侧改用 `new Blob([u8])` 或统一由 `downloadBlob` 处理。同时给 `bytesToDataUrl` 加入参类型断言（非 Uint8Array 立即抛错，避免再次静默）。

#### 🔴 B2 · 更新重试后轮询定时器未重启，可致更新永久挂起

- **位置**：`popup.js:2982-2993`
- **证据**：
  ```js
  } else if (!retried) {
      retried = true; ticks = 0; landedTicks = 0;
      const oldId = dlId; dlId = null;
      cleanup();                                    // ← 内部 clearInterval(pollTimer) 并置 null
      ...
      start();                                      // ← 重启下载，但 pollTimer 未再建立
  }
  ```
  重试后每拍回调第一行 `if (settled || !pollTimer) return;` → **直接 return**，轮询兜底彻底失效，`ticks` 不再自增。
- **影响**：若重试的下载仍无终态事件（**正是触发重试的那个场景**），Promise **永不 settle** → `runUpdate` 卡在 await → `UPDATE_RUNNING` 永不复位 → 「更新」按钮**永久失效**，只能重启插件。
- **修法**：把 `pollTimer = setInterval(...)` 抽成具名函数 `startPoll()`，在 `start()` 之后调用；或改用 `chrome.downloads.onChanged` 事件驱动 + 单次 `setTimeout` 超时双保险。

#### 🔴 B3 · SW 无状态持久化与启动对账，长批次下载中断后 UI 永久卡死

- **位置**：`background.js:120-144`
- **证据**：
  - `background.js` 全文**无** `chrome.alarms`、无 `onStartup`、无 `onInstalled` 启动对账（grep 确认）
  - `dlEng = { running, abort, prog, keepAlive }`（120-125）与 `pendingNames`（30）**全是内存态**，SW 重启即丢失
  - MV3 中 `setInterval` **不能阻止 SW 被回收**（只有事件能延长生命周期；心跳在 SW 已回收后根本不会触发）
  - **中断后不写任何终态帧**
- **影响**：长批次下载中 SW 被回收 → 剩余队列消失，且弹窗**永远停在"正在下载文档 N/M 组"**（`DL_BG_RUNNING` 永不复位，`#btn-dl-all` 永久禁用）。**这与 v0.6.11 宣称根治的"弹窗收起即下载中断"用户感知完全一致，是同构复发。**
- **修法**：
  1. 用 `chrome.alarms`（SW 重启后可恢复）+ `chrome.storage.session` 持久化 job 队列与游标
  2. SW 顶层加启动对账：读 `te_dl_progress`，若 `running===true` 则补写一帧 `{running:false, aborted:true, finalKind:"err", finalMsg:"后台被浏览器回收，下载已中断"}`
  3. 或改为"一次只入队 K 个 + 由 `onChanged` 事件驱动续入队"（天然自带 keepalive）

---

### 严重级（16，摘录关键项）

| 位置 | 问题 | 影响 | 修法 |
|---|---|---|---|
| `popup.js:2010/2025` | 每编号 `tabs.create` 一次 + `finally` 立即 `remove`，零复用零缓存 | 批量耗时的**主要来源** | 标签页池化 + Map 缓存 |
| `popup.js:2051-2114` | 两段固定轮询 40×500ms + 50×500ms，无观察器无硬超时 | 单编号最坏 ≈90s | MutationObserver + 12s 超时 |
| `popup.js:1979-1983` | `catch { return [] }` **无条件吞掉所有错误** | 真失败时显示"未找到文档"，无法排障 | 返回错误标识并提示 |
| `popup.js:1424-1440` | catch 分支对同一编号**再次**触发二次检索 | 重复开页、重复白等 45s | `TRIED_FALLBACK` 去重集合 |
| `popup.js:2591-2596` | `for` 循环里顺序 `await getFolderName()`，每个 ≥2.8s | 20 编号要先干等 **≈56s** 才开始下载 | `Promise.all` 并发 + 收敛窗口降到 800ms |
| `popup.js:1005` | `chrome.downloads.search({limit: 0})` 语义存疑（可能被解释为"返回 0 条"） | 若为 0 条 → 整套"文件夹重名加 (n)"逻辑**全线失效**，新文件静默混进旧目录 | 显式写 `limit: 1000` 或省略；加实测断言 |
| `popup.js:1803-1806` | 同一份 HTML 被 `DOMParser` 解析 **3 次**（1824/2146/2216） | 每编号 3 倍 DOM 构建开销 | 解析一次，doc 复用给三个 parse 函数 |
| `background.js:182-250` | CAD 解压文件转 **data URL**（base64 膨胀 33%），无体积上限，同步 `btoa` 阻塞 | MB~数十 MB 级 STP 下载失败率高；阻塞 SW 事件循环可能被判"无响应"杀掉 | 加 >8MB 阈值回退原始 URL 直下；分块异步 btoa |
| `popup.js:2340/2706/2762` | `onChanged` 监听器只在 complete/interrupted 移除，停在 in_progress 时**永不释放** | 监听器泄漏 | 加 120s 超时清理（照抄 `downloadOverwrite` 的 `cleanup()`） |
| `popup.js:324 vs 327` | `restoreLastSearch()` 与 `restoreSearchPause()` 异步回调**顺序不确定**；后者置 `RESULTS_USER_TOUCHED=true` 会让前者放弃回填 | 用户**丢失已检索结果视图**，与"暂停保留已检索结果"承诺矛盾 | 串行化：pause 恢复放到 lastSearch 之后 |
| `popup.js:3141-3156` | 更新逐文件覆盖写入，**无备份 / 无原子切换 / 无回滚** | 中途失败后磁盘半新半旧，之后任意一次 reload 可能白屏 | 改 tmp 目录写入 + 校验 + 原子重命名 + 保留 `.bak` |
| `popup.js:2974-2979` | 「字节追平连续两拍即 `finish(true)`」——**字节追平 ≠ 落盘完成** | 可能假成功后 reload，带着损坏文件加载失败 | 只在 `state==="complete"` 时判成功 |
| `popup.js:1660` | `specStat: p.specStat` **无默认值**，随后 1512 直接 `specStat.hits` | 断点数据损坏 → `finishSearch` 整体中断 | 补 `|| {hits:0,...}` |
| `popup.js:2485` | `downloadAllDocs` 未豁免 `specColor==="red"` 的 noHit 组，与 1291-1299「noHit 回退显示全部语言版本」约定冲突 | 下载时又把承诺保留的图纸筛掉 | 循环内加 `if (r.specColor === "red") continue;` |
| `zipcore.js:119-141` | 无 ZIP64 + 越界静默截断 + 不校验 CRC/uncompressedSize | 损坏包产出静默错误数据 | 补长度与 CRC 校验、ZIP64 检测 |
| `popup.js:119-120` | `POPUP_W` / `POPUP_MAX_H` 声明后**从未使用**，注释却称"必须保持一致" | 死常量 + 虚假约束 | 删除或改为运行时断言 |

---

### 一般级（22，摘录）

- **18 处** `chrome.storage.local.get` 回调未检查 `chrome.runtime.lastError`（397/421/455/531/577/590/606/641/656/712/739/788/2782/2818/2849/3181/4182/4190）
- **11 处** `chrome.storage.local.set` 无回调，写失败完全静默（411/430/560/628/695/703/728/755/812/826/835）
- `popup.js:2010` `tabs.create` 无 lastError 校验 → 失败时 `tab.id`(2025) 抛 TypeError 掩盖原始错误
- `popup.js:1186` `saveSessionCache` 未检测 session 配额失败 → 大批量缓存静默丢失
- `popup.js:2178-2186` 兜底分类 O(n²)
- `popup.js:3973` `renderPartCard` 内 `CURRENT_RESULTS.find` → N 张卡片 O(n²)
- `popup.js:1087/1094/1619` `FOLDER_RESERVED` / `FOLDER_CACHE` 长会话持续累积
- `popup.js:2900` `recordOverwrite` 在下载发起**前**调用 → 横幅计数虚高
- `popup.js:455-461` 冲突脏路径只运行时回退、不写回 storage → 脏值永久残留
- `popup.js:3061` / `background.js:193` `fetch` 无超时无 AbortSignal
- `background.js:157-161` `pendingNames` 键是完整 data URL（MB 级字符串），TTL 2 分钟
- `background.js:53 & 373` 两个 `onMessage` 监听器，消息类型分散
- `popup.js:3270` 16MB PDF 在弹窗上下文转字符串 → 内存峰值高
- `manifest.json:5` `description` 塞入整份多版本变更日志

---

### 轻微级（5）

- `popup.js:165/1043/1050` `LAST_FOLDER_PROBE` 只写不读（死代码）
- `popup.js:2314-2316` `resolveFilename` 是 `buildFilename` 的纯转发，无附加逻辑
- `popup.js:4100-4101` 内层 `const pn` 遮蔽外层参数
- `popup.js:4016` `innerHTML` 常量字符串（无注入风险，风格不一致）
- `zipcore.js:85-91` `decodeZipName` 用 `fromCharCode` 近似 CP437（非真表，老包会乱码）

---

## 四、优化机会与推荐策略

### 4.1 二次检索优化 —— 五个候选方案

| 方案 | 提速 | 稳定性 | 改动成本 | 风险 |
|---|---|---|---|---|
| **A 标签页池化** | **最大**（N 次开页 → 1 次） | 高 | 中 | 中（需处理页面被导航/崩溃后重建） |
| B 调官网私有接口 | 理论最快 | **低** | 中 | **高**（接口随时变，会让功能整体失能） |
| C 并发检索 | 大 | 中 | 中 | 中（易触发官网限流/风控，与暂停/取消状态机冲突） |
| **D 失败短路 + 超时收紧** | 中 | **高** | 低 | **低**（只改超时，不动主流程） |
| E 扩展侧静默抓取 | 大 | 低-中 | 中 | 高（同 B） |

### 4.2 ⭐ 推荐策略：**A + D 组合**

**为什么是 A**：
- 唯一「投入一次、收益随批量**线性放大**」的改动 —— 现在 20 个编号开 20 次页，改完开 **1 次**；200 个编号也一样
- 实现要点：
  - `DOC_TAB = { id, busy }` 模块级状态 + `ensureDocTab()`
  - 复用时用 `tabs.onUpdated` + DOM 就绪探测，**跳过整段渲染轮询**
  - 批次结束 `finally` 统一关闭；出错即关页置 null（失效自愈）
  - 加 `Map<docNo, rows>` 缓存到 `storage.session`，重复编号直接命中

**为什么否决 B / E**：
代码注释里明确写着「官网是 Angular 单页应用，私有接口随时可能变更，因此驱动官网自己的界面」——**这是当初刻意避开的设计决策**。为了速度去碰私有接口，等于把一个稳定功能变成随时可能整体失效，不符合稳定性优先的原则。

**为什么否决 C**：
并发多页易触发官网限流/风控，且与暂停/取消状态机、进度文案冲突，改动面更大，收益不如 A 确定。

**为什么加 D**：
- 超时从 20s/25s 收紧到约 3s/4s（复用后正常单次搜索 1–2s）
- 轮询间隔 500ms → 250ms，轮次 40/50 → 12/16
- `TRIED_FALLBACK` 去重，消除"主通道查过、catch 又查一次"的重复白等
- 精准打掉"受限产品必然白等"这类场景

**预期效果**：你这批 8 个编号，从「8 次开页 × 每次数秒」降到「1 次开页 + 每编号 1~2 秒站内搜索」，查不到的不再长时间空等。**批量越大收益越明显。**

### 4.3 其他高价值优化（按性价比排序）

| 优先级 | 优化项 | 收益 | 成本 | 说明 |
|---|---|---|---|---|
| **P0** | 修复 B1（ZIP 空文件） | 消除静默数据损坏 | 低（约 10 行） | 类型统一为 `Uint8Array` |
| **P0** | 修复 B2（更新挂起） | 消除按钮永久失效 | 低（抽出 `startPoll()`） | |
| **P0** | 修复 B3（SW 对账） | 消除 UI 死锁 | 低（约 15 行） | 启动补写终态帧 |
| **P1** | 方案 A + D | 批量检索 **-40~70%** | 中 | 用户最关心 |
| **P1** | 更新改原子切换 | 可回滚、消除半新半旧 | 中（约 40 行） | |
| **P2** | HTML 只解析一次 | 每编号 -2/3 DOM 开销 | 低 | 1824/2146/2216 合并 |
| **P2** | `getFolderName` 并发 | 前置等待 56s → <5s | 低 | 2592/2595 |
| **P2** | 统一 `fetch` 超时 | 消除永久挂起 | 低 | `AbortSignal.timeout(20000)` |
| **P3** | 抽共享模块收编 4 处重复逻辑 | 杜绝前后台规则漂移 | 低 | `sanitizePath`/`imageExt`/`isUpdateOwnedPath`/`bytesTo*` |
| **P3** | 统一 storage 读写封装 | 消除 29 处静默失败 | 低 | |
| **P3** | `manifest.json` description 瘦身 | 可维护性 | 低 | |

### 4.4 建议执行顺序

```
第一批（阻断，必须修）：B1 → B2 → B3
第二批（性能，用户最关心）：方案 A（标签页池化）+ 方案 D（超时收紧与去重）
第三批（低成本高收益）：HTML 单次解析 / getFolderName 并发 / fetch 超时
第四批（可维护性）：共享模块收编 / storage 封装 / manifest 瘦身
```

---

## 五、审计结论

1. **功能完备度高**，v0.6.11 的架构方向（下载编排迁后台 SW）正确，注释质量在同类项目中属上乘。
2. **3 个阻断级缺陷必须修复**，其中 **B1（后台 ZIP 产出空文件）优先级最高** —— 它静默产出错误数据且 UI 显示成功，是最危险的一类缺陷；**B3 是 v0.6.11 号称根治问题的同构复发**，直接影响用户对"修复"的信任。
3. **二次检索慢的根因已代码级定位**：`tabs.create` 零复用（2010）是主因，`sleep(500)` 固定轮询叠加隐藏页节流是放大器。推荐 **A + D** 组合，收益随批量线性放大，且不动"驱动官网界面"这一稳定性设计决策。
4. **无阻断级安全漏洞**：overwrite 铁律纵深防御到位，路径穿越在更新链路与 CAD 落盘两处均有防护（虽有纵深缺口，但均依赖 Chrome 侧二次消毒，暂不构成可 exploitation 风险）。

---

*本报告由全量源码只读审计生成，所有结论均附代码行号证据。*
