feat: add importDingTalkUsers and org-tree-with-users to SyncService

This commit is contained in:
2026-07-09 11:04:24 +08:00
parent 5abc27c1f0
commit 3835110204
4 changed files with 428 additions and 0 deletions

View File

@@ -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<DingOrgTreeNodeWithUsers[]> {
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<string>();
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<number, DingOrgTreeNodeWithUsers>();
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)
// ═══════════════════════════════════════════

View File

@@ -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,

View File

@@ -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<Repository<UserDingMapping>, '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 dingTalkService: jest.Mocked<Pick<DingTalkService, 'fetchOrgTreeWithUsers' | 'syncAll' | 'fetchOrgTree'>>;
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>(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);
});
});

View File

@@ -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<SyncState>,
@InjectRepository(UserDingMapping)
private readonly mappingRepo: Repository<UserDingMapping>,
@InjectRepository(User)
private readonly userRepo: Repository<User>,
@InjectRepository(Student)
private readonly studentRepo: Repository<Student>,
@InjectRepository(Role)
private readonly roleRepo: Repository<Role>,
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 };
}
// ── 排班同步 ──
/** 将本地排课同步到钉钉考勤排班 */