feat(integrations,classisland,server,web,desktop): ClassIsland 联动插件——上报课表与上课状态、下发提醒并回执、教室端推送通道
教室机器上的 ClassIsland 现在可以直接和班级小助手互通:插件走 /api/integrations/classisland/*,用设备令牌(X-ClassIsland-Token,库里只存 sha256)独立鉴权,不占用 JWT 通道。 - 服务端:新增 integrations 模块(上报 / 待弹出 / 回执 / 课表镜像 / 回写),两个迁移(设备令牌表、Class.notificationChannel);通知、叫人、联动页下发统一复用 pushToClassIsland,避免"某个页面发的通知不联动" - 插件:packages/classisland-plugin(.NET 8,独立于 pnpm workspace)——BridgeService 上报与提醒轮询、ScheduleMapper/ClassPlanWriter 课表镜像、Avalonia 设置页、ClassHelperNotificationProvider 在 ClassIsland 上弹出提醒;本地/内网地址强制直连,躲开系统代理返回的假 404 - 客户端:设置页可选提醒通道(both / client / classisland),并订阅相应实时事件 - Web 端:新增「ClassIsland 联动」页(设备管理与提醒下发) - 校验与打包:verify:classisland(需后端)、verify:classisland-plugin(静态契约 54 项)、build/dist:classisland-plugin 产出 .cipx - 文档:README 与 AGENTS.md 同步新增联动说明,docs/screenshots/classisland 留三张真机截图 门禁:pnpm lint 0 error、pnpm typecheck 四包通过、verify:classisland-plugin 54/54 通过。运行期仍需在装了 ClassIsland 的机器上实测(步骤见 AGENTS.md §8)。
This commit is contained in:
1 parent
fe41ebb7d6
commit
d0830f6830
61 files changed
+7026
-93
No files matched your search
@@ -18,7 +18,8 @@
|
||||
"db:seed": "prisma db seed",
|
||||
"db:reset": "prisma migrate reset --force",
|
||||
"db:studio": "prisma studio",
|
||||
"verify:e2e": "node scripts/verify-e2e.mjs"
|
||||
"verify:e2e": "node scripts/verify-e2e.mjs",
|
||||
"verify:classisland": "node scripts/verify-classisland.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@classhelper/shared": "workspace:*",
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
-- ClassIsland 联动:设备接入 + 提醒下发
|
||||
--
|
||||
-- 说明:
|
||||
-- 1) IntegrationDevice 保存「班级 ↔ 一台装有联动插件的机器」的绑定关系,
|
||||
-- 令牌只存 sha256 哈希,明文仅在创建/重置接口返回一次。
|
||||
-- 2) 同一班级内 deviceKey 唯一:插件重装后带同一机器码会重绑原记录,避免设备列表堆重复项。
|
||||
-- 3) ClassIslandPush 落库是为了「设备离线时通知不丢」:插件重连后拉取未确认的一条立即弹出。
|
||||
-- 4) 本文件重复执行是安全的(逐条执行、忽略"已存在"错误)。
|
||||
|
||||
CREATE TABLE "IntegrationDevice" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"classId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"deviceKey" TEXT NOT NULL,
|
||||
"mode" TEXT NOT NULL DEFAULT 'plugin',
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"tokenHint" TEXT NOT NULL,
|
||||
"syncScheduleToServer" BOOLEAN NOT NULL DEFAULT true,
|
||||
"mirrorScheduleToClassIsland" BOOLEAN NOT NULL DEFAULT false,
|
||||
"classIslandVersion" TEXT,
|
||||
"pluginVersion" TEXT,
|
||||
"lastSeenAt" DATETIME,
|
||||
"classPlanLoaded" BOOLEAN NOT NULL DEFAULT false,
|
||||
"currentSubject" TEXT,
|
||||
"currentTimeState" TEXT,
|
||||
"currentPeriodStart" TEXT,
|
||||
"currentPeriodEnd" TEXT,
|
||||
"nextSubject" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "IntegrationDevice_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "IntegrationDevice_classId_deviceKey_key" ON "IntegrationDevice"("classId", "deviceKey");
|
||||
CREATE INDEX "IntegrationDevice_classId_idx" ON "IntegrationDevice"("classId");
|
||||
CREATE INDEX "IntegrationDevice_tokenHash_idx" ON "IntegrationDevice"("tokenHash");
|
||||
|
||||
CREATE TABLE "ClassIslandPush" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"classId" TEXT NOT NULL,
|
||||
"deviceId" TEXT,
|
||||
"title" TEXT NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"durationSeconds" INTEGER NOT NULL DEFAULT 8,
|
||||
"speechContent" TEXT,
|
||||
"urgent" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdBy" TEXT,
|
||||
"notificationId" TEXT,
|
||||
"ackedAt" DATETIME,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expiresAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "ClassIslandPush_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "ClassIslandPush_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "IntegrationDevice" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX "ClassIslandPush_classId_createdAt_idx" ON "ClassIslandPush"("classId", "createdAt");
|
||||
CREATE INDEX "ClassIslandPush_deviceId_ackedAt_idx" ON "ClassIslandPush"("deviceId", "ackedAt");
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
-- 通知显示位置(由教室的班级客户端设置):both / client / classisland
|
||||
-- 旧数据默认 both:行为与之前一致(客户端照常收,同时推给 ClassIsland)
|
||||
ALTER TABLE "Class" ADD COLUMN "notificationChannel" TEXT NOT NULL DEFAULT 'both';
|
||||
@@ -60,6 +60,9 @@ model Class {
|
||||
passwordHash String?
|
||||
/// 本学期教学周数(班主任可调,默认 20):课表周次选择与 weekEnd 默认值都用它
|
||||
termWeeks Int @default(20)
|
||||
/// 通知显示位置(由教室的班级客户端在设置页里选):
|
||||
/// both=客户端与 ClassIsland 都弹 / client=只弹客户端 / classisland=只弹 ClassIsland
|
||||
notificationChannel String @default("both")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -73,6 +76,8 @@ model Class {
|
||||
grades Grade[]
|
||||
enrollments Enrollment[]
|
||||
timeLayouts TimeLayout[]
|
||||
integrationDevices IntegrationDevice[]
|
||||
classIslandPushes ClassIslandPush[]
|
||||
|
||||
@@index([teacherId])
|
||||
}
|
||||
@@ -262,3 +267,80 @@ model Grade {
|
||||
@@index([classId, examName])
|
||||
@@index([courseId])
|
||||
}
|
||||
|
||||
/// ClassIsland 联动设备(一台装有联动插件的机器 = 某个班级的接入端)
|
||||
///
|
||||
/// 说明:
|
||||
/// 1) 令牌是「设备级」的,不是用户级:插件在本机保存明文,服务端只存 sha256 哈希 + 前缀提示;
|
||||
/// 2) 同一班级下按 deviceKey(机器码)唯一:插件重装后带同一 deviceKey 会自动重绑到原记录,
|
||||
/// 避免设备列表里堆满重复项;
|
||||
/// 3) 状态字段是「最后一次上报的快照」,Web 端据此展示"某班现在上什么课"。
|
||||
model IntegrationDevice {
|
||||
id String @id @default(cuid())
|
||||
classId String
|
||||
/// 设备显示名(默认取机器名)
|
||||
name String
|
||||
/// 设备唯一标识(插件上报的机器码)
|
||||
deviceKey String
|
||||
/// 联动方式:plugin=实时插件联动 / import=仅一次性导入
|
||||
mode String @default("plugin")
|
||||
/// 是否接入(关闭后插件上报与下发一律 403)
|
||||
enabled Boolean @default(true)
|
||||
/// 设备令牌的 sha256(明文只在创建/重置时返回一次)
|
||||
tokenHash String
|
||||
/// 令牌前缀(chci_xxxxxxxx),设备列表里用于人眼识别与排查
|
||||
tokenHint String
|
||||
/// 是否允许插件把 ClassIsland 的课表回传到本服务
|
||||
syncScheduleToServer Boolean @default(true)
|
||||
/// 是否允许把本班课表镜像回 ClassIsland(由插件执行)
|
||||
mirrorScheduleToClassIsland Boolean @default(false)
|
||||
classIslandVersion String?
|
||||
pluginVersion String?
|
||||
lastSeenAt DateTime?
|
||||
/// 以下为最后一次上报的快照
|
||||
classPlanLoaded Boolean @default(false)
|
||||
currentSubject String?
|
||||
currentTimeState String?
|
||||
currentPeriodStart String?
|
||||
currentPeriodEnd String?
|
||||
nextSubject String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
|
||||
pushes ClassIslandPush[]
|
||||
|
||||
@@unique([classId, deviceKey])
|
||||
@@index([classId])
|
||||
@@index([tokenHash])
|
||||
}
|
||||
|
||||
/// 下发给 ClassIsland 的提醒:教师发通知 → 学生机器全屏弹出。
|
||||
/// 落库是为了「设备离线时通知不丢」——插件重连后拉取未确认的一条立即弹出。
|
||||
model ClassIslandPush {
|
||||
id String @id @default(cuid())
|
||||
classId String
|
||||
deviceId String?
|
||||
title String
|
||||
content String
|
||||
/// 显示时长(秒):映射到 ClassIsland 的 NotificationContent.Duration
|
||||
durationSeconds Int @default(8)
|
||||
/// 语音朗读内容;null = 不朗读
|
||||
speechContent String?
|
||||
urgent Boolean @default(false)
|
||||
/// 发起人的用户 id(教师/管理员)
|
||||
createdBy String?
|
||||
/// 同时落库到通知中心的通知 id(saveToNotifications 时写入)
|
||||
notificationId String?
|
||||
/// 插件确认弹出的时间(确认后不再重复下发)
|
||||
ackedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
/// 过期时间:超过后不再补发(避免学生几天后突然弹出旧提醒)
|
||||
expiresAt DateTime
|
||||
|
||||
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
|
||||
device IntegrationDevice? @relation(fields: [deviceId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([classId, createdAt])
|
||||
@@index([deviceId, ackedAt])
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ClassIsland 联动验证(插件侧接口 + 下发链路)。
|
||||
*
|
||||
* 覆盖范围:
|
||||
* 1) 设备令牌鉴权:无令牌 / 错令牌一律 401,令牌作废后立即失效
|
||||
* 2) 插件上报 → 服务端写库(课表 + 节次时间 + 状态快照)幂等
|
||||
* 3) 教师下发提醒 → 落库 → 插件 pending 拉到 → ack 后不再补发
|
||||
* 4) 停用设备后插件上报被 403 拦下
|
||||
* 5) 课表镜像拉取(mirrorScheduleToClassIsland)返回 ClassIsland 口径的数据
|
||||
*
|
||||
* 用法:
|
||||
* 1) 另开终端启动后端:pnpm dev:server
|
||||
* 2) 执行:node packages/server/scripts/verify-classisland.mjs
|
||||
*
|
||||
* 环境变量:
|
||||
* VERIFY_BASE_URL 默认 http://127.0.0.1:4000(脚本直连,不受系统代理影响)
|
||||
*
|
||||
* 注意:本脚本只操作「新建设备 + 该设备对应班级」的数据,
|
||||
* 结束时会把新建的设备删除;上报的课表会写进该班(与导入模块语义一致,可被覆盖)。
|
||||
*/
|
||||
const BASE_URL = process.env.VERIFY_BASE_URL ?? `http://127.0.0.1:${process.env.PORT ?? 4000}`;
|
||||
|
||||
const results = [];
|
||||
|
||||
function record(name, ok, detail = '') {
|
||||
results.push({ name, ok, detail });
|
||||
console.log(`${ok ? '\u2713' : '\u2717'} ${name}${detail ? ` (${detail})` : ''}`);
|
||||
}
|
||||
|
||||
async function api(path, { method = 'GET', token, deviceToken, body } = {}) {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
if (deviceToken) headers['X-ClassIsland-Token'] = deviceToken;
|
||||
const response = await fetch(`${BASE_URL}/api${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
return { status: response.status, payload };
|
||||
}
|
||||
|
||||
/** 造一份插件会上报的课表(周一/周二,含单双周) */
|
||||
function sampleSchedule() {
|
||||
return {
|
||||
mode: 'merge',
|
||||
entries: [
|
||||
{
|
||||
dayOfWeek: 1,
|
||||
startTime: '08:00',
|
||||
endTime: '08:45',
|
||||
subject: '语文',
|
||||
teacherName: '王老师',
|
||||
weekParity: 'ALL',
|
||||
weekCountDiv: 0,
|
||||
weekCountDivTotal: 0,
|
||||
planName: '周一课表',
|
||||
},
|
||||
{
|
||||
dayOfWeek: 1,
|
||||
startTime: '08:55',
|
||||
endTime: '09:40',
|
||||
subject: '数学',
|
||||
teacherName: '李老师',
|
||||
weekParity: 'ODD',
|
||||
weekCountDiv: 1,
|
||||
weekCountDivTotal: 2,
|
||||
planName: '周一课表',
|
||||
},
|
||||
{
|
||||
dayOfWeek: 2,
|
||||
startTime: '08:00',
|
||||
endTime: '08:45',
|
||||
subject: '英语',
|
||||
teacherName: '张老师',
|
||||
weekParity: 'ALL',
|
||||
weekCountDiv: 0,
|
||||
weekCountDivTotal: 0,
|
||||
planName: '周二课表',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function sampleTimeLayout() {
|
||||
return {
|
||||
name: 'ClassIsland 验证时间表',
|
||||
mode: 'replace',
|
||||
items: [
|
||||
{ index: 1, name: '第 1 节', startTime: '08:00', endTime: '08:45', type: 'class', skipped: false },
|
||||
{ index: 2, name: '课间', startTime: '08:45', endTime: '08:55', type: 'break', skipped: false },
|
||||
{ index: 3, name: '第 2 节', startTime: '08:55', endTime: '09:40', type: 'class', skipped: false },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** 按候选密码依次登录,返回可用 token(不同环境的默认密码可能不同) */
|
||||
async function loginWithCandidates(username, passwords) {
|
||||
for (const password of passwords) {
|
||||
const result = await api('/auth/login', { method: 'POST', body: { username, password } });
|
||||
const token = result.payload?.data?.token;
|
||||
if (token) return token;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`\n=== ClassIsland 联动验证(${BASE_URL})===\n`);
|
||||
|
||||
const health = await api('/health');
|
||||
if (health.status !== 200) {
|
||||
console.log('后端未启动,请先运行 pnpm dev:server\n');
|
||||
process.exit(1);
|
||||
}
|
||||
const modules = health.payload?.data?.modules ?? [];
|
||||
record('integrations 模块已挂载', modules.includes('integrations'), modules.join(', '));
|
||||
if (!modules.includes('integrations')) process.exit(1);
|
||||
|
||||
// ---------------------------------------------------------------- 登录
|
||||
// 演示库里的教师密码由 .env 的 DEFAULT_TEACHER_PASSWORD 决定(种子脚本用它建号),
|
||||
// 因此这里按候选列表尝试,避免「换了环境就整脚本失败」。
|
||||
const teacherToken = await loginWithCandidates('teacher1', ['teacher123', '123456']);
|
||||
record('教师登录 teacher1', Boolean(teacherToken));
|
||||
if (!teacherToken) process.exit(1);
|
||||
|
||||
// 管理员用于取班级码(只有管理员/班主任能看到班级码),学生隔离用例需要它
|
||||
const adminToken = await loginWithCandidates('admin', ['admin123']);
|
||||
|
||||
const classes = await api('/classes', { token: teacherToken });
|
||||
const target = (classes.payload?.data ?? [])[0];
|
||||
record('取到可管理的班级', Boolean(target?.id), target ? `${target.name} (${target.id})` : '无');
|
||||
if (!target?.id) process.exit(1);
|
||||
|
||||
// 叫人用例需要一位学生;班级账号用来验证"教室客户端自己选显示位置"
|
||||
const students = await api(`/classes/${target.id}/students`, { token: teacherToken });
|
||||
const firstStudent = (students.payload?.data ?? [])[0];
|
||||
|
||||
/** 用班级码 + 班级密码登录,拿班级账号 token(教室机器的身份) */
|
||||
async function classLoginToken() {
|
||||
if (adminToken) {
|
||||
const detail = await api(`/classes/${target.id}`, { token: adminToken });
|
||||
const code = detail.payload?.data?.code;
|
||||
if (code) {
|
||||
for (const password of ['123456', 'class123']) {
|
||||
const result = await api('/auth/class-login', {
|
||||
method: 'POST',
|
||||
body: { code, password },
|
||||
});
|
||||
const token = result.payload?.data?.token;
|
||||
if (token) return token;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 1. 新建设备
|
||||
const created = await api('/integrations/devices', {
|
||||
method: 'POST',
|
||||
token: teacherToken,
|
||||
body: { classId: target.id, name: '验证用设备', syncScheduleToServer: true },
|
||||
});
|
||||
const deviceToken = created.payload?.data?.token;
|
||||
const deviceId = created.payload?.data?.device?.id;
|
||||
record(
|
||||
'新建设备并返回设备令牌',
|
||||
created.status === 201 && typeof deviceToken === 'string' && deviceToken.startsWith('chci_'),
|
||||
`status=${created.status} hint=${created.payload?.data?.device?.tokenHint ?? '-'}`,
|
||||
);
|
||||
if (!deviceToken || !deviceId) process.exit(1);
|
||||
|
||||
// ---------------------------------------------------------------- 2. 令牌鉴权
|
||||
const noToken = await api('/integrations/classisland/report', { method: 'POST', body: {} });
|
||||
record('无令牌上报被拒(401)', noToken.status === 401, `status=${noToken.status}`);
|
||||
const badToken = await api('/integrations/classisland/report', {
|
||||
method: 'POST',
|
||||
deviceToken: 'chci_deadbeef',
|
||||
body: {},
|
||||
});
|
||||
record('错误令牌上报被拒(401)', badToken.status === 401, `status=${badToken.status}`);
|
||||
|
||||
// ---------------------------------------------------------------- 3. 上报(状态 + 课表 + 节次)
|
||||
const reportBody = {
|
||||
pluginVersion: '0.1.0.0',
|
||||
classIslandVersion: '2.1.0.0',
|
||||
state: {
|
||||
inClass: true,
|
||||
subject: '语文',
|
||||
nextSubject: '数学',
|
||||
timeState: 'OnClass',
|
||||
periodStart: '08:00',
|
||||
periodEnd: '08:45',
|
||||
week: 1,
|
||||
classPlanLoaded: true,
|
||||
clientTime: new Date().toISOString(),
|
||||
},
|
||||
schedule: sampleSchedule(),
|
||||
timeLayout: sampleTimeLayout(),
|
||||
};
|
||||
const report1 = await api('/integrations/classisland/report', {
|
||||
method: 'POST',
|
||||
deviceToken,
|
||||
body: reportBody,
|
||||
});
|
||||
const r1 = report1.payload?.data;
|
||||
record(
|
||||
'插件上报课表写入成功',
|
||||
report1.status === 200 && r1?.scheduleApplied === true && r1.scheduleCreated + r1.scheduleUpdated === 3,
|
||||
`新增=${r1?.scheduleCreated} 更新=${r1?.scheduleUpdated} 周=${r1?.week}`,
|
||||
);
|
||||
record('插件上报节次时间写入成功', r1?.timeLayoutApplied === true, `applied=${r1?.timeLayoutApplied}`);
|
||||
record('上报回传服务端周次与时间', typeof r1?.serverTime === 'string' && Number.isInteger(r1?.week));
|
||||
|
||||
// 幂等:重复上报不应产生重复行
|
||||
const report2 = await api('/integrations/classisland/report', {
|
||||
method: 'POST',
|
||||
deviceToken,
|
||||
body: reportBody,
|
||||
});
|
||||
const r2 = report2.payload?.data;
|
||||
record(
|
||||
'重复上报幂等(不新增、只更新)',
|
||||
r2?.scheduleCreated === 0 && r2?.scheduleUpdated === 3,
|
||||
`新增=${r2?.scheduleCreated} 更新=${r2?.scheduleUpdated}`,
|
||||
);
|
||||
|
||||
// 设备列表里的状态快照
|
||||
const devices = await api(`/integrations/devices?classId=${target.id}`, { token: teacherToken });
|
||||
const listed = (devices.payload?.data ?? []).find((item) => item.id === deviceId);
|
||||
record(
|
||||
'设备状态快照已更新(Web 端可展示)',
|
||||
Boolean(listed?.lastSeenAt) &&
|
||||
listed?.currentSubject === '语文' &&
|
||||
listed?.currentTimeState === 'OnClass',
|
||||
`科目=${listed?.currentSubject} 状态=${listed?.currentTimeState} ClassIsland=${listed?.classIslandVersion}`,
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------- 4. 下发提醒
|
||||
const notify = await api('/integrations/classisland/notify', {
|
||||
method: 'POST',
|
||||
token: teacherToken,
|
||||
body: {
|
||||
classId: target.id,
|
||||
title: '请到讲台领资料',
|
||||
content: '下课后来讲台领取期中复习资料',
|
||||
durationSeconds: 12,
|
||||
speech: true,
|
||||
},
|
||||
});
|
||||
const n1 = notify.payload?.data;
|
||||
record(
|
||||
'教师下发提醒(落库 + 标记设备数)',
|
||||
notify.status === 200 && n1?.targetCount >= 1 && Boolean(n1?.notificationId),
|
||||
`设备数=${n1?.targetCount} 通知id=${n1?.notificationId ? '有' : '无'}`,
|
||||
);
|
||||
|
||||
const pending = await api('/integrations/classisland/pending', { deviceToken });
|
||||
const items = pending.payload?.data?.notifications ?? [];
|
||||
const picked = items.find((item) => item.title === '请到讲台领资料');
|
||||
record(
|
||||
'插件拉取到待提醒(离线补齐链路可用)',
|
||||
pending.status === 200 && Boolean(picked),
|
||||
`待提醒数=${items.length} 时长=${picked?.durationSeconds}s 朗读=${Boolean(picked?.speechContent)}`,
|
||||
);
|
||||
|
||||
if (picked) {
|
||||
const ack = await api('/integrations/classisland/ack', {
|
||||
method: 'POST',
|
||||
deviceToken,
|
||||
body: { id: picked.id },
|
||||
});
|
||||
record(
|
||||
'插件确认提醒已弹出',
|
||||
ack.status === 200 && ack.payload?.data?.acked === true,
|
||||
`status=${ack.status}`,
|
||||
);
|
||||
const pending2 = await api('/integrations/classisland/pending', { deviceToken });
|
||||
const again = (pending2.payload?.data?.notifications ?? []).some((item) => item.id === picked.id);
|
||||
record('确认后不再补发(避免重复弹窗)', !again, `仍在待提醒中=${again}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 4.5 其他发布入口也会联动 ClassIsland
|
||||
// 需求:"在其他页面发通知也可以联动 ClassIsland"。通知发布页 / 叫人走的是各自的接口,
|
||||
// 这里逐个验证它们真的会生成待弹出提醒(而不是只有联动页那个专用接口才联动)。
|
||||
const classChannel = await api(`/classes/${target.id}/notification-channel`, { token: teacherToken });
|
||||
record(
|
||||
'班级有"通知显示位置"(默认两端都弹)',
|
||||
classChannel.status === 200 && classChannel.payload?.data?.notificationChannel === 'both',
|
||||
`channel=${classChannel.payload?.data?.notificationChannel}`,
|
||||
);
|
||||
|
||||
const published = await api('/notifications', {
|
||||
method: 'POST',
|
||||
token: teacherToken,
|
||||
body: { classId: target.id, title: '通知页联动验证', content: '从通知发布页发的通知', priority: 'NORMAL' },
|
||||
});
|
||||
const pendingAfterPublish = await api('/integrations/classisland/pending', { deviceToken });
|
||||
const publishPush = (pendingAfterPublish.payload?.data?.notifications ?? []).find(
|
||||
(item) => item.title === '通知页联动验证',
|
||||
);
|
||||
record(
|
||||
'通知发布页发的通知也会推送到 ClassIsland',
|
||||
published.status === 201 && Boolean(publishPush),
|
||||
`status=${published.status} 待提醒=${(pendingAfterPublish.payload?.data?.notifications ?? []).length}`,
|
||||
);
|
||||
if (publishPush) {
|
||||
await api('/integrations/classisland/ack', {
|
||||
method: 'POST',
|
||||
deviceToken,
|
||||
body: { id: publishPush.id },
|
||||
});
|
||||
}
|
||||
|
||||
const call = await api('/calls', {
|
||||
method: 'POST',
|
||||
token: teacherToken,
|
||||
body: { classId: target.id, studentId: firstStudent?.id, message: '叫人联动验证' },
|
||||
});
|
||||
const pendingAfterCall = await api('/integrations/classisland/pending', { deviceToken });
|
||||
const callPush = (pendingAfterCall.payload?.data?.notifications ?? []).find(
|
||||
(item) => item.content === '叫人联动验证',
|
||||
);
|
||||
record(
|
||||
'叫人也会推送到 ClassIsland',
|
||||
Boolean(firstStudent) && call.status === 201 && Boolean(callPush),
|
||||
firstStudent ? `status=${call.status}` : '班级里没有学生,跳过',
|
||||
);
|
||||
if (callPush) {
|
||||
await api('/integrations/classisland/ack', {
|
||||
method: 'POST',
|
||||
deviceToken,
|
||||
body: { id: callPush.id },
|
||||
});
|
||||
}
|
||||
|
||||
// 教室在客户端里选"只在 ClassHelper 客户端显示"后,任何入口都不该再打扰 ClassIsland
|
||||
const setClient = await api(`/classes/${target.id}/notification-channel`, {
|
||||
method: 'PATCH',
|
||||
token: teacherToken,
|
||||
body: { notificationChannel: 'client' },
|
||||
});
|
||||
record(
|
||||
'可把"通知显示位置"改成只在客户端显示',
|
||||
setClient.status === 200 && setClient.payload?.data?.notificationChannel === 'client',
|
||||
`channel=${setClient.payload?.data?.notificationChannel}`,
|
||||
);
|
||||
|
||||
const mutedPublish = await api('/notifications', {
|
||||
method: 'POST',
|
||||
token: teacherToken,
|
||||
body: { classId: target.id, title: '不该上 ClassIsland 的通知', content: '教室选择了只在客户端显示' },
|
||||
});
|
||||
const mutedNotify = await api('/integrations/classisland/notify', {
|
||||
method: 'POST',
|
||||
token: teacherToken,
|
||||
body: { classId: target.id, title: '联动页也不该下发', content: '同样被教室的设置拦下' },
|
||||
});
|
||||
const pendingMuted = await api('/integrations/classisland/pending', { deviceToken });
|
||||
const mutedItems = pendingMuted.payload?.data?.notifications ?? [];
|
||||
record(
|
||||
'显示位置=client 时通知不再推送 ClassIsland',
|
||||
mutedPublish.status === 201 && !mutedItems.some((item) => item.title === '不该上 ClassIsland 的通知'),
|
||||
`待提醒=${mutedItems.length}`,
|
||||
);
|
||||
record(
|
||||
'显示位置=client 时联动页的下发也会被跳过(并说明原因)',
|
||||
mutedNotify.status === 200 &&
|
||||
mutedNotify.payload?.data?.skipped === 'channel-client' &&
|
||||
String(mutedNotify.payload?.message ?? '').includes('只在 ClassHelper 客户端显示'),
|
||||
`skipped=${mutedNotify.payload?.data?.skipped} message=${mutedNotify.payload?.message}`,
|
||||
);
|
||||
|
||||
// 班级账号(教室机器)自己就能读写这项设置 —— 需求就是"由 ClassIsland 之外的客户端自己选"
|
||||
const classSessionToken = await classLoginToken();
|
||||
const readByClass = await api(`/classes/${target.id}/notification-channel`, {
|
||||
token: classSessionToken,
|
||||
});
|
||||
record(
|
||||
'班级账号可读取通知显示位置',
|
||||
readByClass.status === 200 && readByClass.payload?.data?.notificationChannel === 'client',
|
||||
`channel=${readByClass.payload?.data?.notificationChannel}`,
|
||||
);
|
||||
const setByClass = await api(`/classes/${target.id}/notification-channel`, {
|
||||
method: 'PATCH',
|
||||
token: classSessionToken,
|
||||
body: { notificationChannel: 'both' },
|
||||
});
|
||||
record(
|
||||
'班级账号可修改通知显示位置(由教室客户端自己选)',
|
||||
setByClass.status === 200 && setByClass.payload?.data?.notificationChannel === 'both',
|
||||
`channel=${setByClass.payload?.data?.notificationChannel}`,
|
||||
);
|
||||
const badChannel = await api(`/classes/${target.id}/notification-channel`, {
|
||||
method: 'PATCH',
|
||||
token: teacherToken,
|
||||
body: { notificationChannel: 'nope' },
|
||||
});
|
||||
record('非法显示位置被拒(422)', badChannel.status === 422, `status=${badChannel.status}`);
|
||||
|
||||
// ---------------------------------------------------------------- 5. 课表镜像(默认关闭 → 打开 → 拉取)
|
||||
const mirrorOff = await api('/integrations/classisland/class-plan', { deviceToken });
|
||||
record(
|
||||
'镜像开关关闭时不下发课表',
|
||||
mirrorOff.status === 200 && mirrorOff.payload?.data === null,
|
||||
`data=${mirrorOff.payload?.data === null ? 'null' : 'object'}`,
|
||||
);
|
||||
|
||||
const enabled = await api(`/integrations/devices/${deviceId}`, {
|
||||
method: 'PATCH',
|
||||
token: teacherToken,
|
||||
body: { mirrorScheduleToClassIsland: true },
|
||||
});
|
||||
record(
|
||||
'打开「镜像课表到 ClassIsland」',
|
||||
enabled.status === 200 && enabled.payload?.data?.mirrorScheduleToClassIsland === true,
|
||||
`status=${enabled.status}`,
|
||||
);
|
||||
|
||||
const mirror = await api('/integrations/classisland/class-plan', { deviceToken });
|
||||
const plan = mirror.payload?.data?.classPlan;
|
||||
const weekDays = (plan?.entries ?? []).map((item) => item.weekDay);
|
||||
record(
|
||||
'拉取课表为 ClassIsland 口径(0=周日 … 6=周六)',
|
||||
mirror.status === 200 &&
|
||||
Array.isArray(plan?.entries) &&
|
||||
plan.entries.length >= 3 &&
|
||||
weekDays.every((day) => day >= 0 && day <= 6),
|
||||
`条目=${plan?.entries?.length} weekDay=[${weekDays.join(',')}] 档案名=${plan?.profileName}`,
|
||||
);
|
||||
record(
|
||||
'单双周换算为 WeekCountDiv/Total',
|
||||
(plan?.entries ?? []).some((item) => item.weekCountDivTotal === 2),
|
||||
(plan?.entries ?? [])
|
||||
.map((item) => `${item.subject}:${item.weekCountDiv}/${item.weekCountDivTotal}`)
|
||||
.join(' '),
|
||||
);
|
||||
// ClassInfo.Index 与"第 i 个上课时间点"一一对应,因此镜像条目必须带起止时间,
|
||||
// 否则插件只能按数组顺序硬排,一旦某天少一节整份课表就错位(这是最容易漏的一处契约)
|
||||
const timeOk = (plan?.entries ?? []).every(
|
||||
(item) => /^\d{2}:\d{2}$/.test(item.startTime ?? '') && /^\d{2}:\d{2}$/.test(item.endTime ?? ''),
|
||||
);
|
||||
record(
|
||||
'镜像条目带起止时间(插件据此重建上课时间点)',
|
||||
(plan?.entries ?? []).length > 0 && timeOk,
|
||||
(plan?.entries ?? [])
|
||||
.slice(0, 3)
|
||||
.map((item) => `${item.subject} ${item.startTime}-${item.endTime}`)
|
||||
.join(' | '),
|
||||
);
|
||||
const classPoints = (plan?.timeLayouts?.[0]?.layouts ?? []).filter((item) => item.timeType === 0);
|
||||
record(
|
||||
'镜像时间表含上课时间点(与课表条目对得上)',
|
||||
classPoints.length > 0 && (plan?.entries ?? []).length >= classPoints.length / 2,
|
||||
`上课点=${classPoints.length} 条目=${(plan?.entries ?? []).length}`,
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------- 6. 停用设备
|
||||
await api(`/integrations/devices/${deviceId}`, {
|
||||
method: 'PATCH',
|
||||
token: teacherToken,
|
||||
body: { enabled: false },
|
||||
});
|
||||
const blocked = await api('/integrations/classisland/report', {
|
||||
method: 'POST',
|
||||
deviceToken,
|
||||
body: { state: { inClass: false, clientTime: new Date().toISOString() } },
|
||||
});
|
||||
record('停用设备后插件上报被拒(403)', blocked.status === 403, `status=${blocked.status}`);
|
||||
|
||||
// ---------------------------------------------------------------- 7. 权限隔离
|
||||
// 班级码只有管理员/班主任可见,班级密码也是默认值,因此这里按管理员信息补齐
|
||||
let classCode = target.code ?? null;
|
||||
if (!classCode && adminToken) {
|
||||
const all = await api('/classes', { token: adminToken });
|
||||
classCode = (all.payload?.data ?? []).find((item) => item.id === target.id)?.code ?? null;
|
||||
}
|
||||
const studentLogin = await api('/auth/class-login', {
|
||||
method: 'POST',
|
||||
body: { code: classCode ?? '', password: '123456' },
|
||||
});
|
||||
const studentToken = studentLogin.payload?.data?.token;
|
||||
if (studentToken) {
|
||||
const forbidden = await api('/integrations/devices', { token: studentToken });
|
||||
record('学生无法查看联动设备列表(403)', forbidden.status === 403, `status=${forbidden.status}`);
|
||||
} else {
|
||||
record('班级账号登录(跳过学生隔离用例)', false, `status=${studentLogin.status}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 清理
|
||||
const removed = await api(`/integrations/devices/${deviceId}`, { method: 'DELETE', token: teacherToken });
|
||||
record('删除验证设备(清理)', removed.status === 200, `status=${removed.status}`);
|
||||
|
||||
const failed = results.filter((item) => !item.ok);
|
||||
console.log(`\n=== 结果:${results.length - failed.length}/${results.length} 通过 ===`);
|
||||
if (failed.length > 0) {
|
||||
console.log('失败用例:');
|
||||
for (const item of failed) console.log(` - ${item.name} (${item.detail})`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('ClassIsland 联动链路全部通过。\n');
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('验证脚本异常:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
ClassDto,
|
||||
ClassIslandNotificationChannel,
|
||||
ClassTeacherBrief,
|
||||
CourseBrief,
|
||||
CourseDto,
|
||||
@@ -95,6 +96,8 @@ export interface ClassLike {
|
||||
teacherId: string;
|
||||
/** 本学期教学周数(班主任可调,默认 20) */
|
||||
termWeeks?: number;
|
||||
/** 通知显示位置:both / client / classisland(由教室的班级客户端设置) */
|
||||
notificationChannel?: string | null;
|
||||
createdAt: Date;
|
||||
teacher?: TeacherBriefLike | null;
|
||||
_count?: {
|
||||
@@ -106,6 +109,11 @@ export interface ClassLike {
|
||||
};
|
||||
}
|
||||
|
||||
/** 通知显示位置:库里存字符串,出参收敛成联合类型(非法值一律按默认 both 处理) */
|
||||
export function toNotificationChannel(value: string | null | undefined): ClassIslandNotificationChannel {
|
||||
return value === 'client' || value === 'classisland' ? value : 'both';
|
||||
}
|
||||
|
||||
export function toClassDto(item: ClassLike): ClassDto {
|
||||
return {
|
||||
id: item.id,
|
||||
@@ -113,6 +121,7 @@ export function toClassDto(item: ClassLike): ClassDto {
|
||||
grade: item.grade,
|
||||
teacherId: item.teacherId,
|
||||
termWeeks: item.termWeeks ?? 20,
|
||||
notificationChannel: toNotificationChannel(item.notificationChannel),
|
||||
createdAt: toIso(item.createdAt),
|
||||
teacher: item.teacher ? toTeacherBrief(item.teacher) : null,
|
||||
studentCount: item._count?.students ?? undefined,
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
import { toNotificationDto } from '../../lib/mappers.js';
|
||||
import { emitToClass, emitToUser } from '../../realtime/bus.js';
|
||||
import { pushToClassIsland, shouldPushToClassIsland } from '../integrations/integrations.service.js';
|
||||
import type { CreateCallInput } from './calls.schemas.js';
|
||||
|
||||
const creatorSelect = { select: { id: true, name: true, username: true } } as const;
|
||||
@@ -63,6 +64,24 @@ export async function createCall(user: TokenPayload, input: CreateCallInput): Pr
|
||||
// 因此"叫人"消息在班级设备上也会立即展开(学生姓名在标题里,全班都能看到叫谁)
|
||||
emitToUser(created.classId, SOCKET_EVENTS.callNew, dto);
|
||||
|
||||
// 叫人同样推到教室的 ClassIsland:老师点名时,教室大屏也弹一条(学生姓名在标题里)。
|
||||
// 显示位置由教室客户端决定;推送失败不影响叫人本身。
|
||||
try {
|
||||
if (await shouldPushToClassIsland(input.classId)) {
|
||||
await pushToClassIsland({
|
||||
classId: input.classId,
|
||||
title: created.title,
|
||||
content: created.content,
|
||||
urgent: input.urgent === true,
|
||||
createdBy: user.sub,
|
||||
notificationId: created.id,
|
||||
teacherName,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`叫人推送 ClassIsland 失败(叫人本身已发出):${String(error)}`);
|
||||
}
|
||||
|
||||
logger.info(`${input.urgent ? '紧急叫人' : '叫人'}:${teacherName} → ${studentName}(${message})`);
|
||||
return dto;
|
||||
}
|
||||
@@ -2,7 +2,12 @@ import { Router } from 'express';
|
||||
import { sendCreated, sendOk } from '../../lib/http.js';
|
||||
import { idParamSchema } from '../../lib/schemas.js';
|
||||
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
|
||||
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||
import {
|
||||
validate,
|
||||
validatedBody,
|
||||
validatedParams,
|
||||
validatedQuery,
|
||||
} from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
addStudentSchema,
|
||||
@@ -14,10 +19,12 @@ import {
|
||||
listClassesQuerySchema,
|
||||
updateClassAccountSchema,
|
||||
updateClassSchema,
|
||||
updateNotificationChannelSchema,
|
||||
type AddStudentInput,
|
||||
type CreateClassInput,
|
||||
type UpdateClassAccountInput,
|
||||
type UpdateClassInput,
|
||||
type UpdateNotificationChannelInput,
|
||||
} from './classes.schemas.js';
|
||||
import * as classService from './classes.service.js';
|
||||
|
||||
@@ -106,6 +113,42 @@ router.patch(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/classes/:id/notification-channel - 读取"通知显示到哪个端"
|
||||
*
|
||||
* 教室的班级客户端登录后会拉这个值,据此决定自己是弹窗/上岛还是静默;
|
||||
* 教师/管理员也能读(Web 端展示用)。
|
||||
*/
|
||||
router.get('/:id/notification-channel', validate({ params: idParamSchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
sendOk(res, await classService.getNotificationChannel(user, id), '获取通知显示位置成功');
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/classes/:id/notification-channel - 设置"通知显示到哪个端"
|
||||
*
|
||||
* **由教室的班级客户端自己选**(班级账号可改),教师/管理员也可改;
|
||||
* 普通学生账号被拒(见 service 内的断言)。
|
||||
*/
|
||||
router.patch(
|
||||
'/:id/notification-channel',
|
||||
validate({ params: idParamSchema, body: updateNotificationChannelSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
sendOk(
|
||||
res,
|
||||
await classService.updateNotificationChannel(
|
||||
user,
|
||||
id,
|
||||
validatedBody<UpdateNotificationChannelInput>(req),
|
||||
),
|
||||
'通知显示位置已更新',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/** GET /api/classes/:id/students - 班级学生名单 */
|
||||
router.get('/:id/students', validate({ params: idParamSchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import { CLASSISLAND_NOTIFICATION_CHANNELS } from '@classhelper/shared';
|
||||
import { keywordSchema } from '../../lib/schemas.js';
|
||||
|
||||
export const createClassSchema = z.object({
|
||||
@@ -54,6 +55,17 @@ export const assignHeadTeacherSchema = z.object({
|
||||
teacherId: z.string().min(1, '请选择班主任'),
|
||||
});
|
||||
|
||||
/**
|
||||
* 通知显示位置(both / client / classisland)。
|
||||
* 由**教室的班级客户端**在设置页里改,教师/管理员也可以在 Web 端改。
|
||||
*/
|
||||
export const updateNotificationChannelSchema = z.object({
|
||||
notificationChannel: z.enum(
|
||||
CLASSISLAND_NOTIFICATION_CHANNELS as unknown as [string, ...string[]],
|
||||
{ message: '显示位置只能是 both / client / classisland' },
|
||||
),
|
||||
});
|
||||
|
||||
export const classStudentParamSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
userId: z.string().min(1),
|
||||
@@ -67,4 +79,5 @@ export const classTeacherParamSchema = z.object({
|
||||
export type CreateClassInput = z.infer<typeof createClassSchema>;
|
||||
export type UpdateClassInput = z.infer<typeof updateClassSchema>;
|
||||
export type UpdateClassAccountInput = z.infer<typeof updateClassAccountSchema>;
|
||||
export type UpdateNotificationChannelInput = z.infer<typeof updateNotificationChannelSchema>;
|
||||
export type AddStudentInput = z.infer<typeof addStudentSchema>;
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { ClassDetailDto, ClassDto, StudentDto } from '@classhelper/shared';
|
||||
import type {
|
||||
ClassDetailDto,
|
||||
ClassDto,
|
||||
ClassIslandNotificationChannel,
|
||||
StudentDto,
|
||||
} from '@classhelper/shared';
|
||||
import { env } from '../../config/env.js';
|
||||
import {
|
||||
assertClassAccess,
|
||||
@@ -6,6 +11,7 @@ import {
|
||||
assertCanManageClasses,
|
||||
assertCanManageRoster,
|
||||
classScopeIdFilter,
|
||||
isStaff,
|
||||
resolveClassScope,
|
||||
} from '../../lib/access.js';
|
||||
import {
|
||||
@@ -15,11 +21,25 @@ import {
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import { toClassDto, toCourseDto, toStudentDto, toTeacherBrief } from '../../lib/mappers.js';
|
||||
import {
|
||||
toClassDto,
|
||||
toCourseDto,
|
||||
toNotificationChannel,
|
||||
toStudentDto,
|
||||
toTeacherBrief,
|
||||
} from '../../lib/mappers.js';
|
||||
import { hashPassword } from '../../lib/password.js';
|
||||
import { emitToClass } from '../../realtime/bus.js';
|
||||
import { SOCKET_EVENTS } from '@classhelper/shared';
|
||||
import type { AddStudentInput, CreateClassInput, UpdateClassInput } from './classes.schemas.js';
|
||||
import type {
|
||||
AddStudentInput,
|
||||
CreateClassInput,
|
||||
UpdateClassInput,
|
||||
UpdateNotificationChannelInput,
|
||||
} from './classes.schemas.js';
|
||||
import { attachIntegrationFlag } from '../integrations/integrations.service.js';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
import { isClassSession } from '../../lib/session.js';
|
||||
|
||||
const countSelect = {
|
||||
students: true,
|
||||
@@ -76,7 +96,8 @@ export async function listClasses(user: TokenPayload, keyword?: string): Promise
|
||||
orderBy: [{ grade: 'asc' }, { name: 'asc' }],
|
||||
});
|
||||
|
||||
return classes.map((item) => toClassDtoWithAccount(user, item));
|
||||
// 附上「是否已接入 ClassIsland」徽标:Web 端班级列表/课表页据此提示
|
||||
return attachIntegrationFlag(classes.map((item) => toClassDtoWithAccount(user, item)));
|
||||
}
|
||||
|
||||
/** 班级详情:学生名单 + 课程 + 协作教师 */
|
||||
@@ -333,3 +354,48 @@ export async function removeTeacher(user: TokenPayload, classId: string, teacher
|
||||
|
||||
await prisma.classTeacher.deleteMany({ where: { classId, teacherId } });
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 通知显示位置 */
|
||||
|
||||
/**
|
||||
* 读取班级的通知显示位置(both / client / classisland)。
|
||||
*
|
||||
* 这是**教室客户端**要读的值:客户端登录后拉一次,把自己的弹窗行为对齐到同一个选择。
|
||||
* 学生也能读自己班的(只读),便于排查"为什么教室没弹"。
|
||||
*/
|
||||
export async function getNotificationChannel(
|
||||
user: TokenPayload,
|
||||
classId: string,
|
||||
): Promise<{ notificationChannel: ClassIslandNotificationChannel }> {
|
||||
await assertClassAccess(user, classId);
|
||||
const record = await prisma.class.findUnique({
|
||||
where: { id: classId },
|
||||
select: { notificationChannel: true },
|
||||
});
|
||||
return { notificationChannel: toNotificationChannel(record?.notificationChannel) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置班级的通知显示位置。
|
||||
*
|
||||
* 允许两类人改:**该班的班级账号**(教室机器的客户端 —— 需求就是"由客户端自己选")
|
||||
* 与教师 / 管理员(Web 端也能改,便于老师统一要求)。
|
||||
* 普通学生账号不允许:否则"教室里到底弹哪个端"会变成任意学生都能改的开关。
|
||||
*/
|
||||
export async function updateNotificationChannel(
|
||||
user: TokenPayload,
|
||||
classId: string,
|
||||
input: UpdateNotificationChannelInput,
|
||||
): Promise<{ notificationChannel: ClassIslandNotificationChannel }> {
|
||||
await assertClassAccess(user, classId);
|
||||
if (!isClassSession(user) && !isStaff(user)) {
|
||||
throw ApiError.forbidden('只有班级账号或教师/管理员可以修改通知显示位置');
|
||||
}
|
||||
|
||||
const channel = toNotificationChannel(input.notificationChannel);
|
||||
await prisma.class.update({ where: { id: classId }, data: { notificationChannel: channel } });
|
||||
// 广播出去:其他端(Web 列表 / 同班客户端)能立刻看到这个选择变了
|
||||
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' });
|
||||
logger.info(`班级通知显示位置已更新:classId=${classId} channel=${channel} by=${user.sub}`);
|
||||
return { notificationChannel: channel };
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { ApiError } from '../../lib/http.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
import { assertCanManageSchedule } from '../../lib/access.js';
|
||||
import { weekParityFromDiv } from '@classhelper/shared';
|
||||
import type { ClassPlanImportInput } from './imports.schemas.js';
|
||||
|
||||
/**
|
||||
@@ -96,14 +97,10 @@ function normalizeTime(value: unknown): string | null {
|
||||
|
||||
/**
|
||||
* `WeekCountDiv` / `WeekCountDivTotal` → 我们课表的 weekParity。
|
||||
* 只支持 2 周循环(=单双周);3 周及以上循环无法用单双周表达,记 warning 并按"每周"处理。
|
||||
* 口径集中在 `@classhelper/shared` 的 `weekParityFromDiv`(插件上报走同一份实现),
|
||||
* 这里只是本模块内的别名,保证"手动导入 JSON"与"插件实时上报"落到库里完全一致。
|
||||
*/
|
||||
function resolveParity(div: number, total: number): 'ALL' | 'ODD' | 'EVEN' {
|
||||
if (div <= 0 || total <= 1) return 'ALL';
|
||||
const position = ((div - 1) % total) + 1;
|
||||
if (total === 2) return position === 1 ? 'ODD' : 'EVEN';
|
||||
return 'ALL';
|
||||
}
|
||||
const resolveParity = weekParityFromDiv;
|
||||
|
||||
/** 解析 ClassIsland 档案 JSON(或只含 ClassPlans 的片段)里的课程表 */
|
||||
export function parseClassIslandClassPlan(input: unknown): ClassPlanParseResult {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import crypto from 'node:crypto';
|
||||
import type { Request, RequestHandler } from 'express';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
|
||||
/**
|
||||
* 联动设备的令牌工具。
|
||||
*
|
||||
* 为什么不用 JWT:
|
||||
* - 令牌是「设备级长期凭证」,插件装在教室机器上,长时间无人值守,
|
||||
* 需要一个"管理员在 Web 端点了重置才失效"的凭证,而不是有固定过期时间的会话;
|
||||
* - 明文只在创建/重置接口返回一次,服务端只存 sha256 —— 库被读走也无法反推出可用令牌。
|
||||
*/
|
||||
export function generateDeviceToken(): string {
|
||||
// 32 字节随机 -> 64 位十六进制,前缀便于在日志/设置页里一眼认出
|
||||
return `chci_${crypto.randomBytes(32).toString('hex')}`;
|
||||
}
|
||||
|
||||
export function hashDeviceToken(token: string): string {
|
||||
return crypto.createHash('sha256').update(token, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
/** 令牌前缀提示(chci_ + 前 8 位),用于设备列表里人眼识别 */
|
||||
export function tokenHintOf(token: string): string {
|
||||
return `${token.slice(0, 13)}…`;
|
||||
}
|
||||
|
||||
/** 从 \`X-ClassIsland-Token\` 或 \`Authorization: Bearer\` 中取出设备令牌 */
|
||||
export function extractDeviceToken(headerValue: string | undefined, custom: unknown): string | null {
|
||||
if (typeof custom === 'string' && custom.trim()) return custom.trim();
|
||||
if (!headerValue) return null;
|
||||
const [scheme, value] = headerValue.split(' ');
|
||||
if (!value || scheme.toLowerCase() !== 'bearer') return null;
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
/** 设备上下文(与 Express.Request.device 的结构一致) */
|
||||
export interface DeviceContext {
|
||||
deviceId: string;
|
||||
classId: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
syncScheduleToServer: boolean;
|
||||
mirrorScheduleToClassIsland: boolean;
|
||||
}
|
||||
|
||||
/** 插件侧路由统一挂这个中间件:解析设备令牌并回查设备记录 */
|
||||
export function authenticateDevice(): RequestHandler {
|
||||
return (req, _res, next) => {
|
||||
void (async () => {
|
||||
try {
|
||||
const token = extractDeviceToken(req.headers.authorization, req.headers['x-classisland-token']);
|
||||
if (!token) throw ApiError.unauthorized('缺少设备令牌(X-ClassIsland-Token)');
|
||||
|
||||
const record = await prisma.integrationDevice.findFirst({
|
||||
where: { tokenHash: hashDeviceToken(token) },
|
||||
select: {
|
||||
id: true,
|
||||
classId: true,
|
||||
name: true,
|
||||
enabled: true,
|
||||
syncScheduleToServer: true,
|
||||
mirrorScheduleToClassIsland: true,
|
||||
},
|
||||
});
|
||||
if (!record) throw ApiError.unauthorized('设备令牌无效,请在「ClassIsland 联动」里重新生成');
|
||||
|
||||
const context: DeviceContext = {
|
||||
deviceId: record.id,
|
||||
classId: record.classId,
|
||||
name: record.name,
|
||||
enabled: record.enabled,
|
||||
syncScheduleToServer: record.syncScheduleToServer,
|
||||
mirrorScheduleToClassIsland: record.mirrorScheduleToClassIsland,
|
||||
};
|
||||
req.device = context;
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
})();
|
||||
};
|
||||
}
|
||||
|
||||
/** 取出当前设备上下文,未鉴权时抛 401 */
|
||||
export function getDevice(req: Request): DeviceContext {
|
||||
const device = req.device;
|
||||
if (!device) throw ApiError.unauthorized('缺少设备上下文');
|
||||
return device;
|
||||
}
|
||||
|
||||
/** 设备被管理员停用:插件上报与下发一律拒绝(403 而不是 401,便于插件提示"请管理员开启") */
|
||||
export function assertDeviceEnabled(device: DeviceContext): void {
|
||||
if (!device.enabled) {
|
||||
throw ApiError.forbidden('该设备已在「ClassIsland 联动」中被停用,请联系管理员开启');
|
||||
}
|
||||
}
|
||||
|
||||
/** 生成的令牌字符串的字面量前缀,供测试与文档引用 */
|
||||
export const DEVICE_TOKEN_PREFIX = 'chci_';
|
||||
@@ -0,0 +1,172 @@
|
||||
import { Router } from 'express';
|
||||
import { sendCreated, sendOk } from '../../lib/http.js';
|
||||
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
|
||||
import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import { authenticateDevice, assertDeviceEnabled, getDevice } from './device-auth.js';
|
||||
import {
|
||||
ackNotificationSchema,
|
||||
classIslandReportSchema,
|
||||
createDeviceSchema,
|
||||
deviceIdParamSchema,
|
||||
listDevicesQuerySchema,
|
||||
sendNotificationSchema,
|
||||
updateDeviceSchema,
|
||||
type ClassIslandReportInput,
|
||||
type CreateDeviceInput,
|
||||
type SendNotificationInput,
|
||||
type UpdateDeviceInput,
|
||||
} from './integrations.schemas.js';
|
||||
import * as integrationService from './integrations.service.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/* ================================================================== 插件侧
|
||||
* 用**设备令牌**鉴权(X-ClassIsland-Token / Authorization: Bearer),不是 JWT 会话。
|
||||
* 路由放在文件最前面,避免被下面的 authenticate() 拦住;每个路由单独挂 authenticateDevice()。
|
||||
*/
|
||||
|
||||
/** POST /api/integrations/classisland/report - 插件上报当前状态 +(可选)课表 */
|
||||
router.post(
|
||||
'/classisland/report',
|
||||
authenticateDevice(),
|
||||
validate({ body: classIslandReportSchema }),
|
||||
async (req, res) => {
|
||||
const device = getDevice(req);
|
||||
assertDeviceEnabled(device);
|
||||
const result = await integrationService.applyReport(device, validatedBody<ClassIslandReportInput>(req));
|
||||
sendOk(res, result, '上报成功');
|
||||
},
|
||||
);
|
||||
|
||||
/** GET /api/integrations/classisland/pending - 插件拉取尚未确认的下发提醒(离线补齐) */
|
||||
router.get('/classisland/pending', authenticateDevice(), async (req, res) => {
|
||||
const device = getDevice(req);
|
||||
assertDeviceEnabled(device);
|
||||
sendOk(res, await integrationService.listPendingNotifications(device), '获取待提醒成功');
|
||||
});
|
||||
|
||||
/** POST /api/integrations/classisland/ack - 插件确认提醒已弹出(之后不再补发) */
|
||||
router.post(
|
||||
'/classisland/ack',
|
||||
authenticateDevice(),
|
||||
validate({ body: ackNotificationSchema }),
|
||||
async (req, res) => {
|
||||
const device = getDevice(req);
|
||||
assertDeviceEnabled(device);
|
||||
await integrationService.ackNotification(device, validatedBody<{ id: string }>(req).id);
|
||||
sendOk(res, { acked: true }, '已确认');
|
||||
},
|
||||
);
|
||||
|
||||
/** GET /api/integrations/classisland/class-plan - 插件拉取本班课表(镜像回 ClassIsland) */
|
||||
router.get('/classisland/class-plan', authenticateDevice(), async (req, res) => {
|
||||
const device = getDevice(req);
|
||||
assertDeviceEnabled(device);
|
||||
if (!device.mirrorScheduleToClassIsland) {
|
||||
sendOk(res, null, '该设备未开启「把班级课表镜像到 ClassIsland」');
|
||||
return;
|
||||
}
|
||||
sendOk(res, await integrationService.pullClassPlanForDevice(device), '获取课表成功');
|
||||
});
|
||||
|
||||
/* ================================================================== Web 管理端
|
||||
* 用 JWT 鉴权;权限与"课表管理 / 发通知"保持一致,由 service 内二次校验班级维度角色。
|
||||
*/
|
||||
router.use(authenticate());
|
||||
|
||||
/**
|
||||
* GET /api/integrations/devices?classId= - 联动设备列表(含最后一次上报的状态快照)
|
||||
*
|
||||
* 仅管理员/教师可见:设备列表属于管理面,学生也不需要知道教室机器接了哪台设备;
|
||||
* 教师只能看到自己可访问班级的设备(service 内按 classScope 过滤)。
|
||||
*/
|
||||
router.get(
|
||||
'/devices',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
validate({ query: listDevicesQuerySchema }),
|
||||
async (req, res) => {
|
||||
const { classId } = validatedQuery<{ classId?: string }>(req);
|
||||
sendOk(res, await integrationService.listDevices(getAuthUser(req), classId), '获取设备列表成功');
|
||||
},
|
||||
);
|
||||
|
||||
/** POST /api/integrations/devices - 新建设备并一次性返回明文令牌(管理员或本班班主任) */
|
||||
router.post(
|
||||
'/devices',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
validate({ body: createDeviceSchema }),
|
||||
async (req, res) => {
|
||||
const result = await integrationService.createDevice(
|
||||
getAuthUser(req),
|
||||
validatedBody<CreateDeviceInput>(req),
|
||||
);
|
||||
sendCreated(res, result, '设备已创建,请把令牌填入插件设置(只显示这一次)');
|
||||
},
|
||||
);
|
||||
|
||||
/** PATCH /api/integrations/devices/:id - 改名 / 启停 / 调整同步开关 */
|
||||
router.patch(
|
||||
'/devices/:id',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
validate({ params: deviceIdParamSchema, body: updateDeviceSchema }),
|
||||
async (req, res) => {
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
sendOk(
|
||||
res,
|
||||
await integrationService.updateDevice(getAuthUser(req), id, validatedBody<UpdateDeviceInput>(req)),
|
||||
'设备已更新',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/** POST /api/integrations/devices/:id/token - 重置令牌(旧令牌立即失效) */
|
||||
router.post(
|
||||
'/devices/:id/token',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
validate({ params: deviceIdParamSchema }),
|
||||
async (req, res) => {
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
sendOk(res, await integrationService.resetDeviceToken(getAuthUser(req), id), '令牌已重置');
|
||||
},
|
||||
);
|
||||
|
||||
/** DELETE /api/integrations/devices/:id - 删除设备 */
|
||||
router.delete(
|
||||
'/devices/:id',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
validate({ params: deviceIdParamSchema }),
|
||||
async (req, res) => {
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
await integrationService.deleteDevice(getAuthUser(req), id);
|
||||
sendOk(res, { id }, '设备已删除');
|
||||
},
|
||||
);
|
||||
|
||||
/** POST /api/integrations/classisland/notify - 把提醒下发到该班的 ClassIsland 设备 */
|
||||
router.post(
|
||||
'/classisland/notify',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
validate({ body: sendNotificationSchema }),
|
||||
async (req, res) => {
|
||||
const result = await integrationService.sendNotification(
|
||||
getAuthUser(req),
|
||||
validatedBody<SendNotificationInput>(req),
|
||||
);
|
||||
sendOk(
|
||||
res,
|
||||
result,
|
||||
result.skipped === 'channel-client'
|
||||
? '该班教室已设置为「只在 ClassHelper 客户端显示」,本次未推送到 ClassIsland'
|
||||
: result.targetCount === 0
|
||||
? '已保存提醒,但该班级还没有已接入的 ClassIsland 设备'
|
||||
: `已下发到 ${result.delivered} 台 ClassIsland 设备`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const integrationsModule = defineModule({
|
||||
name: 'integrations',
|
||||
basePath: '/integrations',
|
||||
router,
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
WEEK_PARITY_VALUES,
|
||||
CLASSISLAND_NOTIFICATION_MAX_DURATION,
|
||||
CLASSISLAND_NOTIFICATION_DEFAULT_DURATION,
|
||||
} from '@classhelper/shared';
|
||||
import { booleanFlagSchema, timeSchema } from '../../lib/schemas.js';
|
||||
|
||||
/** 1=周一 … 7=周日 */
|
||||
const dayOfWeekSchema = z.coerce.number().int().min(1).max(7);
|
||||
|
||||
export const integrationModeSchema = z.enum(['plugin', 'import']);
|
||||
export const weekParitySchema = z.enum(WEEK_PARITY_VALUES as unknown as [string, ...string[]]);
|
||||
|
||||
/* ------------------------------------------------------------------ Web 管理端 */
|
||||
|
||||
export const listDevicesQuerySchema = z.object({
|
||||
classId: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
export const createDeviceSchema = z.object({
|
||||
classId: z.string().min(1, '请选择班级'),
|
||||
name: z.string().trim().max(40).optional(),
|
||||
mode: integrationModeSchema.optional(),
|
||||
/** 是否允许插件把 ClassIsland 的课表回传到本服务 */
|
||||
syncScheduleToServer: z.boolean().optional(),
|
||||
/** 是否允许把本班课表镜像回 ClassIsland */
|
||||
mirrorScheduleToClassIsland: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const updateDeviceSchema = z.object({
|
||||
name: z.string().trim().max(40).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
mode: integrationModeSchema.optional(),
|
||||
syncScheduleToServer: z.boolean().optional(),
|
||||
mirrorScheduleToClassIsland: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const deviceIdParamSchema = z.object({ id: z.string().min(1, '缺少设备 id') });
|
||||
|
||||
export const sendNotificationSchema = z.object({
|
||||
classId: z.string().min(1, '请选择班级'),
|
||||
title: z.string().trim().min(1, '请输入提醒标题').max(120),
|
||||
content: z.string().trim().min(1, '请输入提醒内容').max(2000),
|
||||
durationSeconds: z.coerce.number().int().min(1).max(CLASSISLAND_NOTIFICATION_MAX_DURATION).optional(),
|
||||
speech: booleanFlagSchema,
|
||||
speechContent: z.string().trim().max(500).optional(),
|
||||
saveToNotifications: z.boolean().optional(),
|
||||
priority: z.enum(['LOW', 'NORMAL', 'HIGH', 'URGENT']).optional(),
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ 插件侧(设备令牌鉴权) */
|
||||
|
||||
export const reportScheduleEntrySchema = z.object({
|
||||
dayOfWeek: dayOfWeekSchema,
|
||||
startTime: timeSchema,
|
||||
endTime: timeSchema,
|
||||
subject: z.string().trim().min(1, '科目不能为空').max(40),
|
||||
teacherName: z.string().trim().max(40).nullish(),
|
||||
weekParity: weekParitySchema.optional(),
|
||||
weekCountDiv: z.coerce.number().int().min(0).max(30).optional(),
|
||||
weekCountDivTotal: z.coerce.number().int().min(0).max(30).optional(),
|
||||
planName: z.string().trim().max(60).optional(),
|
||||
});
|
||||
|
||||
export const reportTimeLayoutItemSchema = z.object({
|
||||
index: z.coerce.number().int().min(0).max(200).optional(),
|
||||
name: z.string().trim().max(40).optional(),
|
||||
startTime: timeSchema,
|
||||
endTime: timeSchema,
|
||||
type: z.enum(['class', 'break', 'divider', 'action']).optional(),
|
||||
skipped: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const classIslandReportSchema = z.object({
|
||||
pluginVersion: z.string().trim().max(40).optional(),
|
||||
classIslandVersion: z.string().trim().max(40).optional(),
|
||||
state: z
|
||||
.object({
|
||||
inClass: z.boolean().optional(),
|
||||
subject: z.string().trim().max(40).nullish(),
|
||||
nextSubject: z.string().trim().max(40).nullish(),
|
||||
timeState: z.string().trim().max(40).nullish(),
|
||||
periodStart: timeSchema.nullish(),
|
||||
periodEnd: timeSchema.nullish(),
|
||||
week: z.coerce.number().int().min(1).max(60).nullish(),
|
||||
classPlanLoaded: z.boolean().optional(),
|
||||
clientTime: z.string().trim().max(40).optional(),
|
||||
})
|
||||
.optional(),
|
||||
schedule: z
|
||||
.object({
|
||||
mode: z.enum(['replace', 'merge']).optional(),
|
||||
entries: z.array(reportScheduleEntrySchema).max(500, '单次上报的课程条目过多'),
|
||||
})
|
||||
.nullish(),
|
||||
timeLayout: z
|
||||
.object({
|
||||
name: z.string().trim().max(60).optional(),
|
||||
mode: z.enum(['replace', 'merge']).optional(),
|
||||
items: z.array(reportTimeLayoutItemSchema).max(200, '单次上报的节次过多'),
|
||||
})
|
||||
.nullish(),
|
||||
});
|
||||
|
||||
/** 插件确认某条提醒已弹出(避免重连后重复补发) */
|
||||
export const ackNotificationSchema = z.object({
|
||||
id: z.string().min(1, '缺少提醒 id'),
|
||||
});
|
||||
|
||||
/** 插件上报的默认值集中在这里,避免散落在 service 里 */
|
||||
export const REPORT_DEFAULTS = {
|
||||
scheduleMode: 'merge',
|
||||
timeLayoutMode: 'merge',
|
||||
timeLayoutName: 'ClassIsland 时间表',
|
||||
notificationDuration: CLASSISLAND_NOTIFICATION_DEFAULT_DURATION,
|
||||
} as const;
|
||||
|
||||
export type CreateDeviceInput = z.infer<typeof createDeviceSchema>;
|
||||
export type UpdateDeviceInput = z.infer<typeof updateDeviceSchema>;
|
||||
export type SendNotificationInput = z.infer<typeof sendNotificationSchema>;
|
||||
export type ClassIslandReportInput = z.infer<typeof classIslandReportSchema>;
|
||||
export type ReportScheduleEntry = z.infer<typeof reportScheduleEntrySchema>;
|
||||
export type ReportTimeLayoutItem = z.infer<typeof reportTimeLayoutItemSchema>;
|
||||
@@ -0,0 +1,694 @@
|
||||
import {
|
||||
SOCKET_EVENTS,
|
||||
resolveCurrentWeek,
|
||||
weekDivFromParity,
|
||||
type ClassDto,
|
||||
type ClassIslandPendingResult,
|
||||
type ClassIslandPushNotification,
|
||||
type ClassIslandReportResult,
|
||||
type ClassIslandScheduleEntry,
|
||||
type IntegrationDeviceDto,
|
||||
type IntegrationDeviceTokenDto,
|
||||
type SendClassIslandNotificationResult,
|
||||
type TimeLayoutEntry,
|
||||
type NotificationDto,
|
||||
} from '@classhelper/shared';
|
||||
import { env } from '../../config/env.js';
|
||||
import { assertCanManageSchedule, assertCanPublishContent, resolveClassScope } from '../../lib/access.js';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
import { toNotificationChannel, toNotificationDto } from '../../lib/mappers.js';
|
||||
import { emitToClass, emitToRooms } from '../../realtime/bus.js';
|
||||
import { SOCKET_ROOMS } from '@classhelper/shared';
|
||||
import type { DeviceContext } from './device-auth.js';
|
||||
import { generateDeviceToken, hashDeviceToken, tokenHintOf } from './device-auth.js';
|
||||
import type {
|
||||
ClassIslandReportInput,
|
||||
CreateDeviceInput,
|
||||
SendNotificationInput,
|
||||
UpdateDeviceInput,
|
||||
} from './integrations.schemas.js';
|
||||
import { REPORT_DEFAULTS } from './integrations.schemas.js';
|
||||
import {
|
||||
applyReportedSchedule,
|
||||
applyReportedTimeLayout,
|
||||
toClassIslandWeekDay,
|
||||
type MirrorClassPlan,
|
||||
} from './write-back.service.js';
|
||||
|
||||
/**
|
||||
* ClassIsland 联动服务。
|
||||
*
|
||||
* 两条链路:
|
||||
* 1) **ClassHelper → ClassIsland**:教师发通知 → 落库 + Socket.IO 广播 →
|
||||
* 教室机器上的插件弹出全屏提醒(离线时先落库,插件重连后拉取补发);
|
||||
* 2) **ClassIsland → ClassHelper**:插件把"当前上什么课 + 全量课表"上报 →
|
||||
* 本服务按设备令牌解析出所属班级并写库 → 广播 `classisland:state` 给 Web 端实时展示。
|
||||
*
|
||||
* 权限口径:
|
||||
* - 设备管理(创建/重置令牌/启停):与"课表管理"一致(管理员或本班班主任);
|
||||
* - 下发提醒:与"发通知"一致(管理员 / 班主任 / 科任老师)。
|
||||
*/
|
||||
|
||||
/* ------------------------------------------------------------------ 设备管理(Web 端,JWT 鉴权) */
|
||||
|
||||
/** 设备记录 -> DTO(tokenHash 永不出库) */
|
||||
function toDeviceDto(record: {
|
||||
id: string;
|
||||
classId: string;
|
||||
name: string;
|
||||
deviceKey: string;
|
||||
mode: string;
|
||||
enabled: boolean;
|
||||
syncScheduleToServer: boolean;
|
||||
mirrorScheduleToClassIsland: boolean;
|
||||
classIslandVersion: string | null;
|
||||
pluginVersion: string | null;
|
||||
lastSeenAt: Date | null;
|
||||
classPlanLoaded: boolean;
|
||||
currentSubject: string | null;
|
||||
currentTimeState: string | null;
|
||||
currentPeriodStart: string | null;
|
||||
currentPeriodEnd: string | null;
|
||||
nextSubject: string | null;
|
||||
tokenHint: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
class?: { name: string } | null;
|
||||
}): IntegrationDeviceDto {
|
||||
return {
|
||||
id: record.id,
|
||||
classId: record.classId,
|
||||
className: record.class?.name ?? null,
|
||||
name: record.name,
|
||||
deviceKey: record.deviceKey,
|
||||
mode: record.mode === 'import' ? 'import' : 'plugin',
|
||||
enabled: record.enabled,
|
||||
syncScheduleToServer: record.syncScheduleToServer,
|
||||
mirrorScheduleToClassIsland: record.mirrorScheduleToClassIsland,
|
||||
classIslandVersion: record.classIslandVersion,
|
||||
pluginVersion: record.pluginVersion,
|
||||
lastSeenAt: record.lastSeenAt ? record.lastSeenAt.toISOString() : null,
|
||||
classPlanLoaded: record.classPlanLoaded,
|
||||
currentSubject: record.currentSubject,
|
||||
currentTimeState: record.currentTimeState,
|
||||
currentPeriodStart: record.currentPeriodStart,
|
||||
currentPeriodEnd: record.currentPeriodEnd,
|
||||
nextSubject: record.nextSubject,
|
||||
tokenHint: record.tokenHint,
|
||||
createdAt: record.createdAt.toISOString(),
|
||||
updatedAt: record.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listDevices(user: TokenPayload, classId?: string): Promise<IntegrationDeviceDto[]> {
|
||||
// 管理员不传 classId 时看全部;教师只看自己可访问的班级
|
||||
const scope = await resolveClassScope(user, classId);
|
||||
const records = await prisma.integrationDevice.findMany({
|
||||
where: scope.mode === 'all' ? {} : { classId: { in: scope.classIds } },
|
||||
include: { class: { select: { name: true } } },
|
||||
orderBy: [{ classId: 'asc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
return records.map(toDeviceDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新建设备:一次性返回明文令牌。
|
||||
* deviceKey 由插件上报的机器码决定,但创建时还不知道机器码,
|
||||
* 因此这里用随机值占位,插件首次上报时按 token 找到记录并回填真实机器码。
|
||||
*/
|
||||
export async function createDevice(
|
||||
user: TokenPayload,
|
||||
input: CreateDeviceInput,
|
||||
): Promise<IntegrationDeviceTokenDto> {
|
||||
await assertCanManageSchedule(user, input.classId);
|
||||
const classRecord = await prisma.class.findUnique({
|
||||
where: { id: input.classId },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
if (!classRecord) throw ApiError.notFound('班级不存在');
|
||||
|
||||
const token = generateDeviceToken();
|
||||
const created = await prisma.integrationDevice.create({
|
||||
data: {
|
||||
classId: input.classId,
|
||||
name: input.name?.trim() || `${classRecord.name} ClassIsland 设备`,
|
||||
deviceKey: `pending-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
|
||||
mode: input.mode ?? 'plugin',
|
||||
tokenHash: hashDeviceToken(token),
|
||||
tokenHint: tokenHintOf(token),
|
||||
syncScheduleToServer: input.syncScheduleToServer !== false,
|
||||
mirrorScheduleToClassIsland: input.mirrorScheduleToClassIsland === true,
|
||||
},
|
||||
include: { class: { select: { name: true } } },
|
||||
});
|
||||
|
||||
logger.info(`ClassIsland 联动设备已创建:班级=${classRecord.name} 设备=${created.name}`);
|
||||
return { device: toDeviceDto(created), token };
|
||||
}
|
||||
|
||||
async function findDeviceOrFail(id: string): Promise<{
|
||||
id: string;
|
||||
classId: string;
|
||||
name: string;
|
||||
}> {
|
||||
const record = await prisma.integrationDevice.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, classId: true, name: true },
|
||||
});
|
||||
if (!record) throw ApiError.notFound('设备不存在或已被删除');
|
||||
return record;
|
||||
}
|
||||
|
||||
export async function updateDevice(
|
||||
user: TokenPayload,
|
||||
deviceId: string,
|
||||
input: UpdateDeviceInput,
|
||||
): Promise<IntegrationDeviceDto> {
|
||||
const current = await findDeviceOrFail(deviceId);
|
||||
await assertCanManageSchedule(user, current.classId);
|
||||
|
||||
const updated = await prisma.integrationDevice.update({
|
||||
where: { id: deviceId },
|
||||
data: {
|
||||
...(input.name !== undefined ? { name: input.name.trim() || current.name } : {}),
|
||||
...(input.enabled !== undefined ? { enabled: input.enabled } : {}),
|
||||
...(input.mode !== undefined ? { mode: input.mode } : {}),
|
||||
...(input.syncScheduleToServer !== undefined
|
||||
? { syncScheduleToServer: input.syncScheduleToServer }
|
||||
: {}),
|
||||
...(input.mirrorScheduleToClassIsland !== undefined
|
||||
? { mirrorScheduleToClassIsland: input.mirrorScheduleToClassIsland }
|
||||
: {}),
|
||||
},
|
||||
include: { class: { select: { name: true } } },
|
||||
});
|
||||
logger.info(`ClassIsland 联动设备已更新:${updated.name} enabled=${updated.enabled}`);
|
||||
return toDeviceDto(updated);
|
||||
}
|
||||
|
||||
/** 重置令牌:旧令牌立即失效(插件侧需要重新填写) */
|
||||
export async function resetDeviceToken(
|
||||
user: TokenPayload,
|
||||
deviceId: string,
|
||||
): Promise<IntegrationDeviceTokenDto> {
|
||||
const current = await findDeviceOrFail(deviceId);
|
||||
await assertCanManageSchedule(user, current.classId);
|
||||
|
||||
const token = generateDeviceToken();
|
||||
const updated = await prisma.integrationDevice.update({
|
||||
where: { id: deviceId },
|
||||
data: { tokenHash: hashDeviceToken(token), tokenHint: tokenHintOf(token) },
|
||||
include: { class: { select: { name: true } } },
|
||||
});
|
||||
logger.warn(`ClassIsland 联动设备令牌已重置:${updated.name}(旧令牌立即失效)`);
|
||||
return { device: toDeviceDto(updated), token };
|
||||
}
|
||||
|
||||
export async function deleteDevice(user: TokenPayload, deviceId: string): Promise<void> {
|
||||
const current = await findDeviceOrFail(deviceId);
|
||||
await assertCanManageSchedule(user, current.classId);
|
||||
await prisma.integrationDevice.delete({ where: { id: deviceId } });
|
||||
logger.info(`ClassIsland 联动设备已删除:${current.name}`);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 提醒下发(Web 端 → ClassIsland) */
|
||||
|
||||
function toPushDto(
|
||||
record: {
|
||||
id: string;
|
||||
classId: string;
|
||||
title: string;
|
||||
content: string;
|
||||
durationSeconds: number;
|
||||
speechContent: string | null;
|
||||
urgent: boolean;
|
||||
createdAt: Date;
|
||||
},
|
||||
extras: { className?: string | null; teacherName?: string | null } = {},
|
||||
): ClassIslandPushNotification {
|
||||
return {
|
||||
id: record.id,
|
||||
title: record.title,
|
||||
content: record.content,
|
||||
durationSeconds: record.durationSeconds,
|
||||
speechContent: record.speechContent,
|
||||
createdAt: record.createdAt.toISOString(),
|
||||
urgent: record.urgent,
|
||||
classId: record.classId,
|
||||
className: extras.className ?? null,
|
||||
teacherName: extras.teacherName ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 下发一条提醒到该班级的 ClassIsland 设备。
|
||||
*
|
||||
* - 落库(ClassIslandPush)→ 在线设备通过 Socket.IO 立即收到;离线设备重连后走
|
||||
* `GET /api/integrations/classisland/pending` 补齐,保证"老师按了发送就一定送到";
|
||||
* - `saveToNotifications` 默认 true:同时写一条班级通知,学生端通知中心/灵动岛也能看到,
|
||||
* 避免"只在 ClassIsland 上弹了一下,事后查无此事"。
|
||||
*/
|
||||
export async function sendNotification(
|
||||
user: TokenPayload,
|
||||
input: SendNotificationInput,
|
||||
): Promise<SendClassIslandNotificationResult> {
|
||||
await assertCanPublishContent(user, input.classId);
|
||||
|
||||
const teacher = await prisma.user.findUnique({
|
||||
where: { id: user.sub },
|
||||
select: { name: true, username: true },
|
||||
});
|
||||
const teacherName = teacher?.name ?? teacher?.username ?? null;
|
||||
|
||||
const priority = input.priority ?? 'NORMAL';
|
||||
let notificationId: string | null = null;
|
||||
|
||||
// 教室在客户端里选了"只在 ClassHelper 客户端显示"时,**任何入口**都不再推 ClassIsland
|
||||
// (包括这个专门发给 ClassIsland 的入口)—— 规则只有一条,老师不用记"哪个页面有例外"。
|
||||
// 仍然把通知落库,并把跳过原因回给调用方,由 Web 端提示"不是没送达,是教室没要"。
|
||||
const allowed = await shouldPushToClassIsland(input.classId);
|
||||
|
||||
if (input.saveToNotifications !== false) {
|
||||
const created = await prisma.notification.create({
|
||||
data: {
|
||||
classId: input.classId,
|
||||
title: input.title,
|
||||
content: input.content,
|
||||
priority,
|
||||
createdBy: user.sub,
|
||||
},
|
||||
include: { creator: { select: { id: true, name: true, username: true } }, reads: true },
|
||||
});
|
||||
notificationId = created.id;
|
||||
const dto: NotificationDto = toNotificationDto(created, { userId: user.sub, withStatus: true });
|
||||
// 学生端(桌面客户端 / 班级设备)照旧收到通知中心的实时推送
|
||||
emitToClass(input.classId, SOCKET_EVENTS.notificationNew, dto);
|
||||
}
|
||||
|
||||
if (!allowed) {
|
||||
logger.info(`ClassIsland 提醒被跳过:班级=${input.classId} 已选择"只在 ClassHelper 客户端显示"`);
|
||||
return { delivered: 0, targetCount: 0, notificationId, skipped: 'channel-client' };
|
||||
}
|
||||
|
||||
const result = await pushToClassIsland({
|
||||
classId: input.classId,
|
||||
title: input.title,
|
||||
content: input.content,
|
||||
durationSeconds: input.durationSeconds,
|
||||
speechContent: input.speech
|
||||
? input.speechContent?.trim() || `${input.title}。${input.content}`
|
||||
: null,
|
||||
urgent: priority === 'URGENT',
|
||||
createdBy: user.sub,
|
||||
notificationId,
|
||||
teacherName,
|
||||
});
|
||||
|
||||
return { ...result, notificationId, skipped: null };
|
||||
}
|
||||
|
||||
/** 推送所需的入参:三个发布入口(联动页 / 通知发布 / 叫人)共用同一套语义 */
|
||||
export interface PushToClassIslandInput {
|
||||
classId: string;
|
||||
title: string;
|
||||
content: string;
|
||||
/** 显示时长(秒),留空用默认值 */
|
||||
durationSeconds?: number;
|
||||
/** 语音朗读内容;null = 不朗读 */
|
||||
speechContent?: string | null;
|
||||
urgent?: boolean;
|
||||
createdBy?: string | null;
|
||||
/** 若这次提醒也落了通知中心的库,把通知 id 带上,便于事后追溯 */
|
||||
notificationId?: string | null;
|
||||
/** 覆盖落库/广播时附带的教师名(三个入口的取法不同) */
|
||||
teacherName?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一条提醒推给某个班的 ClassIsland 设备(落库 + 广播)。
|
||||
*
|
||||
* 这是**三个发布入口的唯一共用实现**:
|
||||
* 1. 联动页的「下发提醒」(sendNotification);
|
||||
* 2. 通知发布页的 POST /notifications;
|
||||
* 3. 叫人 POST /calls。
|
||||
* 因此"换个页面发通知就联动不到 ClassIsland"这类不一致不会再出现。
|
||||
*
|
||||
* 调用方负责权限校验与(必要时)显示位置开关判断。
|
||||
*/
|
||||
export async function pushToClassIsland(
|
||||
input: PushToClassIslandInput,
|
||||
): Promise<{ delivered: number; targetCount: number }> {
|
||||
const classRecord = await prisma.class.findUnique({
|
||||
where: { id: input.classId },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
if (!classRecord) throw ApiError.notFound('班级不存在');
|
||||
|
||||
const devices = await prisma.integrationDevice.findMany({
|
||||
where: { classId: input.classId, enabled: true, mode: 'plugin' },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
// 一个已接入的设备都没有时**不落推送记录**:
|
||||
// 否则任何"发通知"的动作(通知发布 / 叫人 / 冒烟脚本)都会给该班堆一条待提醒,
|
||||
// 等这台教室真的装上插件时被一次性补发一堆过期内容。
|
||||
// 教室机器只是离线(设备存在但没上报)的情况不受影响 —— 那正是"离线补齐"要覆盖的场景。
|
||||
if (devices.length === 0) {
|
||||
logger.info(`跳过 ClassIsland 推送:班级=${classRecord.name} 还没有已接入的设备`);
|
||||
return { delivered: 0, targetCount: 0 };
|
||||
}
|
||||
|
||||
const push = await prisma.classIslandPush.create({
|
||||
data: {
|
||||
classId: input.classId,
|
||||
title: input.title,
|
||||
content: input.content,
|
||||
durationSeconds: input.durationSeconds ?? REPORT_DEFAULTS.notificationDuration,
|
||||
speechContent: input.speechContent ?? null,
|
||||
urgent: input.urgent === true,
|
||||
createdBy: input.createdBy ?? null,
|
||||
notificationId: input.notificationId ?? null,
|
||||
// 24 小时未送达/未确认的提醒不再补发,避免学生过几天突然看到旧提醒
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
emitToClass(input.classId, SOCKET_EVENTS.classislandNotification, {
|
||||
...toPushDto(push, { className: classRecord.name, teacherName: input.teacherName ?? null }),
|
||||
targetCount: devices.length,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`ClassIsland 提醒下发:班级=${classRecord.name} 设备数=${devices.length} 标题="${input.title}"`,
|
||||
);
|
||||
return { delivered: devices.length, targetCount: devices.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* 班级的通知显示位置(由教室的班级客户端在设置页里选)。
|
||||
*
|
||||
* - `client`:教室里选了"只在 ClassHelper 客户端弹",因此**不要**推 ClassIsland;
|
||||
* - 其余(both / classisland / 非法值)都推 —— 非法值按默认 both 处理,宁可多送一次也不要静默丢提醒。
|
||||
*/
|
||||
export async function shouldPushToClassIsland(classId: string): Promise<boolean> {
|
||||
const record = await prisma.class.findUnique({
|
||||
where: { id: classId },
|
||||
select: { notificationChannel: true },
|
||||
});
|
||||
return toNotificationChannel(record?.notificationChannel) !== 'client';
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 插件侧(设备令牌鉴权) */
|
||||
|
||||
/**
|
||||
* 插件上报:状态 + 可选的全量课表/节次时间。
|
||||
*
|
||||
* 关键点:**班级由设备令牌决定**,插件无法指定 classId,
|
||||
* 因此一台被绑定到 A 班的机器不可能把课表写进 B 班。
|
||||
*/
|
||||
export async function applyReport(
|
||||
device: DeviceContext,
|
||||
input: ClassIslandReportInput,
|
||||
): Promise<ClassIslandReportResult> {
|
||||
const now = new Date();
|
||||
const week = resolveCurrentWeek(env.termStartDate, now);
|
||||
|
||||
// 1) 状态快照(无论课表是否上报都更新,Web 端据此显示"现在上什么课")
|
||||
const state = input.state;
|
||||
await prisma.integrationDevice.update({
|
||||
where: { id: device.deviceId },
|
||||
data: {
|
||||
lastSeenAt: now,
|
||||
...(input.pluginVersion ? { pluginVersion: input.pluginVersion } : {}),
|
||||
...(input.classIslandVersion ? { classIslandVersion: input.classIslandVersion } : {}),
|
||||
...(state
|
||||
? {
|
||||
classPlanLoaded: state.classPlanLoaded === true,
|
||||
currentSubject: state.subject ?? null,
|
||||
currentTimeState: state.timeState ?? null,
|
||||
currentPeriodStart: state.periodStart ?? null,
|
||||
currentPeriodEnd: state.periodEnd ?? null,
|
||||
nextSubject: state.nextSubject ?? null,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (state) {
|
||||
emitToRooms([SOCKET_ROOMS.class(device.classId), SOCKET_ROOMS.teachers], SOCKET_EVENTS.classislandState, {
|
||||
classId: device.classId,
|
||||
deviceId: device.deviceId,
|
||||
deviceName: device.name,
|
||||
inClass: state.inClass === true,
|
||||
subject: state.subject ?? null,
|
||||
nextSubject: state.nextSubject ?? null,
|
||||
timeState: state.timeState ?? null,
|
||||
periodStart: state.periodStart ?? null,
|
||||
periodEnd: state.periodEnd ?? null,
|
||||
week: state.week ?? week,
|
||||
classPlanLoaded: state.classPlanLoaded === true,
|
||||
at: now.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// 2) 课表写入(受设备开关与班级设置双重约束)
|
||||
let scheduleApplied = false;
|
||||
let scheduleCreated = 0;
|
||||
let scheduleUpdated = 0;
|
||||
let courses: string[] = [];
|
||||
if (input.schedule && input.schedule.entries.length > 0) {
|
||||
if (!device.syncScheduleToServer) {
|
||||
logger.warn(`ClassIsland 上报课表被忽略:设备「${device.name}」的"回传课表到服务端"开关已关闭`);
|
||||
} else {
|
||||
const result = await applyReportedSchedule(
|
||||
device.classId,
|
||||
input.schedule.mode ?? REPORT_DEFAULTS.scheduleMode,
|
||||
input.schedule.entries,
|
||||
);
|
||||
scheduleApplied = result.applied;
|
||||
scheduleCreated = result.created;
|
||||
scheduleUpdated = result.updated;
|
||||
courses = result.courses;
|
||||
if (result.applied) {
|
||||
emitToClass(device.classId, SOCKET_EVENTS.scheduleUpdated, {
|
||||
classId: device.classId,
|
||||
action: 'imported',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 节次时间表写入
|
||||
let timeLayoutApplied = false;
|
||||
if (input.timeLayout && input.timeLayout.items.length > 0) {
|
||||
const result = await applyReportedTimeLayout(
|
||||
device.classId,
|
||||
input.timeLayout.name?.trim() || REPORT_DEFAULTS.timeLayoutName,
|
||||
input.timeLayout.items,
|
||||
);
|
||||
timeLayoutApplied = result.applied;
|
||||
}
|
||||
|
||||
// 4) 取出尚未确认的下发提醒(插件刚启动时把漏掉的提醒立刻弹出来)
|
||||
const pending = await pendingNotificationFor(device.deviceId, device.classId);
|
||||
|
||||
return {
|
||||
scheduleApplied,
|
||||
scheduleCreated,
|
||||
scheduleUpdated,
|
||||
courses,
|
||||
timeLayoutApplied,
|
||||
week,
|
||||
serverTime: now.toISOString(),
|
||||
settings: {
|
||||
mirrorScheduleToClassIsland: device.mirrorScheduleToClassIsland,
|
||||
enabled: device.enabled,
|
||||
},
|
||||
pendingNotification: pending,
|
||||
};
|
||||
}
|
||||
|
||||
/** 该设备/该班级最近一条尚未确认、未过期的下发提醒 */
|
||||
async function pendingNotificationFor(
|
||||
deviceId: string,
|
||||
classId: string,
|
||||
): Promise<ClassIslandPushNotification | null> {
|
||||
const record = await prisma.classIslandPush.findFirst({
|
||||
where: {
|
||||
classId,
|
||||
ackedAt: null,
|
||||
expiresAt: { gt: new Date() },
|
||||
OR: [{ deviceId: null }, { deviceId }],
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
if (!record) return null;
|
||||
return toPushDto(record);
|
||||
}
|
||||
|
||||
/** 插件重连/定时轮询:拉取本班所有未确认的提醒 */
|
||||
export async function listPendingNotifications(device: DeviceContext): Promise<ClassIslandPendingResult> {
|
||||
const now = new Date();
|
||||
const records = await prisma.classIslandPush.findMany({
|
||||
where: {
|
||||
classId: device.classId,
|
||||
ackedAt: null,
|
||||
expiresAt: { gt: now },
|
||||
OR: [{ deviceId: null }, { deviceId: device.deviceId }],
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: 20,
|
||||
});
|
||||
return {
|
||||
notifications: records.map((item) => toPushDto(item)),
|
||||
serverTime: now.toISOString(),
|
||||
week: resolveCurrentWeek(env.termStartDate, now),
|
||||
};
|
||||
}
|
||||
|
||||
/** 插件确认提醒已弹出(之后不再补发) */
|
||||
export async function ackNotification(device: DeviceContext, id: string): Promise<void> {
|
||||
const record = await prisma.classIslandPush.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, classId: true, deviceId: true },
|
||||
});
|
||||
if (!record) throw ApiError.notFound('提醒不存在');
|
||||
if (record.classId !== device.classId) throw ApiError.forbidden('该提醒不属于本设备所在班级');
|
||||
await prisma.classIslandPush.update({
|
||||
where: { id },
|
||||
data: { ackedAt: new Date(), ...(record.deviceId ? {} : { deviceId: device.deviceId }) },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件拉取本班课表(mirrorScheduleToClassIsland 打开时,
|
||||
* 插件把它转换后覆盖写入 ClassIsland,实现"教师在 Web 端排课 → 教室课表跟着变")。
|
||||
*/
|
||||
export async function pullClassPlanForDevice(device: DeviceContext): Promise<{
|
||||
classPlan: MirrorClassPlan;
|
||||
week: number;
|
||||
serverTime: string;
|
||||
}> {
|
||||
const now = new Date();
|
||||
const week = resolveCurrentWeek(env.termStartDate, now);
|
||||
|
||||
const classRecord = await prisma.class.findUnique({
|
||||
where: { id: device.classId },
|
||||
select: { id: true, name: true, termWeeks: true },
|
||||
});
|
||||
if (!classRecord) throw ApiError.notFound('班级不存在');
|
||||
|
||||
const [schedules, timeLayouts] = await Promise.all([
|
||||
prisma.schedule.findMany({
|
||||
where: { classId: device.classId },
|
||||
include: { course: { select: { id: true, name: true } } },
|
||||
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
|
||||
}),
|
||||
prisma.timeLayout.findMany({
|
||||
where: { classId: device.classId },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
}),
|
||||
]);
|
||||
|
||||
// 节次时间表:优先用班级里已导入的 ClassIsland 时间表,其次按课表的开始时间合成
|
||||
const layoutId = `ch-${device.classId}`;
|
||||
const layoutName = timeLayouts[0]?.name ?? '班级小助手时间表';
|
||||
let layouts: { startTime: string; endTime: string; timeType: number }[] = [];
|
||||
if (timeLayouts.length > 0 && timeLayouts[0]) {
|
||||
layouts = parseStoredTimeLayout(timeLayouts[0].items).map((item) => ({
|
||||
startTime: item.startTime,
|
||||
endTime: item.endTime,
|
||||
// 本系统的 class->0(上课)、break->1(课间)、divider->2(分割线)
|
||||
timeType: item.type === 'break' ? 1 : item.type === 'divider' ? 2 : 0,
|
||||
}));
|
||||
} else {
|
||||
layouts = synthesizeTimeLayout(schedules);
|
||||
}
|
||||
|
||||
const entries = schedules.map((item) => {
|
||||
const parity = weekDivFromParity((item.weekParity as 'ALL' | 'ODD' | 'EVEN' | null) ?? 'ALL');
|
||||
return {
|
||||
weekDay: toClassIslandWeekDay(item.dayOfWeek),
|
||||
weekCountDiv: parity.weekCountDiv,
|
||||
weekCountDivTotal: parity.weekCountDivTotal,
|
||||
subject: item.course?.name ?? '未命名科目',
|
||||
teacherName: null,
|
||||
startTime: item.startTime,
|
||||
endTime: item.endTime,
|
||||
timeLayoutId: layoutId,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
classPlan: {
|
||||
entries,
|
||||
timeLayouts: [{ id: layoutId, name: layoutName, layouts }],
|
||||
profileName: `班级小助手-${classRecord.name}`,
|
||||
termStartDate: env.termStartDate,
|
||||
},
|
||||
week,
|
||||
serverTime: now.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** 解析库里存的节次 JSON(容错:坏数据不抛异常,退化为空) */
|
||||
function parseStoredTimeLayout(raw: string): { startTime: string; endTime: string; type: string }[] {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed
|
||||
.map((item) => item as Partial<TimeLayoutEntry>)
|
||||
.filter(
|
||||
(item): item is Partial<TimeLayoutEntry> & { startTime: string; endTime: string } =>
|
||||
typeof item.startTime === 'string' && typeof item.endTime === 'string',
|
||||
)
|
||||
.map((item) => ({
|
||||
startTime: item.startTime,
|
||||
endTime: item.endTime,
|
||||
type: typeof item.type === 'string' ? item.type : 'class',
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** 没有导入节次时间表时,用当天课表里出现过的起止时间合成一份(去重并按时间排序) */
|
||||
function synthesizeTimeLayout(
|
||||
schedules: { startTime: string; endTime: string }[],
|
||||
): { startTime: string; endTime: string; timeType: number }[] {
|
||||
const seen = new Map<string, string>();
|
||||
for (const item of schedules) {
|
||||
if (!seen.has(item.startTime)) seen.set(item.startTime, item.endTime);
|
||||
}
|
||||
return [...seen.entries()]
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([startTime, endTime]) => ({ startTime, endTime, timeType: 0 }));
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 供仪表盘/统计使用 */
|
||||
|
||||
/** 某个班级当前是否已有在线设备(用于 Web 端提示"该班尚未接入 ClassIsland") */
|
||||
export async function countEnabledDevices(classIds: string[]): Promise<number> {
|
||||
if (classIds.length === 0) return 0;
|
||||
return prisma.integrationDevice.count({
|
||||
where: { classId: { in: classIds }, enabled: true, mode: 'plugin' },
|
||||
});
|
||||
}
|
||||
|
||||
/** 供 ClassDto 附加"是否已接入 ClassIsland"(Web 端班级列表里显示徽标) */
|
||||
export async function attachIntegrationFlag(classes: ClassDto[]): Promise<ClassDto[]> {
|
||||
if (classes.length === 0) return classes;
|
||||
const ids = classes.map((item) => item.id);
|
||||
const devices = await prisma.integrationDevice.findMany({
|
||||
where: { classId: { in: ids }, enabled: true },
|
||||
select: { classId: true },
|
||||
});
|
||||
const connected = new Set(devices.map((item) => item.classId));
|
||||
return classes.map((item) => ({ ...item, classIslandConnected: connected.has(item.id) }));
|
||||
}
|
||||
|
||||
export type { ClassIslandScheduleEntry };
|
||||
@@ -0,0 +1,222 @@
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
import { weekParityFromDiv } from '@classhelper/shared';
|
||||
import type { ReportScheduleEntry, ReportTimeLayoutItem } from './integrations.schemas.js';
|
||||
|
||||
/**
|
||||
* 插件上报的课表 / 节次时间 → 写入本服务。
|
||||
*
|
||||
* 设计要点:
|
||||
* 1) **复用导入模块的口径**:与 `modules/imports/class-plan.service.ts`、
|
||||
* `time-layout.service.ts` 完全一致 —— 缺失的科目自动补建课程(任课老师取班主任)、
|
||||
* merge 按 `星期 + 开始时间 + 单双周` 去重、replace 先清空再写入。
|
||||
* 这样"插件实时上报"和"手动导入 JSON"落到库里的结果是一样的,不会出现两套语义。
|
||||
* 2) **幂等**:插件每次上报(启动、课表变化、定时)都会带全量课表,
|
||||
* 因此 merge 必须是"存在则更新、不存在则新增",重复上报不会产生重复行。
|
||||
* 3) 整个写入放在一个事务里:解析/校验失败的批次不会留下半截数据。
|
||||
*/
|
||||
|
||||
export interface ScheduleWriteResult {
|
||||
applied: boolean;
|
||||
created: number;
|
||||
updated: number;
|
||||
courses: string[];
|
||||
weekStart: number;
|
||||
weekEnd: number;
|
||||
}
|
||||
|
||||
/** 单双周口径:优先用插件显式给的 weekParity,否则从 WeekCountDiv/Total 推导 */
|
||||
function resolveParity(entry: ReportScheduleEntry): 'ALL' | 'ODD' | 'EVEN' {
|
||||
if (entry.weekParity === 'ALL' || entry.weekParity === 'ODD' || entry.weekParity === 'EVEN') {
|
||||
return entry.weekParity;
|
||||
}
|
||||
const { weekCountDiv = 0, weekCountDivTotal = 0 } = entry;
|
||||
return weekParityFromDiv(weekCountDiv, weekCountDivTotal);
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入课表。classId 由**设备令牌**决定(不接受插件指定),因此插件无法越权写别的班级。
|
||||
*/
|
||||
export async function applyReportedSchedule(
|
||||
classId: string,
|
||||
mode: 'replace' | 'merge',
|
||||
entries: ReportScheduleEntry[],
|
||||
): Promise<ScheduleWriteResult> {
|
||||
const empty: ScheduleWriteResult = {
|
||||
applied: false,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
courses: [],
|
||||
weekStart: 1,
|
||||
weekEnd: 20,
|
||||
};
|
||||
if (entries.length === 0) return empty;
|
||||
|
||||
const classRecord = await prisma.class.findUnique({
|
||||
where: { id: classId },
|
||||
select: { id: true, teacherId: true, termWeeks: true },
|
||||
});
|
||||
if (!classRecord) {
|
||||
logger.warn(`ClassIsland 上报:班级不存在,已丢弃本批课表(classId=${classId})`);
|
||||
return empty;
|
||||
}
|
||||
|
||||
// 起止周次:与导入模块保持一致(weekStart 由班级进度决定,weekEnd 取班级教学周数)
|
||||
const weekStart = 1;
|
||||
const weekEnd = classRecord.termWeeks > 0 ? classRecord.termWeeks : 20;
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
// 1) 补齐缺失的课程(任课老师先用班主任占位,老师可在课表管理里改)
|
||||
const existingCourses = await tx.course.findMany({
|
||||
where: { classId },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
const courseIdByName = new Map(existingCourses.map((item) => [item.name, item.id]));
|
||||
const createdCourses: string[] = [];
|
||||
for (const name of new Set(entries.map((item) => item.subject))) {
|
||||
if (courseIdByName.has(name)) continue;
|
||||
const created = await tx.course.create({
|
||||
data: { classId, name, teacherId: classRecord.teacherId },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
courseIdByName.set(created.name, created.id);
|
||||
createdCourses.push(created.name);
|
||||
}
|
||||
|
||||
// 2) replace:先清空该班课表(与导入模块的 replace 语义一致)
|
||||
if (mode === 'replace') {
|
||||
await tx.schedule.deleteMany({ where: { classId } });
|
||||
}
|
||||
|
||||
// 3) 逐条 upsert:merge 的去重键是「星期 + 开始时间 + 单双周」
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
for (const entry of entries) {
|
||||
const courseId = courseIdByName.get(entry.subject);
|
||||
if (!courseId) continue;
|
||||
const weekParity = resolveParity(entry);
|
||||
const existing = await tx.schedule.findFirst({
|
||||
where: { classId, dayOfWeek: entry.dayOfWeek, startTime: entry.startTime, weekParity },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) {
|
||||
await tx.schedule.update({
|
||||
where: { id: existing.id },
|
||||
data: { courseId, endTime: entry.endTime, weekStart, weekEnd },
|
||||
});
|
||||
updated += 1;
|
||||
} else {
|
||||
await tx.schedule.create({
|
||||
data: {
|
||||
classId,
|
||||
courseId,
|
||||
dayOfWeek: entry.dayOfWeek,
|
||||
startTime: entry.startTime,
|
||||
endTime: entry.endTime,
|
||||
location: null,
|
||||
weekStart,
|
||||
weekEnd,
|
||||
weekParity,
|
||||
},
|
||||
});
|
||||
created += 1;
|
||||
}
|
||||
}
|
||||
return { created, updated, createdCourses, allCourses: [...courseIdByName.keys()] };
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`ClassIsland 上报课表:班级=${classId} 模式=${mode} 新增=${result.created} 更新=${result.updated} ` +
|
||||
`补建科目=[${result.createdCourses.join(',')}]`,
|
||||
);
|
||||
return {
|
||||
applied: true,
|
||||
created: result.created,
|
||||
updated: result.updated,
|
||||
courses: result.allCourses,
|
||||
weekStart,
|
||||
weekEnd,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入节次时间表(TimeLayout)。
|
||||
* 与 `modules/imports/time-layout.service.ts` 的口径保持一致:
|
||||
* 同一班级同名配置整体替换;没有则新建。
|
||||
* 注意:`action`(ClassIsland TimeType=3,行动)在本系统里没有对应节次类型,
|
||||
* 写入时降级为 `divider`(不占课时,只做视觉分隔),避免类型校验失败。
|
||||
*/
|
||||
export async function applyReportedTimeLayout(
|
||||
classId: string,
|
||||
name: string,
|
||||
items: ReportTimeLayoutItem[],
|
||||
): Promise<{ applied: boolean; count: number }> {
|
||||
if (items.length === 0) return { applied: false, count: 0 };
|
||||
|
||||
const normalized = items.map((item, index) => ({
|
||||
index: item.index && item.index > 0 ? item.index : index + 1,
|
||||
name: item.name?.trim() || `第 ${index + 1} 节`,
|
||||
startTime: item.startTime,
|
||||
endTime: item.endTime,
|
||||
type: item.type === 'action' ? 'divider' : (item.type ?? 'class'),
|
||||
skipped: item.skipped === true,
|
||||
}));
|
||||
|
||||
const current = await prisma.timeLayout.findFirst({
|
||||
where: { classId, name },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (current) {
|
||||
await prisma.timeLayout.update({
|
||||
where: { id: current.id },
|
||||
data: { items: JSON.stringify(normalized), source: 'classisland' },
|
||||
});
|
||||
} else {
|
||||
await prisma.timeLayout.create({
|
||||
data: { classId, name, source: 'classisland', items: JSON.stringify(normalized) },
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(`ClassIsland 上报节次时间表:班级=${classId} 名称=${name} 共 ${normalized.length} 节`);
|
||||
return { applied: true, count: normalized.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* 把本服务的课表转换成 ClassIsland 的档案口径(供插件拉回去镜像)。
|
||||
*
|
||||
* 关键换算:
|
||||
* - 星期:本系统 1=周一…7=周日 → ClassIsland 0=周日、1=周一…6=周六;
|
||||
* - 节次:ClassIsland 的 Classes[i] 对应 Layouts 里第 i 个 TimeType=0(上课)的点,
|
||||
* 因此这里必须**按节次顺序**生成 layouts,并把每个 Schedule 归到"开始时间最接近且不晚于它"的节次上;
|
||||
* - 单双周:ALL→(0,0);ODD→(1,2);EVEN→(2,2),与导入模块的解析互逆。
|
||||
*/
|
||||
export interface MirrorClassPlan {
|
||||
entries: {
|
||||
weekDay: number;
|
||||
weekCountDiv: number;
|
||||
weekCountDivTotal: number;
|
||||
subject: string;
|
||||
teacherName: string | null;
|
||||
/**
|
||||
* 该课目的起止时间(HH:mm)。插件靠它重建 ClassIsland 的时间表:
|
||||
* `ClassPlan.Classes[i]` 必须与时间表里第 i 个上课点一一对应,缺了时间就整份错位。
|
||||
*/
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
timeLayoutId: string;
|
||||
}[];
|
||||
timeLayouts: {
|
||||
id: string;
|
||||
name: string;
|
||||
layouts: { startTime: string; endTime: string; timeType: number }[];
|
||||
}[];
|
||||
profileName: string;
|
||||
termStartDate: string;
|
||||
}
|
||||
|
||||
/** 本系统 dayOfWeek(1=周一…7=周日)→ ClassIsland WeekDay(0=周日…6=周六) */
|
||||
export function toClassIslandWeekDay(dayOfWeek: number): number {
|
||||
return dayOfWeek % 7;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { logger } from '../../lib/logger.js';
|
||||
import { toNotificationDto } from '../../lib/mappers.js';
|
||||
import { personalIdWhere, resolvePersonalIds } from '../../lib/session.js';
|
||||
import { emitToClass } from '../../realtime/bus.js';
|
||||
import { pushToClassIsland, shouldPushToClassIsland } from '../integrations/integrations.service.js';
|
||||
import { computeClassStatus } from '../schedules/schedules.service.js';
|
||||
import type { CreateNotificationInput } from './notifications.schemas.js';
|
||||
|
||||
@@ -97,9 +98,57 @@ export async function createNotification(
|
||||
|
||||
const dto = toNotificationDto(created, { userId: user.sub, withStatus: true });
|
||||
emitToClass(created.classId, SOCKET_EVENTS.notificationNew, dto);
|
||||
|
||||
// 同一条通知也推到教室的 ClassIsland 上("老师发通知 → 教室大屏弹出"不该只在联动页生效)。
|
||||
// 是否真的推送由**教室客户端**在设置页里选的显示位置决定(见 Class.notificationChannel):
|
||||
// 选了"只弹 ClassHelper 客户端"就不打扰 ClassIsland。
|
||||
await pushToClassIslandIfEnabled({
|
||||
classId: created.classId,
|
||||
title: created.title,
|
||||
content: created.content,
|
||||
priority,
|
||||
createdBy: user.sub,
|
||||
notificationId: created.id,
|
||||
teacherName: created.creator?.name ?? created.creator?.username ?? null,
|
||||
});
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知类发布入口的公共尾巴:按班级的显示位置决定要不要推 ClassIsland。
|
||||
*
|
||||
* 推失败**不能**影响通知本身已经发布成功这件事 —— 教室机器离线、插件没装都不该让老师收到 500,
|
||||
* 因此这里把异常收敛成日志告警。
|
||||
*/
|
||||
async function pushToClassIslandIfEnabled(input: {
|
||||
classId: string;
|
||||
title: string;
|
||||
content: string;
|
||||
priority: string;
|
||||
createdBy: string;
|
||||
notificationId: string;
|
||||
teacherName: string | null;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
if (!(await shouldPushToClassIsland(input.classId))) {
|
||||
logger.info(`通知未推送 ClassIsland:班级=${input.classId} 已选择"只在 ClassHelper 客户端显示"`);
|
||||
return;
|
||||
}
|
||||
await pushToClassIsland({
|
||||
classId: input.classId,
|
||||
title: input.title,
|
||||
content: input.content,
|
||||
urgent: input.priority === 'URGENT',
|
||||
createdBy: input.createdBy,
|
||||
notificationId: input.notificationId,
|
||||
teacherName: input.teacherName,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn(`通知推送 ClassIsland 失败(通知本身已发布):${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteNotification(user: TokenPayload, notificationId: string): Promise<void> {
|
||||
const current = await prisma.notification.findUnique({ where: { id: notificationId } });
|
||||
if (!current) throw ApiError.notFound('通知不存在');
|
||||
|
||||
@@ -6,6 +6,7 @@ import { dashboardModule } from './dashboard/dashboard.module.js';
|
||||
import { gradesModule } from './grades/grades.module.js';
|
||||
import { homeworksModule } from './homeworks/homeworks.module.js';
|
||||
import { importsModule } from './imports/imports.module.js';
|
||||
import { integrationsModule } from './integrations/integrations.module.js';
|
||||
import { notificationsModule } from './notifications/notifications.module.js';
|
||||
import { schedulesModule } from './schedules/schedules.module.js';
|
||||
import { studentsModule } from './students/students.module.js';
|
||||
@@ -28,6 +29,7 @@ export const apiModules: ApiModule[] = [
|
||||
notificationsModule,
|
||||
callsModule,
|
||||
importsModule,
|
||||
integrationsModule,
|
||||
gradesModule,
|
||||
studentsModule,
|
||||
teachersModule,
|
||||
|
||||
+9
@@ -15,6 +15,15 @@ declare global {
|
||||
};
|
||||
validatedQuery?: unknown;
|
||||
validatedParams?: unknown;
|
||||
/** ClassIsland 联动插件上报时解析出的设备上下文(见 modules/integrations/device-auth.ts) */
|
||||
device?: {
|
||||
deviceId: string;
|
||||
classId: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
syncScheduleToServer: boolean;
|
||||
mirrorScheduleToClassIsland: boolean;
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user