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,
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<string, { text: string; color: string }> = {
@@ -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<any[]>([]);
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<number[]>([]);
const [batchLoading, setBatchLoading] = useState(false);
const [dingSyncLoading, setDingSyncLoading] = useState(false);
const [enrollmentData, setEnrollmentData] = useState<Record<number, EnrollmentInfo[]>>({});
const [pageInfo, setPageInfo] = useState({ current: 1, pageSize: 15 });
const [drawerOpen, setDrawerOpen] = useState(false);
const [drawerStudentId, setDrawerStudentId] = useState<number | undefined>(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<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 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 = () => {
</Button>
) : null}
{!showArchived && canSyncDingTalk ? (
<Button icon={<SyncOutlined />} loading={dingSyncLoading} onClick={handleDingTalkSync}>
</Button>
) : null}
<PermissionButton
permission="student:view"
icon={<DownloadOutlined />}
@@ -920,6 +970,23 @@ const StudentsPage: React.FC = () => {
</PermissionButton>
</Space>
</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
showIcon
type="warning"
@@ -940,9 +1007,12 @@ const StudentsPage: React.FC = () => {
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={{

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

View File

@@ -16,6 +16,8 @@ export interface DingTalkStudentConflict {
export interface DingTalkStudentSyncResult {
created: number;
updated: number;
matched: number;
skipped: number;
studentIds: Map<string, number>;
conflicts: DingTalkStudentConflict[];
}
@@ -23,7 +25,10 @@ export interface DingTalkStudentSyncResult {
export async function syncDingTalkStudents(
manager: EntityManager,
inputs: DingTalkStudentInput[],
options: { createMissing?: boolean; updateProfile?: boolean } = {},
): Promise<DingTalkStudentSyncResult> {
const createMissing = options.createMissing !== false;
const updateProfile = options.updateProfile !== false;
const users = new Map<string, DingTalkStudentInput>();
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<string, number>();
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<string, Student[]>();
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<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 (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,
};
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -34,13 +34,23 @@ export class SyncService {
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 () => {
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<SyncLog[]> {
if (platform === 'dingtalk_students') return [await this.syncDingTalkStudents(rootDeptId)];
async triggerSync(
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 === '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<SyncLog[]> {
const where: Record<string, SyncPlatform> = {};
if (platform) where.platform = platform;