feat(shared,server,web,client,plugin)!: 角色与权限重构 + 学期周次 + 课表方向反转(1.2.0)

学生、任课关系、周次口径三处长期靠约定维持的事情,改为由数据模型保证。

学生不是账号
- 拆出独立 Student 表,没有 passwordHash / role / 任何登录字段
- 「学生不能登录」由表结构保证,不再靠「登录前判 role 再 403」的代码分支
- 三张个人数据表外键 userId → studentId;删掉 Enrollment 与 ClassTeacher
- 学生一律称「学号」、教师一律称「工号」,「学生账号」这个概念消失

任课关系唯一来源 = Course(班级 + 科目 + 教师)
- 班主任 = Class.teacherId,科任 = Course.teacherId,同一人两处指向同一账号、不重复建号
- 作业与成绩按科目再收一道:挂科目只由该科任课老师改,没挂科目归班主任 / 管理员
- 新增「任课教师分配」与「班级教师批量导入」(带「使用工号匹配」开关)

成绩与名单
- 成绩等级:百分制 / 等级制 A·B·C·D / 自定义,支持单个与批量改等级
- 按学号查成绩明细(教师端 + 班级端 + 设备令牌),受按班开关控制
- 调班 / 转出(单个与批量)+ 可查历史;学号不变、历史作业成绩留原归属
- 毕业归档页(按年度记录毕业班级 / 毕业生 / 转出学生 / 该届通知与作业)
- 学年升级(未毕业不归档、数据沿用);班级称呼改为「2026级1班」

ClassHelper 班级端在线状态
- Web 端显示 班级 / 设备名 / 设备号 / 在线离线 / 最后在线 / 最后心跳
- lastHeartbeatAt 驱动 60 秒在线窗口,与只表示「带状态上报」的 lastSeenAt 分开

学期周次
- 新增 TermWeek(classId 空串 = 全校默认),管理员可用日期范围选择器逐周配置
- 一键按开学日期生成整学期;联网拉取调休建议(只给建议、不改数据,无外网失败属正常)
- 改周次后随课表下发给教室的 ClassHelper 班级端

课表方向反转
- 主方向改为「服务端 → 教室」自动下发;教室不再自动回传
- 取教室课表改为手动动作(点击前弹警告),插件按待办标记在下一次心跳补报

改名
- 班级小助手 → ClassHelper:界面、窗口标题、托盘、灵动岛、EXE、安装包、
  安装目录、systemd 描述、插件清单与设置页、官网、文档站、README

BREAKING CHANGE: 产品名与 EXE 名变更(班级小助手.exe → ClassHelper.exe),
安装目录随之改变,不支持覆盖安装,需先卸载旧版。
服务端首次启动会自动应用 6 个数据库迁移(学生行按 id 原样搬迁,历史数据不丢),
升级前请先备份。
This commit is contained in:
LoveChengke committed 2026-10-06 19:41:18 +08:00
1 parent 17f55864bf
commit 9d5ac7adf9
172 files changed
+8761 -1896

No files matched your search

+2 -2
View File
@@ -1,8 +1,8 @@
{
"name": "@classhelper/server",
"version": "1.1.2",
"version": "1.2.0",
"private": true,
"description": "班级小助手后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问",
"description": "ClassHelper后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问",
"type": "module",
"main": "dist/index.js",
"scripts": {
@@ -0,0 +1,186 @@
-- 学生从「账号」变成「名单记录」:新建 Student 表,个人数据外键改指向它
--
-- 为什么要有这一版:
-- 在此之前学生是 `User` 表里 role='STUDENT' 的行 —— 与教师登录名共用同一个全局唯一
-- 命名空间、靠 passwordHash='' 占位、靠"登录时先判 role 再 403"来阻止登录。
-- 「学生不是账号」这条不变量只靠约定维持,任何一处漏判都会让一个学生变成可登录账号。
-- 本迁移把它变成**结构性保证**:学生只存在于 Student 表,那里没有 passwordHash、没有 role。
--
-- 语义变化:
-- 1) `User` 收缩为纯账号表(ADMIN / TEACHER / CLASS_DEVICE),去掉 classId 与 role='STUDENT';
-- 2) `Student.studentNo`(学号)= 原 `User.username`,是学生唯一标识与查询键;
-- 3) `Grade.userId` / `HomeworkStatus.userId` / `NotificationRead.userId` → `studentId`,
-- 指向 Student(值就是原来那个 User.id,因此历史数据一条不丢);
-- 4) 删除 `Enrollment`(`Student.classId` 已是唯一归属,两处双写必然漂移)
-- 与 `ClassTeacher`(没有科目的协作关系;任课关系的唯一来源改为 `Course.teacherId`);
-- 5) `User.phone`(教师联系方式)。
--
-- 数据保全:
-- - 学生行按 id 原样搬进 Student,因此 Grade / HomeworkStatus / NotificationRead 里
-- 旧 `userId` 直接就是新的 `studentId`,无需任何映射表;
-- - 三张个人数据表回填时用 `IN (SELECT id FROM Student)` 兜底:历史上若有教师/管理员
-- 误留下个人记录(例如教师给自己勾过作业完成),这些行会被丢弃而不是撞外键失败;
-- - 成绩的 `level` 按旧口径(得分率)回填,保持升级后界面显示不变。
-- DropIndex
DROP INDEX "ClassTeacher_classId_teacherId_key";
DROP INDEX "ClassTeacher_teacherId_idx";
DROP INDEX "Enrollment_userId_classId_key";
DROP INDEX "Enrollment_classId_idx";
-- DropTable
PRAGMA foreign_keys=off;
DROP TABLE "ClassTeacher";
DROP TABLE "Enrollment";
PRAGMA foreign_keys=on;
-- CreateTable
CREATE TABLE "Student" (
"id" TEXT NOT NULL PRIMARY KEY,
"studentNo" TEXT NOT NULL,
"name" TEXT NOT NULL,
"classId" TEXT,
"gender" TEXT NOT NULL DEFAULT '',
"guardianPhone" TEXT NOT NULL DEFAULT '',
"status" TEXT NOT NULL DEFAULT 'active',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Student_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- 把原「学生账号」整行搬成名单记录:id 保持不变,这样下面三张表的 userId 可以直接当 studentId 用。
INSERT INTO "Student" ("id", "studentNo", "name", "classId", "gender", "guardianPhone", "status", "createdAt", "updatedAt")
SELECT "id", "username", "name", "classId", '', '', 'active', "createdAt", "updatedAt"
FROM "User"
WHERE "role" = 'STUDENT';
CREATE TABLE "StudentClassTransfer" (
"id" TEXT NOT NULL PRIMARY KEY,
"studentId" TEXT NOT NULL,
"studentNo" TEXT NOT NULL,
"studentName" TEXT NOT NULL,
"fromClassId" TEXT,
"fromClassName" TEXT NOT NULL DEFAULT '',
"toClassId" TEXT,
"toClassName" TEXT NOT NULL DEFAULT '',
"operatorId" TEXT,
"operatorName" TEXT NOT NULL DEFAULT '',
"mode" TEXT NOT NULL DEFAULT 'single',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "StudentClassTransfer_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "Student" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Class" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"grade" TEXT NOT NULL,
"teacherId" TEXT NOT NULL,
"code" TEXT NOT NULL,
"passwordHash" TEXT,
"termWeeks" INTEGER NOT NULL DEFAULT 20,
"notificationChannel" TEXT NOT NULL DEFAULT 'both',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Class_teacherId_fkey" FOREIGN KEY ("teacherId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_Class" ("code", "createdAt", "grade", "id", "name", "notificationChannel", "passwordHash", "teacherId", "termWeeks", "updatedAt") SELECT "code", "createdAt", "grade", "id", "name", "notificationChannel", "passwordHash", "teacherId", "termWeeks", "updatedAt" FROM "Class";
DROP TABLE "Class";
ALTER TABLE "new_Class" RENAME TO "Class";
CREATE UNIQUE INDEX "Class_code_key" ON "Class"("code");
CREATE INDEX "Class_teacherId_idx" ON "Class"("teacherId");
CREATE TABLE "new_Grade" (
"id" TEXT NOT NULL PRIMARY KEY,
"classId" TEXT NOT NULL,
"courseId" TEXT,
"studentId" TEXT NOT NULL,
"examName" TEXT NOT NULL,
"score" REAL NOT NULL,
"totalScore" REAL NOT NULL DEFAULT 100,
"publishedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Grade_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "Grade_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Grade_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "Student" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_Grade" ("classId", "courseId", "createdAt", "examName", "id", "publishedAt", "score", "studentId", "totalScore", "updatedAt")
SELECT "classId", "courseId", "createdAt", "examName", "id", "publishedAt", "score", "userId", "totalScore", "updatedAt"
FROM "Grade"
WHERE "userId" IN (SELECT "id" FROM "Student");
DROP TABLE "Grade";
ALTER TABLE "new_Grade" RENAME TO "Grade";
CREATE INDEX "Grade_studentId_publishedAt_idx" ON "Grade"("studentId", "publishedAt");
CREATE INDEX "Grade_classId_examName_idx" ON "Grade"("classId", "examName");
CREATE INDEX "Grade_courseId_idx" ON "Grade"("courseId");
CREATE TABLE "new_HomeworkStatus" (
"id" TEXT NOT NULL PRIMARY KEY,
"homeworkId" TEXT NOT NULL,
"studentId" TEXT NOT NULL,
"completed" BOOLEAN NOT NULL DEFAULT false,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "HomeworkStatus_homeworkId_fkey" FOREIGN KEY ("homeworkId") REFERENCES "Homework" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "HomeworkStatus_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "Student" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_HomeworkStatus" ("completed", "homeworkId", "id", "studentId", "updatedAt")
SELECT "completed", "homeworkId", "id", "userId", "updatedAt"
FROM "HomeworkStatus"
WHERE "userId" IN (SELECT "id" FROM "Student");
DROP TABLE "HomeworkStatus";
ALTER TABLE "new_HomeworkStatus" RENAME TO "HomeworkStatus";
CREATE INDEX "HomeworkStatus_studentId_idx" ON "HomeworkStatus"("studentId");
CREATE UNIQUE INDEX "HomeworkStatus_homeworkId_studentId_key" ON "HomeworkStatus"("homeworkId", "studentId");
CREATE TABLE "new_NotificationRead" (
"id" TEXT NOT NULL PRIMARY KEY,
"notificationId" TEXT NOT NULL,
"studentId" TEXT NOT NULL,
"readAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "NotificationRead_notificationId_fkey" FOREIGN KEY ("notificationId") REFERENCES "Notification" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "NotificationRead_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "Student" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_NotificationRead" ("id", "notificationId", "readAt", "studentId")
SELECT "id", "notificationId", "readAt", "userId"
FROM "NotificationRead"
WHERE "userId" IN (SELECT "id" FROM "Student");
DROP TABLE "NotificationRead";
ALTER TABLE "new_NotificationRead" RENAME TO "NotificationRead";
CREATE INDEX "NotificationRead_studentId_idx" ON "NotificationRead"("studentId");
CREATE UNIQUE INDEX "NotificationRead_notificationId_studentId_key" ON "NotificationRead"("notificationId", "studentId");
-- User 收缩成纯账号表:只留 ADMIN / TEACHER(+ 未来的 CLASS_DEVICE),去掉 classId,加 phone。
-- 学生行在这里被丢弃 —— 它们已经在上面搬进 Student 了。
CREATE TABLE "new_User" (
"id" TEXT NOT NULL PRIMARY KEY,
"username" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"name" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'TEACHER',
"phone" TEXT NOT NULL DEFAULT '',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_User" ("createdAt", "id", "name", "passwordHash", "role", "updatedAt", "username")
SELECT "createdAt", "id", "name", "passwordHash", "role", "updatedAt", "username"
FROM "User"
WHERE "role" <> 'STUDENT';
DROP TABLE "User";
ALTER TABLE "new_User" RENAME TO "User";
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
CREATE INDEX "User_role_idx" ON "User"("role");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
-- CreateIndex
CREATE UNIQUE INDEX "Student_studentNo_key" ON "Student"("studentNo");
CREATE INDEX "Student_classId_idx" ON "Student"("classId");
CREATE INDEX "Student_name_idx" ON "Student"("name");
CREATE INDEX "StudentClassTransfer_studentId_createdAt_idx" ON "StudentClassTransfer"("studentId", "createdAt");
CREATE INDEX "StudentClassTransfer_createdAt_idx" ON "StudentClassTransfer"("createdAt");
CREATE INDEX "Course_teacherId_idx" ON "Course"("teacherId");
@@ -0,0 +1,8 @@
-- ClassHelper 班级端成绩查询开关(按班级存,默认开启)
--
-- 需求:「ClassHelper 班级端可按学号查询本班学生成绩明细;是否允许学生通过班级端查看本人明细,
-- 由教师或管理员开关控制」。
--
-- 默认 true 是刻意的:教室机器装好就能查(开箱可用),要收紧的班级由班主任/管理员关掉即可。
-- 关闭后班级端调 `GET /api/grades/student/:studentNo` 一律 403,教师端不受影响。
ALTER TABLE "Class" ADD COLUMN "studentGradeQueryEnabled" BOOLEAN NOT NULL DEFAULT true;
@@ -0,0 +1,26 @@
-- 成绩等级:落到库里,教师可单个/批量手动编辑
--
-- 在此之前等级是**前端按得分率现算**的(`@classhelper/shared` 的 `gradeLevel(percent)`),
-- 教师既看不到存下来的等级,也无法手动调整 —— 而需求要求
-- 「发布成绩时可选百分制 / 等级制 A·B·C·D / 自定义等级,且可手动编辑等级(单个 + 批量)」。
--
-- `levelType`:
-- percent 百分制 —— 等级由得分率自动换算(教师仍可手改并覆盖)
-- letter 等级制 —— 直接给 A / B / C / D
-- custom 自定义等级 —— 教师填任意文本(优 / 良 / 合格 / 待提高…)
--
-- `level` 永远有值:percent 下写入换算结果,其余两种按教师指定写入。
ALTER TABLE "Grade" ADD COLUMN "levelType" TEXT NOT NULL DEFAULT 'percent';
ALTER TABLE "Grade" ADD COLUMN "level" TEXT NOT NULL DEFAULT '';
-- 回填旧数据:按升级前的同一套口径(得分率 ≥90 A / ≥80 B / ≥70 C / ≥60 D / 其余 E)算,
-- 保证升级前后界面上看到的等级完全一致。总分非法(≤0)一律落到 E。
UPDATE "Grade"
SET "level" = CASE
WHEN "totalScore" > 0 AND ("score" * 100.0 / "totalScore") >= 90 THEN 'A'
WHEN "totalScore" > 0 AND ("score" * 100.0 / "totalScore") >= 80 THEN 'B'
WHEN "totalScore" > 0 AND ("score" * 100.0 / "totalScore") >= 70 THEN 'C'
WHEN "totalScore" > 0 AND ("score" * 100.0 / "totalScore") >= 60 THEN 'D'
ELSE 'E'
END
WHERE "level" = '';
@@ -0,0 +1,15 @@
-- ClassHelper 班级端心跳时间:把「在线判定」和「最后带状态的上报」拆成两个字段
--
-- 原先只有一个 `lastSeenAt`(每次上报都刷)。但同一台设备的上报其实有两种:
-- - **心跳**:插件每隔 60 秒打一次,只表明"我还在线";
-- - **状态上报**:带运行状态 / 课表,表明"我拿到了新东西"。
-- 合成一个字段后,教师网页无法区分「设备在线但一直没有新状态」和「设备整个失联」。
--
-- 于是:
-- `lastHeartbeatAt` 每次上报都刷新 —— **在线判定看它**(60 秒窗口,见 shared 的
-- `CLASS_HELPER_ONLINE_WINDOW_MS`);
-- `lastSeenAt` 只在带运行状态的上报时刷新 —— 展示为「最后在线时间」。
--
-- 回填:老库只有 lastSeenAt,就把它当作心跳时间的初值(否则所有设备升级后都会显示"从未心跳")。
ALTER TABLE "IntegrationDevice" ADD COLUMN "lastHeartbeatAt" DATETIME;
UPDATE "IntegrationDevice" SET "lastHeartbeatAt" = "lastSeenAt" WHERE "lastHeartbeatAt" IS NULL;
@@ -0,0 +1,145 @@
-- 毕业归档:届别档案 + 班级/学生的归档标记
--
-- 背景(需求):管理员要有一个「归档」页,按**年度**记录毕业的班级与学生、当年转出的学生,
-- 以及毕业班级发过的作业与通知;**未毕业而升级的班级不归档、数据沿用**。
--
-- 关键设计:**归档是打标记 + 只读,不删任何数据**。
-- `ArchivedYear` 是届别档案(入学年份 + 毕业年份 + 归档时的统计快照);
-- `Class.archivedYearId` / `Student.archivedYearId` 指向它,常规列表据此过滤掉归档项;
-- 毕业班级的作业与通知原样留在库里(`Homework.classId` / `Notification.classId` 不变),
-- 归档详情页只读地查它们 —— 「记录每个年度毕业班级的消息和作业」就是这么满足的。
--
-- 升级 ≠ 归档:高一 → 高二 只改 `Class.grade`,班级记录、学生名单、作业成绩全部沿用,
-- 不碰 archivedYearId。因此归档只能由管理员在归档页显式执行。
--
-- 班级称呼改成「XXXX级X班」:新列 `enrollmentYear`(入学年份)+ `classIndex`(班号),
-- `Class.name` 由服务端按二者生成。下面会对**已有班级**做一次尽力而为的推断回填。
-- CreateTable
CREATE TABLE "ArchivedYear" (
"id" TEXT NOT NULL PRIMARY KEY,
"enrollmentYear" INTEGER NOT NULL,
"graduationYear" INTEGER NOT NULL,
"name" TEXT NOT NULL,
"note" TEXT NOT NULL DEFAULT '',
"operatorId" TEXT,
"operatorName" TEXT NOT NULL DEFAULT '',
"classCount" INTEGER NOT NULL DEFAULT 0,
"studentCount" INTEGER NOT NULL DEFAULT 0,
"transferredCount" INTEGER NOT NULL DEFAULT 0,
"homeworkCount" INTEGER NOT NULL DEFAULT 0,
"notificationCount" INTEGER NOT NULL DEFAULT 0,
"archivedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Class" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"grade" TEXT NOT NULL,
"enrollmentYear" INTEGER,
"classIndex" INTEGER,
"teacherId" TEXT NOT NULL,
"code" TEXT NOT NULL,
"passwordHash" TEXT,
"termWeeks" INTEGER NOT NULL DEFAULT 20,
"notificationChannel" TEXT NOT NULL DEFAULT 'both',
"studentGradeQueryEnabled" BOOLEAN NOT NULL DEFAULT true,
"archivedYearId" TEXT,
"archivedAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Class_teacherId_fkey" FOREIGN KEY ("teacherId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "Class_archivedYearId_fkey" FOREIGN KEY ("archivedYearId") REFERENCES "ArchivedYear" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_Class" ("code", "createdAt", "grade", "id", "name", "notificationChannel", "passwordHash", "studentGradeQueryEnabled", "teacherId", "termWeeks", "updatedAt") SELECT "code", "createdAt", "grade", "id", "name", "notificationChannel", "passwordHash", "studentGradeQueryEnabled", "teacherId", "termWeeks", "updatedAt" FROM "Class";
DROP TABLE "Class";
ALTER TABLE "new_Class" RENAME TO "Class";
CREATE UNIQUE INDEX "Class_code_key" ON "Class"("code");
CREATE INDEX "Class_teacherId_idx" ON "Class"("teacherId");
CREATE INDEX "Class_enrollmentYear_idx" ON "Class"("enrollmentYear");
CREATE INDEX "Class_archivedYearId_idx" ON "Class"("archivedYearId");
-- 已有班级的届别推断(尽力而为):
-- 入学年份 = 当前学年起始年 − 该班现在的年级偏移。学年在 9 月切换,
-- 所以 9 月及以后算今年起始、之前算去年起始;高一偏移 0、高二 1、高三 2,
-- 认不出的年级(初中 / 四年制等)按高三处理(宁可偏早,管理员可在班级管理页改正)。
UPDATE "Class"
SET "enrollmentYear" = (
CASE WHEN CAST(strftime('%m', 'now') AS INTEGER) >= 9
THEN CAST(strftime('%Y', 'now') AS INTEGER)
ELSE CAST(strftime('%Y', 'now') AS INTEGER) - 1 END
) - CASE "grade" WHEN '高二' THEN 1 WHEN '高三' THEN 2 ELSE 0 END
WHERE "enrollmentYear" IS NULL;
-- 班号按「同一届内按创建先后」编号(老名字里的括号数字没法跨库 reliably 解析,这是等价的近似)。
UPDATE "Class"
SET "classIndex" = (
SELECT COUNT(*) FROM "Class" c2
WHERE c2."enrollmentYear" = "Class"."enrollmentYear"
AND (c2."createdAt" < "Class"."createdAt"
OR (c2."createdAt" = "Class"."createdAt" AND c2."id" <= "Class"."id"))
)
WHERE "classIndex" IS NULL;
-- 按新口径重写称呼:「2026级1班」。管理员若觉得推断不对,可在班级管理页改入学年份/班号。
UPDATE "Class"
SET "name" = CAST("enrollmentYear" AS TEXT) || '级' || CAST("classIndex" AS TEXT) || '班'
WHERE "enrollmentYear" IS NOT NULL AND "classIndex" IS NOT NULL;
CREATE TABLE "new_Student" (
"id" TEXT NOT NULL PRIMARY KEY,
"studentNo" TEXT NOT NULL,
"name" TEXT NOT NULL,
"classId" TEXT,
"gender" TEXT NOT NULL DEFAULT '',
"guardianPhone" TEXT NOT NULL DEFAULT '',
"status" TEXT NOT NULL DEFAULT 'active',
"archivedYearId" TEXT,
"archivedAt" DATETIME,
"transferredAt" DATETIME,
"transferNote" TEXT NOT NULL DEFAULT '',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Student_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Student_archivedYearId_fkey" FOREIGN KEY ("archivedYearId") REFERENCES "ArchivedYear" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_Student" ("classId", "createdAt", "gender", "guardianPhone", "id", "name", "status", "studentNo", "updatedAt") SELECT "classId", "createdAt", "gender", "guardianPhone", "id", "name", "status", "studentNo", "updatedAt" FROM "Student";
DROP TABLE "Student";
ALTER TABLE "new_Student" RENAME TO "Student";
CREATE UNIQUE INDEX "Student_studentNo_key" ON "Student"("studentNo");
CREATE INDEX "Student_classId_idx" ON "Student"("classId");
CREATE INDEX "Student_name_idx" ON "Student"("name");
CREATE INDEX "Student_status_idx" ON "Student"("status");
CREATE INDEX "Student_archivedYearId_idx" ON "Student"("archivedYearId");
CREATE TABLE "new_StudentClassTransfer" (
"id" TEXT NOT NULL PRIMARY KEY,
"studentId" TEXT NOT NULL,
"studentNo" TEXT NOT NULL,
"studentName" TEXT NOT NULL,
"fromClassId" TEXT,
"fromClassName" TEXT NOT NULL DEFAULT '',
"toClassId" TEXT,
"toClassName" TEXT NOT NULL DEFAULT '',
"operatorId" TEXT,
"operatorName" TEXT NOT NULL DEFAULT '',
"mode" TEXT NOT NULL DEFAULT 'single',
"note" TEXT NOT NULL DEFAULT '',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "StudentClassTransfer_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "Student" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_StudentClassTransfer" ("createdAt", "fromClassId", "fromClassName", "id", "mode", "operatorId", "operatorName", "studentId", "studentName", "studentNo", "toClassId", "toClassName") SELECT "createdAt", "fromClassId", "fromClassName", "id", "mode", "operatorId", "operatorName", "studentId", "studentName", "studentNo", "toClassId", "toClassName" FROM "StudentClassTransfer";
DROP TABLE "StudentClassTransfer";
ALTER TABLE "new_StudentClassTransfer" RENAME TO "StudentClassTransfer";
CREATE INDEX "StudentClassTransfer_studentId_createdAt_idx" ON "StudentClassTransfer"("studentId", "createdAt");
CREATE INDEX "StudentClassTransfer_createdAt_idx" ON "StudentClassTransfer"("createdAt");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
-- CreateIndex
CREATE UNIQUE INDEX "ArchivedYear_enrollmentYear_key" ON "ArchivedYear"("enrollmentYear");
@@ -0,0 +1,79 @@
-- 学期周次配置 + 课表同步方向反转
--
-- 一、学期周次(需求:「第几周的周次数据可以由 admin 修改」)
-- `TermWeek` 让管理员为**每一个教学周**指定实际的起止日期(调休、周末补课、错峰开学),
-- `classId = ''` 表示全校默认;没配到的周次仍按学期开始日期线性兜底。
-- `Class.termStartDate` 允许某个班单独覆盖全局的开学日期(它随镜像课表下发给教室的 ClassIsland)。
--
-- 二、课表同步方向反转(需求:「课表不再自动从教室的 ClassIsland 获取,改为手动获取;
-- ClassHelper 班级端的课表由服务端自动下发」)
-- `syncScheduleToServer` 默认 true → **false**:插件不再自动把教室的课表推上来
-- `mirrorScheduleToClassIsland` 默认 false → **true**:服务端的课表自动下发给教室
-- `requestScheduleReport` 新增:Web 端点「从教室机器获取课表」时置位,
-- 插件下次心跳看到就把当前课表推上来并清除标记
--
-- 下面会把**已有设备**也改成新方向:这正是需求要的行为变化(否则升级后教室里
-- 仍然在自动回传,老师手排的课会被教室的旧课表悄悄覆盖)。设备管理员可以逐台改回去。
-- AlterTable
ALTER TABLE "Class" ADD COLUMN "termStartDate" TEXT;
-- CreateTable
CREATE TABLE "TermWeek" (
"id" TEXT NOT NULL PRIMARY KEY,
"classId" TEXT NOT NULL DEFAULT '',
"weekNumber" INTEGER NOT NULL,
"startDate" TEXT NOT NULL,
"endDate" TEXT NOT NULL,
"note" TEXT NOT NULL DEFAULT '',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_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 false,
"mirrorScheduleToClassIsland" BOOLEAN NOT NULL DEFAULT true,
"requestScheduleReport" BOOLEAN NOT NULL DEFAULT false,
"classIslandVersion" TEXT,
"pluginVersion" TEXT,
"lastSeenAt" DATETIME,
"lastHeartbeatAt" 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
);
INSERT INTO "new_IntegrationDevice" ("classId", "classIslandVersion", "classPlanLoaded", "createdAt", "currentPeriodEnd", "currentPeriodStart", "currentSubject", "currentTimeState", "deviceKey", "enabled", "id", "lastHeartbeatAt", "lastSeenAt", "mirrorScheduleToClassIsland", "mode", "name", "nextSubject", "pluginVersion", "syncScheduleToServer", "tokenHash", "tokenHint", "updatedAt") SELECT "classId", "classIslandVersion", "classPlanLoaded", "createdAt", "currentPeriodEnd", "currentPeriodStart", "currentSubject", "currentTimeState", "deviceKey", "enabled", "id", "lastHeartbeatAt", "lastSeenAt", "mirrorScheduleToClassIsland", "mode", "name", "nextSubject", "pluginVersion", "syncScheduleToServer", "tokenHash", "tokenHint", "updatedAt" FROM "IntegrationDevice";
-- 已有设备切到新方向:不再自动回传课表,改为由服务端自动下发。
UPDATE "new_IntegrationDevice" SET "syncScheduleToServer" = false, "mirrorScheduleToClassIsland" = true;
DROP TABLE "IntegrationDevice";
ALTER TABLE "new_IntegrationDevice" RENAME TO "IntegrationDevice";
CREATE INDEX "IntegrationDevice_classId_idx" ON "IntegrationDevice"("classId");
CREATE INDEX "IntegrationDevice_tokenHash_idx" ON "IntegrationDevice"("tokenHash");
CREATE UNIQUE INDEX "IntegrationDevice_classId_deviceKey_key" ON "IntegrationDevice"("classId", "deviceKey");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
-- CreateIndex
CREATE INDEX "TermWeek_classId_idx" ON "TermWeek"("classId");
-- CreateIndex
CREATE UNIQUE INDEX "TermWeek_classId_weekNumber_key" ON "TermWeek"("classId", "weekNumber");
+232 -71
View File
@@ -1,4 +1,4 @@
// 班级小助手 - 数据模型
// ClassHelper - 数据模型
//
// 设计说明:
// 1) 本 schema 使用 sqlite provider(本地 MVP / 离线开发)。
@@ -6,10 +6,22 @@
// 不使用 Prisma enum 与 @db.* 原生类型,因此可平移到 MySQL。
// MySQL 复用这一份 schema(就地改写下面的 provider,见 `pnpm db:switch:mysql`),
// 没有第二份 schema.mysql.prisma;切换步骤见 docs/mysql.md。
// 2) 角色与优先级用 String 存储('ADMIN' | 'TEACHER' | 'STUDENT'、
// 2) 角色与优先级用 String 存储('ADMIN' | 'TEACHER' | 'CLASS_DEVICE'、
// 'LOW' | 'NORMAL' | 'HIGH' | 'URGENT'),在 API 层用 zod 校验,
// 这样 SQLite / MySQL 行为完全一致。
// 3) 所有主键使用 cuid 字符串,便于多端离线生成与合并。
//
// **学生不是账号**(2026-10-06 重构的核心不变量):
// `Student` 是独立的一张表,只有学号 / 姓名 / 班级 / 性别 / 家长手机号,
// 没有 passwordHash、没有 role、没有登录字段 —— 「学生不能登录」由表结构保证,
// 不再靠"登录前先判 role === 'STUDENT' 并 403"这类代码分支维持。
// `User` 因此收缩为**纯账号表**(管理员 / 教师 / ClassHelper 班级端)。
//
// **任课关系唯一来源是 `Course`**:`Course(classId, name, teacherId)` 就是
// 「班级 + 科目 + 教师」这条关系本身。班主任另由 `Class.teacherId` 表示(每班 1 人)。
// 历史上那张没有科目的 `ClassTeacher` 协作表已删除 —— 两套关系并存必然漂移。
// 同一教师可以在同一个班同时是班主任和某科科任老师:两处都指向同一个 `User`,
// 不重复建号,权限在 `@classhelper/shared` 的 `resolveClassRole()` 里合并。
generator client {
provider = "prisma-client"
@@ -20,101 +32,229 @@ datasource db {
provider = "sqlite"
}
/// 用户:教师 / 管理员 / 学生统一存储
/// 账号:管理员 / 教师 / ClassHelper 班级端。**学生不在这里**(见 Student)。
model User {
id String @id @default(cuid())
/// 登录名:教师即**工号**,班级端即班级码
username String @unique
passwordHash String
name String
role String @default("STUDENT")
classId String?
/// ADMIN | TEACHER | CLASS_DEVICE
role String @default("TEACHER")
/// 手机号(教师联系方式,可空)
phone String @default("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// 学生的主班级(一个学生属于一个主班级)
class Class? @relation("ClassStudents", fields: [classId], references: [id], onDelete: SetNull)
// 教师创建(负责)的班级
ownedClasses Class[] @relation("ClassOwner")
// 教师被分配协作的班级
classAssignments ClassTeacher[]
/// 教师担任班主任的班级(每班 1 人)
ownedClasses Class[] @relation("ClassOwner")
/// 教师任教的课程 —— 「班级 + 科目 + 教师」任课关系
courses Course[]
homeworkStatuses HomeworkStatus[]
notificationReads NotificationRead[]
grades Grade[]
enrollments Enrollment[]
createdHomeworks Homework[] @relation("HomeworkCreator")
createdNotices Notification[] @relation("NotificationCreator")
createdHomeworks Homework[] @relation("HomeworkCreator")
createdNotices Notification[] @relation("NotificationCreator")
@@index([classId])
@@index([role])
}
/// 学生:**只是班级名单里的一条记录**,没有账号、没有密码、不能登录。
///
/// `studentNo`(学号)是学生在系统里的唯一标识与查询键:
/// 名单、成绩、作业提交、导入模板、导出、查询显示一律用它。
/// 历史作业与成绩挂在 `Homework.classId` / `Grade.classId` 上,
/// 因此**调班只改本表的 classId,历史数据保留原归属**。
model Student {
id String @id @default(cuid())
/// 学号:学生唯一标识(全校唯一)
studentNo String @unique
name String
/// 当前班级;null = 未分班(转出/毕业后班级归属仍保留,靠 status 区分)
classId String?
/// 性别:'' | 'MALE' | 'FEMALE'(字符串存,跨库通用)
gender String @default("")
/// 家长手机号(可空)
guardianPhone String @default("")
/// active = 在读 / graduated = 已毕业 / transferred = 已转出 / inactive = 停用
status String @default("active")
/// 毕业归档所属届别(`ArchivedYear.id`);null = 未归档
archivedYearId String?
archivedAt DateTime?
/// 转出时间(status='transferred' 时写入)
transferredAt DateTime?
/// 转出去向 / 备注
transferNote String @default("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
class Class? @relation(fields: [classId], references: [id], onDelete: SetNull)
archivedYear ArchivedYear? @relation(fields: [archivedYearId], references: [id], onDelete: SetNull)
homeworkStatuses HomeworkStatus[]
notificationReads NotificationRead[]
grades Grade[]
transfers StudentClassTransfer[]
@@index([classId])
@@index([name])
@@index([status])
@@index([archivedYearId])
}
/// 调班 / 转出历史:原班级 / 新班级 / 操作人 / 时间。
///
/// 班级 id 与班级名**都存快照且不建外键** —— 班级被删除后这条历史仍要能读出来,
/// 而外键的 Cascade 会把历史一起带走。
model StudentClassTransfer {
id String @id @default(cuid())
studentId String
/// 学号快照(学生被删除后历史仍可读)
studentNo String
studentName String
fromClassId String?
fromClassName String @default("")
toClassId String?
toClassName String @default("")
/// 操作人(User.id);账号被删后留空,姓名快照保留
operatorId String?
operatorName String @default("")
/// single = 单个调班 / batch = 批量调班 / transfer-out = 转出(离开本校)
mode String @default("single")
note String @default("")
createdAt DateTime @default(now())
student Student @relation(fields: [studentId], references: [id], onDelete: Cascade)
@@index([studentId, createdAt])
@@index([createdAt])
}
/// 届别档案:一次「毕业归档」的留痕(管理员在「归档管理」页执行)。
///
/// 归档是**打标记 + 只读**,不删任何数据:
/// - `Class.archivedYearId` / `Student.archivedYearId` 指向本届档案,
/// 常规的班级/学生列表按它过滤掉归档项;
/// - 毕业班级的作业与通知原样留在库里,通过归档详情只读查看
/// (需求「记录每个年度毕业班级的消息和作业」就是这么满足的)。
///
/// 「未毕业而升级的班级不归档」:升级只改 `Class.grade`(高一→高二→高三),
/// 班级记录、学生名单、作业成绩全部沿用,不碰 `archivedYearId`。
model ArchivedYear {
id String @id @default(cuid())
/// 届别 = 入学年份(2026 表示 2026 级,班级称呼因此是「2026级1班」)
enrollmentYear Int @unique
/// 毕业年份(= 入学年份 + 学制年数)
graduationYear Int
/// 届别名,如「2026 级」
name String
note String @default("")
operatorId String?
operatorName String @default("")
/// 归档时的统计快照(给人看的一行汇总;明细实时查班级与学生)
classCount Int @default(0)
studentCount Int @default(0)
transferredCount Int @default(0)
homeworkCount Int @default(0)
notificationCount Int @default(0)
archivedAt DateTime @default(now())
classes Class[]
students Student[]
}
/// 学期周次区间:管理员可以为**每一个教学周**指定实际的起止日期。
///
/// 为什么不能只靠"学期开始日期 + 每周七天"线性推算:法定节假日调休、周末补课、
/// 错峰开学都会让某一周变长或变短(甚至整周不上课),线性推算出来的"第几周"
/// 会与教室里的实际情况整体错位一周以上 —— 而课表、作业、成绩都按周次组织。
///
/// 因此支持逐周设置:
/// `classId = ''` 表示**全校默认**(没有为某个班单独配置时用它);
/// 有班级维度的行则覆盖默认。
/// 没配置的周次仍按学期开始日期线性兜底(见 shared 的 `resolveCurrentWeek`)。
model TermWeek {
id String @id @default(cuid())
/// 归属班级;**空串 = 全校默认**(用空串而不是 null,唯一约束才在两个库上都生效)
classId String @default("")
/// 教学周次(1 起)
weekNumber Int
/// 该周的起始日期(YYYY-MM-DD,本地日期)
startDate String
/// 该周的结束日期(YYYY-MM-DD)
endDate String
note String @default("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([classId, weekNumber])
@@index([classId])
}
/// 班级
///
/// **班级称呼是「XXXX级X班」**(入学年份 + 班号),不是「高一(1)班」:
/// 后者每年都要改,前者一路跟着这届学生到毕业。`name` 由服务端按
/// `enrollmentYear` + `classIndex` 生成(见 shared 的 `formatClassName()`),
/// 保留成列是为了兼容既有引用,不在别处手写。
///
/// `grade`(高一/高二/高三)表示**当前年级**,学年升级时批量改它即可 ——
/// 升级只动这一个字段,班级记录、学生名单、作业与成绩全部沿用,**不归档**。
model Class {
id String @id @default(cuid())
/// 班级称呼:由 enrollmentYear + classIndex 生成,如「2026级1班」
name String
/// 当前年级(高一 / 高二 / 高三),学年升级时更新
grade String
/// 入学年份(届别):2026 → 称呼「2026级…」、毕业年份 2029
enrollmentYear Int?
/// 班号(同一届内的序号)
classIndex Int?
/// 班主任(每班 1 人);科任老师按科目配在 Course 上
teacherId String
/// 班级码:学生端「班级账号」登录用(唯一,字母数字,4~16 位,存大写)
/// 班级码:ClassHelper 班级端登录用(唯一,字母数字,4~16 位,存大写)
code String @unique
/// 班级密码(bcrypt 哈希);null = 尚未设置,此时禁止班级账号登录
/// 班级密码(bcrypt 哈希);null = 尚未设置,此时禁止班级端登录
passwordHash String?
/// 本学期教学周数(班主任可调,默认 20):课表周次选择与 weekEnd 默认值都用它
termWeeks Int @default(20)
/// 通知显示位置(由教室的班级客户端在设置页里选):
/// both=客户端与 ClassIsland 都弹 / client=只弹客户端 / classisland=只弹 ClassIsland
/// 本班学期开始日期(YYYY-MM-DD,本地日期);留空则用全局配置(.env 的 TERM_START_DATE)。
/// 管理员改它会重算「当前第几周」,并随镜像课表下发给教室的 ClassIsland。
termStartDate String?
/// 通知显示位置(由 ClassHelper 班级端在设置页里选):
/// both=班级端与 ClassIsland 都弹 / client=只弹班级端 / classisland=只弹 ClassIsland
notificationChannel String @default("both")
/// 是否允许 ClassHelper 班级端按学号查询本班学生的成绩明细(默认开启)。
/// 关闭后班级端查成绩一律 403;教师端不受影响。
studentGradeQueryEnabled Boolean @default(true)
/// 毕业归档所属届别;null = 在读(常规班级列表只显示未归档的)
archivedYearId String?
archivedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
teacher User @relation("ClassOwner", fields: [teacherId], references: [id], onDelete: Cascade)
students User[] @relation("ClassStudents")
teachers ClassTeacher[]
archivedYear ArchivedYear? @relation(fields: [archivedYearId], references: [id], onDelete: SetNull)
students Student[]
courses Course[]
schedules Schedule[]
homeworks Homework[]
notifications Notification[]
grades Grade[]
enrollments Enrollment[]
timeLayouts TimeLayout[]
integrationDevices IntegrationDevice[]
classIslandPushes ClassIslandPush[]
@@index([teacherId])
@@index([enrollmentYear])
@@index([archivedYearId])
}
/// 教师-班级 分配关系(一个教师可管理多个班级)
model ClassTeacher {
id String @id @default(cuid())
classId String
teacherId String
createdAt DateTime @default(now())
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
teacher User @relation(fields: [teacherId], references: [id], onDelete: Cascade)
@@unique([classId, teacherId])
@@index([teacherId])
}
/// 学生-班级 选课/归属记录(与 User.classId 保持同步,支持未来多班)
model Enrollment {
id String @id @default(cuid())
userId String
classId String
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
@@unique([userId, classId])
@@index([classId])
}
/// 课程
/// 课程:同时也是「班级 + 科目 + 教师」这条**任课关系**的载体。
///
/// `teacherId` 就是该班该科的任课老师(管理员在「班级管理 → 任课老师」里指定)。
/// 作业与成绩都挂在 Course 上,因此"这位老师能不能动这一科的作业/成绩"
/// 就是查 `Course.teacherId`—— 不需要第二张任课表。
model Course {
id String @id @default(cuid())
name String
/// 该班该科的任课老师
teacherId String
classId String
createdAt DateTime @default(now())
@@ -127,6 +267,7 @@ model Course {
@@unique([classId, name])
@@index([classId])
@@index([teacherId])
}
/// 课表条目
@@ -190,19 +331,19 @@ model Homework {
@@index([dueAt])
}
/// 学生作业完成状态
/// 学生作业完成状态(按**学生名单记录**记,学生没有账号)
model HomeworkStatus {
id String @id @default(cuid())
homeworkId String
userId String
studentId String
completed Boolean @default(false)
updatedAt DateTime @updatedAt
homework Homework @relation(fields: [homeworkId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
student Student @relation(fields: [studentId], references: [id], onDelete: Cascade)
@@unique([homeworkId, userId])
@@index([userId])
@@unique([homeworkId, studentId])
@@index([studentId])
}
/// 通知
@@ -227,14 +368,14 @@ model Notification {
model NotificationRead {
id String @id @default(cuid())
notificationId String
userId String
studentId String
readAt DateTime @default(now())
notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
student Student @relation(fields: [studentId], references: [id], onDelete: Cascade)
@@unique([notificationId, userId])
@@index([userId])
@@unique([notificationId, studentId])
@@index([studentId])
}
@@ -256,34 +397,44 @@ model TimeLayout {
}
/// 成绩
///
/// `classId` 是**发布时的归属班级**,调班不改变它 —— 历史成绩留在原班级,
/// 后续新成绩按学生当前班级生成(需求「历史作业和成绩保留原归属」)。
model Grade {
id String @id @default(cuid())
classId String
courseId String?
userId String
/// 学生(名单记录)id
studentId String
examName String
score Float
totalScore Float @default(100)
/// 等级口径:percent 百分制 / letter 等级制 A-D / custom 自定义等级
levelType String @default("percent")
/// 等级文本。percent 下由服务端按得分率生成初值,教师可手改覆盖
level String @default("")
publishedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
course Course? @relation(fields: [courseId], references: [id], onDelete: SetNull)
student User @relation(fields: [userId], references: [id], onDelete: Cascade)
student Student @relation(fields: [studentId], references: [id], onDelete: Cascade)
@@index([userId, publishedAt])
@@index([studentId, publishedAt])
@@index([classId, examName])
@@index([courseId])
}
/// ClassIsland 联动设备(一台装有联动插件的机器 = 某个班级的接入端)
/// ClassIsland 联动设备(一台装有联动插件的机器 = 某个班的 ClassHelper 班级端)
///
/// 说明:
/// 1) 令牌是「设备级」的,不是用户级:插件在本机保存明文,服务端只存 sha256 哈希 + 前缀提示;
/// 2) 同一班级下按 deviceKey(机器码)唯一:插件重装后带同一 deviceKey 会自动重绑到原记录,
/// 避免设备列表里堆满重复项;
/// 3) 状态字段是「最后一次上报的快照」,Web 端据此展示"某班现在上什么课"。
/// 3) 状态字段是「最后一次上报的快照」,Web 端据此展示"某班现在上什么课";
/// 4) `lastHeartbeatAt` 每次上报都刷新(在线判定用它),`lastSeenAt` 只在**带运行状态**的
/// 上报时刷新 —— 这样"能连上但一直没上报状态"的设备不会被误判成完全正常。
model IntegrationDevice {
id String @id @default(cuid())
classId String
@@ -300,12 +451,22 @@ model IntegrationDevice {
/// 令牌前缀(chci_xxxxxxxx),设备列表里用于人眼识别与排查
tokenHint String
/// 是否允许插件把 ClassIsland 的课表回传到本服务
syncScheduleToServer Boolean @default(true)
/// 是否允许把本班课表镜像回 ClassIsland(由插件执行)
mirrorScheduleToClassIsland Boolean @default(false)
///
/// **默认关闭**(2026-10-06 起):课表以服务端为准,由服务端**自动下发**到教室;
/// 「从教室机器取一次课表」改成 Web 端的手动动作(点击前会弹警告),
/// 因为自动回传会在老师手排课之后被教室的旧课表悄悄覆盖。
syncScheduleToServer Boolean @default(false)
/// 是否允许把本班课表镜像回 ClassIsland(由插件执行)。**默认开启** —— 这是现在的主方向。
mirrorScheduleToClassIsland Boolean @default(true)
/// 待办的「立即上报一次课表」请求:Web 端点「从教室机器获取课表」时置位,
/// 插件在下一次心跳看到它就把当前课表推上来并清除标记。
requestScheduleReport Boolean @default(false)
classIslandVersion String?
pluginVersion String?
/// 最后一次**带运行状态**的上报时间
lastSeenAt DateTime?
/// 最后一次心跳时间(每次上报都刷新);「在线」看它
lastHeartbeatAt DateTime?
/// 以下为最后一次上报的快照
classPlanLoaded Boolean @default(false)
currentSubject String?
+208 -42
View File
@@ -2,15 +2,25 @@
* 种子数据:pnpm db:seed
*
* 会清空业务表后重建一套完整演示数据:
* 1 管理员 / 2 教师 / 3 个班级 / 15 名学生 / 课程 / 课表 / 作业 / 通知 / 成绩
* 1 管理员 / 2 教师 / 3 个在读班级 / 1 个已毕业届别 / 15 名学生 /
* 课程(含各科任课老师)/ 课表 / 作业 / 通知 / 成绩 / 调班与转出历史
*
* 班级称呼一律是「XXXX级X班」(入学年份 + 班号)—— 见 shared 的 `formatClassName()`。
*
* 演示账号(密码见文件末尾打印结果):
* admin / admin123 - 系统管理员
* teacher1 / teacher123 - 张老师(高一(1)班、高二(3)班班主任)
* teacher2 / teacher123 - 李老师(高一(2)班班主任,高二(3)班协作教师)
* student01..student15 / student123
* teacher1 / teacher123 - 张老师(2026级1班 班主任,兼 2026级1班 数学)
* teacher2 / teacher123 - 李老师(2026级2班 班主任,兼 2026级1班 语文)
* (学生只有名单、没有账号:ClassHelper 班级端统一用班级码 + 班级密码登录)
*/
import { SUBJECT_CATALOG, dayKeyLocal } from '@classhelper/shared';
import {
SUBJECT_CATALOG,
dayKeyLocal,
formatClassName,
formatYearLabel,
gradeLevel,
gradePercent,
} from '@classhelper/shared';
import { disconnectPrisma, prisma } from '../src/lib/db.js';
import { logger } from '../src/lib/logger.js';
import { hashPassword } from '../src/lib/password.js';
@@ -67,11 +77,13 @@ async function resetDatabase(): Promise<void> {
await prisma.notification.deleteMany();
await prisma.schedule.deleteMany();
await prisma.course.deleteMany();
await prisma.enrollment.deleteMany();
await prisma.classTeacher.deleteMany();
await prisma.user.updateMany({ data: { classId: null } });
await prisma.studentClassTransfer.deleteMany();
// 学生要排在调班历史之后、班级之前(Student.classId 是 SetNull,但历史是 Cascade)
await prisma.student.deleteMany();
await prisma.user.deleteMany();
// 班级归到届别上,所以先删班级再删届别
await prisma.class.deleteMany();
await prisma.archivedYear.deleteMany();
}
async function main(): Promise<void> {
@@ -86,11 +98,11 @@ async function main(): Promise<void> {
const random = createRandom(20260901);
// 同一角色共用密码,只需各哈希一次
// 学生没有密码:个人学生账号已清理(2026-10-01),学生端统一用班级码 + 班级密码登录
// 学生没有密码:个人学生账号已清理(2026-10-01),ClassHelper 班级端统一用班级码 + 班级密码登录
const [adminHash, teacherHash, classPasswordHash] = await Promise.all([
hashPassword('admin123'),
hashPassword('teacher123'),
// 班级账号默认密码(学生端「班级登录」)
// 班级账号默认密码(ClassHelper 班级端「班级登录」)
hashPassword('123456'),
]);
@@ -105,19 +117,24 @@ async function main(): Promise<void> {
data: { username: 'teacher2', name: '李老师', role: 'TEACHER', passwordHash: teacherHash },
});
// 班级账号:班级码(学生端「班级登录」的账号)+ 班级密码(默认 123456,可在班级管理里重置)
// 班级账号:班级码(ClassHelper 班级端「班级登录」的账号)+ 班级密码(默认 123456,可在班级管理里重置)
//
// 称呼按「入学年份 + 班号」生成:2026 级是高一(当前学年 2026-2027),2025 级是高二。
// 毕业年份 = 入学年份 + 3,所以这一届 2026 级将在 2029 年归档。
const classSeeds = [
{ name: '高一(1)班', grade: '高一', teacherId: teacher1.id, code: 'G101' },
{ name: '高一(2)班', grade: '高一', teacherId: teacher2.id, code: 'G102' },
{ name: '高二(3)班', grade: '高二', teacherId: teacher1.id, code: 'G203' },
{ enrollmentYear: 2026, classIndex: 1, grade: '高一', teacherId: teacher1.id, code: 'G101' },
{ enrollmentYear: 2026, classIndex: 2, grade: '高一', teacherId: teacher2.id, code: 'G102' },
{ enrollmentYear: 2025, classIndex: 3, grade: '高二', teacherId: teacher1.id, code: 'G203' },
];
const classes = [];
for (const [index, seed] of classSeeds.entries()) {
const created = await prisma.class.create({
data: {
name: seed.name,
name: formatClassName(seed.enrollmentYear, seed.classIndex),
grade: seed.grade,
enrollmentYear: seed.enrollmentYear,
classIndex: seed.classIndex,
teacherId: seed.teacherId,
code: seed.code,
passwordHash: classPasswordHash,
@@ -126,37 +143,41 @@ async function main(): Promise<void> {
classes.push({ ...created, room: CLASS_ROOMS[index] ?? '教学楼 A101' });
}
// 高二(3)班:李老师作为协作教师
await prisma.classTeacher.create({
data: { classId: classes[2]!.id, teacherId: teacher2.id },
});
// ---------------------------------------------------------------- 学生
// ---------------------------------------------------------------- 学生(只是名单,不是账号)
const students = [];
for (let index = 0; index < STUDENT_NAMES.length; index += 1) {
const classIndex = Math.floor(index / 5);
const targetClass = classes[classIndex] ?? classes[0]!;
const student = await prisma.user.create({
const classSlot = Math.floor(index / 5);
const targetClass = classes[classSlot] ?? classes[0]!;
// 学号:入学年份 + 两位序号(与班级称呼同一套「届别」口径)
const student = await prisma.student.create({
data: {
username: `student${String(index + 1).padStart(2, '0')}`,
studentNo: `${targetClass.enrollmentYear}${String(index + 1).padStart(2, '0')}`,
name: STUDENT_NAMES[index] ?? `学生${index + 1}`,
role: 'STUDENT',
classId: targetClass.id,
// 空串占位:User.passwordHash 必填,但学生登录的 403 判定在密码校验之前,永远用不到
passwordHash: '',
gender: index % 2 === 0 ? 'MALE' : 'FEMALE',
guardianPhone: `138${String(10000000 + index * 137).slice(0, 8)}`,
},
});
students.push(student);
await prisma.enrollment.create({ data: { userId: student.id, classId: targetClass.id } });
}
// ---------------------------------------------------------------- 课程
// ---------------------------------------------------------------- 课程(= 班级 + 科目 + 教师的任课关系)
//
// 每门课都要挂一位**任课老师**:作业与成绩能不能被某位老师改动,查的就是 `Course.teacherId`。
// 演示数据刻意让「语文」由另一位老师任教 —— 这样班主任在语文上就没有编辑权,
// 正好覆盖需求「班主任不自动拥有所有科目作业成绩编辑权」这条规则。
const OTHER_TEACHER_SUBJECT = '语文';
const coursesByClass = new Map<string, { id: string; name: string }[]>();
for (const targetClass of classes) {
const otherTeacher = targetClass.teacherId === teacher1.id ? teacher2.id : teacher1.id;
const created = [];
for (const courseName of COURSE_NAMES) {
const course = await prisma.course.create({
data: { name: courseName, classId: targetClass.id, teacherId: targetClass.teacherId },
data: {
name: courseName,
classId: targetClass.id,
teacherId: courseName === OTHER_TEACHER_SUBJECT ? otherTeacher : targetClass.teacherId,
},
});
created.push({ id: course.id, name: course.name });
}
@@ -280,7 +301,7 @@ async function main(): Promise<void> {
const completed = (index + homework.title.length) % 3 !== 0;
if (!completed && index % 2 === 1) continue;
await prisma.homeworkStatus.create({
data: { homeworkId: homework.id, userId: student.id, completed },
data: { homeworkId: homework.id, studentId: student.id, completed },
});
}
}
@@ -331,7 +352,7 @@ async function main(): Promise<void> {
const classmates = (studentsByClass.get(targetClass.id) ?? []).slice(0, 3);
for (const student of classmates) {
await prisma.notificationRead.create({
data: { notificationId: notification.id, userId: student.id },
data: { notificationId: notification.id, studentId: student.id },
});
}
}
@@ -339,23 +360,46 @@ async function main(): Promise<void> {
}
// ---------------------------------------------------------------- 成绩
//
// 三种等级口径各演示一门,方便在界面上看出差别:
// 数学(percent)—— 按得分率自动换算 A~E
// 语文(custom) —— 自定义等级文本
// 英语(letter) —— A / B / C / D
const examNames = ['第一次月考', '期中考试'];
const customLevels = ['优', '良', '合格'] as const;
const letterLevels = ['A', 'B', 'C', 'D'] as const;
const levelPlan = [
{ type: 'percent' as const },
{ type: 'custom' as const },
{ type: 'letter' as const },
];
for (const targetClass of classes) {
const classCourses = coursesByClass.get(targetClass.id) ?? [];
const classmates = studentsByClass.get(targetClass.id) ?? [];
for (const [examIndex, examName] of examNames.entries()) {
for (const course of classCourses.slice(0, 3)) {
for (const [courseIndex, course] of classCourses.slice(0, 3).entries()) {
const plan = levelPlan[courseIndex % levelPlan.length]!;
for (const student of classmates) {
const base = 60 + random() * 38;
const score = Math.round(Math.min(100, Math.max(35, base - examIndex * 2)));
const percent = gradePercent(score, 100);
const level =
plan.type === 'percent'
? gradeLevel(percent)
: plan.type === 'letter'
? (letterLevels[Math.min(letterLevels.length - 1, Math.floor((100 - percent) / 12))] ?? 'D')
: (customLevels[Math.min(customLevels.length - 1, Math.floor((100 - percent) / 15))] ?? '合格');
await prisma.grade.create({
data: {
classId: targetClass.id,
courseId: course.id,
userId: student.id,
studentId: student.id,
examName,
score,
totalScore: 100,
levelType: plan.type,
level,
publishedAt: new Date(now - (examIndex + 1) * 6 * day),
},
});
@@ -364,24 +408,146 @@ async function main(): Promise<void> {
}
}
// ---------------------------------------------------------------- 调班 / 转出历史
//
// 调班只改 `Student.classId`(学号不变,历史作业与成绩留在原班级),并留一条可查的痕迹。
const movedStudent = students[7]!; // 赵依然:2026级2班 → 2026级1班(曾经)
await prisma.student.update({
where: { id: movedStudent.id },
data: { classId: classes[0]!.id },
});
await prisma.studentClassTransfer.create({
data: {
studentId: movedStudent.id,
studentNo: movedStudent.studentNo,
studentName: movedStudent.name,
fromClassId: classes[0]!.id,
fromClassName: classes[0]!.name,
toClassId: classes[1]!.id,
toClassName: classes[1]!.name,
operatorId: admin.id,
operatorName: admin.name,
mode: 'batch',
note: '军训后按分班考试成绩重新均衡',
createdAt: new Date(now - 30 * day),
},
});
// 转出:学籍离开本校(不是调班),班级归属保留作历史
const transferredStudent = students[13]!; // 朱佳琪
await prisma.student.update({
where: { id: transferredStudent.id },
data: {
status: 'transferred',
transferredAt: new Date(now - 12 * day),
transferNote: '随家长工作调动转学至外地',
},
});
await prisma.studentClassTransfer.create({
data: {
studentId: transferredStudent.id,
studentNo: transferredStudent.studentNo,
studentName: transferredStudent.name,
fromClassId: transferredStudent.classId,
fromClassName: classes[2]!.name,
toClassId: null,
toClassName: '转出本校',
operatorId: admin.id,
operatorName: admin.name,
mode: 'transfer-out',
note: '随家长工作调动转学至外地',
createdAt: new Date(now - 12 * day),
},
});
// ---------------------------------------------------------------- 已毕业届别(归档页的演示数据)
//
// 归档 = 打标记 + 只读:班级与它的作业/通知原样留在库里,只是从常规列表里隐去。
const graduatedClass = await prisma.class.create({
data: {
name: formatClassName(2023, 1),
grade: '高三',
enrollmentYear: 2023,
classIndex: 1,
teacherId: teacher1.id,
code: 'G231',
passwordHash: classPasswordHash,
},
});
const archivedYear = await prisma.archivedYear.create({
data: {
enrollmentYear: 2023,
graduationYear: 2023 + 3,
name: formatYearLabel(2023),
note: '演示用:2023 级已于 2026 年 6 月毕业',
operatorId: admin.id,
operatorName: admin.name,
archivedAt: new Date(now - 100 * day),
},
});
await prisma.class.update({
where: { id: graduatedClass.id },
data: { archivedYearId: archivedYear.id, archivedAt: new Date(now - 100 * day) },
});
const graduateNames = ['毕业演示甲', '毕业演示乙', '毕业演示丙'];
for (const [index, name] of graduateNames.entries()) {
await prisma.student.create({
data: {
studentNo: `2023${String(index + 1).padStart(2, '0')}`,
name,
classId: graduatedClass.id,
gender: index % 2 === 0 ? 'MALE' : 'FEMALE',
status: 'graduated',
archivedYearId: archivedYear.id,
archivedAt: new Date(now - 100 * day),
},
});
}
// 这一届也要有作业与通知,「记录每个年度毕业班级的消息和作业」才有东西可看
await prisma.homework.create({
data: {
classId: graduatedClass.id,
title: '高考前最后一轮复习',
content: '按考点清单过一遍错题本,重点看函数与数列。',
assignDate: dayKeyLocal(new Date(now - 120 * day)),
createdBy: teacher1.id,
},
});
await prisma.notification.create({
data: {
classId: graduatedClass.id,
title: '毕业典礼安排',
content: '6 月 20 日上午 9 点在礼堂举行毕业典礼,请全体同学准时参加。',
priority: 'HIGH',
createdBy: teacher1.id,
createdAt: new Date(now - 110 * day),
},
});
const counts = {
用户: await prisma.user.count(),
班级: await prisma.class.count(),
账号: await prisma.user.count(),
在读班级: await prisma.class.count({ where: { archivedYearId: null } }),
学生: await prisma.student.count({ where: { status: 'active' } }),
课程: await prisma.course.count(),
课表: await prisma.schedule.count(),
作业: await prisma.homework.count(),
通知: await prisma.notification.count(),
成绩: await prisma.grade.count(),
调班历史: await prisma.studentClassTransfer.count(),
归档届别: await prisma.archivedYear.count(),
};
logger.info('种子数据写入完成', counts);
console.log('\n================ 演示账号 ================');
console.log(`管理员 admin / admin123 (${admin.name})`);
console.log(`教师 teacher1 / teacher123 (${teacher1.name} · 高一(1)班、高二(3)班)`);
console.log(`教师 teacher2 / teacher123 (${teacher2.name} · 高一(2)班、高二(3)班协作)`);
console.log('学生端(班级账号):G101 / G102 / G203,密码 123456');
console.log('(学生只有名单、没有个人账号,成绩/未交/叫人按名单记录)');
console.log(`管理员 admin / admin123 (${admin.name} · 可执行毕业归档与调班)`);
console.log(`教师 teacher1 / teacher123 (${teacher1.name} · ${classes[0]!.name} 班主任,教数学)`);
console.log(`教师 teacher2 / teacher123 (${teacher2.name} · ${classes[1]!.name} 班主任,教 ${classes[0]!.name} 语文)`);
console.log('ClassHelper 班级端(班级码):G101 / G102 / G203,密码 123456');
console.log('(学生只有名单、没有账号:成绩/未交/叫人按学号记录)');
console.log(`已在读班级:${classes.map((item) => item.name).join(' / ')}`);
console.log(`已归档届别:${formatYearLabel(2023)}(${graduatedClass.name},${graduateNames.length} 名毕业生)`);
console.log('==========================================\n');
}
@@ -38,8 +38,53 @@ const COLUMNS = [
sql: `ALTER TABLE "Class" ADD COLUMN "termWeeks" INTEGER NOT NULL DEFAULT 20`,
note: '班级教学周数(班主任可调,默认 20)',
},
{
table: 'Class',
column: 'studentGradeQueryEnabled',
sql: `ALTER TABLE "Class" ADD COLUMN "studentGradeQueryEnabled" BOOLEAN NOT NULL DEFAULT true`,
note: 'ClassHelper 班级端按学号查成绩的开关(默认开启)',
},
{
table: 'Grade',
column: 'levelType',
sql: `ALTER TABLE "Grade" ADD COLUMN "levelType" TEXT NOT NULL DEFAULT 'percent'`,
note: '成绩等级口径:percent / letter / custom',
},
{
table: 'Grade',
column: 'level',
sql: `ALTER TABLE "Grade" ADD COLUMN "level" TEXT NOT NULL DEFAULT ''`,
note: '成绩等级文本(percent 下由得分率换算,可手改)',
},
{
table: 'IntegrationDevice',
column: 'lastHeartbeatAt',
sql: `ALTER TABLE "IntegrationDevice" ADD COLUMN "lastHeartbeatAt" DATETIME`,
note: 'ClassHelper 班级端最后一次心跳(在线判定看它)',
},
{
table: 'User',
column: 'phone',
sql: `ALTER TABLE "User" ADD COLUMN "phone" TEXT NOT NULL DEFAULT ''`,
note: '教师手机号',
},
];
/**
* ⚠ 本脚本只能补**新增的列**,补不了结构变化。
*
* 2026-10-06 那版「学生从账号变成名单记录」(`prisma/migrations/20261006120000_add_student_table`)
* 是一次真正的表重构:新建 `Student` / `StudentClassTransfer`、把 `Grade`/`HomeworkStatus`/
* `NotificationRead` 的外键从 `userId` 换到 `studentId`、删掉 `Enrollment` 与 `ClassTeacher`。
* 这些**必须走正式的迁移链路**(`prisma migrate deploy`,安装程序升级时自动执行):
*
* node apply-column-migrations.cjs "<安装目录>/server/dist/lib/db.js" # 只补列
* pnpm --filter @classhelper/server db:deploy # 结构变化走这条
*
* 只跑本脚本、不跑迁移的库会停在"列有了但表没换"的中间态 —— 下面的列已经能把
* 界面撑起来不报错,但学生数据仍留在 `User` 里,属于不可用状态。
*/
(async () => {
let prisma;
try {
+28 -5
View File
@@ -444,11 +444,34 @@ async function main() {
});
record('非法显示位置被拒(422)', badChannel.status === 422, `status=${badChannel.status}`);
// ---------------------------------------------------------------- 5. 课表镜像(默认关闭 → 打开 → 拉取)
// ---------------------------------------------------------------- 5. 课表镜像
//
// 同步方向在 2026-10-06 反转:**服务端 → 教室自动下发**成为主方向(默认开启),
// 「把教室课表传上来」改成 Web 端的一次性人工动作。因此这里先验证"默认就下发",
// 再验证"关掉之后确实不下发"。
const mirrorDefault = await api('/integrations/classisland/class-plan', { deviceToken });
record(
'课表默认由服务端自动下发(新方向:服务端 → 教室)',
mirrorDefault.status === 200 && mirrorDefault.payload?.data?.classPlan?.entries?.length >= 0,
`status=${mirrorDefault.status} data=${mirrorDefault.payload?.data === null ? 'null' : 'object'}`,
);
const disabled = await api(`/integrations/devices/${deviceId}`, {
method: 'PATCH',
token: teacherToken,
body: { mirrorScheduleToClassIsland: false },
});
record(
'关闭「镜像课表到 ClassIsland」',
disabled.status === 200 && disabled.payload?.data?.mirrorScheduleToClassIsland === false,
`status=${disabled.status}`,
);
const mirrorOff = await api('/integrations/classisland/class-plan', { deviceToken });
record(
'镜像开关关闭时不下发课表',
mirrorOff.status === 200 && mirrorOff.payload?.data === null,
'关掉镜像后不再下发课表',
mirrorOff.status === 200 &&
(mirrorOff.payload?.data === null || mirrorOff.payload?.data === undefined),
`data=${mirrorOff.payload?.data === null ? 'null' : 'object'}`,
);
@@ -458,7 +481,7 @@ async function main() {
body: { mirrorScheduleToClassIsland: true },
});
record(
'打开「镜像课表到 ClassIsland」',
'重新打开「镜像课表到 ClassIsland」',
enabled.status === 200 && enabled.payload?.data?.mirrorScheduleToClassIsland === true,
`status=${enabled.status}`,
);
@@ -562,7 +585,7 @@ async function main() {
// 第二次上报:只报第二节 → 第一节应当被清理。
// 原先 merge 只 upsert、从不删除,老师删掉的课会永远留在服务端(幽灵课),
// 学生端与 Web 端会一直显示一节实际上已经不上的课。
// ClassHelper 班级端与 Web 端会一直显示一节实际上已经不上的课。
const pruneReport = await api('/integrations/classisland/report', {
method: 'POST',
deviceToken,
+361 -119
View File
@@ -1,9 +1,9 @@
#!/usr/bin/env node
/**
* 端到端联调验证(对应验收标准):
* 1. 教师发布通知 -> 学生端 Socket.IO 5 秒内收到
* 2. 教师发布作业 -> 学生端实时收到,并可标记完成
* 3. 教师录入成绩 -> 学生端实时收到,且 /grades/my 可查
* 1. 教师发布通知 -> ClassHelper 班级端 Socket.IO 5 秒内收到
* 2. 教师发布作业 -> ClassHelper 班级端实时收到,并可标记完成
* 3. 教师录入成绩 -> ClassHelper 班级端实时收到,且 /grades/my 可查
* 4. 权限隔离:学生无法访问其他班级数据、无法调用教师接口
* 5. 课表按周查询可用
*
@@ -52,7 +52,7 @@ function waitForEvent(socket, event, timeoutMs = TIME_LIMIT_MS) {
}
async function main() {
console.log(`\n=== 班级小助手 端到端验证(${BASE_URL})===\n`);
console.log(`\n=== ClassHelper 端到端验证(${BASE_URL})===\n`);
// ---------------------------------------------------------------- 0. 健康检查
const health = await api('/health');
@@ -97,12 +97,12 @@ async function main() {
record('管理员登录 admin', Boolean(adminToken), `status=${adminLogin.status}`);
if (!adminToken) process.exit(1);
// 学生端主体 = 班级:用「班级码 + 班级密码」登录(个人学生账号已停用登录入口)
// ClassHelper 班级端主体 = 班级:用「班级码 + 班级密码」登录(个人学生账号已停用登录入口)
const classesForLogin = await api('/classes', { token: teacherToken });
const loginClass = classesForLogin.payload?.data?.[0];
if (!loginClass?.code) {
record(
'学生端班级登录(班级码 + 班级密码 → classSession)',
'ClassHelper 班级端班级登录(班级码 + 班级密码 → classSession)',
false,
'没有可用班级码,请先执行 pnpm db:seed',
);
@@ -127,15 +127,19 @@ async function main() {
const studentToken = studentLogin.payload?.data?.token;
const studentUser = studentLogin.payload?.data?.user;
record(
'学生端班级登录(班级码 + 班级密码 → classSession)',
'ClassHelper 班级端班级登录(班级码 + 班级密码 → classSession)',
Boolean(studentToken) && studentUser?.classSession === true,
`code=${loginClass.code} status=${studentLogin.status} classId=${studentUser?.classId ?? studentUser?.id}`,
);
// 学生不是账号:`User` 表里根本没有学生行,所以拿学号当账号登录只可能是"账号不存在"
const studentAsAccount = await api('/auth/login', {
method: 'POST',
body: { username: '202601', password: '123456' },
});
record(
'个人学生账号登录已停用(403)',
(await api('/auth/login', { method: 'POST', body: { username: 'student01', password: 'student123' } }))
.status === 403,
'student01 → 403',
'学生没有账号:拿学号当账号登录被拒(401)',
studentAsAccount.status === 401,
`status=${studentAsAccount.status}`,
);
const wrongPassword = await api('/auth/login', {
@@ -149,7 +153,7 @@ async function main() {
const classes = myClasses.payload?.data ?? [];
record('教师获取班级列表', classes.length >= 2, `共 ${classes.length} 个班级`);
const ownClass = classes.find((item) => item.name?.includes('高一(1)')) ?? classes[0];
const ownClass = classes.find((item) => item.name?.includes('2026级1')) ?? classes[0];
const classId = ownClass?.id;
if (!classId) {
record('教师至少管理一个班级', false, '数据为空,请先执行 pnpm db:seed');
@@ -169,14 +173,17 @@ async function main() {
const teacher2Classes = (await api('/classes', { token: teacher2Token })).payload?.data ?? [];
/** 本次验证临时创建的班级(收尾统一删除,不污染演示数据) */
const probeClassIds = [];
/** 权限矩阵用例里新建的作业(收尾统一删除) */
const tempHomeworkIds = [];
let foreignClass = teacher2Classes.find((item) => !teacher1ClassIds.has(item.id)) ?? null;
if (!foreignClass) {
const created = await api('/classes', {
method: 'POST',
token: adminToken,
body: {
name: `权限校验班 ${Date.now()}`,
grade: '高一',
enrollmentYear: new Date().getFullYear(),
classIndex: 93,
code: `PV${Date.now().toString().slice(-6)}`,
...(teacher2Id ? { teacherId: teacher2Id } : {}),
},
@@ -194,6 +201,8 @@ async function main() {
const classmates = studentList.payload?.data ?? [];
// 班级会话下没有"当前学生",目标学生固定取班里第一位(用于成绩/叫人等需要具体学生 id 的用例)
const targetStudent = classmates[0];
/** 同班第二位学生的学号(导入用例里要造「两条不同的学号」) */
const classmateTwoNo = classmates[1]?.studentNo ?? '202602';
record('教师获取学生名单', classmates.length > 0, `共 ${classmates.length} 人`);
const courses = await api(`/courses?classId=${classId}`, { token: teacherToken });
@@ -420,7 +429,7 @@ async function main() {
});
});
record(
'学生端 Socket.IO 连接与鉴权',
'ClassHelper 班级端 Socket.IO 连接与鉴权',
Boolean(connected && !connected.error),
connected?.error ?? `rooms=${connected?.rooms?.length ?? 0}`,
);
@@ -441,9 +450,9 @@ async function main() {
try {
const { payload, elapsed } = await notificationWait;
record('学生端 5 秒内收到 notification:new', elapsed < TIME_LIMIT_MS, `${elapsed}ms · ${payload?.title}`);
record('ClassHelper 班级端 5 秒内收到 notification:new', elapsed < TIME_LIMIT_MS, `${elapsed}ms · ${payload?.title}`);
} catch (error) {
record('学生端 5 秒内收到 notification:new', false, error.message);
record('ClassHelper 班级端 5 秒内收到 notification:new', false, error.message);
}
// ---------------------------------------------------------------- 5. 作业实时推送 + 标记完成
@@ -499,9 +508,9 @@ async function main() {
try {
const { payload, elapsed } = await homeworkWait;
record('学生端 5 秒内收到 homework:new', elapsed < TIME_LIMIT_MS, `${elapsed}ms · ${payload?.title}`);
record('ClassHelper 班级端 5 秒内收到 homework:new', elapsed < TIME_LIMIT_MS, `${elapsed}ms · ${payload?.title}`);
} catch (error) {
record('学生端 5 秒内收到 homework:new', false, error.message);
record('ClassHelper 班级端 5 秒内收到 homework:new', false, error.message);
}
const statusUpdate = await api(`/homeworks/${homeworkId}/status`, {
@@ -517,7 +526,7 @@ async function main() {
/* ---------------------------------------------------------------- 5.1 未交名单 */
const submissions = await api(`/homeworks/${homeworkId}/submissions`, { token: teacherToken });
const studentIds = (submissions.payload?.data?.students ?? []).map((item) => item.userId);
const studentIds = (submissions.payload?.data?.students ?? []).map((item) => item.studentId);
record(
'教师可读取作业提交名单(全班学生 + 完成状态)',
submissions.status === 200 &&
@@ -531,13 +540,13 @@ async function main() {
const savedSubmissions = await api(`/homeworks/${homeworkId}/submissions`, {
method: 'PATCH',
token: teacherToken,
body: { notSubmittedUserIds: [firstStudentId] },
body: { notSubmittedStudentIds: [firstStudentId] },
});
record(
'保存未交名单:勾选者标记未交、其余学生自动标记已交',
savedSubmissions.status === 200 &&
(savedSubmissions.payload?.data?.notSubmitted ?? []).length === 1 &&
savedSubmissions.payload?.data?.notSubmitted?.[0]?.userId === firstStudentId &&
savedSubmissions.payload?.data?.notSubmitted?.[0]?.studentId === firstStudentId &&
savedSubmissions.payload?.data?.completedCount === studentIds.length - 1,
`status=${savedSubmissions.status} 未交=${(savedSubmissions.payload?.data?.notSubmitted ?? []).length} ` +
`已完成=${savedSubmissions.payload?.data?.completedCount ?? '-'}/${savedSubmissions.payload?.data?.total ?? '-'}`,
@@ -584,7 +593,7 @@ async function main() {
body: {
classId,
courseId,
userId: targetStudent?.id,
studentId: targetStudent?.id,
examName: `联调验证考试 ${new Date().toISOString().slice(0, 10)}`,
score: 88,
totalScore: 100,
@@ -596,16 +605,16 @@ async function main() {
try {
const { payload, elapsed } = await gradeWait;
record(
'学生端 5 秒内收到 grade:updated',
'ClassHelper 班级端 5 秒内收到 grade:updated',
elapsed < TIME_LIMIT_MS,
`${elapsed}ms · ${payload?.score}/${payload?.totalScore}`,
);
} catch (error) {
record('学生端 5 秒内收到 grade:updated', false, error.message);
record('ClassHelper 班级端 5 秒内收到 grade:updated', false, error.message);
}
} else {
gradeWait.catch(() => {});
record('学生端成绩推送(目标学生非验证账号,跳过等待)', true, '已改用 /grades/my 校验');
record('ClassHelper 班级端成绩推送(目标学生非验证账号,跳过等待)', true, '已改用 /grades/my 校验');
}
const myGrades = await api('/grades/my', { token: studentToken });
@@ -658,23 +667,42 @@ async function main() {
record('删除课程 DELETE /courses/:id', removedCourse.status === 200, `status=${removedCourse.status}`);
}
const assigned = await api(`/classes/${classId}/teachers`, {
method: 'POST',
// 任课关系 = 班级 + 科目 + 教师,唯一写入口是 PUT /classes/:id/subject-teachers。
// 用一个演示数据里没用来录成绩的科目,验证完就还回班主任,避免影响后面的成绩权限用例。
const probeSubject = '通用技术';
const adminAssignSubject = await api(`/classes/${classId}/subject-teachers`, {
method: 'PUT',
token: adminToken,
body: { teacherId: teacher2Login.payload?.data?.user?.id },
body: { assignments: [{ subjectName: probeSubject, teacherId: teacher2Login.payload?.data?.user?.id }] },
});
record('分配协作教师', assigned.status === 201, `status=${assigned.status}`);
const unassigned = await api(`/classes/${classId}/teachers/${teacher2Login.payload?.data?.user?.id}`, {
method: 'DELETE',
record(
'管理员设置某班某科的任课老师(PUT /subject-teachers)',
adminAssignSubject.status === 200 &&
(adminAssignSubject.payload?.data ?? []).some(
(item) => item.subjectName === probeSubject &&
item.teacherId === teacher2Login.payload?.data?.user?.id,
),
`status=${adminAssignSubject.status}`,
);
const restoreSubject = await api(`/classes/${classId}/subject-teachers`, {
method: 'PUT',
token: adminToken,
body: { assignments: [{ subjectName: probeSubject, teacherId: ownClass?.teacherId }] },
});
record('取消协作教师', unassigned.status === 200, `status=${unassigned.status}`);
record(
'任课老师可改回班主任(同一个人既是班主任又是科任,不重复建号)',
restoreSubject.status === 200 &&
(restoreSubject.payload?.data ?? []).some(
(item) => item.subjectName === probeSubject && item.teacherId === ownClass?.teacherId,
),
`status=${restoreSubject.status}`,
);
const tempUsername = `e2e_student_${Date.now()}`;
const tempUsername = `E2E${Date.now().toString().slice(-8)}`;
const addedStudent = await api(`/classes/${classId}/students`, {
method: 'POST',
token: adminToken,
body: { username: tempUsername, name: '联调学生' },
body: { studentNo: tempUsername, name: '联调学生' },
});
const addedStudentId = addedStudent.payload?.data?.id;
record('班级添加学生', addedStudent.status === 201, `status=${addedStudent.status}`);
@@ -682,8 +710,10 @@ async function main() {
if (addedStudentId) {
const studentListAfter = await api(`/classes/${classId}/students`, { token: teacherToken });
record(
'学生名单包含新加入学生',
(studentListAfter.payload?.data ?? []).some((item) => item.id === addedStudentId),
'学生名单包含新加入学生(按学号)',
(studentListAfter.payload?.data ?? []).some(
(item) => item.id === addedStudentId && item.studentNo === tempUsername.toUpperCase(),
),
`共 ${studentListAfter.payload?.data?.length ?? 0} 人`,
);
@@ -728,7 +758,7 @@ async function main() {
courseId,
examName: `联调批量考试 ${new Date().toISOString().slice(0, 10)}`,
totalScore: 100,
items: classmates.slice(0, 3).map((item, index) => ({ userId: item.id, score: 70 + index * 5 })),
items: classmates.slice(0, 3).map((item, index) => ({ studentId: item.id, score: 70 + index * 5 })),
},
});
record(
@@ -739,9 +769,13 @@ async function main() {
const gradeStats = await api(`/grades/stats?classId=${classId}`, { token: teacherToken });
record(
// 等级分布不再固定 A~E 五档:等级制(A-D)与自定义等级(优/良/合格)都在用,
// 因此断言"有分布且每个档位都有人",而不是数档位个数。
'成绩统计 /grades/stats',
gradeStats.status === 200 && (gradeStats.payload?.data?.distribution ?? []).length === 5,
`平均得分率 ${gradeStats.payload?.data?.averagePercent ?? 0}%`,
gradeStats.status === 200 &&
(gradeStats.payload?.data?.distribution ?? []).length >= 1 &&
(gradeStats.payload?.data?.distribution ?? []).every((item) => typeof item.level === 'string'),
`平均得分率 ${gradeStats.payload?.data?.averagePercent ?? 0}% 档位=${(gradeStats.payload?.data?.distribution ?? []).map((item) => item.level).join('/')}`,
);
const unread = await api('/notifications/unread-count', { token: studentToken });
@@ -931,7 +965,7 @@ async function main() {
`status=${callByTeacher.status} title="${callTitle}"`,
);
record(
'普通叫人(默认)不是紧急级别:priority=HIGH,学生端按普通通知排队',
'普通叫人(默认)不是紧急级别:priority=HIGH,ClassHelper 班级端按普通通知排队',
callByTeacher.payload?.data?.priority === 'HIGH',
`priority=${callByTeacher.payload?.data?.priority ?? '-'}(期望 HIGH)`,
);
@@ -947,7 +981,7 @@ async function main() {
},
});
record(
'紧急叫人(urgent=true → priority=URGENT,学生端无视上课时段立即展开)',
'紧急叫人(urgent=true → priority=URGENT,ClassHelper 班级端无视上课时段立即展开)',
urgentCall.status === 201 && urgentCall.payload?.data?.priority === 'URGENT',
`status=${urgentCall.status} priority=${urgentCall.payload?.data?.priority ?? '-'}`,
);
@@ -1013,27 +1047,28 @@ async function main() {
{
record('管理员登录(用于权限矩阵校验)', Boolean(adminToken), '已在上文登录');
// 科任老师:teacher2 是某班的协作(科任)老师。
// 同样不依赖演示数据:推断出来的班级不可用(被人工改过权限 / 与外部班级同一个)时现建一个:
// teacher1 当班主任 + teacher2 协作,收尾删除。
// 科任老师:teacher2 在某班**有任课关系**(Course.teacherId === 他)。
// 同样不依赖演示数据:推断出来的班级不可用(被人工改过 / 与外班同一个)时现建一个,
// 用 teacher1 当班主任 + 给 teacher2 配一门课,收尾删除。
let subjectClass = teacher2Classes.find((item) => item.teacherId !== teacher2Id) ?? null;
if (!subjectClass || subjectClass.id === foreignClass?.id) {
const created = await api('/classes', {
method: 'POST',
token: adminToken,
body: {
name: `科任校验班 ${Date.now()}`,
grade: '高一',
enrollmentYear: new Date().getFullYear(),
classIndex: 90,
code: `SJ${Date.now().toString().slice(-6)}`,
teacherId: teacher1Id,
},
});
const createdId = created.payload?.data?.id;
if (createdId) {
await api(`/classes/${createdId}/teachers`, {
method: 'POST',
await api(`/classes/${createdId}/subject-teachers`, {
method: 'PUT',
token: adminToken,
body: { teacherId: teacher2Id },
body: { assignments: [{ subjectName: '数学', teacherId: teacher2Id }] },
});
subjectClass = { id: createdId, name: created.payload?.data?.name ?? '科任校验班' };
probeClassIds.push(createdId);
@@ -1050,53 +1085,52 @@ async function main() {
const headCreate = await api('/classes', {
method: 'POST',
token: teacherToken,
body: { name: '班主任越权班', grade: '高一' },
body: { grade: '高一', enrollmentYear: new Date().getFullYear(), classIndex: 91 },
});
record('班主任创建班级被拒绝(403)', headCreate.status === 403, `status=${headCreate.status}`);
const subjectCreate = await api('/classes', {
method: 'POST',
token: teacher2Token,
body: { name: '科任越权班', grade: '高一' },
body: { grade: '高一', enrollmentYear: new Date().getFullYear(), classIndex: 92 },
});
record('科任老师创建班级被拒绝(403)', subjectCreate.status === 403, `status=${subjectCreate.status}`);
const headDelete = await api(`/classes/${classId}`, { method: 'DELETE', token: teacherToken });
record('班主任删除班级被拒绝(403)', headDelete.status === 403, `status=${headDelete.status}`);
const headAssign = await api(`/classes/${classId}/teachers`, {
method: 'POST',
const headAssign = await api(`/classes/${classId}/subject-teachers`, {
method: 'PUT',
token: teacherToken,
body: { teacherId: teacher2Login.payload?.data?.user?.id },
body: { assignments: [{ subjectName: '音乐', teacherId: teacher2Login.payload?.data?.user?.id }] },
});
record('班主任分配科任老师被拒绝(403)', headAssign.status === 403, `status=${headAssign.status}`);
record('班主任分配任课老师被拒绝(403)', headAssign.status === 403, `status=${headAssign.status}`);
const subjectAssign = await api(`/classes/${classId}/teachers`, {
method: 'POST',
const subjectAssign = await api(`/classes/${classId}/subject-teachers`, {
method: 'PUT',
token: teacher2Token,
body: { teacherId: teacher2Login.payload?.data?.user?.id },
body: { assignments: [{ subjectName: '音乐', teacherId: teacher2Login.payload?.data?.user?.id }] },
});
record('科任老师分配人员被拒绝(403)', subjectAssign.status === 403, `status=${subjectAssign.status}`);
// 管理员可以分配(用"先加后删"验证)
const adminAssign = await api(`/classes/${classId}/teachers`, {
method: 'POST',
const adminAssign = await api(`/classes/${classId}/subject-teachers`, {
method: 'PUT',
token: adminToken,
body: { teacherId: teacher2Login.payload?.data?.user?.id },
body: { assignments: [{ subjectName: '音乐', teacherId: teacher2Login.payload?.data?.user?.id }] },
});
record(
'管理员分配科任老师成功(201/200)',
[200, 201].includes(adminAssign.status),
'管理员分配任课老师成功(200)',
adminAssign.status === 200,
`status=${adminAssign.status}`,
);
const adminUnassign = await api(
`/classes/${classId}/teachers/${teacher2Login.payload?.data?.user?.id}`,
{
method: 'DELETE',
token: adminToken,
},
);
record('管理员取消科任老师成功(200)', adminUnassign.status === 200, `status=${adminUnassign.status}`);
// 解除 = 把该科还回班主任(Course.teacherId 是必填列,不能留空)
const adminUnassign = await api(`/classes/${classId}/subject-teachers`, {
method: 'PUT',
token: adminToken,
body: { assignments: [{ subjectName: '音乐', teacherId: ownClass?.teacherId }] },
});
record('管理员解除任课关系成功(200)', adminUnassign.status === 200, `status=${adminUnassign.status}`);
// 课表:班主任可管理本班,科任老师不可
if (subjectClassId) {
@@ -1120,16 +1154,59 @@ async function main() {
);
}
// 新规则:**作业按科目授权**。
// 科任老师只能布置自己任教科目(Course.teacherId)的作业;
// 不指定科目的作业只有班主任与管理员能发 —— 两条都要验到。
const subjectCourse = (await api(`/courses?classId=${subjectClassId}`, { token: adminToken }))
.payload?.data?.find((item) => item.teacherId === teacher2Id);
const subjectHomework = await api('/homeworks', {
method: 'POST',
token: subjectClassId ? teacher2Token : teacherToken,
body: {
classId: subjectClassId,
courseId: subjectCourse?.id,
title: '科任老师布置的作业',
content: '权限矩阵校验用',
},
});
record('科任老师可布置作业(201)', subjectHomework.status === 201, `status=${subjectHomework.status}`);
record(
'科任老师可布置自己任教科目的作业(201)',
subjectHomework.status === 201,
`status=${subjectHomework.status} 科目=${subjectCourse?.name ?? '-'}`,
);
if (subjectHomework.payload?.data?.id) {
tempHomeworkIds.push(subjectHomework.payload.data.id);
}
const subjectHomeworkNoCourse = await api('/homeworks', {
method: 'POST',
token: subjectClassId ? teacher2Token : teacherToken,
body: { classId: subjectClassId, title: '科任老师的不指定科目作业', content: '应被拒绝' },
});
record(
'科任老师布置不指定科目的作业被拒绝(403)',
subjectHomeworkNoCourse.status === 403,
`status=${subjectHomeworkNoCourse.status}`,
);
// 科任老师改**别人任教科目**的作业同样被拒
if (subjectCourse) {
const otherCourse = (await api(`/courses?classId=${subjectClassId}`, { token: adminToken }))
.payload?.data?.find((item) => item.teacherId !== teacher2Id);
const foreignHomework = await api('/homeworks', {
method: 'POST',
token: teacher2Token,
body: {
classId: subjectClassId,
courseId: otherCourse?.id,
title: '科任老师替别人发作业',
content: '应被拒绝',
},
});
record(
'科任老师给非任教科目布置作业被拒绝(403)',
foreignHomework.status === 403,
`status=${foreignHomework.status} 科目=${otherCourse?.name ?? '-'}`,
);
}
if (subjectHomework.payload?.data?.id) {
await api(`/homeworks/${subjectHomework.payload.data.id}`, {
method: 'DELETE',
@@ -1142,7 +1219,7 @@ async function main() {
token: teacher2Token,
body: {
classId: classId,
userId: targetStudent?.id,
studentId: targetStudent?.id,
examName: '越权成绩',
score: 90,
totalScore: 100,
@@ -1156,7 +1233,7 @@ async function main() {
token: teacherToken,
body: {
classId: classId,
userId: targetStudent?.id,
studentId: targetStudent?.id,
examName: '班主任本班成绩',
score: 90,
totalScore: 100,
@@ -1177,7 +1254,7 @@ async function main() {
token: teacherToken,
body: {
classId: foreignClass.id,
userId: targetStudent?.id,
studentId: targetStudent?.id,
examName: '班主任跨班成绩',
score: 90,
totalScore: 100,
@@ -1195,7 +1272,7 @@ async function main() {
token: adminToken,
body: {
classId: classId,
userId: targetStudent?.id,
studentId: targetStudent?.id,
examName: '管理员录入成绩',
score: 88,
totalScore: 100,
@@ -1438,10 +1515,10 @@ async function main() {
// 8.3 成绩表格导入(CSV 校验 + XLSX 新增/重复/更新 + 错误行提示)
const gradesCsv = [
'用户名,姓名,考试名称,分数,总分,课程',
'student01,,期中导入考试,92,100,',
'学号,姓名,考试名称,分数,总分,科目',
`${targetStudent?.studentNo ?? '202601'},,期中导入考试,92,100,`,
`,${targetStudent?.name ?? '张同学'},期中导入考试,不是数字,100,`,
'unknown-user,,期中导入考试,88,100,',
'UNKNOWN-USER,,期中导入考试,88,100,',
].join(NL);
const gradesCsvBase64 = Buffer.from(`\ufeff${gradesCsv}${NL}`, 'utf8').toString('base64');
@@ -1490,8 +1567,8 @@ async function main() {
const XLSX = xlsxNamespace.default ?? xlsxNamespace;
const xlsxSheet = XLSX.utils.aoa_to_sheet([
['学号', '姓名', '考试', '得分', '满分', '科目'],
['student02', '', '期中导入考试', 85, 100, ''],
['student01', '', '期中导入考试', 78, 100, ''],
[classmateTwoNo, '', '期中导入考试', 85, 100, ''],
[targetStudent?.studentNo ?? '202601', '', '期中导入考试', 78, 100, ''],
]);
const xlsxBook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(xlsxBook, xlsxSheet, '成绩');
@@ -1506,13 +1583,13 @@ async function main() {
'XLSX 成绩表预览(同义词自动映射 学号/得分)',
xlsxPreview.status === 200 &&
xlsxPreview.payload?.data?.totalRows === 2 &&
xlsxPreview.payload?.data?.suggestedMapping?.username === '学号' &&
xlsxPreview.payload?.data?.suggestedMapping?.studentNo === '学号' &&
xlsxPreview.payload?.data?.suggestedMapping?.score === '得分',
`status=${xlsxPreview.status} 映射=${JSON.stringify(xlsxPreview.payload?.data?.suggestedMapping ?? {})}`,
);
const commitMapping = {
username: '学号',
studentNo: '学号',
name: '姓名',
examName: '考试',
score: '得分',
@@ -1573,12 +1650,12 @@ async function main() {
fileName: 'grades.csv',
contentBase64: gradesCsvBase64,
mapping: {
username: '用户名',
studentNo: '学号',
name: '姓名',
examName: '考试名称',
score: '分数',
totalScore: '总分',
courseName: '课程',
courseName: '科目',
},
mode: 'append',
},
@@ -1608,9 +1685,9 @@ async function main() {
);
// 8.4 学生名单导入(仅管理员)+ 数据清理
const importedUsername = `imp${Date.now().toString(36).slice(-6)}`;
// 学生导入模板已无"初始密码"列(学生没有账号属性)
const studentsCsv = ['用户名,姓名', `${importedUsername},导入测试生`, 'bad user,非法用户名'].join(NL);
const importedUsername = `IMP${Date.now().toString(36).slice(-6)}`.toUpperCase();
// 学生导入模板已无"初始密码"列(学生没有账号属性);表头是「学号」
const studentsCsv = ['学号,姓名', `${importedUsername},导入测试生`, 'bad@user,非法学号'].join(NL);
const studentsCommit = await api('/imports/table/commit', {
method: 'POST',
token: adminToken,
@@ -1619,12 +1696,12 @@ async function main() {
classId,
fileName: 'students.csv',
contentBase64: Buffer.from(`\ufeff${studentsCsv}${NL}`, 'utf8').toString('base64'),
mapping: { username: '用户名', name: '姓名' },
mapping: { studentNo: '学号', name: '姓名' },
mode: 'append',
},
});
record(
'学生名单导入(新增 1 / 非法用户名进错误行 1)',
'学生名单导入(新增 1 / 非法学号进错误行 1)',
studentsCommit.status === 200 &&
studentsCommit.payload?.data?.inserted === 1 &&
studentsCommit.payload?.data?.failed === 1,
@@ -1653,12 +1730,12 @@ async function main() {
fileName: 'grades.csv',
contentBase64: gradesCsvBase64,
mapping: {
username: '用户名',
studentNo: '学号',
name: '姓名',
examName: '考试名称',
score: '分数',
totalScore: '总分',
courseName: '课程',
courseName: '科目',
},
mode: 'upsert',
},
@@ -1672,8 +1749,8 @@ async function main() {
`status=${teacherGradesCommit.status} 新增=${teacherGradesCommit.payload?.data?.inserted} 更新=${teacherGradesCommit.payload?.data?.updated} 失败=${teacherGradesCommit.payload?.data?.failed}`,
);
// teacher2 是高二(3)班的科任老师(班主任是 teacher1)——这才是真正的"科任"场景
const subjectOnlyClass = classes.find((item) => item.name?.includes('高二(3)')) ?? null;
// teacher2 是2025级3班的科任老师(班主任是 teacher1)——这才是真正的"科任"场景
const subjectOnlyClass = classes.find((item) => item.name?.includes('2025级3')) ?? null;
const subjectGradesImport = await api('/imports/table/commit', {
method: 'POST',
token: teacher2Token,
@@ -1683,12 +1760,12 @@ async function main() {
fileName: 'grades.csv',
contentBase64: gradesCsvBase64,
mapping: {
username: '用户名',
studentNo: '学号',
name: '姓名',
examName: '考试名称',
score: '分数',
totalScore: '总分',
courseName: '课程',
courseName: '科目',
},
mode: 'upsert',
},
@@ -1696,7 +1773,7 @@ async function main() {
record(
'科任老师导入本班成绩被拒绝(403,需求 7)',
Boolean(subjectOnlyClass) && subjectGradesImport.status === 403,
`班级=${subjectOnlyClass?.name ?? '未找到高二(3)班'} status=${subjectGradesImport.status}`,
`班级=${subjectOnlyClass?.name ?? '未找到2025级3班'} status=${subjectGradesImport.status}`,
);
if (foreignClass) {
@@ -1709,12 +1786,12 @@ async function main() {
fileName: 'grades.csv',
contentBase64: gradesCsvBase64,
mapping: {
username: '用户名',
studentNo: '学号',
name: '姓名',
examName: '考试名称',
score: '分数',
totalScore: '总分',
courseName: '课程',
courseName: '科目',
},
mode: 'upsert',
},
@@ -1749,7 +1826,7 @@ async function main() {
classId,
fileName: 'students.csv',
contentBase64: Buffer.from(`\ufeff${studentsCsv}${NL}`, 'utf8').toString('base64'),
mapping: { username: '用户名', name: '姓名' },
mapping: { studentNo: '学号', name: '姓名' },
mode: 'append',
},
});
@@ -1774,7 +1851,7 @@ async function main() {
record('导入的成绩可正常删除(清理验证数据)', gradesCleaned >= 2, `已删除 ${gradesCleaned} 条`);
const roster = await api(`/students?classId=${classId}`, { token: adminToken });
const importedStudent = (roster.payload?.data ?? []).find((item) => item.username === importedUsername);
const importedStudent = (roster.payload?.data ?? []).find((item) => item.studentNo === importedUsername);
if (importedStudent) {
const removedStudent = await api(`/students/${importedStudent.id}`, {
method: 'DELETE',
@@ -1795,7 +1872,7 @@ async function main() {
record('时间配置可删除(清理验证数据)', layoutDeleted.status === 200, `status=${layoutDeleted.status}`);
}
// ---------------------------------------------------------------- 9. 班级账号(学生端主体 = 班级)
// ---------------------------------------------------------------- 9. 班级账号(ClassHelper 班级端主体 = 班级)
// 设计:班级码 + 班级密码 → classSession 的 JWT;个人数据(作业完成 / 通知已读 / 成绩)
// 由服务端按"全班"范围读写,个人学生不再是登录主体(个人账号接口仍向后兼容)。
// 注意:本段会临时改动班级码,结束时必须恢复(否则演示实例会留下 E2E#### 这种随机码)。
@@ -1887,7 +1964,7 @@ async function main() {
const classGradesWrite = await api('/grades', {
method: 'POST',
token: classToken,
body: { classId, userId: targetStudent?.id, examName: '班级账号越权成绩', score: 90, totalScore: 100 },
body: { classId, studentId: targetStudent?.id, examName: '班级账号越权成绩', score: 90, totalScore: 100 },
});
record(
'班级账号录入成绩被拒绝(403)',
@@ -2020,10 +2097,10 @@ async function main() {
const classNameGrade = await api('/grades', {
method: 'POST',
token: adminToken,
body: { classId, userId: targetStudent?.id, examName: '班级总览回归', score: 77, totalScore: 100 },
body: { classId, studentId: targetStudent?.id, examName: '班级总览回归', score: 77, totalScore: 100 },
});
const classMyGrades = await api('/grades/my', { token: classToken });
const gradeStudentIds = new Set((classMyGrades.payload?.data ?? []).map((item) => item.userId));
const gradeStudentIds = new Set((classMyGrades.payload?.data ?? []).map((item) => item.studentId));
record(
'班级账号查看成绩 = 全班总览',
classMyGrades.status === 200 &&
@@ -2180,7 +2257,7 @@ async function main() {
// 教师名单表格导入:教师预览 403;管理员预览 + 提交(教师与班级无关,classId 可省略)
const teacherCsv =
'\ufeff用户名,姓名,初始密码,角色\n' +
'\ufeff工号,姓名,初始密码,角色\n' +
`imp_t_${Date.now().toString().slice(-8)},冒烟导入教师,import123456,教师\n`;
const teacherCsvBase64 = Buffer.from(teacherCsv, 'utf8').toString('base64');
const importedTeacherUsername = teacherCsv.split('\n')[1]?.split(',')[0] ?? '';
@@ -2202,7 +2279,7 @@ async function main() {
kind: 'teachers',
fileName: 'teachers.csv',
contentBase64: teacherCsvBase64,
mapping: { username: '用户名', name: '姓名', password: '初始密码', role: '角色' },
mapping: { username: '工号', name: '姓名', password: '初始密码', role: '角色' },
mode: 'upsert',
},
});
@@ -2253,11 +2330,11 @@ async function main() {
});
const cascadeTeacherToken = cascadeTeacherLogin.payload?.data?.token;
// 1) 先让他成为该班科任(这样才有发布权限),发一条通知
await api(`/classes/${classId}/teachers`, {
method: 'POST',
// 1) 先让他成为该班某科的任课老师(这样才有发布通知的权限),发一条通知
await api(`/classes/${classId}/subject-teachers`, {
method: 'PUT',
token: adminToken,
body: { teacherId: cascadeTeacherId },
body: { assignments: [{ subjectName: '美术', teacherId: cascadeTeacherId }] },
});
const cascadeNotification = await api('/notifications', {
method: 'POST',
@@ -2265,10 +2342,11 @@ async function main() {
body: { classId, title: '级联删除回归', content: '用于验证删除教师不会连带删除已发布内容' },
});
const cascadeNotificationId = cascadeNotification.payload?.data?.id;
// 2) 解除班级分配:此时他"不在任何班级、名下无课程",但发布过内容
await api(`/classes/${classId}/teachers/${cascadeTeacherId}`, {
method: 'DELETE',
// 2) 解除任课关系(把该科还回班主任):此时他"不在任何班级、名下无课程",但发布过内容
await api(`/classes/${classId}/subject-teachers`, {
method: 'PUT',
token: adminToken,
body: { assignments: [{ subjectName: '美术', teacherId: ownClass?.teacherId }] },
});
// 3) 删除账号应当被拒(409),且那条通知必须还在
const cascadeDelete = await api(`/teachers/${cascadeTeacherId}`, {
@@ -2303,7 +2381,7 @@ async function main() {
const teacherCreateClass = await api('/classes', {
method: 'POST',
token: teacherToken,
body: { name: '越权班级', grade: '高一' },
body: { grade: '高一', enrollmentYear: new Date().getFullYear(), classIndex: 94 },
});
record(
'仅管理员可创建班级(教师 403)',
@@ -2316,8 +2394,9 @@ async function main() {
method: 'POST',
token: adminToken,
body: {
name: `冒烟班级 ${Date.now()}`,
grade: '高一',
enrollmentYear: new Date().getFullYear(),
classIndex: 95,
code: tempClassCode,
...(teacher2 ? { teacherId: teacher2.id } : {}),
},
@@ -2357,11 +2436,11 @@ async function main() {
const dbStatus = await api('/database/status', { token: adminToken });
const dbStatusData = dbStatus.payload?.data ?? {};
record(
'数据库状态(管理员):已连接且返回 14 张表行数',
'数据库状态(管理员):已连接且返回 15 张表行数',
dbStatus.status === 200 &&
dbStatusData.connected === true &&
Array.isArray(dbStatusData.tables) &&
dbStatusData.tables.length === 14 &&
dbStatusData.tables.length === 15 &&
dbStatusData.tables.every((item) => item.count >= 0),
`provider=${dbStatusData.provider} 延迟=${dbStatusData.latencyMs}ms 版本=${dbStatusData.version} 表=${
(dbStatusData.tables ?? []).length
@@ -2478,10 +2557,10 @@ async function main() {
snapshotParsed = null;
}
record(
'导出快照(JSON 格式正确、14 张表、用户数 > 0)',
'导出快照(JSON 格式正确、15 张表、账号数 > 0)',
dbExport.status === 200 &&
snapshotParsed?.format === 'classhelper-snapshot' &&
Object.keys(snapshotParsed?.data ?? {}).length === 14 &&
Object.keys(snapshotParsed?.data ?? {}).length === 15 &&
(snapshotParsed?.counts?.User ?? 0) > 0,
`status=${dbExport.status} 格式=${snapshotParsed?.format ?? '-'} 用户=${snapshotParsed?.counts?.User ?? '-'}`,
);
@@ -2665,6 +2744,169 @@ async function main() {
`status=${updateTeacherForced.status} 命中缓存=${updateTeacherForced.payload?.data?.checkedAt === updateData.checkedAt}`,
);
/* ---------------------------------------------------------------- 13. 学期周次(仅管理员可写) */
// 需求:「第几周的周次数据可以由 admin 修改,并对所有 ClassIsland 下发修改」。
// 线性推算遇到调休/补课会整体错位,所以支持逐周指定日期区间。
const termGlobal = await api('/term', { token: adminToken });
record(
'学期周次:读取全校默认口径',
termGlobal.status === 200 &&
typeof termGlobal.payload?.data?.currentWeek === 'number' &&
Array.isArray(termGlobal.payload?.data?.weeks),
`status=${termGlobal.status} 当前第 ${termGlobal.payload?.data?.currentWeek ?? '-'} 周 配置=${termGlobal.payload?.data?.configured}`,
);
const termForTeacher = await api('/term', { token: teacherToken });
record(
'学期周次:教师可读(课表页要显示"第几周")',
termForTeacher.status === 200,
`status=${termForTeacher.status}`,
);
// 用一段**覆盖今天**的区间,验证"当前周次按区间判定"而不是按开学日期线性推算
const today = new Date();
const pad = (value) => String(value).padStart(2, '0');
const dayKey = (date) => `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
const rangeStart = new Date(today.getTime() - 2 * 86400000);
const rangeEnd = new Date(today.getTime() + 2 * 86400000);
const beforeTerm = (await api('/term', { token: adminToken })).payload?.data;
const termSaved = await api('/term', {
method: 'PUT',
token: adminToken,
body: {
weeks: [
{ weekNumber: 7, startDate: dayKey(rangeStart), endDate: dayKey(rangeEnd), note: 'e2e 区间回归' },
],
},
});
record(
'学期周次:管理员保存逐周区间(PUT /term)',
termSaved.status === 200 &&
termSaved.payload?.data?.configured === true &&
termSaved.payload?.data?.currentWeek === 7,
`status=${termSaved.status} 当前第 ${termSaved.payload?.data?.currentWeek ?? '-'} 周(区间覆盖今天应为第 7 周)`,
);
const termOverlap = await api('/term', {
method: 'PUT',
token: adminToken,
body: {
weeks: [
{ weekNumber: 1, startDate: '2026-09-01', endDate: '2026-09-10' },
{ weekNumber: 2, startDate: '2026-09-05', endDate: '2026-09-14' },
],
},
});
record(
'学期周次:区间重叠被拒绝(400,避免"当前第几周"出现歧义)',
termOverlap.status === 400,
`status=${termOverlap.status} message=${termOverlap.payload?.message ?? ''}`,
);
const termDenied = await api('/term', {
method: 'PUT',
token: teacherToken,
body: { weeks: [{ weekNumber: 1, startDate: '2026-09-01', endDate: '2026-09-07' }] },
});
record(
'学期周次:教师写入被拒绝(403,周次会让全校课表错位)',
termDenied.status === 403,
`status=${termDenied.status}`,
);
const termAuto = await api('/term/auto', {
method: 'POST',
token: adminToken,
body: { termStartDate: '2026-09-07', maxWeek: 3 },
});
record(
'学期周次:按开学日期一键生成(POST /term/auto)',
termAuto.status === 200 &&
(termAuto.payload?.data?.weeks ?? []).length === 3 &&
termAuto.payload?.data?.weeks?.[0]?.startDate === '2026-09-07' &&
termAuto.payload?.data?.weeks?.[2]?.endDate === '2026-09-27',
`status=${termAuto.status} 首周=${termAuto.payload?.data?.weeks?.[0]?.startDate} 末周=${termAuto.payload?.data?.weeks?.[2]?.endDate}`,
);
const holidays = await api('/term/holidays', { token: adminToken });
record(
'学期周次:联网拉取放假安排(机房无外网时 ok=false 属正常结果,不能抛 500)',
holidays.status === 200 && typeof holidays.payload?.data?.ok === 'boolean',
`status=${holidays.status} ok=${holidays.payload?.data?.ok} 条数=${holidays.payload?.data?.holidays?.length ?? 0}`,
);
// 收尾:还原成纯线性推算,不污染演示数据
await api('/term', {
method: 'PUT',
token: adminToken,
body: { termStartDate: beforeTerm?.termStartDate ?? undefined, weeks: [] },
});
const termRestored = await api('/term', { token: adminToken });
record(
'学期周次用例收尾:清空逐周配置(回到按开学日期线性推算)',
termRestored.payload?.data?.configured === false,
`configured=${termRestored.payload?.data?.configured}`,
);
/* ---------------------------------------------------------------- 14. 课表同步方向反转 */
// 自建一台临时设备(收尾删掉):不依赖演示库里有没有接入过设备
const tempDevice = await api('/integrations/devices', {
method: 'POST',
token: adminToken,
body: { classId, name: 'E2E 课表方向校验设备' },
});
const tempDeviceId = tempDevice.payload?.data?.device?.id;
record(
'课表方向:新建设备默认「服务端自动下发、不自动回传」',
tempDevice.status === 201 &&
tempDevice.payload?.data?.device?.syncScheduleToServer === false &&
tempDevice.payload?.data?.device?.mirrorScheduleToClassIsland === true,
`status=${tempDevice.status} syncScheduleToServer=${tempDevice.payload?.data?.device?.syncScheduleToServer} mirrorScheduleToClassIsland=${tempDevice.payload?.data?.device?.mirrorScheduleToClassIsland}`,
);
if (tempDeviceId) {
const pullRequest = await api(`/integrations/devices/${tempDeviceId}/request-schedule`, {
method: 'POST',
token: adminToken,
});
record(
'从教室机器获取课表:管理员可发起(POST /request-schedule)',
pullRequest.status === 200 && pullRequest.payload?.data?.requested === true,
`status=${pullRequest.status} ${pullRequest.payload?.message ?? ''}`,
);
// 置位后再读设备列表应当看到待办(插件下一次心跳看到就会立刻把课表推上来)
const afterRequest = await api(`/integrations/devices?classId=${classId}`, { token: adminToken });
const requestedDevice = (afterRequest.payload?.data ?? []).find((item) => item.id === tempDeviceId);
record(
'从教室机器获取课表:待办标记已落库(插件下次心跳会照做)',
requestedDevice?.requestScheduleReport === true,
`requestScheduleReport=${requestedDevice?.requestScheduleReport}`,
);
// 教师对**别人班**的设备不能发起
const deniedPull = await api(`/integrations/devices/${tempDeviceId}/request-schedule`, {
method: 'POST',
token: teacher2Token,
});
record(
'从教室机器获取课表:非本班教师被拒绝(403)',
deniedPull.status === 403,
`status=${deniedPull.status}`,
);
// 收尾:删掉临时设备
const removedDevice = await api(`/integrations/devices/${tempDeviceId}`, {
method: 'DELETE',
token: adminToken,
});
record('课表方向用例收尾:临时设备已删除', removedDevice.status === 200, `status=${removedDevice.status}`);
}
socket.close();
// ---------------------------------------------------------------- 汇总
+4 -4
View File
@@ -98,7 +98,7 @@ function renderLandingPage(webAdminServed: boolean): string {
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>班级小助手 · 后端服务</title>
<title>ClassHelper · 后端服务</title>
<style>
body { margin: 0; padding: 40px; font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif; background: #f5f7fa; color: #303133; }
.card { max-width: 760px; margin: 0 auto; background: #fff; border: 1px solid #e4e7ed; border-radius: 12px; padding: 28px 32px; }
@@ -116,7 +116,7 @@ function renderLandingPage(webAdminServed: boolean): string {
</head>
<body>
<div class="card">
<h1>班级小助手 · 后端服务 <span class="ok">running</span></h1>
<h1>ClassHelper · 后端服务 <span class="ok">running</span></h1>
<p class="sub">版本 ${APP_VERSION} · 运行环境 ${env.nodeEnv} · 数据库 ${env.databaseProvider}</p>
<p class="sub">
健康检查 <a href="${API_PREFIX}/health">${API_PREFIX}/health</a> ·
@@ -127,7 +127,7 @@ function renderLandingPage(webAdminServed: boolean): string {
<ul>${moduleRows}</ul>
<div class="hint">
WebSocket 端点:<code>ws://${env.host === '0.0.0.0' ? '本机地址' : env.host}:${env.port}/socket.io</code><br />
本服务只提供 REST API 与 WebSocket;学生端请使用「班级小助手」桌面客户端。
本服务只提供 REST API 与 WebSocket;ClassHelper 班级端请使用「ClassHelper」桌面客户端。
</div>
</div>
</body>
@@ -182,7 +182,7 @@ export function createApp(): Express {
// 限流:先挂通用限流,再挂更严格的登录限流(后挂者决定响应头里的策略,便于观察暴力破解防护)
app.use(API_PREFIX, apiRateLimiter());
// 登录限流必须覆盖**两个**登录入口:`/auth/login`(教师/管理员)与 `/auth/class-login`(学生端
// 登录限流必须覆盖**两个**登录入口:`/auth/login`(教师/管理员)与 `/auth/class-login`(ClassHelper 班级端
// 班级账号)。后者的密码默认就是 123456,漏掉它等于形同虚设 —— 这里用数组形式挂载,
// 两个路径共享同一个计数器(同一 IP 对两种登录的失败次数合并计算)。
app.use([`${API_PREFIX}/auth/login`, `${API_PREFIX}/auth/class-login`], loginRateLimiter());
+1 -1
View File
@@ -27,7 +27,7 @@ const envSchema = z.object({
JWT_EXPIRES_IN: z.string().default('7d'),
CORS_ORIGIN: z.string().default('*'),
BCRYPT_ROUNDS: z.coerce.number().int().min(4).max(15).default(10),
/** 新建班级时的默认班级密码(学生端班级账号登录用),管理员可随时重置 */
/** 新建班级时的默认班级密码(ClassHelper 班级端班级账号登录用),管理员可随时重置 */
DEFAULT_CLASS_PASSWORD: z.string().min(6).default('123456'),
/** 新建/重置教师账号时的默认密码(管理员录入教师用) */
DEFAULT_TEACHER_PASSWORD: z.string().min(6).default('123456'),
+1 -1
View File
@@ -79,7 +79,7 @@ httpServer.listen(env.port, env.host, () => {
writePidFile();
const displayHost = env.host === '0.0.0.0' ? '127.0.0.1' : env.host;
const webDir = resolveWebDistDir();
logger.info(`班级小助手后端已启动:http://${displayHost}:${env.port}${API_PREFIX}`);
logger.info(`ClassHelper后端已启动:http://${displayHost}:${env.port}${API_PREFIX}`);
logger.info(`运行环境:${env.nodeEnv} · 监听:${env.host}:${env.port} · 数据库:${env.databaseProvider}`);
if (webDir) {
logger.info(`Web 管理端:http://${displayHost}:${env.port}/(静态目录 ${webDir})`);
+160 -65
View File
@@ -1,10 +1,11 @@
import {
canManageGrades,
canManageSchedule,
canPublishContent,
canManageClasses,
canAssignTeachers,
canManageClasses,
canManageGrades,
canManageRoster,
canManageSchedule,
canManageSubjectContent,
canPublishNotification,
resolveClassRole,
type ClassRole,
} from '@classhelper/shared';
@@ -13,11 +14,20 @@ import { ApiError } from './http.js';
import type { TokenPayload } from './jwt.js';
/**
* 权限(RBAC)规则:
* - ADMIN :全部班级;班级增删改、人员分配、成绩、课表
* - HEAD :班主任(Class.teacherId)—— 本班课表 + 作业/通知/叫人
* - SUBJECT :科任老师(ClassTeacher)—— 仅作业/通知/叫人
* - STUDENT :只读;班级账号(classSession)以班级为单位读写自己的数据
* 权限(RBAC)规则。
*
* | 角色 | 范围 | 能做什么 |
* | ---- | ---- | -------- |
* | ADMIN | 全局 | 班级/教师/学生名单/调班/归档/全部科目的作业与成绩 |
* | HEAD 班主任 | 本班 | 本班学生名单、课表、本班汇总;**作业与成绩仍按科目收口** |
* | SUBJECT 科任 | 班级 + 科目 | 只能发布/修改/删除**自己任教科目**的作业与成绩 |
* | CLASS_DEVICE | 绑定班级 | ClassHelper 班级端:代本班读写个人数据、按开关查成绩明细 |
* | 学生 | 无账号 | 仅作为 `Student` 名单记录,不登录、无权限 |
*
* 三条铁律(都是这次重构的落点):
* 1. **任课关系的唯一来源是 `Course`**(班级 + 科目 + 教师),没有第二张关系表;
* 2. **班主任不自动拥有本班所有科目的作业成绩编辑权** —— 只有同时是该科科任老师时才有;
* 3. **所有权限都在后端判**,前端只做展示控制。
*
* 判定逻辑与前端共用 `@classhelper/shared/permissions`,避免前后端口径漂移。
*/
@@ -30,14 +40,36 @@ export function isTeacher(user: TokenPayload): boolean {
return user.role === 'TEACHER';
}
export function isStudent(user: TokenPayload): boolean {
return user.role === 'STUDENT';
}
/** 教师与管理员(staff):能以自己的身份操作班级内容 */
export function isStaff(user: TokenPayload): boolean {
return user.role === 'ADMIN' || user.role === 'TEACHER';
}
/**
* 是否是 ClassHelper 班级端(教室机器上那台绑定到某个班的客户端)。
* 它代全班读写个人数据(作业完成、通知已读),也能按开关查本班学生成绩明细。
*/
export function isClassDevice(user: TokenPayload): boolean {
return user.role === 'CLASS_DEVICE';
}
/** ClassHelper 班级端绑定的班级 id;不是班级端会话时返回 null */
export function classDeviceClassId(user: TokenPayload): string | null {
return isClassDevice(user) ? (user.classId ?? user.sub) : null;
}
/**
* 班级访问的判定条件:班主任,**或在该班有任课关系**(`Course.teacherId`)。
*
* 这里就是「科任老师」的判定源 —— 以前查的是没有科目的 `ClassTeacher` 协作表,
* 现在查 `Course`,于是"能不能进这个班"与"能不能改这一科的作业成绩"用的是同一份事实。
*/
function classMembershipWhere(teacherId: string) {
return {
OR: [{ teacherId }, { courses: { some: { teacherId } } }],
};
}
/** 当前用户可访问的全部班级 id */
export async function getAccessibleClassIds(user: TokenPayload): Promise<string[]> {
if (isAdmin(user)) {
@@ -47,39 +79,23 @@ export async function getAccessibleClassIds(user: TokenPayload): Promise<string[
if (isTeacher(user)) {
const classes = await prisma.class.findMany({
where: { OR: [{ teacherId: user.sub }, { teachers: { some: { teacherId: user.sub } } }] },
where: classMembershipWhere(user.sub),
select: { id: true },
});
return classes.map((item) => item.id);
}
const ids = new Set<string>();
if (user.classId) ids.add(user.classId);
const enrollments = await prisma.enrollment.findMany({
where: { userId: user.sub },
select: { classId: true },
});
for (const item of enrollments) ids.add(item.classId);
return [...ids];
const classId = classDeviceClassId(user);
return classId ? [classId] : [];
}
export async function canAccessClass(user: TokenPayload, classId: string): Promise<boolean> {
if (isAdmin(user)) return true;
if (isTeacher(user)) {
const count = await prisma.class.count({
where: {
id: classId,
OR: [{ teacherId: user.sub }, { teachers: { some: { teacherId: user.sub } } }],
},
});
const count = await prisma.class.count({ where: { id: classId, ...classMembershipWhere(user.sub) } });
return count > 0;
}
if (user.classId === classId) return true;
const enrollment = await prisma.enrollment.findFirst({
where: { userId: user.sub, classId },
select: { id: true },
});
return enrollment !== null;
return classDeviceClassId(user) === classId;
}
/** 读权限断言 */
@@ -89,31 +105,31 @@ export async function assertClassAccess(user: TokenPayload, classId: string): Pr
if (!(await canAccessClass(user, classId))) throw ApiError.forbidden('无权访问该班级的数据');
}
/** 写权限断言:学生一律拒绝 */
/** 写权限断言:无账号的学生不在会话里,因此这里只放行 staff 与 ClassHelper 班级端 */
export async function assertClassWritable(user: TokenPayload, classId: string): Promise<void> {
if (!isStaff(user)) throw ApiError.forbidden('学生账号没有管理权限');
if (!isStaff(user) && !isClassDevice(user)) throw ApiError.forbidden('当前身份没有管理权限');
await assertClassAccess(user, classId);
}
/* ---------------------------------------------------------------- 班级内角色 */
/** 解析当前用户在某班级里的角色(ADMIN / HEAD / SUBJECT / STUDENT / NONE) */
/** 解析当前用户在某班级里的角色(ADMIN / HEAD / SUBJECT / CLASS_DEVICE / NONE) */
export async function resolveUserClassRole(user: TokenPayload, classId: string): Promise<ClassRole> {
if (user.role === 'ADMIN') return 'ADMIN';
if (user.role === 'STUDENT') return canAccessClass(user, classId).then((ok) => (ok ? 'STUDENT' : 'NONE'));
if (user.role === 'CLASS_DEVICE') return (await canAccessClass(user, classId)) ? 'CLASS_DEVICE' : 'NONE';
const record = await prisma.class.findUnique({
where: { id: classId },
select: {
teacherId: true,
teachers: { where: { teacherId: user.sub }, select: { id: true } },
courses: { where: { teacherId: user.sub }, select: { id: true } },
},
});
if (!record) return 'NONE';
return resolveClassRole({
role: user.role,
isHeadTeacher: record.teacherId === user.sub,
isSubjectTeacher: record.teachers.length > 0,
teachesSubjects: record.courses.length > 0,
});
}
@@ -124,17 +140,30 @@ export function assertCanManageClasses(user: TokenPayload): void {
}
}
/** 班主任 / 科任老师分配:仅管理员 */
/** 班主任 / 各科科任老师的分配:仅管理员 */
export function assertCanAssignTeachers(user: TokenPayload): void {
if (!canAssignTeachers(user.role)) {
throw ApiError.forbidden('只有管理员可以分配班主任与科任老师');
throw ApiError.forbidden('只有管理员可以分配班主任与各科科任老师');
}
}
/** 学生名单管理:仅管理员 */
export function assertCanManageRoster(user: TokenPayload): void {
if (!canManageRoster(user.role)) {
throw ApiError.forbidden('只有管理员可以管理学生名单与账号');
/**
* 学生名单(增删学生 / 导入名单 / 调班):管理员,或**本班班主任**。
*
* @param classId 目标班级;不传表示"全局名单操作"(例如未分班的学生),此时仅管理员
*/
export async function assertCanManageRoster(user: TokenPayload, classId?: string): Promise<void> {
if (!classId) {
if (!isAdmin(user)) throw ApiError.forbidden('该操作需要指定班级,只有管理员可以全局执行');
return;
}
const classRole = await resolveUserClassRole(user, classId);
if (!canManageRoster(classRole)) {
throw ApiError.forbidden(
classRole === 'SUBJECT'
? '科任老师不能管理学生名单,请联系班主任或管理员'
: '只能管理自己担任班主任的班级的学生名单',
);
}
}
@@ -143,30 +172,32 @@ export async function assertCanManageSchedule(user: TokenPayload, classId: strin
const classRole = await resolveUserClassRole(user, classId);
if (!canManageSchedule(classRole)) {
throw ApiError.forbidden(
classRole === 'SUBJECT' ? '科任老师不能管理课表,请联系班主任或管理员' : '无权管理该班级的课表',
classRole === 'SUBJECT' || classRole === 'CLASS_DEVICE'
? '科任老师与班级端不能管理课表,请联系班主任或管理员'
: '无权管理该班级的课表',
);
}
}
/** 作业 / 通知 / 叫人:管理员、班主任、科任老师 */
export async function assertCanPublishContent(user: TokenPayload, classId: string): Promise<void> {
/** 通知 / 叫人:管理员、班主任、科任老师、ClassHelper 班级端(面向全班,不分科目) */
export async function assertCanPublishNotification(user: TokenPayload, classId: string): Promise<void> {
await assertClassAccess(user, classId);
const classRole = await resolveUserClassRole(user, classId);
if (!canPublishContent(classRole)) {
throw ApiError.forbidden('当前账号没有发布作业/通知或叫人的权限');
if (!canPublishNotification(classRole)) {
throw ApiError.forbidden('当前账号没有发布通知或叫人的权限');
}
}
/**
* 成绩录入 / 修改 / 导入:管理员,或**本班班主任**(需求 6"老师端可用且不越权")。
* 科任老师仍然被拒(需求 7:科任只有作业 / 叫人 / 通知)。
* 成绩录入 / 修改 / 导入的**班级维度**准入:管理员,或本班班主任。
*
* @param classId 目标成绩所属班级;不传时按"全局"判定(仅管理员可用,例如空白模板下载)
*
* 这只是第一道门:具体某一条成绩能不能动,还要过 `assertCanManageSubjectContent` 的**科目**那道门。
*/
export async function assertCanManageGrades(user: TokenPayload, classId?: string): Promise<void> {
if (!classId) {
// 没有班级上下文(例如下载空白模板):只有管理员可以执行
if (user.role !== 'ADMIN') throw ApiError.forbidden('该操作需要指定班级,只有管理员可以全局执行');
if (!isAdmin(user)) throw ApiError.forbidden('该操作需要指定班级,只有管理员可以全局执行');
return;
}
@@ -174,22 +205,86 @@ export async function assertCanManageGrades(user: TokenPayload, classId?: string
if (canManageGrades(classRole)) return;
throw ApiError.forbidden(
classRole === 'SUBJECT'
? '科任老师不能录入或导入成绩,请联系班主任或管理员'
: '只能录入或导入自己担任班主任的班级的成绩',
classRole === 'SUBJECT' || classRole === 'CLASS_DEVICE'
? '科任老师与班级端不能代录全班成绩,请在自己的科目里录入'
: '只能录入自己担任班主任的班级的成绩',
);
}
/** 学生的主班级,未分配班级时抛 403 */
export function requireStudentClassId(user: TokenPayload): string {
if (!user.classId) throw ApiError.forbidden('当前账号尚未被分配到任何班级,请联系班主任');
return user.classId;
/**
* **科目维度的写权限**(作业与成绩共用的那道细门)—— 本次重构的核心规则。
*
* | 情形 | 谁可以写 |
* | ---- | -------- |
* | 管理员 | 全部 |
* | ClassHelper 班级端 | 仅当 `allowClassDevice`(作业允许、成绩不允许) |
* | 挂了科目(courseId 有值) | **该科的任课老师**(`Course.teacherId`) |
* | 没挂科目(courseId 为空) | 本班**班主任** |
*
* 于是:班主任若不教这一科,就改不了这一科的作业与成绩 —— 这正是需求
* 「班主任不自动拥有所有科目作业成绩编辑权,只有同时是该科科任老师时才有该科权限」。
*
* @param courseId 目标内容挂在哪门课(科目)上;null/undefined 表示"不指定科目"
* @param action 给用户看的动作名,例如「布置作业」「录入成绩」
* @param options.allowClassDevice 是否允许教室机器代写(作业为 true,成绩为 false)
*/
export async function assertCanManageSubjectContent(
user: TokenPayload,
classId: string,
courseId: string | null | undefined,
action: string,
options: { allowClassDevice?: boolean } = {},
): Promise<void> {
await assertClassAccess(user, classId);
const classRole = await resolveUserClassRole(user, classId);
const allowClassDevice = options.allowClassDevice === true;
// 管理员不受科目限制,提前返回省掉一次课程查询
if (classRole === 'ADMIN') return;
if (classRole === 'CLASS_DEVICE') {
if (allowClassDevice) return;
throw ApiError.forbidden(`ClassHelper 班级端不能${action},请由任课老师在 Web 端操作`);
}
let isSubjectTeacherOfCourse = false;
let courseName = '';
if (courseId) {
const course = await prisma.course.findUnique({
where: { id: courseId },
select: { classId: true, teacherId: true, name: true },
});
if (!course) throw ApiError.badRequest('课程不存在');
if (course.classId !== classId) throw ApiError.badRequest('该课程不属于所选班级');
courseName = course.name;
isSubjectTeacherOfCourse = course.teacherId === user.sub;
}
const allowed = canManageSubjectContent({
classRole,
hasSubject: Boolean(courseId),
isSubjectTeacherOfCourse,
allowClassDevice,
});
if (allowed) return;
throw ApiError.forbidden(
courseId
? `「${courseName}」不是你任教的科目,不能${action}该科目的内容`
: `未指定科目的内容只有班主任或管理员可以${action}`,
);
}
/** 学生的主班级(ClassHelper 班级端会话);未绑定班级时抛 403 */
export function requireClassDeviceClassId(user: TokenPayload): string {
const classId = classDeviceClassId(user);
if (!classId) throw ApiError.forbidden('当前会话未绑定班级,请联系管理员');
return classId;
}
/**
* 查询范围解析:
* - 传入 classId:校验后只查该班级
* - 未传:教师/学生只能查自己可访问的班级;管理员不加限制
* - 未传:教师只能查自己可访问的班级;管理员不加限制
*/
export type ClassScope = { mode: 'all' } | { mode: 'in'; classIds: string[] };
@@ -215,8 +310,8 @@ export function classScopeIdFilter(scope: ClassScope): { id?: { in: string[] } }
return scope.mode === 'all' ? {} : { id: { in: scope.classIds } };
}
/** 单班级场景:学生强制取自己班级,其他角色沿用请求参数 */
/** 单班级场景:班级端强制取自己绑定的班级,其他角色沿用请求参数 */
export function resolveEffectiveClassId(user: TokenPayload, requestedClassId?: string): string | undefined {
if (isStudent(user)) return requireStudentClassId(user);
if (isClassDevice(user)) return requireClassDeviceClassId(user);
return requestedClassId;
}
+20 -6
View File
@@ -4,12 +4,12 @@ import { ApiError } from './http.js';
import { hashPassword, verifyPassword } from './password.js';
/**
* 班级账号(学生端「班级」主体):
* ClassHelper 班级端(ClassHelper 班级端「班级」主体):
* - 每个班级有一个**班级码**(唯一、大写字母数字)和**班级密码**(bcrypt);
* - 学生端班级设备用「班级码 + 班级密码」登录,拿到 `classSession` 的 JWT;
* - 教室机器用「班级码 + 班级密码」登录,拿到 `classSession` 的 JWT(角色 `CLASS_DEVICE`);
* - 管理员可在「班级管理」里修改班级码、重置班级密码。
*
* 兼容性:个人学生账号(User.username)完全不受影响,仍然是独立账号。
* **学生没有账号**:`Student` 只是名单记录,这里与它无关。
*/
/** 班级码:去掉易混淆字符(0/O/1/I),4~16 位大写字母数字 */
@@ -31,7 +31,7 @@ export async function generateClassCode(prefix = ''): Promise<string> {
const length = Math.max(4, 6 - prefix.length);
let body = '';
for (let index = 0; index < length; index += 1) {
// 用 CSPRNG 而不是 Math.random():班级码会作为学生端登录的账号名打印/张贴,
// 用 CSPRNG 而不是 Math.random():班级码会作为 ClassHelper 班级端登录的账号名打印/张贴,
// 用可预测的 PRNG 生成等于把"半个凭证"送给猜码的人(randomInt 无取模偏差)
body += CODE_ALPHABET[randomInt(0, CODE_ALPHABET.length)];
}
@@ -91,18 +91,27 @@ export async function updateClassAccount(
return { code: updated.code, hasPassword: Boolean(updated.passwordHash) };
}
/** 班级账号登录:返回班级记录(密码校验在 service 层完成) */
/** ClassHelper 班级端登录:返回班级记录(密码校验在 service 层完成) */
export async function findClassForLogin(code: string): Promise<{
id: string;
name: string;
grade: string;
code: string;
passwordHash: string | null;
studentGradeQueryEnabled: boolean;
}> {
const normalized = normalizeClassCode(code);
const record = await prisma.class.findUnique({
where: { code: normalized },
select: { id: true, name: true, grade: true, code: true, passwordHash: true },
select: {
id: true,
name: true,
grade: true,
code: true,
passwordHash: true,
studentGradeQueryEnabled: true,
archivedYearId: true,
},
});
if (!record) throw ApiError.unauthorized('班级码或班级密码错误');
if (!record.passwordHash) {
@@ -112,6 +121,11 @@ export async function findClassForLogin(code: string): Promise<{
'该班级尚未设置班级密码,请联系管理员在「班级管理」中设置',
);
}
// 已毕业归档的班级不再允许教室机器登录:继续登进去只会看到一个只读的历史壳子,
// 明确拒绝比"能进但没有东西可做"好排查。
if (record.archivedYearId) {
throw new ApiError(403, 'CLASS_ARCHIVED', '该班级已毕业归档,教室端已停止使用,请联系管理员');
}
return record;
}
+2
View File
@@ -30,6 +30,8 @@ export type ApiErrorCode =
| 'IMPORT_EMPTY'
/** 班级账号:该班级尚未设置班级密码(禁止班级登录,提示管理员设置) */
| 'CLASS_PASSWORD_NOT_SET'
/** 班级账号:该班级已毕业归档,教室端已停止使用 */
| 'CLASS_ARCHIVED'
| 'INTERNAL_ERROR';
/** 业务异常:中间件会把它转换成统一错误响应体 */
+4 -3
View File
@@ -7,13 +7,14 @@ import { ApiError } from './http.js';
/** JWT 载荷(同时作为 req.auth.user 的类型来源) */
export interface TokenPayload {
sub: string;
/** 教师/管理员的工号,或 ClassHelper 班级端的班级码 */
username: string;
name: string;
role: UserRole;
classId: string | null;
/** true 表示"班级账号(班级设备)"登录:sub 是班级 id,而非某个学生账号 */
/** true 表示「ClassHelper 班级端」登录:sub 是班级 id,而非某个账号 */
classSession?: boolean;
/** 班级账号的班级码(便于日志/审计) */
/** 班级端的班级码(便于日志/审计) */
classCode?: string;
}
@@ -31,7 +32,7 @@ export function verifyToken(token: string): TokenPayload {
const record = decoded as Record<string, unknown>;
const role = record.role;
if (role !== 'ADMIN' && role !== 'TEACHER' && role !== 'STUDENT') {
if (role !== 'ADMIN' && role !== 'TEACHER' && role !== 'CLASS_DEVICE') {
throw new Error('unexpected role in token');
}
+236 -29
View File
@@ -1,5 +1,7 @@
import { dayKeyLocal } from '@classhelper/shared';
import type {
ArchivedClassDto,
ArchivedYearDto,
ClassDto,
ClassIslandNotificationChannel,
ClassTeacherBrief,
@@ -11,7 +13,10 @@ import type {
NotificationDto,
NotificationPriority,
ScheduleDto,
SessionUser,
StudentBrief,
StudentDto,
StudentTransferDto,
UserDto,
UserRole,
} from '@classhelper/shared';
@@ -28,13 +33,13 @@ const toIso = (value: Date): string => value.toISOString();
const toIsoOrNull = (value: Date | null | undefined): string | null => (value ? value.toISOString() : null);
/**
* "个人记录"归属范围解析:
* - 传入 userIds(班级账号 = 全班学生)时优先使用;
* - 否则退化为单个 userId(普通学生账号 = 自己)。
* "个人记录"归属范围解析(记录键是**学生 id**):
* - 传入 studentIds(ClassHelper 班级端 = 全班学生)时优先使用;
* - 否则退化为单个 studentId。
*/
function resolvePool(options: { userId?: string | null; userIds?: string[] }): string[] {
if (options.userIds && options.userIds.length > 0) return options.userIds;
return options.userId ? [options.userId] : [];
function resolvePool(options: { studentId?: string | null; studentIds?: string[] }): string[] {
if (options.studentIds && options.studentIds.length > 0) return options.studentIds;
return options.studentId ? [options.studentId] : [];
}
export interface UserLike {
@@ -42,7 +47,8 @@ export interface UserLike {
username: string;
name: string;
role: string;
classId: string | null;
/** 手机号;老库/老对象可能没有这个字段 */
phone?: string | null;
createdAt: Date;
}
@@ -52,7 +58,32 @@ export interface TeacherBriefLike {
username: string;
}
export interface StudentLike extends UserLike {
/** 学生简要信息(id + 学号 + 姓名) */
export interface StudentBriefLike {
id: string;
studentNo: string;
name: string;
}
/**
* 学生记录(`Student` 表)。
*
* 学生不是账号 —— 这里没有 `role`、没有 `passwordHash`,
* 唯一的标识是 `studentNo`(学号)。
*/
export interface StudentLike {
id: string;
studentNo: string;
name: string;
classId: string | null;
gender: string;
guardianPhone: string;
status: string;
archivedYearId?: string | null;
archivedAt?: Date | null;
transferredAt?: Date | null;
transferNote?: string;
createdAt: Date;
class?: { name: string; grade: string } | null;
}
@@ -67,19 +98,78 @@ export function toUserDto(user: UserLike): UserDto {
username: user.username,
name: user.name,
role: user.role as UserRole,
classId: user.classId,
phone: user.phone ?? '',
createdAt: toIso(user.createdAt),
};
}
export function toStudentDto(user: StudentLike): StudentDto {
/** 账号的会话主体(登录返回 / GET /auth/me) */
export function toUserSessionUser(user: UserLike): SessionUser {
return {
...toUserDto(user),
className: user.class?.name ?? null,
grade: user.class?.grade ?? null,
id: user.id,
username: user.username,
name: user.name,
role: user.role as UserRole,
phone: user.phone ?? '',
classId: null,
createdAt: toIso(user.createdAt),
};
}
/** ClassHelper 班级端登录时用到的班级字段 */
export interface ClassDeviceLike {
id: string;
code: string;
name: string;
grade: string;
studentGradeQueryEnabled: boolean;
createdAt: Date;
}
/**
* ClassHelper 班级端的会话主体:`id` 与 `classId` 都是**班级 id**,
* 角色是 `CLASS_DEVICE`(学生不是账号,因此这里不会出现学生身份)。
*/
export function toClassDeviceUser(record: ClassDeviceLike): SessionUser {
return {
id: record.id,
username: record.code,
name: record.name,
role: 'CLASS_DEVICE',
classId: record.id,
className: record.name,
grade: record.grade,
classSession: true,
classCode: record.code,
studentGradeQueryEnabled: record.studentGradeQueryEnabled,
createdAt: toIso(record.createdAt),
};
}
export function toStudentDto(student: StudentLike): StudentDto {
return {
id: student.id,
studentNo: student.studentNo,
name: student.name,
classId: student.classId,
className: student.class?.name ?? null,
grade: student.class?.grade ?? null,
gender: (student.gender ?? '') as StudentDto['gender'],
guardianPhone: student.guardianPhone ?? '',
status: (student.status ?? 'active') as StudentDto['status'],
archivedYearId: student.archivedYearId ?? null,
archivedAt: toIsoOrNull(student.archivedAt),
transferredAt: toIsoOrNull(student.transferredAt),
transferNote: student.transferNote ?? '',
createdAt: toIso(student.createdAt),
};
}
/** 学生简要信息(成绩、提交名单等引用位) */
export function toStudentBrief(student: StudentBriefLike): StudentBrief {
return { id: student.id, studentNo: student.studentNo, name: student.name };
}
export function toTeacherBrief(user: TeacherBriefLike): ClassTeacherBrief {
return { id: user.id, name: user.name, username: user.username };
}
@@ -94,11 +184,20 @@ export interface ClassLike {
id: string;
name: string;
grade: string;
/** 入学年份(届别);老数据可能为空 */
enrollmentYear?: number | null;
/** 班号;老数据可能为空 */
classIndex?: number | null;
teacherId: string;
/** 本学期教学周数(班主任可调,默认 20) */
termWeeks?: number;
/** 通知显示位置:both / client / classisland(由教室的班级客户端设置) */
/** 通知显示位置:both / client / classisland(由 ClassHelper 班级端设置) */
notificationChannel?: string | null;
/** 班级端按学号查成绩的开关 */
studentGradeQueryEnabled?: boolean | null;
/** 毕业归档所属届别;null = 在读 */
archivedYearId?: string | null;
archivedAt?: Date | null;
createdAt: Date;
teacher?: TeacherBriefLike | null;
_count?: {
@@ -120,9 +219,15 @@ export function toClassDto(item: ClassLike): ClassDto {
id: item.id,
name: item.name,
grade: item.grade,
enrollmentYear: item.enrollmentYear ?? null,
classIndex: item.classIndex ?? null,
teacherId: item.teacherId,
termWeeks: item.termWeeks ?? 20,
notificationChannel: toNotificationChannel(item.notificationChannel),
// 默认开启:老库补列时的默认值也是 true,两边保持一致
studentGradeQueryEnabled: item.studentGradeQueryEnabled ?? true,
archivedYearId: item.archivedYearId ?? null,
archivedAt: toIsoOrNull(item.archivedAt),
createdAt: toIso(item.createdAt),
teacher: item.teacher ? toTeacherBrief(item.teacher) : null,
studentCount: item._count?.students ?? undefined,
@@ -193,7 +298,7 @@ export function toScheduleDto(item: ScheduleLike): ScheduleDto {
export interface HomeworkStatusLike {
id: string;
homeworkId: string;
userId: string;
studentId: string;
completed: boolean;
updatedAt: Date;
}
@@ -218,24 +323,24 @@ export function toHomeworkStatusDto(item: HomeworkStatusLike): HomeworkStatusDto
return {
id: item.id,
homeworkId: item.homeworkId,
userId: item.userId,
studentId: item.studentId,
completed: item.completed,
updatedAt: toIso(item.updatedAt),
};
}
/**
* @param options.userId 指定当前用户,用于填充 completed / homeworkStatus
* @param options.userIds 班级账号(班级设备)场景:以"全班学生"为范围找完成状态
* @param options.studentId 指定单个学生,用于填充 completed / homeworkStatus
* @param options.studentIds ClassHelper 班级端场景:以"全班学生"为范围找完成状态
* @param options.withStatus 是否统计完成人数(教师视角)
*/
export function toHomeworkDto(
item: HomeworkLike,
options: { userId?: string | null; userIds?: string[]; withStatus?: boolean } = {},
options: { studentId?: string | null; studentIds?: string[]; withStatus?: boolean } = {},
): HomeworkDto {
const statuses = item.statuses ?? [];
const pool = resolvePool(options);
const own = pool.length > 0 ? statuses.find((status) => pool.includes(status.userId)) : undefined;
const own = pool.length > 0 ? statuses.find((status) => pool.includes(status.studentId)) : undefined;
return {
id: item.id,
@@ -261,7 +366,7 @@ export function toHomeworkDto(
export interface NotificationReadLike {
id: string;
notificationId: string;
userId: string;
studentId: string;
readAt: Date;
}
@@ -278,17 +383,17 @@ export interface NotificationLike {
}
/**
* @param options.userId 指定当前用户(普通学生账号)
* @param options.userIds 班级账号(班级设备)场景:以"全班学生"为范围找已读状态
* @param options.studentId 指定单个学生
* @param options.studentIds ClassHelper 班级端场景:以"全班学生"为范围找已读状态
* @param options.withStatus 是否统计已读人数(教师视角)
*/
export function toNotificationDto(
item: NotificationLike,
options: { userId?: string | null; userIds?: string[]; withStatus?: boolean } = {},
options: { studentId?: string | null; studentIds?: string[]; withStatus?: boolean } = {},
): NotificationDto {
const reads = item.reads ?? [];
const pool = resolvePool(options);
const own = pool.length > 0 ? reads.find((read) => pool.includes(read.userId)) : undefined;
const own = pool.length > 0 ? reads.find((read) => pool.includes(read.studentId)) : undefined;
return {
id: item.id,
@@ -311,13 +416,17 @@ export interface GradeLike {
id: string;
classId: string;
courseId: string | null;
userId: string;
studentId: string;
examName: string;
score: number;
totalScore: number;
/** 等级口径:percent / letter / custom(老库可能为空,按 percent 兜底) */
levelType?: string | null;
/** 等级文本(老库可能为空) */
level?: string | null;
publishedAt: Date;
course?: CourseBriefLike | null;
student?: TeacherBriefLike | null;
student?: StudentBriefLike | null;
class?: { name: string } | null;
}
@@ -326,13 +435,111 @@ export function toGradeDto(item: GradeLike): GradeDto {
id: item.id,
classId: item.classId,
courseId: item.courseId,
userId: item.userId,
studentId: item.studentId,
examName: item.examName,
score: item.score,
totalScore: item.totalScore,
levelType: (item.levelType ?? 'percent') as GradeDto['levelType'],
level: item.level ?? '',
publishedAt: toIso(item.publishedAt),
course: item.course ? toCourseBrief(item.course) : null,
student: item.student ? toTeacherBrief(item.student) : null,
student: item.student ? toStudentBrief(item.student) : null,
className: item.class?.name ?? null,
};
}
/* ------------------------------------------------------------------ 调班 / 归档 */
export interface StudentTransferLike {
id: string;
studentId: string;
studentNo: string;
studentName: string;
fromClassId: string | null;
fromClassName: string;
toClassId: string | null;
toClassName: string;
operatorId: string | null;
operatorName: string;
mode: string;
note?: string | null;
createdAt: Date;
}
export function toStudentTransferDto(item: StudentTransferLike): StudentTransferDto {
return {
id: item.id,
studentId: item.studentId,
studentNo: item.studentNo,
studentName: item.studentName,
fromClassId: item.fromClassId,
fromClassName: item.fromClassName,
toClassId: item.toClassId,
toClassName: item.toClassName,
operatorId: item.operatorId,
operatorName: item.operatorName,
mode: item.mode as StudentTransferDto['mode'],
note: item.note ?? '',
createdAt: toIso(item.createdAt),
};
}
export interface ArchivedYearLike {
id: string;
enrollmentYear: number;
graduationYear: number;
name: string;
note: string;
operatorId: string | null;
operatorName: string;
classCount: number;
studentCount: number;
transferredCount: number;
homeworkCount: number;
notificationCount: number;
archivedAt: Date;
}
export function toArchivedYearDto(item: ArchivedYearLike): ArchivedYearDto {
return {
id: item.id,
enrollmentYear: item.enrollmentYear,
graduationYear: item.graduationYear,
name: item.name,
note: item.note,
operatorId: item.operatorId,
operatorName: item.operatorName,
classCount: item.classCount,
studentCount: item.studentCount,
transferredCount: item.transferredCount,
homeworkCount: item.homeworkCount,
notificationCount: item.notificationCount,
archivedAt: toIso(item.archivedAt),
};
}
export interface ArchivedClassLike {
id: string;
name: string;
grade: string;
enrollmentYear: number | null;
classIndex: number | null;
archivedAt: Date | null;
teacher?: { name: string } | null;
_count?: { students?: number; homeworks?: number; notifications?: number };
}
export function toArchivedClassDto(item: ArchivedClassLike): ArchivedClassDto {
return {
id: item.id,
name: item.name,
grade: item.grade,
enrollmentYear: item.enrollmentYear,
classIndex: item.classIndex,
headTeacherName: item.teacher?.name ?? null,
studentCount: item._count?.students ?? 0,
homeworkCount: item._count?.homeworks ?? 0,
notificationCount: item._count?.notifications ?? 0,
archivedAt: toIsoOrNull(item.archivedAt),
};
}
+41
View File
@@ -1,4 +1,5 @@
import { z } from 'zod';
import { isValidStudentNo, normalizeStudentNo } from '@classhelper/shared';
/** 通用路径参数::id */
export const idParamSchema = z.object({ id: z.string().min(1, '缺少 id') });
@@ -52,3 +53,43 @@ export const attachmentUrlSchema = z
/** 周次:1..30 */
export const weekNumberSchema = z.coerce.number().int().min(1).max(30);
/**
* 学号:学生的唯一标识与查询键。
*
* 只做**格式**校验(1~32 位数字/字母/`._-`),归一化(去空白)后入库;
* 唯一性由 `Student.studentNo` 的唯一约束 + service 层的大小写无关查重共同保证。
*/
export const studentNoSchema = z
.string()
.trim()
.min(1, '请填写学号')
.max(32, '学号最多 32 位')
.transform((value) => normalizeStudentNo(value))
.refine((value) => isValidStudentNo(value), {
message: '学号只能包含字母、数字与 . _ -(1~32 位)',
});
/** 学生姓名 */
export const studentNameSchema = z.string().trim().min(1, '请输入姓名').max(32);
/** 学生性别:'' / MALE / FEMALE */
export const studentGenderSchema = z.enum(['', 'MALE', 'FEMALE']).optional();
/** 学生状态 */
export const studentStatusSchema = z.enum(['active', 'graduated', 'transferred', 'inactive']).optional();
/** 家长手机号(可空) */
export const guardianPhoneSchema = z.string().trim().max(32).optional();
/** 入学年份(届别) */
export const enrollmentYearSchema = z.coerce.number().int().min(2000).max(2100);
/** 班号 */
export const classIndexSchema = z.coerce.number().int().min(1).max(99);
/** 成绩等级口径 */
export const gradeLevelTypeSchema = z.enum(['percent', 'letter', 'custom']);
/** 成绩等级文本(自定义等级也要能填,所以只限长度) */
export const gradeLevelSchema = z.string().trim().max(16);
+25 -29
View File
@@ -1,36 +1,32 @@
import { classDeviceClassId, isClassDevice } from './access.js';
import { prisma } from './db.js';
import type { TokenPayload } from './jwt.js';
/**
* 会话主体解析:普通账号 vs 班级账号(班级设备)。
* 会话主体解析:staff 账号(管理员 / 教师)vs ClassHelper 班级端。
*
* 背景(需求 1):学生端以「班级」为主体登录,个人学生不再作为登录入口。
* 班级账号的 JWT 形如:
* { sub: <classId>, username: <班级码>, name: <班级名>, role: 'STUDENT',
* 背景:**学生不是账号**(`Student` 只是班级名单记录),因此班级端成为个人数据的唯一读写主体。
* 班级端的 JWT 形如:
* { sub: <classId>, username: <班级码>, name: <班级名>, role: 'CLASS_DEVICE',
* classId: <classId>, classSession: true }
*
* 语义(由用户确认):
* - **班级设备代全班操作**:勾选作业完成、标记通知已读,都会写到该班全体学生的记录上,
* 语义:
* - **班级端代全班操作**:勾选作业完成、标记通知已读,都会写到该班全体学生的记录上,
* 因此教师端的"完成人数/已读人数"统计依然准确;
* - 读状态判定使用 `userId in 全班学生`:任一条记录存在即视为该班设备读过/完成过;
* - 个人学生账号(student01…)保持完全兼容,此时"全班"范围退化为"只有自己"。
*
* 这样所有个人数据的读写都收敛到 `resolvePersonalIds()` 一个入口,
* 既不需要改数据库结构,也不会让旧的个人账号行为发生变化。
* - 读状态判定使用 `studentId in 全班学生`:任一条记录存在即视为该班设备读过/完成过;
* - 所有个人数据的读写都收敛到 `resolvePersonalIds()` 一个入口。
*/
export function isClassSession(user: TokenPayload): boolean {
return user.role === 'STUDENT' && user.classSession === true;
export { isClassDevice };
/** 班级端对应的班级 id;不是班级端会话时为 null */
export function classSessionClassId(user: TokenPayload): string | null {
return classDeviceClassId(user);
}
/** 班级账号对应的班级 id */
export function classSessionClassId(user: TokenPayload): string {
return user.classId ?? user.sub;
}
/** 该班级的全部学生 id(班级账号的"全班"范围) */
/** 该班级的全部学生 id(班级端的"全班"范围) */
export async function listClassStudentIds(classId: string): Promise<string[]> {
const students = await prisma.user.findMany({
where: { classId, role: 'STUDENT' },
const students = await prisma.student.findMany({
where: { classId, status: { not: 'inactive' } },
select: { id: true },
});
return students.map((item) => item.id);
@@ -38,16 +34,16 @@ export async function listClassStudentIds(classId: string): Promise<string[]> {
/**
* 当前会话的"个人记录主体"列表:
* - 普通学生:自己
* - 班级账号:全班学生
* - 教师/管理员:自己(教师侧的完成情况统计走 withStatus,不依赖这个列表)
* - ClassHelper 班级端:全班学生的 id;
* - 教师 / 管理员:空数组 —— 他们没有"个人作业完成/成绩"这类记录。
*/
export async function resolvePersonalIds(user: TokenPayload): Promise<string[]> {
if (isClassSession(user)) return listClassStudentIds(classSessionClassId(user));
return [user.sub];
const classId = classDeviceClassId(user);
if (classId) return listClassStudentIds(classId);
return [];
}
/** Prisma 过滤片段:`{ userId: { in: ids } }` */
export function personalIdWhere(ids: string[]): { userId: { in: string[] } } {
return { userId: { in: ids } };
/** Prisma 过滤片段:`{ studentId: { in: ids } }` */
export function personalIdWhere(ids: string[]): { studentId: { in: string[] } } {
return { studentId: { in: ids } };
}
+14 -4
View File
@@ -8,7 +8,8 @@ import type { DatabaseProvider } from './db.js';
* - 文件级备份只对 SQLite 有意义,跨库迁移根本用不上;
* - JSON 快照与 provider 无关,一份实现同时服务备份、导入导出和 SQLite⇄MySQL 迁移。
*
* 外键注意:schema 里 User.classId → Class 且 Class.teacherId → User(互相引用),
* 外键注意:`Class.teacherId → User` 与 `Student.classId → Class` 构成一条链,
* 而 `Class.archivedYearId` / `Student.archivedYearId → ArchivedYear` 又是另一条;
* 恢复时必须**临时关闭外键检查**再按序写入,否则任何排序都会被其中一侧卡死。
* - SQLite:PRAGMA foreign_keys=OFF(libsql 连接级,进程内安全)
* - MySQL:SET FOREIGN_KEY_CHECKS=0/1(会话级,连接池下同一客户端实例内生效)
@@ -17,13 +18,22 @@ import type { DatabaseProvider } from './db.js';
export const SNAPSHOT_FORMAT = 'classhelper-snapshot';
export const SNAPSHOT_VERSION = 1;
/** 导出与恢复顺序(外键关闭后顺序不再影响写入,但保持拓扑序便于人工阅读与排查) */
/**
* 导出与恢复顺序(外键关闭后顺序不再影响写入,但保持拓扑序便于人工阅读与排查)。
*
* **学生不是账号**:`Student` 是独立的一张表(没有密码、没有 role),
* `Grade` / `HomeworkStatus` / `NotificationRead` 都按 `studentId` 指向它;
* `StudentClassTransfer` 存调班与转出历史;`ArchivedYear` 是毕业归档的届别档案。
* 历史上那两张 `ClassTeacher`(无科目的协作关系)与 `Enrollment`(与 Student.classId 双写)
* 已随 2026-10-06 的重构删除。
*/
export const SNAPSHOT_TABLES = [
'User',
'ArchivedYear',
'Class',
'Student',
'Course',
'ClassTeacher',
'Enrollment',
'StudentClassTransfer',
'Schedule',
'TimeLayout',
'Homework',
+72 -5
View File
@@ -1,13 +1,80 @@
import { MAX_TERM_WEEK, resolveCurrentWeek as resolveWeek } from '@classhelper/shared';
import {
MAX_TERM_WEEK,
resolveCurrentWeek as resolveWeek,
resolveWeekFromRanges,
} from '@classhelper/shared';
import { env } from '../config/env.js';
import { prisma } from './db.js';
export { MAX_TERM_WEEK };
/** 某个班的周次口径(管理员可以为每个班单独配开学日期与逐周区间) */
export interface TermWeekContext {
termStartDate: string;
/** 逐周区间;空数组 = 完全按学期开始日期线性推算 */
weeks: Array<{ weekNumber: number; startDate: string; endDate: string }>;
maxWeek: number;
}
/**
* 当前教学周。
* 具体计算逻辑在 @classhelper/shared(三端共用),这里只负责注入服务端的
* TERM_START_DATE 与周次上限,避免"周次"口径在服务端/客户端出现分叉。
* 具体计算逻辑在 `@classhelper/shared`(三端共用),这里只负责注入服务端的
* 配置,避免"周次"口径在服务端/客户端出现分叉。
*
* @param context 班级维度的周次口径(`Class.termStartDate` + 该班的逐周区间)。
* 不传时退回全局 `TERM_START_DATE` 线性推算。
*/
export function resolveCurrentWeek(now: Date = new Date()): number {
return resolveWeek(env.termStartDate, now, MAX_TERM_WEEK);
export function resolveCurrentWeek(now: Date = new Date(), context?: TermWeekContext | null): number {
const termStartDate = context?.termStartDate || env.termStartDate;
const maxWeek = context?.maxWeek ?? MAX_TERM_WEEK;
// 配了逐周区间就按区间判定 —— 调休/补课让某一周变长变短时,线性推算会整体错位
if (context && context.weeks.length > 0) {
const fromRanges = resolveWeekFromRanges(context.weeks, now);
if (fromRanges !== null) return fromRanges;
}
return resolveWeek(termStartDate, now, maxWeek);
}
/**
* 取出某个班的周次口径。
*
* 优先级:班级自己的逐周区间 → 全校默认的逐周区间 → 纯线性推算。
* 开学日期同理:`Class.termStartDate` 优先,没配就用全局 `TERM_START_DATE`。
*
* @param classId 不传/空串表示**只看全校默认**(管理员在「学期周次」页编辑默认口径时就是这种调用)
*/
export async function loadTermContext(classId?: string | null): Promise<TermWeekContext> {
const globalWeeks = await prisma.termWeek.findMany({
where: { classId: '' },
orderBy: { weekNumber: 'asc' },
select: { weekNumber: true, startDate: true, endDate: true },
});
if (!classId) {
// 全校默认没有"班级记录"可以存开学日期,因此**第 1 周的起始日就是它**
// (管理员点「按开学日期生成」时,第 1 周就是从那天开始的)
const firstGlobal = globalWeeks.find((item) => item.weekNumber === 1)?.startDate;
return { termStartDate: firstGlobal || env.termStartDate, weeks: globalWeeks, maxWeek: MAX_TERM_WEEK };
}
const [cls, classWeeks] = await Promise.all([
prisma.class.findUnique({
where: { id: classId },
select: { termStartDate: true, termWeeks: true },
}),
prisma.termWeek.findMany({
where: { classId },
orderBy: { weekNumber: 'asc' },
select: { weekNumber: true, startDate: true, endDate: true },
}),
]);
const weeks = classWeeks.length > 0 ? classWeeks : globalWeeks;
// 班级的逐周区间若覆盖了第 1 周,开学日期就以它为准(管理员改周次时最直观)
const firstWeekStart = weeks.find((item) => item.weekNumber === 1)?.startDate;
return {
termStartDate: cls?.termStartDate || firstWeekStart || env.termStartDate,
weeks,
maxWeek: cls?.termWeeks && cls.termWeeks > 0 ? cls.termWeeks : MAX_TERM_WEEK,
};
}
+6 -5
View File
@@ -6,7 +6,7 @@ import { extractBearerToken, verifyToken, type TokenPayload } from '../lib/jwt.j
/**
* 认证中间件:解析 Bearer token 并回查数据库,
* 保证角色 / 班级变更(例如教师把学生转到别的班)立即生效,不被过期载荷影响。
* 保证角色 / 班级变更(例如管理员换了班主任)立即生效,不被过期载荷影响。
*/
export function authenticate(): RequestHandler {
return async (req, _res, next) => {
@@ -16,7 +16,7 @@ export function authenticate(): RequestHandler {
const payload = verifyToken(token);
// 班级账号(班级设备):主体是班级而不是某个学生账号,回查 Class 表
// ClassHelper 班级端:主体是**班级**而不是某个账号,回查 Class 表
if (payload.classSession) {
const record = await prisma.class.findUnique({
where: { id: payload.classId ?? payload.sub },
@@ -30,7 +30,7 @@ export function authenticate(): RequestHandler {
sub: record.id,
username: record.code,
name: record.name,
role: 'STUDENT',
role: 'CLASS_DEVICE',
classId: record.id,
classSession: true,
classCode: record.code,
@@ -40,9 +40,10 @@ export function authenticate(): RequestHandler {
return;
}
// 账号(教师 / 管理员):`User` 是纯账号表,学生不在里面
const user = await prisma.user.findUnique({
where: { id: payload.sub },
select: { id: true, username: true, name: true, role: true, classId: true },
select: { id: true, username: true, name: true, role: true },
});
if (!user) throw ApiError.unauthorized('账号不存在或已被删除');
@@ -53,7 +54,7 @@ export function authenticate(): RequestHandler {
username: user.username,
name: user.name,
role: user.role as UserRole,
classId: user.classId,
classId: null,
},
};
next();
@@ -0,0 +1,88 @@
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, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
archiveClassParamSchema,
archivedContentQuerySchema,
createArchiveSchema,
listArchivesQuerySchema,
suggestedEnrollmentYears,
} from './archives.schemas.js';
import * as archiveService from './archives.service.js';
const router = Router();
router.use(authenticate());
/**
* 毕业归档(**全部接口仅管理员**)。
*
* 归档是**打标记 + 只读**:毕业班级的作业与通知原样留在库里,
* 通过 `GET /:id/classes/:classId/content` 只读查看。
* 「未毕业而升级的班级不归档」—— 升级走 `POST /api/classes/promote`,只改年级。
*/
/** GET /api/archives - 届别列表 */
router.get('/', requireRole('ADMIN'), validate({ query: listArchivesQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
sendOk(res, await archiveService.listArchivedYears(user), '获取届别列表成功');
});
/** GET /api/archives/summary - 归档页顶部汇总 + 可归档的届别建议 */
router.get('/summary', requireRole('ADMIN'), async (req, res) => {
sendOk(
res,
{
...(await archiveService.getArchiveSummary()),
suggestedEnrollmentYears: suggestedEnrollmentYears(),
},
'获取归档汇总成功',
);
});
/** GET /api/archives/:id - 某届详情(班级 + 毕业生 + 转出的学生) */
router.get('/:id', requireRole('ADMIN'), validate({ params: idParamSchema }), async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
sendOk(res, await archiveService.getArchivedYearDetail(user, id), '获取届别详情成功');
});
/** GET /api/archives/:id/classes/:classId/content - 归档班级的作业与通知(只读) */
router.get(
'/:id/classes/:classId/content',
requireRole('ADMIN'),
validate({ params: archiveClassParamSchema, query: archivedContentQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id, classId } = validatedParams<{ id: string; classId: string }>(req);
const { limit } = validatedQuery<{ limit?: number }>(req);
sendOk(
res,
await archiveService.getArchivedClassContent(user, id, classId, limit ?? 200),
'获取归档班级内容成功',
);
},
);
/** POST /api/archives - 执行毕业归档(按届别整届归档) */
router.post('/', requireRole('ADMIN'), validate({ body: createArchiveSchema }), async (req, res) => {
const user = getAuthUser(req);
const result = await archiveService.createArchive(user, validatedBody(req));
sendCreated(res, result, `${result.name} 已归档,共 ${result.classCount} 个班级`);
});
/** DELETE /api/archives/:id - 撤销归档(管理员误操作的退路,不删任何数据) */
router.delete('/:id', requireRole('ADMIN'), validate({ params: idParamSchema }), async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
await archiveService.deleteArchive(user, id);
sendOk(res, { id }, '已撤销归档,班级与学生回到在读状态');
});
export const archivesModule = defineModule({
name: 'archives',
basePath: '/archives',
router,
});
@@ -0,0 +1,40 @@
import { z } from 'zod';
import { currentAcademicYearStart, graduationYearOf } from '@classhelper/shared';
import { enrollmentYearSchema } from '../../lib/schemas.js';
/** 执行毕业归档:按**届别**(入学年份)把整届班级与学生归档 */
export const createArchiveSchema = z.object({
enrollmentYear: enrollmentYearSchema,
/** 毕业年份;留空 = 入学年份 + 默认学制(3 年) */
graduationYear: z.coerce.number().int().min(2000).max(2200).optional(),
note: z.string().trim().max(200).optional(),
});
/** 届别列表的查询参数 */
export const listArchivesQuerySchema = z.object({
/** 只看某一届 */
enrollmentYear: enrollmentYearSchema.optional(),
keyword: z.string().trim().max(64).optional(),
});
/** 归档班级的内容(作业 / 通知)查询 */
export const archivedContentQuerySchema = z.object({
/** 作业与通知最多各返回多少条(默认 200) */
limit: z.coerce.number().int().min(1).max(500).optional(),
});
export const archiveClassParamSchema = z.object({
id: z.string().min(1),
classId: z.string().min(1),
});
/**
* 可归档的届别(前端下拉用):当前学年往前推几届,已经毕业的那几届就是候选。
* 只给"看起来该毕业了"的届别,避免管理员手滑把在读的届归档掉。
*/
export function suggestedEnrollmentYears(now: Date = new Date()): number[] {
const start = currentAcademicYearStart(now);
return [start - 2, start - 3, start - 4, start - 5];
}
export { graduationYearOf };
@@ -0,0 +1,281 @@
import {
SOCKET_EVENTS,
formatYearLabel,
graduationYearOf,
type ArchivedClassContentDto,
type ArchivedYearDetailDto,
type ArchivedYearDto,
} from '@classhelper/shared';
import { assertCanManageClasses } from '../../lib/access.js';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
import {
toArchivedClassDto,
toArchivedYearDto,
toHomeworkDto,
toNotificationDto,
toStudentDto,
} from '../../lib/mappers.js';
import { emitToClass } from '../../realtime/bus.js';
import { logger } from '../../lib/logger.js';
import type { createArchiveSchema } from './archives.schemas.js';
import type { z } from 'zod';
type CreateArchiveInput = z.infer<typeof createArchiveSchema>;
/**
* 毕业归档。
*
* **归档 = 打标记 + 只读,不删任何数据**:
* - `ArchivedYear` 是一届的档案(入学年份、毕业年份、归档时的统计快照);
* - `Class.archivedYearId` / `Student.archivedYearId` 指向它,常规列表据此过滤掉归档项;
* - 毕业班级发过的作业与通知**原样留在库里**,通过归档详情只读查看
* —— 「记录每个年度毕业班级的消息和作业」就是这么满足的。
*
* **未毕业而升级的班级不归档**:升级只改 `Class.grade`(高一 → 高二),
* 班级记录、学生名单、学号、作业成绩全部沿用(见 `classes.service.promoteClasses`)。
*
* 全部接口**仅管理员**。
*/
/** 届别列表(按入学年份倒序) */
export async function listArchivedYears(user: TokenPayload): Promise<ArchivedYearDto[]> {
assertCanManageClasses(user);
const records = await prisma.archivedYear.findMany({ orderBy: { enrollmentYear: 'desc' } });
return records.map(toArchivedYearDto);
}
/** 某届的详情:班级 + 毕业生 + 转出的学生 */
export async function getArchivedYearDetail(
user: TokenPayload,
archiveId: string,
): Promise<ArchivedYearDetailDto> {
assertCanManageClasses(user);
const record = await prisma.archivedYear.findUnique({ where: { id: archiveId } });
if (!record) throw ApiError.notFound('该届别档案不存在');
const [classes, students] = await Promise.all([
prisma.class.findMany({
where: { archivedYearId: archiveId },
orderBy: { classIndex: 'asc' },
include: {
teacher: { select: { name: true } },
_count: { select: { students: true, homeworks: true, notifications: true } },
},
}),
prisma.student.findMany({
where: { archivedYearId: archiveId },
orderBy: [{ status: 'asc' }, { studentNo: 'asc' }],
include: { class: { select: { name: true, grade: true } } },
}),
]);
const dtos = students.map(toStudentDto);
return {
...toArchivedYearDto(record),
classes: classes.map(toArchivedClassDto),
graduates: dtos.filter((item) => item.status === 'graduated'),
transferred: dtos.filter((item) => item.status === 'transferred'),
};
}
/**
* 归档班级的作业与通知(只读)。
*
* 归档不删数据,所以这里就是普通的按 classId 查询;单独开一个接口是为了让
* 「归档详情 → 查看这个班发过什么」有一个明确的、只读的入口。
*/
export async function getArchivedClassContent(
user: TokenPayload,
archiveId: string,
classId: string,
limit = 200,
): Promise<ArchivedClassContentDto> {
assertCanManageClasses(user);
const record = await prisma.class.findUnique({
where: { id: classId },
select: { id: true, name: true, archivedYearId: true },
});
if (!record) throw ApiError.notFound('班级不存在');
if (record.archivedYearId !== archiveId) {
throw ApiError.badRequest('该班级不属于这个届别档案');
}
const [homeworks, notifications] = await Promise.all([
prisma.homework.findMany({
where: { classId },
include: {
course: { select: { id: true, name: true } },
creator: { select: { id: true, name: true, username: true } },
statuses: true,
},
orderBy: [{ assignDate: 'desc' }, { createdAt: 'desc' }],
take: limit,
}),
prisma.notification.findMany({
where: { classId },
include: {
creator: { select: { id: true, name: true, username: true } },
reads: true,
},
orderBy: { createdAt: 'desc' },
take: limit,
}),
]);
return {
classId: record.id,
className: record.name,
homeworks: homeworks.map((item) => toHomeworkDto(item, { withStatus: true })),
notifications: notifications.map((item) => toNotificationDto(item, { withStatus: true })),
};
}
/**
* 执行归档:把某一届的全部班级与学生标记为已归档。
*
* 步骤:
* 1. 找到该届**尚未归档**的班级(`enrollmentYear` 匹配且 `archivedYearId` 为空);
* 2. 把这些班的学生标记为 `graduated`;把其中已经 `transferred` 的保持原状态;
* 3. 统计班级数 / 学生数 / 转出数 / 作业数 / 通知数写进档案快照;
* 4. 全部在**一个事务**里完成,中途失败不会留下"班归档了、学生没归档"的半截状态。
*/
export async function createArchive(
user: TokenPayload,
input: CreateArchiveInput,
): Promise<ArchivedYearDetailDto> {
assertCanManageClasses(user);
const existing = await prisma.archivedYear.findUnique({
where: { enrollmentYear: input.enrollmentYear },
select: { id: true },
});
if (existing) throw ApiError.conflict(`${formatYearLabel(input.enrollmentYear)} 已经归档过了`);
const classes = await prisma.class.findMany({
where: { enrollmentYear: input.enrollmentYear, archivedYearId: null },
select: { id: true },
});
if (classes.length === 0) {
throw ApiError.badRequest(
`没有找到 ${formatYearLabel(input.enrollmentYear)} 的在读班级(可能已归档,或入学年份没填对)`,
);
}
const classIds = classes.map((item) => item.id);
const [students, homeworkCount, notificationCount] = await Promise.all([
prisma.student.findMany({
where: { classId: { in: classIds }, archivedYearId: null },
select: { id: true, status: true },
}),
prisma.homework.count({ where: { classId: { in: classIds } } }),
prisma.notification.count({ where: { classId: { in: classIds } } }),
]);
const transferredCount = students.filter((item) => item.status === 'transferred').length;
const graduateCount = students.length - transferredCount;
const created = await prisma.$transaction(async (tx) => {
const archive = await tx.archivedYear.create({
data: {
enrollmentYear: input.enrollmentYear,
graduationYear: input.graduationYear ?? graduationYearOf(input.enrollmentYear),
name: formatYearLabel(input.enrollmentYear),
note: input.note ?? '',
operatorId: user.sub,
operatorName: user.name,
classCount: classes.length,
studentCount: graduateCount,
transferredCount,
homeworkCount,
notificationCount,
},
select: { id: true },
});
const now = new Date();
await tx.class.updateMany({
where: { id: { in: classIds } },
data: { archivedYearId: archive.id, archivedAt: now },
});
// 在读的 → 已毕业;已经转出的保持 transferred(他们的归档记录也一并挂到这一届)
if (students.length > 0) {
await tx.student.updateMany({
where: { id: { in: students.filter((s) => s.status !== 'transferred').map((s) => s.id) } },
data: { status: 'graduated', archivedYearId: archive.id, archivedAt: now },
});
await tx.student.updateMany({
where: { id: { in: students.filter((s) => s.status === 'transferred').map((s) => s.id) } },
data: { archivedYearId: archive.id, archivedAt: now },
});
}
return archive;
});
for (const classId of classIds) {
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'deleted' });
}
logger.info(
`毕业归档:${formatYearLabel(input.enrollmentYear)} 班级=${classes.length} 毕业生=${graduateCount} 转出=${transferredCount} 由 ${user.name} 执行`,
);
return getArchivedYearDetail(user, created.id);
}
/**
* 撤销归档(管理员误操作的退路)。
*
* 把该届的班级与学生的归档标记清掉:班级回到在读列表,学生状态 `graduated → active`。
* **不会删除任何作业或通知** —— 它们本来就没被删过。
*/
export async function deleteArchive(user: TokenPayload, archiveId: string): Promise<void> {
assertCanManageClasses(user);
const record = await prisma.archivedYear.findUnique({ where: { id: archiveId } });
if (!record) throw ApiError.notFound('该届别档案不存在');
const classes = await prisma.class.findMany({
where: { archivedYearId: archiveId },
select: { id: true },
});
await prisma.$transaction([
prisma.class.updateMany({
where: { archivedYearId: archiveId },
data: { archivedYearId: null, archivedAt: null },
}),
// 毕业的还原成在读;转出的保持转出(那是事实,不是归档造成的)
prisma.student.updateMany({
where: { archivedYearId: archiveId, status: 'graduated' },
data: { status: 'active', archivedYearId: null, archivedAt: null },
}),
prisma.student.updateMany({
where: { archivedYearId: archiveId, status: { not: 'graduated' } },
data: { archivedYearId: null, archivedAt: null },
}),
prisma.archivedYear.delete({ where: { id: archiveId } }),
]);
for (const item of classes) {
emitToClass(item.id, SOCKET_EVENTS.classUpdated, { classId: item.id, action: 'updated' });
}
logger.info(`撤销毕业归档:${record.name} 由 ${user.name} 执行`);
}
/** 归档页顶部的汇总(一届一行之外的全局数字) */
export async function getArchiveSummary(): Promise<{
archiveCount: number;
classCount: number;
graduateCount: number;
transferredCount: number;
}> {
const [archiveCount, classCount, graduateCount, transferredCount] = await Promise.all([
prisma.archivedYear.count(),
prisma.class.count({ where: { NOT: { archivedYearId: null } } }),
prisma.student.count({ where: { status: 'graduated' } }),
prisma.student.count({ where: { status: 'transferred' } }),
]);
return { archiveCount, classCount, graduateCount, transferredCount };
}
@@ -23,7 +23,7 @@ router.post('/login', validate({ body: loginSchema }), async (req, res) => {
});
/**
* POST /api/auth/class-login - 班级账号登录(公开,学生端主入口)
* POST /api/auth/class-login - 班级账号登录(公开,ClassHelper 班级端主入口)
*
* 班级码 + 班级密码 → 以「班级」为主体的 JWT(个人数据按全班读写)。
* 个人学生账号仍可用 /auth/login 登录(向后兼容,客户端已不再展示该入口)。
@@ -5,7 +5,7 @@ export const loginSchema = z.object({
password: z.string().min(1, '请输入密码').max(128),
});
/** 班级账号登录(学生端):班级码 + 班级密码 */
/** 班级账号登录(ClassHelper 班级端):班级码 + 班级密码 */
export const classLoginSchema = z.object({
code: z.string().trim().min(4, '请输入班级码').max(16),
password: z.string().min(1, '请输入班级密码').max(128),
@@ -1,51 +1,45 @@
import type { LoginResponse, StudentDto, UserRole } from '@classhelper/shared';
import type { LoginResponse, SessionUser, UserRole } from '@classhelper/shared';
import { changeClassPassword, findClassForLogin, verifyClassPassword } from '../../lib/class-account.js';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import { signToken, type TokenPayload } from '../../lib/jwt.js';
import { toStudentDto, toUserDto } from '../../lib/mappers.js';
import { toClassDeviceUser, toUserDto, toUserSessionUser } from '../../lib/mappers.js';
import { hashPassword, verifyPassword } from '../../lib/password.js';
import { isClassSession } from '../../lib/session.js';
import { isClassDevice } from '../../lib/access.js';
import type { ChangePasswordInput, ClassLoginInput, LoginInput } from './auth.schemas.js';
/**
* 登录:校验用户名密码并签发 JWT。
* 登录:校验工号(管理员为登录名)与密码并签发 JWT。
*
* 学生端主体是**班级**(班级码 + 班级密码):个人学生账号(student01…)**不再允许登录**,
* 它们的用户名只是"作业完成 / 通知已读 / 成绩"等个人数据的记录键(由班级会话代全班读写)。
* 教师与管理员仍用用户名登录。
* **学生不在 `User` 表里**,因此这里没有、也不该有"学生账号"分支:
* 学生只是 `Student` 名单记录,唯一的登录途径是教室机器用班级码 + 班级密码
* (`classLogin`),而那签发的会话主体是**班级**,不是某个学生。
*/
export async function login(input: LoginInput): Promise<LoginResponse> {
const user = await prisma.user.findUnique({ where: { username: input.username } });
if (!user) throw ApiError.unauthorized('用户名或密码错误');
// 角色判定必须放在密码校验**之前**:否则"密码错"与"密码对但学生账号已停用"会给出
// 不同的响应(401 / 403),攻击者据此就能把学生账号的密码当成可离线验证的预言机。
// 文案与状态码保持不变(客户端按 403 提示"请用班级码登录")。
if (user.role === 'STUDENT') {
throw ApiError.forbidden('学生请使用「班级码 + 班级密码」登录(个人学生账号已停用)');
}
if (!user) throw ApiError.unauthorized('工号或密码错误');
const passwordMatched = await verifyPassword(input.password, user.passwordHash);
if (!passwordMatched) throw ApiError.unauthorized('用户名或密码错误');
if (!passwordMatched) throw ApiError.unauthorized('工号或密码错误');
const payload: TokenPayload = {
sub: user.id,
username: user.username,
name: user.name,
role: user.role as UserRole,
classId: user.classId,
classId: null,
};
const token = signToken(payload);
return { token, user: toUserDto(user) };
return { token, user: toUserSessionUser(user) };
}
/**
* 班级账号登录(学生端):班级码 + 班级密码。
* ClassHelper 班级端登录:班级码 + 班级密码。
*
* 签发的 JWT 以**班级**为主体:`sub` 与 `classId` 都是班级 id,并带 `classSession: true`,
* 后续个人数据(作业完成、通知已读)由服务端按"全班"范围读写,个人学生不再是登录主体。
* 角色是 `CLASS_DEVICE`。后续个人数据(作业完成、通知已读)由服务端按"全班学生"范围读写,
* 因此教师端的"完成人数 / 已读人数"统计依然准确。
*/
export async function classLogin(input: ClassLoginInput): Promise<LoginResponse> {
const record = await findClassForLogin(input.code);
@@ -58,7 +52,7 @@ export async function classLogin(input: ClassLoginInput): Promise<LoginResponse>
sub: record.id,
username: record.code,
name: record.name,
role: 'STUDENT',
role: 'CLASS_DEVICE',
classId: record.id,
classSession: true,
classCode: record.code,
@@ -67,57 +61,53 @@ export async function classLogin(input: ClassLoginInput): Promise<LoginResponse>
return {
token,
user: {
user: toClassDeviceUser({
id: record.id,
username: record.code,
code: record.code,
name: record.name,
role: 'STUDENT',
classId: record.id,
createdAt: new Date().toISOString(),
className: record.name,
grade: record.grade,
classSession: true,
classCode: record.code,
},
studentGradeQueryEnabled: record.studentGradeQueryEnabled,
createdAt: new Date(),
}),
};
}
/**
* 当前用户信息(学生额外带班级名与年级)。
* 班级账号返回班级信息(id 即班级 id)。
* 当前账号信息。
* ClassHelper 班级端返回班级信息(id 即班级 id)。
*/
export async function getProfile(user: TokenPayload): Promise<StudentDto> {
if (isClassSession(user)) {
export async function getProfile(user: TokenPayload): Promise<SessionUser> {
if (isClassDevice(user)) {
const record = await prisma.class.findUnique({
where: { id: user.classId ?? user.sub },
select: { id: true, name: true, grade: true, code: true, createdAt: true },
select: {
id: true,
name: true,
grade: true,
code: true,
studentGradeQueryEnabled: true,
createdAt: true,
},
});
if (!record) throw ApiError.unauthorized('班级不存在或已被删除');
return {
return toClassDeviceUser({
id: record.id,
username: record.code,
code: record.code,
name: record.name,
role: 'STUDENT',
classId: record.id,
createdAt: record.createdAt.toISOString(),
className: record.name,
grade: record.grade,
classSession: true,
classCode: record.code,
} as StudentDto & { classSession: boolean; classCode: string };
studentGradeQueryEnabled: record.studentGradeQueryEnabled,
createdAt: record.createdAt,
});
}
const user_ = await prisma.user.findUnique({
where: { id: user.sub },
include: { class: { select: { name: true, grade: true } } },
});
if (!user_) throw ApiError.unauthorized('账号不存在或已被删除');
return toStudentDto(user_);
const record = await prisma.user.findUnique({ where: { id: user.sub } });
if (!record) throw ApiError.unauthorized('账号不存在或已被删除');
return toUserSessionUser(record);
}
/** 修改自己的密码;班级账号修改的是「班级密码」 */
/** 修改自己的密码;ClassHelper 班级端修改的是「班级密码」 */
export async function changePassword(user: TokenPayload, input: ChangePasswordInput): Promise<void> {
if (isClassSession(user)) {
if (isClassDevice(user)) {
await changeClassPassword(user.classId ?? user.sub, input.currentPassword, input.newPassword);
return;
}
@@ -133,3 +123,6 @@ export async function changePassword(user: TokenPayload, input: ChangePasswordIn
data: { passwordHash: await hashPassword(input.newPassword) },
});
}
/** 供 auth 模块的路由复用:账号 DTO */
export { toUserDto };
@@ -11,7 +11,7 @@ router.use(authenticate());
/**
* POST /api/calls - 叫人(老师点名让学生过来)
* 学生端会在灵动岛上立即弹出"请 XXX 同学找 XXX 老师",无论是否在上课。
* ClassHelper 班级端会在灵动岛上立即弹出"请 XXX 同学找 XXX 老师",无论是否在上课。
*/
router.post('/', requireRole('ADMIN', 'TEACHER'), validate({ body: createCallSchema }), async (req, res) => {
const user = getAuthUser(req);
@@ -13,7 +13,7 @@ export const createCallSchema = z
quickPhrase: z.string().trim().max(64).optional(),
/** 自定义消息(可选;与 quickPhrase 至少填一个) */
message: z.string().trim().max(200).optional(),
/** 紧急叫人:落库 URGENT,学生端无视上课时段立即展开;默认普通(只进队列) */
/** 紧急叫人:落库 URGENT,ClassHelper 班级端无视上课时段立即展开;默认普通(只进队列) */
urgent: z.boolean().optional(),
})
.refine((data) => Boolean(data.studentId), { message: '请选择学生', path: ['studentId'] })
@@ -1,11 +1,11 @@
import { SOCKET_EVENTS, buildCallTitle, type NotificationDto } from '@classhelper/shared';
import { assertCanPublishContent } from '../../lib/access.js';
import { assertCanPublishNotification } 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 { toNotificationDto } from '../../lib/mappers.js';
import { emitToClass, emitToUser } from '../../realtime/bus.js';
import { emitToClass, emitToSession } from '../../realtime/bus.js';
import { pushToClassIsland, shouldPushToClassIsland } from '../integrations/integrations.service.js';
import type { CreateCallInput } from './calls.schemas.js';
@@ -14,20 +14,24 @@ const creatorSelect = { select: { id: true, name: true, username: true } } as co
/**
* "叫人":老师点名让某位同学去找他。
*
* 设计:复用通知表落库(学生端通知中心可见、已读状态天然可用、离线也能看到历史),
* 同时额外广播 `call:new` 到该学生的 user 房间 —— 客户端据此把消息标记为
* "叫人"类型(徽标/按钮/尺寸),**是否立即展开则由 priority 决定**:
* 设计:复用通知表落库(通知中心可见、已读状态天然可用、离线也能看到历史),
* 同时额外广播 `call:new`,客户端据此把消息标记为"叫人"类型(徽标/按钮/尺寸)。
* **是否立即展开由 priority 决定**:
* - 紧急叫人(urgent=true → URGENT):无视上课时段立即展开;
* - 普通叫人(默认 → HIGH):按普通通知处理,上课时段只进队列、下课后弹出,
* 课间则先显示胶囊、点击后展开。
* 两种情况都不会被"上课时段发布紧急通知"的 409 拦截(老师确实需要学生过来)。
*
* 注:学生不是账号,没有"学生自己的客户端",因此 `call:new` 只发给
* **教室机器**(ClassHelper 班级端,会话房间以班级 id 为键)—— 学生姓名在标题里,
* 全班都看得到在叫谁,这与需求「教室机器代全班」一致。
*/
export async function createCall(user: TokenPayload, input: CreateCallInput): Promise<NotificationDto> {
await assertCanPublishContent(user, input.classId);
await assertCanPublishNotification(user, input.classId);
const student = await prisma.user.findUnique({
const student = await prisma.student.findUnique({
where: { id: input.studentId },
select: { id: true, name: true, username: true, classId: true },
select: { id: true, name: true, studentNo: true, classId: true },
});
if (!student) throw ApiError.notFound('学生不存在');
if (student.classId !== input.classId) {
@@ -39,7 +43,7 @@ export async function createCall(user: TokenPayload, input: CreateCallInput): Pr
select: { name: true, username: true },
});
const teacherName = teacher?.name ?? teacher?.username ?? '老师';
const studentName = student.name || student.username;
const studentName = student.name || student.studentNo;
const message = input.message?.trim() || input.quickPhrase?.trim() || '请尽快到老师这里来';
const created = await prisma.notification.create({
@@ -48,24 +52,21 @@ export async function createCall(user: TokenPayload, input: CreateCallInput): Pr
title: buildCallTitle(studentName, teacherName),
content: message,
// 紧急叫人 → URGENT(客户端无视上课时段立即展开);普通叫人 → HIGH
// (不会触发上课时段 409 拦截,但学生端按普通通知排队处理)
// (不会触发上课时段 409 拦截,但客户端按普通通知排队处理)
priority: input.urgent ? 'URGENT' : 'HIGH',
createdBy: user.sub,
},
include: { creator: creatorSelect, reads: true },
});
const dto = toNotificationDto(created, { userIds: [student.id], withStatus: true });
// 班级房间广播:通知中心/其他端都能看到
const dto = toNotificationDto(created, { studentId: student.id, withStatus: true });
// 班级房间广播:通知中心都能看到
emitToClass(created.classId, SOCKET_EVENTS.notificationNew, dto);
// 定向广播给被叫的学生:个人学生端灵动岛立即展开
emitToUser(student.id, SOCKET_EVENTS.callNew, dto);
// 同时广播到 classId 对应的 user 房间:班级账号(班级设备)以此房间登录,
// 因此"叫人"消息在班级设备上也会立即展开(学生姓名在标题里,全班都能看到叫谁)
emitToUser(created.classId, SOCKET_EVENTS.callNew, dto);
// 定向广播给教室机器:会话房间以**班级 id** 为键,叫人消息因此立即展开
emitToSession(created.classId, SOCKET_EVENTS.callNew, dto);
// 叫人同样推到教室的 ClassIsland:老师点名时,教室大屏也弹一条(学生姓名在标题里)。
// 显示位置由教室客户端决定;推送失败不影响叫人本身。
// 显示位置由班级端决定;推送失败不影响叫人本身。
try {
if (await shouldPushToClassIsland(input.classId)) {
await pushToClassIsland({
@@ -12,19 +12,22 @@ import { defineModule } from '../module.types.js';
import {
addStudentSchema,
assignHeadTeacherSchema,
assignTeacherSchema,
assignSubjectTeachersSchema,
classStudentParamSchema,
classTeacherParamSchema,
createClassSchema,
listClassesQuerySchema,
promoteClassesSchema,
updateClassAccountSchema,
updateClassSchema,
updateNotificationChannelSchema,
updateStudentGradeQuerySchema,
type AddStudentInput,
type AssignSubjectTeachersInput,
type CreateClassInput,
type UpdateClassAccountInput,
type UpdateClassInput,
type UpdateNotificationChannelInput,
type UpdateStudentGradeQueryInput,
} from './classes.schemas.js';
import * as classService from './classes.service.js';
@@ -33,30 +36,52 @@ const router = Router();
// 班级相关接口统一需要登录
router.use(authenticate());
/** GET /api/classes - 班级列表(教师/管理员:自己管理的班级;学生:自己所在班级) */
/**
* GET /api/classes - 班级列表
*
* 只列**在读**班级(毕业归档的班级属于「归档管理」页);
* 教师/管理员看自己可访问的班级(班主任 ∪ 任课),ClassHelper 班级端看自己绑定的那个。
*/
router.get('/', validate({ query: listClassesQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
const { keyword } = validatedQuery<{ keyword?: string }>(req);
sendOk(res, await classService.listClasses(user, keyword), '获取班级列表成功');
});
/** GET /api/classes/:id - 班级详情 */
/**
* POST /api/classes/promote - 学年升级(批量改年级,仅管理员)
*
* **升级不归档、数据沿用**:只改 `grade`(高一 → 高二),班级记录、学生名单、学号、
* 作业与成绩全部原样。归档只在毕业时由「归档管理」页显式执行。
*/
router.post(
'/promote',
requireRole('ADMIN'),
validate({ body: promoteClassesSchema }),
async (req, res) => {
const user = getAuthUser(req);
const body = validatedBody<{ classIds: string[]; grade: string }>(req);
sendOk(res, await classService.promoteClasses(user, body), '班级已升级,数据继续沿用');
},
);
/** GET /api/classes/:id - 班级详情(学生名单 + 课程 + 班主任 + 各科任课老师) */
router.get('/:id', validate({ params: idParamSchema }), async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
sendOk(res, await classService.getClassDetail(user, id), '获取班级详情成功');
});
/** POST /api/classes - 创建班级 */
/** POST /api/classes - 创建班级(称呼由入学年份 + 班号生成,如「2026级1班」) */
router.post('/', requireRole('ADMIN'), validate({ body: createClassSchema }), async (req, res) => {
const user = getAuthUser(req);
sendCreated(res, await classService.createClass(user, req.body as CreateClassInput), '班级创建成功');
});
/** PATCH /api/classes/:id - 编辑班级 */
/** PATCH /api/classes/:id - 编辑班级(年级 / 届别 / 教学周数),仅管理员 */
router.patch(
'/:id',
requireRole('ADMIN', 'TEACHER'),
requireRole('ADMIN'),
validate({ params: idParamSchema, body: updateClassSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -68,7 +93,7 @@ router.patch(
/** DELETE /api/classes/:id - 删除班级 */
router.delete(
'/:id',
requireRole('ADMIN', 'TEACHER'),
requireRole('ADMIN'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -80,7 +105,7 @@ router.delete(
/**
* PATCH /api/classes/:id/class-account - 设置 / 重置班级账号(班级码 + 班级密码)
* 仅管理员:学生端「班级登录」用的就是这个班级码
* 仅管理员:教室机器「ClassHelper 班级端」登录用的就是这个班级码
*/
router.patch(
'/:id/class-account',
@@ -98,8 +123,8 @@ router.patch(
);
/**
* PATCH /api/classes/:id/head-teacher - 设置 / 更改班主任
* 仅管理员:班主任决定谁能管这个班的课表与成绩,属于人员分配权限
* PATCH /api/classes/:id/head-teacher - 设置 / 更改班主任(每班 1 人)
* 仅管理员:班主任决定谁能管这个班的学生名单与课表,属于人员分配权限
*/
router.patch(
'/:id/head-teacher',
@@ -114,9 +139,31 @@ router.patch(
);
/**
* GET /api/classes/:id/classisland-status - 本班 ClassIsland 联动状态
* PUT /api/classes/:id/subject-teachers - 设置各科任课老师(班级 + 科目 + 教师)
*
* 教室机器(班级账号)也能读:客户端「设置 → ClassIsland 联动」据此显示"已接入 / 未接入"。
* 仅管理员。这是「科任老师」这条关系的唯一写入口 —— 作业与成绩能不能被某位老师改动,
* 查的就是这里写下的 `Course.teacherId`。
* 班主任同时是某科科任老师时,两条记录指向同一个账号:不重复建号、权限自动合并。
*/
router.put(
'/:id/subject-teachers',
requireRole('ADMIN'),
validate({ params: idParamSchema, body: assignSubjectTeachersSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
sendOk(
res,
await classService.assignSubjectTeachers(user, id, validatedBody<AssignSubjectTeachersInput>(req)),
'任课老师已更新',
);
},
);
/**
* GET /api/classes/:id/classisland-status - 本班 ClassHelper 班级端 / ClassIsland 联动状态
*
* 教室机器(ClassHelper 班级端)也能读:客户端「设置 → ClassIsland 联动」据此显示状态。
*/
router.get('/:id/classisland-status', validate({ params: idParamSchema }), async (req, res) => {
const user = getAuthUser(req);
@@ -127,7 +174,7 @@ router.get('/:id/classisland-status', validate({ params: idParamSchema }), async
/**
* GET /api/classes/:id/notification-channel - 读取"通知显示到哪个端"
*
* 教室的班级客户端登录后会拉这个值,据此决定自己是弹窗/上岛还是静默;
* 教室的 ClassHelper 班级端登录后会拉这个值,据此决定自己是弹窗/上岛还是静默;
* 教师/管理员也能读(Web 端展示用)。
*/
router.get('/:id/notification-channel', validate({ params: idParamSchema }), async (req, res) => {
@@ -139,8 +186,7 @@ router.get('/:id/notification-channel', validate({ params: idParamSchema }), asy
/**
* PATCH /api/classes/:id/notification-channel - 设置"通知显示到哪个端"
*
* **由教室的班级客户端自己选**(班级账号可改),教师/管理员也可改;
* 普通学生账号被拒(见 service 内的断言)。
* **由教室的 ClassHelper 班级端自己选**,教师/管理员也可改。
*/
router.patch(
'/:id/notification-channel',
@@ -160,6 +206,34 @@ router.patch(
},
);
/**
* GET /api/classes/:id/student-grade-query - 本班是否允许班级端按学号查成绩明细
* PATCH 同路径 - 开关它(班级端自己可改,教师/管理员也可改)
*/
router.get('/:id/student-grade-query', validate({ params: idParamSchema }), async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
sendOk(res, await classService.getStudentGradeQuery(user, id), '获取成绩查询开关成功');
});
router.patch(
'/:id/student-grade-query',
validate({ params: idParamSchema, body: updateStudentGradeQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
sendOk(
res,
await classService.updateStudentGradeQuery(
user,
id,
validatedBody<UpdateStudentGradeQueryInput>(req),
),
'成绩查询开关已更新',
);
},
);
/** GET /api/classes/:id/students - 班级学生名单 */
router.get('/:id/students', validate({ params: idParamSchema }), async (req, res) => {
const user = getAuthUser(req);
@@ -167,7 +241,7 @@ router.get('/:id/students', validate({ params: idParamSchema }), async (req, res
sendOk(res, await classService.listClassStudents(user, id), '获取学生名单成功');
});
/** POST /api/classes/:id/students - 添加学生(支持已存在账号转入) */
/** POST /api/classes/:id/students - 把学生加入班级(按学号,已存在则转入并留调班痕迹) */
router.post(
'/:id/students',
requireRole('ADMIN', 'TEACHER'),
@@ -179,43 +253,16 @@ router.post(
},
);
/** DELETE /api/classes/:id/students/:userId - 移出学生 */
/** DELETE /api/classes/:id/students/:studentId - 移出学生(回到「未分班」,名单记录保留) */
router.delete(
'/:id/students/:userId',
'/:id/students/:studentId',
requireRole('ADMIN', 'TEACHER'),
validate({ params: classStudentParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id, userId } = validatedParams<{ id: string; userId: string }>(req);
await classService.removeStudent(user, id, userId);
sendOk(res, { classId: id, userId }, '学生已移出班级');
},
);
/** POST /api/classes/:id/teachers - 分配协作教师 */
router.post(
'/:id/teachers',
requireRole('ADMIN', 'TEACHER'),
validate({ params: idParamSchema, body: assignTeacherSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
const { teacherId } = req.body as { teacherId: string };
await classService.assignTeacher(user, id, teacherId);
sendCreated(res, { classId: id, teacherId }, '教师已分配');
},
);
/** DELETE /api/classes/:id/teachers/:teacherId - 取消协作教师 */
router.delete(
'/:id/teachers/:teacherId',
requireRole('ADMIN', 'TEACHER'),
validate({ params: classTeacherParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id, teacherId } = validatedParams<{ id: string; teacherId: string }>(req);
await classService.removeTeacher(user, id, teacherId);
sendOk(res, { classId: id, teacherId }, '已取消教师分配');
const { id, studentId } = validatedParams<{ id: string; studentId: string }>(req);
await classService.removeStudent(user, id, studentId);
sendOk(res, { classId: id, studentId }, '学生已移出班级');
},
);
@@ -1,13 +1,26 @@
import { z } from 'zod';
import { CLASSISLAND_NOTIFICATION_CHANNELS, MAX_TERM_WEEK } from '@classhelper/shared';
import { keywordSchema } from '../../lib/schemas.js';
import { CLASSISLAND_NOTIFICATION_CHANNELS, MAX_TERM_WEEK, SUBJECT_CATALOG, isDayKey } from '@classhelper/shared';
import {
classIndexSchema,
enrollmentYearSchema,
guardianPhoneSchema,
keywordSchema,
studentGenderSchema,
studentNameSchema,
studentNoSchema,
} from '../../lib/schemas.js';
export const createClassSchema = z.object({
name: z.string().trim().min(1, '请输入班级名称').max(64),
/** 显示名由入学年份与班号生成,不收;保留字段仅为兼容旧客户端 */
name: z.string().trim().max(64).optional(),
grade: z.string().trim().min(1, '请输入年级').max(32),
/** 入学年份(届别):2026 → 班级称呼「2026级1班」 */
enrollmentYear: enrollmentYearSchema,
/** 班号(同一届内的序号) */
classIndex: classIndexSchema,
/** 仅管理员可指定班主任,教师创建时固定为自己 */
teacherId: z.string().min(1).optional(),
/** 可选:自定义班级码(学生端班级账号登录用),留空自动生成 */
/** 可选:自定义班级码(ClassHelper 班级端登录用),留空自动生成 */
code: z
.string()
.trim()
@@ -21,13 +34,29 @@ export const createClassSchema = z.object({
termWeeks: z.coerce.number().int().min(1).max(MAX_TERM_WEEK).optional(),
});
/**
* 编辑班级。
*
* `grade` 是**学年升级**用的:高一 → 高二 只改它,班级记录与学生数据全部沿用,**不归档**。
* `enrollmentYear` / `classIndex` 是给"有名无实的旧数据"补届别用的,改了会重算班级称呼。
*/
export const updateClassSchema = z.object({
name: z.string().trim().min(1).max(64).optional(),
grade: z.string().trim().min(1).max(32).optional(),
/** 本学期教学周数(1~30,管理员可调) */
/** 本班学期开始日期(第 1 教学周的周一);空串 = 回落到全局配置 */
termStartDate: z
.union([z.string().trim().refine(isDayKey, { message: '日期格式应为 YYYY-MM-DD' }), z.literal('')])
.nullish(),
enrollmentYear: enrollmentYearSchema.optional(),
classIndex: classIndexSchema.optional(),
termWeeks: z.coerce.number().int().min(1).max(MAX_TERM_WEEK).optional(),
});
/** 学年升级(批量改年级,不归档) */
export const promoteClassesSchema = z.object({
classIds: z.array(z.string().min(1)).min(1, '请至少选择一个班级').max(200),
grade: z.string().trim().min(1, '请输入升级后的年级').max(32),
});
/** 设置 / 重置班级账号(班级码 + 班级密码):仅管理员 */
export const updateClassAccountSchema = z
.object({
@@ -46,13 +75,38 @@ export const listClassesQuerySchema = z.object({
keyword: keywordSchema,
});
/** 往班级名单里加一名学生(学生没有账号,只有学号与姓名) */
export const addStudentSchema = z.object({
username: z.string().trim().min(3, '用户名至少 3 位').max(32),
name: z.string().trim().min(1, '请输入姓名').max(32),
studentNo: studentNoSchema,
name: studentNameSchema,
gender: studentGenderSchema,
guardianPhone: guardianPhoneSchema,
});
export const assignTeacherSchema = z.object({
teacherId: z.string().min(1, '请选择教师'),
/**
* 设置某班各科的任课老师(班级 + 科目 + 教师)。
*
* `subjectName` 用全校统一科目目录里的名字(找不到就自动建课);
* `teacherId` 留空表示解除该科的任课老师。
*/
export const assignSubjectTeachersSchema = z.object({
assignments: z
.array(
z.object({
courseId: z.string().min(1).nullish(),
subjectName: z
.string()
.trim()
.min(1, '请选择或填写科目')
.max(32)
.refine(
(value) => value.length > 0,
{ message: '科目名不能为空' },
),
teacherId: z.string().min(1).nullish(),
}),
)
.max(SUBJECT_CATALOG.length + 20, '科目数量超出预期'),
});
/** 设置 / 更改班主任(仅管理员) */
@@ -62,7 +116,7 @@ export const assignHeadTeacherSchema = z.object({
/**
* 通知显示位置(both / client / classisland)。
* 由**教室的班级客户端**在设置页里改,教师/管理员也可以在 Web 端改。
* 由 **ClassHelper 班级端**在设置页里改,教师/管理员也可以在 Web 端改。
*/
export const updateNotificationChannelSchema = z.object({
notificationChannel: z.enum(
@@ -71,18 +125,20 @@ export const updateNotificationChannelSchema = z.object({
),
});
export const classStudentParamSchema = z.object({
id: z.string().min(1),
userId: z.string().min(1),
/** 班级端按学号查询成绩明细的开关 */
export const updateStudentGradeQuerySchema = z.object({
enabled: z.boolean(),
});
export const classTeacherParamSchema = z.object({
export const classStudentParamSchema = z.object({
id: z.string().min(1),
teacherId: z.string().min(1),
studentId: z.string().min(1),
});
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 UpdateStudentGradeQueryInput = z.infer<typeof updateStudentGradeQuerySchema>;
export type AddStudentInput = z.infer<typeof addStudentSchema>;
export type AssignSubjectTeachersInput = z.infer<typeof assignSubjectTeachersSchema>;
@@ -3,14 +3,17 @@ import type {
ClassDto,
ClassIslandNotificationChannel,
StudentDto,
SubjectTeacherDto,
} from '@classhelper/shared';
import { SOCKET_EVENTS, formatClassName } from '@classhelper/shared';
import { env } from '../../config/env.js';
import {
assertClassAccess,
assertCanAssignTeachers,
assertCanManageClasses,
assertCanManageRoster,
assertClassAccess,
classScopeIdFilter,
isClassDevice,
isStaff,
resolveClassScope,
} from '../../lib/access.js';
@@ -29,16 +32,16 @@ import {
toTeacherBrief,
} from '../../lib/mappers.js';
import { emitToClass } from '../../realtime/bus.js';
import { SOCKET_EVENTS } from '@classhelper/shared';
import type {
AddStudentInput,
AssignSubjectTeachersInput,
CreateClassInput,
UpdateClassInput,
UpdateNotificationChannelInput,
UpdateStudentGradeQueryInput,
} 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,
@@ -47,6 +50,8 @@ const countSelect = {
notifications: true,
} as const;
const teacherBriefSelect = { select: { id: true, name: true, username: true } } as const;
/**
* 说明:班级码 code 与班级密码哈希 passwordHash 是 Class 的标量字段,
* 使用 include 查询时会默认返回,无需(也不能)写进 include 里。
@@ -56,7 +61,7 @@ const countSelect = {
/**
* 是否可以看到该班级的班级账号信息:
* 管理员(管理全部班级)或该班级的班主任(要发给学生用,只读展示)。
* 科任老师与学生都看不到班级码。
* 科任老师与 ClassHelper 班级端都看不到别人班的班级码。
*/
function canSeeClassAccount(user: TokenPayload, item: { teacherId: string }): boolean {
if (user.role === 'ADMIN') return true;
@@ -77,55 +82,92 @@ function toClassDtoWithAccount(
};
}
/** 班级列表(按权限过滤) */
/**
* 班级列表(按权限过滤)。
*
* **默认只列在读班级**(`archivedYearId IS NULL`):毕业归档的班级属于「归档管理」页,
* 混在常规列表里会让老师误以为那届还在上课。需要看归档班级请用 `/api/archives`。
*/
export async function listClasses(user: TokenPayload, keyword?: string): Promise<ClassDto[]> {
const scope = await resolveClassScope(user);
// Class 表按主键 id 过滤(classScopeWhere 针对的是带 classId 字段的子表)
const where = {
...classScopeIdFilter(scope),
archivedYearId: null,
...(keyword ? { OR: [{ name: { contains: keyword } }, { grade: { contains: keyword } }] } : {}),
};
const classes = await prisma.class.findMany({
where,
include: {
teacher: { select: { id: true, name: true, username: true } },
teacher: teacherBriefSelect,
_count: { select: countSelect },
},
orderBy: [{ grade: 'asc' }, { name: 'asc' }],
orderBy: [{ enrollmentYear: 'desc' }, { classIndex: 'asc' }, { grade: 'asc' }],
});
// 附上「是否已接入 ClassIsland」徽标:Web 端班级列表/课表页据此提示
return attachIntegrationFlag(classes.map((item) => toClassDtoWithAccount(user, item)));
}
/** 班级详情:学生名单 + 课程 + 协作教师 */
/** 班级详情:学生名单 + 课程 + 班主任 + 各科任课老师 */
export async function getClassDetail(user: TokenPayload, classId: string): Promise<ClassDetailDto> {
await assertClassAccess(user, classId);
const item = await prisma.class.findUnique({
where: { id: classId },
include: {
teacher: { select: { id: true, name: true, username: true } },
teacher: teacherBriefSelect,
_count: { select: countSelect },
students: { orderBy: { username: 'asc' } },
students: { orderBy: { studentNo: 'asc' } },
courses: {
orderBy: { name: 'asc' },
include: { teacher: { select: { id: true, name: true, username: true } } },
},
teachers: {
include: { teacher: { select: { id: true, name: true, username: true } } },
orderBy: { createdAt: 'asc' },
include: { teacher: teacherBriefSelect },
},
},
});
if (!item) throw ApiError.notFound('班级不存在');
const base = toClassDtoWithAccount(user, item);
return {
...toClassDtoWithAccount(user, item),
students: item.students.map((student) => toStudentDto(student)),
...base,
students: item.students.map(toStudentDto),
courses: item.courses.map(toCourseDto),
teachers: item.teachers.map((assignment) => toTeacherBrief(assignment.teacher)),
headTeacher: item.teacher ? toTeacherBrief(item.teacher) : null,
subjectTeachers: item.courses.map((course) => ({
courseId: course.id,
subjectName: course.name,
teacherId: course.teacherId,
teacherName: course.teacher?.name ?? '',
})),
};
}
/**
* 班级的任课老师表(班级 + 科目 + 教师)。
* 与 `getClassDetail` 分开,是因为「ClassHelper 在线状态」等页面只需要这一小块。
*/
export async function listSubjectTeachers(
user: TokenPayload,
classId: string,
): Promise<{ headTeacher: SubjectTeacherDto | null; subjectTeachers: SubjectTeacherDto[] }> {
await assertClassAccess(user, classId);
const item = await prisma.class.findUnique({
where: { id: classId },
include: {
teacher: teacherBriefSelect,
courses: { orderBy: { name: 'asc' }, include: { teacher: teacherBriefSelect } },
},
});
if (!item) throw ApiError.notFound('班级不存在');
return {
headTeacher: null,
subjectTeachers: item.courses.map((course) => ({
courseId: course.id,
subjectName: course.name,
teacherId: course.teacherId,
teacherName: course.teacher?.name ?? '',
})),
};
}
@@ -134,28 +176,29 @@ export async function createClass(user: TokenPayload, input: CreateClassInput):
assertCanManageClasses(user);
let teacherId = user.sub;
if (input.teacherId && user.role === 'ADMIN') {
const teacher = await prisma.user.findUnique({ where: { id: input.teacherId } });
if (!teacher || (teacher.role !== 'TEACHER' && teacher.role !== 'ADMIN')) {
throw ApiError.badRequest('指定的班主任不存在或角色不是教师');
}
teacherId = teacher.id;
teacherId = await requireTeacher(input.teacherId, '指定的班主任不存在或角色不是教师');
}
// 班级账号:自动生成班级码(也可由管理员指定)+ 默认班级密码
const account = await buildClassAccountCreateData(env.defaultClassPassword, input.code);
// 称呼一律由「入学年份 + 班号」生成:名字与届别必须一致,
// 否则会出现"名字是高一(1)班、届别却写 2026 级"这种自相矛盾的数据
const name = formatClassName(input.enrollmentYear, input.classIndex);
const created = await prisma.class.create({
data: {
name: input.name,
name,
grade: input.grade,
enrollmentYear: input.enrollmentYear,
classIndex: input.classIndex,
teacherId,
code: account.code,
passwordHash: account.passwordHash,
// 新建时就能指定学期周数(之前只有编辑接口支持,Web 端表单里填了也会被丢弃)
...(input.termWeeks !== undefined ? { termWeeks: input.termWeeks } : {}),
},
include: {
teacher: { select: { id: true, name: true, username: true } },
teacher: teacherBriefSelect,
_count: { select: countSelect },
},
});
@@ -166,8 +209,9 @@ export async function createClass(user: TokenPayload, input: CreateClassInput):
/**
* 设置 / 更改班主任(仅管理员)。
*
* 班主任决定了"谁能管这个班的课表与成绩",所以更换时要保证目标账号确实是教师/管理员;
* 原班主任保留在协作教师名单里(`ClassTeacher`)不变,避免换人后丢掉历史协作关系。
* 班主任决定了"谁能管这个班的学生名单与课表",所以更换时要保证目标账号确实是教师/管理员。
* 科任关系**不受影响** —— 它在 `Course` 上,是独立的一条条记录;
* 同一位老师可以同时是班主任与某科科任,系统里只有一个账号,权限在 resolveClassRole 里合并。
*/
export async function updateHeadTeacher(
user: TokenPayload,
@@ -175,17 +219,13 @@ export async function updateHeadTeacher(
teacherId: string,
): Promise<ClassDto> {
assertCanAssignTeachers(user);
const target = await prisma.user.findUnique({ where: { id: teacherId } });
if (!target || (target.role !== 'TEACHER' && target.role !== 'ADMIN')) {
throw ApiError.badRequest('指定的班主任不存在或角色不是教师');
}
const targetId = await requireTeacher(teacherId, '指定的班主任不存在或角色不是教师');
const updated = await prisma.class.update({
where: { id: classId },
data: { teacherId: target.id },
data: { teacherId: targetId },
include: {
teacher: { select: { id: true, name: true, username: true } },
teacher: teacherBriefSelect,
_count: { select: countSelect },
},
});
@@ -194,9 +234,64 @@ export async function updateHeadTeacher(
return toClassDtoWithAccount(user, updated);
}
/** 设置 / 重置班级账号(班级码 + 班级密码):仅管理员。
* 班级码即学生端"班级登录"的账号;密码留空表示不修改。
/**
* 批量设置各科科任老师(仅管理员)。
*
* 入参里每条是「某班某科的任课老师」:
* - 给了 `courseId` 就改那门课;
* - 只给 `subjectName` 时按名字找,找不到就**自动建课**(复用全校统一科目目录的习惯做法);
* - `teacherId` 留空表示**解除**该科的任课老师(课程本身保留,作业与成绩仍挂在它上面)。
*
* 班主任同时是科任老师时,这里指向的还是同一个 `User` —— 不重复建号、不产生权限冲突。
*/
export async function assignSubjectTeachers(
user: TokenPayload,
classId: string,
input: AssignSubjectTeachersInput,
): Promise<SubjectTeacherDto[]> {
assertCanAssignTeachers(user);
const targetClass = await prisma.class.findUnique({ where: { id: classId }, select: { id: true } });
if (!targetClass) throw ApiError.notFound('班级不存在');
for (const assignment of input.assignments) {
const teacherId = assignment.teacherId
? await requireTeacher(assignment.teacherId, `「${assignment.subjectName}」的任课老师不存在或不是教师`)
: // 解除任课关系时把课程挂回班主任,Course.teacherId 是必填列
(await prisma.class.findUniqueOrThrow({ where: { id: classId }, select: { teacherId: true } }))
.teacherId;
const existing = assignment.courseId
? await prisma.course.findUnique({ where: { id: assignment.courseId }, select: { id: true, classId: true } })
: await prisma.course.findUnique({
where: { classId_name: { classId, name: assignment.subjectName } },
select: { id: true, classId: true },
});
if (existing && existing.classId !== classId) {
throw ApiError.badRequest(`课程「${assignment.subjectName}」不属于该班级`);
}
if (existing) {
await prisma.course.update({ where: { id: existing.id }, data: { teacherId } });
} else {
await prisma.course.create({ data: { name: assignment.subjectName, classId, teacherId } });
}
}
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' });
const refreshed = await prisma.class.findUniqueOrThrow({
where: { id: classId },
include: { courses: { orderBy: { name: 'asc' }, include: { teacher: teacherBriefSelect } } },
});
return refreshed.courses.map((course) => ({
courseId: course.id,
subjectName: course.name,
teacherId: course.teacherId,
teacherName: course.teacher?.name ?? '',
}));
}
/** 设置 / 重置班级账号(班级码 + 班级密码):仅管理员 */
export async function updateClassAccount(
user: TokenPayload,
classId: string,
@@ -208,7 +303,7 @@ export async function updateClassAccount(
const updated = await prisma.class.findUnique({
where: { id: classId },
include: {
teacher: { select: { id: true, name: true, username: true } },
teacher: teacherBriefSelect,
_count: { select: countSelect },
},
});
@@ -216,7 +311,7 @@ export async function updateClassAccount(
return toClassDtoWithAccount(user, updated);
}
/** 编辑班级 */
/** 编辑班级(年级 / 教学周数 / 届别) */
export async function updateClass(
user: TokenPayload,
classId: string,
@@ -224,16 +319,31 @@ export async function updateClass(
): Promise<ClassDto> {
assertCanManageClasses(user);
const current = await prisma.class.findUnique({
where: { id: classId },
select: { enrollmentYear: true, classIndex: true },
});
if (!current) throw ApiError.notFound('班级不存在');
const enrollmentYear = input.enrollmentYear ?? current.enrollmentYear;
const classIndex = input.classIndex ?? current.classIndex;
const updated = await prisma.class.update({
where: { id: classId },
data: {
...(input.name ? { name: input.name } : {}),
...(input.grade ? { grade: input.grade } : {}),
// 教学周数(仅管理员可调,见 assertCanManageClasses):课表周次选择与默认 weekEnd 都用它
...(input.termWeeks !== undefined ? { termWeeks: input.termWeeks } : {}),
// 学期开始日期:空串/null 都表示"回落到全局配置"(如 .env 的 TERM_START_DATE)
...(input.termStartDate !== undefined
? { termStartDate: input.termStartDate ? input.termStartDate : null }
: {}),
...(input.enrollmentYear !== undefined ? { enrollmentYear } : {}),
...(input.classIndex !== undefined ? { classIndex } : {}),
// 届别齐了就把称呼重算一遍:班级称呼的唯一来源是「入学年份 + 班号」
...(enrollmentYear && classIndex ? { name: formatClassName(enrollmentYear, classIndex) } : {}),
},
include: {
teacher: { select: { id: true, name: true, username: true } },
teacher: teacherBriefSelect,
_count: { select: countSelect },
},
});
@@ -242,7 +352,28 @@ export async function updateClass(
return toClassDto(updated);
}
/** 删除班级(级联删除课表/作业/通知/成绩) */
/**
* 学年升级(仅管理员):把若干班级的年级改掉,**不归档、数据沿用**。
*
* 这是「未毕业而升级的班级不归档」这条例则的操作入口 —— 高一 → 高二 只动 `grade`,
* 班级记录、学生名单、学号、作业与成绩全部原样。归档只在毕业时由归档页显式执行。
*/
export async function promoteClasses(
user: TokenPayload,
input: { classIds: string[]; grade: string },
): Promise<{ promoted: number }> {
assertCanManageClasses(user);
const result = await prisma.class.updateMany({
where: { id: { in: input.classIds }, archivedYearId: null },
data: { grade: input.grade },
});
for (const classId of input.classIds) {
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' });
}
return { promoted: result.count };
}
/** 删除班级(级联删除课表/作业/通知/成绩;学生记录保留但解除班级归属) */
export async function deleteClass(user: TokenPayload, classId: string): Promise<void> {
assertCanManageClasses(user);
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'deleted' });
@@ -252,61 +383,68 @@ export async function deleteClass(user: TokenPayload, classId: string): Promise<
/** 班级学生名单 */
export async function listClassStudents(user: TokenPayload, classId: string): Promise<StudentDto[]> {
await assertClassAccess(user, classId);
const students = await prisma.user.findMany({
where: { classId, role: 'STUDENT' },
const students = await prisma.student.findMany({
where: { classId, archivedYearId: null },
include: { class: { select: { name: true, grade: true } } },
orderBy: { username: 'asc' },
orderBy: { studentNo: 'asc' },
});
return students.map(toStudentDto);
}
/**
* 添加学生到班级(名单实体)。
* 已存在的学生直接转入本班(同时维护 Enrollment,保证"一个学生一个主班级")。
* 学生没有账号属性:不设密码、不能登录,学生端统一用班级码 + 班级密码。
* 添加学生到班级(名单记录)。
*
* 已存在的学生(按学号)直接转入本班,并记一条调班历史;
* 学生没有账号属性:不设密码、不能登录,教室机器用班级码 + 班级密码。
*/
export async function addStudent(
user: TokenPayload,
classId: string,
input: AddStudentInput,
): Promise<StudentDto> {
assertCanManageRoster(user);
await assertCanManageRoster(user, classId);
await assertClassAccess(user, classId);
const existing = await prisma.user.findUnique({ where: { username: input.username } });
const existing = await prisma.student.findUnique({ where: { studentNo: input.studentNo } });
let studentId: string;
if (existing) {
if (existing.role !== 'STUDENT') {
throw ApiError.conflict(`用户名 ${input.username} 已被教师/管理员账号占用`);
}
studentId = existing.id;
// 换班:清理其他班级的选课记录
await prisma.enrollment.deleteMany({ where: { userId: existing.id, classId: { not: classId } } });
await prisma.user.update({
if (existing.classId !== classId) {
await prisma.studentClassTransfer.create({
data: {
studentId: existing.id,
studentNo: existing.studentNo,
studentName: input.name || existing.name,
fromClassId: existing.classId,
fromClassName: await classLabel(existing.classId),
toClassId: classId,
toClassName: await classLabel(classId),
operatorId: user.sub,
operatorName: user.name,
mode: 'single',
note: '在班级名单里直接加入',
},
});
}
await prisma.student.update({
where: { id: existing.id },
data: { classId, name: input.name },
data: { classId, name: input.name, status: 'active' },
});
} else {
const created = await prisma.user.create({
const created = await prisma.student.create({
data: {
username: input.username,
studentNo: input.studentNo,
name: input.name,
role: 'STUDENT',
classId,
// 空串占位:User.passwordHash 必填,但学生的 403 登录判定在密码校验之前,永远用不到
passwordHash: '',
gender: input.gender ?? '',
guardianPhone: input.guardianPhone ?? '',
},
});
studentId = created.id;
}
await prisma.enrollment.upsert({
where: { userId_classId: { userId: studentId, classId } },
create: { userId: studentId, classId },
update: {},
});
const student = await prisma.user.findUniqueOrThrow({
const student = await prisma.student.findUniqueOrThrow({
where: { id: studentId },
include: { class: { select: { name: true, grade: true } } },
});
@@ -315,55 +453,41 @@ export async function addStudent(
return toStudentDto(student);
}
/** 将学生移出班级(保留账号) */
/** 将学生移出班级(保留名单记录,回到「未分班」) */
export async function removeStudent(user: TokenPayload, classId: string, studentId: string): Promise<void> {
assertCanManageRoster(user);
await assertCanManageRoster(user, classId);
const student = await prisma.user.findUnique({ where: { id: studentId } });
if (!student || student.role !== 'STUDENT') throw ApiError.notFound('学生不存在');
const student = await prisma.student.findUnique({ where: { id: studentId } });
if (!student) throw ApiError.notFound('学生不存在');
if (student.classId !== classId) throw ApiError.badRequest('该学生不属于此班级');
await prisma.$transaction([
prisma.user.update({ where: { id: studentId }, data: { classId: null } }),
prisma.enrollment.deleteMany({ where: { userId: studentId, classId } }),
]);
await prisma.student.update({ where: { id: studentId }, data: { classId: null } });
await prisma.studentClassTransfer.create({
data: {
studentId: student.id,
studentNo: student.studentNo,
studentName: student.name,
fromClassId: classId,
fromClassName: await classLabel(classId),
toClassId: null,
toClassName: '未分班',
operatorId: user.sub,
operatorName: user.name,
mode: 'single',
note: '从班级名单里移出',
},
});
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' });
}
/** 分配协作教师 */
export async function assignTeacher(user: TokenPayload, classId: string, teacherId: string): Promise<void> {
assertCanAssignTeachers(user);
const teacher = await prisma.user.findUnique({ where: { id: teacherId } });
if (!teacher || (teacher.role !== 'TEACHER' && teacher.role !== 'ADMIN')) {
throw ApiError.badRequest('目标账号不存在或角色不是教师');
}
await prisma.classTeacher.upsert({
where: { classId_teacherId: { classId, teacherId } },
create: { classId, teacherId },
update: {},
});
}
/** 取消协作教师(班主任不可被移除) */
export async function removeTeacher(user: TokenPayload, classId: string, teacherId: string): Promise<void> {
assertCanAssignTeachers(user);
const target = await prisma.class.findUnique({ where: { id: classId }, select: { teacherId: true } });
if (target?.teacherId === teacherId) throw ApiError.badRequest('班主任不能被移除,请先转移班级');
await prisma.classTeacher.deleteMany({ where: { classId, teacherId } });
}
/* ---------------------------------------------------------------- ClassIsland 联动状态 */
/* ---------------------------------------------------------------- ClassHelper 班级端状态 */
/**
* 本班的 ClassIsland 联动状态(教室客户端的「ClassIsland 联动」面板展示)。
* 本班的 ClassHelper 联动状态(教室机器上的「联动」面板展示)。
*
* 为什么单独开一个接口:设备列表(/integrations/devices)是管理面,只对教师/管理员开放;
* 而教室机器(班级账号)需要知道"本班接没接 ClassIsland、最后什么时候上报的",
* 而教室机器(ClassHelper 班级端)需要知道"本班接没接 ClassIsland、最后什么时候上报的",
* 才能给出准确的提示(例如没接就别选"只在 ClassIsland 上弹")。
*/
export async function getClassIslandStatus(
@@ -373,35 +497,46 @@ export async function getClassIslandStatus(
connected: boolean;
deviceName: string | null;
deviceCount: number;
onlineCount: number;
lastSeenAt: string | null;
lastHeartbeatAt: string | null;
pluginVersion: string | null;
classIslandVersion: string | null;
}> {
await assertClassAccess(user, classId);
const devices = await prisma.integrationDevice.findMany({
where: { classId, enabled: true, mode: 'plugin' },
select: { name: true, lastSeenAt: true, pluginVersion: true, classIslandVersion: true },
orderBy: { lastSeenAt: 'desc' },
select: {
name: true,
lastSeenAt: true,
lastHeartbeatAt: true,
pluginVersion: true,
classIslandVersion: true,
},
orderBy: { lastHeartbeatAt: 'desc' },
});
const latest = devices[0];
const window = 60_000;
return {
connected: devices.length > 0,
deviceName: latest?.name ?? null,
deviceCount: devices.length,
onlineCount: devices.filter(
(item) => item.lastHeartbeatAt && Date.now() - item.lastHeartbeatAt.getTime() < window,
).length,
lastSeenAt: latest?.lastSeenAt ? latest.lastSeenAt.toISOString() : null,
lastHeartbeatAt: latest?.lastHeartbeatAt ? latest.lastHeartbeatAt.toISOString() : null,
pluginVersion: latest?.pluginVersion ?? null,
classIslandVersion: latest?.classIslandVersion ?? null,
};
}
/* ---------------------------------------------------------------- 通知显示位置 */
/* ---------------------------------------------------------------- 班级端设置 */
/**
* 读取班级的通知显示位置(both / client / classisland)。
*
* 这是**教室客户端**要读的值:客户端登录后拉一次,把自己的弹窗行为对齐到同一个选择。
* 学生也能读自己班的(只读),便于排查"为什么教室没弹"。
* 这是**教室机器**要读的值:登录后拉一次,把自己的弹窗行为对齐到同一个选择。
*/
export async function getNotificationChannel(
user: TokenPayload,
@@ -418,9 +553,8 @@ export async function getNotificationChannel(
/**
* 设置班级的通知显示位置。
*
* 允许两类人改:**该班的班级账号**(教室机器的客户端 —— 需求就是"由客户端自己选")
* 允许两类人改:**该班的 ClassHelper 班级端**(需求就是"由教室机器自己选")
* 与教师 / 管理员(Web 端也能改,便于老师统一要求)。
* 普通学生账号不允许:否则"教室里到底弹哪个端"会变成任意学生都能改的开关。
*/
export async function updateNotificationChannel(
user: TokenPayload,
@@ -428,14 +562,77 @@ export async function updateNotificationChannel(
input: UpdateNotificationChannelInput,
): Promise<{ notificationChannel: ClassIslandNotificationChannel }> {
await assertClassAccess(user, classId);
if (!isClassSession(user) && !isStaff(user)) {
throw ApiError.forbidden('只有班级账号或教师/管理员可以修改通知显示位置');
if (!isClassDevice(user) && !isStaff(user)) {
throw ApiError.forbidden('只有 ClassHelper 班级端或教师/管理员可以修改通知显示位置');
}
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 };
}
/**
* 读取「是否允许 ClassHelper 班级端按学号查询本班学生成绩明细」。
* 班级端设置页用它渲染开关的当前状态。
*/
export async function getStudentGradeQuery(
user: TokenPayload,
classId: string,
): Promise<{ studentGradeQueryEnabled: boolean }> {
await assertClassAccess(user, classId);
const record = await prisma.class.findUnique({
where: { id: classId },
select: { studentGradeQueryEnabled: true },
});
if (!record) throw ApiError.notFound('班级不存在');
// 老库补列时的默认值也是 true,两边保持一致
return { studentGradeQueryEnabled: record.studentGradeQueryEnabled ?? true };
}
/**
* 设置「是否允许 ClassHelper 班级端按学号查询本班学生成绩明细」。
*
* 与通知显示位置同一套模式:**班级端自己改**(教室里老师最清楚要不要给学生看),
* 教师 / 管理员在 Web 端也能改。关闭后班级端查成绩一律 403,教师端不受影响。
*/
export async function updateStudentGradeQuery(
user: TokenPayload,
classId: string,
input: UpdateStudentGradeQueryInput,
): Promise<{ studentGradeQueryEnabled: boolean }> {
await assertClassAccess(user, classId);
if (!isClassDevice(user) && !isStaff(user)) {
throw ApiError.forbidden('只有 ClassHelper 班级端或教师/管理员可以修改成绩查询开关');
}
await prisma.class.update({
where: { id: classId },
data: { studentGradeQueryEnabled: input.enabled },
});
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' });
logger.info(`班级端成绩查询开关:classId=${classId} enabled=${input.enabled} by=${user.sub}`);
return { studentGradeQueryEnabled: input.enabled };
}
/* ---------------------------------------------------------------- 内部工具 */
/** 校验目标账号确实是教师/管理员,返回其 id */
async function requireTeacher(teacherId: string, message: string): Promise<string> {
const teacher = await prisma.user.findUnique({
where: { id: teacherId },
select: { id: true, role: true },
});
if (!teacher || (teacher.role !== 'TEACHER' && teacher.role !== 'ADMIN')) {
throw ApiError.badRequest(message);
}
return teacher.id;
}
/** 班级名(写调班历史快照用);班级不存在时返回空串 */
async function classLabel(classId: string | null): Promise<string> {
if (!classId) return '';
const record = await prisma.class.findUnique({ where: { id: classId }, select: { name: true } });
return record?.name ?? '';
}
@@ -3,9 +3,9 @@ import {
assertClassAccess,
classScopeIdFilter,
classScopeWhere,
isStudent,
isClassDevice,
resolveClassScope,
requireStudentClassId,
requireClassDeviceClassId,
} from '../../lib/access.js';
import { prisma } from '../../lib/db.js';
import { MAX_TERM_WEEK } from '../../lib/term.js';
@@ -16,20 +16,16 @@ import { personalIdWhere, resolvePersonalIds } from '../../lib/session.js';
const courseSelect = { select: { id: true, name: true } } as const;
const creatorSelect = { select: { id: true, name: true, username: true } } as const;
/**
* 仪表盘汇总。
* 教师/管理员看到的是"自己管理的班级"的汇总;学生看到自己班级的汇总。
*/
/**
* 本班教学周数(默认 20)。
* - 学生 / 班级会话:固定为自己所在班级;
* - ClassHelper 班级端:固定为自己绑定的班级;
* - 教师 / 管理员:优先用显式 classId,**并且必须通过班级访问校验**(与其他接口同口径,
* 否则任意登录用户都能拿 classId 探测别班的教学周数);
* - 都不传:全局默认(MAX_TERM_WEEK 兜底)。
*/
export async function resolveTermWeeks(user: TokenPayload, classId?: string): Promise<number> {
if (classId) await assertClassAccess(user, classId);
const targetClassId = classId ?? (isStudent(user) ? requireStudentClassId(user) : undefined);
const targetClassId = classId ?? (isClassDevice(user) ? requireClassDeviceClassId(user) : undefined);
if (!targetClassId) return MAX_TERM_WEEK;
const record = await prisma.class.findUnique({
where: { id: targetClassId },
@@ -37,14 +33,19 @@ export async function resolveTermWeeks(user: TokenPayload, classId?: string): Pr
});
return record?.termWeeks && record.termWeeks > 0 ? record.termWeeks : MAX_TERM_WEEK;
}
/**
* 仪表盘汇总。
* 教师/管理员看到的是"自己管理的班级"的汇总;ClassHelper 班级端看到自己绑定班级的汇总。
*/
export async function getDashboardSummary(user: TokenPayload): Promise<DashboardSummary> {
const student = isStudent(user);
// 学生强制限定在自己班级,避免越权统计
const scope = await resolveClassScope(user, student ? requireStudentClassId(user) : undefined);
const device = isClassDevice(user);
// 班级端强制限定在自己班级,避免越权统计
const scope = await resolveClassScope(user, device ? requireClassDeviceClassId(user) : undefined);
// 子表按 classId 过滤;Class 表本身按主键 id 过滤
const where = classScopeWhere(scope);
const classWhere = classScopeIdFilter(scope);
// 班级账号(班级设备)以全班学生为范围;普通学生即自己
// ClassHelper 班级端以全班学生为范围
const personalIds = await resolvePersonalIds(user);
const personalWhere = personalIdWhere(personalIds);
@@ -61,15 +62,16 @@ export async function getDashboardSummary(user: TokenPayload): Promise<Dashboard
recentNotifications,
recentHomeworks,
] = await Promise.all([
// 学生数只算在读的:已毕业/转出的学生不属于"本班现在有多少人"
prisma.class.count({ where: classWhere }),
prisma.user.count({ where: { ...where, role: 'STUDENT' } }),
prisma.student.count({ where: { ...where, status: 'active', archivedYearId: null } }),
prisma.course.count({ where }),
prisma.schedule.count({ where }),
prisma.homework.count({ where }),
prisma.notification.count({ where }),
prisma.grade.count({ where }),
prisma.notification.count({ where: { ...where, reads: { none: personalWhere } } }),
student
device
? prisma.homework.count({
where: { ...where, statuses: { none: { ...personalWhere, completed: true } } },
})
@@ -85,7 +87,7 @@ export async function getDashboardSummary(user: TokenPayload): Promise<Dashboard
include: {
course: courseSelect,
creator: creatorSelect,
statuses: student ? { where: personalWhere } : true,
statuses: device ? { where: personalWhere } : true,
},
orderBy: { createdAt: 'desc' },
take: 5,
@@ -103,10 +105,10 @@ export async function getDashboardSummary(user: TokenPayload): Promise<Dashboard
unreadNotificationCount,
pendingHomeworkCount,
recentNotifications: recentNotifications.map((item): NotificationDto =>
toNotificationDto(item, { userIds: personalIds }),
toNotificationDto(item, { studentIds: personalIds }),
),
recentHomeworks: recentHomeworks.map((item): HomeworkDto =>
toHomeworkDto(item, { userIds: student ? personalIds : [], withStatus: !student }),
toHomeworkDto(item, { studentIds: device ? personalIds : [], withStatus: !device }),
),
};
}
@@ -9,22 +9,50 @@ import {
createGradeSchema,
listGradesQuerySchema,
statsQuerySchema,
studentGradeParamSchema,
studentGradeQuerySchema,
updateGradeLevelsSchema,
updateGradeSchema,
type BulkCreateGradeInput,
type CreateGradeInput,
type UpdateGradeInput,
type UpdateGradeLevelsInput,
} from './grades.schemas.js';
import * as gradeService from './grades.service.js';
const router = Router();
router.use(authenticate());
/** GET /api/grades/my - 学生查看个人成绩(任意角色都可查自己的) */
/** GET /api/grades/my - ClassHelper 班级端查看本班成绩(教师/管理员返回空数组) */
router.get('/my', async (req, res) => {
const user = getAuthUser(req);
sendOk(res, await gradeService.listMyGrades(user), '获取个人成绩成功');
sendOk(res, await gradeService.listMyGrades(user), '获取本班成绩成功');
});
/**
* GET /api/grades/student/:studentNo?classId= - 按**学号**查询某学生的成绩明细
*
* 三类调用方共用这一个接口:
* - 教师 / 管理员(Web 端"按学号查成绩");
* - ClassHelper 班级端(教室机器,学生按学号查自己的明细);
* - 科任老师查自己班的学生。
* 班级端需要该班打开 `studentGradeQueryEnabled`,否则 403。
*/
router.get(
'/student/:studentNo',
validate({ params: studentGradeParamSchema, query: studentGradeQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
const { studentNo } = validatedParams<{ studentNo: string }>(req);
const { classId } = validatedQuery<{ classId?: string }>(req);
sendOk(
res,
await gradeService.getStudentGradeDetail(user, studentNo, classId),
'获取学生成绩明细成功',
);
},
);
/** GET /api/grades/stats?classId=&courseId=&examName= - 成绩统计(图表) */
router.get(
'/stats',
@@ -37,21 +65,24 @@ router.get(
},
);
/** GET /api/grades?classId=&courseId=&userId=&examName= - 成绩列表(教师) */
/** GET /api/grades?classId=&courseId=&studentId=&examName= - 成绩列表(教师) */
router.get(
'/',
requireRole('ADMIN', 'TEACHER'),
validate({ query: listGradesQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
const query = validatedQuery<{ classId?: string; courseId?: string; userId?: string; examName?: string }>(
req,
);
const query = validatedQuery<{
classId?: string;
courseId?: string;
studentId?: string;
examName?: string;
}>(req);
sendOk(res, await gradeService.listGrades(user, query), '获取成绩列表成功');
},
);
/** POST /api/grades - 录入单条成绩(广播 grade:updated) */
/** POST /api/grades - 录入单条成绩(仅自己任教科目) */
router.post('/', requireRole('ADMIN', 'TEACHER'), validate({ body: createGradeSchema }), async (req, res) => {
const user = getAuthUser(req);
sendCreated(
@@ -61,6 +92,23 @@ router.post('/', requireRole('ADMIN', 'TEACHER'), validate({ body: createGradeSc
);
});
/**
* PATCH /api/grades/levels - 批量修改等级
*
* 两种用法:`items` 逐条指定;或 `classId + examName + levelType` 整批重算。
* 逐条改时按每条成绩自己的班级 + 科目判权限。
*/
router.patch(
'/levels',
requireRole('ADMIN', 'TEACHER'),
validate({ body: updateGradeLevelsSchema }),
async (req, res) => {
const user = getAuthUser(req);
const result = await gradeService.updateGradeLevels(user, req.body as UpdateGradeLevelsInput);
sendOk(res, result, `已更新 ${result.updated} 条成绩的等级`);
},
);
/** POST /api/grades/bulk - 批量录入 / 导入成绩 */
router.post(
'/bulk',
@@ -73,7 +121,7 @@ router.post(
},
);
/** PATCH /api/grades/:id - 修改成绩 */
/** PATCH /api/grades/:id - 修改成绩(含单个改等级) */
router.patch(
'/:id',
requireRole('ADMIN', 'TEACHER'),
@@ -1,10 +1,11 @@
import { z } from 'zod';
import { optionalDateTimeSchema } from '../../lib/schemas.js';
import { gradeLevelSchema, gradeLevelTypeSchema, optionalDateTimeSchema } from '../../lib/schemas.js';
export const listGradesQuerySchema = z.object({
classId: z.string().min(1).optional(),
courseId: z.string().min(1).optional(),
userId: z.string().min(1).optional(),
/** 学生 id(学生是名单记录,不是账号) */
studentId: z.string().min(1).optional(),
examName: z.string().trim().max(64).optional(),
});
@@ -17,10 +18,14 @@ export const statsQuerySchema = z.object({
export const createGradeSchema = z.object({
classId: z.string().min(1, '请选择班级'),
courseId: z.string().min(1).nullish(),
userId: z.string().min(1, '请选择学生'),
studentId: z.string().min(1, '请选择学生'),
examName: z.string().trim().min(1, '请输入考试名称').max(64),
score: z.coerce.number().min(0, '分数不能为负数'),
totalScore: z.coerce.number().positive('总分必须大于 0').optional(),
/** 等级口径:percent 百分制 / letter 等级制 A-D / custom 自定义等级 */
levelType: gradeLevelTypeSchema.optional(),
/** 手填等级;percent 且留空时由服务端按得分率换算 */
level: gradeLevelSchema.optional(),
publishedAt: optionalDateTimeSchema,
});
@@ -29,6 +34,8 @@ export const updateGradeSchema = z.object({
score: z.coerce.number().min(0).optional(),
totalScore: z.coerce.number().positive().optional(),
courseId: z.string().min(1).nullish(),
levelType: gradeLevelTypeSchema.optional(),
level: gradeLevelSchema.optional(),
publishedAt: optionalDateTimeSchema,
});
@@ -38,17 +45,56 @@ export const bulkCreateGradeSchema = z.object({
courseId: z.string().min(1).nullish(),
examName: z.string().trim().min(1).max(64),
totalScore: z.coerce.number().positive().optional(),
levelType: gradeLevelTypeSchema.optional(),
publishedAt: optionalDateTimeSchema,
items: z
.array(
z.object({
userId: z.string().min(1),
studentId: z.string().min(1),
score: z.coerce.number().min(0),
level: gradeLevelSchema.optional(),
}),
)
.min(1, '至少需要一条成绩记录'),
});
/**
* 批量修改等级,两种用法二选一:
* 1. `items`:逐条指定等级(前端表格里改完一次提交;单个编辑就是长度为 1 的数组);
* 2. `classId + examName + levelType`:把某个考试的某门课**整批重算**等级
* (例如把一次百分制月考整班切成 A/B/C/D 等级制)。
*/
export const updateGradeLevelsSchema = z
.object({
items: z
.array(z.object({ id: z.string().min(1), level: gradeLevelSchema }))
.min(1, '至少需要一条等级记录')
.max(1000)
.optional(),
classId: z.string().min(1).optional(),
examName: z.string().trim().min(1).max(64).optional(),
courseId: z.string().min(1).nullish(),
levelType: gradeLevelTypeSchema.optional(),
})
.refine(
(value) =>
(value.items && value.items.length > 0) ||
Boolean(value.classId && value.examName && value.levelType),
{ message: '请提供 items,或同时提供 classId + examName + levelType' },
);
/** 按学号查成绩明细:学号走路径参数,classId 可选(班级端固定为自己绑定的班级) */
export const studentGradeParamSchema = z.object({
studentNo: z.string().trim().min(1, '请填写学号').max(32),
});
export const studentGradeQuerySchema = z.object({
classId: z.string().min(1).optional(),
/** 设备令牌通道用查询参数传学号(JWT 通道走路径参数) */
studentNo: z.string().trim().min(1, '请填写学号').max(32).optional(),
});
export type CreateGradeInput = z.infer<typeof createGradeSchema>;
export type UpdateGradeInput = z.infer<typeof updateGradeSchema>;
export type BulkCreateGradeInput = z.infer<typeof bulkCreateGradeSchema>;
export type UpdateGradeLevelsInput = z.infer<typeof updateGradeLevelsSchema>;
@@ -1,64 +1,81 @@
import {
SOCKET_EVENTS,
canQueryStudentGrades,
gradeLevel,
gradePercent,
normalizeStudentNo,
type GradeDto,
type GradeLevel,
type GradeLevelType,
type GradeStats,
type StudentGradeDetailDto,
} from '@classhelper/shared';
import { assertCanManageGrades, classScopeWhere, resolveClassScope } from '../../lib/access.js';
import {
assertCanManageSubjectContent,
assertClassAccess,
classScopeWhere,
isClassDevice,
resolveClassScope,
resolveUserClassRole,
} from '../../lib/access.js';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { toGradeDto } from '../../lib/mappers.js';
import { toGradeDto, toStudentBrief } from '../../lib/mappers.js';
import { parseOptionalDate } from '../../lib/schemas.js';
import { resolvePersonalIds } from '../../lib/session.js';
import { emitToClass, emitToUser } from '../../realtime/bus.js';
import type { BulkCreateGradeInput, CreateGradeInput, UpdateGradeInput } from './grades.schemas.js';
import { emitToClass } from '../../realtime/bus.js';
import type {
BulkCreateGradeInput,
CreateGradeInput,
UpdateGradeInput,
UpdateGradeLevelsInput,
} from './grades.schemas.js';
const courseSelect = { select: { id: true, name: true } } as const;
const studentSelect = { select: { id: true, name: true, username: true } } as const;
const studentSelect = { select: { id: true, studentNo: true, name: true } } as const;
const classSelect = { select: { name: true } } as const;
const include = { course: courseSelect, student: studentSelect, class: classSelect } as const;
export interface ListGradeOptions {
classId?: string;
courseId?: string;
userId?: string;
studentId?: string;
examName?: string;
}
/**
* 学生查看成绩。
* - 普通学生账号:只看自己的成绩;
* - 班级账号(班级设备):返回**本班全部学生的成绩总览**(个人学生不再是登录主体,
* 班级设备上一屏展示全班成绩,便于张贴/核对)。
* ClassHelper 班级端查看本班成绩。
*
* 班级端是个人数据的唯一读写主体(学生不是账号),因此这里返回**本班全部学生的成绩**,
* 便于在教室大屏上一屏展示全班成绩供核对。
* 教师/管理员调用时返回空数组(他们没有"自己的成绩"),请用 `listGrades`。
*/
export async function listMyGrades(user: TokenPayload): Promise<GradeDto[]> {
const personalIds = await resolvePersonalIds(user);
const studentIds = await resolvePersonalIds(user);
if (studentIds.length === 0) return [];
const grades = await prisma.grade.findMany({
where: { userId: { in: personalIds } },
where: { studentId: { in: studentIds } },
include,
orderBy: [{ publishedAt: 'desc' }, { examName: 'asc' }],
take: 500,
take: 1000,
});
return grades.map(toGradeDto);
}
/** 教师查看班级成绩 */
/** 教师 / 管理员查看成绩(按可访问班级过滤) */
export async function listGrades(user: TokenPayload, options: ListGradeOptions): Promise<GradeDto[]> {
const scope = await resolveClassScope(user, options.classId);
const grades = await prisma.grade.findMany({
where: {
...classScopeWhere(scope),
...(options.courseId ? { courseId: options.courseId } : {}),
...(options.userId ? { userId: options.userId } : {}),
...(options.studentId ? { studentId: options.studentId } : {}),
...(options.examName ? { examName: options.examName } : {}),
},
include,
orderBy: [{ publishedAt: 'desc' }, { examName: 'asc' }],
take: 500,
take: 1000,
});
return grades.map(toGradeDto);
}
@@ -66,15 +83,15 @@ export async function listGrades(user: TokenPayload, options: ListGradeOptions):
/** 成绩统计(等级分布 + 各课程平均得分率),供 Web 端图表使用 */
export async function getGradeStats(user: TokenPayload, options: ListGradeOptions): Promise<GradeStats> {
const grades = await listGrades(user, options);
const levels: GradeLevel[] = ['A', 'B', 'C', 'D', 'E'];
const distribution = levels.map((level) => ({ level, count: 0 }));
const distributionMap = new Map<string, number>();
const byCourseMap = new Map<string, { courseId: string | null; courseName: string; percents: number[] }>();
for (const grade of grades) {
const percent = gradePercent(grade.score, grade.totalScore);
const bucket = distribution.find((item) => item.level === gradeLevel(percent));
if (bucket) bucket.count += 1;
// 自定义等级也要能统计,所以按 level 文本分桶(不再是固定的 A~E)
const level = grade.level || gradeLevel(percent);
distributionMap.set(level, (distributionMap.get(level) ?? 0) + 1);
const key = grade.courseId ?? '__none__';
const entry = byCourseMap.get(key) ?? {
@@ -101,58 +118,77 @@ export async function getGradeStats(user: TokenPayload, options: ListGradeOption
averagePercent:
Math.round((entry.percents.reduce((sum, value) => sum + value, 0) / entry.percents.length) * 10) / 10,
})),
distribution,
distribution: [...distributionMap.entries()]
.map(([level, count]) => ({ level, count }))
.sort((a, b) => b.count - a.count),
};
}
/* ---------------------------------------------------------------- 录入 / 修改 / 删除 */
/**
* 录入一条成绩。
*
* 权限走 `assertCanManageSubjectContent`:**科任老师只能录自己任教科目**的成绩,
* 班主任若不教这一科同样被拒;不指定科目(courseId 为空)时只有班主任/管理员可录。
*/
export async function createGrade(user: TokenPayload, input: CreateGradeInput): Promise<GradeDto> {
await assertCanManageGrades(user, input.classId);
await assertStudentInClass(input.userId, input.classId);
await assertCanManageSubjectContent(user, input.classId, input.courseId ?? null, '录入成绩');
await assertStudentInClass(input.studentId, input.classId);
if (input.courseId) await assertCourseInClass(input.courseId, input.classId);
const levelType: GradeLevelType = input.levelType ?? 'percent';
const totalScore = input.totalScore ?? 100;
const dto = await upsertGrade({
classId: input.classId,
courseId: input.courseId ?? null,
userId: input.userId,
studentId: input.studentId,
examName: input.examName,
score: input.score,
totalScore: input.totalScore ?? 100,
totalScore,
levelType,
level: resolveLevel(levelType, input.level, input.score, totalScore),
publishedAt: parseOptionalDate(input.publishedAt) ?? new Date(),
});
emitToClass(dto.classId, SOCKET_EVENTS.gradeUpdated, dto);
emitToUser(dto.userId, SOCKET_EVENTS.gradeUpdated, dto);
return dto;
}
/** 批量录入:同一个考试一次提交整班成绩 */
export async function bulkCreateGrades(user: TokenPayload, input: BulkCreateGradeInput): Promise<GradeDto[]> {
await assertCanManageGrades(user, input.classId);
await assertCanManageSubjectContent(user, input.classId, input.courseId ?? null, '录入成绩');
if (input.courseId) await assertCourseInClass(input.courseId, input.classId);
const publishedAt = parseOptionalDate(input.publishedAt) ?? new Date();
const levelType: GradeLevelType = input.levelType ?? 'percent';
const totalScore = input.totalScore ?? 100;
const results: GradeDto[] = [];
for (const item of input.items) {
await assertStudentInClass(item.userId, input.classId);
await assertStudentInClass(item.studentId, input.classId);
const dto = await upsertGrade({
classId: input.classId,
courseId: input.courseId ?? null,
userId: item.userId,
studentId: item.studentId,
examName: input.examName,
score: item.score,
totalScore: input.totalScore ?? 100,
totalScore,
levelType,
level: resolveLevel(levelType, item.level, item.score, totalScore),
publishedAt,
});
results.push(dto);
}
// 一次广播覆盖整班(房间内包含全部学生)
emitToClass(input.classId, SOCKET_EVENTS.gradeUpdated, {
...results[0],
bulk: true,
count: results.length,
});
if (results[0]) {
emitToClass(input.classId, SOCKET_EVENTS.gradeUpdated, {
...results[0],
bulk: true,
count: results.length,
});
}
return results;
}
@@ -163,8 +199,22 @@ export async function updateGrade(
): Promise<GradeDto> {
const current = await prisma.grade.findUnique({ where: { id: gradeId } });
if (!current) throw ApiError.notFound('成绩记录不存在');
await assertCanManageGrades(user, current.classId);
if (input.courseId) await assertCourseInClass(input.courseId, current.classId);
// 改的是哪条成绩,就按那条成绩所属的班级 + 科目判权限
const nextCourseId = input.courseId === undefined ? current.courseId : (input.courseId ?? null);
await assertCanManageSubjectContent(user, current.classId, nextCourseId, '修改成绩');
if (nextCourseId) await assertCourseInClass(nextCourseId, current.classId);
const totalScore = input.totalScore ?? current.totalScore;
const score = input.score ?? current.score;
const levelType = (input.levelType ?? current.levelType) as GradeLevelType;
// 改了分数就重算 percent 口径下的等级;显式传了 level 则以它为准
const level =
input.level !== undefined
? input.level
: levelType === 'percent'
? resolveLevel(levelType, '', score, totalScore)
: current.level;
const updated = await prisma.grade.update({
where: { id: gradeId },
@@ -172,7 +222,9 @@ export async function updateGrade(
...(input.examName ? { examName: input.examName } : {}),
...(input.score !== undefined ? { score: input.score } : {}),
...(input.totalScore !== undefined ? { totalScore: input.totalScore } : {}),
...(input.courseId !== undefined ? { courseId: input.courseId ?? null } : {}),
...(input.courseId !== undefined ? { courseId: nextCourseId } : {}),
levelType,
level,
...(input.publishedAt !== undefined
? { publishedAt: parseOptionalDate(input.publishedAt) ?? new Date() }
: {}),
@@ -182,14 +234,14 @@ export async function updateGrade(
const dto = toGradeDto(updated);
emitToClass(dto.classId, SOCKET_EVENTS.gradeUpdated, dto);
emitToUser(dto.userId, SOCKET_EVENTS.gradeUpdated, dto);
return dto;
}
export async function deleteGrade(user: TokenPayload, gradeId: string): Promise<void> {
const current = await prisma.grade.findUnique({ where: { id: gradeId } });
if (!current) throw ApiError.notFound('成绩记录不存在');
await assertCanManageGrades(user, current.classId);
await assertCanManageSubjectContent(user, current.classId, current.courseId, '删除成绩');
await prisma.grade.delete({ where: { id: gradeId } });
emitToClass(current.classId, SOCKET_EVENTS.gradeUpdated, {
...toGradeDto({ ...current, course: null, student: null, class: null }),
@@ -197,6 +249,172 @@ export async function deleteGrade(user: TokenPayload, gradeId: string): Promise<
});
}
/* ---------------------------------------------------------------- 等级:单个 / 批量 */
/**
* 批量修改等级。
*
* 两种用法:
* 1. `items`:逐条指定等级(前端表格里改完一次提交;单个编辑就是长度 1 的数组);
* 2. `classId + examName + levelType`:把某个考试的某门课整批**重算**等级
* (percent 按得分率换算,其余口径沿用原等级或留空)。
*
* 逐条改时按**每条成绩自己的班级 + 科目**判权限,科任老师改不了别人的科目。
*/
export async function updateGradeLevels(
user: TokenPayload,
input: UpdateGradeLevelsInput,
): Promise<{ updated: number }> {
if (input.items && input.items.length > 0) {
const records = await prisma.grade.findMany({
where: { id: { in: input.items.map((item) => item.id) } },
select: { id: true, classId: true, courseId: true },
});
if (records.length !== input.items.length) {
throw ApiError.badRequest('部分成绩记录不存在,请刷新后重试');
}
const byId = new Map(records.map((item) => [item.id, item]));
// 逐条判权限:可能跨多个班级/科目(管理员在成绩页多选修改时会这样)
for (const item of input.items) {
const record = byId.get(item.id)!;
await assertCanManageSubjectContent(user, record.classId, record.courseId, '修改成绩等级');
}
await prisma.$transaction(
input.items.map((item) =>
prisma.grade.update({ where: { id: item.id }, data: { level: item.level } }),
),
);
const sample = byId.get(input.items[0]!.id);
if (sample) emitToClass(sample.classId, SOCKET_EVENTS.gradeUpdated, { bulk: true, count: input.items.length });
return { updated: input.items.length };
}
// 整批重算
const classId = input.classId!;
await assertCanManageSubjectContent(user, classId, input.courseId ?? null, '调整成绩等级');
const levelType = input.levelType!;
const records = await prisma.grade.findMany({
where: {
classId,
examName: input.examName!,
...(input.courseId !== undefined ? { courseId: input.courseId ?? null } : {}),
},
select: { id: true, score: true, totalScore: true, level: true },
});
await prisma.$transaction(
records.map((item) =>
prisma.grade.update({
where: { id: item.id },
data: {
levelType,
level:
levelType === 'percent'
? gradeLevel(gradePercent(item.score, item.totalScore))
: item.level,
},
}),
),
);
emitToClass(classId, SOCKET_EVENTS.gradeUpdated, { bulk: true, count: records.length });
return { updated: records.length };
}
/* ---------------------------------------------------------------- 按学号查明细 */
/**
* 按**学号**查询某个学生的成绩明细(ClassHelper 班级端 / 教师端共用)。
*
* 权限:
* - 管理员:全库任意学号;
* - 班主任 / 科任老师:自己可访问的班级;
* - ClassHelper 班级端:仅本班,**且**该班打开了 `studentGradeQueryEnabled`
* —— 这就是「是否允许学生通过班级端查看本人明细,由教师或管理员开关控制」。
*/
export async function getStudentGradeDetail(
user: TokenPayload,
studentNo: string,
requestedClassId?: string,
): Promise<StudentGradeDetailDto> {
const scope = await resolveClassScope(user, requestedClassId);
const student = await prisma.student.findFirst({
where: { studentNo: normalizeStudentNo(studentNo), ...classScopeWhere(scope) },
include: { class: { select: { id: true, name: true, studentGradeQueryEnabled: true } } },
});
if (!student) throw ApiError.notFound('没找到这个学号的学生,请核对学号是否输入正确');
if (student.class) {
const classRole = await resolveUserClassRole(user, student.class.id);
const allowed = canQueryStudentGrades({
classRole,
// 老库补列时的默认值是 true(开箱可用),所以 null/undefined 都按开启处理
gradeQueryEnabled: student.class.studentGradeQueryEnabled ?? true,
});
if (!allowed) {
throw ApiError.forbidden('本班未开放学生自助查询成绩,请联系老师开启');
}
} else if (isClassDevice(user)) {
throw ApiError.forbidden('该学生尚未分班,无法查询');
}
const grades = await prisma.grade.findMany({
where: { studentId: student.id },
include,
orderBy: [{ publishedAt: 'desc' }, { examName: 'asc' }],
take: 500,
});
const percents = grades.map((item) => gradePercent(item.score, item.totalScore));
return {
student: toStudentBrief(student),
classId: student.classId,
className: student.class?.name ?? null,
items: grades.map(toGradeDto),
averagePercent:
percents.length === 0
? 0
: Math.round((percents.reduce((sum, value) => sum + value, 0) / percents.length) * 10) / 10,
};
}
/** 教师端:查看某个学生的成绩明细(按学生 id,内部复用按学号的实现) */
export async function getStudentGradeDetailById(
user: TokenPayload,
studentId: string,
): Promise<StudentGradeDetailDto> {
const student = await prisma.student.findUnique({
where: { id: studentId },
select: { studentNo: true, classId: true },
});
if (!student) throw ApiError.notFound('学生不存在');
await assertClassAccess(user, student.classId ?? '');
return getStudentGradeDetail(user, student.studentNo);
}
/* ---------------------------------------------------------------- 内部工具 */
/**
* 等级文本:
* - 显式给了就用它(教师手改的等级优先);
* - 否则 percent 口径按得分率换算;
* - letter / custom 留空时给空串,由前端提示"请选择等级"。
*/
function resolveLevel(
levelType: GradeLevelType,
level: string | undefined,
score: number,
totalScore: number,
): string {
const provided = (level ?? '').trim();
if (provided) return provided;
return levelType === 'percent' ? gradeLevel(gradePercent(score, totalScore)) : '';
}
/**
* 同一学生 + 同一课程 + 同一考试名称视为同一条记录,
* 重复录入时覆盖旧分数,避免教师"改分"产生重复行。
@@ -204,17 +422,19 @@ export async function deleteGrade(user: TokenPayload, gradeId: string): Promise<
async function upsertGrade(data: {
classId: string;
courseId: string | null;
userId: string;
studentId: string;
examName: string;
score: number;
totalScore: number;
levelType: GradeLevelType;
level: string;
publishedAt: Date;
}): Promise<GradeDto> {
const existing = await prisma.grade.findFirst({
where: {
classId: data.classId,
courseId: data.courseId,
userId: data.userId,
studentId: data.studentId,
examName: data.examName,
},
select: { id: true },
@@ -223,7 +443,13 @@ async function upsertGrade(data: {
if (existing) {
const updated = await prisma.grade.update({
where: { id: existing.id },
data: { score: data.score, totalScore: data.totalScore, publishedAt: data.publishedAt },
data: {
score: data.score,
totalScore: data.totalScore,
levelType: data.levelType,
level: data.level,
publishedAt: data.publishedAt,
},
include,
});
return toGradeDto(updated);
@@ -233,12 +459,12 @@ async function upsertGrade(data: {
return toGradeDto(created);
}
async function assertStudentInClass(userId: string, classId: string): Promise<void> {
const student = await prisma.user.findUnique({
where: { id: userId },
select: { role: true, classId: true },
async function assertStudentInClass(studentId: string, classId: string): Promise<void> {
const student = await prisma.student.findUnique({
where: { id: studentId },
select: { classId: true },
});
if (!student || student.role !== 'STUDENT') throw ApiError.badRequest('学生不存在');
if (!student) throw ApiError.badRequest('学生不存在');
if (student.classId !== classId) throw ApiError.badRequest('该学生不属于所选班级');
}
@@ -11,7 +11,7 @@ const dayKeySchema = z
export const listHomeworksQuerySchema = z.object({
classId: z.string().min(1).optional(),
courseId: z.string().min(1).optional(),
/** 学生端:只看未完成 */
/** ClassHelper 班级端:只看未完成 */
pendingOnly: booleanFlagSchema,
keyword: z.string().trim().max(64).optional(),
/** 只看某一天的作业(按 Homework.assignDate,本地日期) */
@@ -48,13 +48,16 @@ export const updateHomeworkSchema = z.object({
export const updateHomeworkStatusSchema = z.object({
completed: z.boolean(),
/** 教师代学生标记时使用;学生只能标记自己 */
userId: z.string().min(1).optional(),
/**
* 教师代学生标记时必填(学生不是账号,必须显式指定本班学生);
* ClassHelper 班级端**不要传** —— 它代全班标记。
*/
studentId: z.string().min(1).optional(),
});
/** 保存"未交名单":列出未交的学生 id(其余学生一律标记为已交) */
export const updateHomeworkSubmissionsSchema = z.object({
notSubmittedUserIds: z.array(z.string().min(1)).max(200).default([]),
notSubmittedStudentIds: z.array(z.string().min(1)).max(500).default([]),
});
export type ListHomeworkDaysInput = z.infer<typeof listHomeworkDaysQuerySchema>;
@@ -9,22 +9,17 @@ import {
type HomeworkSubmissionsDto,
} from '@classhelper/shared';
import {
assertCanPublishContent,
assertCanManageSubjectContent,
assertClassAccess,
classScopeWhere,
isStudent,
isClassDevice,
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 { toHomeworkDto, toHomeworkStatusDto } from '../../lib/mappers.js';
import {
classSessionClassId,
isClassSession,
personalIdWhere,
resolvePersonalIds,
} from '../../lib/session.js';
import { personalIdWhere, resolvePersonalIds } from '../../lib/session.js';
import { emitToClass } from '../../realtime/bus.js';
import type {
CreateHomeworkInput,
@@ -47,7 +42,7 @@ export interface ListHomeworkOptions {
/**
* 作业列表。
* - 学生:附带自己的完成状态,可通过 pendingOnly 只看未完成
* - ClassHelper 班级端:附带本班的完成情况,可通过 pendingOnly 只看未完成
* - 教师/管理员:附带完成人数统计
*/
export async function listHomeworks(
@@ -55,9 +50,9 @@ export async function listHomeworks(
options: ListHomeworkOptions,
): Promise<HomeworkDto[]> {
const scope = await resolveClassScope(user, options.classId);
const student = isStudent(user);
// 班级账号(班级设备)以全班学生为范围;普通学生即自己
const personalIds = student ? await resolvePersonalIds(user) : [];
const device = isClassDevice(user);
// ClassHelper 班级端以全班学生为范围;教师/管理员没有"自己的完成状态"
const personalIds = device ? await resolvePersonalIds(user) : [];
const personalWhere = personalIdWhere(personalIds);
const homeworks = await prisma.homework.findMany({
@@ -67,7 +62,7 @@ export async function listHomeworks(
...(options.keyword
? { OR: [{ title: { contains: options.keyword } }, { content: { contains: options.keyword } }] }
: {}),
...(student && options.pendingOnly
...(device && options.pendingOnly
? { statuses: { none: { ...personalWhere, completed: true } } }
: {}),
// 按天查看:作业「属于哪一天」看 assignDate(本地日期),不是 createdAt
@@ -76,14 +71,14 @@ export async function listHomeworks(
include: {
course: courseSelect,
creator: creatorSelect,
statuses: student ? { where: personalWhere } : true,
statuses: device ? { where: personalWhere } : true,
},
orderBy: [{ assignDate: 'desc' }, { createdAt: 'desc' }],
take: 200,
});
return homeworks.map((item) =>
toHomeworkDto(item, { userIds: student ? personalIds : [], withStatus: !student }),
toHomeworkDto(item, { studentIds: device ? personalIds : [], withStatus: !device }),
);
}
@@ -118,44 +113,48 @@ export async function listHomeworkDays(
.sort((a, b) => a.date.localeCompare(b.date)),
};
}
export async function getHomework(user: TokenPayload, homeworkId: string): Promise<HomeworkDto> {
const student = isStudent(user);
const personalIds = student ? await resolvePersonalIds(user) : [];
const device = isClassDevice(user);
const personalIds = device ? await resolvePersonalIds(user) : [];
const homework = await prisma.homework.findUnique({
where: { id: homeworkId },
include: {
course: courseSelect,
creator: creatorSelect,
statuses: student ? { where: personalIdWhere(personalIds) } : true,
statuses: device ? { where: personalIdWhere(personalIds) } : true,
},
});
if (!homework) throw ApiError.notFound('作业不存在');
await assertClassAccess(user, homework.classId);
return toHomeworkDto(homework, {
userIds: student ? personalIds : [],
withStatus: !student,
studentIds: device ? personalIds : [],
withStatus: !device,
});
}
/* ------------------------------------------------------------------ 发布 / 修改 / 删除 */
/**
* 发布作业并实时推送到班级房间。
*
* 权限:管理员 / 班主任 / 科任老师,**以及本班的班级账号**(教室机器的客户端录入 ——
* 需求就是「支持作业在客户端录入」,老师在教室电脑上顺手就能记一条)。
* 权限走 `assertCanManageSubjectContent`(`allowClassDevice: true`):
* - **选了科目** → 必须是该科的任课老师(班主任不教这一科也发不了);
* - **没选科目** → 本班班主任或管理员;
* - **ClassHelper 班级端** → 可以录(老师在教室电脑上顺手记一条是常见需求,
* 见 AGENTS.md §5 第 20 条),班级端录入的作业归属该班班主任。
*/
export async function createHomework(user: TokenPayload, input: CreateHomeworkInput): Promise<HomeworkDto> {
if (isClassSession(user) && classSessionClassId(user) === input.classId) {
await assertClassAccess(user, input.classId);
} else {
await assertCanPublishContent(user, input.classId);
}
await assertCanManageSubjectContent(user, input.classId, input.courseId ?? null, '布置作业', {
allowClassDevice: true,
});
if (input.courseId) await assertCourseInClass(input.courseId, input.classId);
// 班级账号的 sub 是**班级 id**(不是用户 id),直接写 createdBy 会撞外键;
// ClassHelper 班级端的 sub 是**班级 id**(不是账号 id),直接写 createdBy 会撞外键;
// 教室机器录入的作业归属该班班主任,Web 端看到的作者才是真人。
let createdBy = user.sub;
if (isClassSession(user)) {
if (isClassDevice(user)) {
const owner = await prisma.class.findUnique({
where: { id: input.classId },
select: { teacherId: true },
@@ -190,16 +189,20 @@ export async function updateHomework(
): Promise<HomeworkDto> {
const current = await prisma.homework.findUnique({ where: { id: homeworkId } });
if (!current) throw ApiError.notFound('作业不存在');
await assertCanPublishContent(user, current.classId);
if (input.courseId) await assertCourseInClass(input.courseId, current.classId);
// 改科目等于把这条作业挪到另一个科目的权限域下,两端都要判
const nextCourseId = input.courseId === undefined ? current.courseId : (input.courseId ?? null);
await assertCanManageSubjectContent(user, current.classId, nextCourseId, '修改作业', {
allowClassDevice: true,
});
if (nextCourseId) await assertCourseInClass(nextCourseId, current.classId);
const updated = await prisma.homework.update({
where: { id: homeworkId },
data: {
...(input.title ? { title: input.title } : {}),
...(input.content ? { content: input.content } : {}),
...(input.courseId !== undefined ? { courseId: input.courseId ?? null } : {}),
...(input.courseId !== undefined ? { courseId: nextCourseId } : {}),
...(input.attachmentUrl !== undefined ? { attachmentUrl: input.attachmentUrl ?? null } : {}),
...(input.assignDate !== undefined ? { assignDate: input.assignDate } : {}),
},
@@ -217,7 +220,9 @@ export async function deleteHomework(user: TokenPayload, homeworkId: string): Pr
include: { course: courseSelect, creator: creatorSelect },
});
if (!current) throw ApiError.notFound('作业不存在');
await assertCanPublishContent(user, current.classId);
await assertCanManageSubjectContent(user, current.classId, current.courseId, '删除作业', {
allowClassDevice: true,
});
await prisma.homework.delete({ where: { id: homeworkId } });
emitToClass(current.classId, SOCKET_EVENTS.homeworkUpdated, {
@@ -226,11 +231,12 @@ export async function deleteHomework(user: TokenPayload, homeworkId: string): Pr
});
}
/* ------------------------------------------------------------------ 完成状态 */
/**
* 标记完成 / 取消完成。
* - 普通学生:标记自己;
* - 班级账号(班级设备):为全班学生写入完成状态(代全班操作);
* - 教师 / 管理员:可代指定学生标记。
* - ClassHelper 班级端:为**全班学生**写入完成状态(代全班操作,因此教师端的完成人数依然准确);
* - 教师 / 管理员:必须显式指定该班的学生(学生不是账号,不能落到操作者自己身上)。
*/
export async function updateHomeworkStatus(
user: TokenPayload,
@@ -242,47 +248,45 @@ export async function updateHomeworkStatus(
await assertClassAccess(user, homework.classId);
if (isStudent(user)) {
const personalIds = await resolvePersonalIds(user);
let result: {
id: string;
homeworkId: string;
userId: string;
completed: boolean;
updatedAt: Date;
} | null = null;
if (isClassDevice(user)) {
const studentIds = await resolvePersonalIds(user);
let result: HomeworkStatusDto | null = null;
for (const userId of personalIds) {
for (const studentId of studentIds) {
const status = await prisma.homeworkStatus.upsert({
where: { homeworkId_userId: { homeworkId, userId } },
create: { homeworkId, userId, completed: input.completed },
where: { homeworkId_studentId: { homeworkId, studentId } },
create: { homeworkId, studentId, completed: input.completed },
update: { completed: input.completed },
});
result ??= status;
result ??= toHomeworkStatusDto(status);
}
if (!result) throw ApiError.forbidden('当前班级还没有学生账号,无法标记作业完成状态');
if (!result) throw ApiError.badRequest('当前班级还没有学生,请先在名单里添加学生');
const dto = toHomeworkStatusDto(result);
emitToClass(homework.classId, SOCKET_EVENTS.homeworkStatus, { ...dto, classId: homework.classId });
return dto;
emitToClass(homework.classId, SOCKET_EVENTS.homeworkStatus, {
...result,
classId: homework.classId,
});
return result;
}
// 教师 / 管理员分支:必须落到**该班学生**身上。旧实现允许不传 userId 并默默写到
// 操作者自己的账号上(教师时 user.sub 就是教师 id),那条记录既不在学生名单里,
// 又会被教师视角的 completedCount 计入,导致"完成人数 > 班级人数"。
const targetUserId = input.userId ?? user.sub;
const target = await prisma.user.findUnique({
where: { id: targetUserId },
select: { role: true, classId: true },
// 教师 / 管理员分支:必须落到**该班学生**身上。旧实现允许不传 id 并默默写到操作者自己的账号上
// (教师时 sub 就是教师 id),那条记录既不在学生名单里、又会被 completedCount 计入,
// 导致"完成人数 > 班级人数"。
if (!input.studentId) {
throw ApiError.badRequest('请指定该作业所在班级的学生(studentId)');
}
const target = await prisma.student.findUnique({
where: { id: input.studentId },
select: { classId: true },
});
if (!target || target.role !== 'STUDENT' || target.classId !== homework.classId) {
throw ApiError.badRequest('请指定该作业所在班级的学生(userId),未指定时只能操作自己的账号');
if (!target || target.classId !== homework.classId) {
throw ApiError.badRequest('请指定该作业所在班级的学生(studentId)');
}
const status = await prisma.homeworkStatus.upsert({
where: { homeworkId_userId: { homeworkId, userId: targetUserId } },
create: { homeworkId, userId: targetUserId, completed: input.completed },
where: { homeworkId_studentId: { homeworkId, studentId: input.studentId } },
create: { homeworkId, studentId: input.studentId, completed: input.completed },
update: { completed: input.completed },
});
@@ -293,21 +297,21 @@ export async function updateHomeworkStatus(
/* ------------------------------------------------------------------ 未交名单 */
/** 组装某次作业的全班提交名单(未交名单 = completed=false 的那些) */
/** 组装某次作业的全班提交名单(未交名单 = completed=false 的那些),按学号排序 */
async function buildSubmissions(classId: string, homeworkId: string): Promise<HomeworkSubmissionsDto> {
const [students, statuses] = await Promise.all([
prisma.user.findMany({
where: { classId, role: 'STUDENT' },
select: { id: true, name: true, username: true },
orderBy: { username: 'asc' },
prisma.student.findMany({
where: { classId, archivedYearId: null },
select: { id: true, name: true, studentNo: true },
orderBy: { studentNo: 'asc' },
}),
prisma.homeworkStatus.findMany({ where: { homeworkId }, select: { userId: true, completed: true } }),
prisma.homeworkStatus.findMany({ where: { homeworkId }, select: { studentId: true, completed: true } }),
]);
const completedBy = new Map(statuses.map((item) => [item.userId, item.completed]));
const completedBy = new Map(statuses.map((item) => [item.studentId, item.completed]));
const list: HomeworkSubmissionDto[] = students.map((item) => ({
userId: item.id,
name: item.name || item.username,
username: item.username,
studentId: item.id,
studentNo: item.studentNo,
name: item.name || item.studentNo,
completed: completedBy.get(item.id) === true,
}));
return {
@@ -320,14 +324,14 @@ async function buildSubmissions(classId: string, homeworkId: string): Promise<Ho
};
}
/** 只有教师 / 管理员 / 班级设备可以维护"未交名单"(普通学生只能标记自己) */
/** 只有教师 / 管理员 / ClassHelper 班级端可以维护"未交名单" */
function assertCanManageSubmissions(user: TokenPayload): void {
if (user.role === 'ADMIN' || user.role === 'TEACHER') return;
if (isClassSession(user)) return;
throw ApiError.forbidden('只有教师或班级设备可以维护未交名单');
if (isClassDevice(user)) return;
throw ApiError.forbidden('只有教师或 ClassHelper 班级端可以维护未交名单');
}
/** 读取作业提交名单(教师 / 班级设备) */
/** 读取作业提交名单(教师 / 班级端) */
export async function listHomeworkSubmissions(
user: TokenPayload,
homeworkId: string,
@@ -344,7 +348,7 @@ export async function listHomeworkSubmissions(
/**
* 保存"未交名单":勾选的学生标记未完成,其余学生一律标记完成。
* 这样班级设备的操作语义是"点名谁没交",比逐个勾"谁交了"更省事。
* 这样教室机器的操作语义是"点名谁没交",比逐个勾"谁交了"更省事。
*/
export async function updateHomeworkSubmissions(
user: TokenPayload,
@@ -359,13 +363,13 @@ export async function updateHomeworkSubmissions(
await assertClassAccess(user, homework.classId);
assertCanManageSubmissions(user);
const students = await prisma.user.findMany({
where: { classId: homework.classId, role: 'STUDENT' },
const students = await prisma.student.findMany({
where: { classId: homework.classId, archivedYearId: null },
select: { id: true },
});
if (students.length === 0) throw ApiError.badRequest('当前班级还没有学生账号');
if (students.length === 0) throw ApiError.badRequest('当前班级还没有学生,请先在名单里添加学生');
const notSubmitted = new Set(input.notSubmittedUserIds);
const notSubmitted = new Set(input.notSubmittedStudentIds);
const studentIds = new Set(students.map((item) => item.id));
const outsiders = [...notSubmitted].filter((id) => !studentIds.has(id));
if (outsiders.length > 0) throw ApiError.badRequest('未交名单里包含非本班学生');
@@ -373,15 +377,15 @@ export async function updateHomeworkSubmissions(
await prisma.$transaction(
students.map((item) =>
prisma.homeworkStatus.upsert({
where: { homeworkId_userId: { homeworkId, userId: item.id } },
create: { homeworkId, userId: item.id, completed: !notSubmitted.has(item.id) },
where: { homeworkId_studentId: { homeworkId, studentId: item.id } },
create: { homeworkId, studentId: item.id, completed: !notSubmitted.has(item.id) },
update: { completed: !notSubmitted.has(item.id) },
}),
),
);
const result = await buildSubmissions(homework.classId, homeworkId);
// 实时刷新教师端/学生端的完成情况
// 实时刷新教师端/班级端的完成情况
emitToClass(homework.classId, SOCKET_EVENTS.homeworkStatus, {
homeworkId,
classId: homework.classId,
@@ -1,7 +1,7 @@
import { z } from 'zod';
/** 导入类型:成绩 / 学生名单 / 教师名单(仅管理员) */
export const IMPORT_KINDS = ['grades', 'students', 'teachers'] as const;
/** 导入类型:成绩 / 学生名单 / 教师名单 / 班级任课老师 */
export const IMPORT_KINDS = ['grades', 'students', 'teachers', 'classTeachers'] as const;
/** 表格导入:上传的文件(base64)+ 类型 */
export const tableFileSchema = z.object({
@@ -17,12 +17,17 @@ export const tableFileSchema = z.object({
/** 表格提交:在预览基础上带字段映射、目标班级与写入模式 */
export const tableCommitSchema = tableFileSchema.extend({
/** 目标班级:成绩/学生名单必填;教师名单与班级无关(可省略) */
/** 目标班级:成绩/学生名单必填;教师名单与本班任课老师也可用表格里的班级列 */
classId: z.string().min(1, '请选择班级').optional(),
/** 列映射:规范字段 → 文件列名(或列索引字符串) */
mapping: z.record(z.string(), z.string().min(1)),
/** append = 仅新增;upsert = 已存在则更新(成绩按 学生+考试+课程,名单按用户名) */
/** append = 仅新增;upsert = 已存在则更新(成绩按 学生+考试+课程,名单按学号) */
mode: z.enum(['append', 'upsert']).default('upsert'),
/**
* **班级任课老师导入**专用:是否用「工号」去匹配已有教师账号。
* 打开时优先按工号匹配(匹配不到就按工号新建账号);关闭时只按姓名匹配、匹配不到即报错。
*/
useTeacherNo: z.boolean().optional(),
});
/** ClassIsland 课表时间导入:先校验/预览,再提交 */
@@ -1,11 +1,13 @@
import * as XLSX from 'xlsx';
import { formatClassName, normalizeStudentNo, type TableImportKind } from '@classhelper/shared';
import { prisma } from '../../lib/db.js';
import { env } from '../../config/env.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { logger } from '../../lib/logger.js';
import { hashPassword } from '../../lib/password.js';
import { assertCanManageGrades, assertCanManageRoster } from '../../lib/access.js';
import { resolveUserClassRole } from '../../lib/access.js';
import { canImport } from '@classhelper/shared';
import type { TableCommitInput, TableFileInput } from './imports.schemas.js';
/**
@@ -16,23 +18,32 @@ import type { TableCommitInput, TableFileInput } from './imports.schemas.js';
*
* 异常处理:空文件、超大文件、格式错误、缺列、非法数值、重复数据、未知学生等
* 都会给出明确原因与行号,绝不静默失败。
*
* **学生一律按「学号」匹配**(原「用户名」列已改名):
* 学生不是账号,导入只生成班级名单,不产生任何登录凭证。
*/
const MAX_IMPORT_BYTES = 8 * 1024 * 1024; // 8MB
const PREVIEW_ROWS = 20;
export type ImportKind = TableFileInput['kind'];
interface ImportField {
key: string;
label: string;
required: boolean;
synonyms: string[];
}
/** 规范字段定义(预览、模板、映射提示共用) */
export const IMPORT_FIELDS: Record<
ImportKind,
{ key: string; label: string; required: boolean; synonyms: string[] }[]
> = {
export const IMPORT_FIELDS: Record<ImportKind, ImportField[]> = {
grades: [
{
key: 'username',
label: '学生用户名',
key: 'studentNo',
label: '学生学号',
required: false,
synonyms: ['用户名', 'username', '学号', '账号'],
// 「用户名 / username」保留为兼容别名:老模板导出的文件还能直接导入,
// 但界面上、提示里一律叫「学号」。
synonyms: ['学号', 'studentNo', '用户名', 'username', '账号', '编号'],
},
{ key: 'name', label: '学生姓名', required: false, synonyms: ['姓名', 'name', '学生', '学生姓名'] },
{
@@ -43,16 +54,46 @@ export const IMPORT_FIELDS: Record<
},
{ key: 'score', label: '分数', required: true, synonyms: ['分数', 'score', '成绩', '得分'] },
{ key: 'totalScore', label: '总分', required: false, synonyms: ['总分', 'totalScore', '满分'] },
{ key: 'courseName', label: '课程', required: false, synonyms: ['课程', '科目', 'course', 'courseName'] },
{ key: 'courseName', label: '科目', required: false, synonyms: ['科目', '课程', 'course', 'courseName'] },
{
key: 'level',
label: '等级',
required: false,
synonyms: ['等级', 'level', '档次', '评级'],
},
],
students: [
// 学生只有名单没有账号(不能登录、无密码),模板不再有"初始密码"列
{ key: 'username', label: '用户名', required: true, synonyms: ['用户名', 'username', '学号', '账号'] },
// 学生只有名单没有账号(不能登录、无密码),模板里既没有密码列也不该有
{
key: 'studentNo',
label: '学号',
required: true,
synonyms: ['学号', 'studentNo', '用户名', 'username', '账号', '学籍号', '编号'],
},
{ key: 'name', label: '姓名', required: true, synonyms: ['姓名', 'name', '学生', '学生姓名'] },
{
key: 'className',
label: '班级',
required: false,
synonyms: ['班级', 'class', 'className', '班', '所在班级'],
},
{ key: 'gender', label: '性别', required: false, synonyms: ['性别', 'gender', 'sex'] },
{
key: 'guardianPhone',
label: '家长手机号',
required: false,
synonyms: ['家长手机号', '家长电话', '手机号', '联系电话', 'phone', '电话', '家长联系方式'],
},
],
teachers: [
{ key: 'username', label: '用户名', required: true, synonyms: ['用户名', 'username', '工号', '账号'] },
{
key: 'username',
label: '工号',
required: true,
synonyms: ['工号', '用户名', 'username', '账号', '教师工号'],
},
{ key: 'name', label: '姓名', required: true, synonyms: ['姓名', 'name', '教师', '教师姓名'] },
{ key: 'phone', label: '手机号', required: false, synonyms: ['手机号', '电话', 'phone', '联系方式'] },
{ key: 'password', label: '初始密码', required: false, synonyms: ['密码', 'password', '初始密码'] },
{
key: 'role',
@@ -61,6 +102,39 @@ export const IMPORT_FIELDS: Record<
synonyms: ['角色', 'role', '身份', '权限', '管理员'],
},
],
classTeachers: [
{
key: 'className',
label: '班级',
required: true,
synonyms: ['班级', 'class', 'className', '班', '班级名称'],
},
{
key: 'subjectName',
label: '科目',
required: false,
// 班主任行没有科目;科任行必须有
synonyms: ['科目', '课程', '学科', 'subject', 'subjectName'],
},
{
key: 'teacherNo',
label: '教师工号',
required: true,
synonyms: ['工号', '教师工号', '用户名', 'teacherNo', 'username', '账号'],
},
{
key: 'teacherName',
label: '教师姓名',
required: false,
synonyms: ['姓名', '教师姓名', '教师', 'name', 'teacherName'],
},
{
key: 'role',
label: '角色',
required: true,
synonyms: ['角色', 'role', '身份', '职务'],
},
],
};
export interface TablePreview {
@@ -105,9 +179,7 @@ function parseTable(buffer: Buffer, fileName: string): ParsedTable {
400,
'IMPORT_FORMAT_INVALID',
`文件解析失败:${(error as Error).message}(支持 .xlsx/.xls/.csv)`,
{
fileName,
},
{ fileName },
);
}
const sheetName = workbook.SheetNames[0];
@@ -137,36 +209,37 @@ function parseTable(buffer: Buffer, fileName: string): ParsedTable {
return { columns, rows };
}
/** 各类导入的模板示例行(与 IMPORT_FIELDS 的顺序一一对应) */
const TEMPLATE_SAMPLES: Record<ImportKind, (string | number)[]> = {
grades: ['202601', '王小明', '期中考试', 92, 100, '数学', 'A'],
students: ['202601', '王小明', '2026级1班', '男', '13800000000'],
teachers: ['T1001', '王老师', '13800000000', 'teacher123', '教师'],
classTeachers: ['2026级1班', '数学', 'T1001', '王老师', '科任'],
};
const TEMPLATE_SHEET_NAMES: Record<ImportKind, string> = {
grades: '成绩',
students: '学生名单',
teachers: '教师名单',
classTeachers: '班级任课老师',
};
/** 生成模板 CSV(同时用于预览弹窗的"下载模板") */
export function buildTemplateCsv(kind: ImportKind): string {
const fields = IMPORT_FIELDS[kind];
const header = fields.map((field) => field.label).join(',');
const sample =
kind === 'grades'
? ['student01', '王小明', '期中考试', '92', '100', '数学']
: kind === 'teachers'
? ['teacher3', '王老师', 'teacher123', '教师']
: ['student01', '王小明', 'student123'];
const sampleLine = fields
.map((field) => sample[fields.findIndex((item) => item.key === field.key)] ?? '')
.join(',');
return `\ufeff${header}\n${sampleLine}\n`;
const sample = TEMPLATE_SAMPLES[kind];
const sampleLine = fields.map((_, index) => sample[index] ?? '').join(',');
return `${header}\n${sampleLine}\n`;
}
/** 生成模板 XLSX(Base64),让老师可以直接下载后填写 */
export function buildTemplateXlsx(kind: ImportKind): string {
const fields = IMPORT_FIELDS[kind];
const header = fields.map((field) => field.label);
const sample =
kind === 'grades'
? ['student01', '王小明', '期中考试', 92, 100, '数学']
: kind === 'teachers'
? ['teacher3', '王老师', 'teacher123', '教师']
: ['student01', '王小明', 'student123'];
const sheet = XLSX.utils.aoa_to_sheet([header, sample]);
const sheet = XLSX.utils.aoa_to_sheet([header, TEMPLATE_SAMPLES[kind]]);
const workbook = XLSX.utils.book_new();
const sheetName = kind === 'grades' ? '成绩' : kind === 'teachers' ? '教师名单' : '学生名单';
XLSX.utils.book_append_sheet(workbook, sheet, sheetName);
XLSX.utils.book_append_sheet(workbook, sheet, TEMPLATE_SHEET_NAMES[kind]);
return XLSX.write(workbook, { type: 'base64', bookType: 'xlsx' });
}
@@ -235,20 +308,32 @@ function cellValue(row: string[], columns: string[], mapping: Record<string, str
return (row[index] ?? '').trim();
}
/** 导入成绩:学生按用户名/姓名匹配;重复(同班级+学生+考试+课程)按 mode 处理 */
/** 性别文本归一:男/M/1 → MALE;女/F/2 → FEMALE;其余空 */
function parseGender(value: string): '' | 'MALE' | 'FEMALE' {
const text = value.trim();
if (!text) return '';
if (/^(男|male|m|1)$/i.test(text)) return 'MALE';
if (/^(女|female|f|2)$/i.test(text)) return 'FEMALE';
return '';
}
/* ------------------------------------------------------------------ 成绩 */
/** 导入成绩:学生按**学号**匹配(学号缺失时退化为按姓名匹配) */
export async function commitGrades(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> {
const classId = input.classId;
if (!classId) throw ApiError.badRequest('导入成绩需要先选择班级');
// 成绩导入:管理员或本班班主任(需求 6);科任 403
await assertCanManageGrades(user, classId);
await assertImportAllowed(user, classId, 'grades');
const buffer = decodeBase64(input.contentBase64);
const parsed = parseTable(buffer, input.fileName);
const students = await prisma.user.findMany({
where: { classId, role: 'STUDENT' },
select: { id: true, username: true, name: true },
const students = await prisma.student.findMany({
where: { classId },
select: { id: true, studentNo: true, name: true },
});
const byUsername = new Map(students.map((item) => [item.username.toLowerCase(), item]));
// 学号在库里已归一化为大写,比对前统一一次
const byStudentNo = new Map(students.map((item) => [normalizeStudentNo(item.studentNo), item]));
const byName = new Map(students.map((item) => [item.name.toLowerCase(), item]));
const courses = await prisma.course.findMany({
where: { classId },
@@ -263,22 +348,23 @@ export async function commitGrades(user: TokenPayload, input: TableCommitInput):
for (const [index, row] of parsed.rows.entries()) {
const rowNumber = index + 2; // 含表头,从 Excel 视角的行号
const username = cellValue(row, parsed.columns, input.mapping, 'username');
const studentNo = cellValue(row, parsed.columns, input.mapping, 'studentNo');
const name = cellValue(row, parsed.columns, input.mapping, 'name');
const examName = cellValue(row, parsed.columns, input.mapping, 'examName');
const scoreText = cellValue(row, parsed.columns, input.mapping, 'score');
const totalText = cellValue(row, parsed.columns, input.mapping, 'totalScore');
const courseName = cellValue(row, parsed.columns, input.mapping, 'courseName');
const levelText = cellValue(row, parsed.columns, input.mapping, 'level');
if (!username && !name) {
errors.push({ row: rowNumber, message: '缺少学生标识(用户名与姓名至少填写一个)' });
if (!studentNo && !name) {
errors.push({ row: rowNumber, message: '缺少学生标识(学号与姓名至少填写一个)' });
continue;
}
const student =
(username ? byUsername.get(username.toLowerCase()) : undefined) ??
(studentNo ? byStudentNo.get(normalizeStudentNo(studentNo)) : undefined) ??
(name ? byName.get(name.toLowerCase()) : undefined);
if (!student) {
errors.push({ row: rowNumber, message: `找不到该班学生:${username || name}` });
errors.push({ row: rowNumber, message: `找不到该班学生:${studentNo || name}` });
continue;
}
if (!examName) {
@@ -286,7 +372,7 @@ export async function commitGrades(user: TokenPayload, input: TableCommitInput):
continue;
}
const score = Number(scoreText);
if (!Number.isFinite(score)) {
if (scoreText === '' || !Number.isFinite(score)) {
errors.push({ row: rowNumber, message: `分数「${scoreText}」不是数字` });
continue;
}
@@ -302,10 +388,12 @@ export async function commitGrades(user: TokenPayload, input: TableCommitInput):
const course = courseName ? courses.find((item) => item.name === courseName) : null;
if (courseName && !course)
warnings.push(`第 ${rowNumber} 行:课程「${courseName}」不存在,已按"不指定课程"导入`);
warnings.push(`第 ${rowNumber} 行:科目「${courseName}」不存在,已按"不指定科目"导入`);
// 表格里带等级列就按"自定义等级"落库(教师明确给了等级),否则按百分制换算
const levelType = levelText ? 'custom' : 'percent';
const existing = await prisma.grade.findFirst({
where: { classId: classId, userId: student.id, examName, courseId: course?.id ?? null },
where: { classId, studentId: student.id, examName, courseId: course?.id ?? null },
select: { id: true },
});
@@ -316,18 +404,20 @@ export async function commitGrades(user: TokenPayload, input: TableCommitInput):
}
await prisma.grade.update({
where: { id: existing.id },
data: { score, totalScore, publishedAt: new Date() },
data: { score, totalScore, levelType, level: levelText, publishedAt: new Date() },
});
updated += 1;
} else {
await prisma.grade.create({
data: {
classId: classId,
userId: student.id,
classId,
studentId: student.id,
courseId: course?.id ?? null,
examName,
score,
totalScore,
levelType,
level: levelText,
},
});
inserted += 1;
@@ -350,16 +440,34 @@ export async function commitGrades(user: TokenPayload, input: TableCommitInput):
return result;
}
/** 导入学生名单:按用户名去重(append 跳过 / upsert 更新姓名与班级),仅管理员可用 */
/* ------------------------------------------------------------------ 学生名单 */
/**
* 导入学生名单:**按学号**去重(append 跳过 / upsert 更新姓名、班级、性别、家长手机号)。
*
* 只写 `Student`(名单记录),**不创建任何账号** —— 学生没有密码、不能登录。
* 班级列可选:填了按班级名解析(重名报错),否则用弹窗里选中的班级。
*/
export async function commitStudents(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> {
assertCanManageRoster(user);
const classId = input.classId;
if (!classId) throw ApiError.badRequest('导入学生名单需要先选择班级');
const buffer = decodeBase64(input.contentBase64);
const parsed = parseTable(buffer, input.fileName);
const cls = await prisma.class.findUnique({ where: { id: classId }, select: { id: true } });
if (!cls) throw ApiError.notFound('班级不存在');
// 班级列可用时按名字解析;同名班级(跨年级)会让导入变得含糊,直接报错让老师改用班级码
const hasClassColumn = Boolean(input.mapping.className);
const fallbackClassId = input.classId ?? null;
if (fallbackClassId) await assertImportAllowed(user, fallbackClassId, 'students');
else if (!hasClassColumn) throw ApiError.badRequest('导入学生名单需要选择班级,或在表格里提供「班级」列');
const allClasses = await prisma.class.findMany({
where: { archivedYearId: null },
select: { id: true, name: true, enrollmentYear: true, classIndex: true },
});
const classByName = new Map<string, { id: string; name: string }>();
for (const item of allClasses) {
const key = item.name.toLowerCase();
if (classByName.has(key)) classByName.delete(key); // 重名 → 标记为不可用
else classByName.set(key, item);
}
const errors: ImportRowError[] = [];
const warnings: string[] = [];
@@ -369,40 +477,91 @@ export async function commitStudents(user: TokenPayload, input: TableCommitInput
for (const [index, row] of parsed.rows.entries()) {
const rowNumber = index + 2;
const username = cellValue(row, parsed.columns, input.mapping, 'username');
const studentNoRaw = cellValue(row, parsed.columns, input.mapping, 'studentNo');
const name = cellValue(row, parsed.columns, input.mapping, 'name');
const className = cellValue(row, parsed.columns, input.mapping, 'className');
const gender = parseGender(cellValue(row, parsed.columns, input.mapping, 'gender'));
const guardianPhone = cellValue(row, parsed.columns, input.mapping, 'guardianPhone');
if (!username || !name) {
errors.push({ row: rowNumber, message: '用户名与姓名都必须填写' });
if (!studentNoRaw || !name) {
errors.push({ row: rowNumber, message: '学号与姓名都必须填写' });
continue;
}
if (!/^[A-Za-z0-9_.-]{3,32}$/.test(username)) {
errors.push({ row: rowNumber, message: `用户名「${username}」格式不合法(3~32 位字母/数字/_.-)` });
const studentNo = normalizeStudentNo(studentNoRaw);
if (!/^[A-Za-z0-9._-]{1,32}$/.test(studentNo)) {
errors.push({ row: rowNumber, message: `学号「${studentNoRaw}」格式不合法(1~32 位字母/数字/._-)` });
continue;
}
const existing = await prisma.user.findUnique({ where: { username }, select: { id: true, role: true } });
if (existing) {
if (existing.role !== 'STUDENT') {
errors.push({ row: rowNumber, message: `用户名「${username}」已被教师/管理员账号占用` });
// 目标班级:表格里的班级列优先,其次弹窗选的班级
let classId = fallbackClassId;
if (className) {
const matched = classByName.get(className.toLowerCase());
if (!matched) {
errors.push({
row: rowNumber,
message: `找不到班级「${className}」(可能已毕业归档或名称重复,请核对后在班级管理里查看)`,
});
continue;
}
classId = matched.id;
if (fallbackClassId && fallbackClassId !== matched.id) {
// 表格里写了班级就以表格为准,只是提醒一下
warnings.push(`第 ${rowNumber} 行:按表格里的班级「${className}」导入(与所选班级不同)`);
}
}
if (!classId) {
errors.push({ row: rowNumber, message: '该行没有班级信息,请填写「班级」列或在上一步选择班级' });
continue;
}
await assertImportAllowed(user, classId, 'students');
const existing = await prisma.student.findUnique({
where: { studentNo },
select: { id: true, classId: true },
});
if (existing) {
if (input.mode === 'append') {
skipped += 1;
continue;
}
await prisma.user.update({ where: { id: existing.id }, data: { name, classId: classId } });
if (existing.classId !== classId) {
// 换班要留痕,与手动调班同一种历史
const [from, to] = await Promise.all([
classNameOf(existing.classId),
classNameOf(classId),
]);
await prisma.studentClassTransfer.create({
data: {
studentId: existing.id,
studentNo,
studentName: name,
fromClassId: existing.classId,
fromClassName: from,
toClassId: classId,
toClassName: to,
operatorId: user.sub,
operatorName: user.name,
mode: 'batch',
note: '学生名单导入',
},
});
}
await prisma.student.update({
where: { id: existing.id },
data: {
name,
classId,
status: 'active',
...(gender ? { gender } : {}),
...(guardianPhone ? { guardianPhone } : {}),
},
});
updated += 1;
} else {
await prisma.user.create({
data: {
username,
name,
role: 'STUDENT',
classId: classId,
// 学生不设密码(空串占位,登录 403 判定在密码校验之前),见"学生只有名单没有账号"
passwordHash: '',
},
await prisma.student.create({
data: { studentNo, name, classId, gender, guardianPhone },
});
inserted += 1;
}
@@ -419,19 +578,19 @@ export async function commitStudents(user: TokenPayload, input: TableCommitInput
warnings,
};
logger.info(
`学生名单导入:班级=${classId} 模式=${input.mode} 新增=${inserted} 更新=${updated} 跳过=${skipped} 失败=${errors.length}`,
`学生名单导入:模式=${input.mode} 新增=${inserted} 更新=${updated} 跳过=${skipped} 失败=${errors.length}`,
);
return result;
}
/* ------------------------------------------------------------------ 教师名单 */
/**
* 导入教师名单:按用户名去重(append 跳过 / upsert 更新姓名与角色),**仅管理员**可用。
*
* 与班级无关(教师不属于班级),因此不需要 classId,模板里也没有班级列。
* 角色列可选:填「管理员 / ADMIN / admin」建管理员账号,其余一律建教师账号。
* 导入教师名单:按**工号**去重(append 跳过 / upsert 更新姓名与角色),**仅管理员**可用。
* 与班级无关(教师不属于班级),因此不需要 classId。
*/
export async function commitTeachers(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> {
assertCanManageRoster(user);
await assertImportAllowed(user, undefined, 'teachers');
const buffer = decodeBase64(input.contentBase64);
const parsed = parseTable(buffer, input.fileName);
@@ -445,30 +604,30 @@ export async function commitTeachers(user: TokenPayload, input: TableCommitInput
const rowNumber = index + 2;
const username = cellValue(row, parsed.columns, input.mapping, 'username');
const name = cellValue(row, parsed.columns, input.mapping, 'name');
const phone = cellValue(row, parsed.columns, input.mapping, 'phone');
const password = cellValue(row, parsed.columns, input.mapping, 'password');
const roleCell = cellValue(row, parsed.columns, input.mapping, 'role');
if (!username || !name) {
errors.push({ row: rowNumber, message: '用户名与姓名都必须填写' });
errors.push({ row: rowNumber, message: '工号与姓名都必须填写' });
continue;
}
if (!/^[A-Za-z0-9_.-]{3,32}$/.test(username)) {
errors.push({ row: rowNumber, message: `用户名「${username}」格式不合法(3~32 位字母/数字/_.-)` });
errors.push({ row: rowNumber, message: `工号「${username}」格式不合法(3~32 位字母/数字/._-)` });
continue;
}
const existing = await prisma.user.findUnique({ where: { username }, select: { id: true, role: true } });
if (existing) {
if (existing.role === 'STUDENT') {
errors.push({ row: rowNumber, message: `用户名「${username}」已被学生账号占用` });
continue;
}
if (input.mode === 'append') {
skipped += 1;
continue;
}
const nextRole = parseTeacherRole(roleCell) ?? existing.role;
await prisma.user.update({ where: { id: existing.id }, data: { name, role: nextRole } });
await prisma.user.update({
where: { id: existing.id },
data: { name, role: nextRole, ...(phone ? { phone } : {}) },
});
updated += 1;
} else {
const initialPassword = password || env.defaultTeacherPassword;
@@ -476,6 +635,7 @@ export async function commitTeachers(user: TokenPayload, input: TableCommitInput
data: {
username,
name,
phone,
role: parseTeacherRole(roleCell) ?? 'TEACHER',
passwordHash: await hashPassword(initialPassword),
},
@@ -494,12 +654,216 @@ export async function commitTeachers(user: TokenPayload, input: TableCommitInput
errors,
warnings,
};
logger.info(`教师名单导入:模式=${input.mode} 新增=${inserted} 更新=${updated} 跳过=${skipped} 失败=${errors.length}`);
return result;
}
/* ------------------------------------------------------------------ 班级任课老师 */
/**
* 导入班级任课老师(班级 + 科目 + 工号 + 姓名 + 角色),**仅管理员**。
*
* 角色列:
* - 「班主任」→ 写 `Class.teacherId`(每班 1 人,后导入的覆盖先导入的);
* - 「科任」 → 写 `Course.teacherId`(该班该科的任课老师,没有该科目就自动建课)。
*
* **不产生重复教师**:同一工号在表格里出现多次(班主任行 + 科任行)时,
* 后面几行指向的还是同一个账号(`useTeacherNo` 打开时按工号匹配,关闭时按姓名唯一匹配),
* 因此"班主任同时是科任老师"只会有一条教师记录,权限在 `resolveClassRole` 里合并。
*/
export async function commitClassTeachers(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> {
await assertImportAllowed(user, undefined, 'classTeachers');
const buffer = decodeBase64(input.contentBase64);
const parsed = parseTable(buffer, input.fileName);
const useTeacherNo = input.useTeacherNo !== false; // 默认开:有工号就优先按工号匹配
const errors: ImportRowError[] = [];
const warnings: string[] = [];
let inserted = 0;
let updated = 0;
let skipped = 0;
const classes = await prisma.class.findMany({
where: { archivedYearId: null },
select: { id: true, name: true, teacherId: true },
});
const classByName = new Map<string, { id: string; name: string; teacherId: string }>();
for (const item of classes) {
const key = item.name.toLowerCase();
if (classByName.has(key)) classByName.delete(key);
else classByName.set(key, item);
}
// 本次导入新建的教师账号(同一份表格里再遇到同名/同工号时复用,避免重复建号)
const createdTeachers = new Map<string, string>();
for (const [index, row] of parsed.rows.entries()) {
const rowNumber = index + 2;
const className = cellValue(row, parsed.columns, input.mapping, 'className');
const subjectName = cellValue(row, parsed.columns, input.mapping, 'subjectName');
const teacherNo = cellValue(row, parsed.columns, input.mapping, 'teacherNo');
const teacherName = cellValue(row, parsed.columns, input.mapping, 'teacherName');
const roleCell = cellValue(row, parsed.columns, input.mapping, 'role');
if (!className || !teacherNo) {
errors.push({ row: rowNumber, message: '班级与教师工号都必须填写' });
continue;
}
const targetClass = classByName.get(className.toLowerCase());
if (!targetClass) {
errors.push({ row: rowNumber, message: `找不到班级「${className}」(可能已毕业归档或名称重复)` });
continue;
}
const role = parseClassTeacherRole(roleCell);
if (!role) {
errors.push({ row: rowNumber, message: `角色「${roleCell}」无法识别,请填「班主任」或「科任」` });
continue;
}
if (role === 'SUBJECT' && !subjectName) {
errors.push({ row: rowNumber, message: '科任老师必须填写科目' });
continue;
}
// ---- 匹配或新建教师账号
let teacherId = createdTeachers.get(teacherNo.toLowerCase());
if (!teacherId) {
if (useTeacherNo) {
const byNo = await prisma.user.findUnique({
where: { username: teacherNo },
select: { id: true },
});
if (byNo) {
teacherId = byNo.id;
}
} else if (teacherName) {
const byName = await prisma.user.findMany({
where: { name: teacherName, role: { in: ['TEACHER', 'ADMIN'] } },
select: { id: true },
take: 2,
});
if (byName.length === 1) {
teacherId = byName[0]!.id;
} else if (byName.length > 1) {
errors.push({
row: rowNumber,
message: `姓名「${teacherName}」对应多个教师账号,无法确定是哪一个;请打开「使用工号匹配」再导入`,
});
continue;
}
}
}
if (!teacherId) {
if (!useTeacherNo) {
errors.push({
row: rowNumber,
message: `按姓名找不到教师「${teacherName || teacherNo}」;请打开「使用工号匹配」以便按工号新建账号`,
});
continue;
}
const created = await prisma.user.create({
data: {
username: teacherNo,
name: teacherName || teacherNo,
role: 'TEACHER',
passwordHash: await hashPassword(env.defaultTeacherPassword),
},
select: { id: true },
});
teacherId = created.id;
inserted += 1;
warnings.push(
`第 ${rowNumber} 行:工号 ${teacherNo} 没有对应账号,已新建教师「${teacherName || teacherNo}」(初始密码见部署配置)`,
);
}
createdTeachers.set(teacherNo.toLowerCase(), teacherId);
// ---- 写入角色
if (role === 'HEAD') {
if (targetClass.teacherId === teacherId) {
skipped += 1;
continue;
}
await prisma.class.update({ where: { id: targetClass.id }, data: { teacherId } });
targetClass.teacherId = teacherId;
updated += 1;
} else {
const existing = await prisma.course.findUnique({
where: { classId_name: { classId: targetClass.id, name: subjectName } },
select: { id: true, teacherId: true },
});
if (existing) {
if (existing.teacherId === teacherId) {
skipped += 1;
continue;
}
await prisma.course.update({ where: { id: existing.id }, data: { teacherId } });
} else {
await prisma.course.create({
data: { name: subjectName, classId: targetClass.id, teacherId },
});
}
updated += 1;
}
}
const result: ImportResult = {
kind: 'classTeachers',
total: parsed.rows.length,
inserted,
updated,
skipped,
failed: errors.length,
errors,
warnings,
};
logger.info(
`教师名单导入:模式=${input.mode} 新增=${inserted} 更新=${updated} 跳过=${skipped} 失败=${errors.length}`,
`班级任课老师导入:用工号匹配=${useTeacherNo} 新建教师=${inserted} 更新关系=${updated} 跳过=${skipped} 失败=${errors.length}`,
);
return result;
}
export async function commitTable(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> {
if (input.kind === 'teachers') return commitTeachers(user, input);
if (input.kind === 'classTeachers') return commitClassTeachers(user, input);
if (input.kind === 'grades') return commitGrades(user, input);
return commitStudents(user, input);
}
/** 供预览接口复用的模板生成(Web 端「下载模板」) */
export function templateFor(kind: TableImportKind): { csv: string; xlsx: string } {
return { csv: buildTemplateCsv(kind), xlsx: buildTemplateXlsx(kind) };
}
/* ------------------------------------------------------------------ 内部工具 */
/** 导入权限:与学生名单/成绩同一套口径(见 shared 的 canImport) */
async function assertImportAllowed(
user: TokenPayload,
classId: string | undefined,
kind: 'grades' | 'students' | 'teachers' | 'classTeachers' | 'scheduleTimes',
): Promise<void> {
const classRole = classId ? await resolveUserClassRole(user, classId) : 'NONE';
if (canImport(user.role, classRole, kind)) return;
if (kind === 'students' || kind === 'grades') {
throw ApiError.forbidden(
classRole === 'SUBJECT' || classRole === 'CLASS_DEVICE'
? '科任老师与 ClassHelper 班级端不能导入名单或成绩,请联系班主任或管理员'
: '只能导入自己担任班主任的班级的数据',
);
}
throw ApiError.forbidden('该导入仅限管理员使用');
}
async function classNameOf(classId: string | null): Promise<string> {
if (!classId) return '';
const record = await prisma.class.findUnique({ where: { id: classId }, select: { name: true } });
return record?.name ?? '';
}
/** 角色列解析:只认「管理员」类词;其余(含空)返回 null 交给调用方取默认值 */
function parseTeacherRole(cell: string): 'TEACHER' | 'ADMIN' | null {
const value = cell.trim().toUpperCase();
@@ -508,8 +872,14 @@ function parseTeacherRole(cell: string): 'TEACHER' | 'ADMIN' | null {
return 'TEACHER';
}
export async function commitTable(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> {
if (input.kind === 'teachers') return commitTeachers(user, input);
if (input.kind === 'grades') return commitGrades(user, input);
return commitStudents(user, input);
/** 班级任课老师的角色列:班主任 / 科任 */
function parseClassTeacherRole(cell: string): 'HEAD' | 'SUBJECT' | null {
const value = cell.trim();
if (!value) return null;
if (value.includes('班主任') || value.toUpperCase() === 'HEAD') return 'HEAD';
if (value.includes('科任') || value.includes('任课') || value.toUpperCase() === 'SUBJECT') return 'SUBJECT';
return null;
}
/** 导出:把班级名与入学年份生成统一称呼(供未来导出功能复用) */
export { formatClassName };
@@ -1,8 +1,10 @@
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { ApiError, 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 * as gradeService from '../grades/grades.service.js';
import { studentGradeQuerySchema } from '../grades/grades.schemas.js';
import { authenticateDevice, assertDeviceEnabled, getDevice } from './device-auth.js';
import {
ackNotificationSchema,
@@ -70,6 +72,38 @@ router.get('/classisland/class-plan', authenticateDevice(), async (req, res) =>
sendOk(res, await integrationService.pullClassPlanForDevice(device), '获取课表成功');
});
/**
* GET /api/integrations/classisland/student-grades?studentNo= - 班级端按学号查成绩明细
*
* 给"插件形态的 ClassHelper 班级端"留的通道:走设备令牌而不是 JWT。
* 受**同一个班级开关**约束(`Class.studentGradeQueryEnabled`),与桌面客户端走的是同一条 service。
*/
router.get(
'/classisland/student-grades',
authenticateDevice(),
validate({ query: studentGradeQuerySchema }),
async (req, res) => {
const device = getDevice(req);
assertDeviceEnabled(device);
const { studentNo } = validatedQuery<{ studentNo?: string }>(req);
if (!studentNo) throw ApiError.badRequest('请提供学号(studentNo)');
// 设备令牌没有"账号",构造一个等价于 ClassHelper 班级端的会话载荷
const session = {
sub: device.classId,
username: '',
name: '',
role: 'CLASS_DEVICE' as const,
classId: device.classId,
classSession: true,
};
sendOk(
res,
await gradeService.getStudentGradeDetail(session, studentNo, device.classId),
'获取学生成绩明细成功',
);
},
);
/* ================================================================== Web 管理端
* 用 JWT 鉴权;权限与"课表管理 / 发通知"保持一致,由 service 内二次校验班级维度角色。
*/
@@ -91,6 +125,16 @@ router.get(
},
);
/**
* GET /api/integrations/classisland/status - **ClassHelper 班级端在线状态**(教师网页)
*
* 管理员看全部班级;普通教师看自己担任班主任的班级 ∪ 任课的班级。
* 每条含:班级、设备名/设备号、在线/离线、最后在线时间、最后心跳时间。
*/
router.get('/classisland/status', requireRole('ADMIN', 'TEACHER'), async (req, res) => {
sendOk(res, await integrationService.listClassHelperStatus(getAuthUser(req)), '获取在线状态成功');
});
/** POST /api/integrations/devices - 新建设备并一次性返回明文令牌(管理员或本班班主任) */
router.post(
'/devices',
@@ -120,6 +164,25 @@ router.patch(
},
);
/**
* POST /api/integrations/devices/:id/request-schedule - 「从教室机器获取一次课表」
*
* **这是现在取教室课表的唯一入口**(原来的自动回传已默认关闭,见
* `IntegrationDevice.syncScheduleToServer`)。Web 端点击前会弹警告对话框,
* 说明它会用教室机器的课表覆盖本班现有课表。
* 置一个待办标记,插件在下一次心跳看到就立刻把课表推上来。
*/
router.post(
'/devices/:id/request-schedule',
requireRole('ADMIN', 'TEACHER'),
validate({ params: deviceIdParamSchema }),
async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
const result = await integrationService.requestScheduleReport(getAuthUser(req), id);
sendOk(res, result, result.nextHeartbeatHint);
},
);
/** POST /api/integrations/devices/:id/token - 重置令牌(旧令牌立即失效) */
router.post(
'/devices/:id/token',
@@ -22,9 +22,12 @@ export const createDeviceSchema = z.object({
classId: z.string().min(1, '请选择班级'),
name: z.string().trim().max(40).optional(),
mode: integrationModeSchema.optional(),
/** 是否允许插件把 ClassIsland 的课表回传到本服务 */
/**
* 是否允许插件把 ClassIsland 的课表**自动**回传到本服务。
* 默认 **false**(课表以服务端为准);要取教室的课表用「从教室机器获取课表」这个手动动作。
*/
syncScheduleToServer: z.boolean().optional(),
/** 是否允许把本班课表镜像回 ClassIsland */
/** 是否允许把本班课表镜像回 ClassIsland。默认 **true** —— 这是现在课表同步的主方向。 */
mirrorScheduleToClassIsland: z.boolean().optional(),
});
@@ -1,8 +1,10 @@
import {
CLASS_HELPER_ONLINE_WINDOW_MS,
SOCKET_EVENTS,
resolveCurrentWeek,
weekDivFromParity,
type ClassDto,
type ClassHelperDeviceStatusDto,
type ClassHelperStatusListDto,
type ClassIslandPendingResult,
type ClassIslandPushNotification,
type ClassIslandReportResult,
@@ -13,8 +15,8 @@ import {
type TimeLayoutEntry,
type NotificationDto,
} from '@classhelper/shared';
import { env } from '../../config/env.js';
import { assertCanManageSchedule, assertCanPublishContent, resolveClassScope } from '../../lib/access.js';
import { assertCanManageSchedule, assertCanPublishNotification, resolveClassScope } from '../../lib/access.js';
import { loadTermContext, resolveCurrentWeek } from '../../lib/term.js';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
@@ -54,7 +56,13 @@ import {
/* ------------------------------------------------------------------ 设备管理(Web 端,JWT 鉴权) */
/** 设备记录 -> DTO(tokenHash 永不出库) */
/**
* 设备记录 -> DTO(tokenHash 永不出库)。
*
* `online` 由**最后一次心跳**(`lastHeartbeatAt`)与共享的
* `CLASS_HELPER_ONLINE_WINDOW_MS`(60 秒)算出,而不是最后一次带状态的上报 ——
* 插件每 60 秒打一次心跳,因此"最近一分钟内有心跳"才是最贴合的口径。
*/
function toDeviceDto(record: {
id: string;
classId: string;
@@ -64,9 +72,11 @@ function toDeviceDto(record: {
enabled: boolean;
syncScheduleToServer: boolean;
mirrorScheduleToClassIsland: boolean;
requestScheduleReport: boolean;
classIslandVersion: string | null;
pluginVersion: string | null;
lastSeenAt: Date | null;
lastHeartbeatAt: Date | null;
classPlanLoaded: boolean;
currentSubject: string | null;
currentTimeState: string | null;
@@ -78,6 +88,7 @@ function toDeviceDto(record: {
updatedAt: Date;
class?: { name: string } | null;
}): IntegrationDeviceDto {
const heartbeat = record.lastHeartbeatAt ?? record.lastSeenAt;
return {
id: record.id,
classId: record.classId,
@@ -88,9 +99,12 @@ function toDeviceDto(record: {
enabled: record.enabled,
syncScheduleToServer: record.syncScheduleToServer,
mirrorScheduleToClassIsland: record.mirrorScheduleToClassIsland,
requestScheduleReport: record.requestScheduleReport,
classIslandVersion: record.classIslandVersion,
pluginVersion: record.pluginVersion,
lastSeenAt: record.lastSeenAt ? record.lastSeenAt.toISOString() : null,
lastHeartbeatAt: heartbeat ? heartbeat.toISOString() : null,
online: Boolean(record.enabled && heartbeat && Date.now() - heartbeat.getTime() < CLASS_HELPER_ONLINE_WINDOW_MS),
classPlanLoaded: record.classPlanLoaded,
currentSubject: record.currentSubject,
currentTimeState: record.currentTimeState,
@@ -103,6 +117,74 @@ function toDeviceDto(record: {
};
}
/**
* GET /api/integrations/classisland/status —— **ClassHelper 班级端在线状态**(教师网页)。
*
* 范围:管理员看全部班级,普通教师看自己担任班主任的班级 ∪ 任课(有 Course)的班级。
* 这是「教师网页可查看班级 ClassHelper 在线状态」这条需求的落点。
*/
export async function listClassHelperStatus(user: TokenPayload): Promise<ClassHelperStatusListDto> {
const scope = await resolveClassScope(user);
const classes = await prisma.class.findMany({
where: {
...(scope.mode === 'all' ? {} : { id: { in: scope.classIds } }),
// 归档班级的班级端已经停用,不列出来
archivedYearId: null,
},
orderBy: [{ enrollmentYear: 'desc' }, { classIndex: 'asc' }],
select: {
id: true,
name: true,
grade: true,
studentGradeQueryEnabled: true,
integrationDevices: {
where: { mode: 'plugin' },
orderBy: [{ lastHeartbeatAt: 'desc' }],
select: {
id: true,
name: true,
deviceKey: true,
enabled: true,
lastSeenAt: true,
lastHeartbeatAt: true,
pluginVersion: true,
classIslandVersion: true,
},
},
},
});
const now = Date.now();
return {
serverTime: new Date().toISOString(),
onlineWindowMs: CLASS_HELPER_ONLINE_WINDOW_MS,
classes: classes.map((item) => {
const devices: ClassHelperDeviceStatusDto[] = item.integrationDevices.map((device) => {
const heartbeat = device.lastHeartbeatAt ?? device.lastSeenAt;
return {
deviceId: device.id,
deviceName: device.name,
deviceKey: device.deviceKey,
enabled: device.enabled,
online: Boolean(device.enabled && heartbeat && now - heartbeat.getTime() < CLASS_HELPER_ONLINE_WINDOW_MS),
lastSeenAt: device.lastSeenAt ? device.lastSeenAt.toISOString() : null,
lastHeartbeatAt: heartbeat ? heartbeat.toISOString() : null,
pluginVersion: device.pluginVersion,
classIslandVersion: device.classIslandVersion,
};
});
return {
classId: item.id,
className: item.name,
grade: item.grade,
devices,
online: devices.some((device) => device.online),
studentGradeQueryEnabled: item.studentGradeQueryEnabled ?? true,
};
}),
};
}
export async function listDevices(user: TokenPayload, classId?: string): Promise<IntegrationDeviceDto[]> {
// 管理员不传 classId 时看全部;教师只看自己可访问的班级
const scope = await resolveClassScope(user, classId);
@@ -134,13 +216,15 @@ export async function createDevice(
const created = await prisma.integrationDevice.create({
data: {
classId: input.classId,
name: input.name?.trim() || `${classRecord.name} ClassIsland 设备`,
name: input.name?.trim() || `${classRecord.name} ClassHelper 班级端`,
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,
// 同步方向(2026-10-06 起):课表以服务端为准,**服务端 → 教室自动下发**;
// 「把教室课表传上来」改成 Web 端的一次性人工动作,所以默认关掉自动回传。
syncScheduleToServer: input.syncScheduleToServer === true,
mirrorScheduleToClassIsland: input.mirrorScheduleToClassIsland !== false,
},
include: { class: { select: { name: true } } },
});
@@ -153,10 +237,11 @@ async function findDeviceOrFail(id: string): Promise<{
id: string;
classId: string;
name: string;
enabled: boolean;
}> {
const record = await prisma.integrationDevice.findUnique({
where: { id },
select: { id: true, classId: true, name: true },
select: { id: true, classId: true, name: true, enabled: true },
});
if (!record) throw ApiError.notFound('设备不存在或已被删除');
return record;
@@ -189,6 +274,37 @@ export async function updateDevice(
return toDeviceDto(updated);
}
/**
* 「**从教室机器获取一次课表**」的待办(Web 端手动动作)。
*
* 为什么是"待办"而不是直接拉:课表由**插件主动上报**,服务端没有反向通道去催它。
* 所以这里只置一个标记,插件在下一次上报(默认 60 秒一次的心跳)看到
* `settings.scheduleRequested = true` 就立刻把当前课表推上来并完成待办。
*
* 它独立于 `syncScheduleToServer`:后者是"允许自动回传",现在默认**关闭**
* (自动回传会在老师手排课之后被教室的旧课表悄悄覆盖);
* 管理员显式点"获取课表"时仍然应当能取到一次。
*/
export async function requestScheduleReport(
user: TokenPayload,
deviceId: string,
): Promise<{ requested: boolean; deviceName: string; nextHeartbeatHint: string }> {
const current = await findDeviceOrFail(deviceId);
await assertCanManageSchedule(user, current.classId);
if (!current.enabled) throw ApiError.badRequest('该设备已停用,请先启用后再获取课表');
await prisma.integrationDevice.update({
where: { id: deviceId },
data: { requestScheduleReport: true },
});
logger.info(`已请求设备上报课表:${current.name}(classId=${current.classId})由 ${user.name} 发起`);
return {
requested: true,
deviceName: current.name,
nextHeartbeatHint: '教室机器会在下一次心跳(约 1 分钟内)把当前课表上报上来',
};
}
/** 重置令牌:旧令牌立即失效(插件侧需要重新填写) */
export async function resetDeviceToken(
user: TokenPayload,
@@ -251,14 +367,14 @@ function toPushDto(
*
* - 落库(ClassIslandPush)→ 在线设备通过 Socket.IO 立即收到;离线设备重连后走
* `GET /api/integrations/classisland/pending` 补齐,保证"老师按了发送就一定送到";
* - `saveToNotifications` 默认 true:同时写一条班级通知,学生端通知中心/灵动岛也能看到,
* - `saveToNotifications` 默认 true:同时写一条班级通知,ClassHelper 班级端通知中心/灵动岛也能看到,
* 避免"只在 ClassIsland 上弹了一下,事后查无此事"。
*/
export async function sendNotification(
user: TokenPayload,
input: SendNotificationInput,
): Promise<SendClassIslandNotificationResult> {
await assertCanPublishContent(user, input.classId);
await assertCanPublishNotification(user, input.classId);
const teacher = await prisma.user.findUnique({
where: { id: user.sub },
@@ -286,8 +402,8 @@ export async function sendNotification(
include: { creator: { select: { id: true, name: true, username: true } }, reads: true },
});
notificationId = created.id;
const dto: NotificationDto = toNotificationDto(created, { userId: user.sub, withStatus: true });
// 学生端(桌面客户端 / 班级设备)照旧收到通知中心的实时推送
const dto: NotificationDto = toNotificationDto(created, { withStatus: true });
// ClassHelper 班级端照旧收到通知中心的实时推送
emitToClass(input.classId, SOCKET_EVENTS.notificationNew, dto);
}
@@ -420,17 +536,30 @@ export async function applyReport(
input: ClassIslandReportInput,
): Promise<ClassIslandReportResult> {
const now = new Date();
const week = resolveCurrentWeek(env.termStartDate, now);
// 周次口径按**本班**算(管理员可以给每个班单独配开学日期与逐周区间)
const week = resolveCurrentWeek(now, await loadTermContext(device.classId));
// 0) 机器码回填(设备创建时只能用 pending-… 占位,首次上报才知道它到底是哪台机器)
if (input.deviceKey) await syncDeviceKey(device, input.deviceKey);
// 1) 状态快照(无论课表是否上报都更新,Web 端据此显示"现在上什么课")
//
// 心跳与"带状态的上报"分开记:`lastHeartbeatAt` 每次上报都刷(在线判定看它),
// `lastSeenAt` 只在确实带了运行状态时刷 —— 这样"连得上但一直没上报状态"的设备
// 不会被误判成一切正常。
const state = input.state;
const currentDevice = await prisma.integrationDevice.findUnique({
where: { id: device.deviceId },
select: { requestScheduleReport: true },
});
const scheduleRequested = currentDevice?.requestScheduleReport === true;
await prisma.integrationDevice.update({
where: { id: device.deviceId },
data: {
lastSeenAt: now,
lastHeartbeatAt: now,
// 这次真的带了课表上来 → 待办完成
...(input.schedule ? { requestScheduleReport: false } : {}),
...(state ? { lastSeenAt: now } : {}),
...(input.pluginVersion ? { pluginVersion: input.pluginVersion } : {}),
...(input.classIslandVersion ? { classIslandVersion: input.classIslandVersion } : {}),
...(state
@@ -516,6 +645,10 @@ export async function applyReport(
settings: {
mirrorScheduleToClassIsland: device.mirrorScheduleToClassIsland,
enabled: device.enabled,
// 「从教室机器取一次课表」的待办:Web 端点过之后置位,插件看到就立刻推一次课表上来。
// 注意它**独立于** syncScheduleToServer —— 后者是"允许自动回传",现在默认关闭,
// 但管理员显式点"获取课表"时仍然应当能取到。
scheduleRequested: scheduleRequested,
},
pendingNotification: pending,
};
@@ -588,7 +721,7 @@ export async function listPendingNotifications(device: DeviceContext): Promise<C
return {
notifications: records.map((item) => toPushDto(item)),
serverTime: now.toISOString(),
week: resolveCurrentWeek(env.termStartDate, now),
week: resolveCurrentWeek(now, await loadTermContext(device.classId)),
};
}
@@ -613,14 +746,18 @@ export async function ackNotification(device: DeviceContext, id: string): Promis
export async function pullClassPlanForDevice(device: DeviceContext): Promise<{
classPlan: MirrorClassPlan;
week: number;
/** 学期周次区间(管理员逐周配置的);空数组 = 按学期开始日期线性推算 */
weekRanges: Array<{ weekNumber: number; startDate: string; endDate: string }>;
serverTime: string;
}> {
const now = new Date();
const week = resolveCurrentWeek(env.termStartDate, now);
// 周次口径按**本班**算:管理员可以给每个班单独配开学日期与逐周区间(调休、错峰开学)
const termContext = await loadTermContext(device.classId);
const week = resolveCurrentWeek(now, termContext);
const classRecord = await prisma.class.findUnique({
where: { id: device.classId },
select: { id: true, name: true, termWeeks: true },
select: { id: true, name: true, termWeeks: true, termStartDate: true },
});
if (!classRecord) throw ApiError.notFound('班级不存在');
@@ -638,7 +775,7 @@ export async function pullClassPlanForDevice(device: DeviceContext): Promise<{
// 节次时间表:优先用班级里已导入的 ClassIsland 时间表,其次按课表的开始时间合成
const layoutId = `ch-${device.classId}`;
const layoutName = timeLayouts[0]?.name ?? '班级小助手时间表';
const layoutName = timeLayouts[0]?.name ?? 'ClassHelper时间表';
let layouts: { startTime: string; endTime: string; timeType: number }[] = [];
if (timeLayouts.length > 0 && timeLayouts[0]) {
layouts = parseStoredTimeLayout(timeLayouts[0].items).map((item) => ({
@@ -669,14 +806,31 @@ export async function pullClassPlanForDevice(device: DeviceContext): Promise<{
classPlan: {
entries,
timeLayouts: [{ id: layoutId, name: layoutName, layouts }],
profileName: `班级小助手-${classRecord.name}`,
termStartDate: env.termStartDate,
profileName: `ClassHelper-${classRecord.name}`,
// 下发本班实际生效的开学日期(管理员可以为某个班单独配,优先级高于全局 .env)
termStartDate: termContext.termStartDate,
},
week,
weekRanges: termContext.weeks,
serverTime: now.toISOString(),
};
}
/**
* 「课表/周次变了,请重新镜像」的轻量通知。
*
* 插件是**轮询**镜像的(不订阅 Socket.IO),所以这里只做两件事:
* 广播给 Web 端让页面刷新,以及把该班的设备标记为"有新版本"。
* 真正的下发发生在插件下一次拉取课表时 —— 它会把新的开学日期与周次区间一并带走。
*/
export async function notifyScheduleChanged(classId: string): Promise<void> {
emitToRooms([SOCKET_ROOMS.class(classId), SOCKET_ROOMS.teachers], SOCKET_EVENTS.scheduleUpdated, {
classId,
action: 'imported',
});
logger.info(`课表/周次已变更,等待教室机器下次镜像时下发:classId=${classId}`);
}
/** 解析库里存的节次 JSON(容错:坏数据不抛异常,退化为空) */
function parseStoredTimeLayout(raw: string): { startTime: string; endTime: string; type: string }[] {
try {
@@ -702,7 +856,7 @@ function parseStoredTimeLayout(raw: string): { startTime: string; endTime: strin
* 没有导入节次时间表时,用课表里出现过的起止时间合成一份(去重、按时间排序)。
*
* **每节课之间补一个课间**(timeType=1:上一节下课 → 下一节上课),最后一节之后不补(放学)。
* 班级小助手这边的节次时间表与 ClassIsland 档案保持同一口径,镜像过去才不会缺课间。
* ClassHelper这边的节次时间表与 ClassIsland 档案保持同一口径,镜像过去才不会缺课间。
*/
function synthesizeTimeLayout(
schedules: { startTime: string; endTime: string }[],
@@ -1,7 +1,7 @@
import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared';
import {
assertClassAccess,
assertCanPublishContent,
assertCanPublishNotification,
classScopeWhere,
resolveClassScope,
} from '../../lib/access.js';
@@ -52,7 +52,7 @@ export async function listNotifications(
take: 200,
});
return notifications.map((item) => toNotificationDto(item, { userIds: personalIds, withStatus: isStaff }));
return notifications.map((item) => toNotificationDto(item, { studentIds: personalIds, withStatus: isStaff }));
}
/**
@@ -66,7 +66,7 @@ export async function createNotification(
user: TokenPayload,
input: CreateNotificationInput,
): Promise<NotificationDto> {
await assertCanPublishContent(user, input.classId);
await assertCanPublishNotification(user, input.classId);
const priority = input.priority ?? 'NORMAL';
if (priority === 'URGENT' && input.confirmDuringClass !== true) {
@@ -96,7 +96,7 @@ export async function createNotification(
include: { creator: creatorSelect, reads: true },
});
const dto = toNotificationDto(created, { userId: user.sub, withStatus: true });
const dto = toNotificationDto(created, { withStatus: true });
emitToClass(created.classId, SOCKET_EVENTS.notificationNew, dto);
// 同一条通知也推到教室的 ClassIsland 上("老师发通知 → 教室大屏弹出"不该只在联动页生效)。
@@ -152,7 +152,7 @@ async function pushToClassIslandIfEnabled(input: {
export async function deleteNotification(user: TokenPayload, notificationId: string): Promise<void> {
const current = await prisma.notification.findUnique({ where: { id: notificationId } });
if (!current) throw ApiError.notFound('通知不存在');
await assertCanPublishContent(user, current.classId);
await assertCanPublishNotification(user, current.classId);
await prisma.notification.delete({ where: { id: notificationId } });
}
@@ -172,15 +172,15 @@ export async function markAsRead(
// 先过滤已读过的记录再创建:SQLite 下 createMany 不支持 skipDuplicates,
// 直接 createMany 遇到重复会整体失败。
const existing = await prisma.notificationRead.findMany({
where: { notificationId, userId: { in: personalIds } },
select: { userId: true },
where: { notificationId, studentId: { in: personalIds } },
select: { studentId: true },
});
const have = new Set(existing.map((item) => item.userId));
const missing = personalIds.filter((userId) => !have.has(userId));
const have = new Set(existing.map((item) => item.studentId));
const missing = personalIds.filter((studentId) => !have.has(studentId));
if (missing.length > 0) {
await prisma.notificationRead.createMany({
data: missing.map((userId) => ({ notificationId, userId })),
data: missing.map((studentId) => ({ notificationId, studentId })),
});
}
@@ -201,15 +201,15 @@ export async function markAllAsRead(user: TokenPayload, classId?: string): Promi
let marked = 0;
for (const item of pending) {
const existing = await prisma.notificationRead.findMany({
where: { notificationId: item.id, userId: { in: personalIds } },
select: { userId: true },
where: { notificationId: item.id, studentId: { in: personalIds } },
select: { studentId: true },
});
const have = new Set(existing.map((row) => row.userId));
const missing = personalIds.filter((userId) => !have.has(userId));
const have = new Set(existing.map((row) => row.studentId));
const missing = personalIds.filter((studentId) => !have.has(studentId));
if (missing.length === 0) continue;
await prisma.notificationRead.createMany({
data: missing.map((userId) => ({ notificationId: item.id, userId })),
data: missing.map((studentId) => ({ notificationId: item.id, studentId })),
});
marked += missing.length;
}
+4
View File
@@ -1,3 +1,4 @@
import { archivesModule } from './archives/archives.module.js';
import { authModule } from './auth/auth.module.js';
import { callsModule } from './calls/calls.module.js';
import { classesModule } from './classes/classes.module.js';
@@ -12,6 +13,7 @@ import { notificationsModule } from './notifications/notifications.module.js';
import { schedulesModule } from './schedules/schedules.module.js';
import { studentsModule } from './students/students.module.js';
import { teachersModule } from './teachers/teachers.module.js';
import { termModule } from './term/term.module.js';
import { updateModule } from './update/update.module.js';
import type { ApiModule } from './module.types.js';
@@ -35,6 +37,8 @@ export const apiModules: ApiModule[] = [
gradesModule,
studentsModule,
teachersModule,
archivesModule,
termModule,
dashboardModule,
databaseModule,
updateModule,
@@ -1,6 +1,4 @@
import { Router } from 'express';
import { resolveCurrentWeek } from '@classhelper/shared';
import { env } from '../../config/env.js';
import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
@@ -35,7 +33,8 @@ router.get('/grid', validate({ query: gridQuerySchema }), async (req, res) => {
res,
await scheduleService.getScheduleGrid(user, {
classId,
week: week ?? resolveCurrentWeek(env.termStartDate),
// 不传 week 就取「本班口径」的当前周次(由 service 内按班级配置解析)
week,
}),
'获取周视图成功',
);
@@ -2,7 +2,6 @@ import {
SOCKET_EVENTS,
buildScheduleWeekView,
resolveClassStatus,
resolveCurrentWeek,
weekParityOf,
type ClassStatusDto,
type ScheduleDto,
@@ -12,12 +11,13 @@ import {
assertClassAccess,
assertCanManageSchedule,
classScopeWhere,
isStudent,
requireStudentClassId,
isClassDevice,
requireClassDeviceClassId,
resolveClassScope,
} from '../../lib/access.js';
import { env } from '../../config/env.js';
import { prisma } from '../../lib/db.js';
// 周次口径按**班级**取(管理员可以为每个班单独配开学日期与逐周区间)
import { loadTermContext, resolveCurrentWeek } from '../../lib/term.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { toScheduleDto } from '../../lib/mappers.js';
@@ -60,7 +60,8 @@ export async function getScheduleGrid(
user: TokenPayload,
options: { classId?: string; week?: number },
): Promise<ScheduleWeekView> {
const week = options.week ?? resolveCurrentWeek(env.termStartDate);
// 当前周次按**本班**的口径算(管理员可以为每个班单独配开学日期与逐周区间)
const week = options.week ?? resolveCurrentWeek(new Date(), await loadTermContext(options.classId));
const items = await listSchedules(user, { classId: options.classId, week });
return buildScheduleWeekView(items, week);
}
@@ -71,7 +72,7 @@ export async function getScheduleGrid(
*/
export async function computeClassStatus(classId: string, at?: Date): Promise<ClassStatusDto> {
const now = at ?? new Date();
const week = resolveCurrentWeek(env.termStartDate, now);
const week = resolveCurrentWeek(now, await loadTermContext(classId));
const schedules = await prisma.schedule.findMany({
where: { classId },
include: { course: courseSelect },
@@ -98,7 +99,7 @@ export async function getClassStatus(
user: TokenPayload,
options: { classId?: string; at?: Date } = {},
): Promise<ClassStatusDto> {
const classId = isStudent(user) ? requireStudentClassId(user) : options.classId;
const classId = isClassDevice(user) ? requireClassDeviceClassId(user) : options.classId;
if (!classId) throw ApiError.badRequest('请指定班级(classId)');
await assertClassAccess(user, classId);
return computeClassStatus(classId, options.at);
@@ -1,4 +1,4 @@
import { Router } from 'express';
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';
@@ -7,41 +7,114 @@ import { defineModule } from '../module.types.js';
import {
createStudentSchema,
listStudentsQuerySchema,
listTransfersQuerySchema,
transferOutStudentsSchema,
transferStudentsSchema,
updateStudentSchema,
type CreateStudentInput,
type ListStudentsQuery,
type ListTransfersQuery,
type TransferOutStudentsInput,
type TransferStudentsInput,
type UpdateStudentInput,
} from './students.schemas.js';
import * as studentService from './students.service.js';
const router = Router();
router.use(authenticate());
/**
* GET /api/students?classId=&keyword= - 学生名单
* 学生名单。
*
* 注意是**仅管理员**(不是教师/管理员):学生名单自带用户名,而学生初始密码是统一的默认值,
* 因此名单属于管理员专属数据;教师查看本班学生请用 `GET /api/classes/:id/students`
* (那条按班级权限校验,班级设备也能读)。
* - 学生**没有账号**:这里是"名单记录"的增删改,没有任何密码/角色字段;
* - 读:教师/管理员按自己可访问的班级过滤(服务层 `resolveClassScope`);
* 教室机器读本班名单请用 `GET /api/classes/:id/students`;
* - 写:管理员或**本班班主任**(服务层逐班判权限),调班与转出仅管理员。
*/
router.get('/', requireRole('ADMIN'), validate({ query: listStudentsQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
const query = validatedQuery<{ classId?: string; keyword?: string }>(req);
sendOk(res, await studentService.listStudents(user, query), '获取学生列表成功');
});
router.get(
'/',
requireRole('ADMIN', 'TEACHER'),
validate({ query: listStudentsQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
const query = validatedQuery<ListStudentsQuery>(req);
sendOk(res, await studentService.listStudents(user, query), '获取学生列表成功');
},
);
/** POST /api/students - 新建学生账号 */
router.post('/', requireRole('ADMIN'), validate({ body: createStudentSchema }), async (req, res) => {
const user = getAuthUser(req);
sendCreated(
res,
await studentService.createStudent(user, req.body as CreateStudentInput),
'学生账号创建成功',
);
});
/** GET /api/students/transfers - 调班 / 转出历史(管理员看全部,也可按学生或班级过滤) */
router.get(
'/transfers',
requireRole('ADMIN', 'TEACHER'),
validate({ query: listTransfersQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
const query = validatedQuery<ListTransfersQuery>(req);
sendOk(res, await studentService.listTransfers(user, query), '获取调班历史成功');
},
);
/**
* POST /api/students/transfer - 调班(单个与批量同一接口)
*
* 只改学生当前班级:**学号不变、不创建账号**,原班级/新班级/操作人/时间记入历史;
* 历史作业与成绩保留原归属。
*/
router.post(
'/transfer',
requireRole('ADMIN'),
validate({ body: transferStudentsSchema }),
async (req, res) => {
const user = getAuthUser(req);
const result = await studentService.transferStudents(user, req.body as TransferStudentsInput);
sendOk(res, result, `已调动 ${result.moved} 名学生`);
},
);
/** POST /api/students/transfer-out - 学生转出(学籍离开本校,不是调班) */
router.post(
'/transfer-out',
requireRole('ADMIN'),
validate({ body: transferOutStudentsSchema }),
async (req, res) => {
const user = getAuthUser(req);
const result = await studentService.transferOutStudents(user, req.body as TransferOutStudentsInput);
sendOk(res, result, `已办理 ${result.transferred} 名学生转出`);
},
);
/** GET /api/students/:id/transfers - 单个学生的调班 / 转出历史 */
router.get(
'/:id/transfers',
requireRole('ADMIN', 'TEACHER'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
const result = await studentService.listTransfers(user, { studentId: id, pageSize: 200 });
sendOk(res, result, '获取该生调班历史成功');
},
);
/** POST /api/students - 新建学生(名单记录,不是账号) */
router.post(
'/',
requireRole('ADMIN', 'TEACHER'),
validate({ body: createStudentSchema }),
async (req, res) => {
const user = getAuthUser(req);
sendCreated(
res,
await studentService.createStudent(user, req.body as CreateStudentInput),
'学生已加入名单',
);
},
);
/** PATCH /api/students/:id - 修改学生信息 / 调班 */
router.patch(
'/:id',
requireRole('ADMIN'),
requireRole('ADMIN', 'TEACHER'),
validate({ params: idParamSchema, body: updateStudentSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -54,13 +127,18 @@ router.patch(
},
);
/** DELETE /api/students - 删除学生名单记录 */
router.delete('/:id', requireRole('ADMIN'), validate({ params: idParamSchema }), async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
await studentService.deleteStudent(user, id);
sendOk(res, { id }, '学生账号已删除');
});
/** DELETE /api/students/:id - 删除学生名单记录 */
router.delete(
'/:id',
requireRole('ADMIN', 'TEACHER'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
await studentService.deleteStudent(user, id);
sendOk(res, { id }, '学生记录已删除');
},
);
export const studentsModule = defineModule({
name: 'students',
@@ -1,24 +1,80 @@
import { z } from 'zod';
import { keywordSchema } from '../../lib/schemas.js';
import {
enrollmentYearSchema,
guardianPhoneSchema,
keywordSchema,
studentGenderSchema,
studentNameSchema,
studentNoSchema,
studentStatusSchema,
} from '../../lib/schemas.js';
export const listStudentsQuerySchema = z.object({
classId: z.string().min(1).optional(),
keyword: keywordSchema,
/** 只看某个状态(默认不筛选;`active` = 只看在读) */
status: studentStatusSchema,
/** 是否包含已归档(毕业/转出)的学生,默认 false */
includeArchived: z
.union([z.boolean(), z.string()])
.optional()
.transform((value) => value === true || value === 'true' || value === '1'),
});
// 学生只有"名单"没有"账号":不设密码、不能登录(2026-10-01 起学生个人账号已全部清理,
// 学生端统一用班级码 + 班级密码),因此这里没有也不该有任何密码字段。
// 学生只有「名单」没有「账号」:没有密码字段、没有角色字段,也不能登录。
// 学号是学生的唯一标识与查询键,`studentNoSchema` 负责归一化与格式校验。
export const createStudentSchema = z.object({
username: z.string().trim().min(3, '用户名至少 3 位').max(32),
name: z.string().trim().min(1, '请输入姓名').max(32),
studentNo: studentNoSchema,
name: studentNameSchema,
classId: z.string().min(1).nullish(),
gender: studentGenderSchema,
guardianPhone: guardianPhoneSchema,
status: studentStatusSchema,
});
export const updateStudentSchema = z.object({
username: z.string().trim().min(3).max(32).optional(),
name: z.string().trim().min(1).max(32).optional(),
studentNo: studentNoSchema.optional(),
name: studentNameSchema.optional(),
classId: z.string().min(1).nullish(),
gender: studentGenderSchema,
guardianPhone: guardianPhoneSchema,
status: studentStatusSchema,
});
/**
* 调班:单个与批量共用。
* 传一个 id 就是单个调班;`toClassId = null` 表示移出班级(回到「未分班」)。
* **学号不变**,历史作业与成绩保留原归属。
*/
export const transferStudentsSchema = z.object({
studentIds: z.array(z.string().min(1)).min(1, '请至少选择一名学生').max(500),
toClassId: z.string().min(1).nullable(),
note: z.string().trim().max(200).optional(),
});
/** 转出(学籍离开本校,不是调班):班级归属保留作历史 */
export const transferOutStudentsSchema = z.object({
studentIds: z.array(z.string().min(1)).min(1, '请至少选择一名学生').max(500),
note: z.string().trim().max(200).optional(),
});
/** 调班历史查询 */
export const listTransfersQuerySchema = z.object({
studentId: z.string().min(1).optional(),
classId: z.string().min(1).optional(),
page: z.coerce.number().int().min(1).optional(),
pageSize: z.coerce.number().int().min(1).max(200).optional(),
});
/** 学生名单导入时的班级归属(按班级名解析,重名报错) */
export const studentClassHintSchema = z.object({
className: z.string().trim().max(64).optional(),
enrollmentYear: enrollmentYearSchema.optional(),
});
export type CreateStudentInput = z.infer<typeof createStudentSchema>;
export type UpdateStudentInput = z.infer<typeof updateStudentSchema>;
export type TransferStudentsInput = z.infer<typeof transferStudentsSchema>;
export type TransferOutStudentsInput = z.infer<typeof transferOutStudentsSchema>;
export type ListStudentsQuery = z.infer<typeof listStudentsQuerySchema>;
export type ListTransfersQuery = z.infer<typeof listTransfersQuerySchema>;
@@ -1,126 +1,349 @@
import type { StudentDto } from '@classhelper/shared';
import { assertCanManageRoster, classScopeWhere, isAdmin, resolveClassScope } from '../../lib/access.js';
import type { PaginatedResult, StudentDto, StudentTransferDto } from '@classhelper/shared';
import { normalizeStudentNo } from '@classhelper/shared';
import {
assertCanManageRoster,
classScopeWhere,
isAdmin,
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 { toStudentDto } from '../../lib/mappers.js';
import type { CreateStudentInput, UpdateStudentInput } from './students.schemas.js';
import { toStudentDto, toStudentTransferDto } from '../../lib/mappers.js';
import { SOCKET_EVENTS } from '@classhelper/shared';
import { emitToClass } from '../../realtime/bus.js';
import type {
CreateStudentInput,
ListStudentsQuery,
ListTransfersQuery,
TransferOutStudentsInput,
TransferStudentsInput,
UpdateStudentInput,
} from './students.schemas.js';
export interface ListStudentOptions {
classId?: string;
keyword?: string;
}
/**
* 学生是**班级名单里的记录**,不是账号:
* 没有密码、不能登录、没有个人入口。`studentNo`(学号)是唯一标识与查询键。
*
* 本模块负责名单的增删改与**调班 / 转出**;学生的个人数据(作业完成、通知已读、成绩)
* 挂在 `studentId` 上,因此调班只改 `Student.classId` ——
* 历史作业与成绩保留原归属,后续数据按新班级生成。
*/
const withClass = { class: { select: { name: true, grade: true } } } as const;
/** 学生名单(按班级权限过滤) */
export async function listStudents(user: TokenPayload, options: ListStudentOptions): Promise<StudentDto[]> {
export async function listStudents(
user: TokenPayload,
options: ListStudentsQuery,
): Promise<StudentDto[]> {
const scope = await resolveClassScope(user, options.classId);
const students = await prisma.user.findMany({
const students = await prisma.student.findMany({
where: {
role: 'STUDENT',
...classScopeWhere(scope),
...(options.status ? { status: options.status } : {}),
// 默认不显示已归档(毕业 / 转出)的学生:他们属于归档页,不该混在在读名单里
...(options.includeArchived ? {} : { archivedYearId: null }),
...(options.keyword
? {
OR: [{ name: { contains: options.keyword } }, { username: { contains: options.keyword } }],
}
? { OR: [{ name: { contains: options.keyword } }, { studentNo: { contains: options.keyword } }] }
: {}),
},
include: { class: { select: { name: true, grade: true } } },
orderBy: [{ classId: 'asc' }, { username: 'asc' }],
take: 500,
include: withClass,
orderBy: [{ classId: 'asc' }, { studentNo: 'asc' }],
take: 1000,
});
return students.map(toStudentDto);
}
/** 新建学生(名单实体,可选直接分班)。学生不设密码:登录一律走班级账号,见 createStudentSchema 的说明 */
export async function createStudent(user: TokenPayload, input: CreateStudentInput): Promise<StudentDto> {
if (input.classId) assertCanManageRoster(user);
const created = await prisma.user.create({
data: {
username: input.username,
name: input.name,
role: 'STUDENT',
classId: input.classId ?? null,
// User.passwordHash 是必填列,学生用空串占位(登录的 STUDENT 403 判定在密码校验之前,永远不会用到它)
passwordHash: '',
},
/** 按学号取学生(学生没账号,因此没有"按 id 取自己"的入口) */
export async function findStudentByNo(studentNo: string, classId?: string): Promise<StudentDto | null> {
const record = await prisma.student.findFirst({
where: { studentNo: normalizeStudentNo(studentNo), ...(classId ? { classId } : {}) },
include: withClass,
});
if (input.classId) {
await prisma.enrollment.upsert({
where: { userId_classId: { userId: created.id, classId: input.classId } },
create: { userId: created.id, classId: input.classId },
update: {},
});
}
const student = await prisma.user.findUniqueOrThrow({
where: { id: created.id },
include: { class: { select: { name: true, grade: true } } },
});
return toStudentDto(student);
return record ? toStudentDto(record) : null;
}
/** 修改学生信息 / 调班 */
/** 新建学生(名单记录,可选直接分班)。学生不设密码、没有账号属性 */
export async function createStudent(user: TokenPayload, input: CreateStudentInput): Promise<StudentDto> {
await assertCanManageRoster(user, input.classId ?? undefined);
await assertStudentNoFree(input.studentNo);
if (input.classId) await assertClassExists(input.classId);
const created = await prisma.student.create({
data: {
studentNo: input.studentNo,
name: input.name,
classId: input.classId ?? null,
gender: input.gender ?? '',
guardianPhone: input.guardianPhone ?? '',
status: input.status ?? 'active',
},
include: withClass,
});
if (created.classId) {
emitToClass(created.classId, SOCKET_EVENTS.classUpdated, {
classId: created.classId,
action: 'updated',
});
}
return toStudentDto(created);
}
/** 修改学生信息(含调班;走本接口的单个调班同样会记入调班历史) */
export async function updateStudent(
user: TokenPayload,
studentId: string,
input: UpdateStudentInput,
): Promise<StudentDto> {
const student = await prisma.user.findUnique({ where: { id: studentId } });
if (!student || student.role !== 'STUDENT') throw ApiError.notFound('学生不存在');
const student = await prisma.student.findUnique({ where: { id: studentId } });
if (!student) throw ApiError.notFound('学生不存在');
if (student.classId) {
assertCanManageRoster(user);
} else if (!isAdmin(user)) {
throw ApiError.forbidden('该学生尚未分班,仅管理员可以直接修改');
await assertCanManageRoster(user, student.classId ?? undefined);
if (input.studentNo && input.studentNo !== student.studentNo) {
await assertStudentNoFree(input.studentNo, studentId);
}
const nextClassId = input.classId === undefined ? student.classId : (input.classId ?? null);
if (nextClassId && nextClassId !== student.classId) {
assertCanManageRoster(user);
}
const classChanged = nextClassId !== student.classId;
if (classChanged) await assertCanManageRoster(user, nextClassId ?? undefined);
const updated = await prisma.user.update({
const updated = await prisma.student.update({
where: { id: studentId },
data: {
...(input.studentNo ? { studentNo: input.studentNo } : {}),
...(input.name ? { name: input.name } : {}),
...(input.username ? { username: input.username } : {}),
...(input.gender !== undefined ? { gender: input.gender } : {}),
...(input.guardianPhone !== undefined ? { guardianPhone: input.guardianPhone } : {}),
...(input.status !== undefined ? { status: input.status } : {}),
classId: nextClassId,
},
include: withClass,
});
// 同步 Enrollment:换班时清理旧记录
if (nextClassId) {
await prisma.enrollment.deleteMany({ where: { userId: studentId, classId: { not: nextClassId } } });
await prisma.enrollment.upsert({
where: { userId_classId: { userId: studentId, classId: nextClassId } },
create: { userId: studentId, classId: nextClassId },
update: {},
});
} else {
await prisma.enrollment.deleteMany({ where: { userId: studentId } });
// 换班要留痕(单个调班与批量调班都写同一种历史)
if (classChanged) {
await recordTransfer(user, [updated], student.classId, nextClassId, 'single', '');
}
const result = await prisma.user.findUniqueOrThrow({
where: { id: updated.id },
include: { class: { select: { name: true, grade: true } } },
});
return toStudentDto(result);
return toStudentDto(updated);
}
/** 删除学生账号(级联删除其作业状态、成绩、已读记录) */
/** 删除学生记录(级联删除其作业状态、成绩、已读记录与调班历史) */
export async function deleteStudent(user: TokenPayload, studentId: string): Promise<void> {
const student = await prisma.user.findUnique({ where: { id: studentId } });
if (!student || student.role !== 'STUDENT') throw ApiError.notFound('学生不存在');
const student = await prisma.student.findUnique({ where: { id: studentId } });
if (!student) throw ApiError.notFound('学生不存在');
await assertCanManageRoster(user, student.classId ?? undefined);
if (student.classId) {
assertCanManageRoster(user);
} else if (!isAdmin(user)) {
throw ApiError.forbidden('该学生尚未分班,仅管理员可以删除');
await prisma.student.delete({ where: { id: studentId } });
}
/* ---------------------------------------------------------------- 调班 / 转出 */
/**
* 调班:单个与批量共用(传一个 id 就是单个)。
*
* 规则(需求「管理员调班」):
* - 只改学生**当前班级**,学号不变、不创建任何账号;
* - 原班级、新班级、操作人、时间写入 `StudentClassTransfer`,历史可查;
* - 历史作业与成绩保留原归属(它们挂在各自的 `classId` 上,不随学生走)。
*/
export async function transferStudents(
user: TokenPayload,
input: TransferStudentsInput,
): Promise<{ moved: number; transfers: StudentTransferDto[] }> {
if (!isAdmin(user)) throw ApiError.forbidden('只有管理员可以调班');
const students = await prisma.student.findMany({ where: { id: { in: input.studentIds } } });
if (students.length !== input.studentIds.length) {
throw ApiError.badRequest('部分学生不存在,请刷新后重试');
}
if (input.toClassId) await assertClassExists(input.toClassId);
const movable = students.filter((item) => item.classId !== input.toClassId);
if (movable.length === 0) {
throw ApiError.badRequest('所选学生已经全部在这个班级里');
}
await prisma.user.delete({ where: { id: studentId } });
await prisma.$transaction(
movable.map((item) =>
prisma.student.update({ where: { id: item.id }, data: { classId: input.toClassId } }),
),
);
const mode = movable.length > 1 ? 'batch' : 'single';
// 注意传的是**调班前**的 movable(classId 还是原班级):历史里的"原班级"要按每条自己的来
const transfers = await recordTransfer(user, movable, null, input.toClassId, mode, input.note ?? '');
notifyClassChange(movable.map((item) => item.classId), input.toClassId);
return { moved: movable.length, transfers };
}
/**
* 学生转出:学籍离开本校(**不是调班**)。
*
* 班级归属保留作历史(成绩/作业/已读都还在),状态置为 `transferred`,
* 并写一条 `mode = 'transfer-out'` 的历史,归档页据此统计"当年转出的学生"。
*/
export async function transferOutStudents(
user: TokenPayload,
input: TransferOutStudentsInput,
): Promise<{ transferred: number; transfers: StudentTransferDto[] }> {
if (!isAdmin(user)) throw ApiError.forbidden('只有管理员可以办理学生转出');
const students = await prisma.student.findMany({ where: { id: { in: input.studentIds } } });
if (students.length !== input.studentIds.length) {
throw ApiError.badRequest('部分学生不存在,请刷新后重试');
}
const pending = students.filter((item) => item.status !== 'transferred');
if (pending.length === 0) throw ApiError.badRequest('所选学生都已经转出');
const now = new Date();
await prisma.$transaction(
pending.map((item) =>
prisma.student.update({
where: { id: item.id },
data: { status: 'transferred', transferredAt: now, transferNote: input.note ?? '' },
}),
),
);
// 班级名快照:转出时把学生"离开的那个班"记下来,之后班级改名也不影响历史
const classNames = await loadClassNames(pending.map((item) => item.classId));
await prisma.studentClassTransfer.createMany({
data: pending.map((item) => ({
studentId: item.id,
studentNo: item.studentNo,
studentName: item.name,
fromClassId: item.classId,
fromClassName: item.classId ? (classNames.get(item.classId) ?? '') : '',
toClassId: null,
toClassName: '转出本校',
operatorId: user.sub,
operatorName: user.name,
mode: 'transfer-out',
note: input.note ?? '',
})),
});
notifyClassChange(pending.map((item) => item.classId), null);
const created = await prisma.studentClassTransfer.findMany({
where: { studentId: { in: pending.map((item) => item.id) }, mode: 'transfer-out' },
orderBy: { createdAt: 'desc' },
take: pending.length,
});
return { transferred: pending.length, transfers: created.map(toStudentTransferDto) };
}
/** 调班 / 转出历史(单个学生或某个班级,管理员看全部) */
export async function listTransfers(
user: TokenPayload,
options: ListTransfersQuery,
): Promise<PaginatedResult<StudentTransferDto>> {
if (!isAdmin(user) && !options.classId && !options.studentId) {
throw ApiError.forbidden('查看全部调班历史需要管理员权限');
}
const where = {
...(options.studentId ? { studentId: options.studentId } : {}),
...(options.classId
? { OR: [{ fromClassId: options.classId }, { toClassId: options.classId }] }
: {}),
};
const page = options.page ?? 1;
const pageSize = options.pageSize ?? 50;
const [items, total] = await Promise.all([
prisma.studentClassTransfer.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.studentClassTransfer.count({ where }),
]);
return { items: items.map(toStudentTransferDto), total, page, pageSize };
}
/* ---------------------------------------------------------------- 内部工具 */
/**
* 写入调班历史。
*
* - `fromOverride` 非空时,所有条目都用它作为"原班级"(单个调班场景);
* - 为空时逐条取**该学生自己的 classId** 作为"原班级"(批量调班场景,
* 各人的来源班可能不同)。
*/
async function recordTransfer(
user: TokenPayload,
students: { id: string; studentNo: string; name: string; classId: string | null }[],
fromOverride: string | null,
toClassId: string | null,
mode: 'single' | 'batch',
note: string,
): Promise<StudentTransferDto[]> {
const fromIds = students.map((item) => fromOverride ?? item.classId);
const classNames = await loadClassNames([...fromIds, toClassId]);
await prisma.studentClassTransfer.createMany({
data: students.map((item, index) => {
const fromClassId = fromIds[index] ?? null;
return {
studentId: item.id,
studentNo: item.studentNo,
studentName: item.name,
fromClassId,
fromClassName: fromClassId ? (classNames.get(fromClassId) ?? '') : '',
toClassId,
toClassName: toClassId ? (classNames.get(toClassId) ?? '') : '未分班',
operatorId: user.sub,
operatorName: user.name,
mode,
note,
};
}),
});
const created = await prisma.studentClassTransfer.findMany({
where: { studentId: { in: students.map((item) => item.id) } },
orderBy: { createdAt: 'desc' },
take: students.length,
});
return created.map(toStudentTransferDto);
}
async function loadClassNames(classIds: (string | null)[]): Promise<Map<string, string>> {
const ids = [...new Set(classIds.filter((id): id is string => Boolean(id)))];
if (ids.length === 0) return new Map();
const classes = await prisma.class.findMany({ where: { id: { in: ids } }, select: { id: true, name: true } });
return new Map(classes.map((item) => [item.id, item.name]));
}
/** 调班后让相关班级的前端刷新名单(旧班与新班都要收到) */
function notifyClassChange(fromClassIds: (string | null)[], toClassId: string | null): void {
const targets = new Set([...fromClassIds, toClassId].filter((id): id is string => Boolean(id)));
for (const classId of targets) {
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' });
}
}
async function assertClassExists(classId: string): Promise<void> {
const record = await prisma.class.findUnique({ where: { id: classId }, select: { id: true } });
if (!record) throw ApiError.badRequest('班级不存在');
}
/** 学号唯一:`Student.studentNo` 上已有唯一约束,这里给出一句人话的 409 */
async function assertStudentNoFree(studentNo: string, exceptId?: string): Promise<void> {
const existing = await prisma.student.findUnique({
where: { studentNo },
select: { id: true, name: true, classId: true },
});
if (existing && existing.id !== exceptId) {
throw ApiError.conflict(`学号 ${studentNo} 已被学生「${existing.name}」占用`);
}
}
@@ -20,7 +20,7 @@ router.use(authenticate());
/**
* 教师账号管理:**全部接口仅管理员**。
* 学生端主体是班级,但教师账号决定谁是哪班班主任、谁能录课表/成绩,属于系统级配置,
* ClassHelper 班级端主体是班级,但教师账号决定谁是哪班班主任、谁能录课表/成绩,属于系统级配置,
* 因此录入/修改/重置密码/删除都只开放给 ADMIN(路由层 + 前端菜单双重收敛)。
*/
@@ -5,18 +5,25 @@ export const listTeachersQuerySchema = z.object({
keyword: keywordSchema,
});
/**
* 录入教师账号。
* `username` 对教师而言就是**工号**(界面上也这么叫),也是登录名。
*/
export const createTeacherSchema = z.object({
username: z.string().trim().min(3, '用户名至少 3 位').max(32),
username: z.string().trim().min(3, '工号至少 3 位').max(32),
name: z.string().trim().min(1, '请输入姓名').max(32),
/** 手机号(可空) */
phone: z.string().trim().max(32).optional(),
/** 留空则用默认初始密码(DEFAULT_TEACHER_PASSWORD) */
password: z.string().min(6, '密码至少 6 位').max(128).optional(),
role: z.enum(['TEACHER', 'ADMIN']).optional(),
});
/** 编辑教师:姓名 / 用户名 / 角色(角色可升为管理员,也可降回教师) */
/** 编辑教师:姓名 / 工号 / 手机号 / 角色(角色可升为管理员,也可降回教师) */
export const updateTeacherSchema = z.object({
username: z.string().trim().min(3).max(32).optional(),
name: z.string().trim().min(1).max(32).optional(),
phone: z.string().trim().max(32).optional(),
role: z.enum(['TEACHER', 'ADMIN']).optional(),
});
@@ -104,9 +104,8 @@ export async function resetTeacherPassword(
export async function deleteTeacher(userId: string): Promise<void> {
const teacher = await findTeacherOrThrow(userId);
const [ownedClasses, assignedClasses, courses, homeworks, notifications] = await Promise.all([
const [ownedClasses, courses, homeworks, notifications] = await Promise.all([
prisma.class.count({ where: { teacherId: teacher.id } }),
prisma.classTeacher.count({ where: { teacherId: teacher.id } }),
prisma.course.count({ where: { teacherId: teacher.id } }),
prisma.homework.count({ where: { createdBy: teacher.id } }),
prisma.notification.count({ where: { createdBy: teacher.id } }),
@@ -122,9 +121,9 @@ export async function deleteTeacher(userId: string): Promise<void> {
'删除账号会连带删除这些内容,请先删除内容或改为保留账号',
);
}
if (assignedClasses > 0 || courses > 0) {
if (courses > 0) {
throw ApiError.conflict(
`「${teacher.name}」还有 ${assignedClasses} 条班级分配 / ${courses} 门课程,请先解除后再删除`,
`「${teacher.name}」还是 ${courses} 门课的任课老师,请先在班级管理里改掉该科的任课老师再删除`,
);
}
@@ -0,0 +1,86 @@
import { Router } from 'express';
import { sendOk } from '../../lib/http.js';
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
import { validate, validatedBody, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
autoTermWeeksSchema,
holidayQuerySchema,
termQuerySchema,
updateTermWeeksSchema,
type AutoTermWeeksInput,
type UpdateTermWeeksInput,
} from './term.schemas.js';
import * as termService from './term.service.js';
const router = Router();
router.use(authenticate());
/**
* 学期周次。
*
* - **读**:教师/管理员都能看自己可访问班级的周次(课表页要显示"第几周");
* - **写**:仅管理员 —— 周次是全校口径,改错会让所有班的课表整体错位;
* - `classId` 留空 = 全校默认;某个班有自己的配置时优先用它。
*/
router.get('/', requireRole('ADMIN', 'TEACHER'), validate({ query: termQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
const { classId } = validatedQuery<{ classId?: string }>(req);
sendOk(res, await termService.getTermWeeks(user, classId), '获取学期周次成功');
});
/** PUT /api/term - 保存逐周区间(整表覆盖,仅管理员) */
router.put(
'/',
requireRole('ADMIN'),
validate({ body: updateTermWeeksSchema }),
async (req, res) => {
const user = getAuthUser(req);
sendOk(
res,
await termService.updateTermWeeks(user, validatedBody<UpdateTermWeeksInput>(req)),
'学期周次已保存,变更会随课表下发给教室的 ClassHelper 班级端',
);
},
);
/**
* POST /api/term/auto - 按学期开始日期一键生成逐周区间(仅管理员)
*
* 配置的起点:先生成一份"标准学期",再把调休那几周改掉。
*/
router.post(
'/auto',
requireRole('ADMIN'),
validate({ body: autoTermWeeksSchema }),
async (req, res) => {
const user = getAuthUser(req);
sendOk(
res,
await termService.autoTermWeeks(user, validatedBody<AutoTermWeeksInput>(req)),
'已按学期开始日期生成逐周区间,可继续手动微调',
);
},
);
/**
* GET /api/term/holidays?year= - 联网获取法定节假日安排(给建议,不改数据)
*
* **机房没有外网时 `ok:false` 是正常结果**,前端按"暂时取不到"呈现即可。
*/
router.get(
'/holidays',
requireRole('ADMIN'),
validate({ query: holidayQuerySchema }),
async (req, res) => {
const { year } = validatedQuery<{ year?: number }>(req);
const result = await termService.suggestHolidays(year);
sendOk(res, result, result.ok ? '已获取当年的放假安排' : (result.error ?? '暂时无法获取放假安排'));
},
);
export const termModule = defineModule({
name: 'term',
basePath: '/term',
router,
});
@@ -0,0 +1,37 @@
import { z } from 'zod';
import { isDayKey } from '@classhelper/shared';
/** 一个教学周的日期区间 */
const termWeekItemSchema = z.object({
weekNumber: z.coerce.number().int().min(1).max(60),
startDate: z.string().trim().refine(isDayKey, { message: '开始日期格式应为 YYYY-MM-DD' }),
endDate: z.string().trim().refine(isDayKey, { message: '结束日期格式应为 YYYY-MM-DD' }),
note: z.string().trim().max(64).optional(),
});
const termStartDateSchema = z.string().trim().refine(isDayKey, { message: '日期格式应为 YYYY-MM-DD' });
/** `classId` 为空表示全校默认 */
export const termQuerySchema = z.object({
classId: z.string().min(1).optional(),
});
export const updateTermWeeksSchema = z.object({
classId: z.string().min(1).nullish(),
termStartDate: termStartDateSchema.optional(),
/** 整表覆盖:传空数组 = 清空逐周配置,回到按开学日期线性推算 */
weeks: z.array(termWeekItemSchema).max(60),
});
export const autoTermWeeksSchema = z.object({
classId: z.string().min(1).nullish(),
termStartDate: termStartDateSchema.optional(),
maxWeek: z.coerce.number().int().min(1).max(60).optional(),
});
export const holidayQuerySchema = z.object({
year: z.coerce.number().int().min(2000).max(2100).optional(),
});
export type UpdateTermWeeksInput = z.infer<typeof updateTermWeeksSchema>;
export type AutoTermWeeksInput = z.infer<typeof autoTermWeeksSchema>;
@@ -0,0 +1,239 @@
import {
buildAutoTermWeeks,
validateTermWeeks,
type HolidaySuggestionDto,
type TermWeeksDto,
} from '@classhelper/shared';
import { assertCanManageClasses, assertClassAccess } from '../../lib/access.js';
import { env } from '../../config/env.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 { MAX_TERM_WEEK, loadTermContext, resolveCurrentWeek } from '../../lib/term.js';
import { emitToClass } from '../../realtime/bus.js';
import { SOCKET_EVENTS } from '@classhelper/shared';
import { notifyScheduleChanged } from '../integrations/integrations.service.js';
import type { AutoTermWeeksInput, UpdateTermWeeksInput } from './term.schemas.js';
/**
* 学期周次(仅管理员可写)。
*
* 为什么需要逐周配置:`TERM_START_DATE + 每周七天`的线性推算遇到法定节假日调休、
* 周末补课、错峰开学就会**整体错位**——而课表、作业、成绩都按周次组织,错一周就全错。
* 因此管理员可以:
* 1. 先按开学日期一键生成逐周区间(`autoTermWeeks`);
* 2. 再逐周微调(调休那一周拉长、开学前那几天并进第 1 周…);
* 3. 也可以直接改某个班的开学日期(`Class.termStartDate`)。
*
* `classId = ''` 表示**全校默认**;某个班有自己的配置时优先用它。
* 改完之后会把新的课表口径(含周次区间)**下发给该班所有在线的 ClassIsland**。
*/
/** 把 null/undefined 归一成空串(空串 = 全校默认) */
function scopeOf(classId?: string | null): string {
return classId ?? '';
}
/** 读取某个范围的周次配置 */
export async function getTermWeeks(user: TokenPayload, classId?: string | null): Promise<TermWeeksDto> {
const scope = scopeOf(classId);
if (scope) await assertClassAccess(user, scope);
const [weeks, cls] = await Promise.all([
prisma.termWeek.findMany({ where: { classId: scope }, orderBy: { weekNumber: 'asc' } }),
scope
? prisma.class.findUnique({ where: { id: scope }, select: { termStartDate: true, termWeeks: true } })
: Promise.resolve(null),
]);
const context = await loadTermContext(scope || null);
return {
classId: scope,
termStartDate: context.termStartDate || env.termStartDate,
currentWeek: resolveCurrentWeek(new Date(), context),
maxWeek: cls?.termWeeks && cls.termWeeks > 0 ? cls.termWeeks : MAX_TERM_WEEK,
configured: weeks.length > 0,
weeks: weeks.map((item) => ({
weekNumber: item.weekNumber,
startDate: item.startDate,
endDate: item.endDate,
note: item.note,
})),
};
}
/**
* 保存逐周配置(整表覆盖)。仅管理员。
*
* 校验:日期格式、起止顺序、**区间不重叠**(重叠会让"当前第几周"出现歧义)。
* 保存后把新的周次口径下发给该班所有在线设备。
*/
export async function updateTermWeeks(user: TokenPayload, input: UpdateTermWeeksInput): Promise<TermWeeksDto> {
assertCanManageClasses(user);
const scope = scopeOf(input.classId);
const errors = validateTermWeeks(input.weeks);
if (errors.length > 0) throw ApiError.badRequest(errors[0]!, { errors });
// 重复的周次号会让唯一约束直接抛 500,这里给一句人话
const seen = new Set<number>();
for (const week of input.weeks) {
if (seen.has(week.weekNumber)) {
throw ApiError.badRequest(`第 ${week.weekNumber} 周出现了两次`);
}
seen.add(week.weekNumber);
}
if (scope) {
const exists = await prisma.class.findUnique({ where: { id: scope }, select: { id: true } });
if (!exists) throw ApiError.notFound('班级不存在');
}
await prisma.$transaction([
prisma.termWeek.deleteMany({ where: { classId: scope } }),
...(input.weeks.length > 0
? [
prisma.termWeek.createMany({
data: input.weeks.map((week) => ({
classId: scope,
weekNumber: week.weekNumber,
startDate: week.startDate,
endDate: week.endDate,
note: week.note ?? '',
})),
}),
]
: []),
...(input.termStartDate && scope
? [prisma.class.update({ where: { id: scope }, data: { termStartDate: input.termStartDate } })]
: []),
]);
logger.info(
`学期周次已更新:范围=${scope || '全校默认'} 周数=${input.weeks.length} 由 ${user.name} 执行`,
);
await publishTermChange(scope);
return getTermWeeks(user, scope || null);
}
/**
* 按学期开始日期**一键生成**逐周区间(管理员再逐周微调)。仅管理员。
*
* 这是配置的起点:先生成一份"标准学期",再把调休那几周改掉,
* 比从空白开始一周周填要快得多。
*/
export async function autoTermWeeks(user: TokenPayload, input: AutoTermWeeksInput): Promise<TermWeeksDto> {
assertCanManageClasses(user);
const scope = scopeOf(input.classId);
const context = await loadTermContext(scope || null);
const termStartDate = input.termStartDate || context.termStartDate || env.termStartDate;
const maxWeek = input.maxWeek ?? context.maxWeek;
const weeks = buildAutoTermWeeks(termStartDate, maxWeek);
if (weeks.length === 0) throw ApiError.badRequest('请先填写学期开始日期(第 1 教学周的周一)');
return updateTermWeeks(user, { classId: scope || null, termStartDate, weeks });
}
/* ------------------------------------------------------------------ 调休建议(联网,可选) */
/** 建议结果的缓存(1 小时):节假日数据一年才变几次,不必每次点都打外网 */
const holidayCache = new Map<number, { at: number; data: HolidaySuggestionDto }>();
const HOLIDAY_CACHE_TTL_MS = 60 * 60 * 1000;
/**
* 联网拉取法定节假日安排,给出「哪几天不上课」的建议。
*
* **`ok:false` 是设计内的结果**:机房与教室机器常常只有内网,离线 / 超时 / 限流
* 一律收敛成 `ok:false` + 一句人话,绝不抛 500(与更新检查同一种处理思路)。
* 而且它只**给建议**,不会自动改任何数据 —— 管理员确认后才应用到周次上。
*/
export async function suggestHolidays(year?: number): Promise<HolidaySuggestionDto> {
const targetYear = year ?? new Date().getFullYear();
const cached = holidayCache.get(targetYear);
if (cached && Date.now() - cached.at < HOLIDAY_CACHE_TTL_MS) return cached.data;
const fallback = (error: string): HolidaySuggestionDto => ({
ok: false,
error,
year: targetYear,
holidays: [],
checkedAt: new Date().toISOString(),
});
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 8000);
const response = await fetch(`https://timor.tech/api/holiday/year/${targetYear}`, {
signal: controller.signal,
headers: { 'user-agent': 'ClassHelper-TermWeeks' },
}).finally(() => clearTimeout(timer));
if (!response.ok) {
const data = fallback(`节假日服务返回 ${response.status}`);
holidayCache.set(targetYear, { at: Date.now(), data });
return data;
}
const payload = (await response.json()) as {
holiday?: Record<string, { holiday: boolean; name: string; date: string }>;
};
const holidays = Object.values(payload.holiday ?? {})
.filter((item) => item.holiday === true)
.map((item) => ({
name: item.name,
startDate: item.date,
endDate: item.date,
}))
.sort((a, b) => a.startDate.localeCompare(b.startDate));
const data: HolidaySuggestionDto = {
ok: true,
error: null,
year: targetYear,
holidays,
checkedAt: new Date().toISOString(),
};
holidayCache.set(targetYear, { at: Date.now(), data });
return data;
} catch (error) {
const reason =
error instanceof Error && error.name === 'AbortError'
? '请求超时(服务器可能没有外网)'
: `无法获取节假日数据:${error instanceof Error ? error.message : String(error)}`;
const data = fallback(reason);
// 失败结果同样进缓存:否则断网时点一次按钮就要白等一个超时
holidayCache.set(targetYear, { at: Date.now(), data });
return data;
}
}
/* ------------------------------------------------------------------ 内部工具 */
/**
* 周次变了要把新的口径**下发给教室的 ClassIsland**(需求:「对所有 ClassIsland 下发修改」)。
*
* 逐周区间没法直接塞进 ClassIsland 的模型(它按"教学周序号 + 单双周"组织课表),
* 因此实际下发的是**学期开始日期**与**各周次的起止**,随镜像课表一起走:
* 插件据此校正"现在是第几周",并让课表的周次口径与教室里的实际日历对齐。
*/
async function publishTermChange(scope: string): Promise<void> {
if (scope) {
emitToClass(scope, SOCKET_EVENTS.classUpdated, { classId: scope, action: 'updated' });
await notifyScheduleChanged(scope).catch((error) => {
logger.warn(`周次变更后下发课表失败(数据已保存):${String(error)}`);
});
return;
}
// 全校默认:把每个在读班级都刷一遍
const classes = await prisma.class.findMany({
where: { archivedYearId: null },
select: { id: true },
});
for (const item of classes) {
emitToClass(item.id, SOCKET_EVENTS.classUpdated, { classId: item.id, action: 'updated' });
await notifyScheduleChanged(item.id).catch(() => undefined);
}
}
+8 -3
View File
@@ -24,7 +24,12 @@ export function emitToClass(classId: string, event: string, payload: unknown): v
emitToRooms([SOCKET_ROOMS.class(classId)], event, payload);
}
/** 定向推送给单个用户 */
export function emitToUser(userId: string, event: string, payload: unknown): void {
emitToRooms([SOCKET_ROOMS.user(userId)], event, payload);
/**
* 定向推送给单个会话。
*
* 会话 id:教师/管理员是自己的账号 id,ClassHelper 班级端是**班级 id**
* (见 socket.ts 的握手逻辑)。学生不是账号,因此没有"学生房间"。
*/
export function emitToSession(sessionId: string, event: string, payload: unknown): void {
emitToRooms([SOCKET_ROOMS.session(sessionId)], event, payload);
}
+8 -6
View File
@@ -52,8 +52,8 @@ export function initRealtime(httpServer: HttpServer): RealtimeServer {
return;
}
const payload = verifyToken(token);
// 班级账号(班级设备):socket.data.user.id 用班级 id,据此加入 user:{classId} 房间,
// "叫人"等定向消息因此能直达班级设备(见 calls.service)。
// ClassHelper 班级端:socket.data.user.id 用**班级 id**,据此加入 session:{classId} 房间,
// 「叫人」等定向消息因此能直达教室机器(见 calls.service)。
socket.data.user = {
id: payload.classSession ? (payload.classId ?? payload.sub) : payload.sub,
username: payload.classSession ? (payload.classCode ?? payload.username) : payload.username,
@@ -70,7 +70,7 @@ export function initRealtime(httpServer: HttpServer): RealtimeServer {
io.on('connection', (socket) => {
const user = socket.data.user;
void (async () => {
const rooms = [SOCKET_ROOMS.user(user.id), SOCKET_ROOMS.role(user.role)];
const rooms = [SOCKET_ROOMS.session(user.id), SOCKET_ROOMS.role(user.role)];
try {
const classIds = await getAccessibleClassIds({
@@ -88,11 +88,13 @@ export function initRealtime(httpServer: HttpServer): RealtimeServer {
logger.warn(`加入班级房间失败(user=${user.id})`, error);
}
await socket.join(user.role === 'STUDENT' ? SOCKET_ROOMS.students : SOCKET_ROOMS.teachers);
rooms.push(user.role === 'STUDENT' ? SOCKET_ROOMS.students : SOCKET_ROOMS.teachers);
// ClassHelper 班级端归入 students 房间(它代表全班),教师/管理员归入 teachers
const audienceRoom = user.role === 'CLASS_DEVICE' ? SOCKET_ROOMS.students : SOCKET_ROOMS.teachers;
await socket.join(audienceRoom);
rooms.push(audienceRoom);
logger.info(`实时连接建立:${user.name}(${user.role}) sid=${socket.id}`);
socket.emit(SOCKET_EVENTS.connected, { userId: user.id, role: user.role, rooms });
socket.emit(SOCKET_EVENTS.connected, { sessionId: user.id, role: user.role, rooms });
})();
// 客户端可显式订阅某个班级(服务端二次校验权限)