feat(students,teachers,classes,auth,web): 学生个人账号彻底清理,学生只是名单记录

学生不再有登录能力:User.passwordHash 以空串占位,role=STUDENT 在密码校验之前
一律 403 并提示改用班级登录(否则「密码错 401 / 密码对但已停用 403」的差异会把
学生账号密码变成可离线验证的预言机)。成绩、未交名单、叫人、已读全部按名单记。

配套改动:
- 删除 POST /students/:id/reset-password(调用返回 404,e2e 有负断言);
- 新建学生不设密码,StudentsView 移除密码输入与「重置密码」入口,
  列表标识仍自动生成为学号式(student01…);
- class-account.ts / auth.service.ts 收紧班级会话语义;
- 经管「修改密码」入口收敛到 Web 端:教师改本人密码走顶栏下拉,
  班级密码由管理员在班级管理里维护,教师可用 POST /teachers/:id/reset-password 设初始密码。
This commit is contained in:
LoveChengke committed 2026-10-02 00:25:04 +08:00
1 parent 7ea0e0f7c9
commit d27f9a4b9e
11 files changed
+121 -104

No files matched your search

@@ -19,14 +19,16 @@ export async function login(input: LoginInput): Promise<LoginResponse> {
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,
@@ -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({
@@ -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;
@@ -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);
@@ -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<typeof createStudentSchema>;
export type UpdateStudentInput = z.infer<typeof updateStudentSchema>;
export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>;
@@ -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<StudentDto> {
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<void> {
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) },
});
}
@@ -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<void> {
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} 门课程,请先解除后再删除`,