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

> **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<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);
    }
  };
```

改为：

```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<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 行：

```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
```
