From 1fa336331c84b59a78425d6db8d5641bc2af60a6 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Thu, 9 Jul 2026 16:49:38 +0800 Subject: [PATCH] docs: student ding mapping + batch operations implementation plan --- .../plans/2026-07-09-student-ding-mapping.md | 636 ++++++++++++++++++ 1 file changed, 636 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-09-student-ding-mapping.md diff --git a/docs/superpowers/plans/2026-07-09-student-ding-mapping.md b/docs/superpowers/plans/2026-07-09-student-ding-mapping.md new file mode 100644 index 0000000..6e97f34 --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-student-ding-mapping.md @@ -0,0 +1,636 @@ +# Student Ding Mapping + Drawer Batch Operations — 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:** Replace UserDingMapping with StudentDingMapping, rewrite syncAll to create Students directly, add class batch-import endpoint, rebuild Drawer with checkable Tree + class list. + +**Architecture:** Student becomes independent from User — sync creates Student + StudentDingMapping directly. Frontend Drawer uses Ant Design `` with left-right split layout for batch user selection and class operations. + +**Tech Stack:** NestJS 11 + TypeORM 0.3 + Ant Design 6 + +## Global Constraints + +- Student is independent of User (no userId, no RBAC) +- StudentDingMapping replaces UserDingMapping everywhere +- fetchOrgTree (dept picker) is NOT modified +- syncAll still syncs all departments — only user handling changes +- Depth filtering (getDeptDepthMap) is removed entirely + +--- + +### Task 1: Create StudentDingMapping entity + swap in all modules + +**Files:** +- Create: `apps/server/src/entities/student-ding-mapping.entity.ts` +- Modify: `apps/server/src/entities/index.ts` +- Modify: `apps/server/src/app.module.ts` +- Modify: `apps/server/src/integration/dingtalk.service.ts` — import + repo injection +- Modify: `apps/server/src/integration/integration.module.ts` — TypeOrmModule.forFeature +- Modify: `apps/server/src/sync/sync.service.ts` — import + repo injection +- Modify: `apps/server/src/sync/sync.module.ts` — TypeOrmModule.forFeature +- Modify: `apps/server/src/sync/schedule-sync.service.ts` — import + repo injection +- Modify: `apps/server/src/attendance/attendance-import.service.ts` — import + repo injection +- Modify: `apps/server/src/attendance/attendance.module.ts` — TypeOrmModule.forFeature +- Modify: `apps/server/src/attendance/attendance.service.ts` — import + repo injection +- Modify: `apps/server/src/rbac/rbac.service.ts` — import + repo injection +- Modify: `apps/server/src/rbac/rbac.module.ts` — TypeOrmModule.forFeature + +**Interfaces:** +- Produces: `StudentDingMapping` entity with `id`, `dingUserId`(unique), `studentId`(unique), `student`(ManyToOne), `createdAt` +- Note: rbac controller endpoints (getUserDingMappings etc.) removed in Task 4 + +- [ ] **Step 1: Create `student-ding-mapping.entity.ts`** + +```typescript +import { + Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, + ManyToOne, JoinColumn, +} from 'typeorm'; +import { Student } from './student.entity'; + +@Entity('student_ding_mapping') +export class StudentDingMapping { + @PrimaryGeneratedColumn() + id: number; + + @Column({ name: 'ding_user_id', length: 100, unique: true }) + dingUserId: string; + + @Column({ name: 'student_id', type: 'integer', unique: true }) + studentId: number; + + @ManyToOne(() => Student, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'student_id' }) + student: Student; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; +} +``` + +- [ ] **Step 2: Swap exports in `entities/index.ts`** + +Remove: `export { UserDingMapping } from './user-ding-mapping.entity';` +Add: `export { StudentDingMapping } from './student-ding-mapping.entity';` + +- [ ] **Step 3: Swap in `app.module.ts`** + +In the import from `'./entities'`: replace `UserDingMapping` with `StudentDingMapping`. +In the `TypeOrmModule.forRoot` entities array: same replacement. + +- [ ] **Step 4: Swap in every module file** + +For each file listed above, replace mechanically: +- `UserDingMapping` → `StudentDingMapping` +- `user_ding_mapping` → `student_ding_mapping` +- `mappingRepo` / `userDingMappingRepo` → `studentDingMappingRepo` + +- [ ] **Step 5: Delete `user-ding-mapping.entity.ts`** + +- [ ] **Step 6: Verify compile** + +```bash +cd apps/server && npx tsc --noEmit +``` + +Expected: zero new errors (pre-existing spec-file errors ignored). + +- [ ] **Step 7: Commit** + +```bash +git add apps/server/src/entities/ apps/server/src/app.module.ts apps/server/src/integration/ apps/server/src/sync/ apps/server/src/attendance/ apps/server/src/rbac/ +git commit -m "refactor: replace UserDingMapping with StudentDingMapping entity" +``` + +--- + +### Task 2: Rewrite syncAll to create Student directly + +**Files:** +- Modify: `apps/server/src/integration/dingtalk.service.ts` — `syncAll` and `syncOneUser` + +**Interfaces:** +- Consumes: `StudentDingMapping` entity (Task 1) +- Produces: same `{ deptCount, userCount }` return type + +- [ ] **Step 1: Rewrite `syncOneUser`** + +Replace existing `syncOneUser` (lines ~553-624) with: + +```typescript +private async syncOneUser(du: { + userid: string; name: string; mobile: string; +}): Promise { + let mapping = await this.studentDingMappingRepo.findOne({ + where: { dingUserId: du.userid }, + }); + if (mapping) { + const student = await this.studentRepo.findOne({ + where: { id: mapping.studentId }, + }); + if (student) { + student.name = du.name; + if (du.mobile) student.phone = du.mobile; + await this.studentRepo.save(student); + } + return; + } + + const student = this.studentRepo.create({ + name: du.name, + phone: du.mobile || undefined, + status: 'active', + }); + await this.studentRepo.save(student); + + mapping = this.studentDingMappingRepo.create({ + dingUserId: du.userid, + studentId: student.id, + }); + await this.studentDingMappingRepo.save(mapping); +} +``` + +Remove unused imports: `bcrypt`, `User`, `userRepo` injection. `studentDingMappingRepo` already injected from Task 1. + +- [ ] **Step 2: Verify compile** + +```bash +cd apps/server && npx tsc --noEmit +``` + +- [ ] **Step 3: Commit** + +```bash +git add apps/server/src/integration/dingtalk.service.ts +git commit -m "refactor: syncAll creates Student + StudentDingMapping directly" +``` + +--- + +### Task 3: Revert deepest-2-levels filtering + +**Files:** +- Modify: `apps/server/src/integration/dingtalk.service.ts` — `fetchOrgTreeWithUsers` + remove `getDeptDepthMap` + +- [ ] **Step 1: Remove `getDeptDepthMap` method** (the ~37 lines added in commit 0509175) + +- [ ] **Step 2: Restore `fetchOrgTreeWithUsers` loop** + +Remove depthMap/maxDepth/filteredIds. Restore: +```typescript +const deptIds = await this.getAllDeptIds(token, rootDeptId); +``` +And `for (let i = 0; i < deptIds.length; i++)` instead of `filteredIds`. + +- [ ] **Step 3: Restore tree assembly condition** + +Add back `&& node.id !== rootDeptId` in the root-detection line. + +- [ ] **Step 4: Verify compile + commit** + +```bash +cd apps/server && npx tsc --noEmit +git add apps/server/src/integration/dingtalk.service.ts +git commit -m "revert: remove deepest-2-levels filter, restore full org tree" +``` + +--- + +### Task 4: Remove User-based import + RBAC UserDingMapping endpoints + +**Files:** +- Modify: `apps/server/src/sync/sync.service.ts` — remove `importDingTalkUsers`, `ImportUserDto` +- Modify: `apps/server/src/sync/sync.controller.ts` — remove `POST /dingtalk/import-users` +- Modify: `apps/server/src/sync/dto/import-users.dto.ts` — remove `ImportUsersDto` +- Modify: `apps/server/src/rbac/rbac.service.ts` — remove `getUserDingMappings`, `createUserDingMapping`, `deleteUserDingMapping`, unbound-users query +- Modify: `apps/server/src/rbac/rbac.controller.ts` — remove corresponding endpoints + imports +- Modify: `apps/server/src/rbac/dto/rbac.dto.ts` — remove `CreateUserDingMappingDto` + +- [ ] **Step 1: Remove importDingTalkUsers from sync.service.ts** + +Delete the entire `importDingTalkUsers` method and the `ImportUserDto` interface. +Remove now-unused imports: `Role`, `bcrypt`, `ClassTeacher`, `ClassStudent`, `Department`, `ClassEntity`, `BadRequestException`. + +- [ ] **Step 2: Remove import endpoint from sync.controller.ts** + +Delete the `POST /dingtalk/import-users` handler and `ImportUsersDto` import. + +- [ ] **Step 3: Remove RBAC UserDingMapping methods** + +In `rbac.service.ts`: delete `getUserDingMappings()`, `createUserDingMapping()`, `deleteUserDingMapping()`, and the unbound-users query. +In `rbac.controller.ts`: delete `GET /user-ding-mappings`, `POST /user-ding-mappings`, `DELETE /user-ding-mappings/:id`, `GET /user-ding-mappings/unbound-users`. +In `rbac.dto.ts`: delete `CreateUserDingMappingDto`. + +- [ ] **Step 4: Verify compile** + +```bash +cd apps/server && npx tsc --noEmit +``` + +- [ ] **Step 5: Commit** + +```bash +git add apps/server/src/sync/ apps/server/src/rbac/ +git commit -m "refactor: remove User-based import and RBAC UserDingMapping endpoints" +``` + +--- + +### Task 5: Add class batch-import + extend class create + +**Files:** +- Modify: `apps/server/src/classes/classes.service.ts` — new `batchImportStudents`, extend `create` +- Modify: `apps/server/src/classes/classes.controller.ts` — new endpoint +- Modify: `apps/server/src/classes/dto/class.dto.ts` — new DTOs +- Modify: `apps/server/src/classes/classes.module.ts` — add StudentDingMapping + +**Interfaces:** +- `POST /classes/:id/students/import` — body `{ dingUserIds: string[] }` → `{ imported: number, skipped: number }` +- `POST /classes` — extended: optional `dingUserIds: string[]` + +- [ ] **Step 1: Add DTOs in `class.dto.ts`** + +```typescript +import { IsArray, IsString, ArrayNotEmpty, IsOptional } from 'class-validator'; + +export class BatchImportStudentsDto { + @IsArray() + @IsString({ each: true }) + @ArrayNotEmpty() + dingUserIds: string[]; +} +``` + +In existing `CreateClassDto`, add: +```typescript +@IsArray() +@IsString({ each: true }) +@IsOptional() +dingUserIds?: string[]; +``` + +- [ ] **Step 2: Inject `StudentDingMapping` in classes.service.ts** + +```typescript +import { StudentDingMapping } from '../entities'; +// ... +@InjectRepository(StudentDingMapping) +private readonly studentDingMappingRepo: Repository, +``` + +- [ ] **Step 3: Add `batchImportStudents` method** + +```typescript +async batchImportStudents(classId: number, dingUserIds: string[]): Promise<{ imported: number; skipped: number }> { + const classEntity = await this.classRepo.findOne({ where: { id: classId } }); + if (!classEntity) throw new NotFoundException('班级不存在'); + + let imported = 0; + let skipped = 0; + + for (const dingUserId of dingUserIds) { + let mapping = await this.studentDingMappingRepo.findOne({ where: { dingUserId } }); + let studentId: number; + + if (mapping) { + studentId = mapping.studentId; + } else { + const student = this.studentRepo.create({ + name: `dd_${dingUserId}`, + status: 'active', + departmentId: classEntity.departmentId ?? undefined, + }); + const saved = await this.studentRepo.save(student); + studentId = saved.id; + mapping = this.studentDingMappingRepo.create({ dingUserId, studentId }); + await this.studentDingMappingRepo.save(mapping); + } + + const existing = await this.classStudentRepo.findOne({ + where: { classId, studentId }, + }); + if (existing) { skipped++; continue; } + + await this.classStudentRepo.save( + this.classStudentRepo.create({ + classId, studentId, status: 'active', + joinDate: new Date().toISOString().slice(0, 10), + }), + ); + imported++; + } + + return { imported, skipped }; +} +``` + +- [ ] **Step 4: Extend `create` method** + +At end of `create`, before return: +```typescript +if (dto.dingUserIds?.length) { + await this.batchImportStudents(saved.id, dto.dingUserIds); +} +``` + +- [ ] **Step 5: Add endpoint in classes.controller.ts** + +```typescript +import { BatchImportStudentsDto } from './dto/class.dto'; + +@Post(':id/students/import') +@RequirePermission('class:edit') +async batchImportStudents( + @Param('id') id: string, + @Body() dto: BatchImportStudentsDto, +) { + return this.classesService.batchImportStudents(+id, dto.dingUserIds); +} +``` + +- [ ] **Step 6: Add StudentDingMapping to classes.module.ts** + +```typescript +TypeOrmModule.forFeature([Class, ClassStudent, ClassTeacher, ClassSchedule, Student, StudentDingMapping]), +``` + +- [ ] **Step 7: Verify compile + commit** + +```bash +cd apps/server && npx tsc --noEmit +git add apps/server/src/classes/ +git commit -m "feat: add batch-import students to class endpoint" +``` + +--- + +### Task 6: Frontend — rewrite Drawer with checkable Tree + class list + +**Files:** +- Modify: `apps/admin/src/pages/IntegrationConfig/index.tsx` + +**Interfaces:** +- Consumes: `GET /sync/dingtalk/org-tree-with-users`, `GET /classes`, `POST /classes/:id/students/import`, `POST /classes` +- Produces: redesigned Drawer with left-right split layout + +- [ ] **Step 1: Update imports** + +```typescript +import { Tree, Row, Col, Card, List, Button, Space, Drawer, Alert, TreeSelect, message, Modal, Form, Input, DatePicker, InputNumber, Select, Tag } from 'antd'; +import { SyncOutlined, BankOutlined, UserOutlined } from '@ant-design/icons'; +``` + +Remove: `ReloadOutlined` (no longer used), `Spin`, `Descriptions`, etc. + +- [ ] **Step 2: Replace state** + +```typescript +// Remove: teacherChecks, teacherRoles, defaultTeacherRoleId, roles, classMarks, classModalDept +// Add: +const [checkedKeys, setCheckedKeys] = useState([]); +const [selectedClassId, setSelectedClassId] = useState(null); +const [classes, setClasses] = useState([]); +const [classForm] = Form.useForm(); +const [classModalOpen, setClassModalOpen] = useState(false); +const [importing, setImporting] = useState(false); +``` + +- [ ] **Step 3: Replace `buildTreeData` (checkable, no toggles)** + +```typescript +const buildTreeData = useCallback((nodes: any[]): any[] => { + return nodes.map((node) => ({ + title: ( + + + {node.name} + {node.users.length}人 + + ), + key: `dept-${node.id}`, + children: [ + ...buildTreeData(node.children), + ...node.users.map((u: any) => ({ + title: {u.name}{u.mobile}, + key: `user-${u.userid}`, + })), + ], + })); +}, []); +``` + +- [ ] **Step 4: Add `fetchClasses`** + +```typescript +const fetchClasses = async () => { + try { + const res = await api.get('/classes'); + setClasses(Array.isArray(res) ? res : res.data ?? []); + } catch { /* ignore */ } +}; +``` + +Call `fetchClasses()` inside `handleFetchOrgTree`. + +- [ ] **Step 5: `handleJoinClass`** + +```typescript +const handleJoinClass = async () => { + if (selectedClassId === null) return message.warning('请先选择一个班级'); + const userIds = checkedKeys + .filter((k) => String(k).startsWith('user-')) + .map((k) => String(k).replace('user-', '')); + if (userIds.length === 0) return message.warning('请勾选要导入的用户'); + + setImporting(true); + try { + const res = await api.post(`/classes/${selectedClassId}/students/import`, { dingUserIds: userIds }); + message.success(`导入 ${res.imported} 人,跳过 ${res.skipped} 人`); + setCheckedKeys([]); + setSelectedClassId(null); + } catch (e: any) { + message.error(e?.message || '导入失败'); + } finally { + setImporting(false); + } +}; +``` + +- [ ] **Step 6: `handleCreateClass` modal** + +```typescript +const handleCreateClass = async () => { + try { + const values = await classForm.validateFields(); + const userIds = checkedKeys + .filter((k) => String(k).startsWith('user-')) + .map((k) => String(k).replace('user-', '')); + const deptKeys = checkedKeys.filter((k) => String(k).startsWith('dept-')); + const deptId = deptKeys.length > 0 + ? Number(String(deptKeys[0]).replace('dept-', '')) + : undefined; + + setImporting(true); + await api.post('/classes', { ...values, dingUserIds: userIds, departmentId: deptId }); + message.success('班级创建成功'); + setClassModalOpen(false); + classForm.resetFields(); + setCheckedKeys([]); + fetchClasses(); + } catch (e: any) { + message.error(e?.message || '创建失败'); + } finally { + setImporting(false); + } +}; +``` + +- [ ] **Step 7: Render Drawer with left-right layout** + +```tsx + { setDrawerOpen(false); }} + width={900} + footer={ + + + + + + } +> + + +
+ setCheckedKeys(checked as React.Key[])} + /> +
+ + + setClassModalOpen(true)}>+ 创建班级}> + ( + setSelectedClassId(cls.id)} + style={{ + cursor: 'pointer', + background: selectedClassId === cls.id ? '#e6f4ff' : undefined, + borderRadius: 4, + padding: '8px 12px', + }} + > + + + )} + /> + + +
+ + {/* 创建班级 Modal */} + { setClassModalOpen(false); classForm.resetFields(); }} + confirmLoading={importing} + destroyOnClose + > +
+ + + + + + + +