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:
- 同步后
- USB:
data\auth\(目录为空) - token.json 已删除
- USB:
根本原因找到了: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 激活后的写入改为:
- 先写 cache(launcher 已经通过
WAKOU_LOCAL_CACHE_ROOTenv 注入了 cache 路径)- 这使 cache 成为 source-of-truth
- guardian 的 final-sync 读到的 cache 是最新的 token
- 下一轮
/MIR会把新 token 从 cache 同步到 USB,而不是删它
- 再写 USB(原有逻辑保留)
- 让用户激活后立刻能用
- USB 是面板直接读取的位置(via
resolve_portable_root的WAKOU_USB_ROOT环境变量) - 即使 cache 写入间歇性失败,激活也能完成,用户不卡激活页
兼容性
- 老用户若 cache 里碰巧有 token(之前某次 boot 从 USB 同步过来)→ 新激活覆盖,无问题
- 老用户 cache 没 token → 新激活补上 cache 副本,同时 USB 也有 → 自洽
- dev 环境没设
WAKOU_LOCAL_CACHE_ROOT→ 退化成单写 USB(dev 没跑 guardian /MIR,不会误删)
防回归与思考
教训很直白:镜像同步不该作用于两侧都可能独立写入的目录。
具体措施:
- 同步前明确数据流向
- 激活写 USB,配置更新写 cache,两侧都有独立修改源
- 这种情况下
/MIR必然是陷阱 - 要么改成合并模式,要么明确约定唯一的 source-of-truth,不要试图同时满足两侧
- 代码审查检查清单
- panel 写入 portable_root 的任何文件都要检查 guardian 的同步白名单
- 如果白名单包含这个目录且用
/MIR,立刻改法:写 cache 让 guardian 主动同步到 USB,或改合并模式
- 构建期检查
- 扫描 PowerShell 启动命令,检测"写 USB 但同步源指向 cache"的组合
- 检测到就 fail-build
本质上这是时序问题。panel 运行时改动和 boot 期同步是分开的两个阶段,中间的窗口期——从 token 写入到 guardian 同步的这 7 分钟——成了陷阱的发生地。任何使用删除操作的同步(/PURGE、rsync --delete、rm -rf),都必须确认源端的数据在整个同步周期内是完整的。不确认就用删除,会把最新的写入无声地删掉。
■