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
@@ -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';
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user