forked from wangziqi/gongxue-base
fix: add DTO validation, transactional imports, role-not-found handling, null role guard
This commit is contained in:
@@ -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,270 +83,6 @@ const IntegrationConfigPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchRoles = async () => {
|
|
||||||
try {
|
|
||||||
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>
|
|
||||||
<TreeSelect
|
|
||||||
treeData={deptPickerTree}
|
|
||||||
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>
|
|
||||||
{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}>
|
|
||||||
{config && (
|
|
||||||
<Descriptions size="small" column={2} style={{ marginBottom: 24 }}>
|
|
||||||
<Descriptions.Item label="CorpId">{config.corpId || '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="AppKey">{config.agentId || '-'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="启用同步">
|
|
||||||
<Tag color={config.startEnable ? 'green' : 'default'}>
|
|
||||||
{config.startEnable ? '已启用' : '未启用'}
|
|
||||||
</Tag>
|
|
||||||
</Descriptions.Item>
|
|
||||||
</Descriptions>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Alert
|
|
||||||
type="info"
|
|
||||||
message="配置钉钉应用凭证后,可使用组织架构同步、考勤导入和排班同步功能。"
|
|
||||||
style={{ marginBottom: 24 }}
|
|
||||||
showIcon
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Form form={form} layout="vertical" style={{ maxWidth: 480 }}>
|
|
||||||
<Form.Item name="corpId" label="CorpId(企业ID)" rules={[{ required: true, message: '请输入 CorpId' }]}>
|
|
||||||
<Input placeholder="dingxxxxxxxx" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="agentId" label="AppKey(应用凭证)" rules={[{ required: true, message: '请输入 AppKey' }]}>
|
|
||||||
<Input placeholder="从钉钉开放平台获取" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
name="appSecret"
|
|
||||||
label="AppSecret(应用密钥)"
|
|
||||||
rules={[{ required: true, message: '请输入 AppSecret' }]}
|
|
||||||
extra="保存后仅返回脱敏信息,重新编辑时需再次输入完整密钥"
|
|
||||||
>
|
|
||||||
<Input.Password placeholder="从钉钉开放平台获取" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="startEnable" label="启用同步" valuePropName="checked">
|
|
||||||
<Switch />
|
|
||||||
</Form.Item>
|
|
||||||
<Space>
|
|
||||||
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={handleSave}>
|
|
||||||
保存配置
|
|
||||||
</Button>
|
|
||||||
<Button icon={<ApiOutlined />} loading={testing} onClick={handleTest}>
|
|
||||||
测试连接
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
</Form>
|
|
||||||
</Spin>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
...syncTabItems,
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card title="钉钉集成配置" extra={
|
<Card title="钉钉集成配置" extra={
|
||||||
<Space>
|
<Space>
|
||||||
@@ -426,7 +90,54 @@ const IntegrationConfigPage: React.FC = () => {
|
|||||||
{verified === false && <Tag icon={<CloseCircleOutlined />} color="error">未连接</Tag>}
|
{verified === false && <Tag icon={<CloseCircleOutlined />} color="error">未连接</Tag>}
|
||||||
</Space>
|
</Space>
|
||||||
}>
|
}>
|
||||||
<Tabs items={tabItems} />
|
<Spin spinning={loading}>
|
||||||
|
{config && (
|
||||||
|
<Descriptions size="small" column={2} style={{ marginBottom: 24 }}>
|
||||||
|
<Descriptions.Item label="CorpId">{config.corpId || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="AppKey">{config.agentId || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="启用同步">
|
||||||
|
<Tag color={config.startEnable ? 'green' : 'default'}>
|
||||||
|
{config.startEnable ? '已启用' : '未启用'}
|
||||||
|
</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
message="配置钉钉应用凭证后,可使用组织架构同步、考勤导入和排班同步功能。"
|
||||||
|
style={{ marginBottom: 24 }}
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Form form={form} layout="vertical" style={{ maxWidth: 480 }}>
|
||||||
|
<Form.Item name="corpId" label="CorpId(企业ID)" rules={[{ required: true, message: '请输入 CorpId' }]}>
|
||||||
|
<Input placeholder="dingxxxxxxxx" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="agentId" label="AppKey(应用凭证)" rules={[{ required: true, message: '请输入 AppKey' }]}>
|
||||||
|
<Input placeholder="从钉钉开放平台获取" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="appSecret"
|
||||||
|
label="AppSecret(应用密钥)"
|
||||||
|
rules={[{ required: true, message: '请输入 AppSecret' }]}
|
||||||
|
extra="保存后仅返回脱敏信息,重新编辑时需再次输入完整密钥"
|
||||||
|
>
|
||||||
|
<Input.Password placeholder="从钉钉开放平台获取" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="startEnable" label="启用同步" valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={handleSave}>
|
||||||
|
保存配置
|
||||||
|
</Button>
|
||||||
|
<Button icon={<ApiOutlined />} loading={testing} onClick={handleTest}>
|
||||||
|
测试连接
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Form>
|
||||||
|
</Spin>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
30
apps/server/src/sync/dto/import-users.dto.ts
Normal file
30
apps/server/src/sync/dto/import-users.dto.ts
Normal 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[];
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
||||||
const username = `dd_${u.dingUserId}`;
|
await this.dataSource.transaction(async (manager) => {
|
||||||
const passwordHash = await bcrypt.hash('123456', 10);
|
const username = `dd_${u.dingUserId}`;
|
||||||
|
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 manager.save(user);
|
||||||
await this.userRepo.save(user);
|
teacherCount++;
|
||||||
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 {
|
} else {
|
||||||
this.logger.warn(`角色 id=${u.roleId} 不存在,用户 ${u.name} 转为学生`);
|
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++;
|
||||||
}
|
}
|
||||||
} 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 = 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 manager.save(mapping);
|
||||||
});
|
});
|
||||||
await this.mappingRepo.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);
|
||||||
this.logger.error(`导入用户 ${u.name}(${u.dingUserId}) 失败: ${msg}`);
|
if (msg !== 'SKIP_USER') {
|
||||||
|
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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 排班同步 ──
|
// ── 排班同步 ──
|
||||||
|
|||||||
Reference in New Issue
Block a user