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
296 lines
10 KiB
Markdown
296 lines
10 KiB
Markdown
# 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"的向后兼容——不传参时行为和以前完全一样。
|
||
|
||
做完后用一句话总结改了哪些文件、加了哪些方法。
|