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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user