feat: 钉钉学生同步支持仅绑定手机号及学生列表修复
This commit is contained in:
@@ -28,6 +28,7 @@ import {
|
|||||||
InboxOutlined,
|
InboxOutlined,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
SwapOutlined,
|
SwapOutlined,
|
||||||
|
SyncOutlined,
|
||||||
UndoOutlined,
|
UndoOutlined,
|
||||||
UploadOutlined,
|
UploadOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
@@ -39,6 +40,7 @@ import JinshujuMatchModal from '../../components/JinshujuMatchModal';
|
|||||||
import { maskIdNumber, maskPhone } from '../../utils/sensitive';
|
import { maskIdNumber, maskPhone } from '../../utils/sensitive';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
import { usePermission } from '../../hooks/usePermission';
|
import { usePermission } from '../../hooks/usePermission';
|
||||||
|
import { useUserStore } from '../../store/user/userStore';
|
||||||
import { selectArchiveRecords } from '../archive-view';
|
import { selectArchiveRecords } from '../archive-view';
|
||||||
|
|
||||||
const statusMap: Record<string, { text: string; color: string }> = {
|
const statusMap: Record<string, { text: string; color: string }> = {
|
||||||
@@ -79,6 +81,17 @@ interface StudentUpdateImportResult {
|
|||||||
skipped?: number;
|
skipped?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DingTalkSyncLog {
|
||||||
|
status: string;
|
||||||
|
recordsCount: number;
|
||||||
|
errorMessage?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DingTalkSyncResult {
|
||||||
|
synced: number;
|
||||||
|
logs: DingTalkSyncLog[];
|
||||||
|
}
|
||||||
|
|
||||||
interface StudentFilterLookups {
|
interface StudentFilterLookups {
|
||||||
classes: Array<{ id: number; name: string; code?: string }>;
|
classes: Array<{ id: number; name: string; code?: string }>;
|
||||||
teachers: Array<{ id: number; name: string; username: string }>;
|
teachers: Array<{ id: number; name: string; username: string }>;
|
||||||
@@ -98,6 +111,7 @@ const StudentsPage: React.FC = () => {
|
|||||||
const canEditStudent = hasPermission('student:edit');
|
const canEditStudent = hasPermission('student:edit');
|
||||||
const canDeleteStudent = hasPermission('student:delete');
|
const canDeleteStudent = hasPermission('student:delete');
|
||||||
const canSyncJinshuju = hasAllPermissions('sync:read', 'sync:trigger');
|
const canSyncJinshuju = hasAllPermissions('sync:read', 'sync:trigger');
|
||||||
|
const canSyncDingTalk = hasAllPermissions('sync:read', 'sync:trigger');
|
||||||
const [data, setData] = useState<any[]>([]);
|
const [data, setData] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
@@ -114,7 +128,9 @@ const StudentsPage: React.FC = () => {
|
|||||||
const [showArchived, setShowArchived] = useState(false);
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
|
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
|
||||||
const [batchLoading, setBatchLoading] = useState(false);
|
const [batchLoading, setBatchLoading] = useState(false);
|
||||||
|
const [dingSyncLoading, setDingSyncLoading] = useState(false);
|
||||||
const [enrollmentData, setEnrollmentData] = useState<Record<number, EnrollmentInfo[]>>({});
|
const [enrollmentData, setEnrollmentData] = useState<Record<number, EnrollmentInfo[]>>({});
|
||||||
|
const [pageInfo, setPageInfo] = useState({ current: 1, pageSize: 15 });
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [drawerStudentId, setDrawerStudentId] = useState<number | undefined>(undefined);
|
const [drawerStudentId, setDrawerStudentId] = useState<number | undefined>(undefined);
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
@@ -336,7 +352,7 @@ const StudentsPage: React.FC = () => {
|
|||||||
const baseURL = import.meta.env.PROD
|
const baseURL = import.meta.env.PROD
|
||||||
? '/api'
|
? '/api'
|
||||||
: `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
|
: `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
|
||||||
const token = localStorage.getItem('token');
|
const token = useUserStore.getState().token;
|
||||||
fetch(`${baseURL}/students/template`, { headers: { Authorization: `Bearer ${token}` } })
|
fetch(`${baseURL}/students/template`, { headers: { Authorization: `Bearer ${token}` } })
|
||||||
.then((res) => res.blob())
|
.then((res) => res.blob())
|
||||||
.then((blob) => {
|
.then((blob) => {
|
||||||
@@ -441,11 +457,33 @@ const StudentsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDingTalkSync = async () => {
|
||||||
|
setDingSyncLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await api.post<DingTalkSyncResult>('/sync/trigger', null, {
|
||||||
|
params: { platform: 'dingtalk_students', createMissing: false, updateProfile: false },
|
||||||
|
timeout: 120000,
|
||||||
|
});
|
||||||
|
const log = res.logs?.[0];
|
||||||
|
if (log?.status === 'partial') {
|
||||||
|
message.warning(log.errorMessage || '钉钉同步完成,但有数据需要人工处理');
|
||||||
|
} else {
|
||||||
|
message.success(log?.errorMessage || `钉钉同步完成,共处理 ${res.synced} 条`);
|
||||||
|
}
|
||||||
|
await fetchData();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { message?: string };
|
||||||
|
message.error(err?.message || '钉钉同步失败');
|
||||||
|
} finally {
|
||||||
|
setDingSyncLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleExport = () => {
|
const handleExport = () => {
|
||||||
const baseURL = import.meta.env.PROD
|
const baseURL = import.meta.env.PROD
|
||||||
? '/api'
|
? '/api'
|
||||||
: `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
|
: `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
|
||||||
const token = localStorage.getItem('token');
|
const token = useUserStore.getState().token;
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (searchName) params.set('name', searchName);
|
if (searchName) params.set('name', searchName);
|
||||||
if (filterStatus) params.set('status', filterStatus);
|
if (filterStatus) params.set('status', filterStatus);
|
||||||
@@ -469,7 +507,13 @@ const StudentsPage: React.FC = () => {
|
|||||||
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{ title: 'ID', dataIndex: 'id', width: 70 },
|
{
|
||||||
|
title: '序号',
|
||||||
|
key: 'index',
|
||||||
|
width: 70,
|
||||||
|
render: (_: unknown, __: unknown, index: number) =>
|
||||||
|
(pageInfo.current - 1) * pageInfo.pageSize + index + 1,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '姓名',
|
title: '姓名',
|
||||||
dataIndex: 'name',
|
dataIndex: 'name',
|
||||||
@@ -741,6 +785,7 @@ const StudentsPage: React.FC = () => {
|
|||||||
saveCell,
|
saveCell,
|
||||||
hasPermission,
|
hasPermission,
|
||||||
canChooseOrganization,
|
canChooseOrganization,
|
||||||
|
pageInfo,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -904,6 +949,11 @@ const StudentsPage: React.FC = () => {
|
|||||||
同步金数据
|
同步金数据
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
{!showArchived && canSyncDingTalk ? (
|
||||||
|
<Button icon={<SyncOutlined />} loading={dingSyncLoading} onClick={handleDingTalkSync}>
|
||||||
|
同步钉钉
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
<PermissionButton
|
<PermissionButton
|
||||||
permission="student:view"
|
permission="student:view"
|
||||||
icon={<DownloadOutlined />}
|
icon={<DownloadOutlined />}
|
||||||
@@ -920,6 +970,23 @@ const StudentsPage: React.FC = () => {
|
|||||||
</PermissionButton>
|
</PermissionButton>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
{selectedRowKeys.length > 0 ? (
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 12 }}
|
||||||
|
title={
|
||||||
|
<span>
|
||||||
|
已选 <strong style={{ color: '#1677ff' }}>{selectedRowKeys.length}</strong> 人(支持跨页勾选)
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
action={
|
||||||
|
<Button size="small" type="link" onClick={() => setSelectedRowKeys([])}>
|
||||||
|
清空选择
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<Alert
|
<Alert
|
||||||
showIcon
|
showIcon
|
||||||
type="warning"
|
type="warning"
|
||||||
@@ -940,9 +1007,12 @@ const StudentsPage: React.FC = () => {
|
|||||||
scroll={{ x: 1410 }}
|
scroll={{ x: 1410 }}
|
||||||
pagination={{
|
pagination={{
|
||||||
defaultPageSize: 15,
|
defaultPageSize: 15,
|
||||||
|
current: pageInfo.current,
|
||||||
|
pageSize: pageInfo.pageSize,
|
||||||
showSizeChanger: true,
|
showSizeChanger: true,
|
||||||
pageSizeOptions: [15, 30, 50, 100],
|
pageSizeOptions: [15, 30, 50, 100],
|
||||||
showTotal: (total) => `共 ${total} 人`,
|
showTotal: (total) => `共 ${total} 人`,
|
||||||
|
onChange: (current, pageSize) => setPageInfo({ current, pageSize }),
|
||||||
}}
|
}}
|
||||||
rowClassName={(record: any) => (record.status === 'archived' ? 'archived-row' : '')}
|
rowClassName={(record: any) => (record.status === 'archived' ? 'archived-row' : '')}
|
||||||
rowSelection={{
|
rowSelection={{
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ describe('syncDingTalkStudents', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reports a phone conflict without creating a duplicate student', async () => {
|
it('binds an existing student by unique phone and creates a mapping', async () => {
|
||||||
const occupied = { id: 5, phone: '13800000000' } as Student;
|
const occupied = { id: 5, phone: '13800000000' } as Student;
|
||||||
const { manager, saves } = managerFixture({ occupiedPhones: [occupied] });
|
const { manager, saves } = managerFixture({ occupiedPhones: [occupied] });
|
||||||
|
|
||||||
@@ -66,13 +66,79 @@ describe('syncDingTalkStudents', () => {
|
|||||||
{ dingUserId: 'u2', name: '李四', mobile: occupied.phone },
|
{ dingUserId: 'u2', name: '李四', mobile: occupied.phone },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(result.created).toBe(0);
|
expect(result).toMatchObject({ created: 0, updated: 1, matched: 1, conflicts: [] });
|
||||||
|
expect(saves).toContainEqual({
|
||||||
|
entity: Student,
|
||||||
|
values: [expect.objectContaining({ id: 5, name: '李四' })],
|
||||||
|
});
|
||||||
|
expect(saves).toContainEqual({
|
||||||
|
entity: StudentDingMapping,
|
||||||
|
values: [expect.objectContaining({ dingUserId: 'u2', studentId: 5 })],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a conflict when a phone matches multiple students', async () => {
|
||||||
|
const occupied = [
|
||||||
|
{ id: 5, phone: '13800000000' },
|
||||||
|
{ id: 6, phone: '13800000000' },
|
||||||
|
] as Student[];
|
||||||
|
const { manager, saves } = managerFixture({ occupiedPhones: occupied });
|
||||||
|
|
||||||
|
const result = await syncDingTalkStudents(manager, [
|
||||||
|
{ dingUserId: 'u2', name: '李四', mobile: '13800000000' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ created: 0, updated: 0, matched: 0 });
|
||||||
expect(result.conflicts).toEqual([
|
expect(result.conflicts).toEqual([
|
||||||
expect.objectContaining({ dingUserId: 'u2', reason: expect.stringContaining('人工绑定') }),
|
expect.objectContaining({ reason: expect.stringContaining('多名学生') }),
|
||||||
]);
|
]);
|
||||||
expect(saves).toEqual([]);
|
expect(saves).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('skips unmatched users when createMissing is false', async () => {
|
||||||
|
const { manager, saves } = managerFixture();
|
||||||
|
|
||||||
|
const result = await syncDingTalkStudents(
|
||||||
|
manager,
|
||||||
|
[{ dingUserId: 'u4', name: '赵六', mobile: '13700000000' }],
|
||||||
|
{ createMissing: false, updateProfile: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
created: 0,
|
||||||
|
updated: 0,
|
||||||
|
matched: 0,
|
||||||
|
skipped: 1,
|
||||||
|
conflicts: [],
|
||||||
|
});
|
||||||
|
expect(saves).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates only the mapping without touching the student profile when updateProfile is false', async () => {
|
||||||
|
const occupied = { id: 5, name: '原名', phone: '13800000000' } as Student;
|
||||||
|
const { manager, saves } = managerFixture({ occupiedPhones: [occupied] });
|
||||||
|
|
||||||
|
const result = await syncDingTalkStudents(
|
||||||
|
manager,
|
||||||
|
[{ dingUserId: 'u2', name: '钉钉名', mobile: occupied.phone }],
|
||||||
|
{ createMissing: false, updateProfile: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
created: 0,
|
||||||
|
updated: 0,
|
||||||
|
matched: 1,
|
||||||
|
skipped: 0,
|
||||||
|
conflicts: [],
|
||||||
|
});
|
||||||
|
expect(occupied).toMatchObject({ name: '原名', phone: '13800000000' });
|
||||||
|
expect(saves.filter((save) => save.entity === Student)).toEqual([]);
|
||||||
|
expect(saves).toContainEqual({
|
||||||
|
entity: StudentDingMapping,
|
||||||
|
values: [expect.objectContaining({ dingUserId: 'u2', studentId: 5 })],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('surfaces mapping persistence failure so the surrounding transaction can roll back', async () => {
|
it('surfaces mapping persistence failure so the surrounding transaction can roll back', async () => {
|
||||||
const { manager } = managerFixture({ failMappingSave: true });
|
const { manager } = managerFixture({ failMappingSave: true });
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export interface DingTalkStudentConflict {
|
|||||||
export interface DingTalkStudentSyncResult {
|
export interface DingTalkStudentSyncResult {
|
||||||
created: number;
|
created: number;
|
||||||
updated: number;
|
updated: number;
|
||||||
|
matched: number;
|
||||||
|
skipped: number;
|
||||||
studentIds: Map<string, number>;
|
studentIds: Map<string, number>;
|
||||||
conflicts: DingTalkStudentConflict[];
|
conflicts: DingTalkStudentConflict[];
|
||||||
}
|
}
|
||||||
@@ -23,7 +25,10 @@ export interface DingTalkStudentSyncResult {
|
|||||||
export async function syncDingTalkStudents(
|
export async function syncDingTalkStudents(
|
||||||
manager: EntityManager,
|
manager: EntityManager,
|
||||||
inputs: DingTalkStudentInput[],
|
inputs: DingTalkStudentInput[],
|
||||||
|
options: { createMissing?: boolean; updateProfile?: boolean } = {},
|
||||||
): Promise<DingTalkStudentSyncResult> {
|
): Promise<DingTalkStudentSyncResult> {
|
||||||
|
const createMissing = options.createMissing !== false;
|
||||||
|
const updateProfile = options.updateProfile !== false;
|
||||||
const users = new Map<string, DingTalkStudentInput>();
|
const users = new Map<string, DingTalkStudentInput>();
|
||||||
const conflicts: DingTalkStudentConflict[] = [];
|
const conflicts: DingTalkStudentConflict[] = [];
|
||||||
|
|
||||||
@@ -43,7 +48,7 @@ export async function syncDingTalkStudents(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (users.size === 0) {
|
if (users.size === 0) {
|
||||||
return { created: 0, updated: 0, studentIds: new Map(), conflicts };
|
return { created: 0, updated: 0, matched: 0, skipped: 0, studentIds: new Map(), conflicts };
|
||||||
}
|
}
|
||||||
|
|
||||||
const dingUserIds = [...users.keys()];
|
const dingUserIds = [...users.keys()];
|
||||||
@@ -58,6 +63,7 @@ export async function syncDingTalkStudents(
|
|||||||
const studentById = new Map(mappedStudents.map((student) => [student.id, student]));
|
const studentById = new Map(mappedStudents.map((student) => [student.id, student]));
|
||||||
const studentIds = new Map<string, number>();
|
const studentIds = new Map<string, number>();
|
||||||
const updates: Student[] = [];
|
const updates: Student[] = [];
|
||||||
|
let updatedCount = 0;
|
||||||
|
|
||||||
for (const mapping of mappings) {
|
for (const mapping of mappings) {
|
||||||
const input = users.get(mapping.dingUserId);
|
const input = users.get(mapping.dingUserId);
|
||||||
@@ -71,9 +77,20 @@ export async function syncDingTalkStudents(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
studentIds.set(mapping.dingUserId, student.id);
|
studentIds.set(mapping.dingUserId, student.id);
|
||||||
|
if (!updateProfile) continue;
|
||||||
|
let changed = false;
|
||||||
|
if (student.name !== input.name) {
|
||||||
student.name = input.name;
|
student.name = input.name;
|
||||||
if (input.mobile) student.phone = input.mobile;
|
changed = true;
|
||||||
|
}
|
||||||
|
if (input.mobile && student.phone !== input.mobile) {
|
||||||
|
student.phone = input.mobile;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
updates.push(student);
|
updates.push(student);
|
||||||
|
updatedCount++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const newUsers = [...users.values()].filter((user) => !mappingByDingId.has(user.dingUserId));
|
const newUsers = [...users.values()].filter((user) => !mappingByDingId.has(user.dingUserId));
|
||||||
@@ -81,19 +98,91 @@ export async function syncDingTalkStudents(
|
|||||||
const occupiedPhones = mobiles.length
|
const occupiedPhones = mobiles.length
|
||||||
? await manager.find(Student, { where: { phone: In(mobiles) } })
|
? await manager.find(Student, { where: { phone: In(mobiles) } })
|
||||||
: [];
|
: [];
|
||||||
const studentByPhone = new Map(occupiedPhones.map((student) => [student.phone, student]));
|
const studentsByPhone = new Map<string, Student[]>();
|
||||||
const creatable = newUsers.filter((user) => {
|
for (const student of occupiedPhones) {
|
||||||
if (!user.mobile || !studentByPhone.has(user.mobile)) return true;
|
const list = studentsByPhone.get(student.phone) ?? [];
|
||||||
conflicts.push({ dingUserId: user.dingUserId, name: user.name, reason: '手机号已属于其他学生,请人工绑定' });
|
list.push(student);
|
||||||
return false;
|
studentsByPhone.set(student.phone, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新钉钉用户按手机号匹配:唯一命中 → 自动绑定;多人同号 → 冲突;无命中 → 新建
|
||||||
|
const boundStudentIds = new Set(mappings.map((mapping) => mapping.studentId));
|
||||||
|
const bindable: DingTalkStudentInput[] = [];
|
||||||
|
const creatable: DingTalkStudentInput[] = [];
|
||||||
|
const skipped: DingTalkStudentInput[] = [];
|
||||||
|
for (const user of newUsers) {
|
||||||
|
if (!user.mobile) {
|
||||||
|
(createMissing ? creatable : skipped).push(user);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const matches = studentsByPhone.get(user.mobile) ?? [];
|
||||||
|
if (matches.length === 0) {
|
||||||
|
(createMissing ? creatable : skipped).push(user);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (matches.length > 1) {
|
||||||
|
conflicts.push({
|
||||||
|
dingUserId: user.dingUserId,
|
||||||
|
name: user.name,
|
||||||
|
reason: '手机号匹配到多名学生,请人工绑定',
|
||||||
});
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const student = matches[0];
|
||||||
|
if (boundStudentIds.has(student.id)) {
|
||||||
|
conflicts.push({
|
||||||
|
dingUserId: user.dingUserId,
|
||||||
|
name: user.name,
|
||||||
|
reason: '手机号对应的学生已绑定其他钉钉账号',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
boundStudentIds.add(student.id);
|
||||||
|
bindable.push(user);
|
||||||
|
}
|
||||||
|
|
||||||
const host = creatable.length
|
const host = creatable.length
|
||||||
? await manager.findOne(Organization, { where: { isHost: true, status: 'active' } })
|
? await manager.findOne(Organization, { where: { isHost: true, status: 'active' } })
|
||||||
: null;
|
: null;
|
||||||
if (creatable.length && !host) throw new Error('尚未配置本机构');
|
if (creatable.length && !host) throw new Error('尚未配置本机构');
|
||||||
|
|
||||||
|
const bindableStudent = new Map<string, Student>();
|
||||||
|
for (const user of bindable) {
|
||||||
|
const student = studentsByPhone.get(user.mobile!)![0];
|
||||||
|
bindableStudent.set(user.dingUserId, student);
|
||||||
|
if (!updateProfile) continue;
|
||||||
|
let changed = false;
|
||||||
|
if (student.name !== user.name) {
|
||||||
|
student.name = user.name;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (user.mobile && student.phone !== user.mobile) {
|
||||||
|
student.phone = user.mobile;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
updates.push(student);
|
||||||
|
updatedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (updates.length) await manager.save(Student, updates);
|
if (updates.length) await manager.save(Student, updates);
|
||||||
|
|
||||||
|
if (bindable.length) {
|
||||||
|
await manager.save(
|
||||||
|
StudentDingMapping,
|
||||||
|
bindable.map((user) =>
|
||||||
|
manager.create(StudentDingMapping, {
|
||||||
|
dingUserId: user.dingUserId,
|
||||||
|
studentId: bindableStudent.get(user.dingUserId)!.id,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (const user of bindable) {
|
||||||
|
studentIds.set(user.dingUserId, bindableStudent.get(user.dingUserId)!.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const createdStudents = creatable.length
|
const createdStudents = creatable.length
|
||||||
? await manager.save(
|
? await manager.save(
|
||||||
Student,
|
Student,
|
||||||
@@ -120,5 +209,12 @@ export async function syncDingTalkStudents(
|
|||||||
createdStudents.forEach((student, index) => studentIds.set(creatable[index].dingUserId, student.id));
|
createdStudents.forEach((student, index) => studentIds.set(creatable[index].dingUserId, student.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
return { created: createdStudents.length, updated: updates.length, studentIds, conflicts };
|
return {
|
||||||
|
created: createdStudents.length,
|
||||||
|
updated: updatedCount,
|
||||||
|
matched: bindable.length,
|
||||||
|
skipped: skipped.length,
|
||||||
|
studentIds,
|
||||||
|
conflicts,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -335,11 +335,16 @@ export class DingTalkService {
|
|||||||
// Sync all — 主入口
|
// Sync all — 主入口
|
||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
async syncAll(rootDeptId = 1): Promise<{
|
async syncAll(
|
||||||
|
rootDeptId = 1,
|
||||||
|
options: { createMissing?: boolean; updateProfile?: boolean } = {},
|
||||||
|
): Promise<{
|
||||||
deptCount: number;
|
deptCount: number;
|
||||||
userCount: number;
|
userCount: number;
|
||||||
created: number;
|
created: number;
|
||||||
updated: number;
|
updated: number;
|
||||||
|
matched: number;
|
||||||
|
skipped: number;
|
||||||
conflicts: Array<{ dingUserId: string; name: string; reason: string }>;
|
conflicts: Array<{ dingUserId: string; name: string; reason: string }>;
|
||||||
}> {
|
}> {
|
||||||
if (!(await this.isConfigured())) {
|
if (!(await this.isConfigured())) {
|
||||||
@@ -373,11 +378,12 @@ export class DingTalkService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const result = await this.dataSource.transaction((manager) =>
|
const result = await this.dataSource.transaction((manager) =>
|
||||||
syncDingTalkStudents(manager, [...users.values()]),
|
syncDingTalkStudents(manager, [...users.values()], options),
|
||||||
);
|
);
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`钉钉同步完成: ${users.size} 个用户, ${allDeptIds.length} 个部门, ` +
|
`钉钉同步完成: ${users.size} 个用户, ${allDeptIds.length} 个部门, ` +
|
||||||
`${result.created} 个新增, ${result.updated} 个更新, ${result.conflicts.length} 个冲突, ` +
|
`${result.created} 个新增, ${result.updated} 个更新, ${result.matched} 个手机号绑定, ` +
|
||||||
|
`${result.skipped} 个跳过, ${result.conflicts.length} 个冲突, ` +
|
||||||
`API 请求 ${this.apiRequestCount} 次, 耗时 ${Date.now() - t0}ms`,
|
`API 请求 ${this.apiRequestCount} 次, 耗时 ${Date.now() - t0}ms`,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
@@ -385,6 +391,8 @@ export class DingTalkService {
|
|||||||
userCount: users.size,
|
userCount: users.size,
|
||||||
created: result.created,
|
created: result.created,
|
||||||
updated: result.updated,
|
updated: result.updated,
|
||||||
|
matched: result.matched,
|
||||||
|
skipped: result.skipped,
|
||||||
conflicts: result.conflicts,
|
conflicts: result.conflicts,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -638,6 +638,7 @@ export class StudentsService {
|
|||||||
'student.gender',
|
'student.gender',
|
||||||
'student.status',
|
'student.status',
|
||||||
'student.organizationId',
|
'student.organizationId',
|
||||||
|
'student.createdAt',
|
||||||
'organization.name',
|
'organization.name',
|
||||||
])
|
])
|
||||||
.leftJoin('student.organization', 'organization');
|
.leftJoin('student.organization', 'organization');
|
||||||
@@ -648,12 +649,12 @@ export class StudentsService {
|
|||||||
// ---- Filters ----
|
// ---- Filters ----
|
||||||
if (query?.keyword) {
|
if (query?.keyword) {
|
||||||
qb.andWhere(
|
qb.andWhere(
|
||||||
'(student.name LIKE :keyword OR student.studentNo LIKE :keyword)',
|
'(student.name LIKE :keyword OR student.student_no LIKE :keyword)',
|
||||||
{ keyword: `%${query.keyword}%` },
|
{ keyword: `%${query.keyword}%` },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (query?.organizationId) {
|
if (query?.organizationId) {
|
||||||
qb.andWhere('student.organizationId = :orgId', { orgId: query.organizationId });
|
qb.andWhere('student.organization_id = :orgId', { orgId: query.organizationId });
|
||||||
}
|
}
|
||||||
|
|
||||||
qb.orderBy('student.createdAt', 'DESC').take(limit);
|
qb.orderBy('student.createdAt', 'DESC').take(limit);
|
||||||
@@ -668,12 +669,12 @@ export class StudentsService {
|
|||||||
const csQb = this.classStudentRepo
|
const csQb = this.classStudentRepo
|
||||||
.createQueryBuilder('cs')
|
.createQueryBuilder('cs')
|
||||||
.select(['cs.studentId', 'cs.classId'])
|
.select(['cs.studentId', 'cs.classId'])
|
||||||
.where('cs.studentId IN (:...ids)', { ids: studentIds })
|
.where('cs.student_id IN (:...ids)', { ids: studentIds })
|
||||||
.andWhere('cs.status = :status', { status: 'active' });
|
.andWhere('cs.status = :status', { status: 'active' });
|
||||||
|
|
||||||
if (scope.type === 'teacher') {
|
if (scope.type === 'teacher') {
|
||||||
csQb.andWhere(
|
csQb.andWhere(
|
||||||
'cs.classId IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)',
|
'cs.class_id IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)',
|
||||||
{ scopeTeacherUserId: scope.userId },
|
{ scopeTeacherUserId: scope.userId },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -740,12 +741,12 @@ export class StudentsService {
|
|||||||
const csQb = this.classStudentRepo
|
const csQb = this.classStudentRepo
|
||||||
.createQueryBuilder('cs')
|
.createQueryBuilder('cs')
|
||||||
.select(['cs.classId'])
|
.select(['cs.classId'])
|
||||||
.where('cs.studentId = :studentId', { studentId })
|
.where('cs.student_id = :studentId', { studentId })
|
||||||
.andWhere('cs.status = :status', { status: 'active' });
|
.andWhere('cs.status = :status', { status: 'active' });
|
||||||
|
|
||||||
if (scope.type === 'teacher') {
|
if (scope.type === 'teacher') {
|
||||||
csQb.andWhere(
|
csQb.andWhere(
|
||||||
'cs.classId IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)',
|
'cs.class_id IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)',
|
||||||
{ scopeTeacherUserId: scope.userId },
|
{ scopeTeacherUserId: scope.userId },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ describe('SyncController — schedule sync options', () => {
|
|||||||
|
|
||||||
await controller.triggerSync('dingtalk_students', '12');
|
await controller.triggerSync('dingtalk_students', '12');
|
||||||
|
|
||||||
expect(syncService.triggerSync).toHaveBeenCalledWith('dingtalk_students', 12);
|
expect(syncService.triggerSync).toHaveBeenCalledWith('dingtalk_students', 12, true, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns Jinshuju form fields for the selector', async () => {
|
it('returns Jinshuju form fields for the selector', async () => {
|
||||||
|
|||||||
@@ -16,9 +16,16 @@ export class SyncController {
|
|||||||
async triggerSync(
|
async triggerSync(
|
||||||
@Query('platform') platform?: SyncPlatform,
|
@Query('platform') platform?: SyncPlatform,
|
||||||
@Query('rootDeptId') rootDeptId?: string,
|
@Query('rootDeptId') rootDeptId?: string,
|
||||||
|
@Query('createMissing') createMissing?: string,
|
||||||
|
@Query('updateProfile') updateProfile?: string,
|
||||||
) {
|
) {
|
||||||
const rootId = rootDeptId ? this.parseRootDeptId(rootDeptId) : 1;
|
const rootId = rootDeptId ? this.parseRootDeptId(rootDeptId) : 1;
|
||||||
const logs = await this.syncService.triggerSync(platform, rootId);
|
const logs = await this.syncService.triggerSync(
|
||||||
|
platform,
|
||||||
|
rootId,
|
||||||
|
createMissing !== 'false',
|
||||||
|
updateProfile !== 'false',
|
||||||
|
);
|
||||||
return { synced: logs.length, logs };
|
return { synced: logs.length, logs };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,13 @@ function createService(options?: {
|
|||||||
find: jest.fn(),
|
find: jest.fn(),
|
||||||
};
|
};
|
||||||
const dingTalkService = {
|
const dingTalkService = {
|
||||||
syncAll: jest.fn().mockResolvedValue({ created: 1, updated: 2, conflicts: [] }),
|
syncAll: jest.fn().mockResolvedValue({
|
||||||
|
created: 1,
|
||||||
|
updated: 2,
|
||||||
|
matched: 0,
|
||||||
|
skipped: 0,
|
||||||
|
conflicts: [],
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
const attendanceImportService = {
|
const attendanceImportService = {
|
||||||
importFromDingTalk: jest.fn().mockResolvedValue(options?.attendanceResult ?? {
|
importFromDingTalk: jest.fn().mockResolvedValue(options?.attendanceResult ?? {
|
||||||
|
|||||||
@@ -34,13 +34,23 @@ export class SyncService {
|
|||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async syncDingTalkStudents(rootDeptId = 1): Promise<SyncLog> {
|
async syncDingTalkStudents(
|
||||||
|
rootDeptId = 1,
|
||||||
|
createMissing = true,
|
||||||
|
updateProfile = true,
|
||||||
|
): Promise<SyncLog> {
|
||||||
return this.runSync('dingtalk_students', async () => {
|
return this.runSync('dingtalk_students', async () => {
|
||||||
const result = await this.dingTalkService.syncAll(rootDeptId);
|
const result = await this.dingTalkService.syncAll(rootDeptId, { createMissing, updateProfile });
|
||||||
return {
|
return {
|
||||||
recordsCount: result.created + result.updated,
|
recordsCount: result.created + result.updated + (result.matched ?? 0),
|
||||||
status: result.conflicts.length ? 'partial' : 'success',
|
status: result.conflicts.length ? 'partial' : 'success',
|
||||||
message: result.conflicts.length ? JSON.stringify(result.conflicts.slice(0, 20)) : undefined,
|
message: result.conflicts.length
|
||||||
|
? JSON.stringify(result.conflicts.slice(0, 20))
|
||||||
|
: !createMissing && !updateProfile
|
||||||
|
? `手机号绑定 ${result.matched ?? 0} 人,跳过 ${result.skipped ?? 0} 人`
|
||||||
|
: createMissing
|
||||||
|
? `新增 ${result.created} 人,更新 ${result.updated} 人,手机号绑定 ${result.matched ?? 0} 人`
|
||||||
|
: `手机号绑定 ${result.matched ?? 0} 人,更新 ${result.updated} 人,跳过 ${result.skipped ?? 0} 人`,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -220,12 +230,19 @@ export class SyncService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async triggerSync(platform?: SyncPlatform, rootDeptId = 1): Promise<SyncLog[]> {
|
async triggerSync(
|
||||||
if (platform === 'dingtalk_students') return [await this.syncDingTalkStudents(rootDeptId)];
|
platform?: SyncPlatform,
|
||||||
|
rootDeptId = 1,
|
||||||
|
createMissing = true,
|
||||||
|
updateProfile = true,
|
||||||
|
): Promise<SyncLog[]> {
|
||||||
|
if (platform === 'dingtalk_students') {
|
||||||
|
return [await this.syncDingTalkStudents(rootDeptId, createMissing, updateProfile)];
|
||||||
|
}
|
||||||
if (platform === 'dingtalk_attendance') return [await this.syncDingTalkAttendance()];
|
if (platform === 'dingtalk_attendance') return [await this.syncDingTalkAttendance()];
|
||||||
if (platform === 'wecom') return [await this.syncWeCom()];
|
if (platform === 'wecom') return [await this.syncWeCom()];
|
||||||
return [
|
return [
|
||||||
await this.syncDingTalkStudents(rootDeptId),
|
await this.syncDingTalkStudents(rootDeptId, createMissing, updateProfile),
|
||||||
await this.syncDingTalkAttendance(),
|
await this.syncDingTalkAttendance(),
|
||||||
await this.syncWeCom(),
|
await this.syncWeCom(),
|
||||||
];
|
];
|
||||||
@@ -274,6 +291,33 @@ export class SyncService {
|
|||||||
return this.scheduleSyncService.getStatus(date || new Date().toISOString().slice(0, 10));
|
return this.scheduleSyncService.getStatus(date || new Date().toISOString().slice(0, 10));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent tool: 汇总各平台最近一次同步状态和排课映射状态。
|
||||||
|
*/
|
||||||
|
async agentGetSyncStatus(): Promise<{
|
||||||
|
dingTalkStudents: { lastSyncAt: Date | null; status: string } | null;
|
||||||
|
dingTalkAttendance: { lastSyncAt: Date | null; status: string } | null;
|
||||||
|
weCom: { lastSyncAt: Date | null; status: string } | null;
|
||||||
|
schedule: { activeSchedules: number; mappedClasses: number; totalClasses: number };
|
||||||
|
}> {
|
||||||
|
const [students, attendance, weCom, schedule] = await Promise.all([
|
||||||
|
this.getLastSync('dingtalk_students'),
|
||||||
|
this.getLastSync('dingtalk_attendance'),
|
||||||
|
this.getLastSync('wecom'),
|
||||||
|
this.getScheduleSyncStatus(),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
dingTalkStudents: students
|
||||||
|
? { lastSyncAt: students.finishedAt ?? null, status: students.status }
|
||||||
|
: null,
|
||||||
|
dingTalkAttendance: attendance
|
||||||
|
? { lastSyncAt: attendance.finishedAt ?? null, status: attendance.status }
|
||||||
|
: null,
|
||||||
|
weCom: weCom ? { lastSyncAt: weCom.finishedAt ?? null, status: weCom.status } : null,
|
||||||
|
schedule,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async getLogs(platform?: SyncPlatform, limit = 50): Promise<SyncLog[]> {
|
async getLogs(platform?: SyncPlatform, limit = 50): Promise<SyncLog[]> {
|
||||||
const where: Record<string, SyncPlatform> = {};
|
const where: Record<string, SyncPlatform> = {};
|
||||||
if (platform) where.platform = platform;
|
if (platform) where.platform = platform;
|
||||||
|
|||||||
Reference in New Issue
Block a user