forked from wangziqi/gongxue-base
docs: implementation plan for org tree deepest levels filter
This commit is contained in:
174
docs/superpowers/plans/2026-07-09-org-tree-deepest-levels.md
Normal file
174
docs/superpowers/plans/2026-07-09-org-tree-deepest-levels.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# Org Tree Deepest Levels — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
> **Required skills per agent:** `superpowers:vercel-react-best-practices`, `superpowers:ui-ux-pro-max`
|
||||
|
||||
**Goal:** `fetchOrgTreeWithUsers` only returns departments at the deepest 2 levels of the global org tree.
|
||||
|
||||
**Architecture:** Add a `getDeptDepthMap` BFS method that mirrors `getAllDeptIds` but tracks level. Insert depth filtering in `fetchOrgTreeWithUsers` before the detail+user fetch loop, slashing API calls.
|
||||
|
||||
**Tech Stack:** NestJS + TypeScript, DingTalk Open API v2
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Depth is computed from rootDeptId=1 globally, regardless of the user's `rootDeptId` param
|
||||
- `fetchOrgTree` (dept picker) is NOT modified
|
||||
- `syncAll` is NOT modified
|
||||
- Frontend receives the same `DingOrgTreeNodeWithUsers[]` shape, zero frontend changes
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add `getDeptDepthMap` method
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/server/src/integration/dingtalk.service.ts` (insert after `getAllDeptIds`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing new (uses existing `rateLimit()`, `SubDeptIdListResponse`, DingTalk `listsubid` API)
|
||||
- Produces: `private async getDeptDepthMap(token: string): Promise<Map<number, number>>` — key=deptId, value=1-based depth
|
||||
|
||||
- [ ] **Step 1: Add `getDeptDepthMap` after `getAllDeptIds` (after line 263)**
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* BFS from rootDeptId=1, returns depth of every department.
|
||||
* Depth is 1-based (root=1). Uses listsubid API only, no detail fetches.
|
||||
*/
|
||||
private async getDeptDepthMap(token: string): Promise<Map<number, number>> {
|
||||
const depthMap = new Map<number, number>();
|
||||
const queue: Array<{ id: number; depth: number }> = [{ id: 1, depth: 1 }];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const { id, depth } = queue.shift()!;
|
||||
depthMap.set(id, depth);
|
||||
|
||||
try {
|
||||
await this.rateLimit();
|
||||
const res = await fetch(
|
||||
`https://oapi.dingtalk.com/topapi/v2/department/listsubid?access_token=${token}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ dept_id: id }),
|
||||
},
|
||||
);
|
||||
const body: SubDeptIdListResponse = await res.json();
|
||||
if (body.errcode === 0 && body.result?.dept_id_list) {
|
||||
for (const childId of body.result.dept_id_list) {
|
||||
queue.push({ id: childId, depth: depth + 1 });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.error(`getDeptDepthMap 获取部门 ${id} 子部门失败: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return depthMap;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify it compiles**
|
||||
|
||||
Run: `cd apps/server && npx tsc --noEmit`
|
||||
Expected: no new errors (may have pre-existing ones in other files)
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/server/src/integration/dingtalk.service.ts
|
||||
git commit -m "feat: add getDeptDepthMap BFS method for org tree depth tracking"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Filter `fetchOrgTreeWithUsers` to deepest 2 levels
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/server/src/integration/dingtalk.service.ts:476-547`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `getDeptDepthMap(token)` from Task 1
|
||||
- Produces: same `Promise<DingOrgTreeNodeWithUsers[]>` return type, now filtered
|
||||
|
||||
- [ ] **Step 1: Insert depth filtering before the detail+user fetch loop**
|
||||
|
||||
Replace lines 480-481 (`const token = ...; const deptIds = ...;`) with the depth-aware version, and wrap the loop to use `filteredIds`:
|
||||
|
||||
```typescript
|
||||
const token = await this.getAccessToken();
|
||||
|
||||
// Compute global depth map (always from rootDeptId=1)
|
||||
const depthMap = await this.getDeptDepthMap(token);
|
||||
const maxDepth = Math.max(...depthMap.values());
|
||||
|
||||
// Get subtree IDs for the user's selected root
|
||||
const subtreeIds = await this.getAllDeptIds(token, rootDeptId);
|
||||
|
||||
// Filter: only keep departments at the deepest 2 levels of the global tree
|
||||
const filteredIds = subtreeIds.filter((id) => {
|
||||
const d = depthMap.get(id) ?? -1;
|
||||
return d === maxDepth || d === maxDepth - 1;
|
||||
});
|
||||
|
||||
if (filteredIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 拉每个部门的详情(仅过滤后的)
|
||||
const nodes: DingOrgTreeNodeWithUsers[] = [];
|
||||
const userDeptMap = new Map<string, number[]>();
|
||||
|
||||
for (let i = 0; i < filteredIds.length; i++) {
|
||||
if (i > 0) await this.delay(i);
|
||||
const detail = await this.getDeptDetail(token, filteredIds[i]);
|
||||
```
|
||||
|
||||
And update the deptId reference inside the loop body — line 494 uses `deptIds[i]`:
|
||||
|
||||
Change line 494:
|
||||
```typescript
|
||||
// Before:
|
||||
this.logger.log(`[dingtalk] dept ${deptIds[i]} (${detail.name}): ${dingUsers.length} users`);
|
||||
// After:
|
||||
this.logger.log(`[dingtalk] dept ${filteredIds[i]} (${detail.name}): ${dingUsers.length} users`);
|
||||
```
|
||||
|
||||
And the `getDeptUsers` call on line 493 must also use `filteredIds[i]` instead of `deptIds[i]` — it already does since we're iterating `filteredIds`.
|
||||
|
||||
- [ ] **Step 2: Update tree assembly to handle missing parent nodes**
|
||||
|
||||
The tree assembly at lines 534-546 currently checks `node.id !== rootDeptId` to decide if a node is a root. Since intermediate levels are filtered out, a maxDepth-1 node's parent won't be in the `map`. Update the root detection:
|
||||
|
||||
```typescript
|
||||
// 组装成树(父节点可能已被过滤,缺失的父节点 → 节点提升为根)
|
||||
const map = new Map<number, DingOrgTreeNodeWithUsers>();
|
||||
nodes.forEach((n) => map.set(n.id, n));
|
||||
const roots: DingOrgTreeNodeWithUsers[] = [];
|
||||
for (const node of nodes) {
|
||||
const parent = map.get(node.parentId);
|
||||
if (parent) {
|
||||
parent.children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note: removed the `&& node.id !== rootDeptId` condition — it's redundant with the `map.get` check when intermediate levels are filtered. Nodes whose parent exists in `map` get attached; those whose parent was filtered out become roots. Same behavior, simpler logic.
|
||||
|
||||
- [ ] **Step 3: Verify it compiles**
|
||||
|
||||
Run: `cd apps/server && npx tsc --noEmit`
|
||||
Expected: no new errors
|
||||
|
||||
- [ ] **Step 4: Smoke test with a real DingTalk config (if available)**
|
||||
|
||||
Run: `curl -s http://localhost:3000/api/sync/dingtalk/org-tree-with-users | jq '. | length'`
|
||||
Expected: returns departments; check that the response depth is at most 2 levels (manual inspection of `parentId` chains, or verify response size is smaller than before).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/server/src/integration/dingtalk.service.ts
|
||||
git commit -m "feat: filter org-tree-with-users to deepest 2 levels"
|
||||
```
|
||||
Reference in New Issue
Block a user