# 修复学生导入姓名与账号 — 实现计划 > **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:** 修复批量导入学生时 name 填占位符 `dd_xxx`、phone 未设置的问题,改为使用前端传入的真实姓名和手机号。 **Architecture:** 前端已有钉钉用户 name/mobile 数据,导入时随请求传给后端,后端直接用于创建 Student,零额外钉钉 API 调用。 **Tech Stack:** NestJS + TypeORM + React + Ant Design ## Global Constraints - 不改 Student entity / 数据库 schema - 不改 DingTalkService - 不改 StudentDingMapping - `tsc --noEmit` 编译通过 --- ### Task 1: 后端 DTO — 字段改名 **Files:** - Modify: `apps/server/src/classes/dto/class.dto.ts` **Interfaces:** - Consumes: nothing - Produces: `CreateClassDto.users`, `BatchImportStudentsDto.users` - [ ] **Step 1: 改 BatchImportStudentsDto** `class.dto.ts` 第 136-141 行: ```typescript export class BatchImportStudentsDto { @IsArray() @IsString({ each: true }) @ArrayNotEmpty() dingUserIds: string[]; } ``` 改为: ```typescript export class BatchImportStudentsDto { @IsArray() @ArrayNotEmpty() @ValidateNested({ each: true }) @Type(() => ImportUserItem) users: ImportUserItem[]; } export class ImportUserItem { @IsString() @IsNotEmpty() dingUserId: string; @IsString() @IsNotEmpty() name: string; @IsOptional() @IsString() mobile?: string; } ``` - [ ] **Step 2: 改 CreateClassDto** 第 43-46 行,`dingUserIds` 字段: ```typescript @IsArray() @IsString({ each: true }) @IsOptional() dingUserIds?: string[]; ``` 改为: ```typescript @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => ImportUserItem) users?: ImportUserItem[]; ``` - [ ] **Step 3: 补 import** 文件头部 import 行(第 1 行),追加 `ValidateNested`: ```typescript import { IsOptional, IsString, IsNotEmpty, IsInt, IsArray, IsDateString, IsEnum, ArrayNotEmpty, ValidateNested } from 'class-validator'; ``` - [ ] **Step 4: 编译验证** ```bash cd apps/server && npx tsc --noEmit 2>&1 | head -20 ``` 预期:Pass(如果后续 Task 没改完会有 type error,属于预期) - [ ] **Step 5: Commit** ```bash git add apps/server/src/classes/dto/class.dto.ts git commit -m "feat: change dingUserIds to users array with name/mobile in DTOs" ``` --- ### Task 2: 后端 Service — 使用真实 name 和 phone **Files:** - Modify: `apps/server/src/classes/classes.service.ts:96-191` **Interfaces:** - Consumes: `BatchImportStudentsDto.users`, `CreateClassDto.users` (from Task 1) - Produces: updated `batchImportStudents` signature - [ ] **Step 1: 改 create() 方法** 第 96-128 行,两处 `dingUserIds` → `users`: ```typescript async create(dto: CreateClassDto) { const { studentIds, teachers, users, ...classData } = dto; … // batch import students by dingUserIds if (users?.length) { await this.batchImportStudents(saved.id, users); } … } ``` - [ ] **Step 2: 改 batchImportStudents 签名和逻辑** 第 131-191 行: ```typescript async batchImportStudents(classId: number, users: Array<{ dingUserId: string; name: string; mobile?: string; }>): Promise<{ imported: number; skipped: number }> { const classEntity = await this.classRepo.findOne({ where: { id: classId } }); if (!classEntity) throw new NotFoundException('班级不存在'); if (users.length === 0) return { imported: 0, skipped: 0 }; const dingUserIds = users.map(u => u.dingUserId); // 1. Fetch all existing ding mappings in one query const existingMappings = await this.studentDingMappingRepo.find({ where: { dingUserId: In(dingUserIds) }, }); const dingToStudentId = new Map(existingMappings.map(m => [m.dingUserId, m.studentId])); // 2. Batch create students for new dingUserIds const newUsers = users.filter(u => !dingToStudentId.has(u.dingUserId)); if (newUsers.length > 0) { const newStudents = newUsers.map(u => this.studentRepo.create({ name: u.name, phone: u.mobile || `dt_${u.dingUserId}`, status: 'active', }) ); const savedStudents = await this.studentRepo.save(newStudents); const newMappings = savedStudents.map((s, i) => this.studentDingMappingRepo.create({ dingUserId: newUsers[i].dingUserId, studentId: s.id }) ); await this.studentDingMappingRepo.save(newMappings); for (let i = 0; i < newUsers.length; i++) { dingToStudentId.set(newUsers[i].dingUserId, savedStudents[i].id); } } // 3. Fetch existing class-student links in one query const allStudentIds = Array.from(dingToStudentId.values()); … // 不变 } ``` - [ ] **Step 3: 编译验证** ```bash cd apps/server && npx tsc --noEmit 2>&1 | head -20 ``` 预期:Pass - [ ] **Step 4: Commit** ```bash git add apps/server/src/classes/classes.service.ts git commit -m "fix: use real name and phone from frontend when importing students" ``` --- ### Task 3: 后端 Controller — 传参修正 **Files:** - Modify: `apps/server/src/classes/classes.controller.ts:93-98` **Interfaces:** - Consumes: `BatchImportStudentsDto.users` (from Task 1) - Produces: nothing new - [ ] **Step 1: 改 controller 传参** 第 97 行: ```typescript return this.service.batchImportStudents(+id, dto.users); ``` - [ ] **Step 2: 编译验证** ```bash cd apps/server && npx tsc --noEmit 2>&1 | head -20 ``` 预期:Pass - [ ] **Step 3: Commit** ```bash git add apps/server/src/classes/classes.controller.ts git commit -m "fix: pass dto.users instead of dto.dingUserIds in controller" ``` --- ### Task 4: 前端 — 传用户信息而非仅 ID **Files:** - Modify: `apps/admin/src/pages/IntegrationConfig/index.tsx` **Interfaces:** - Consumes: `orgTree` (state), `checkedKeys` (state) - Produces: updated API call payloads - [ ] **Step 1: 写 extractCheckedUsers 工具函数** 在 `handleJoinClass` 上方插入: ```typescript const extractCheckedUsers = useCallback((): Array<{ dingUserId: string; name: string; mobile?: string }> => { const result: Array<{ dingUserId: string; name: string; mobile?: string }> = []; const walk = (nodes: DingOrgTreeNodeExt[]) => { for (const node of nodes) { for (const u of node.users) { if (checkedKeys.includes(`user-${u.userid}`)) { result.push({ dingUserId: u.userid, name: u.name, mobile: u.mobile || undefined }); } } walk(node.children); } }; walk(orgTree); return result; }, [checkedKeys, orgTree]); ``` - [ ] **Step 2: 改 handleJoinClass** 第 212-231 行,旧代码: ```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: unknown) { const err = e as { message?: string }; message.error(err?.message || '导入失败'); } finally { setImporting(false); } }; ``` 改为: ```typescript const handleJoinClass = async () => { if (selectedClassId === null) return message.warning('请先选择一个班级'); const users = extractCheckedUsers(); if (users.length === 0) return message.warning('请勾选要导入的用户'); setImporting(true); try { const res = await api.post(`/classes/${selectedClassId}/students/import`, { users }); message.success(`导入 ${res.imported} 人,跳过 ${res.skipped} 人`); setCheckedKeys([]); setSelectedClassId(null); } catch (e: unknown) { const err = e as { message?: string }; message.error(err?.message || '导入失败'); } finally { setImporting(false); } }; ``` - [ ] **Step 3: 改 handleCreateClass** 第 233-251 行: ```typescript const handleCreateClass = async () => { try { const values = await classForm.validateFields(); const users = extractCheckedUsers(); await api.post('/classes', { ...values, users }); message.success('班级创建成功'); setClassModalOpen(false); classForm.resetFields(); setCheckedKeys([]); fetchClasses(); } catch (e: unknown) { const err = e as { message?: string }; message.error(err?.message || '创建失败'); } finally { setImporting(false); } }; ``` (关键变更:`dingUserIds: userIds` → `users`,删除手动的 `.filter().map()` 逻辑) - [ ] **Step 4: 前端编译验证** ```bash cd apps/admin && npx tsc --noEmit 2>&1 | head -20 ``` 预期:Pass - [ ] **Step 5: Commit** ```bash git add apps/admin/src/pages/IntegrationConfig/index.tsx git commit -m "fix: pass user name and mobile to backend when importing students" ``` --- ### Task 5: 端到端验证 - [ ] **Step 1: 后端全量编译** ```bash cd apps/server && npx tsc --noEmit ``` 预期:0 errors - [ ] **Step 2: 前端全量编译** ```bash cd apps/admin && npx tsc --noEmit ``` 预期:0 errors - [ ] **Step 3: 后端测试** ```bash cd apps/server && npm test 2>&1 | tail -20 ``` 预期:现有测试全部通过 - [ ] **Step 4: Commit(如有遗漏)** ```bash git status ```