Files
gongxue-base/docs/prd-dingtalk-batch2.md
wangziqi 42d3f0e27f feat: DingTalk attendance import + integration config + expense types + UI polish
Server:
- Add DingTalk attendance import service with SSE progress streaming
- Add IntegrationConfig entity & module for multi-tenant DingTalk setup
- Add ExpenseType entity & ExpenseTypesModule
- Add SeedModule for DB initialization
- Add UserDingMapping entity for DingTalk user linkage
- Attendance service: import flow with dedup & student auto-mapping
- Rooms service: time-range overlap queries
- Sync controller/service: DingTalk integration wiring
- Permission guard: refactor to pure re-export
- Campus scope middleware: tenant-aware filtering

Admin UI:
- Attendance page: import UI with progress & result summary
- All pages: tableStyle/tablePagination standardization
- Login page: responsive styling
- Sensitive data: useViewSensitive hook for masked viewing
- Vite config: path aliases, build optimization
- Test infra: vitest config, test utilities

Docs: PRD DingTalk batch 1 & 2 design docs
2026-07-09 09:11:56 +08:00

10 KiB
Raw Blame History

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);


任务

修改 1apps/server/src/integration/dingtalk.service.ts

(1a) 改造 getAllDeptIds —— 支持传起始节点

找到现有方法(大致长这样):

  private async getAllDeptIds(token: string): Promise<number[]> {
    const ids: number[] = [];
    const queue: number[] = [1];
    // ... while 循环 BFS ...
  }

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

  private async getAllDeptIds(token: string, rootDeptId = 1): Promise<number[]> {
    const ids: number[] = [];
    const queue: number[] = [rootDeptId];
    // ... 下面的 while 循环体保持原样,一个字都不要改 ...
  }

(1b) 改造 syncAll —— 接受可选起始节点并透传

找到 async syncAll(),把签名改成:

  async syncAll(rootDeptId = 1): Promise<{ deptCount: number; userCount: number }> {

然后在方法体里找到这一行:

    const deptIds = await this.getAllDeptIds(token);

改成:

    const deptIds = await this.getAllDeptIds(token, rootDeptId);

方法体其它部分(同步部门、同步用户、日志)全部保持原样。

(1c) 新增方法 fetchOrgTree —— 返回部门树给前端

在类里新增一个 public 方法(放在 syncAll 之后即可)。它 BFS 拉所有部门详情,然后组装成树。照抄:

  /**
   * 获取钉钉组织部门树(只含部门,不含用户),供前端选择同步起点。
   * 返回从指定 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 {...} 附近)新增并导出:

/** 钉钉部门树节点,供前端选择器使用 */
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
  • 其余原有方法体不变

修改 2apps/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

找到:

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

改成:

  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> {,把签名改成:

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

在方法体里找到调用 performDingTalkSync 的那行(大概是 const recordsCount = await this.performDingTalkSync(lastSyncAt);),改成:

      const recordsCount = await this.performDingTalkSync(lastSyncAt, rootDeptId);

该方法其余部分createSyncLog、finishSyncLog、catch 等)保持原样。

(2c) triggerSync 接受 rootDeptId 并透传给钉钉

找到:

  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()];
  }

改成:

  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 之后):

  /** 获取钉钉组织部门树,供前端选择同步起点 */
  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)


修改 3apps/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 查询参数

找到:

  @Post('trigger')
  @RequirePermission('sync:trigger')
  async triggerSync(@Query('platform') platform?: SyncPlatform) {
    const logs = await this.syncService.triggerSync(platform);
    return { synced: logs.length, logs };
  }

改成(新增 rootDeptId 查询参数,字符串转数字):

  @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 之后):

  /** 获取钉钉组织部门树,供前端选择同步起点 */
  @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 路由是 statuslogs,新增的是 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.tssync/sync.service.tssync/sync.controller.ts。没动别的。
  3. 所有改动都保持"默认 rootDeptId=1"的向后兼容——不传参时行为和以前完全一样。

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