diff --git a/packages/server/src/lib/class-account.ts b/packages/server/src/lib/class-account.ts index 2512294..8407c97 100644 --- a/packages/server/src/lib/class-account.ts +++ b/packages/server/src/lib/class-account.ts @@ -1,3 +1,4 @@ +import { randomInt } from 'node:crypto'; import { prisma } from './db.js'; import { ApiError } from './http.js'; import { hashPassword, verifyPassword } from './password.js'; @@ -30,7 +31,9 @@ export async function generateClassCode(prefix = ''): Promise { const length = Math.max(4, 6 - prefix.length); let body = ''; for (let index = 0; index < length; index += 1) { - body += CODE_ALPHABET[Math.floor(Math.random() * CODE_ALPHABET.length)]; + // 用 CSPRNG 而不是 Math.random():班级码会作为学生端登录的账号名打印/张贴, + // 用可预测的 PRNG 生成等于把"半个凭证"送给猜码的人(randomInt 无取模偏差) + body += CODE_ALPHABET[randomInt(0, CODE_ALPHABET.length)]; } const code = `${prefix}${body}`.toUpperCase(); const exists = await prisma.class.findUnique({ where: { code }, select: { id: true } }); diff --git a/packages/server/src/modules/auth/auth.service.ts b/packages/server/src/modules/auth/auth.service.ts index b5765db..238e8ba 100644 --- a/packages/server/src/modules/auth/auth.service.ts +++ b/packages/server/src/modules/auth/auth.service.ts @@ -19,14 +19,16 @@ export async function login(input: LoginInput): Promise { const user = await prisma.user.findUnique({ where: { username: input.username } }); if (!user) throw ApiError.unauthorized('用户名或密码错误'); - const passwordMatched = await verifyPassword(input.password, user.passwordHash); - if (!passwordMatched) throw ApiError.unauthorized('用户名或密码错误'); - - // 个人学生账号已停用登录入口(需求:以班级为单位,删掉学生用自己用户名登录) + // 角色判定必须放在密码校验**之前**:否则"密码错"与"密码对但学生账号已停用"会给出 + // 不同的响应(401 / 403),攻击者据此就能把学生账号的密码当成可离线验证的预言机。 + // 文案与状态码保持不变(客户端按 403 提示"请用班级码登录")。 if (user.role === 'STUDENT') { throw ApiError.forbidden('学生请使用「班级码 + 班级密码」登录(个人学生账号已停用)'); } + const passwordMatched = await verifyPassword(input.password, user.passwordHash); + if (!passwordMatched) throw ApiError.unauthorized('用户名或密码错误'); + const payload: TokenPayload = { sub: user.id, username: user.username, diff --git a/packages/server/src/modules/classes/classes.schemas.ts b/packages/server/src/modules/classes/classes.schemas.ts index 77f0c91..3f8f31c 100644 --- a/packages/server/src/modules/classes/classes.schemas.ts +++ b/packages/server/src/modules/classes/classes.schemas.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { CLASSISLAND_NOTIFICATION_CHANNELS } from '@classhelper/shared'; +import { CLASSISLAND_NOTIFICATION_CHANNELS, MAX_TERM_WEEK } from '@classhelper/shared'; import { keywordSchema } from '../../lib/schemas.js'; export const createClassSchema = z.object({ @@ -13,13 +13,19 @@ export const createClassSchema = z.object({ .trim() .regex(/^[A-Za-z0-9]{4,16}$/, '班级码需为 4~16 位字母或数字') .optional(), + /** + * 本学期教学周数。上限与课表周次口径统一取 MAX_TERM_WEEK(30): + * 周次选择器与 weekNumberSchema 都只到 30,允许更大的值只会让用户设出一个 + * 系统兑现不了的学期长度(第 31 周之后永远为空)。 + */ + termWeeks: z.coerce.number().int().min(1).max(MAX_TERM_WEEK).optional(), }); export const updateClassSchema = z.object({ name: z.string().trim().min(1).max(64).optional(), grade: z.string().trim().min(1).max(32).optional(), - /** 本学期教学周数(1~40,班主任可调) */ - termWeeks: z.coerce.number().int().min(1).max(40).optional(), + /** 本学期教学周数(1~30,管理员可调) */ + termWeeks: z.coerce.number().int().min(1).max(MAX_TERM_WEEK).optional(), }); /** 设置 / 重置班级账号(班级码 + 班级密码):仅管理员 */ @@ -43,7 +49,6 @@ export const listClassesQuerySchema = z.object({ export const addStudentSchema = z.object({ username: z.string().trim().min(3, '用户名至少 3 位').max(32), name: z.string().trim().min(1, '请输入姓名').max(32), - password: z.string().min(6, '密码至少 6 位').max(128).optional(), }); export const assignTeacherSchema = z.object({ diff --git a/packages/server/src/modules/classes/classes.service.ts b/packages/server/src/modules/classes/classes.service.ts index 6fdc6e3..980d695 100644 --- a/packages/server/src/modules/classes/classes.service.ts +++ b/packages/server/src/modules/classes/classes.service.ts @@ -28,7 +28,6 @@ import { toStudentDto, toTeacherBrief, } from '../../lib/mappers.js'; -import { hashPassword } from '../../lib/password.js'; import { emitToClass } from '../../realtime/bus.js'; import { SOCKET_EVENTS } from '@classhelper/shared'; import type { @@ -152,6 +151,8 @@ export async function createClass(user: TokenPayload, input: CreateClassInput): teacherId, code: account.code, passwordHash: account.passwordHash, + // 新建时就能指定学期周数(之前只有编辑接口支持,Web 端表单里填了也会被丢弃) + ...(input.termWeeks !== undefined ? { termWeeks: input.termWeeks } : {}), }, include: { teacher: { select: { id: true, name: true, username: true } }, @@ -228,7 +229,7 @@ export async function updateClass( data: { ...(input.name ? { name: input.name } : {}), ...(input.grade ? { grade: input.grade } : {}), - // 教学周数(班主任可调):课表周次选择与默认 weekEnd 都用它 + // 教学周数(仅管理员可调,见 assertCanManageClasses):课表周次选择与默认 weekEnd 都用它 ...(input.termWeeks !== undefined ? { termWeeks: input.termWeeks } : {}), }, include: { @@ -260,9 +261,9 @@ export async function listClassStudents(user: TokenPayload, classId: string): Pr } /** - * 添加学生到班级。 - * 已存在的学生账号直接转入本班(同时维护 Enrollment,保证"一个学生一个主班级")。 - * 新账号使用 DEFAULT_STUDENT_PASSWORD 作为初始密码。 + * 添加学生到班级(名单实体)。 + * 已存在的学生直接转入本班(同时维护 Enrollment,保证"一个学生一个主班级")。 + * 学生没有账号属性:不设密码、不能登录,学生端统一用班级码 + 班级密码。 */ export async function addStudent( user: TokenPayload, @@ -292,7 +293,8 @@ export async function addStudent( name: input.name, role: 'STUDENT', classId, - passwordHash: await hashPassword(input.password ?? env.defaultStudentPassword), + // 空串占位:User.passwordHash 必填,但学生的 403 登录判定在密码校验之前,永远用不到 + passwordHash: '', }, }); studentId = created.id; diff --git a/packages/server/src/modules/students/students.module.ts b/packages/server/src/modules/students/students.module.ts index e1ae0bf..20ec9d1 100644 --- a/packages/server/src/modules/students/students.module.ts +++ b/packages/server/src/modules/students/students.module.ts @@ -7,18 +7,21 @@ import { defineModule } from '../module.types.js'; import { createStudentSchema, listStudentsQuerySchema, - resetPasswordSchema, updateStudentSchema, type CreateStudentInput, - type ResetPasswordInput, 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/students?classId=&keyword= - 学生名单 + * + * 注意是**仅管理员**(不是教师/管理员):学生名单自带用户名,而学生初始密码是统一的默认值, + * 因此名单属于管理员专属数据;教师查看本班学生请用 `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); @@ -51,20 +54,7 @@ router.patch( }, ); -/** POST /api/students/:id/reset-password - 重置密码 */ -router.post( - '/:id/reset-password', - requireRole('ADMIN'), - validate({ params: idParamSchema, body: resetPasswordSchema }), - async (req, res) => { - const user = getAuthUser(req); - const { id } = validatedParams<{ id: string }>(req); - await studentService.resetPassword(user, id, req.body as ResetPasswordInput); - sendOk(res, { id }, '密码已重置'); - }, -); - -/** DELETE /api/students/:id - 删除学生账号 */ +/** DELETE /api/students - 删除学生名单记录 */ router.delete('/:id', requireRole('ADMIN'), validate({ params: idParamSchema }), async (req, res) => { const user = getAuthUser(req); const { id } = validatedParams<{ id: string }>(req); diff --git a/packages/server/src/modules/students/students.schemas.ts b/packages/server/src/modules/students/students.schemas.ts index 1546f40..7e25d62 100644 --- a/packages/server/src/modules/students/students.schemas.ts +++ b/packages/server/src/modules/students/students.schemas.ts @@ -6,10 +6,11 @@ export const listStudentsQuerySchema = z.object({ keyword: keywordSchema, }); +// 学生只有"名单"没有"账号":不设密码、不能登录(2026-10-01 起学生个人账号已全部清理, +// 学生端统一用班级码 + 班级密码),因此这里没有也不该有任何密码字段。 export const createStudentSchema = z.object({ username: z.string().trim().min(3, '用户名至少 3 位').max(32), name: z.string().trim().min(1, '请输入姓名').max(32), - password: z.string().min(6, '密码至少 6 位').max(128).optional(), classId: z.string().min(1).nullish(), }); @@ -19,10 +20,5 @@ export const updateStudentSchema = z.object({ classId: z.string().min(1).nullish(), }); -export const resetPasswordSchema = z.object({ - newPassword: z.string().min(6, '新密码至少 6 位').max(128).optional(), -}); - export type CreateStudentInput = z.infer; export type UpdateStudentInput = z.infer; -export type ResetPasswordInput = z.infer; diff --git a/packages/server/src/modules/students/students.service.ts b/packages/server/src/modules/students/students.service.ts index 18fcacf..cf09015 100644 --- a/packages/server/src/modules/students/students.service.ts +++ b/packages/server/src/modules/students/students.service.ts @@ -1,12 +1,10 @@ import type { StudentDto } from '@classhelper/shared'; -import { env } from '../../config/env.js'; 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 { hashPassword } from '../../lib/password.js'; -import type { CreateStudentInput, ResetPasswordInput, UpdateStudentInput } from './students.schemas.js'; +import type { CreateStudentInput, UpdateStudentInput } from './students.schemas.js'; export interface ListStudentOptions { classId?: string; @@ -35,18 +33,18 @@ export async function listStudents(user: TokenPayload, options: ListStudentOptio return students.map(toStudentDto); } -/** 新建学生账号(可选直接分班) */ +/** 新建学生(名单实体,可选直接分班)。学生不设密码:登录一律走班级账号,见 createStudentSchema 的说明 */ export async function createStudent(user: TokenPayload, input: CreateStudentInput): Promise { if (input.classId) assertCanManageRoster(user); - const passwordHash = await hashPassword(input.password ?? env.defaultStudentPassword); const created = await prisma.user.create({ data: { username: input.username, name: input.name, role: 'STUDENT', classId: input.classId ?? null, - passwordHash, + // User.passwordHash 是必填列,学生用空串占位(登录的 STUDENT 403 判定在密码校验之前,永远不会用到它) + passwordHash: '', }, }); @@ -126,24 +124,3 @@ export async function deleteStudent(user: TokenPayload, studentId: string): Prom await prisma.user.delete({ where: { id: studentId } }); } - -/** 重置学生密码(未指定时使用默认初始密码) */ -export async function resetPassword( - user: TokenPayload, - studentId: string, - input: ResetPasswordInput, -): Promise { - const student = await prisma.user.findUnique({ where: { id: studentId } }); - if (!student || student.role !== 'STUDENT') throw ApiError.notFound('学生不存在'); - - if (student.classId) { - assertCanManageRoster(user); - } else if (!isAdmin(user)) { - throw ApiError.forbidden('该学生尚未分班,仅管理员可以重置密码'); - } - - await prisma.user.update({ - where: { id: studentId }, - data: { passwordHash: await hashPassword(input.newPassword ?? env.defaultStudentPassword) }, - }); -} diff --git a/packages/server/src/modules/teachers/teachers.service.ts b/packages/server/src/modules/teachers/teachers.service.ts index 79287d0..01f4e71 100644 --- a/packages/server/src/modules/teachers/teachers.service.ts +++ b/packages/server/src/modules/teachers/teachers.service.ts @@ -81,23 +81,47 @@ export async function resetTeacherPassword( /** * 删除教师账号。 * - * 安全护栏:教师是数据的创建者,`Class.teacherId` / `Course.teacherId` / 作业通知的 createdBy - * 都是级联删除 —— 直接删班主任会连带删掉整个班级的数据。因此只有"没有任何班级职责、 - * 也不持有课程"的账号才允许删除(实践中就是刚录入错的那条),否则返回 409 让管理员先转移。 + * 安全护栏:教师是**班级共享内容**的创建者,而下面这些外键都是 `onDelete: Cascade`, + * 删除账号会连带删掉整份内容(不是"作者名变空",是内容真的没了): + * + * | 关系 | 后果 | + * | ---- | ---- | + * | `Class.teacherId` | 整个班级(含课表/作业/通知/成绩)被删 | + * | `Course.teacherId` | 该教师名下课程被删 | + * | `ClassTeacher.teacherId` | 仅协作关系(无害,但一并列出以免漏项) | + * | `Homework.createdBy` | **该教师发布过的全部作业被删** | + * | `Notification.createdBy` | **该教师发布过的全部通知被删**(含"叫人"消息) | + * + * 前三条历史上被检查过,后两条(Homework / Notification)曾漏检 —— 于是"班主任转给别人 → + * 不在任何协作班级 → 名下无课程"的账号会被放行,删除时静默清掉他在各班发过的作业与通知。 + * **改 schema 时请回到本函数核对一遍**:任何新增的"指向 User 且 Cascade"的关系都要加到这里 + * (`HomeworkStatus` / `NotificationRead` / `Grade` / `Enrollment` 的 `userId` 属于个人数据, + * 随账号删除是正确行为,不算漏项)。 + * + * 因此只有"没有留下任何班级共享内容"的账号才允许删除(实践中就是刚录入错的那条), + * 否则返回 409 让管理员先转移/清理。 */ export async function deleteTeacher(userId: string): Promise { const teacher = await findTeacherOrThrow(userId); - const [ownedClasses, assignedClasses, courses] = await Promise.all([ + const [ownedClasses, assignedClasses, 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 } }), ]); if (ownedClasses > 0) { throw ApiError.conflict( `「${teacher.name}」还是 ${ownedClasses} 个班级的班主任,请先在班级管理里更换班主任再删除`, ); } + if (homeworks > 0 || notifications > 0) { + throw ApiError.conflict( + `「${teacher.name}」发布过 ${homeworks} 条作业 / ${notifications} 条通知,` + + '删除账号会连带删除这些内容,请先删除内容或改为保留账号', + ); + } if (assignedClasses > 0 || courses > 0) { throw ApiError.conflict( `「${teacher.name}」还有 ${assignedClasses} 条班级分配 / ${courses} 门课程,请先解除后再删除`, diff --git a/packages/web-admin/src/views/ClassesView.vue b/packages/web-admin/src/views/ClassesView.vue index e4a86c7..bb5e9b8 100644 --- a/packages/web-admin/src/views/ClassesView.vue +++ b/packages/web-admin/src/views/ClassesView.vue @@ -3,6 +3,7 @@ import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'; import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'; import { SOCKET_EVENTS, + MAX_TERM_WEEK, formatDate, type ClassDetailDto, type ClassDto, @@ -76,6 +77,11 @@ function openEdit(row: ClassDto): void { form.grade = row.grade; form.code = ''; form.termWeeks = row.termWeeks ?? 20; + // 必须回填当前班主任:编辑弹窗里的下拉与"新建"共用同一个 form.headTeacherId, + // 而 submitForm 会在"值不同"时直接调 assignHeadTeacher。不回填的话,只要之前打开过一次 + // 「新建班级」(openCreate 会把它设成当前管理员自己),之后保存任何一次编辑都会把 + // 该班班主任静默改成管理员本人 —— 而班主任决定谁能管这个班的课表与成绩。 + form.headTeacherId = row.teacher?.id ?? ''; void loadStaffOptions(); formVisible.value = true; } @@ -102,6 +108,9 @@ async function submitForm(): Promise { await classApi.create({ name: form.name, grade: form.grade, + // 学期周数:新建时也要提交。原先只提交名称/年级/班级码,表单里填的周数被直接丢弃, + // 服务端落库恒为默认 20,用户看到的是"设置了没生效"且没有任何提示。 + termWeeks: form.termWeeks, ...(form.code.trim() ? { code: form.code.trim().toUpperCase() } : {}), ...(isAdmin.value && form.headTeacherId ? { teacherId: form.headTeacherId } : {}), }); @@ -452,9 +461,9 @@ onUnmounted(() => { - + - 本学期一共多少教学周(默认 20);课表周次选择与默认结束周都按它来 + 本学期一共多少教学周(默认 20,最多 {{ MAX_TERM_WEEK }} 周);课表周次选择与默认结束周都按它来 diff --git a/packages/web-admin/src/views/StudentsView.vue b/packages/web-admin/src/views/StudentsView.vue index c6e8735..e4554f1 100644 --- a/packages/web-admin/src/views/StudentsView.vue +++ b/packages/web-admin/src/views/StudentsView.vue @@ -47,8 +47,8 @@ function openImport(): void { const formVisible = ref(false); const formRef = ref(); const editingId = ref(null); -/** 表单不再暴露「用户名」:新建时由前端自动生成学号式登录名(见 autoCreateStudent) */ -const form = reactive({ name: '', password: '', classId: '' }); +/** 表单不再暴露「用户名」:新建时由前端自动生成学号式名单标识(见 autoCreateStudent) */ +const form = reactive({ name: '', classId: '' }); const rules: FormRules = { name: [{ required: true, message: '请输入姓名', trigger: 'blur' }], }; @@ -56,7 +56,6 @@ const rules: FormRules = { function openCreate(): void { editingId.value = null; form.name = ''; - form.password = ''; form.classId = filter.classId; formVisible.value = true; } @@ -64,18 +63,17 @@ function openCreate(): void { function openEdit(row: StudentDto): void { editingId.value = row.id; form.name = row.name; - form.password = ''; form.classId = row.classId ?? ''; formVisible.value = true; } /** - * 自动生成学生登录名(学号式:student01、student02 …,与种子数据 student01~student15 同风格)。 + * 自动生成学生名单标识(学号式:student01、student02 …,与种子数据同风格)。 * - * 后端 `POST /api/students` 的 username 是必填且全局唯一的(教师/管理员账号同样占位), - * 界面已按需求隐藏「用户名」输入,因此这里用「全量学生账号 + 递增序号」生成, - * 并在服务端返回 409(唯一约束冲突,例如并发创建或撞上教师账号)时换下一个序号重试; - * 其它错误(姓名/密码不合法等)立即抛出,不再重试,避免刷屏。 + * 后端 `POST /api/students` 的 username 必填且全局唯一(教师/管理员账号同样占位), + * 界面已按需求隐藏「用户名」输入,因此这里用「全量学生 + 递增序号」生成, + * 并在服务端返回 409(唯一约束冲突)时换下一个序号重试;其它错误立即抛出,避免刷屏。 + * 学生没有密码、不能登录,username 仅作名单标识。 */ function studentUsernameAt(index: number): string { return index < 100 ? `student${String(index).padStart(2, '0')}` : `student${index}`; @@ -84,7 +82,7 @@ function studentUsernameAt(index: number): string { async function autoCreateStudent(): Promise { const existing = new Set((await studentApi.list()).map((item) => item.username)); let index = existing.size + 1; - let lastError: unknown = new Error('自动生成学生账号失败'); + let lastError: unknown = new Error('自动生成名单标识失败'); for (let attempt = 0; attempt < 20; attempt += 1) { let username = studentUsernameAt(index); @@ -97,7 +95,6 @@ async function autoCreateStudent(): Promise { username, name: form.name.trim(), classId: form.classId || null, - ...(form.password ? { password: form.password } : {}), }); return; } catch (error) { @@ -125,7 +122,7 @@ async function submitForm(): Promise { } else { try { await autoCreateStudent(); - ElMessage.success('学生账号创建成功(登录名已自动生成)'); + ElMessage.success('学生已创建(名单标识已自动生成)'); } catch { // 接口层已弹出服务端原因,这里补充可执行的兜底建议 ElMessage.error('创建失败:可稍后重试,或用「导入名单」在表格里显式指定用户名'); @@ -146,12 +143,6 @@ async function removeStudent(row: StudentDto): Promise { await loadStudents(); } -async function resetPassword(row: StudentDto): Promise { - await ElMessageBox.confirm(`将「${row.name}」的密码重置为默认密码?`, '重置密码', { type: 'warning' }); - await studentApi.resetPassword(row.id); - ElMessage.success('密码已重置为默认密码'); -} - onMounted(async () => { await Promise.all([loadClasses(), loadStudents()]); }); @@ -209,7 +200,7 @@ async function submitCall(): Promise {