diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index 231ce36..a89c7b7 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -41,7 +41,7 @@ import { ExpenseType, ResultArchive, ArchiveAttachment, - UserDingMapping, + StudentDingMapping, } from './entities'; import { AuthModule } from './auth/auth.module'; import { RbacModule } from './rbac/rbac.module'; @@ -124,7 +124,7 @@ import { CampusScopeMiddleware } from './common/campus-scope.middleware'; ResultArchive, SyncLog, SyncState, - UserDingMapping, + StudentDingMapping, IntegrationConfig, IntegrationConfigDetail, ]; diff --git a/apps/server/src/attendance/attendance-import.service.ts b/apps/server/src/attendance/attendance-import.service.ts index fbb3b29..7076a55 100644 --- a/apps/server/src/attendance/attendance-import.service.ts +++ b/apps/server/src/attendance/attendance-import.service.ts @@ -5,7 +5,7 @@ import { Subject, Observable } from 'rxjs'; import { DingAttendanceRaw, Student, - UserDingMapping, + StudentDingMapping, } from '../entities'; import { DingTalkService, DingTalkAttendanceResult } from '../integration/dingtalk.service'; import { AttendanceService } from './attendance.service'; @@ -33,8 +33,8 @@ export class AttendanceImportService { private readonly dingRawRepo: Repository, @InjectRepository(Student) private readonly studentRepo: Repository, - @InjectRepository(UserDingMapping) - private readonly userDingMappingRepo: Repository, + @InjectRepository(StudentDingMapping) + private readonly studentDingMappingRepo: Repository, private readonly dingTalkService: DingTalkService, private readonly attendanceService: AttendanceService, ) {} diff --git a/apps/server/src/attendance/attendance.module.ts b/apps/server/src/attendance/attendance.module.ts index afd24ba..eeccbe4 100644 --- a/apps/server/src/attendance/attendance.module.ts +++ b/apps/server/src/attendance/attendance.module.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { AttendanceRecord, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, UserDingMapping } from '../entities'; +import { AttendanceRecord, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, StudentDingMapping } from '../entities'; import { AttendanceService } from './attendance.service'; import { AttendanceImportService } from './attendance-import.service'; import { AttendanceController } from './attendance.controller'; @@ -10,7 +10,7 @@ import { IntegrationModule } from '../integration/integration.module'; @Module({ imports: [ - TypeOrmModule.forFeature([AttendanceRecord, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, UserDingMapping]), + TypeOrmModule.forFeature([AttendanceRecord, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, StudentDingMapping]), OperationLogsModule, CommonModule, IntegrationModule, diff --git a/apps/server/src/attendance/attendance.service.spec.ts b/apps/server/src/attendance/attendance.service.spec.ts index 0d03a86..1ce4694 100644 --- a/apps/server/src/attendance/attendance.service.spec.ts +++ b/apps/server/src/attendance/attendance.service.spec.ts @@ -9,7 +9,7 @@ import { Class } from '../entities/class.entity'; import { Student } from '../entities/student.entity'; import { ClassSchedule } from '../entities/class-schedule.entity'; import { ClassStudent } from '../entities/class-student.entity'; -import { UserDingMapping } from '../entities/user-ding-mapping.entity'; +import { StudentDingMapping } from '../entities/student-ding-mapping.entity'; import { BatchCreateAttendanceDto } from './dto/attendance.dto'; describe('AttendanceService — batchCreate', () => { @@ -39,7 +39,7 @@ describe('AttendanceService — batchCreate', () => { // Reserved for future tests (auto-match, schedule-based attendance, etc.) const mockScheduleRepo = { find: jest.fn().mockResolvedValue([]) }; const mockClassStudentRepo = { find: jest.fn().mockResolvedValue([]) }; - const mockUserDingMappingRepo = { find: jest.fn().mockResolvedValue([]) }; + const mockStudentDingMappingRepo = { find: jest.fn().mockResolvedValue([]) }; const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -49,7 +49,7 @@ describe('AttendanceService — batchCreate', () => { { provide: getRepositoryToken(Class), useValue: mockClassRepo }, { provide: getRepositoryToken(Student), useValue: mockStudentRepo }, { provide: getRepositoryToken(ClassSchedule), useValue: mockScheduleRepo }, - { provide: getRepositoryToken(UserDingMapping), useValue: mockUserDingMappingRepo }, + { provide: getRepositoryToken(StudentDingMapping), useValue: mockStudentDingMappingRepo }, { provide: getRepositoryToken(ClassStudent), useValue: mockClassStudentRepo }, ], }).compile(); @@ -99,9 +99,9 @@ describe('AttendanceService — batchCreate', () => { await expect(service.batchCreate(dto)).rejects.toThrow(BadRequestException); }); - it.skip('autoMatchDingRecords with UserDingMapping chain', async () => { - // TODO: match dingtalk raw records to students via UserDingMapping lookup, + it.skip('autoMatchDingRecords with StudentDingMapping chain', async () => { + // TODO: match dingtalk raw records to students via StudentDingMapping lookup, // then to class schedules → ClassStudent association, producing attendance records. - // Requires mock setup for UserDingMapping, ClassSchedule, ClassStudent, and DingAttendanceRaw repos. + // Requires mock setup for StudentDingMapping, ClassSchedule, ClassStudent, and DingAttendanceRaw repos. }); }); diff --git a/apps/server/src/attendance/attendance.service.ts b/apps/server/src/attendance/attendance.service.ts index b158614..29651b4 100644 --- a/apps/server/src/attendance/attendance.service.ts +++ b/apps/server/src/attendance/attendance.service.ts @@ -5,7 +5,7 @@ import { } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, In, Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; -import { AttendanceRecord, DingAttendanceRaw, Class, Student, ClassSchedule, ClassStudent, ScheduleType, UserDingMapping } from '../entities'; +import { AttendanceRecord, DingAttendanceRaw, Class, Student, ClassSchedule, ClassStudent, ScheduleType, StudentDingMapping } from '../entities'; import { BatchCreateAttendanceDto, AttendanceSummaryQueryDto, @@ -33,8 +33,8 @@ export class AttendanceService { private scheduleRepo: Repository, @InjectRepository(ClassStudent) private classStudentRepo: Repository, - @InjectRepository(UserDingMapping) - private userDingMappingRepo: Repository, + @InjectRepository(StudentDingMapping) + private studentDingMappingRepo: Repository, ) {} // ── Batch create attendance records ── @@ -363,10 +363,10 @@ export class AttendanceService { if (unmatched.length === 0) return { matched: 0, total: 0 }; // Build dingUserId → userId map from the mapping table - const mappings = await this.userDingMappingRepo.find(); + const mappings = await this.studentDingMappingRepo.find(); const dingToUserId = new Map(); for (const m of mappings) { - dingToUserId.set(m.dingUserId, m.userId); + dingToUserId.set(m.dingUserId, m.studentId); } // Build userId → studentId map (only students linked to a user) diff --git a/apps/server/src/entities/index.ts b/apps/server/src/entities/index.ts index fe7611f..2d27e91 100644 --- a/apps/server/src/entities/index.ts +++ b/apps/server/src/entities/index.ts @@ -35,4 +35,4 @@ export { LearningRecord } from './learning-record.entity'; export { ResultArchive } from './result-archive.entity'; export { ArchiveAttachment } from './archive-attachment.entity'; export { StudentReport } from './student-report.entity'; -export { UserDingMapping } from './user-ding-mapping.entity'; +export { StudentDingMapping } from './student-ding-mapping.entity'; diff --git a/apps/server/src/entities/student-ding-mapping.entity.ts b/apps/server/src/entities/student-ding-mapping.entity.ts new file mode 100644 index 0000000..3f8320b --- /dev/null +++ b/apps/server/src/entities/student-ding-mapping.entity.ts @@ -0,0 +1,24 @@ +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; +} diff --git a/apps/server/src/entities/user-ding-mapping.entity.ts b/apps/server/src/entities/user-ding-mapping.entity.ts deleted file mode 100644 index f927a39..0000000 --- a/apps/server/src/entities/user-ding-mapping.entity.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - Index, - ManyToOne, - JoinColumn, -} from 'typeorm'; -import { User } from './user.entity'; - -/** - * Maps DingTalk user IDs to local User records. - * Used for incremental sync and attendance auto-matching. - * - * Mapping chain: - * dingtalk userid → UserDingMapping.dingUserId → UserDingMapping.userId - * → User.id → Student.userId → Student.id - * → DingAttendanceRaw.dingUserId → UserDingMapping.dingUserId → auto-match - */ -@Entity('user_ding_mapping') -export class UserDingMapping { - @PrimaryGeneratedColumn() - id: number; - - /** DingTalk user ID (from DingTalk API) */ - @Column({ name: 'ding_user_id', length: 100, unique: true }) - dingUserId: string; - - /** Local User ID */ - @Column({ name: 'user_id', type: 'integer', unique: true }) - userId: number; - - @ManyToOne(() => User, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'user_id' }) - user: User; - - /** DingTalk user name (cached for display) */ - @Column({ name: 'ding_name', length: 100, nullable: true }) - dingName: string; - - /** DingTalk mobile (cached for display) */ - @Column({ name: 'ding_mobile', length: 20, nullable: true }) - dingMobile: string; - - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - @UpdateDateColumn({ name: 'updated_at' }) - updatedAt: Date; -} diff --git a/apps/server/src/integration/dingtalk.service.ts b/apps/server/src/integration/dingtalk.service.ts index 112f0c2..4d62ea8 100644 --- a/apps/server/src/integration/dingtalk.service.ts +++ b/apps/server/src/integration/dingtalk.service.ts @@ -4,7 +4,7 @@ * 提供: * - OAuth2 access_token(新版 API + 缓存) * - BFS 遍历所有部门 + 用户(带限流) - * - 用户同步(自动建 User + Student + UserDingMapping) + * - 用户同步(自动建 User + Student + StudentDingMapping) */ import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; @@ -14,7 +14,7 @@ import { Department } from '../entities/department.entity'; import { User } from '../entities/user.entity'; import { Student } from '../entities/student.entity'; import { Class } from '../entities/class.entity'; -import { UserDingMapping } from '../entities/user-ding-mapping.entity'; +import { StudentDingMapping } from '../entities/student-ding-mapping.entity'; // ── Types ── @@ -191,8 +191,8 @@ export class DingTalkService { private readonly userRepo: Repository, @InjectRepository(Student) private readonly studentRepo: Repository, - @InjectRepository(UserDingMapping) - private readonly mappingRepo: Repository, + @InjectRepository(StudentDingMapping) + private readonly studentDingMappingRepo: Repository, @InjectRepository(Class) private readonly classRepo: Repository, ) {} @@ -608,18 +608,16 @@ export class DingTalkService { mobile: string; }): Promise { // Look up by mapping first - let mapping = await this.mappingRepo.findOne({ where: { dingUserId: du.userid } }); + let mapping = await this.studentDingMappingRepo.findOne({ where: { dingUserId: du.userid } }); let user: User | null = null; if (mapping) { - user = await this.userRepo.findOne({ where: { id: mapping.userId } }); + user = await this.userRepo.findOne({ where: { id: mapping.studentId } }); if (user) { user.name = du.name; await this.userRepo.save(user); } - mapping.dingName = du.name; - mapping.dingMobile = du.mobile; - await this.mappingRepo.save(mapping); + await this.studentDingMappingRepo.save(mapping); return; } @@ -666,13 +664,11 @@ export class DingTalkService { } // Create mapping - mapping = this.mappingRepo.create({ + mapping = this.studentDingMappingRepo.create({ dingUserId: du.userid, - userId: user.id, - dingName: du.name, - dingMobile: du.mobile, + studentId: user.id, }); - await this.mappingRepo.save(mapping); + await this.studentDingMappingRepo.save(mapping); } // ═══════════════════════════════════════════ diff --git a/apps/server/src/integration/integration.module.ts b/apps/server/src/integration/integration.module.ts index 334e4ab..9f4db7e 100644 --- a/apps/server/src/integration/integration.module.ts +++ b/apps/server/src/integration/integration.module.ts @@ -1,11 +1,11 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { Department, User, Student, UserDingMapping, Class } from '../entities'; +import { Department, User, Student, StudentDingMapping, Class } from '../entities'; import { DingTalkService } from './dingtalk.service'; import { WeComService } from './wecom.service'; @Module({ - imports: [TypeOrmModule.forFeature([Department, User, Student, UserDingMapping, Class])], + imports: [TypeOrmModule.forFeature([Department, User, Student, StudentDingMapping, Class])], providers: [DingTalkService, WeComService], exports: [DingTalkService, WeComService], }) diff --git a/apps/server/src/rbac/dto/rbac.dto.ts b/apps/server/src/rbac/dto/rbac.dto.ts index 9395630..ede9468 100644 --- a/apps/server/src/rbac/dto/rbac.dto.ts +++ b/apps/server/src/rbac/dto/rbac.dto.ts @@ -80,20 +80,12 @@ export class UpdateProfileDto { qualifications?: string; } -export class CreateUserDingMappingDto { +export class CreateStudentDingMappingDto { @IsInt() @IsNotEmpty() - userId: number; + studentId: number; @IsString() @IsNotEmpty() dingUserId: string; - - @IsOptional() - @IsString() - dingName?: string; - - @IsOptional() - @IsString() - dingMobile?: string; } diff --git a/apps/server/src/rbac/rbac.controller.ts b/apps/server/src/rbac/rbac.controller.ts index ca7f67c..79746e0 100644 --- a/apps/server/src/rbac/rbac.controller.ts +++ b/apps/server/src/rbac/rbac.controller.ts @@ -19,7 +19,7 @@ import { UpdateUserDto, ResetPasswordDto, UpdateProfileDto, - CreateUserDingMappingDto, + CreateStudentDingMappingDto, } from './dto/rbac.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RequirePermission } from '../auth/decorators/permission.decorator'; @@ -304,7 +304,7 @@ export class RbacController { @Get('user-ding-mappings') @RequirePermission('user:view') - async getUserDingMappings() { + async getStudentDingMappings() { return this.rbacService.getUserDingMappings(); } @@ -316,13 +316,13 @@ export class RbacController { @Post('user-ding-mappings') @RequirePermission('user:edit') - async createUserDingMapping(@Body() dto: CreateUserDingMappingDto) { + async createStudentDingMapping(@Body() dto: CreateStudentDingMappingDto) { return this.rbacService.createUserDingMapping(dto); } @Delete('user-ding-mappings/:id') @RequirePermission('user:delete') - async deleteUserDingMapping(@Param('id') id: string) { + async deleteStudentDingMapping(@Param('id') id: string) { return this.rbacService.deleteUserDingMapping(+id); } // ---- 教师工作台 ---- diff --git a/apps/server/src/rbac/rbac.module.ts b/apps/server/src/rbac/rbac.module.ts index 4317fcd..4db07c0 100644 --- a/apps/server/src/rbac/rbac.module.ts +++ b/apps/server/src/rbac/rbac.module.ts @@ -1,12 +1,12 @@ import { Module, OnModuleInit, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { Permission, Role, User, Class, ClassStudent, ClassTeacher, ClassSchedule, Student, UserDingMapping } from '../entities'; +import { Permission, Role, User, Class, ClassStudent, ClassTeacher, ClassSchedule, Student, StudentDingMapping } from '../entities'; import { RbacService } from './rbac.service'; import { RbacController } from './rbac.controller'; import { AuthModule } from '../auth/auth.module'; @Module({ - imports: [TypeOrmModule.forFeature([Permission, Role, User, Class, ClassStudent, ClassTeacher, ClassSchedule, Student, UserDingMapping]), forwardRef(() => AuthModule)], + imports: [TypeOrmModule.forFeature([Permission, Role, User, Class, ClassStudent, ClassTeacher, ClassSchedule, Student, StudentDingMapping]), forwardRef(() => AuthModule)], controllers: [RbacController], providers: [RbacService], exports: [RbacService], diff --git a/apps/server/src/rbac/rbac.service.ts b/apps/server/src/rbac/rbac.service.ts index 9a4b226..99ca16c 100644 --- a/apps/server/src/rbac/rbac.service.ts +++ b/apps/server/src/rbac/rbac.service.ts @@ -2,7 +2,7 @@ import { Injectable, Logger, NotFoundException, ConflictException } from '@nestj import { InjectRepository } from '@nestjs/typeorm'; import { Repository, In } from 'typeorm'; import * as bcrypt from 'bcryptjs'; -import { Permission, Role, User, Class, ClassStudent, ClassTeacher, ClassSchedule, Student, UserDingMapping } from '../entities'; +import { Permission, Role, User, Class, ClassStudent, ClassTeacher, ClassSchedule, Student, StudentDingMapping } from '../entities'; const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> = [ { code: 'dashboard:view', name: '查看数据面板', group: 'dashboard' }, @@ -171,7 +171,7 @@ export class RbacService { @InjectRepository(ClassTeacher) private classTeacherRepo: Repository, @InjectRepository(ClassSchedule) private classScheduleRepo: Repository, @InjectRepository(Student) private studentRepo: Repository, - @InjectRepository(UserDingMapping) private mappingRepo: Repository, + @InjectRepository(StudentDingMapping) private mappingRepo: Repository, ) {} async seedData(): Promise { @@ -249,15 +249,15 @@ export class RbacService { // ---- 钉钉用户绑定 ---- - async getUserDingMappings(): Promise { - return this.mappingRepo.find({ relations: ['user'] }); + async getUserDingMappings(): Promise { + return this.mappingRepo.find({ relations: ['student'] }); } - async createUserDingMapping(dto: { userId: number; dingUserId: string; dingName?: string; dingMobile?: string }) { + 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.userId}`); - const userExisting = await this.mappingRepo.findOne({ where: { userId: dto.userId } }); - if (userExisting) throw new ConflictException(`本地用户 #${dto.userId} 已绑定到钉钉用户 ${userExisting.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); } @@ -272,7 +272,7 @@ export class RbacService { async getUnboundUsers(): Promise<{ id: number; username: string; name: string }[]> { return this.userRepo .createQueryBuilder('u') - .leftJoin(UserDingMapping, 'm', 'm.userId = u.id') + .leftJoin(StudentDingMapping, 'm', 'm.studentId = u.id') .where('m.id IS NULL') .andWhere('u.isArchived = false') .select(['u.id', 'u.username', 'u.name']) diff --git a/apps/server/src/sync/schedule-sync.service.ts b/apps/server/src/sync/schedule-sync.service.ts index eeac1f4..28219c4 100644 --- a/apps/server/src/sync/schedule-sync.service.ts +++ b/apps/server/src/sync/schedule-sync.service.ts @@ -3,7 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository, In, Not, IsNull } from 'typeorm'; import { ClassSchedule, - UserDingMapping, + StudentDingMapping, ClassStudent, ClassTeacher, Department, @@ -47,8 +47,8 @@ export class ScheduleSyncService { constructor( @InjectRepository(ClassSchedule) private readonly scheduleRepo: Repository, - @InjectRepository(UserDingMapping) - private readonly mappingRepo: Repository, + @InjectRepository(StudentDingMapping) + private readonly studentDingMappingRepo: Repository, @InjectRepository(ClassTeacher) private readonly classTeacherRepo: Repository, @InjectRepository(Department) @@ -88,10 +88,10 @@ export class ScheduleSyncService { // ── Step 2: 获取教师→钉钉用户ID映射 ── const teacherIds = [...new Set(schedules.map((s) => s.teacherId!).filter(Boolean))]; - const mappings = await this.mappingRepo.find({ - where: { userId: In(teacherIds) }, + const mappings = await this.studentDingMappingRepo.find({ + where: { studentId: In(teacherIds) }, }); - const userIdToDingId = new Map(mappings.map((m) => [m.userId, m.dingUserId])); + const userIdToDingId = new Map(mappings.map((m) => [m.studentId, m.dingUserId])); // ── Step 3: 按 (startTime, endTime) 创建/匹配班次 ── const shiftKey = (start: string, end: string) => `${start}-${end}`; @@ -288,8 +288,8 @@ export class ScheduleSyncService { where: { status: 'active', teacherId: Not(IsNull()) }, }); const teacherIds = [...new Set(schedules.map((s) => s.teacherId!).filter(Boolean))]; - const mappings = await this.mappingRepo.find({ - where: { userId: In(teacherIds) }, + const mappings = await this.studentDingMappingRepo.find({ + where: { studentId: In(teacherIds) }, }); return { activeSchedules: schedules.length, diff --git a/apps/server/src/sync/sync.module.ts b/apps/server/src/sync/sync.module.ts index 5cba3e8..f21bc75 100644 --- a/apps/server/src/sync/sync.module.ts +++ b/apps/server/src/sync/sync.module.ts @@ -5,7 +5,7 @@ import { AttendanceModule } from '../attendance/attendance.module'; import { SyncLog, SyncState, - UserDingMapping, + StudentDingMapping, ClassSchedule, Department, UserDepartment, @@ -24,7 +24,7 @@ import { ScheduleSyncService } from './schedule-sync.service'; TypeOrmModule.forFeature([ SyncLog, SyncState, - UserDingMapping, + StudentDingMapping, ClassSchedule, Department, UserDepartment, diff --git a/apps/server/src/sync/sync.service.spec.ts b/apps/server/src/sync/sync.service.spec.ts index e8da096..3fc58e7 100644 --- a/apps/server/src/sync/sync.service.spec.ts +++ b/apps/server/src/sync/sync.service.spec.ts @@ -2,7 +2,7 @@ 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, UserDingMapping, ClassStudent, ClassTeacher } from '../entities'; +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'; @@ -36,7 +36,7 @@ describe('SyncService — importDingTalkUsers (transactional)', () => { let service: SyncService; let mappingRepo: jest.Mocked< - Pick, 'findOne' | 'create' | 'save' | 'find'> + Pick, 'findOne' | 'create' | 'save' | 'find'> >; let userRepo: jest.Mocked, 'create' | 'save'>>; let studentRepo: jest.Mocked, 'create' | 'save'>>; @@ -109,7 +109,7 @@ describe('SyncService — importDingTalkUsers (transactional)', () => { 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(UserDingMapping), useValue: mappingRepo }, + { provide: getRepositoryToken(StudentDingMapping), useValue: mappingRepo }, { provide: getRepositoryToken(User), useValue: userRepo }, { provide: getRepositoryToken(Student), useValue: studentRepo }, { provide: getRepositoryToken(Role), useValue: roleRepo }, @@ -142,7 +142,7 @@ describe('SyncService — importDingTalkUsers (transactional)', () => { 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 === UserDingMapping) return null; + if (entityClass === StudentDingMapping) return null; if (entityClass === Role) return mockRole; return null; }); @@ -170,7 +170,7 @@ describe('SyncService — importDingTalkUsers (transactional)', () => { }); it('throws when role not found', async () => { - // UserDingMapping lookup → null (not skipped), Role lookup → null → throws + // StudentDingMapping lookup → null (not skipped), Role lookup → null → throws mgr.findOne.mockResolvedValue(null); const mockUser = { id: 11 } as User; @@ -194,8 +194,8 @@ describe('SyncService — importDingTalkUsers (transactional)', () => { mgr.save.mockResolvedValueOnce(mockStudent); // mapping create+save also calls create/save - mgr.create.mockReturnValueOnce({} as UserDingMapping); - mgr.save.mockResolvedValueOnce({} as UserDingMapping); + mgr.create.mockReturnValueOnce({} as StudentDingMapping); + mgr.save.mockResolvedValueOnce({} as StudentDingMapping); mappingRepo.findOne.mockResolvedValue(null); @@ -225,7 +225,7 @@ describe('SyncService — importDingTalkUsers (transactional)', () => { }); it('skips user when mapping already exists', async () => { - // mgr.findOne(UserDingMapping, ...) returns truthy → skip inside transaction + // mgr.findOne(StudentDingMapping, ...) returns truthy → skip inside transaction mgr.findOne.mockResolvedValue({ id: 1 }); const users: ImportUserDto[] = [ @@ -259,10 +259,10 @@ describe('SyncService — importDingTalkUsers (transactional)', () => { // Single transaction: User 1→skip, User 2→teacher, User 3→student mgr.findOne - .mockResolvedValueOnce({ id: 99 }) // User 1: UserDingMapping → skip - .mockResolvedValueOnce(null) // User 2: UserDingMapping → not skipped + .mockResolvedValueOnce({ id: 99 }) // User 1: StudentDingMapping → skip + .mockResolvedValueOnce(null) // User 2: StudentDingMapping → not skipped .mockResolvedValueOnce(mockRole) // User 2: Role lookup - .mockResolvedValueOnce(null); // User 3: UserDingMapping → not skipped + .mockResolvedValueOnce(null); // User 3: StudentDingMapping → not skipped const users: ImportUserDto[] = [ { dingUserId: 'skip', name: 'Skip', mobile: '138', roleId: null, dingDeptIds: [] }, @@ -395,7 +395,7 @@ describe('SyncService — importDingTalkUsers (transactional)', () => { }); mgr.count.mockResolvedValue(0); // save(ClassEntity, data) returns a class — but the smart mock already handles this - mgr.findOne.mockResolvedValueOnce(null); // first findOne: UserDingMapping for teacher + 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 diff --git a/apps/server/src/sync/sync.service.ts b/apps/server/src/sync/sync.service.ts index fdc05fe..a2c878a 100644 --- a/apps/server/src/sync/sync.service.ts +++ b/apps/server/src/sync/sync.service.ts @@ -1,7 +1,7 @@ import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, Repository } from 'typeorm'; -import { SyncLog, SyncState, UserDingMapping, ClassStudent, ClassTeacher, Department } from '../entities'; +import { SyncLog, SyncState, StudentDingMapping, ClassStudent, ClassTeacher, Department } from '../entities'; import { Class as ClassEntity } from '../entities/class.entity'; import type { SyncPlatform, SyncType, SyncStatus } from '../entities/sync-log.entity'; import { DingTalkService } from '../integration/dingtalk.service'; @@ -30,8 +30,8 @@ export class SyncService { private readonly syncLogRepo: Repository, @InjectRepository(SyncState) private readonly syncStateRepo: Repository, - @InjectRepository(UserDingMapping) - private readonly mappingRepo: Repository, + @InjectRepository(StudentDingMapping) + private readonly studentDingMappingRepo: Repository, @InjectRepository(User) private readonly userRepo: Repository, @InjectRepository(Student) @@ -123,7 +123,7 @@ export class SyncService { /** * 从钉钉导入用户:roleId 非 null → 老师(User + 指定角色),roleId null → 学生(User + Student)。 * 支持同时创建班级并建立师生关联。 - * 已存在 UserDingMapping 的记录跳过。 + * 已存在 StudentDingMapping 的记录跳过。 */ async importDingTalkUsers( users: ImportUserDto[], @@ -190,7 +190,7 @@ export class SyncService { // 2. 导入用户(逐用户) for (const u of users) { - const existingMapping = await manager.findOne(UserDingMapping, { + const existingMapping = await manager.findOne(StudentDingMapping, { where: { dingUserId: u.dingUserId }, }); @@ -199,7 +199,7 @@ export class SyncService { if (existingMapping) { skipped++; - userId = existingMapping.userId; + userId = existingMapping.studentId; // 判断是老师还是学生:查角色 const existingUser = await manager.findOne(User, { where: { id: userId }, @@ -258,15 +258,13 @@ export class SyncService { } // 钉钉映射(幂等:可能已被 syncAll 创建) - const existingMappingForUser = await manager.findOne(UserDingMapping, { + const existingMappingForUser = await manager.findOne(StudentDingMapping, { where: { dingUserId: u.dingUserId }, }); if (!existingMappingForUser) { - const mapping = manager.create(UserDingMapping, { + const mapping = manager.create(StudentDingMapping, { dingUserId: u.dingUserId, - userId: user.id, - dingName: u.name, - dingMobile: u.mobile, + studentId: user.id, }); await manager.save(mapping); } @@ -422,7 +420,7 @@ export class SyncService { const end = endDate.toISOString().slice(0, 10); this.logger.log(`Importing DingTalk attendance: ${start} ~ ${end}`); - const mappings = await this.mappingRepo.find(); + const mappings = await this.studentDingMappingRepo.find(); const userIds = mappings.map((m) => m.dingUserId); const importResult = await this.attendanceImportService.importFromDingTalk({ startDate: start,