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