This commit is contained in:
YeYuheng
2026-04-29 20:39:24 +08:00
committed by GitHub
parent 34842fc16e
commit ea8abf47a1
45 changed files with 3418 additions and 26 deletions
@@ -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: 修改全局类型与常量<br/>目的: 注册 dingtalk 类型和 dingtalkServer schema"] --> B["Step 2: 新增钉钉 Provider<br/>目的: 封装钉钉 token、用户、workspace、node、blocks API"]
B --> C["Step 3: 修改 Provider 分发入口<br/>目的: getApiDatasetRequest 识别 dingtalkServer"]
C --> D["Step 4: 修改导入递归入口<br/>目的: apiCollectionV2 从 rootNodeId 拉取文件树"]
D --> E["Step 5: 修改前端配置流程<br/>目的: 用户填写 3 个字段并选择 workspace"]
E --> F["Step 6: 复用添加文件导入页<br/>目的: 配置后选择要导入的文件/文件夹"]
F --> G["Step 7: 复用 apiFile 同步链路<br/>目的: 导入后可手动同步钉钉最新正文"]
G --> H["Step 8: 修改详情与脱敏<br/>目的: 展示配置摘要且隐藏 appSecret"]
H --> I["Step 9: 修改 i18n、图标、审计<br/>目的: 补齐用户可见展示"]
I --> J["Step 10: 新增产品文档<br/>目的: 指导用户配置字段、钉钉权限、添加文件和同步"]
J --> K["Step 11: 新增测试与验证<br/>目的: 覆盖成功、权限失败、导入、同步和回滚风险"]
```
### 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/rootNodeIdnode 阶段保存 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<string, Promise<string>>` 合并进程内并发请求,缓存落 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<string, Promise<string>>();
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 <T>({
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` 写的路径与当前代码不一致,可以顺手修正文档,但不要扩大到无关章节重写。
@@ -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
- 企业内部应用 accessTokenhttps://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` 写 Rediskey 使用 `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: 扩展全局类型与常量<br/>目的: 让 dingtalk 成为合法知识库类型"] --> B["Step 2: 实现钉钉 Provider<br/>目的: 适配 accessToken、operatorId、workspace、node、blocks"]
B --> C["Step 3: 接入 Provider 分发<br/>目的: 让现有 apiDataset 调用链识别 dingtalkServer"]
C --> D["Step 4: 补导入根节点<br/>目的: apiCollectionV2 从 rootNodeId 递归导入"]
D --> E["Step 5: 补前端创建与配置<br/>目的: 用户只填 AppKey/AppSecret/UserId 并选择 workspace"]
E --> F["Step 6: 复用添加文件导入页<br/>目的: 进入详情页后选择要导入的文件/文件夹"]
F --> G["Step 7: 复用 apiFile 同步链路<br/>目的: 已导入集合可拉取钉钉最新正文"]
G --> H["Step 8: 补详情展示与脱敏<br/>目的: 展示连接信息且不泄漏 AppSecret"]
H --> I["Step 9: 补 i18n、图标、审计文案<br/>目的: 页面、列表和审计展示完整"]
I --> J["Step 10: 补用户文档与中英文文档<br/>目的: 给用户说明字段来源、权限、添加文件和同步"]
J --> K["Step 11: 补测试与验证<br/>目的: 覆盖 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` 的关键产物要求。
+5
View File
@@ -0,0 +1,5 @@
{
"recommendations": [
"alexcvzz.vscode-sqlite"
]
}
@@ -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
![Create a DingTalk app](../../../../public/imgs/image-dd3.png)
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
![Get App Key and App Secret](../../../../public/imgs/image-dd4.png)
| 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
![Enable DingTalk app permissions](../../../../public/imgs/image-dd5.png)
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.
@@ -0,0 +1,77 @@
---
title: 钉钉知识库
description: FastGPT 钉钉知识库功能介绍和使用方式
---
| | |
| ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| ![alt text](../../../../public/imgs/image-dd1.png) | ![alt text](../../../../public/imgs/image-dd2.png) |
FastGPT 支持通过钉钉企业内部应用接入钉钉知识库。创建时只需要填写 `App Key`、`App Secret`、`User ID`,创建完成后进入知识库详情页点击`添加文件`,再选择要导入的钉钉知识库、在线文档或文件夹。
当前仅支持钉钉在线文档文本,不支持 PDF、Word、Excel、PPT 等二进制文件。
## 1. 创建钉钉应用
![创建钉钉应用](../../../../public/imgs/image-dd3.png)
打开 [钉钉开发者后台应用详情](https://open-dev.dingtalk.com/fe/app?hash=%23%2Fcorp%2Fapp#/corp/app),选择目标企业下的企业内部应用。
如果还没有应用,先进入`应用开发`创建一个企业内部应用。
## 2. 获取 FastGPT 要填写的参数
![获取 App Key 和 App Secret](../../../../public/imgs/image-dd4.png)
| 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. 配置钉钉应用权限
![配置钉钉应用权限](../../../../public/imgs/image-dd5.png)
在钉钉应用详情页左侧进入`权限管理`,搜索并开通以下权限:
| 权限标识 | 用途 |
| --- | --- |
| `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 会重新读取最新正文并更新索引。
@@ -8,6 +8,7 @@
"api_dataset",
"lark_dataset",
"yuque_dataset",
"dingtalk_dataset",
"websync",
"third_dataset",
"template"
@@ -8,6 +8,7 @@
"api_dataset",
"lark_dataset",
"yuque_dataset",
"dingtalk_dataset",
"websync",
"third_dataset",
"template"
@@ -8,6 +8,7 @@ description: 'FastGPT V4.15.0 更新说明'
1. 新增循环节点,弃用旧的批量执行。
2. 全局变量输入框支持输入 object 类型数据。
3. 工具调用模式下,如果开启了虚拟机功能,用户对话框上传的文件会直接注入到虚拟机中。
4. 第三方知识库接入钉钉知识库。
## ⚙️ 优化
+1
View File
@@ -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)
+1
View File
@@ -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)
+1 -1
View File
@@ -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",
Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 512 KiB

@@ -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;
@@ -40,12 +40,26 @@ export const YuqueServerSchema = z
.meta({ description: '语雀服务器配置' });
export type YuqueServerType = z.infer<typeof YuqueServerSchema>;
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<typeof DingtalkServerSchema>;
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<typeof ApiDatasetServerSchema>;
@@ -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
};
};
+9 -1
View File
@@ -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<
+5
View File
@@ -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: '知识库' });
+2 -2
View File
@@ -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: '是否自动同步'
@@ -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<typeof GetApiDatasetCatalogBodySchema>;
@@ -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'
}
});
});
@@ -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<T> = {
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<T> = {
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<string, Promise<string>>();
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 = <T extends Record<string, any>>(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<DingtalkAccessTokenResponse> => {
if (!appKey || !appSecret) {
return Promise.reject('钉钉应用鉴权失败,请检查 AppKey/AppSecret 和应用权限');
}
try {
const { data } = await axios.post<DingtalkAccessTokenResponse>(
`${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 <T>({
url,
method,
accessToken,
params,
data
}: {
url: string;
method: Method;
accessToken: string;
params?: Record<string, any>;
data?: Record<string, any>;
}): Promise<T> => {
try {
const response = await instance.request<T>({
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 <T>(fn: () => Promise<T>): Promise<T> => {
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<DingtalkUserResponse>(
`${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 <T>({
requestPage,
maxResults = dingtalkListPageSize
}: ListAllByNextTokenProps<T>) => {
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 = <T>(data: DingtalkListResponse<T>, 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<DingtalkWorkspace>({
requestPage: async ({ nextToken, maxResults }) => {
const data = await requestWithRateLimitRetry(() =>
request<DingtalkListResponse<DingtalkWorkspace>>({
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<DingtalkNode>({
requestPage: async ({ nextToken, maxResults }) => {
const data = await requestWithRateLimitRetry(() =>
request<DingtalkListResponse<DingtalkNode>>({
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<any>({
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<DingtalkNodeDetailResponse>({
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<APIFileItemType[]> => {
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<ApiFileReadContentResponseType> => {
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<ApiDatasetDetailResponse> => {
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
};
};
@@ -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');
};
@@ -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');
}
File diff suppressed because it is too large Load Diff
@@ -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'),
@@ -0,0 +1 @@
<svg t="1777364982098" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6500" width="200" height="200"><path d="M512.003 79C272.855 79 79 272.855 79 512.003 79 751.145 272.855 945 512.003 945 751.145 945 945 751.145 945 512.003 945 272.855 751.145 79 512.003 79z m200.075 375.014c-0.867 3.764-3.117 9.347-6.234 16.012h0.087l-0.347 0.648c-18.183 38.86-65.631 115.108-65.631 115.108l-0.215-0.52-13.856 24.147h66.8L565.063 779l29.002-115.368h-52.598l18.27-76.29c-14.76 3.55-32.253 8.436-52.945 15.1 0 0-27.967 16.36-80.607-31.5 0 0-35.501-31.29-14.891-39.078 8.744-3.33 42.466-7.573 69.004-11.122 35.93-4.845 57.965-7.441 57.965-7.441s-110.607 1.643-136.841-2.468c-26.237-4.11-59.525-47.905-66.626-86.377 0 0-10.953-21.117 23.595-11.122 34.547 10 177.535 38.95 177.535 38.95s-185.933-56.992-198.36-70.929c-12.381-13.846-36.406-75.902-33.289-113.981 0 0 1.343-9.521 11.127-6.926 0 0 137.49 62.75 231.475 97.152 94.028 34.403 175.76 51.885 165.2 96.414z" fill="#3AA2EB" p-id="6501"></path></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1 @@
<svg t="1777364982098" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6500" width="200" height="200"><path d="M512.003 79C272.855 79 79 272.855 79 512.003 79 751.145 272.855 945 512.003 945 751.145 945 945 751.145 945 512.003 945 272.855 751.145 79 512.003 79z m200.075 375.014c-0.867 3.764-3.117 9.347-6.234 16.012h0.087l-0.347 0.648c-18.183 38.86-65.631 115.108-65.631 115.108l-0.215-0.52-13.856 24.147h66.8L565.063 779l29.002-115.368h-52.598l18.27-76.29c-14.76 3.55-32.253 8.436-52.945 15.1 0 0-27.967 16.36-80.607-31.5 0 0-35.501-31.29-14.891-39.078 8.744-3.33 42.466-7.573 69.004-11.122 35.93-4.845 57.965-7.441 57.965-7.441s-110.607 1.643-136.841-2.468c-26.237-4.11-59.525-47.905-66.626-86.377 0 0-10.953-21.117 23.595-11.122 34.547 10 177.535 38.95 177.535 38.95s-185.933-56.992-198.36-70.929c-12.381-13.846-36.406-75.902-33.289-113.981 0 0 1.343-9.521 11.127-6.926 0 0 137.49 62.75 231.475 97.152 94.028 34.403 175.76 51.885 165.2 96.414z" fill="#3AA2EB" p-id="6501"></path></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -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",
+4 -1
View File
@@ -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",
@@ -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": "语雀知识库",
+4 -1
View File
@@ -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": "文件名",
@@ -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": "語雀知識庫",
+4 -1
View File
@@ -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": "檔案名稱",
+1 -1
Submodule pro updated: 1a56dfc0d3...23b39240c4
@@ -267,6 +267,43 @@ const ApiDatasetForm = ({
{renderDirectoryModal()}
</>
)}
{type === DatasetTypeEnum.dingtalk && (
<>
<Flex mt={6} alignItems={'center'}>
<FormLabel flex={['', '0 0 110px']} fontSize={'sm'} required>
App Key
</FormLabel>
<Input
bg={'myWhite.600'}
placeholder={'App Key'}
maxLength={200}
{...register('apiDatasetServer.dingtalkServer.appKey', { required: true })}
/>
</Flex>
<Flex mt={6} alignItems={'center'}>
<FormLabel flex={['', '0 0 110px']} fontSize={'sm'} required>
App Secret
</FormLabel>
<Input
bg={'myWhite.600'}
placeholder={'App Secret'}
maxLength={200}
{...register('apiDatasetServer.dingtalkServer.appSecret', { required: true })}
/>
</Flex>
<Flex mt={6} alignItems={'center'}>
<FormLabel flex={['', '0 0 110px']} fontSize={'sm'} required>
User ID
</FormLabel>
<Input
bg={'myWhite.600'}
placeholder={'User ID'}
maxLength={200}
{...register('apiDatasetServer.dingtalkServer.userId', { required: true })}
/>
</Flex>
</>
)}
</>
);
};
@@ -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
}
@@ -374,6 +374,37 @@ const Info = ({ datasetId }: { datasetId: string }) => {
</Box>
</>
)}
{datasetDetail.type === DatasetTypeEnum.dingtalk && (
<>
<Box w={'100%'} alignItems={'center'} pt={4}>
<Flex justifyContent={'space-between'} mb={1}>
<FormLabel fontSize={'mini'} fontWeight={'500'}>
{t('dataset:dingtalk_dataset_config')}
</FormLabel>
<MyIcon
name={'edit'}
w={'14px'}
_hover={{ color: 'primary.600' }}
cursor={'pointer'}
onClick={() =>
setEditedAPIDataset({
id: datasetDetail._id,
apiDatasetServer: datasetDetail.apiDatasetServer
})
}
/>
</Flex>
<Box fontSize={'mini'}>
{datasetDetail.apiDatasetServer?.dingtalkServer?.workspaceName ||
datasetDetail.apiDatasetServer?.dingtalkServer?.workspaceId}
</Box>
<Box fontSize={'mini'} color={'myGray.500'} mt={1}>
{datasetDetail.apiDatasetServer?.dingtalkServer?.userId}
</Box>
</Box>
</>
)}
</Box>
{datasetDetail.permission.hasManagePer && (
@@ -27,7 +27,8 @@ export type CreateDatasetType =
| DatasetTypeEnum.apiDataset
| DatasetTypeEnum.websiteDataset
| DatasetTypeEnum.feishu
| DatasetTypeEnum.yuque;
| DatasetTypeEnum.yuque
| DatasetTypeEnum.dingtalk;
const CreateModal = ({
onClose,
@@ -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;
@@ -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)
}
]
: [])
]
}
@@ -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'
})
);
});
});