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:
LoveChengke committed 2026-10-02 00:24:58 +08:00
1 parent 9c678b3730
commit 7ea0e0f7c9
17 files changed
+620 -44

No files matched your search

@@ -0,0 +1,7 @@
-- 课表行来源:manual=手工建立(Web 端录入 / JSON 导入)/ classisland=ClassIsland 插件上报同步。
--
-- 用途:插件每次全量上报课表时,只清理「source=classisland 且本次未再上报」的行。
-- 这样"老师在 ClassIsland 里删掉一节"能被真正同步下来(原先 merge 只 upsert,
-- 服务端会永远留着那行幽灵课),同时老师在小助手这边手排的课不受影响。
-- 存量数据默认按 manual 处理(保守:宁可留下也不误删)。
ALTER TABLE "Schedule" ADD COLUMN "source" TEXT NOT NULL DEFAULT 'manual';
+7 -2
View File
@@ -146,8 +146,13 @@ model Schedule {
weekEnd Int @default(20)
/// 单双周:ALL=每周(默认)/ ODD=单周 / EVEN=双周(ClassIsland WeekCountDiv + WeekCountDivTotal 的映射)
weekParity String @default("ALL")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
/// 该行的来源:manual=手工建立(Web 端录入 / ClassIsland JSON 导入)
/// / classisland=插件上报同步得到。
/// 插件每次全量上报时只清理「classisland 来源且本次未再上报」的行(解决"老师在 ClassIsland
/// 里删掉一节、服务端却永远留着"的幽灵课),老师手排的课一律不删。
source String @default("manual")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
@@ -466,6 +466,109 @@ async function main() {
`上课点=${classPoints.length} 条目=${(plan?.entries ?? []).length}`,
);
/* ---------------------------------------------------------------- 5.1 课表同步:幽灵行清理 + 机器码回填 */
// 老师在 Web 端手排的一节课(source=manual):插件同步时**绝不能**删它
const courseList = await api(`/courses?classId=${target.id}`, { token: teacherToken });
const courseId = (courseList.payload?.data ?? [])[0]?.id;
const manualCreated = await api('/schedules', {
method: 'POST',
token: teacherToken,
body: {
classId: target.id,
courseId,
dayOfWeek: 7,
startTime: '21:00',
endTime: '21:45',
weekStart: 1,
weekEnd: 20,
weekParity: 'ALL',
},
});
const manualScheduleId = manualCreated.payload?.data?.id;
record(
'(准备)Web 端手排一节课(source=manual)',
manualCreated.status === 201 && Boolean(manualScheduleId),
`status=${manualCreated.status}`,
);
const entry = (startTime, endTime) => ({
dayOfWeek: 6,
startTime,
endTime,
subject: '语文',
weekParity: 'ALL',
});
/** 该班课表里"周六 19:xx"的行(插件上报写入的那些) */
const listSaturday = async () => {
const list = await api(`/schedules?classId=${target.id}`, { token: teacherToken });
return (list.payload?.data ?? []).filter(
(item) => item.dayOfWeek === 6 && item.startTime.startsWith('19:'),
);
};
// 第一次上报:两节(第二节随后会被"老师在 ClassIsland 里删掉")
const ghostReport = await api('/integrations/classisland/report', {
method: 'POST',
deviceToken,
body: {
deviceKey: 'smoke-machine-01',
schedule: { mode: 'merge', entries: [entry('19:00', '19:45'), entry('19:50', '20:35')] },
},
});
record(
'插件上报两节课(其中一节随后会被删除)',
ghostReport.payload?.data?.scheduleApplied === true,
`status=${ghostReport.status}`,
);
const beforePrune = await listSaturday();
record('上报后两节都在库里', beforePrune.length === 2, `周六 19 点=${beforePrune.length} 节`);
// 第二次上报:只报第二节 → 第一节应当被清理。
// 原先 merge 只 upsert、从不删除,老师删掉的课会永远留在服务端(幽灵课),
// 学生端与 Web 端会一直显示一节实际上已经不上的课。
const pruneReport = await api('/integrations/classisland/report', {
method: 'POST',
deviceToken,
body: {
deviceKey: 'smoke-machine-01',
schedule: { mode: 'merge', entries: [entry('19:50', '20:35')] },
},
});
const afterPrune = await listSaturday();
record(
'插件侧删除的课被同步清理(不再留幽灵课)',
pruneReport.payload?.data?.scheduleApplied === true &&
afterPrune.length === 1 &&
afterPrune[0]?.startTime === '19:50',
`周六 19 点=${afterPrune.map((item) => item.startTime).join(',') || '空'}`,
);
// 只清理"插件同步出来的行":老师手排的课必须活着
const scheduleList = await api(`/schedules?classId=${target.id}`, { token: teacherToken });
const manualAlive = (scheduleList.payload?.data ?? []).some((item) => item.id === manualScheduleId);
record('Web 端手排的课不被插件同步删除', manualAlive, `手排行 id=${manualScheduleId ?? '-'}`);
// 机器码回填:设备列表里不再是 pending-…
const deviceList = await api(`/integrations/devices?classId=${target.id}`, { token: teacherToken });
const backfilledKey =
(deviceList.payload?.data ?? []).find((item) => item.id === deviceId)?.deviceKey ?? '';
record(
'插件上报的机器码已回填到设备记录',
backfilledKey === 'smoke-machine-01',
`deviceKey=${backfilledKey}`,
);
// 清理:把手排的课与本次上报写进去的课都删掉,避免污染演示数据
if (manualScheduleId) {
await api(`/schedules/${manualScheduleId}`, { method: 'DELETE', token: teacherToken });
}
for (const row of await listSaturday()) {
await api(`/schedules/${row.id}`, { method: 'DELETE', token: teacherToken });
}
record('清理:本次验证写入的课表与手排课已删除', (await listSaturday()).length === 0, '周六 19 点已清空');
// ---------------------------------------------------------------- 6. 停用设备
await api(`/integrations/devices/${deviceId}`, {
method: 'PATCH',
@@ -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,