07 日志授权掉线分析

分析时间:2026-07-08
日志文件:/Users/catsleep/Documents/projects/zleap_cli_server/test/07.logs
目标 profile:8753734f-56d4-46ff-9604-ffd2d69b48de

结论

这次不是飞书侧用户授权真正过期或被撤销,而是服务重启后从 Redis 恢复 config.json 时,目标 profile 被同步合并逻辑丢掉了。

目标 profile 的 keychain 信息和用户 token 仍然存在,所以 /api/v1/feishu/info 可以通过 keychain fallback 返回 200;但 /api/v1/feishu/conversation 会先走 ResolveWithRefresh,它必须在 config.json 的 profile 列表里找到该 profile。重启后 config.json 中没有目标 profile,于是直接报:

profile "8753734f-56d4-46ff-9604-ffd2d69b48de" not found

因此表现为“掉授权”,实际根因是 profile 配置丢失。

关键时间线

时间事件证据
20:57:12服务启动,从 Redis 同步 lark-cli 文件larkcli: synced from redis total=528 dedup=528 written=528 skipped=0
20:59:57目标 profile 注册成功,写入 app 配置registration config saved profile=8753734f-56d4-46ff-9604-ffd2d69b48de app_id=cli_aabb89d590395cd2
21:00:07目标 profile 登录成功login completed profile=8753734f-56d4-46ff-9604-ffd2d69b48de open_id=ou_f76af3445fea8b99961eb47b82ed6fe1
21:00:09目标 profile 拉会话成功/api/v1/feishu/conversation ... status=200
21:01:30另一个 profile 注册到同一个 app_idregistration config saved profile=a322f95e-4bbb-4086-a068-c92e55ff77b5 app_id=cli_aabb89d590395cd2
21:01:38该 profile 登录成功,open_id 与目标 profile 相同login completed profile=a322f95e-4bbb-4086-a068-c92e55ff77b5 open_id=ou_f76af3445fea8b99961eb47b82ed6fe1
21:02:18服务停止,同步本地 lark-cli 文件到 Redislarkcli: stopping auth syncer / larkcli: synced to redis files=536
21:02:20服务重启,从 Redis 重新拉回 lark-cli 文件larkcli: synced from redis total=536 dedup=536 written=536 skipped=0
21:02:39重启后目标 profile 首次会话请求失败conversation token maintenance failed ... profile "...8753734f..." not found
21:04:06/feishu/info 仍能从 keychain 找到 app_idfeishu info: resolved from keychain (JSON format) profile=8753734f-... app_id=cli_aabb89d590395cd2

现象解释

为什么 21:00:09 能成功

目标 profile 在注册和登录后,当时本地进程中的 config.json 能找到它,所以 conversation 正常返回 200。

为什么 21:02:39 后失败

21:02:18 服务停止并执行 SyncToRedis,21:02:20 新实例从 Redis 恢复文件。恢复后的 config.json 的 available profile 列表里没有目标 profile。

日志中 21:02:39 的 available 列表统计结果:

available_count = 141
8753734f-56d4-46ff-9604-ffd2d69b48de = false
a322f95e-4bbb-4086-a068-c92e55ff77b5 = false
2acc5a28-0ada-4926-a2f5-905595ef251c = true
6f4cffec-162a-4290-b038-8622804f09f0 = true

这说明重启后配置恢复结果只保留了部分新 profile,同 app_id=cli_aabb89d590395cd2 的目标 profile 和 a322f95e-... 都没有被保留。

为什么 /feishu/info 还是 200

/feishu/info 有额外兜底逻辑,可以从 keychain JSON 格式恢复 app_id/app_secret,所以它能显示 profile 信息:

feishu info: resolved from keychain (JSON format)

但 conversation 的授权维护走的是 ResolveWithRefresh,该路径先依赖 config.json 找 profile,profile 不在 config 里就直接失败。

代码层根因

注册写配置时,是按 profile name 追加 app 配置:

existing.Apps = append(existing.Apps, core.AppConfig{
    Name:      profileName,
    AppId:     appID,
    AppSecret: secret,
    Brand:     larkBrand,
    Users:     []core.AppUser{},
})

位置:feishu_cli/pkg/register/register.go

而服务的 Redis 同步器在合并多个 config.json 时按 AppId 去重:

// Dedup by AppId; fallback to Name when AppId is empty.
if a.AppId != "" {
    if seen[a.AppId] {
        ...
        continue
    }
    seen[a.AppId] = true
}

位置:service_gateway/internal/larkcli/syncer.go

这与实际身份模型冲突:业务请求传的是 profile,CLI 查配置也是优先按 Name/ProfileName 查。多个不同 profile 可以共用同一个 app_id,例如日志中:

8753734f-56d4-46ff-9604-ffd2d69b48de -> cli_aabb89d590395cd2
a322f95e-4bbb-4086-a068-c92e55ff77b5 -> cli_aabb89d590395cd2
452ab633-c35c-48b2-9799-f5622cac3870 -> cli_aabb89d590395cd2

因此 mergeConfigJSONAppId 去重会把不同 profile 当成同一个 app 合并,最终导致其中一些 profile 在重启恢复后丢失。

直接原因链路

多个 profile 共用同一个 app_id
        ↓
注册和登录后,本地 config.json 暂时可用
        ↓
服务停止时 SyncToRedis 合并 config.json
        ↓
mergeConfigJSON 按 app_id 去重
        ↓
不同 profile 被误判为重复 app,被跳过或替换
        ↓
服务重启后从 Redis 恢复到缺 profile 的 config.json
        ↓
conversation 调用 ResolveWithRefresh 找不到 profile
        ↓
返回 not_authenticated,表现为掉授权

建议修复

必修

修改 service_gateway/internal/larkcli/syncer.gomergeConfigJSON

  1. Name/ProfileName 作为主去重键。
  2. 只有同名 profile 才进行合并或覆盖。
  3. 不要因为 AppId 相同就丢弃不同 profile。
  4. 对同名 profile,可选择保留字段更完整、Users 更完整或更新时间更新的配置。

回归测试

增加测试覆盖:

输入:
  profile A: name=8753734f..., appId=cli_aabb89d590395cd2
  profile B: name=a322f95e..., appId=cli_aabb89d590395cd2

期望:
  mergeConfigJSON 后 A 和 B 都保留

还应覆盖:

同名 profile + 不同 users:保留 users 更完整的版本
同名 profile + 新旧 config:按更新时间或字段完整度合并
不同 name + 同 appId:必须全部保留

建议增强

maintainFeishuUserToken / ResolveWithRefresh 支持 keychain fallback,至少在 config.json 丢 profile 时能通过 profile-based keychain token 恢复,避免 info 成功但 conversation 失败的不一致体验。

日志增强

同步器合并 config.json 时建议输出:

before_apps
after_apps
dedup_by
dropped_profiles
duplicate_app_ids

尤其是 dropped_profiles,可以直接暴露这类问题。

临时恢复方案

短期可以重新 register/login 目标 profile,或者手动把目标 profile 恢复到容器内 /root/.lark-cli/config.json 并同步到 Redis。

但只做恢复不能根治。只要 mergeConfigJSON 继续按 AppId 去重,后续服务重启或多副本同步时仍可能再次丢 profile。