diff --git a/.claude/design/core/dataset/dingtalk-dataset-功能开发文档.md b/.claude/design/core/dataset/dingtalk-dataset-功能开发文档.md
new file mode 100644
index 0000000000..2116f3e200
--- /dev/null
+++ b/.claude/design/core/dataset/dingtalk-dataset-功能开发文档.md
@@ -0,0 +1,741 @@
+# 功能开发文档
+
+## 开发总流程
+
+本功能不要一上来就写页面或新建同步链路,开发顺序必须按“先确认接口,后接入抽象,再复用现有导入/同步能力”推进。钉钉只新增配置入口和 Provider,导入、训练、同步尽量复用现有 `apiFile` 链路,别整出一套钉钉专属流程给后人添堵。
+
+1. **开发前确认钉钉 API**
+ 先用钉钉 API Explorer 或最小脚本确认 `appKey/appSecret -> accessToken`、`userId -> unionId/operatorId`、`operatorId -> workspace 列表`、`rootNodeId -> 节点树`、`nodeId -> 在线文档 blocks 文本` 的最终 endpoint、权限点、入参、返回字段、分页字段和限流表现。`GET /v2.0/wiki/workspaces` 与 `GET /v2.0/wiki/nodes` 都已暴露 `nextToken/maxResults`,文件列表接口需要额外做小流量压测,确认分页和限流响应结构。
+
+2. **补全全局类型和常量**
+ 在 `packages/global` 中新增 `DatasetTypeEnum.dingtalk`、`DingtalkServerSchema`、`apiDatasetServer.dingtalkServer` 和 OpenAPI schema,让前端、后端、OpenAPI 都能合法识别钉钉配置。
+
+3. **实现钉钉 Provider**
+ 新增 `packages/service/core/dataset/apiDataset/dingtalkDataset/api.ts`,按飞书/语雀 Provider 模式实现 `listFiles`、`getFileContent`、`getFilePreviewUrl`、`getFileDetail`、`getFileRawId`。`accessToken` 缓存必须在 Provider/helper 内完成,按钉钉返回的有效期提前刷新;workspace 列表和节点列表必须按 `nextToken + maxResults` 循环拉全;目录拉取要限制并发并对限流错误做短暂退避重试。钉钉差异全部收敛在 Provider 内部,不污染 `APIFileItemSchema` 和训练流程。
+
+4. **接入 Provider 分发**
+ 在 `packages/service/core/dataset/apiDataset/index.ts` 的 `getApiDatasetRequest` 中增加 `dingtalkServer` 分支,让现有列目录、导入、读取正文、预览和同步自动走钉钉 Provider。
+
+5. **补导入根节点**
+ 在 `projects/app/src/pages/api/core/dataset/collection/create/apiCollectionV2.ts` 中补 `dingtalkServer.rootNodeId` 作为递归导入起点。用户选择 workspace 后只保存 `workspaceId/rootNodeId`,不自动导入整个 workspace。
+
+6. **补前端创建和配置流程**
+ 在创建入口、CreateModal 和 `ApiDatasetForm` 中新增钉钉类型。前台只让用户填写 `App Key`、`App Secret`、`操作人 User ID`,再通过“获取知识库列表”选择 workspace;不要让用户手填 `unionId/workspaceId/rootNodeId`。
+
+7. **复用“添加文件”导入流程**
+ 配置保存后,用户进入知识库详情页点击“添加文件”,复用现有 `ImportDataSourceEnum.apiDataset` 和 `APIDataset` 导入页,展开钉钉文件树并勾选在线文档或文件夹,最后创建 `apiFile` collection 并进入训练。
+
+8. **复用 `apiFile` 同步能力**
+ 导入后的钉钉文档仍然是 `apiFile` collection。同步时走现有 `collectionCanSync`、`postLinkCollectionSync`、`syncCollection`、`readApiServerFileContent` 链路;钉钉 Provider 的 `getFileContent` 负责根据 `apiFileId/nodeId` 拉取最新正文,hash 未变化返回 `sameRaw`,变化则重建集合。
+
+9. **补详情展示、脱敏、i18n 和审计**
+ 详情页展示 `userId`、`workspaceName/workspaceId`,不能展示明文 `appSecret`。同时补中文、英文、繁中 i18n、图标、审计文案,确保用户可见文本不硬编码。
+
+10. **补产品文档和用户指南**
+ 补充钉钉知识库中英文文档、导航 `meta`,并以 `.claude/design/dingtalk-dataset-用户接入指南.md` 为用户操作说明来源,明确字段去哪拿、应用开哪些权限、怎么添加文件、怎么同步、哪些类型不支持。
+
+11. **测试和验收**
+ 重点覆盖 `appSecret` 脱敏、workspace 列表、文件树、在线文档正文读取、文件夹递归导入、`apiFile` 同步、权限不足错误提示、非在线文档拦截。单测优先 100% 行/分支覆盖,最低不得低于 90%。
+
+## 文档标识
+
+- 任务前缀:`dingtalk-dataset`
+- 文档文件名:`dingtalk-dataset-功能开发文档.md`
+- 关联需求文档:`.claude/design/dingtalk-dataset-需求设计文档.md`
+
+## 0. 开发目标与约束
+
+- 功能目标:新增钉钉知识库接入,支持用户通过企业内部应用 `appKey/appSecret` 和操作人 `userId` 获取当前用户可访问的钉钉知识库列表;选择 workspace 后只保存数据源配置,进入知识库详情页点击“添加文件”选择在线文档或文件夹导入,并复用现有 `apiFile` 同步链路。
+- 代码范围:`packages/global`、`packages/service`、`packages/web`、`projects/app`、`document/content/introduction/guide/knowledge_base`。
+- 非目标:
+ - 不接钉钉 AI 助理知识管理 API。
+ - 不支持第三方应用授权。
+ - 不支持 pdf/docx/xlsx 等二进制文件解析。
+ - 不让用户手填 `unionId/workspaceId/rootNodeId`;这些信息由后端查询和前端列表选择得到。
+- 实现原则:简单优先、最小改动、避免不必要代码与抽象。
+- 必须遵循规范:`references/style-standards-entry.md`、`references/testing-standards.md`、`references/doc-update-reminder.md`、`references/doc-i18n-standards.md`。
+- 适用维度:API[x] DB[x] Front[x] Logger[x] Package[x] BugFix[ ] DocUpdate[x] DocI18n[x]。
+
+### 0.1 Skill 产物核对表
+
+规范源位于 `/Users/xxyyh/.codex/skills/fastgpt-requirement-design/references`;当前仓库根目录没有 `references/`,本文档中的 `references/*` 均按 skill reference 解析。
+
+| Skill 要求 | 本文档落点 | 状态 |
+|---|---|---|
+| 可直接执行的任务拆解 | `1. 实施任务拆解` | 已补齐 |
+| 技术实现流程图和步骤映射表 | `1.1`、`1.2` | 已补齐 |
+| 文件级改动清单和关键代码片段 | `2. 文件级改动清单`、`2.1` | 已补齐 |
+| API/Service/DB/Front/Logger/Package 实施说明 | `3`、`4`、`5`、`3.5` | 已补齐 |
+| 文档更新提醒 | `6. 文档更新提醒` | 已补齐 |
+| 文档 i18n 实施说明 | `7. 文档 i18n 实施说明` | 已补齐 |
+| 测试文件映射、覆盖率目标、场景、命令 | `8. 测试与验证` | 已补齐并补充覆盖率规则 |
+| 实施风险与防呆 | `9. 实施风险与防呆` | 已补齐 |
+
+## 1. 实施任务拆解
+
+| 任务ID | 任务名称 | 责任层 | 输入 | 输出 | 完成定义 |
+|---|---|---|---|---|---|
+| T1 | 扩展全局类型和常量 | Global/API | 钉钉配置字段 | `DatasetTypeEnum.dingtalk`、`DingtalkServerSchema` | 类型编译通过,OpenAPI schema 可引用钉钉配置。 |
+| T2 | 实现钉钉 Provider | Service | 钉钉官方 API、现有 Provider 模式 | `useDingtalkDatasetRequest`、`accessToken` 缓存、分页拉取、目录限流退避 | 支持列目录、读正文、预览、详情、rawId;workspace 和节点列表按 `nextToken/maxResults` 拉全;`accessToken` 不重复高频获取;目录接口遇到限流可控失败或退避重试。 |
+| T3 | 接入 Provider 分发 | Service | `dingtalkServer` 配置 | `getApiDatasetRequest` 支持钉钉 | 导入、同步、预览均能调用钉钉 Provider。 |
+| T4 | 补导入递归根节点 | API/Service | `workspaceId/rootNodeId` 配置 | `apiCollectionV2` 支持钉钉根节点 | 在“添加文件”流程中选择根目录或文件夹时能递归列出钉钉节点。 |
+| T5 | 补前端创建和配置入口 | Front | `appKey/appSecret/userId` | 创建菜单、Modal 类型、配置表单、workspace 选择 | 用户只填 3 个字段即可选择钉钉知识库。 |
+| T6 | 对齐添加文件、同步和详情流程 | Front/Service | 钉钉配置、`apiFile` collection | 添加文件入口、导入页刷新、集合同步入口、详情展示、审计文案 | 配置后可选文件导入,导入后可手动同步,详情不展示密钥。 |
+| T7 | 补 i18n 和图标 | Web | 新增文案和图标 | 三语言文案、钉钉图标 | 前端无硬编码中文。 |
+| T8 | 补产品文档和导航 | Docs | 使用流程和限制 | 中英文 MDX、meta 同步 | 文档可被导航访问。 |
+| T9 | 补测试 | Test | Provider、脱敏、导入根节点、同步 | 单测/集成测试 | 关键逻辑覆盖,外部钉钉接口 mock。 |
+| T10 | 验证和自检 | QA | 测试命令、手工流程 | 验证结果 | 创建、导入、同步、脱敏、文档均通过。 |
+
+### 1.1 技术实现流程图
+
+```mermaid
+flowchart TD
+ A["Step 1: 修改全局类型与常量
目的: 注册 dingtalk 类型和 dingtalkServer schema"] --> B["Step 2: 新增钉钉 Provider
目的: 封装钉钉 token、用户、workspace、node、blocks API"]
+ B --> C["Step 3: 修改 Provider 分发入口
目的: getApiDatasetRequest 识别 dingtalkServer"]
+ C --> D["Step 4: 修改导入递归入口
目的: apiCollectionV2 从 rootNodeId 拉取文件树"]
+ D --> E["Step 5: 修改前端配置流程
目的: 用户填写 3 个字段并选择 workspace"]
+ E --> F["Step 6: 复用添加文件导入页
目的: 配置后选择要导入的文件/文件夹"]
+ F --> G["Step 7: 复用 apiFile 同步链路
目的: 导入后可手动同步钉钉最新正文"]
+ G --> H["Step 8: 修改详情与脱敏
目的: 展示配置摘要且隐藏 appSecret"]
+ H --> I["Step 9: 修改 i18n、图标、审计
目的: 补齐用户可见展示"]
+ I --> J["Step 10: 新增产品文档
目的: 指导用户配置字段、钉钉权限、添加文件和同步"]
+ J --> K["Step 11: 新增测试与验证
目的: 覆盖成功、权限失败、导入、同步和回滚风险"]
+```
+
+### 1.2 步骤映射表
+
+| 步骤 | 目标文件或模块 | 变更目的 | 输入 | 输出 | 前置依赖 | 后续衔接 |
+|---|---|---|---|---|---|---|
+| Step 1 | `packages/global/core/dataset/constants.ts`、`packages/global/core/dataset/apiDataset/type.ts`、`packages/global/openapi/core/dataset/api.ts`、`packages/global/openapi/core/dataset/apiDataset/api.ts` | 注册钉钉知识库类型、配置 schema 和 OpenAPI 描述。 | 字段:`appKey/appSecret/userId/operatorId/workspaceId/rootNodeId/workspaceName`。 | 可被前后端共同引用的类型和 schema。 | 现有 `apiDataset/feishu/yuque` 类型模式。 | Step 2 Provider、Step 5 表单按统一类型实现。 |
+| Step 2 | `packages/service/core/dataset/apiDataset/dingtalkDataset/api.ts` | 新增钉钉 Provider,封装外部接口与 `APIFileItemType` 映射。 | `dingtalkServer`、钉钉 API 响应、Mock 测试数据。 | `useDingtalkDatasetRequest` 及内部 helper。 | Step 1 类型。 | Step 3 统一分发,Step 11 测试 Provider。 |
+| Step 3 | `packages/service/core/dataset/apiDataset/index.ts` | 让现有 `getApiDatasetRequest` 支持 `dingtalkServer`。 | `ApiDatasetServerType`。 | 返回钉钉 Provider 实例。 | Step 2 Provider。 | `list/getCatalog/getPathNames/read/sync` 自动复用。 |
+| Step 4 | `projects/app/src/pages/api/core/dataset/collection/create/apiCollectionV2.ts` | 让目录递归导入从钉钉 `rootNodeId` 开始。 | `dataset.apiDatasetServer.dingtalkServer.rootNodeId`。 | 创建 `apiFile` collection 并触发训练。 | Step 3 能列节点。 | 现有训练、同步和读取链路继续工作。 |
+| Step 5 | `projects/app/src/pages/dataset/list/index.tsx`、`projects/app/src/pageComponents/dataset/list/CreateModal.tsx`、`projects/app/src/pageComponents/dataset/ApiDatasetForm.tsx` | 新增钉钉创建入口、三字段表单、workspace 选择。 | 用户填写 `appKey/appSecret/userId`。 | 表单保存 `operatorId/workspaceId/rootNodeId/workspaceName`。 | Step 1 schema、Step 2/3 试连能力。 | Step 6 添加文件导入,Step 8 展示配置。 |
+| Step 6 | `projects/app/src/pageComponents/dataset/detail/CollectionCard/Header.tsx`、`projects/app/src/pageComponents/dataset/detail/Import/diffSource/APIDataset.tsx`、`projects/app/src/pages/api/core/dataset/collection/create/apiCollectionV2.ts` | 复用现有“添加文件”入口和 API 文件导入页,让用户选择要导入的在线文档或文件夹。 | 已保存 `dingtalkServer.rootNodeId`、用户勾选的 `apiFiles`。 | `apiFile` collection 创建请求和训练任务。 | Step 4/5。 | Step 7 的同步对象来自这里创建的 `apiFile` 集合。 |
+| Step 7 | `packages/global/core/dataset/collection/utils.ts`、`projects/app/src/pageComponents/dataset/detail/CollectionCard/index.tsx`、`projects/app/src/pages/api/core/dataset/collection/sync.ts`、`packages/service/core/dataset/collection/utils.ts`、`packages/service/core/dataset/read.ts` | 确认钉钉 `apiFile` collection 复用同步菜单和服务端同步流程。 | `collectionId`、`apiFileId`、`dingtalkServer`。 | `success/sameRaw` 等同步结果,必要时重建集合。 | Step 2 的 `getFileContent`。 | 用户修改钉钉在线文档后可在 FastGPT 手动同步。 |
+| Step 8 | `packages/global/core/dataset/apiDataset/utils.ts`、`projects/app/src/pageComponents/dataset/detail/Info/index.tsx` | 配置详情展示和敏感字段脱敏。 | 已保存 `dingtalkServer`。 | 详情页摘要、`appSecret` 空值回显。 | Step 5 保存配置。 | 用户后续编辑和安全审查。 |
+| Step 9 | `packages/web/i18n/zh-CN/dataset.json`、`packages/web/i18n/en/dataset.json`、`packages/web/i18n/zh-Hant/dataset.json`、`packages/web/i18n/*/account_team.json`、`packages/web/components/common/Icon/constants.ts` | 补齐 UI 文案、审计文案和图标。 | 新增钉钉入口和配置字段。 | 三语言文案、审计类型、图标注册。 | Step 5/6/8 页面展示需要文案。 | 文档和 UI 展示一致。 |
+| Step 10 | `.claude/design/dingtalk-dataset-用户接入指南.md`、`document/content/introduction/guide/knowledge_base/dingtalk_dataset.mdx`、`document/content/introduction/guide/knowledge_base/dingtalk_dataset.en.mdx`、`meta.json`、`meta.en.json` | 输出用户接入说明和文档站页面。 | 最小填写项、权限清单、常见错误、添加文件和同步说明。 | 中文/英文文档与导航。 | Step 1-9 行为已确定。 | 发布前文档验收。 |
+| Step 11 | `test/cases/service/core/dataset/apiDataset/dingtalkDataset/api.test.ts`、`test/cases/global/core/dataset/apiDataset/utils.test.ts`、`projects/app/test/api/core/dataset/collection/create/apiCollectionV2.test.ts` | 验证 Provider、脱敏、递归导入、同步和错误路径。 | Mock 钉钉响应、测试 dataset/collection。 | 单测、集成测试、手工验证结果。 | Step 2-8 代码完成。 | 发布和回滚判断。 |
+
+## 2. 文件级改动清单
+
+| 文件路径 | 改动类型 | 变更摘要 | 关键代码 | 关联任务ID |
+|---|---|---|---|---|
+| `packages/global/core/dataset/constants.ts` | 修改 | 新增 `DatasetTypeEnum.dingtalk`、`ApiDatasetTypeMap`、`DatasetTypeMap`。 | `dingtalk = 'dingtalk'` | T1 |
+| `packages/global/core/dataset/apiDataset/type.ts` | 修改 | 新增 `DingtalkServerSchema`、`DingtalkServerType`、`dingtalkServer`。 | 见 2.1 | T1 |
+| `packages/global/core/dataset/apiDataset/utils.ts` | 修改 | 脱敏 `dingtalkServer.appSecret`。 | 见 2.1 | T1 |
+| `packages/global/openapi/core/dataset/api.ts` | 修改 | `apiDatasetServer` 描述补钉钉。 | 描述从 API/飞书/语雀改为 API/飞书/语雀/钉钉 | T1 |
+| `packages/global/openapi/core/dataset/apiDataset/api.ts` | 修改 | 第三方知识库接口描述补钉钉。 | 描述补 `DingTalk` | T1 |
+| `packages/service/core/dataset/apiDataset/dingtalkDataset/api.ts` | 新增 | 钉钉 Provider。 | 见 2.1 | T2 |
+| `packages/service/core/dataset/apiDataset/index.ts` | 修改 | 分发 `dingtalkServer`。 | 见 2.1 | T3 |
+| `projects/app/src/pages/api/core/dataset/collection/create/apiCollectionV2.ts` | 修改 | `startId` 补钉钉根节点。 | 见 2.1 | T4 |
+| `projects/app/src/pageComponents/dataset/detail/CollectionCard/Header.tsx` | 确认/少量修改 | 钉钉进入 `ApiDatasetTypeMap` 后复用“添加文件”按钮,进入 `ImportDataSourceEnum.apiDataset`。 | `router.replace(...source=apiDataset)` | T6 |
+| `projects/app/src/pageComponents/dataset/detail/Import/diffSource/APIDataset.tsx` | 修改 | 复用 API 文件导入页,并让列表刷新依赖覆盖 `apiDatasetServer`,避免编辑钉钉配置后仍显示旧树。 | `refreshDeps: [datasetDetail.apiDatasetServer]` | T6 |
+| `projects/app/src/pageComponents/dataset/detail/CollectionCard/index.tsx` | 确认 | `apiFile` collection 已通过更多菜单展示同步入口。 | `collectionCanSync(collection.type)` | T6 |
+| `packages/global/core/dataset/collection/utils.ts` | 确认 | `collectionCanSync` 已支持 `DatasetCollectionTypeEnum.apiFile`,无需为钉钉新增类型。 | `[link, apiFile].includes(type)` | T6 |
+| `projects/app/src/pages/api/core/dataset/collection/sync.ts` | 确认 | 复用现有单集合同步 API。 | `postLinkCollectionSync(collection._id)` | T6 |
+| `packages/service/core/dataset/collection/utils.ts` | 确认 | `syncCollection` 对 `apiFile` 调用 `readDatasetSourceRawText` 并按 hash 判断重建。 | `DatasetCollectionTypeEnum.apiFile` | T6 |
+| `packages/service/core/dataset/read.ts` | 确认 | `apiFile` 原文读取调用 `getApiDatasetRequest(...).getFileContent`。 | `readApiServerFileContent` | T6 |
+| `packages/global/common/system/types/index.ts` | 修改 | 可选新增 `show_dataset_dingtalk?: boolean`。 | 与飞书/语雀开关一致 | T5 |
+| `projects/app/src/pages/dataset/list/index.tsx` | 修改 | 第三方知识库菜单新增钉钉。 | `DatasetTypeEnum.dingtalk` | T5 |
+| `projects/app/src/pageComponents/dataset/list/CreateModal.tsx` | 修改 | `CreateDatasetType` 支持钉钉。 | union 加 `DatasetTypeEnum.dingtalk` | T5 |
+| `projects/app/src/pageComponents/dataset/ApiDatasetForm.tsx` | 修改 | 新增钉钉配置表单和 workspace 选择。 | appKey/appSecret/userId,选择后保存 operatorId/workspaceId/rootNodeId | T5 |
+| `projects/app/src/pageComponents/dataset/detail/Info/index.tsx` | 修改 | 详情展示钉钉配置并支持编辑。 | 不展示 appSecret | T6 |
+| `packages/service/support/user/audit/util.ts` | 修改 | 审计类型支持钉钉。 | `dataset.dingtalk_dataset` | T6 |
+| `packages/web/i18n/zh-CN/dataset.json` | 修改 | 新增钉钉文案。 | `dingtalk_dataset` 等 | T7 |
+| `packages/web/i18n/en/dataset.json` | 修改 | 新增英文文案。 | `DingTalk Knowledge Base` | T7 |
+| `packages/web/i18n/zh-Hant/dataset.json` | 修改 | 新增繁中文案。 | `釘釘知識庫` | T7 |
+| `packages/web/i18n/*/account_team.json` | 修改 | 新增审计文案。 | `dataset.dingtalk_dataset` | T7 |
+| `packages/web/components/common/Icon/constants.ts` | 修改 | 注册钉钉知识库图标。 | `core/dataset/dingtalkDatasetColor` | T7 |
+| `packages/web/components/common/Icon/icons/core/dataset/dingtalkDatasetColor.svg` | 新增 | 彩色图标。 | 可参考 `common/dingtalkFill` | T7 |
+| `packages/web/components/common/Icon/icons/core/dataset/dingtalkDatasetOutline.svg` | 新增 | 线性图标。 | 与现有 dataset 图标命名一致 | T7 |
+| `document/content/introduction/guide/knowledge_base/dingtalk_dataset.mdx` | 新增 | 中文使用文档。 | 前置条件、字段说明、限制 | T8 |
+| `document/content/introduction/guide/knowledge_base/dingtalk_dataset.en.mdx` | 新增 | 英文使用文档。 | DingTalk terminology | T8 |
+| `document/content/introduction/guide/knowledge_base/meta.json` | 修改 | 导航新增 `dingtalk_dataset`。 | 放在 `yuque_dataset` 后或 `lark_dataset` 后 | T8 |
+| `document/content/introduction/guide/knowledge_base/meta.en.json` | 修改 | 英文导航同步。 | pages 保持一致 | T8 |
+
+### 2.1 关键代码片段
+
+全局配置类型:
+
+```ts
+export const DingtalkServerSchema = z
+ .object({
+ appKey: z.string(),
+ appSecret: z.string().optional(),
+ userId: z.string(),
+ operatorId: z.string().optional(),
+ workspaceId: z.string().optional(),
+ rootNodeId: z.string().optional(),
+ workspaceName: z.string().optional()
+ })
+ .meta({ description: '钉钉知识库配置' });
+
+export const ApiDatasetServerSchema = z
+ .object({
+ apiServer: APIFileServerSchema.optional(),
+ feishuServer: FeishuServerSchema.optional(),
+ yuqueServer: YuqueServerSchema.optional(),
+ dingtalkServer: DingtalkServerSchema.optional()
+ })
+ .meta({ description: '第三方知识库配置' });
+```
+
+敏感字段脱敏:
+
+```ts
+const { apiServer, yuqueServer, feishuServer, dingtalkServer } = apiDatasetServer;
+
+return {
+ apiServer: apiServer ? { ...apiServer, authorization: '' } : undefined,
+ yuqueServer: yuqueServer ? { ...yuqueServer, token: '' } : undefined,
+ feishuServer: feishuServer ? { ...feishuServer, appSecret: '' } : undefined,
+ dingtalkServer: dingtalkServer ? { ...dingtalkServer, appSecret: '' } : undefined
+};
+```
+
+Provider 分发:
+
+```ts
+export const getApiDatasetRequest = async (apiDatasetServer?: ApiDatasetServerType) => {
+ const { apiServer, yuqueServer, feishuServer, dingtalkServer } = apiDatasetServer || {};
+
+ if (apiServer) return useApiDatasetRequest({ apiServer });
+ if (yuqueServer) return useYuqueDatasetRequest({ yuqueServer });
+ if (feishuServer) return useFeishuDatasetRequest({ feishuServer });
+ if (dingtalkServer) return useDingtalkDatasetRequest({ dingtalkServer });
+
+ return Promise.reject('Can not find api dataset server');
+};
+```
+
+导入根节点:
+
+```ts
+const startId =
+ dataset.apiDatasetServer?.apiServer?.basePath ||
+ dataset.apiDatasetServer?.yuqueServer?.basePath ||
+ dataset.apiDatasetServer?.feishuServer?.folderToken ||
+ dataset.apiDatasetServer?.dingtalkServer?.rootNodeId;
+```
+
+钉钉 Provider 控制流:
+
+```ts
+export const useDingtalkDatasetRequest = ({ dingtalkServer }: Props) => {
+ const getAccessToken = async () => {
+ // POST https://api.dingtalk.com/v1.0/oauth2/accessToken
+ // body: { appKey, appSecret }
+ // 必须缓存 accessToken:缓存 key 使用 appKey 或 appKey + appSecret hash
+ // TTL 使用钉钉返回 expireIn,提前 5 分钟刷新;并发刷新要避免同 appKey 击穿
+ // 注意:不要记录 appSecret 和 accessToken
+ };
+
+ const getOperatorId = async () => {
+ // POST https://oapi.dingtalk.com/topapi/v2/user/get
+ // body: { userid: userId }
+ // 返回 result.unionid,作为 operatorId 使用
+ };
+
+ return {
+ listFiles: async ({ parentId, searchKey }) => {
+ const token = await getAccessToken();
+ const operatorId = dingtalkServer.operatorId || (await getOperatorId());
+
+ if (!dingtalkServer.rootNodeId && !parentId) {
+ // GET /v2.0/wiki/workspaces 支持 nextToken/maxResults,必须循环拉全
+ const workspaces = await listDingtalkWorkspaces({ token, operatorId, searchKey });
+ return workspaces.map(formatDingtalkWorkspaceItem);
+ }
+
+ const parentNodeId = parentId || dingtalkServer.rootNodeId;
+ // GET /v2.0/wiki/nodes 支持 nextToken/maxResults,必须循环拉全当前目录
+ const list = await listDingtalkChildren({ token, operatorId, parentNodeId, searchKey });
+ return list.map(formatDingtalkNodeItem);
+ },
+ getFileContent: async ({ apiFileId }) => {
+ const token = await getAccessToken();
+ const operatorId = dingtalkServer.operatorId || (await getOperatorId());
+ const rawText = await readDingtalkOnlineDocText({ token, operatorId, nodeId: apiFileId });
+ return { rawText };
+ },
+ getFilePreviewUrl: async ({ apiFileId }) => {
+ const token = await getAccessToken();
+ return { url: await getDingtalkPreviewUrl({ token, apiFileId }) };
+ },
+ getFileDetail: async ({ apiFileId }) => {
+ const token = await getAccessToken();
+ return formatDingtalkFileItem(await getDingtalkNodeDetail({ token, apiFileId }));
+ },
+ getFileRawId: (apiFileId) => parseDingtalkFileId(apiFileId).rawId
+ };
+};
+```
+
+注意:核心链路已通过最小脚本验证:`appKey/appSecret -> accessToken`、`userId -> unionId/operatorId`、`operatorId -> workspaces`、`rootNodeId -> nodes`、`nodeId -> document blocks`。实现时仍要按钉钉 API Explorer 核对 endpoint 和权限点,避免基于猜测编码。
+
+## 3. 后端实施说明
+
+### 3.1 API 改动
+
+不新增 FastGPT API 路由,只扩展已有 schema 和 Provider;下表列出本需求会复用或扩展的现有路由。
+
+| 路由 | 方法 | 请求参数 | 响应结构 | 鉴权 | 错误处理 |
+|---|---|---|---|---|---|
+| `/api/core/dataset/create` | POST | `type=dingtalk`、`apiDatasetServer.dingtalkServer` | datasetId | 现有创建鉴权 | schema 错误、权限错误走现有错误处理。 |
+| `/api/core/dataset/update` | PUT | `apiDatasetServer.dingtalkServer` | 更新结果 | 管理权限 | 空 `appSecret` 不覆盖旧值。 |
+| `/api/core/dataset/apiDataset/getCatalog` | POST | `apiDatasetServer`、`parentId` | `APIFileItemType[]` | 登录态/配置试连 | 钉钉错误转成可读错误。 |
+| `/api/core/dataset/apiDataset/list` | POST | `datasetId`、`parentId` | `APIFileItemType[]` | 读权限 | Provider 抛错向上返回。 |
+| `/api/core/dataset/collection/create/apiCollectionV2` | POST | `datasetId`、`apiFiles` | 创建结果 | 写权限 | 递归失败中断并返回错误。 |
+| `/api/core/dataset/collection/sync` | POST | `collectionId` | 同步结果 | 写权限 | 钉钉正文读取失败、hash 未变化、训练失败走现有同步错误处理。 |
+
+钉钉外部接口与权限:
+
+| 目的 | 钉钉接口 | 实现函数 | 权限 |
+|---|---|---|---|
+| 获取 accessToken | `POST /v1.0/oauth2/accessToken` | `getDingtalkAccessToken` | 应用凭证可用即可;必须按 `expireIn` 缓存,禁止每次列目录/读正文都重新获取。 |
+| User ID 换 operatorId | `POST /topapi/v2/user/get` | `getDingtalkOperatorId` | `qyapi_get_member` |
+| 获取知识库列表 | `GET /v2.0/wiki/workspaces` | `listDingtalkWorkspaces` | `Wiki.Workspace.Read`;接口支持 `nextToken/maxResults`,必须分页拉全。 |
+| 获取节点列表 | `GET /v2.0/wiki/nodes` | `listDingtalkChildren` | `Wiki.Node.Read`;接口支持 `nextToken/maxResults`,必须分页拉全当前目录。 |
+| 读取在线文档正文 | `GET /v1.0/doc/suites/documents/{nodeId}/blocks` | `readDingtalkOnlineDocText` | `Storage.File.Read` |
+
+创建请求示例:
+
+```json
+{
+ "parentId": null,
+ "type": "dingtalk",
+ "name": "钉钉产品知识库",
+ "intro": "钉钉在线文档同步",
+ "avatar": "core/dataset/dingtalkDatasetColor",
+ "apiDatasetServer": {
+ "dingtalkServer": {
+ "appKey": "dingxxxx",
+ "appSecret": "secret",
+ "userId": "300112376621597279",
+ "operatorId": "WYdSICEVT95nyee1HTr69wiEiE",
+ "workspaceId": "nV06pSYbo6XBEbaB",
+ "rootNodeId": "NkDwLng8ZLGMOea0Tx1KeXLyVKMEvZBY",
+ "workspaceName": "测试文档"
+ }
+ }
+}
+```
+
+详情响应中的配置示例:
+
+```json
+{
+ "apiDatasetServer": {
+ "dingtalkServer": {
+ "appKey": "dingxxxx",
+ "appSecret": "",
+ "userId": "300112376621597279",
+ "operatorId": "WYdSICEVT95nyee1HTr69wiEiE",
+ "workspaceId": "nV06pSYbo6XBEbaB",
+ "rootNodeId": "NkDwLng8ZLGMOea0Tx1KeXLyVKMEvZBY",
+ "workspaceName": "测试文档"
+ }
+ }
+}
+```
+
+### 3.2 Service/Core 改动
+
+| 模块 | 函数/类型 | 具体改动 | 依赖关系 |
+|---|---|---|---|
+| `packages/service/core/dataset/apiDataset/dingtalkDataset/api.ts` | `useDingtalkDatasetRequest` | 新增钉钉 Provider。 | 依赖 `@fastgpt/global/core/dataset/apiDataset/type`、logger。 |
+| 同文件 | `getDingtalkAccessToken` | 获取并缓存企业内部应用 accessToken。 | 调用 `https://api.dingtalk.com/v1.0/oauth2/accessToken`,通过 `@fastgpt/service/common/redis/cache` 的 `getRedisCache/setRedisCache/delRedisCache` 写入 Redis,按返回 `expireIn` 提前刷新。 |
+| 同文件 | `getDingtalkOperatorId` | 通过 `userId` 获取 `unionId/operatorId`。 | 调用 `https://oapi.dingtalk.com/topapi/v2/user/get`。 |
+| 同文件 | `listDingtalkWorkspaces` | 获取当前操作人可访问的知识库列表。 | 调用 `GET /v2.0/wiki/workspaces`,使用 `maxResults` 和 `nextToken` 循环拉全,失败时识别限流错误并提示稍后重试。 |
+| 同文件 | `listDingtalkChildren` | 获取目录节点列表。 | 调用 `GET /v2.0/wiki/nodes`,使用 `maxResults` 和 `nextToken` 循环拉全当前目录;递归导入时必须限制并发,避免大目录触发钉钉限流。 |
+| 同文件 | `formatDingtalkFileItem` | 钉钉 workspace 或 node 转 `APIFileItemType`。 | workspace 阶段保存 workspaceId/rootNodeId,node 阶段保存 nodeId。 |
+| `packages/service/core/dataset/apiDataset/index.ts` | `getApiDatasetRequest` | 新增 dingtalk 分支。 | 被导入、同步、预览共用。 |
+| `projects/app/src/pages/api/core/dataset/collection/create/apiCollectionV2.ts` | `createApiDatasetCollection` | `startId` 支持钉钉根节点。 | 只读取 global 配置,不写钉钉接口细节。 |
+| `packages/service/core/dataset/collection/utils.ts` | `syncCollection` | 不新增钉钉分支;钉钉导入后是 `apiFile`,直接走现有同步逻辑。 | 依赖 `getFileContent` 返回最新 `rawText`。 |
+| `packages/service/core/dataset/read.ts` | `readApiServerFileContent` | 不改主逻辑;通过 Provider 分发读取钉钉原文。 | hash 比对和重建集合由现有同步链路负责。 |
+
+钉钉错误处理要求:
+
+- accessToken 失败:抛出“钉钉应用鉴权失败,请检查 AppKey/AppSecret 和应用权限”。
+- accessToken 限流:命中钉钉 token 获取频控时不继续重试轰炸,返回“钉钉鉴权接口请求过快,请稍后重试”;实现上必须先依赖缓存降低触发概率。
+- 获取操作人失败:抛出“获取钉钉用户 unionId 失败,请检查 UserId、通讯录可见范围和 qyapi_get_member 权限”。
+- 获取知识库列表失败:抛出“读取钉钉知识库列表失败,请检查 Wiki.Workspace.Read 权限”。
+- 目录失败:抛出“读取钉钉目录失败,请检查 rootNodeId、Wiki.Node.Read 权限和知识库访问权限”。
+- 目录限流:命中钉钉文件列表接口限流时,允许 1 次短暂退避重试;仍失败则返回“钉钉目录接口请求过快,请稍后重试或减少一次导入的文件夹规模”。
+- 正文失败:抛出“读取钉钉在线文档失败,请检查文档类型和权限”。
+- 非在线文档:抛出“当前仅支持钉钉在线文档文本,不支持该文件类型”。
+
+### 3.2.1 accessToken 缓存与目录限流策略
+
+结论:`accessToken` 缓存必须做,目录接口限流保护必须做。钉钉服务端接口存在通用调用频率限制,且官方文档明确要求调用服务端接口前了解频率限制;获取 token 也不应被每次业务请求重复触发。这里不做缓存就是拿自己系统当压测工具,属于纯纯给后面挖坑。
+
+| 项目 | 是否必须做 | 实施要求 |
+|---|---|---|
+| `accessToken` 缓存 | 是 | 直接复用 `packages/service/common/redis/cache.ts`,使用 `getRedisCache/setRedisCache/delRedisCache` 写 Redis;缓存 key 使用 `dataset:dingtalk:accessToken:${appKey}:${hash(appSecret)}`;缓存值只保存在服务端;TTL 使用钉钉返回 `expireIn`,提前 5 分钟刷新。 |
+| token 并发刷新防击穿 | 是 | 同一 `appKey + appSecret hash` 同一时刻只允许一个刷新请求;首版用 Provider 模块内 `Map>` 合并进程内并发请求,缓存落 Redis;其他请求等待结果或复用旧 token,避免并发导入时瞬间打爆 token 接口。 |
+| token 错误缓存 | 否 | 鉴权失败、权限失败、网络失败都不缓存错误结果。 |
+| 文件列表并发控制 | 是 | 递归导入文件夹时限制 `GET /v2.0/wiki/nodes` 并发,首版建议串行或小并发,避免触发单接口 QPS 限制。 |
+| 限流重试 | 是 | 对明确的限流错误做 1 次 1 秒左右退避重试;重试失败直接返回可读错误,不做无限重试。 |
+| 真实压测 | 待执行 | 需要使用测试应用的环境变量跑小流量脚本验证 `wiki/nodes` 的限流错误码和响应结构,结果补回本文档。 |
+
+Redis 缓存实现建议:
+
+```ts
+import { createHash } from 'node:crypto';
+import {
+ getRedisCache,
+ setRedisCache,
+ delRedisCache
+} from '@fastgpt/service/common/redis/cache';
+
+const refreshingTokenMap = new Map>();
+const tokenSafeWindowSeconds = 5 * 60;
+
+const hashSecret = (secret: string) => createHash('sha256').update(secret).digest('hex').slice(0, 12);
+const getDingtalkAccessTokenCacheKey = ({ appKey, appSecret }: DingtalkServerType) =>
+ `dataset:dingtalk:accessToken:${appKey}:${hashSecret(appSecret)}`;
+
+const getDingtalkAccessToken = async (server: DingtalkServerType) => {
+ const cacheKey = getDingtalkAccessTokenCacheKey(server);
+ const cachedToken = await getRedisCache(cacheKey);
+ if (cachedToken) return cachedToken;
+
+ const refreshing = refreshingTokenMap.get(cacheKey);
+ if (refreshing) return refreshing;
+
+ const promise = (async () => {
+ try {
+ const { accessToken, expireIn } = await requestDingtalkAccessToken(server);
+ const ttl = Math.max(expireIn - tokenSafeWindowSeconds, 60);
+ await setRedisCache(cacheKey, accessToken, ttl);
+ return accessToken;
+ } catch (error) {
+ await delRedisCache(cacheKey).catch(() => undefined);
+ throw error;
+ } finally {
+ refreshingTokenMap.delete(cacheKey);
+ }
+ })();
+
+ refreshingTokenMap.set(cacheKey, promise);
+ return promise;
+};
+```
+
+实现注意:
+
+- 不新增 Redis 依赖,项目已有 `ioredis` 和 `@fastgpt/service/common/redis/cache`。
+- 不把 `accessToken` 返回前端,不写入日志,不存数据库。
+- `appSecret` 不直接进入 Redis key,只使用 hash 后的短摘要。
+- 用户修改 `appSecret` 后 cache key 会变化,旧 token 等 TTL 自然过期即可;如更新接口能判断密钥变化,可额外调用 `delRedisCache` 清理旧 key,但不是首版必需。
+- Redis 异常时不要阻塞核心错误提示:获取缓存失败可降级为直接请求钉钉 token,但请求成功后写缓存失败只记 warn,不应让用户创建失败。这个降级要谨慎包裹,别把真实鉴权失败吃掉。
+
+### 3.2.2 workspace 与节点列表分页策略
+
+结论:钉钉知识库列表和节点列表都必须做分页,不能按“当前测试数据一页返回完”来偷懒。官方接口已经暴露 `nextToken/maxResults`,说明服务端允许分批返回;如果 Provider 只请求第一页,大客户目录一多就会漏知识库或漏文件,后面排查起来能把人整精神。
+
+| 接口 | 是否分页 | 实施要求 |
+|---|---|---|
+| `GET /v2.0/wiki/workspaces` | 是 | `listDingtalkWorkspaces` 使用 `maxResults` 控制单页数量,首次请求不传 `nextToken`;响应存在 `nextToken` 时继续请求下一页,直到没有 `nextToken`。 |
+| `GET /v2.0/wiki/nodes` | 是 | `listDingtalkChildren` 对同一个 `parentNodeId` 使用 `maxResults + nextToken` 循环拉全当前目录,再映射为 `APIFileItemType[]`。 |
+| `GET /v1.0/doc/suites/documents/{nodeId}/blocks` | 待接口确认 | 若 blocks 接口也返回分页游标,`readDingtalkOnlineDocText` 必须同样循环拉全文本;若无分页字段,按单次响应处理。 |
+
+分页实现建议:
+
+```ts
+const listAllByNextToken = async ({
+ requestPage,
+ maxResults = 100
+}: {
+ requestPage: (params: { nextToken?: string; maxResults: number }) => Promise<{
+ items: T[];
+ nextToken?: string;
+ }>;
+ maxResults?: number;
+}) => {
+ const allItems: T[] = [];
+ let nextToken: string | undefined;
+
+ do {
+ const page = await requestPage({ nextToken, maxResults });
+ allItems.push(...page.items);
+ nextToken = page.nextToken || undefined;
+ } while (nextToken);
+
+ return allItems;
+};
+```
+
+注意:分页是为了拉全数据,限流是为了别拉太快,这俩不是一回事。分页循环内部仍要复用限流退避和错误处理,不要分页一多就一路猛冲,冲到 429 了再假装无辜。
+
+### 3.3 数据层改动
+
+| 集合/表 | 字段 | 类型 | 必填 | 默认值 | 索引 | 迁移策略 |
+|---|---|---|---|---|---|---|
+| `datasets` | `type=dingtalk` | enum | 是 | 无 | 复用现有 `type` | 无迁移。 |
+| `datasets` | `apiDatasetServer.dingtalkServer` | Object | 钉钉知识库是 | 无 | 无新增索引 | 无迁移。 |
+| `dataset_collections` | `apiFileId` | string | apiFile 是 | 无 | 现有查询模式 | 无迁移。 |
+| `dataset_collections` | `apiFileParentId` | string | 否 | 无 | 现有查询模式 | 无迁移。 |
+
+### 3.4 Bug 修复实施
+
+Not Applicable。
+
+### 3.5 Package 与依赖实施说明
+
+| 层级 | 可依赖 | 禁止事项 | 实施要求 |
+|---|---|---|---|
+| `packages/global` | 无运行时业务依赖 | 不调用钉钉 HTTP API,不引入 logger,不依赖 `service/web/app`。 | 只放 `DingtalkServerSchema`、`DatasetTypeEnum.dingtalk`、OpenAPI schema 和纯类型。 |
+| `packages/service` | `@fastgpt/global` | 不依赖 `projects/app`、不导入前端组件。 | 钉钉 Provider、外部接口请求、日志与错误处理都在该层完成。 |
+| `packages/web` | `@fastgpt/global` | 不调用后端 service,不包含密钥处理逻辑。 | 只补图标、i18n 和通用展示资源。 |
+| `projects/app` | `@fastgpt/global`、`@fastgpt/service`、`@fastgpt/web` | 不把钉钉 API 细节散落在页面组件中。 | API 路由和页面组合复用 Provider、表单和现有导入/同步链路。 |
+
+导入约束:
+
+- 跨包导入使用 `@fastgpt/global`、`@fastgpt/service`、`@fastgpt/web`。
+- 不使用 `../../../../packages/*` 这类跨包相对路径,省得后面维护人员看路径看到眼睛冒火。
+- 新增公共类型必须从稳定入口导出,供 OpenAPI、前端表单、Provider 共用。
+
+## 4. 前端实施说明
+
+| 页面/组件 | 文件路径 | 交互变化 | i18n 改动 | 状态覆盖 |
+|---|---|---|---|---|
+| 创建列表页 | `projects/app/src/pages/dataset/list/index.tsx` | 第三方知识库菜单新增钉钉项。 | `dingtalk_dataset`、`dingtalk_dataset_desc` | 成功态展示;可受开关隐藏。 |
+| 创建 Modal | `projects/app/src/pageComponents/dataset/list/CreateModal.tsx` | `CreateDatasetType` 支持 `dingtalk`。 | 复用 DatasetTypeMap | 成功创建。 |
+| 配置表单 | `projects/app/src/pageComponents/dataset/ApiDatasetForm.tsx` | 第一步填写 3 个字段,第二步展示 workspace 列表选择。 | appKey/appSecret/userId/workspace | 必填错误、权限错误、列表加载、保存成功。 |
+| 详情页 | `projects/app/src/pageComponents/dataset/detail/Info/index.tsx` | 展示钉钉配置摘要和编辑入口。 | `dingtalk_dataset_config` | 展示 userId/workspaceName,密钥不展示。 |
+| 添加文件入口 | `projects/app/src/pageComponents/dataset/detail/CollectionCard/Header.tsx` | 配置完成后在知识库详情页点击“添加文件”,进入 API 文件导入页。 | 复用已有添加文件文案 | 对齐飞书/现有第三方知识库流程。 |
+| 导入页 | `projects/app/src/pageComponents/dataset/detail/Import/diffSource/APIDataset.tsx` | 复用现有 API 文件导入,支持勾选钉钉在线文档或文件夹。 | 复用已有导入文案 | 加载、空态、错误、成功复用;配置变更后应刷新列表。 |
+| 同步入口 | `projects/app/src/pageComponents/dataset/detail/CollectionCard/index.tsx` | 导入后的 `apiFile` 集合在更多菜单显示“同步”。 | `dataset:collection_sync` | 点击后走现有 `postLinkCollectionSync`。 |
+
+表单字段建议:
+
+- `App Key`:必填,`apiDatasetServer.dingtalkServer.appKey`。
+- `App Secret`:必填,`apiDatasetServer.dingtalkServer.appSecret`,编辑时允许为空以保留旧值。
+- `操作人 User ID`:必填,`apiDatasetServer.dingtalkServer.userId`。
+- `workspace`:不让用户手填;点击“获取知识库列表”后选择,保存 `workspaceId/rootNodeId/workspaceName/operatorId`。
+- 保存配置后不自动导入全库;用户进入详情页点击“添加文件”,选择要同步到 FastGPT 的在线文档或文件夹。
+
+## 5. 日志与可观测性
+
+| 触发点 | 日志级别 | category | 字段 | 备注 |
+|---|---|---|---|---|
+| accessToken 获取失败 | `warn` | `LogCategories.MODULE.DATASET.API_DATASET` | `provider`、`userId`、`workspaceId`、`error` | 不记录 appSecret/accessToken。 |
+| 获取 operatorId 失败 | `warn` | 同上 | `provider`、`userId`、`error` | 不记录 token。 |
+| 获取 workspace 列表失败 | `warn` | 同上 | `provider`、`userId`、`error` | 输出 requiredScopes,便于用户开权限。 |
+| 列目录失败 | `warn` | 同上 | `provider`、`workspaceId`、`parentId`、`error` | parentId 可记录,密钥不可记录。 |
+| 读取正文失败 | `error` | 同上 | `provider`、`datasetId`、`apiFileId`、`error` | 不记录文档正文。 |
+| 文件类型不支持 | `warn` | 同上 | `provider`、`apiFileId`、`fileType` | 用户可恢复错误。 |
+
+注意事项:
+
+- 统一使用 `@fastgpt/service/common/logger`。
+- 不记录 token、密码、密钥、完整文档内容。
+- 高频列表循环不要逐项打 info 日志。
+
+## 6. 文档更新提醒
+
+| 文档路径 | 文档类型 | 更新原因 | 计划更新内容 | 负责人 | 截止时间 | 状态 |
+|---|---|---|---|---|---|---|
+| `document/content/introduction/guide/knowledge_base/dingtalk_dataset.mdx` | 产品文档 | 新增钉钉知识库能力 | 配置准备、字段说明、导入流程、限制 | 开发执行者 | 合并前 | 待更新 |
+| `document/content/introduction/guide/knowledge_base/dingtalk_dataset.en.mdx` | 英文产品文档 | 文档 i18n | 英文同步 | 开发执行者 | 合并前 | 待更新 |
+| `.claude/design/dingtalk-dataset-用户接入指南.md` | 用户操作文档 | 给项目组和用户说明如何接入 | 前台填写项、字段获取位置、应用权限、常见报错 | 开发执行者 | 合并前 | 已新增 |
+| `document/content/introduction/guide/knowledge_base/meta.json` | 中文导航 | 新增页面 | `pages` 加 `dingtalk_dataset` | 开发执行者 | 合并前 | 待更新 |
+| `document/content/introduction/guide/knowledge_base/meta.en.json` | 英文导航 | 新增页面 | `pages` 加 `dingtalk_dataset` | 开发执行者 | 合并前 | 待更新 |
+
+## 7. 文档 i18n 实施说明
+
+### 7.1 翻译范围识别
+
+- 自动检测命令:
+ - `git diff --name-only`
+ - `git diff --cached --name-only`
+- 手动指定路径:
+ - `document/content/introduction/guide/knowledge_base/dingtalk_dataset.mdx`
+ - `document/content/introduction/guide/knowledge_base/dingtalk_dataset.en.mdx`
+ - `document/content/introduction/guide/knowledge_base/meta.json`
+ - `document/content/introduction/guide/knowledge_base/meta.en.json`
+
+### 7.2 文件映射与动作
+
+| 中文文件 | 英文文件 | 类型 | 动作 | 状态 |
+|---|---|---|---|---|
+| `document/content/introduction/guide/knowledge_base/dingtalk_dataset.mdx` | `document/content/introduction/guide/knowledge_base/dingtalk_dataset.en.mdx` | mdx | 新增 | 待更新 |
+| `document/content/introduction/guide/knowledge_base/meta.json` | `document/content/introduction/guide/knowledge_base/meta.en.json` | meta | 更新 | 待更新 |
+
+### 7.3 翻译约束清单
+
+- 保持不变:import、图片路径、URL、HTML/JSX 结构、表格结构、代码块主体。
+- 必须翻译:frontmatter 的 `title`、`description`、正文文本、组件文本、表格文字。
+- 导航文件:`meta.json` 到 `meta.en.json`,保持 `pages` 完全一致。
+- 术语:
+ - 钉钉:`DingTalk`
+ - 知识库:`Knowledge Base`
+ - 在线文档:`online document`
+ - 钉盘:`DingTalk Drive` 或按钉钉官方英文命名确认。
+
+### 7.4 缺失文件与提醒
+
+| 缺失英文文件 | 对应中文文件 | 处理建议 |
+|---|---|---|
+| `document/content/introduction/guide/knowledge_base/dingtalk_dataset.en.mdx` | `dingtalk_dataset.mdx` | 新增英文同步文档。 |
+
+## 8. 测试与验证
+
+测试规范来源:`references/testing-standards.md`。
+
+### 8.1 测试文件映射
+
+| 源文件路径 | 文件类型 | 目标测试文件路径 | 是否跳过 | 跳过理由 |
+|---|---|---|---|---|
+| `packages/service/core/dataset/apiDataset/dingtalkDataset/api.ts` | packages | `test/cases/service/core/dataset/apiDataset/dingtalkDataset/api.test.ts` | 否 | 核心 Provider,必须测。 |
+| `packages/global/core/dataset/apiDataset/utils.ts` | packages | `test/cases/global/core/dataset/apiDataset/utils.test.ts` | 否 | 脱敏逻辑需要覆盖。 |
+| `projects/app/src/pages/api/core/dataset/collection/create/apiCollectionV2.ts` | projects | `projects/app/test/api/core/dataset/collection/create/apiCollectionV2.test.ts` | 否 | 根节点递归逻辑需要覆盖。 |
+| `packages/service/core/dataset/collection/utils.ts` | packages | `test/cases/service/core/dataset/collection/utils.test.ts` | 否 | 同步功能是本需求明确链路,需补钉钉 `apiFile` mock 场景。 |
+| `packages/global/core/dataset/apiDataset/type.ts` | packages | N/A | 是 | schema/type 静态文件可跳过。 |
+| `packages/global/core/dataset/constants.ts` | packages | N/A | 是 | 纯常量文件可跳过。 |
+| `projects/app/src/pageComponents/dataset/ApiDatasetForm.tsx` | projects | `projects/app/test/pageComponents/dataset/ApiDatasetForm.test.tsx` | 视项目测试能力 | 若现有前端测试环境不支持 TSX,使用类型检查和手工验证补充。 |
+
+### 8.2 自动化测试设计
+
+| 类型 | 用例 | 预期结果 |
+|---|---|---|
+| 单元测试 | `filterApiDatasetServerPublicData` 输入 `dingtalkServer.appSecret` | 输出中 `appSecret` 为空,其他字段保留。 |
+| 单元测试 | `getDingtalkAccessToken` mock 成功 | 使用 `appKey/appSecret` 请求,返回 token,不记录敏感信息。 |
+| 单元测试 | `getDingtalkAccessToken` 连续调用 | 首次请求钉钉,后续命中缓存;缓存过期后刷新;失败结果不写入缓存。 |
+| 单元测试 | `getDingtalkAccessToken` 并发调用 | 同一 `appKey` 并发只触发一次真实 token 请求,避免缓存击穿。 |
+| 单元测试 | `getDingtalkOperatorId` mock 成功 | 使用 `userId` 请求,返回 `operatorId`。 |
+| 单元测试 | `listDingtalkWorkspaces` mock 单页成功 | 返回 workspace 列表并映射为可选择项。 |
+| 单元测试 | `listDingtalkWorkspaces` mock 多页成功 | 按 `nextToken/maxResults` 连续请求,合并所有 workspace,直到响应不再返回 `nextToken`。 |
+| 单元测试 | `listFiles` mock 钉钉目录单页 | 返回 `APIFileItemType[]`,文件夹和在线文档类型正确。 |
+| 单元测试 | `listFiles` mock 钉钉目录多页 | 对同一个 `parentNodeId` 按 `nextToken/maxResults` 拉全所有节点,不漏第二页及后续页面。 |
+| 单元测试 | `listFiles` mock 钉钉限流 | 触发 1 次退避重试;重试仍失败时返回可读错误,不泄漏响应体和 token。 |
+| 单元测试 | `getFileContent` mock 在线文档 | 返回 `{ rawText }`。 |
+| 单元测试 | `getFileContent` mock 二进制文件 | 抛出“不支持该文件类型”。 |
+| 单元测试 | `getFileDetail` mock 节点详情 | 返回标准 `APIFileItemType`。 |
+| 集成测试 | `apiCollectionV2` 使用 dingtalkServer 根节点 | 递归调用从钉钉根节点开始。 |
+| 集成测试 | 添加文件导入 | 通过 `ImportDataSourceEnum.apiDataset` 选择钉钉在线文档或文件夹,创建 `apiFile` collection。 |
+| 集成测试 | 同步 `apiFile` | 调用钉钉 Provider 的 `getFileContent`,hash 变化时重建集合,未变化返回 `sameRaw`。 |
+
+### 8.3 场景覆盖核对
+
+| 场景 | 是否覆盖 | 对应用例 |
+|---|---|---|
+| 基础场景 | 是 | 创建配置、列目录、读正文。 |
+| 复杂场景 | 是 | 递归导入文件夹。 |
+| 边界值 | 是 | workspace 列表为空、根目录为空、分页最后一页为空、`operatorId` 需要重新换取。 |
+| 安全边界 | 是 | 不记录密钥,不返回明文 `appSecret`。 |
+| 异常场景 | 是 | token 失败、目录失败、正文失败、文件类型不支持。 |
+| 限流场景 | 是 | token 缓存、token 并发防击穿、目录接口限流退避。 |
+| 分页场景 | 是 | workspace 分页、nodes 分页、分页和限流同时出现。 |
+
+### 8.4 覆盖率目标与例外说明
+
+| 目标文件 | 行覆盖率目标 | 分支覆盖率目标 | 允许低于 100% 的原因 | 风险处理 |
+|---|---|---|---|---|
+| `packages/service/core/dataset/apiDataset/dingtalkDataset/api.ts` | 90%+,优先 100% | 90%+,优先 100% | 外部钉钉网络调用通过 mock 覆盖,真实网络失败仅做手工联调。 | mock 成功、权限失败、空列表、正文失败、不支持类型。 |
+| `packages/global/core/dataset/apiDataset/utils.ts` | 100% | 100% | 纯函数,无例外。 | 覆盖 `dingtalkServer.appSecret` 脱敏。 |
+| `projects/app/src/pages/api/core/dataset/collection/create/apiCollectionV2.ts` | 90%+,优先 100% | 90%+,优先 100% | 训练队列和外部 Provider 通过 mock/集成夹具处理。 | 验证 `dingtalkServer.rootNodeId` startId 和递归入口。 |
+| `packages/service/core/dataset/collection/utils.ts` | 90%+,优先 100% | 90%+,优先 100% | 同步训练重建链路涉及现有集合重建流程。 | 补钉钉 `apiFile` hash 变化、`sameRaw`、正文读取失败。 |
+| `projects/app/src/pageComponents/dataset/ApiDatasetForm.tsx` | 以类型检查和手工验证为主 | 以类型检查和手工验证为主 | 若当前项目 TSX 测试环境不稳定,不强行造测试框架。 | 手工覆盖加载、空态、权限错误、保存成功。 |
+
+覆盖率规则:遵循 `references/testing-standards.md`,优先 100% 行/分支覆盖,最低不得低于 90%;低于 100% 必须保留上表原因与风险处理。
+
+### 8.5 执行命令与结果
+
+实现后执行:
+
+```shell
+pnpm test test/cases/service/core/dataset/apiDataset/dingtalkDataset/api.test.ts
+pnpm test test/cases/global/core/dataset/apiDataset/utils.test.ts
+pnpm test projects/app/test/api/core/dataset/collection/create/apiCollectionV2.test.ts
+pnpm test test/cases/service/core/dataset/collection/utils.test.ts
+pnpm typecheck
+```
+
+| 命令 | 结果 | 覆盖率(行/分支) | 备注 |
+|---|---|---|---|
+| `pnpm test test/cases/service/core/dataset/apiDataset/dingtalkDataset/api.test.ts` | 待执行 | 目标 90%+/90%+,优先 100% | 外部钉钉接口 mock。 |
+| `pnpm test test/cases/global/core/dataset/apiDataset/utils.test.ts` | 待执行 | 目标 100%/100% | 脱敏纯函数。 |
+| `pnpm test projects/app/test/api/core/dataset/collection/create/apiCollectionV2.test.ts` | 待执行 | 目标 90%+/90%+,优先 100% | 验证 dingtalk startId。 |
+| `pnpm test test/cases/service/core/dataset/collection/utils.test.ts` | 待执行 | 目标 90%+/90%+,优先 100% | 验证钉钉 `apiFile` 同步。 |
+| `pnpm typecheck` | 待执行 | N/A | 类型检查。 |
+
+### 8.6 手工验证
+
+| 场景 | 操作步骤 | 预期结果 |
+|---|---|---|
+| 正常创建 | 进入知识库列表,选择钉钉知识库,填写 appKey/appSecret/userId,拉取 workspace 列表并选择。 | 创建成功,详情页显示钉钉配置摘要。 |
+| workspace 分页验证 | 使用测试应用调用 `GET /v2.0/wiki/workspaces?maxResults=1`,若返回 `nextToken`,继续请求下一页。 | Provider 能合并多页 workspace;即使当前数据量少,也保留分页逻辑。 |
+| 添加文件导入 | 进入知识库详情页,点击“添加文件”,展开钉钉目录,选择在线文档或文件夹导入。 | 创建 `apiFile` 集合并完成训练。 |
+| 节点分页验证 | 使用测试应用调用 `GET /v2.0/wiki/nodes?parentNodeId={rootNodeId}&maxResults=1`,若返回 `nextToken`,继续请求下一页。 | Provider 能合并同一目录下多页节点;添加文件页面不漏文件。 |
+| 同步文档 | 修改钉钉在线文档后,在已导入集合的更多菜单点击“同步”。 | 内容变化时同步成功,集合名称按标题更新;未变化返回 `sameRaw`。 |
+| 文件列表限流验证 | 使用测试应用对同一 `rootNodeId` 小批量连续调用 `GET /v2.0/wiki/nodes`,记录 QPS、错误码、响应结构。 | 明确当前租户下目录接口限流表现;若触发限流,Provider 能返回可读错误。 |
+| 权限错误 | 使用无权限应用或错误根节点。 | 页面出现可读错误,不泄漏密钥。 |
+| 类型限制 | 选择二进制文件。 | 提示首版仅支持在线文档文本。 |
+
+## 9. 实施风险与防呆
+
+| 风险点 | 可能后果 | 防呆要求 | 关联文件 |
+|---|---|---|---|
+| `appSecret` 编辑时传空字符串 | 用户编辑配置后把旧密钥冲掉,连接失效。 | 复用现有“空值不覆盖旧密钥”逻辑,并补脱敏测试。 | `projects/app/src/pages/api/core/dataset/update.ts`、`packages/global/core/dataset/apiDataset/utils.ts` |
+| 前端把 `workspaceId/rootNodeId` 做成手填 | 用户填错概率高,排查成本爆炸。 | 表单必须通过“获取知识库列表”选择 workspace 后保存。 | `projects/app/src/pageComponents/dataset/ApiDatasetForm.tsx` |
+| 钉钉 Provider 把二进制文件当在线文档读 | 训练失败或产生垃圾文本。 | `getFileContent` 对非在线文档抛明确错误。 | `packages/service/core/dataset/apiDataset/dingtalkDataset/api.ts` |
+| 导入页编辑配置后列表不刷新 | 用户看到旧 workspace 文件树。 | `APIDataset` 刷新依赖覆盖 `datasetDetail.apiDatasetServer`。 | `projects/app/src/pageComponents/dataset/detail/Import/diffSource/APIDataset.tsx` |
+| 同步链路新增钉钉专属分支 | 维护成本上升,和飞书/语雀不一致。 | 钉钉导入后仍是 `apiFile`,只实现 Provider 的 `getFileContent`。 | `packages/service/core/dataset/collection/utils.ts`、`packages/service/core/dataset/read.ts` |
+| 日志记录 token/正文 | 密钥或企业文档泄漏。 | 日志只记录结构化上下文,不记录 appSecret、accessToken、文档正文。 | `packages/service/core/dataset/apiDataset/dingtalkDataset/api.ts` |
+| 不缓存 `accessToken` | 每次列目录/读正文都请求 token,增加延迟且容易触发钉钉鉴权接口频控。 | `getDingtalkAccessToken` 必须按 `expireIn` 缓存并提前刷新,失败结果不缓存。 | `packages/service/core/dataset/apiDataset/dingtalkDataset/api.ts` |
+| 只取第一页 workspace 或节点 | 用户看不到部分知识库或目录文件,导入结果不完整。 | `workspaces/nodes` 都必须按 `nextToken/maxResults` 循环拉全;测试覆盖多页响应。 | `packages/service/core/dataset/apiDataset/dingtalkDataset/api.ts` |
+| 递归导入大文件夹时目录接口调用过快 | 钉钉返回限流错误,导入中断,用户以为配置坏了。 | 目录拉取限制并发;明确限流错误提示;真实压测结果补充到手工验证记录。 | `packages/service/core/dataset/apiDataset/dingtalkDataset/api.ts`、`projects/app/src/pages/api/core/dataset/collection/create/apiCollectionV2.ts` |
+
+## 10. 质量自检清单
+
+- [ ] 输入校验与权限校验完整。
+- [ ] 无 `any` 滥用、无未处理 Promise。
+- [ ] API 错误处理统一,错误信息可追踪。
+- [ ] 前端文本都接入 i18n。
+- [ ] 日志结构化且已脱敏。
+- [ ] `accessToken` 已缓存且不会记录到日志。
+- [ ] workspace 和节点列表已按 `nextToken/maxResults` 分页拉全。
+- [ ] 文件列表接口已做并发控制和限流错误处理。
+- [ ] 包依赖方向符合 monorepo 约束。
+- [ ] 覆盖空态、加载态、错误态、成功态。
+- [ ] 文档更新提醒已填写目标文档路径。
+- [ ] 中英文文档与导航文件均已同步。
+- [ ] 实现方案保持最小改动,无不必要抽象、依赖和防御性分支。
+- [ ] 钉钉具体接口路径和权限点已通过 API Explorer 确认后再编码。
+
+## 11. 发布与回滚
+
+### 11.1 发布步骤
+
+1. 合并代码前执行单测、类型检查和 i18n 检查。
+2. 在测试环境配置钉钉企业内部应用权限。
+3. 使用真实钉钉在线文档验证创建、导入、同步。
+4. 确认文档站可访问钉钉知识库中英文页面。
+5. 发布到生产。
+
+### 11.2 回滚触发条件
+
+- 钉钉 Provider 大面积读取失败。
+- 创建钉钉知识库导致现有第三方知识库创建或同步异常。
+- 发现密钥或 accessToken 泄漏风险。
+- 文档类型识别错误导致二进制文件进入在线文档文本链路。
+
+### 11.3 回滚步骤
+
+1. 优先关闭 `show_dataset_dingtalk`,隐藏创建入口。
+2. 若问题在 Provider,回滚钉钉 Provider 和分发分支。
+3. 若已创建钉钉知识库,保留数据但禁止继续导入和同步。
+4. 因无新增表和迁移,无需数据库回滚脚本。
+
+## 12. AI 实施提示
+
+- 严格按 T1 到 T10 执行,不要跳过类型和脱敏。
+- 不要自行扩展到第三方应用授权或二进制文件解析。
+- 不要接钉钉 AI 助理知识管理接口。
+- 钉钉接口实现前,必须先在 API Explorer 确认目录、正文、预览、详情接口的 endpoint、权限点、请求参数和响应字段。
+- 若钉钉响应字段与本文档假设不一致,以 Provider 内部适配为准,不要污染 `APIFileItemSchema`。
+- 若发现现有 `third_dataset.mdx` 写的路径与当前代码不一致,可以顺手修正文档,但不要扩大到无关章节重写。
diff --git a/.claude/design/core/dataset/dingtalk-dataset-需求设计文档.md b/.claude/design/core/dataset/dingtalk-dataset-需求设计文档.md
new file mode 100644
index 0000000000..669d9bb9d4
--- /dev/null
+++ b/.claude/design/core/dataset/dingtalk-dataset-需求设计文档.md
@@ -0,0 +1,427 @@
+# 需求设计文档
+
+## 0. 文档标识
+
+- 任务前缀:`dingtalk-dataset`
+- 文档文件名:`dingtalk-dataset-需求设计文档.md`
+- 需求类型:新增第三方知识库接入
+- 当前状态:方案设计完成,等待进入代码实现
+
+## 1. 需求背景与目标
+
+### 1.1 背景
+
+- 问题现状:当前 FastGPT 已支持自定义 API 文件库、飞书知识库、语雀知识库等第三方知识库接入,但缺少钉钉文档知识库/知识空间、钉盘文件夹的接入形式。
+- 触发场景:企业已有在线文档沉淀在钉钉文档知识库、知识空间或钉盘文件夹,希望通过 FastGPT 直接读取在线文档文本构建知识库,不把原文档二次存储为独立文件。
+- 官方参考:
+ - 钉钉开放平台基础概念:https://open.dingtalk.com/document/development/development-basic-concepts
+ - 企业内部应用 accessToken:https://open.dingtalk.com/document/development/obtain-the-access-token-of-an-internal-app
+ - 获取知识库列表:https://dingtalk.apifox.cn/api-141794382
+ - 获取节点列表:https://dingtalk.apifox.cn/api-141798046
+
+### 1.2 目标
+
+- 业务目标:新增“钉钉知识库”作为第三方知识库入口,用户填写 `appKey/appSecret/userId` 后,系统自动换取 `operatorId` 并拉取当前用户可访问的钉钉知识库列表;用户选择知识库后保存数据源配置,再进入知识库详情页点击“添加文件”,从文件树中选择要导入/同步的在线文档或文件夹。
+- 技术目标:复用现有 `apiDatasetServer`、`APIFileItemSchema`、`apiFile` 集合类型、第三方知识库导入和同步链路,新增钉钉 Provider,不新增独立数据表和独立导入链路。
+- 成功指标:
+ - 用户可以创建 `DatasetTypeEnum.dingtalk` 类型知识库。
+ - 用户可以配置企业内部应用 `appKey/appSecret` 和操作人 `userId`。
+ - 系统可以通过 `userId` 获取 `operatorId/unionId`,并列出当前用户可访问的 `workspace`。
+ - 配置保存后,详情页可以通过现有“添加文件”入口进入 API 文件导入页。
+ - 导入页可以展示钉钉根节点下的文件夹和在线文档。
+ - 选择在线文档或文件夹后可以创建 `apiFile` 集合并完成训练。
+ - 已导入的 `apiFile` 集合可以通过现有同步按钮重新拉取钉钉在线文档文本。
+ - `appSecret` 不在详情接口、前端展示和日志中明文暴露。
+
+### 1.3 项目画像
+
+- 仓库入口:根目录存在 `package.json`、`pnpm-workspace.yaml`,项目是 pnpm workspace + Turbo monorepo。
+- 应用分层:`projects/app` 是 Next.js 主应用,API 路由位于 `projects/app/src/pages/api`。
+- 公共包分层:`packages/global` 放类型、常量、OpenAPI schema;`packages/service` 放后端服务、Mongo schema、Provider、日志;`packages/web` 放前端通用组件、图标和 i18n。
+- 文档位置:当前文档站实际路径是 `document/content/introduction/guide/knowledge_base`,不是 skill 示例里的 `document/content/docs`。
+- 测试位置:`packages/*` 对应 `test/cases/*`;`projects/app/src/*` 对应 `projects/app/test/*`。
+
+## 2. 当前项目事实基线
+
+| 能力项 | 现有实现位置 | 现状说明 | 结论 |
+|---|---|---|---|
+| 知识库类型 | `packages/global/core/dataset/constants.ts` | `DatasetTypeEnum` 已有 `apiDataset`、`feishu`、`yuque`,`ApiDatasetTypeMap` 维护入口图标、文案和文档地址。 | 修改:新增 `dingtalk`。 |
+| 第三方配置类型 | `packages/global/core/dataset/apiDataset/type.ts` | `ApiDatasetServerSchema` 已包含 `apiServer`、`feishuServer`、`yuqueServer`。 | 修改:新增 `DingtalkServerSchema` 和 `dingtalkServer`。 |
+| 敏感信息脱敏 | `packages/global/core/dataset/apiDataset/utils.ts` | `filterApiDatasetServerPublicData` 会清空 `authorization`、`token`、`appSecret`。 | 修改:清空 `dingtalkServer.appSecret`。 |
+| Provider 分发 | `packages/service/core/dataset/apiDataset/index.ts` | `getApiDatasetRequest` 根据配置分发到 custom、yuque、feishu Provider。 | 修改:增加 dingtalk 分支。 |
+| Provider 实现参考 | `packages/service/core/dataset/apiDataset/feishuDataset/api.ts`、`packages/service/core/dataset/apiDataset/yuqueDataset/api.ts` | Provider 统一实现 `listFiles`、`getFileContent`、`getFilePreviewUrl`、`getFileDetail`、`getFileRawId`。 | 新增:`dingtalkDataset/api.ts`。 |
+| 添加文件入口 | `projects/app/src/pageComponents/dataset/detail/CollectionCard/Header.tsx` | `ApiDatasetTypeMap` 类型会展示“添加文件”按钮,点击后进入 `ImportDataSourceEnum.apiDataset`。 | 复用:钉钉进入 `ApiDatasetTypeMap` 后自然获得入口。 |
+| 导入 API 文件 | `projects/app/src/pageComponents/dataset/detail/Import/diffSource/APIDataset.tsx`、`projects/app/src/pages/api/core/dataset/collection/create/apiCollectionV2.ts` | 导入页可勾选文件/文件夹;递归导入 `apiFile`,根节点起点目前取 `apiServer.basePath`、`yuqueServer.basePath`、`feishuServer.folderToken`。 | 修改:新增钉钉根节点起点,并确认配置变更后文件列表刷新。 |
+| 同步 API 文件 | `packages/global/core/dataset/collection/utils.ts`、`projects/app/src/pageComponents/dataset/detail/CollectionCard/index.tsx`、`packages/service/core/dataset/collection/utils.ts`、`packages/service/core/dataset/read.ts` | `collectionCanSync` 已支持 `apiFile`;集合行菜单调用 `postLinkCollectionSync`;服务端通过 `getApiDatasetRequest(...).getFileContent` 拉取原文并按 hash 判断是否重建。 | 复用:钉钉 Provider 必须实现稳定的 `getFileContent`。 |
+| API 合约 | `packages/global/openapi/core/dataset/api.ts`、`packages/global/openapi/core/dataset/apiDataset/api.ts` | 创建/更新知识库、列文件、列目录、查路径均复用 `ApiDatasetServerSchema`。 | 修改描述和 schema 即可,不新增路由。 |
+| 前端创建入口 | `projects/app/src/pages/dataset/list/index.tsx`、`projects/app/src/pageComponents/dataset/list/CreateModal.tsx` | 第三方知识库菜单已有自定义 API、飞书、语雀。 | 修改:新增钉钉菜单项和创建类型。 |
+| 前端配置表单 | `projects/app/src/pageComponents/dataset/ApiDatasetForm.tsx` | 根据知识库类型渲染不同配置字段。 | 修改:新增钉钉配置字段。 |
+| 详情页配置展示 | `projects/app/src/pageComponents/dataset/detail/Info/index.tsx` | 已展示 API、语雀、飞书配置并支持编辑。 | 修改:新增钉钉配置展示与编辑入口。 |
+| i18n | `packages/web/i18n/zh-CN/dataset.json`、`packages/web/i18n/en/dataset.json`、`packages/web/i18n/zh-Hant/dataset.json` | 已有 `feishu_dataset`、`yuque_dataset`。 | 修改:新增钉钉相关 key。 |
+| 审计文案 | `packages/service/support/user/audit/util.ts`、`packages/web/i18n/*/account_team.json` | 审计日志类型文案已覆盖现有知识库类型。 | 修改:新增钉钉类型文案。 |
+| 文档 | `document/content/introduction/guide/knowledge_base/*.mdx`、`meta.json`、`meta.en.json` | 已有 API 文件库、飞书、语雀、第三方知识库扩展文档。 | 新增:钉钉中英文文档并同步导航。 |
+| 日志分类 | `packages/service/common/logger/categories.ts` | 已有 `LogCategories.MODULE.DATASET.API_DATASET`。 | 复用,不新增 category。 |
+| 数据库 Schema | `packages/service/core/dataset/schema.ts` | `apiDatasetServer` 使用 Object 存储,`type` enum 来自 `DatasetTypeMap`。 | 弱修改:无需新字段/迁移,新增 enum 值会进入合法类型。 |
+
+## 3. 需求澄清记录
+
+| 维度 | 已确认内容 | 待确认内容 | 备注 |
+|---|---|---|---|
+| 业务目标 | 接入钉钉文档知识库/知识空间、钉盘文件夹。 | 无。 | 不接钉钉 AI 助理知识库。 |
+| 范围边界 | 首版只支持在线文档文本。 | 钉钉具体“列目录/读正文/预览”接口参数需在 API Explorer 最终确认。 | 二进制文件不做。 |
+| 权限模型 | 用户填写企业内部应用 `appKey/appSecret` 和操作人 `userId`;后端用 `userId` 换取 `operatorId/unionId`。 | 无。 | 不让用户手填 unionId,降低接入成本。 |
+| 数据模型 | 在 `apiDatasetServer` 下新增 `dingtalkServer`,保存 `appKey/appSecret/userId/operatorId/workspaceId/rootNodeId`。 | 无。 | 不新增表。 |
+| API 行为 | 复用现有创建、更新、列目录、导入、同步 API。 | 无新增 FastGPT API 路由。 | 只扩展类型和 Provider。 |
+| 前端交互 | 第一步用户填写 `appKey/appSecret/userId`,第二步系统拉取知识库列表,用户选择 `workspace` 并保存;第三步进入知识库详情页点击“添加文件”,在导入页选择在线文档或文件夹;第四步导入后的 `apiFile` 集合可在列表中点击“同步”。 | 无。 | 对齐飞书/现有第三方知识库流程,不让用户手填 `workspaceId/rootNodeId`,由系统自动保存。 |
+| Bug 修复分析 | Not Applicable。 | 无。 | 新功能。 |
+| 文档更新 | 需要补钉钉知识库文档。 | 无。 | 中英文都补。 |
+| 文档 i18n | 中文文档与英文文档、中文导航与英文导航同步。 | 无。 | 钉钉英文使用 `DingTalk`。 |
+
+## 3.1 影响域判定
+
+| 维度 | 是否命中 | 证据 | 核对规范 | 结论 |
+|---|---|---|---|---|
+| API | Yes | `CreateDatasetBodySchema`、`UpdateDatasetBodySchema`、`GetApiDataset*Schema` 都引用 `ApiDatasetServerSchema`。 | `references/style/api.md` | 不新增路由,扩展 schema、请求示例和错误分支。 |
+| DB | Yes | `packages/service/core/dataset/schema.ts` 存储 `apiDatasetServer: Object`,`type` enum 来自 `DatasetTypeMap`。 | `references/style/db.md` | 不新增字段和索引,无迁移;注意 enum 兼容。 |
+| Front | Yes | 创建菜单、配置表单、详情编辑、i18n 均需显示钉钉。 | `references/style/front.md` | 使用现有 React + Chakra + i18n 模式。 |
+| Logger | Yes | 钉钉 Provider 需要记录外部接口失败上下文。 | `references/style/logger.md` | 复用 `DATASET.API_DATASET`,禁止输出密钥。 |
+| Package | Yes | 变更跨 `packages/global`、`packages/service`、`packages/web`、`projects/app`。 | `references/style/package.md` | 遵循 monorepo 依赖方向。 |
+| BugFix | No | 不是修复存量缺陷。 | Not Applicable | 不写 Bug 修复章节。 |
+| DocUpdate | Yes | 新增用户可见的知识库类型和配置流程。 | `references/doc-update-reminder.md` | 必须补产品文档。 |
+| DocI18n | Yes | 文档要求中英文同步。 | `references/doc-i18n-standards.md` | 新增 `.mdx` 与 `.en.mdx`,同步 `meta`。 |
+
+### 3.2 命中规范核对结果
+
+规范源位于 skill 目录:`/Users/xxyyh/.codex/skills/fastgpt-requirement-design/references`。当前仓库根目录没有 `references/`,所以本文档中的 `references/*` 均指该 skill 的 reference 文件。
+
+| 维度 | 必须检查项 | 本方案落点 | 核对结论 |
+|---|---|---|---|
+| API | 路由位置、方法、鉴权、OpenAPI 合约、错误处理 | `6.1 API 设计`、`packages/global/openapi/core/dataset/api.ts`、`projects/app/src/pages/api/core/dataset/*` | 命中。只扩展 schema 和 Provider,路由复用现有鉴权。 |
+| DB | Schema/字段、索引、迁移、兼容策略 | `6.2 数据设计`、`packages/service/core/dataset/schema.ts` | 命中。使用 `apiDatasetServer` Object,无新增表和索引。 |
+| Front | React + TS、状态、i18n、加载/空态/错误/成功 | `6.4 前端设计`、`ApiDatasetForm.tsx`、`APIDataset.tsx` | 命中。新增配置字段,导入页复用现有状态。 |
+| Logger | category、结构化字段、敏感信息脱敏 | `6.6 日志与观测设计`、`LogCategories.MODULE.DATASET.API_DATASET` | 命中。不记录 appSecret、accessToken、正文。 |
+| Package | monorepo 依赖方向、导入路径、类型导出 | `6.5 Package 与依赖设计`、`packages/global`/`service`/`web`/`projects/app` | 命中。维持 global -> service/web -> app 的依赖方向。 |
+| DocUpdate | 文档路径、更新内容、负责人、状态 | `6.8 文档更新提醒` | 命中。产品文档、英文文档、导航文件均列出。 |
+| DocI18n | 中文/英文映射、导航同步、术语本地化、缺失英文文件 | `6.7 文档 i18n 设计` | 命中。DingTalk、Knowledge Base 等术语明确。 |
+| TechFlow | Mermaid 流程图、步骤映射表 | `6.10 技术实现流程图` | 命中。包含目标文件、目的、输入/输出、上下游衔接。 |
+
+### 3.3 完备性确认
+
+| 门槛 | 判定 | 证据 |
+|---|---|---|
+| 问题定义和目标可量化 | 通过 | 成功指标覆盖创建、配置、列 workspace、添加文件导入、同步、脱敏。 |
+| 改动对象可枚举 | 通过 | 影响域覆盖 API、DB、Front、Logger、Package、DocUpdate、DocI18n。 |
+| 验收标准明确 | 通过 | `8. 验收标准` 按创建、列目录、导入、同步、类型限制、i18n、文档、日志列出。 |
+| 回滚触发条件明确 | 通过 | `7.3 回滚策略` 与功能开发文档发布回滚章节已定义。 |
+| 待确认项不阻塞设计 | 通过 | 仅剩钉钉 API Explorer 最终字段核对,已要求实现前确认。 |
+
+## 4. 范围定义
+
+### 4.1 In Scope
+
+- 新增 `DatasetTypeEnum.dingtalk` 和对应 `ApiDatasetTypeMap`。
+- 新增 `apiDatasetServer.dingtalkServer` 配置:
+ - `appKey`:string,必填。
+ - `appSecret`:string,保存时必填,详情返回时脱敏。
+ - `userId`:string,必填,企业通讯录里的成员 ID,由用户填写。
+ - `operatorId`:string,必填,后端通过成员详情接口由 `userId` 换取,作为钉钉知识库 API 的操作人。
+ - `workspaceId`:string,用户选择知识库后保存。
+ - `rootNodeId`:string,用户选择知识库后保存,作为 FastGPT 列文件树的起点。
+- 新增钉钉 Provider,映射为统一 `APIFileItemSchema`。
+- 支持列目录、列文件、读取在线文档文本、获取预览地址、获取节点详情。
+- 创建知识库、编辑配置、通过详情页“添加文件”导入文件、同步 `apiFile` 集合。
+- 配置保存不自动导入全库;真正进入训练的是用户在导入页明确选择的文件或文件夹。
+- 新增前端入口、配置表单、详情页展示、i18n、图标。
+- 新增钉钉知识库中英文使用文档并同步导航。
+
+### 4.2 Out of Scope
+
+- 不接入钉钉 AI 助理的“助理学习知识/获取学习知识列表”接口。
+- 不支持第三方应用授权、服务商授权、免登授权。
+- 不支持上传或解析钉盘二进制文件,例如 pdf、docx、xlsx。
+- 不让用户手填 `unionId/workspaceId/rootNodeId`,这些值由后端接口自动查询或由用户在列表中点选。
+- 不新增独立集合类型,不新增独立数据表,不改变训练主流程。
+- 不做钉钉文档权限管理,只读取用户已授权应用可访问的内容。
+
+## 5. 方案对比
+
+| 方案 | 核心思路 | 优点 | 风险 | 性能影响 | 兼容性 | 维护复杂度 | 实施成本 | 结论 |
+|---|---|---|---|---|---|---|---|---|
+| 方案 A:新增内置钉钉 Provider | 在现有第三方知识库抽象下新增 `dingtalkServer` 和 `dingtalkDataset/api.ts`,用户只填 `appKey/appSecret/userId`,后端自动列出 workspace。 | 用户体验好,复用导入/同步链路,改动边界清晰。 | 需要处理钉钉权限缺失时的清晰提示。 | 与飞书/语雀一致,主要消耗在外部 API 和文档读取。 | 对存量知识库无迁移影响,只新增类型。 | 中,Provider 内聚维护。 | 中 | 推荐。 |
+| 方案 B:用自定义 API 文件库代理钉钉 | 由部署方写一个代理服务,把钉钉转成 FastGPT 标准 `/v1/file/*`。 | FastGPT 基本不改代码。 | 不是内置钉钉接入,用户配置成本高,产品感弱。 | 取决于外部代理,FastGPT 侧不可控。 | 对 FastGPT 兼容好,但对用户部署环境要求高。 | 高,代理服务和 FastGPT 两头排查。 | 低 | 不推荐作为产品方案。 |
+| 方案 C:接钉钉 AI 助理知识管理 API | 调用“助理学习知识”等接口。 | 表面上名字像知识库。 | 方向错误,是给钉钉助理喂知识,不是 FastGPT 拉文档。 | 无法满足 FastGPT 拉取文档文本的目标。 | 与现有 `apiFile` 抽象不匹配。 | 高,后续会接错模型。 | 中 | 明确放弃。 |
+
+推荐方案:方案 A。
+
+选型原则:同等可行时优先最小改动、少新增代码、少新增依赖。现有 API 文件库抽象已经覆盖导入与同步主流程,不需要新增独立导入链路。
+
+## 6. 推荐方案详细设计
+
+### 6.1 API 设计
+
+| 路由 | 方法 | 鉴权 | 请求 | 响应 | 错误分支 | 相关文件 |
+|---|---|---|---|---|---|---|
+| `/api/core/dataset/create` | POST | 现有创建知识库鉴权 | `type=dingtalk`、`apiDatasetServer.dingtalkServer` | 新知识库 ID | schema 校验失败、权限失败、模型配置失败 | `projects/app/src/pages/api/core/dataset/create.ts`、`packages/global/openapi/core/dataset/api.ts` |
+| `/api/core/dataset/update` | PUT | 现有知识库管理权限 | `apiDatasetServer.dingtalkServer` | 更新结果 | schema 校验失败、无权限、敏感字段空值处理错误 | `projects/app/src/pages/api/core/dataset/update.ts` |
+| `/api/core/dataset/apiDataset/getCatalog` | POST | 登录态/配置试连 | `apiDatasetServer.dingtalkServer`、`parentId` | 目录节点列表 | 钉钉鉴权失败、目录不存在、无权限 | `projects/app/src/pages/api/core/dataset/apiDataset/getCatalog.ts` |
+| `/api/core/dataset/apiDataset/list` | POST | 知识库读权限 | `datasetId`、`parentId` | 文件和文件夹列表 | 知识库不存在、钉钉接口失败 | `projects/app/src/pages/api/core/dataset/apiDataset/list.ts` |
+| `/api/core/dataset/apiDataset/getPathNames` | POST | 登录态或知识库读取配置 | `datasetId` 或 `apiDatasetServer`、`parentId` | 路径字符串 | 节点详情接口失败 | `projects/app/src/pages/api/core/dataset/apiDataset/getPathNames.ts` |
+| `/api/core/dataset/collection/create/apiCollectionV2` | POST | 知识库写权限 | `datasetId`、`apiFiles` | 创建集合结果 | 根节点缺失、递归拉取失败、训练限制超额 | `projects/app/src/pages/api/core/dataset/collection/create/apiCollectionV2.ts` |
+| `/api/core/dataset/collection/sync` | POST | 知识库写权限 | `collectionId` | 同步结果 | 集合不存在、非 `apiFile`、正文读取失败、训练失败 | `projects/app/src/pages/api/core/dataset/collection/sync.ts` |
+
+请求示例:
+
+```json
+{
+ "type": "dingtalk",
+ "name": "钉钉产品知识库",
+ "intro": "从钉钉在线文档同步产品资料",
+ "avatar": "core/dataset/dingtalkDatasetColor",
+ "apiDatasetServer": {
+ "dingtalkServer": {
+ "appKey": "dingxxxx",
+ "appSecret": "******",
+ "userId": "300112376621597279",
+ "operatorId": "WYdSICEVT95nyee1HTr69wiEiE",
+ "workspaceId": "nV06pSYbo6XBEbaB",
+ "rootNodeId": "NkDwLng8ZLGMOea0Tx1KeXLyVKMEvZBY"
+ }
+ }
+}
+```
+
+响应示例复用现有创建知识库响应:
+
+```json
+"68ad85a7463006c963799a05"
+```
+
+对应规范:API 路由沿用 Next.js API Routes,业务逻辑放在 `packages/service`,错误通过现有 `NextAPI`/`APIError` 链路处理。
+
+钉钉服务端接口链路:
+
+| 目的 | 钉钉接口 | 入参来源 | 输出 | 需要权限 |
+|---|---|---|---|---|
+| 获取应用 token | `POST /v1.0/oauth2/accessToken` | `appKey/appSecret` | `accessToken` | 应用基础凭证可用即可。 |
+| 查询操作人 | `POST /topapi/v2/user/get` | `accessToken/userId` | `unionId`,作为 `operatorId` | `qyapi_get_member` |
+| 获取知识库列表 | `GET /v2.0/wiki/workspaces` | `accessToken/operatorId` | `workspaceId/rootNodeId/name` | `Wiki.Workspace.Read` |
+| 获取文件树 | `GET /v2.0/wiki/nodes` | `accessToken/operatorId/parentNodeId` | 钉钉节点列表 | `Wiki.Node.Read` |
+| 读取在线文档正文 | `GET /v1.0/doc/suites/documents/{nodeId}/blocks` | `accessToken/operatorId/nodeId` | 文档 blocks | `Storage.File.Read` |
+
+限流与缓存结论:
+
+- `accessToken` 缓存是必须做,不是优化项。缓存 key 按应用维度区分,TTL 使用钉钉返回的有效期并提前刷新;鉴权失败、权限失败、网络失败不缓存错误结果。
+- 缓存实现直接使用项目已有 `@fastgpt/service/common/redis/cache`,通过 `getRedisCache/setRedisCache/delRedisCache` 写 Redis;key 使用 `dataset:dingtalk:accessToken:${appKey}:${hash(appSecret)}`,不新增缓存依赖,不把 token 存入数据库。
+- 文件列表接口 `GET /v2.0/wiki/nodes` 是递归导入的高频接口,必须限制并发,并对明确限流错误做短暂退避重试。
+- 真实限流压测需要测试应用凭证以环境变量方式提供,避免把 `appSecret` 写入脚本或日志;当前文档先把“压测并补充错误码”列为开发前验证项。
+
+### 6.2 数据设计
+
+| 实体/集合 | 字段 | 类型 | 必填 | 默认值 | 索引/约束 | 兼容策略 |
+|---|---|---|---|---|---|---|
+| `dataset.type` | `dingtalk` | enum value | 是 | 无 | 由 `DatasetTypeMap` 驱动 Mongo enum | 存量数据不变。 |
+| `dataset.apiDatasetServer.dingtalkServer.appKey` | `appKey` | string | 是 | 无 | 无新增索引 | 新增配置,不影响旧知识库。 |
+| `dataset.apiDatasetServer.dingtalkServer.appSecret` | `appSecret` | string | 保存时是,详情返回时脱敏 | 无 | 禁止前端明文展示 | 更新时如果传空字符串,沿用现有 update 逻辑保留旧密钥。 |
+| `dataset.apiDatasetServer.dingtalkServer.userId` | `userId` | string | 是 | 无 | 无新增索引 | 用户填写,便于后续重新换取 operatorId。 |
+| `dataset.apiDatasetServer.dingtalkServer.operatorId` | `operatorId` | string | 是 | 无 | 无新增索引 | 由后端通过 `userId` 换取,不要求用户手填。 |
+| `dataset.apiDatasetServer.dingtalkServer.workspaceId` | `workspaceId` | string | 是 | 无 | 无新增索引 | 用户选择知识库后保存。 |
+| `dataset.apiDatasetServer.dingtalkServer.rootNodeId` | `rootNodeId` | string | 是 | 无 | 无新增索引 | FastGPT 列文件树的入口。 |
+| `dataset_collections.apiFileId` | 钉钉节点 ID | string | 文件集合是 | 无 | 无新增索引 | 复用现有 apiFile 存储。 |
+
+不需要 Mongo 迁移,因为 `apiDatasetServer` 当前是 Object;不需要新增索引,因为查询仍按 `datasetId/teamId` 和集合主键走现有逻辑。
+
+对应规范:DB 只扩展已有 Object 配置,不新增高基数字段索引,不引入迁移风险。
+
+### 6.3 核心代码设计
+
+| 模块 | 关键函数/类型 | 变更说明 | 上下游影响 |
+|---|---|---|---|
+| Global 类型 | `DingtalkServerSchema`、`ApiDatasetServerSchema` | 新增钉钉配置 schema。 | OpenAPI、前端表单、服务端 Provider 共享。 |
+| Global 常量 | `DatasetTypeEnum`、`ApiDatasetTypeMap`、`DatasetTypeMap` | 新增钉钉知识库类型、图标、文档链接。 | 创建入口、列表展示、Mongo enum。 |
+| 脱敏工具 | `filterApiDatasetServerPublicData` | 清空 `dingtalkServer.appSecret`。 | 详情页不会泄漏密钥。 |
+| Provider | `useDingtalkDatasetRequest` | 新增 token 获取、Redis token 缓存、目录列表、正文读取、预览、详情、ID 规范化。 | 所有 apiDataset 路由自动复用。 |
+| 分发入口 | `getApiDatasetRequest` | 增加 `dingtalkServer` 分支。 | 导入、同步、预览统一生效。 |
+| 导入递归 | `createApiDatasetCollection` | `startId` 增加钉钉根节点。 | 支持选择根目录递归导入。 |
+| 添加文件导入 | `APIDataset`、`CollectionCard/Header` | 不新增钉钉专属导入页;复用现有 API 文件导入页选择文件/文件夹。 | 用户体验与飞书一致。 |
+| 同步集合 | `collectionCanSync`、`postLinkCollectionSync`、`syncCollection`、`readApiServerFileContent` | 不改同步主流程,依赖 Provider 返回最新 `rawText`。 | 导入后的钉钉 `apiFile` 集合同步链路复用。 |
+
+钉钉 Provider 的统一返回策略:
+
+- `listFiles`:未选择 `workspace` 时列出当前用户可访问的知识库列表;已选择 `workspace` 后把钉钉节点映射为 `APIFileItemType`。
+- `type`:文件夹为 `folder`,在线文档为 `file`。
+- `id`:workspace 列表阶段使用 `workspaceId`,文件树阶段使用钉钉 `nodeId`,保持稳定可反查。
+- `rawId`:保存钉钉原始节点 ID。
+- `parentId`:根节点下一级使用根节点 ID,子级使用上级节点 ID。
+- `getFileContent`:只返回在线文档文本,非在线文档直接抛出“不支持的文件类型”;同步功能也会调用该方法拉取最新正文并比较 hash。
+- `getFilePreviewUrl`:返回钉钉文档可访问 URL;若官方接口不提供预览 URL,则按官方文档链接规则拼接或返回空并让前端降级。
+
+### 6.4 前端设计
+
+| 页面/组件 | 入口文件 | 交互状态 | i18n key | 变更说明 |
+|---|---|---|---|---|
+| 创建知识库菜单 | `projects/app/src/pages/dataset/list/index.tsx` | 成功:展示钉钉入口;隐藏:受 `show_dataset_dingtalk` 控制。 | `dataset:dingtalk_dataset`、`dataset:dingtalk_dataset_desc` | 第三方知识库菜单新增钉钉。 |
+| 创建 Modal 类型 | `projects/app/src/pageComponents/dataset/list/CreateModal.tsx` | 成功:可创建 `dingtalk` 类型。 | 复用类型文案 | `CreateDatasetType` 加 `DatasetTypeEnum.dingtalk`。 |
+| 配置表单 | `projects/app/src/pageComponents/dataset/ApiDatasetForm.tsx` | 加载:获取知识库列表;错误:权限/必填校验;成功:保存配置。 | `dataset:dingtalk_app_key` 等 | 第一步渲染 appKey、appSecret、userId;第二步展示 workspace 列表供用户选择。 |
+| 详情配置 | `projects/app/src/pageComponents/dataset/detail/Info/index.tsx` | 成功:展示 userId、workspace 名称或 workspaceId,密钥不展示;编辑:打开现有编辑弹窗。 | `dataset:dingtalk_dataset_config` | 类似飞书/语雀配置展示。 |
+| 添加文件入口 | `projects/app/src/pageComponents/dataset/detail/CollectionCard/Header.tsx` | 点击“添加文件”进入 `currentTab=import&source=apiDataset`。 | 复用现有按钮文案 | 钉钉类型进入 `ApiDatasetTypeMap` 后自动展示入口。 |
+| 导入页 | `projects/app/src/pageComponents/dataset/detail/Import/diffSource/APIDataset.tsx` | 加载、空态、错误、成功复用现有逻辑;用户勾选在线文档或文件夹后进入训练。 | 复用 `apiFile` 文案 | 不新增导入组件;建议刷新依赖覆盖 `apiDatasetServer`,避免编辑钉钉配置后列表仍用旧缓存。 |
+| 同步入口 | `projects/app/src/pageComponents/dataset/detail/CollectionCard/index.tsx` | 导入后的 `apiFile` 集合在更多菜单展示同步;点击后调用 `postLinkCollectionSync`。 | `dataset:collection_sync` | 复用已有同步 UI,不新增钉钉按钮。 |
+
+对应规范:所有用户可见文本接入 i18n;使用现有 Chakra UI 表单模式,不新增前端状态库。
+
+### 6.5 Package 与依赖设计
+
+| 包/模块 | 依赖方向 | 改动原则 | 相关文件 |
+|---|---|---|---|
+| `packages/global` | 不依赖 `service/web/app` | 只新增类型、常量、OpenAPI schema,不引入运行时请求逻辑。 | `packages/global/core/dataset/constants.ts`、`packages/global/core/dataset/apiDataset/type.ts`、`packages/global/openapi/core/dataset/*` |
+| `packages/service` | 可依赖 `packages/global` | 钉钉 Provider、日志、Mongo 相关逻辑放这里;不反向依赖 `projects/app`。 | `packages/service/core/dataset/apiDataset/dingtalkDataset/api.ts`、`packages/service/core/dataset/apiDataset/index.ts` |
+| `packages/web` | 可依赖 `packages/global` | 仅补 i18n、图标注册、通用展示资源。 | `packages/web/i18n/*/dataset.json`、`packages/web/components/common/Icon/constants.ts` |
+| `projects/app` | 可依赖所有 packages | 页面入口、表单、API 路由、导入/同步 UI 都在 app 层组合。 | `projects/app/src/pageComponents/dataset/*`、`projects/app/src/pages/api/core/dataset/*` |
+
+对应规范:遵循 `references/style/package.md`,跨包导入使用 `@fastgpt/global`、`@fastgpt/service`、`@fastgpt/web` 别名,不使用跨包相对路径。
+
+### 6.6 日志与观测设计
+
+| 场景 | 日志级别 | category | 结构化字段 | 脱敏策略 |
+|---|---|---|---|---|
+| 获取 accessToken 失败 | `warn` | `LogCategories.MODULE.DATASET.API_DATASET` | `provider=dingtalk`、`userId`、`workspaceId`、`error` | 不记录 `appSecret`、accessToken。 |
+| 目录接口触发限流 | `warn` | 同上 | `provider`、`workspaceId`、`parentId`、`retryCount`、`error` | 不记录 token、密钥和完整响应体。 |
+| 列目录失败 | `warn` | 同上 | `provider`、`workspaceId`、`parentId`、`error` | 不记录密钥和完整响应体。 |
+| 读取正文失败 | `error` | 同上 | `provider`、`datasetId`、`apiFileId`、`error` | 不记录文档正文。 |
+| 不支持的文件类型 | `warn` | 同上 | `provider`、`apiFileId`、`fileType` | 不记录敏感信息。 |
+
+对应规范:统一 `getLogger(LogCategories.MODULE.DATASET.API_DATASET)`,不用 `console.log`,敏感信息禁止入日志。
+
+### 6.7 文档 i18n 设计
+
+| 中文文件 | 英文文件 | 类型 | 处理动作 | 翻译注意项 |
+|---|---|---|---|---|
+| `document/content/introduction/guide/knowledge_base/dingtalk_dataset.mdx` | `document/content/introduction/guide/knowledge_base/dingtalk_dataset.en.mdx` | 内容 | 新增 | 钉钉翻译为 `DingTalk`,知识库翻译为 `Knowledge Base`。 |
+| `document/content/introduction/guide/knowledge_base/meta.json` | `document/content/introduction/guide/knowledge_base/meta.en.json` | 导航 | 更新 | `pages` 同步插入 `dingtalk_dataset`,仅翻译 title/description。 |
+| `document/content/introduction/guide/knowledge_base/third_dataset.mdx` | `document/content/introduction/guide/knowledge_base/third_dataset.en.mdx` | 内容 | 可选更新 | 现有扩展文档路径有旧路径描述,可顺手校正。 |
+
+缺失英文文件清单:新增 `dingtalk_dataset.mdx` 后必须同步新增 `dingtalk_dataset.en.mdx`。
+
+### 6.8 文档更新提醒
+
+| 文档路径 | 文档类型 | 更新原因 | 计划更新内容 | 负责人 | 截止时间 | 状态 |
+|---|---|---|---|---|---|---|
+| `document/content/introduction/guide/knowledge_base/dingtalk_dataset.mdx` | 产品/使用文档 | 新增钉钉知识库入口 | 前置条件、权限配置、字段说明、导入流程、限制说明;内容可参考 `.claude/design/dingtalk-dataset-用户接入指南.md` | 开发执行者 | 功能合并前 | 待更新 |
+| `document/content/introduction/guide/knowledge_base/dingtalk_dataset.en.mdx` | 产品/英文文档 | 文档 i18n | 英文同步版本 | 开发执行者 | 功能合并前 | 待更新 |
+| `document/content/introduction/guide/knowledge_base/meta.json` | 导航 | 新增页面 | 插入 `dingtalk_dataset` | 开发执行者 | 功能合并前 | 待更新 |
+| `document/content/introduction/guide/knowledge_base/meta.en.json` | 英文导航 | 新增英文页面 | 插入 `dingtalk_dataset` | 开发执行者 | 功能合并前 | 待更新 |
+
+### 6.9 Bug 修复分析
+
+Not Applicable:本需求不是 Bug 修复。
+
+### 6.10 技术实现流程图
+
+```mermaid
+flowchart TD
+ A["Step 1: 扩展全局类型与常量
目的: 让 dingtalk 成为合法知识库类型"] --> B["Step 2: 实现钉钉 Provider
目的: 适配 accessToken、operatorId、workspace、node、blocks"]
+ B --> C["Step 3: 接入 Provider 分发
目的: 让现有 apiDataset 调用链识别 dingtalkServer"]
+ C --> D["Step 4: 补导入根节点
目的: apiCollectionV2 从 rootNodeId 递归导入"]
+ D --> E["Step 5: 补前端创建与配置
目的: 用户只填 AppKey/AppSecret/UserId 并选择 workspace"]
+ E --> F["Step 6: 复用添加文件导入页
目的: 进入详情页后选择要导入的文件/文件夹"]
+ F --> G["Step 7: 复用 apiFile 同步链路
目的: 已导入集合可拉取钉钉最新正文"]
+ G --> H["Step 8: 补详情展示与脱敏
目的: 展示连接信息且不泄漏 AppSecret"]
+ H --> I["Step 9: 补 i18n、图标、审计文案
目的: 页面、列表和审计展示完整"]
+ I --> J["Step 10: 补用户文档与中英文文档
目的: 给用户说明字段来源、权限、添加文件和同步"]
+ J --> K["Step 11: 补测试与验证
目的: 覆盖 Provider、脱敏、递归导入、同步和权限错误"]
+```
+
+步骤映射表:
+
+| 步骤 | 目标文件或模块 | 变更目的 | 输入 | 输出 | 前置依赖 | 后续衔接 |
+|---|---|---|---|---|---|---|
+| Step 1 | `packages/global/core/dataset/constants.ts`、`packages/global/core/dataset/apiDataset/type.ts`、`packages/global/openapi/core/dataset/api.ts` | 增加 `dingtalk` 类型和 `dingtalkServer` 配置 schema。 | 钉钉字段设计:`appKey/appSecret/userId/operatorId/workspaceId/rootNodeId`。 | 全局类型、OpenAPI schema、DatasetTypeMap 可识别钉钉。 | 当前 DatasetTypeEnum 和 ApiDatasetServerSchema。 | Service Provider 和前端表单可以引用统一类型。 |
+| Step 2 | `packages/service/core/dataset/apiDataset/dingtalkDataset/api.ts` | 封装钉钉接口调用与 `APIFileItemType` 映射。 | `dingtalkServer`、钉钉 accessToken、operatorId、workspace/node 数据。 | `listFiles/getFileContent/getFilePreviewUrl/getFileDetail/getFileRawId`。 | Step 1 的类型定义。 | Step 3 通过统一分发入口调用。 |
+| Step 3 | `packages/service/core/dataset/apiDataset/index.ts` | 将 `dingtalkServer` 接入现有 Provider 分发。 | `ApiDatasetServerType.dingtalkServer`。 | `getApiDatasetRequest` 返回钉钉 Provider。 | Step 2 Provider 实现。 | API 路由、导入、同步、预览复用现有调用链。 |
+| Step 4 | `projects/app/src/pages/api/core/dataset/collection/create/apiCollectionV2.ts` | 递归导入时以 `rootNodeId` 作为钉钉根节点。 | `dataset.apiDatasetServer.dingtalkServer.rootNodeId`。 | `apiFile` 集合创建和训练参数。 | Step 3 可列节点。 | 训练与同步进入现有 `apiFile` 流程。 |
+| Step 5 | `projects/app/src/pages/dataset/list/index.tsx`、`projects/app/src/pageComponents/dataset/list/CreateModal.tsx`、`projects/app/src/pageComponents/dataset/ApiDatasetForm.tsx` | 新增创建入口、配置表单和 workspace 选择流程。 | 用户填写 `appKey/appSecret/userId`。 | 保存 `operatorId/workspaceId/rootNodeId/workspaceName`。 | Step 1 OpenAPI 类型,Step 2/3 可试连。 | Step 6 添加文件导入,Step 8 展示配置。 |
+| Step 6 | `projects/app/src/pageComponents/dataset/detail/CollectionCard/Header.tsx`、`projects/app/src/pageComponents/dataset/detail/Import/diffSource/APIDataset.tsx` | 复用详情页“添加文件”与 API 文件导入页,让用户选择文件/文件夹。 | 已保存 `dingtalkServer.rootNodeId`、用户选择的 `apiFiles`。 | `apiFile` collection 创建请求。 | Step 5 保存配置,Step 4 支持根节点。 | 训练链路与现有第三方知识库一致。 |
+| Step 7 | `packages/global/core/dataset/collection/utils.ts`、`projects/app/src/pageComponents/dataset/detail/CollectionCard/index.tsx`、`packages/service/core/dataset/collection/utils.ts`、`packages/service/core/dataset/read.ts` | 复用 `apiFile` 同步能力,确认钉钉集合可显示同步菜单并拉取最新正文。 | `collectionId`、`apiFileId`、`dataset.apiDatasetServer.dingtalkServer`。 | `success/sameRaw` 等同步结果。 | Step 2 Provider 的 `getFileContent` 正确返回文本。 | 用户修改钉钉在线文档后可手动同步。 |
+| Step 8 | `projects/app/src/pageComponents/dataset/detail/Info/index.tsx`、`packages/global/core/dataset/apiDataset/utils.ts` | 详情页展示钉钉配置并脱敏密钥。 | 已保存的 `dingtalkServer`。 | 前端安全展示、`appSecret` 置空。 | Step 5 保存配置。 | 用户可后续编辑配置,避免密钥泄漏。 |
+| Step 9 | `packages/web/i18n/*/dataset.json`、`packages/web/i18n/*/account_team.json`、`packages/web/components/common/Icon/constants.ts` | 补齐页面文案、审计文案和图标。 | 新增钉钉知识库用户可见入口。 | 中/英/繁文案和图标可用。 | Step 5/6/8 页面需要文案。 | 文档与 UI 展示一致。 |
+| Step 10 | `.claude/design/dingtalk-dataset-用户接入指南.md`、`document/content/introduction/guide/knowledge_base/dingtalk_dataset.mdx`、`document/content/introduction/guide/knowledge_base/dingtalk_dataset.en.mdx`、`meta.json`、`meta.en.json` | 形成用户可读接入说明并同步文档导航。 | 实测链路和权限清单。 | 用户知道填什么、去哪里拿、开哪些权限、如何添加文件和同步。 | Step 1-9 明确功能行为。 | 发布前文档验收。 |
+| Step 11 | `test/cases/service/core/dataset/apiDataset/dingtalkDataset/api.test.ts`、`test/cases/global/core/dataset/apiDataset/utils.test.ts`、`projects/app/test/api/core/dataset/collection/create/apiCollectionV2.test.ts` | 覆盖 Provider、脱敏、递归导入、同步和错误分支。 | Mock 钉钉接口响应、现有导入/同步函数。 | 自动化测试与手工验证结果。 | Step 2-8 实现完成。 | 发布与回滚决策依据。 |
+
+## 7. 风险、迁移与回滚
+
+### 7.1 风险清单
+
+- 钉钉应用权限缺失时接口会返回 requiredScopes,前端需要把缺少的权限直接提示给用户。
+- 钉钉知识库主链路已实测使用 `workspaceId/rootNodeId/nodeId`,不要再走偏钉盘存储体系的 `spaceId/dentryId/dentryUuid`。
+- 企业内部应用权限不足时,表现会像“配置对了但拉不到文件”,需要错误提示清晰。
+- 在线文档文本接口可能只支持特定文档类型,首版必须明确“不支持二进制文件”。
+- `appSecret` 更新逻辑要复用现有“空值不覆盖旧密钥”能力,避免用户编辑配置后把密钥冲掉。
+- 不缓存 `accessToken` 会导致每次列目录/读正文都打鉴权接口,延迟高且容易触发钉钉频控;必须作为 T2 的必做项。
+- 递归导入大文件夹时 `wiki/nodes` 调用会集中爆发,必须限制并发并补充限流错误提示,否则用户会把限流误判成权限配置错误。
+
+### 7.2 迁移策略
+
+- 无需数据迁移。
+- 存量知识库类型不变。
+- 新增 enum 值后只影响新建钉钉知识库。
+- 若加 `show_dataset_dingtalk` 开关,默认建议展示,即 `undefined !== false`,保持与飞书/语雀一致。
+
+### 7.3 回滚策略
+
+- 回滚代码后,已创建的 `dingtalk` 类型知识库在旧版本中可能无法识别。
+- 若需要安全回滚,应先下线创建入口,再处理已创建的钉钉知识库或禁止继续导入。
+- 因无新增表和迁移,数据库层无需回滚脚本。
+
+## 8. 验收标准
+
+| 验收项 | 验收方式 | 通过标准 |
+|---|---|---|
+| 创建钉钉知识库 | 前端手工 + API 测试 | `type=dingtalk` 可保存,详情返回不含明文 `appSecret`。 |
+| 列出钉钉目录 | mock 钉钉接口 + 手工试连 | 文件夹和在线文档映射为 `APIFileItemType`。 |
+| 限流与 token 缓存 | 单测 + 小流量手工压测 | `accessToken` 连续调用命中缓存;目录接口遇到限流可退避或返回可读错误。 |
+| 添加文件导入 | 集成测试 + 手工验证 | 在知识库详情页点击“添加文件”,选择在线文档或文件夹后创建 `apiFile` 集合并完成训练。 |
+| 同步在线文档 | 单测 + 手工验证 | 导入后的 `apiFile` 集合显示同步入口;文档内容变化后同步结果为 `success`,未变化为 `sameRaw`。 |
+| 不支持二进制文件 | 单测 | pdf/docx/xlsx 等返回明确错误,不进入训练。 |
+| i18n | 静态检查 + 手工查看 | 中文、英文、繁中前端文案齐全。 |
+| 文档 | 文件检查 | 中英文文档和导航同步。 |
+| 日志安全 | 代码审查 | 日志和详情接口不泄漏 `appSecret`、accessToken、文档正文。 |
+
+## 9. MECE 核查结论
+
+### 9.1 相互独立检查结果
+
+- Provider 只负责钉钉 API 适配,不处理训练逻辑。
+- `apiCollectionV2` 只补根节点来源,不理解钉钉字段细节。
+- 前端表单只收集配置,不实现钉钉接口调用细节。
+- 日志复用 `DATASET.API_DATASET`,不新增重复 category。
+
+### 9.2 完全穷尽检查结果
+
+- 正常流程:创建配置、保存 workspace、详情页添加文件、选择文件/文件夹导入、读取正文、同步已导入集合。
+- 参数非法:缺少 `appKey/appSecret/userId/operatorId/workspaceId/rootNodeId`。
+- 无权限:钉钉应用权限不足、文档无访问权限。
+- 外部依赖失败:accessToken 获取失败、目录接口失败、正文接口失败。
+- 兼容迁移:存量配置不变,无 DB 迁移。
+- 前端状态:配置必填错误、导入列表加载、空态、错误态、成功态复用现有导入页。
+
+### 9.3 修订动作与最终边界
+
+`[问题]` 钉钉“知识库”容易和 AI 助理知识管理混淆。
+`影响:` 可能接错 API,导致 FastGPT 无法拉取文档。
+`修订动作:` 明确排除 `api-learnknowledge`、`api-getknowledgelist`。
+`修订后结果:` 只接钉钉文档知识库/知识空间/钉盘文件夹。
+
+`[问题]` 早期方案把钉钉知识库误按钉盘存储字段设计为 `spaceId/dentryId/dentryUuid`。
+`影响:` 接入路径绕远,用户需要填写难找字段,产品体验和排错成本都会变差。
+`修订动作:` 根据实测结果改为 `appKey/appSecret/userId -> operatorId -> workspaceId/rootNodeId -> nodeId`。
+`修订后结果:` 前台只让用户填写 3 个字段,知识库和根节点由系统查询和用户点选。
+
+`[问题]` 用户希望首版只支持在线文档文本。
+`影响:` 如果泛化支持二进制,会引入解析、存储和权限复杂度。
+`修订动作:` 明确二进制文件 Out of Scope。
+`修订后结果:` 首版范围收敛,复用现有 API 文件库文本导入链路。
+
+`[问题]` 原文档缺少 skill 要求的项目画像、完备性确认、命中规范核对和 Package 依赖设计。
+`影响:` 交给开发执行时,规范依据和跨包边界不够清晰,容易把逻辑塞错层。
+`修订动作:` 补充 `1.3 项目画像`、`3.2 命中规范核对结果`、`3.3 完备性确认`、`6.5 Package 与依赖设计`,并扩展方案对比维度。
+`修订后结果:` 两份设计文档符合 `fastgpt-requirement-design` 的关键产物要求。
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
new file mode 100644
index 0000000000..fd53c8496b
--- /dev/null
+++ b/.vscode/extensions.json
@@ -0,0 +1,5 @@
+{
+ "recommendations": [
+ "alexcvzz.vscode-sqlite"
+ ]
+}
\ No newline at end of file
diff --git a/document/content/introduction/guide/knowledge_base/dingtalk_dataset.en.mdx b/document/content/introduction/guide/knowledge_base/dingtalk_dataset.en.mdx
new file mode 100644
index 0000000000..76627b9dc6
--- /dev/null
+++ b/document/content/introduction/guide/knowledge_base/dingtalk_dataset.en.mdx
@@ -0,0 +1,75 @@
+---
+title: DingTalk Knowledge Base
+description: How to connect DingTalk Knowledge Base to FastGPT
+---
+
+FastGPT supports connecting DingTalk Knowledge Base through a DingTalk internal enterprise app. When creating the dataset, enter `App Key`, `App Secret`, and `User ID`. After creation, open the dataset detail page, click `Add file`, and select the DingTalk workspace, online documents, or folders to import.
+
+Only DingTalk online document text is supported. Binary files such as PDF, Word, Excel, and PPT are not supported.
+
+## 1. Create a DingTalk app
+
+
+
+Open the [DingTalk developer app page](https://open-dev.dingtalk.com/fe/app?hash=%23%2Fcorp%2Fapp#/corp/app), then select an internal enterprise app under the target organization.
+
+If you do not have an app yet, create an internal enterprise app from `Application Development`.
+
+## 2. Get the FastGPT fields
+
+
+
+| FastGPT field | Where to get it in DingTalk |
+| --- | --- |
+| `App Key` | Open `Credentials and Basic Information` in the app detail page, then copy `Client ID (formerly AppKey and SuiteKey)`. |
+| `App Secret` | Copy `Client Secret (formerly AppSecret and SuiteSecret)` from the same page. |
+| `User ID` | Ask the organization contact administrator to open DingTalk admin. Path: [oa.dingtalk.com](https://oa.dingtalk.com/) -> `Contacts` -> `Member Management` -> select the operator member -> copy the member `User ID` from the detail page. |
+
+Notes:
+
+- `App Secret` is sensitive. Do not share it publicly.
+- `User ID` is not a phone number, display name, or `unionId`.
+- If the member detail page does not show `User ID`, ask the contact administrator to export the member list from `Contacts`; the exported sheet usually contains member `User ID`.
+- We recommend using a dedicated DingTalk member as the FastGPT sync account and granting it read-only access to the target workspace.
+- Workspaces that this member cannot access will not appear in FastGPT.
+
+## 3. Enable DingTalk app permissions
+
+
+
+Open `Permissions` in the DingTalk app detail page, then search for and enable:
+
+| Permission | Purpose |
+| --- | --- |
+| `qyapi_get_member` | Get the operator ID from `User ID`. |
+| `Wiki.Workspace.Read` | List DingTalk workspaces accessible to the operator. |
+| `Wiki.Node.Read` | List folders and documents under a workspace. |
+| `Storage.File.Read` | Read DingTalk online document content. |
+
+Save and publish the app configuration after enabling permissions. If an error contains `requiredScopes`, enable the permissions listed there.
+
+## 4. Create a DingTalk dataset in FastGPT
+
+1. Open the FastGPT dataset list and click `New`.
+2. Select `DingTalk Knowledge Base` under external document sources.
+3. Enter:
+ - `App Key`
+ - `App Secret`
+ - `User ID`
+4. Confirm creation.
+
+You do not need to select a DingTalk workspace or root directory during creation.
+
+## 5. Add files and sync
+
+After creation:
+
+1. Open the dataset detail page.
+2. Click `Add file`.
+3. Select the target DingTalk workspace.
+4. Select online documents or folders to import.
+5. Confirm the import.
+
+When a folder is selected, FastGPT recursively imports supported online documents under that folder.
+
+When DingTalk document content changes, click `Sync` from the imported file menu. FastGPT will read the latest content and update indexes.
diff --git a/document/content/introduction/guide/knowledge_base/dingtalk_dataset.mdx b/document/content/introduction/guide/knowledge_base/dingtalk_dataset.mdx
new file mode 100644
index 0000000000..5d61fd95ed
--- /dev/null
+++ b/document/content/introduction/guide/knowledge_base/dingtalk_dataset.mdx
@@ -0,0 +1,77 @@
+---
+title: 钉钉知识库
+description: FastGPT 钉钉知识库功能介绍和使用方式
+---
+
+| | |
+| ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
+|  |  |
+
+FastGPT 支持通过钉钉企业内部应用接入钉钉知识库。创建时只需要填写 `App Key`、`App Secret`、`User ID`,创建完成后进入知识库详情页点击`添加文件`,再选择要导入的钉钉知识库、在线文档或文件夹。
+
+当前仅支持钉钉在线文档文本,不支持 PDF、Word、Excel、PPT 等二进制文件。
+
+## 1. 创建钉钉应用
+
+
+
+打开 [钉钉开发者后台应用详情](https://open-dev.dingtalk.com/fe/app?hash=%23%2Fcorp%2Fapp#/corp/app),选择目标企业下的企业内部应用。
+
+如果还没有应用,先进入`应用开发`创建一个企业内部应用。
+
+## 2. 获取 FastGPT 要填写的参数
+
+
+
+| FastGPT 字段 | 钉钉里去哪里拿 |
+| --- | --- |
+| `App Key` | 应用详情页左侧进入`凭证与基础信息`,复制`Client ID(原 AppKey 和 SuiteKey)`。 |
+| `App Secret` | 同一页面复制`Client Secret(原 AppSecret 和 SuiteSecret)`。 |
+| `User ID` | 由企业通讯录管理员进入钉钉管理后台查看。路径:[oa.dingtalk.com](https://oa.dingtalk.com/) -> `通讯录` -> `成员管理` -> 找到作为操作人的成员 -> 点击成员详情,复制该成员的 `User ID`。 |
+
+注意:
+
+- `App Secret` 是密钥,不要公开发送。
+- `User ID` 不是手机号、姓名,也不是 `unionId`。
+- 如果成员详情页没有展示 `User ID`,让通讯录管理员在`通讯录`里导出成员列表,导出的表格中通常包含成员 `User ID`。
+- 建议使用一个专门的钉钉成员作为 FastGPT 同步账号,并给它目标知识库的只读权限。
+- 该成员没有权限访问的钉钉知识库,不会出现在 FastGPT 的添加文件列表里。
+
+## 3. 配置钉钉应用权限
+
+
+
+在钉钉应用详情页左侧进入`权限管理`,搜索并开通以下权限:
+
+| 权限标识 | 用途 |
+| --- | --- |
+| `qyapi_get_member` | 通过 `User ID` 获取接口需要的操作人 ID。 |
+| `Wiki.Workspace.Read` | 获取当前操作人可访问的钉钉知识库列表。 |
+| `Wiki.Node.Read` | 获取知识库下的文件夹和文档列表。 |
+| `Storage.File.Read` | 读取钉钉在线文档正文。 |
+
+权限配置完成后,保存并发布应用配置。若接口报错中出现 `requiredScopes`,按提示补开对应权限。
+
+## 4. 在 FastGPT 中创建钉钉知识库
+
+1. 进入 FastGPT 知识库列表,点击`新建`。
+2. 选择`第三方知识库`下的`钉钉知识库`。
+3. 填写:
+ - `App Key`
+ - `App Secret`
+ - `User ID`
+4. 点击确认创建。
+
+## 5. 添加文件和同步
+
+创建完成后:
+
+1. 进入该知识库详情页。
+2. 右上角点击`添加文件`。
+3. 选择目标钉钉知识库。
+4. 选择要导入的在线文档或文件夹。
+5. 确认导入。
+
+选择文件夹时,FastGPT 会递归导入该文件夹下支持的在线文档。
+
+钉钉文档内容更新后,可在已导入文件的更多菜单中点击`同步`,FastGPT 会重新读取最新正文并更新索引。
diff --git a/document/content/introduction/guide/knowledge_base/meta.en.json b/document/content/introduction/guide/knowledge_base/meta.en.json
index 9ffcbd069a..5caa94bf34 100644
--- a/document/content/introduction/guide/knowledge_base/meta.en.json
+++ b/document/content/introduction/guide/knowledge_base/meta.en.json
@@ -8,6 +8,7 @@
"api_dataset",
"lark_dataset",
"yuque_dataset",
+ "dingtalk_dataset",
"websync",
"third_dataset",
"template"
diff --git a/document/content/introduction/guide/knowledge_base/meta.json b/document/content/introduction/guide/knowledge_base/meta.json
index 5ff5fe1c2e..be6a3b7fa0 100644
--- a/document/content/introduction/guide/knowledge_base/meta.json
+++ b/document/content/introduction/guide/knowledge_base/meta.json
@@ -8,6 +8,7 @@
"api_dataset",
"lark_dataset",
"yuque_dataset",
+ "dingtalk_dataset",
"websync",
"third_dataset",
"template"
diff --git a/document/content/self-host/upgrading/4-15/4150.mdx b/document/content/self-host/upgrading/4-15/4150.mdx
index 0e7d2afabd..ae3384adb3 100644
--- a/document/content/self-host/upgrading/4-15/4150.mdx
+++ b/document/content/self-host/upgrading/4-15/4150.mdx
@@ -8,6 +8,7 @@ description: 'FastGPT V4.15.0 更新说明'
1. 新增循环节点,弃用旧的批量执行。
2. 全局变量输入框支持输入 object 类型数据。
3. 工具调用模式下,如果开启了虚拟机功能,用户对话框上传的文件会直接注入到虚拟机中。
+4. 第三方知识库接入钉钉知识库。
## ⚙️ 优化
diff --git a/document/content/toc.en.mdx b/document/content/toc.en.mdx
index c896a0c672..ccadc0fda0 100644
--- a/document/content/toc.en.mdx
+++ b/document/content/toc.en.mdx
@@ -50,6 +50,7 @@ description: FastGPT Toc
- [/en/introduction/guide/knowledge_base/api_dataset](/en/introduction/guide/knowledge_base/api_dataset)
- [/en/introduction/guide/knowledge_base/collection_tags](/en/introduction/guide/knowledge_base/collection_tags)
- [/en/introduction/guide/knowledge_base/dataset_engine](/en/introduction/guide/knowledge_base/dataset_engine)
+- [/en/introduction/guide/knowledge_base/dingtalk_dataset](/en/introduction/guide/knowledge_base/dingtalk_dataset)
- [/en/introduction/guide/knowledge_base/lark_dataset](/en/introduction/guide/knowledge_base/lark_dataset)
- [/en/introduction/guide/knowledge_base/rag](/en/introduction/guide/knowledge_base/rag)
- [/en/introduction/guide/knowledge_base/template](/en/introduction/guide/knowledge_base/template)
diff --git a/document/content/toc.mdx b/document/content/toc.mdx
index b2465c931a..186f1cdc3a 100644
--- a/document/content/toc.mdx
+++ b/document/content/toc.mdx
@@ -50,6 +50,7 @@ description: FastGPT 文档目录
- [/introduction/guide/knowledge_base/api_dataset](/introduction/guide/knowledge_base/api_dataset)
- [/introduction/guide/knowledge_base/collection_tags](/introduction/guide/knowledge_base/collection_tags)
- [/introduction/guide/knowledge_base/dataset_engine](/introduction/guide/knowledge_base/dataset_engine)
+- [/introduction/guide/knowledge_base/dingtalk_dataset](/introduction/guide/knowledge_base/dingtalk_dataset)
- [/introduction/guide/knowledge_base/lark_dataset](/introduction/guide/knowledge_base/lark_dataset)
- [/introduction/guide/knowledge_base/rag](/introduction/guide/knowledge_base/rag)
- [/introduction/guide/knowledge_base/template](/introduction/guide/knowledge_base/template)
diff --git a/document/data/doc-last-modified.json b/document/data/doc-last-modified.json
index 7454a2a93d..95ecdd4e65 100644
--- a/document/data/doc-last-modified.json
+++ b/document/data/doc-last-modified.json
@@ -252,7 +252,7 @@
"content/self-host/upgrading/4-14/41481.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/4-14/4149.en.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/4-14/4149.mdx": "2026-04-26T21:08:47+08:00",
- "content/self-host/upgrading/4-15/4150.mdx": "2026-04-28T22:44:51+08:00",
+ "content/self-host/upgrading/4-15/4150.mdx": "2026-04-29T14:53:45+08:00",
"content/self-host/upgrading/outdated/40.en.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/outdated/40.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/outdated/41.en.mdx": "2026-04-26T21:08:47+08:00",
diff --git a/document/public/imgs/image-dd1.png b/document/public/imgs/image-dd1.png
new file mode 100644
index 0000000000..50b3e61a9d
Binary files /dev/null and b/document/public/imgs/image-dd1.png differ
diff --git a/document/public/imgs/image-dd2.png b/document/public/imgs/image-dd2.png
new file mode 100644
index 0000000000..f1b2d79f7b
Binary files /dev/null and b/document/public/imgs/image-dd2.png differ
diff --git a/document/public/imgs/image-dd3.png b/document/public/imgs/image-dd3.png
new file mode 100644
index 0000000000..7823170c78
Binary files /dev/null and b/document/public/imgs/image-dd3.png differ
diff --git a/document/public/imgs/image-dd4.png b/document/public/imgs/image-dd4.png
new file mode 100644
index 0000000000..3afa55591b
Binary files /dev/null and b/document/public/imgs/image-dd4.png differ
diff --git a/document/public/imgs/image-dd5.png b/document/public/imgs/image-dd5.png
new file mode 100644
index 0000000000..23fd89fcf6
Binary files /dev/null and b/document/public/imgs/image-dd5.png differ
diff --git a/packages/global/common/system/types/index.ts b/packages/global/common/system/types/index.ts
index af7f534d1d..69ef61e881 100644
--- a/packages/global/common/system/types/index.ts
+++ b/packages/global/common/system/types/index.ts
@@ -67,6 +67,7 @@ export type FastGPTFeConfigsType = {
show_dataset_feishu?: boolean;
show_dataset_yuque?: boolean;
+ show_dataset_dingtalk?: boolean;
show_publish_feishu?: boolean;
show_publish_dingtalk?: boolean;
show_publish_wecom?: boolean;
diff --git a/packages/global/core/dataset/apiDataset/type.ts b/packages/global/core/dataset/apiDataset/type.ts
index 35c70c3127..bee9ee9722 100644
--- a/packages/global/core/dataset/apiDataset/type.ts
+++ b/packages/global/core/dataset/apiDataset/type.ts
@@ -40,12 +40,26 @@ export const YuqueServerSchema = z
.meta({ description: '语雀服务器配置' });
export type YuqueServerType = z.infer;
export type YuqueServer = YuqueServerType;
+export const DingtalkServerSchema = z
+ .object({
+ appKey: z.string(),
+ appSecret: z.string().optional(),
+ userId: z.string(),
+ operatorId: z.string().optional(),
+ workspaceId: z.string().optional(),
+ rootNodeId: z.string().optional(),
+ workspaceName: z.string().optional()
+ })
+ .meta({ description: '钉钉知识库配置' });
+export type DingtalkServerType = z.infer;
+export type DingtalkServer = DingtalkServerType;
export const ApiDatasetServerSchema = z
.object({
apiServer: APIFileServerSchema.optional(),
feishuServer: FeishuServerSchema.optional(),
- yuqueServer: YuqueServerSchema.optional()
+ yuqueServer: YuqueServerSchema.optional(),
+ dingtalkServer: DingtalkServerSchema.optional()
})
.meta({ description: '第三方知识库配置' });
export type ApiDatasetServerType = z.infer;
diff --git a/packages/global/core/dataset/apiDataset/utils.ts b/packages/global/core/dataset/apiDataset/utils.ts
index 3eeb38f443..499b5a5c14 100644
--- a/packages/global/core/dataset/apiDataset/utils.ts
+++ b/packages/global/core/dataset/apiDataset/utils.ts
@@ -3,7 +3,7 @@ import type { ApiDatasetServerType } from './type';
export const filterApiDatasetServerPublicData = (apiDatasetServer?: ApiDatasetServerType) => {
if (!apiDatasetServer) return undefined;
- const { apiServer, yuqueServer, feishuServer } = apiDatasetServer;
+ const { apiServer, yuqueServer, feishuServer, dingtalkServer } = apiDatasetServer;
return {
apiServer: apiServer
@@ -26,6 +26,17 @@ export const filterApiDatasetServerPublicData = (apiDatasetServer?: ApiDatasetSe
appSecret: '',
folderToken: feishuServer.folderToken
}
+ : undefined,
+ dingtalkServer: dingtalkServer
+ ? {
+ appKey: dingtalkServer.appKey,
+ appSecret: '',
+ userId: dingtalkServer.userId,
+ operatorId: dingtalkServer.operatorId,
+ workspaceId: dingtalkServer.workspaceId,
+ rootNodeId: dingtalkServer.rootNodeId,
+ workspaceName: dingtalkServer.workspaceName
+ }
: undefined
};
};
diff --git a/packages/global/core/dataset/constants.ts b/packages/global/core/dataset/constants.ts
index 16abb2e9d6..996da90428 100644
--- a/packages/global/core/dataset/constants.ts
+++ b/packages/global/core/dataset/constants.ts
@@ -9,7 +9,8 @@ export enum DatasetTypeEnum {
apiDataset = 'apiDataset',
feishu = 'feishu',
- yuque = 'yuque'
+ yuque = 'yuque',
+ dingtalk = 'dingtalk'
}
// @ts-ignore
@@ -43,6 +44,13 @@ export const ApiDatasetTypeMap: Record<
label: i18nT('dataset:yuque_dataset'),
collectionLabel: i18nT('common:File'),
courseUrl: '/introduction/guide/knowledge_base/yuque_dataset/'
+ },
+ [DatasetTypeEnum.dingtalk]: {
+ icon: 'core/dataset/dingtalkDatasetOutline',
+ avatar: 'core/dataset/dingtalkDatasetColor',
+ label: i18nT('dataset:dingtalk_dataset'),
+ collectionLabel: i18nT('common:File'),
+ courseUrl: '/introduction/guide/knowledge_base/dingtalk_dataset/'
}
};
export const DatasetTypeMap: Record<
diff --git a/packages/global/core/dataset/type.ts b/packages/global/core/dataset/type.ts
index af8b3472c1..b9e4122142 100644
--- a/packages/global/core/dataset/type.ts
+++ b/packages/global/core/dataset/type.ts
@@ -14,6 +14,7 @@ import {
import {
ApiDatasetServerSchema,
APIFileServerSchema,
+ DingtalkServerSchema,
FeishuServerSchema,
YuqueServerSchema
} from './apiDataset/type';
@@ -107,6 +108,10 @@ export const DatasetSchema = z
yuqueServer: YuqueServerSchema.optional().meta({
description: '语雀服务器配置',
deprecated: true
+ }),
+ dingtalkServer: DingtalkServerSchema.optional().meta({
+ description: '钉钉知识库配置',
+ deprecated: true
})
})
.meta({ description: '知识库' });
diff --git a/packages/global/openapi/core/dataset/api.ts b/packages/global/openapi/core/dataset/api.ts
index fc46cdec17..67616947be 100644
--- a/packages/global/openapi/core/dataset/api.ts
+++ b/packages/global/openapi/core/dataset/api.ts
@@ -51,7 +51,7 @@ export const CreateDatasetBodySchema = z.object({
description: '视觉语言模型名称'
}),
apiDatasetServer: ApiDatasetServerSchema.optional().meta({
- description: '第三方知识库服务器配置(API/飞书/语雀)'
+ description: '第三方知识库服务器配置(API/飞书/语雀/钉钉)'
})
});
@@ -266,7 +266,7 @@ export const UpdateDatasetBodySchema = z.object({
description: '外部读取 URL'
}),
apiDatasetServer: ApiDatasetServerSchema.optional().meta({
- description: '第三方知识库服务器配置(API/飞书/语雀)'
+ description: '第三方知识库服务器配置(API/飞书/语雀/钉钉)'
}),
autoSync: z.boolean().optional().meta({
description: '是否自动同步'
diff --git a/packages/global/openapi/core/dataset/apiDataset/api.ts b/packages/global/openapi/core/dataset/apiDataset/api.ts
index 88f5cd2390..90f9d6a415 100644
--- a/packages/global/openapi/core/dataset/apiDataset/api.ts
+++ b/packages/global/openapi/core/dataset/apiDataset/api.ts
@@ -19,7 +19,7 @@ export const GetApiDatasetCatalogBodySchema = z.object({
description: '父级节点 ID,不传或 null 表示根目录'
}),
apiDatasetServer: ApiDatasetServerSchema.optional().meta({
- description: '第三方知识库服务器配置(API/飞书/语雀)'
+ description: '第三方知识库服务器配置(API/飞书/语雀/钉钉)'
})
});
export type GetApiDatasetCatalogBody = z.infer;
diff --git a/packages/global/test/core/dataset/apiDataset/utils.test.ts b/packages/global/test/core/dataset/apiDataset/utils.test.ts
index 0ecac6ad57..e5c066c2b8 100644
--- a/packages/global/test/core/dataset/apiDataset/utils.test.ts
+++ b/packages/global/test/core/dataset/apiDataset/utils.test.ts
@@ -17,7 +17,8 @@ describe('filterApiDatasetServerPublicData', () => {
expect(result).toEqual({
apiServer: undefined,
yuqueServer: undefined,
- feishuServer: undefined
+ feishuServer: undefined,
+ dingtalkServer: undefined
});
});
@@ -39,7 +40,8 @@ describe('filterApiDatasetServerPublicData', () => {
basePath: '/v1'
},
yuqueServer: undefined,
- feishuServer: undefined
+ feishuServer: undefined,
+ dingtalkServer: undefined
});
});
@@ -61,7 +63,8 @@ describe('filterApiDatasetServerPublicData', () => {
token: '',
basePath: '/docs'
},
- feishuServer: undefined
+ feishuServer: undefined,
+ dingtalkServer: undefined
});
});
@@ -83,7 +86,8 @@ describe('filterApiDatasetServerPublicData', () => {
appId: 'app-123',
appSecret: '',
folderToken: 'folder-token-456'
- }
+ },
+ dingtalkServer: undefined
});
});
@@ -123,7 +127,8 @@ describe('filterApiDatasetServerPublicData', () => {
appId: 'feishu-app',
appSecret: '',
folderToken: 'feishu-folder'
- }
+ },
+ dingtalkServer: undefined
});
});
@@ -161,7 +166,8 @@ describe('filterApiDatasetServerPublicData', () => {
appId: 'app-id',
appSecret: '',
folderToken: 'folder-token'
- }
+ },
+ dingtalkServer: undefined
});
});
@@ -192,6 +198,37 @@ describe('filterApiDatasetServerPublicData', () => {
appId: 'app',
appSecret: '',
folderToken: 'folder'
+ },
+ dingtalkServer: undefined
+ });
+ });
+
+ it('should filter dingtalkServer and clear appSecret', () => {
+ const input: ApiDatasetServerType = {
+ dingtalkServer: {
+ appKey: 'ding-app',
+ appSecret: 'ding-secret',
+ userId: 'user-id',
+ operatorId: 'operator-id',
+ workspaceId: 'workspace-id',
+ rootNodeId: 'root-node-id',
+ workspaceName: 'Workspace'
+ }
+ };
+ const result = filterApiDatasetServerPublicData(input);
+
+ expect(result).toEqual({
+ apiServer: undefined,
+ yuqueServer: undefined,
+ feishuServer: undefined,
+ dingtalkServer: {
+ appKey: 'ding-app',
+ appSecret: '',
+ userId: 'user-id',
+ operatorId: 'operator-id',
+ workspaceId: 'workspace-id',
+ rootNodeId: 'root-node-id',
+ workspaceName: 'Workspace'
}
});
});
diff --git a/packages/service/core/dataset/apiDataset/dingtalkDataset/api.ts b/packages/service/core/dataset/apiDataset/dingtalkDataset/api.ts
new file mode 100644
index 0000000000..0b96e4cdab
--- /dev/null
+++ b/packages/service/core/dataset/apiDataset/dingtalkDataset/api.ts
@@ -0,0 +1,708 @@
+import { createHash } from 'node:crypto';
+import type {
+ APIFileItemType,
+ ApiDatasetDetailResponse,
+ ApiFileReadContentResponseType,
+ DingtalkServerType
+} from '@fastgpt/global/core/dataset/apiDataset/type';
+import type { ParentIdType } from '@fastgpt/global/common/parentFolder/type';
+import type { Method } from 'axios';
+import { axios, createProxyAxios } from '../../../../common/api/axios';
+import { delRedisCache, getRedisCache, setRedisCache } from '../../../../common/redis/cache';
+import { getLogger, LogCategories } from '../../../../common/logger';
+
+type DingtalkAccessTokenResponse = {
+ accessToken: string;
+ expireIn: number;
+};
+
+type DingtalkUserResponse = {
+ errcode?: number;
+ errmsg?: string;
+ result?: {
+ unionid?: string;
+ userid?: string;
+ };
+};
+
+type DingtalkListResponse = {
+ workspaces?: T[];
+ nodes?: T[];
+ items?: T[];
+ list?: T[];
+ nextToken?: string;
+ hasMore?: boolean;
+};
+
+type DingtalkWorkspace = {
+ workspaceId?: string;
+ spaceId?: string;
+ id?: string;
+ name?: string;
+ workspaceName?: string;
+ rootNodeId?: string;
+ rootDentryUuid?: string;
+ rootDentryId?: string;
+ nodeId?: string;
+ dentryUuid?: string;
+ modifiedTime?: number | string;
+ updatedAt?: number | string;
+ createTime?: number | string;
+ createdAt?: number | string;
+};
+
+type DingtalkNode = {
+ nodeId?: string;
+ dentryUuid?: string;
+ dentryId?: string;
+ uuid?: string;
+ id?: string;
+ parentNodeId?: string;
+ parentId?: string;
+ name?: string;
+ title?: string;
+ type?: string;
+ nodeType?: string;
+ docType?: string;
+ fileType?: string;
+ extension?: string;
+ hasChild?: boolean;
+ hasChildren?: boolean;
+ modifiedTime?: number | string;
+ updatedAt?: number | string;
+ createTime?: number | string;
+ createdAt?: number | string;
+};
+
+type DingtalkNodeDetailResponse = {
+ node: DingtalkNode;
+};
+
+type ListAllByNextTokenProps = {
+ requestPage: (params: { nextToken?: string; maxResults: number }) => Promise<{
+ items: T[];
+ nextToken?: string;
+ }>;
+ maxResults?: number;
+};
+
+const dingtalkBaseUrl = process.env.DINGTALK_BASE_URL || 'https://api.dingtalk.com';
+const dingtalkOapiBaseUrl = process.env.DINGTALK_OAPI_BASE_URL || 'https://oapi.dingtalk.com';
+const tokenSafeWindowSeconds = 5 * 60;
+const dingtalkListPageSize = 100;
+const refreshingTokenMap = new Map>();
+const logger = getLogger(LogCategories.MODULE.DATASET.API_DATASET);
+
+const instance = createProxyAxios({
+ baseURL: dingtalkBaseUrl,
+ timeout: 60000
+});
+
+const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
+
+const cleanParams = >(data: T): T => {
+ Object.keys(data).forEach((key) => {
+ if (data[key] === undefined || data[key] === '') {
+ delete data[key];
+ }
+ });
+ return data;
+};
+
+const hashSecret = (secret = '') => createHash('sha256').update(secret).digest('hex').slice(0, 12);
+
+const getDingtalkAccessTokenCacheKey = ({ appKey, appSecret }: DingtalkServerType) =>
+ `dataset:dingtalk:accessToken:${appKey}:${hashSecret(appSecret)}`;
+
+const isRateLimitError = (error: any) => {
+ const status = error?.response?.status;
+ const data = error?.response?.data || error?.data || {};
+ const code = String(data.code ?? data.errcode ?? data.errorCode ?? '');
+ const message = String(data.message ?? data.errmsg ?? error?.message ?? '');
+
+ return (
+ status === 429 ||
+ code === '429' ||
+ code === '88' ||
+ code === '90018' ||
+ /rate|limit|too many|频繁|限流/i.test(message)
+ );
+};
+
+const toSafeError = (error: any) => {
+ if (!error) return { message: '未知错误' };
+ if (typeof error === 'string') return { message: error };
+ if (error?.response?.data) return error.response.data;
+ if (error?.data) return error.data;
+ if (error?.message) return { message: error.message };
+ return error;
+};
+
+const requestDingtalkAccessToken = async ({
+ appKey,
+ appSecret
+}: DingtalkServerType): Promise => {
+ if (!appKey || !appSecret) {
+ return Promise.reject('钉钉应用鉴权失败,请检查 AppKey/AppSecret 和应用权限');
+ }
+
+ try {
+ const { data } = await axios.post(
+ `${dingtalkBaseUrl}/v1.0/oauth2/accessToken`,
+ {
+ appKey,
+ appSecret
+ }
+ );
+
+ if (!data?.accessToken) {
+ return Promise.reject('钉钉应用鉴权失败,请检查 AppKey/AppSecret 和应用权限');
+ }
+
+ return data;
+ } catch (error) {
+ logger.warn('DingTalk accessToken request failed', {
+ provider: 'dingtalk',
+ error: toSafeError(error)
+ });
+
+ if (isRateLimitError(error)) {
+ return Promise.reject('钉钉鉴权接口请求过快,请稍后重试');
+ }
+ return Promise.reject('钉钉应用鉴权失败,请检查 AppKey/AppSecret 和应用权限');
+ }
+};
+
+const getDingtalkAccessToken = async (server: DingtalkServerType) => {
+ const cacheKey = getDingtalkAccessTokenCacheKey(server);
+
+ try {
+ const cachedToken = await getRedisCache(cacheKey);
+ if (cachedToken) return cachedToken;
+ } catch (error) {
+ logger.warn('DingTalk accessToken cache read failed', {
+ provider: 'dingtalk',
+ appKey: server.appKey,
+ error
+ });
+ }
+
+ const refreshing = refreshingTokenMap.get(cacheKey);
+ if (refreshing) return refreshing;
+
+ const promise = (async () => {
+ try {
+ const { accessToken, expireIn } = await requestDingtalkAccessToken(server);
+ const ttl = Math.max(expireIn - tokenSafeWindowSeconds, 60);
+
+ try {
+ await setRedisCache(cacheKey, accessToken, ttl);
+ } catch (error) {
+ logger.warn('DingTalk accessToken cache write failed', {
+ provider: 'dingtalk',
+ appKey: server.appKey,
+ ttl,
+ error
+ });
+ }
+
+ return accessToken;
+ } catch (error) {
+ await delRedisCache(cacheKey).catch(() => undefined);
+ return Promise.reject(error);
+ } finally {
+ refreshingTokenMap.delete(cacheKey);
+ }
+ })();
+
+ refreshingTokenMap.set(cacheKey, promise);
+ return promise;
+};
+
+const request = async ({
+ url,
+ method,
+ accessToken,
+ params,
+ data
+}: {
+ url: string;
+ method: Method;
+ accessToken: string;
+ params?: Record;
+ data?: Record;
+}): Promise => {
+ try {
+ const response = await instance.request({
+ url,
+ method,
+ headers: {
+ 'x-acs-dingtalk-access-token': accessToken,
+ 'content-type': 'application/json'
+ },
+ params: params ? cleanParams(params) : undefined,
+ data: data ? cleanParams(data) : undefined
+ });
+
+ return response.data;
+ } catch (error) {
+ return Promise.reject(error);
+ }
+};
+
+const requestWithRateLimitRetry = async (fn: () => Promise): Promise => {
+ try {
+ return await fn();
+ } catch (error) {
+ if (!isRateLimitError(error)) {
+ return Promise.reject(error);
+ }
+
+ await sleep(1000);
+ return fn().catch((retryError) => {
+ if (isRateLimitError(retryError)) {
+ return Promise.reject('钉钉目录接口请求过快,请稍后重试或减少一次导入的文件夹规模');
+ }
+ return Promise.reject(retryError);
+ });
+ }
+};
+
+const getDingtalkOperatorId = async ({
+ dingtalkServer,
+ accessToken
+}: {
+ dingtalkServer: DingtalkServerType;
+ accessToken: string;
+}) => {
+ try {
+ const { data } = await axios.post(
+ `${dingtalkOapiBaseUrl}/topapi/v2/user/get`,
+ {
+ userid: dingtalkServer.userId
+ },
+ {
+ params: {
+ access_token: accessToken
+ }
+ }
+ );
+
+ if (data.errcode && data.errcode !== 0) {
+ return Promise.reject(data.errmsg || 'DingTalk user get failed');
+ }
+
+ const operatorId = data.result?.unionid || data.result?.userid;
+ if (!operatorId) {
+ return Promise.reject('DingTalk operatorId is empty');
+ }
+
+ return operatorId;
+ } catch (error) {
+ logger.warn('DingTalk operatorId request failed', {
+ provider: 'dingtalk',
+ userId: dingtalkServer.userId,
+ error: toSafeError(error)
+ });
+ return Promise.reject(
+ '获取钉钉用户 unionId 失败,请检查 UserId、通讯录可见范围和 qyapi_get_member 权限'
+ );
+ }
+};
+
+const listAllByNextToken = async ({
+ requestPage,
+ maxResults = dingtalkListPageSize
+}: ListAllByNextTokenProps) => {
+ const allItems: T[] = [];
+ let nextToken: string | undefined;
+
+ do {
+ const page = await requestPage({ nextToken, maxResults });
+ allItems.push(...page.items);
+ nextToken = page.nextToken || undefined;
+ } while (nextToken);
+
+ return allItems;
+};
+
+const pickListItems = (data: DingtalkListResponse, field: 'workspaces' | 'nodes') =>
+ data[field] || data.items || data.list || [];
+
+const parseDingtalkDate = (value?: string | number) => {
+ if (!value) return new Date();
+ if (typeof value === 'number') {
+ return new Date(value < 10000000000 ? value * 1000 : value);
+ }
+ return new Date(value);
+};
+
+const isDingtalkFolderNode = (node: DingtalkNode) => {
+ const typeText = [node.type, node.nodeType, node.docType, node.fileType, node.extension]
+ .filter(Boolean)
+ .join(',')
+ .toLowerCase();
+
+ return (
+ node.hasChild === true ||
+ node.hasChildren === true ||
+ typeText.includes('folder') ||
+ typeText.includes('directory') ||
+ typeText.includes('catalog')
+ );
+};
+
+const isDingtalkOnlineDocNode = (node: DingtalkNode) => {
+ const typeText = [node.type, node.nodeType, node.docType, node.fileType, node.extension]
+ .filter(Boolean)
+ .join(',')
+ .toLowerCase();
+
+ return (
+ typeText.includes('wiki_doc') ||
+ typeText.includes('document') ||
+ typeText.includes('doc') ||
+ typeText.includes('adoc')
+ );
+};
+
+const formatDingtalkWorkspaceItem = ({
+ workspace,
+ operatorId
+}: {
+ workspace: DingtalkWorkspace;
+ operatorId: string;
+}): APIFileItemType | undefined => {
+ const workspaceId = workspace.workspaceId || workspace.spaceId || workspace.id;
+ const rootNodeId =
+ workspace.rootNodeId ||
+ workspace.rootDentryUuid ||
+ workspace.rootDentryId ||
+ workspace.nodeId ||
+ workspace.dentryUuid;
+
+ if (!workspaceId || !rootNodeId) return undefined;
+
+ return {
+ id: rootNodeId,
+ rawId: rootNodeId,
+ parentId: operatorId,
+ name: workspace.workspaceName || workspace.name || workspaceId,
+ type: 'folder',
+ hasChild: true,
+ updateTime: parseDingtalkDate(workspace.updatedAt || workspace.modifiedTime),
+ createTime: parseDingtalkDate(workspace.createdAt || workspace.createTime)
+ };
+};
+
+const formatDingtalkNodeItem = (node: DingtalkNode): APIFileItemType | undefined => {
+ const id = node.nodeId || node.dentryUuid || node.dentryId || node.uuid || node.id;
+ const name = node.name || node.title;
+ if (!id || !name) return undefined;
+
+ const isFolder = isDingtalkFolderNode(node);
+ if (!isFolder && !isDingtalkOnlineDocNode(node)) return undefined;
+
+ return {
+ id,
+ rawId: id,
+ parentId: node.parentNodeId || node.parentId,
+ name,
+ type: isFolder ? 'folder' : 'file',
+ hasChild: isFolder,
+ updateTime: parseDingtalkDate(node.updatedAt || node.modifiedTime),
+ createTime: parseDingtalkDate(node.createdAt || node.createTime)
+ };
+};
+
+const listDingtalkWorkspaces = async ({
+ accessToken,
+ operatorId,
+ searchKey
+}: {
+ accessToken: string;
+ operatorId: string;
+ searchKey?: string;
+}) => {
+ try {
+ const workspaces = await listAllByNextToken({
+ requestPage: async ({ nextToken, maxResults }) => {
+ const data = await requestWithRateLimitRetry(() =>
+ request>({
+ url: '/v2.0/wiki/workspaces',
+ method: 'GET',
+ accessToken,
+ params: {
+ operatorId,
+ maxResults,
+ nextToken
+ }
+ })
+ );
+
+ return {
+ items: pickListItems(data, 'workspaces'),
+ nextToken: data.nextToken
+ };
+ }
+ });
+
+ return workspaces
+ .filter((item) => !searchKey || (item.workspaceName || item.name || '').includes(searchKey))
+ .map((workspace) => formatDingtalkWorkspaceItem({ workspace, operatorId }))
+ .filter(Boolean) as APIFileItemType[];
+ } catch (error) {
+ logger.warn('DingTalk workspace list request failed', {
+ provider: 'dingtalk',
+ userId: operatorId,
+ error: toSafeError(error)
+ });
+ return Promise.reject('读取钉钉知识库列表失败,请检查 Wiki.Workspace.Read 权限');
+ }
+};
+
+const listDingtalkChildren = async ({
+ accessToken,
+ operatorId,
+ parentNodeId,
+ searchKey
+}: {
+ accessToken: string;
+ operatorId: string;
+ parentNodeId: string;
+ searchKey?: string;
+}) => {
+ try {
+ const nodes = await listAllByNextToken({
+ requestPage: async ({ nextToken, maxResults }) => {
+ const data = await requestWithRateLimitRetry(() =>
+ request>({
+ url: '/v2.0/wiki/nodes',
+ method: 'GET',
+ accessToken,
+ params: {
+ operatorId,
+ parentNodeId,
+ maxResults,
+ nextToken
+ }
+ })
+ );
+
+ return {
+ items: pickListItems(data, 'nodes'),
+ nextToken: data.nextToken
+ };
+ }
+ });
+
+ return nodes
+ .filter((item) => !searchKey || (item.title || item.name || '').includes(searchKey))
+ .map(formatDingtalkNodeItem)
+ .filter(Boolean) as APIFileItemType[];
+ } catch (error) {
+ logger.warn('DingTalk node list request failed', {
+ provider: 'dingtalk',
+ parentId: parentNodeId,
+ error: toSafeError(error)
+ });
+
+ if (typeof error === 'string') return Promise.reject(error);
+ return Promise.reject(
+ '读取钉钉目录失败,请检查 rootNodeId、Wiki.Node.Read 权限和知识库访问权限'
+ );
+ }
+};
+
+const collectBlockText = (data: any) => {
+ const textList: string[] = [];
+
+ const walk = (value: any, key?: string) => {
+ if (typeof value === 'string') {
+ if (['text', 'plainText', 'content'].includes(key || '') && value.trim()) {
+ textList.push(value.trim());
+ }
+ return;
+ }
+
+ if (Array.isArray(value)) {
+ value.forEach((item) => walk(item));
+ return;
+ }
+
+ if (value && typeof value === 'object') {
+ Object.entries(value).forEach(([childKey, childValue]) => walk(childValue, childKey));
+ }
+ };
+
+ walk(data);
+
+ return textList.join('\n');
+};
+
+const readDingtalkOnlineDocText = async ({
+ accessToken,
+ operatorId,
+ nodeId
+}: {
+ accessToken: string;
+ operatorId: string;
+ nodeId: string;
+}) => {
+ try {
+ let nextToken: string | undefined;
+ const contents: string[] = [];
+
+ do {
+ const data = await request({
+ url: `/v1.0/doc/suites/documents/${nodeId}/blocks`,
+ method: 'GET',
+ accessToken,
+ params: {
+ operatorId,
+ maxResults: dingtalkListPageSize,
+ nextToken
+ }
+ });
+
+ const rawText = collectBlockText(data);
+ if (rawText) contents.push(rawText);
+ nextToken = data?.nextToken || data?.nextPageToken;
+ } while (nextToken);
+
+ const rawText = contents.join('\n');
+ if (!rawText) {
+ return Promise.reject('当前仅支持钉钉在线文档文本,不支持该文件类型');
+ }
+
+ return rawText;
+ } catch (error) {
+ logger.error('DingTalk document content request failed', {
+ provider: 'dingtalk',
+ apiFileId: nodeId,
+ error: toSafeError(error)
+ });
+
+ if (typeof error === 'string') return Promise.reject(error);
+ return Promise.reject('读取钉钉在线文档失败,请检查文档类型和权限');
+ }
+};
+
+const getDingtalkNodeDetail = async ({
+ accessToken,
+ operatorId,
+ apiFileId
+}: {
+ accessToken: string;
+ operatorId: string;
+ apiFileId: string;
+}) => {
+ const data = await request({
+ url: `/v2.0/wiki/nodes/${apiFileId}`,
+ method: 'GET',
+ accessToken,
+ params: {
+ operatorId
+ }
+ });
+
+ return formatDingtalkNodeItem(data.node);
+};
+
+export const useDingtalkDatasetRequest = ({
+ dingtalkServer
+}: {
+ dingtalkServer: DingtalkServerType;
+}) => {
+ const getTokenAndOperatorId = async () => {
+ const accessToken = await getDingtalkAccessToken(dingtalkServer);
+ const operatorId = await getDingtalkOperatorId({ dingtalkServer, accessToken });
+
+ return {
+ accessToken,
+ operatorId
+ };
+ };
+
+ const listFiles = async ({
+ parentId,
+ searchKey
+ }: {
+ parentId?: ParentIdType;
+ searchKey?: string;
+ }): Promise => {
+ const { accessToken, operatorId } = await getTokenAndOperatorId();
+
+ if (!dingtalkServer.rootNodeId && !parentId) {
+ return listDingtalkWorkspaces({ accessToken, operatorId, searchKey });
+ }
+
+ const parentNodeId = String(parentId || dingtalkServer.rootNodeId || '');
+ if (!parentNodeId) return [];
+
+ return listDingtalkChildren({
+ accessToken,
+ operatorId,
+ parentNodeId,
+ searchKey
+ });
+ };
+
+ const getFileContent = async ({
+ apiFileId
+ }: {
+ apiFileId: string;
+ }): Promise => {
+ const { accessToken, operatorId } = await getTokenAndOperatorId();
+ const [rawText, detail] = await Promise.all([
+ readDingtalkOnlineDocText({ accessToken, operatorId, nodeId: apiFileId }),
+ getDingtalkNodeDetail({ accessToken, operatorId, apiFileId }).catch(() => undefined)
+ ]);
+
+ return {
+ title: detail?.name,
+ rawText
+ };
+ };
+
+ const getFilePreviewUrl = async ({ apiFileId }: { apiFileId: string }) => {
+ return `https://alidocs.dingtalk.com/i/nodes/${apiFileId}`;
+ };
+
+ const getFileDetail = async ({
+ apiFileId
+ }: {
+ apiFileId: string;
+ }): Promise => {
+ if (apiFileId === dingtalkServer.rootNodeId && dingtalkServer.workspaceName) {
+ return {
+ id: apiFileId,
+ rawId: apiFileId,
+ parentId: null,
+ name: dingtalkServer.workspaceName,
+ type: 'folder',
+ hasChild: true,
+ updateTime: new Date(),
+ createTime: new Date()
+ };
+ }
+
+ const { accessToken, operatorId } = await getTokenAndOperatorId();
+ const detail = await getDingtalkNodeDetail({ accessToken, operatorId, apiFileId });
+ if (!detail) return Promise.reject('文件不存在');
+
+ return detail;
+ };
+
+ const getFileRawId = (fileId: string) => {
+ return fileId;
+ };
+
+ return {
+ getFileContent,
+ listFiles,
+ getFilePreviewUrl,
+ getFileDetail,
+ getFileRawId
+ };
+};
diff --git a/packages/service/core/dataset/apiDataset/index.ts b/packages/service/core/dataset/apiDataset/index.ts
index 46c5e87e0f..a47c892471 100644
--- a/packages/service/core/dataset/apiDataset/index.ts
+++ b/packages/service/core/dataset/apiDataset/index.ts
@@ -1,10 +1,11 @@
import { useApiDatasetRequest } from './custom/api';
import { useYuqueDatasetRequest } from './yuqueDataset/api';
import { useFeishuDatasetRequest } from './feishuDataset/api';
+import { useDingtalkDatasetRequest } from './dingtalkDataset/api';
import type { ApiDatasetServerType } from '@fastgpt/global/core/dataset/apiDataset/type';
export const getApiDatasetRequest = async (apiDatasetServer?: ApiDatasetServerType) => {
- const { apiServer, yuqueServer, feishuServer } = apiDatasetServer || {};
+ const { apiServer, yuqueServer, feishuServer, dingtalkServer } = apiDatasetServer || {};
if (apiServer) {
return useApiDatasetRequest({ apiServer });
@@ -15,5 +16,8 @@ export const getApiDatasetRequest = async (apiDatasetServer?: ApiDatasetServerTy
if (feishuServer) {
return useFeishuDatasetRequest({ feishuServer });
}
+ if (dingtalkServer) {
+ return useDingtalkDatasetRequest({ dingtalkServer });
+ }
return Promise.reject('Can not find api dataset server');
};
diff --git a/packages/service/support/user/audit/util.ts b/packages/service/support/user/audit/util.ts
index 496ed4b18c..510f47fc56 100644
--- a/packages/service/support/user/audit/util.ts
+++ b/packages/service/support/user/audit/util.ts
@@ -43,6 +43,7 @@ export function getI18nDatasetType(type: DatasetTypeEnum | string): string {
if (type === DatasetTypeEnum.apiDataset) return i18nT('account_team:dataset.api_file');
if (type === DatasetTypeEnum.feishu) return i18nT('account_team:dataset.feishu_dataset');
if (type === DatasetTypeEnum.yuque) return i18nT('account_team:dataset.yuque_dataset');
+ if (type === DatasetTypeEnum.dingtalk) return i18nT('account_team:dataset.dingtalk_dataset');
return i18nT('common:UnKnow');
}
diff --git a/packages/service/test/core/dataset/apiDataset/dingtalkDataset/api.test.ts b/packages/service/test/core/dataset/apiDataset/dingtalkDataset/api.test.ts
new file mode 100644
index 0000000000..a3bf26ef65
--- /dev/null
+++ b/packages/service/test/core/dataset/apiDataset/dingtalkDataset/api.test.ts
@@ -0,0 +1,1060 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+const { mockAxiosPost, mockRequest, mockGetRedisCache, mockSetRedisCache, mockDelRedisCache } =
+ vi.hoisted(() => ({
+ mockAxiosPost: vi.fn(),
+ mockRequest: vi.fn(),
+ mockGetRedisCache: vi.fn(),
+ mockSetRedisCache: vi.fn(),
+ mockDelRedisCache: vi.fn()
+ }));
+
+vi.mock('../../../../../common/api/axios', () => ({
+ axios: {
+ post: mockAxiosPost
+ },
+ createProxyAxios: vi.fn(() => ({
+ request: mockRequest
+ }))
+}));
+
+vi.mock('../../../../../common/redis/cache', () => ({
+ getRedisCache: mockGetRedisCache,
+ setRedisCache: mockSetRedisCache,
+ delRedisCache: mockDelRedisCache
+}));
+
+vi.mock('../../../../../common/logger', () => ({
+ getLogger: () => ({
+ warn: vi.fn(),
+ error: vi.fn()
+ }),
+ LogCategories: {
+ MODULE: {
+ DATASET: {
+ API_DATASET: 'dataset.apiDataset'
+ }
+ }
+ }
+}));
+
+import { useDingtalkDatasetRequest } from '@fastgpt/service/core/dataset/apiDataset/dingtalkDataset/api';
+
+const server = {
+ appKey: 'ding-app',
+ appSecret: 'ding-secret',
+ userId: 'user-id'
+};
+
+const mockTokenAndUser = () => {
+ mockAxiosPost.mockImplementation((url: string) => {
+ if (url.includes('/v1.0/oauth2/accessToken')) {
+ return Promise.resolve({
+ data: {
+ accessToken: 'access-token',
+ expireIn: 7200
+ }
+ });
+ }
+
+ return Promise.resolve({
+ data: {
+ errcode: 0,
+ result: {
+ unionid: 'operator-id'
+ }
+ }
+ });
+ });
+};
+
+describe('useDingtalkDatasetRequest', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockGetRedisCache.mockResolvedValue(null);
+ mockSetRedisCache.mockResolvedValue(undefined);
+ mockDelRedisCache.mockResolvedValue(undefined);
+ mockTokenAndUser();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('should list workspaces with nextToken and cache accessToken', async () => {
+ mockRequest
+ .mockResolvedValueOnce({
+ data: {
+ workspaces: [
+ {
+ workspaceId: 'workspace-1',
+ workspaceName: 'Workspace 1',
+ rootNodeId: 'root-1'
+ }
+ ],
+ nextToken: 'next-1'
+ }
+ })
+ .mockResolvedValueOnce({
+ data: {
+ workspaces: [
+ {
+ workspaceId: 'workspace-2',
+ name: 'Workspace 2',
+ rootNodeId: 'root-2'
+ }
+ ]
+ }
+ });
+
+ const request = useDingtalkDatasetRequest({ dingtalkServer: server });
+ const result = await request.listFiles({});
+
+ expect(result).toEqual([
+ expect.objectContaining({
+ id: 'root-1',
+ rawId: 'root-1',
+ parentId: 'operator-id',
+ name: 'Workspace 1',
+ type: 'folder'
+ }),
+ expect.objectContaining({
+ id: 'root-2',
+ rawId: 'root-2',
+ parentId: 'operator-id',
+ name: 'Workspace 2',
+ type: 'folder'
+ })
+ ]);
+ expect(mockSetRedisCache).toHaveBeenCalledWith(
+ expect.stringContaining('dataset:dingtalk:accessToken:ding-app:'),
+ 'access-token',
+ 6900
+ );
+ expect(mockRequest).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({
+ params: expect.objectContaining({
+ nextToken: 'next-1'
+ })
+ })
+ );
+ });
+
+ it('should list folder children with nextToken and filter unsupported nodes', async () => {
+ mockGetRedisCache.mockResolvedValue('cached-token');
+ mockRequest
+ .mockResolvedValueOnce({
+ data: {
+ nodes: [
+ {
+ nodeId: 'folder-1',
+ title: 'Folder',
+ nodeType: 'folder',
+ hasChildren: true
+ },
+ {
+ nodeId: 'doc-1',
+ title: 'Doc',
+ docType: 'wiki_doc'
+ }
+ ],
+ nextToken: 'next-node'
+ }
+ })
+ .mockResolvedValueOnce({
+ data: {
+ nodes: [
+ {
+ nodeId: 'pdf-1',
+ title: 'PDF',
+ fileType: 'pdf'
+ }
+ ]
+ }
+ });
+
+ const request = useDingtalkDatasetRequest({
+ dingtalkServer: {
+ ...server,
+ operatorId: 'operator-id',
+ rootNodeId: 'root-1'
+ }
+ });
+ const result = await request.listFiles({});
+
+ expect(result).toHaveLength(2);
+ expect(result[0]).toMatchObject({
+ id: 'folder-1',
+ type: 'folder',
+ hasChild: true
+ });
+ expect(result[1]).toMatchObject({
+ id: 'doc-1',
+ type: 'file',
+ hasChild: false
+ });
+ expect(mockAxiosPost).toHaveBeenCalledWith(
+ expect.stringContaining('/topapi/v2/user/get'),
+ expect.objectContaining({
+ userid: 'user-id'
+ }),
+ expect.any(Object)
+ );
+ });
+
+ it('should read online document blocks content', async () => {
+ mockGetRedisCache.mockResolvedValue('cached-token');
+ mockRequest.mockImplementation(({ url }: { url: string }) => {
+ if (url.includes('/blocks')) {
+ return Promise.resolve({
+ data: {
+ blocks: [
+ {
+ paragraph: {
+ elements: [{ text: 'hello' }, { text: 'world' }]
+ }
+ }
+ ]
+ }
+ });
+ }
+
+ return Promise.resolve({
+ data: {
+ node: {
+ nodeId: 'doc-1',
+ title: 'Doc',
+ docType: 'wiki_doc'
+ }
+ }
+ });
+ });
+
+ const request = useDingtalkDatasetRequest({
+ dingtalkServer: {
+ ...server,
+ operatorId: 'operator-id',
+ rootNodeId: 'root-1'
+ }
+ });
+ const result = await request.getFileContent({ apiFileId: 'doc-1' });
+
+ expect(result).toEqual({
+ title: 'Doc',
+ rawText: 'hello\nworld'
+ });
+ });
+
+ it('should read title from nested node detail response', async () => {
+ mockGetRedisCache.mockResolvedValue('cached-token');
+ mockRequest.mockImplementation(({ url }: { url: string }) => {
+ if (url.includes('/blocks')) {
+ return Promise.resolve({
+ data: {
+ result: {
+ data: [{ paragraph: { text: 'hello' } }]
+ },
+ success: true
+ }
+ });
+ }
+
+ return Promise.resolve({
+ data: {
+ node: {
+ nodeId: 'doc-1',
+ name: 'Doc From Node',
+ type: 'FILE',
+ extension: 'adoc'
+ }
+ }
+ });
+ });
+
+ const request = useDingtalkDatasetRequest({
+ dingtalkServer: {
+ ...server,
+ operatorId: 'operator-id',
+ rootNodeId: 'root-1'
+ }
+ });
+ const result = await request.getFileContent({ apiFileId: 'doc-1' });
+
+ expect(result).toEqual({
+ title: 'Doc From Node',
+ rawText: 'hello'
+ });
+ });
+
+ it('should reuse the same token refresh promise for concurrent requests', async () => {
+ let resolveToken: (value: any) => void = () => undefined;
+ const tokenPromise = new Promise((resolve) => {
+ resolveToken = resolve;
+ });
+ mockAxiosPost.mockImplementation((url: string) => {
+ if (url.includes('/v1.0/oauth2/accessToken')) {
+ return tokenPromise;
+ }
+
+ return Promise.resolve({
+ data: {
+ errcode: 0,
+ result: {
+ unionid: 'operator-id'
+ }
+ }
+ });
+ });
+ mockRequest.mockResolvedValue({
+ data: {
+ workspaces: []
+ }
+ });
+
+ const request = useDingtalkDatasetRequest({ dingtalkServer: server });
+ const p1 = request.listFiles({});
+ const p2 = request.listFiles({});
+
+ resolveToken({
+ data: {
+ accessToken: 'access-token',
+ expireIn: 7200
+ }
+ });
+
+ await Promise.all([p1, p2]);
+
+ const tokenCalls = mockAxiosPost.mock.calls.filter(([url]) =>
+ String(url).includes('/v1.0/oauth2/accessToken')
+ );
+ expect(tokenCalls).toHaveLength(1);
+ });
+
+ it('should return workspace root detail, preview url and raw id without requesting dingtalk', async () => {
+ const request = useDingtalkDatasetRequest({
+ dingtalkServer: {
+ ...server,
+ operatorId: 'operator-id',
+ workspaceId: 'workspace-1',
+ rootNodeId: 'root-1',
+ workspaceName: 'Workspace 1'
+ }
+ });
+
+ const detail = await request.getFileDetail({ apiFileId: 'root-1' });
+ const previewUrl = await request.getFilePreviewUrl({ apiFileId: 'doc-1' });
+
+ expect(detail).toMatchObject({
+ id: 'root-1',
+ rawId: 'root-1',
+ name: 'Workspace 1',
+ type: 'folder'
+ });
+ expect(previewUrl).toBe('https://alidocs.dingtalk.com/i/nodes/doc-1');
+ expect(request.getFileRawId('doc-1')).toBe('doc-1');
+ expect(mockRequest).not.toHaveBeenCalled();
+ });
+
+ it('should fetch node detail for non-root file', async () => {
+ mockGetRedisCache.mockResolvedValue('cached-token');
+ mockRequest.mockResolvedValueOnce({
+ data: {
+ node: {
+ nodeId: 'doc-1',
+ title: 'Doc',
+ docType: 'wiki_doc',
+ parentNodeId: 'root-1',
+ updatedAt: 1700000000000
+ }
+ }
+ });
+
+ const request = useDingtalkDatasetRequest({
+ dingtalkServer: {
+ ...server,
+ operatorId: 'operator-id',
+ rootNodeId: 'root-1'
+ }
+ });
+
+ const detail = await request.getFileDetail({ apiFileId: 'doc-1' });
+
+ expect(detail).toMatchObject({
+ id: 'doc-1',
+ rawId: 'doc-1',
+ parentId: 'root-1',
+ name: 'Doc',
+ type: 'file'
+ });
+ });
+
+ it('should filter workspaces by searchKey and ignore invalid workspace payloads', async () => {
+ mockRequest.mockResolvedValueOnce({
+ data: {
+ items: [
+ {
+ workspaceId: 'workspace-1',
+ workspaceName: 'Product Space',
+ rootDentryUuid: 'root-1',
+ createTime: '2024-01-01T00:00:00.000Z'
+ },
+ {
+ workspaceId: 'workspace-2',
+ workspaceName: 'Sales Space',
+ rootNodeId: 'root-2'
+ },
+ {
+ workspaceId: 'workspace-3',
+ workspaceName: 'Invalid Space'
+ }
+ ]
+ }
+ });
+
+ const request = useDingtalkDatasetRequest({ dingtalkServer: server });
+ const result = await request.listFiles({ searchKey: 'Product' });
+
+ expect(result).toEqual([
+ expect.objectContaining({
+ id: 'root-1',
+ rawId: 'root-1',
+ name: 'Product Space'
+ })
+ ]);
+ });
+
+ it('should map workspace and node fallback fields', async () => {
+ mockGetRedisCache.mockResolvedValue('cached-token');
+ mockRequest
+ .mockResolvedValueOnce({
+ data: {
+ workspaces: [
+ {
+ id: 'workspace-id',
+ rootDentryId: 'root-dentry-id'
+ },
+ {
+ spaceId: 'space-id',
+ dentryUuid: 'root-dentry-uuid',
+ name: 'Space Name'
+ }
+ ]
+ }
+ })
+ .mockResolvedValueOnce({
+ data: {
+ nodes: [
+ {
+ dentryUuid: 'node-dentry-uuid',
+ name: 'Directory',
+ type: 'directory'
+ },
+ {
+ dentryId: 'node-dentry-id',
+ title: 'Document',
+ extension: 'doc'
+ },
+ {
+ uuid: 'node-uuid',
+ nodeType: 'catalog'
+ },
+ {
+ id: 'node-id',
+ name: 'Unknown',
+ fileType: 'unknown'
+ }
+ ]
+ }
+ });
+
+ const request = useDingtalkDatasetRequest({
+ dingtalkServer: {
+ ...server,
+ operatorId: 'operator-id'
+ }
+ });
+
+ const workspaces = await request.listFiles({});
+ const nodes = await request.listFiles({ parentId: 'root-dentry-id', searchKey: 'Doc' });
+
+ expect(workspaces).toEqual([
+ expect.objectContaining({
+ id: 'root-dentry-id',
+ rawId: 'root-dentry-id',
+ name: 'workspace-id'
+ }),
+ expect.objectContaining({
+ id: 'root-dentry-uuid',
+ rawId: 'root-dentry-uuid',
+ name: 'Space Name'
+ })
+ ]);
+ expect(nodes).toEqual([
+ expect.objectContaining({
+ id: 'node-dentry-id',
+ name: 'Document',
+ type: 'file'
+ })
+ ]);
+ });
+
+ it('should return empty list when parentId resolves to empty string', async () => {
+ mockGetRedisCache.mockResolvedValue('cached-token');
+
+ const request = useDingtalkDatasetRequest({
+ dingtalkServer: {
+ ...server,
+ operatorId: 'operator-id',
+ rootNodeId: ''
+ }
+ });
+ const result = await request.listFiles({ parentId: [] as any });
+
+ expect(result).toEqual([]);
+ expect(mockRequest).not.toHaveBeenCalled();
+ });
+
+ it('should retry rate limited node list once', async () => {
+ vi.useFakeTimers();
+ mockGetRedisCache.mockResolvedValue('cached-token');
+ mockRequest
+ .mockRejectedValueOnce({
+ response: {
+ status: 429,
+ data: {
+ message: 'too many requests'
+ }
+ }
+ })
+ .mockResolvedValueOnce({
+ data: {
+ list: [
+ {
+ nodeId: 'doc-1',
+ name: 'Doc',
+ fileType: 'adoc'
+ }
+ ]
+ }
+ });
+
+ const request = useDingtalkDatasetRequest({
+ dingtalkServer: {
+ ...server,
+ operatorId: 'operator-id',
+ rootNodeId: 'root-1'
+ }
+ });
+ const resultPromise = request.listFiles({});
+ await vi.advanceTimersByTimeAsync(1000);
+ const result = await resultPromise;
+
+ expect(result).toEqual([
+ expect.objectContaining({
+ id: 'doc-1',
+ type: 'file'
+ })
+ ]);
+ expect(mockRequest).toHaveBeenCalledTimes(2);
+ });
+
+ it('should reject with readable message when node list keeps being rate limited', async () => {
+ vi.useFakeTimers();
+ mockGetRedisCache.mockResolvedValue('cached-token');
+ mockRequest.mockRejectedValue({
+ response: {
+ status: 429,
+ data: {
+ code: 429
+ }
+ }
+ });
+
+ const request = useDingtalkDatasetRequest({
+ dingtalkServer: {
+ ...server,
+ operatorId: 'operator-id',
+ rootNodeId: 'root-1'
+ }
+ });
+ const resultPromise = expect(request.listFiles({})).rejects.toBe(
+ '钉钉目录接口请求过快,请稍后重试或减少一次导入的文件夹规模'
+ );
+ await vi.advanceTimersByTimeAsync(1000);
+ await resultPromise;
+ });
+
+ it('should reject permission message when node list retry fails without rate limit', async () => {
+ vi.useFakeTimers();
+ mockGetRedisCache.mockResolvedValue('cached-token');
+ mockRequest
+ .mockRejectedValueOnce({
+ response: {
+ status: 429
+ }
+ })
+ .mockRejectedValueOnce(new Error('network down'));
+
+ const request = useDingtalkDatasetRequest({
+ dingtalkServer: {
+ ...server,
+ operatorId: 'operator-id',
+ rootNodeId: 'root-1'
+ }
+ });
+ const resultPromise = expect(request.listFiles({})).rejects.toBe(
+ '读取钉钉目录失败,请检查 rootNodeId、Wiki.Node.Read 权限和知识库访问权限'
+ );
+ await vi.advanceTimersByTimeAsync(1000);
+ await resultPromise;
+ });
+
+ it('should read paged document blocks content', async () => {
+ mockGetRedisCache.mockResolvedValue('cached-token');
+ mockRequest.mockImplementation(
+ ({ url, params }: { url: string; params?: Record }) => {
+ if (url.includes('/blocks') && !params?.nextToken) {
+ return Promise.resolve({
+ data: {
+ blocks: [{ text: 'page 1' }],
+ nextPageToken: 'next-page'
+ }
+ });
+ }
+
+ if (url.includes('/blocks') && params?.nextToken === 'next-page') {
+ return Promise.resolve({
+ data: {
+ blocks: [{ plainText: 'page 2' }]
+ }
+ });
+ }
+
+ return Promise.resolve({
+ data: {
+ node: {
+ nodeId: 'doc-1',
+ name: 'Doc',
+ docType: 'document'
+ }
+ }
+ });
+ }
+ );
+
+ const request = useDingtalkDatasetRequest({
+ dingtalkServer: {
+ ...server,
+ operatorId: 'operator-id',
+ rootNodeId: 'root-1'
+ }
+ });
+ const result = await request.getFileContent({ apiFileId: 'doc-1' });
+
+ expect(result).toEqual({
+ title: 'Doc',
+ rawText: 'page 1\npage 2'
+ });
+ expect(mockRequest.mock.calls.some(([args]) => args.params?.nextToken === 'next-page')).toBe(
+ true
+ );
+ });
+
+ it('should reject unsupported document content without leaking raw response', async () => {
+ mockGetRedisCache.mockResolvedValue('cached-token');
+ mockRequest
+ .mockResolvedValueOnce({
+ data: {
+ blocks: [{ image: { url: 'https://example.com/a.png' } }]
+ }
+ })
+ .mockResolvedValueOnce({
+ data: {
+ nodeId: 'doc-1',
+ name: 'Doc',
+ docType: 'wiki_doc'
+ }
+ });
+
+ const request = useDingtalkDatasetRequest({
+ dingtalkServer: {
+ ...server,
+ operatorId: 'operator-id',
+ rootNodeId: 'root-1'
+ }
+ });
+
+ await expect(request.getFileContent({ apiFileId: 'doc-1' })).rejects.toBe(
+ '当前仅支持钉钉在线文档文本,不支持该文件类型'
+ );
+ });
+
+ it('should reject when app secret is missing', async () => {
+ const request = useDingtalkDatasetRequest({
+ dingtalkServer: {
+ ...server,
+ appSecret: ''
+ }
+ });
+
+ await expect(request.listFiles({})).rejects.toBe(
+ '钉钉应用鉴权失败,请检查 AppKey/AppSecret 和应用权限'
+ );
+ expect(mockRequest).not.toHaveBeenCalled();
+ });
+
+ it('should continue when token cache read and write fail', async () => {
+ mockGetRedisCache.mockRejectedValueOnce(new Error('redis read failed'));
+ mockSetRedisCache.mockRejectedValueOnce(new Error('redis write failed'));
+ mockRequest.mockResolvedValueOnce({
+ data: {
+ workspaces: []
+ }
+ });
+
+ const request = useDingtalkDatasetRequest({ dingtalkServer: server });
+ const result = await request.listFiles({});
+
+ expect(result).toEqual([]);
+ expect(mockAxiosPost).toHaveBeenCalled();
+ expect(mockSetRedisCache).toHaveBeenCalled();
+ });
+
+ it('should reject readable message when accessToken request is rate limited', async () => {
+ mockAxiosPost.mockRejectedValueOnce({
+ response: {
+ status: 429,
+ data: {
+ errmsg: 'rate limit'
+ }
+ }
+ });
+
+ const request = useDingtalkDatasetRequest({ dingtalkServer: server });
+
+ await expect(request.listFiles({})).rejects.toBe('钉钉鉴权接口请求过快,请稍后重试');
+ expect(mockDelRedisCache).toHaveBeenCalled();
+ });
+
+ it('should reject readable message when accessToken response is empty', async () => {
+ mockAxiosPost.mockResolvedValueOnce({
+ data: {}
+ });
+
+ const request = useDingtalkDatasetRequest({ dingtalkServer: server });
+
+ await expect(request.listFiles({})).rejects.toBe(
+ '钉钉应用鉴权失败,请检查 AppKey/AppSecret 和应用权限'
+ );
+ });
+
+ it('should reject readable message when accessToken request fails without rate limit', async () => {
+ mockAxiosPost.mockRejectedValueOnce({
+ response: {
+ data: {
+ errmsg: 'invalid app secret'
+ }
+ }
+ });
+
+ const request = useDingtalkDatasetRequest({ dingtalkServer: server });
+
+ await expect(request.listFiles({})).rejects.toBe(
+ '钉钉应用鉴权失败,请检查 AppKey/AppSecret 和应用权限'
+ );
+ });
+
+ it('should reject readable message when workspace list fails', async () => {
+ mockGetRedisCache.mockResolvedValue('cached-token');
+ mockRequest.mockRejectedValueOnce(new Error('wiki permission denied'));
+
+ const request = useDingtalkDatasetRequest({
+ dingtalkServer: {
+ ...server,
+ operatorId: 'operator-id'
+ }
+ });
+
+ await expect(request.listFiles({})).rejects.toBe(
+ '读取钉钉知识库列表失败,请检查 Wiki.Workspace.Read 权限'
+ );
+ });
+
+ it('should reject readable message when document blocks request fails', async () => {
+ mockGetRedisCache.mockResolvedValue('cached-token');
+ mockRequest.mockImplementation(({ url }: { url: string }) => {
+ if (url.includes('/blocks')) {
+ return Promise.reject(new Error('doc permission denied'));
+ }
+
+ return Promise.resolve({
+ data: {
+ nodeId: 'doc-1',
+ name: 'Doc',
+ docType: 'wiki_doc'
+ }
+ });
+ });
+
+ const request = useDingtalkDatasetRequest({
+ dingtalkServer: {
+ ...server,
+ operatorId: 'operator-id',
+ rootNodeId: 'root-1'
+ }
+ });
+
+ await expect(request.getFileContent({ apiFileId: 'doc-1' })).rejects.toBe(
+ '读取钉钉在线文档失败,请检查文档类型和权限'
+ );
+ });
+
+ it('should keep string error when document blocks request rejects with string', async () => {
+ mockGetRedisCache.mockResolvedValue('cached-token');
+ mockRequest.mockImplementation(({ url }: { url: string }) => {
+ if (url.includes('/blocks')) {
+ return Promise.reject('raw string error');
+ }
+
+ return Promise.resolve({
+ data: {
+ nodeId: 'doc-1',
+ name: 'Doc',
+ docType: 'wiki_doc'
+ }
+ });
+ });
+
+ const request = useDingtalkDatasetRequest({
+ dingtalkServer: {
+ ...server,
+ operatorId: 'operator-id',
+ rootNodeId: 'root-1'
+ }
+ });
+
+ await expect(request.getFileContent({ apiFileId: 'doc-1' })).rejects.toBe('raw string error');
+ });
+
+ it('should ignore blank block text and unsupported primitive values', async () => {
+ mockGetRedisCache.mockResolvedValue('cached-token');
+ mockRequest.mockImplementation(({ url }: { url: string }) => {
+ if (url.includes('/blocks')) {
+ return Promise.resolve({
+ data: {
+ blocks: [{ text: ' ' }, 123, false]
+ }
+ });
+ }
+
+ return Promise.resolve({
+ data: {
+ nodeId: 'doc-1',
+ name: 'Doc',
+ docType: 'wiki_doc'
+ }
+ });
+ });
+
+ const request = useDingtalkDatasetRequest({
+ dingtalkServer: {
+ ...server,
+ operatorId: 'operator-id',
+ rootNodeId: 'root-1'
+ }
+ });
+
+ await expect(request.getFileContent({ apiFileId: 'doc-1' })).rejects.toBe(
+ '当前仅支持钉钉在线文档文本,不支持该文件类型'
+ );
+ });
+
+ it('should reject when node detail cannot be formatted', async () => {
+ mockGetRedisCache.mockResolvedValue('cached-token');
+ mockRequest.mockResolvedValueOnce({
+ data: {
+ node: {
+ nodeId: 'unknown-1',
+ title: 'Unknown',
+ fileType: 'pdf'
+ }
+ }
+ });
+
+ const request = useDingtalkDatasetRequest({
+ dingtalkServer: {
+ ...server,
+ operatorId: 'operator-id',
+ rootNodeId: 'root-1'
+ }
+ });
+
+ await expect(request.getFileDetail({ apiFileId: 'unknown-1' })).rejects.toBe('文件不存在');
+ });
+
+ it('should use root node id as rawId when workspace id is empty', async () => {
+ const request = useDingtalkDatasetRequest({
+ dingtalkServer: {
+ ...server,
+ operatorId: 'operator-id',
+ rootNodeId: 'root-1',
+ workspaceName: 'Workspace 1'
+ }
+ });
+
+ const detail = await request.getFileDetail({ apiFileId: 'root-1' });
+
+ expect(detail.rawId).toBe('root-1');
+ });
+
+ it('should reject when operatorId cannot be resolved from userId', async () => {
+ mockAxiosPost.mockImplementation((url: string) => {
+ if (url.includes('/v1.0/oauth2/accessToken')) {
+ return Promise.resolve({
+ data: {
+ accessToken: 'access-token',
+ expireIn: 7200
+ }
+ });
+ }
+
+ return Promise.resolve({
+ data: {
+ errcode: 500,
+ errmsg: 'no permission'
+ }
+ });
+ });
+
+ const request = useDingtalkDatasetRequest({ dingtalkServer: server });
+
+ await expect(request.listFiles({})).rejects.toBe('no permission');
+ });
+
+ it('should fallback to userid when unionid is empty', async () => {
+ mockAxiosPost.mockImplementation((url: string) => {
+ if (url.includes('/v1.0/oauth2/accessToken')) {
+ return Promise.resolve({
+ data: {
+ accessToken: 'access-token',
+ expireIn: 7200
+ }
+ });
+ }
+
+ return Promise.resolve({
+ data: {
+ errcode: 0,
+ result: {
+ userid: 'operator-user-id'
+ }
+ }
+ });
+ });
+ mockRequest.mockResolvedValueOnce({
+ data: {
+ workspaces: []
+ }
+ });
+
+ const request = useDingtalkDatasetRequest({ dingtalkServer: server });
+ await request.listFiles({});
+
+ expect(mockRequest).toHaveBeenCalledWith(
+ expect.objectContaining({
+ params: expect.objectContaining({
+ operatorId: 'operator-user-id'
+ })
+ })
+ );
+ });
+
+ it('should ignore cached operatorId and resolve operator from current userId', async () => {
+ mockAxiosPost.mockImplementation((url: string) => {
+ if (url.includes('/v1.0/oauth2/accessToken')) {
+ return Promise.resolve({
+ data: {
+ accessToken: 'access-token',
+ expireIn: 7200
+ }
+ });
+ }
+
+ return Promise.resolve({
+ data: {
+ errcode: 0,
+ result: {
+ unionid: 'fresh-operator-id'
+ }
+ }
+ });
+ });
+ mockRequest.mockResolvedValueOnce({
+ data: {
+ workspaces: []
+ }
+ });
+
+ const request = useDingtalkDatasetRequest({
+ dingtalkServer: {
+ ...server,
+ operatorId: 'stale-operator-id'
+ }
+ });
+ await request.listFiles({});
+
+ expect(mockRequest).toHaveBeenCalledWith(
+ expect.objectContaining({
+ params: expect.objectContaining({
+ operatorId: 'fresh-operator-id'
+ })
+ })
+ );
+ });
+
+ it('should reject when operator response has no operator id', async () => {
+ mockAxiosPost.mockImplementation((url: string) => {
+ if (url.includes('/v1.0/oauth2/accessToken')) {
+ return Promise.resolve({
+ data: {
+ accessToken: 'access-token',
+ expireIn: 7200
+ }
+ });
+ }
+
+ return Promise.resolve({
+ data: {
+ errcode: 0,
+ result: {}
+ }
+ });
+ });
+
+ const request = useDingtalkDatasetRequest({ dingtalkServer: server });
+
+ await expect(request.listFiles({})).rejects.toBe('DingTalk operatorId is empty');
+ });
+
+ it('should return user permission message when user api request fails', async () => {
+ mockAxiosPost.mockImplementation((url: string) => {
+ if (url.includes('/v1.0/oauth2/accessToken')) {
+ return Promise.resolve({
+ data: {
+ accessToken: 'access-token',
+ expireIn: 7200
+ }
+ });
+ }
+
+ return Promise.reject(new Error('network error'));
+ });
+
+ const request = useDingtalkDatasetRequest({ dingtalkServer: server });
+
+ await expect(request.listFiles({})).rejects.toBe(
+ '获取钉钉用户 unionId 失败,请检查 UserId、通讯录可见范围和 qyapi_get_member 权限'
+ );
+ });
+});
diff --git a/packages/web/components/common/Icon/constants.ts b/packages/web/components/common/Icon/constants.ts
index edef49626b..11727f7d97 100644
--- a/packages/web/components/common/Icon/constants.ts
+++ b/packages/web/components/common/Icon/constants.ts
@@ -218,6 +218,10 @@ export const iconPaths = {
import('./icons/core/dataset/externalDatasetColor.svg'),
'core/dataset/externalDatasetOutline': () =>
import('./icons/core/dataset/externalDatasetOutline.svg'),
+ 'core/dataset/dingtalkDatasetColor': () =>
+ import('./icons/core/dataset/dingtalkDatasetColor.svg'),
+ 'core/dataset/dingtalkDatasetOutline': () =>
+ import('./icons/core/dataset/dingtalkDatasetOutline.svg'),
'core/dataset/feishuDatasetColor': () => import('./icons/core/dataset/feishuDatasetColor.svg'),
'core/dataset/feishuDatasetOutline': () =>
import('./icons/core/dataset/feishuDatasetOutline.svg'),
diff --git a/packages/web/components/common/Icon/icons/core/dataset/dingtalkDatasetColor.svg b/packages/web/components/common/Icon/icons/core/dataset/dingtalkDatasetColor.svg
new file mode 100644
index 0000000000..e036e3a2b8
--- /dev/null
+++ b/packages/web/components/common/Icon/icons/core/dataset/dingtalkDatasetColor.svg
@@ -0,0 +1 @@
+
diff --git a/packages/web/components/common/Icon/icons/core/dataset/dingtalkDatasetOutline.svg b/packages/web/components/common/Icon/icons/core/dataset/dingtalkDatasetOutline.svg
new file mode 100644
index 0000000000..e036e3a2b8
--- /dev/null
+++ b/packages/web/components/common/Icon/icons/core/dataset/dingtalkDatasetOutline.svg
@@ -0,0 +1 @@
+
diff --git a/packages/web/i18n/en/account_team.json b/packages/web/i18n/en/account_team.json
index 03fb8ed123..f5ab1205a9 100644
--- a/packages/web/i18n/en/account_team.json
+++ b/packages/web/i18n/en/account_team.json
@@ -61,6 +61,7 @@
"dataset.common_dataset": "Dataset",
"dataset.external_file": "External File",
"dataset.feishu_dataset": "Feishu Cloud Documentation",
+ "dataset.dingtalk_dataset": "DingTalk Knowledge Base",
"dataset.folder_dataset": "Folder",
"dataset.website_dataset": "Website Sync",
"dataset.yuque_dataset": "Yuque Knowledge Base",
diff --git a/packages/web/i18n/en/dataset.json b/packages/web/i18n/en/dataset.json
index 4f20d5fbbf..951db2be14 100644
--- a/packages/web/i18n/en/dataset.json
+++ b/packages/web/i18n/en/dataset.json
@@ -72,13 +72,16 @@
"external_file": "External file library",
"external_file_dataset_desc": "You can use external file library to build a knowledge library through the API",
"external_id": "File Reading ID",
- "external_other_dataset_desc": "Customize API, Feishu, Yuque and other external documents as knowledge bases",
+ "external_other_dataset_desc": "Customize API, Feishu, Yuque, DingTalk and other external documents as knowledge bases",
"external_read_url": "External Preview URL",
"external_read_url_tip": "Configure the reading URL of your file library for user authentication. Use the {{fileId}} variable to refer to the external file ID.",
"external_url": "File Access URL",
"feishu_dataset": "Feishu Dataset",
"feishu_dataset_config": "Feishu Dataset Config",
"feishu_dataset_desc": "Can build a dataset using Feishu documents by configuring permissions, without secondary storage",
+ "dingtalk_dataset": "DingTalk Knowledge Base",
+ "dingtalk_dataset_config": "Configure DingTalk Knowledge Base",
+ "dingtalk_dataset_desc": "Build knowledge base using DingTalk online documents by configuring permissions, without secondary storage",
"file_list": "File list",
"file_model_function_tip": "Used for QA generation, auto-indexing, and other AI-powered data processing.",
"filename": "Filename",
diff --git a/packages/web/i18n/zh-CN/account_team.json b/packages/web/i18n/zh-CN/account_team.json
index 51b27fa182..0ca241d62e 100644
--- a/packages/web/i18n/zh-CN/account_team.json
+++ b/packages/web/i18n/zh-CN/account_team.json
@@ -61,6 +61,7 @@
"dataset.common_dataset": "知识库",
"dataset.external_file": "外部文件",
"dataset.feishu_dataset": "飞书云文档",
+ "dataset.dingtalk_dataset": "钉钉知识库",
"dataset.folder_dataset": "文件夹",
"dataset.website_dataset": "网站同步",
"dataset.yuque_dataset": "语雀知识库",
diff --git a/packages/web/i18n/zh-CN/dataset.json b/packages/web/i18n/zh-CN/dataset.json
index fa8581f9f5..60162f9961 100644
--- a/packages/web/i18n/zh-CN/dataset.json
+++ b/packages/web/i18n/zh-CN/dataset.json
@@ -72,13 +72,16 @@
"external_file": "外部文件库",
"external_file_dataset_desc": "可以通过 API,使用外部文件库构建知识库",
"external_id": "文件阅读 ID",
- "external_other_dataset_desc": "自定义API、飞书、语雀等外部文档作为知识库",
+ "external_other_dataset_desc": "自定义API、飞书、语雀、钉钉等外部文档作为知识库",
"external_read_url": "外部预览地址",
"external_read_url_tip": "可以配置你文件库的阅读地址。便于对用户进行阅读鉴权操作。目前可以使用 {{fileId}} 变量来指代外部文件 ID。",
"external_url": "文件访问 URL",
"feishu_dataset": "飞书知识库",
"feishu_dataset_config": "配置飞书知识库",
"feishu_dataset_desc": "可通过配置飞书文档权限,使用飞书文档构建知识库,文档不会进行二次存储",
+ "dingtalk_dataset": "钉钉知识库",
+ "dingtalk_dataset_config": "配置钉钉知识库",
+ "dingtalk_dataset_desc": "可通过配置钉钉知识库权限,使用钉钉在线文档构建知识库,文档不会进行二次存储",
"file_list": "文件列表",
"file_model_function_tip": "用于增强索引和 QA 生成",
"filename": "文件名",
diff --git a/packages/web/i18n/zh-Hant/account_team.json b/packages/web/i18n/zh-Hant/account_team.json
index 2d87143017..ff015f53da 100644
--- a/packages/web/i18n/zh-Hant/account_team.json
+++ b/packages/web/i18n/zh-Hant/account_team.json
@@ -60,6 +60,7 @@
"dataset.common_dataset": "知識庫",
"dataset.external_file": "外部文件",
"dataset.feishu_dataset": "飛書云文檔",
+ "dataset.dingtalk_dataset": "釘釘知識庫",
"dataset.folder_dataset": "資料夾",
"dataset.website_dataset": "網站同步",
"dataset.yuque_dataset": "語雀知識庫",
diff --git a/packages/web/i18n/zh-Hant/dataset.json b/packages/web/i18n/zh-Hant/dataset.json
index 65c1db9f80..9ad8be397b 100644
--- a/packages/web/i18n/zh-Hant/dataset.json
+++ b/packages/web/i18n/zh-Hant/dataset.json
@@ -72,13 +72,16 @@
"external_file": "外部檔案庫",
"external_file_dataset_desc": "可以通過 API,使用外部文件庫構建知識庫",
"external_id": "檔案讀取識別碼",
- "external_other_dataset_desc": "自定義API、飛書、語雀等外部文檔作為知識庫",
+ "external_other_dataset_desc": "自定義API、飛書、語雀、釘釘等外部文檔作為知識庫",
"external_read_url": "外部預覽網址",
"external_read_url_tip": "可以設定您檔案庫的讀取網址,方便對使用者進行讀取權限驗證。目前可使用 {{fileId}} 變數來代表外部檔案識別碼。",
"external_url": "檔案存取網址",
"feishu_dataset": "飛書知識庫",
"feishu_dataset_config": "設定飛書知識庫",
"feishu_dataset_desc": "可透過設定飛書文件權限,使用飛書文件建構知識庫,文件不會進行二次儲存",
+ "dingtalk_dataset": "釘釘知識庫",
+ "dingtalk_dataset_config": "設定釘釘知識庫",
+ "dingtalk_dataset_desc": "可透過設定釘釘知識庫權限,使用釘釘線上文件建構知識庫,文件不會進行二次儲存",
"file_list": "文件列表",
"file_model_function_tip": "用於增強索引和問答生成",
"filename": "檔案名稱",
diff --git a/pro b/pro
index 1a56dfc0d3..23b39240c4 160000
--- a/pro
+++ b/pro
@@ -1 +1 @@
-Subproject commit 1a56dfc0d3c2fc1dc3e4a2f23ad1847926c804a8
+Subproject commit 23b39240c48e8b75132258e8e0549f91fc871020
diff --git a/projects/app/src/pageComponents/dataset/ApiDatasetForm.tsx b/projects/app/src/pageComponents/dataset/ApiDatasetForm.tsx
index b9b2be6601..e8baa6f7de 100644
--- a/projects/app/src/pageComponents/dataset/ApiDatasetForm.tsx
+++ b/projects/app/src/pageComponents/dataset/ApiDatasetForm.tsx
@@ -267,6 +267,43 @@ const ApiDatasetForm = ({
{renderDirectoryModal()}
>
)}
+ {type === DatasetTypeEnum.dingtalk && (
+ <>
+
+
+ App Key
+
+
+
+
+
+ App Secret
+
+
+
+
+
+ User ID
+
+
+
+ >
+ )}
>
);
};
diff --git a/projects/app/src/pageComponents/dataset/detail/Import/diffSource/APIDataset.tsx b/projects/app/src/pageComponents/dataset/detail/Import/diffSource/APIDataset.tsx
index 2ff40b390e..087a28e6cd 100644
--- a/projects/app/src/pageComponents/dataset/detail/Import/diffSource/APIDataset.tsx
+++ b/projects/app/src/pageComponents/dataset/detail/Import/diffSource/APIDataset.tsx
@@ -69,12 +69,7 @@ const CustomAPIFileInput = () => {
});
},
{
- refreshDeps: [
- datasetDetail._id,
- datasetDetail.apiDatasetServer?.apiServer,
- parent,
- searchKey
- ],
+ refreshDeps: [datasetDetail._id, datasetDetail.apiDatasetServer, parent, searchKey],
throttleWait: 500,
manual: false
}
diff --git a/projects/app/src/pageComponents/dataset/detail/Info/index.tsx b/projects/app/src/pageComponents/dataset/detail/Info/index.tsx
index ab6bf704b1..075676aa6d 100644
--- a/projects/app/src/pageComponents/dataset/detail/Info/index.tsx
+++ b/projects/app/src/pageComponents/dataset/detail/Info/index.tsx
@@ -374,6 +374,37 @@ const Info = ({ datasetId }: { datasetId: string }) => {
>
)}
+
+ {datasetDetail.type === DatasetTypeEnum.dingtalk && (
+ <>
+
+
+
+ {t('dataset:dingtalk_dataset_config')}
+
+
+ setEditedAPIDataset({
+ id: datasetDetail._id,
+ apiDatasetServer: datasetDetail.apiDatasetServer
+ })
+ }
+ />
+
+
+ {datasetDetail.apiDatasetServer?.dingtalkServer?.workspaceName ||
+ datasetDetail.apiDatasetServer?.dingtalkServer?.workspaceId}
+
+
+ {datasetDetail.apiDatasetServer?.dingtalkServer?.userId}
+
+
+ >
+ )}
{datasetDetail.permission.hasManagePer && (
diff --git a/projects/app/src/pageComponents/dataset/list/CreateModal.tsx b/projects/app/src/pageComponents/dataset/list/CreateModal.tsx
index e1a0e2331d..7741734eff 100644
--- a/projects/app/src/pageComponents/dataset/list/CreateModal.tsx
+++ b/projects/app/src/pageComponents/dataset/list/CreateModal.tsx
@@ -27,7 +27,8 @@ export type CreateDatasetType =
| DatasetTypeEnum.apiDataset
| DatasetTypeEnum.websiteDataset
| DatasetTypeEnum.feishu
- | DatasetTypeEnum.yuque;
+ | DatasetTypeEnum.yuque
+ | DatasetTypeEnum.dingtalk;
const CreateModal = ({
onClose,
diff --git a/projects/app/src/pages/api/core/dataset/collection/create/apiCollectionV2.ts b/projects/app/src/pages/api/core/dataset/collection/create/apiCollectionV2.ts
index 4f985d80df..63b9ac58a3 100644
--- a/projects/app/src/pages/api/core/dataset/collection/create/apiCollectionV2.ts
+++ b/projects/app/src/pages/api/core/dataset/collection/create/apiCollectionV2.ts
@@ -75,7 +75,8 @@ export const createApiDatasetCollection = async ({
const startId =
dataset.apiDatasetServer?.apiServer?.basePath ||
dataset.apiDatasetServer?.yuqueServer?.basePath ||
- dataset.apiDatasetServer?.feishuServer?.folderToken;
+ dataset.apiDatasetServer?.feishuServer?.folderToken ||
+ dataset.apiDatasetServer?.dingtalkServer?.rootNodeId;
// check if the directory is selected
const isDirectorySelected = apiFiles.length === 1 && apiFiles[0].id === RootCollectionId;
diff --git a/projects/app/src/pages/dataset/list/index.tsx b/projects/app/src/pages/dataset/list/index.tsx
index 4c898a048f..b41241fe2e 100644
--- a/projects/app/src/pages/dataset/list/index.tsx
+++ b/projects/app/src/pages/dataset/list/index.tsx
@@ -196,6 +196,16 @@ const Dataset = () => {
onClick: () => onSelectDatasetType(DatasetTypeEnum.yuque)
}
]
+ : []),
+ ...(feConfigs?.show_dataset_dingtalk !== false
+ ? [
+ {
+ icon: 'core/dataset/dingtalkDatasetColor',
+ label: t('dataset:dingtalk_dataset'),
+ description: t('dataset:dingtalk_dataset_desc'),
+ onClick: () => onSelectDatasetType(DatasetTypeEnum.dingtalk)
+ }
+ ]
: [])
]
}
diff --git a/projects/app/test/api/core/dataset/collection/create/apiCollectionV2.test.ts b/projects/app/test/api/core/dataset/collection/create/apiCollectionV2.test.ts
new file mode 100644
index 0000000000..1ccde654b8
--- /dev/null
+++ b/projects/app/test/api/core/dataset/collection/create/apiCollectionV2.test.ts
@@ -0,0 +1,120 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { RootCollectionId } from '@fastgpt/global/core/dataset/collection/constants';
+import { DatasetTypeEnum } from '@fastgpt/global/core/dataset/constants';
+
+const { mockListFiles, mockCreateCollectionAndInsertData, mockCreateOneCollection } = vi.hoisted(
+ () => ({
+ mockListFiles: vi.fn(),
+ mockCreateCollectionAndInsertData: vi.fn(),
+ mockCreateOneCollection: vi.fn()
+ })
+);
+
+vi.mock('@/service/middleware/entry', () => ({
+ NextAPI: (handler: any) => handler
+}));
+
+vi.mock('@fastgpt/service/support/permission/dataset/auth', () => ({
+ authDataset: vi.fn()
+}));
+
+vi.mock('@fastgpt/service/support/permission/teamLimit', () => ({
+ checkDatasetIndexLimit: vi.fn()
+}));
+
+vi.mock('@fastgpt/service/core/dataset/collection/schema', () => ({
+ MongoDatasetCollection: {
+ find: vi.fn(() => ({
+ lean: vi.fn().mockResolvedValue([])
+ }))
+ }
+}));
+
+vi.mock('@fastgpt/service/core/dataset/apiDataset', () => ({
+ getApiDatasetRequest: vi.fn(async () => ({
+ listFiles: mockListFiles
+ }))
+}));
+
+vi.mock('@fastgpt/service/common/mongo/sessionRun', () => ({
+ mongoSessionRun: vi.fn((fn: any) => fn('session'))
+}));
+
+vi.mock('@fastgpt/service/core/dataset/collection/controller', () => ({
+ createCollectionAndInsertData: mockCreateCollectionAndInsertData,
+ createOneCollection: mockCreateOneCollection
+}));
+
+import { createApiDatasetCollection } from '@/pages/api/core/dataset/collection/create/apiCollectionV2';
+import { getApiDatasetRequest } from '@fastgpt/service/core/dataset/apiDataset';
+
+describe('createApiDatasetCollection', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should use dingtalk rootNodeId when importing root folder recursively', async () => {
+ mockListFiles.mockResolvedValueOnce([
+ {
+ id: 'doc-1',
+ rawId: 'doc-1',
+ parentId: 'dingtalk-root',
+ name: 'Doc 1',
+ type: 'file',
+ hasChild: false,
+ updateTime: new Date(),
+ createTime: new Date()
+ }
+ ]);
+
+ const dataset = {
+ _id: 'dataset-id',
+ teamId: 'team-id',
+ type: DatasetTypeEnum.dingtalk,
+ apiDatasetServer: {
+ dingtalkServer: {
+ appKey: 'ding-app',
+ userId: 'user-id',
+ rootNodeId: 'dingtalk-root'
+ }
+ },
+ permission: {}
+ } as any;
+
+ await createApiDatasetCollection({
+ datasetId: 'dataset-id',
+ apiFiles: [
+ {
+ id: RootCollectionId,
+ rawId: RootCollectionId,
+ parentId: '',
+ name: 'ROOT_FOLDER',
+ type: 'folder',
+ hasChild: true,
+ updateTime: new Date(),
+ createTime: new Date()
+ }
+ ],
+ customPdfParse: false,
+ trainingType: 'chunk',
+ teamId: 'team-id',
+ tmbId: 'tmb-id',
+ dataset
+ } as any);
+
+ expect(getApiDatasetRequest).toHaveBeenCalledWith(dataset.apiDatasetServer);
+ expect(mockListFiles).toHaveBeenCalledWith({
+ parentId: 'dingtalk-root'
+ });
+ expect(mockCreateCollectionAndInsertData).toHaveBeenCalledWith(
+ expect.objectContaining({
+ dataset,
+ createCollectionParams: expect.objectContaining({
+ apiFileId: 'doc-1',
+ type: 'apiFile'
+ }),
+ session: 'session'
+ })
+ );
+ });
+});