fix: audit remediation — SSE user scoping, FK transactional safety, UI error handling

- H4: scoped SSE import progress to exact userId match; non-HTTP events excluded from all subscribers
- H2: moved PRAGMA foreign_key_check inside SQLite transaction before COMMIT; violations rollback preserving old tables
- M1: removed dead axios-style error branch from extractErrorMessage (interceptor already unwraps)
- M2: split handleSave try/catch — save errors vs reload errors shown distinctly
- M3: added provider field validation before AI config test request
- Added SSE scoping regression tests (import service + controller)
- Added FK check failure rollback test (database-migrations.spec)
- Updated controller spec expectations for userId parameter

Co-authored-by: Code Review <branch-review>
This commit is contained in:
2026-07-12 22:59:03 +08:00
parent b6fca99390
commit cc4f4dae4e
69 changed files with 6262 additions and 1980 deletions

View File

@@ -28,7 +28,7 @@ describe('ScheduleSyncService — absence threshold', () => {
find: jest.fn().mockResolvedValue([{ id: 10, name: '冲刺班' }]),
};
const dingTalkService = {
queryShifts: jest.fn().mockResolvedValue([{ id: 456, name: '排课_16:00-17:00' }]),
queryShifts: jest.fn().mockResolvedValue([{ id: 456, name: '冲刺班_16:00-17:00' }]),
upsertShift: jest.fn().mockResolvedValue(456),
queryAttendanceGroups: jest.fn().mockResolvedValue([
{ group_id: 123, group_name: '排课_冲刺班', type: 'TURN', member_count: 1 },
@@ -50,7 +50,7 @@ describe('ScheduleSyncService — absence threshold', () => {
expect(dingTalkService.upsertShift).toHaveBeenCalledWith(expect.objectContaining({
id: 456,
name: '排课_16:00-17:00',
name: '冲刺班_16:00-17:00',
setting: expect.objectContaining({ absenteeism_late_minutes: 60 }),
}));
});
@@ -118,3 +118,347 @@ describe('ScheduleSyncService — attendance machine only', () => {
expect(dingTalkService.createAttendanceGroup).not.toHaveBeenCalled();
});
});
describe('ScheduleSyncService — partial batch failure', () => {
it('reports failedBatchCount > 0 when a scheduleUsers batch fails, not full success', async () => {
const scheduleRepo = {
find: jest.fn().mockResolvedValue([
{
id: 1,
classId: 10,
classroomId: 1,
weekDay: 1,
startTime: '09:00',
endTime: '11:00',
startDate: '2026-07-06',
endDate: '2026-07-06',
status: 'active',
} as ClassSchedule,
{
id: 2,
classId: 20,
classroomId: 2,
weekDay: 2,
startTime: '14:00',
endTime: '16:00',
startDate: '2026-07-07',
endDate: '2026-07-07',
status: 'active',
} as ClassSchedule,
]),
};
const classStudentRepo = {
find: jest.fn().mockResolvedValue([
{ classId: 10, studentId: 20, status: 'active' },
{ classId: 20, studentId: 30, status: 'active' },
]),
};
const mappingRepo = {
find: jest.fn().mockResolvedValue([
{ studentId: 20, dingUserId: 'student-1' },
{ studentId: 30, dingUserId: 'student-2' },
]),
};
const classRepo = {
find: jest.fn().mockResolvedValue([
{ id: 10, name: '冲刺班' },
{ id: 20, name: '强化班' },
]),
};
const scheduleUsers = jest
.fn()
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error('钉钉排班失败: rate limited (code=33018)'));
const dingTalkService = {
queryShifts: jest.fn().mockResolvedValue([
{ id: 900, name: '排课_09:00-11:00' },
{ id: 901, name: '排课_14:00-16:00' },
]),
upsertShift: jest.fn().mockResolvedValue(900).mockResolvedValueOnce(900).mockResolvedValueOnce(901),
queryAttendanceGroups: jest.fn().mockResolvedValue([
{ group_id: 777, group_name: '排课_冲刺班', type: 'TURN', member_count: 1 },
]),
updateAttendanceGroup: jest.fn().mockResolvedValue(undefined),
createAttendanceGroup: jest.fn().mockResolvedValue(888),
scheduleUsers,
};
const service = new ScheduleSyncService(
scheduleRepo as never,
classStudentRepo as never,
mappingRepo as never,
classRepo as never,
dingTalkService as never,
);
const result = await service.syncAll('2026-07-06', 2);
expect(scheduleUsers).toHaveBeenCalledTimes(2);
expect(result.failedBatchCount).toBeGreaterThan(0);
expect(result.errors).toBeDefined();
expect(result.errors!.length).toBeGreaterThan(0);
// syncedItems should only count the successful batch
expect(result.syncedItems).toBeGreaterThan(0);
});
});
describe('ScheduleSyncService — attendance group failure', () => {
it('counts createAttendanceGroup failure as real failure, not skippedNoMapping', async () => {
const scheduleRepo = {
find: jest.fn().mockResolvedValue([
{
id: 1,
classId: 10,
classroomId: 1,
weekDay: 1,
startTime: '09:00',
endTime: '11:00',
startDate: '2026-07-06',
endDate: '2026-07-06',
status: 'active',
} as ClassSchedule,
{
id: 2,
classId: 20,
classroomId: 2,
weekDay: 2,
startTime: '14:00',
endTime: '16:00',
startDate: '2026-07-07',
endDate: '2026-07-07',
status: 'active',
} as ClassSchedule,
]),
};
const classStudentRepo = {
find: jest.fn().mockResolvedValue([
{ classId: 10, studentId: 20, status: 'active' },
{ classId: 20, studentId: 30, status: 'active' },
]),
};
const mappingRepo = {
find: jest.fn().mockResolvedValue([
{ studentId: 20, dingUserId: 'student-1' },
{ studentId: 30, dingUserId: 'student-2' },
]),
};
const classRepo = {
find: jest.fn().mockResolvedValue([
{ id: 10, name: '冲刺班' },
{ id: 20, name: '强化班' },
]),
};
const createAttendanceGroup = jest
.fn()
.mockRejectedValue(new Error('钉钉考勤组创建失败: insuffient permission (code=403)'));
const dingTalkService = {
queryShifts: jest.fn().mockResolvedValue([
{ id: 900, name: '排课_09:00-11:00' },
{ id: 901, name: '排课_14:00-16:00' },
]),
upsertShift: jest.fn().mockResolvedValue(900),
queryAttendanceGroups: jest.fn().mockResolvedValue([
{ group_id: 888, group_name: '排课_冲刺班', type: 'TURN', member_count: 1 },
]),
updateAttendanceGroup: jest.fn().mockResolvedValue(undefined),
createAttendanceGroup,
scheduleUsers: jest.fn().mockResolvedValue(undefined),
};
const service = new ScheduleSyncService(
scheduleRepo as never,
classStudentRepo as never,
mappingRepo as never,
classRepo as never,
dingTalkService as never,
);
const result = await service.syncAll('2026-07-06', 2);
// group failure must NOT be counted as skippedNoMapping
expect(result.skippedNoMapping).toBe(0);
// group failure must increment failure counters
expect(result.failedBatchCount).toBeGreaterThan(0);
expect(result.failedItems).toBeGreaterThan(0);
// error message must contain the group failure detail
expect(result.errors).toBeDefined();
expect(result.errors!.some((e) => e.includes('考勤组'))).toBe(true);
expect(result.errors!.some((e) => e.includes('强化班'))).toBe(true);
// the successful class should still sync
expect(result.syncedItems).toBeGreaterThan(0);
expect(result.groupCount).toBe(1);
});
});
describe('ScheduleSyncService — dedup', () => {
it('does not write duplicate schedule items for same user/date/shift', async () => {
const scheduleRepo = {
find: jest.fn().mockResolvedValue([
{
id: 1,
classId: 10,
classroomId: 1,
weekDay: 3,
startTime: '10:00',
endTime: '12:00',
startDate: '2026-07-01',
endDate: '2026-07-31',
status: 'active',
} as ClassSchedule,
{
id: 2,
classId: 10,
classroomId: 1,
weekDay: 3,
startTime: '10:00',
endTime: '12:00',
startDate: '2026-07-08',
endDate: '2026-07-08',
status: 'active',
} as ClassSchedule,
]),
};
const classStudentRepo = {
find: jest.fn().mockResolvedValue([{ classId: 10, studentId: 20, status: 'active' }]),
};
const mappingRepo = {
find: jest.fn().mockResolvedValue([{ studentId: 20, dingUserId: 'student-1' }]),
};
const classRepo = {
find: jest.fn().mockResolvedValue([{ id: 10, name: '冲刺班' }]),
};
const scheduleUsers = jest.fn().mockResolvedValue(undefined);
const dingTalkService = {
queryShifts: jest
.fn()
.mockResolvedValue([{ id: 456, name: '排课_10:00-12:00' }]),
upsertShift: jest.fn().mockResolvedValue(456),
queryAttendanceGroups: jest.fn().mockResolvedValue([
{ group_id: 123, group_name: '排课_冲刺班', type: 'TURN', member_count: 1 },
]),
updateAttendanceGroup: jest.fn().mockResolvedValue(undefined),
createAttendanceGroup: jest.fn(),
scheduleUsers,
};
const service = new ScheduleSyncService(
scheduleRepo as never,
classStudentRepo as never,
mappingRepo as never,
classRepo as never,
dingTalkService as never,
);
await service.syncAll('2026-07-01', 31);
const batchItems = scheduleUsers.mock.calls[0][1] as Array<{ userid: string; work_date: number; shift_id: number }>;
// 2026-07-08 is a Wednesday (weekDay 3), so both schedules hit that date.
// The dedup should collapse the two identical {userid, work_date, shift_id} items into one.
const key = (item: { userid: string; work_date: number; shift_id: number }) =>
`${item.userid}-${item.work_date}-${item.shift_id}`;
const seen = new Set<string>();
for (const item of batchItems) {
const k = key(item);
expect(seen.has(k)).toBe(false);
seen.add(k);
}
// At least one item exists for 07-08 (proving overlap was handled)
// work_date is epoch ms at 00:00:00+08:00; convert back to date string
const fmtDate = (epochMs: number) => {
const d = new Date(epochMs);
return new Date(d.getTime() - d.getTimezoneOffset() * 60000)
.toISOString().slice(0, 10);
};
const july8Items = batchItems.filter((i) => fmtDate(i.work_date) === '2026-07-08');
expect(july8Items.length).toBe(1);
});
});
describe('ScheduleSyncService — all shifts fail', () => {
it('counts failedBatchCount and failedItems when every shift creation fails, never skippedNoMapping', async () => {
const scheduleRepo = {
find: jest.fn().mockResolvedValue([
{
id: 1,
classId: 10,
classroomId: 1,
weekDay: 1,
startTime: '09:00',
endTime: '11:00',
startDate: '2026-07-06',
endDate: '2026-07-06',
status: 'active',
} as ClassSchedule,
{
id: 2,
classId: 10,
classroomId: 1,
weekDay: 2,
startTime: '14:00',
endTime: '16:00',
startDate: '2026-07-07',
endDate: '2026-07-07',
status: 'active',
} as ClassSchedule,
]),
};
const classStudentRepo = {
find: jest.fn().mockResolvedValue([
{ classId: 10, studentId: 20, status: 'active' },
]),
};
const mappingRepo = {
find: jest.fn().mockResolvedValue([{ studentId: 20, dingUserId: 'student-1' }]),
};
const classRepo = {
find: jest.fn().mockResolvedValue([{ id: 10, name: '冲刺班' }]),
};
const dingTalkService = {
queryShifts: jest.fn().mockResolvedValue([]),
upsertShift: jest.fn().mockRejectedValue(new Error('钉钉班次创建失败: permission denied')),
queryAttendanceGroups: jest.fn().mockResolvedValue([]),
updateAttendanceGroup: jest.fn(),
createAttendanceGroup: jest.fn(),
scheduleUsers: jest.fn(),
};
const service = new ScheduleSyncService(
scheduleRepo as never,
classStudentRepo as never,
mappingRepo as never,
classRepo as never,
dingTalkService as never,
);
const result = await service.syncAll('2026-07-06', 2);
// All shifts failed → no shifts created
expect(result.shiftCount).toBe(0);
// No attendance groups created (no usable shifts)
expect(result.groupCount).toBe(0);
// Nothing synced
expect(result.syncedItems).toBe(0);
// Must NOT count as skippedNoMapping
expect(result.skippedNoMapping).toBe(0);
// Failure counters must reflect the failed shifts
expect(result.failedBatchCount).toBeGreaterThan(0);
expect(result.failedItems).toBeGreaterThan(0);
// Errors must contain shift failure messages
expect(result.errors).toBeDefined();
expect(result.errors!.length).toBeGreaterThan(0);
expect(result.errors!.some((e) => e.includes('班次'))).toBe(true);
// No scheduleUsers calls (no group created)
expect(dingTalkService.scheduleUsers).not.toHaveBeenCalled();
});
});

View File

@@ -21,6 +21,12 @@ export interface ScheduleSyncResult {
syncedItems: number;
/** 因无学生或无钉钉映射而跳过的排课数 */
skippedNoMapping: number;
/** 写入失败的排班批次数 */
failedBatchCount: number;
/** 写入失败的排班条数 */
failedItems: number;
/** 失败批次错误详情 */
errors: string[];
/** 按班级分组的详情 */
groups: Array<{
className: string;
@@ -39,6 +45,13 @@ export interface ScheduleSyncResult {
* 4. 每个班级创建/匹配一个排班制考勤组(考勤组列表只拉一次)
* 5. 将排课展开为每个学生的每日排班,批量写入钉钉
*
* ## 残余风险:同步窗口内已不存在的旧排班无法清理
* 钉钉开放平台未暴露排班删除接口(仅提供 `schedule/listbyusers` 查询和
* `group/schedule/async` 写入)。`queryScheduleByUsers` 受限于 7 天窗口
* 和每次 50 个用户,且无配套删除能力,无法在同步前清理旧排班。
* 当前产品流程为"排课后手动同步钉钉",依赖运营人员知晓同步时机;
* 若后续需要自动清理,需等钉钉开放排班删除 API 或改用考勤组覆盖策略。
*
* ## API 调用优化
* - 班次列表、考勤组列表各只查询一次,在内存中按名称匹配,避免每次 findOrCreate 都发一次查询。
* - 排班写入按考勤组分批(钉钉单次最多 200 条)。
@@ -77,7 +90,9 @@ export class ScheduleSyncService {
const empty: ScheduleSyncResult = {
scheduleCount: 0, shiftCount: 0, groupCount: 0,
syncedItems: 0, skippedNoMapping: 0, groups: [],
syncedItems: 0, skippedNoMapping: 0,
failedBatchCount: 0, failedItems: 0, errors: [],
groups: [],
};
// ── Step 1: 查询活跃排课(必须关联到班级才能取学生) ──
@@ -93,23 +108,37 @@ export class ScheduleSyncService {
// ── Step 2: 班级 → 学生钉钉ID 映射 ──
const classIds = [...new Set(schedules.map((s) => s.classId as number))];
const classDingUsers = await this.buildClassDingUserMap(classIds);
const classNameMap = await this.loadClassNames(classIds);
// ── Step 3: 班次(按时间段去重,班次列表只查一次) ──
const shiftKey = (start: string, end: string) => `${start}-${end}`;
const uniqueShifts = new Map<string, { startTime: string; endTime: string }>();
for (const s of schedules) {
const key = shiftKey(s.startTime, s.endTime);
const shiftKey = (classId: number, start: string, end: string) =>
`${classId}|${start}-${end}`;
const uniqueShifts = new Map<
string,
{ className: string; startTime: string; endTime: string }
>();
const shiftScheduleCount = new Map<string, number>();
for (const schedule of schedules) {
const classId = schedule.classId as number;
const key = shiftKey(classId, schedule.startTime, schedule.endTime);
if (!uniqueShifts.has(key)) {
uniqueShifts.set(key, { startTime: s.startTime, endTime: s.endTime });
uniqueShifts.set(key, {
className: classNameMap.get(classId) || `班级${classId}`,
startTime: schedule.startTime,
endTime: schedule.endTime,
});
}
shiftScheduleCount.set(key, (shiftScheduleCount.get(key) || 0) + 1);
}
const existingShifts = await this.dingTalkService.queryShifts(opUserId);
const shiftByName = new Map(existingShifts.map((s) => [s.name, s.id]));
const timeToShiftId = new Map<string, number>();
const errors: string[] = [];
let failedBatchCount = 0;
let failedItems = 0;
let shiftCount = 0;
for (const [key, { startTime, endTime }] of uniqueShifts) {
const shiftName = `排课_${startTime}-${endTime}`;
for (const [key, { className, startTime, endTime }] of uniqueShifts) {
const shiftName = `${className}_${startTime}-${endTime}`;
try {
let shiftId = shiftByName.get(shiftName);
const shiftParams = {
@@ -133,7 +162,11 @@ export class ScheduleSyncService {
timeToShiftId.set(key, shiftId);
shiftCount++;
} catch (e) {
this.logger.error(`创建班次 ${shiftName} 失败: ${(e as Error).message}`);
const msg = `创建班次 ${shiftName} 失败: ${(e as Error).message}`;
this.logger.error(msg);
errors.push(msg);
failedBatchCount++;
failedItems += shiftScheduleCount.get(key) || 0;
}
}
@@ -142,7 +175,6 @@ export class ScheduleSyncService {
const groupByName = new Map(existingGroups.map((g) => [g.group_name, g.group_id]));
// ── Step 5: 按班级同步 ──
const classNameMap = await this.loadClassNames(classIds);
const schedulesByClass = new Map<number, ClassSchedule[]>();
for (const s of schedules) {
const cid = s.classId as number;
@@ -154,7 +186,6 @@ export class ScheduleSyncService {
let skippedNoMapping = 0;
let groupCount = 0;
const groupDetails: ScheduleSyncResult['groups'] = [];
for (const [classId, classSchedules] of schedulesByClass) {
const className = classNameMap.get(classId) || `班级${classId}`;
const dingUserIds = classDingUsers.get(classId) ?? [];
@@ -168,11 +199,21 @@ export class ScheduleSyncService {
// 该班级用到的班次
const classShiftIds = new Set<number>();
for (const s of classSchedules) {
const sid = timeToShiftId.get(shiftKey(s.startTime, s.endTime));
const sid = timeToShiftId.get(shiftKey(classId, s.startTime, s.endTime));
if (sid) classShiftIds.add(sid);
}
if (classShiftIds.size === 0) {
this.logger.warn(`班级 ${className} 无可用班次,跳过`);
this.logger.warn(`班级 ${className} 无可用班次,跳过(班次创建已计入 failure`);
continue;
}
// 先展开排班以计算受影响条数
const items = this.expandSchedules(
classSchedules, dingUserIds, timeToShiftId, startDate, endDate,
);
if (items.length === 0) {
this.logger.warn(`班级 ${className} 无可用班次匹配,跳过`);
skippedNoMapping += classSchedules.length;
continue;
}
@@ -205,16 +246,14 @@ export class ScheduleSyncService {
}
groupCount++;
} catch (e) {
this.logger.error(`创建考勤组 ${groupName} 失败: ${(e as Error).message}`);
skippedNoMapping += classSchedules.length;
const msg = `考勤组 ${groupName} 创建/更新失败: ${(e as Error).message}`;
this.logger.error(msg);
errors.push(msg);
failedBatchCount++;
failedItems += items.length;
continue;
}
// 展开为每个学生的每日排班
const items = this.expandSchedules(
classSchedules, dingUserIds, timeToShiftId, startDate, endDate,
);
// 批量写入单次≤200
let classItems = 0;
for (let i = 0; i < items.length; i += 200) {
@@ -224,7 +263,11 @@ export class ScheduleSyncService {
syncedItems += batch.length;
classItems += batch.length;
} catch (e) {
this.logger.error(`排班写入失败 (groupId=${attendanceGroupId}, offset=${i}): ${(e as Error).message}`);
const msg = `排班写入失败 (groupId=${attendanceGroupId}, batch=${Math.floor(i / 200) + 1}): ${(e as Error).message}`;
this.logger.error(msg);
failedBatchCount++;
failedItems += batch.length;
errors.push(msg);
}
}
@@ -233,7 +276,8 @@ export class ScheduleSyncService {
this.logger.log(
`排班同步完成: ${schedules.length} 条排课 → ${syncedItems} 条钉钉排班, ` +
`${shiftCount} 班次, ${groupCount} 考勤组, 跳过 ${skippedNoMapping} 条无映射`,
`${shiftCount} 班次, ${groupCount} 考勤组, 跳过 ${skippedNoMapping} 条无映射` +
(failedBatchCount > 0 ? `, ${failedBatchCount} 批写入失败 (${failedItems} 条)` : ''),
);
return {
@@ -242,6 +286,9 @@ export class ScheduleSyncService {
groupCount,
syncedItems,
skippedNoMapping,
failedBatchCount,
failedItems,
errors,
groups: groupDetails,
};
}
@@ -297,6 +344,7 @@ export class ScheduleSyncService {
syncFrom: string,
syncTo: string,
): DingTalkScheduleItem[] {
const seen = new Set<string>();
const items: DingTalkScheduleItem[] = [];
const fromDate = new Date(syncFrom);
const toDate = new Date(syncTo);
@@ -309,7 +357,7 @@ export class ScheduleSyncService {
}
for (const s of schedules) {
const shiftId = timeToShiftId.get(`${s.startTime}-${s.endTime}`);
const shiftId = timeToShiftId.get(`${s.classId}|${s.startTime}-${s.endTime}`);
if (!shiftId) continue;
const scheduleStart = s.startDate > syncFrom ? s.startDate : syncFrom;
@@ -321,6 +369,9 @@ export class ScheduleSyncService {
const workDate = new Date(dateStr + 'T00:00:00+08:00').getTime();
for (const userid of dingUserIds) {
const dedupKey = `${userid}|${workDate}|${shiftId}`;
if (seen.has(dedupKey)) continue;
seen.add(dedupKey);
items.push({ userid, work_date: workDate, shift_id: shiftId, is_rest: false });
}
}
@@ -329,6 +380,7 @@ export class ScheduleSyncService {
return items;
}
private minutesBetween(startTime: string, endTime: string): number {
const [startHour, startMinute] = startTime.split(':').map(Number);
const [endHour, endMinute] = endTime.split(':').map(Number);

View File

@@ -50,6 +50,21 @@ export class SyncController {
return { success: true, data: tree };
}
@Get('dingtalk/attendance-groups')
@RequirePermission('sync:read')
async getDingTalkAttendanceGroups() {
return { success: true, data: await this.syncService.getDingTalkAttendanceGroups() };
}
@Post('dingtalk/attendance-groups/delete-all')
@RequirePermission('sync:trigger')
async deleteAllDingTalkAttendanceGroups() {
return {
success: true,
data: await this.syncService.deleteAllDingTalkAttendanceGroups(),
};
}
@Get('logs')
@RequirePermission('sync:read')
async getLogs(

View File

@@ -98,6 +98,29 @@ export class SyncService {
return this.dingTalkService.fetchOrgTreeWithUsers(rootDeptId);
}
async getDingTalkAttendanceGroups() {
return this.dingTalkService.queryAttendanceGroups();
}
async deleteAllDingTalkAttendanceGroups() {
const groups = await this.dingTalkService.queryAttendanceGroups();
const deleted: Array<{ groupId: number; groupName: string }> = [];
const failed: Array<{ groupId: number; groupName: string; error: string }> = [];
for (const group of groups) {
try {
await this.dingTalkService.deleteAttendanceGroup(group.group_id);
deleted.push({ groupId: group.group_id, groupName: group.group_name });
} catch (error: unknown) {
failed.push({
groupId: group.group_id,
groupName: group.group_name,
error: error instanceof Error ? error.message : String(error),
});
}
}
return { total: groups.length, deleted, failed };
}
// ── 排班同步 ──
/** 将本地排课同步到钉钉考勤排班 */