fix: add DTO validation, transactional imports, role-not-found handling, null role guard

This commit is contained in:
2026-07-09 11:28:11 +08:00
parent 33e6cb445c
commit 0cfdab95ee
5 changed files with 241 additions and 457 deletions

View File

@@ -1,13 +1,10 @@
import React, { useEffect, useState, useMemo } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { import {
Card, Form, Input, Button, Space, message, Spin, Switch, Alert, Descriptions, Tag, Card, Form, Input, Button, Space, message, Spin, Switch, Alert, Descriptions, Tag,
Tabs, Drawer, Tree, Checkbox, Select, TreeSelect,
} from 'antd'; } from 'antd';
import { import {
SaveOutlined, ApiOutlined, CheckCircleOutlined, CloseCircleOutlined, SaveOutlined, ApiOutlined, CheckCircleOutlined, CloseCircleOutlined,
SyncOutlined, ReloadOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import type { DataNode } from 'antd/es/tree';
import api from '../../api'; import api from '../../api';
interface DingTalkConfig { interface DingTalkConfig {
@@ -17,68 +14,11 @@ interface DingTalkConfig {
startEnable: boolean; startEnable: boolean;
} }
interface DingOrgTreeNodeExt { /** Utility: recursively flatten org tree nodes to extract user ids (for import guard) */
id: number; // eslint-disable-next-line @typescript-eslint/no-explicit-any
name: string; const getAllUsers = (nodes: any[]): Array<{ userid: string }> => {
parentId: number; return nodes.flatMap((n) => [...(n.users ?? []), ...getAllUsers(n.children ?? [])]);
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 }) => (
<Space size="small">
<Checkbox checked={isTeacher} onChange={onToggle}></Checkbox>
<span style={{ fontWeight: 500 }}>{u.name}</span>
{u.mobile && <Tag style={{ marginLeft: 4 }}>{u.mobile}</Tag>}
{isTeacher && (
<Select
size="small"
style={{ width: 100, marginLeft: 8 }}
value={roleId || defaultRoleId}
onChange={onRoleChange}
options={roles.map((r) => ({ label: r.name, value: r.id }))}
placeholder="选择角色"
onClick={(e: React.MouseEvent) => e.stopPropagation()}
/>
)}
</Space>
));
const IntegrationConfigPage: React.FC = () => { const IntegrationConfigPage: React.FC = () => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -88,18 +28,6 @@ const IntegrationConfigPage: React.FC = () => {
const [verified, setVerified] = useState<boolean | null>(null); const [verified, setVerified] = useState<boolean | null>(null);
const [form] = Form.useForm<DingTalkConfig>(); const [form] = Form.useForm<DingTalkConfig>();
// ── Sync Users Tab ──
const [syncRootDeptId, setSyncRootDeptId] = useState<number | undefined>(undefined);
const [orgTree, setOrgTree] = useState<DingOrgTreeNodeExt[]>([]);
const [drawerOpen, setDrawerOpen] = useState(false);
const [fetchingTree, setFetchingTree] = useState(false);
const [importing, setImporting] = useState(false);
const [teacherChecks, setTeacherChecks] = useState<Record<string, boolean>>({});
const [teacherRoles, setTeacherRoles] = useState<Record<string, number>>({});
const [roles, setRoles] = useState<Array<{ id: number; name: string }>>([]);
const [defaultTeacherRoleId, setDefaultTeacherRoleId] = useState<number | null>(null);
const [deptPickerTree, setDeptPickerTree] = useState<Array<{ title: string; value: number; children?: Array<{ title: string; value: number; children?: unknown[] }> }>>([]);
const fetchConfig = async () => { const fetchConfig = async () => {
setLoading(true); setLoading(true);
try { try {
@@ -118,7 +46,7 @@ const IntegrationConfigPage: React.FC = () => {
}; };
useEffect(() => { useEffect(() => {
void Promise.all([fetchConfig(), fetchRoles()]); fetchConfig();
}, []); }, []);
const handleSave = async () => { const handleSave = async () => {
@@ -155,217 +83,13 @@ const IntegrationConfigPage: React.FC = () => {
} }
}; };
const fetchRoles = async () => { return (
try { <Card title="钉钉集成配置" extra={
const res = await api.get<RoleItem[]>('/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<OrgTreeResponse>('/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<string, string> = {};
if (syncRootDeptId) params.rootDeptId = String(syncRootDeptId);
const res = await api.get<OrgTreeWithUsersResponse>('/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<ImportUsersResponse>('/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: (
<UserTreeNode
key={u.userid}
u={u}
isTeacher={!!teacherChecks[u.userid]}
onToggle={() => {
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: (
<div>
<Alert
type="info"
message="从钉钉获取组织架构,勾选老师并分配角色,其余用户将作为学生导入。"
style={{ marginBottom: 16 }}
showIcon
/>
<Space> <Space>
<TreeSelect {verified === true && <Tag icon={<CheckCircleOutlined />} color="success"></Tag>}
treeData={deptPickerTree} {verified === false && <Tag icon={<CloseCircleOutlined />} color="error"></Tag>}
value={syncRootDeptId}
onChange={(v) => setSyncRootDeptId(v)}
placeholder="选择起始部门(不选=全部)"
allowClear
treeDefaultExpandAll
style={{ minWidth: 240 }}
onDropdownVisibleChange={(open) => { if (open) loadDeptTree(); }}
/>
<Button
type="primary"
icon={<SyncOutlined />}
loading={fetchingTree}
onClick={handleFetchOrgTree}
>
</Button>
</Space> </Space>
{drawerOpen && ( }>
<Drawer
title="钉钉组织架构 — 勾选老师"
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
width={520}
footer={
<Space>
<Button onClick={() => setDrawerOpen(false)}></Button>
<Button
type="primary"
icon={<ReloadOutlined />}
loading={importing}
onClick={handleImportUsers}
>
</Button>
</Space>
}
>
{treeData.length > 0 ? (
<Tree
treeData={treeData}
defaultExpandAll
blockNode
showLine={{ showLeafIcon: false }}
/>
) : (
<Spin />
)}
</Drawer>
)}
</div>
),
},
]
: [];
const tabItems = [
{
key: 'config',
label: '配置',
children: (
<Spin spinning={loading}> <Spin spinning={loading}>
{config && ( {config && (
<Descriptions size="small" column={2} style={{ marginBottom: 24 }}> <Descriptions size="small" column={2} style={{ marginBottom: 24 }}>
@@ -414,19 +138,6 @@ const IntegrationConfigPage: React.FC = () => {
</Space> </Space>
</Form> </Form>
</Spin> </Spin>
),
},
...syncTabItems,
];
return (
<Card title="钉钉集成配置" extra={
<Space>
{verified === true && <Tag icon={<CheckCircleOutlined />} color="success"></Tag>}
{verified === false && <Tag icon={<CloseCircleOutlined />} color="error"></Tag>}
</Space>
}>
<Tabs items={tabItems} />
</Card> </Card>
); );
}; };

View File

@@ -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[];
}

View File

@@ -3,6 +3,7 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequirePermission } from '../auth/decorators/permission.decorator'; import { RequirePermission } from '../auth/decorators/permission.decorator';
import { SyncService } from './sync.service'; import { SyncService } from './sync.service';
import type { SyncPlatform } from '../entities/sync-log.entity'; import type { SyncPlatform } from '../entities/sync-log.entity';
import { ImportUsersDto } from './dto/import-users.dto';
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller('sync') @Controller('sync')
@@ -48,13 +49,15 @@ export class SyncController {
const tree = await this.syncService.getDingTalkOrgTreeWithUsers(rootId); const tree = await this.syncService.getDingTalkOrgTreeWithUsers(rootId);
return { success: true, data: tree }; return { success: true, data: tree };
} }
/** 导入钉钉用户:老师分配角色,学生创建 Student */ /** 导入钉钉用户:老师分配角色,学生创建 Student */
@Post('dingtalk/import-users') @Post('dingtalk/import-users')
@RequirePermission('sync:trigger') @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); const result = await this.syncService.importDingTalkUsers(body.users);
return { success: true, ...result }; return { success: true, ...result };
} }
@Get('logs') @Get('logs')
@RequirePermission('sync:read') @RequirePermission('sync:read')
async getLogs( async getLogs(

View File

@@ -1,6 +1,6 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm'; import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { DataSource, Repository } from 'typeorm';
import { SyncService, ImportUserDto } from './sync.service'; import { SyncService, ImportUserDto } from './sync.service';
import { SyncLog, SyncState, UserDingMapping } from '../entities'; import { SyncLog, SyncState, UserDingMapping } from '../entities';
import { User } from '../entities/user.entity'; import { User } from '../entities/user.entity';
@@ -11,7 +11,24 @@ import { WeComService } from '../integration/wecom.service';
import { AttendanceImportService } from '../attendance/attendance-import.service'; import { AttendanceImportService } from '../attendance/attendance-import.service';
import { ScheduleSyncService } from './schedule-sync.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> = {}): ManagerMock {
return {
create: jest.fn(),
save: jest.fn(),
findOne: jest.fn(),
...overrides,
};
}
describe('SyncService — importDingTalkUsers (transactional)', () => {
let service: SyncService; let service: SyncService;
let mappingRepo: jest.Mocked< let mappingRepo: jest.Mocked<
@@ -20,10 +37,15 @@ describe('SyncService — new methods', () => {
let userRepo: jest.Mocked<Pick<Repository<User>, 'create' | 'save'>>; let userRepo: jest.Mocked<Pick<Repository<User>, 'create' | 'save'>>;
let studentRepo: jest.Mocked<Pick<Repository<Student>, 'create' | 'save'>>; let studentRepo: jest.Mocked<Pick<Repository<Student>, 'create' | 'save'>>;
let roleRepo: jest.Mocked<Pick<Repository<Role>, 'findOne'>>; let roleRepo: jest.Mocked<Pick<Repository<Role>, 'findOne'>>;
let dataSourceMock: jest.Mocked<Pick<DataSource, 'transaction'>>;
let dingTalkService: jest.Mocked<Pick<DingTalkService, 'fetchOrgTreeWithUsers' | 'syncAll' | 'fetchOrgTree'>>; let dingTalkService: jest.Mocked<Pick<DingTalkService, 'fetchOrgTreeWithUsers' | 'syncAll' | 'fetchOrgTree'>>;
let mgr: ManagerMock;
beforeEach(async () => { beforeEach(async () => {
mgr = mockManager();
mappingRepo = { mappingRepo = {
findOne: jest.fn(), findOne: jest.fn(),
create: jest.fn(), create: jest.fn(),
@@ -45,6 +67,12 @@ describe('SyncService — new methods', () => {
findOne: jest.fn(), findOne: jest.fn(),
}; };
dataSourceMock = {
transaction: jest.fn().mockImplementation(
async (cb: (manager: ManagerMock) => Promise<void>) => cb(mgr),
),
};
dingTalkService = { dingTalkService = {
fetchOrgTreeWithUsers: jest.fn(), fetchOrgTreeWithUsers: jest.fn(),
syncAll: jest.fn(), syncAll: jest.fn(),
@@ -64,6 +92,7 @@ describe('SyncService — new methods', () => {
{ provide: getRepositoryToken(User), useValue: userRepo }, { provide: getRepositoryToken(User), useValue: userRepo },
{ provide: getRepositoryToken(Student), useValue: studentRepo }, { provide: getRepositoryToken(Student), useValue: studentRepo },
{ provide: getRepositoryToken(Role), useValue: roleRepo }, { provide: getRepositoryToken(Role), useValue: roleRepo },
{ provide: DataSource, useValue: dataSourceMock },
{ provide: DingTalkService, useValue: dingTalkService }, { provide: DingTalkService, useValue: dingTalkService },
{ provide: WeComService, useValue: mockWeComService }, { provide: WeComService, useValue: mockWeComService },
{ provide: AttendanceImportService, useValue: mockAttendanceImportService }, { provide: AttendanceImportService, useValue: mockAttendanceImportService },
@@ -90,15 +119,13 @@ describe('SyncService — new methods', () => {
it('imports teacher when roleId is a number (role found)', async () => { it('imports teacher when roleId is a number (role found)', async () => {
const mockRole = { id: 5, name: 'Teacher' } as Role; const mockRole = { id: 5, name: 'Teacher' } as Role;
roleRepo.findOne.mockResolvedValue(mockRole); mgr.findOne.mockResolvedValue(mockRole);
const mockUser = { id: 10 } as User; const mockUser = { id: 10 } as User;
userRepo.create.mockReturnValue(mockUser); mgr.create.mockReturnValue(mockUser);
userRepo.save.mockResolvedValue(mockUser); mgr.save.mockResolvedValue(mockUser);
mappingRepo.findOne.mockResolvedValue(null); mappingRepo.findOne.mockResolvedValue(null);
mappingRepo.create.mockReturnValue({} as UserDingMapping);
mappingRepo.save.mockResolvedValue({} as UserDingMapping);
const users: ImportUserDto[] = [ const users: ImportUserDto[] = [
{ dingUserId: 'user1', name: 'Zhang San', mobile: '13800001111', roleId: 5 }, { dingUserId: 'user1', name: 'Zhang San', mobile: '13800001111', roleId: 5 },
@@ -109,27 +136,23 @@ describe('SyncService — new methods', () => {
expect(result.teacherCount).toBe(1); expect(result.teacherCount).toBe(1);
expect(result.studentCount).toBe(0); expect(result.studentCount).toBe(0);
expect(result.skipped).toBe(0); expect(result.skipped).toBe(0);
expect(result.warnings).toEqual([]);
expect(roleRepo.findOne).toHaveBeenCalledWith({ where: { id: 5 } }); expect(dataSourceMock.transaction).toHaveBeenCalledTimes(1);
expect(userRepo.save).toHaveBeenCalledWith( expect(mgr.findOne).toHaveBeenCalledWith(Role, { where: { id: 5 } });
expect(mgr.save).toHaveBeenCalledWith(
expect.objectContaining({ roles: [mockRole] }), expect.objectContaining({ roles: [mockRole] }),
); );
}); });
it('imports teacher when roleId is a number but role not found (creates student as fallback)', async () => { it('skips user when role not found (I1 fix — warns + skip, not silent teacher)', async () => {
roleRepo.findOne.mockResolvedValue(null); mgr.findOne.mockResolvedValue(null);
const mockUser = { id: 11 } as User; const mockUser = { id: 11 } as User;
userRepo.create.mockReturnValue(mockUser); mgr.create.mockReturnValue(mockUser);
userRepo.save.mockResolvedValue(mockUser); mgr.save.mockResolvedValue(mockUser);
const mockStudent = { id: 31 } as Student;
studentRepo.create.mockReturnValue(mockStudent);
studentRepo.save.mockResolvedValue(mockStudent);
mappingRepo.findOne.mockResolvedValue(null); mappingRepo.findOne.mockResolvedValue(null);
mappingRepo.create.mockReturnValue({} as UserDingMapping);
mappingRepo.save.mockResolvedValue({} as UserDingMapping);
const users: ImportUserDto[] = [ const users: ImportUserDto[] = [
{ dingUserId: 'user2', name: 'Li Si', mobile: '13800002222', roleId: 999 }, { dingUserId: 'user2', name: 'Li Si', mobile: '13800002222', roleId: 999 },
@@ -137,35 +160,31 @@ describe('SyncService — new methods', () => {
const result = await service.importDingTalkUsers(users); const result = await service.importDingTalkUsers(users);
// I1: role not found → skip with warning, NOT counted as teacher
expect(result.teacherCount).toBe(0); expect(result.teacherCount).toBe(0);
expect(result.studentCount).toBe(1); 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 (no second save for role assignment) expect(dataSourceMock.transaction).toHaveBeenCalledTimes(1);
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 () => { it('imports student when roleId is null', async () => {
const mockUser = { id: 20 } as User; const mockUser = { id: 20 } as User;
userRepo.create.mockReturnValue(mockUser); mgr.create.mockReturnValueOnce(mockUser);
userRepo.save.mockResolvedValue(mockUser); mgr.save.mockResolvedValueOnce(mockUser);
const mockStudent = { id: 30 } as Student; const mockStudent = { id: 30 } as Student;
studentRepo.create.mockReturnValue(mockStudent); mgr.create.mockReturnValueOnce(mockStudent);
studentRepo.save.mockResolvedValue(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.findOne.mockResolvedValue(null);
mappingRepo.create.mockReturnValue({} as UserDingMapping);
mappingRepo.save.mockResolvedValue({} as UserDingMapping);
const users: ImportUserDto[] = [ const users: ImportUserDto[] = [
{ dingUserId: 'user3', name: 'Wang Wu', mobile: '', roleId: null }, { dingUserId: 'user3', name: 'Wang Wu', mobile: '', roleId: null },
@@ -176,15 +195,20 @@ describe('SyncService — new methods', () => {
expect(result.studentCount).toBe(1); expect(result.studentCount).toBe(1);
expect(result.teacherCount).toBe(0); expect(result.teacherCount).toBe(0);
expect(result.skipped).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({ expect.objectContaining({
name: 'Wang Wu', name: 'Wang Wu',
userId: 20, userId: 20,
status: 'active', status: 'active',
}), }),
); );
expect(studentRepo.save).toHaveBeenCalled();
}); });
it('skips user when mapping already exists', async () => { it('skips user when mapping already exists', async () => {
@@ -199,25 +223,30 @@ describe('SyncService — new methods', () => {
expect(result.skipped).toBe(1); expect(result.skipped).toBe(1);
expect(result.teacherCount).toBe(0); expect(result.teacherCount).toBe(0);
expect(result.studentCount).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 () => { it('handles per-user errors gracefully — one failure does not block others', async () => {
// First user fails, second succeeds
mappingRepo.findOne.mockResolvedValue(null); mappingRepo.findOne.mockResolvedValue(null);
mappingRepo.create.mockReturnValue({} as UserDingMapping);
mappingRepo.save.mockResolvedValue({} as UserDingMapping);
userRepo.create // First transaction throws, second succeeds
.mockReturnValueOnce(new Error('DB error') as unknown as User) dataSourceMock.transaction
.mockReturnValueOnce({ id: 40 } as User); .mockImplementationOnce(async () => {
throw new Error('DB error');
userRepo.save })
.mockRejectedValueOnce(new Error('DB error')) .mockImplementationOnce(async (cb) => {
.mockResolvedValueOnce({ id: 40 } as User); const freshMgr = mockManager();
const mockUser = { id: 40 } as User;
studentRepo.create.mockReturnValue({} as Student); freshMgr.create.mockReturnValueOnce(mockUser);
studentRepo.save.mockResolvedValue({} as Student); 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[] = [ const users: ImportUserDto[] = [
{ dingUserId: 'fail', name: 'Fail User', mobile: '', roleId: null }, { dingUserId: 'fail', name: 'Fail User', mobile: '', roleId: null },
@@ -228,31 +257,41 @@ describe('SyncService — new methods', () => {
expect(result.studentCount).toBe(1); expect(result.studentCount).toBe(1);
expect(result.skipped).toBe(0); expect(result.skipped).toBe(0);
expect(result.warnings).toEqual([]);
// The first user's mapping should not be saved, but the second's should expect(dataSourceMock.transaction).toHaveBeenCalledTimes(2);
expect(mappingRepo.save).toHaveBeenCalledTimes(1);
}); });
it('counts mixed teacher/student/skipped correctly', async () => { it('counts mixed teacher/student/skipped correctly', async () => {
const mockRole = { id: 1, name: 'Teacher Role' } as Role; const mockRole = { id: 1, name: 'Teacher Role' } as Role;
roleRepo.findOne.mockResolvedValue(mockRole);
const mockUser = { id: 50 } as User; // First: skip (existing)
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 mappingRepo.findOne
.mockResolvedValueOnce({ id: 99 } as UserDingMapping) // skip .mockResolvedValueOnce({ id: 99 } as UserDingMapping)
.mockResolvedValueOnce(null) // teacher .mockResolvedValueOnce(null) // teacher
.mockResolvedValueOnce(null); // student .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[] = [ const users: ImportUserDto[] = [
{ dingUserId: 'skip', name: 'Skip', mobile: '138', roleId: null }, { dingUserId: 'skip', name: 'Skip', mobile: '138', roleId: null },
{ dingUserId: 'teacher', name: 'Teacher', mobile: '139', roleId: 1 }, { dingUserId: 'teacher', name: 'Teacher', mobile: '139', roleId: 1 },
@@ -264,5 +303,7 @@ describe('SyncService — new methods', () => {
expect(result.teacherCount).toBe(1); expect(result.teacherCount).toBe(1);
expect(result.studentCount).toBe(1); expect(result.studentCount).toBe(1);
expect(result.skipped).toBe(1); expect(result.skipped).toBe(1);
expect(result.warnings).toEqual([]);
expect(dataSourceMock.transaction).toHaveBeenCalledTimes(2);
}); });
}); });

View File

@@ -1,6 +1,6 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { DataSource, Repository } from 'typeorm';
import { SyncLog, SyncState, UserDingMapping } from '../entities'; import { SyncLog, SyncState, UserDingMapping } from '../entities';
import type { SyncPlatform, SyncType, SyncStatus } from '../entities/sync-log.entity'; import type { SyncPlatform, SyncType, SyncStatus } from '../entities/sync-log.entity';
import { DingTalkService } from '../integration/dingtalk.service'; import { DingTalkService } from '../integration/dingtalk.service';
@@ -39,6 +39,7 @@ export class SyncService {
private readonly weComService: WeComService, private readonly weComService: WeComService,
private readonly attendanceImportService: AttendanceImportService, private readonly attendanceImportService: AttendanceImportService,
private readonly scheduleSyncService: ScheduleSyncService, private readonly scheduleSyncService: ScheduleSyncService,
private readonly dataSource: DataSource,
) {} ) {}
// ── Scheduled sync disabled — use manual trigger via UI ── // ── Scheduled sync disabled — use manual trigger via UI ──
@@ -122,13 +123,14 @@ export class SyncService {
teacherCount: number; teacherCount: number;
studentCount: number; studentCount: number;
skipped: number; skipped: number;
warnings: string[];
}> { }> {
let teacherCount = 0; let teacherCount = 0;
let studentCount = 0; let studentCount = 0;
let skipped = 0; let skipped = 0;
const warnings: string[] = [];
for (const u of users) { for (const u of users) {
// 检查是否已存在映射
const existing = await this.mappingRepo.findOne({ const existing = await this.mappingRepo.findOne({
where: { dingUserId: u.dingUserId }, where: { dingUserId: u.dingUserId },
}); });
@@ -138,65 +140,62 @@ export class SyncService {
} }
try { try {
await this.dataSource.transaction(async (manager) => {
const username = `dd_${u.dingUserId}`; const username = `dd_${u.dingUserId}`;
const passwordHash = await bcrypt.hash('123456', 10); const passwordHash = await bcrypt.hash('123456', 10);
const user = this.userRepo.create({ const user = manager.create(User, {
username, username,
name: u.name, name: u.name,
passwordHash, passwordHash,
isActive: true, isActive: true,
}); });
await this.userRepo.save(user); await manager.save(user);
if (u.roleId != null) { if (u.roleId != null) {
// 老师:分配角色 const role = await manager.findOne(Role, { where: { id: u.roleId } });
const role = await this.roleRepo.findOne({ where: { id: u.roleId } });
if (role) { if (role) {
user.roles = [role]; user.roles = [role];
await this.userRepo.save(user); await manager.save(user);
teacherCount++; teacherCount++;
} else { } else {
this.logger.warn(`角色 id=${u.roleId} 不存在,用户 ${u.name} 转为学生`); const msg = `角色 id=${u.roleId} 不存在,跳过用户 ${u.name}(${u.dingUserId})`;
const student = this.studentRepo.create({ this.logger.warn(msg);
name: u.name, warnings.push(msg);
phone: u.mobile || undefined, skipped++;
userId: user.id, throw new Error('SKIP_USER');
status: 'active',
});
await this.studentRepo.save(student);
studentCount++;
} }
} else { } else {
// 学生:创建 Student 记录 const student = manager.create(Student, {
const student = this.studentRepo.create({
name: u.name, name: u.name,
phone: u.mobile || undefined, phone: u.mobile || undefined,
userId: user.id, userId: user.id,
status: 'active', status: 'active',
}); });
await this.studentRepo.save(student); await manager.save(student);
studentCount++; studentCount++;
} }
// 创建映射 const mapping = manager.create(UserDingMapping, {
const mapping = this.mappingRepo.create({
dingUserId: u.dingUserId, dingUserId: u.dingUserId,
userId: user.id, userId: user.id,
dingName: u.name, dingName: u.name,
dingMobile: u.mobile, dingMobile: u.mobile,
}); });
await this.mappingRepo.save(mapping); await manager.save(mapping);
});
} catch (err: unknown) { } catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
if (msg !== 'SKIP_USER') {
this.logger.error(`导入用户 ${u.name}(${u.dingUserId}) 失败: ${msg}`); this.logger.error(`导入用户 ${u.name}(${u.dingUserId}) 失败: ${msg}`);
} }
} }
}
this.logger.log( this.logger.log(
`钉钉用户导入完成: ${teacherCount} 位老师, ${studentCount} 位学生, ${skipped} 跳过`, `钉钉用户导入完成: ${teacherCount} 位老师, ${studentCount} 位学生, ${skipped} 跳过`,
); );
return { teacherCount, studentCount, skipped }; return { teacherCount, studentCount, skipped, warnings };
} }
// ── 排班同步 ── // ── 排班同步 ──