Files
onlyinone/docs/troubleshooting.md

49 KiB
Raw Permalink Blame History

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 时必须同时把备份改成:

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(生产环境无效)。

排查步骤

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                        # 看第一段报错,它会直接告诉你哪条不满足

解决

# 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 上仍标为实验特性。这不是错误。

排查步骤

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。

排查步骤

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 即可):

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 看不到它,所以毫无察觉。

排查步骤

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 + 高熵」判定)。

排查步骤

# 看具体命中位置与规则
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 次心跳失败),界面显示的是本地缓存。

排查步骤

# 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 换掉。所以「跳到旧地址」一定是配置或代理缓存问题, 不是重定向逻辑问题。

排查步骤

# 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 分钟

现象

登录一直返回:

{ "ok": false, "error": { "code": "RATE_LIMITED", "message": "尝试过于频繁,请 15 分钟后再试", "hint": "...", "details": { "retryAfter": 812 } } }

连正确密码也进不去。

可能原因

同一 IP 或同一用户名,在 60s 窗口内失败 ≥5 次即锁定 15 分钟。 最常见的原因是自己写脚本连续试密码。计数落库(login_failures 表), 重启进程不会解锁。

排查步骤

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))})));
"

解决

# 方案 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 份。

排查步骤

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());
"

解决

# 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 只拿到主文件 ⇒ 备份缺最新数据,而它正是回滚用的那份。

排查步骤

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():

// 推荐:一致性快照,无关 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) 查找,只更新它找到的那一行。

排查步骤

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 过滤自己,不影响连接)。想清理就手工合并:

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(系统加密存储) ✅ ✅ ❌ 仅内存

排查步骤

# 在启动器里看「本机角色与依赖状态」卡片;或直接查能力表
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)。

排查步骤

# 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} 内。

排查步骤

# Windows:共享是否真的存在
Get-SmbShare | Where-Object Name -eq 'OnlyInOne'
# 防火墙
Get-NetFirewallRule -DisplayGroup 'File and Printer Sharing' | Select DisplayName,Enabled
# 端口
nc -zv <host> 445
# 共享名白名单(与服务端/客户端同一规则)
node -e "console.log(/^[A-Za-z0-9_\$-]{1,64}$/.test('OnlyInOne'))"

解决

# 管理员 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 条清单。

排查步骤

# 确认不是环境问题,而是设计如此
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 以服务账号运行、 而启动器以当前用户运行。

排查步骤

# 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 删除)。

排查步骤

# 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 → 钥匙串),不在数据目录里。

排查步骤

# 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
:: 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):
    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 通过)必须分开处理。

排查步骤

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 账号:

    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(…) 恒真

反模式:先过滤再逐条断言,过滤器一下子把元素全滤掉时,循环体一次都不执行 ⇒ 恒真。

// ❌ 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 也不查类型。

规约:提交前必须按顺序跑四条,缺一不可:

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. 自证循环(实现同时充当预期)

反模式:用常量生成期望值,而常量正是被测函数的输入。

// ❌ 白名单里被误加一条 '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 断言 「设置文件里真的写进了新值」。漏掉这半,掉一次重启就又回到冲突状态。