Files
gongxue-base/apps/server/src/sync/schedule-sync.service.ts
wangziqi f50301148d fix(correctness): 并发/事务/实体/时区/状态一致性修复
由 OCR(open-codereview.ai,deepseek-v4-flash)审查驱动修复:
- wallets 原子扣款防 double-spend;refund 条件更新幂等;findTransactions 分页
- financial/imports/occupancies/attendance 事务与 advisory lock;重复生成/提交幂等
- 矛盾校验器、日期区间、实体双映射/DECIMAL/nullable、时区统一(china-time)
- rbac-seed 防重激活、exam 权限恢复、状态一致性、路由顺序、N+1/IN 分块等性能项

Reviewed-by: OCR (open-codereview.ai)
2026-08-09 21:29:54 +08:00

317 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ClassSchedule, ClassStudent, StudentDingMapping, Class } from '../entities';
import { DingTalkService } from '../integration/dingtalk.service';
import dayjs from '../common/dayjs';
import {
buildClassDingUserMap,
loadClassNames,
buildDailySchedulePlans,
expandDailySchedulePlans,
toMinutes,
minutesBetween,
addDays,
type DailySchedulePeriod,
type DailySchedulePlan,
type ScheduleSyncResult,
} from './schedule-sync.helpers';
@Injectable()
export class ScheduleSyncService {
private readonly logger = new Logger(ScheduleSyncService.name);
constructor(
@InjectRepository(ClassSchedule)
private readonly scheduleRepo: Repository<ClassSchedule>,
@InjectRepository(ClassStudent)
private readonly classStudentRepo: Repository<ClassStudent>,
@InjectRepository(StudentDingMapping)
private readonly mappingRepo: Repository<StudentDingMapping>,
@InjectRepository(Class)
private readonly classRepo: Repository<Class>,
private readonly dingTalkService: DingTalkService,
) {}
/**
* 全量同步:将所有活跃排课同步到钉钉排班。
* @param dateFrom 起始日期YYYY-MM-DD默认今天
* @param days 同步天数,默认 30
* @param opUserId 钉钉操作人 userId
* @param attendanceMachineOnly 是否关闭手机类打卡入口,仅使用考勤机
*/
async syncAll(
dateFrom?: string,
days = 30,
opUserId = 'manager',
attendanceMachineOnly = false,
): Promise<ScheduleSyncResult> {
const startDate = dateFrom || dayjs().utcOffset(8).format('YYYY-MM-DD');
const normalizedDays = Number.isFinite(days) ? Math.max(1, Math.floor(days)) : 30;
const endDate = addDays(startDate, normalizedDays - 1);
const empty: ScheduleSyncResult = {
scheduleCount: 0,
shiftCount: 0,
groupCount: 0,
syncedItems: 0,
skippedNoMapping: 0,
failedBatchCount: 0,
failedItems: 0,
errors: [],
groups: [],
};
// ── Step 1: 查询活跃排课(必须关联到班级才能取学生) ──
const allSchedules = await this.scheduleRepo.find({
where: { status: 'active' },
});
const schedules = allSchedules.filter((s) => s.classId != null);
if (schedules.length === 0) {
this.logger.log('没有需要同步的活跃排课(无关联班级)');
return empty;
}
// ── Step 2: 班级 → 学生钉钉ID 映射 ──
const classIds = [...new Set(schedules.map((s) => s.classId as number))];
const classDingUsers = await buildClassDingUserMap(this.classStudentRepo, this.mappingRepo, classIds);
const classNameMap = await loadClassNames(this.classRepo, classIds);
// 同名班级会串组:统计本次参与同步的班级名出现次数,
// 重名时在考勤组名/组标识中带上 classId 以避免共享同一个考勤组。
const classNameCounts = new Map<string, number>();
for (const name of classNameMap.values()) {
classNameCounts.set(name, (classNameCounts.get(name) || 0) + 1);
}
// ── Step 3: 将每天的多节课合并成一个钉钉班次 ──
// 钉钉要求每人每天只能写入一条排班,因此同一天的多节课必须作为
// 同一个班次的多个 sections 写入,不能拆成多条 schedule item。
const dailyPlans = buildDailySchedulePlans(schedules, startDate, endDate);
const uniqueShifts = new Map<
string,
{ className: string; periods: DailySchedulePeriod[] }
>();
const shiftPlanCount = new Map<string, number>();
for (const plan of dailyPlans) {
if (!uniqueShifts.has(plan.shiftKey)) {
uniqueShifts.set(plan.shiftKey, {
className: classNameMap.get(plan.classId) || `班级${plan.classId}`,
periods: plan.periods,
});
}
shiftPlanCount.set(plan.shiftKey, (shiftPlanCount.get(plan.shiftKey) || 0) + 1);
}
const existingShifts = await this.dingTalkService.queryShifts(opUserId);
const shiftByName = new Map(existingShifts.map((shift) => [shift.name, shift.id]));
const planToShiftId = new Map<string, number>();
const errors: string[] = [];
let failedBatchCount = 0;
let failedItems = 0;
let shiftCount = 0;
for (const [key, { className, periods }] of uniqueShifts) {
const periodLabel = periods
.map((period) => `${period.startTime}-${period.endTime}`)
.join('+');
const shiftName = `${className}_${periodLabel}`;
try {
let shiftId = shiftByName.get(shiftName);
const shiftParams = {
...(shiftId === undefined ? {} : { id: shiftId }),
name: shiftName,
owner: opUserId,
sections: periods.map((period) => ({
times: [
{
check_type: 'OnDuty' as const,
across: 0,
check_time: `1970-01-01 ${period.startTime}:00`,
free_check: false,
},
{
check_type: 'OffDuty' as const,
across: toMinutes(period.endTime) <= toMinutes(period.startTime) ? 1 : 0,
check_time: `1970-01-01 ${period.endTime}:00`,
free_check: false,
},
],
})),
setting: {
is_flexible: false,
serious_late_minutes: -1,
absenteeism_late_minutes: Math.max(
...periods.map((period) => minutesBetween(period.startTime, period.endTime)),
),
},
};
shiftId = await this.dingTalkService.upsertShift(shiftParams);
shiftByName.set(shiftName, shiftId);
planToShiftId.set(key, shiftId);
shiftCount++;
} catch (e) {
const msg = `创建班次 ${shiftName} 失败: ${(e as Error).message}`;
this.logger.error(msg);
errors.push(msg);
failedBatchCount++;
failedItems += shiftPlanCount.get(key) || 0;
}
}
// ── Step 4: 考勤组列表只查一次,供每个班级匹配 ──
const existingGroups = await this.dingTalkService.queryAttendanceGroups(opUserId);
const groupByName = new Map(existingGroups.map((g) => [g.group_name, g.group_id]));
// ── Step 5: 按班级同步 ──
const schedulesByClass = new Map<number, ClassSchedule[]>();
for (const schedule of schedules) {
const classId = schedule.classId as number;
if (!schedulesByClass.has(classId)) schedulesByClass.set(classId, []);
schedulesByClass.get(classId)!.push(schedule);
}
const dailyPlansByClass = new Map<number, DailySchedulePlan[]>();
for (const plan of dailyPlans) {
if (!dailyPlansByClass.has(plan.classId)) dailyPlansByClass.set(plan.classId, []);
dailyPlansByClass.get(plan.classId)!.push(plan);
}
let syncedItems = 0;
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) ?? [];
if (dingUserIds.length === 0) {
this.logger.warn(`班级 ${className} 无钉钉映射学生,跳过 ${classSchedules.length} 条排课`);
skippedNoMapping += classSchedules.length;
continue;
}
const classDailyPlans = dailyPlansByClass.get(classId) ?? [];
// 该班级在同步日期范围内用到的合并班次
const classShiftIds = new Set<number>();
for (const plan of classDailyPlans) {
const shiftId = planToShiftId.get(plan.shiftKey);
if (shiftId) classShiftIds.add(shiftId);
}
if (classShiftIds.size === 0) {
this.logger.warn(`班级 ${className} 无可用班次,跳过(班次创建已计入 failure`);
continue;
}
// 先展开排班以计算受影响条数
const items = expandDailySchedulePlans(classDailyPlans, dingUserIds, planToShiftId);
if (items.length === 0) {
this.logger.warn(`班级 ${className} 无可用班次匹配,跳过`);
skippedNoMapping += classSchedules.length;
continue;
}
// 创建/匹配该班级的考勤组;重名班级时追加 classId 后缀避免串组
const groupName =
(classNameCounts.get(className) ?? 0) > 1
? `排课_${classId}_${className}`
: `排课_${className}`;
let attendanceGroupId: number;
try {
const cached = groupByName.get(groupName);
const groupParams = {
name: groupName,
type: 'TURN' as const,
owner: opUserId,
members: dingUserIds.map((uid) => ({
role: 'Attendance',
type: 'StaffMember' as const,
user_id: uid,
})),
shift_ids: [...classShiftIds],
enable_emp_select_class: true,
disable_check_without_schedule: false,
disable_check_when_rest: true,
attendance_machine_only: attendanceMachineOnly,
};
if (cached !== undefined) {
attendanceGroupId = cached;
await this.dingTalkService.updateAttendanceGroup({
...groupParams,
id: attendanceGroupId,
});
} else {
attendanceGroupId = await this.dingTalkService.createAttendanceGroup(groupParams);
groupByName.set(groupName, attendanceGroupId);
}
groupCount++;
} catch (e) {
const msg = `考勤组 ${groupName} 创建/更新失败: ${(e as Error).message}`;
this.logger.error(msg);
errors.push(msg);
failedBatchCount++;
failedItems += items.length;
continue;
}
// 批量写入单次≤200
let classItems = 0;
for (let i = 0; i < items.length; i += 200) {
const batch = items.slice(i, i + 200);
try {
await this.dingTalkService.scheduleUsers(attendanceGroupId, batch, opUserId);
syncedItems += batch.length;
classItems += batch.length;
} catch (e) {
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);
}
}
groupDetails.push({ className, groupId: attendanceGroupId, itemCount: classItems });
}
this.logger.log(
`排班同步完成: ${schedules.length} 条排课 → ${syncedItems} 条钉钉排班, ` +
`${shiftCount} 班次, ${groupCount} 考勤组, 跳过 ${skippedNoMapping} 条无映射` +
(failedBatchCount > 0 ? `, ${failedBatchCount} 批写入失败 (${failedItems} 条)` : ''),
);
return {
scheduleCount: schedules.length,
shiftCount,
groupCount,
syncedItems,
skippedNoMapping,
failedBatchCount,
failedItems,
errors,
groups: groupDetails,
};
}
/**
* 构建 classId → 学生钉钉 userId 列表。
* 一次性查询所有班级的活跃学生与钉钉映射,避免 N+1。
*/
async getStatus(_targetDate: string): Promise<{
activeSchedules: number;
mappedClasses: number;
totalClasses: number;
}> {
const allSchedules = await this.scheduleRepo.find({ where: { status: 'active' } });
const schedules = allSchedules.filter((s) => s.classId != null);
const classIds = [...new Set(schedules.map((s) => s.classId as number))];
const classDingUsers = await buildClassDingUserMap(this.classStudentRepo, this.mappingRepo, classIds);
const mappedClasses = [...classDingUsers.values()].filter((u) => u.length > 0).length;
return {
activeSchedules: schedules.length,
mappedClasses,
totalClasses: classIds.length,
};
}
}