故障档案

三个组件都没做错,token 还是被删了

激活后 token 写到 USB,但 guardian 最终同步用镜像模式把它删了,导致反复激活。

00:19:34 激活成功,token 写到 USB。00:26:23 guardian 同步跑了一次。00:26:44 用户重启面板,激活页又出现了。

这不是网络问题、不是激活逻辑问题,也不是 token 文件损坏。这是一个诡异的时序陷阱:激活流程写的位置和同步删除的方向完全相反,导致刚写入的 token 在 30 秒内被镜像同步无声地删掉。

现象

实际用户操作序列(某客户机,2026-05-09):

  • 00:19:34 激活流程成功,用户看到"激活成功"提示
  •      进入仪表盘,但侧边栏无内容(无模型、无项目列表)
  • 用户关闭面板,准备重启
  • 00:26:23 重启后打开面板
  • 00:26:44 面板启动,检测激活状态,跳转到激活页
  • 用户再次输入激活码
  • 00:26:54 又一次激活成功提示,循环开始

这 7 分钟内发生了什么。

诊断命令验证出现了矛盾的两个事实:

USB 上的 token 确实存在,每次激活都更新修改时间:

ls E:\wakou-portable\data\auth\token.json

结果:312 字节,时间戳不断更新。

但 cache 位置始终空着:

ls C:\Users\*\AppData\Local\*\Runtime\*\data\auth\token.json

结果:MISSING。

这是关键矛盾:USB 的文件明明在、明明被激活流程反复写入,panel 怎么还看不到激活状态。

排查

排除三个假设

假设 1:token 文件格式损坏或部分丢失

检查 token.json 内容(脱敏):

{
  "version": 1,
  "token": "[REDACTED_TOKEN]",
  "fingerprint": "c0badff089...",
  "expires_at": "2026-08-12T...",
  ...
}

大小始终 312 字节,JSON 结构完整,解析无错误。排除。

假设 2:panel 启动时读的是 cache 版本而非 USB

检查 resolve_portable_root() 的 env 优先级:panel 进程的 WAKOU_USB_ROOT 始终指向 USB 盘符,不会误读 cache。排除。

假设 3:激活流程存在间隔性失败,某些次激活没真正写文件

trace 激活命令的日志时间戳与文件修改时间对齐,激活流程本身无异常。排除。

定位根因

检查 guardian 进程的日志,看最后一行输出:

[2026-05-09 00:26:23] Invoke-FinalSync completed
  Source (cache): C:\Users\*\AppData\Local\*\Runtime\*\data\auth
  Destination (USB): E:\wakou-portable\data\auth
  Mode: /MIR

时间戳 00:26:23 与用户重启时间一致。

同步模式是 /MIR(mirror 镜像)。对比同步前后:

  • 同步前
    • USB: data\auth\token.json (size=312)
    • cache: data\auth\ (目录不存在或为空)
  • 同步后
    • USB: data\auth\ (目录为空)
    • token.json 已删除

根本原因找到了:cache 端没有 token 文件,robocopy /MIR 的语义是"让目标与源完全一致",包括删除目标上源没有的文件

根因分析

这个问题看起来是 token 丢失,实际上是三个独立正确的决策互相冲突了。

激活流程直接写 USB(为了立刻可用)

activation.rs::write_token_json 优先级:

fn resolve_portable_root() -> Result<PathBuf> {
    // WAKOU_USB_ROOT 总是优先使用(便携应用特性)
    if let Ok(usb) = env::var("WAKOU_USB_ROOT") {
        return Ok(PathBuf::from(usb));
    }
    // fallback 到 cache
    env::var("WAKOU_LOCAL_CACHE_ROOT").map(PathBuf::from)
}

fn write_token_json(portable_root: &Path, payload: &Value) {
    let auth_path = portable_root.join("data/auth");
    fs::write(auth_path.join("token.json"), ...)?;
}

激活流程得到 portable_root 时,它已经是 USB 根。为什么?为了让用户激活后立刻能用——不必等 guardian 周期同步,直接读 USB 上的 token。这个设计是对的。

Guardian 把 auth 目录加入同步白名单(为了持久化配置变更)

wakou-guardian.ps1::Invoke-FinalSync 维护的白名单(Get-WakouSyncWhitelist)包括 data\auth,原因是:config 更新、凭据变更等在 cache 本地修改后,需要通过同步把新数据持久化到 USB。这个设计也是对的。

Sync 使用 /MIR 镜像模式(为了清理用户手动删除的文件)

wakou-common.psm1::Get-RobocopyArgs:

"Final" { $args += @("/MIR") }   # mirror, includes purge

/MIR 等价于 /E /PURGE

  • /E 递归复制整个目录树
  • /PURGE 删除目标上源没有的文件

为什么用 /MIR?为了清理用户手动删除的文件。如果用户在 USB 上删了某个 config,不用 /PURGE 的话,cache 可能还保留旧数据,重启后反而恢复了"幽灵文件"。这个决定也是对的。

三个对的决策,组在一起变成了错

三个决策各自都没错,但组合起来形成了一条"写入位置与同步方向相反"的路径:

激活流程:USB ← 写 token
         ↓
用户关面板,guardian 最终同步
         ↓
同步方向:cache (empty) → USB (via /MIR)
         ↓
USB 的 token 被 /MIR 删掉

根本原因是 cache 永远 lag 一拍。USB→cache 的同步只在 boot 期运行(Sync-UsbToLocal),激活发生在运行时,cache 里的 auth 目录在下一次 boot 之前始终是旧状态(或空)。而 guardian 的 final-sync 是在用户关面板时立刻跑的,此时 cache 还没有最新 token。

修复方案

核心改动:redeem 同时写 cache 和 USB

activation.rs 新增 helper 函数(素材里给出了伪代码,实现逻辑):

token 激活后的写入改为:

  1. 先写 cache(launcher 已经通过 WAKOU_LOCAL_CACHE_ROOT env 注入了 cache 路径)
    • 这使 cache 成为 source-of-truth
    • guardian 的 final-sync 读到的 cache 是最新的 token
    • 下一轮 /MIR 会把新 token 从 cache 同步到 USB,而不是删它
  2. 再写 USB(原有逻辑保留)
    • 让用户激活后立刻能用
    • USB 是面板直接读取的位置(via resolve_portable_rootWAKOU_USB_ROOT 环境变量)
    • 即使 cache 写入间歇性失败,激活也能完成,用户不卡激活页

兼容性

  • 老用户若 cache 里碰巧有 token(之前某次 boot 从 USB 同步过来)→ 新激活覆盖,无问题
  • 老用户 cache 没 token → 新激活补上 cache 副本,同时 USB 也有 → 自洽
  • dev 环境没设 WAKOU_LOCAL_CACHE_ROOT → 退化成单写 USB(dev 没跑 guardian /MIR,不会误删)

防回归与思考

教训很直白:镜像同步不该作用于两侧都可能独立写入的目录。

具体措施:

  1. 同步前明确数据流向
    • 激活写 USB,配置更新写 cache,两侧都有独立修改源
    • 这种情况下 /MIR 必然是陷阱
    • 要么改成合并模式,要么明确约定唯一的 source-of-truth,不要试图同时满足两侧
  2. 代码审查检查清单
    • panel 写入 portable_root 的任何文件都要检查 guardian 的同步白名单
    • 如果白名单包含这个目录且用 /MIR,立刻改法:写 cache 让 guardian 主动同步到 USB,或改合并模式
  3. 构建期检查
    • 扫描 PowerShell 启动命令,检测"写 USB 但同步源指向 cache"的组合
    • 检测到就 fail-build

本质上这是时序问题。panel 运行时改动和 boot 期同步是分开的两个阶段,中间的窗口期——从 token 写入到 guardian 同步的这 7 分钟——成了陷阱的发生地。任何使用删除操作的同步(/PURGErsync --deleterm -rf),都必须确认源端的数据在整个同步周期内是完整的。不确认就用删除,会把最新的写入无声地删掉。

星野的头像

星野 XINGYE

一个人维护 AI 平台的工程师。这里记录 63 篇复盘:18 份故障档案、OTA、架构演进与工作流。