# PRD 批次2 — 钉钉「指定节点 BFS 同步」+ 组织树接口(后端) > 你是 NestJS 后端工程师,项目 `gongxue-base`。本批次是**改造已有文件**,不是全新建。 > 严格按下面给的方法体照抄替换。不要改动未提到的方法或文件。 > 完成后跑 tsc 自检。 ## 背景 现状:`apps/server/src/integration/dingtalk.service.ts` 里的 `DingTalkService.syncAll()` 会从钉钉**根部门(dept_id=1)**开始 BFS 遍历所有部门+用户,写入本地。现在写死从 1 开始。 本批次目标: 1. 让 `syncAll` 支持传一个**起始部门 ID**,从该节点开始 BFS(默认仍是 1,保持向后兼容)。 2. 新增一个方法 `fetchOrgTree()`:只拉钉钉部门(不拉用户),返回**树形结构**,给前端"组织树选择器"用来勾选同步起点。 3. 新增一个 controller 接口把组织树暴露给前端。 4. 让现有的 `SyncService.syncDingTalk` / `triggerSync` 能把起始节点透传下去。 ## 关键事实(现有代码,供你理解,不要改这些无关部分) `DingTalkService` 已有这些**私有方法**(保持不动,你会复用它们): - `private async getAccessToken(): Promise` — 拿 token(从 env 读密钥) - `private async getAllDeptIds(token: string): Promise` — **当前写死 `queue=[1]`**,你要改它 - `private async getDeptDetail(token, deptId): Promise<{dept_id, name, parent_id} | null>` - `private async getDeptUsers(token, deptId): Promise<...>` - `private async rateLimit()` / `private delay(i)` / `private sleep(ms)` - `private get configured(): boolean` `syncAll()` 现在签名是 `async syncAll(): Promise<{ deptCount: number; userCount: number }>`,内部第一步调用 `const deptIds = await this.getAllDeptIds(token);`。 --- ## 任务 ### 修改 1:`apps/server/src/integration/dingtalk.service.ts` #### (1a) 改造 `getAllDeptIds` —— 支持传起始节点 找到现有方法(大致长这样): ```typescript private async getAllDeptIds(token: string): Promise { const ids: number[] = []; const queue: number[] = [1]; // ... while 循环 BFS ... } ``` 把签名和第一行改成接受可选起始节点,**其余循环体不动**: ```typescript private async getAllDeptIds(token: string, rootDeptId = 1): Promise { const ids: number[] = []; const queue: number[] = [rootDeptId]; // ... 下面的 while 循环体保持原样,一个字都不要改 ... } ``` #### (1b) 改造 `syncAll` —— 接受可选起始节点并透传 找到 `async syncAll()`,把签名改成: ```typescript async syncAll(rootDeptId = 1): Promise<{ deptCount: number; userCount: number }> { ``` 然后在方法体里找到这一行: ```typescript const deptIds = await this.getAllDeptIds(token); ``` 改成: ```typescript const deptIds = await this.getAllDeptIds(token, rootDeptId); ``` **方法体其它部分(同步部门、同步用户、日志)全部保持原样。** #### (1c) 新增方法 `fetchOrgTree` —— 返回部门树给前端 在类里新增一个 **public** 方法(放在 `syncAll` 之后即可)。它 BFS 拉所有部门详情,然后组装成树。照抄: ```typescript /** * 获取钉钉组织部门树(只含部门,不含用户),供前端选择同步起点。 * 返回从指定 rootDeptId 开始的树;默认根部门 1。 */ async fetchOrgTree(rootDeptId = 1): Promise { if (!this.configured) { throw new Error('钉钉未配置 (DINGTALK_APP_KEY / DINGTALK_APP_SECRET)'); } const token = await this.getAccessToken(); const deptIds = await this.getAllDeptIds(token, rootDeptId); // 拉每个部门详情 const nodes: DingOrgTreeNode[] = []; for (let i = 0; i < deptIds.length; i++) { if (i > 0) await this.delay(i); const detail = await this.getDeptDetail(token, deptIds[i]); if (detail) { nodes.push({ id: detail.dept_id, name: detail.name, parentId: detail.parent_id, children: [], }); } } // 组装成树 const map = new Map(); nodes.forEach((n) => map.set(n.id, n)); const roots: DingOrgTreeNode[] = []; for (const node of nodes) { const parent = map.get(node.parentId); if (parent && node.id !== rootDeptId) { parent.children.push(node); } else { roots.push(node); } } return roots; } ``` #### (1d) 新增类型定义 `DingOrgTreeNode` 在文件**顶部的类型定义区**(现有那些 `interface DingTalkTokenResponse {...}` 附近)新增并导出: ```typescript /** 钉钉部门树节点,供前端选择器使用 */ export interface DingOrgTreeNode { id: number; name: string; parentId: number; children: DingOrgTreeNode[]; } ``` **验收 (修改1)**: - `getAllDeptIds(token, rootDeptId=1)` 第一行是 `const queue: number[] = [rootDeptId];` - `syncAll(rootDeptId=1)` 内部调用 `getAllDeptIds(token, rootDeptId)` - 新增 public 方法 `fetchOrgTree(rootDeptId=1)`,返回 `DingOrgTreeNode[]` - 导出 `DingOrgTreeNode` interface - 其余原有方法体不变 --- ### 修改 2:`apps/server/src/sync/sync.service.ts` 现有 `SyncService` 里有: - `async syncDingTalk(): Promise` — 内部调用 `this.performDingTalkSync(lastSyncAt)` - `private async performDingTalkSync(lastSyncAt): Promise` — 内部第一行 `const result = await this.dingTalkService.syncAll();` - `async triggerSync(platform?): Promise` 我们要让触发同步时能带一个可选起始部门 ID `rootDeptId`。 #### (2a) `performDingTalkSync` 接受 rootDeptId 找到: ```typescript private async performDingTalkSync(lastSyncAt: Date | null): Promise { // Stage 1: Sync departments and users const result = await this.dingTalkService.syncAll(); ``` 改成: ```typescript private async performDingTalkSync(lastSyncAt: Date | null, rootDeptId = 1): Promise { // Stage 1: Sync departments and users const result = await this.dingTalkService.syncAll(rootDeptId); ``` **该方法其余部分(考勤导入那段)保持原样。** #### (2b) `syncDingTalk` 接受并透传 rootDeptId 找到 `async syncDingTalk(): Promise {`,把签名改成: ```typescript async syncDingTalk(rootDeptId = 1): Promise { ``` 在方法体里找到调用 `performDingTalkSync` 的那行(大概是 `const recordsCount = await this.performDingTalkSync(lastSyncAt);`),改成: ```typescript const recordsCount = await this.performDingTalkSync(lastSyncAt, rootDeptId); ``` **该方法其余部分(createSyncLog、finishSyncLog、catch 等)保持原样。** #### (2c) `triggerSync` 接受 rootDeptId 并透传给钉钉 找到: ```typescript async triggerSync(platform?: SyncPlatform): Promise { if (platform === 'dingtalk') return [await this.syncDingTalk()]; if (platform === 'wecom') return [await this.syncWeCom()]; return [await this.syncDingTalk(), await this.syncWeCom()]; } ``` 改成: ```typescript async triggerSync(platform?: SyncPlatform, rootDeptId = 1): Promise { if (platform === 'dingtalk') return [await this.syncDingTalk(rootDeptId)]; if (platform === 'wecom') return [await this.syncWeCom()]; return [await this.syncDingTalk(rootDeptId), await this.syncWeCom()]; } ``` #### (2d) 新增一个获取组织树的方法 在 `SyncService` 类里新增一个 public 方法(放在 `triggerSync` 之后): ```typescript /** 获取钉钉组织部门树,供前端选择同步起点 */ async getDingTalkOrgTree(rootDeptId = 1) { return this.dingTalkService.fetchOrgTree(rootDeptId); } ``` 注意:`SyncService` 构造函数里已经注入了 `private readonly dingTalkService: DingTalkService`,直接用即可,不用改构造函数。 **验收 (修改2)**:`triggerSync(platform?, rootDeptId=1)`、`syncDingTalk(rootDeptId=1)`、`performDingTalkSync(lastSyncAt, rootDeptId=1)` 三处签名都改了并透传;新增 `getDingTalkOrgTree(rootDeptId=1)`。 --- ### 修改 3:`apps/server/src/sync/sync.controller.ts` 现有 controller(`@Controller('sync')`,全局前缀 api,所以是 `/api/sync/...`)有: - `@Post('trigger')` → `triggerSync(@Query('platform') platform?)` - `@Get('status')` - `@Get('logs')` #### (3a) 给 trigger 接口加 rootDeptId 查询参数 找到: ```typescript @Post('trigger') @RequirePermission('sync:trigger') async triggerSync(@Query('platform') platform?: SyncPlatform) { const logs = await this.syncService.triggerSync(platform); return { synced: logs.length, logs }; } ``` 改成(新增 `rootDeptId` 查询参数,字符串转数字): ```typescript @Post('trigger') @RequirePermission('sync:trigger') async triggerSync( @Query('platform') platform?: SyncPlatform, @Query('rootDeptId') rootDeptId?: string, ) { const rootId = rootDeptId ? Number(rootDeptId) : 1; const logs = await this.syncService.triggerSync(platform, rootId); return { synced: logs.length, logs }; } ``` #### (3b) 新增获取组织树接口 在 controller 里新增(放在 `getStatus` 之后): ```typescript /** 获取钉钉组织部门树,供前端选择同步起点 */ @Get('dingtalk/org-tree') @RequirePermission('sync:read') async getDingTalkOrgTree(@Query('rootDeptId') rootDeptId?: string) { const rootId = rootDeptId ? Number(rootDeptId) : 1; const tree = await this.syncService.getDingTalkOrgTree(rootId); return { success: true, data: tree }; } ``` **路由顺序检查**:现有 GET 路由是 `status`、`logs`,新增的是 `dingtalk/org-tree`,三者路径不同,无冲突。无需调整顺序。 **验收 (修改3)**:`trigger` 接口多了 `rootDeptId` 查询参数;新增 `GET /api/sync/dingtalk/org-tree` 接口,权限 `sync:read`。 --- ## 全局验收(做完自检) 1. `cd /Users/tiku1/code/gongxue-base/apps/server && npx tsc --noEmit -p tsconfig.build.json` —— 你改的文件不能有 error。(忽略 `attendance/dto/dingtalk-import.dto.ts` 等你没碰的历史文件。) 2. 只改了 3 个文件:`integration/dingtalk.service.ts`、`sync/sync.service.ts`、`sync/sync.controller.ts`。没动别的。 3. 所有改动都保持"默认 rootDeptId=1"的向后兼容——不传参时行为和以前完全一样。 做完后用一句话总结改了哪些文件、加了哪些方法。