diff --git a/apps/server/src/rbac/dto/rbac.dto.ts b/apps/server/src/rbac/dto/rbac.dto.ts index ede9468..3373646 100644 --- a/apps/server/src/rbac/dto/rbac.dto.ts +++ b/apps/server/src/rbac/dto/rbac.dto.ts @@ -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 { @IsString() @@ -80,12 +80,3 @@ export class UpdateProfileDto { qualifications?: string; } -export class CreateStudentDingMappingDto { - @IsInt() - @IsNotEmpty() - studentId: number; - - @IsString() - @IsNotEmpty() - dingUserId: string; -} diff --git a/apps/server/src/rbac/rbac.controller.ts b/apps/server/src/rbac/rbac.controller.ts index 79746e0..06d1e96 100644 --- a/apps/server/src/rbac/rbac.controller.ts +++ b/apps/server/src/rbac/rbac.controller.ts @@ -19,7 +19,6 @@ import { UpdateUserDto, ResetPasswordDto, UpdateProfileDto, - CreateStudentDingMappingDto, } from './dto/rbac.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; 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') diff --git a/apps/server/src/rbac/rbac.service.ts b/apps/server/src/rbac/rbac.service.ts index 30f27f8..e18ed1e 100644 --- a/apps/server/src/rbac/rbac.service.ts +++ b/apps/server/src/rbac/rbac.service.ts @@ -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 { Repository, In } from 'typeorm'; 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 }> = [ { code: 'dashboard:view', name: '查看数据面板', group: 'dashboard' }, @@ -171,7 +171,6 @@ export class RbacService { @InjectRepository(ClassTeacher) private classTeacherRepo: Repository, @InjectRepository(ClassSchedule) private classScheduleRepo: Repository, @InjectRepository(Student) private studentRepo: Repository, - @InjectRepository(StudentDingMapping) private mappingRepo: Repository, ) {} async seedData(): Promise { @@ -247,32 +246,6 @@ export class RbacService { this.logger.log(`种子数据初始化完成: ${allPerms.length} 权限点, ${allRoles.length} 角色`); } - // ---- 钉钉用户绑定 ---- - - async getUserDingMappings(): Promise { - 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 { return this.roleRepo.find({ diff --git a/apps/server/src/sync/dto/import-users.dto.ts b/apps/server/src/sync/dto/import-users.dto.ts deleted file mode 100644 index 7992ad5..0000000 --- a/apps/server/src/sync/dto/import-users.dto.ts +++ /dev/null @@ -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[]; -} diff --git a/apps/server/src/sync/sync.controller.ts b/apps/server/src/sync/sync.controller.ts index db4e757..a7a90c9 100644 --- a/apps/server/src/sync/sync.controller.ts +++ b/apps/server/src/sync/sync.controller.ts @@ -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 { RequirePermission } from '../auth/decorators/permission.decorator'; import { SyncService } from './sync.service'; import type { SyncPlatform } from '../entities/sync-log.entity'; -import { ImportUsersDto } from './dto/import-users.dto'; @UseGuards(JwtAuthGuard) @Controller('sync') @@ -51,17 +50,6 @@ export class SyncController { 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') @RequirePermission('sync:read') async getLogs( diff --git a/apps/server/src/sync/sync.service.spec.ts b/apps/server/src/sync/sync.service.spec.ts deleted file mode 100644 index 3fc58e7..0000000 --- a/apps/server/src/sync/sync.service.spec.ts +++ /dev/null @@ -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 { - 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, 'findOne' | 'create' | 'save' | 'find'> - >; - let userRepo: jest.Mocked, 'create' | 'save'>>; - let studentRepo: jest.Mocked, 'create' | 'save'>>; - let roleRepo: jest.Mocked, 'findOne'>>; - let classRepo: jest.Mocked, 'find'>>; - let dataSourceMock: jest.Mocked>; - - let dingTalkService: jest.Mocked>; - - 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) => ({ - id: nextId++, - ...data, - })); - mgr.save.mockImplementation((entityOrClass: unknown, data?: Record) => { - 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) => 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); - }); - - // ── 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 }) => { - 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 }) => { - 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(); - }); -}); diff --git a/apps/server/src/sync/sync.service.ts b/apps/server/src/sync/sync.service.ts index f1e8ec2..d0c3c18 100644 --- a/apps/server/src/sync/sync.service.ts +++ b/apps/server/src/sync/sync.service.ts @@ -1,26 +1,12 @@ -import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { DataSource, Repository } from 'typeorm'; -import { SyncLog, SyncState, StudentDingMapping, ClassStudent, ClassTeacher, Department } from '../entities'; -import { Class as ClassEntity } from '../entities/class.entity'; +import { Repository } from 'typeorm'; +import { SyncLog, SyncState, StudentDingMapping } from '../entities'; import type { SyncPlatform, SyncType, SyncStatus } from '../entities/sync-log.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 { 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() export class SyncService { @@ -32,19 +18,11 @@ export class SyncService { private readonly syncStateRepo: Repository, @InjectRepository(StudentDingMapping) private readonly studentDingMappingRepo: Repository, - @InjectRepository(User) - private readonly userRepo: Repository, - @InjectRepository(Student) - private readonly studentRepo: Repository, - @InjectRepository(Role) - private readonly roleRepo: Repository, - @InjectRepository(ClassEntity) - private readonly classRepo: Repository, + private readonly dingTalkService: DingTalkService, private readonly weComService: WeComService, private readonly attendanceImportService: AttendanceImportService, private readonly scheduleSyncService: ScheduleSyncService, - private readonly dataSource: DataSource, ) {} // ── Scheduled sync disabled — use manual trigger via UI ── @@ -120,208 +98,6 @@ export class SyncService { 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(); // 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(); // 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); - 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[] = [{ 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 }; - } - // ── 排班同步 ── /** 将本地排课同步到钉钉考勤排班 */