forked from wangziqi/gongxue-base
refactor: remove User-based import and RBAC UserDingMapping endpoints
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { IsString, MinLength, IsOptional, IsArray, IsBoolean, IsInt, IsNotEmpty } from 'class-validator';
|
import { IsString, MinLength, IsOptional, IsArray, IsBoolean } from 'class-validator';
|
||||||
|
|
||||||
export class CreateRoleDto {
|
export class CreateRoleDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -80,12 +80,3 @@ export class UpdateProfileDto {
|
|||||||
qualifications?: string;
|
qualifications?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CreateStudentDingMappingDto {
|
|
||||||
@IsInt()
|
|
||||||
@IsNotEmpty()
|
|
||||||
studentId: number;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
dingUserId: string;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
UpdateUserDto,
|
UpdateUserDto,
|
||||||
ResetPasswordDto,
|
ResetPasswordDto,
|
||||||
UpdateProfileDto,
|
UpdateProfileDto,
|
||||||
CreateStudentDingMappingDto,
|
|
||||||
} from './dto/rbac.dto';
|
} from './dto/rbac.dto';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||||
@@ -299,32 +298,6 @@ export class RbacController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ---- 钉钉用户绑定 ----
|
|
||||||
|
|
||||||
@Get('user-ding-mappings')
|
|
||||||
@RequirePermission('user:view')
|
|
||||||
async getStudentDingMappings() {
|
|
||||||
return this.rbacService.getUserDingMappings();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('user-ding-mappings/unbound-users')
|
|
||||||
@RequirePermission('user:view')
|
|
||||||
async getUnboundUsers() {
|
|
||||||
return this.rbacService.getUnboundUsers();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('user-ding-mappings')
|
|
||||||
@RequirePermission('user:edit')
|
|
||||||
async createStudentDingMapping(@Body() dto: CreateStudentDingMappingDto) {
|
|
||||||
return this.rbacService.createUserDingMapping(dto);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete('user-ding-mappings/:id')
|
|
||||||
@RequirePermission('user:delete')
|
|
||||||
async deleteStudentDingMapping(@Param('id') id: string) {
|
|
||||||
return this.rbacService.deleteUserDingMapping(+id);
|
|
||||||
}
|
|
||||||
// ---- 教师工作台 ----
|
// ---- 教师工作台 ----
|
||||||
|
|
||||||
@Get('teacher-workspace')
|
@Get('teacher-workspace')
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Injectable, Logger, NotFoundException, ConflictException } from '@nestjs/common';
|
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, In } from 'typeorm';
|
import { Repository, In } from 'typeorm';
|
||||||
import * as bcrypt from 'bcryptjs';
|
import * as bcrypt from 'bcryptjs';
|
||||||
import { Permission, Role, User, Class, ClassStudent, ClassTeacher, ClassSchedule, Student, StudentDingMapping } from '../entities';
|
import { Permission, Role, User, Class, ClassStudent, ClassTeacher, ClassSchedule, Student } from '../entities';
|
||||||
|
|
||||||
const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> = [
|
const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> = [
|
||||||
{ code: 'dashboard:view', name: '查看数据面板', group: 'dashboard' },
|
{ code: 'dashboard:view', name: '查看数据面板', group: 'dashboard' },
|
||||||
@@ -171,7 +171,6 @@ export class RbacService {
|
|||||||
@InjectRepository(ClassTeacher) private classTeacherRepo: Repository<ClassTeacher>,
|
@InjectRepository(ClassTeacher) private classTeacherRepo: Repository<ClassTeacher>,
|
||||||
@InjectRepository(ClassSchedule) private classScheduleRepo: Repository<ClassSchedule>,
|
@InjectRepository(ClassSchedule) private classScheduleRepo: Repository<ClassSchedule>,
|
||||||
@InjectRepository(Student) private studentRepo: Repository<Student>,
|
@InjectRepository(Student) private studentRepo: Repository<Student>,
|
||||||
@InjectRepository(StudentDingMapping) private mappingRepo: Repository<StudentDingMapping>,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async seedData(): Promise<void> {
|
async seedData(): Promise<void> {
|
||||||
@@ -247,32 +246,6 @@ export class RbacService {
|
|||||||
this.logger.log(`种子数据初始化完成: ${allPerms.length} 权限点, ${allRoles.length} 角色`);
|
this.logger.log(`种子数据初始化完成: ${allPerms.length} 权限点, ${allRoles.length} 角色`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 钉钉用户绑定 ----
|
|
||||||
|
|
||||||
async getUserDingMappings(): Promise<StudentDingMapping[]> {
|
|
||||||
return this.mappingRepo.find({ relations: ['student'] });
|
|
||||||
}
|
|
||||||
|
|
||||||
async createUserDingMapping(dto: { studentId: number; dingUserId: string }) {
|
|
||||||
const existing = await this.mappingRepo.findOne({ where: { dingUserId: dto.dingUserId } });
|
|
||||||
if (existing) throw new ConflictException(`钉钉用户 ${dto.dingUserId} 已绑定到学生 #${existing.studentId}`);
|
|
||||||
const studentExisting = await this.mappingRepo.findOne({ where: { studentId: dto.studentId } });
|
|
||||||
if (studentExisting) throw new ConflictException(`学生 #${dto.studentId} 已绑定到钉钉用户 ${studentExisting.dingUserId}`);
|
|
||||||
const mapping = this.mappingRepo.create(dto);
|
|
||||||
return this.mappingRepo.save(mapping);
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteUserDingMapping(id: number) {
|
|
||||||
const mapping = await this.mappingRepo.findOne({ where: { id } });
|
|
||||||
if (!mapping) throw new NotFoundException('绑定记录不存在');
|
|
||||||
await this.mappingRepo.remove(mapping);
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ponytail: StudentDingMapping.studentId is Student FK, not User; method deleted in Task 4
|
|
||||||
async getUnboundUsers(): Promise<{ id: number; username: string; name: string }[]> {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
async findAllRoles(): Promise<Role[]> {
|
async findAllRoles(): Promise<Role[]> {
|
||||||
return this.roleRepo.find({
|
return this.roleRepo.find({
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
import {
|
|
||||||
IsArray,
|
|
||||||
IsString,
|
|
||||||
IsNumber,
|
|
||||||
IsOptional,
|
|
||||||
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[];
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import { BadRequestException, Body, Controller, Get, Logger, Post, Query, UseGuards } from '@nestjs/common';
|
import { BadRequestException, Controller, Get, Logger, Post, Query, UseGuards } from '@nestjs/common';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||||
import { SyncService } from './sync.service';
|
import { SyncService } from './sync.service';
|
||||||
import type { SyncPlatform } from '../entities/sync-log.entity';
|
import type { SyncPlatform } from '../entities/sync-log.entity';
|
||||||
import { ImportUsersDto } from './dto/import-users.dto';
|
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('sync')
|
@Controller('sync')
|
||||||
@@ -51,17 +50,6 @@ export class SyncController {
|
|||||||
return { success: true, data: tree };
|
return { success: true, data: tree };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 导入钉钉用户:老师分配角色,学生创建 Student */
|
|
||||||
@Post('dingtalk/import-users')
|
|
||||||
@RequirePermission('sync:trigger')
|
|
||||||
async importDingTalkUsers(@Body() body: ImportUsersDto) {
|
|
||||||
this.logger.log(`[import] users=${body.users.length} classes=${body.classes?.length || 0}`);
|
|
||||||
this.logger.log(`[import] sample user: ${JSON.stringify(body.users[0])}`);
|
|
||||||
const result = await this.syncService.importDingTalkUsers(body.users, body.classes);
|
|
||||||
this.logger.log(`[import result] ${JSON.stringify(result)}`);
|
|
||||||
return { success: true, ...result };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('logs')
|
@Get('logs')
|
||||||
@RequirePermission('sync:read')
|
@RequirePermission('sync:read')
|
||||||
async getLogs(
|
async getLogs(
|
||||||
|
|||||||
@@ -1,467 +0,0 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
|
||||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
|
||||||
import { DataSource, Repository } from 'typeorm';
|
|
||||||
import { SyncService, ImportUserDto } from './sync.service';
|
|
||||||
import { SyncLog, SyncState, StudentDingMapping, ClassStudent, ClassTeacher } from '../entities';
|
|
||||||
import { Class as ClassEntity } from '../entities/class.entity';
|
|
||||||
import { User } from '../entities/user.entity';
|
|
||||||
import { Student } from '../entities/student.entity';
|
|
||||||
import { Role } from '../entities/role.entity';
|
|
||||||
import { DingTalkService } from '../integration/dingtalk.service';
|
|
||||||
import { WeComService } from '../integration/wecom.service';
|
|
||||||
import { AttendanceImportService } from '../attendance/attendance-import.service';
|
|
||||||
import { ScheduleSyncService } from './schedule-sync.service';
|
|
||||||
import { BadRequestException } from '@nestjs/common';
|
|
||||||
|
|
||||||
// ── EntityManager mock helpers ──
|
|
||||||
|
|
||||||
interface ManagerMock {
|
|
||||||
create: jest.Mock;
|
|
||||||
save: jest.Mock;
|
|
||||||
findOne: jest.Mock;
|
|
||||||
count: jest.Mock;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mockManager(overrides: Partial<ManagerMock> = {}): ManagerMock {
|
|
||||||
return {
|
|
||||||
create: jest.fn(),
|
|
||||||
save: jest.fn(),
|
|
||||||
findOne: jest.fn(),
|
|
||||||
count: jest.fn(),
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('SyncService — importDingTalkUsers (transactional)', () => {
|
|
||||||
let service: SyncService;
|
|
||||||
|
|
||||||
let mappingRepo: jest.Mocked<
|
|
||||||
Pick<Repository<StudentDingMapping>, 'findOne' | 'create' | 'save' | 'find'>
|
|
||||||
>;
|
|
||||||
let userRepo: jest.Mocked<Pick<Repository<User>, 'create' | 'save'>>;
|
|
||||||
let studentRepo: jest.Mocked<Pick<Repository<Student>, 'create' | 'save'>>;
|
|
||||||
let roleRepo: jest.Mocked<Pick<Repository<Role>, 'findOne'>>;
|
|
||||||
let classRepo: jest.Mocked<Pick<Repository<ClassEntity>, 'find'>>;
|
|
||||||
let dataSourceMock: jest.Mocked<Pick<DataSource, 'transaction'>>;
|
|
||||||
|
|
||||||
let dingTalkService: jest.Mocked<Pick<DingTalkService, 'fetchOrgTreeWithUsers' | 'syncAll' | 'fetchOrgTree'>>;
|
|
||||||
|
|
||||||
let mgr: ManagerMock;
|
|
||||||
beforeEach(async () => {
|
|
||||||
mgr = mockManager();
|
|
||||||
|
|
||||||
// Smart defaults: create returns data with id, save passes through
|
|
||||||
let nextId = 1;
|
|
||||||
mgr.create.mockImplementation((_entityClass: unknown, data: Record<string, unknown>) => ({
|
|
||||||
id: nextId++,
|
|
||||||
...data,
|
|
||||||
}));
|
|
||||||
mgr.save.mockImplementation((entityOrClass: unknown, data?: Record<string, unknown>) => {
|
|
||||||
if (data !== undefined) {
|
|
||||||
return Promise.resolve({ id: nextId++, ...data });
|
|
||||||
}
|
|
||||||
return Promise.resolve(entityOrClass);
|
|
||||||
});
|
|
||||||
mgr.count.mockResolvedValue(0);
|
|
||||||
|
|
||||||
classRepo = {
|
|
||||||
find: jest.fn().mockResolvedValue([]),
|
|
||||||
};
|
|
||||||
|
|
||||||
mappingRepo = {
|
|
||||||
findOne: jest.fn(),
|
|
||||||
create: jest.fn(),
|
|
||||||
save: jest.fn(),
|
|
||||||
find: jest.fn().mockResolvedValue([]),
|
|
||||||
};
|
|
||||||
|
|
||||||
userRepo = {
|
|
||||||
create: jest.fn(),
|
|
||||||
save: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
studentRepo = {
|
|
||||||
create: jest.fn(),
|
|
||||||
save: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
roleRepo = {
|
|
||||||
findOne: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
dataSourceMock = {
|
|
||||||
transaction: jest.fn().mockImplementation(
|
|
||||||
async (cb: (manager: ManagerMock) => Promise<void>) => cb(mgr),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
dingTalkService = {
|
|
||||||
fetchOrgTreeWithUsers: jest.fn(),
|
|
||||||
syncAll: jest.fn(),
|
|
||||||
fetchOrgTree: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockWeComService = { syncAll: jest.fn() };
|
|
||||||
const mockAttendanceImportService = { importFromDingTalk: jest.fn() };
|
|
||||||
const mockScheduleSyncService = { syncAll: jest.fn(), getStatus: jest.fn() };
|
|
||||||
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
|
||||||
providers: [
|
|
||||||
SyncService,
|
|
||||||
{ provide: getRepositoryToken(SyncLog), useValue: { create: jest.fn(), save: jest.fn(), find: jest.fn(), findOne: jest.fn() } },
|
|
||||||
{ provide: getRepositoryToken(SyncState), useValue: { findOne: jest.fn(), upsert: jest.fn() } },
|
|
||||||
{ provide: getRepositoryToken(StudentDingMapping), useValue: mappingRepo },
|
|
||||||
{ provide: getRepositoryToken(User), useValue: userRepo },
|
|
||||||
{ provide: getRepositoryToken(Student), useValue: studentRepo },
|
|
||||||
{ provide: getRepositoryToken(Role), useValue: roleRepo },
|
|
||||||
{ provide: getRepositoryToken(ClassEntity), useValue: classRepo },
|
|
||||||
{ provide: DataSource, useValue: dataSourceMock },
|
|
||||||
{ provide: DingTalkService, useValue: dingTalkService },
|
|
||||||
{ provide: WeComService, useValue: mockWeComService },
|
|
||||||
{ provide: AttendanceImportService, useValue: mockAttendanceImportService },
|
|
||||||
{ provide: ScheduleSyncService, useValue: mockScheduleSyncService },
|
|
||||||
],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
service = module.get<SyncService>(SyncService);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── getDingTalkOrgTreeWithUsers ──
|
|
||||||
|
|
||||||
it('getDingTalkOrgTreeWithUsers delegates to dingTalkService.fetchOrgTreeWithUsers', async () => {
|
|
||||||
const mockTree = [{ id: 1, name: 'root', parentId: 0, children: [], users: [] }];
|
|
||||||
dingTalkService.fetchOrgTreeWithUsers.mockResolvedValue(mockTree);
|
|
||||||
|
|
||||||
const result = await service.getDingTalkOrgTreeWithUsers(1);
|
|
||||||
|
|
||||||
expect(dingTalkService.fetchOrgTreeWithUsers).toHaveBeenCalledWith(1);
|
|
||||||
expect(result).toBe(mockTree);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── importDingTalkUsers ──
|
|
||||||
|
|
||||||
it('imports teacher when roleId is a number (role found)', async () => {
|
|
||||||
const mockRole = { id: 5, name: 'Teacher' } as Role;
|
|
||||||
mgr.findOne.mockImplementation(async (entityClass: unknown) => {
|
|
||||||
if (entityClass === StudentDingMapping) return null;
|
|
||||||
if (entityClass === Role) return mockRole;
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockUser = { id: 10 } as User;
|
|
||||||
mgr.create.mockReturnValue(mockUser);
|
|
||||||
mgr.save.mockResolvedValue(mockUser);
|
|
||||||
|
|
||||||
const users: ImportUserDto[] = [
|
|
||||||
{ dingUserId: 'user1', name: 'Zhang San', mobile: '13800001111', roleId: 5 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = await service.importDingTalkUsers(users);
|
|
||||||
|
|
||||||
expect(result.teacherCount).toBe(1);
|
|
||||||
expect(result.studentCount).toBe(0);
|
|
||||||
expect(result.skipped).toBe(0);
|
|
||||||
expect(result.warnings).toEqual([]);
|
|
||||||
|
|
||||||
expect(dataSourceMock.transaction).toHaveBeenCalledTimes(1);
|
|
||||||
expect(mgr.findOne).toHaveBeenCalledWith(Role, { where: { id: 5 } });
|
|
||||||
expect(mgr.save).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ roles: [mockRole] }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws when role not found', async () => {
|
|
||||||
// StudentDingMapping lookup → null (not skipped), Role lookup → null → throws
|
|
||||||
mgr.findOne.mockResolvedValue(null);
|
|
||||||
|
|
||||||
const mockUser = { id: 11 } as User;
|
|
||||||
mgr.create.mockReturnValue(mockUser);
|
|
||||||
mgr.save.mockResolvedValue(mockUser);
|
|
||||||
|
|
||||||
const users: ImportUserDto[] = [
|
|
||||||
{ dingUserId: 'user2', name: 'Li Si', mobile: '13800002222', roleId: 999 },
|
|
||||||
];
|
|
||||||
|
|
||||||
await expect(service.importDingTalkUsers(users)).rejects.toThrow(BadRequestException);
|
|
||||||
expect(dataSourceMock.transaction).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
it('imports student when roleId is null', async () => {
|
|
||||||
const mockUser = { id: 20 } as User;
|
|
||||||
mgr.create.mockReturnValueOnce(mockUser);
|
|
||||||
mgr.save.mockResolvedValueOnce(mockUser);
|
|
||||||
|
|
||||||
const mockStudent = { id: 30 } as Student;
|
|
||||||
mgr.create.mockReturnValueOnce(mockStudent);
|
|
||||||
mgr.save.mockResolvedValueOnce(mockStudent);
|
|
||||||
|
|
||||||
// mapping create+save also calls create/save
|
|
||||||
mgr.create.mockReturnValueOnce({} as StudentDingMapping);
|
|
||||||
mgr.save.mockResolvedValueOnce({} as StudentDingMapping);
|
|
||||||
|
|
||||||
mappingRepo.findOne.mockResolvedValue(null);
|
|
||||||
|
|
||||||
const users: ImportUserDto[] = [
|
|
||||||
{ dingUserId: 'user3', name: 'Wang Wu', mobile: '', roleId: null },
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = await service.importDingTalkUsers(users);
|
|
||||||
|
|
||||||
expect(result.studentCount).toBe(1);
|
|
||||||
expect(result.teacherCount).toBe(0);
|
|
||||||
expect(result.skipped).toBe(0);
|
|
||||||
expect(result.warnings).toEqual([]);
|
|
||||||
|
|
||||||
// Verify Student was created via manager
|
|
||||||
const studentCreateCalls = mgr.create.mock.calls.filter(
|
|
||||||
([entity]) => entity === Student,
|
|
||||||
);
|
|
||||||
expect(studentCreateCalls.length).toBe(1);
|
|
||||||
expect(studentCreateCalls[0][1]).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
name: 'Wang Wu',
|
|
||||||
userId: 20,
|
|
||||||
status: 'active',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
});
|
|
||||||
it('skips user when mapping already exists', async () => {
|
|
||||||
// mgr.findOne(StudentDingMapping, ...) returns truthy → skip inside transaction
|
|
||||||
mgr.findOne.mockResolvedValue({ id: 1 });
|
|
||||||
|
|
||||||
const users: ImportUserDto[] = [
|
|
||||||
{ dingUserId: 'existing', name: 'Zhao Liu', mobile: '13800003333', roleId: null },
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = await service.importDingTalkUsers(users);
|
|
||||||
|
|
||||||
expect(result.skipped).toBe(1);
|
|
||||||
expect(result.teacherCount).toBe(0);
|
|
||||||
expect(result.studentCount).toBe(0);
|
|
||||||
expect(result.warnings).toEqual([]);
|
|
||||||
expect(dataSourceMock.transaction).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('transaction error propagates to caller', async () => {
|
|
||||||
dataSourceMock.transaction.mockImplementationOnce(async () => {
|
|
||||||
throw new Error('DB error');
|
|
||||||
});
|
|
||||||
|
|
||||||
const users: ImportUserDto[] = [
|
|
||||||
{ dingUserId: 'fail', name: 'Fail User', mobile: '', roleId: null },
|
|
||||||
];
|
|
||||||
|
|
||||||
await expect(service.importDingTalkUsers(users)).rejects.toThrow('DB error');
|
|
||||||
expect(dataSourceMock.transaction).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('counts mixed teacher/student/skipped correctly', async () => {
|
|
||||||
const mockRole = { id: 1, name: 'Teacher Role' } as Role;
|
|
||||||
|
|
||||||
// Single transaction: User 1→skip, User 2→teacher, User 3→student
|
|
||||||
mgr.findOne
|
|
||||||
.mockResolvedValueOnce({ id: 99 }) // User 1: StudentDingMapping → skip
|
|
||||||
.mockResolvedValueOnce(null) // User 2: StudentDingMapping → not skipped
|
|
||||||
.mockResolvedValueOnce(mockRole) // User 2: Role lookup
|
|
||||||
.mockResolvedValueOnce(null); // User 3: StudentDingMapping → not skipped
|
|
||||||
|
|
||||||
const users: ImportUserDto[] = [
|
|
||||||
{ dingUserId: 'skip', name: 'Skip', mobile: '138', roleId: null, dingDeptIds: [] },
|
|
||||||
{ dingUserId: 'teacher', name: 'Teacher', mobile: '139', roleId: 1, dingDeptIds: [] },
|
|
||||||
{ dingUserId: 'student', name: 'Student', mobile: '', roleId: null, dingDeptIds: [] },
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = await service.importDingTalkUsers(users);
|
|
||||||
|
|
||||||
expect(result.teacherCount).toBe(1);
|
|
||||||
expect(result.studentCount).toBe(1);
|
|
||||||
expect(result.skipped).toBe(1);
|
|
||||||
expect(result.warnings).toEqual([]);
|
|
||||||
expect(dataSourceMock.transaction).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── class-marking import ──
|
|
||||||
|
|
||||||
it('creates Class, ClassTeacher(roleType=teacher), and ClassStudent for single mixed dept', async () => {
|
|
||||||
mgr.findOne.mockImplementation(async (entityClass: unknown, opts?: { where?: Record<string, unknown> }) => {
|
|
||||||
if (entityClass === Role) return { id: 5 };
|
|
||||||
if (entityClass === Student && opts?.where) return { id: 100, userId: opts.where.userId };
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
mgr.count.mockImplementation(async (entityClass: unknown) =>
|
|
||||||
entityClass === ClassTeacher || entityClass === ClassStudent ? 1 : 0,
|
|
||||||
);
|
|
||||||
|
|
||||||
const users: ImportUserDto[] = [
|
|
||||||
{ dingUserId: 't1', name: 'Teacher', mobile: '1381', roleId: 5, dingDeptIds: [1] },
|
|
||||||
{ dingUserId: 's1', name: 'Student', mobile: '1382', roleId: null, dingDeptIds: [1] },
|
|
||||||
];
|
|
||||||
const classes = [{ deptId: 1, name: 'Class A', code: 'A01', classType: 'culture' }];
|
|
||||||
|
|
||||||
const result = await service.importDingTalkUsers(users, classes);
|
|
||||||
|
|
||||||
expect(result.teacherCount).toBe(1);
|
|
||||||
expect(result.studentCount).toBe(1);
|
|
||||||
expect(result.classCount).toBe(1);
|
|
||||||
expect(result.skipped).toBe(0);
|
|
||||||
expect(result.warnings).toEqual([]);
|
|
||||||
|
|
||||||
// ClassTeacher: roleType='teacher'
|
|
||||||
const ctCalls = mgr.create.mock.calls.filter(([ec]) => ec === ClassTeacher);
|
|
||||||
expect(ctCalls.length).toBe(1);
|
|
||||||
expect(ctCalls[0][1]).toEqual(expect.objectContaining({ roleType: 'teacher' }));
|
|
||||||
|
|
||||||
// ClassStudent: status='active'
|
|
||||||
const csCalls = mgr.create.mock.calls.filter(([ec]) => ec === ClassStudent);
|
|
||||||
expect(csCalls.length).toBe(1);
|
|
||||||
expect(csCalls[0][1]).toEqual(expect.objectContaining({ status: 'active' }));
|
|
||||||
|
|
||||||
// Class saved via manager.save(ClassEntity, data)
|
|
||||||
expect(mgr.save).toHaveBeenCalledWith(ClassEntity, expect.objectContaining({
|
|
||||||
name: 'Class A',
|
|
||||||
code: 'A01',
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates ClassTeacher entries for all teachers in a dept', async () => {
|
|
||||||
mgr.findOne.mockImplementation(async (entityClass: unknown) => {
|
|
||||||
if (entityClass === Role) return { id: 5 };
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
mgr.count.mockImplementation(async (entityClass: unknown) =>
|
|
||||||
entityClass === ClassTeacher ? 2 : 0,
|
|
||||||
);
|
|
||||||
|
|
||||||
const users: ImportUserDto[] = [
|
|
||||||
{ dingUserId: 't1', name: 'T1', mobile: '1381', roleId: 5, dingDeptIds: [1] },
|
|
||||||
{ dingUserId: 't2', name: 'T2', mobile: '1382', roleId: 5, dingDeptIds: [1] },
|
|
||||||
];
|
|
||||||
const classes = [{ deptId: 1, name: 'Class A', code: 'A02', classType: 'culture' }];
|
|
||||||
|
|
||||||
const result = await service.importDingTalkUsers(users, classes);
|
|
||||||
|
|
||||||
expect(result.teacherCount).toBe(2);
|
|
||||||
expect(result.studentCount).toBe(0);
|
|
||||||
expect(result.classCount).toBe(1);
|
|
||||||
|
|
||||||
const ctCalls = mgr.create.mock.calls.filter(([ec]) => ec === ClassTeacher);
|
|
||||||
expect(ctCalls.length).toBe(2);
|
|
||||||
ctCalls.forEach(([, data]) => {
|
|
||||||
expect(data).toEqual(expect.objectContaining({ roleType: 'teacher' }));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('joins user to all matching classes when in multiple marked depts', async () => {
|
|
||||||
mgr.findOne.mockImplementation(async (entityClass: unknown) => {
|
|
||||||
if (entityClass === Role) return { id: 5 };
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
mgr.count.mockImplementation(async (entityClass: unknown) =>
|
|
||||||
entityClass === ClassTeacher ? 2 : 0,
|
|
||||||
);
|
|
||||||
|
|
||||||
const users: ImportUserDto[] = [
|
|
||||||
{ dingUserId: 't1', name: 'T1', mobile: '1381', roleId: 5, dingDeptIds: [1, 2] },
|
|
||||||
];
|
|
||||||
const classes = [
|
|
||||||
{ deptId: 1, name: 'Class A', code: 'A03', classType: 'culture' },
|
|
||||||
{ deptId: 2, name: 'Class B', code: 'B01', classType: 'professional' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = await service.importDingTalkUsers(users, classes);
|
|
||||||
|
|
||||||
expect(result.teacherCount).toBe(1);
|
|
||||||
expect(result.classCount).toBe(2);
|
|
||||||
|
|
||||||
const ctCalls = mgr.create.mock.calls.filter(([ec]) => ec === ClassTeacher);
|
|
||||||
expect(ctCalls.length).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws BadRequestException when class code already exists', async () => {
|
|
||||||
classRepo.find.mockResolvedValue([{ id: 1, code: 'DUP', name: 'Existing' } as ClassEntity]);
|
|
||||||
|
|
||||||
const users: ImportUserDto[] = [
|
|
||||||
{ dingUserId: 't1', name: 'T1', mobile: '1381', roleId: 5, dingDeptIds: [1] },
|
|
||||||
];
|
|
||||||
const classes = [{ deptId: 1, name: 'New Class', code: 'DUP', classType: 'culture' }];
|
|
||||||
|
|
||||||
await expect(service.importDingTalkUsers(users, classes)).rejects.toThrow(BadRequestException);
|
|
||||||
expect(dataSourceMock.transaction).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns warning for empty dept with no users', async () => {
|
|
||||||
mgr.findOne.mockImplementation(async (entityClass: unknown) => {
|
|
||||||
if (entityClass === Role) return { id: 5 };
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
mgr.count.mockResolvedValue(0);
|
|
||||||
// save(ClassEntity, data) returns a class — but the smart mock already handles this
|
|
||||||
mgr.findOne.mockResolvedValueOnce(null); // first findOne: StudentDingMapping for teacher
|
|
||||||
mgr.findOne.mockResolvedValueOnce({ id: 5 }); // Role lookup
|
|
||||||
mgr.findOne.mockResolvedValueOnce({ name: 'Empty Class' }); // Class lookup in empty check
|
|
||||||
|
|
||||||
const users: ImportUserDto[] = [
|
|
||||||
{ dingUserId: 't1', name: 'T1', mobile: '1381', roleId: 5, dingDeptIds: [99] },
|
|
||||||
];
|
|
||||||
const classes = [{ deptId: 1, name: 'Empty Class', code: 'E01', classType: 'culture' }];
|
|
||||||
|
|
||||||
const result = await service.importDingTalkUsers(users, classes);
|
|
||||||
|
|
||||||
expect(result.classCount).toBe(1);
|
|
||||||
expect(result.warnings).toContainEqual(
|
|
||||||
expect.stringContaining('无任何师生'),
|
|
||||||
);
|
|
||||||
expect(result.warnings[0]).toContain('Empty Class');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates ClassStudent correctly for student-only dept', async () => {
|
|
||||||
mgr.findOne.mockImplementation(async (entityClass: unknown, opts?: { where?: Record<string, unknown> }) => {
|
|
||||||
if (entityClass === Student && opts?.where) return { id: 100, userId: opts.where.userId };
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
mgr.count.mockImplementation(async (entityClass: unknown) =>
|
|
||||||
entityClass === ClassStudent ? 1 : 0,
|
|
||||||
);
|
|
||||||
|
|
||||||
const users: ImportUserDto[] = [
|
|
||||||
{ dingUserId: 's1', name: 'Student', mobile: '1382', roleId: null, dingDeptIds: [1] },
|
|
||||||
];
|
|
||||||
const classes = [{ deptId: 1, name: 'Class A', code: 'S01', classType: 'culture' }];
|
|
||||||
|
|
||||||
const result = await service.importDingTalkUsers(users, classes);
|
|
||||||
|
|
||||||
expect(result.teacherCount).toBe(0);
|
|
||||||
expect(result.studentCount).toBe(1);
|
|
||||||
expect(result.classCount).toBe(1);
|
|
||||||
expect(result.warnings).toEqual([]);
|
|
||||||
|
|
||||||
const csCalls = mgr.create.mock.calls.filter(([ec]) => ec === ClassStudent);
|
|
||||||
expect(csCalls.length).toBe(1);
|
|
||||||
expect(csCalls[0][1]).toEqual(expect.objectContaining({ status: 'active' }));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns classCount=0 and preserves backward compat when no classes param', async () => {
|
|
||||||
mgr.findOne.mockImplementation(async (entityClass: unknown) => {
|
|
||||||
if (entityClass === Role) return { id: 5 };
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
const users: ImportUserDto[] = [
|
|
||||||
{ dingUserId: 't1', name: 'Teacher', mobile: '1381', roleId: 5, dingDeptIds: [1] },
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = await service.importDingTalkUsers(users);
|
|
||||||
|
|
||||||
expect(result.teacherCount).toBe(1);
|
|
||||||
expect(result.studentCount).toBe(0);
|
|
||||||
expect(result.classCount).toBe(0);
|
|
||||||
expect(result.skipped).toBe(0);
|
|
||||||
expect(result.warnings).toEqual([]);
|
|
||||||
|
|
||||||
// No class-related operations
|
|
||||||
const ctCalls = mgr.create.mock.calls.filter(([ec]) => ec === ClassTeacher);
|
|
||||||
expect(ctCalls.length).toBe(0);
|
|
||||||
const csCalls = mgr.create.mock.calls.filter(([ec]) => ec === ClassStudent);
|
|
||||||
expect(csCalls.length).toBe(0);
|
|
||||||
expect(classRepo.find).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,26 +1,12 @@
|
|||||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { DataSource, Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { SyncLog, SyncState, StudentDingMapping, ClassStudent, ClassTeacher, Department } from '../entities';
|
import { SyncLog, SyncState, StudentDingMapping } from '../entities';
|
||||||
import { Class as ClassEntity } from '../entities/class.entity';
|
|
||||||
import type { SyncPlatform, SyncType, SyncStatus } from '../entities/sync-log.entity';
|
import type { SyncPlatform, SyncType, SyncStatus } from '../entities/sync-log.entity';
|
||||||
import { DingTalkService } from '../integration/dingtalk.service';
|
import { DingTalkService } from '../integration/dingtalk.service';
|
||||||
import { WeComService } from '../integration/wecom.service';
|
import { WeComService } from '../integration/wecom.service';
|
||||||
import { AttendanceImportService } from '../attendance/attendance-import.service';
|
import { AttendanceImportService } from '../attendance/attendance-import.service';
|
||||||
import { ScheduleSyncService } from './schedule-sync.service';
|
import { ScheduleSyncService } from './schedule-sync.service';
|
||||||
import { User } from '../entities/user.entity';
|
|
||||||
import { Student } from '../entities/student.entity';
|
|
||||||
import { Role } from '../entities/role.entity';
|
|
||||||
import * as bcrypt from 'bcryptjs';
|
|
||||||
import type { ImportClassItemDto } from './dto/import-users.dto';
|
|
||||||
|
|
||||||
export interface ImportUserDto {
|
|
||||||
dingUserId: string;
|
|
||||||
name: string;
|
|
||||||
mobile: string;
|
|
||||||
roleId: number | null;
|
|
||||||
dingDeptIds: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SyncService {
|
export class SyncService {
|
||||||
@@ -32,19 +18,11 @@ export class SyncService {
|
|||||||
private readonly syncStateRepo: Repository<SyncState>,
|
private readonly syncStateRepo: Repository<SyncState>,
|
||||||
@InjectRepository(StudentDingMapping)
|
@InjectRepository(StudentDingMapping)
|
||||||
private readonly studentDingMappingRepo: Repository<StudentDingMapping>,
|
private readonly studentDingMappingRepo: Repository<StudentDingMapping>,
|
||||||
@InjectRepository(User)
|
|
||||||
private readonly userRepo: Repository<User>,
|
|
||||||
@InjectRepository(Student)
|
|
||||||
private readonly studentRepo: Repository<Student>,
|
|
||||||
@InjectRepository(Role)
|
|
||||||
private readonly roleRepo: Repository<Role>,
|
|
||||||
@InjectRepository(ClassEntity)
|
|
||||||
private readonly classRepo: Repository<ClassEntity>,
|
|
||||||
private readonly dingTalkService: DingTalkService,
|
private readonly dingTalkService: DingTalkService,
|
||||||
private readonly weComService: WeComService,
|
private readonly weComService: WeComService,
|
||||||
private readonly attendanceImportService: AttendanceImportService,
|
private readonly attendanceImportService: AttendanceImportService,
|
||||||
private readonly scheduleSyncService: ScheduleSyncService,
|
private readonly scheduleSyncService: ScheduleSyncService,
|
||||||
private readonly dataSource: DataSource,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ── Scheduled sync disabled — use manual trigger via UI ──
|
// ── Scheduled sync disabled — use manual trigger via UI ──
|
||||||
@@ -120,208 +98,6 @@ export class SyncService {
|
|||||||
return this.dingTalkService.fetchOrgTreeWithUsers(rootDeptId);
|
return this.dingTalkService.fetchOrgTreeWithUsers(rootDeptId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 从钉钉导入用户:roleId 非 null → 老师(User + 指定角色),roleId null → 学生(User + Student)。
|
|
||||||
* 支持同时创建班级并建立师生关联。
|
|
||||||
* 已存在 StudentDingMapping 的记录跳过。
|
|
||||||
*/
|
|
||||||
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 })) });
|
|
||||||
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) => {
|
|
||||||
// 0. 同步部门:为每个班级标记的钉钉部门创建/查找本地 Department
|
|
||||||
const deptLocalIdMap = new Map<number, number>(); // dingDeptId -> local deptId
|
|
||||||
for (const c of classItems) {
|
|
||||||
let dept = await manager.findOne(Department, {
|
|
||||||
where: { source: 'dingtalk', sourceId: String(c.deptId) },
|
|
||||||
});
|
|
||||||
if (!dept) {
|
|
||||||
dept = manager.create(Department, {
|
|
||||||
name: c.name,
|
|
||||||
type: 'department',
|
|
||||||
source: 'dingtalk',
|
|
||||||
sourceId: String(c.deptId),
|
|
||||||
});
|
|
||||||
dept = await manager.save(dept);
|
|
||||||
}
|
|
||||||
deptLocalIdMap.set(c.deptId, dept.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. 创建班级
|
|
||||||
const deptClassMap = new Map<number, number>(); // deptId -> classId
|
|
||||||
for (const c of classItems) {
|
|
||||||
const deptId = deptLocalIdMap.get(c.deptId) ?? null;
|
|
||||||
const result = await manager.save(ClassEntity, {
|
|
||||||
name: c.name,
|
|
||||||
code: c.code,
|
|
||||||
departmentId: deptId,
|
|
||||||
classType: c.classType,
|
|
||||||
startDate: c.startDate ?? null,
|
|
||||||
endDate: c.endDate ?? null,
|
|
||||||
maxStudents: c.maxStudents ?? 0,
|
|
||||||
notes: c.notes ?? null,
|
|
||||||
} as unknown as Record<string, unknown>);
|
|
||||||
deptClassMap.set(c.deptId, (result as ClassEntity).id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 导入用户(逐用户)
|
|
||||||
for (const u of users) {
|
|
||||||
const existingMapping = await manager.findOne(StudentDingMapping, {
|
|
||||||
where: { dingUserId: u.dingUserId },
|
|
||||||
});
|
|
||||||
|
|
||||||
let userId: number | null = null;
|
|
||||||
let isTeacher = false;
|
|
||||||
|
|
||||||
if (existingMapping) {
|
|
||||||
skipped++;
|
|
||||||
// ponytail: studentDingMapping.studentId is Student FK, not User; full rewrite in Task 4
|
|
||||||
userId = existingMapping.studentId;
|
|
||||||
isTeacher = false;
|
|
||||||
} else {
|
|
||||||
// 检查是否已存在(syncAll 或历史导入),避免撞 username 唯一约束
|
|
||||||
const username = `dd_${u.dingUserId}`;
|
|
||||||
const whereConditions: Record<string, unknown>[] = [{ username }];
|
|
||||||
if (u.mobile) whereConditions.push({ username: u.mobile });
|
|
||||||
let user = await manager.findOne(User, { where: whereConditions });
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
const passwordHash = await bcrypt.hash('123456', 10);
|
|
||||||
user = manager.create(User, {
|
|
||||||
username,
|
|
||||||
name: u.name,
|
|
||||||
passwordHash,
|
|
||||||
isActive: true,
|
|
||||||
});
|
|
||||||
await manager.save(user);
|
|
||||||
} else {
|
|
||||||
// 已存在:更新姓名
|
|
||||||
user.name = u.name;
|
|
||||||
await manager.save(user);
|
|
||||||
}
|
|
||||||
userId = user.id;
|
|
||||||
|
|
||||||
// 解析学生所属部门
|
|
||||||
const studentDeptId = u.roleId == null && classItems.length > 0 && u.dingDeptIds?.length > 0
|
|
||||||
? deptLocalIdMap.get(u.dingDeptIds.find((d) => deptLocalIdMap.has(d)) ?? -1) ?? undefined
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
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 {
|
|
||||||
// 学生:检查 Student 是否已存在,不存在则创建
|
|
||||||
let student = await manager.findOne(Student, { where: { userId: user.id } });
|
|
||||||
if (!student) {
|
|
||||||
student = manager.create(Student, {
|
|
||||||
name: u.name,
|
|
||||||
phone: u.mobile || undefined,
|
|
||||||
userId: user.id,
|
|
||||||
departmentId: studentDeptId,
|
|
||||||
status: 'active',
|
|
||||||
});
|
|
||||||
await manager.save(student);
|
|
||||||
}
|
|
||||||
studentCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 钉钉映射(幂等:可能已被 syncAll 创建)
|
|
||||||
const existingMappingForUser = await manager.findOne(StudentDingMapping, {
|
|
||||||
where: { dingUserId: u.dingUserId },
|
|
||||||
});
|
|
||||||
if (!existingMappingForUser) {
|
|
||||||
const mapping = manager.create(StudentDingMapping, {
|
|
||||||
dingUserId: u.dingUserId,
|
|
||||||
studentId: (user as any).id,
|
|
||||||
});
|
|
||||||
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 existingCT = await manager.findOne(ClassTeacher, {
|
|
||||||
where: { classId, userId },
|
|
||||||
});
|
|
||||||
if (!existingCT) {
|
|
||||||
const ct = manager.create(ClassTeacher, {
|
|
||||||
classId,
|
|
||||||
userId,
|
|
||||||
roleType: 'teacher',
|
|
||||||
});
|
|
||||||
await manager.save(ct);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const studentRecord = await manager.findOne(Student, {
|
|
||||||
where: { userId },
|
|
||||||
});
|
|
||||||
if (!studentRecord) continue;
|
|
||||||
const existingCS = await manager.findOne(ClassStudent, {
|
|
||||||
where: { classId, studentId: studentRecord.id },
|
|
||||||
});
|
|
||||||
if (!existingCS) {
|
|
||||||
const cs = manager.create(ClassStudent, {
|
|
||||||
classId,
|
|
||||||
studentId: studentRecord.id,
|
|
||||||
status: 'active',
|
|
||||||
});
|
|
||||||
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(ClassEntity, { 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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 排班同步 ──
|
// ── 排班同步 ──
|
||||||
|
|
||||||
/** 将本地排课同步到钉钉考勤排班 */
|
/** 将本地排课同步到钉钉考勤排班 */
|
||||||
|
|||||||
Reference in New Issue
Block a user