feat(schedules,classisland,server,plugin,shared): 课表行加来源字段,插件上报可真正同步删除
课表行新增 Schedule.source(manual / classisland):插件每次全量上报时,服务端 只清理「source=classisland 且本次未再上报」的行。原先 merge 只做 upsert,老师在 ClassIsland 里删掉或挪走一节,服务端会永远留着那行幽灵课;现在能真正同步下来, 而老师在小助手这边手排的课(manual)一律不删。 配套改动: - 插件上报时带回 machineKey,服务端回填到设备记录(避开 [classId, deviceKey] 唯一约束); - 新增迁移 20260930120000_add_schedule_source,存量数据保守地按 manual 处理; - verify:classisland 补「幽灵行清理」「手排课不被误删」用例。 另:.gitignore 补上 .zcode/ 与 .zcodeignore(AI 会话产物,不属于产品代码)。
This commit is contained in:
1 parent
9c678b3730
commit
7ea0e0f7c9
17 files changed
+620
-44
No files matched your search
@@ -75,6 +75,12 @@ export const reportTimeLayoutItemSchema = z.object({
|
||||
export const classIslandReportSchema = z.object({
|
||||
pluginVersion: z.string().trim().max(40).optional(),
|
||||
classIslandVersion: z.string().trim().max(40).optional(),
|
||||
/**
|
||||
* 插件生成的机器码。设备创建时服务端还不知道它是谁,只能用 `pending-…` 占位,
|
||||
* 因此插件每次上报都带上它,服务端据此回填,Web 端设备列表才能与教室机器对上
|
||||
* (见 integrations.service 的 syncDeviceKey)。
|
||||
*/
|
||||
deviceKey: z.string().trim().max(64).optional(),
|
||||
state: z
|
||||
.object({
|
||||
inClass: z.boolean().optional(),
|
||||
|
||||
@@ -422,6 +422,9 @@ export async function applyReport(
|
||||
const now = new Date();
|
||||
const week = resolveCurrentWeek(env.termStartDate, now);
|
||||
|
||||
// 0) 机器码回填(设备创建时只能用 pending-… 占位,首次上报才知道它到底是哪台机器)
|
||||
if (input.deviceKey) await syncDeviceKey(device, input.deviceKey);
|
||||
|
||||
// 1) 状态快照(无论课表是否上报都更新,Web 端据此显示"现在上什么课")
|
||||
const state = input.state;
|
||||
await prisma.integrationDevice.update({
|
||||
@@ -518,6 +521,39 @@ export async function applyReport(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 把插件上报的机器码回填到设备记录。
|
||||
*
|
||||
* `IntegrationDevice` 上有 `@@unique([classId, deviceKey])`,因此要避开两个坑:
|
||||
* 1) 同一台机器在这个班已有另一条设备记录(例如管理员重复建过设备)时直接写会撞唯一约束 → 500,
|
||||
* 这里改为记 warning 并保留原值;
|
||||
* 2) 值没变时不必白写一次库。
|
||||
*/
|
||||
async function syncDeviceKey(device: DeviceContext, reportedKey: string): Promise<void> {
|
||||
const key = reportedKey.trim();
|
||||
if (!key) return;
|
||||
|
||||
const current = await prisma.integrationDevice.findUnique({
|
||||
where: { id: device.deviceId },
|
||||
select: { deviceKey: true },
|
||||
});
|
||||
if (!current || current.deviceKey === key) return;
|
||||
|
||||
const taken = await prisma.integrationDevice.findFirst({
|
||||
where: { classId: device.classId, deviceKey: key, id: { not: device.deviceId } },
|
||||
select: { name: true },
|
||||
});
|
||||
if (taken) {
|
||||
logger.warn(
|
||||
`ClassIsland 上报的机器码「${key}」已被本班设备「${taken.name}」占用,本次不回填(设备=${device.name})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.integrationDevice.update({ where: { id: device.deviceId }, data: { deviceKey: key } });
|
||||
logger.info(`ClassIsland 设备机器码已回填:${device.name} -> ${key}`);
|
||||
}
|
||||
|
||||
/** 该设备/该班级最近一条尚未确认、未过期的下发提醒 */
|
||||
async function pendingNotificationFor(
|
||||
deviceId: string,
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface ScheduleWriteResult {
|
||||
applied: boolean;
|
||||
created: number;
|
||||
updated: number;
|
||||
/** 被清理掉的旧同步行(老师已在 ClassIsland 里删除的课) */
|
||||
removed: number;
|
||||
courses: string[];
|
||||
weekStart: number;
|
||||
weekEnd: number;
|
||||
@@ -46,6 +48,7 @@ export async function applyReportedSchedule(
|
||||
applied: false,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
removed: 0,
|
||||
courses: [],
|
||||
weekStart: 1,
|
||||
weekEnd: 20,
|
||||
@@ -91,6 +94,8 @@ export async function applyReportedSchedule(
|
||||
// 3) 逐条 upsert:merge 的去重键是「星期 + 开始时间 + 单双周」
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
/** 本轮上报覆盖到的行 id:不在其中的 classisland 来源行会被清理(见第 4 步) */
|
||||
const keptIds = new Set<string>();
|
||||
for (const entry of entries) {
|
||||
const courseId = courseIdByName.get(entry.subject);
|
||||
if (!courseId) continue;
|
||||
@@ -102,8 +107,21 @@ export async function applyReportedSchedule(
|
||||
if (existing) {
|
||||
await tx.schedule.update({
|
||||
where: { id: existing.id },
|
||||
data: { courseId, endTime: entry.endTime, weekStart, weekEnd },
|
||||
data: {
|
||||
courseId,
|
||||
endTime: entry.endTime,
|
||||
// 不覆盖 weekStart / weekEnd:插件上报契约里没有周次范围,
|
||||
// 硬写"整学期"会把老师在 Web 端为选修/短期课设的「第 1~10 周」悄悄抹掉。
|
||||
// 只在新建时给默认值(见下面 create)。
|
||||
//
|
||||
// 同样**不要**把 source 改成 'classisland':merge 的去重键是「星期 + 开始时间 + 单双周」,
|
||||
// 老师在 Web 端手排的同一时间槽会被这里"收养"。一旦收养时改写来源,
|
||||
// 后续那次"清理插件侧已删除的课"就会把这行老师手排的课一起删掉
|
||||
// (真机上发生过:种子课表 93 行被删成 91 行,`verify:e2e` 的上课状态用例随之失败)。
|
||||
// 因此来源只在 create 时确定,update 永远保持原值。
|
||||
},
|
||||
});
|
||||
keptIds.add(existing.id);
|
||||
updated += 1;
|
||||
} else {
|
||||
await tx.schedule.create({
|
||||
@@ -117,22 +135,53 @@ export async function applyReportedSchedule(
|
||||
weekStart,
|
||||
weekEnd,
|
||||
weekParity,
|
||||
source: 'classisland',
|
||||
},
|
||||
});
|
||||
created += 1;
|
||||
}
|
||||
}
|
||||
return { created, updated, createdCourses, allCourses: [...courseIdByName.keys()] };
|
||||
|
||||
// 4) 清理"老师已在 ClassIsland 里删掉"的行。
|
||||
//
|
||||
// merge 模式原先只 upsert、从不删除:老师在 ClassIsland 删掉一节,或把 8:00 的课挪到 8:05,
|
||||
// 服务端那行会永远留着(幽灵课 / 同一天两节重复课)。但也不能照 replace 那样整表清空 ——
|
||||
// 那会连老师在 Web 端手排的课一起删。
|
||||
// 因此只清理 `source='classisland'`(即由上报同步产生)且本轮未再出现的行。
|
||||
let removed = 0;
|
||||
if (mode === 'merge') {
|
||||
const synced = await tx.schedule.findMany({
|
||||
where: { classId, source: 'classisland' },
|
||||
select: { id: true, dayOfWeek: true, startTime: true, weekParity: true },
|
||||
});
|
||||
const stale = synced.filter(
|
||||
(row) =>
|
||||
!keptIds.has(row.id) &&
|
||||
!entries.some(
|
||||
(entry) =>
|
||||
entry.dayOfWeek === row.dayOfWeek &&
|
||||
entry.startTime === row.startTime &&
|
||||
resolveParity(entry) === row.weekParity,
|
||||
),
|
||||
);
|
||||
if (stale.length > 0) {
|
||||
await tx.schedule.deleteMany({ where: { id: { in: stale.map((row) => row.id) } } });
|
||||
removed = stale.length;
|
||||
}
|
||||
}
|
||||
|
||||
return { created, updated, removed, createdCourses, allCourses: [...courseIdByName.keys()] };
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`ClassIsland 上报课表:班级=${classId} 模式=${mode} 新增=${result.created} 更新=${result.updated} ` +
|
||||
`补建科目=[${result.createdCourses.join(',')}]`,
|
||||
`清理=${result.removed} 补建科目=[${result.createdCourses.join(',')}]`,
|
||||
);
|
||||
return {
|
||||
applied: true,
|
||||
created: result.created,
|
||||
updated: result.updated,
|
||||
removed: result.removed,
|
||||
courses: result.allCourses,
|
||||
weekStart,
|
||||
weekEnd,
|
||||
|
||||
Reference in new issue
Block a user