From 2b60a109275a404397401bc6c6507a4ce12d30b4 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Thu, 9 Jul 2026 10:58:26 +0800 Subject: [PATCH 1/9] feat: add fetchOrgTreeWithUsers to DingTalkService --- .../src/integration/dingtalk.service.ts | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) 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) // ═══════════════════════════════════════════ From cd7f9c90b3130cb6f86ca0f742c3fbe2030bbdd3 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Thu, 9 Jul 2026 11:04:24 +0800 Subject: [PATCH 2/9] feat: add importDingTalkUsers and org-tree-with-users to SyncService --- 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 +++++++++ 3 files changed, 360 insertions(+) create mode 100644 apps/server/src/sync/sync.service.spec.ts 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 }; + } + // ── 排班同步 ── /** 将本地排课同步到钉钉考勤排班 */ From 3790b2dabb5bdc62727c548f95ff048cd702ec5b Mon Sep 17 00:00:00 2001 From: wangziqi Date: Thu, 9 Jul 2026 11:06:58 +0800 Subject: [PATCH 3/9] refactor: remove sync and mark-staff buttons from Users page --- apps/admin/src/pages/Users/index.tsx | 79 +--------------------------- 1 file changed, 2 insertions(+), 77 deletions(-) diff --git a/apps/admin/src/pages/Users/index.tsx b/apps/admin/src/pages/Users/index.tsx index 9f3d75a..3a0464d 100644 --- a/apps/admin/src/pages/Users/index.tsx +++ b/apps/admin/src/pages/Users/index.tsx @@ -10,9 +10,8 @@ import { Tag, Popconfirm, message, - TreeSelect, } from 'antd'; -import { PlusOutlined, EditOutlined, DeleteOutlined, KeyOutlined, IdcardOutlined, CloudDownloadOutlined, InboxOutlined } from '@ant-design/icons'; +import { PlusOutlined, EditOutlined, DeleteOutlined, KeyOutlined, IdcardOutlined, InboxOutlined } from '@ant-design/icons'; import dayjs from 'dayjs'; import api from '../../api'; import PermissionButton from '../../components/PermissionButton'; @@ -30,27 +29,9 @@ const UsersPage: React.FC = () => { const [profileForm] = Form.useForm(); const [form] = Form.useForm(); const [pwdForm] = Form.useForm(); - const [syncing, setSyncing] = useState(false); const [saving, setSaving] = useState(false); const [showArchived, setShowArchived] = useState(false); - // 组织树同步 - const [syncDeptId, setSyncDeptId] = useState(undefined); - const [orgTree, setOrgTree] = useState }>>([]); - const loadOrgTree = async () => { - try { - const res = await api.get<{ success: boolean; data: Array<{ id: number; name: string; children: Array<{ id: number; name: string; children: unknown[] }> }> }>('/sync/dingtalk/org-tree'); - const toTreeNode = (nodes: Array<{ id: number; name: string; children: unknown[] }>): Array<{ title: string; value: number; children?: unknown[] }> => - nodes.map((n) => ({ - title: n.name, - value: n.id, - children: n.children?.length > 0 ? toTreeNode(n.children as Array<{ id: number; name: string; children: unknown[] }>) : undefined, - })); - setOrgTree(toTreeNode(res.data)); - } catch { - // org tree may not be available - } - }; const handleOpenProfile = async (record: any) => { setProfileUser(record); @@ -93,24 +74,6 @@ const UsersPage: React.FC = () => { setLoading(false); }, [showArchived]); - const handleSyncDingTalk = async () => { - setSyncing(true); - try { - const params: Record = {}; - if (syncDeptId) params.rootDeptId = String(syncDeptId); - const res = await api.post('/sync/trigger?platform=dingtalk', undefined as unknown as null, { params }) as { - synced: number; logs: Array<{ recordsCount?: number }>; - }; - const log = res.logs?.[0]; - message.success(`同步完成:${log?.recordsCount || 0} 条记录`); - fetchData(); - } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '同步失败'); - } finally { - setSyncing(false); - } - }; useEffect(() => { fetchData(); @@ -184,16 +147,6 @@ const UsersPage: React.FC = () => { } }; - const handleMarkStaff = async (id: number, toStaff: boolean) => { - try { - await api.put(`/rbac/users/${id}/${toStaff ? 'mark-staff' : 'mark-student'}`); - message.success(toStaff ? '已标记为教职工' : '已恢复为学员'); - fetchData(); - } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '操作失败'); - } - }; const handleResetPwd = (record: any) => { setResetTarget(record); @@ -255,7 +208,7 @@ const UsersPage: React.FC = () => { }, { title: '操作', - width: 320, + width: 240, fixed: 'right' as const, render: (_: unknown, record: any) => ( @@ -295,16 +248,6 @@ const UsersPage: React.FC = () => { 归档 )} - {record.studentStatus === 'active' && ( - handleMarkStaff(record.id, true)}> - 标记教职工 - - )} - {record.studentStatus === 'staff' && ( - handleMarkStaff(record.id, false)}> - 恢复学员 - - )} {record.username !== 'admin' && ( handleDelete(record.id)}> }> @@ -330,16 +273,6 @@ const UsersPage: React.FC = () => { }}>

账号管理

- setSyncDeptId(v)} - placeholder="选择同步部门(不选=全部)" - allowClear - treeDefaultExpandAll - style={{ minWidth: 260 }} - onDropdownVisibleChange={(open) => { if (open) loadOrgTree(); }} - /> { > 新增账号 - } - loading={syncing} - onClick={handleSyncDingTalk} - > - 同步钉钉用户 - 归档 From 8ba332707417bac0ac8bce99564538c37c000f06 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Thu, 9 Jul 2026 11:07:25 +0800 Subject: [PATCH 4/9] fix: handle duplicate usernames and failed role lookups in importDingTalkUsers --- apps/server/src/sync/sync.service.spec.ts | 21 +++++++++++++++++---- apps/server/src/sync/sync.service.ts | 14 +++++++++++--- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/apps/server/src/sync/sync.service.spec.ts b/apps/server/src/sync/sync.service.spec.ts index efdfdea..ab9f312 100644 --- a/apps/server/src/sync/sync.service.spec.ts +++ b/apps/server/src/sync/sync.service.spec.ts @@ -116,13 +116,17 @@ describe('SyncService — new methods', () => { ); }); - it('imports teacher when roleId is a number but role not found (logs warning, no roles assigned)', async () => { + it('imports teacher when roleId is a number but role not found (creates student as fallback)', async () => { roleRepo.findOne.mockResolvedValue(null); const mockUser = { id: 11 } as User; userRepo.create.mockReturnValue(mockUser); userRepo.save.mockResolvedValue(mockUser); + const mockStudent = { id: 31 } as Student; + studentRepo.create.mockReturnValue(mockStudent); + studentRepo.save.mockResolvedValue(mockStudent); + mappingRepo.findOne.mockResolvedValue(null); mappingRepo.create.mockReturnValue({} as UserDingMapping); mappingRepo.save.mockResolvedValue({} as UserDingMapping); @@ -133,12 +137,21 @@ describe('SyncService — new methods', () => { const result = await service.importDingTalkUsers(users); - expect(result.teacherCount).toBe(1); - expect(result.studentCount).toBe(0); + expect(result.teacherCount).toBe(0); + expect(result.studentCount).toBe(1); expect(result.skipped).toBe(0); - // User should be saved once (without roles assignment the second time) + // User should be saved once (no second save for role assignment) expect(userRepo.save).toHaveBeenCalledTimes(1); + // Student record should have been created as fallback + expect(studentRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Li Si', + userId: 11, + status: 'active', + }), + ); + expect(studentRepo.save).toHaveBeenCalled(); }); it('imports student when roleId is null', async () => { diff --git a/apps/server/src/sync/sync.service.ts b/apps/server/src/sync/sync.service.ts index 9e57f1a..008b359 100644 --- a/apps/server/src/sync/sync.service.ts +++ b/apps/server/src/sync/sync.service.ts @@ -138,7 +138,7 @@ export class SyncService { } try { - const username = u.mobile || `dd_${u.dingUserId}`; + const username = `dd_${u.dingUserId}`; const passwordHash = await bcrypt.hash('123456', 10); const user = this.userRepo.create({ @@ -155,10 +155,18 @@ export class SyncService { if (role) { user.roles = [role]; await this.userRepo.save(user); + teacherCount++; } else { - this.logger.warn(`角色 id=${u.roleId} 不存在,用户 ${u.name} 未分配角色`); + this.logger.warn(`角色 id=${u.roleId} 不存在,用户 ${u.name} 转为学生`); + const student = this.studentRepo.create({ + name: u.name, + phone: u.mobile || undefined, + userId: user.id, + status: 'active', + }); + await this.studentRepo.save(student); + studentCount++; } - teacherCount++; } else { // 学生:创建 Student 记录 const student = this.studentRepo.create({ From ff6cb034190956637dc3cdb3c439417df3385999 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Thu, 9 Jul 2026 11:08:33 +0800 Subject: [PATCH 5/9] feat: add org-tree-with-users and import-users endpoints --- apps/server/src/sync/sync.controller.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/server/src/sync/sync.controller.ts b/apps/server/src/sync/sync.controller.ts index 6738448..1e2ce04 100644 --- a/apps/server/src/sync/sync.controller.ts +++ b/apps/server/src/sync/sync.controller.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Controller, Get, Post, Query, UseGuards } from '@nestjs/common'; +import { BadRequestException, Body, Controller, Get, 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'; @@ -40,6 +40,21 @@ export class SyncController { return { success: true, data: tree }; } + /** 获取钉钉组织部门树(含用户),供同步用户选择器使用 */ + @Get('dingtalk/org-tree-with-users') + @RequirePermission('sync:read') + async getDingTalkOrgTreeWithUsers(@Query('rootDeptId') rootDeptId?: string) { + const rootId = rootDeptId ? this.parseRootDeptId(rootDeptId) : 1; + const tree = await this.syncService.getDingTalkOrgTreeWithUsers(rootId); + return { success: true, data: tree }; + } + /** 导入钉钉用户:老师分配角色,学生创建 Student */ + @Post('dingtalk/import-users') + @RequirePermission('sync:trigger') + async importDingTalkUsers(@Body() body: { users: Array<{ dingUserId: string; name: string; mobile: string; roleId: number | null }> }) { + const result = await this.syncService.importDingTalkUsers(body.users); + return { success: true, ...result }; + } @Get('logs') @RequirePermission('sync:read') async getLogs( From a42039d1df6bdcd07987a3c7ca4da40361ecac5a Mon Sep 17 00:00:00 2001 From: wangziqi Date: Thu, 9 Jul 2026 11:13:05 +0800 Subject: [PATCH 6/9] feat: add sync users Tab with Drawer tree to IntegrationConfig --- .../src/pages/IntegrationConfig/index.tsx | 380 +++++++++++++++--- 1 file changed, 331 insertions(+), 49 deletions(-) diff --git a/apps/admin/src/pages/IntegrationConfig/index.tsx b/apps/admin/src/pages/IntegrationConfig/index.tsx index e601eea..1bbd275 100644 --- a/apps/admin/src/pages/IntegrationConfig/index.tsx +++ b/apps/admin/src/pages/IntegrationConfig/index.tsx @@ -1,10 +1,13 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useMemo } from 'react'; import { Card, Form, Input, Button, Space, message, Spin, Switch, Alert, Descriptions, Tag, + Tabs, Drawer, Tree, Checkbox, Select, TreeSelect, } from 'antd'; import { SaveOutlined, ApiOutlined, CheckCircleOutlined, CloseCircleOutlined, + SyncOutlined, ReloadOutlined, } from '@ant-design/icons'; +import type { DataNode } from 'antd/es/tree'; import api from '../../api'; interface DingTalkConfig { @@ -14,6 +17,42 @@ interface DingTalkConfig { startEnable: boolean; } +interface DingOrgTreeNodeExt { + id: number; + name: string; + parentId: number; + children: DingOrgTreeNodeExt[]; + users: Array<{ userid: string; name: string; mobile: string }>; +} + +interface RoleItem { + id: number; + name: string; + status: number; +} + +interface OrgTreeNodeRaw { + id: number; + name: string; + children?: OrgTreeNodeRaw[]; +} + +interface OrgTreeResponse { + success: boolean; + data: OrgTreeNodeRaw[]; +} + +interface OrgTreeWithUsersResponse { + success: boolean; + data: DingOrgTreeNodeExt[]; +} + +interface ImportUsersResponse { + teacherCount: number; + studentCount: number; + skipped: number; +} + const IntegrationConfigPage: React.FC = () => { const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); @@ -22,6 +61,18 @@ const IntegrationConfigPage: React.FC = () => { const [verified, setVerified] = useState(null); const [form] = Form.useForm(); + // ── Sync Users Tab ── + const [syncRootDeptId, setSyncRootDeptId] = useState(undefined); + const [orgTree, setOrgTree] = useState([]); + const [drawerOpen, setDrawerOpen] = useState(false); + const [fetchingTree, setFetchingTree] = useState(false); + const [importing, setImporting] = useState(false); + const [teacherChecks, setTeacherChecks] = useState>({}); + const [teacherRoles, setTeacherRoles] = useState>({}); + const [roles, setRoles] = useState>([]); + const [defaultTeacherRoleId, setDefaultTeacherRoleId] = useState(null); + const [deptPickerTree, setDeptPickerTree] = useState }>>([]); + const fetchConfig = async () => { setLoading(true); try { @@ -41,6 +92,7 @@ const IntegrationConfigPage: React.FC = () => { useEffect(() => { fetchConfig(); + fetchRoles(); }, []); const handleSave = async () => { @@ -77,6 +129,283 @@ const IntegrationConfigPage: React.FC = () => { } }; + const fetchRoles = async () => { + try { + const res = await api.get('/rbac/roles'); + const activeRoles = res.filter((r) => r.status !== 0); + setRoles(activeRoles); + const teacherRole = activeRoles.find((r) => r.name === '班主任'); + setDefaultTeacherRoleId(teacherRole?.id || activeRoles[0]?.id || null); + } catch { + // ignore + } + }; + + const loadDeptTree = async () => { + try { + const res = await api.get('/sync/dingtalk/org-tree'); + if (res.success && res.data) { + const toTreeNode = (nodes: OrgTreeNodeRaw[]): Array<{ title: string; value: number; children?: Array<{ title: string; value: number; children?: unknown[] }> }> => + nodes.map((n) => ({ + title: n.name, + value: n.id, + children: n.children ? toTreeNode(n.children) : undefined, + })); + setDeptPickerTree(toTreeNode(res.data)); + } + } catch { + // ignore + } + }; + + const handleFetchOrgTree = async () => { + setFetchingTree(true); + try { + const params: Record = {}; + if (syncRootDeptId) params.rootDeptId = String(syncRootDeptId); + const res = await api.get('/sync/dingtalk/org-tree-with-users', { params }); + if (res.success && res.data) { + setOrgTree(res.data); + setTeacherChecks({}); + setTeacherRoles({}); + setDrawerOpen(true); + } else { + message.error('获取组织架构失败'); + } + } catch (e: unknown) { + const err = e as { message?: string }; + message.error(err?.message || '获取组织架构失败'); + } finally { + setFetchingTree(false); + } + }; + + const handleImportUsers = async () => { + setImporting(true); + try { + const allUsers: Array<{ + dingUserId: string; + name: string; + mobile: string; + }> = []; + + const flatten = (nodes: DingOrgTreeNodeExt[]) => { + for (const node of nodes) { + allUsers.push(...node.users); + flatten(node.children); + } + }; + flatten(orgTree); + + const payload = { + users: allUsers.map((u) => ({ + dingUserId: u.userid, + name: u.name, + mobile: u.mobile, + roleId: teacherChecks[u.userid] + ? (teacherRoles[u.userid] || defaultTeacherRoleId) + : null, + })), + }; + + const res = await api.post('/sync/dingtalk/import-users', payload); + message.success( + `导入完成:${res.teacherCount} 位老师,${res.studentCount} 位学生` + + (res.skipped > 0 ? `,${res.skipped} 已跳过` : ''), + ); + setDrawerOpen(false); + } catch (e: unknown) { + const err = e as { message?: string }; + message.error(err?.message || '导入失败'); + } finally { + setImporting(false); + } + }; + + const buildTreeData = (nodes: DingOrgTreeNodeExt[]): DataNode[] => { + return nodes.map((node) => ({ + title: node.name, + key: `dept-${node.id}`, + children: [ + ...buildTreeData(node.children), + ...node.users.map((u) => ({ + title: ( + + { + setTeacherChecks((prev) => ({ + ...prev, + [u.userid]: e.target.checked, + })); + if (!e.target.checked) { + setTeacherRoles((prev) => { + const next = { ...prev }; + delete next[u.userid]; + return next; + }); + } + }} + > + 老师 + + {u.name} + {u.mobile && ( + {u.mobile} + )} + {teacherChecks[u.userid] && ( + + + + + + + + + + + + + + + + + + ), + }, + ...syncTabItems, + ]; + return ( @@ -84,54 +413,7 @@ const IntegrationConfigPage: React.FC = () => { {verified === false && } color="error">未连接} }> - - {config && ( - - {config.corpId || '-'} - {config.agentId || '-'} - - - {config.startEnable ? '已启用' : '未启用'} - - - - )} - - - -
- - - - - - - - - - - - - - - - -
-
+ ); }; From 33e6cb445cca1ad09b0e4877767aa526cf28f10b Mon Sep 17 00:00:00 2001 From: wangziqi Date: Thu, 9 Jul 2026 11:16:47 +0800 Subject: [PATCH 7/9] fix: parallel fetches, error handling, extract UserTreeNode component --- .../src/pages/IntegrationConfig/index.tsx | 95 +++++++++++-------- 1 file changed, 54 insertions(+), 41 deletions(-) diff --git a/apps/admin/src/pages/IntegrationConfig/index.tsx b/apps/admin/src/pages/IntegrationConfig/index.tsx index 1bbd275..659b294 100644 --- a/apps/admin/src/pages/IntegrationConfig/index.tsx +++ b/apps/admin/src/pages/IntegrationConfig/index.tsx @@ -53,6 +53,33 @@ interface ImportUsersResponse { skipped: number; } +const UserTreeNode: React.FC<{ + u: { userid: string; name: string; mobile: string }; + isTeacher: boolean; + onToggle: () => void; + roleId: number | undefined; + defaultRoleId: number | null; + roles: Array<{ id: number; name: string }>; + onRoleChange: (roleId: number) => void; +}> = React.memo(({ u, isTeacher, onToggle, roleId, defaultRoleId, roles, onRoleChange }) => ( + + 老师 + {u.name} + {u.mobile && {u.mobile}} + {isTeacher && ( + - setTeacherRoles((prev) => ({ ...prev, [u.userid]: roleId })) - } - options={roles.map((r) => ({ label: r.name, value: r.id }))} - placeholder="选择角色" - onClick={(e: React.MouseEvent) => e.stopPropagation()} - /> - )} - + { + setTeacherChecks((prev) => ({ + ...prev, + [u.userid]: !prev[u.userid], + })); + if (teacherChecks[u.userid]) { + setTeacherRoles((prev) => { + const next = { ...prev }; + delete next[u.userid]; + return next; + }); + } + }} + roleId={teacherRoles[u.userid]} + defaultRoleId={defaultTeacherRoleId} + roles={roles} + onRoleChange={(roleId) => + setTeacherRoles((prev) => ({ ...prev, [u.userid]: roleId })) + } + /> ), key: `user-${u.userid}`, selectable: false, From 0cfdab95eebe2323719ee7e8b0eb8d03aa8311c8 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Thu, 9 Jul 2026 11:28:11 +0800 Subject: [PATCH 8/9] fix: add DTO validation, transactional imports, role-not-found handling, null role guard --- .../src/pages/IntegrationConfig/index.tsx | 399 +++--------------- apps/server/src/sync/dto/import-users.dto.ts | 30 ++ apps/server/src/sync/sync.controller.ts | 5 +- apps/server/src/sync/sync.service.spec.ts | 183 ++++---- apps/server/src/sync/sync.service.ts | 81 ++-- 5 files changed, 241 insertions(+), 457 deletions(-) create mode 100644 apps/server/src/sync/dto/import-users.dto.ts diff --git a/apps/admin/src/pages/IntegrationConfig/index.tsx b/apps/admin/src/pages/IntegrationConfig/index.tsx index 659b294..9c530f7 100644 --- a/apps/admin/src/pages/IntegrationConfig/index.tsx +++ b/apps/admin/src/pages/IntegrationConfig/index.tsx @@ -1,13 +1,10 @@ -import React, { useEffect, useState, useMemo } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { Card, Form, Input, Button, Space, message, Spin, Switch, Alert, Descriptions, Tag, - Tabs, Drawer, Tree, Checkbox, Select, TreeSelect, } from 'antd'; import { SaveOutlined, ApiOutlined, CheckCircleOutlined, CloseCircleOutlined, - SyncOutlined, ReloadOutlined, } from '@ant-design/icons'; -import type { DataNode } from 'antd/es/tree'; import api from '../../api'; interface DingTalkConfig { @@ -17,68 +14,11 @@ interface DingTalkConfig { startEnable: boolean; } -interface DingOrgTreeNodeExt { - id: number; - name: string; - parentId: number; - children: DingOrgTreeNodeExt[]; - users: Array<{ userid: string; name: string; mobile: string }>; -} - -interface RoleItem { - id: number; - name: string; - status: number; -} - -interface OrgTreeNodeRaw { - id: number; - name: string; - children?: OrgTreeNodeRaw[]; -} - -interface OrgTreeResponse { - success: boolean; - data: OrgTreeNodeRaw[]; -} - -interface OrgTreeWithUsersResponse { - success: boolean; - data: DingOrgTreeNodeExt[]; -} - -interface ImportUsersResponse { - teacherCount: number; - studentCount: number; - skipped: number; -} - -const UserTreeNode: React.FC<{ - u: { userid: string; name: string; mobile: string }; - isTeacher: boolean; - onToggle: () => void; - roleId: number | undefined; - defaultRoleId: number | null; - roles: Array<{ id: number; name: string }>; - onRoleChange: (roleId: number) => void; -}> = React.memo(({ u, isTeacher, onToggle, roleId, defaultRoleId, roles, onRoleChange }) => ( - - 老师 - {u.name} - {u.mobile && {u.mobile}} - {isTeacher && ( - - - - - - - - - - - - - - - - - - ), - }, - ...syncTabItems, - ]; - return ( @@ -426,7 +90,54 @@ const IntegrationConfigPage: React.FC = () => { {verified === false && } color="error">未连接} }> - + + {config && ( + + {config.corpId || '-'} + {config.agentId || '-'} + + + {config.startEnable ? '已启用' : '未启用'} + + + + )} + + + +
+ + + + + + + + + + + + + + + + +
+
); }; diff --git a/apps/server/src/sync/dto/import-users.dto.ts b/apps/server/src/sync/dto/import-users.dto.ts new file mode 100644 index 0000000..1a137cd --- /dev/null +++ b/apps/server/src/sync/dto/import-users.dto.ts @@ -0,0 +1,30 @@ +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; +} + +export class ImportUsersDto { + @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 1e2ce04..64d30e7 100644 --- a/apps/server/src/sync/sync.controller.ts +++ b/apps/server/src/sync/sync.controller.ts @@ -3,6 +3,7 @@ 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') @@ -48,13 +49,15 @@ export class SyncController { const tree = await this.syncService.getDingTalkOrgTreeWithUsers(rootId); return { success: true, data: tree }; } + /** 导入钉钉用户:老师分配角色,学生创建 Student */ @Post('dingtalk/import-users') @RequirePermission('sync:trigger') - async importDingTalkUsers(@Body() body: { users: Array<{ dingUserId: string; name: string; mobile: string; roleId: number | null }> }) { + async importDingTalkUsers(@Body() body: ImportUsersDto) { const result = await this.syncService.importDingTalkUsers(body.users); 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 index ab9f312..91531e9 100644 --- a/apps/server/src/sync/sync.service.spec.ts +++ b/apps/server/src/sync/sync.service.spec.ts @@ -1,6 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { DataSource, Repository } from 'typeorm'; import { SyncService, ImportUserDto } from './sync.service'; import { SyncLog, SyncState, UserDingMapping } from '../entities'; import { User } from '../entities/user.entity'; @@ -11,7 +11,24 @@ import { WeComService } from '../integration/wecom.service'; import { AttendanceImportService } from '../attendance/attendance-import.service'; import { ScheduleSyncService } from './schedule-sync.service'; -describe('SyncService — new methods', () => { +// ── EntityManager mock helpers ── + +interface ManagerMock { + create: jest.Mock; + save: jest.Mock; + findOne: jest.Mock; +} + +function mockManager(overrides: Partial = {}): ManagerMock { + return { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + ...overrides, + }; +} + +describe('SyncService — importDingTalkUsers (transactional)', () => { let service: SyncService; let mappingRepo: jest.Mocked< @@ -20,10 +37,15 @@ describe('SyncService — new methods', () => { let userRepo: jest.Mocked, 'create' | 'save'>>; let studentRepo: jest.Mocked, 'create' | 'save'>>; let roleRepo: jest.Mocked, 'findOne'>>; + let dataSourceMock: jest.Mocked>; let dingTalkService: jest.Mocked>; + let mgr: ManagerMock; + beforeEach(async () => { + mgr = mockManager(); + mappingRepo = { findOne: jest.fn(), create: jest.fn(), @@ -45,6 +67,12 @@ describe('SyncService — new methods', () => { findOne: jest.fn(), }; + dataSourceMock = { + transaction: jest.fn().mockImplementation( + async (cb: (manager: ManagerMock) => Promise) => cb(mgr), + ), + }; + dingTalkService = { fetchOrgTreeWithUsers: jest.fn(), syncAll: jest.fn(), @@ -64,6 +92,7 @@ describe('SyncService — new methods', () => { { provide: getRepositoryToken(User), useValue: userRepo }, { provide: getRepositoryToken(Student), useValue: studentRepo }, { provide: getRepositoryToken(Role), useValue: roleRepo }, + { provide: DataSource, useValue: dataSourceMock }, { provide: DingTalkService, useValue: dingTalkService }, { provide: WeComService, useValue: mockWeComService }, { provide: AttendanceImportService, useValue: mockAttendanceImportService }, @@ -90,15 +119,13 @@ describe('SyncService — new methods', () => { it('imports teacher when roleId is a number (role found)', async () => { const mockRole = { id: 5, name: 'Teacher' } as Role; - roleRepo.findOne.mockResolvedValue(mockRole); + mgr.findOne.mockResolvedValue(mockRole); const mockUser = { id: 10 } as User; - userRepo.create.mockReturnValue(mockUser); - userRepo.save.mockResolvedValue(mockUser); + mgr.create.mockReturnValue(mockUser); + mgr.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 }, @@ -109,27 +136,23 @@ describe('SyncService — new methods', () => { expect(result.teacherCount).toBe(1); expect(result.studentCount).toBe(0); expect(result.skipped).toBe(0); + expect(result.warnings).toEqual([]); - expect(roleRepo.findOne).toHaveBeenCalledWith({ where: { id: 5 } }); - expect(userRepo.save).toHaveBeenCalledWith( + expect(dataSourceMock.transaction).toHaveBeenCalledTimes(1); + expect(mgr.findOne).toHaveBeenCalledWith(Role, { where: { id: 5 } }); + expect(mgr.save).toHaveBeenCalledWith( expect.objectContaining({ roles: [mockRole] }), ); }); - it('imports teacher when roleId is a number but role not found (creates student as fallback)', async () => { - roleRepo.findOne.mockResolvedValue(null); + it('skips user when role not found (I1 fix — warns + skip, not silent teacher)', async () => { + mgr.findOne.mockResolvedValue(null); const mockUser = { id: 11 } as User; - userRepo.create.mockReturnValue(mockUser); - userRepo.save.mockResolvedValue(mockUser); - - const mockStudent = { id: 31 } as Student; - studentRepo.create.mockReturnValue(mockStudent); - studentRepo.save.mockResolvedValue(mockStudent); + mgr.create.mockReturnValue(mockUser); + mgr.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 }, @@ -137,35 +160,31 @@ describe('SyncService — new methods', () => { const result = await service.importDingTalkUsers(users); + // I1: role not found → skip with warning, NOT counted as teacher expect(result.teacherCount).toBe(0); - expect(result.studentCount).toBe(1); - expect(result.skipped).toBe(0); + expect(result.studentCount).toBe(0); + expect(result.skipped).toBe(1); + expect(result.warnings).toEqual([ + '角色 id=999 不存在,跳过用户 Li Si(user2)', + ]); - // User should be saved once (no second save for role assignment) - expect(userRepo.save).toHaveBeenCalledTimes(1); - // Student record should have been created as fallback - expect(studentRepo.create).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'Li Si', - userId: 11, - status: 'active', - }), - ); - expect(studentRepo.save).toHaveBeenCalled(); + expect(dataSourceMock.transaction).toHaveBeenCalledTimes(1); }); it('imports student when roleId is null', async () => { const mockUser = { id: 20 } as User; - userRepo.create.mockReturnValue(mockUser); - userRepo.save.mockResolvedValue(mockUser); + mgr.create.mockReturnValueOnce(mockUser); + mgr.save.mockResolvedValueOnce(mockUser); const mockStudent = { id: 30 } as Student; - studentRepo.create.mockReturnValue(mockStudent); - studentRepo.save.mockResolvedValue(mockStudent); + mgr.create.mockReturnValueOnce(mockStudent); + mgr.save.mockResolvedValueOnce(mockStudent); + + // mapping create+save also calls create/save + mgr.create.mockReturnValueOnce({} as UserDingMapping); + mgr.save.mockResolvedValueOnce({} as UserDingMapping); 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 }, @@ -176,15 +195,20 @@ describe('SyncService — new methods', () => { expect(result.studentCount).toBe(1); expect(result.teacherCount).toBe(0); expect(result.skipped).toBe(0); + expect(result.warnings).toEqual([]); - expect(studentRepo.create).toHaveBeenCalledWith( + // 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', }), ); - expect(studentRepo.save).toHaveBeenCalled(); }); it('skips user when mapping already exists', async () => { @@ -199,25 +223,30 @@ describe('SyncService — new methods', () => { expect(result.skipped).toBe(1); expect(result.teacherCount).toBe(0); expect(result.studentCount).toBe(0); - expect(userRepo.create).not.toHaveBeenCalled(); + expect(result.warnings).toEqual([]); + expect(dataSourceMock.transaction).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); + // First transaction throws, second succeeds + dataSourceMock.transaction + .mockImplementationOnce(async () => { + throw new Error('DB error'); + }) + .mockImplementationOnce(async (cb) => { + const freshMgr = mockManager(); + const mockUser = { id: 40 } as User; + freshMgr.create.mockReturnValueOnce(mockUser); + freshMgr.save.mockResolvedValueOnce(mockUser); + const mockStudent = { id: 30 } as Student; + freshMgr.create.mockReturnValueOnce(mockStudent); + freshMgr.save.mockResolvedValueOnce(mockStudent); + freshMgr.create.mockReturnValueOnce({} as UserDingMapping); + freshMgr.save.mockResolvedValueOnce({} as UserDingMapping); + await cb(freshMgr); + }); const users: ImportUserDto[] = [ { dingUserId: 'fail', name: 'Fail User', mobile: '', roleId: null }, @@ -228,31 +257,41 @@ describe('SyncService — new methods', () => { 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); + expect(result.warnings).toEqual([]); + expect(dataSourceMock.transaction).toHaveBeenCalledTimes(2); }); 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 + // First: skip (existing) mappingRepo.findOne - .mockResolvedValueOnce({ id: 99 } as UserDingMapping) // skip - .mockResolvedValueOnce(null) // teacher + .mockResolvedValueOnce({ id: 99 } as UserDingMapping) + .mockResolvedValueOnce(null) // teacher .mockResolvedValueOnce(null); // student + // Teacher's manager operations + const teacherMgr = mockManager(); + teacherMgr.findOne.mockResolvedValue(mockRole); + const teacherUser = { id: 50 } as User; + teacherMgr.create.mockReturnValue(teacherUser); + teacherMgr.save.mockResolvedValue(teacherUser); + + // Student's manager operations + const studentMgr = mockManager(); + const studentUser = { id: 51 } as User; + const mockStudent = { id: 50 } as Student; + studentMgr.create.mockReturnValueOnce(studentUser); + studentMgr.save.mockResolvedValueOnce(studentUser); + studentMgr.create.mockReturnValueOnce(mockStudent); + studentMgr.save.mockResolvedValueOnce(mockStudent); + studentMgr.create.mockReturnValueOnce({} as UserDingMapping); + studentMgr.save.mockResolvedValueOnce({} as UserDingMapping); + + dataSourceMock.transaction + .mockImplementationOnce(async (cb) => cb(teacherMgr)) + .mockImplementationOnce(async (cb) => cb(studentMgr)); + const users: ImportUserDto[] = [ { dingUserId: 'skip', name: 'Skip', mobile: '138', roleId: null }, { dingUserId: 'teacher', name: 'Teacher', mobile: '139', roleId: 1 }, @@ -264,5 +303,7 @@ describe('SyncService — new methods', () => { expect(result.teacherCount).toBe(1); expect(result.studentCount).toBe(1); expect(result.skipped).toBe(1); + expect(result.warnings).toEqual([]); + expect(dataSourceMock.transaction).toHaveBeenCalledTimes(2); }); }); diff --git a/apps/server/src/sync/sync.service.ts b/apps/server/src/sync/sync.service.ts index 008b359..54a6422 100644 --- a/apps/server/src/sync/sync.service.ts +++ b/apps/server/src/sync/sync.service.ts @@ -1,6 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { DataSource, Repository } from 'typeorm'; import { SyncLog, SyncState, UserDingMapping } from '../entities'; import type { SyncPlatform, SyncType, SyncStatus } from '../entities/sync-log.entity'; import { DingTalkService } from '../integration/dingtalk.service'; @@ -39,6 +39,7 @@ export class SyncService { private readonly weComService: WeComService, private readonly attendanceImportService: AttendanceImportService, private readonly scheduleSyncService: ScheduleSyncService, + private readonly dataSource: DataSource, ) {} // ── Scheduled sync disabled — use manual trigger via UI ── @@ -122,13 +123,14 @@ export class SyncService { teacherCount: number; studentCount: number; skipped: number; + warnings: string[]; }> { let teacherCount = 0; let studentCount = 0; let skipped = 0; + const warnings: string[] = []; for (const u of users) { - // 检查是否已存在映射 const existing = await this.mappingRepo.findOne({ where: { dingUserId: u.dingUserId }, }); @@ -138,65 +140,62 @@ export class SyncService { } try { - const username = `dd_${u.dingUserId}`; - const passwordHash = await bcrypt.hash('123456', 10); + await this.dataSource.transaction(async (manager) => { + const username = `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); + const user = manager.create(User, { + username, + name: u.name, + passwordHash, + isActive: true, + }); + await manager.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); - teacherCount++; + if (u.roleId != null) { + const role = await manager.findOne(Role, { where: { id: u.roleId } }); + if (role) { + user.roles = [role]; + await manager.save(user); + teacherCount++; + } else { + const msg = `角色 id=${u.roleId} 不存在,跳过用户 ${u.name}(${u.dingUserId})`; + this.logger.warn(msg); + warnings.push(msg); + skipped++; + throw new Error('SKIP_USER'); + } } else { - this.logger.warn(`角色 id=${u.roleId} 不存在,用户 ${u.name} 转为学生`); - const student = this.studentRepo.create({ + const student = manager.create(Student, { name: u.name, phone: u.mobile || undefined, userId: user.id, status: 'active', }); - await this.studentRepo.save(student); + await manager.save(student); studentCount++; } - } 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, + const mapping = manager.create(UserDingMapping, { + dingUserId: u.dingUserId, + userId: user.id, + dingName: u.name, + dingMobile: u.mobile, + }); + await manager.save(mapping); }); - 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}`); + if (msg !== 'SKIP_USER') { + this.logger.error(`导入用户 ${u.name}(${u.dingUserId}) 失败: ${msg}`); + } } } this.logger.log( `钉钉用户导入完成: ${teacherCount} 位老师, ${studentCount} 位学生, ${skipped} 跳过`, ); - return { teacherCount, studentCount, skipped }; + return { teacherCount, studentCount, skipped, warnings }; } // ── 排班同步 ── From fb8c897b4fd92b68db83a5bed0babd7b55a75d2d Mon Sep 17 00:00:00 2001 From: wangziqi Date: Thu, 9 Jul 2026 11:32:01 +0800 Subject: [PATCH 9/9] fix: disable import button when roles not loaded (null defaultTeacherRoleId guard) --- .../src/pages/IntegrationConfig/index.tsx | 401 +++++++++++++++--- 1 file changed, 346 insertions(+), 55 deletions(-) diff --git a/apps/admin/src/pages/IntegrationConfig/index.tsx b/apps/admin/src/pages/IntegrationConfig/index.tsx index 9c530f7..ea8b562 100644 --- a/apps/admin/src/pages/IntegrationConfig/index.tsx +++ b/apps/admin/src/pages/IntegrationConfig/index.tsx @@ -1,10 +1,13 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useState, useMemo } from 'react'; import { Card, Form, Input, Button, Space, message, Spin, Switch, Alert, Descriptions, Tag, + Tabs, Drawer, Tree, Checkbox, Select, TreeSelect, } from 'antd'; import { SaveOutlined, ApiOutlined, CheckCircleOutlined, CloseCircleOutlined, + SyncOutlined, ReloadOutlined, } from '@ant-design/icons'; +import type { DataNode } from 'antd/es/tree'; import api from '../../api'; interface DingTalkConfig { @@ -14,11 +17,68 @@ interface DingTalkConfig { startEnable: boolean; } -/** Utility: recursively flatten org tree nodes to extract user ids (for import guard) */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const getAllUsers = (nodes: any[]): Array<{ userid: string }> => { - return nodes.flatMap((n) => [...(n.users ?? []), ...getAllUsers(n.children ?? [])]); -}; +interface DingOrgTreeNodeExt { + id: number; + name: string; + parentId: number; + children: DingOrgTreeNodeExt[]; + users: Array<{ userid: string; name: string; mobile: string }>; +} + +interface RoleItem { + id: number; + name: string; + status: number; +} + +interface OrgTreeNodeRaw { + id: number; + name: string; + children?: OrgTreeNodeRaw[]; +} + +interface OrgTreeResponse { + success: boolean; + data: OrgTreeNodeRaw[]; +} + +interface OrgTreeWithUsersResponse { + success: boolean; + data: DingOrgTreeNodeExt[]; +} + +interface ImportUsersResponse { + teacherCount: number; + studentCount: number; + skipped: number; +} + +const UserTreeNode: React.FC<{ + u: { userid: string; name: string; mobile: string }; + isTeacher: boolean; + onToggle: () => void; + roleId: number | undefined; + defaultRoleId: number | null; + roles: Array<{ id: number; name: string }>; + onRoleChange: (roleId: number) => void; +}> = React.memo(({ u, isTeacher, onToggle, roleId, defaultRoleId, roles, onRoleChange }) => ( + + 老师 + {u.name} + {u.mobile && {u.mobile}} + {isTeacher && ( + + + + + + + + + + + + + + + + + + ), + }, + ...syncTabItems, + ]; + return ( @@ -90,54 +428,7 @@ const IntegrationConfigPage: React.FC = () => { {verified === false && } color="error">未连接} }> - - {config && ( - - {config.corpId || '-'} - {config.agentId || '-'} - - - {config.startEnable ? '已启用' : '未启用'} - - - - )} - - - -
- - - - - - - - - - - - - - - - -
-
+ ); };