diff --git a/apps/admin/src/pages/IntegrationConfig/index.tsx b/apps/admin/src/pages/IntegrationConfig/index.tsx
index e601eea..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, 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,69 @@ 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 && (
+
+));
+
const IntegrationConfigPage: React.FC = () => {
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
@@ -22,6 +88,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 {
@@ -40,7 +118,7 @@ const IntegrationConfigPage: React.FC = () => {
};
useEffect(() => {
- fetchConfig();
+ void Promise.all([fetchConfig(), fetchRoles()]);
}, []);
const handleSave = async () => {
@@ -77,6 +155,272 @@ 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 {
+ message.error('获取角色列表失败');
+ }
+ };
+
+ 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 {
+ message.error('获取部门架构失败');
+ }
+ };
+
+ 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]: !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,
+ })),
+ ],
+ }));
+ };
+
+ const treeData = useMemo(() => buildTreeData(orgTree), [orgTree, teacherChecks, teacherRoles, defaultTeacherRoleId, roles]);
+
+ const syncTabItems = config
+ ? [
+ {
+ key: 'sync-users',
+ label: '同步用户',
+ children: (
+
+
+
+ setSyncRootDeptId(v)}
+ placeholder="选择起始部门(不选=全部)"
+ allowClear
+ treeDefaultExpandAll
+ style={{ minWidth: 240 }}
+ onDropdownVisibleChange={(open) => { if (open) loadDeptTree(); }}
+ />
+ }
+ loading={fetchingTree}
+ onClick={handleFetchOrgTree}
+ >
+ 获取组织架构
+
+
+ {drawerOpen && (
+
setDrawerOpen(false)}
+ width={520}
+ footer={
+
+
+ }
+ loading={importing}
+ disabled={!defaultTeacherRoleId}
+ title={!defaultTeacherRoleId ? '角色列表未加载,无法导入' : undefined}
+ onClick={handleImportUsers}
+ >
+ 导入
+
+
+ }
+ >
+ {treeData.length > 0 ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+ ),
+ },
+ ]
+ : [];
+
+ const tabItems = [
+ {
+ key: 'config',
+ label: '配置',
+ children: (
+
+ {config && (
+
+ {config.corpId || '-'}
+ {config.agentId || '-'}
+
+
+ {config.startEnable ? '已启用' : '未启用'}
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ } loading={saving} onClick={handleSave}>
+ 保存配置
+
+ } loading={testing} onClick={handleTest}>
+ 测试连接
+
+
+
+
+ ),
+ },
+ ...syncTabItems,
+ ];
+
return (
@@ -84,54 +428,7 @@ const IntegrationConfigPage: React.FC = () => {
{verified === false && } color="error">未连接}
}>
-
- {config && (
-
- {config.corpId || '-'}
- {config.agentId || '-'}
-
-
- {config.startEnable ? '已启用' : '未启用'}
-
-
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- } loading={saving} onClick={handleSave}>
- 保存配置
-
- } loading={testing} onClick={handleTest}>
- 测试连接
-
-
-
-
+
);
};
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}
- >
- 同步钉钉用户
-
归档
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 6738448..64d30e7 100644
--- a/apps/server/src/sync/sync.controller.ts
+++ b/apps/server/src/sync/sync.controller.ts
@@ -1,8 +1,9 @@
-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';
import type { SyncPlatform } from '../entities/sync-log.entity';
+import { ImportUsersDto } from './dto/import-users.dto';
@UseGuards(JwtAuthGuard)
@Controller('sync')
@@ -40,6 +41,23 @@ 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: 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 efdfdea..7a5d410 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,14 @@ 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 +66,11 @@ 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 +90,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 +117,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,23 +134,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 (logs warning, no roles assigned)', 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);
+ 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 },
@@ -133,26 +158,31 @@ describe('SyncService — new methods', () => {
const result = await service.importDingTalkUsers(users);
- expect(result.teacherCount).toBe(1);
+ // I1: role not found → skip with warning, NOT counted as teacher
+ expect(result.teacherCount).toBe(0);
expect(result.studentCount).toBe(0);
- expect(result.skipped).toBe(0);
+ expect(result.skipped).toBe(1);
+ expect(result.warnings).toEqual([
+ '角色 id=999 不存在,跳过用户 Li Si(user2)',
+ ]);
- // User should be saved once (without roles assignment the second time)
- expect(userRepo.save).toHaveBeenCalledTimes(1);
+ 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 },
@@ -163,15 +193,21 @@ 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 () => {
@@ -186,25 +222,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 },
@@ -215,31 +256,40 @@ 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 },
@@ -251,5 +301,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 9e57f1a..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,57 +140,62 @@ export class SyncService {
}
try {
- const username = u.mobile || `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);
-
- 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({
+ const user = manager.create(User, {
+ username,
name: u.name,
- phone: u.mobile || undefined,
- userId: user.id,
- status: 'active',
+ passwordHash,
+ isActive: true,
});
- await this.studentRepo.save(student);
- studentCount++;
- }
+ await manager.save(user);
- // 创建映射
- const mapping = this.mappingRepo.create({
- dingUserId: u.dingUserId,
- userId: user.id,
- dingName: u.name,
- dingMobile: u.mobile,
+ 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 {
+ const student = manager.create(Student, {
+ name: u.name,
+ phone: u.mobile || undefined,
+ userId: user.id,
+ status: 'active',
+ });
+ await manager.save(student);
+ studentCount++;
+ }
+
+ 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 };
}
// ── 排班同步 ──