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

296 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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