Files
gongxue-base/docs/superpowers/plans/2026-07-09-dingtalk-import-class-marking-plan.md

806 lines
22 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:** 在钉钉组织导入抽屉中支持标记部门为班级,导入时自动创建班级并建立师生关联。
**Architecture:** 前端在 IntegrationConfig 抽屉中添加部门级「标为班级」按钮和 Modal 表单;后端 `importDingTalkUsers` 方法接收可选的 `classes[]` 参数,在单事务中先建班、再导人、最后建关联。导入时勾选的用户分配角色后即为老师,所有老师统一写入 `ClassTeacher``roleType='teacher'`),班级对老师为多对多。
**Tech Stack:** React 19 + Ant Design 6 (前端), NestJS 11 + TypeORM 0.3 (后端), SQLite/MySQL
## Global Constraints
- 规范约束自 `CLAUDE.md`(恭学教育学生管理系统 — 项目约束)
- 前端编码 agent 需注入 `ui-ux-pro-max`Ant Design 6 交互规范)和 `vercel-react-best-practices`(性能优化)
- 后端编码 agent 需注入 `nestjs-best-practices`
- 所有编辑遵循现有 NestJS 模块结构
- 敏感信息脱敏规则照旧(不涉及本次改动)
- 遵循 skil `ponytail` full 级别约束:最简实现,不引入新依赖,不创建不必要的抽象
---
### Task 1: 后端 — 扩展 DTO 和钉钉接口返回 deptIds
**Files:**
- Modify: `apps/server/src/sync/dto/import-users.dto.ts`
- Modify: `apps/server/src/integration/dingtalk.service.ts:486-499`
**Interfaces:**
- Consumes: 现有 `ImportUserItemDto`, `DingOrgTreeNodeWithUsers`
- Produces: `ImportClassItemDto`, `ImportUserItemDto.dingDeptIds`, `DingOrgTreeNodeWithUsers.users[].deptIds`
- [ ] **Step 1: 扩展 ImportUsersDto新增 ImportClassItemDto**
编辑 `apps/server/src/sync/dto/import-users.dto.ts`,在现有 `ImportUserItemDto` 中加 `dingDeptIds` 字段,新增 `ImportClassItemDto``ImportUsersDto.classes`
```ts
import {
IsArray,
IsString,
IsNumber,
IsOptional,
IsEnum,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
export class ImportUserItemDto {
@IsString()
dingUserId: string;
@IsString()
name: string;
@IsString()
mobile: string;
@IsOptional()
@IsNumber()
roleId: number | null;
@IsArray()
@IsNumber({}, { each: true })
dingDeptIds: number[];
}
export class ImportClassItemDto {
@IsNumber()
deptId: number;
@IsString()
name: string;
@IsString()
code: string;
@IsString()
classType: string;
@IsOptional()
@IsString()
startDate?: string;
@IsOptional()
@IsString()
endDate?: string;
@IsOptional()
@IsNumber()
maxStudents?: number;
@IsOptional()
@IsString()
notes?: string;
}
export class ImportUsersDto {
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => ImportClassItemDto)
classes?: ImportClassItemDto[];
@IsArray()
@ValidateNested({ each: true })
@Type(() => ImportUserItemDto)
users: ImportUserItemDto[];
}
```
- [ ] **Step 2: dingtalk.service.ts — fetchOrgTreeWithUsers 返回 deptIds**
编辑 `apps/server/src/integration/dingtalk.service.ts`,在 `fetchOrgTreeWithUsers` 方法中保留 `dept_id_list` 到每个 user。
找到第 493-498 行的 users mapping改为
```ts
// Before dedup: collect deptIds per user
const userDeptMap = new Map<string, number[]>();
nodes.push({
id: detail.dept_id,
name: detail.name,
parentId: detail.parent_id,
children: [],
users: dingUsers.map((u) => ({
userid: u.userid,
name: u.name,
mobile: u.mobile,
})),
});
// Record which departments each user belongs to
for (const u of dingUsers) {
if (!userDeptMap.has(u.userid)) {
userDeptMap.set(u.userid, []);
}
userDeptMap.get(u.userid)!.push(detail.dept_id);
}
```
然后在去重循环后(第 503-509 行),为每个 user 附加 deptIds
```ts
for (const node of nodes) {
node.users = node.users
.filter((u) => {
if (seenUserIds.has(u.userid)) return false;
seenUserIds.add(u.userid);
return true;
})
.map((u) => ({
...u,
deptIds: userDeptMap.get(u.userid) || [],
}));
}
```
同步更新 `DingOrgTreeNodeWithUsers` interface
```ts
export interface DingOrgTreeNodeWithUsers {
id: number;
name: string;
parentId: number;
children: DingOrgTreeNodeWithUsers[];
users: Array<{
userid: string;
name: string;
mobile: string;
deptIds: number[];
}>;
}
```
- [ ] **Step 3: 编译验证**
```bash
cd apps/server && npx tsc --noEmit
```
Expected: no new type errors from the modified files.
- [ ] **Step 4: Commit**
```bash
git add apps/server/src/sync/dto/import-users.dto.ts apps/server/src/integration/dingtalk.service.ts
git commit -m "feat(sync): add ImportClassItemDto and expose deptIds in org-tree-with-users"
```
---
### Task 2: 后端 — 改造 importDingTalkUsers 支持班级关联
**Files:**
- Modify: `apps/server/src/sync/sync.service.ts:122-199`
- Modify: `apps/server/src/sync/sync.module.ts:16-34`
**Interfaces:**
- Consumes: `ImportClassItemDto`, `ImportUserItemDto.dingDeptIds` (from Task 1)
- Produces: 改造后的 `importDingTalkUsers(classes?: ImportClassItemDto[], users: ImportUserItemDto[])`,返回增加 `classCount`
- [ ] **Step 1: sync.module.ts — 注入 Class 和 ClassStudent Repository**
SyncModule 当前未导入 `Class``ClassStudent` entity。编辑 `apps/server/src/sync/sync.module.ts`
```ts
import {
SyncLog, SyncState, UserDingMapping, ClassSchedule, Department,
UserDepartment, ClassTeacher, User, Student, Role,
Class, // 新增
ClassStudent, // 新增
} from '../entities';
```
并在 `TypeOrmModule.forFeature` 数组中添加 `Class, ClassStudent`
- [ ] **Step 2: sync.service.ts — constructor 注入新 repo**
编辑 `apps/server/src/sync/sync.service.ts`
```ts
import { Class } from '../entities/class.entity';
import { ClassStudent } from '../entities/class-student.entity';
import type { ImportClassItemDto } from './dto/import-users.dto';
```
Constructor 添加:
```ts
@InjectRepository(Class)
private readonly classRepo: Repository<Class>,
@InjectRepository(ClassStudent)
private readonly classStudentRepo: Repository<ClassStudent>,
```
更新 `ImportUserDto` 接口以包含 `dingDeptIds`
```ts
export interface ImportUserDto {
dingUserId: string;
name: string;
mobile: string;
roleId: number | null;
dingDeptIds: number[];
}
```
- [ ] **Step 3: 改写 importDingTalkUsers 方法签名和逻辑**
将方法签名改为:
```ts
async importDingTalkUsers(
users: ImportUserDto[],
classes?: ImportClassItemDto[],
): Promise<{
teacherCount: number;
studentCount: number;
classCount: number;
skipped: number;
warnings: string[];
}>
```
完整方法体替换为单事务版本:
```ts
async importDingTalkUsers(
users: ImportUserDto[],
classes?: ImportClassItemDto[],
): Promise<{
teacherCount: number;
studentCount: number;
classCount: number;
skipped: number;
warnings: string[];
}> {
const classItems = classes ?? [];
const warnings: string[] = [];
// 预检查班级编码重复
if (classItems.length > 0) {
const codes = classItems.map((c) => c.code);
const existing = await this.classRepo.find({ where: codes.map((code) => ({ code } as any)) });
if (existing.length > 0) {
const dup = existing.map((c) => c.code).join(', ');
throw new BadRequestException(`班级编码已存在: ${dup}`);
}
}
let teacherCount = 0;
let studentCount = 0;
let skipped = 0;
await this.dataSource.transaction(async (manager) => {
// 1. 创建班级
const deptClassMap = new Map<number, number>(); // deptId -> classId
for (const c of classItems) {
const cls = manager.create(Class, {
name: c.name,
code: c.code,
classType: c.classType,
startDate: c.startDate ?? null,
endDate: c.endDate ?? null,
maxStudents: c.maxStudents ?? 0,
notes: c.notes ?? null,
} as any);
await manager.save(cls);
deptClassMap.set(c.deptId, cls.id);
}
// 2. 导入用户(逐用户)
for (const u of users) {
const existingMapping = await manager.findOne(UserDingMapping, {
where: { dingUserId: u.dingUserId },
});
if (existingMapping) {
skipped++;
continue;
}
const username = `dd_${u.dingUserId}`;
const passwordHash = await bcrypt.hash('123456', 10);
const user = manager.create(User, {
username,
name: u.name,
passwordHash,
isActive: true,
});
await manager.save(user);
let isTeacher = false;
let isHeadTeacher = false;
if (u.roleId != null) {
const role = await manager.findOne(Role, { where: { id: u.roleId } });
if (!role) {
throw new BadRequestException(`角色 id=${u.roleId} 不存在`);
}
user.roles = [role];
let isTeacher = false;
if (u.roleId != null) {
const role = await manager.findOne(Role, { where: { id: u.roleId } });
if (!role) {
throw new BadRequestException(`角色 id=${u.roleId} 不存在`);
}
user.roles = [role];
await manager.save(user);
isTeacher = true;
teacherCount++;
} else {
const student = manager.create(Student, {
name: u.name,
phone: u.mobile || undefined,
userId: user.id,
status: 'active',
});
await manager.save(student);
studentCount++;
}
// 钉钉映射
const mapping = manager.create(UserDingMapping, {
dingUserId: u.dingUserId,
userId: user.id,
dingName: u.name,
dingMobile: u.mobile,
});
await manager.save(mapping);
// 3. 建立班级关联
if (classItems.length > 0 && u.dingDeptIds?.length > 0) {
for (const deptId of u.dingDeptIds) {
const classId = deptClassMap.get(deptId);
if (!classId) continue;
if (isTeacher) {
const ct = manager.create(ClassTeacher, {
classId,
userId: user.id,
roleType: 'teacher',
} as any);
await manager.save(ct);
} else {
const cs = manager.create(ClassStudent, {
classId,
studentId: (await manager.findOne(Student, { where: { userId: user.id } }))?.id,
status: 'active',
} as any);
await manager.save(cs);
}
}
}
}
// 4. 检查空班级
for (const [deptId, classId] of deptClassMap) {
const tc = await manager.count(ClassTeacher, { where: { classId } });
const sc = await manager.count(ClassStudent, { where: { classId } });
if (tc === 0 && sc === 0) {
const cls = await manager.findOne(Class, { where: { id: classId } });
warnings.push(`班级 "${cls?.name}" (deptId=${deptId}) 无任何师生`);
}
}
});
this.logger.log(
`钉钉用户导入完成: ${teacherCount} 位老师, ${studentCount} 位学生, ${classItems.length} 个班级, ${skipped} 跳过`,
);
return { teacherCount, studentCount, classCount: classItems.length, skipped, warnings };
}
```
需要新增 import
```ts
import { BadRequestException } from '@nestjs/common';
import { Class } from '../entities/class.entity';
import { ClassStudent } from '../entities/class-student.entity';
```
- [ ] **Step 4: 编译验证**
```bash
cd apps/server && npx tsc --noEmit
```
Expected: no errors.
- [ ] **Step 5: 更新 sync.controller.ts 调用方式**
`apps/server/src/sync/sync.controller.ts` 第 57 行:
```ts
const result = await this.syncService.importDingTalkUsers(body.users);
```
改为:
```ts
const result = await this.syncService.importDingTalkUsers(body.users, body.classes);
```
- [ ] **Step 6: Commit**
```bash
git add apps/server/src/sync/sync.module.ts apps/server/src/sync/sync.service.ts apps/server/src/sync/sync.controller.ts
git commit -m "feat(sync): importDingTalkUsers supports class creation and teacher/student linking"
```
---
### Task 3: 前端 — IntegrationConfig 树节点和班级标记 Modal
**Files:**
- Modify: `apps/admin/src/pages/IntegrationConfig/index.tsx`
**Interfaces:**
- Consumes: 改造后的 `POST /sync/dingtalk/import-users` (classes + users)DingOrgTreeNodeExt 新增 deptIds
- Produces: 树中部门节点可标为班级Modal 表单,导入 payload 含 classes
**Skills to load before coding:**
- `ui-ux-pro-max` — Ant Design 6 组件选型、交互细节
- `vercel-react-best-practices` — memo、useMemo 避免无意义重渲染
- [ ] **Step 1: 扩展前端类型定义**
`IntegrationConfig/index.tsx` 的 interface 定义区域,修改 `DingOrgTreeNodeExt`
```ts
interface DingOrgTreeNodeExt {
id: number;
name: string;
parentId: number;
children: DingOrgTreeNodeExt[];
users: Array<{ userid: string; name: string; mobile: string; deptIds: number[] }>;
}
```
新增 class mark 表单类型和状态:
```ts
interface ClassMarkForm {
deptId: number;
name: string;
code: string;
classType: string;
startDate?: string;
endDate?: string;
maxStudents?: number;
notes?: string;
}
```
在组件 state 区域(第 91-101 行附近)新增:
```ts
const [classMarks, setClassMarks] = useState<Record<number, ClassMarkForm>>({});
const [classModalOpen, setClassModalOpen] = useState(false);
const [classModalDept, setClassModalDept] = useState<{ id: number; name: string } | null>(null);
const [classForm] = Form.useForm<ClassMarkForm>();
```
- [ ] **Step 2: 标记班级 Modal 组件**
在组件内部(`handleImportUsers` 之前)添加 Modal 处理函数:
```ts
const openClassModal = (deptId: number, deptName: string) => {
const existing = classMarks[deptId];
if (existing) {
classForm.setFieldsValue(existing);
} else {
classForm.setFieldsValue({
deptId,
name: deptName,
code: '',
classType: 'culture',
});
}
setClassModalDept({ id: deptId, name: deptName });
setClassModalOpen(true);
};
const handleClassModalOk = async () => {
const values = await classForm.validateFields();
setClassMarks((prev) => ({
...prev,
[values.deptId]: values,
}));
setClassModalOpen(false);
setClassModalDept(null);
};
const handleClassModalCancel = () => {
setClassModalOpen(false);
setClassModalDept(null);
};
```
Modal JSX放在 Drawer 之前或之后):
```tsx
<Modal
title={classMarks[classModalDept?.id ?? -1] ? '修改班级信息' : '标记为班级'}
open={classModalOpen}
onOk={handleClassModalOk}
onCancel={handleClassModalCancel}
destroyOnClose
>
<Form form={classForm} layout="vertical">
<Form.Item name="deptId" hidden><Input /></Form.Item>
<Form.Item name="name" label="班级名称" rules={[{ required: true, message: '请输入班级名称' }]}>
<Input />
</Form.Item>
<Form.Item name="code" label="班级编码" rules={[{ required: true, message: '请输入班级编码' }]}>
<Input placeholder="如 CS2024-01" />
</Form.Item>
<Form.Item name="classType" label="班型" rules={[{ required: true }]}>
<Select
options={[
{ value: 'culture', label: '文化课' },
{ value: 'professional', label: '专业课' },
{ value: 'bootcamp', label: '集训营' },
{ value: 'sprint', label: '冲刺班' },
]}
/>
</Form.Item>
<Form.Item name="startDate" label="开班日期">
<DatePicker style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="endDate" label="结束日期">
<DatePicker style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="maxStudents" label="最大人数">
<InputNumber min={0} style={{ width: '100%' }} placeholder="0 表示不限制" />
</Form.Item>
<Form.Item name="notes" label="备注">
<TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
```
需要新增 import`Modal, DatePicker, InputNumber` from antd已有 Modal、Drawer、Select、Input检查是否缺少
- [ ] **Step 3: 树节点中嵌入班级操作**
`buildTreeData` 函数(约第 251 行)中,修改部门节点的 `title` 显示:
```ts
const buildTreeData = useCallback((nodes: DingOrgTreeNodeExt[]): DataNode[] => {
return nodes.map((node) => ({
title: (
<Space size="small">
<span>{node.name}</span>
{classMarks[node.id] ? (
<Tag
color="blue"
style={{ cursor: 'pointer' }}
onClick={() => openClassModal(node.id, node.name)}
>
班级: {classMarks[node.id].name} [已标记]
</Tag>
) : (
<Button
size="small"
type="link"
icon={<span>🏫</span>}
onClick={() => openClassModal(node.id, node.name)}
>
标为班级
</Button>
)}
</Space>
),
key: `dept-${node.id}`,
children: [
...buildTreeData(node.children),
...node.users.map((u) => ({
title: (
<UserTreeNode
key={u.userid}
u={{ userid: u.userid, name: u.name, mobile: u.mobile }}
isTeacher={!!teacherChecks[u.userid]}
onToggle={() => { /* ... existing toggle logic ... */ }}
roleId={teacherRoles[u.userid]}
defaultRoleId={defaultTeacherRoleId}
roles={roles}
onRoleChange={(newRoleId) => { /* ... existing role change logic ... */ }}
/>
),
key: `user-${u.userid}`,
isLeaf: true,
})),
],
}));
}, [classMarks, teacherChecks, teacherRoles, defaultTeacherRoleId, roles]);
```
- [ ] **Step 4: 修改 handleImportUsers payload**
编辑 `handleImportUsers`(约第 209 行),在 payload 中加上 classes
```ts
const payload = {
classes: Object.values(classMarks),
users: allUsers.map((u) => ({
dingUserId: u.userid,
name: u.name,
mobile: u.mobile,
roleId: teacherChecks[u.userid]
? (teacherRoles[u.userid] || defaultTeacherRoleId)
: null,
dingDeptIds: u.deptIds || [], // 新增
})),
};
```
注意:`allUsers` 需要在 flatten 时也收集 deptIds。修改 flatten
```ts
const flatten = (nodes: DingOrgTreeNodeExt[]) => {
for (const node of nodes) {
allUsers.push(...node.users);
flatten(node.children);
}
};
```
由于 `node.users` 现在包含 `deptIds``allUsers` 的类型需调整。修改 `allUsers` 声明:
```ts
const allUsers: Array<{
dingUserId: string;
name: string;
mobile: string;
deptIds: number[];
}> = [];
```
在 flatten 中 push 时展开正确字段:
```ts
allUsers.push(
...node.users.map((u) => ({
dingUserId: u.userid,
name: u.name,
mobile: u.mobile,
deptIds: u.deptIds || [],
})),
);
```
然后 payload 中 `u.deptIds` 可用。
- [ ] **Step 5: 关闭抽屉时清理 classMarks**
`onClose` 处理中(已有 `setDrawerOpen(false)` 的地方)加:
```ts
setClassMarks({});
```
- [ ] **Step 6: 编译前端验证**
```bash
cd apps/admin && npx tsc --noEmit
```
修正所有类型错误。
- [ ] **Step 7: Commit**
```bash
git add apps/admin/src/pages/IntegrationConfig/index.tsx
git commit -m "feat(admin): add class marking modal in DingTalk org import drawer"
```
---
### Task 4: 后端 — 编写测试
**Files:**
- Modify: `apps/server/src/sync/sync.service.spec.ts`
**Interfaces:**
- Consumes: 改造后的 `importDingTalkUsers` (Task 2)
- Produces: 7 个新测试用例
**Skills to load before coding:**
- Tester agent — 但不在此处写测试,而是委托给 Tester 子代理
- [ ] **Step 1: 委托 Tester 子代理编写测试**
由于同步服务已有完善的 mock 基础设施,直接委托 Tester 代理根据 spec 编写以下用例:
1. **单部门标班级 + 1老师 + 1学生** — 验证 Class, ClassTeacher(roleType='teacher'), ClassStudent 均创建
2. **单部门多老师** — 所有老师均写入 ClassTeacher
3. **用户在多个被标记部门** — 同时加入多个班级的 ClassStudent 或 ClassTeacher
4. **班级编码重复** — 事务回滚,抛出 BadRequestException
5. **空部门(无人)** — 班级创建,返回 warning
6. **纯学生无老师** — 班级创建ClassStudent 正确
7. **无 classes 参数** — 向下兼容,返回结果中 classCount=0
代理需扩展 mock Manager 以支持 `Class.create/save/findOne/count``ClassStudent.create/save/count`
- [ ] **Step 2: 运行全部 sync 测试**
```bash
cd apps/server && npx jest --testPathPattern='sync.service.spec' --no-coverage
```
Expected: 全部通过。
- [ ] **Step 3: Commit**
```bash
git add apps/server/src/sync/sync.service.spec.ts
git commit -m "test(sync): add class-marking import test cases"
```
---
### Task 5: 端到端验证 & 清理
**Files:** 无新增,仅验证
- [ ] **Step 1: 启动后端**
```bash
cd apps/server && npm run start:dev
```
确认无启动错误。
- [ ] **Step 2: 启动前端**
```bash
cd apps/admin && npm run dev
```
- [ ] **Step 3: 手动验证流程**
1. 打开浏览器 → 钉钉集成配置页
2. 点击「获取组织架构」→ 确认部门节点显示「🏫 标为班级」按钮
3. 点击按钮 → Modal 弹出,部门名已预填
4. 填写编码、班型 → 确定 → 节点显示 `🏫 班级: XXX [已标记]`
5. 在该部门下勾选一个用户为「老师」角色
6. 点击「导入」→ 确认成功
7. 到班级管理页验证:班级存在、老师关联正确、学生归属正确
- [ ] **Step 4: 验证向下兼容**
不标记任何班级,仅勾选老师学生 → 导入 → 确认行为不变。
---
## Self-Review Checklist
1. **Spec coverage**: DTO 扩展 ✓, 服务层逻辑 ✓, 前端树节点 ✓, Modal 表单 ✓, 导入 payload ✓, 错误场景 ✓, 测试策略 ✓
2. **Placeholder scan**: 无 TBD/TODO所有代码块均为具体实现
3. **Type consistency**: `ImportUserDto.dingDeptIds` 在 Task 1 DTO 和 Task 2 service 中类型一致;`ClassMarkForm` 在 Task 3 定义和使用一致