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

+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 });
})();
// 客户端可显式订阅某个班级(服务端二次校验权限)