From 383511020433ab3db43f3c5cfe7bb66d7d4e1bc9 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Thu, 9 Jul 2026 11:04:24 +0800 Subject: [PATCH] feat: add importDingTalkUsers and org-tree-with-users to SyncService --- .../src/integration/dingtalk.service.ts | 68 +++++ apps/server/src/sync/sync.module.ts | 6 + apps/server/src/sync/sync.service.spec.ts | 255 ++++++++++++++++++ apps/server/src/sync/sync.service.ts | 99 +++++++ 4 files changed, 428 insertions(+) create mode 100644 apps/server/src/sync/sync.service.spec.ts diff --git a/apps/server/src/integration/dingtalk.service.ts b/apps/server/src/integration/dingtalk.service.ts index 2dd9810..19bf595 100644 --- a/apps/server/src/integration/dingtalk.service.ts +++ b/apps/server/src/integration/dingtalk.service.ts @@ -72,6 +72,15 @@ export interface DingOrgTreeNode { children: DingOrgTreeNode[]; } +/** 钉钉部门树节点(含用户),供同步用户选择器使用 */ +export interface DingOrgTreeNodeWithUsers { + id: number; + name: string; + parentId: number; + children: DingOrgTreeNodeWithUsers[]; + users: Array<{ userid: string; name: string; mobile: string }>; +} + // ── 考勤排班 API 类型 ── /** 班次卡段打卡时间 */ @@ -455,6 +464,65 @@ export class DingTalkService { return roots; } + /** + * 获取钉钉组织部门树(含用户),供前端同步用户选择器使用。 + * 返回从指定 rootDeptId 开始的树,每个部门节点含 users 数组。 + */ + async fetchOrgTreeWithUsers(rootDeptId = 1): Promise { + if (!this.configured) { + throw new ServiceUnavailableException('钉钉未配置'); + } + const token = await this.getAccessToken(); + const deptIds = await this.getAllDeptIds(token, rootDeptId); + + // 拉每个部门详情 + const nodes: DingOrgTreeNodeWithUsers[] = []; + for (let i = 0; i < deptIds.length; i++) { + if (i > 0) await this.delay(i); + const detail = await this.getDeptDetail(token, deptIds[i]); + if (!detail) continue; + + // 拉该部门下的用户 + const dingUsers = await this.getDeptUsers(token, deptIds[i]); + + nodes.push({ + id: detail.dept_id, + name: detail.name, + parentId: detail.parent_id, + children: [], + users: dingUsers.map((u) => ({ + userid: u.userid, + name: u.name, + mobile: u.mobile, + })), + }); + } + + // 全局去重:同一个 dingUserId 可能在多个部门出现 + const seenUserIds = new Set(); + for (const node of nodes) { + node.users = node.users.filter((u) => { + if (seenUserIds.has(u.userid)) return false; + seenUserIds.add(u.userid); + return true; + }); + } + + // 组装成树 + const map = new Map(); + nodes.forEach((n) => map.set(n.id, n)); + const roots: DingOrgTreeNodeWithUsers[] = []; + for (const node of nodes) { + const parent = map.get(node.parentId); + if (parent && node.id !== rootDeptId) { + parent.children.push(node); + } else { + roots.push(node); + } + } + return roots; + } + // ═══════════════════════════════════════════ // Sync one user (with mapping) // ═══════════════════════════════════════════ diff --git a/apps/server/src/sync/sync.module.ts b/apps/server/src/sync/sync.module.ts index 9c993f8..ae0b92e 100644 --- a/apps/server/src/sync/sync.module.ts +++ b/apps/server/src/sync/sync.module.ts @@ -10,6 +10,9 @@ import { Department, UserDepartment, ClassTeacher, + User, + Student, + Role, } from '../entities'; import { SyncService } from './sync.service'; import { SyncController } from './sync.controller'; @@ -25,6 +28,9 @@ import { ScheduleSyncService } from './schedule-sync.service'; Department, UserDepartment, ClassTeacher, + User, + Student, + Role, ]), IntegrationModule, AttendanceModule, diff --git a/apps/server/src/sync/sync.service.spec.ts b/apps/server/src/sync/sync.service.spec.ts new file mode 100644 index 0000000..efdfdea --- /dev/null +++ b/apps/server/src/sync/sync.service.spec.ts @@ -0,0 +1,255 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { SyncService, ImportUserDto } from './sync.service'; +import { SyncLog, SyncState, UserDingMapping } from '../entities'; +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'; + +describe('SyncService — new methods', () => { + 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 dingTalkService: jest.Mocked>; + + beforeEach(async () => { + 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(), + }; + + 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(UserDingMapping), useValue: mappingRepo }, + { provide: getRepositoryToken(User), useValue: userRepo }, + { provide: getRepositoryToken(Student), useValue: studentRepo }, + { provide: getRepositoryToken(Role), useValue: roleRepo }, + { 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; + roleRepo.findOne.mockResolvedValue(mockRole); + + const mockUser = { id: 10 } as User; + userRepo.create.mockReturnValue(mockUser); + userRepo.save.mockResolvedValue(mockUser); + + mappingRepo.findOne.mockResolvedValue(null); + mappingRepo.create.mockReturnValue({} as UserDingMapping); + mappingRepo.save.mockResolvedValue({} as UserDingMapping); + + 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(roleRepo.findOne).toHaveBeenCalledWith({ where: { id: 5 } }); + expect(userRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ roles: [mockRole] }), + ); + }); + + it('imports teacher when roleId is a number but role not found (logs warning, no roles assigned)', async () => { + roleRepo.findOne.mockResolvedValue(null); + + const mockUser = { id: 11 } as User; + userRepo.create.mockReturnValue(mockUser); + userRepo.save.mockResolvedValue(mockUser); + + mappingRepo.findOne.mockResolvedValue(null); + mappingRepo.create.mockReturnValue({} as UserDingMapping); + mappingRepo.save.mockResolvedValue({} as UserDingMapping); + + const users: ImportUserDto[] = [ + { dingUserId: 'user2', name: 'Li Si', mobile: '13800002222', roleId: 999 }, + ]; + + const result = await service.importDingTalkUsers(users); + + expect(result.teacherCount).toBe(1); + expect(result.studentCount).toBe(0); + expect(result.skipped).toBe(0); + + // User should be saved once (without roles assignment the second time) + expect(userRepo.save).toHaveBeenCalledTimes(1); + }); + + it('imports student when roleId is null', async () => { + const mockUser = { id: 20 } as User; + userRepo.create.mockReturnValue(mockUser); + userRepo.save.mockResolvedValue(mockUser); + + const mockStudent = { id: 30 } as Student; + studentRepo.create.mockReturnValue(mockStudent); + studentRepo.save.mockResolvedValue(mockStudent); + + mappingRepo.findOne.mockResolvedValue(null); + mappingRepo.create.mockReturnValue({} as UserDingMapping); + mappingRepo.save.mockResolvedValue({} as UserDingMapping); + + 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(studentRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Wang Wu', + userId: 20, + status: 'active', + }), + ); + expect(studentRepo.save).toHaveBeenCalled(); + }); + + it('skips user when mapping already exists', async () => { + mappingRepo.findOne.mockResolvedValue({ id: 1 } as UserDingMapping); + + 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(userRepo.create).not.toHaveBeenCalled(); + }); + + it('handles per-user errors gracefully — one failure does not block others', async () => { + // First user fails, second succeeds + mappingRepo.findOne.mockResolvedValue(null); + mappingRepo.create.mockReturnValue({} as UserDingMapping); + mappingRepo.save.mockResolvedValue({} as UserDingMapping); + + userRepo.create + .mockReturnValueOnce(new Error('DB error') as unknown as User) + .mockReturnValueOnce({ id: 40 } as User); + + userRepo.save + .mockRejectedValueOnce(new Error('DB error')) + .mockResolvedValueOnce({ id: 40 } as User); + + studentRepo.create.mockReturnValue({} as Student); + studentRepo.save.mockResolvedValue({} as Student); + + const users: ImportUserDto[] = [ + { dingUserId: 'fail', name: 'Fail User', mobile: '', roleId: null }, + { dingUserId: 'ok', name: 'Ok User', mobile: '', roleId: null }, + ]; + + const result = await service.importDingTalkUsers(users); + + expect(result.studentCount).toBe(1); + expect(result.skipped).toBe(0); + + // The first user's mapping should not be saved, but the second's should + expect(mappingRepo.save).toHaveBeenCalledTimes(1); + }); + + it('counts mixed teacher/student/skipped correctly', async () => { + const mockRole = { id: 1, name: 'Teacher Role' } as Role; + roleRepo.findOne.mockResolvedValue(mockRole); + + const mockUser = { id: 50 } as User; + userRepo.create.mockReturnValue(mockUser); + userRepo.save.mockResolvedValue(mockUser); + + studentRepo.create.mockReturnValue({} as Student); + studentRepo.save.mockResolvedValue({} as Student); + + mappingRepo.create.mockReturnValue({} as UserDingMapping); + mappingRepo.save.mockResolvedValue({} as UserDingMapping); + + // First: skip (existing), second: teacher, third: student + mappingRepo.findOne + .mockResolvedValueOnce({ id: 99 } as UserDingMapping) // skip + .mockResolvedValueOnce(null) // teacher + .mockResolvedValueOnce(null); // student + + const users: ImportUserDto[] = [ + { dingUserId: 'skip', name: 'Skip', mobile: '138', roleId: null }, + { dingUserId: 'teacher', name: 'Teacher', mobile: '139', roleId: 1 }, + { dingUserId: 'student', name: 'Student', mobile: '', roleId: null }, + ]; + + const result = await service.importDingTalkUsers(users); + + expect(result.teacherCount).toBe(1); + expect(result.studentCount).toBe(1); + expect(result.skipped).toBe(1); + }); +}); diff --git a/apps/server/src/sync/sync.service.ts b/apps/server/src/sync/sync.service.ts index e874c98..9e57f1a 100644 --- a/apps/server/src/sync/sync.service.ts +++ b/apps/server/src/sync/sync.service.ts @@ -7,6 +7,17 @@ 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'; + +export interface ImportUserDto { + dingUserId: string; + name: string; + mobile: string; + roleId: number | null; +} @Injectable() export class SyncService { @@ -18,6 +29,12 @@ export class SyncService { private readonly syncStateRepo: Repository, @InjectRepository(UserDingMapping) private readonly mappingRepo: Repository, + @InjectRepository(User) + private readonly userRepo: Repository, + @InjectRepository(Student) + private readonly studentRepo: Repository, + @InjectRepository(Role) + private readonly roleRepo: Repository, private readonly dingTalkService: DingTalkService, private readonly weComService: WeComService, private readonly attendanceImportService: AttendanceImportService, @@ -92,6 +109,88 @@ export class SyncService { return this.dingTalkService.fetchOrgTree(rootDeptId); } + /** 获取钉钉组织部门树(含用户),供前端同步用户选择器使用 */ + async getDingTalkOrgTreeWithUsers(rootDeptId = 1) { + return this.dingTalkService.fetchOrgTreeWithUsers(rootDeptId); + } + + /** + * 从钉钉导入用户:roleId 非 null → 老师(User + 指定角色),roleId null → 学生(User + Student)。 + * 已存在 UserDingMapping 的记录跳过。 + */ + async importDingTalkUsers(users: ImportUserDto[]): Promise<{ + teacherCount: number; + studentCount: number; + skipped: number; + }> { + let teacherCount = 0; + let studentCount = 0; + let skipped = 0; + + for (const u of users) { + // 检查是否已存在映射 + const existing = await this.mappingRepo.findOne({ + where: { dingUserId: u.dingUserId }, + }); + if (existing) { + skipped++; + continue; + } + + try { + const username = u.mobile || `dd_${u.dingUserId}`; + const passwordHash = await bcrypt.hash('123456', 10); + + const user = this.userRepo.create({ + username, + name: u.name, + passwordHash, + isActive: true, + }); + await this.userRepo.save(user); + + if (u.roleId != null) { + // 老师:分配角色 + const role = await this.roleRepo.findOne({ where: { id: u.roleId } }); + if (role) { + user.roles = [role]; + await this.userRepo.save(user); + } else { + this.logger.warn(`角色 id=${u.roleId} 不存在,用户 ${u.name} 未分配角色`); + } + teacherCount++; + } else { + // 学生:创建 Student 记录 + const student = this.studentRepo.create({ + name: u.name, + phone: u.mobile || undefined, + userId: user.id, + status: 'active', + }); + await this.studentRepo.save(student); + studentCount++; + } + + // 创建映射 + const mapping = this.mappingRepo.create({ + dingUserId: u.dingUserId, + userId: user.id, + dingName: u.name, + dingMobile: u.mobile, + }); + await this.mappingRepo.save(mapping); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + this.logger.error(`导入用户 ${u.name}(${u.dingUserId}) 失败: ${msg}`); + } + } + + this.logger.log( + `钉钉用户导入完成: ${teacherCount} 位老师, ${studentCount} 位学生, ${skipped} 跳过`, + ); + return { teacherCount, studentCount, skipped }; + } + // ── 排班同步 ── /** 将本地排课同步到钉钉考勤排班 */