# 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，于是直接报：

```text
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_id | `registration 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 文件到 Redis | `larkcli: 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_id | `feishu 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 列表统计结果：

```text
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 信息：

```text
feishu info: resolved from keychain (JSON format)
```

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

## 代码层根因

注册写配置时，是按 profile name 追加 app 配置：

```go
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` 去重：

```go
// 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`，例如日志中：

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

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

## 直接原因链路

```text
多个 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.go` 的 `mergeConfigJSON`：

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

### 回归测试

增加测试覆盖：

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

期望：
  mergeConfigJSON 后 A 和 B 都保留
```

还应覆盖：

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

### 建议增强

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

### 日志增强

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

```text
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。

