From 216a20ffafe1e4f7b2f2ce3cb998953ded253de4 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Tue, 4 Aug 2026 14:41:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=92=89=E9=92=89=E5=AD=A6=E7=94=9F?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E6=94=AF=E6=8C=81=E4=BB=85=E7=BB=91=E5=AE=9A?= =?UTF-8?q?=E6=89=8B=E6=9C=BA=E5=8F=B7=E5=8F=8A=E5=AD=A6=E7=94=9F=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin/src/pages/Students/index.tsx | 76 ++++++++++- .../integration/dingtalk-student-sync.spec.ts | 72 ++++++++++- .../src/integration/dingtalk-student-sync.ts | 118 ++++++++++++++++-- .../src/integration/dingtalk.service.ts | 14 ++- apps/server/src/students/students.service.ts | 13 +- apps/server/src/sync/sync.controller.spec.ts | 2 +- apps/server/src/sync/sync.controller.ts | 9 +- apps/server/src/sync/sync.service.spec.ts | 8 +- apps/server/src/sync/sync.service.ts | 58 +++++++-- 9 files changed, 334 insertions(+), 36 deletions(-) diff --git a/apps/admin/src/pages/Students/index.tsx b/apps/admin/src/pages/Students/index.tsx index 69657b7..5e70cea 100644 --- a/apps/admin/src/pages/Students/index.tsx +++ b/apps/admin/src/pages/Students/index.tsx @@ -28,6 +28,7 @@ import { InboxOutlined, PlusOutlined, SwapOutlined, + SyncOutlined, UndoOutlined, UploadOutlined, } from '@ant-design/icons'; @@ -39,6 +40,7 @@ import JinshujuMatchModal from '../../components/JinshujuMatchModal'; import { maskIdNumber, maskPhone } from '../../utils/sensitive'; import { message } from '../../ui/app-message'; import { usePermission } from '../../hooks/usePermission'; +import { useUserStore } from '../../store/user/userStore'; import { selectArchiveRecords } from '../archive-view'; const statusMap: Record = { @@ -79,6 +81,17 @@ interface StudentUpdateImportResult { skipped?: number; } +interface DingTalkSyncLog { + status: string; + recordsCount: number; + errorMessage?: string | null; +} + +interface DingTalkSyncResult { + synced: number; + logs: DingTalkSyncLog[]; +} + interface StudentFilterLookups { classes: Array<{ id: number; name: string; code?: string }>; teachers: Array<{ id: number; name: string; username: string }>; @@ -98,6 +111,7 @@ const StudentsPage: React.FC = () => { const canEditStudent = hasPermission('student:edit'); const canDeleteStudent = hasPermission('student:delete'); const canSyncJinshuju = hasAllPermissions('sync:read', 'sync:trigger'); + const canSyncDingTalk = hasAllPermissions('sync:read', 'sync:trigger'); const [data, setData] = useState([]); const [loading, setLoading] = useState(false); const [modalOpen, setModalOpen] = useState(false); @@ -114,7 +128,9 @@ const StudentsPage: React.FC = () => { const [showArchived, setShowArchived] = useState(false); const [selectedRowKeys, setSelectedRowKeys] = useState([]); const [batchLoading, setBatchLoading] = useState(false); + const [dingSyncLoading, setDingSyncLoading] = useState(false); const [enrollmentData, setEnrollmentData] = useState>({}); + const [pageInfo, setPageInfo] = useState({ current: 1, pageSize: 15 }); const [drawerOpen, setDrawerOpen] = useState(false); const [drawerStudentId, setDrawerStudentId] = useState(undefined); const [form] = Form.useForm(); @@ -336,7 +352,7 @@ const StudentsPage: React.FC = () => { const baseURL = import.meta.env.PROD ? '/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}` } }) .then((res) => res.blob()) .then((blob) => { @@ -441,11 +457,33 @@ const StudentsPage: React.FC = () => { } }; + const handleDingTalkSync = async () => { + setDingSyncLoading(true); + try { + const res = await api.post('/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 baseURL = import.meta.env.PROD ? '/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(); if (searchName) params.set('name', searchName); if (filterStatus) params.set('status', filterStatus); @@ -469,7 +507,13 @@ const StudentsPage: React.FC = () => { 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: '姓名', dataIndex: 'name', @@ -741,6 +785,7 @@ const StudentsPage: React.FC = () => { saveCell, hasPermission, canChooseOrganization, + pageInfo, ], ); @@ -904,6 +949,11 @@ const StudentsPage: React.FC = () => { 同步金数据 ) : null} + {!showArchived && canSyncDingTalk ? ( + + ) : null} } @@ -920,6 +970,23 @@ const StudentsPage: React.FC = () => { + {selectedRowKeys.length > 0 ? ( + + 已选 {selectedRowKeys.length} 人(支持跨页勾选) + + } + action={ + + } + /> + ) : null} { scroll={{ x: 1410 }} pagination={{ defaultPageSize: 15, + current: pageInfo.current, + pageSize: pageInfo.pageSize, showSizeChanger: true, pageSizeOptions: [15, 30, 50, 100], showTotal: (total) => `共 ${total} 人`, + onChange: (current, pageSize) => setPageInfo({ current, pageSize }), }} rowClassName={(record: any) => (record.status === 'archived' ? 'archived-row' : '')} rowSelection={{ diff --git a/apps/server/src/integration/dingtalk-student-sync.spec.ts b/apps/server/src/integration/dingtalk-student-sync.spec.ts index 90bb37e..6e92b4b 100644 --- a/apps/server/src/integration/dingtalk-student-sync.spec.ts +++ b/apps/server/src/integration/dingtalk-student-sync.spec.ts @@ -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 { manager, saves } = managerFixture({ occupiedPhones: [occupied] }); @@ -66,13 +66,79 @@ describe('syncDingTalkStudents', () => { { 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.objectContaining({ dingUserId: 'u2', reason: expect.stringContaining('人工绑定') }), + expect.objectContaining({ reason: expect.stringContaining('多名学生') }), ]); 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 () => { const { manager } = managerFixture({ failMappingSave: true }); diff --git a/apps/server/src/integration/dingtalk-student-sync.ts b/apps/server/src/integration/dingtalk-student-sync.ts index 10ace6f..bda0305 100644 --- a/apps/server/src/integration/dingtalk-student-sync.ts +++ b/apps/server/src/integration/dingtalk-student-sync.ts @@ -16,6 +16,8 @@ export interface DingTalkStudentConflict { export interface DingTalkStudentSyncResult { created: number; updated: number; + matched: number; + skipped: number; studentIds: Map; conflicts: DingTalkStudentConflict[]; } @@ -23,7 +25,10 @@ export interface DingTalkStudentSyncResult { export async function syncDingTalkStudents( manager: EntityManager, inputs: DingTalkStudentInput[], + options: { createMissing?: boolean; updateProfile?: boolean } = {}, ): Promise { + const createMissing = options.createMissing !== false; + const updateProfile = options.updateProfile !== false; const users = new Map(); const conflicts: DingTalkStudentConflict[] = []; @@ -43,7 +48,7 @@ export async function syncDingTalkStudents( } 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()]; @@ -58,6 +63,7 @@ export async function syncDingTalkStudents( const studentById = new Map(mappedStudents.map((student) => [student.id, student])); const studentIds = new Map(); const updates: Student[] = []; + let updatedCount = 0; for (const mapping of mappings) { const input = users.get(mapping.dingUserId); @@ -71,9 +77,20 @@ export async function syncDingTalkStudents( continue; } studentIds.set(mapping.dingUserId, student.id); - student.name = input.name; - if (input.mobile) student.phone = input.mobile; - updates.push(student); + if (!updateProfile) continue; + let changed = false; + 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)); @@ -81,19 +98,91 @@ export async function syncDingTalkStudents( const occupiedPhones = mobiles.length ? await manager.find(Student, { where: { phone: In(mobiles) } }) : []; - const studentByPhone = new Map(occupiedPhones.map((student) => [student.phone, student])); - const creatable = newUsers.filter((user) => { - if (!user.mobile || !studentByPhone.has(user.mobile)) return true; - conflicts.push({ dingUserId: user.dingUserId, name: user.name, reason: '手机号已属于其他学生,请人工绑定' }); - return false; - }); + const studentsByPhone = new Map(); + for (const student of occupiedPhones) { + const list = studentsByPhone.get(student.phone) ?? []; + list.push(student); + 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 ? await manager.findOne(Organization, { where: { isHost: true, status: 'active' } }) : null; if (creatable.length && !host) throw new Error('尚未配置本机构'); + const bindableStudent = new Map(); + 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 (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 ? await manager.save( Student, @@ -120,5 +209,12 @@ export async function syncDingTalkStudents( 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, + }; } diff --git a/apps/server/src/integration/dingtalk.service.ts b/apps/server/src/integration/dingtalk.service.ts index cb2c3b2..e98d30c 100644 --- a/apps/server/src/integration/dingtalk.service.ts +++ b/apps/server/src/integration/dingtalk.service.ts @@ -335,11 +335,16 @@ export class DingTalkService { // Sync all — 主入口 // ═══════════════════════════════════════════ - async syncAll(rootDeptId = 1): Promise<{ + async syncAll( + rootDeptId = 1, + options: { createMissing?: boolean; updateProfile?: boolean } = {}, + ): Promise<{ deptCount: number; userCount: number; created: number; updated: number; + matched: number; + skipped: number; conflicts: Array<{ dingUserId: string; name: string; reason: string }>; }> { if (!(await this.isConfigured())) { @@ -373,11 +378,12 @@ export class DingTalkService { } } const result = await this.dataSource.transaction((manager) => - syncDingTalkStudents(manager, [...users.values()]), + syncDingTalkStudents(manager, [...users.values()], options), ); this.logger.log( `钉钉同步完成: ${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`, ); return { @@ -385,6 +391,8 @@ export class DingTalkService { userCount: users.size, created: result.created, updated: result.updated, + matched: result.matched, + skipped: result.skipped, conflicts: result.conflicts, }; } diff --git a/apps/server/src/students/students.service.ts b/apps/server/src/students/students.service.ts index cc82c0e..f6a7593 100644 --- a/apps/server/src/students/students.service.ts +++ b/apps/server/src/students/students.service.ts @@ -638,6 +638,7 @@ export class StudentsService { 'student.gender', 'student.status', 'student.organizationId', + 'student.createdAt', 'organization.name', ]) .leftJoin('student.organization', 'organization'); @@ -648,12 +649,12 @@ export class StudentsService { // ---- Filters ---- if (query?.keyword) { qb.andWhere( - '(student.name LIKE :keyword OR student.studentNo LIKE :keyword)', + '(student.name LIKE :keyword OR student.student_no LIKE :keyword)', { keyword: `%${query.keyword}%` }, ); } 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); @@ -668,12 +669,12 @@ export class StudentsService { const csQb = this.classStudentRepo .createQueryBuilder('cs') .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' }); if (scope.type === 'teacher') { 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 }, ); } @@ -740,12 +741,12 @@ export class StudentsService { const csQb = this.classStudentRepo .createQueryBuilder('cs') .select(['cs.classId']) - .where('cs.studentId = :studentId', { studentId }) + .where('cs.student_id = :studentId', { studentId }) .andWhere('cs.status = :status', { status: 'active' }); if (scope.type === 'teacher') { 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 }, ); } diff --git a/apps/server/src/sync/sync.controller.spec.ts b/apps/server/src/sync/sync.controller.spec.ts index 96bd9ce..53a86fb 100644 --- a/apps/server/src/sync/sync.controller.spec.ts +++ b/apps/server/src/sync/sync.controller.spec.ts @@ -41,7 +41,7 @@ describe('SyncController — schedule sync options', () => { 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 () => { diff --git a/apps/server/src/sync/sync.controller.ts b/apps/server/src/sync/sync.controller.ts index 78ff454..a3148ab 100644 --- a/apps/server/src/sync/sync.controller.ts +++ b/apps/server/src/sync/sync.controller.ts @@ -16,9 +16,16 @@ export class SyncController { async triggerSync( @Query('platform') platform?: SyncPlatform, @Query('rootDeptId') rootDeptId?: string, + @Query('createMissing') createMissing?: string, + @Query('updateProfile') updateProfile?: string, ) { 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 }; } diff --git a/apps/server/src/sync/sync.service.spec.ts b/apps/server/src/sync/sync.service.spec.ts index 9c9545a..8780816 100644 --- a/apps/server/src/sync/sync.service.spec.ts +++ b/apps/server/src/sync/sync.service.spec.ts @@ -36,7 +36,13 @@ function createService(options?: { find: jest.fn(), }; 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 = { importFromDingTalk: jest.fn().mockResolvedValue(options?.attendanceResult ?? { diff --git a/apps/server/src/sync/sync.service.ts b/apps/server/src/sync/sync.service.ts index 91d2e3b..cfa5a25 100644 --- a/apps/server/src/sync/sync.service.ts +++ b/apps/server/src/sync/sync.service.ts @@ -34,13 +34,23 @@ export class SyncService { private readonly dataSource: DataSource, ) {} - async syncDingTalkStudents(rootDeptId = 1): Promise { + async syncDingTalkStudents( + rootDeptId = 1, + createMissing = true, + updateProfile = true, + ): Promise { return this.runSync('dingtalk_students', async () => { - const result = await this.dingTalkService.syncAll(rootDeptId); + const result = await this.dingTalkService.syncAll(rootDeptId, { createMissing, updateProfile }); return { - recordsCount: result.created + result.updated, + recordsCount: result.created + result.updated + (result.matched ?? 0), 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 { - if (platform === 'dingtalk_students') return [await this.syncDingTalkStudents(rootDeptId)]; + async triggerSync( + platform?: SyncPlatform, + rootDeptId = 1, + createMissing = true, + updateProfile = true, + ): Promise { + if (platform === 'dingtalk_students') { + return [await this.syncDingTalkStudents(rootDeptId, createMissing, updateProfile)]; + } if (platform === 'dingtalk_attendance') return [await this.syncDingTalkAttendance()]; if (platform === 'wecom') return [await this.syncWeCom()]; return [ - await this.syncDingTalkStudents(rootDeptId), + await this.syncDingTalkStudents(rootDeptId, createMissing, updateProfile), await this.syncDingTalkAttendance(), await this.syncWeCom(), ]; @@ -274,6 +291,33 @@ export class SyncService { 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 { const where: Record = {}; if (platform) where.platform = platform;