# ISS-0093: Lark bot 在同话题飞书聊天中切换到不同 Workspace

## Background

用户 lingtao.li@pingcap.com 报告（GitHub issue #597，prod 环境）：Lark bot 在同一个飞书话题（topic）的聊天中，
会切换到不同的 Workspace。触发原因未知，无法稳定复现。

问题 workspace：`lark-ws-a55196b5-4566-4dbb-9352-4a161cfbebf7`（prod，Sources: 14）

## Observed Behavior

- 同一个飞书话题内的连续消息被路由到不同的 `lark-ws-xxx` workspace
- 用户认为是同一个诊断对话，但 bot 却在不同 workspace 中响应
- 具体触发条件未知，无法稳定复现

## Session Resolution Logic

会话路由在 `backend/investibook/core/lark_bot.py` 的 `get_or_create_bot_session()` 函数中：

**优先级顺序：**
1. `thread_root_id` 查找 → `_db_get_feishu_session_by_thread_root(user_id, chat_id, thread_root_id)`
   - 若查不到，再通过 `_db_get_feishu_chat_message(thread_root_id)` 取 aliased_thread_root_id 重查
2. `resolved_issue_id` 查找 → `_db_get_feishu_session_by_issue(user_id, chat_id, resolved_issue_id)`
3. 都没有 → 创建新 session

**thread_root_id 提取（`lark.py` 第 597-602 行）：**
```python
thread_id = str(
    message.get("thread_id")
    or message.get("parent_id")
    or message.get("root_id")
    or ""
).strip() or None
thread_root_id = extract_thread_root_id(message)
```

## Root Cause Candidates

### 候选 1：thread_root_id 在不同 Lark 消息类型中不一致

Lark API 对不同消息类型（普通消息、话题回复、转发消息、@机器人等）返回的
`thread_id`/`root_id`/`parent_id` 字段可能不一致：

- 话题第一条消息：`thread_id = root_id = message_id`
- 话题后续消息：`root_id = thread_id` 但 `parent_id` 可能是上一条消息的 ID
- 某些消息类型可能缺失 `thread_id` 字段，导致 `extract_thread_root_id` 返回 `None` 或返回不同值

若 `thread_root_id` 不一致，第一步查找失败，进入第二步（issue 查找）或直接创建新 session。

### 候选 2：aliased_thread_root_id 逻辑覆盖不完整

`get_or_create_bot_session` 第 810-823 行：若按 `thread_root_id` 找不到 session，
会尝试从 `feishu_chat_messages` 表中找 root message，取其 `thread_root_id` 作为别名再查。
但如果 root message 本身未被存储（消息未经过 bot 处理），则别名查找也会失败，导致新建 session。

### 候选 3：race condition（并发消息）

若用户快速发送多条消息，或话题中有多个用户同时发消息，可能出现 session 尚未写入 DB 时
另一条消息到来，导致两条消息各自创建了独立的 session。

### 候选 4：extract_thread_root_id 对某些消息类型返回错误值

需要检查 `extract_thread_root_id` 的具体实现，确认其是否处理了所有 Lark 消息类型。

## Investigation Steps

1. 检查 prod 日志，搜索 workspace `lark-ws-a55196b5-4566-4dbb-9352-4a161cfbebf7` 的
   相关 `trace_id`，找到 session 切换时的日志记录：
   ```
   grep "lark-ws-a55196b5" prod.log | grep "session"
   grep "reason=new_session" prod.log | grep "chat_id=<该话题的 chat_id>"
   ```

2. 关注日志中的 `thread_root_id` 字段变化：
   ```
   lark message received event_id=... thread_root_id=<X>
   lark session decision event_id=... session_id=... reused=false reason=new_session
   ```

3. 检查 `extract_thread_root_id` 函数实现（在 `lark.py` 中），确认其对 topic 回复的处理：
   - Lark 话题（thread）消息中，`root_id` 字段是否稳定存在
   - `thread_id` 和 `root_id` 在不同场景是否相同

4. 检查 `_db_get_feishu_session_by_thread_root` 的 SQL 查询：
   - 是否按 `(user_id, chat_id, thread_root_id)` 精确匹配
   - 是否有 NULL/空字符串边界情况

## Fix Plan

### 短期（增加日志，帮助复现）

在 `get_or_create_bot_session` 中增加详细日志：
```python
logger.info(
    "lark session routing thread_root_id=%s resolved_issue_id=%s thread_session=%s",
    thread_root_id,
    resolved_issue_id,
    bool(thread_session),
)
```

在 `lark.py` 的 `handle_lark_message_event` 中记录原始 Lark 字段：
```python
logger.debug(
    "lark thread fields message_id=%s thread_id=%s parent_id=%s root_id=%s thread_root_id=%s",
    message_id,
    message.get("thread_id"),
    message.get("parent_id"),
    message.get("root_id"),
    thread_root_id,
)
```

### 中期（修复 session 复用逻辑）

若确认是 `thread_root_id` 不稳定导致的问题，可以在 session 查找时增加回退策略：

```python
# 额外尝试：按 chat_id + user_id + message 时间窗口查找最近 session
if not thread_session and not resolved_issue_id:
    # 在同一 chat 中，若最近 N 分钟内有活跃 session，自动复用
    recent_session = await asyncio.to_thread(
        _db_get_recent_feishu_session_by_chat,
        user_id, chat_id,
        within_minutes=60,
    )
    if recent_session:
        return BotSessionDecision(session=recent_session, reused=True, reason="same_recent_chat")
```

### 长期（话题 ID 稳定化）

确保 Lark 话题中所有消息都能稳定提取同一个 `thread_root_id`：
- 在第一次创建 session 时，将 `{chat_id}:{thread_root_id}` 或 `{chat_id}:{message_id}` 作为 session 的稳定唯一键
- 后续消息通过 `chat_id` + Lark 的 `root_id`（话题根消息 ID）来定位 session，不依赖 `thread_id`（可能变化）

## Verification

修复后，在飞书话题中：
1. 发送消息 A，确认创建了 session S1
2. 发送消息 B（同一话题），确认复用了 session S1（`reused=true, reason=same_thread_root`）
3. 发送消息 C（间隔 30 分钟），确认仍复用 session S1
4. 在不同话题发送消息，确认创建了新 session S2
