Files
gongxue-base/docs/superpowers/plans/2026-07-10-fix-student-import-name.md

9.7 KiB
Raw Blame History

修复学生导入姓名与账号 — 实现计划

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 行:

export class BatchImportStudentsDto {
  @IsArray()
  @IsString({ each: true })
  @ArrayNotEmpty()
  dingUserIds: string[];
}

改为:

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 字段:

  @IsArray()
  @IsString({ each: true })
  @IsOptional()
  dingUserIds?: string[];

改为:

  @IsOptional()
  @IsArray()
  @ValidateNested({ each: true })
  @Type(() => ImportUserItem)
  users?: ImportUserItem[];
  • Step 3: 补 import

文件头部 import 行(第 1 行),追加 ValidateNested

import { IsOptional, IsString, IsNotEmpty, IsInt, IsArray, IsDateString, IsEnum, ArrayNotEmpty, ValidateNested } from 'class-validator';
  • Step 4: 编译验证
cd apps/server && npx tsc --noEmit 2>&1 | head -20

预期Pass如果后续 Task 没改完会有 type error属于预期

  • Step 5: Commit
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 行,两处 dingUserIdsusers

  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 行:

  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: 编译验证
cd apps/server && npx tsc --noEmit 2>&1 | head -20

预期Pass

  • Step 4: Commit
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 行:

    return this.service.batchImportStudents(+id, dto.users);
  • Step 2: 编译验证
cd apps/server && npx tsc --noEmit 2>&1 | head -20

预期Pass

  • Step 3: Commit
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 上方插入:

  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 行,旧代码:

  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<ImportResult>(`/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);
    }
  };

改为:

  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<ImportResult>(`/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 行:

  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: userIdsusers,删除手动的 .filter().map() 逻辑)

  • Step 4: 前端编译验证
cd apps/admin && npx tsc --noEmit 2>&1 | head -20

预期Pass

  • Step 5: Commit
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: 后端全量编译
cd apps/server && npx tsc --noEmit

预期0 errors

  • Step 2: 前端全量编译
cd apps/admin && npx tsc --noEmit

预期0 errors

  • Step 3: 后端测试
cd apps/server && npm test 2>&1 | tail -20

预期:现有测试全部通过

  • Step 4: Commit如有遗漏
git status