plan: fix student import name/phone from dingtalk
This commit is contained in:
390
docs/superpowers/plans/2026-07-10-fix-student-import-name.md
Normal file
390
docs/superpowers/plans/2026-07-10-fix-student-import-name.md
Normal file
@@ -0,0 +1,390 @@
|
||||
# 修复学生导入姓名与账号 — 实现计划
|
||||
|
||||
> **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
|
||||
```
|
||||
Reference in New Issue
Block a user