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

@@ -163,7 +163,7 @@ const nullableModelFields = [
] as const; ] as const;
type SystemSettingValue = string | number | boolean; type SystemSettingValue = string | number | boolean;
type SystemSettingType = "number" | "switch" | "text"; type SystemSettingType = "number" | "switch" | "text" | "password";
interface SystemSettingDefinition { interface SystemSettingDefinition {
key: string; key: string;
@@ -289,6 +289,22 @@ const systemSettingSections: SystemSettingSection[] = [
placeholder: "例如 https://example.com/search", placeholder: "例如 https://example.com/search",
description: "如使用独立飞书检索适配服务,在这里填写调用地址。", 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", key: "feishu_timeout_seconds",
label: "飞书请求超时(秒)", label: "飞书请求超时(秒)",
@@ -787,7 +803,7 @@ function normalizeSystemSettingValue(setting: (typeof systemSettingDefinitions)[
if (setting.type === "switch") { if (setting.type === "switch") {
return ["1", "true", "yes", "on", "启用"].includes(rawValue.toLowerCase()); return ["1", "true", "yes", "on", "启用"].includes(rawValue.toLowerCase());
} }
if (setting.type === "text") { if (setting.type === "text" || setting.type === "password") {
return rawValue; return rawValue;
} }
const parsed = Number(rawValue); const parsed = Number(rawValue);
@@ -1302,6 +1318,13 @@ function buildChatQuery() {
v-model="systemSettingValues[setting.key]" v-model="systemSettingValues[setting.key]"
:placeholder="setting.placeholder" :placeholder="setting.placeholder"
/> />
<el-input
v-else-if="setting.type === 'password'"
v-model="systemSettingValues[setting.key]"
:placeholder="setting.placeholder"
type="password"
show-password
/>
<el-switch v-else v-model="systemSettingValues[setting.key]" /> <el-switch v-else v-model="systemSettingValues[setting.key]" />
<small>{{ setting.description }}</small> <small>{{ setting.description }}</small>
</label> </label>

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
import hashlib
import time import time
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
@@ -19,33 +20,36 @@ if TYPE_CHECKING:
# 飞书 API 基地址 # 飞书 API 基地址
FEISHU_OPEN_API = "https://open.feishu.cn" FEISHU_OPEN_API = "https://open.feishu.cn"
# tenant_access_token 缓存 # tenant_access_token 缓存,按应用凭证隔离
_token_cache: dict[str, Any] = {"token": "", "expires_at": 0.0} _token_cache: dict[str, dict[str, Any]] = {}
@dataclass(frozen=True) @dataclass(frozen=True)
class FeishuRetrievalConfig: class FeishuRetrievalConfig:
search_url: str search_url: str
app_id: str
app_secret: str
timeout_seconds: int timeout_seconds: int
retry_count: int retry_count: int
def _get_tenant_token() -> str: def _get_tenant_token(config: FeishuRetrievalConfig) -> str:
"""获取飞书 tenant_access_token自动缓存""" """获取飞书 tenant_access_token自动缓存"""
settings = get_settings()
now = time.time() 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"]: if cached and cached["token"] and now < cached["expires_at"]:
return _token_cache["token"] return cached["token"]
url = f"{FEISHU_OPEN_API}/open-apis/auth/v3/tenant_access_token/internal" url = f"{FEISHU_OPEN_API}/open-apis/auth/v3/tenant_access_token/internal"
payload = { payload = {
"app_id": settings.feishu_app_id, "app_id": config.app_id,
"app_secret": settings.feishu_app_secret, "app_secret": config.app_secret,
} }
try: 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() resp.raise_for_status()
data = resp.json() data = resp.json()
@@ -58,22 +62,25 @@ def _get_tenant_token() -> str:
token = data["tenant_access_token"] token = data["tenant_access_token"]
expire = data.get("expire", 7200) expire = data.get("expire", 7200)
_token_cache["token"] = token _token_cache[cache_key] = {
_token_cache["expires_at"] = now + expire - 300 # 提前5分钟过期 "token": token,
"expires_at": now + expire - 300, # 提前5分钟过期
}
return token return token
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
raise ExternalServiceError(f"获取飞书Token网络错误{exc}", provider="feishu") from 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 请求""" """封装飞书 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}" url = f"{FEISHU_OPEN_API}{path}"
headers = {"Authorization": f"Bearer {token}"} 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() resp.raise_for_status()
data = resp.json() data = resp.json()
@@ -85,14 +92,15 @@ def _feishu_get(path: str, params: dict | None = None) -> dict:
return data 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 请求""" """封装飞书 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}" url = f"{FEISHU_OPEN_API}{path}"
headers = {"Authorization": f"Bearer {token}"} 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() resp.raise_for_status()
data = resp.json() data = resp.json()
@@ -180,16 +188,16 @@ def _parse_docx_content(blocks: list[dict]) -> str:
return "".join(texts).strip() 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", []) items = data.get("data", {}).get("items", [])
return _parse_docx_content(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""" """获取 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", {}) node = data.get("data", {}).get("node", {})
return { return {
"space_id": node.get("space_id", ""), "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 节点的子节点列表""" """获取 Wiki 节点的子节点列表"""
all_children = [] all_children = []
page_token = "" page_token = ""
@@ -212,7 +220,7 @@ def _get_wiki_children(node_token: str, page_size: int = 50) -> list[dict]:
if page_token: if page_token:
params["page_token"] = 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", []) children = data.get("data", {}).get("items", [])
all_children.extend(children) all_children.extend(children)
@@ -269,13 +277,13 @@ class FeishuKnowledgeService:
return cls._retrieve_from_search(question, scopes, config) return cls._retrieve_from_search(question, scopes, config)
except ExternalServiceError as exc: except ExternalServiceError as exc:
try: try:
return cls._retrieve_from_feishu(question, scopes) return cls._retrieve_from_feishu(question, scopes, config)
except ExternalServiceError as fallback_exc: except ExternalServiceError as fallback_exc:
raise ExternalServiceError( raise ExternalServiceError(
f"{exc};飞书直读也失败:{fallback_exc}", f"{exc};飞书直读也失败:{fallback_exc}",
provider="feishu", provider="feishu",
) from fallback_exc ) from fallback_exc
return cls._retrieve_from_feishu(question, scopes) return cls._retrieve_from_feishu(question, scopes, config)
@classmethod @classmethod
def _retrieve_from_search( def _retrieve_from_search(
@@ -300,15 +308,19 @@ class FeishuKnowledgeService:
raise ExternalServiceError(f"飞书搜索接口调用失败:{last_error}", provider="feishu-search") raise ExternalServiceError(f"飞书搜索接口调用失败:{last_error}", provider="feishu-search")
@classmethod @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 from app.services.rag_service import RetrievedChunk
settings = get_settings() if not config.app_id or not config.app_secret:
if not settings.feishu_app_id or not settings.feishu_app_secret:
raise ExternalServiceError( raise ExternalServiceError(
"未配置飞书应用凭证,无法读取飞书知识库。请配置 FEISHU_APP_ID/FEISHU_APP_SECRET" "未配置飞书应用凭证,无法读取飞书知识库。请配置 FEISHU_APP_ID/FEISHU_APP_SECRET"
"或在系统置中填写可用的飞书搜索接口地址", "或在系统置中填写飞书 AppID/AppSecret",
provider="feishu", provider="feishu",
) )
@@ -318,7 +330,7 @@ class FeishuKnowledgeService:
for scope in scopes: for scope in scopes:
try: try:
documents = cls._load_documents(scope) documents = cls._load_documents(scope, config)
except ExternalServiceError as exc: except ExternalServiceError as exc:
load_errors.append(f"{scope.name}{exc}") load_errors.append(f"{scope.name}{exc}")
continue continue
@@ -350,7 +362,11 @@ class FeishuKnowledgeService:
] ]
@classmethod @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 下的所有文档""" """加载指定知识库 scope 下的所有文档"""
node_id = scope.feishu_node_id node_id = scope.feishu_node_id
base_url = f"https://zcn7hk6n047c.feishu.cn" base_url = f"https://zcn7hk6n047c.feishu.cn"
@@ -360,11 +376,11 @@ class FeishuKnowledgeService:
# 1. 判断 node 类型:先查 wiki 节点信息 # 1. 判断 node 类型:先查 wiki 节点信息
try: try:
node_info = cls._get_node_info_with_cache(node_id) node_info = cls._get_node_info_with_cache(node_id, config)
except ExternalServiceError: except ExternalServiceError:
# 可能是普通 docx token直接尝试读取 # 可能是普通 docx token直接尝试读取
try: try:
content = cls._get_document_content_with_cache(node_id) content = cls._get_document_content_with_cache(node_id, config)
title = node_id title = node_id
return [(title, content, f"{base_url}/docx/{node_id}")] return [(title, content, f"{base_url}/docx/{node_id}")]
except ExternalServiceError: except ExternalServiceError:
@@ -377,12 +393,12 @@ class FeishuKnowledgeService:
# 如果节点本身就是一个文档 # 如果节点本身就是一个文档
if obj_type == "docx": 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}" source_url = f"{base_url}/wiki/{node_id}"
return [(title, content, source_url)] 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 = [] documents = []
for child in children: for child in children:
@@ -393,7 +409,7 @@ class FeishuKnowledgeService:
if child_obj_type == "docx" and child_obj_token: if child_obj_type == "docx" and child_obj_token:
try: 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}" source_url = f"{base_url}/wiki/{child_node_token}"
documents.append((child_title, content, source_url)) documents.append((child_title, content, source_url))
except ExternalServiceError: except ExternalServiceError:
@@ -402,7 +418,7 @@ class FeishuKnowledgeService:
# 也读取节点本身的文档(如果有) # 也读取节点本身的文档(如果有)
if obj_type == "docx" and obj_token: if obj_type == "docx" and obj_token:
try: 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}" source_url = f"{base_url}/wiki/{node_id}"
documents.insert(0, (title, content, source_url)) documents.insert(0, (title, content, source_url))
except ExternalServiceError: except ExternalServiceError:
@@ -411,9 +427,9 @@ class FeishuKnowledgeService:
return documents return documents
@classmethod @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() now = time.time()
if cache_key in cls._content_cache: if cache_key in cls._content_cache:
@@ -421,28 +437,29 @@ class FeishuKnowledgeService:
if now - ts < cls._CACHE_TTL: if now - ts < cls._CACHE_TTL:
return eval(cached_data) # noqa: S307 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) cls._content_cache[cache_key] = (str(info), now)
return info return info
@classmethod @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() now = time.time()
if doc_token in cls._content_cache: if cache_key in cls._content_cache:
content, ts = cls._content_cache[doc_token] content, ts = cls._content_cache[cache_key]
if now - ts < cls._CACHE_TTL: if now - ts < cls._CACHE_TTL:
return content return content
content = _get_docx_content(doc_token) content = _get_docx_content(doc_token, config)
cls._content_cache[doc_token] = (content, now) cls._content_cache[cache_key] = (content, now)
return content return content
@classmethod @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() now = time.time()
if cache_key in cls._content_cache: if cache_key in cls._content_cache:
@@ -450,7 +467,7 @@ class FeishuKnowledgeService:
if now - ts < cls._CACHE_TTL: if now - ts < cls._CACHE_TTL:
return eval(cached_data) # noqa: S307 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) cls._content_cache[cache_key] = (str(children), now)
return children return children
@@ -513,11 +530,17 @@ def _feishu_retrieval_config(db: Session | None) -> FeishuRetrievalConfig:
settings = get_settings() settings = get_settings()
return FeishuRetrievalConfig( return FeishuRetrievalConfig(
search_url=_config_text(db, "feishu_search_url", settings.feishu_search_url), 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), 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), 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: def _config_text(db: Session | None, key: str, default: str) -> str:
value = _config_value(db, key) value = _config_value(db, key)
return value.strip() if value is not None else default return value.strip() if value is not None else default