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:
1 parent
17f55864bf
commit
9d5ac7adf9
172 files changed
+8761
-1896
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,8 @@ export type ApiErrorCode =
|
||||
| 'IMPORT_EMPTY'
|
||||
/** 班级账号:该班级尚未设置班级密码(禁止班级登录,提示管理员设置) */
|
||||
| 'CLASS_PASSWORD_NOT_SET'
|
||||
/** 班级账号:该班级已毕业归档,教室端已停止使用 */
|
||||
| 'CLASS_ARCHIVED'
|
||||
| 'INTERNAL_ERROR';
|
||||
|
||||
/** 业务异常:中间件会把它转换成统一错误响应体 */
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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 } };
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user