Files
onlyinone/docs/troubleshooting.md

1099 lines
49 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Only in One · 故障排查手册
> 19 条真实踩过的坑,每条按 **现象 / 可能原因 / 排查步骤 / 解决** 四段写。
> 命令默认在仓库根目录执行;涉及服务端的在 `server/` 下。
>
> 第 16 条(对端证书不落盘)是 **D3/D4 把证书文件名键从 `device_name` 改成
> `device_id` 之后**新增的;升级后「凭据目录里同时存在新旧两种文件名」是预期行为,
> 见该条末尾的说明。
>
> 第 17 / 18 条是 **T-6 打包交付**新增的:覆盖安装后"还是旧版本/两个 app",
> 以及「数据目录到底在哪、能不能搬、怎么彻底卸载」。
>
> 第 19 条是**用户在真机上第一次撞到**之后补的:双击提示「包含恶意软件」打不开。
> 它把「未公证 ≠ 中毒」「macOS 15 起『右键 → 打开』已失效」「每次重新打包要再放行一次」
> 三件事一次说清 —— 这条最容易被误判成"包中毒了",**先看它再决定要不要删包**。
>
> 文末**附录 A** 是本项目真实踩过的「测试反模式」清单(6 条),写用例前值得扫一眼。
---
> ## ⚠️ 先看这条:迁移备份只复制主 db 文件
>
> `server/migrate.js#createBackup()` 用的是 `fs.copyFileSync(dbPath, target)` ——
> **只复制主数据库文件**。当前实现没开 WAL(`journal_mode` 保持 SQLite 默认),
> 所以「冷启动时备份」是完整的。
>
> **但一旦将来把 `journal_mode` 改成 `WAL`(并发读性能更好,很常见的改动)**,
> 最新事务可能还在 `-wal` 文件里没 checkpoint 回主文件,此时 `copyFileSync`
> 出来的备份会**缺最后一部分已提交数据**,而它恰恰是「迁移失败后用来回滚」的那份。
>
> 改 WAL 时必须同时把备份改成:
> ```js
> db.exec(`VACUUM INTO '${target.replace(/'/g, "''")}'`); // 推荐:一致性快照
> // 或先 db.exec('PRAGMA wal_checkpoint(TRUNCATE)') 再 copyFileSync
> ```
> 详见第 10 条。
---
## 1. 服务端起不来:`JWT_SECRET` 被拒绝
**现象**
```
配置校验失败:
- JWT_SECRET 未设置。生成方式:node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
```
或
```
- JWT_SECRET 长度必须 ≥32 字符(当前 12 字符),否则可被暴力穷举
- JWT_SECRET 命中了内置默认值黑名单(如 dev-only-change-me),必须替换为随机串
```
进程 **exit code 1**,不监听端口。
**可能原因**
1. `.env` 没建、或建了但没被加载;
2. `JWT_SECRET` 太短(<32)或命中弱值黑名单(`secret` / `changeme` / `todo` / `onlyinone` …);
3. 全同字符(`aaaaaaaa…`);
4. `NODE_ENV=production` 下设了 `ALLOW_WEAK_SECRET=1`(生产环境无效)。
**排查步骤**
```bash
cd server
ls -la .env # 存在吗?
node -e "console.log(JSON.stringify(process.env.JWT_SECRET||'(unset)'))" # 别直接 echo,避免进 shell history
node -e "console.log(require('fs').readFileSync('.env','utf8').split('\n').filter(l=>l.startsWith('JWT')).map(l=>l.split('=')[0]))"
npm start # 看第一段报错,它会直接告诉你哪条不满足
```
**解决**
```bash
# 1) 生成强密钥
node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
# 或: openssl rand -hex 48
# 2) 写进 server/.env(不要提交进仓库)
echo "JWT_SECRET=<上一步的输出>" >> server/.env
# 3) 用带 env 加载的方式启动(package.json 的 start 脚本已内置 --env-file-if-exists)
cd server && npm start
```
> 校验逻辑落点:`server/config.js#inspectJwtSecret()`。
> `npm start` = `node --env-file-if-exists=.env server.js`,所以 `.env` 会被真正加载。
---
## 2. `ExperimentalWarning: SQLite is an experimental feature`
**现象**
```
(node:11395) ExperimentalWarning: SQLite is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
```
服务照常启动、测试照常通过。
**可能原因**
用的是 `node:sqlite`(Node 内置模块),在 Node 22 上仍标为实验特性。**这不是错误**。
**排查步骤**
```bash
node -v # 需要 >= 22.5;推荐 >= 24
node -e "require('node:sqlite'); console.log('sqlite ok')"
```
**解决**
无需处理。想彻底消掉告警就升级到 Node ≥24(`server/package.json` 的
`engines.node` 是 `>=22.5`)。服务启动时也会打一条 info 日志提醒这一点
(`server.js` 里检测到 22.x 才打)。
---
## 3. `node --test <目录>` 报 `Cannot find module .../test`
**现象**
```
Error: Cannot find module '/path/to/server/test'
```
或测试数变成 0。
**可能原因**
`node --test` 早期版本的目录参数解析与 glob 支持在不同小版本间不一致;
传**目录**在部分版本上会被当成模块路径去 require。
**排查步骤**
```bash
cd server
cat package.json | node -e "process.stdin.on('data',d=>console.log(JSON.parse(d).scripts.test))"
# 必须是: node --test test/*.test.mjs
```
**解决**
用 **glob 形式**(`package.json` 里已经是这样,直接 `npm test` 即可):
```bash
cd server && npm test
# 等价于: node --test test/*.test.mjs
# 覆盖率门禁
cd server && npm run test:coverage
# 等价于: node --test --experimental-test-coverage --test-coverage-lines=60 test/*.test.mjs
```
---
## 4. clone 之后 `npm test` 报 fixture ENOENT
**现象**
```
Error: ENOENT: no such file or directory, open '.../server/test/fixtures/test-cert.pem'
```
而在原作者机器上一切正常。
**可能原因**
机器的**全局** gitignore(`~/.gitignore_global`)里常有 `*.pem`,导致 `.pem` fixture
从来没进过版本库。原作者 `git status` 看不到它,所以毫无察觉。
**排查步骤**
```bash
git ls-files | grep -i fixtures
# 期望能看到:server/test/fixtures/fakeSecrets.mjs、server/test/fixtures/testCert.mjs
# 如果只看到 .mjs、看不到任何 .pem —— 说明设计如此(见下)
git check-ignore -v server/test/fixtures/test-cert.pem
# 若输出命中 ~/.gitignore_global 的 *.pem,就是本条的根因
```
**解决**
**不要**用 `git add -f` 强塞 `.pem`(下一个人加新 fixture 会再踩一次)。
正确做法是 fixture 用 `.mjs` 模块导出字符串 —— 现成实现:
- `server/test/fixtures/testCert.mjs`:`export const TEST_CERT_PEM = [...].join('\n')`
- `server/test/fixtures/fakeSecrets.mjs`:`dec()` + 运行期拼装(见第 5 条)
---
## 5. `git commit` 被 gitleaks 阻断
**现象**
```
WRN leaks found: 6
Finding: <PEM 私钥块起始标记> ← 命中的就是这段标记本身
Secret: <同上>
RuleID: private-key
Tags: [decoded:base64 decode-depth:1]
```
> 本文也遵守同样的规矩:**不写 PEM 字面量**(连示例都不写),否则本文件自己就会被
> pre-commit 拦下 —— 写这份文档时已实测撞了一次。完整输出请直接跑 `gitleaks protect --staged -v` 看。
```
```
提交被 pre-commit hook 拒绝。
**可能原因**
1. 测试里为了验证「含私钥的 PEM 必须被拒绝」,直接写了明文 PEM;
2. 把假密钥 base64 之后**仍然**命中 —— gitleaks 8.30.1 **会先 base64 解码再匹配**
(日志里的 `decoded:base64` 就是证据);
3. 把 base64 切块拼接 —— 切出来的高熵串会命中 `generic-api-key`
(按「关键字邻近 key/token/secret + 高熵」判定)。
**排查步骤**
```bash
# 看具体命中位置与规则
gitleaks protect --staged --verbose 2>&1 | head -40
# 确认 hook 是全局的(不是仓库里的)
git config --get core.hooksPath
ls -la "$(git config --get core.hooksPath 2>/dev/null || echo .git/hooks)" | head
```
**解决**
按 `server/test/fixtures/fakeSecrets.mjs` 的模式改:
1. PEM 的 `BEGIN/END` 标记**运行期拼装**:`'-'.repeat(5)` + 标签 + `'-'.repeat(5)`;
2. 载荷体 base64 存、运行时 `dec()` 解码;
3. 每个 base64 串**单独成行**且该行**不含** `key` / `token` / `secret` 关键字
(连 PEM 标签也 base64 存:`TAG_PKCS8`);
4. JWT 分隔符用 `String.fromCharCode(46)`;
5. 加 import 期 `SELF_CHECK` 保证解码结果与原来逐字节一致。
> ❌ **不要**加 `.gitleaksignore`、**不要** `git commit --no-verify`。
> 那等于把整个仓库的密钥扫描关掉 —— 之后真泄漏也发现不了。
---
## 6. 登录后设备一直显示「离线 / 暂无其他设备」
**现象**
两台机器都登录了,但设备列表里对方一直是灰点或干脆没有。
**可能原因**
1. 对方启动器**只登录了、没点「上线」** —— 心跳是 30s 周期,首帧要等第一次 tick;
2. 服务端 `last_seen` 超过 **90 秒**即判定离线(客户端 `online` 阈值与
`server.js#ONLINE_THRESHOLD_MS` 同为 90_000);
3. 两台机器的 `device_name` 相同(都取 `os.hostname()`),被当成同一行设备;
4. 客户端处于 `degraded`(连续 2 次心跳失败),界面显示的是本地缓存。
**排查步骤**
```bash
# 1) 服务端看设备行与 last_seen(毫秒时间戳)
cd server && node -e "
const {DatabaseSync}=require('node:sqlite');
const db=new DatabaseSync('data/onlyinone.db');
console.log(db.prepare('SELECT id,user_id,device_id,device_name,platform,last_seen FROM devices').all());
console.log('now=',Date.now());
"
# 2) 直接打心跳看返回
curl -s -X POST http://127.0.0.1:3000/api/v1/devices/heartbeat \
-H "Authorization: Bearer $ACCESS" -H 'Content-Type: application/json' \
-d '{"device_name":"manual-test"}'
# 3) 看启动器日志
tail -50 ~/Library/Application\ Support/onlyinone-launcher/logs/launcher.log
```
**解决**
- 点一次「上线(心跳 + 同步证书 + 发现设备)」,等 ≥30s 再看;
- 若两台机器 hostname 相同:改其中一台的系统主机名(`scutil --set HostName` / Windows 设置里改);
- `degraded` 态下先确认服务器可达(`curl /api/v1/health`),心跳会自动重试、无需重启。
---
## 7. 换了服务器地址/端口后,请求还打到旧地址
**现象**
服务器已迁到新机器/新端口,启动器仍报 `NETWORK_UNREACHABLE`,或抓包看到请求发往旧 IP。
**可能原因**
1. 启动器设置项里存着旧的 `serverUrl`(**设置项优先级最高**,压过环境变量);
2. 部署在反向代理后,代理缓存了旧的 **308** 重定向(308 是永久重定向,会被缓存);
3. 旧地址上还跑着一个旧版本服务,看起来「有响应」但其实连错了库。
> 说明:本服务的 308 只做**路径**重写(`/api/*` → `/api/v1/*`),`Location` 是
> **相对路径**,不会把 host 换掉。所以「跳到旧地址」一定是配置或代理缓存问题,
> 不是重定向逻辑问题。
**排查步骤**
```bash
# 1) 启动器实际用的是哪个地址(设置项 > 环境变量 > 默认值)
# 界面上「服务器地址」输入框即设置项值;查看落盘:
cat ~/Library/Application\ Support/onlyinone-launcher/settings.json
# 2) 服务端自己监听的地址
curl -s http://127.0.0.1:3000/api/v1/health
# 3) 跟随重定向看完整链路
curl -sv http://<新地址>:3000/api/devices 2>&1 | grep -E "^< HTTP|^< Location|Connected"
```
**解决**
1. 在启动器界面把「服务器地址」改成新地址并点登录(会持久化到 `settings.json`);
2. 清代理缓存里 308 的条目,或把代理规则改成直接转发到 `/api/v1`;
3. 内网调试可用环境变量覆盖:`OIO_API_BASE=http://192.168.1.10:3000`,
但**设置项一旦有值就压过它** —— 想用环境变量请先把界面上的地址清空。
---
## 8. 限流把自己锁了 15 分钟
**现象**
登录一直返回:
```json
{ "ok": false, "error": { "code": "RATE_LIMITED", "message": "尝试过于频繁,请 15 分钟后再试", "hint": "...", "details": { "retryAfter": 812 } } }
```
连正确密码也进不去。
**可能原因**
同一 IP **或**同一用户名,在 60s 窗口内失败 ≥5 次即锁定 15 分钟。
最常见的原因是**自己写脚本连续试密码**。计数落库(`login_failures` 表),
**重启进程不会解锁**。
**排查步骤**
```bash
cd server && node -e "
const {DatabaseSync}=require('node:sqlite');
const db=new DatabaseSync('data/onlyinone.db');
const rows=db.prepare('SELECT subject,count,locked_until FROM login_failures ORDER BY locked_until DESC').all();
console.log(rows.map(r=>({...r, remainSec: Math.max(0, Math.round((r.locked_until-Date.now())/1000))})));
"
```
**解决**
```bash
# 方案 A:等(最小 15 分钟)
# 方案 B:清掉某个主体
cd server && node -e "
const {DatabaseSync}=require('node:sqlite');
const db=new DatabaseSync('data/onlyinone.db');
console.log(db.prepare(\"DELETE FROM login_failures WHERE subject=?\").run('ip:127.0.0.1'));
"
# 方案 C:全清(演示环境/本地开发用)
cd server && node -e "
const {DatabaseSync}=require('node:sqlite');
const db=new DatabaseSync('data/onlyinone.db');
console.log(db.prepare('DELETE FROM login_failures').run().changes,'rows cleared');
"
```
> 部署在反向代理后**务必设 `TRUST_PROXY=1`**,否则所有人共享一个 IP 计数器,
> 一个人失败 5 次就把全团队锁住(见 `docs/security.md` §7)。
---
## 9. 迁移失败后数据库被回滚了,想确认/手工恢复
**现象**
启动时报 `数据库迁移失败,拒绝启动`,进程 exit 1。再启动时数据「回到迁移前」。
**可能原因**
这是**设计行为**:`runMigrations()` 在每个迁移文件前都先备份,任一语句失败会
`ROLLBACK` → 关连接 → **用备份覆盖 db 文件** → 抛 `MigrationError`。
备份在 `server/data/backups/onlyinone-<ISO时间戳>.db`,只保留最近 **3 份**。
**排查步骤**
```bash
cd server
ls -la data/backups/ # 应该有 1~3 个 onlyinone-*.db
tail -30 data/logs/server.log | grep -i migration
node -e "
const {DatabaseSync}=require('node:sqlite');
const db=new DatabaseSync('data/onlyinone.db');
console.log('applied:',db.prepare('SELECT version,name FROM schema_migrations ORDER BY version').all());
console.log('users:',db.prepare('SELECT COUNT(*) c FROM users').get());
"
```
**解决**
```bash
# 0) 先停服务,确认没有进程占用 db
pgrep -f "server.js" && echo "还有进程,先停" || echo clean
# 1) 备份当前(已经回滚过的)库,便于事后对比
cp data/onlyinone.db /tmp/broken-$(date +%s).db
# 2) 用指定备份手工恢复(先选好时间戳)
cp data/backups/onlyinone-2025-01-01T12-00-00-000Z.db data/onlyinone.db
# 3) 若目录里存在 -wal / -shm(WAL 模式才有),一并删掉,否则旧快照会与新主文件不一致
rm -f data/onlyinone.db-wal data/onlyinone.db-shm
# 4) 修掉那个坏迁移 SQL,再启动
npm start
```
> **备份只有 3 份**:如果连着失败 3 次以上,真正的「迁移前」备份可能已经被轮转删掉了。
> 所以修 SQL 之前不要反复重启 —— 每失败一次就消耗一代备份。
---
## 10. 迁移备份只复制主 db 文件(将来启用 WAL 必须改)
**现象**
目前**没有现象** —— 这是给未来埋的雷。
**可能原因**
`createBackup()` 用 `fs.copyFileSync(dbPath, target)`。默认 journal 模式下冷启动复制
是完整的;但如果有人把 `journal_mode` 改成 `WAL`(为并发读):最新事务在
`-wal` 文件里,`copyFileSync` 只拿到主文件 ⇒ **备份缺最新数据**,而它正是回滚用的那份。
**排查步骤**
```bash
cd server && node -e "
const {DatabaseSync}=require('node:sqlite');
const db=new DatabaseSync('data/onlyinone.db');
console.log('journal_mode =',db.prepare('PRAGMA journal_mode').get());
"
# 若输出 wal,就命中本条的适用范围
```
**解决**
启用 WAL 时**必须**同步改 `server/migrate.js#createBackup()`:
```js
// 推荐:一致性快照,无关 journal 模式
db.exec(`VACUUM INTO '${target.replace(/'/g, "''")}'`);
// 或者先 checkpoint 再复制文件
db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
fs.copyFileSync(dbPath, target);
```
并且回滚时一并处理 `-wal`/`-shm`(见第 9 条第 3 步)。
---
## 11. 首次升级后,同一台机器出现两行设备
**现象**
`GET /api/v1/devices` 里同一台机器出现两条:一条 `device_id` 为 `null`(旧行,只有
`device_name`),一条 `device_id` 有值(新行)。
**可能原因**
**这是预期行为。** 迁移 002 给 `devices` 加了 `device_id` 列;老数据填 `null`,
而唯一索引是 `UNIQUE(user_id, device_id)` —— SQLite 里 `NULL` 互不相等,所以
老行 + 新行可以共存。心跳按 `(user_id, device_name)` 查找,只更新它找到的那一行。
**排查步骤**
```bash
cd server && node -e "
const {DatabaseSync}=require('node:sqlite');
const db=new DatabaseSync('data/onlyinone.db');
console.log(db.prepare('SELECT id,user_id,device_id,device_name,last_seen FROM devices ORDER BY device_name,id').all());
"
```
**解决**
一般**无需处理**(客户端按 `device_name` 过滤自己,不影响连接)。想清理就手工合并:
```bash
cd server && node -e "
const {DatabaseSync}=require('node:sqlite');
const db=new DatabaseSync('data/onlyinone.db');
// 1) 把旧行的证书迁到有 device_id 的那行(若旧行有证书)
// 2) 删掉 device_id 为 NULL 且 device_name 与某新行重名的旧行
const dupes = db.prepare(\`
SELECT old.id AS old_id FROM devices old
JOIN devices nw ON nw.user_id=old.user_id AND nw.device_name=old.device_name AND nw.device_id IS NOT NULL
WHERE old.device_id IS NULL\`).all();
for (const {old_id} of dupes) {
db.prepare('DELETE FROM certs WHERE device_id = ?').run(old_id);
db.prepare('DELETE FROM devices WHERE id = ?').run(old_id);
}
console.log('removed', dupes.length);
"
```
---
## 12. USB 挂载报 `UNSUPPORTED_ON_PLATFORM`
**现象**
点「挂载」后提示「当前系统不支持该操作(错误码 UNSUPPORTED_ON_PLATFORM)」。
**可能原因**
该方向的能力位在平台表里是 `unsupported` —— **不是缺依赖,是本轮没做**。
三端能力矩阵(`launcher/src/platform/*.ts` 的 `*_CAPABILITIES`):
| 能力位 | Windows | macOS | Linux |
|---|---|---|---|
| `streamHost`(被控) | ✅ | ❌ 需虚拟显示器,V2 | ❌ 未验收 |
| `streamClient`(主控) | ✅ | ✅ | ❌ 未验收 |
| `usbExport`(导出本机 USB) | ✅ | ❌ 无 usbipd | ❌ 未验收 |
| `usbImport`(挂载远端 USB) | ❌ 本轮只做导出 | ✅ | ❌ 未验收 |
| `smbExport`(本机共享) | ✅ | ❌ 用系统「文件共享」 | ❌ 未验收 |
| `smbImport`(打开远端共享) | ✅ | ✅ | ❌ 未验收 |
| `mdns` | ❌ 回落端口扫描 | ✅ | ❌ 未验收 |
| `credential`(系统加密存储) | ✅ | ✅ | ❌ 仅内存 |
**排查步骤**
```bash
# 在启动器里看「本机角色与依赖状态」卡片;或直接查能力表
cd launcher && npx vitest run test/platform.test.ts # 含 8 位 × 3 端逐项断言
```
**解决**
按矩阵换机器:**Windows 当被控 + 导出 USB,macOS 当主控 + 挂载 USB**。
Linux 见第 15 条。UI 会显示能力位自带的中文原因(例如
「本轮 Windows 端只做 USB 导出,导入请用 macOS 主控端」),不会显示成"未安装"。
---
## 13. `usbip attach` 提权失败 / macOS 弹授权框后没反应
**现象**
挂载长时间无响应;或返回 `ELEVATION_REQUIRED` / `COMMAND_FAILED`。
**可能原因**
1. macOS 的 `usbip attach` 需要 `sudo`,我们走 `osascript` 弹系统授权框 —— 用户点了「取消」
会得到 `execution error: User canceled. (-128)`,映射为 `ELEVATION_REQUIRED`;
2. 目标机没有 `usbipd`(Windows)或该 busid 未共享;
3. 网络不通(`usbip` 默认端口 3240)。
**排查步骤**
```bash
# 1) 目标端是否在共享
usbipd list # Windows(管理员)
# 2) 网络是否可达
nc -zv <host> 3240
# 3) 手动跑一次,看真实报错(这条最能定位问题)
sudo usbip attach -r <host> -b 1-2
# 4) 启动器日志里有结构化错误码
tail -50 ~/Library/Application\ Support/onlyinone-launcher/logs/launcher.log | grep -i usb
```
**解决**
- macOS 上请在弹窗里点「允许」(`osascript` 需要「辅助功能」或管理员口令);
- 目标端先 `usbipd bind --busid 1-2`,再在自己的启动器里点「共享」;
- 跨网段时确认 3240/tcp 放行;USB over IP 不建议跨公网(延迟与稳定性)。
---
## 14. SMB 共享打不开
**现象**
「打开共享文件夹」失败;或本机共享开启失败。
**可能原因**
1. 本机共享在 Windows 上依赖 `scripts/setup-smb.ps1`(建共享 + `EncryptData`),
没执行过 `enableLocal` 就没有共享;
2. 防火墙拦了 445/tcp;
3. macOS 的「本机共享」是 `unsupported`(要用系统设置里的「文件共享」);
4. 共享名不在白名单 `[A-Za-z0-9_$-]{1,64}` 内。
**排查步骤**
```powershell
# Windows:共享是否真的存在
Get-SmbShare | Where-Object Name -eq 'OnlyInOne'
# 防火墙
Get-NetFirewallRule -DisplayGroup 'File and Printer Sharing' | Select DisplayName,Enabled
```
```bash
# 端口
nc -zv <host> 445
# 共享名白名单(与服务端/客户端同一规则)
node -e "console.log(/^[A-Za-z0-9_\$-]{1,64}$/.test('OnlyInOne'))"
```
**解决**
```powershell
# 管理员 PowerShell,在目标目录执行(脚本内置路径/共享名校验 + 幂等)
powershell -ExecutionPolicy Bypass -File scripts\setup-smb.ps1 -SharePath "D:\Share" -ShareName OnlyInOne
```
macOS 作为共享方:系统设置 → 通用 → 共享 → 文件共享,手动添加目录;
客户端侧仍可用「打开共享文件夹」(`smbImport` 在 macOS 是支持的)。
---
## 15. Linux 端为什么大部分功能不可用
**现象**
Linux 上启动器能登录、能看设备列表,但 USB / 共享 / 串流全部报
`UNSUPPORTED_ON_PLATFORM`,且提示「本轮未做 Linux 真机验证,暂不支持」。
**可能原因**
这是**本轮明确的范围边界**,不是 bug:Linux 只做了「代码分支就绪、不纳入验收」。
所有 8 个能力位在 `LINUX_CAPABILITIES` 里都是 `unsupported`(`credential` 为
「仅内存」),`usb === null`(显式降级实现类,不是 else 兜底)。
`docs/packaging.md` 里列了「未来纳入 Linux 需补什么」的 9 条清单。
**排查步骤**
```bash
# 确认不是环境问题,而是设计如此
cd launcher && npx vitest run test/platform.test.ts
# 断言包含:linux 的 usb === null、8 位能力全 unsupported、原因文案为中文
# 想验证 Linux 分支代码本身是否可用(不真机):
cd launcher && npx vitest run test/... # 各平台纯函数测试都可在任意宿主跑
```
**解决**
- 需要 Linux 串流/USB:本轮请改用 Windows(被控 + USB 导出)+ macOS(主控 + USB 挂载);
- 想自己接 Linux:按 `docs/packaging.md` 的 9 条清单逐条补(`platform/linux.ts` 把
`state` 从 `unsupported` 改成 `supported` 并补实现,**调用方零改动**);
- 登录/设备列表/心跳在 Linux 上本来就能用(`credential` 降级为内存,重启需重登)。
---
## 16. 对端证书不落盘 / 凭据目录里的文件「只增不减」
**现象**
- 连对端时仍然被要求输 PIN(说明 Sunshine 没拿到对方公钥);
- 或者凭据目录里躺着一堆 `moonlight-*.pem`,把服务端已经没有的设备删掉后它们还在;
- 启动器日志里反复出现 `peer 证书缺少 device_id,跳过写入` /
`peer 证书 device_id 不符合 [A-Za-z0-9-]{8,64}` / `受管证书未全部落盘`;
- 或者出现 `本机 device_id 已被他人占用,已换新 id` —— 这是**自愈动作**,
不是故障,见下方「解决」第 1 条。
**可能原因**(按概率排序)
1. **该设备的 `device_id` 为空**(`reason = missing_device_id`)。
D3 之后证书文件名主体是 `device_id`,没有它就无法生成文件名。为空的唯一情形是
「这台设备从未成功心跳过」(心跳里 `device_id` 是可选字段,老客户端不带)。
2. **`device_id` 格式不合法**(`reason = invalid_device_id`)。必须是
`[A-Za-z0-9-]{8,64}`;带 `.` `_` 空格、或长度不足 8 位的值一律拒收。
3. **某一轮写入失败,导致整轮清理被跳过**(`reason = expected_missing`)。
这是有意的保护:只有当「本该落盘的文件」全部确实在目录里时,才允许删除多余文件;
否则说明本地与服务端快照不一致,删任何一个都可能删掉唯一可用副本。
4. **凭据目录本身不可写 / 不存在**(`reason = io_error` 带 `code`,
或 `dir_missing`)。`CRED_DIR_NO_PERMISSION` 常见于 Sunshine 以服务账号运行、
而启动器以当前用户运行。
**排查步骤**
```bash
# 1) 看服务端记的 device_id(为空/过短的设备就是「写不进去」的那台)
cd server && node -e "
const {DatabaseSync}=require('node:sqlite');
const db=new DatabaseSync('data/onlyinone.db');
console.log(db.prepare('SELECT device_name,device_id,last_seen FROM devices ORDER BY id').all());
"
# 2) 看凭据目录里到底有什么(只列文件名,不要 cat 内容)
# macOS / Linux
ls -l ~/Library/Application\ Support/Sunshine/credentials/ 2>/dev/null || ls -l ~/.config/sunshine/credentials/
# Windows
dir "%APPDATA%\Sunshine\credentials"
# 3) 看启动器日志里的 reason(时间戳对齐一次心跳周期 30s)
grep -E "missing_device_id|invalid_device_id|受管证书未全部落盘|peer 证书" \
~/Library/Application\ Support/onlyinone-launcher/logs/launcher.log | tail -20
# 3b) 「凭据目录只增不减」专门看这一条:expected_missing 的 warn 带
# missingCount(缺几个)+ sample(缺哪几个文件名,最多 3 个)。
# 它意味着「本该落盘却没落盘」⇒ prune 整轮静默跳过,不是清理本身有 bug。
grep -A2 "受管证书未全部落盘" ~/Library/Application\ Support/onlyinone-launcher/logs/launcher.log | tail -20
# missingCount > 0 且持续多轮不降 = L5 在长期拦住 prune:
# 先按上面第 1 步查那几台设备的 device_id,再按第 4 步查目录权限。
# 4) 确认目标设备的 device_id 是否真的上报成功(正常应回显非 null)
curl -sS -X POST http://127.0.0.1:3000/api/v1/devices/heartbeat \
-H "Authorization: Bearer $ACCESS" -H 'Content-Type: application/json' \
-d '{"device_name":"windesk","device_id":"9f1c2b7e-2a4d-4c6b-9f0e-1a2b3c4d5e6f"}'
```
**解决**
- **两台机器共用同一个 `device_id`(克隆虚拟机 / 复制了 `settings.json`)⇒ 一个心跳周期内自动恢复。**
现象是启动器日志里出现两条 warn:
```
服务端:新设备 device_id 与既有记录冲突,已降级为 null 插入(保住在线状态) deviceId8=xxxxxxxx
客户端:本机 device_id 已被他人占用,已换新 id(下一轮心跳生效) previous8=xxxxxxxx next8=yyyyyyyy
```
服务端把**后到的那台**降级为 `device_id = null`(不返回 500,在线状态保住);
客户端读到响应里的 `device_id: null` 后**立即生成并持久化一个新 UUID**,下一轮心跳
(≤30s)就带上新 id ⇒ 证书随之落进对端 Sunshine 目录 ⇒ **不需要手输 PIN、不需要
重启、不需要人工干预**。
判据:等一个心跳周期后看设备列表里那台设备的 `device_id`,已经从 `null` 变成新值。
若**一直是 `null`**,说明跑的是老客户端(不会自愈),升级该客户端即可。
- `missing_device_id` / `invalid_device_id`:**先让那台设备成功心跳一次**
(在它的启动器上点「上线」)。心跳写入 `device_id` 后,本轮 ≤30s 即自愈,
不需要重启、不需要手工建文件。若它跑的是很老的客户端(心跳不带 `device_id`),
升级该客户端即可 —— 服务端不会替它编一个 id。
- `expected_missing`:先修掉同一轮里的 `io_error`(权限/磁盘满),其余不动。
这条**不需要**任何「清理」动作:受管文件补齐后,陈旧文件会在下一轮正常被清。
- `CRED_DIR_NO_PERMISSION`:让启动器与 Sunshine 用同一个用户运行,
或在设置页把「凭据目录」指到一个当前用户可写的目录。
- **升级后凭据目录里同时存在新旧两种文件名是正常的、无需处理**:
新文件形如 `moonlight-<device_id>.pem`,旧文件形如
`moonlight-<device_name>.pem`。旧文件对本工具来说属于「命名不合规」
(`not_managed_naming`),既不参与清理也不会被删除;它们只是几 KB 的公钥副本,
可以手工删。**本轮不提供、也不需要迁移脚本** —— 证书文件是纯缓存
(内容完全来自 `GET /api/v1/certs`,本机私钥从不写在这里),下一次成功同步即重建。
- 仍然不行:按第 6 条的「排查步骤」导出启动器日志,并把 `GET /api/v1/certs`
的响应(**去掉 `cert_pem`**)一起贴出来。
> **已知边界(不阻塞、不需要改设计)**:理论上两个 `device_id` 若**仅大小写不同**
> (如 `Aaaa1111` / `aaaa1111`),在大小写不敏感的文件系统上仍会落到同一个文件。
> 现实里不会触发:`device_id` 由 `ensureDeviceId()` 用 `crypto.randomUUID()` 生成,
> 全小写且随机,两台机器撞到「仅大小写不同」的概率可忽略。即便真撞上,后果也只是
> 其中一台的证书本轮没写成功 —— prune 会因 L5 走 `expected_missing` 零删除,
> **不会误删**,不会变成「连接突然要求输 PIN」。
> 这也是 D3 相比 D1 的实质改进:旧方案(用 `device_name` 当文件名)在大小写不敏感
> 文件系统上**必然**覆盖,而这里只是「理论上可能」,且被 L5 兜住。
---
## 17. 覆盖安装后还是旧版本 / `/Applications` 里出现两个 app
**现象**
- 把新 `OnlyInOne-0.2.0-mac-arm64.dmg` 拖进「应用程序」替换后,行为/界面还是旧的
(本项目 UI 不显示版本号,所以"看起来一样"本身不能当判据);
- 或者 `/Applications` 里出现两个 `Only in One`(Finder 会叫 `Only in One 2.app`);
- 或者覆盖后"数据丢了":登录态没了、设置回到默认。
**可能原因**(按概率排序)
1. **应用还在跑**。本工具有托盘常驻:**关窗口 ≠ 退出**,进程还在时 Finder 替换会失败
(或替换的是另一个副本),用户看到的仍是旧进程。
2. **旧副本被 LaunchServices 记住**。曾直接从 dmg 里双击运行过 → 注册的是
`/Volumes/Only in One 0.2.0-arm64/...` 那份(**卷名带版本号**);覆盖后双击 dmg 仍可能拉起旧副本。
3. **`productName` 被改过**(`.app` 改名)→ 新旧两个 app 并存,用户双击到哪个看运气。
`launcher/test/paths.test.ts` 有守卫断言它必须是 `Only in One`。
4. **`appId` 被改过** → 系统视为**另一个 App**:Preferences plist 新建、TCC 授权重新要、
登录项失效。此时数据其实没丢,只是落在"新 App"的目录里(见第 18 条)。
5. **拖进去的不是新包**(比如拿了 `launcher/release/` 里残留的 8 月旧产物 —— 已在 T-6 删除)。
**排查步骤**
```bash
# 1) 还有没有实例在跑(有输出 = 没真退出,先退出再装)
pgrep -af "Only in One.app/Contents/MacOS/Only in One"
# 2) /Applications 里到底几个
ls -d /Applications/Only*
# 3) 版本与身份(期望 0.2.0 / com.onlyinone.launcher)
plutil -p "/Applications/Only in One.app/Contents/Info.plist" \
| grep -E "CFBundleShortVersionString|CFBundleIdentifier|CFBundleName"
# 4) app.asar 的时间戳应是本次构建时间(不是 8 月)
ls -l "/Applications/Only in One.app/Contents/Resources/app.asar"
# 5) LaunchServices 里登记了几份(同一 appId 出现多条 = 第 2/3 条原因)
/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister \
-dump | grep -i "Only in One" | grep -i "\.app" | head
```
**解决**
- **先真退出**:托盘菜单 →「退出」(或 `osascript -e 'quit app "Only in One"'`),
确认 `pgrep` 无输出后再拖拽替换。
- **重注册新位置**:`lsregister -f "/Applications/Only in One.app"`;
若记录里还有 `/Volumes/...`,把 dmg「推出」后重跑一次。
- **删掉多出来的副本**:删前先看它的 appId(`plutil -p "…/Contents/Info.plist" | grep CFBundleIdentifier`)。
appId 相同 = 两份共用同一个数据目录,删哪份都不丢数据;appId 不同 = 跑的是改过配置的包,
数据目录也不同(见第 18 条迁移办法)。
- **数据"消失"先别慌**:先按第 18 条确认目录落在哪,再决定是搬回来还是本来就没丢。
- **校验你拿到的是新包**:`shasum -a 256 OnlyInOne-0.2.0-mac-arm64.dmg`,与同目录
`SHA256SUMS.txt`(或发送方给出的值)逐字符对比。
---
## 18. 应用到底建了哪些文件夹 / 数据能不能搬 / 怎么彻底卸载
**现象**
- 想知道「这个工具会不会在系统里到处建文件夹」;
- 换机器或重装前想确认哪些文件是"数据"、能不能整体搬走;
- 或者反过来:`~/Library/Application Support` 里同时出现 `onlyinone-launcher` 与
`Only in One`(带空格)两个目录。
**可能原因**
1. 正常形态就是 **1 个数据目录 + 1 个 Preferences plist**(见下表)。数量是收敛的,
因为数据根目录被 `core/paths.ts` 的 `DATA_DIR_NAME` 常量钉死,**不再由 app 名推算**。
2. 多出来的 `Application Support/Only in One` 只可能来自「`package.json` 被加过
`productName`」的老包(T-6 之前有 `"productName": "Only in One"` 时会这样)。
3. `Credentials`/`Keychain` 里的凭据是系统托管的(`safeStorage` → 钥匙串),不在数据目录里。
**排查步骤**
```bash
# macOS:期望**恰好 2 条**(1 个数据目录 + 1 个 plist)
find ~/Library -maxdepth 2 -iname "*onlyinone*"
# 数据目录里应该有什么(settings.json / credential.bin / logs/ / app-cache/ + Chromium profile)
ls ~/Library/Application\ Support/onlyinone-launcher
du -sh ~/Library/Application\ Support/onlyinone-launcher
# 应用日志(排障第一现场)
tail -50 ~/Library/Application\ Support/onlyinone-launcher/logs/launcher.log
```
```bat
:: Windows:数据目录唯一,程序本体在你自己选的解压目录
dir "%APPDATA%\onlyinone-launcher"
dir "%LOCALAPPDATA%" | findstr /i onlyinone :: 期望无输出(zip 形态不建 LocalAppData 目录)
```
**这张表就是全部**(macOS)
| 路径 | 由什么决定 | 内容 | 能否不建 |
|---|---|---|---|
| `~/Library/Application Support/onlyinone-launcher/` | `core/paths.ts#DATA_DIR_NAME`(**硬编码常量**) | **唯一数据目录**:`settings.json`、`credential.bin`、`logs/`、`app-cache/`,外加 Chromium 自己的 profile(`Cache/`、`Code Cache/`、`GPUCache/`、`Cookies`、`Local Storage/` …) | ❌ 必须有 |
| `~/Library/Preferences/com.onlyinone.launcher.plist` | `appId`(`CFBundleIdentifier`) | Electron 首启写入的 4 条 macOS 外观默认值,约 190 B | ❌ 系统托管,无法重定向 |
| `/Applications/Only in One.app` | `productName` | 程序本体(约 225 MB 未压缩) | ❌ |
| `/Volumes/Only in One 0.2.0-arm64` | dmg 挂载点(**卷名带版本号**,不是 `Only in One`) | 装机时的临时卷 | ✅ 推出即消失 |
| `~/Library/Caches/onlyinone-launcher/`、`~/Library/Logs/…`、`~/Library/Saved Application State/…` | — | **不存在**(Chromium 缓存在数据目录内部;日志写 `数据目录/logs/`) | ✅ 本来就不会建 |
> Windows 侧同理:`%APPDATA%\onlyinone-launcher\` 是唯一数据目录;程序本体在用户自选的
> 解压目录里,**不写注册表卸载项、不建 `%LOCALAPPDATA%` 目录**。开机自启(默认关闭)
> 由 `app.setLoginItemSettings` 写 `HKCU\…\Run`,关掉自启即消失。
>
> ⚠️ 我们的子目录叫 `app-cache` 而**不是** `cache`:Chromium 在同一目录建的是 `Cache/`,
> 而 APFS 与 NTFS 都大小写不敏感,`cache` 与 `Cache` 会是**同一个目录**,
> 我们的 `devices.json` 就会被 Chromium 的缓存清理逻辑波及(表现为设备列表缓存莫名失效)。
> 这条有单测守卫(`test/paths.test.ts`)。
**解决**
- **搬数据到另一台机器**:退出应用 → 把整个 `onlyinone-launcher` 目录拷到新机器同位置 → 启动。
`settings.json` 会原样生效;但 **`credential.bin` 解不开**(`safeStorage` 用系统钥匙串
加密,换机器/换用户后密钥不同)→ 表现是"需要重新登录一次"。这是设计,不是故障。
- **程序挪目录不影响数据**:Windows 上把 zip 从 `D:\Apps\OnlyInOne` 换到 `E:\Tools\OiO`,
数据仍在 `%APPDATA%\onlyinone-launcher`(这正是 T-6 固化目录的直接收益)。
- **彻底卸载(macOS)**:
```bash
osascript -e 'quit app "Only in One"' # 先真退出(托盘常驻)
rm -rf "/Applications/Only in One.app" # 程序
rm -rf ~/Library/Application\ Support/onlyinone-launcher # 数据(含登录态)
defaults delete com.onlyinone.launcher # Preferences plist
```
- **彻底卸载(Windows)**:退出 → 删掉解压目录 → `rmdir /s /q "%APPDATA%\onlyinone-launcher"`
→ 若开过自启,删 `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` 下的 OnlyInOne 值。
- **只想清缓存**(不动登录态):删数据目录里的 `app-cache/`、`Cache/`、`Code Cache/`、
`GPUCache/`、`Dawn*Cache/`;**不要**碰 `settings.json` / `credential.bin`。
---
## 19. 双击提示「包含恶意软件」打不开(访达这条路被 Gatekeeper 拦)
**现象**
在「访达」里双击 `/Applications/Only in One.app`,弹出:
```
未打开"Only in One.app",因其包含恶意软件。此操作未对Mac造成危害。
```
(更早的系统措辞是:「"Only in One" 无法打开,因为 Apple 无法检查其是否包含恶意软件。」)
点掉提示后再双击,还是一样。**但终端 `open -a "/Applications/Only in One.app"` 能正常启动**
(四进程齐全,功能一切正常)。
**可能原因**(按概率排序)
1. **不是病毒,是本包没做 Apple 公证**。团队内部分发,没有 Apple 开发者账号($99/年),
只有 ad-hoc 签名。上面那句话是 macOS 对**所有未经公证的 App** 的固定文案,确切含义只有
一句:*Apple 手里没有它的公证记录,没法替它背书*。功能极简的内部工具也会收到一模一样的提示。
2. **照着网上教程去右键找「打开」**。macOS 15(Sequoia)起 Apple **已移除**这条绕过路径,
右键菜单里现在没有那个「打开」项 —— 怎么点都点不出来,这不代表 Mac 出了问题。
网上绝大多数教程仍是旧写法。
3. **刚覆盖安装了新版本**。放行记录绑在 App 的 **cdhash** 上,ad-hoc 签名每次打包 cdhash 都变,
之前的放行失效 → 又拦一次。属预期行为,重新放行一次即可。
4. (少数)**签名真的坏了**。这时的提示是「"Only in One" **已损坏**,无法打开。你应该将它移到废纸篓」,
且 `codesign --verify` 会**失败**。与上面三种(`codesign` 通过)必须分开处理。
**排查步骤**
```bash
APP="/Applications/Only in One.app"
# 1) 三条命令定性:包是"好的但没公证",还是"真的坏了"
codesign --verify --deep --strict "$APP"; echo "exit=$?"
# 无输出 + exit=0 → 每个文件与签名记录逐字节一致,**没被篡改**(下面两条都是好消息)
codesign -dv --verbose=4 "$APP" 2>&1 | grep -E "Identifier|flags|Signature|TeamIdentifier"
# Identifier=com.onlyinone.launcher / flags=0x2(adhoc) / Signature=adhoc /
# TeamIdentifier=not set → 本机临时签名,没有开发者身份可追溯
spctl -a -t exec -v "$APP"; echo "exit=$?"
# rejected + exit=3 → 没有 Apple 公证票据。**必然**如此,不等于含恶意软件
# (spctl 根本不看文件内容,它只查 Apple 的公证票据)
# 2) 确认它不是"下载来的、带隔离标记"的(顺带否掉一条流传很广的错方子)
xattr -l "$APP" # 期望只有 com.apple.provenance
xattr -r -l "$APP" | grep -c quarantine # 期望 0
# → 所以 xattr -dr com.apple.quarantine 对本包是**空操作**,跑了也不会改变任何事
# 3) 确认进程侧是干净的(能跑,只是访达这条路被拦)
open -a "$APP" && sleep 3 && pgrep -fl "Only in One.app/Contents/MacOS"
# 期望:main + GPU Helper + NetworkService Helper + Renderer Helper 四进程齐全
# 4) 换新版本后又被拦?看一眼构建时间(每次构建 cdhash 都不同,放行记录随之失效)
ls -l "$APP/Contents/Resources/app.asar"
```
**判据口诀**
| `codesign --verify --deep --strict` | `spctl -a -t exec` | 结论 |
|---|---|---|
| **exit 0** | rejected | **包是好的,只是没公证** → 按「解决」第 1 条放行 |
| 非 0 | rejected | **签名真坏了** → 重新拿包,或按「解决」第 4 条本地补签 |
| exit 0 | accepted | 已公证(将来买了开发者账号才会出现) |
**解决**
1. **正规放行(当前唯一路径,一次性)**:
1. 双击 App 让它被拦 → 点「完成」关掉提示。
**这一步不能省**:系统要先"看见"一次被拦,设置里才会出现放行入口。
2. 苹果菜单 → **系统设置 → 隐私与安全性** → 滚到底部「安全性」一节 →
看到「"Only in One" 已被阻止,以保护 Mac」→ 点 **「仍要打开」**。
3. 输开机密码 / 触控 ID → 系统再问一次 → 再点一次「仍要打开」。
4. 之后双击正常,不再拦。
> 「仍要打开」按钮**只在不早于被拦后约 1 小时**出现。翻遍设置也找不到 =
> 隔太久了,回第 1 步再双击一次,然后立刻去系统设置。
2. **应急(终端)**:`open -a "/Applications/Only in One.app"`。实测能起来
(四进程齐全、`settings.json` 正常写入、`--user-data-dir` 仍指向
`~/Library/Application Support/onlyinone-launcher`、`~/Library` 下与项目相关仍恰好 2 条)。
但它**不改变**双击的行为 —— 想让双击也正常,仍要走第 1 条一次。
3. **每次换新版本又会拦一次**:放行记录绑在 App 的 cdhash 上,ad-hoc 签名没有稳定的
Team Identifier,每次重新打包 cdhash 都变 → 系统视为"没见过的新 App"。
**重新走第 1 条即可**:这不是新包退化、不是杀毒变严、更不是中毒。
彻底消除只有两条路:买 Apple 开发者账号做 Developer ID 签名 + 公证;或关掉 Gatekeeper
(不推荐,等于对整台机器关掉这道防线)。本轮明确选"不做"(PRD-v2 Q11 裁决 B)。
4. **真报「已损坏,无法打开」**(即 `codesign --verify` 失败)才需要补签,**不需要 Apple 账号**:
```bash
codesign --force --deep --sign - "/Applications/Only in One.app"
codesign --verify --deep --strict "/Applications/Only in One.app" && echo "补签成功"
```
正常情况用不到 —— 打包时 `scripts/after-pack.js` 已经做过 ad-hoc 签名。
5. **想确认拿到的是原包**:`shasum -a 256 -c SHA256SUMS.txt`(与 dmg 放在同一目录)。
dmg 卷内还自带一份 `首次打开必读.txt`,上面这些步骤都写在里面 —— 拿到包不用问人。
> ⚠️ 三条**不要**做的事:
> - 不要 `sudo spctl --master-disable`:macOS 15 起已不接受这种非交互式全局策略改动
> (要走 MDM 配置描述符),而且它是对**整台机器**关掉这道防线,为一个内部工具不值。
> - 不要 `xattr -cr` 整个 App「试试看」:本包根本没有 quarantine 属性,跑了只是把
> `com.apple.provenance` 也一起抹掉,把线索掩盖掉。
> - 不要把「右键 → 打开」当成"再试一次就好" —— 它在 macOS 15+ **已经不存在了**。
> 打包侧的同源说明见 `docs/packaging.md` §6(放行步骤)与 §1(dmg 卷内的
> 《首次打开必读.txt》)。
---
# 附录 A · 测试反模式清单
> 本清单只收**本项目真实踩过**的坑,每条都附当时的现场。目的不是讲测试理论,
> 而是让「下次写用例时能立刻认出来」。
>
> 底线一句话:**测试绿 ≠ 能编译 ≠ 可交付**(第 3 条就是活证据)。
## A1. upsert 只覆盖一条路径(本轮 R10 的根因)
**反模式**:只测「行已存在时冲突」,测不到「行不存在 + 目标键已被他人占用」。
**真实案例**:`heartbeat` 是 `按 (user_id, device_name) 查 → 有则 UPDATE / 无则 INSERT`。
我写的用例顺序是「先让 devB 用自己的 id 建行 → 再改成 idA」,第二次必然走 UPDATE,
于是 **INSERT 分支永远没被触达**。结果 `devices` 上的
`UNIQUE INDEX idx_devices_uuid` 在 INSERT 路径上未兜底 → 500,
且因为行没建成,**之后每一次心跳都 500(永久性)**,那台设备永远进不了设备列表。
**规约**:
> 凡「按 key 查 → 有则 UPDATE / 无则 INSERT」的 upsert 逻辑,必须**同时**覆盖两条路径:
> ① 行已存在时冲突(UPDATE);② **行不存在 + 目标键已被他人占用**(INSERT)。
> 只覆盖 ① 的用例看起来是绿的,但它跳过了「行根本建不出来」这类**更重**的失败形态。
**断言要点**:不要只断状态码。要断「那一行到底存不存在」(`GET /devices` 列表里能看到)、
`last_seen` 有没有刷新、以及「下一轮能否自愈」。
## A2. `for (… of 空数组) expect(…)` 恒真
**反模式**:先过滤再逐条断言,过滤器一下子把元素全滤掉时,循环体一次都不执行 ⇒ 恒真。
```ts
// ❌ skipped 为空时,这条断言永远不会失败
for (const item of result.skipped) expect(item.reason).toBe('not_managed_naming');
// ✅ 先钉住「数量/名单完全等于预期」,再断字段
expect(result.skipped.map((i) => i.file).sort()).toEqual(untouched.slice().sort());
expect(result.skipped.every((i) => i.reason === 'not_managed_naming')).toBe(true);
```
**真实案例**:`qa-certs-adversarial.test.ts` 的 C5 —— 我写的版本在 `skipped` 为空时恒绿,
由 QA 复验时改成上面的强断言。
## A3. 测试绿 ≠ 类型正确(esbuild 只剥类型)
**反模式**:把 `npm test` 绿当成「可以提交」。
**真实案例**:客户端自愈那个中间态里
`vitest 493/493 全绿`、`eslint 0 error`,但 `tsc` 报 **3 个 `TS2322`**
(`readDeviceId()` 的推断返回类型仍是 `string | (() => string)`)。
原因:vitest 用 esbuild **只剥类型不做类型检查**,eslint 也不查类型。
**规约**:提交前**必须按顺序**跑四条,缺一不可:
```bash
npx tsc --noEmit -p tsconfig.typecheck.json # 类型
npm run lint # 规则
npm run format:check # 格式
npm test # 行为
```
顺序有意义:CI 里若 `typecheck` 排在 `test` 之后,同样的类型错误会被 `test` 的绿掩盖。
## A4. 夹具形状失效 ⇒ 用例空转
**反模式**:正则/白名单改了,用例还在用「旧规则下合法、新规则下不合法」的夹具。
此时不是「断言太弱」,而是**被测代码根本没进那个分支**。
**真实案例**:证书文件名键从 `device_name` 改成 `device_id`(长度 8–64)后,
QA 夹具 `moonlight-peer-a.pem` 里的 `peer-a` 只有 **6** 个字符 ⇒ 在新正则下
**不是受管文件** ⇒ L1/L2/L3/L4 的保护用例全部走「命名不合规」提前返回,**表面通过**。
修法是把夹具换成 `moonlight-peer-0001.pem` 这种 8 位以上 id,并逐条重核期望值。
**自检问法**:改完规则先问「现有夹具在新规则下还落在原来那条分支吗?」
## A5. 自证循环(实现同时充当预期)
**反模式**:用常量生成期望值,而常量正是被测函数的输入。
```ts
// ❌ 白名单里被误加一条 '1920x1080;rm -rf /',isResolution(它) 仍返回 true,用例照绿
for (const r of ALLOWED_RESOLUTIONS) expect(isResolution(r)).toBe(true);
// ✅ 独立手写结构不变式(正则不复用实现)+ 显式负例
for (const r of ALLOWED_RESOLUTIONS) expect(r).toMatch(/^\d{3,5}x\d{3,5}$/);
expect(ALLOWED_RESOLUTIONS).toHaveLength(12); // 内容被改会红
```
**同族问题**:双端契约靠「两份 ACCEPT/REJECT 字面量清单 + 注释提醒同步」。
只要有人只往一边加值,另一边不会红。**补法**:两端都再钉一条
`DEVICE_ID_PATTERN.source === '^[A-Za-z0-9-]{8,64}$'` —— 任何一边改字符集都会红。
## A6. 只断「函数被调用」不断「调用之后世界变成什么样」
**反模式**:`expect(regenerateDeviceId).toHaveBeenCalled()` 就收工。
**真实案例**:客户端自愈里「生成了新 id」和「新 id **已持久化**」是两回事 ——
前者注入 mock 就能验,后者必须在校验层用内存 store 断言
「设置文件里真的写进了新值」。漏掉这半,掉一次重启就又回到冲突状态。