---
title: "Portal 平台安全与可靠性修复 — 通道、LLM 缓存、服务端关闭"
date: 2026-06-26
category: docs/solutions/security-issues
module: channels/llm/server
problem_type: security_issue
component: service_object
severity: mixed
priority_breakdown: {p1: 4, p2: 5, p3: 1}
symptoms:
  - "WeCom webhook accepted replayed requests within an unlimited time window (no timestamp freshness check)"
  - "Anonymous LLM requests polluted per-user cache namespace when user_id was None (per_user_namespace=True ignored)"
  - "Webhook receive_message() exceptions returned HTTP 500, triggering platform retry storms"
  - "App shutdown leaked httpx connections and dropped in-flight IM replies (no await on _pending_webhook_tasks / close_all_adapters)"
  - "Feishu token cache TTL was 300s instead of ~6900s, causing token refresh storms (24x too short)"
root_cause: missing_validation
resolution_type: code_fix
tags: [webhook-security, replay-attack, cache-isolation, shutdown-cleanup, token-ttl, backpressure, n-plus-1, code-review-fixes]
related_components:
  - channels/wecom
  - channels/feishu
  - llm/cache
  - llm/cache_key
  - llm/gateway
  - llm/config
  - server/app
  - server/routes/channels
---

# Portal 平台安全与可靠性修复 — 通道、LLM 缓存、服务端关闭

## Root Cause

10 个修复共享三个共性根因：

1. **信任边界输入校验缺失**：WeCom webhook 信任签名正确即放行（缺时间戳新鲜度）；LLM 缓存信任 `user_id` 非空（缺 None 检查）；webhook `receive_message` 信任 payload 格式（缺异常隔离）。三处均在信任边界省略了输入校验。
2. **资源生命周期未闭合**：应用 shutdown 未调用 `close_all_adapters()`、未 await `_pending_webhook_tasks`，httpx 连接与 IM 回复在进程退出时被丢弃。Feishu token TTL 设置过短（300s vs 实际 ~6900s）导致频繁刷新。
3. **缓存键设计缺陷**：`generate_cache_key` 对每个组件单独 SHA-256 后再哈希一次（8-10 次冗余哈希）；配额检查重复查询（4 次/部门/周期 vs 实际需 1 次 summary）。

## Problem

在 `feat/portal-platform-evolution` 分支上完成了一次 ce-code-review 修复批次（commit `53faa60`），针对代码评审发现的 10 个安全、可靠性与性能缺陷进行了集中修复。缺陷横跨通道接入（WeCom/Feishu webhook）、LLM 网关（缓存命名空间、配额查询）、应用生命周期（关闭泄漏）等子系统，按严重度分布为 4 个 P1（安全/可靠性）、5 个 P2（效率/可维护性）、1 个 P3（去重）。

## Symptoms

- **WeCom webhook 重放风险**：`verify_signature()` 仅校验签名正确性，不校验时间戳新鲜度，攻击者可无限重放历史合法请求。
- **LLM 缓存跨用户泄漏**：`should_cache()` 对 `user_id` 参数执行 `_ = user_id` 直接丢弃，在 `per_user_namespace` 开启时，`user_id=None` 的请求仍会命中其他用户的缓存。
- **Webhook 异常触发 500 重试风暴**：`receive_message()` 解析失败直接抛出未捕获异常，平台收到 500 后按退避策略无限重试，造成异常放大。
- **应用关闭资源泄漏**：shutdown 流程未调用 `close_all_adapters()`、未 await `_pending_webhook_tasks`，导致 httpx 连接泄漏与 IM 回复丢失。
- **配额查询 N+1 与缓存键冗余哈希**：配额检查对每个部门每个周期调用 4 次 `get_usage()`（实际只需 1 次唯一查询，token 与 cost 可共用 summary）；`generate_cache_key` 对每个组件单独 SHA-256 后再哈希一次，8-10 次冗余哈希。

## What Didn't Work

- **预存在的测试失败（环境问题，非代码问题）**：`litellm` 未安装导致依赖其的测试在本地跳过或报 collect error；`jieba` 未安装导致分词相关测试失败。这些是环境依赖缺失，与本次修复无关，不应误判为回归。
- **WeCom 测试使用固定时间戳 `1609459200`（2021-01-01）**：在加入 `_SIGNATURE_MAX_AGE_SECONDS = 300` 新鲜度校验后，该固定时间戳距离当前时间已超出 5 分钟窗口，测试立即失败。需改用 `int(time.time())` 动态生成时间戳。
- **缓存测试以默认 `user_id=None` 调用 `should_cache()`**：安全修复后 `per_user_namespace=True & user_id=None` 会返回 `False`，原本默认参数的测试用例不再缓存，需显式传入 `user_id` 或在测试中关闭 `per_user_namespace`。

## Solution

### P1 #1 — WeCom webhook 重放攻击修复

**文件**：[src/agentkit/channels/wecom.py](src/agentkit/channels/wecom.py)

**问题**：`verify_signature()` 只比对签名，不校验时间戳新鲜度，重放窗口无限大。

**修复**：新增 `_SIGNATURE_MAX_AGE_SECONDS = 300` 常量，在签名校验前先校验时间戳绝对偏差。

```python
_SIGNATURE_MAX_AGE_SECONDS = 300

# In verify_signature():
try:
    ts_int = int(timestamp)
except (TypeError, ValueError):
    return False
now = int(time.time())
if abs(now - ts_int) > _SIGNATURE_MAX_AGE_SECONDS:
    logger.warning(
        "企微 webhook 时间戳超出 %ds 窗口: ts=%s now=%d",
        _SIGNATURE_MAX_AGE_SECONDS, timestamp, now,
    )
    return False
```

**说明**：使用绝对值 `abs(now - ts_int)` 同时防御未来时间戳与历史重放；窗口设为 300s 兼顾时钟漂移与攻击面。

### P1 #2 — LiteLLM 缓存跨用户泄漏修复

**文件**：[src/agentkit/llm/cache.py](src/agentkit/llm/cache.py)

**问题**：`should_cache()` 形参 `user_id` 被显式丢弃（`_ = user_id`），`per_user_namespace` 开启时 `user_id=None` 的请求仍会命中缓存，造成跨用户数据泄漏。

**修复**：重写 `should_cache()`，强制 per-user 命名空间安全。

```python
def should_cache(self, kb_caching_disabled: bool = False, user_id: str | None = None) -> bool:
    if kb_caching_disabled:
        return False
    if self._config.per_user_namespace and user_id is None:
        logger.debug("should_cache: per_user_namespace on but user_id=None — skip cache")
        return False
    return True
```

**说明**：`per_user_namespace` 开启时，`user_id=None` 视为不可命名空间化的请求，直接跳过缓存，避免命中他人缓存块。

### P1 #3 — Webhook 异常风暴防御

**文件**：[src/agentkit/server/routes/channels.py](src/agentkit/server/routes/channels.py)

**问题**：`receive_message()` 抛出异常时，路由返回 500，平台按重试策略无限重试，形成异常风暴。

**修复**：在 URLVerification 处理之后兜底捕获异常，返回 `code:0` 抑制平台重试。

```python
except Exception as exc:  # noqa: BLE001 — 防止 receive_message 异常导致 500 触发平台重试风暴
    logger.warning("receive_message 解析失败 channel=%s: %s", channel_id, exc)
    return {"code": 0, "msg": "invalid_payload"}
```

**说明**：`code:0` 是平台约定的"已接收"信号，可终止重试；`BLE001` 广度捕获是有意为之，webhook 入口必须吞掉所有解析异常。

### P1 #4 — 应用关闭泄漏修复

**文件**：[src/agentkit/server/app.py](src/agentkit/server/app.py)

**问题**：shutdown 流程未关闭 channel adapters、未等待后台 webhook 任务，导致 httpx 连接泄漏与 IM 回复丢失。

**修复**：在 calendar scheduler 停止后追加 adapter 关闭与 webhook 任务等待逻辑。

```python
from agentkit.server.routes.channels import _pending_webhook_tasks

if _pending_webhook_tasks:
    logger.info("等待 %d 个后台 webhook 任务完成", len(_pending_webhook_tasks))
    await asyncio.gather(*_pending_webhook_tasks, return_exceptions=True)
try:
    from agentkit.server.routes.channels import close_all_adapters
    await close_all_adapters()
except Exception:
    logger.debug("close_all_adapters 异常已忽略")
```

**说明**：`return_exceptions=True` 保证一个任务失败不阻断其他任务收尾；`close_all_adapters` 异常吞掉是因为关闭路径上再抛异常已无意义。

### P2 #8 — Feishu token TTL 修正

**文件**：[src/agentkit/channels/feishu.py](src/agentkit/channels/feishu.py)

**问题**：`_TOKEN_CACHE_TTL = 300.0` 比实际有效期（2h）短 24 倍，造成每 5 分钟强制刷新一次 token，无谓增加 QPS。

**修复**：`300.0` → `6900.0`（2h - 5min 余量），并更新注释。

```python
_TOKEN_CACHE_TTL = 6900.0  # 2h - 5min 余量，避免临界点失效
```

### P2 #10 — 无界 webhook 任务集

**文件**：[src/agentkit/server/routes/channels.py](src/agentkit/server/routes/channels.py)

**问题**：`_pending_webhook_tasks` set 在高负载下无上限增长，可能耗尽内存与协程。

**修复**：加入上界检查（`_WEBHOOK_MAX_CONCURRENT * 2`），超限时返回 HTTP 429。

```python
if len(_pending_webhook_tasks) >= _WEBHOOK_MAX_CONCURRENT * 2:
    logger.warning("webhook 后台任务积压 %d，拒绝新任务", len(_pending_webhook_tasks))
    raise HTTPException(status_code=429, detail="服务器繁忙，请稍后重试")
```

**说明**：2x 余量允许短时尖峰消化；429 让客户端退避而非雪崩。

### P2 #12 — 配额检查 N+1 查询消除

**文件**：[src/agentkit/llm/gateway.py](src/agentkit/llm/gateway.py)

**问题**：配额检查对每个部门每个周期调用 4 次 `get_usage()`（token 与 cost 各一次，但实际可合并为一次查询），重复查询放大数据库压力。

**修复**：抽取 `_get_usage_summary` 每周期只查一次，token 与 cost 共用 summary，再通过 `_check_quota_value` 分别校验。

```python
for period in ("daily", "monthly"):
    summary = self._get_usage_summary(dept_id, period)
    current_tokens = int(summary.total_tokens)
    current_cost_cents = float(summary.total_cost) * 100.0
    await self._check_quota_value(quota_service, db, dept_id, period, "token_limit", current_tokens)
    await self._check_quota_value(quota_service, db, dept_id, period, "cost_limit", current_cost_cents)

def _get_usage_summary(self, department_id: str, period: str) -> UsageSummary:
    now = datetime.now(timezone.utc)
    if period == "monthly":
        start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
    else:
        start = now.replace(hour=0, minute=0, second=0, microsecond=0)
    return self._usage_tracker.get_usage(department_id=department_id, start_time=start, end_time=now)
```

**说明**：查询数从 4 次/部门/周期降至 1 次/部门/周期，下降 75%。

### P2 #13 — 缓存键冗余 SHA-256 消除

**文件**：[src/agentkit/llm/cache_key.py](src/agentkit/llm/cache_key.py)

**问题**：`generate_cache_key` 对每个组件单独 SHA-256（8-10 次），再对哈希拼接再做一次 SHA-256，CPU 浪费且无安全增益。

**修复**：改为单次 SHA-256，使用 `\x1f`（Unit Separator）分隔组件，防止组件内容注入分隔符。

```python
parts = [
    f"m:{model}",
    f"s:{system_prompt}",
    f"msg:{json.dumps(messages, sort_keys=True, ensure_ascii=False)}",
    f"t:{temperature:.2f}",
    f"tools:{json.dumps(tools, sort_keys=True, ensure_ascii=False) if tools is not None else 'null'}",
    f"tc:{tool_choice}",
    f"mt:{max_tokens}",
]
if user_id is not None:
    parts.append(f"u:{user_id}")
if kb_acl_hash is not None:
    parts.append(f"a:{kb_acl_hash}")
combined = "\x1f".join(parts)  # US (Unit Separator) 防止组件内容注入分隔符
return hashlib.sha256(combined.encode()).hexdigest()
```

**说明**：移除了 `_hash_str` 与 `_hash_json` 辅助函数；`\x1f` 是 ASCII 控制字符，正常文本中不会出现，天然防注入。

### P2 #18 — 移除未使用的 secrets_store 参数

**文件**：[src/agentkit/llm/config.py](src/agentkit/llm/config.py)

**问题**：`get_api_key()` 接受 `secrets_store` 形参但从未使用（同步方法无法 await 异步 `get_secret`），属于误导性 API。

**修复**：从签名中移除 `secrets_store` 参数。

```python
def get_api_key(self) -> str:
    """同步读取 API Key — 返回 plaintext。"""
    if self.api_key_encrypted:
        logger.debug("get_api_key: encrypted key set — use aget_api_key for decryption")
    return self.api_key
```

**说明**：调用方需同步明文 key 时直接调用；需要解密时使用 `aget_api_key()`。

### P3 #21 — DIRECT_CHAT 路径去重

**文件**：[src/agentkit/server/routes/channels.py](src/agentkit/server/routes/channels.py)

**问题**：DIRECT_CHAT 逻辑在主路径与 ReAct 回退路径中重复实现，维护时易漂移。

**修复**：抽取 `_direct_chat()` helper，两条路径共用。

```python
async def _direct_chat(llm_gateway: Any, routing: Any) -> str:
    """DIRECT_CHAT 路径 — 直接调用 LLM（主路径与 ReAct 回退共用）。"""
    response = await llm_gateway.chat(
        messages=[{"role": "user", "content": message.content}],
        model=routing.model or "default",
    )
    return response.content
```

## Verification

修复完成后按以下方式验证：

- **ruff check**：`src/` 干净，仅剩预存在的 `gui_mode` F821（与本次修复无关）。
- **Channels 测试**：`pytest tests/unit/channels/test_wecom.py` — 验证 `verify_signature` 与 `_SIGNATURE_MAX_AGE_SECONDS` 新鲜度校验。
- **配置迁移测试**：`pytest tests/unit/llm/test_config_migration.py` — 全部通过。
- **配额强制测试**：`pytest tests/unit/llm/test_quota_enforcement.py` — 11 passed。
- **litellm 相关测试**：因 `litellm` 未安装被 skip（环境问题，非代码问题）。
- **WeCom 测试调整**：将固定时间戳 `1609459200` 改为 `int(time.time())` 动态生成后，新鲜度校验测试通过。
- **缓存测试调整**：显式传入 `user_id` 或关闭 `per_user_namespace` 后，`should_cache()` 测试通过。

## Impact Scope

**受影响模块**：

| 模块 | 文件 | 影响类型 |
|---|---|---|
| WeCom 通道 | `src/agentkit/channels/wecom.py` | 安全（重放防御） |
| Feishu 通道 | `src/agentkit/channels/feishu.py` | 可靠性（TTL 修正） |
| LLM 缓存 | `src/agentkit/llm/cache.py` | 安全（跨用户泄漏） |
| LLM 缓存键 | `src/agentkit/llm/cache_key.py` | 性能（冗余哈希） |
| LLM 网关 | `src/agentkit/llm/gateway.py` | 可靠性（配额 N+1） |
| LLM 配置 | `src/agentkit/llm/config.py` | 可维护性（未用参数） |
| 应用生命周期 | `src/agentkit/server/app.py` | 可靠性（shutdown 泄漏） |
| 通道路由 | `src/agentkit/server/routes/channels.py` | 可靠性（webhook 风暴） |

**向后兼容性**：所有修复向后兼容。WeCom 签名校验增加时间戳窗口后，历史合法请求在 300s 窗口内仍被接受；Feishu TTL 从 300s 延长至 ~6900s 仅减少刷新频率；缓存键生成逻辑变更不影响命中（旧键自然过期）；shutdown 顺序变更仅影响关闭流程，不影响运行时行为。

**生产影响**：WeCom 重放修复立即生效；缓存跨用户泄漏修复消除潜在数据混淆；shutdown 泄漏修复消除进程重启时的连接错误日志。无破坏性变更，无需停机部署。

## Prevention

### 安全

- **Webhook 签名必须校验时间戳新鲜度**：任何带时间戳的 webhook 签名校验都应同时验证 `abs(now - ts)` 在合理窗口内（建议 300s），且使用绝对值防御未来时间戳。仅校验签名等于零防御。
- **Per-user 缓存命名空间必须强制 user_id 校验**：`per_user_namespace=True` 时，`user_id=None` 必须跳过缓存，不可降级为全局缓存。`should_cache` 类方法应显式拒绝缺失 user_id 的请求。
- **缓存键分隔符使用 ASCII 控制字符**：拼接多组件生成哈希键时，使用 `\x1f`（Unit Separator）而非可见字符（`:`、`|`），杜绝组件内容注入分隔符的攻击面。

### 可靠性

- **Webhook handler 必须兜底捕获异常**：任何 webhook 入口路由都应在业务逻辑外包一层 `except Exception`，返回平台约定的"已接收"信号（如 `code:0`），避免 500 触发重试风暴。`# noqa: BLE001` 是有意为之。
- **应用 shutdown 必须关闭资源与等待后台任务**：shutdown 流程必须显式调用 `close_all_adapters()`、`await asyncio.gather(*_pending_webhook_tasks, return_exceptions=True)`，避免连接泄漏与回复丢失。
- **后台任务集必须有上界**：任何 `_pending_tasks` set 都应设置 `max_concurrent * 2` 上界，超限返回 429 让客户端退避，防止内存耗尽。
- **Token TTL 必须匹配真实有效期**：缓存 token 时 TTL 应为"真实有效期 - 5min 余量"，避免临界点失效；不要凭直觉设短值。

### 性能

- **配额/统计类查询避免 N+1**：同一周期内 token 与 cost 共享一次 `get_usage()` 查询，不要为每个指标单独查询。抽取 `_get_usage_summary` 统一入口。
- **缓存键避免冗余哈希**：单次 SHA-256 足够安全，无需对每个组件单独哈希后再哈希。移除 `_hash_str` / `_hash_json` 类辅助函数。

### 测试

- **涉及时间戳的测试使用动态值**：不要硬编码 `1609459200` 等历史时间戳，统一用 `int(time.time())` 生成，避免新鲜度校验引入后测试立即失效。
- **涉及 per-user 命名的测试显式传入 user_id**：测试 `should_cache` 类方法时，显式传入 `user_id` 或在 fixture 中关闭 `per_user_namespace`，避免默认参数变更导致回归。

## Related Docs

- [long-horizon-reliability-code-review-fixes.md](docs/solutions/logic-errors/long-horizon-reliability-code-review-fixes.md) — 上一批 U1-U7 长期可靠性代码评审修复，与本批次同属 code-review 修复系列，可在遇到类似可靠性问题时交叉参考。
- [bitable-companion-service-security-reliability-patterns.md](docs/solutions/architecture-patterns/bitable-companion-service-security-reliability-patterns.md) — Bitable 伴生服务的安全/可靠性架构模式（SSRF、SQL 注入、IDOR、缓存失效等），与本批次的 LLM 缓存隔离威胁模型不同但可对照阅读。
