Support Feishu credentials in system settings

This commit is contained in:
2026-07-08 12:03:37 +08:00
parent aed6d8ffbf
commit 7be5dde1db
2 changed files with 98 additions and 52 deletions

View File

@@ -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