feat: 钉钉学生同步支持仅绑定手机号及学生列表修复

This commit is contained in:
2026-08-04 14:41:48 +08:00
parent 50c44e4410
commit 216a20ffaf
9 changed files with 334 additions and 36 deletions

View File

@@ -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={{

View File

@@ -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 });

View File

@@ -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);
student.name = input.name; if (!updateProfile) continue;
if (input.mobile) student.phone = input.mobile; let changed = false;
updates.push(student); if (student.name !== input.name) {
student.name = input.name;
changed = true;
}
if (input.mobile && student.phone !== input.mobile) {
student.phone = input.mobile;
changed = true;
}
if (changed) {
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,
};
} }

View File

@@ -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,
}; };
} }

View File

@@ -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 },
); );
} }

View File

@@ -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 () => {

View File

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

View File

@@ -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 ?? {

View File

@@ -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;