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