docs: add DingTalk import fix implementation plan
This commit is contained in:
324
docs/superpowers/plans/2026-07-09-dingtalk-import-fixes.md
Normal file
324
docs/superpowers/plans/2026-07-09-dingtalk-import-fixes.md
Normal file
@@ -0,0 +1,324 @@
|
||||
# DingTalk 导入链路修复 — 实现计划
|
||||
|
||||
> **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.
|
||||
|
||||
**Goal:** 修复钉钉导入链路的两个阻塞 bug:前端叶子节点判断、后端 CampusScope 过滤导致学生/考勤数据不可见。
|
||||
|
||||
**Architecture:** 教学域(学生、考勤)移除 CampusScope 部门过滤,改为依赖 RBAC 权限码控制访问;管理域(宿舍、财务)保留 CampusScope 不变。
|
||||
|
||||
**Tech Stack:** React 19 + TypeScript + NestJS 11 + TypeORM
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 遵循现有 NestJS 模块结构
|
||||
- 后端编译检查:`npx tsc --noEmit -p tsconfig.build.json`
|
||||
- 前端编译检查:`npx tsc --noEmit`
|
||||
- 不改动 `syncAll`、`importDingTalkUsers`、RBAC 权限码
|
||||
- 不改动 `OccupanciesService`、`RoomsService`、`BillsService`、`ExpensesService`(保留 CampusScope)
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 前端 — 叶子节点判断
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/admin/src/pages/IntegrationConfig/index.tsx:375-393`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `DingOrgTreeNodeExt` (has `children: DingOrgTreeNodeExt[]`, `users: Array<...>`)
|
||||
- Produces: `DataNode[]` tree nodes with conditional class-mark button
|
||||
|
||||
- [ ] **Step 1: Read current code to confirm line numbers**
|
||||
|
||||
Run: `read apps/admin/src/pages/IntegrationConfig/index.tsx:373-395`
|
||||
|
||||
- [ ] **Step 2: Modify buildTreeData — add leaf-node condition**
|
||||
|
||||
当前 `node.children` 和 `node.users` 渲染逻辑不需要改,只需要在 `title` 渲染部分(第 373–395 行)用条件包裹"标为班级"按钮:
|
||||
|
||||
```tsx
|
||||
const buildTreeData = useCallback((nodes: DingOrgTreeNodeExt[]): DataNode[] => {
|
||||
return nodes.map((node) => ({
|
||||
title: (
|
||||
<Space size="small">
|
||||
<span>{node.name}</span>
|
||||
{node.children.length === 0 && node.users.length > 0 && (
|
||||
classMarks[node.id] ? (
|
||||
<Tag
|
||||
color="blue"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => openClassModal(node.id, node.name)}
|
||||
>
|
||||
班级: {classMarks[node.id].name} [已标记]
|
||||
</Tag>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
icon={<span>🏫</span>}
|
||||
onClick={() => openClassModal(node.id, node.name)}
|
||||
>
|
||||
标为班级
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
key: `dept-${node.id}`,
|
||||
children: [
|
||||
...buildTreeData(node.children),
|
||||
...node.users.map((u) => ({ ... })),
|
||||
],
|
||||
}));
|
||||
}, [classMarks, teacherChecks, teacherRoles, defaultTeacherRoleId, roles]);
|
||||
```
|
||||
|
||||
改动要点:用 `{node.children.length === 0 && node.users.length > 0 && ( ... )}` 包裹原来的三元表达式。只有无子部门且有用户的叶子节点才显示按钮。
|
||||
|
||||
- [ ] **Step 3: 验证 TypeScript 编译**
|
||||
|
||||
Run: `cd /Users/tiku1/code/gongxue-base/apps/admin && npx tsc --noEmit`
|
||||
Expected: no errors
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/admin/src/pages/IntegrationConfig/index.tsx
|
||||
git commit -m "fix(admin): only show class-mark button on leaf departments in DingTalk org import"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 后端 — StudentsService 移除 CampusScope
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/server/src/students/students.service.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: (none external — internal change only)
|
||||
- Produces: `findAll(query)` returns `Promise<Student[]>` — same signature, no scope filtering
|
||||
|
||||
- [ ] **Step 1: Read current code**
|
||||
|
||||
Run: `read apps/server/src/students/students.service.ts`
|
||||
|
||||
- [ ] **Step 2: Remove CampusScope import**
|
||||
|
||||
Delete line 4:
|
||||
```typescript
|
||||
import { CampusScope } from '../common/campus-scope';
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Remove constructor parameter**
|
||||
|
||||
SWAP lines 14-20 — remove `private readonly scope: CampusScope` and trailing comma on line 18:
|
||||
|
||||
```typescript
|
||||
constructor(
|
||||
@InjectRepository(Student) private repo: Repository<Student>,
|
||||
@InjectRepository(ClassStudent) private classStudentRepo: Repository<ClassStudent>,
|
||||
@InjectRepository(Class) private classRepo: Repository<Class>,
|
||||
@InjectRepository(AttendanceRecord) private attendanceRepo: Repository<AttendanceRecord>,
|
||||
) {}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Remove scope.filter call in findAll**
|
||||
|
||||
SWAP lines 22-33 — remove the `filteredWhere` intermediate. `findAll` now uses `where` directly:
|
||||
|
||||
```typescript
|
||||
async findAll(query?: { name?: string; status?: string; includeArchived?: boolean; tenantId?: number | string }) {
|
||||
const where: FindOptionsWhere<Student> = {};
|
||||
if (query?.name) where.name = Like(`%${query.name}%`);
|
||||
if (query?.tenantId) where.tenantId = Number(query.tenantId);
|
||||
if (query?.status) {
|
||||
where.status = query.status;
|
||||
} else if (!query?.includeArchived) {
|
||||
where.status = Not(In(['archived']));
|
||||
}
|
||||
return this.repo.find({ where, order: { createdAt: 'DESC' }, relations: ['tenant'] });
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify TypeScript compile**
|
||||
|
||||
Run: `cd /Users/tiku1/code/gongxue-base/apps/server && npx tsc --noEmit -p tsconfig.build.json`
|
||||
Expected: no errors (ignore pre-existing errors in files not touched)
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/server/src/students/students.service.ts
|
||||
git commit -m "fix(server): remove CampusScope from StudentsService, classes are teacher-managed not dept-filtered"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 后端 — AttendanceService 移除 CampusScope
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/server/src/attendance/attendance.service.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: (none external — internal change only)
|
||||
- Produces: all public method signatures unchanged, no scope filtering applied
|
||||
|
||||
- [ ] **Step 1: Read current code to confirm line numbers**
|
||||
|
||||
Run: `read apps/server/src/attendance/attendance.service.ts:1-40`
|
||||
|
||||
- [ ] **Step 2: Remove CampusScope import (line 9)**
|
||||
|
||||
```typescript
|
||||
import { CampusScope } from '../common/campus-scope';
|
||||
```
|
||||
→ Delete this line.
|
||||
|
||||
- [ ] **Step 3: Remove constructor parameter (line 39)**
|
||||
|
||||
SWAP the constructor body — remove `private readonly scope: CampusScope`:
|
||||
|
||||
```typescript
|
||||
constructor(
|
||||
@InjectRepository(AttendanceRecord)
|
||||
private attendanceRepo: Repository<AttendanceRecord>,
|
||||
@InjectRepository(DingAttendanceRaw)
|
||||
private dingRawRepo: Repository<DingAttendanceRaw>,
|
||||
@InjectRepository(Class)
|
||||
private classRepo: Repository<Class>,
|
||||
@InjectRepository(Student)
|
||||
private studentRepo: Repository<Student>,
|
||||
@InjectRepository(ClassSchedule)
|
||||
private scheduleRepo: Repository<ClassSchedule>,
|
||||
@InjectRepository(ClassStudent)
|
||||
private classStudentRepo: Repository<ClassStudent>,
|
||||
@InjectRepository(UserDingMapping)
|
||||
private userDingMappingRepo: Repository<UserDingMapping>,
|
||||
) {}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Remove scope block in getSummary (lines 182-185)**
|
||||
|
||||
Delete lines 182-185:
|
||||
```typescript
|
||||
const scopeIds = await this.scope.getScopeDepartmentIds();
|
||||
if (scopeIds) {
|
||||
qb.andWhere('ar.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Remove scope block in findAll (lines 287-290)**
|
||||
|
||||
Delete lines 287-290:
|
||||
```typescript
|
||||
const scopeIds = await this.scope.getScopeDepartmentIds();
|
||||
if (scopeIds) {
|
||||
qb.andWhere('ar.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Remove scope blocks in getClasses (lines 327-330 and 339)**
|
||||
|
||||
Delete lines 327-330:
|
||||
```typescript
|
||||
const scopeIds = await this.scope.getScopeDepartmentIds();
|
||||
if (scopeIds) {
|
||||
qb.andWhere('ar.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
}
|
||||
```
|
||||
|
||||
SWAP line 339 — replace `await this.scope.filter({ id: In(classIds) })` with `{ id: In(classIds) }`:
|
||||
```typescript
|
||||
const classes = await this.classRepo.find({ where: { id: In(classIds) } });
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Remove scope block in findAllForExport (lines 422-425)**
|
||||
|
||||
Delete lines 422-425:
|
||||
```typescript
|
||||
const scopeIds = await this.scope.getScopeDepartmentIds();
|
||||
if (scopeIds) {
|
||||
qb.andWhere('ar.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Remove scope guard in update (lines 460-463)**
|
||||
|
||||
Delete lines 460-463:
|
||||
```typescript
|
||||
const scopeIds = await this.scope.getScopeDepartmentIds();
|
||||
if (scopeIds && !scopeIds.includes(record.departmentId)) {
|
||||
throw new NotFoundException(`AttendanceRecord ${id} not found`);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 9: Remove scope guard in remove (lines 482-485)**
|
||||
|
||||
Delete lines 482-485:
|
||||
```typescript
|
||||
const scopeIds = await this.scope.getScopeDepartmentIds();
|
||||
if (scopeIds && !scopeIds.includes(record.departmentId)) {
|
||||
throw new NotFoundException(`AttendanceRecord ${id} not found`);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 10: Remove scope block in getReport (lines 494-497)**
|
||||
|
||||
Delete lines 494-497:
|
||||
```typescript
|
||||
const scopeIds = await this.scope.getScopeDepartmentIds();
|
||||
if (scopeIds) {
|
||||
qb.andWhere('ar.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 11: Remove scope block in getAlerts (lines 570-573)**
|
||||
|
||||
Delete lines 570-573:
|
||||
```typescript
|
||||
const scopeIds = await this.scope.getScopeDepartmentIds();
|
||||
if (scopeIds) {
|
||||
qb.andWhere('a.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 12: Verify TypeScript compile**
|
||||
|
||||
Run: `cd /Users/tiku1/code/gongxue-base/apps/server && npx tsc --noEmit -p tsconfig.build.json`
|
||||
Expected: no errors (ignore pre-existing errors in files not touched)
|
||||
|
||||
- [ ] **Step 13: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/server/src/attendance/attendance.service.ts
|
||||
git commit -m "fix(server): remove CampusScope from AttendanceService, attendance is teacher-managed not dept-filtered"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 端到端验证
|
||||
|
||||
- [ ] **Step 1: 启动后端**
|
||||
|
||||
```bash
|
||||
cd /Users/tiku1/code/gongxue-base/apps/server && npm run start:dev
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 启动前端**
|
||||
|
||||
```bash
|
||||
cd /Users/tiku1/code/gongxue-base/apps/admin && npm run dev
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 验证场景**
|
||||
|
||||
1. 以非超管老师身份登录,打开集成配置页面
|
||||
2. 获取钉钉组织架构 → 确认父部门没有"标为班级"按钮,叶子部门有
|
||||
3. 标记班级、勾选老师、执行导入 → 确认导入成功
|
||||
4. 导航到学生管理页面 → 确认能看到导入的学生
|
||||
5. 导航到考勤管理页面 → 确认有数据
|
||||
|
||||
- [ ] **Step 4: Commit verification notes**
|
||||
|
||||
```bash
|
||||
git add -A && git commit -m "chore: end-to-end verification notes"
|
||||
```
|
||||
Reference in New Issue
Block a user