plan: fix student import name/phone from dingtalk

This commit is contained in:
2026-07-10 09:47:26 +08:00
parent e7e335cd95
commit 1f9308c1b7

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