docs: student ding mapping + batch operations implementation plan

This commit is contained in:
2026-07-09 16:49:38 +08:00
parent 6762a7f661
commit 1fa336331c

View File

@@ -0,0 +1,636 @@
# 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`**
```typescript
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:
- `UserDingMapping``StudentDingMapping`
- `user_ding_mapping``student_ding_mapping`
- `mappingRepo` / `userDingMappingRepo``studentDingMappingRepo`
- [ ] **Step 5: Delete `user-ding-mapping.entity.ts`**
- [ ] **Step 6: Verify compile**
```bash
cd apps/server && npx tsc --noEmit
```
Expected: zero new errors (pre-existing spec-file errors ignored).
- [ ] **Step 7: Commit**
```bash
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.ts``syncAll` 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:
```typescript
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**
```bash
cd apps/server && npx tsc --noEmit
```
- [ ] **Step 3: Commit**
```bash
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.ts``fetchOrgTreeWithUsers` + remove `getDeptDepthMap`
- [ ] **Step 1: Remove `getDeptDepthMap` method** (the ~37 lines added in commit 0509175)
- [ ] **Step 2: Restore `fetchOrgTreeWithUsers` loop**
Remove depthMap/maxDepth/filteredIds. Restore:
```typescript
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**
```bash
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**
```bash
cd apps/server && npx tsc --noEmit
```
- [ ] **Step 5: Commit**
```bash
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`**
```typescript
import { IsArray, IsString, ArrayNotEmpty, IsOptional } from 'class-validator';
export class BatchImportStudentsDto {
@IsArray()
@IsString({ each: true })
@ArrayNotEmpty()
dingUserIds: string[];
}
```
In existing `CreateClassDto`, add:
```typescript
@IsArray()
@IsString({ each: true })
@IsOptional()
dingUserIds?: string[];
```
- [ ] **Step 2: Inject `StudentDingMapping` in classes.service.ts**
```typescript
import { StudentDingMapping } from '../entities';
// ...
@InjectRepository(StudentDingMapping)
private readonly studentDingMappingRepo: Repository<StudentDingMapping>,
```
- [ ] **Step 3: Add `batchImportStudents` method**
```typescript
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:
```typescript
if (dto.dingUserIds?.length) {
await this.batchImportStudents(saved.id, dto.dingUserIds);
}
```
- [ ] **Step 5: Add endpoint in classes.controller.ts**
```typescript
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**
```typescript
TypeOrmModule.forFeature([Class, ClassStudent, ClassTeacher, ClassSchedule, Student, StudentDingMapping]),
```
- [ ] **Step 7: Verify compile + commit**
```bash
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**
```typescript
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**
```typescript
// 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)**
```typescript
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`**
```typescript
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`**
```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(`/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**
```typescript
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**
```tsx
<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**
```bash
cd apps/admin && npx tsc --noEmit
```
- [ ] **Step 10: Commit**
```bash
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**
```bash
cd apps/server && npx tsc --noEmit
cd apps/admin && npx tsc --noEmit
```
- [ ] **Step 4: Commit**
```bash
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"
```