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

391 lines
9.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 修复学生导入姓名与账号 — 实现计划
> **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
```