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

22 KiB
Raw Blame History

钉钉导入标记班级 — 实现计划

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[] 参数,在单事务中先建班、再导人、最后建关联。导入时勾选的用户分配角色后即为老师,所有老师统一写入 ClassTeacherroleType='teacher'),班级对老师为多对多。

Tech Stack: React 19 + Ant Design 6 (前端), NestJS 11 + TypeORM 0.3 (后端), SQLite/MySQL

Global Constraints

  • 规范约束自 CLAUDE.md(恭学教育学生管理系统 — 项目约束)
  • 前端编码 agent 需注入 ui-ux-pro-maxAnt 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 字段,新增 ImportClassItemDtoImportUsersDto.classes

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改为

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

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

export interface DingOrgTreeNodeWithUsers {
  id: number;
  name: string;
  parentId: number;
  children: DingOrgTreeNodeWithUsers[];
  users: Array<{
    userid: string;
    name: string;
    mobile: string;
    deptIds: number[];
  }>;
}
  • Step 3: 编译验证
cd apps/server && npx tsc --noEmit

Expected: no new type errors from the modified files.

  • Step 4: Commit
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 当前未导入 ClassClassStudent entity。编辑 apps/server/src/sync/sync.module.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

import { Class } from '../entities/class.entity';
import { ClassStudent } from '../entities/class-student.entity';
import type { ImportClassItemDto } from './dto/import-users.dto';

Constructor 添加:

@InjectRepository(Class)
private readonly classRepo: Repository<Class>,
@InjectRepository(ClassStudent)
private readonly classStudentRepo: Repository<ClassStudent>,

更新 ImportUserDto 接口以包含 dingDeptIds

export interface ImportUserDto {
  dingUserId: string;
  name: string;
  mobile: string;
  roleId: number | null;
  dingDeptIds: number[];
}
  • Step 3: 改写 importDingTalkUsers 方法签名和逻辑

将方法签名改为:

async importDingTalkUsers(
  users: ImportUserDto[],
  classes?: ImportClassItemDto[],
): Promise<{
  teacherCount: number;
  studentCount: number;
  classCount: number;
  skipped: number;
  warnings: string[];
}>

完整方法体替换为单事务版本:

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

import { BadRequestException } from '@nestjs/common';
import { Class } from '../entities/class.entity';
import { ClassStudent } from '../entities/class-student.entity';
  • Step 4: 编译验证
cd apps/server && npx tsc --noEmit

Expected: no errors.

  • Step 5: 更新 sync.controller.ts 调用方式

apps/server/src/sync/sync.controller.ts 第 57 行:

const result = await this.syncService.importDingTalkUsers(body.users);

改为:

const result = await this.syncService.importDingTalkUsers(body.users, body.classes);
  • Step 6: Commit
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

interface DingOrgTreeNodeExt {
  id: number;
  name: string;
  parentId: number;
  children: DingOrgTreeNodeExt[];
  users: Array<{ userid: string; name: string; mobile: string; deptIds: number[] }>;
}

新增 class mark 表单类型和状态:

interface ClassMarkForm {
  deptId: number;
  name: string;
  code: string;
  classType: string;
  startDate?: string;
  endDate?: string;
  maxStudents?: number;
  notes?: string;
}

在组件 state 区域(第 91-101 行附近)新增:

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 处理函数:

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 之前或之后):

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

需要新增 importModal, DatePicker, InputNumber from antd已有 Modal、Drawer、Select、Input检查是否缺少

  • Step 3: 树节点中嵌入班级操作

buildTreeData 函数(约第 251 行)中,修改部门节点的 title 显示:

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

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

const flatten = (nodes: DingOrgTreeNodeExt[]) => {
  for (const node of nodes) {
    allUsers.push(...node.users);
    flatten(node.children);
  }
};

由于 node.users 现在包含 deptIdsallUsers 的类型需调整。修改 allUsers 声明:

const allUsers: Array<{
  dingUserId: string;
  name: string;
  mobile: string;
  deptIds: number[];
}> = [];

在 flatten 中 push 时展开正确字段:

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) 的地方)加:

setClassMarks({});
  • Step 6: 编译前端验证
cd apps/admin && npx tsc --noEmit

修正所有类型错误。

  • Step 7: Commit
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/countClassStudent.create/save/count

  • Step 2: 运行全部 sync 测试
cd apps/server && npx jest --testPathPattern='sync.service.spec' --no-coverage

Expected: 全部通过。

  • Step 3: Commit
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: 启动后端
cd apps/server && npm run start:dev

确认无启动错误。

  • Step 2: 启动前端
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 定义和使用一致