Files
gongxue-base/docs/superpowers/plans/2026-07-09-student-ding-mapping.md

20 KiB

Student Ding Mapping + Drawer Batch Operations — Implementation Plan

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. Required skills per agent: superpowers:vercel-react-best-practices, superpowers:ui-ux-pro-max

Goal: Replace UserDingMapping with StudentDingMapping, rewrite syncAll to create Students directly, add class batch-import endpoint, rebuild Drawer with checkable Tree + class list.

Architecture: Student becomes independent from User — sync creates Student + StudentDingMapping directly. Frontend Drawer uses Ant Design <Tree checkable> with left-right split layout for batch user selection and class operations.

Tech Stack: NestJS 11 + TypeORM 0.3 + Ant Design 6

Global Constraints

  • Student is independent of User (no userId, no RBAC)
  • StudentDingMapping replaces UserDingMapping everywhere
  • fetchOrgTree (dept picker) is NOT modified
  • syncAll still syncs all departments — only user handling changes
  • Depth filtering (getDeptDepthMap) is removed entirely

Task 1: Create StudentDingMapping entity + swap in all modules

Files:

  • Create: apps/server/src/entities/student-ding-mapping.entity.ts
  • Modify: apps/server/src/entities/index.ts
  • Modify: apps/server/src/app.module.ts
  • Modify: apps/server/src/integration/dingtalk.service.ts — import + repo injection
  • Modify: apps/server/src/integration/integration.module.ts — TypeOrmModule.forFeature
  • Modify: apps/server/src/sync/sync.service.ts — import + repo injection
  • Modify: apps/server/src/sync/sync.module.ts — TypeOrmModule.forFeature
  • Modify: apps/server/src/sync/schedule-sync.service.ts — import + repo injection
  • Modify: apps/server/src/attendance/attendance-import.service.ts — import + repo injection
  • Modify: apps/server/src/attendance/attendance.module.ts — TypeOrmModule.forFeature
  • Modify: apps/server/src/attendance/attendance.service.ts — import + repo injection
  • Modify: apps/server/src/rbac/rbac.service.ts — import + repo injection
  • Modify: apps/server/src/rbac/rbac.module.ts — TypeOrmModule.forFeature

Interfaces:

  • Produces: StudentDingMapping entity with id, dingUserId(unique), studentId(unique), student(ManyToOne), createdAt

  • Note: rbac controller endpoints (getUserDingMappings etc.) removed in Task 4

  • Step 1: Create student-ding-mapping.entity.ts

import {
  Entity, PrimaryGeneratedColumn, Column, CreateDateColumn,
  ManyToOne, JoinColumn,
} from 'typeorm';
import { Student } from './student.entity';

@Entity('student_ding_mapping')
export class StudentDingMapping {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ name: 'ding_user_id', length: 100, unique: true })
  dingUserId: string;

  @Column({ name: 'student_id', type: 'integer', unique: true })
  studentId: number;

  @ManyToOne(() => Student, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'student_id' })
  student: Student;

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;
}
  • Step 2: Swap exports in entities/index.ts

Remove: export { UserDingMapping } from './user-ding-mapping.entity'; Add: export { StudentDingMapping } from './student-ding-mapping.entity';

  • Step 3: Swap in app.module.ts

In the import from './entities': replace UserDingMapping with StudentDingMapping. In the TypeOrmModule.forRoot entities array: same replacement.

  • Step 4: Swap in every module file

For each file listed above, replace mechanically:

  • UserDingMappingStudentDingMapping

  • user_ding_mappingstudent_ding_mapping

  • mappingRepo / userDingMappingRepostudentDingMappingRepo

  • Step 5: Delete user-ding-mapping.entity.ts

  • Step 6: Verify compile

cd apps/server && npx tsc --noEmit

Expected: zero new errors (pre-existing spec-file errors ignored).

  • Step 7: Commit
git add apps/server/src/entities/ apps/server/src/app.module.ts apps/server/src/integration/ apps/server/src/sync/ apps/server/src/attendance/ apps/server/src/rbac/
git commit -m "refactor: replace UserDingMapping with StudentDingMapping entity"

Task 2: Rewrite syncAll to create Student directly

Files:

  • Modify: apps/server/src/integration/dingtalk.service.tssyncAll and syncOneUser

Interfaces:

  • Consumes: StudentDingMapping entity (Task 1)

  • Produces: same { deptCount, userCount } return type

  • Step 1: Rewrite syncOneUser

Replace existing syncOneUser (lines ~553-624) with:

private async syncOneUser(du: {
  userid: string; name: string; mobile: string;
}): Promise<void> {
  let mapping = await this.studentDingMappingRepo.findOne({
    where: { dingUserId: du.userid },
  });
  if (mapping) {
    const student = await this.studentRepo.findOne({
      where: { id: mapping.studentId },
    });
    if (student) {
      student.name = du.name;
      if (du.mobile) student.phone = du.mobile;
      await this.studentRepo.save(student);
    }
    return;
  }

  const student = this.studentRepo.create({
    name: du.name,
    phone: du.mobile || undefined,
    status: 'active',
  });
  await this.studentRepo.save(student);

  mapping = this.studentDingMappingRepo.create({
    dingUserId: du.userid,
    studentId: student.id,
  });
  await this.studentDingMappingRepo.save(mapping);
}

Remove unused imports: bcrypt, User, userRepo injection. studentDingMappingRepo already injected from Task 1.

  • Step 2: Verify compile
cd apps/server && npx tsc --noEmit
  • Step 3: Commit
git add apps/server/src/integration/dingtalk.service.ts
git commit -m "refactor: syncAll creates Student + StudentDingMapping directly"

Task 3: Revert deepest-2-levels filtering

Files:

  • Modify: apps/server/src/integration/dingtalk.service.tsfetchOrgTreeWithUsers + remove getDeptDepthMap

  • Step 1: Remove getDeptDepthMap method (the ~37 lines added in commit 0509175)

  • Step 2: Restore fetchOrgTreeWithUsers loop

Remove depthMap/maxDepth/filteredIds. Restore:

const deptIds = await this.getAllDeptIds(token, rootDeptId);

And for (let i = 0; i < deptIds.length; i++) instead of filteredIds.

  • Step 3: Restore tree assembly condition

Add back && node.id !== rootDeptId in the root-detection line.

  • Step 4: Verify compile + commit
cd apps/server && npx tsc --noEmit
git add apps/server/src/integration/dingtalk.service.ts
git commit -m "revert: remove deepest-2-levels filter, restore full org tree"

Task 4: Remove User-based import + RBAC UserDingMapping endpoints

Files:

  • Modify: apps/server/src/sync/sync.service.ts — remove importDingTalkUsers, ImportUserDto

  • Modify: apps/server/src/sync/sync.controller.ts — remove POST /dingtalk/import-users

  • Modify: apps/server/src/sync/dto/import-users.dto.ts — remove ImportUsersDto

  • Modify: apps/server/src/rbac/rbac.service.ts — remove getUserDingMappings, createUserDingMapping, deleteUserDingMapping, unbound-users query

  • Modify: apps/server/src/rbac/rbac.controller.ts — remove corresponding endpoints + imports

  • Modify: apps/server/src/rbac/dto/rbac.dto.ts — remove CreateUserDingMappingDto

  • Step 1: Remove importDingTalkUsers from sync.service.ts

Delete the entire importDingTalkUsers method and the ImportUserDto interface. Remove now-unused imports: Role, bcrypt, ClassTeacher, ClassStudent, Department, ClassEntity, BadRequestException.

  • Step 2: Remove import endpoint from sync.controller.ts

Delete the POST /dingtalk/import-users handler and ImportUsersDto import.

  • Step 3: Remove RBAC UserDingMapping methods

In rbac.service.ts: delete getUserDingMappings(), createUserDingMapping(), deleteUserDingMapping(), and the unbound-users query. In rbac.controller.ts: delete GET /user-ding-mappings, POST /user-ding-mappings, DELETE /user-ding-mappings/:id, GET /user-ding-mappings/unbound-users. In rbac.dto.ts: delete CreateUserDingMappingDto.

  • Step 4: Verify compile
cd apps/server && npx tsc --noEmit
  • Step 5: Commit
git add apps/server/src/sync/ apps/server/src/rbac/
git commit -m "refactor: remove User-based import and RBAC UserDingMapping endpoints"

Task 5: Add class batch-import + extend class create

Files:

  • Modify: apps/server/src/classes/classes.service.ts — new batchImportStudents, extend create
  • Modify: apps/server/src/classes/classes.controller.ts — new endpoint
  • Modify: apps/server/src/classes/dto/class.dto.ts — new DTOs
  • Modify: apps/server/src/classes/classes.module.ts — add StudentDingMapping

Interfaces:

  • POST /classes/:id/students/import — body { dingUserIds: string[] }{ imported: number, skipped: number }

  • POST /classes — extended: optional dingUserIds: string[]

  • Step 1: Add DTOs in class.dto.ts

import { IsArray, IsString, ArrayNotEmpty, IsOptional } from 'class-validator';

export class BatchImportStudentsDto {
  @IsArray()
  @IsString({ each: true })
  @ArrayNotEmpty()
  dingUserIds: string[];
}

In existing CreateClassDto, add:

@IsArray()
@IsString({ each: true })
@IsOptional()
dingUserIds?: string[];
  • Step 2: Inject StudentDingMapping in classes.service.ts
import { StudentDingMapping } from '../entities';
// ...
@InjectRepository(StudentDingMapping)
private readonly studentDingMappingRepo: Repository<StudentDingMapping>,
  • Step 3: Add batchImportStudents method
async batchImportStudents(classId: number, dingUserIds: string[]): Promise<{ imported: number; skipped: number }> {
  const classEntity = await this.classRepo.findOne({ where: { id: classId } });
  if (!classEntity) throw new NotFoundException('班级不存在');

  let imported = 0;
  let skipped = 0;

  for (const dingUserId of dingUserIds) {
    let mapping = await this.studentDingMappingRepo.findOne({ where: { dingUserId } });
    let studentId: number;

    if (mapping) {
      studentId = mapping.studentId;
    } else {
      const student = this.studentRepo.create({
        name: `dd_${dingUserId}`,
        status: 'active',
        departmentId: classEntity.departmentId ?? undefined,
      });
      const saved = await this.studentRepo.save(student);
      studentId = saved.id;
      mapping = this.studentDingMappingRepo.create({ dingUserId, studentId });
      await this.studentDingMappingRepo.save(mapping);
    }

    const existing = await this.classStudentRepo.findOne({
      where: { classId, studentId },
    });
    if (existing) { skipped++; continue; }

    await this.classStudentRepo.save(
      this.classStudentRepo.create({
        classId, studentId, status: 'active',
        joinDate: new Date().toISOString().slice(0, 10),
      }),
    );
    imported++;
  }

  return { imported, skipped };
}
  • Step 4: Extend create method

At end of create, before return:

if (dto.dingUserIds?.length) {
  await this.batchImportStudents(saved.id, dto.dingUserIds);
}
  • Step 5: Add endpoint in classes.controller.ts
import { BatchImportStudentsDto } from './dto/class.dto';

@Post(':id/students/import')
@RequirePermission('class:edit')
async batchImportStudents(
  @Param('id') id: string,
  @Body() dto: BatchImportStudentsDto,
) {
  return this.classesService.batchImportStudents(+id, dto.dingUserIds);
}
  • Step 6: Add StudentDingMapping to classes.module.ts
TypeOrmModule.forFeature([Class, ClassStudent, ClassTeacher, ClassSchedule, Student, StudentDingMapping]),
  • Step 7: Verify compile + commit
cd apps/server && npx tsc --noEmit
git add apps/server/src/classes/
git commit -m "feat: add batch-import students to class endpoint"

Task 6: Frontend — rewrite Drawer with checkable Tree + class list

Files:

  • Modify: apps/admin/src/pages/IntegrationConfig/index.tsx

Interfaces:

  • Consumes: GET /sync/dingtalk/org-tree-with-users, GET /classes, POST /classes/:id/students/import, POST /classes

  • Produces: redesigned Drawer with left-right split layout

  • Step 1: Update imports

import { Tree, Row, Col, Card, List, Button, Space, Drawer, Alert, TreeSelect, message, Modal, Form, Input, DatePicker, InputNumber, Select, Tag } from 'antd';
import { SyncOutlined, BankOutlined, UserOutlined } from '@ant-design/icons';

Remove: ReloadOutlined (no longer used), Spin, Descriptions, etc.

  • Step 2: Replace state
// Remove: teacherChecks, teacherRoles, defaultTeacherRoleId, roles, classMarks, classModalDept
// Add:
const [checkedKeys, setCheckedKeys] = useState<React.Key[]>([]);
const [selectedClassId, setSelectedClassId] = useState<number | null>(null);
const [classes, setClasses] = useState<any[]>([]);
const [classForm] = Form.useForm();
const [classModalOpen, setClassModalOpen] = useState(false);
const [importing, setImporting] = useState(false);
  • Step 3: Replace buildTreeData (checkable, no toggles)
const buildTreeData = useCallback((nodes: any[]): any[] => {
  return nodes.map((node) => ({
    title: (
      <Space size="small">
        <BankOutlined />
        <span>{node.name}</span>
        <Tag>{node.users.length}</Tag>
      </Space>
    ),
    key: `dept-${node.id}`,
    children: [
      ...buildTreeData(node.children),
      ...node.users.map((u: any) => ({
        title: <Space><UserOutlined /><span>{u.name}</span><Tag>{u.mobile}</Tag></Space>,
        key: `user-${u.userid}`,
      })),
    ],
  }));
}, []);
  • Step 4: Add fetchClasses
const fetchClasses = async () => {
  try {
    const res = await api.get('/classes');
    setClasses(Array.isArray(res) ? res : res.data ?? []);
  } catch { /* ignore */ }
};

Call fetchClasses() inside handleFetchOrgTree.

  • Step 5: handleJoinClass
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(`/classes/${selectedClassId}/students/import`, { dingUserIds: userIds });
    message.success(`导入 ${res.imported} 人,跳过 ${res.skipped} 人`);
    setCheckedKeys([]);
    setSelectedClassId(null);
  } catch (e: any) {
    message.error(e?.message || '导入失败');
  } finally {
    setImporting(false);
  }
};
  • Step 6: handleCreateClass modal
const handleCreateClass = async () => {
  try {
    const values = await classForm.validateFields();
    const userIds = checkedKeys
      .filter((k) => String(k).startsWith('user-'))
      .map((k) => String(k).replace('user-', ''));
    const deptKeys = checkedKeys.filter((k) => String(k).startsWith('dept-'));
    const deptId = deptKeys.length > 0
      ? Number(String(deptKeys[0]).replace('dept-', ''))
      : undefined;

    setImporting(true);
    await api.post('/classes', { ...values, dingUserIds: userIds, departmentId: deptId });
    message.success('班级创建成功');
    setClassModalOpen(false);
    classForm.resetFields();
    setCheckedKeys([]);
    fetchClasses();
  } catch (e: any) {
    message.error(e?.message || '创建失败');
  } finally {
    setImporting(false);
  }
};
  • Step 7: Render Drawer with left-right layout
<Drawer
  title="钉钉组织架构 — 批量导入"
  open={drawerOpen}
  onClose={() => { setDrawerOpen(false); }}
  width={900}
  footer={
    <Space>
      <Button onClick={() => { setDrawerOpen(false); }}>取消</Button>
      <Button
        type="primary"
        loading={importing}
        disabled={checkedKeys.filter((k) => String(k).startsWith('user-')).length === 0 || selectedClassId === null}
        onClick={handleJoinClass}
      >加入选中的班级</Button>
      <Button
        disabled={checkedKeys.filter((k) => String(k).startsWith('user-')).length === 0}
        onClick={() => setClassModalOpen(true)}
      >创建班级</Button>
    </Space>
  }
>
  <Row gutter={16}>
    <Col span={14}>
      <div style={{ maxHeight: '60vh', overflow: 'auto' }}>
        <Tree
          checkable
          treeData={treeData}
          defaultExpandAll
          showLine={{ showLeafIcon: false }}
          checkedKeys={checkedKeys}
          onCheck={(checked) => setCheckedKeys(checked as React.Key[])}
        />
      </div>
    </Col>
    <Col span={10}>
      <Card title="班级列表" size="small"
        extra={<Button size="small" onClick={() => setClassModalOpen(true)}>+ 创建班级</Button>}>
        <List
          dataSource={classes}
          renderItem={(cls: any) => (
            <List.Item
              onClick={() => setSelectedClassId(cls.id)}
              style={{
                cursor: 'pointer',
                background: selectedClassId === cls.id ? '#e6f4ff' : undefined,
                borderRadius: 4,
                padding: '8px 12px',
              }}
            >
              <List.Item.Meta title={cls.name} description={`${cls.code}  ${cls.classType || ''}`} />
            </List.Item>
          )}
        />
      </Card>
    </Col>
  </Row>

  {/* 创建班级 Modal */}
  <Modal
    title="创建班级"
    open={classModalOpen}
    onOk={handleCreateClass}
    onCancel={() => { setClassModalOpen(false); classForm.resetFields(); }}
    confirmLoading={importing}
    destroyOnClose
  >
    <Form form={classForm} layout="vertical">
      <Form.Item name="name" label="班级名称" rules={[{ required: true }]}>
        <Input />
      </Form.Item>
      <Form.Item name="code" label="班级编码" rules={[{ required: true }]}>
        <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="备注">
        <Input.TextArea rows={2} />
      </Form.Item>
    </Form>
  </Modal>
</Drawer>
  • Step 8: Delete old code

Remove: UserTreeNode component, teacherChecks/teacherRoles/classMarks/classModalDept state, handleToggleTeacher/handleClassModalOk/handleClassModalCancel/handleImportUsers callbacks, buildTreeData (old version), per-department class mark modal, role fetch logic.

  • Step 9: Verify compile
cd apps/admin && npx tsc --noEmit
  • Step 10: Commit
git add apps/admin/src/pages/IntegrationConfig/index.tsx
git commit -m "feat: rewrite sync drawer with checkable tree + class batch import"

Task 7: Cleanup tests

Files:

  • Modify: apps/server/src/sync/sync.service.spec.ts — swap UserDingMapping → StudentDingMapping, remove User-related mocks

  • Modify: apps/server/src/attendance/attendance.service.spec.ts — same swap

  • Step 1: Fix sync.service.spec.ts

Replace all UserDingMapping with StudentDingMapping. Remove userRepo, roleRepo, bcrypt mocks. Update test cases that tested importDingTalkUsers — those tests are removed (the method is gone in Task 4). Keep only remaining tests.

  • Step 2: Fix attendance.service.spec.ts

Replace UserDingMapping with StudentDingMapping in imports and mock providers.

  • Step 3: Verify compile
cd apps/server && npx tsc --noEmit
cd apps/admin && npx tsc --noEmit
  • Step 4: Commit
git add apps/server/src/sync/sync.service.spec.ts apps/server/src/attendance/attendance.service.spec.ts
git commit -m "chore: update tests for StudentDingMapping"