# 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<string>` — 拿 token（从 env 读密钥）
- `private async getAllDeptIds(token: string): Promise<number[]>` — **当前写死 `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<number[]> {
    const ids: number[] = [];
    const queue: number[] = [1];
    // ... while 循环 BFS ...
  }
```

把签名和第一行改成接受可选起始节点，**其余循环体不动**：

```typescript
  private async getAllDeptIds(token: string, rootDeptId = 1): Promise<number[]> {
    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<DingOrgTreeNode[]> {
    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<number, DingOrgTreeNode>();
    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<SyncLog>` — 内部调用 `this.performDingTalkSync(lastSyncAt)`
- `private async performDingTalkSync(lastSyncAt): Promise<number>` — 内部第一行 `const result = await this.dingTalkService.syncAll();`
- `async triggerSync(platform?): Promise<SyncLog[]>`

我们要让触发同步时能带一个可选起始部门 ID `rootDeptId`。

#### (2a) `performDingTalkSync` 接受 rootDeptId

找到：

```typescript
  private async performDingTalkSync(lastSyncAt: Date | null): Promise<number> {
    // Stage 1: Sync departments and users
    const result = await this.dingTalkService.syncAll();
```

改成：

```typescript
  private async performDingTalkSync(lastSyncAt: Date | null, rootDeptId = 1): Promise<number> {
    // Stage 1: Sync departments and users
    const result = await this.dingTalkService.syncAll(rootDeptId);
```

**该方法其余部分（考勤导入那段）保持原样。**

#### (2b) `syncDingTalk` 接受并透传 rootDeptId

找到 `async syncDingTalk(): Promise<SyncLog> {`，把签名改成：

```typescript
  async syncDingTalk(rootDeptId = 1): Promise<SyncLog> {
```

在方法体里找到调用 `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<SyncLog[]> {
    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<SyncLog[]> {
    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"的向后兼容——不传参时行为和以前完全一样。

做完后用一句话总结改了哪些文件、加了哪些方法。
