diff --git a/ai_knowledge_base_v2/apps/admin-web/src/App.vue b/ai_knowledge_base_v2/apps/admin-web/src/App.vue
index 9d41e48..10656fc 100644
--- a/ai_knowledge_base_v2/apps/admin-web/src/App.vue
+++ b/ai_knowledge_base_v2/apps/admin-web/src/App.vue
@@ -163,7 +163,7 @@ const nullableModelFields = [
] as const;
type SystemSettingValue = string | number | boolean;
-type SystemSettingType = "number" | "switch" | "text";
+type SystemSettingType = "number" | "switch" | "text" | "password";
interface SystemSettingDefinition {
key: string;
@@ -289,6 +289,22 @@ const systemSettingSections: SystemSettingSection[] = [
placeholder: "例如 https://example.com/search",
description: "如使用独立飞书检索适配服务,在这里填写调用地址。",
},
+ {
+ key: "feishu_app_id",
+ label: "飞书应用 AppID",
+ type: "text",
+ defaultValue: "",
+ placeholder: "填写飞书开放平台应用 AppID",
+ description: "用于后端直读飞书知识库;系统设置优先于环境变量。",
+ },
+ {
+ key: "feishu_app_secret",
+ label: "飞书应用 AppSecret",
+ type: "password",
+ defaultValue: "",
+ placeholder: "填写飞书开放平台应用 AppSecret",
+ description: "用于后端获取飞书 tenant_access_token,保存后立即生效。",
+ },
{
key: "feishu_timeout_seconds",
label: "飞书请求超时(秒)",
@@ -787,7 +803,7 @@ function normalizeSystemSettingValue(setting: (typeof systemSettingDefinitions)[
if (setting.type === "switch") {
return ["1", "true", "yes", "on", "启用"].includes(rawValue.toLowerCase());
}
- if (setting.type === "text") {
+ if (setting.type === "text" || setting.type === "password") {
return rawValue;
}
const parsed = Number(rawValue);
@@ -1302,6 +1318,13 @@ function buildChatQuery() {
v-model="systemSettingValues[setting.key]"
:placeholder="setting.placeholder"
/>
+
{{ setting.description }}
diff --git a/ai_knowledge_base_v2/apps/backend/app/services/feishu_service.py b/ai_knowledge_base_v2/apps/backend/app/services/feishu_service.py
index 54b4dab..ffb4a72 100644
--- a/ai_knowledge_base_v2/apps/backend/app/services/feishu_service.py
+++ b/ai_knowledge_base_v2/apps/backend/app/services/feishu_service.py
@@ -1,6 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
+import hashlib
import time
from typing import TYPE_CHECKING, Any
@@ -19,33 +20,36 @@ if TYPE_CHECKING:
# 飞书 API 基地址
FEISHU_OPEN_API = "https://open.feishu.cn"
-# tenant_access_token 缓存
-_token_cache: dict[str, Any] = {"token": "", "expires_at": 0.0}
+# tenant_access_token 缓存,按应用凭证隔离
+_token_cache: dict[str, dict[str, Any]] = {}
@dataclass(frozen=True)
class FeishuRetrievalConfig:
search_url: str
+ app_id: str
+ app_secret: str
timeout_seconds: int
retry_count: int
-def _get_tenant_token() -> str:
+def _get_tenant_token(config: FeishuRetrievalConfig) -> str:
"""获取飞书 tenant_access_token,自动缓存"""
- settings = get_settings()
now = time.time()
+ cache_key = _token_cache_key(config.app_id, config.app_secret)
+ cached = _token_cache.get(cache_key)
- if _token_cache["token"] and now < _token_cache["expires_at"]:
- return _token_cache["token"]
+ if cached and cached["token"] and now < cached["expires_at"]:
+ return cached["token"]
url = f"{FEISHU_OPEN_API}/open-apis/auth/v3/tenant_access_token/internal"
payload = {
- "app_id": settings.feishu_app_id,
- "app_secret": settings.feishu_app_secret,
+ "app_id": config.app_id,
+ "app_secret": config.app_secret,
}
try:
- resp = httpx.post(url, json=payload, timeout=settings.feishu_timeout_seconds)
+ resp = httpx.post(url, json=payload, timeout=config.timeout_seconds)
resp.raise_for_status()
data = resp.json()
@@ -58,22 +62,25 @@ def _get_tenant_token() -> str:
token = data["tenant_access_token"]
expire = data.get("expire", 7200)
- _token_cache["token"] = token
- _token_cache["expires_at"] = now + expire - 300 # 提前5分钟过期
+ _token_cache[cache_key] = {
+ "token": token,
+ "expires_at": now + expire - 300, # 提前5分钟过期
+ }
return token
except httpx.HTTPError as exc:
raise ExternalServiceError(f"获取飞书Token网络错误:{exc}", provider="feishu") from exc
-def _feishu_get(path: str, params: dict | None = None) -> dict:
+def _feishu_get(path: str, params: dict | None = None, config: FeishuRetrievalConfig | None = None) -> dict:
"""封装飞书 GET 请求"""
- token = _get_tenant_token()
+ if config is None:
+ config = _feishu_retrieval_config(None)
+ token = _get_tenant_token(config)
url = f"{FEISHU_OPEN_API}{path}"
headers = {"Authorization": f"Bearer {token}"}
- settings = get_settings()
- resp = httpx.get(url, headers=headers, params=params, timeout=settings.feishu_timeout_seconds)
+ resp = httpx.get(url, headers=headers, params=params, timeout=config.timeout_seconds)
resp.raise_for_status()
data = resp.json()
@@ -85,14 +92,15 @@ def _feishu_get(path: str, params: dict | None = None) -> dict:
return data
-def _feishu_post(path: str, payload: dict | None = None) -> dict:
+def _feishu_post(path: str, payload: dict | None = None, config: FeishuRetrievalConfig | None = None) -> dict:
"""封装飞书 POST 请求"""
- token = _get_tenant_token()
+ if config is None:
+ config = _feishu_retrieval_config(None)
+ token = _get_tenant_token(config)
url = f"{FEISHU_OPEN_API}{path}"
headers = {"Authorization": f"Bearer {token}"}
- settings = get_settings()
- resp = httpx.post(url, headers=headers, json=payload, timeout=settings.feishu_timeout_seconds)
+ resp = httpx.post(url, headers=headers, json=payload, timeout=config.timeout_seconds)
resp.raise_for_status()
data = resp.json()
@@ -180,16 +188,16 @@ def _parse_docx_content(blocks: list[dict]) -> str:
return "".join(texts).strip()
-def _get_docx_content(doc_token: str) -> str:
+def _get_docx_content(doc_token: str, config: FeishuRetrievalConfig) -> str:
"""读取单个飞书云文档内容"""
- data = _feishu_get(f"/open-apis/docx/v1/documents/{doc_token}/blocks")
+ data = _feishu_get(f"/open-apis/docx/v1/documents/{doc_token}/blocks", config=config)
items = data.get("data", {}).get("items", [])
return _parse_docx_content(items)
-def _get_wiki_node_info(node_token: str) -> dict:
+def _get_wiki_node_info(node_token: str, config: FeishuRetrievalConfig) -> dict:
"""获取 Wiki 节点信息,返回 space_id 和 obj_token"""
- data = _feishu_get("/open-apis/wiki/v2/spaces/get_node", params={"token": node_token})
+ data = _feishu_get("/open-apis/wiki/v2/spaces/get_node", params={"token": node_token}, config=config)
node = data.get("data", {}).get("node", {})
return {
"space_id": node.get("space_id", ""),
@@ -200,7 +208,7 @@ def _get_wiki_node_info(node_token: str) -> dict:
}
-def _get_wiki_children(node_token: str, page_size: int = 50) -> list[dict]:
+def _get_wiki_children(node_token: str, config: FeishuRetrievalConfig, page_size: int = 50) -> list[dict]:
"""获取 Wiki 节点的子节点列表"""
all_children = []
page_token = ""
@@ -212,7 +220,7 @@ def _get_wiki_children(node_token: str, page_size: int = 50) -> list[dict]:
if page_token:
params["page_token"] = page_token
- data = _feishu_get("/open-apis/wiki/v2/spaces/nodes/get_child_nodes", params=params)
+ data = _feishu_get("/open-apis/wiki/v2/spaces/nodes/get_child_nodes", params=params, config=config)
children = data.get("data", {}).get("items", [])
all_children.extend(children)
@@ -269,13 +277,13 @@ class FeishuKnowledgeService:
return cls._retrieve_from_search(question, scopes, config)
except ExternalServiceError as exc:
try:
- return cls._retrieve_from_feishu(question, scopes)
+ return cls._retrieve_from_feishu(question, scopes, config)
except ExternalServiceError as fallback_exc:
raise ExternalServiceError(
f"{exc};飞书直读也失败:{fallback_exc}",
provider="feishu",
) from fallback_exc
- return cls._retrieve_from_feishu(question, scopes)
+ return cls._retrieve_from_feishu(question, scopes, config)
@classmethod
def _retrieve_from_search(
@@ -300,15 +308,19 @@ class FeishuKnowledgeService:
raise ExternalServiceError(f"飞书搜索接口调用失败:{last_error}", provider="feishu-search")
@classmethod
- def _retrieve_from_feishu(cls, question: str, scopes: list[KnowledgeScope]) -> list[RetrievedChunk]:
+ def _retrieve_from_feishu(
+ cls,
+ question: str,
+ scopes: list[KnowledgeScope],
+ config: FeishuRetrievalConfig,
+ ) -> list[RetrievedChunk]:
"""从飞书知识库检索文档内容"""
from app.services.rag_service import RetrievedChunk
- settings = get_settings()
- if not settings.feishu_app_id or not settings.feishu_app_secret:
+ if not config.app_id or not config.app_secret:
raise ExternalServiceError(
"未配置飞书应用凭证,无法读取飞书知识库。请配置 FEISHU_APP_ID/FEISHU_APP_SECRET,"
- "或在系统配置中填写可用的飞书搜索接口地址。",
+ "或在系统设置中填写飞书 AppID/AppSecret。",
provider="feishu",
)
@@ -318,7 +330,7 @@ class FeishuKnowledgeService:
for scope in scopes:
try:
- documents = cls._load_documents(scope)
+ documents = cls._load_documents(scope, config)
except ExternalServiceError as exc:
load_errors.append(f"{scope.name}:{exc}")
continue
@@ -350,7 +362,11 @@ class FeishuKnowledgeService:
]
@classmethod
- def _load_documents(cls, scope: KnowledgeScope) -> list[tuple[str, str, str | None]]:
+ def _load_documents(
+ cls,
+ scope: KnowledgeScope,
+ config: FeishuRetrievalConfig,
+ ) -> list[tuple[str, str, str | None]]:
"""加载指定知识库 scope 下的所有文档"""
node_id = scope.feishu_node_id
base_url = f"https://zcn7hk6n047c.feishu.cn"
@@ -360,11 +376,11 @@ class FeishuKnowledgeService:
# 1. 判断 node 类型:先查 wiki 节点信息
try:
- node_info = cls._get_node_info_with_cache(node_id)
+ node_info = cls._get_node_info_with_cache(node_id, config)
except ExternalServiceError:
# 可能是普通 docx token,直接尝试读取
try:
- content = cls._get_document_content_with_cache(node_id)
+ content = cls._get_document_content_with_cache(node_id, config)
title = node_id
return [(title, content, f"{base_url}/docx/{node_id}")]
except ExternalServiceError:
@@ -377,12 +393,12 @@ class FeishuKnowledgeService:
# 如果节点本身就是一个文档
if obj_type == "docx":
- content = cls._get_document_content_with_cache(obj_token)
+ content = cls._get_document_content_with_cache(obj_token, config)
source_url = f"{base_url}/wiki/{node_id}"
return [(title, content, source_url)]
# 如果节点是文件夹,获取子节点
- children = cls._get_wiki_children_with_cache(node_id)
+ children = cls._get_wiki_children_with_cache(node_id, config)
documents = []
for child in children:
@@ -393,7 +409,7 @@ class FeishuKnowledgeService:
if child_obj_type == "docx" and child_obj_token:
try:
- content = cls._get_document_content_with_cache(child_obj_token)
+ content = cls._get_document_content_with_cache(child_obj_token, config)
source_url = f"{base_url}/wiki/{child_node_token}"
documents.append((child_title, content, source_url))
except ExternalServiceError:
@@ -402,7 +418,7 @@ class FeishuKnowledgeService:
# 也读取节点本身的文档(如果有)
if obj_type == "docx" and obj_token:
try:
- content = cls._get_document_content_with_cache(obj_token)
+ content = cls._get_document_content_with_cache(obj_token, config)
source_url = f"{base_url}/wiki/{node_id}"
documents.insert(0, (title, content, source_url))
except ExternalServiceError:
@@ -411,9 +427,9 @@ class FeishuKnowledgeService:
return documents
@classmethod
- def _get_node_info_with_cache(cls, node_token: str) -> dict:
+ def _get_node_info_with_cache(cls, node_token: str, config: FeishuRetrievalConfig) -> dict:
"""带缓存获取节点信息"""
- cache_key = f"node_info:{node_token}"
+ cache_key = f"{config.app_id}:node_info:{node_token}"
now = time.time()
if cache_key in cls._content_cache:
@@ -421,28 +437,29 @@ class FeishuKnowledgeService:
if now - ts < cls._CACHE_TTL:
return eval(cached_data) # noqa: S307
- info = _get_wiki_node_info(node_token)
+ info = _get_wiki_node_info(node_token, config)
cls._content_cache[cache_key] = (str(info), now)
return info
@classmethod
- def _get_document_content_with_cache(cls, doc_token: str) -> str:
+ def _get_document_content_with_cache(cls, doc_token: str, config: FeishuRetrievalConfig) -> str:
"""带缓存读取文档内容"""
+ cache_key = f"{config.app_id}:doc:{doc_token}"
now = time.time()
- if doc_token in cls._content_cache:
- content, ts = cls._content_cache[doc_token]
+ if cache_key in cls._content_cache:
+ content, ts = cls._content_cache[cache_key]
if now - ts < cls._CACHE_TTL:
return content
- content = _get_docx_content(doc_token)
- cls._content_cache[doc_token] = (content, now)
+ content = _get_docx_content(doc_token, config)
+ cls._content_cache[cache_key] = (content, now)
return content
@classmethod
- def _get_wiki_children_with_cache(cls, node_token: str) -> list[dict]:
+ def _get_wiki_children_with_cache(cls, node_token: str, config: FeishuRetrievalConfig) -> list[dict]:
"""带缓存获取子节点"""
- cache_key = f"children:{node_token}"
+ cache_key = f"{config.app_id}:children:{node_token}"
now = time.time()
if cache_key in cls._content_cache:
@@ -450,7 +467,7 @@ class FeishuKnowledgeService:
if now - ts < cls._CACHE_TTL:
return eval(cached_data) # noqa: S307
- children = _get_wiki_children(node_token)
+ children = _get_wiki_children(node_token, config)
cls._content_cache[cache_key] = (str(children), now)
return children
@@ -513,11 +530,17 @@ def _feishu_retrieval_config(db: Session | None) -> FeishuRetrievalConfig:
settings = get_settings()
return FeishuRetrievalConfig(
search_url=_config_text(db, "feishu_search_url", settings.feishu_search_url),
+ app_id=_config_text(db, "feishu_app_id", settings.feishu_app_id),
+ app_secret=_config_text(db, "feishu_app_secret", settings.feishu_app_secret),
timeout_seconds=_config_int(db, "feishu_timeout_seconds", settings.feishu_timeout_seconds, minimum=1, maximum=120),
retry_count=_config_int(db, "feishu_retry_count", settings.feishu_retry_count, minimum=0, maximum=10),
)
+def _token_cache_key(app_id: str, app_secret: str) -> str:
+ return hashlib.sha256(f"{app_id}:{app_secret}".encode()).hexdigest()
+
+
def _config_text(db: Session | None, key: str, default: str) -> str:
value = _config_value(db, key)
return value.strip() if value is not None else default